aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-06-10 16:50:55 +0200
committerGitHub <noreply@github.com>2026-06-10 16:50:55 +0200
commitb6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch)
tree38c77a381cfb937b428752a588c09a45c81434c9
parentfeat: PWA support for the web client (#5184) (diff)
downloadsl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.gz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.xz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.zip
Mobile layout (#5241)
* phase 2+3 * initial mobile plugin (phases 4 + 5 + 6) * some bugfixes * implement stage 7 * mobile interface improvements * improve mobile pane/tab picker * more ui improvements and fixes * various mobile fixes * some mobile fixes * mobile keyboard triggering fix * some cleanups * initial plugin keyboard implementation * improve keyboard hit zones * improve keyboard * pinch resize * pinching fixes * implement scrolling + panning * initial keyboard resizing * fix: mobile first render issues * keyboard responsiveness * fix some issues with the keyboard * implement fit * fix mobile scrolling * add tilde cell to compact * improve mobile keyboard * fix starting mobile terminal font to natural keyboard size * fix mobile resize blank screen flash * shadow focus for mobile * more keyboard fixes * improve mobile status bar * fix: render plugins to mobile clients * allow opening new panes/tabs from the mobile plugin * allow configuring * fix dot slash * improve mobile native soft keyboard interaction * switch to native keyboard - initial implementation * dirty hacks, but phone keyboard works now * adjust keyboard help strip * fix cursor in mobile * fix keyboard and help line * rename keyboard to modifier bar * allow starting new session * make clicking session name bring us to new session menu * allow switching to desktop from the mobile ui * mobile welcome screen initial implementation * mobile welcome screen fixes * various welcome screen fixes * more mobile fixes * moar mobile fixes * mobile ui redesign * fix: swallowed backspace bug * switch session screen improvement * touch up some interfaces * refactor: move mobile state to own struct * refactor: move fit resizing to the server * remove dead flash code * some cleanups * refactor: clear fit on close * refactor: improve fit api * refactor: pane sync functions * refactor: mouse * refactor: break into modules * some cleanups for the sessions screen * fix scrolling and other stuff * refactor: sessions screen * refactor: panes screen * refactor: new session screen * refactor: menu screen * refactor: render * cleanups * refactor: plugin structure * remove unused welcome screen path * Rename api * refactor input.js * cleanup useless comments * fit/mobile refactoring * take out the garbage * remove more comments * remove mobile flash on startup/attach * fix: various mobile startup issues * fix space bugs * cleanup * rustfmt * fix windows build * adjust modifier bar ui * add pr
-rw-r--r--CHANGELOG.md1
-rw-r--r--Cargo.lock9
-rw-r--r--Cargo.toml1
-rw-r--r--default-plugins/mobile/.cargo/config.toml2
-rw-r--r--default-plugins/mobile/.gitignore1
-rw-r--r--default-plugins/mobile/Cargo.toml10
-rw-r--r--default-plugins/mobile/src/ansi.rs235
-rw-r--r--default-plugins/mobile/src/click.rs131
-rw-r--r--default-plugins/mobile/src/components/mod.rs2
-rw-r--r--default-plugins/mobile/src/components/modifier_bar.rs681
-rw-r--r--default-plugins/mobile/src/components/top_bar.rs412
-rw-r--r--default-plugins/mobile/src/fit.rs146
-rw-r--r--default-plugins/mobile/src/frame.rs142
-rw-r--r--default-plugins/mobile/src/input.rs28
-rw-r--r--default-plugins/mobile/src/keys.rs195
-rw-r--r--default-plugins/mobile/src/main.rs577
-rw-r--r--default-plugins/mobile/src/mouse.rs180
-rw-r--r--default-plugins/mobile/src/navigation.rs14
-rw-r--r--default-plugins/mobile/src/pane_sync.rs99
-rw-r--r--default-plugins/mobile/src/render.rs125
-rw-r--r--default-plugins/mobile/src/screens/menu.rs217
-rw-r--r--default-plugins/mobile/src/screens/mod.rs20
-rw-r--r--default-plugins/mobile/src/screens/new_session.rs233
-rw-r--r--default-plugins/mobile/src/screens/panes.rs879
-rw-r--r--default-plugins/mobile/src/screens/sessions.rs458
-rw-r--r--default-plugins/mobile/src/screens/viewport.rs239
-rw-r--r--default-plugins/mobile/src/state.rs24
-rw-r--r--default-plugins/mobile/src/workspace.rs122
-rw-r--r--default-plugins/session-manager/src/main.rs32
-rw-r--r--example/default.kdl1
-rw-r--r--xtask/src/main.rs4
-rw-r--r--zellij-client/assets/ime-bypass.js49
-rw-r--r--zellij-client/assets/index.html6
-rw-r--r--zellij-client/assets/index.js16
-rw-r--r--zellij-client/assets/input.js271
-rw-r--r--zellij-client/assets/key-handler.js64
-rw-r--r--zellij-client/assets/mouse.js25
-rw-r--r--zellij-client/assets/pinch.js134
-rw-r--r--zellij-client/assets/soft-keyboard.js288
-rw-r--r--zellij-client/assets/style.css19
-rw-r--r--zellij-client/assets/terminal.js53
-rw-r--r--zellij-client/assets/touch.js266
-rw-r--r--zellij-client/assets/websockets.js213
-rw-r--r--zellij-client/src/input_handler.rs6
-rw-r--r--zellij-client/src/lib.rs8
-rw-r--r--zellij-client/src/unit/terminal_loop_tests.rs42
-rw-r--r--zellij-client/src/web_client/control_message.rs9
-rw-r--r--zellij-client/src/web_client/server_listener.rs5
-rw-r--r--zellij-client/src/web_client/unit/web_client_tests.rs179
-rw-r--r--zellij-client/src/web_client/websocket_handlers.rs22
-rw-r--r--zellij-server/src/background_jobs.rs16
-rw-r--r--zellij-server/src/lib.rs63
-rw-r--r--zellij-server/src/mobile_mode.rs733
-rw-r--r--zellij-server/src/panes/active_panes.rs25
-rw-r--r--zellij-server/src/panes/floating_panes/mod.rs40
-rw-r--r--zellij-server/src/panes/grid.rs17
-rw-r--r--zellij-server/src/panes/plugin_pane.rs18
-rw-r--r--zellij-server/src/panes/tiled_panes/mod.rs46
-rw-r--r--zellij-server/src/plugins/mod.rs10
-rw-r--r--zellij-server/src/plugins/wasm_bridge.rs74
-rw-r--r--zellij-server/src/plugins/zellij_exports.rs139
-rw-r--r--zellij-server/src/route.rs68
-rw-r--r--zellij-server/src/screen.rs688
-rw-r--r--zellij-server/src/tab/mod.rs111
-rw-r--r--zellij-server/src/tab/mouse_handler.rs138
-rw-r--r--zellij-server/src/tab/unit/tab_integration_tests.rs22
-rw-r--r--zellij-server/src/tab/unit/tab_tests.rs399
-rw-r--r--zellij-server/src/unit/screen_tests.rs1322
-rw-r--r--zellij-tile/src/prelude.rs1
-rw-r--r--zellij-tile/src/shim.rs63
-rw-r--r--zellij-utils/assets/config/default.kdl1
-rwxr-xr-xzellij-utils/assets/plugins/mobile.wasmbin0 -> 1505278 bytes
-rw-r--r--zellij-utils/assets/prost/api.action.rs4
-rw-r--r--zellij-utils/assets/prost/api.event.rs21
-rw-r--r--zellij-utils/assets/prost/api.plugin_command.rs91
-rw-r--r--zellij-utils/assets/prost_ipc/client_server_contract.rs101
-rw-r--r--zellij-utils/src/client_server_contract/client_to_server.proto12
-rw-r--r--zellij-utils/src/client_server_contract/common_types.proto14
-rw-r--r--zellij-utils/src/client_server_contract/server_to_client.proto5
-rw-r--r--zellij-utils/src/consts.rs33
-rw-r--r--zellij-utils/src/data.rs27
-rw-r--r--zellij-utils/src/errors.rs13
-rw-r--r--zellij-utils/src/input/actions.rs1
-rw-r--r--zellij-utils/src/input/mod.rs251
-rw-r--r--zellij-utils/src/input/mouse.rs78
-rw-r--r--zellij-utils/src/input/options.rs289
-rw-r--r--zellij-utils/src/input/plugins.rs1
-rw-r--r--zellij-utils/src/input/web_client.rs27
-rw-r--r--zellij-utils/src/ipc.rs28
-rw-r--r--zellij-utils/src/ipc/protobuf_conversion.rs91
-rw-r--r--zellij-utils/src/ipc/tests/roundtrip_tests.rs47
-rw-r--r--zellij-utils/src/ipc/tests/socket_tests.rs28
-rw-r--r--zellij-utils/src/kdl/mod.rs175
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap1
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap18
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap3
-rw-r--r--zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap17
-rw-r--r--zellij-utils/src/pane_size.rs10
-rw-r--r--zellij-utils/src/plugin_api/action.proto2
-rw-r--r--zellij-utils/src/plugin_api/action.rs7
-rw-r--r--zellij-utils/src/plugin_api/event.proto9
-rw-r--r--zellij-utils/src/plugin_api/event.rs62
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.proto46
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.rs223
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap3
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap3
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap3
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap16
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap16
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap16
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap3
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap16
-rw-r--r--zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap16
113 files changed, 11668 insertions, 1048 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0dd1fad..2698a730 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)
* feat: PWA support for the web client (manifest + icons + iOS meta tags) so the page can be installed as a standalone app (https://github.com/zellij-org/zellij/pull/5184)
+* feat: mobile UI (https://github.com/zellij-org/zellij/pull/5241)
## [0.44.3] - 2026-05-13
* fix(windows): bump windows-sys to 0.59 to align manifest with code, fixing source builds via `cargo install`/`cargo binstall` (https://github.com/zellij-org/zellij/pull/5139)
diff --git a/Cargo.lock b/Cargo.lock
index 0f8eb8d2..9f86bf89 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2186,6 +2186,15 @@ dependencies = [
]
[[package]]
+name = "mobile"
+version = "0.1.0"
+dependencies = [
+ "fuzzy-matcher",
+ "unicode-width 0.1.10",
+ "zellij-tile",
+]
+
+[[package]]
name = "multimap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index ddae7131..1a45e4cf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -58,6 +58,7 @@ members = [
"default-plugins/multiple-select",
"default-plugins/layout-manager",
"default-plugins/link",
+ "default-plugins/mobile",
"zellij-client",
"zellij-server",
"zellij-utils",
diff --git a/default-plugins/mobile/.cargo/config.toml b/default-plugins/mobile/.cargo/config.toml
new file mode 100644
index 00000000..6b509f5b
--- /dev/null
+++ b/default-plugins/mobile/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "wasm32-wasip1"
diff --git a/default-plugins/mobile/.gitignore b/default-plugins/mobile/.gitignore
new file mode 100644
index 00000000..ea8c4bf7
--- /dev/null
+++ b/default-plugins/mobile/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/default-plugins/mobile/Cargo.toml b/default-plugins/mobile/Cargo.toml
new file mode 100644
index 00000000..ea50f7f6
--- /dev/null
+++ b/default-plugins/mobile/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "mobile"
+version = "0.1.0"
+authors = ["Aram Drevekenin <aram@poor.dev>"]
+edition.workspace = true
+
+[dependencies]
+zellij-tile = { path = "../../zellij-tile" }
+fuzzy-matcher = "0.3.7"
+unicode-width = "0.1.10"
diff --git a/default-plugins/mobile/src/ansi.rs b/default-plugins/mobile/src/ansi.rs
new file mode 100644
index 00000000..7b398235
--- /dev/null
+++ b/default-plugins/mobile/src/ansi.rs
@@ -0,0 +1,235 @@
+use unicode_width::UnicodeWidthStr;
+
+pub(crate) const RESET: &str = "\x1b[0m";
+
+pub(crate) fn move_to(row: usize, col: usize) -> String {
+ format!("\x1b[{};{}H", row + 1, col + 1)
+}
+
+pub(crate) fn format_time_ago(then_unix_secs: Option<u64>, now_unix_secs: u64) -> String {
+ let Some(then) = then_unix_secs else {
+ return "—".to_string();
+ };
+ let diff = now_unix_secs.saturating_sub(then);
+ let body = if diff < 5 {
+ "just now".to_string()
+ } else if diff < 60 {
+ format!("{}s ago", diff)
+ } else if diff < 3600 {
+ format!("{}m ago", diff / 60)
+ } else if diff < 86400 {
+ format!("{}h ago", diff / 3600)
+ } else {
+ format!("{}d ago", diff / 86400)
+ };
+ format!("Active {}", body)
+}
+
+pub(crate) fn pad_or_truncate(text: &str, width: usize) -> String {
+ let text_w = UnicodeWidthStr::width(text);
+ if width == 0 {
+ return String::new();
+ }
+ if text_w == width {
+ return text.to_string();
+ }
+ if text_w < width {
+ return format!("{}{}", text, " ".repeat(width - text_w));
+ }
+ if width == 1 {
+ let first = text.chars().find(|c| char_width(*c) <= 1);
+ return first.map(String::from).unwrap_or_else(|| " ".to_string());
+ }
+ let mut out = String::new();
+ let mut taken = 0;
+ let target = width - 1;
+ for ch in text.chars() {
+ let w = char_width(ch);
+ if taken + w > target {
+ break;
+ }
+ out.push(ch);
+ taken += w;
+ }
+ out.push('…');
+ let out_w = UnicodeWidthStr::width(out.as_str());
+ out.push_str(&" ".repeat(width.saturating_sub(out_w)));
+ out
+}
+
+fn char_width(ch: char) -> usize {
+ let mut buf = [0u8; 4];
+ UnicodeWidthStr::width(ch.encode_utf8(&mut buf) as &str)
+}
+
+pub(crate) fn visible_width(text: &str) -> usize {
+ let bytes = text.as_bytes();
+ let mut width = 0;
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == 0x1b {
+ i = skip_escape(bytes, i);
+ continue;
+ }
+ let ch_len = utf8_char_len(bytes[i]).max(1);
+ if let Some(s) = bytes
+ .get(i..i + ch_len)
+ .and_then(|b| std::str::from_utf8(b).ok())
+ {
+ width += UnicodeWidthStr::width(s);
+ }
+ i += ch_len;
+ }
+ width
+}
+
+pub(crate) fn slice_ansi_visible(line: &str, h_offset: usize, max_cols: usize) -> String {
+ if max_cols == 0 {
+ return String::new();
+ }
+ let bytes = line.as_bytes();
+ let right_edge = h_offset.saturating_add(max_cols);
+ let mut out = String::new();
+ let mut cell = 0usize;
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == 0x1b {
+ let end = skip_escape(bytes, i);
+ if let Ok(esc) = std::str::from_utf8(&bytes[i..end]) {
+ out.push_str(esc);
+ }
+ i = end;
+ continue;
+ }
+ let ch_len = utf8_char_len(bytes[i]).max(1);
+ let end = (i + ch_len).min(bytes.len());
+ let Ok(ch) = std::str::from_utf8(&bytes[i..end]) else {
+ i = end;
+ continue;
+ };
+ let w = UnicodeWidthStr::width(ch);
+ if w == 0 {
+ if cell > h_offset && cell <= right_edge && !out.is_empty() {
+ out.push_str(ch);
+ }
+ } else if cell + w <= h_offset {
+ } else if cell < h_offset {
+ out.push(' ');
+ } else if cell >= right_edge || cell + w > right_edge {
+ break;
+ } else {
+ out.push_str(ch);
+ }
+ cell += w;
+ i = end;
+ }
+ out.push_str(RESET);
+ out
+}
+
+fn skip_escape(bytes: &[u8], start: usize) -> usize {
+ let mut i = start + 1;
+ match bytes.get(i) {
+ Some(b'[') => {
+ i += 1;
+ while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
+ i += 1;
+ }
+ (i + 1).min(bytes.len())
+ },
+ Some(b']') => {
+ i += 1;
+ while i < bytes.len()
+ && bytes[i] != 0x07
+ && !(bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\'))
+ {
+ i += 1;
+ }
+ match bytes.get(i) {
+ Some(0x07) => i + 1,
+ Some(0x1b) => (i + 2).min(bytes.len()),
+ _ => i,
+ }
+ },
+ Some(_) => i + 1,
+ None => i,
+ }
+}
+
+fn utf8_char_len(byte: u8) -> usize {
+ match byte {
+ b if b < 0xc0 => 1,
+ b if b < 0xe0 => 2,
+ b if b < 0xf0 => 3,
+ _ => 4,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn visible(s: &str) -> &str {
+ s.strip_suffix(RESET).unwrap_or(s)
+ }
+
+ #[test]
+ fn ascii_slice_inside_line() {
+ assert_eq!(visible(&slice_ansi_visible("abcdefghij", 2, 4)), "cdef");
+ }
+
+ #[test]
+ fn ascii_slice_at_left_edge() {
+ assert_eq!(visible(&slice_ansi_visible("abcdefghij", 0, 4)), "abcd");
+ }
+
+ #[test]
+ fn ascii_slice_past_right_edge() {
+ assert_eq!(visible(&slice_ansi_visible("abcd", 1, 10)), "bcd");
+ }
+
+ #[test]
+ fn empty_when_offset_past_line_width() {
+ let sliced = slice_ansi_visible("abcd", 10, 4);
+ assert_eq!(visible(&sliced), "");
+ assert!(sliced.ends_with(RESET));
+ }
+
+ #[test]
+ fn max_cols_zero_returns_empty() {
+ assert_eq!(slice_ansi_visible("abcd", 0, 0), "");
+ }
+
+ #[test]
+ fn ansi_escape_preserved_when_in_window() {
+ let sliced = slice_ansi_visible("\x1b[31mred\x1b[0m end", 0, 7);
+ assert!(sliced.contains("\x1b[31m"));
+ assert!(sliced.contains("\x1b[0m"));
+ assert!(sliced.contains("red"));
+ assert!(sliced.contains("end"));
+ }
+
+ #[test]
+ fn ansi_escape_replayed_when_offset_skips_text() {
+ let sliced = slice_ansi_visible("\x1b[31maaaa\x1b[32mbbbb", 4, 4);
+ assert!(sliced.contains("\x1b[31m"));
+ assert!(sliced.contains("\x1b[32m"));
+ assert!(sliced.contains("bbbb"));
+ assert!(!sliced.contains("aaaa"));
+ }
+
+ #[test]
+ fn wide_char_straddling_left_boundary_becomes_space() {
+ assert_eq!(visible(&slice_ansi_visible("中abc", 1, 3)), " ab");
+ }
+
+ #[test]
+ fn wide_char_straddling_right_boundary_dropped() {
+ assert_eq!(visible(&slice_ansi_visible("ab中cd", 0, 3)), "ab");
+ }
+
+ #[test]
+ fn wide_char_entirely_inside_window() {
+ assert_eq!(visible(&slice_ansi_visible("ab中cd", 0, 4)), "ab中");
+ }
+}
diff --git a/default-plugins/mobile/src/click.rs b/default-plugins/mobile/src/click.rs
new file mode 100644
index 00000000..42271c52
--- /dev/null
+++ b/default-plugins/mobile/src/click.rs
@@ -0,0 +1,131 @@
+use zellij_tile::prelude::*;
+
+use crate::state::State;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ClickAction {
+ ExpandSessions,
+ ExpandPanes,
+ CollapseSelector,
+ SelectSession(String),
+ SelectPane {
+ tab_position: usize,
+ pane_id: PaneId,
+ },
+ ToggleFit,
+ Keyboard(crate::components::modifier_bar::CellId),
+ ToggleMenu,
+ NewPaneInTab {
+ tab_position: usize,
+ },
+ NewTab,
+ OpenNewSessionPrompt,
+ CancelNewSessionPrompt,
+ AcceptNewSessionPrompt,
+ ExitMobileMode,
+}
+
+#[derive(Debug, Clone)]
+pub struct ClickRegion {
+ pub row_start: usize,
+ pub row_end: usize,
+ pub col_start: usize,
+ pub col_end: usize,
+ pub action: ClickAction,
+ pub priority: u8,
+ pub center: Option<(usize, usize)>,
+}
+
+impl ClickRegion {
+ pub fn tight(row: usize, col_start: usize, col_end: usize, action: ClickAction) -> Self {
+ Self::tight_range(row, row + 1, col_start, col_end, action)
+ }
+
+ pub fn tight_range(
+ row_start: usize,
+ row_end: usize,
+ col_start: usize,
+ col_end: usize,
+ action: ClickAction,
+ ) -> Self {
+ Self {
+ row_start,
+ row_end,
+ col_start,
+ col_end,
+ action,
+ priority: 0,
+ center: None,
+ }
+ }
+
+ pub fn slop(
+ row: usize,
+ col_start: usize,
+ col_end: usize,
+ action: ClickAction,
+ center: (usize, usize),
+ ) -> Self {
+ Self::slop_range(row, row + 1, col_start, col_end, action, center)
+ }
+
+ pub fn slop_range(
+ row_start: usize,
+ row_end: usize,
+ col_start: usize,
+ col_end: usize,
+ action: ClickAction,
+ center: (usize, usize),
+ ) -> Self {
+ Self {
+ row_start,
+ row_end,
+ col_start,
+ col_end,
+ action,
+ priority: 1,
+ center: Some(center),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct ViewportRegion {
+ pub row_start: usize,
+ pub row_end: usize,
+ pub cols: usize,
+ pub skip: usize,
+ pub h_offset: usize,
+}
+
+pub fn slop_key(r: &ClickRegion) -> (usize, usize) {
+ match r.center {
+ Some((cx, cy)) => (cy, cx),
+ None => (r.row_start, r.col_start),
+ }
+}
+
+pub fn dispatch(state: &mut State, action: ClickAction) -> bool {
+ match action {
+ ClickAction::ExpandSessions => state.open_sessions(),
+ ClickAction::ExpandPanes => state.open_panes(),
+ ClickAction::ToggleMenu => state.menu.toggle(&mut state.active),
+ ClickAction::CollapseSelector => state.collapse_selector(),
+ ClickAction::SelectSession(name) => state.sessions.select_session(&mut state.active, &name),
+ ClickAction::OpenNewSessionPrompt => state.open_new_session_prompt(),
+ ClickAction::CancelNewSessionPrompt => state.cancel_new_session_prompt(),
+ ClickAction::AcceptNewSessionPrompt => state.accept_new_session_prompt(),
+ ClickAction::SelectPane {
+ tab_position,
+ pane_id,
+ } => state.select_pane(tab_position, pane_id),
+ ClickAction::ToggleFit => state.toggle_fit(),
+ ClickAction::NewPaneInTab { tab_position } => state.new_pane_in_tab(tab_position),
+ ClickAction::NewTab => state.new_tab(),
+ ClickAction::ExitMobileMode => {
+ exit_mobile_mode();
+ true
+ },
+ ClickAction::Keyboard(cell) => state.keyboard_tap(cell),
+ }
+}
diff --git a/default-plugins/mobile/src/components/mod.rs b/default-plugins/mobile/src/components/mod.rs
new file mode 100644
index 00000000..bcaf27c8
--- /dev/null
+++ b/default-plugins/mobile/src/components/mod.rs
@@ -0,0 +1,2 @@
+pub mod modifier_bar;
+pub mod top_bar;
diff --git a/default-plugins/mobile/src/components/modifier_bar.rs b/default-plugins/mobile/src/components/modifier_bar.rs
new file mode 100644
index 00000000..ba32a511
--- /dev/null
+++ b/default-plugins/mobile/src/components/modifier_bar.rs
@@ -0,0 +1,681 @@
+use std::collections::BTreeSet;
+
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::keys;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Modifier {
+ Ctrl,
+ Alt,
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct KeyboardModifiers {
+ pub ctrl_armed: bool,
+ pub alt_armed: bool,
+}
+
+impl KeyboardModifiers {
+ pub fn consume_one_shots(&mut self) {
+ self.ctrl_armed = false;
+ self.alt_armed = false;
+ }
+
+ pub fn is_armed(&self, m: Modifier) -> bool {
+ match m {
+ Modifier::Ctrl => self.ctrl_armed,
+ Modifier::Alt => self.alt_armed,
+ }
+ }
+
+ pub fn toggle(&mut self, m: Modifier) {
+ match m {
+ Modifier::Ctrl => self.ctrl_armed = !self.ctrl_armed,
+ Modifier::Alt => self.alt_armed = !self.alt_armed,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct CellId(pub u16);
+
+#[derive(Debug, Clone)]
+pub enum KeyAction {
+ SendKey(KeyWithModifier),
+ ToggleModifier(Modifier),
+ NoOp,
+}
+
+pub const CELL_ESC: CellId = CellId(0);
+pub const CELL_TAB: CellId = CellId(1);
+pub const CELL_CTRL: CellId = CellId(2);
+pub const CELL_ALT: CellId = CellId(3);
+pub const CELL_LEFT: CellId = CellId(4);
+pub const CELL_DOWN: CellId = CellId(5);
+pub const CELL_UP: CellId = CellId(6);
+pub const CELL_RIGHT: CellId = CellId(7);
+pub const CELL_MINUS: CellId = CellId(8);
+pub const BAR_CELL_COUNT: usize = 9;
+
+pub enum TapOutcome {
+ SendBytes(Vec<u8>),
+ Toggled,
+ NoOp,
+}
+
+#[derive(Default)]
+pub struct ModifierBarController {
+ pub modifiers: KeyboardModifiers,
+}
+
+impl ModifierBarController {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn handle_tap(
+ &mut self,
+ cell: CellId,
+ ctrl_held: &mut bool,
+ alt_held: &mut bool,
+ ) -> TapOutcome {
+ self.modifiers.ctrl_armed = *ctrl_held;
+ self.modifiers.alt_armed = *alt_held;
+
+ let action = cell_action(cell);
+
+ match action {
+ KeyAction::ToggleModifier(m) => {
+ self.modifiers.toggle(m);
+ match m {
+ Modifier::Ctrl => *ctrl_held = self.modifiers.ctrl_armed,
+ Modifier::Alt => *alt_held = self.modifiers.alt_armed,
+ }
+ TapOutcome::Toggled
+ },
+ KeyAction::SendKey(mut kwm) => {
+ if self.modifiers.ctrl_armed {
+ kwm.key_modifiers.insert(KeyModifier::Ctrl);
+ }
+ if self.modifiers.alt_armed {
+ kwm.key_modifiers.insert(KeyModifier::Alt);
+ }
+ let bytes = keys::serialize_key(&kwm);
+ self.modifiers.consume_one_shots();
+ *ctrl_held = false;
+ *alt_held = false;
+ TapOutcome::SendBytes(bytes)
+ },
+ KeyAction::NoOp => TapOutcome::NoOp,
+ }
+ }
+}
+
+fn bare(k: BareKey) -> KeyWithModifier {
+ KeyWithModifier {
+ bare_key: k,
+ key_modifiers: BTreeSet::new(),
+ }
+}
+
+fn cell_action(cell: CellId) -> KeyAction {
+ match cell {
+ CELL_ESC => KeyAction::SendKey(bare(BareKey::Esc)),
+ CELL_TAB => KeyAction::SendKey(bare(BareKey::Tab)),
+ CELL_CTRL => KeyAction::ToggleModifier(Modifier::Ctrl),
+ CELL_ALT => KeyAction::ToggleModifier(Modifier::Alt),
+ CELL_LEFT => KeyAction::SendKey(bare(BareKey::Left)),
+ CELL_DOWN => KeyAction::SendKey(bare(BareKey::Down)),
+ CELL_UP => KeyAction::SendKey(bare(BareKey::Up)),
+ CELL_RIGHT => KeyAction::SendKey(bare(BareKey::Right)),
+ CELL_MINUS => KeyAction::SendKey(bare(BareKey::Char('-'))),
+ _ => KeyAction::NoOp,
+ }
+}
+
+struct BarCell {
+ id: CellId,
+ label: &'static str,
+ short_label: &'static str,
+ modifier: Option<Modifier>,
+}
+
+const BAR: [BarCell; BAR_CELL_COUNT] = [
+ BarCell {
+ id: CELL_ESC,
+ label: "ESC",
+ short_label: "ES",
+ modifier: None,
+ },
+ BarCell {
+ id: CELL_TAB,
+ label: "TAB",
+ short_label: "TB",
+ modifier: None,
+ },
+ BarCell {
+ id: CELL_CTRL,
+ label: "CTRL",
+ short_label: "CTL",
+ modifier: Some(Modifier::Ctrl),
+ },
+ BarCell {
+ id: CELL_ALT,
+ label: "ALT",
+ short_label: "AL",
+ modifier: Some(Modifier::Alt),
+ },
+ BarCell {
+ id: CELL_LEFT,
+ label: "\u{2190}",
+ short_label: "\u{2190}",
+ modifier: None,
+ },
+ BarCell {
+ id: CELL_DOWN,
+ label: "\u{2193}",
+ short_label: "\u{2193}",
+ modifier: None,
+ },
+ BarCell {
+ id: CELL_UP,
+ label: "\u{2191}",
+ short_label: "\u{2191}",
+ modifier: None,
+ },
+ BarCell {
+ id: CELL_RIGHT,
+ label: "\u{2192}",
+ short_label: "\u{2192}",
+ modifier: None,
+ },
+ BarCell {
+ id: CELL_MINUS,
+ label: "-",
+ short_label: "-",
+ modifier: None,
+ },
+];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum SepMode {
+ Wide,
+ Compact,
+}
+
+impl SepMode {
+ fn width(self) -> usize {
+ match self {
+ Self::Wide => 3,
+ Self::Compact => 1,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum LabelMode {
+ Full,
+ Short,
+}
+
+impl LabelMode {
+ fn label_for(self, cell: &BarCell) -> &'static str {
+ match self {
+ Self::Full => cell.label,
+ Self::Short => cell.short_label,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum DropMode {
+ None,
+ DropMinus,
+ DropMinusAndTab,
+}
+
+impl DropMode {
+ fn cell_indices(self) -> &'static [usize] {
+ match self {
+ Self::None => &[0, 1, 2, 3, 4, 5, 6, 7, 8],
+ Self::DropMinus => &[0, 1, 2, 3, 4, 5, 6, 7],
+ Self::DropMinusAndTab => &[0, 2, 3, 4, 5, 6, 7],
+ }
+ }
+}
+
+fn required_width(drop: DropMode, labels: LabelMode, sep: SepMode) -> usize {
+ let indices = drop.cell_indices();
+ let n = indices.len();
+ let labels_sum: usize = indices
+ .iter()
+ .map(|&i| UnicodeWidthStr::width(labels.label_for(&BAR[i])))
+ .sum();
+ labels_sum + n.saturating_sub(1) * sep.width()
+}
+
+fn choose_config(cols: usize) -> Option<(DropMode, LabelMode, SepMode)> {
+ const CONFIGS: [(DropMode, LabelMode, SepMode); 12] = [
+ (DropMode::None, LabelMode::Full, SepMode::Wide),
+ (DropMode::None, LabelMode::Full, SepMode::Compact),
+ (DropMode::None, LabelMode::Short, SepMode::Wide),
+ (DropMode::None, LabelMode::Short, SepMode::Compact),
+ (DropMode::DropMinus, LabelMode::Full, SepMode::Wide),
+ (DropMode::DropMinus, LabelMode::Full, SepMode::Compact),
+ (DropMode::DropMinus, LabelMode::Short, SepMode::Wide),
+ (DropMode::DropMinus, LabelMode::Short, SepMode::Compact),
+ (DropMode::DropMinusAndTab, LabelMode::Full, SepMode::Wide),
+ (DropMode::DropMinusAndTab, LabelMode::Full, SepMode::Compact),
+ (DropMode::DropMinusAndTab, LabelMode::Short, SepMode::Wide),
+ (
+ DropMode::DropMinusAndTab,
+ LabelMode::Short,
+ SepMode::Compact,
+ ),
+ ];
+ CONFIGS
+ .iter()
+ .copied()
+ .find(|&(d, l, s)| cols >= required_width(d, l, s))
+}
+
+pub fn render_modifier_bar(
+ modifiers: &KeyboardModifiers,
+ row: usize,
+ cols: usize,
+ click_regions: &mut Vec<ClickRegion>,
+) {
+ let (drop_mode, label_mode, sep_mode) = match choose_config(cols) {
+ Some(c) => c,
+ None => return,
+ };
+
+ let indices = drop_mode.cell_indices();
+ let n = indices.len();
+ let sep_w = sep_mode.width();
+ let sep_total = n.saturating_sub(1) * sep_w;
+ let content_cols = cols - sep_total;
+ let naturals: Vec<usize> = indices
+ .iter()
+ .map(|&i| UnicodeWidthStr::width(label_mode.label_for(&BAR[i])))
+ .collect();
+ let layout = compute_bar_layout(content_cols, &naturals);
+ let widths = &layout.widths;
+
+ let mut bar = String::with_capacity(cols);
+ let mut label_ranges: Vec<(std::ops::Range<usize>, bool)> = Vec::with_capacity(n);
+ let mut cell_boundaries: Vec<usize> = Vec::with_capacity(n + 1);
+
+ let mut chars_cursor: usize = 0;
+ let mut cells_cursor: usize = 0;
+ cell_boundaries.push(0);
+
+ for _ in 0..layout.left_margin {
+ bar.push(' ');
+ }
+ chars_cursor += layout.left_margin;
+ cells_cursor += layout.left_margin;
+
+ for (slot, &cell_idx) in indices.iter().enumerate() {
+ let cell = &BAR[cell_idx];
+ let label = label_mode.label_for(cell);
+ let width = widths[slot];
+ let label_w = UnicodeWidthStr::width(label);
+ let visible = if label_w <= width {
+ label.to_string()
+ } else {
+ truncate_to_width(label, width)
+ };
+ let visible_w = UnicodeWidthStr::width(visible.as_str());
+ let left_pad = (width - visible_w) / 2;
+ let right_pad = width - visible_w - left_pad;
+
+ for _ in 0..left_pad {
+ bar.push(' ');
+ }
+ chars_cursor += left_pad;
+ let label_chars_start = chars_cursor;
+ bar.push_str(&visible);
+ chars_cursor += visible.chars().count();
+ let label_chars_end = chars_cursor;
+ for _ in 0..right_pad {
+ bar.push(' ');
+ }
+ chars_cursor += right_pad;
+
+ let armed = cell
+ .modifier
+ .map(|m| modifiers.is_armed(m))
+ .unwrap_or(false);
+ label_ranges.push((label_chars_start..label_chars_end, armed));
+
+ cells_cursor += width;
+
+ if slot + 1 < n {
+ // Separators are blank cells on the shared selected background; the
+ // gap is represented by spaces rather than a drawn glyph.
+ for _ in 0..sep_w {
+ bar.push(' ');
+ }
+ chars_cursor += sep_w;
+ cells_cursor += sep_w;
+ cell_boundaries.push(cells_cursor);
+ }
+ }
+
+ for _ in 0..layout.right_margin {
+ bar.push(' ');
+ }
+ chars_cursor += layout.right_margin;
+ cells_cursor += layout.right_margin;
+ cell_boundaries.push(cells_cursor);
+ let _ = chars_cursor;
+
+ let mut text = Text::new(&bar).selected();
+ for (range, armed) in &label_ranges {
+ let level = if *armed { 2 } else { 3 };
+ text = text.color_range(level, range.clone());
+ }
+ print_text_with_coordinates(text, 0, row, Some(cols), None);
+
+ for (slot, &cell_idx) in indices.iter().enumerate() {
+ click_regions.push(ClickRegion::tight(
+ row,
+ cell_boundaries[slot],
+ cell_boundaries[slot + 1],
+ ClickAction::Keyboard(BAR[cell_idx].id),
+ ));
+ }
+}
+
+struct BarLayout {
+ widths: Vec<usize>,
+ left_margin: usize,
+ right_margin: usize,
+}
+
+fn compute_bar_layout(cols: usize, naturals: &[usize]) -> BarLayout {
+ let n = naturals.len();
+ if n == 0 {
+ return BarLayout {
+ widths: Vec::new(),
+ left_margin: cols,
+ right_margin: 0,
+ };
+ }
+ let natural_sum: usize = naturals.iter().sum();
+ let slack = cols.saturating_sub(natural_sum);
+ let pad = slack / (2 * n);
+ let widths: Vec<usize> = naturals.iter().map(|&nat| nat + 2 * pad).collect();
+ let used: usize = widths.iter().sum();
+ let outer = cols.saturating_sub(used);
+ let left_margin = outer / 2;
+ let right_margin = outer - left_margin;
+ BarLayout {
+ widths,
+ left_margin,
+ right_margin,
+ }
+}
+
+fn truncate_to_width(label: &str, max_w: usize) -> String {
+ let mut out = String::new();
+ let mut w = 0usize;
+ for ch in label.chars() {
+ let mut buf = [0u8; 4];
+ let ch_str = ch.encode_utf8(&mut buf);
+ let ch_w = UnicodeWidthStr::width(ch_str as &str);
+ if w + ch_w > max_w {
+ break;
+ }
+ out.push(ch);
+ w += ch_w;
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn consume_one_shots_clears_modifiers() {
+ let mut m = KeyboardModifiers {
+ ctrl_armed: true,
+ alt_armed: true,
+ };
+ m.consume_one_shots();
+ assert!(!m.ctrl_armed);
+ assert!(!m.alt_armed);
+ }
+
+ #[test]
+ fn toggle_flips_modifier_state() {
+ let mut m = KeyboardModifiers::default();
+ assert!(!m.is_armed(Modifier::Ctrl));
+ m.toggle(Modifier::Ctrl);
+ assert!(m.is_armed(Modifier::Ctrl));
+ m.toggle(Modifier::Ctrl);
+ assert!(!m.is_armed(Modifier::Ctrl));
+ }
+
+ #[test]
+ fn bar_layout_totals_to_cols() {
+ let naturals = vec![1usize; BAR_CELL_COUNT];
+ for cols in [9, 10, 20, 80, 137] {
+ let layout = compute_bar_layout(cols, &naturals);
+ let total: usize =
+ layout.widths.iter().sum::<usize>() + layout.left_margin + layout.right_margin;
+ assert_eq!(total, cols, "cols={}", cols);
+ assert_eq!(layout.widths.len(), BAR_CELL_COUNT);
+ }
+ }
+
+ #[test]
+ fn bar_layout_every_cell_has_identical_symmetric_padding() {
+ let naturals = vec![3, 3, 4, 3, 1, 1, 1, 1, 1];
+ for cols in [18, 19, 20, 26, 27, 50, 80, 137] {
+ let layout = compute_bar_layout(cols, &naturals);
+ let pads: Vec<usize> = layout
+ .widths
+ .iter()
+ .zip(naturals.iter())
+ .map(|(&w, &nat)| {
+ assert!(w >= nat, "cols={} cell width {} < natural {}", cols, w, nat);
+ let p2 = w - nat;
+ assert_eq!(p2 % 2, 0, "cols={} cell padding {} not even", cols, p2);
+ p2 / 2
+ })
+ .collect();
+ let first = pads[0];
+ for (i, &p) in pads.iter().enumerate() {
+ assert_eq!(
+ p, first,
+ "cols={} cell {} pad={} (expected {})",
+ cols, i, p, first
+ );
+ }
+ assert!(
+ layout.left_margin + layout.right_margin < 2 * naturals.len(),
+ "cols={} outer margin overflow",
+ cols
+ );
+ let diff = layout.left_margin.abs_diff(layout.right_margin);
+ assert!(
+ diff <= 1,
+ "cols={} margins {}/{} not centred",
+ cols,
+ layout.left_margin,
+ layout.right_margin
+ );
+ }
+ }
+
+ #[test]
+ fn bar_layout_no_padding_when_slack_too_small() {
+ let naturals = vec![3, 3, 4, 3, 1, 1, 1, 1, 1];
+ let layout = compute_bar_layout(19, &naturals);
+ assert_eq!(layout.widths, naturals);
+ assert_eq!(layout.left_margin + layout.right_margin, 1);
+ }
+
+ #[test]
+ fn bar_layout_uses_padding_when_slack_permits() {
+ let naturals = vec![3, 3, 4, 3, 1, 1, 1, 1, 1];
+ let layout = compute_bar_layout(36, &naturals);
+ assert_eq!(layout.widths, vec![5, 5, 6, 5, 3, 3, 3, 3, 3]);
+ assert_eq!(layout.left_margin, 0);
+ assert_eq!(layout.right_margin, 0);
+ }
+
+ #[test]
+ fn truncate_to_width_drops_overflow() {
+ assert_eq!(truncate_to_width("CTRL", 2), "CT");
+ assert_eq!(truncate_to_width("ESC", 3), "ESC");
+ assert_eq!(truncate_to_width("ESC", 0), "");
+ }
+
+ #[test]
+ fn choose_config_picks_full_layout_when_wide() {
+ assert_eq!(
+ choose_config(42),
+ Some((DropMode::None, LabelMode::Full, SepMode::Wide))
+ );
+ assert_eq!(
+ choose_config(80),
+ Some((DropMode::None, LabelMode::Full, SepMode::Wide))
+ );
+ }
+
+ #[test]
+ fn choose_config_degrades_separator_before_labels() {
+ assert_eq!(
+ choose_config(41),
+ Some((DropMode::None, LabelMode::Full, SepMode::Compact))
+ );
+ assert_eq!(
+ choose_config(26),
+ Some((DropMode::None, LabelMode::Full, SepMode::Compact))
+ );
+ }
+
+ #[test]
+ fn choose_config_degrades_labels_before_dropping_cells() {
+ assert_eq!(
+ choose_config(25),
+ Some((DropMode::None, LabelMode::Short, SepMode::Compact))
+ );
+ assert_eq!(
+ choose_config(22),
+ Some((DropMode::None, LabelMode::Short, SepMode::Compact))
+ );
+ }
+
+ #[test]
+ fn choose_config_drops_minus_when_short_compact_falls_short() {
+ assert_eq!(
+ choose_config(21),
+ Some((DropMode::DropMinus, LabelMode::Short, SepMode::Compact))
+ );
+ assert_eq!(
+ choose_config(20),
+ Some((DropMode::DropMinus, LabelMode::Short, SepMode::Compact))
+ );
+ }
+
+ #[test]
+ fn choose_config_drops_minus_and_tab_at_minimum() {
+ assert_eq!(
+ choose_config(19),
+ Some((
+ DropMode::DropMinusAndTab,
+ LabelMode::Short,
+ SepMode::Compact
+ ))
+ );
+ assert_eq!(
+ choose_config(17),
+ Some((
+ DropMode::DropMinusAndTab,
+ LabelMode::Short,
+ SepMode::Compact
+ ))
+ );
+ }
+
+ #[test]
+ fn choose_config_none_when_too_narrow() {
+ assert_eq!(choose_config(16), None);
+ assert_eq!(choose_config(0), None);
+ }
+
+ #[test]
+ fn render_modifier_bar_pushes_one_region_per_cell() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 5, 90, &mut regions);
+ assert_eq!(regions.len(), BAR_CELL_COUNT);
+ regions.sort_by_key(|r| r.col_start);
+ let mut cursor = 0usize;
+ for r in &regions {
+ assert_eq!(r.row_start, 5);
+ assert_eq!(r.row_end, 6);
+ assert_eq!(r.col_start, cursor);
+ cursor = r.col_end;
+ }
+ assert_eq!(cursor, 90);
+ }
+
+ #[test]
+ fn render_modifier_bar_tiles_at_compact_width() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 30, &mut regions);
+ assert_eq!(regions.len(), BAR_CELL_COUNT);
+ regions.sort_by_key(|r| r.col_start);
+ let mut cursor = 0usize;
+ for r in &regions {
+ assert_eq!(r.col_start, cursor);
+ cursor = r.col_end;
+ }
+ assert_eq!(cursor, 30);
+ }
+
+ #[test]
+ fn render_modifier_bar_drops_minus_at_narrow() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 21, &mut regions);
+ assert_eq!(regions.len(), 8);
+ for r in &regions {
+ if let ClickAction::Keyboard(id) = r.action {
+ assert_ne!(id, CELL_MINUS);
+ }
+ }
+ }
+
+ #[test]
+ fn render_modifier_bar_drops_minus_and_tab_at_minimum() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 17, &mut regions);
+ assert_eq!(regions.len(), 7);
+ for r in &regions {
+ if let ClickAction::Keyboard(id) = r.action {
+ assert_ne!(id, CELL_MINUS);
+ assert_ne!(id, CELL_TAB);
+ }
+ }
+ }
+
+ #[test]
+ fn render_modifier_bar_noop_when_too_narrow() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 16, &mut regions);
+ assert!(regions.is_empty());
+ }
+}
diff --git a/default-plugins/mobile/src/components/top_bar.rs b/default-plugins/mobile/src/components/top_bar.rs
new file mode 100644
index 00000000..c873c2e6
--- /dev/null
+++ b/default-plugins/mobile/src/components/top_bar.rs
@@ -0,0 +1,412 @@
+use std::ops::Range;
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::ansi::pad_or_truncate;
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::screens::ActiveScreen;
+use crate::workspace::Workspace;
+
+const PREFIX: &str = "Zellij ";
+const HAMBURGER: &str = "\u{2630}";
+const SESSION_PANE_SEP: &str = " ";
+
+const HAMBURGER_SLOP_CELLS: usize = 3;
+
+pub(crate) fn render(
+ ws: &Workspace,
+ frame: &mut Frame,
+ active: ActiveScreen,
+ row: usize,
+ cols: usize,
+) {
+ let pane_name = current_pane_name(ws);
+ let session_name = ws.session_name.clone();
+ let content_max = cols.saturating_sub(HAMBURGER_SLOP_CELLS + width(HAMBURGER));
+ let plan = Plan::compute(
+ session_name.as_deref().map(width),
+ width(&pane_name),
+ width(PREFIX),
+ width(SESSION_PANE_SEP),
+ content_max,
+ );
+
+ let mut bar = BarBuilder::default();
+ if plan.show_prefix {
+ bar.push(PREFIX);
+ }
+
+ let session_seg = session_name
+ .as_ref()
+ .filter(|_| plan.session_target > 0)
+ .map(|session| {
+ let seg = bar.push(&pad_or_truncate(session, plan.session_target));
+ bar.push(SESSION_PANE_SEP);
+ seg
+ });
+
+ let pane_seg = bar.push(&pad_or_truncate(&pane_name, plan.pane_target));
+ let pane_end = bar.cells;
+
+ let pad = cols
+ .saturating_sub(bar.cells + width(HAMBURGER))
+ .max(HAMBURGER_SLOP_CELLS);
+ bar.push(&" ".repeat(pad));
+
+ let hamburger_start = bar.cells;
+ let hamburger_seg = bar.push(HAMBURGER);
+
+ paint(
+ &bar.text,
+ row,
+ cols,
+ &pane_seg,
+ &hamburger_seg,
+ session_seg.as_ref(),
+ );
+
+ let (pane_action, session_action) = actions_for(active);
+ let session_cells = session_seg.map(|s| (s.cells.start, s.cells.end));
+ for region in click_regions(
+ row,
+ cols,
+ pane_end,
+ hamburger_start,
+ pane_action,
+ session_cells,
+ session_action,
+ ) {
+ frame.click_regions.push(region);
+ }
+}
+
+fn paint(
+ bar: &str,
+ row: usize,
+ cols: usize,
+ pane_seg: &Segment,
+ hamburger_seg: &Segment,
+ session_seg: Option<&Segment>,
+) {
+ let mut text = Text::new(bar)
+ .selected()
+ .color_range(2, pane_seg.chars.clone())
+ .color_range(3, hamburger_seg.chars.clone());
+ if let Some(seg) = session_seg {
+ text = text.color_range(0, seg.chars.clone());
+ }
+ print_text_with_coordinates(text, 0, row, Some(cols), None);
+}
+
+fn actions_for(active: ActiveScreen) -> (ClickAction, ClickAction) {
+ if active == ActiveScreen::Viewport {
+ (ClickAction::ExpandPanes, ClickAction::ExpandSessions)
+ } else {
+ (ClickAction::CollapseSelector, ClickAction::CollapseSelector)
+ }
+}
+
+fn current_pane_name(ws: &Workspace) -> String {
+ ws.current_pane()
+ .map(|p| {
+ if p.title.is_empty() {
+ format!("#{}", p.id)
+ } else {
+ p.title.clone()
+ }
+ })
+ .unwrap_or_else(|| "—".to_string())
+}
+
+fn width(s: &str) -> usize {
+ UnicodeWidthStr::width(s)
+}
+
+#[derive(Default)]
+struct BarBuilder {
+ text: String,
+ chars: usize,
+ cells: usize,
+}
+
+struct Segment {
+ chars: Range<usize>,
+ cells: Range<usize>,
+}
+
+impl BarBuilder {
+ fn push(&mut self, s: &str) -> Segment {
+ let chars = self.chars..self.chars + s.chars().count();
+ let cells = self.cells..self.cells + width(s);
+ self.text.push_str(s);
+ self.chars = chars.end;
+ self.cells = cells.end;
+ Segment { chars, cells }
+ }
+}
+
+struct Plan {
+ show_prefix: bool,
+ session_target: usize,
+ pane_target: usize,
+}
+
+impl Plan {
+ fn compute(
+ session_w: Option<usize>,
+ pane_w: usize,
+ prefix_w: usize,
+ sep_w: usize,
+ content_max: usize,
+ ) -> Self {
+ let Some(session_w) = session_w else {
+ return Plan {
+ show_prefix: true,
+ session_target: 0,
+ pane_target: pane_w.min(content_max.saturating_sub(prefix_w)),
+ };
+ };
+ if prefix_w + session_w + sep_w + pane_w <= content_max {
+ return Plan {
+ show_prefix: true,
+ session_target: session_w,
+ pane_target: pane_w,
+ };
+ }
+ if session_w + sep_w + pane_w <= content_max {
+ return Plan {
+ show_prefix: false,
+ session_target: session_w,
+ pane_target: pane_w,
+ };
+ }
+ let available = content_max.saturating_sub(sep_w);
+ let half = available / 2;
+ let (session_target, pane_target) = if session_w <= half {
+ (session_w, available.saturating_sub(session_w))
+ } else if pane_w <= half {
+ (available.saturating_sub(pane_w), pane_w)
+ } else {
+ (half, available.saturating_sub(half))
+ };
+ Plan {
+ show_prefix: false,
+ session_target,
+ pane_target,
+ }
+ }
+}
+
+pub fn click_regions(
+ row: usize,
+ cols: usize,
+ pane_end: usize,
+ hamburger_start: usize,
+ pane_action: ClickAction,
+ session_cells: Option<(usize, usize)>,
+ session_action: ClickAction,
+) -> Vec<ClickRegion> {
+ let mut regions = Vec::with_capacity(5);
+ push_content_regions(
+ &mut regions,
+ row,
+ pane_end,
+ session_cells,
+ pane_action,
+ session_action,
+ );
+
+ regions.push(ClickRegion::tight(
+ row,
+ hamburger_start,
+ cols,
+ ClickAction::ToggleMenu,
+ ));
+ let hamburger_center = (hamburger_start.min(cols.saturating_sub(1)), row);
+ regions.push(ClickRegion::slop(
+ row,
+ pane_end,
+ cols,
+ ClickAction::ToggleMenu,
+ hamburger_center,
+ ));
+ regions
+}
+
+fn push_content_regions(
+ regions: &mut Vec<ClickRegion>,
+ row: usize,
+ pane_end: usize,
+ session_cells: Option<(usize, usize)>,
+ pane_action: ClickAction,
+ session_action: ClickAction,
+) {
+ let Some((session_start, session_end)) = clamp_session(session_cells, pane_end) else {
+ if pane_end > 0 {
+ regions.push(ClickRegion::tight(row, 0, pane_end, pane_action));
+ }
+ return;
+ };
+ if session_start > 0 {
+ regions.push(ClickRegion::tight(
+ row,
+ 0,
+ session_start,
+ pane_action.clone(),
+ ));
+ }
+ regions.push(ClickRegion::tight(
+ row,
+ session_start,
+ session_end,
+ session_action,
+ ));
+ if session_end < pane_end {
+ regions.push(ClickRegion::tight(row, session_end, pane_end, pane_action));
+ }
+}
+
+fn clamp_session(session_cells: Option<(usize, usize)>, pane_end: usize) -> Option<(usize, usize)> {
+ let (start, end) = session_cells?;
+ let (start, end) = (start.min(pane_end), end.min(pane_end));
+ (start < end).then_some((start, end))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::state::State;
+
+ #[test]
+ fn partition_with_slop() {
+ let cols = 80;
+ let pane_end = 40;
+ let hamburger_start = 79;
+ let regions = click_regions(
+ 0,
+ cols,
+ pane_end,
+ hamburger_start,
+ ClickAction::ExpandPanes,
+ None,
+ ClickAction::ExpandSessions,
+ );
+
+ assert_eq!(regions.len(), 3);
+ assert!(matches!(regions[0].action, ClickAction::ExpandPanes));
+ assert_eq!(regions[0].priority, 0);
+ assert_eq!(regions[0].col_start, 0);
+ assert_eq!(regions[0].col_end, pane_end);
+ assert!(matches!(regions[1].action, ClickAction::ToggleMenu));
+ assert_eq!(regions[1].priority, 0);
+ assert_eq!(regions[1].col_start, hamburger_start);
+ assert_eq!(regions[1].col_end, cols);
+ assert!(matches!(regions[2].action, ClickAction::ToggleMenu));
+ assert_eq!(regions[2].priority, 1);
+ assert_eq!(regions[2].col_start, pane_end);
+ assert_eq!(regions[2].col_end, cols);
+
+ let mut state = State::default();
+ state.frame.click_regions = regions.clone();
+ assert_eq!(
+ state.frame.click_to_action(0, 0),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, pane_end + 5),
+ Some(ClickAction::ToggleMenu),
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, hamburger_start),
+ Some(ClickAction::ToggleMenu)
+ );
+ }
+
+ #[test]
+ fn pane_action_collapses_in_selector_mode() {
+ let regions = click_regions(
+ 0,
+ 80,
+ 40,
+ 79,
+ ClickAction::CollapseSelector,
+ None,
+ ClickAction::CollapseSelector,
+ );
+ assert!(matches!(regions[0].action, ClickAction::CollapseSelector));
+ let mut state = State::default();
+ state.frame.click_regions = regions;
+ assert_eq!(
+ state.frame.click_to_action(0, 0),
+ Some(ClickAction::CollapseSelector)
+ );
+ }
+
+ #[test]
+ fn session_sub_region_dispatches_expand_sessions() {
+ let regions = click_regions(
+ 0,
+ 80,
+ 17,
+ 79,
+ ClickAction::ExpandPanes,
+ Some((7, 11)),
+ ClickAction::ExpandSessions,
+ );
+
+ assert_eq!(regions.len(), 5);
+ assert_eq!((regions[0].col_start, regions[0].col_end), (0, 7));
+ assert!(matches!(regions[0].action, ClickAction::ExpandPanes));
+ assert_eq!((regions[1].col_start, regions[1].col_end), (7, 11));
+ assert!(matches!(regions[1].action, ClickAction::ExpandSessions));
+ assert_eq!((regions[2].col_start, regions[2].col_end), (11, 17));
+ assert!(matches!(regions[2].action, ClickAction::ExpandPanes));
+
+ let mut state = State::default();
+ state.frame.click_regions = regions;
+ assert_eq!(
+ state.frame.click_to_action(0, 3),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 9),
+ Some(ClickAction::ExpandSessions)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 11),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 14),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 30),
+ Some(ClickAction::ToggleMenu)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 79),
+ Some(ClickAction::ToggleMenu)
+ );
+ }
+
+ #[test]
+ fn session_at_left_edge_skips_empty_prefix_region() {
+ let regions = click_regions(
+ 0,
+ 40,
+ 11,
+ 39,
+ ClickAction::ExpandPanes,
+ Some((0, 4)),
+ ClickAction::ExpandSessions,
+ );
+
+ assert_eq!(regions.len(), 4);
+ assert_eq!((regions[0].col_start, regions[0].col_end), (0, 4));
+ assert!(matches!(regions[0].action, ClickAction::ExpandSessions));
+ assert_eq!((regions[1].col_start, regions[1].col_end), (4, 11));
+ assert!(matches!(regions[1].action, ClickAction::ExpandPanes));
+ }
+}
diff --git a/default-plugins/mobile/src/fit.rs b/default-plugins/mobile/src/fit.rs
new file mode 100644
index 00000000..b5ea284d
--- /dev/null
+++ b/default-plugins/mobile/src/fit.rs
@@ -0,0 +1,146 @@
+use zellij_tile::prelude::*;
+
+use crate::frame::{chrome_offsets, Frame};
+use crate::workspace::{pane_id_of, Workspace};
+
+#[derive(Default)]
+pub struct Fit {
+ pub active: bool,
+ pub tab_id: Option<usize>,
+ pub last_sent_size: Option<Size>,
+}
+
+impl Fit {
+ pub fn toggle(&mut self, ws: &Workspace, frame: &Frame, suppress_top_bar: bool) -> bool {
+ if self.active {
+ let tab_id = self.tab_id.unwrap_or_default();
+ self.active = false;
+ self.tab_id = None;
+ self.last_sent_size = None;
+ set_tab_fit(tab_id, None);
+ true
+ } else {
+ let Some(pane) = ws.current_pane() else {
+ return false;
+ };
+ let Some(tab) = ws.current_tab().cloned() else {
+ return false;
+ };
+ self.active = true;
+ self.tab_id = Some(tab.tab_id);
+ let size = embedded_size(frame, suppress_top_bar);
+ set_tab_fit(tab.tab_id, Some((pane_id_of(&pane), size)));
+ self.last_sent_size = Some(size);
+ true
+ }
+ }
+
+ pub fn clear_if_active(&mut self) {
+ if self.active {
+ let tab_id = self.tab_id.unwrap_or_default();
+ self.active = false;
+ self.tab_id = None;
+ self.last_sent_size = None;
+ set_tab_fit(tab_id, None);
+ }
+ }
+
+ pub fn reset_local(&mut self) {
+ self.active = false;
+ self.tab_id = None;
+ self.last_sent_size = None;
+ }
+
+ pub fn notify_size(&mut self, ws: &Workspace, frame: &Frame, suppress_top_bar: bool) {
+ if !self.active {
+ return;
+ }
+ let Some(tab_id) = self.tab_id else {
+ return;
+ };
+ let Some(pane) = ws.current_pane() else {
+ return;
+ };
+ let size = embedded_size(frame, suppress_top_bar);
+ if self.last_sent_size == Some(size) {
+ return;
+ }
+ set_tab_fit(tab_id, Some((pane_id_of(&pane), size)));
+ self.last_sent_size = Some(size);
+ }
+}
+
+pub fn embedded_size(frame: &Frame, suppress_top_bar: bool) -> Size {
+ let (body_top, bar_height) = chrome_offsets(
+ frame.last_render_rows,
+ suppress_top_bar,
+ frame.soft_keyboard_visible,
+ );
+ Size {
+ rows: frame
+ .last_render_rows
+ .saturating_sub(bar_height)
+ .saturating_sub(body_top),
+ cols: frame.last_render_cols,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frame_with_dims(rows: usize, cols: usize, soft_keyboard: bool) -> Frame {
+ let mut frame = Frame::default();
+ frame.last_render_rows = rows;
+ frame.last_render_cols = cols;
+ frame.soft_keyboard_visible = soft_keyboard;
+ frame
+ }
+
+ #[test]
+ fn embedded_size_default_top_bar_only() {
+ let frame = frame_with_dims(20, 80, false);
+ assert_eq!(embedded_size(&frame, false), Size { rows: 19, cols: 80 });
+ }
+
+ #[test]
+ fn embedded_size_soft_keyboard_adds_bottom() {
+ let frame = frame_with_dims(20, 80, true);
+ assert_eq!(embedded_size(&frame, false), Size { rows: 18, cols: 80 });
+ }
+
+ #[test]
+ fn embedded_size_suppressed_top_bar() {
+ let frame = frame_with_dims(20, 80, false);
+ assert_eq!(embedded_size(&frame, true), Size { rows: 20, cols: 80 });
+ }
+
+ #[test]
+ fn notify_size_gated_off() {
+ let ws = Workspace::default();
+ let frame = frame_with_dims(20, 80, false);
+ let mut fit = Fit::default();
+ fit.active = false;
+ fit.tab_id = Some(7);
+ fit.notify_size(&ws, &frame, false);
+ assert_eq!(fit.last_sent_size, None);
+ fit.active = true;
+ fit.tab_id = None;
+ fit.notify_size(&ws, &frame, false);
+ assert!(fit.active);
+ assert_eq!(fit.last_sent_size, None);
+ }
+
+ #[test]
+ fn clear_if_active_round_trip() {
+ let mut fit = Fit::default();
+ fit.active = true;
+ fit.tab_id = Some(7);
+ fit.clear_if_active();
+ assert!(!fit.active);
+ assert_eq!(fit.tab_id, None);
+ fit.clear_if_active();
+ assert!(!fit.active);
+ assert_eq!(fit.tab_id, None);
+ }
+}
diff --git a/default-plugins/mobile/src/frame.rs b/default-plugins/mobile/src/frame.rs
new file mode 100644
index 00000000..9428de27
--- /dev/null
+++ b/default-plugins/mobile/src/frame.rs
@@ -0,0 +1,142 @@
+use zellij_tile::prelude::*;
+
+use crate::click::{slop_key, ClickAction, ClickRegion};
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum LastEmittedCursor {
+ #[default]
+ Unknown,
+ Sent(Option<(usize, usize)>),
+}
+
+#[derive(Default)]
+pub struct Frame {
+ pub click_regions: Vec<ClickRegion>,
+ pub last_render_rows: usize,
+ pub last_render_cols: usize,
+ pub last_emitted_cursor: LastEmittedCursor,
+ pub soft_keyboard_visible: bool,
+}
+
+impl Frame {
+ pub fn emit_cursor(&mut self, new_pos: Option<(usize, usize)>) {
+ let needs_emit = match self.last_emitted_cursor {
+ LastEmittedCursor::Unknown => true,
+ LastEmittedCursor::Sent(prev) => prev != new_pos,
+ };
+ if needs_emit {
+ show_cursor(new_pos);
+ self.last_emitted_cursor = LastEmittedCursor::Sent(new_pos);
+ }
+ }
+
+ pub fn click_to_action(&self, row: usize, col: usize) -> Option<ClickAction> {
+ for region in &self.click_regions {
+ if region.priority == 0
+ && region.row_start <= row
+ && row < region.row_end
+ && col >= region.col_start
+ && col < region.col_end
+ {
+ return Some(region.action.clone());
+ }
+ }
+ let mut best: Option<(&ClickRegion, u64)> = None;
+ for region in &self.click_regions {
+ if region.priority == 0 {
+ continue;
+ }
+ if row < region.row_start || row >= region.row_end {
+ continue;
+ }
+ if col < region.col_start || col >= region.col_end {
+ continue;
+ }
+ let Some((cx, cy)) = region.center else {
+ continue;
+ };
+ let dx = (cx as i64 - col as i64).unsigned_abs();
+ let dy = (cy as i64 - row as i64).unsigned_abs();
+ let dist_sq = dx * dx + dy * dy;
+ best = Some(match best {
+ None => (region, dist_sq),
+ Some((cur, cur_d)) if dist_sq < cur_d => (region, dist_sq),
+ Some((cur, cur_d)) if dist_sq == cur_d => {
+ let cur_key = slop_key(cur);
+ let new_key = slop_key(region);
+ if new_key < cur_key {
+ (region, dist_sq)
+ } else {
+ (cur, cur_d)
+ }
+ },
+ Some(prev) => prev,
+ });
+ }
+ best.map(|(r, _)| r.action.clone())
+ }
+}
+
+pub fn chrome_offsets(
+ rows: usize,
+ suppress_top_bar: bool,
+ soft_keyboard_visible: bool,
+) -> (usize, usize) {
+ let body_top = if suppress_top_bar { 0 } else { 1 };
+ let bar_height = if soft_keyboard_visible && rows.saturating_sub(body_top) >= 2 {
+ 1
+ } else {
+ 0
+ };
+ (body_top, bar_height)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::components::modifier_bar::CellId;
+
+ fn kb(id: u16) -> ClickAction {
+ ClickAction::Keyboard(CellId(id))
+ }
+
+ #[test]
+ fn tight_wins_over_overlapping_slop() {
+ let mut f = Frame::default();
+ f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
+ f.click_regions
+ .push(ClickRegion::slop(5, 9, 14, kb(1), (11, 5)));
+ f.click_regions.push(ClickRegion::tight(5, 13, 16, kb(2)));
+ f.click_regions
+ .push(ClickRegion::slop(5, 12, 17, kb(2), (14, 5)));
+ assert_eq!(f.click_to_action(5, 12), Some(kb(1)));
+ assert_eq!(f.click_to_action(5, 13), Some(kb(2)));
+ }
+
+ #[test]
+ fn slop_resolves_by_nearest_center() {
+ let mut f = Frame::default();
+ f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
+ for r in 4..=6 {
+ f.click_regions
+ .push(ClickRegion::slop(r, 9, 14, kb(1), (11, 5)));
+ }
+ f.click_regions.push(ClickRegion::tight(7, 10, 13, kb(2)));
+ for r in 6..=8 {
+ f.click_regions
+ .push(ClickRegion::slop(r, 9, 14, kb(2), (11, 7)));
+ }
+ assert_eq!(f.click_to_action(6, 11), Some(kb(1)));
+ assert_eq!(f.click_to_action(8, 11), Some(kb(2)));
+ }
+
+ #[test]
+ fn miss_returns_none() {
+ let mut f = Frame::default();
+ f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
+ f.click_regions
+ .push(ClickRegion::slop(5, 9, 14, kb(1), (11, 5)));
+ assert!(f.click_to_action(0, 0).is_none());
+ assert!(f.click_to_action(5, 20).is_none());
+ }
+}
diff --git a/default-plugins/mobile/src/input.rs b/default-plugins/mobile/src/input.rs
new file mode 100644
index 00000000..d797f19c
--- /dev/null
+++ b/default-plugins/mobile/src/input.rs
@@ -0,0 +1,28 @@
+use zellij_tile::prelude::*;
+
+use crate::components::modifier_bar::{CellId, ModifierBarController, TapOutcome};
+
+#[derive(Default)]
+pub struct Input {
+ pub modifier_bar: ModifierBarController,
+ pub ctrl_held: bool,
+ pub alt_held: bool,
+}
+
+impl Input {
+ pub fn merge_held_modifiers(&self, key: &KeyWithModifier) -> KeyWithModifier {
+ let mut merged = key.clone();
+ if self.ctrl_held {
+ merged.key_modifiers.insert(KeyModifier::Ctrl);
+ }
+ if self.alt_held {
+ merged.key_modifiers.insert(KeyModifier::Alt);
+ }
+ merged
+ }
+
+ pub fn handle_tap(&mut self, cell: CellId) -> TapOutcome {
+ self.modifier_bar
+ .handle_tap(cell, &mut self.ctrl_held, &mut self.alt_held)
+ }
+}
diff --git a/default-plugins/mobile/src/keys.rs b/default-plugins/mobile/src/keys.rs
new file mode 100644
index 00000000..ba385686
--- /dev/null
+++ b/default-plugins/mobile/src/keys.rs
@@ -0,0 +1,195 @@
+use zellij_tile::prelude::*;
+
+pub fn serialize_key(key: &KeyWithModifier) -> Vec<u8> {
+ let ctrl = key.key_modifiers.contains(&KeyModifier::Ctrl);
+ let alt = key.key_modifiers.contains(&KeyModifier::Alt);
+ let shift = key.key_modifiers.contains(&KeyModifier::Shift);
+
+ match key.bare_key {
+ BareKey::Char(c) => serialize_char(c, ctrl, alt, shift),
+ BareKey::Enter => prefix_alt(alt, b"\r"),
+ BareKey::Tab => {
+ if shift {
+ let back_tab = b"\x1b[Z";
+ prefix_alt(alt, back_tab)
+ } else {
+ prefix_alt(alt, b"\t")
+ }
+ },
+ BareKey::Backspace => {
+ if ctrl {
+ prefix_alt(alt, b"\x08")
+ } else {
+ prefix_alt(alt, b"\x7f")
+ }
+ },
+ BareKey::Esc => prefix_alt(alt, b"\x1b"),
+ BareKey::Up => arrow_or_modified(b'A', ctrl, alt, shift),
+ BareKey::Down => arrow_or_modified(b'B', ctrl, alt, shift),
+ BareKey::Right => arrow_or_modified(b'C', ctrl, alt, shift),
+ BareKey::Left => arrow_or_modified(b'D', ctrl, alt, shift),
+ BareKey::Home => arrow_or_modified(b'H', ctrl, alt, shift),
+ BareKey::End => arrow_or_modified(b'F', ctrl, alt, shift),
+ BareKey::Insert => tilde_or_modified(2, ctrl, alt, shift),
+ BareKey::Delete => tilde_or_modified(3, ctrl, alt, shift),
+ BareKey::PageUp => tilde_or_modified(5, ctrl, alt, shift),
+ BareKey::PageDown => tilde_or_modified(6, ctrl, alt, shift),
+ BareKey::F(n) => serialize_function_key(n, ctrl, alt, shift),
+ BareKey::CapsLock
+ | BareKey::ScrollLock
+ | BareKey::NumLock
+ | BareKey::PrintScreen
+ | BareKey::Pause
+ | BareKey::Menu => Vec::new(),
+ }
+}
+
+fn serialize_char(c: char, ctrl: bool, alt: bool, _shift: bool) -> Vec<u8> {
+ if ctrl {
+ if let Some(byte) = ctrl_byte(c) {
+ return prefix_alt(alt, &[byte]);
+ }
+ }
+ let mut buf = [0u8; 4];
+ let encoded = c.encode_utf8(&mut buf).as_bytes();
+ prefix_alt(alt, encoded)
+}
+
+fn ctrl_byte(c: char) -> Option<u8> {
+ let lower = c.to_ascii_lowercase();
+ match lower {
+ '@' | ' ' => Some(0x00),
+ c if ('a'..='z').contains(&c) => Some((c as u8) - b'`'),
+ '[' => Some(0x1b),
+ '\\' => Some(0x1c),
+ ']' => Some(0x1d),
+ '^' => Some(0x1e),
+ '_' | '?' => Some(0x1f),
+ _ => None,
+ }
+}
+
+fn arrow_or_modified(letter: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ if ctrl || shift {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[1;{}{}", modifier, letter as char).into_bytes()
+ } else {
+ prefix_alt(alt, &[0x1b, b'[', letter])
+ }
+}
+
+fn tilde_or_modified(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ if ctrl || shift {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[{};{}~", n, modifier).into_bytes()
+ } else {
+ prefix_alt(alt, format!("\x1b[{}~", n).as_bytes())
+ }
+}
+
+fn serialize_function_key(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ let any_mod = ctrl || shift;
+ match n {
+ 1..=4 => {
+ let letter = match n {
+ 1 => 'P',
+ 2 => 'Q',
+ 3 => 'R',
+ 4 => 'S',
+ _ => unreachable!(),
+ };
+ if any_mod {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[1;{}{}", modifier, letter).into_bytes()
+ } else {
+ prefix_alt(alt, format!("\x1bO{}", letter).as_bytes())
+ }
+ },
+ 5 => format_tilde_fkey(15, ctrl, alt, shift),
+ 6 => format_tilde_fkey(17, ctrl, alt, shift),
+ 7 => format_tilde_fkey(18, ctrl, alt, shift),
+ 8 => format_tilde_fkey(19, ctrl, alt, shift),
+ 9 => format_tilde_fkey(20, ctrl, alt, shift),
+ 10 => format_tilde_fkey(21, ctrl, alt, shift),
+ 11 => format_tilde_fkey(23, ctrl, alt, shift),
+ 12 => format_tilde_fkey(24, ctrl, alt, shift),
+ _ => Vec::new(),
+ }
+}
+
+fn format_tilde_fkey(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ if ctrl || shift {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[{};{}~", n, modifier).into_bytes()
+ } else {
+ prefix_alt(alt, format!("\x1b[{}~", n).as_bytes())
+ }
+}
+
+fn encode_modifiers(ctrl: bool, alt: bool, shift: bool) -> u8 {
+ let mut m = 1u8;
+ if shift {
+ m += 1;
+ }
+ if alt {
+ m += 2;
+ }
+ if ctrl {
+ m += 4;
+ }
+ m
+}
+
+fn prefix_alt(alt: bool, body: &[u8]) -> Vec<u8> {
+ if alt {
+ let mut out = Vec::with_capacity(body.len() + 1);
+ out.push(0x1b);
+ out.extend_from_slice(body);
+ out
+ } else {
+ body.to_vec()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeSet;
+
+ fn key_with(bare: BareKey, mods: &[KeyModifier]) -> KeyWithModifier {
+ let mut set = BTreeSet::new();
+ for m in mods {
+ set.insert(*m);
+ }
+ KeyWithModifier {
+ bare_key: bare,
+ key_modifiers: set,
+ }
+ }
+
+ #[test]
+ fn f1_unmodified() {
+ assert_eq!(serialize_key(&key_with(BareKey::F(1), &[])), b"\x1bOP");
+ }
+
+ #[test]
+ fn f12_unmodified() {
+ assert_eq!(serialize_key(&key_with(BareKey::F(12), &[])), b"\x1b[24~");
+ }
+
+ #[test]
+ fn ctrl_c() {
+ assert_eq!(
+ serialize_key(&key_with(BareKey::Char('c'), &[KeyModifier::Ctrl])),
+ vec![0x03]
+ );
+ }
+
+ #[test]
+ fn alt_x() {
+ assert_eq!(
+ serialize_key(&key_with(BareKey::Char('x'), &[KeyModifier::Alt])),
+ vec![0x1b, b'x']
+ );
+ }
+}
diff --git a/default-plugins/mobile/src/main.rs b/default-plugins/mobile/src/main.rs
new file mode 100644
index 00000000..a61fe8d5
--- /dev/null
+++ b/default-plugins/mobile/src/main.rs
@@ -0,0 +1,577 @@
+mod ansi;
+mod click;
+mod components;
+mod fit;
+mod frame;
+mod input;
+mod keys;
+mod mouse;
+mod navigation;
+mod pane_sync;
+mod render;
+mod screens;
+mod state;
+mod workspace;
+
+use std::collections::BTreeMap;
+use zellij_tile::prelude::*;
+
+use crate::components::modifier_bar::{CellId, TapOutcome};
+use crate::screens::ActiveScreen;
+use crate::state::State;
+use crate::workspace::pane_id_of;
+
+register_plugin!(State);
+
+impl ZellijPlugin for State {
+ fn load(&mut self, _configuration: BTreeMap<String, String>) {
+ let ids = get_plugin_ids();
+ self.workspace.own_plugin_pane_id = Some(PaneId::Plugin(ids.plugin_id));
+
+ subscribe(&[
+ EventType::ModeUpdate,
+ EventType::TabUpdate,
+ EventType::PaneUpdate,
+ EventType::Key,
+ EventType::Mouse,
+ EventType::PaneRenderReportWithAnsi,
+ EventType::SessionUpdate,
+ EventType::SoftKeyboardVisibilityChanged,
+ EventType::Timer,
+ ]);
+ }
+
+ fn update(&mut self, event: Event) -> bool {
+ let should_render = match event {
+ Event::ModeUpdate(mode_info) => {
+ self.workspace.mode_info = Some(mode_info);
+ true
+ },
+ Event::TabUpdate(tabs) => {
+ self.workspace.tabs = tabs;
+ if self.workspace.selected_tab_position.is_none() {
+ self.workspace.selected_tab_position =
+ self.workspace.tabs_in_order().first().map(|t| t.position);
+ }
+ if let Some(pos) = self.workspace.selected_tab_position {
+ let still_visible = self
+ .workspace
+ .tabs_in_order()
+ .iter()
+ .any(|t| t.position == pos);
+ if !still_visible {
+ self.workspace.selected_tab_position =
+ self.workspace.tabs_in_order().first().map(|t| t.position);
+ }
+ }
+ sync_shadow_focus(self);
+ true
+ },
+ Event::PaneUpdate(manifest) => {
+ pane_sync::refresh_pane_manifest(self, manifest);
+ pane_sync::reconcile_selected_tab(self);
+ pane_sync::resolve_pending_new_tab(self);
+ pane_sync::ensure_pane_selected(self);
+ sync_shadow_focus(self);
+ pane_sync::maybe_take_over_welcome(self);
+ true
+ },
+ Event::SessionUpdate(sessions, _) => {
+ if let Some(current) = sessions.iter().find(|s| s.is_current_session) {
+ self.workspace.session_name = Some(current.name.clone());
+ }
+ let filtered = filter_sessions_for_client(sessions, self);
+ self.sessions.sessions = filtered;
+ true
+ },
+ Event::Timer(_) => {
+ self.empty_welcome_list_grace_elapsed = true;
+ true
+ },
+ Event::PaneRenderReportWithAnsi(map) => {
+ let now = unix_now();
+ for id in map.keys() {
+ self.workspace.pane_last_activity.insert(*id, now);
+ }
+ self.workspace.latest_pane_contents.extend(map);
+ if self.active == ActiveScreen::Panes {
+ refresh_pane_titles(self);
+ }
+ true
+ },
+ Event::Mouse(mouse) => {
+ match mouse {
+ Mouse::ScrollUp(lines) => {
+ let up = true;
+ return mouse::scroll_or_pan(self, lines, up);
+ },
+ Mouse::ScrollDown(lines) => {
+ let up = false;
+ return mouse::scroll_or_pan(self, lines, up);
+ },
+ Mouse::ScrollRight(cols) => {
+ let right = true;
+ return mouse::pan_horizontally(self, cols, right);
+ },
+ Mouse::ScrollLeft(cols) => {
+ let right = false;
+ return mouse::pan_horizontally(self, cols, right);
+ },
+ Mouse::LeftClick(..) => {
+ if let Some((line, col)) = mouse.position() {
+ return mouse::handle_left_click(self, line, col);
+ }
+ },
+ _ => {},
+ }
+ false
+ },
+ Event::Key(key) => match self.active {
+ ActiveScreen::NewSessionPrompt => {
+ self.new_session.handle_key(&mut self.active, key)
+ },
+ ActiveScreen::Sessions => {
+ self.sessions
+ .handle_key(&mut self.active, &mut self.navigation, key)
+ },
+ ActiveScreen::Panes => {
+ if let Some((tab_position, pane_id)) = self.panes.handle_key(
+ &mut self.active,
+ &mut self.navigation,
+ &self.workspace,
+ key,
+ ) {
+ self.select_pane(tab_position, pane_id);
+ }
+ true
+ },
+ ActiveScreen::Viewport => {
+ if key.bare_key == BareKey::Esc && self.menu.open {
+ self.menu.open = false;
+ true
+ } else {
+ self.viewport
+ .handle_key(&self.workspace, &mut self.input, key)
+ }
+ },
+ },
+ Event::SoftKeyboardVisibilityChanged(visible) => {
+ if self.frame.soft_keyboard_visible == visible {
+ return false;
+ }
+ self.frame.soft_keyboard_visible = visible;
+ true
+ },
+ _ => false,
+ };
+ if self.fit.active {
+ let suppress_top_bar =
+ self.sessions.is_welcome_screen || self.active == ActiveScreen::Sessions;
+ self.fit
+ .notify_size(&self.workspace, &self.frame, suppress_top_bar);
+ }
+ should_render && self.is_ready_to_render()
+ }
+
+ fn render(&mut self, rows: usize, cols: usize) {
+ if rows == 0 || cols == 0 || !self.is_ready_to_render() {
+ return;
+ }
+ render::render(self, rows, cols);
+ }
+}
+
+impl State {
+ fn is_ready_to_render(&self) -> bool {
+ if self.workspace.tabs.is_empty() && self.workspace.panes_by_tab_position.is_empty() {
+ return false;
+ }
+ if self.showing_empty_welcome_list_within_grace() {
+ return false;
+ }
+ if self.active == ActiveScreen::Viewport {
+ return self.viewport_pane_has_content();
+ }
+ true
+ }
+
+ fn showing_empty_welcome_list_within_grace(&self) -> bool {
+ self.sessions.is_welcome_screen
+ && self.sessions.sessions.is_empty()
+ && !self.empty_welcome_list_grace_elapsed
+ }
+
+ fn viewport_pane_has_content(&self) -> bool {
+ let Some(pane) = self.workspace.current_pane() else {
+ return false;
+ };
+ self.workspace
+ .latest_pane_contents
+ .get(&pane_id_of(&pane))
+ .map(|contents| !contents.viewport.is_empty())
+ .unwrap_or(false)
+ }
+
+ pub fn open_sessions(&mut self) -> bool {
+ self.menu.open = false;
+ self.navigation.selector_scroll_offset = 0;
+ self.sessions.welcome_search.clear();
+ if let Ok(snapshot) = get_session_list() {
+ let filtered = filter_sessions_for_client(snapshot.live_sessions, self);
+ self.sessions.sessions = filtered;
+ }
+ self.active = ActiveScreen::Sessions;
+ true
+ }
+
+ pub fn open_panes(&mut self) -> bool {
+ self.menu.open = false;
+ self.navigation.selector_scroll_offset = 0;
+ self.panes.panes_search.clear();
+ refresh_pane_titles(self);
+ self.active = ActiveScreen::Panes;
+ true
+ }
+
+ pub fn collapse_selector(&mut self) -> bool {
+ self.active = ActiveScreen::Viewport;
+ self.sessions.welcome_search.clear();
+ self.panes.panes_search.clear();
+ self.navigation.selector_scroll_offset = 0;
+ true
+ }
+
+ pub fn select_pane(&mut self, tab_position: usize, pane_id: PaneId) -> bool {
+ self.fit.clear_if_active();
+ self.workspace.selected_tab_position = Some(tab_position);
+ self.workspace.selected_pane_id = Some(pane_id);
+ self.viewport.reset_pan();
+ self.active = ActiveScreen::Viewport;
+ sync_shadow_focus(self);
+ true
+ }
+
+ pub fn toggle_fit(&mut self) -> bool {
+ let suppress_top_bar =
+ self.sessions.is_welcome_screen || self.active == ActiveScreen::Sessions;
+ self.fit
+ .toggle(&self.workspace, &self.frame, suppress_top_bar)
+ }
+
+ pub fn new_pane_in_tab(&mut self, tab_position: usize) -> bool {
+ self.fit.clear_if_active();
+ if let Some(new_id) = new_tiled_pane_in_tab(tab_position) {
+ self.workspace.selected_tab_position = Some(tab_position);
+ self.workspace.selected_pane_id = Some(new_id);
+ self.viewport.reset_pan();
+ self.active = ActiveScreen::Viewport;
+ sync_shadow_focus(self);
+ }
+ true
+ }
+
+ pub fn new_tab(&mut self) -> bool {
+ self.fit.clear_if_active();
+ if let Some(tab_position) = new_tab_unfocused::<&str>(None, None) {
+ self.workspace.pending_new_tab_position = Some(tab_position);
+ }
+ true
+ }
+
+ pub fn open_new_session_prompt(&mut self) -> bool {
+ self.new_session.open(&mut self.active)
+ }
+
+ pub fn cancel_new_session_prompt(&mut self) -> bool {
+ self.new_session.cancel(&mut self.active)
+ }
+
+ pub fn accept_new_session_prompt(&mut self) -> bool {
+ self.new_session.accept(&mut self.active)
+ }
+
+ pub fn keyboard_tap(&mut self, cell: CellId) -> bool {
+ let outcome = self.input.handle_tap(cell);
+ match outcome {
+ TapOutcome::SendBytes(bytes) => {
+ if let Some(pane) = self.workspace.current_pane() {
+ if !bytes.is_empty() {
+ write_to_pane_id(bytes, pane_id_of(&pane));
+ }
+ }
+ },
+ TapOutcome::Toggled | TapOutcome::NoOp => {},
+ }
+ true
+ }
+}
+
+pub fn filter_sessions_for_client(sessions: Vec<SessionInfo>, state: &State) -> Vec<SessionInfo> {
+ let is_web_client = state
+ .workspace
+ .mode_info
+ .as_ref()
+ .and_then(|m| m.is_web_client)
+ .unwrap_or(false);
+ sessions
+ .into_iter()
+ .filter(|s| !is_welcome_session(s))
+ .filter(|s| !is_web_client || s.web_clients_allowed)
+ .collect()
+}
+
+fn is_welcome_session(session: &SessionInfo) -> bool {
+ session
+ .panes
+ .panes
+ .values()
+ .flatten()
+ .any(|p| p.is_plugin && p.plugin_url.as_deref() == Some("welcome-screen"))
+}
+
+pub fn sync_shadow_focus(state: &State) {
+ if let Some(pane) = state.workspace.current_pane() {
+ set_shadow_focus(pane_id_of(&pane));
+ }
+}
+
+pub fn unix_now() -> u64 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+pub fn refresh_pane_titles(state: &mut State) {
+ let pane_ids: Vec<PaneId> = state
+ .workspace
+ .panes_by_tab_position
+ .values()
+ .flat_map(|panes| panes.iter().map(pane_id_of))
+ .collect();
+ for id in pane_ids {
+ let Some(fresh) = get_pane_info(id) else {
+ continue;
+ };
+ for panes in state.workspace.panes_by_tab_position.values_mut() {
+ for p in panes.iter_mut() {
+ if pane_id_of(p) == id {
+ p.title = fresh.title.clone();
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::click::{self, ClickAction};
+ use zellij_tile::prelude::{PaneInfo, SessionInfo, TabInfo};
+
+ fn fit_ready_state() -> State {
+ let mut state = State::default();
+ let tab = TabInfo {
+ position: 0,
+ name: "shell".to_string(),
+ tab_id: 7,
+ display_area_rows: 24,
+ display_area_columns: 80,
+ viewport_rows: 22,
+ viewport_columns: 80,
+ ..TabInfo::default()
+ };
+ state.workspace.tabs.push(tab);
+ state.workspace.selected_tab_position = Some(0);
+ let pane = PaneInfo {
+ id: 3,
+ is_plugin: false,
+ is_selectable: true,
+ pane_rows: 22,
+ pane_columns: 80,
+ pane_content_rows: 20,
+ pane_content_columns: 78,
+ ..PaneInfo::default()
+ };
+ state.workspace.panes_by_tab_position.insert(0, vec![pane]);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(3));
+ state
+ }
+
+ #[test]
+ fn dispatch_toggle_fit_on_path_seeds_fields() {
+ let mut state = fit_ready_state();
+ assert!(!state.fit.active, "Pre-condition: fit is off");
+ let consumed = click::dispatch(&mut state, ClickAction::ToggleFit);
+ assert!(consumed);
+ assert!(state.fit.active);
+ assert_eq!(state.fit.tab_id, Some(7));
+ }
+
+ #[test]
+ fn dispatch_toggle_fit_off_path_clears_fields() {
+ let mut state = fit_ready_state();
+ state.fit.active = true;
+ state.fit.tab_id = Some(7);
+ let consumed = click::dispatch(&mut state, ClickAction::ToggleFit);
+ assert!(consumed);
+ assert!(!state.fit.active);
+ assert_eq!(state.fit.tab_id, None);
+ }
+
+ #[test]
+ fn pane_update_clears_fit_when_selected_pane_disappears() {
+ let mut state = fit_ready_state();
+ state.fit.active = true;
+ state.fit.tab_id = Some(7);
+
+ let replacement_pane = PaneInfo {
+ id: 99,
+ is_plugin: false,
+ is_selectable: true,
+ ..PaneInfo::default()
+ };
+ let mut panes = std::collections::HashMap::new();
+ panes.insert(0_usize, vec![replacement_pane]);
+ let manifest = PaneManifest { panes };
+
+ state.update(Event::PaneUpdate(manifest));
+
+ assert!(!state.fit.active, "Local fit mirror cleared");
+ assert_eq!(state.fit.tab_id, None);
+ }
+
+ #[test]
+ fn pane_update_resolves_pending_new_tab() {
+ let mut state = State::default();
+ let mut tab0 = TabInfo::default();
+ tab0.position = 0;
+ state.workspace.tabs.push(tab0);
+ let mut pane0 = PaneInfo::default();
+ pane0.id = 1;
+ pane0.is_plugin = false;
+ pane0.is_selectable = true;
+ state.workspace.panes_by_tab_position.insert(0, vec![pane0]);
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(1));
+ state.active = ActiveScreen::Panes;
+ state.workspace.pending_new_tab_position = Some(1);
+
+ let mut new_tab = TabInfo::default();
+ new_tab.position = 1;
+ state.workspace.tabs.push(new_tab);
+ let mut new_pane = PaneInfo::default();
+ new_pane.id = 7;
+ new_pane.is_plugin = false;
+ new_pane.is_selectable = true;
+ let mut panes_map = std::collections::HashMap::new();
+ panes_map.insert(
+ 0_usize,
+ vec![PaneInfo {
+ id: 1,
+ is_plugin: false,
+ is_selectable: true,
+ ..PaneInfo::default()
+ }],
+ );
+ panes_map.insert(1_usize, vec![new_pane]);
+ let manifest = PaneManifest { panes: panes_map };
+
+ state.update(Event::PaneUpdate(manifest));
+
+ assert_eq!(state.workspace.selected_tab_position, Some(1));
+ assert_eq!(state.workspace.selected_pane_id, Some(PaneId::Terminal(7)));
+ assert_eq!(state.active, ActiveScreen::Viewport);
+ assert_eq!(state.workspace.pending_new_tab_position, None);
+ }
+
+ #[test]
+ fn pane_update_keeps_pending_when_target_tab_empty() {
+ let mut state = State::default();
+ let mut tab0 = TabInfo::default();
+ tab0.position = 0;
+ state.workspace.tabs.push(tab0);
+ let mut pane0 = PaneInfo::default();
+ pane0.id = 1;
+ pane0.is_plugin = false;
+ pane0.is_selectable = true;
+ state.workspace.panes_by_tab_position.insert(0, vec![pane0]);
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(1));
+ state.workspace.pending_new_tab_position = Some(5);
+
+ let mut panes_map = std::collections::HashMap::new();
+ panes_map.insert(
+ 0_usize,
+ vec![PaneInfo {
+ id: 1,
+ is_plugin: false,
+ is_selectable: true,
+ ..PaneInfo::default()
+ }],
+ );
+ let manifest = PaneManifest { panes: panes_map };
+ state.update(Event::PaneUpdate(manifest));
+
+ assert_eq!(state.workspace.pending_new_tab_position, Some(5));
+ assert_eq!(state.workspace.selected_tab_position, Some(0));
+ }
+
+ fn welcome_state(session_names: &[&str]) -> State {
+ let mut state = fit_ready_state();
+ state.active = ActiveScreen::Sessions;
+ state.sessions.is_welcome_screen = true;
+ state.sessions.sessions = session_names
+ .iter()
+ .map(|name| SessionInfo {
+ name: name.to_string(),
+ ..SessionInfo::default()
+ })
+ .collect();
+ state
+ }
+
+ #[test]
+ fn welcome_empty_list_is_not_ready_within_grace() {
+ let mut state = welcome_state(&[]);
+ state.empty_welcome_list_grace_elapsed = false;
+ assert!(!state.is_ready_to_render());
+ }
+
+ #[test]
+ fn welcome_empty_list_is_ready_after_grace() {
+ let mut state = welcome_state(&[]);
+ state.empty_welcome_list_grace_elapsed = true;
+ assert!(state.is_ready_to_render());
+ }
+
+ #[test]
+ fn welcome_non_empty_list_is_ready_within_grace() {
+ let mut state = welcome_state(&["alpha"]);
+ state.empty_welcome_list_grace_elapsed = false;
+ assert!(state.is_ready_to_render());
+ }
+
+ #[test]
+ fn timer_event_elapses_welcome_grace() {
+ let mut state = welcome_state(&[]);
+ assert!(!state.empty_welcome_list_grace_elapsed);
+ state.update(Event::Timer(0.0));
+ assert!(state.empty_welcome_list_grace_elapsed);
+ assert!(state.is_ready_to_render());
+ }
+
+ #[test]
+ fn viewport_without_a_pane_is_not_ready() {
+ let mut state = State::default();
+ state.workspace.tabs.push(TabInfo {
+ position: 0,
+ ..TabInfo::default()
+ });
+ state.workspace.selected_tab_position = Some(0);
+ state.active = ActiveScreen::Viewport;
+ assert!(state.workspace.current_pane().is_none());
+ assert!(!state.is_ready_to_render());
+ }
+}
diff --git a/default-plugins/mobile/src/mouse.rs b/default-plugins/mobile/src/mouse.rs
new file mode 100644
index 00000000..1112d213
--- /dev/null
+++ b/default-plugins/mobile/src/mouse.rs
@@ -0,0 +1,180 @@
+use std::collections::BTreeMap;
+use zellij_tile::prelude::*;
+
+use crate::click;
+use crate::screens::ActiveScreen;
+use crate::state::State;
+use crate::workspace::pane_id_of;
+
+pub(crate) fn scroll_or_pan(state: &mut State, lines: usize, up: bool) -> bool {
+ if state.active != ActiveScreen::Viewport {
+ handle_selector_scroll(state, lines, up)
+ } else {
+ handle_scroll_pan(state, lines, up)
+ }
+}
+
+pub(crate) fn pan_horizontally(state: &mut State, cols: usize, right: bool) -> bool {
+ if !pan_is_allowed(state) {
+ return false;
+ }
+ state.viewport.viewport_h_pan = if right {
+ state.viewport.viewport_h_pan.saturating_add(cols)
+ } else {
+ state.viewport.viewport_h_pan.saturating_sub(cols)
+ };
+ true
+}
+
+pub(crate) fn handle_left_click(state: &mut State, line: usize, col: usize) -> bool {
+ if let Some(action) = state.frame.click_to_action(line, col) {
+ return click::dispatch(state, action);
+ }
+ if state.menu.open {
+ state.menu.open = false;
+ return true;
+ }
+ forward_click_to_pane(state, line, col)
+}
+
+fn forward_click_to_pane(state: &mut State, line: usize, col: usize) -> bool {
+ let Some((pane_row, pane_col)) = state.viewport.click_in_viewport(line, col) else {
+ return false;
+ };
+ let Some(pane) = state.workspace.current_pane() else {
+ return false;
+ };
+ if pane.is_plugin {
+ let mut args = BTreeMap::new();
+ args.insert("row".to_string(), pane_row.to_string());
+ args.insert("col".to_string(), pane_col.to_string());
+ let message = MessageToPlugin::new("mobile_viewport_click")
+ .with_destination_plugin_id(pane.id)
+ .with_args(args);
+ pipe_message_to_plugin(message);
+ } else {
+ let bytes = sgr_left_click(pane_row, pane_col);
+ write_to_pane_id(bytes, pane_id_of(&pane));
+ }
+ false
+}
+
+fn apply_v_pan(old_pan: usize, max_pan: usize, lines: usize, up: bool) -> (usize, usize) {
+ if up {
+ let desired = old_pan.saturating_add(lines);
+ let new_pan = desired.min(max_pan);
+ let absorbed = new_pan - old_pan;
+ (new_pan, lines - absorbed)
+ } else {
+ let new_pan = old_pan.saturating_sub(lines);
+ let absorbed = old_pan - new_pan;
+ (new_pan, lines - absorbed)
+ }
+}
+
+fn handle_selector_scroll(state: &mut State, lines: usize, up: bool) -> bool {
+ let step = lines.min(1);
+ let old = state.navigation.selector_scroll_offset;
+ state.navigation.selector_scroll_offset = if up {
+ old.saturating_sub(step)
+ } else {
+ old.saturating_add(step)
+ };
+ state.navigation.selector_scroll_offset != old
+}
+
+fn handle_scroll_pan(state: &mut State, lines: usize, up: bool) -> bool {
+ if !pan_is_allowed(state) {
+ return false;
+ }
+ let Some(max_v_pan) = state.viewport.max_viewport_v_pan(&state.workspace) else {
+ if up {
+ state.viewport.viewport_v_pan = state.viewport.viewport_v_pan.saturating_add(lines);
+ } else {
+ state.viewport.viewport_v_pan = state.viewport.viewport_v_pan.saturating_sub(lines);
+ }
+ return true;
+ };
+ let old_pan = state.viewport.viewport_v_pan;
+ let (new_pan, overflow) = apply_v_pan(old_pan, max_v_pan, lines, up);
+ let pan_moved = new_pan != old_pan;
+ state.viewport.viewport_v_pan = new_pan;
+ if overflow > 0 {
+ if let Some(pane) = state.workspace.current_pane() {
+ let pane_id = pane_id_of(&pane);
+ for _ in 0..overflow {
+ if up {
+ scroll_up_in_pane_id(pane_id);
+ } else {
+ scroll_down_in_pane_id(pane_id);
+ }
+ }
+ }
+ }
+ pan_moved
+}
+
+fn pan_is_allowed(state: &State) -> bool {
+ if state.active != ActiveScreen::Viewport {
+ return false;
+ }
+ if state.workspace.current_pane().is_none() {
+ return false;
+ }
+ let len = state.workspace.current_pane_viewport_len();
+ if len == 0 {
+ return false;
+ }
+ true
+}
+
+fn sgr_left_click(pane_row: usize, pane_col: usize) -> Vec<u8> {
+ let col = pane_col + 1;
+ let row = pane_row + 1;
+ format!("\x1b[<0;{};{}M\x1b[<0;{};{}m", col, row, col, row).into_bytes()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn apply_v_pan_up_fully_absorbed() {
+ assert_eq!(apply_v_pan(0, 100, 3, true), (3, 0));
+ assert_eq!(apply_v_pan(50, 100, 3, true), (53, 0));
+ }
+
+ #[test]
+ fn apply_v_pan_up_partial_overflow() {
+ assert_eq!(apply_v_pan(99, 100, 3, true), (100, 2));
+ assert_eq!(apply_v_pan(98, 100, 5, true), (100, 3));
+ }
+
+ #[test]
+ fn apply_v_pan_up_fully_overflowed() {
+ assert_eq!(apply_v_pan(100, 100, 3, true), (100, 3));
+ }
+
+ #[test]
+ fn apply_v_pan_up_zero_max() {
+ assert_eq!(apply_v_pan(0, 0, 3, true), (0, 3));
+ assert_eq!(apply_v_pan(0, 0, 0, true), (0, 0));
+ }
+
+ #[test]
+ fn apply_v_pan_down_partial_overflow() {
+ assert_eq!(apply_v_pan(2, 100, 3, false), (0, 1));
+ assert_eq!(apply_v_pan(5, 100, 3, false), (2, 0));
+ }
+
+ #[test]
+ fn apply_v_pan_down_fully_overflowed() {
+ assert_eq!(apply_v_pan(0, 100, 3, false), (0, 3));
+ }
+
+ #[test]
+ fn apply_v_pan_zero_lines() {
+ assert_eq!(apply_v_pan(5, 100, 0, true), (5, 0));
+ assert_eq!(apply_v_pan(5, 100, 0, false), (5, 0));
+ }
+}
diff --git a/default-plugins/mobile/src/navigation.rs b/default-plugins/mobile/src/navigation.rs
new file mode 100644
index 00000000..424ce88d
--- /dev/null
+++ b/default-plugins/mobile/src/navigation.rs
@@ -0,0 +1,14 @@
+use fuzzy_matcher::skim::SkimMatcherV2;
+
+#[derive(Default)]
+pub struct Navigation {
+ pub selector_scroll_offset: usize,
+ pub fuzzy_matcher: Option<SkimMatcherV2>,
+}
+
+impl Navigation {
+ pub fn matcher(&mut self) -> &mut SkimMatcherV2 {
+ self.fuzzy_matcher
+ .get_or_insert_with(|| SkimMatcherV2::default().use_cache(true))
+ }
+}
diff --git a/default-plugins/mobile/src/pane_sync.rs b/default-plugins/mobile/src/pane_sync.rs
new file mode 100644
index 00000000..4b6abd15
--- /dev/null
+++ b/default-plugins/mobile/src/pane_sync.rs
@@ -0,0 +1,99 @@
+use zellij_tile::prelude::*;
+
+use crate::filter_sessions_for_client;
+use crate::screens::ActiveScreen;
+use crate::state::State;
+use crate::workspace::pane_id_of;
+
+const EMPTY_WELCOME_LIST_GRACE_SECS: f64 = 0.4;
+
+pub fn refresh_pane_manifest(state: &mut State, manifest: PaneManifest) {
+ state.workspace.panes_by_tab_position = manifest.panes;
+ let live_pane_ids: std::collections::HashSet<PaneId> = state
+ .workspace
+ .panes_by_tab_position
+ .values()
+ .flat_map(|panes| panes.iter().map(pane_id_of))
+ .collect();
+ state
+ .workspace
+ .latest_pane_contents
+ .retain(|id, _| live_pane_ids.contains(id));
+ state
+ .workspace
+ .pane_last_activity
+ .retain(|id, _| live_pane_ids.contains(id));
+ if let Some(selected) = state.workspace.selected_pane_id {
+ if !live_pane_ids.contains(&selected) {
+ state.fit.reset_local();
+ }
+ }
+}
+
+pub fn reconcile_selected_tab(state: &mut State) {
+ let Some(pos) = state.workspace.selected_tab_position else {
+ state.workspace.selected_tab_position =
+ state.workspace.tabs_in_order().first().map(|t| t.position);
+ return;
+ };
+ let still_visible = state
+ .workspace
+ .tabs_in_order()
+ .iter()
+ .any(|t| t.position == pos);
+ if !still_visible {
+ state.fit.clear_if_active();
+ state.workspace.selected_tab_position =
+ state.workspace.tabs_in_order().first().map(|t| t.position);
+ state.workspace.selected_pane_id = None;
+ }
+}
+
+pub fn resolve_pending_new_tab(state: &mut State) {
+ let Some(target) = state.workspace.pending_new_tab_position else {
+ return;
+ };
+ let Some(id) = state
+ .workspace
+ .panes_for_tab(target)
+ .into_iter()
+ .next()
+ .map(pane_id_of)
+ else {
+ return;
+ };
+ state.workspace.selected_tab_position = Some(target);
+ state.workspace.selected_pane_id = Some(id);
+ state.viewport.reset_pan();
+ state.active = ActiveScreen::Viewport;
+ state.workspace.pending_new_tab_position = None;
+}
+
+pub fn ensure_pane_selected(state: &mut State) {
+ if state.workspace.selected_pane_id.is_none() {
+ if let Some(pane) = state.workspace.current_tab_panes().into_iter().next() {
+ state.workspace.selected_pane_id = Some(pane_id_of(pane));
+ }
+ }
+}
+
+pub fn maybe_take_over_welcome(state: &mut State) {
+ if state.sessions.is_welcome_screen
+ || state.active != ActiveScreen::Viewport
+ || !state.workspace.current_pane_is_welcome()
+ {
+ return;
+ }
+ if let Some(pane) = state.workspace.current_pane() {
+ close_plugin_pane(pane.id);
+ }
+ state.active = ActiveScreen::Sessions;
+ state.navigation.selector_scroll_offset = 0;
+ state.menu.open = false;
+ state.sessions.is_welcome_screen = true;
+ if let Ok(snapshot) = get_session_list() {
+ let filtered = filter_sessions_for_client(snapshot.live_sessions, state);
+ state.sessions.sessions = filtered;
+ }
+ set_timeout(EMPTY_WELCOME_LIST_GRACE_SECS);
+}
diff --git a/default-plugins/mobile/src/render.rs b/default-plugins/mobile/src/render.rs
new file mode 100644
index 00000000..c03a4570
--- /dev/null
+++ b/default-plugins/mobile/src/render.rs
@@ -0,0 +1,125 @@
+use crate::ansi::{move_to, RESET};
+use crate::components::{modifier_bar, top_bar};
+use crate::frame::chrome_offsets;
+use crate::screens::ActiveScreen;
+use crate::state::State;
+
+pub fn render(state: &mut State, rows: usize, cols: usize) {
+ reset_frame(state, rows, cols);
+ if rows < 4 || cols < 8 {
+ render_too_small(state, rows, cols);
+ return;
+ }
+
+ let suppress_top_bar = top_bar_suppressed(state);
+ let (body_top, bar_height) =
+ chrome_offsets(rows, suppress_top_bar, state.frame.soft_keyboard_visible);
+ let body_bottom = rows.saturating_sub(bar_height);
+ let viewport_height = body_bottom.saturating_sub(body_top);
+
+ state
+ .frame
+ .emit_cursor(viewport_cursor(state, body_top, viewport_height, cols));
+
+ disable_autowrap();
+ if !suppress_top_bar && cols > 0 {
+ top_bar::render(&state.workspace, &mut state.frame, state.active, 0, cols);
+ }
+ if body_bottom > body_top {
+ render_active_screen(state, body_top, body_bottom, cols);
+ render_menu_overlay(state, body_top, body_bottom, cols);
+ }
+ if bar_height > 0 {
+ render_modifier_bar(state, body_bottom, cols);
+ }
+ enable_autowrap();
+}
+
+fn reset_frame(state: &mut State, rows: usize, cols: usize) {
+ state.frame.click_regions.clear();
+ state.viewport.viewport_region = None;
+ state.frame.last_render_rows = rows;
+ state.frame.last_render_cols = cols;
+}
+
+fn render_too_small(state: &mut State, rows: usize, cols: usize) {
+ state.frame.emit_cursor(None);
+ print!("{}\x1b[2J{}mobile {}x{}", RESET, move_to(0, 0), rows, cols);
+}
+
+fn top_bar_suppressed(state: &State) -> bool {
+ state.sessions.is_welcome_screen || state.active == ActiveScreen::Sessions
+}
+
+fn viewport_cursor(
+ state: &State,
+ body_top: usize,
+ viewport_height: usize,
+ cols: usize,
+) -> Option<(usize, usize)> {
+ if state.active != ActiveScreen::Viewport {
+ return None;
+ }
+ let lines = state.workspace.current_pane_viewport_len();
+ let max_v_pan = lines.saturating_sub(viewport_height);
+ let skip = max_v_pan - state.viewport.viewport_v_pan.min(max_v_pan);
+ state.viewport.compute_cursor_position(
+ &state.workspace,
+ body_top,
+ viewport_height,
+ cols,
+ skip,
+ state.viewport.viewport_h_pan,
+ )
+}
+
+fn render_active_screen(state: &mut State, body_top: usize, body_bottom: usize, cols: usize) {
+ let frame = &mut state.frame;
+ match state.active {
+ ActiveScreen::Viewport => {
+ state
+ .viewport
+ .render(&state.workspace, frame, body_top, body_bottom, cols)
+ },
+ ActiveScreen::Sessions => {
+ state
+ .sessions
+ .render(&mut state.navigation, frame, body_top, body_bottom, cols)
+ },
+ ActiveScreen::Panes => state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ frame,
+ body_top,
+ body_bottom,
+ cols,
+ ),
+ ActiveScreen::NewSessionPrompt => {
+ state.new_session.render(frame, body_top, body_bottom, cols)
+ },
+ }
+}
+
+fn render_menu_overlay(state: &mut State, body_top: usize, body_bottom: usize, cols: usize) {
+ if state.menu.open && state.active == ActiveScreen::Viewport {
+ state
+ .menu
+ .render(&state.fit, &mut state.frame, body_top, body_bottom, cols);
+ }
+}
+
+fn render_modifier_bar(state: &mut State, body_bottom: usize, cols: usize) {
+ let armed = modifier_bar::KeyboardModifiers {
+ ctrl_armed: state.input.ctrl_held,
+ alt_armed: state.input.alt_held,
+ };
+ modifier_bar::render_modifier_bar(&armed, body_bottom, cols, &mut state.frame.click_regions);
+}
+
+fn disable_autowrap() {
+ print!("\x1b[?7l");
+}
+
+fn enable_autowrap() {
+ print!("\x1b[?7h");
+}
diff --git a/default-plugins/mobile/src/screens/menu.rs b/default-plugins/mobile/src/screens/menu.rs
new file mode 100644
index 00000000..e016f133
--- /dev/null
+++ b/default-plugins/mobile/src/screens/menu.rs
@@ -0,0 +1,217 @@
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::fit::Fit;
+use crate::frame::Frame;
+use crate::screens::ActiveScreen;
+
+const H_PAD: usize = 1;
+const SEPARATOR_CHAR: char = '\u{2500}';
+
+const ENTRIES: [HamburgerEntry; 5] = [
+ HamburgerEntry::Item(HamburgerItem::Fit),
+ HamburgerEntry::Item(HamburgerItem::ChangePane),
+ HamburgerEntry::Item(HamburgerItem::ChangeSession),
+ HamburgerEntry::Separator,
+ HamburgerEntry::Item(HamburgerItem::SwitchToDesktop),
+];
+
+#[derive(Default)]
+pub struct MenuScreen {
+ pub open: bool,
+}
+
+impl MenuScreen {
+ pub fn toggle(&mut self, active: &mut ActiveScreen) -> bool {
+ if self.open {
+ self.open = false;
+ } else {
+ *active = ActiveScreen::Viewport;
+ self.open = true;
+ }
+ true
+ }
+
+ pub fn render(
+ &self,
+ fit: &Fit,
+ frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ let Some(layout) = MenuLayout::compute(&ENTRIES, cols) else {
+ return;
+ };
+
+ let max_visible = row_end.saturating_sub(row_start);
+ for (i, entry) in ENTRIES.iter().take(max_visible).enumerate() {
+ let row = row_start + i;
+ match entry {
+ HamburgerEntry::Item(item) => draw_item_row(frame, fit, item, &layout, row),
+ HamburgerEntry::Separator => draw_separator_row(&layout, row),
+ }
+ }
+ }
+}
+
+enum HamburgerItem {
+ Fit,
+ ChangePane,
+ ChangeSession,
+ SwitchToDesktop,
+}
+
+impl HamburgerItem {
+ fn label(&self) -> &'static str {
+ match self {
+ HamburgerItem::Fit => "Fit to Screen",
+ HamburgerItem::ChangePane => "Change Pane",
+ HamburgerItem::ChangeSession => "Change Session",
+ HamburgerItem::SwitchToDesktop => "Switch to Desktop",
+ }
+ }
+
+ fn action(&self) -> ClickAction {
+ match self {
+ HamburgerItem::Fit => ClickAction::ToggleFit,
+ HamburgerItem::ChangePane => ClickAction::ExpandPanes,
+ HamburgerItem::ChangeSession => ClickAction::ExpandSessions,
+ HamburgerItem::SwitchToDesktop => ClickAction::ExitMobileMode,
+ }
+ }
+
+ fn is_armed(&self, fit: &Fit) -> bool {
+ matches!(self, HamburgerItem::Fit) && fit.active
+ }
+}
+
+enum HamburgerEntry {
+ Item(HamburgerItem),
+ Separator,
+}
+
+impl HamburgerEntry {
+ fn label_width(&self) -> Option<usize> {
+ match self {
+ HamburgerEntry::Item(item) => Some(UnicodeWidthStr::width(item.label())),
+ HamburgerEntry::Separator => None,
+ }
+ }
+}
+
+struct MenuLayout {
+ menu_x: usize,
+ menu_w: usize,
+ label_max: usize,
+}
+
+impl MenuLayout {
+ fn compute(entries: &[HamburgerEntry], cols: usize) -> Option<Self> {
+ let label_max = entries
+ .iter()
+ .filter_map(HamburgerEntry::label_width)
+ .max()
+ .unwrap_or(0);
+ let menu_w = label_max + 2 * H_PAD;
+ if label_max == 0 || menu_w > cols {
+ return None;
+ }
+ Some(MenuLayout {
+ menu_x: cols - menu_w,
+ menu_w,
+ label_max,
+ })
+ }
+
+ fn padded_row(&self, content: &str, content_w: usize) -> String {
+ let pad = " ".repeat(H_PAD);
+ let trailing = " ".repeat(self.label_max.saturating_sub(content_w));
+ format!("{pad}{content}{trailing}{pad}")
+ }
+}
+
+fn draw_item_row(
+ frame: &mut Frame,
+ fit: &Fit,
+ item: &HamburgerItem,
+ layout: &MenuLayout,
+ row: usize,
+) {
+ let label = item.label();
+ let text = layout.padded_row(label, UnicodeWidthStr::width(label));
+ let label_chars = H_PAD..H_PAD + label.chars().count();
+
+ let mut t = Text::new(&text).selected();
+ t = if item.is_armed(fit) {
+ t.success_color_range(label_chars)
+ } else {
+ t.color_range(3, label_chars)
+ };
+ print_text_with_coordinates(t, layout.menu_x, row, Some(layout.menu_w), None);
+
+ frame.click_regions.push(ClickRegion::tight(
+ row,
+ layout.menu_x,
+ layout.menu_x + layout.menu_w,
+ item.action(),
+ ));
+}
+
+fn draw_separator_row(layout: &MenuLayout, row: usize) {
+ let rule: String = std::iter::repeat(SEPARATOR_CHAR)
+ .take(layout.label_max)
+ .collect();
+ let text = layout.padded_row(&rule, layout.label_max);
+ print_text_with_coordinates(
+ Text::new(&text).selected(),
+ layout.menu_x,
+ row,
+ Some(layout.menu_w),
+ None,
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::state::State;
+
+ #[test]
+ fn hamburger_menu_emits_four_click_regions_with_separator_above_exit() {
+ let mut state = State::default();
+ let cols = 40;
+ state.menu.render(&state.fit, &mut state.frame, 0, 20, cols);
+
+ assert_eq!(state.frame.click_regions.len(), 4);
+ let actions: Vec<ClickAction> = state
+ .frame
+ .click_regions
+ .iter()
+ .map(|r| r.action.clone())
+ .collect();
+ assert!(matches!(actions[0], ClickAction::ToggleFit));
+ assert!(matches!(actions[1], ClickAction::ExpandPanes));
+ assert!(matches!(actions[2], ClickAction::ExpandSessions));
+ assert!(matches!(actions[3], ClickAction::ExitMobileMode));
+
+ let rows: Vec<usize> = state
+ .frame
+ .click_regions
+ .iter()
+ .map(|r| r.row_start)
+ .collect();
+ assert_eq!(rows, vec![0, 1, 2, 4]);
+
+ let menu_x = state.frame.click_regions[0].col_start;
+ let menu_end = state.frame.click_regions[0].col_end;
+ for c in menu_x..menu_end {
+ assert_eq!(
+ state.frame.click_to_action(3, c),
+ None,
+ "separator row should be non-interactive at col {c}",
+ );
+ }
+ }
+}
diff --git a/default-plugins/mobile/src/screens/mod.rs b/default-plugins/mobile/src/screens/mod.rs
new file mode 100644
index 00000000..d643611c
--- /dev/null
+++ b/default-plugins/mobile/src/screens/mod.rs
@@ -0,0 +1,20 @@
+pub mod menu;
+pub mod new_session;
+pub mod panes;
+pub mod sessions;
+pub mod viewport;
+
+pub use menu::MenuScreen;
+pub use new_session::NewSessionPromptScreen;
+pub use panes::PanesScreen;
+pub use sessions::SessionsScreen;
+pub use viewport::ViewportScreen;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum ActiveScreen {
+ #[default]
+ Viewport,
+ Sessions,
+ Panes,
+ NewSessionPrompt,
+}
diff --git a/default-plugins/mobile/src/screens/new_session.rs b/default-plugins/mobile/src/screens/new_session.rs
new file mode 100644
index 00000000..cc9e7626
--- /dev/null
+++ b/default-plugins/mobile/src/screens/new_session.rs
@@ -0,0 +1,233 @@
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::screens::ActiveScreen;
+
+const H_PAD: usize = 1;
+const RESERVED_INPUT_CHARS: usize = 20;
+const ELLIPSIS: &str = "\u{2026}";
+const DEFAULT_BUTTON_GAP: usize = 6;
+const BLOCK_ROWS: usize = 5;
+const TITLE: &str = "New Session";
+const INPUT_LABEL: &str = "Name: ";
+const CANCEL_LABEL: &str = "[Cancel]";
+const ACCEPT_LABEL: &str = "[Accept]";
+
+#[derive(Default)]
+pub struct NewSessionPromptScreen {
+ pub pending_session_name: String,
+ pub new_session_view_offset: usize,
+ pub new_session_content_w: usize,
+}
+
+impl NewSessionPromptScreen {
+ fn reset(&mut self) {
+ self.pending_session_name.clear();
+ self.new_session_view_offset = 0;
+ self.new_session_content_w = 0;
+ }
+
+ pub fn open(&mut self, active: &mut ActiveScreen) -> bool {
+ self.reset();
+ *active = ActiveScreen::NewSessionPrompt;
+ true
+ }
+
+ pub fn cancel(&mut self, active: &mut ActiveScreen) -> bool {
+ self.reset();
+ *active = ActiveScreen::Sessions;
+ true
+ }
+
+ pub fn accept(&mut self, active: &mut ActiveScreen) -> bool {
+ let name = std::mem::take(&mut self.pending_session_name);
+ let arg = if name.is_empty() {
+ None
+ } else {
+ Some(name.as_str())
+ };
+ switch_session(arg);
+ self.new_session_view_offset = 0;
+ self.new_session_content_w = 0;
+ *active = ActiveScreen::Viewport;
+ true
+ }
+
+ pub fn handle_key(&mut self, active: &mut ActiveScreen, key: KeyWithModifier) -> bool {
+ match key.bare_key {
+ BareKey::Esc => {
+ self.cancel(active);
+ },
+ BareKey::Enter => {
+ self.accept(active);
+ },
+ BareKey::Backspace => {
+ self.pending_session_name.pop();
+ },
+ BareKey::Char(c) => {
+ self.pending_session_name.push(c);
+ },
+ _ => {},
+ }
+ true
+ }
+
+ pub fn render(&mut self, frame: &mut Frame, row_start: usize, row_end: usize, cols: usize) {
+ let body_height = row_end.saturating_sub(row_start);
+ if body_height == 0 || cols == 0 {
+ return;
+ }
+
+ let input = self.visible_input_row(cols);
+ let layout = self.layout(&input, cols, row_start, row_end, body_height);
+ draw_title(&layout);
+ draw_input(&input, &layout);
+ draw_buttons(frame, &layout);
+ }
+
+ fn visible_input_row(&mut self, cols: usize) -> String {
+ const CURSOR_CELL: usize = 1;
+ let max_input_total_w = cols.saturating_sub(2 * H_PAD);
+ let max_chars_no_ellipsis = max_input_total_w
+ .saturating_sub(INPUT_LABEL.len())
+ .saturating_sub(CURSOR_CELL);
+ let max_chars_with_ellipsis =
+ max_chars_no_ellipsis.saturating_sub(UnicodeWidthStr::width(ELLIPSIS));
+
+ let buffer_chars = self.pending_session_name.chars().count();
+ let view_offset = if buffer_chars > max_chars_no_ellipsis {
+ let min_offset = buffer_chars.saturating_sub(max_chars_with_ellipsis);
+ self.new_session_view_offset
+ .max(min_offset)
+ .min(buffer_chars)
+ } else {
+ 0
+ };
+ self.new_session_view_offset = view_offset;
+
+ let visible: String = self
+ .pending_session_name
+ .chars()
+ .skip(view_offset)
+ .collect();
+ if view_offset > 0 {
+ format!("{}{}{}_", INPUT_LABEL, ELLIPSIS, visible)
+ } else {
+ format!("{}{}_", INPUT_LABEL, visible)
+ }
+ }
+
+ fn layout(
+ &mut self,
+ input: &str,
+ cols: usize,
+ row_start: usize,
+ row_end: usize,
+ body_height: usize,
+ ) -> PromptLayout {
+ let default_buttons_w = UnicodeWidthStr::width(CANCEL_LABEL)
+ + DEFAULT_BUTTON_GAP
+ + UnicodeWidthStr::width(ACCEPT_LABEL);
+ let default_input_w = INPUT_LABEL.len() + RESERVED_INPUT_CHARS + 1;
+ let default_content_w = UnicodeWidthStr::width(TITLE)
+ .max(default_input_w)
+ .max(default_buttons_w);
+
+ let target_content_w = default_content_w.max(UnicodeWidthStr::width(input));
+ let content_w = self.new_session_content_w.max(target_content_w);
+ self.new_session_content_w = content_w;
+
+ let box_w = (content_w + 2 * H_PAD).min(cols);
+ let content_x = cols.saturating_sub(box_w) / 2 + H_PAD;
+
+ let top = if body_height >= BLOCK_ROWS {
+ row_start + (body_height - BLOCK_ROWS) / 2
+ } else {
+ row_start
+ };
+
+ PromptLayout {
+ content_x,
+ content_w: box_w.saturating_sub(2 * H_PAD),
+ row_title: top,
+ row_input: top + 2,
+ row_buttons: top + 4,
+ row_end,
+ }
+ }
+}
+
+struct PromptLayout {
+ content_x: usize,
+ content_w: usize,
+ row_title: usize,
+ row_input: usize,
+ row_buttons: usize,
+ row_end: usize,
+}
+
+impl PromptLayout {
+ fn centered_x(&self, label_w: usize) -> usize {
+ self.content_x + self.content_w.saturating_sub(label_w) / 2
+ }
+
+ fn right_x(&self, label_w: usize) -> usize {
+ self.content_x + self.content_w.saturating_sub(label_w)
+ }
+}
+
+fn draw_title(layout: &PromptLayout) {
+ if layout.row_title < layout.row_end {
+ let x = layout.centered_x(UnicodeWidthStr::width(TITLE));
+ print_text_with_coordinates(
+ Text::new(TITLE).color_range(3, ..),
+ x,
+ layout.row_title,
+ None,
+ None,
+ );
+ }
+}
+
+fn draw_input(input: &str, layout: &PromptLayout) {
+ if layout.row_input < layout.row_end {
+ print_text_with_coordinates(
+ Text::new(input),
+ layout.content_x,
+ layout.row_input,
+ None,
+ None,
+ );
+ }
+}
+
+fn draw_buttons(frame: &mut Frame, layout: &PromptLayout) {
+ if layout.row_buttons >= layout.row_end {
+ return;
+ }
+ let cancel_w = UnicodeWidthStr::width(CANCEL_LABEL);
+ let accept_w = UnicodeWidthStr::width(ACCEPT_LABEL);
+ let cancel_x = layout.content_x;
+ let accept_x = layout.right_x(accept_w);
+ let gap = " ".repeat(accept_x.saturating_sub(cancel_x + cancel_w));
+ let buttons = format!("{}{}{}", CANCEL_LABEL, gap, ACCEPT_LABEL);
+ let buttons_text = Text::new(&buttons)
+ .error_color_substring(CANCEL_LABEL)
+ .success_color_substring(ACCEPT_LABEL);
+ print_text_with_coordinates(buttons_text, cancel_x, layout.row_buttons, None, None);
+
+ frame.click_regions.push(ClickRegion::tight(
+ layout.row_buttons,
+ cancel_x,
+ cancel_x + cancel_w,
+ ClickAction::CancelNewSessionPrompt,
+ ));
+ frame.click_regions.push(ClickRegion::tight(
+ layout.row_buttons,
+ accept_x,
+ accept_x + accept_w,
+ ClickAction::AcceptNewSessionPrompt,
+ ));
+}
diff --git a/default-plugins/mobile/src/screens/panes.rs b/default-plugins/mobile/src/screens/panes.rs
new file mode 100644
index 00000000..bef10c5e
--- /dev/null
+++ b/default-plugins/mobile/src/screens/panes.rs
@@ -0,0 +1,879 @@
+use fuzzy_matcher::FuzzyMatcher;
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::navigation::Navigation;
+use crate::screens::ActiveScreen;
+use crate::workspace::{pane_id_of, Workspace};
+
+const CARD_BULLET: &str = "- ";
+const CARD_INDENT_W: usize = 2;
+const META_SEPARATOR: &str = ", ";
+const TITLE: &str = "Switch Pane";
+const PROMPT_LABEL: &str = "Pane: ";
+const CURRENT_PANE_LABEL: &str = "[CURRENT PANE]";
+const NEW_TAB_LABEL: &str = "+ New Tab";
+const NEW_PANE_LABEL: &str = "+ New Pane";
+const FOOTER_GAP: usize = 4;
+
+#[derive(Default)]
+pub struct PanesScreen {
+ pub panes_search: String,
+}
+
+impl PanesScreen {
+ pub fn handle_key(
+ &mut self,
+ active: &mut ActiveScreen,
+ nav: &mut Navigation,
+ ws: &Workspace,
+ key: KeyWithModifier,
+ ) -> Option<(usize, PaneId)> {
+ match key.bare_key {
+ BareKey::Esc => {
+ if !self.panes_search.is_empty() {
+ self.panes_search.clear();
+ nav.selector_scroll_offset = 0;
+ } else {
+ *active = ActiveScreen::Viewport;
+ }
+ None
+ },
+ BareKey::Enter => {
+ if let Some((tab_position, pane_id)) = self.panes_top_match(ws, nav) {
+ self.panes_search.clear();
+ nav.selector_scroll_offset = 0;
+ Some((tab_position, pane_id))
+ } else {
+ None
+ }
+ },
+ BareKey::Backspace => {
+ self.panes_search.pop();
+ nav.selector_scroll_offset = 0;
+ None
+ },
+ BareKey::Char(c) => {
+ self.panes_search.push(c);
+ nav.selector_scroll_offset = 0;
+ None
+ },
+ _ => None,
+ }
+ }
+
+ pub fn panes_top_match(
+ &mut self,
+ ws: &Workspace,
+ nav: &mut Navigation,
+ ) -> Option<(usize, PaneId)> {
+ let tabs: Vec<TabInfo> = ws.tabs_in_order().into_iter().cloned().collect();
+ let mut entries: Vec<(String, usize, PaneId)> = Vec::new();
+ for tab in &tabs {
+ for pane in ws.panes_for_tab(tab.position) {
+ entries.push((pane_title(pane), tab.position, pane_id_of(pane)));
+ }
+ }
+ if entries.is_empty() {
+ return None;
+ }
+ let search = self.panes_search.clone();
+ if search.is_empty() {
+ let first = entries.into_iter().next()?;
+ return Some((first.1, first.2));
+ }
+ let matcher = nav.matcher();
+ let mut best: Option<(i64, String, usize, PaneId)> = None;
+ for (title, tab_pos, pane_id) in entries.into_iter() {
+ if let Some((score, _)) = matcher.fuzzy_indices(&title, &search) {
+ let take = match &best {
+ None => true,
+ Some((bs, bn, _, _)) => score > *bs || (score == *bs && &title < bn),
+ };
+ if take {
+ best = Some((score, title, tab_pos, pane_id));
+ }
+ }
+ }
+ best.map(|(_, _, tab_pos, pane_id)| (tab_pos, pane_id))
+ }
+
+ pub fn render(
+ &mut self,
+ ws: &Workspace,
+ nav: &mut Navigation,
+ frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ if row_start < row_end {
+ draw_back_button(frame, row_start);
+ }
+
+ let body_start = row_start.saturating_add(1);
+ let body_end = row_end.saturating_sub(1);
+ let body_height = body_end.saturating_sub(body_start);
+ if body_height == 0 || cols == 0 {
+ return;
+ }
+
+ let new_pane_target_tab = ws.current_tab().map(|t| t.position);
+ let search = self.panes_search.clone();
+ let cards = self.ordered_cards(ws, nav, &search);
+ let layout = PickerLayout::compute(
+ nav,
+ &cards,
+ body_start,
+ body_end,
+ body_height,
+ cols,
+ new_pane_target_tab.is_some(),
+ );
+ let visible: Vec<&PaneCard> = cards
+ .iter()
+ .skip(layout.offset)
+ .take(layout.visible_count)
+ .collect();
+
+ draw_title(&layout);
+ draw_prompt(&search, &layout);
+ draw_scroll_indicators(&layout);
+ draw_cards(frame, &visible, &layout);
+ draw_footer(frame, new_pane_target_tab, &layout);
+ }
+
+ fn ordered_cards(&self, ws: &Workspace, nav: &mut Navigation, search: &str) -> Vec<PaneCard> {
+ let cards = self.collect_cards(ws);
+ if search.is_empty() {
+ return cards;
+ }
+ let matcher = nav.matcher();
+ let mut scored: Vec<(i64, PaneCard)> = cards
+ .into_iter()
+ .filter_map(|mut card| {
+ matcher
+ .fuzzy_indices(&card.title_label, search)
+ .map(|(score, indices)| {
+ card.title_indices = indices;
+ (score, card)
+ })
+ })
+ .collect();
+ scored.sort_by(|a, b| {
+ b.0.cmp(&a.0)
+ .then_with(|| a.1.title_label.cmp(&b.1.title_label))
+ });
+ scored.into_iter().map(|(_, card)| card).collect()
+ }
+
+ fn collect_cards(&self, ws: &Workspace) -> Vec<PaneCard> {
+ let now = crate::unix_now();
+ let current_tab_position = ws.current_tab().map(|t| t.position);
+ let current_pane_id = ws.current_pane().as_ref().map(pane_id_of);
+ let tabs: Vec<TabInfo> = ws.tabs_in_order().into_iter().cloned().collect();
+
+ let mut cards = Vec::new();
+ for tab in &tabs {
+ for pane in ws.panes_for_tab(tab.position) {
+ let id = pane_id_of(pane);
+ let is_current =
+ current_tab_position == Some(tab.position) && current_pane_id == Some(id);
+ cards.push(PaneCard {
+ title_label: pane_title(pane),
+ tab_label: tab.name.clone(),
+ activity_label: pane_activity_label(ws, id, is_current, now),
+ action: ClickAction::SelectPane {
+ tab_position: tab.position,
+ pane_id: id,
+ },
+ title_indices: Vec::new(),
+ is_current,
+ });
+ }
+ }
+ cards
+ }
+}
+
+fn pane_title(pane: &PaneInfo) -> String {
+ if pane.title.is_empty() {
+ format!("#{}", pane.id)
+ } else {
+ pane.title.clone()
+ }
+}
+
+fn pane_activity_label(ws: &Workspace, id: PaneId, is_current: bool, now: u64) -> String {
+ if is_current {
+ CURRENT_PANE_LABEL.to_string()
+ } else {
+ crate::ansi::format_time_ago(ws.pane_last_activity.get(&id).copied(), now)
+ }
+}
+
+struct PaneCard {
+ title_label: String,
+ tab_label: String,
+ activity_label: String,
+ action: ClickAction,
+ title_indices: Vec<usize>,
+ is_current: bool,
+}
+
+impl PaneCard {
+ fn meta_width(&self) -> usize {
+ UnicodeWidthStr::width(self.tab_label.as_str())
+ + META_SEPARATOR.len()
+ + UnicodeWidthStr::width(self.activity_label.as_str())
+ }
+
+ fn content_width(&self) -> usize {
+ UnicodeWidthStr::width(self.title_label.as_str()).max(self.meta_width())
+ }
+
+ fn draw(
+ &self,
+ frame: &mut Frame,
+ card_x: usize,
+ content_x: usize,
+ row_title: usize,
+ row_meta: usize,
+ row_end: usize,
+ ) {
+ print_text_with_coordinates(Text::new(CARD_BULLET), card_x, row_title, None, None);
+
+ let mut title_text = Text::new(&self.title_label).color_range(2, ..);
+ if !self.title_indices.is_empty() {
+ title_text = title_text.color_indices(3, self.title_indices.clone());
+ }
+ print_text_with_coordinates(title_text, content_x, row_title, None, None);
+
+ if row_meta < row_end {
+ self.draw_meta_row(content_x, row_meta);
+ }
+
+ frame.click_regions.push(ClickRegion::tight_range(
+ row_title,
+ row_meta + 1,
+ card_x,
+ content_x + self.content_width(),
+ self.action.clone(),
+ ));
+ }
+
+ fn draw_meta_row(&self, content_x: usize, row: usize) {
+ // The host reapplies bold per color-range without composing state, so a
+ // single Text would bleed the tab segment's bold onto the activity
+ // segment; emit each segment as its own Text.
+ let tab_w = UnicodeWidthStr::width(self.tab_label.as_str());
+ print_text_with_coordinates(
+ Text::new(&self.tab_label).color_range(1, ..),
+ content_x,
+ row,
+ None,
+ None,
+ );
+
+ let sep_x = content_x + tab_w;
+ print_text_with_coordinates(Text::new(META_SEPARATOR), sep_x, row, None, None);
+
+ let activity_x = sep_x + META_SEPARATOR.len();
+ let activity_text = if self.is_current {
+ Text::new(&self.activity_label).color_range(0, ..)
+ } else {
+ Text::new(&self.activity_label).unbold_all()
+ };
+ print_text_with_coordinates(activity_text, activity_x, row, None, None);
+ }
+}
+
+struct PickerLayout {
+ cols: usize,
+ row_end: usize,
+ top_y: usize,
+ block_height: usize,
+ card_x: usize,
+ content_x: usize,
+ offset: usize,
+ visible_count: usize,
+ hidden_above: usize,
+ hidden_below: usize,
+ new_tab_x: usize,
+ new_tab_w: usize,
+ new_pane_x: usize,
+ new_pane_w: usize,
+}
+
+impl PickerLayout {
+ fn compute(
+ nav: &mut Navigation,
+ cards: &[PaneCard],
+ body_start: usize,
+ body_end: usize,
+ body_height: usize,
+ cols: usize,
+ show_new_pane: bool,
+ ) -> Self {
+ let total_cards = cards.len();
+ const CHROME_ROWS: usize = 6;
+ const ROWS_PER_CARD: usize = 2;
+ let max_visible_cards =
+ (body_height.saturating_sub(CHROME_ROWS) / ROWS_PER_CARD).min(total_cards);
+ let max_offset = total_cards.saturating_sub(max_visible_cards);
+ let offset = nav.selector_scroll_offset.min(max_offset);
+ nav.selector_scroll_offset = offset;
+ let visible_count = total_cards.saturating_sub(offset).min(max_visible_cards);
+
+ let block_height = if visible_count == 0 {
+ 5.min(body_height)
+ } else {
+ (6 + 2 * visible_count).min(body_height)
+ };
+ let top_y = body_start + body_height.saturating_sub(block_height) / 2;
+
+ let content_w = cards
+ .iter()
+ .skip(offset)
+ .take(visible_count)
+ .map(PaneCard::content_width)
+ .max()
+ .unwrap_or(0);
+ let card_x = cols.saturating_sub(CARD_INDENT_W + content_w) / 2;
+ let content_x = card_x + CARD_INDENT_W;
+
+ let new_tab_w = UnicodeWidthStr::width(NEW_TAB_LABEL);
+ let new_pane_w = UnicodeWidthStr::width(NEW_PANE_LABEL);
+ let (new_tab_x, new_pane_x) = if show_new_pane {
+ let total = new_tab_w + FOOTER_GAP + new_pane_w;
+ let block_x = cols.saturating_sub(total) / 2;
+ (block_x, block_x + new_tab_w + FOOTER_GAP)
+ } else {
+ (cols.saturating_sub(new_tab_w) / 2, 0)
+ };
+
+ PickerLayout {
+ cols,
+ row_end: body_end,
+ top_y,
+ block_height,
+ card_x,
+ content_x,
+ offset,
+ visible_count,
+ hidden_above: offset,
+ hidden_below: total_cards.saturating_sub(offset + visible_count),
+ new_tab_x,
+ new_tab_w,
+ new_pane_x,
+ new_pane_w,
+ }
+ }
+
+ fn centered_x(&self, label_w: usize) -> usize {
+ self.cols.saturating_sub(label_w) / 2
+ }
+
+ fn prompt_x(&self) -> usize {
+ if self.visible_count > 0 {
+ self.content_x
+ } else {
+ self.new_tab_x
+ }
+ }
+}
+
+fn draw_back_button(frame: &mut Frame, row: usize) {
+ let back_label = "[← BACK]";
+ let back_w = UnicodeWidthStr::width(back_label);
+ print_text_with_coordinates(Text::new(back_label).color_range(3, ..), 0, row, None, None);
+ frame.click_regions.push(ClickRegion::tight(
+ row,
+ 0,
+ back_w,
+ ClickAction::CollapseSelector,
+ ));
+}
+
+fn draw_title(layout: &PickerLayout) {
+ let title_y = layout.top_y;
+ if title_y < layout.row_end {
+ let title_x = layout.centered_x(UnicodeWidthStr::width(TITLE));
+ print_text_with_coordinates(Text::new(TITLE), title_x, title_y, None, None);
+ }
+}
+
+fn draw_prompt(search: &str, layout: &PickerLayout) {
+ let prompt_y = layout.top_y + 2;
+ if prompt_y >= layout.row_end {
+ return;
+ }
+ let prompt_full = format!("{}{}_", PROMPT_LABEL, search);
+ let label_chars = PROMPT_LABEL.chars().count();
+ let total_chars = prompt_full.chars().count();
+ let prompt_text = Text::new(&prompt_full).color_range(3, label_chars..total_chars);
+ print_text_with_coordinates(prompt_text, layout.prompt_x(), prompt_y, None, None);
+}
+
+fn draw_scroll_indicators(layout: &PickerLayout) {
+ if layout.visible_count == 0 {
+ return;
+ }
+ if layout.hidden_above > 0 {
+ let y = layout.top_y + 3;
+ if y < layout.row_end {
+ let label = format!("\u{2191} [+{}]", layout.hidden_above);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+ if layout.hidden_below > 0 {
+ let y = layout.top_y + 4 + 2 * layout.visible_count;
+ if y < layout.row_end {
+ let label = format!("\u{2193} [+{}]", layout.hidden_below);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+}
+
+fn draw_cards(frame: &mut Frame, visible: &[&PaneCard], layout: &PickerLayout) {
+ let first_card_y = layout.top_y + 4;
+ for (i, card) in visible.iter().enumerate() {
+ let row_title = first_card_y + i * 2;
+ if row_title >= layout.row_end {
+ break;
+ }
+ card.draw(
+ frame,
+ layout.card_x,
+ layout.content_x,
+ row_title,
+ row_title + 1,
+ layout.row_end,
+ );
+ }
+}
+
+fn draw_footer(frame: &mut Frame, new_pane_target_tab: Option<usize>, layout: &PickerLayout) {
+ let footer_y = layout.top_y + layout.block_height.saturating_sub(1);
+ if footer_y >= layout.row_end {
+ return;
+ }
+ print_text_with_coordinates(
+ Text::new(NEW_TAB_LABEL).color_range(3, ..),
+ layout.new_tab_x,
+ footer_y,
+ None,
+ None,
+ );
+ frame.click_regions.push(ClickRegion::tight(
+ footer_y,
+ layout.new_tab_x,
+ layout.new_tab_x + layout.new_tab_w,
+ ClickAction::NewTab,
+ ));
+
+ if let Some(tab_position) = new_pane_target_tab {
+ print_text_with_coordinates(
+ Text::new(NEW_PANE_LABEL).color_range(3, ..),
+ layout.new_pane_x,
+ footer_y,
+ None,
+ None,
+ );
+ frame.click_regions.push(ClickRegion::tight(
+ footer_y,
+ layout.new_pane_x,
+ layout.new_pane_x + layout.new_pane_w,
+ ClickAction::NewPaneInTab { tab_position },
+ ));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::state::State;
+ fn state_with_tabs_and_panes(tab_count: usize) -> State {
+ use zellij_tile::prelude::TabInfo;
+ let mut state = State::default();
+ for i in 0..tab_count {
+ let mut tab = TabInfo::default();
+ tab.position = i;
+ tab.name = format!("Tab {}", i);
+ state.workspace.tabs.push(tab);
+ let mut pane = PaneInfo::default();
+ pane.id = (100 + i) as u32;
+ pane.is_plugin = false;
+ pane.is_selectable = true;
+ pane.is_suppressed = false;
+ state.workspace.panes_by_tab_position.insert(i, vec![pane]);
+ }
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(100));
+ state
+ }
+ #[test]
+ fn panes_menu_one_tab_emits_four_click_regions() {
+ let mut state = state_with_tabs_and_panes(1);
+ let cols = 40;
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ cols,
+ );
+ assert_eq!(state.frame.click_regions.len(), 4);
+ let actions: Vec<ClickAction> = state
+ .frame
+ .click_regions
+ .iter()
+ .map(|r| r.action.clone())
+ .collect();
+ assert!(matches!(actions[0], ClickAction::CollapseSelector));
+ assert!(matches!(
+ actions[1],
+ ClickAction::SelectPane {
+ tab_position: 0,
+ pane_id: PaneId::Terminal(100)
+ }
+ ));
+ assert!(matches!(actions[2], ClickAction::NewTab));
+ assert!(matches!(
+ actions[3],
+ ClickAction::NewPaneInTab { tab_position: 0 }
+ ));
+ }
+
+ #[test]
+ fn panes_menu_two_tabs_emits_single_footer_new_pane() {
+ let mut state = state_with_tabs_and_panes(2);
+ let cols = 40;
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ cols,
+ );
+ assert_eq!(state.frame.click_regions.len(), 5);
+
+ let new_panes: Vec<usize> = state
+ .frame
+ .click_regions
+ .iter()
+ .filter_map(|r| match &r.action {
+ ClickAction::NewPaneInTab { tab_position } => Some(*tab_position),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(new_panes, vec![0]);
+
+ let new_tabs = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::NewTab))
+ .count();
+ assert_eq!(new_tabs, 1);
+ }
+
+ #[test]
+ fn footer_affordances_share_a_row_and_order_left_to_right() {
+ let mut state = state_with_tabs_and_panes(1);
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let new_tab = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| matches!(r.action, ClickAction::NewTab))
+ .expect("expected a NewTab region")
+ .clone();
+ let new_pane = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| matches!(r.action, ClickAction::NewPaneInTab { .. }))
+ .expect("expected a NewPaneInTab region")
+ .clone();
+ assert_eq!(
+ new_tab.row_start, new_pane.row_start,
+ "footer affordances must share a row",
+ );
+ assert!(
+ new_tab.col_end <= new_pane.col_start,
+ "+ New Tab ({}..{}) must sit left of + New Pane ({}..)",
+ new_tab.col_start,
+ new_tab.col_end,
+ new_pane.col_start,
+ );
+ }
+
+ #[test]
+ fn click_on_new_pane_row_resolves_to_action() {
+ let mut state = state_with_tabs_and_panes(2);
+ state.workspace.selected_tab_position = Some(1);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(101));
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let new_pane_region = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| matches!(r.action, ClickAction::NewPaneInTab { tab_position: 1 }))
+ .expect("expected NewPaneInTab targeting the selected tab")
+ .clone();
+ assert_eq!(
+ state
+ .frame
+ .click_to_action(new_pane_region.row_start, new_pane_region.col_start,),
+ Some(ClickAction::NewPaneInTab { tab_position: 1 })
+ );
+ }
+
+ #[test]
+ fn panes_menu_back_button_at_top_left_collapses_selector() {
+ let mut state = state_with_tabs_and_panes(1);
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let first = state
+ .frame
+ .click_regions
+ .first()
+ .expect("expected at least one click region");
+ assert_eq!(first.row_start, 0);
+ assert_eq!(first.col_start, 0);
+ assert!(matches!(first.action, ClickAction::CollapseSelector));
+ assert_eq!(
+ state.frame.click_to_action(0, 0),
+ Some(ClickAction::CollapseSelector)
+ );
+ }
+
+ #[test]
+ fn pane_card_click_region_spans_two_rows() {
+ let mut state = state_with_tabs_and_panes(1);
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let pane_region = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| {
+ matches!(
+ r.action,
+ ClickAction::SelectPane {
+ tab_position: 0,
+ pane_id: PaneId::Terminal(100),
+ }
+ )
+ })
+ .expect("expected a SelectPane region")
+ .clone();
+ assert_eq!(
+ pane_region.row_end - pane_region.row_start,
+ 2,
+ "pane card click region should cover two rows",
+ );
+ let expected = Some(ClickAction::SelectPane {
+ tab_position: 0,
+ pane_id: PaneId::Terminal(100),
+ });
+ assert_eq!(
+ state
+ .frame
+ .click_to_action(pane_region.row_start, pane_region.col_start),
+ expected,
+ "tap on title row should select the pane",
+ );
+ assert_eq!(
+ state
+ .frame
+ .click_to_action(pane_region.row_start + 1, pane_region.col_start,),
+ expected,
+ "tap on activity row should select the pane",
+ );
+ }
+
+ #[test]
+ fn panes_menu_fuzzy_filter_keeps_footer_visible() {
+ let mut state = state_with_tabs_and_panes(2);
+ for (i, panes) in state.workspace.panes_by_tab_position.iter_mut() {
+ for pane in panes.iter_mut() {
+ pane.title = format!("alpha-{}", i);
+ }
+ }
+ state.panes.panes_search = "alpha".to_string();
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 60,
+ );
+
+ let new_tab_count = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::NewTab))
+ .count();
+ assert_eq!(
+ new_tab_count, 1,
+ "+ New Tab must stay visible during search"
+ );
+ let new_pane_count = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::NewPaneInTab { .. }))
+ .count();
+ assert_eq!(
+ new_pane_count, 1,
+ "+ New Pane must stay visible during search",
+ );
+ assert_eq!(state.frame.click_regions.len(), 5);
+ }
+
+ #[test]
+ fn current_pane_card_span_includes_current_pane_label() {
+ let mut state = state_with_tabs_and_panes(2);
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(100));
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 60,
+ );
+
+ let select_regions: Vec<&ClickRegion> = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::SelectPane { .. }))
+ .collect();
+ assert_eq!(select_regions.len(), 2);
+
+ let (current, other): (&&ClickRegion, &&ClickRegion) = {
+ let current = select_regions
+ .iter()
+ .find(|r| {
+ matches!(
+ r.action,
+ ClickAction::SelectPane {
+ pane_id: PaneId::Terminal(100),
+ ..
+ }
+ )
+ })
+ .expect("expected a SelectPane region for pane 100");
+ let other = select_regions
+ .iter()
+ .find(|r| {
+ matches!(
+ r.action,
+ ClickAction::SelectPane {
+ pane_id: PaneId::Terminal(101),
+ ..
+ }
+ )
+ })
+ .expect("expected a SelectPane region for pane 101");
+ (current, other)
+ };
+
+ let current_span = current.col_end.saturating_sub(current.col_start);
+ assert_eq!(current_span, 23);
+
+ let other_span = other.col_end.saturating_sub(other.col_start);
+ assert_eq!(other_span, 10);
+
+ assert_eq!(
+ current.col_start, other.col_start,
+ "both pane cards must anchor at the card column's left edge",
+ );
+ }
+
+ #[test]
+ fn panes_menu_fuzzy_filter_narrows_to_matching_pane() {
+ let mut state = state_with_tabs_and_panes(2);
+ state.workspace.panes_by_tab_position.get_mut(&0).unwrap()[0].title = "alpha".to_string();
+ state.workspace.panes_by_tab_position.get_mut(&1).unwrap()[0].title = "bravo".to_string();
+ state.panes.panes_search = "brv".to_string();
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 60,
+ );
+
+ let select_panes: Vec<ClickAction> = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::SelectPane { .. }))
+ .map(|r| r.action.clone())
+ .collect();
+ assert_eq!(
+ select_panes.len(),
+ 1,
+ "only the 'bravo' pane should survive the 'brv' filter",
+ );
+ assert!(matches!(
+ select_panes[0],
+ ClickAction::SelectPane {
+ tab_position: 1,
+ pane_id: PaneId::Terminal(101),
+ }
+ ));
+ }
+}
diff --git a/default-plugins/mobile/src/screens/sessions.rs b/default-plugins/mobile/src/screens/sessions.rs
new file mode 100644
index 00000000..237e104e
--- /dev/null
+++ b/default-plugins/mobile/src/screens/sessions.rs
@@ -0,0 +1,458 @@
+use fuzzy_matcher::FuzzyMatcher;
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::navigation::Navigation;
+use crate::screens::ActiveScreen;
+
+const CARD_BULLET: &str = "- ";
+const CARD_INDENT_W: usize = 2;
+const NEW_SESSION_LABEL: &str = "+ New Session";
+
+#[derive(Default)]
+pub struct SessionsScreen {
+ pub sessions: Vec<SessionInfo>,
+ pub welcome_search: String,
+ pub is_welcome_screen: bool,
+}
+
+impl SessionsScreen {
+ pub fn select_session(&self, active: &mut ActiveScreen, name: &str) -> bool {
+ switch_session(Some(name));
+ *active = ActiveScreen::Viewport;
+ true
+ }
+
+ pub fn handle_key(
+ &mut self,
+ active: &mut ActiveScreen,
+ nav: &mut Navigation,
+ key: KeyWithModifier,
+ ) -> bool {
+ match key.bare_key {
+ BareKey::Esc => {
+ if !self.welcome_search.is_empty() {
+ self.welcome_search.clear();
+ nav.selector_scroll_offset = 0;
+ } else if !self.is_welcome_screen {
+ *active = ActiveScreen::Viewport;
+ }
+ },
+ BareKey::Enter => {
+ if let Some(name) = self.top_match_name(nav) {
+ switch_session(Some(&name));
+ *active = ActiveScreen::Viewport;
+ }
+ },
+ BareKey::Backspace => {
+ self.welcome_search.pop();
+ nav.selector_scroll_offset = 0;
+ },
+ BareKey::Char(c) => {
+ self.welcome_search.push(c);
+ nav.selector_scroll_offset = 0;
+ },
+ _ => {},
+ }
+ true
+ }
+
+ pub fn top_match_name(&mut self, nav: &mut Navigation) -> Option<String> {
+ let search = self.welcome_search.clone();
+ if search.is_empty() {
+ return self.selectable_sessions().map(|s| s.name.clone()).min();
+ }
+ let matcher = nav.matcher();
+ let mut best: Option<(i64, String)> = None;
+ for s in self.selectable_sessions() {
+ if let Some((score, _)) = matcher.fuzzy_indices(&s.name, &search) {
+ let take = match &best {
+ None => true,
+ Some((bs, bn)) => score > *bs || (score == *bs && &s.name < bn),
+ };
+ if take {
+ best = Some((score, s.name.clone()));
+ }
+ }
+ }
+ best.map(|(_, name)| name)
+ }
+
+ fn selectable_sessions(&self) -> impl Iterator<Item = &SessionInfo> {
+ self.sessions.iter().filter(|s| !s.is_current_session)
+ }
+
+ fn header(&self) -> (&'static str, bool) {
+ if self.is_welcome_screen {
+ ("Hi from Zellij!", false)
+ } else {
+ ("Switch Session", true)
+ }
+ }
+
+ pub fn render(
+ &mut self,
+ nav: &mut Navigation,
+ frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ let (title, show_back_button) = self.header();
+
+ if show_back_button && row_start < row_end {
+ draw_back_button(frame, row_start);
+ }
+
+ let body_start = if show_back_button {
+ row_start.saturating_add(1)
+ } else {
+ row_start
+ };
+ let body_end = row_end.saturating_sub(1);
+ let body_height = body_end.saturating_sub(body_start);
+ if body_height == 0 || cols == 0 {
+ return;
+ }
+
+ let search = self.welcome_search.clone();
+ let cards = self.ordered_cards(nav, &search);
+ let layout = PickerLayout::compute(nav, &cards, body_start, body_end, body_height, cols);
+ let visible: Vec<&Card> = cards
+ .iter()
+ .skip(layout.offset)
+ .take(layout.visible_count)
+ .collect();
+
+ draw_title(title, &layout);
+ draw_prompt(&search, &layout);
+ draw_scroll_indicators(&layout);
+ draw_cards(frame, &visible, &layout);
+ draw_new_session_button(frame, &layout);
+ }
+
+ fn ordered_cards(&self, nav: &mut Navigation, search: &str) -> Vec<Card> {
+ self.scored_session_order(nav, search)
+ .into_iter()
+ .map(|(idx, name_indices)| Card::from_session(&self.sessions[idx], name_indices))
+ .collect()
+ }
+
+ fn scored_session_order(&self, nav: &mut Navigation, search: &str) -> Vec<(usize, Vec<usize>)> {
+ if search.is_empty() {
+ let mut indexed: Vec<(usize, &str)> = self
+ .sessions
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| !s.is_current_session)
+ .map(|(i, s)| (i, s.name.as_str()))
+ .collect();
+ indexed.sort_by(|a, b| a.1.cmp(b.1));
+ return indexed.into_iter().map(|(i, _)| (i, Vec::new())).collect();
+ }
+
+ let matcher = nav.matcher();
+ let mut scored: Vec<(usize, i64, Vec<usize>)> = self
+ .sessions
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| !s.is_current_session)
+ .filter_map(|(i, s)| {
+ matcher
+ .fuzzy_indices(&s.name, search)
+ .map(|(score, indices)| (i, score, indices))
+ })
+ .collect();
+ scored.sort_by(|a, b| {
+ b.1.cmp(&a.1)
+ .then_with(|| self.sessions[a.0].name.cmp(&self.sessions[b.0].name))
+ });
+ scored
+ .into_iter()
+ .map(|(i, _, indices)| (i, indices))
+ .collect()
+ }
+}
+
+struct Card {
+ name_label: String,
+ counts_label: String,
+ action: ClickAction,
+ tab_range: std::ops::Range<usize>,
+ pane_range: std::ops::Range<usize>,
+ client_range: std::ops::Range<usize>,
+ name_indices: Vec<usize>,
+}
+
+impl Card {
+ fn from_session(s: &SessionInfo, name_indices: Vec<usize>) -> Self {
+ let pane_count: usize = s
+ .panes
+ .panes
+ .values()
+ .map(|panes| {
+ panes
+ .iter()
+ .filter(|p| p.is_selectable && !p.is_suppressed)
+ .count()
+ })
+ .sum();
+ let name = s.name.clone();
+ let tab_str = format!("{}", s.tabs.len());
+ let pane_str = format!("{}", pane_count);
+ let conn_str = format!("{}", s.connected_clients);
+ let client_word = if s.connected_clients == 1 {
+ "client"
+ } else {
+ "clients"
+ };
+ let counts_label = format!(
+ "{} tabs, {} panes, {} {}",
+ tab_str, pane_str, conn_str, client_word
+ );
+ // Color ranges mirror the session-manager welcome screen
+ // (UnifiedResultsRenderCache::rebuild): tab count color 1, pane and
+ // client counts color 2.
+ let tab_end = tab_str.len();
+ let pane_offset = tab_str.len() + " tabs, ".len();
+ let pane_end = pane_offset + pane_str.len();
+ let conn_offset = pane_end + " panes, ".len();
+ let conn_end = conn_offset + conn_str.len();
+ Card {
+ name_label: name.clone(),
+ counts_label,
+ action: ClickAction::SelectSession(name),
+ tab_range: 0..tab_end,
+ pane_range: pane_offset..pane_end,
+ client_range: conn_offset..conn_end,
+ name_indices,
+ }
+ }
+
+ fn content_width(&self) -> usize {
+ UnicodeWidthStr::width(self.name_label.as_str())
+ .max(UnicodeWidthStr::width(self.counts_label.as_str()))
+ }
+
+ fn draw(
+ &self,
+ frame: &mut Frame,
+ card_x: usize,
+ content_x: usize,
+ row_name: usize,
+ row_counts: usize,
+ row_end: usize,
+ ) {
+ print_text_with_coordinates(Text::new(CARD_BULLET), card_x, row_name, None, None);
+
+ let mut name_text = Text::new(&self.name_label).color_range(0, ..);
+ if !self.name_indices.is_empty() {
+ name_text = name_text.color_indices(3, self.name_indices.clone());
+ }
+ print_text_with_coordinates(name_text, content_x, row_name, None, None);
+
+ if row_counts < row_end {
+ let counts_text = Text::new(&self.counts_label)
+ .color_range(1, self.tab_range.clone())
+ .color_range(2, self.pane_range.clone())
+ .color_range(2, self.client_range.clone());
+ print_text_with_coordinates(counts_text, content_x, row_counts, None, None);
+ }
+
+ frame.click_regions.push(ClickRegion::tight_range(
+ row_name,
+ row_counts + 1,
+ card_x,
+ content_x + self.content_width(),
+ self.action.clone(),
+ ));
+ }
+}
+
+struct PickerLayout {
+ cols: usize,
+ row_end: usize,
+ top_y: usize,
+ block_height: usize,
+ card_x: usize,
+ content_x: usize,
+ new_session_x: usize,
+ new_session_w: usize,
+ offset: usize,
+ visible_count: usize,
+ hidden_above: usize,
+ hidden_below: usize,
+}
+
+impl PickerLayout {
+ fn compute(
+ nav: &mut Navigation,
+ cards: &[Card],
+ body_start: usize,
+ body_end: usize,
+ body_height: usize,
+ cols: usize,
+ ) -> Self {
+ let total_cards = cards.len();
+ const CHROME_ROWS: usize = 6;
+ const ROWS_PER_CARD: usize = 2;
+ let max_visible_cards =
+ (body_height.saturating_sub(CHROME_ROWS) / ROWS_PER_CARD).min(total_cards);
+ let max_offset = total_cards.saturating_sub(max_visible_cards);
+ let offset = nav.selector_scroll_offset.min(max_offset);
+ nav.selector_scroll_offset = offset;
+ let visible_count = total_cards.saturating_sub(offset).min(max_visible_cards);
+
+ let block_height = if visible_count == 0 {
+ 5.min(body_height)
+ } else {
+ (6 + 2 * visible_count).min(body_height)
+ };
+ let top_y = body_start + body_height.saturating_sub(block_height) / 2;
+
+ let content_w = cards
+ .iter()
+ .skip(offset)
+ .take(visible_count)
+ .map(Card::content_width)
+ .max()
+ .unwrap_or(0);
+ let card_w = CARD_INDENT_W + content_w;
+ let card_x = cols.saturating_sub(card_w) / 2;
+ let content_x = card_x + CARD_INDENT_W;
+
+ let new_session_w = UnicodeWidthStr::width(NEW_SESSION_LABEL);
+ let new_session_x = cols.saturating_sub(new_session_w) / 2;
+
+ PickerLayout {
+ cols,
+ row_end: body_end,
+ top_y,
+ block_height,
+ card_x,
+ content_x,
+ new_session_x,
+ new_session_w,
+ offset,
+ visible_count,
+ hidden_above: offset,
+ hidden_below: total_cards.saturating_sub(offset + visible_count),
+ }
+ }
+
+ fn centered_x(&self, label_w: usize) -> usize {
+ self.cols.saturating_sub(label_w) / 2
+ }
+}
+
+fn draw_back_button(frame: &mut Frame, row: usize) {
+ let back_label = "[← BACK]";
+ let back_w = UnicodeWidthStr::width(back_label);
+ print_text_with_coordinates(Text::new(back_label).color_range(3, ..), 0, row, None, None);
+ frame.click_regions.push(ClickRegion::tight(
+ row,
+ 0,
+ back_w,
+ ClickAction::CollapseSelector,
+ ));
+}
+
+fn draw_title(title: &str, layout: &PickerLayout) {
+ let title_y = layout.top_y;
+ if title_y < layout.row_end {
+ let title_x = layout.centered_x(UnicodeWidthStr::width(title));
+ print_text_with_coordinates(Text::new(title), title_x, title_y, None, None);
+ }
+}
+
+fn draw_prompt(search: &str, layout: &PickerLayout) {
+ let prompt_y = layout.top_y + 2;
+ if prompt_y >= layout.row_end {
+ return;
+ }
+ let prompt_label = "Session: ";
+ let prompt_full = format!("{}{}_", prompt_label, search);
+ let prompt_x = if layout.visible_count > 0 {
+ layout.content_x
+ } else {
+ layout.new_session_x
+ };
+ let label_chars = prompt_label.chars().count();
+ let total_chars = prompt_full.chars().count();
+ let prompt_text = Text::new(&prompt_full).color_range(3, label_chars..total_chars);
+ print_text_with_coordinates(prompt_text, prompt_x, prompt_y, None, None);
+}
+
+fn draw_scroll_indicators(layout: &PickerLayout) {
+ if layout.visible_count == 0 {
+ return;
+ }
+ if layout.hidden_above > 0 {
+ let y = layout.top_y + 3;
+ if y < layout.row_end {
+ let label = format!("\u{2191} [+{}]", layout.hidden_above);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+ if layout.hidden_below > 0 {
+ let y = layout.top_y + 4 + 2 * layout.visible_count;
+ if y < layout.row_end {
+ let label = format!("\u{2193} [+{}]", layout.hidden_below);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+}
+
+fn draw_cards(frame: &mut Frame, visible: &[&Card], layout: &PickerLayout) {
+ let first_card_y = layout.top_y + 4;
+ for (i, card) in visible.iter().enumerate() {
+ let row_name = first_card_y + i * 2;
+ if row_name >= layout.row_end {
+ break;
+ }
+ card.draw(
+ frame,
+ layout.card_x,
+ layout.content_x,
+ row_name,
+ row_name + 1,
+ layout.row_end,
+ );
+ }
+}
+
+fn draw_new_session_button(frame: &mut Frame, layout: &PickerLayout) {
+ let new_session_y = layout.top_y + layout.block_height.saturating_sub(1);
+ if new_session_y >= layout.row_end {
+ return;
+ }
+ print_text_with_coordinates(
+ Text::new(NEW_SESSION_LABEL).color_range(3, ..),
+ layout.new_session_x,
+ new_session_y,
+ None,
+ None,
+ );
+ frame.click_regions.push(ClickRegion::tight(
+ new_session_y,
+ layout.new_session_x,
+ layout.new_session_x + layout.new_session_w,
+ ClickAction::OpenNewSessionPrompt,
+ ));
+}
diff --git a/default-plugins/mobile/src/screens/viewport.rs b/default-plugins/mobile/src/screens/viewport.rs
new file mode 100644
index 00000000..96c78554
--- /dev/null
+++ b/default-plugins/mobile/src/screens/viewport.rs
@@ -0,0 +1,239 @@
+use zellij_tile::prelude::*;
+
+use crate::ansi::{move_to, slice_ansi_visible, visible_width, RESET};
+use crate::click::ViewportRegion;
+use crate::frame::Frame;
+use crate::input::Input;
+use crate::keys;
+use crate::workspace::{pane_id_of, Workspace};
+
+const DISABLE_AUTOWRAP: &str = "\x1b[?7l";
+const ENABLE_AUTOWRAP: &str = "\x1b[?7h";
+
+#[derive(Default)]
+pub struct ViewportScreen {
+ pub viewport_v_pan: usize,
+ pub viewport_h_pan: usize,
+ pub viewport_region: Option<ViewportRegion>,
+}
+
+impl ViewportScreen {
+ pub fn reset_pan(&mut self) {
+ self.viewport_v_pan = 0;
+ self.viewport_h_pan = 0;
+ }
+
+ pub fn handle_key(&self, ws: &Workspace, input: &mut Input, key: KeyWithModifier) -> bool {
+ if let Some(pane) = ws.current_pane() {
+ let key = if input.ctrl_held || input.alt_held {
+ input.merge_held_modifiers(&key)
+ } else {
+ key
+ };
+ let bytes = keys::serialize_key(&key);
+ if !bytes.is_empty() {
+ write_to_pane_id(bytes, pane_id_of(&pane));
+ }
+ }
+ let consumed = input.ctrl_held || input.alt_held;
+ input.ctrl_held = false;
+ input.alt_held = false;
+ consumed
+ }
+
+ pub fn max_viewport_v_pan(&self, ws: &Workspace) -> Option<usize> {
+ let region = self.viewport_region?;
+ let embed_height = region.row_end.saturating_sub(region.row_start);
+ Some(ws.current_pane_viewport_len().saturating_sub(embed_height))
+ }
+
+ pub fn click_in_viewport(&self, row: usize, col: usize) -> Option<(usize, usize)> {
+ let region = self.viewport_region?;
+ if row < region.row_start || row >= region.row_end {
+ return None;
+ }
+ if col >= region.cols {
+ return None;
+ }
+ let pane_row = region.skip + (row - region.row_start);
+ let pane_col = region.h_offset + col;
+ Some((pane_row, pane_col))
+ }
+
+ pub fn compute_cursor_position(
+ &self,
+ ws: &Workspace,
+ viewport_top: usize,
+ viewport_height: usize,
+ cols: usize,
+ skip: usize,
+ h_offset: usize,
+ ) -> Option<(usize, usize)> {
+ if viewport_height == 0 {
+ return None;
+ }
+ let pane = ws.current_pane()?;
+ let pane_id = pane_id_of(&pane);
+ let (cursor_x, cursor_y) = ws.latest_pane_contents.get(&pane_id)?.cursor?;
+ if cursor_y < skip {
+ return None;
+ }
+ let row_in_slice = cursor_y - skip;
+ if row_in_slice >= viewport_height {
+ return None;
+ }
+ if cursor_x < h_offset {
+ return None;
+ }
+ let plugin_x = cursor_x - h_offset;
+ if plugin_x >= cols {
+ return None;
+ }
+ let plugin_y = viewport_top + row_in_slice;
+ Some((plugin_x, plugin_y))
+ }
+
+ pub fn render(
+ &mut self,
+ ws: &Workspace,
+ _frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ let height = row_end - row_start;
+ if height == 0 {
+ return;
+ }
+
+ let pane = ws.current_pane();
+ let pane_id = pane.as_ref().map(pane_id_of);
+ let viewport_lines: Vec<String> = pane_id
+ .and_then(|id| ws.latest_pane_contents.get(&id))
+ .map(|c| c.viewport.clone())
+ .unwrap_or_default();
+
+ let max_v_pan = self
+ .max_viewport_v_pan(ws)
+ .unwrap_or_else(|| viewport_lines.len().saturating_sub(height));
+ self.viewport_v_pan = self.viewport_v_pan.min(max_v_pan);
+ let skip = max_v_pan.saturating_sub(self.viewport_v_pan);
+ let pane_width = pane
+ .as_ref()
+ .map(|p| p.pane_content_columns)
+ .filter(|&w| w > 0)
+ .unwrap_or_else(|| {
+ viewport_lines
+ .iter()
+ .map(|l| visible_width(l))
+ .max()
+ .unwrap_or(0)
+ });
+ let max_h_pan = pane_width.saturating_sub(cols);
+ self.viewport_h_pan = self.viewport_h_pan.min(max_h_pan);
+ let h_offset = self.viewport_h_pan;
+
+ self.viewport_region = Some(ViewportRegion {
+ row_start,
+ row_end,
+ cols,
+ skip,
+ h_offset,
+ });
+
+ print!("{}", DISABLE_AUTOWRAP);
+
+ for i in 0..height {
+ let row = row_start + i;
+ print!("{}{}", RESET, move_to(row, 0));
+ if let Some(line) = viewport_lines.get(skip + i) {
+ if h_offset == 0 {
+ print!("{}", line);
+ } else {
+ let sliced = slice_ansi_visible(line, h_offset, cols);
+ print!("{}", sliced);
+ }
+ } else if i == 0 && pane_id.is_none() {
+ print!("{}(no pane selected)", RESET);
+ } else if i == 0 && viewport_lines.is_empty() {
+ print!("{}(awaiting first render…)", RESET);
+ }
+ let raw_width = viewport_lines
+ .get(skip + i)
+ .map(|l| visible_width(l))
+ .unwrap_or(0);
+ let printed_width = raw_width.saturating_sub(h_offset).min(cols);
+ if printed_width < cols {
+ print!("{}\x1b[K", RESET);
+ }
+ }
+
+ print!("{}", ENABLE_AUTOWRAP);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::click::ViewportRegion;
+
+ fn state_with_viewport(
+ viewport_len: usize,
+ embed_height: Option<usize>,
+ ) -> (Workspace, ViewportScreen) {
+ let mut ws = Workspace::default();
+ let mut tab = TabInfo::default();
+ tab.position = 0;
+ ws.tabs.push(tab);
+ ws.selected_tab_position = Some(0);
+
+ let mut pane = PaneInfo::default();
+ pane.id = 42;
+ pane.is_plugin = false;
+ pane.is_selectable = true;
+ pane.is_suppressed = false;
+ ws.panes_by_tab_position.insert(0, vec![pane]);
+ ws.selected_pane_id = Some(PaneId::Terminal(42));
+
+ let mut contents = PaneContents::default();
+ contents.viewport = vec![String::new(); viewport_len];
+ ws.latest_pane_contents
+ .insert(PaneId::Terminal(42), contents);
+
+ let mut vp = ViewportScreen::default();
+ if let Some(h) = embed_height {
+ vp.viewport_region = Some(ViewportRegion {
+ row_start: 0,
+ row_end: h,
+ cols: 80,
+ skip: 0,
+ h_offset: 0,
+ });
+ }
+ (ws, vp)
+ }
+
+ #[test]
+ fn max_viewport_v_pan_none_without_region() {
+ let (ws, vp) = state_with_viewport(100, None);
+ assert_eq!(vp.max_viewport_v_pan(&ws), None);
+ }
+
+ #[test]
+ fn max_viewport_v_pan_some_typical() {
+ let (ws, vp) = state_with_viewport(100, Some(20));
+ assert_eq!(vp.max_viewport_v_pan(&ws), Some(80));
+ }
+
+ #[test]
+ fn max_viewport_v_pan_saturates_when_embed_larger() {
+ let (ws, vp) = state_with_viewport(10, Some(20));
+ assert_eq!(vp.max_viewport_v_pan(&ws), Some(0));
+ }
+
+ #[test]
+ fn max_viewport_v_pan_empty_cache() {
+ let (ws, vp) = state_with_viewport(0, Some(20));
+ assert_eq!(vp.max_viewport_v_pan(&ws), Some(0));
+ }
+}
diff --git a/default-plugins/mobile/src/state.rs b/default-plugins/mobile/src/state.rs
new file mode 100644
index 00000000..ec575cf1
--- /dev/null
+++ b/default-plugins/mobile/src/state.rs
@@ -0,0 +1,24 @@
+use crate::fit::Fit;
+use crate::frame::Frame;
+use crate::input::Input;
+use crate::navigation::Navigation;
+use crate::screens::{
+ ActiveScreen, MenuScreen, NewSessionPromptScreen, PanesScreen, SessionsScreen, ViewportScreen,
+};
+use crate::workspace::Workspace;
+
+#[derive(Default)]
+pub struct State {
+ pub workspace: Workspace,
+ pub fit: Fit,
+ pub frame: Frame,
+ pub input: Input,
+ pub navigation: Navigation,
+ pub active: ActiveScreen,
+ pub viewport: ViewportScreen,
+ pub sessions: SessionsScreen,
+ pub panes: PanesScreen,
+ pub new_session: NewSessionPromptScreen,
+ pub menu: MenuScreen,
+ pub empty_welcome_list_grace_elapsed: bool,
+}
diff --git a/default-plugins/mobile/src/workspace.rs b/default-plugins/mobile/src/workspace.rs
new file mode 100644
index 00000000..3b59bf32
--- /dev/null
+++ b/default-plugins/mobile/src/workspace.rs
@@ -0,0 +1,122 @@
+use std::collections::HashMap;
+use zellij_tile::prelude::*;
+
+const WELCOME_SCREEN_PLUGIN_ALIAS: &str = "welcome-screen";
+
+#[derive(Default)]
+pub struct Workspace {
+ pub own_plugin_pane_id: Option<PaneId>,
+ pub tabs: Vec<TabInfo>,
+ pub panes_by_tab_position: HashMap<usize, Vec<PaneInfo>>,
+ pub selected_tab_position: Option<usize>,
+ pub selected_pane_id: Option<PaneId>,
+ pub latest_pane_contents: HashMap<PaneId, PaneContents>,
+ pub mode_info: Option<ModeInfo>,
+ pub session_name: Option<String>,
+ pub pane_last_activity: HashMap<PaneId, u64>,
+ pub pending_new_tab_position: Option<usize>,
+}
+
+impl Workspace {
+ pub fn tabs_in_order(&self) -> Vec<&TabInfo> {
+ let own = self.own_plugin_pane_id;
+ let mut tabs: Vec<&TabInfo> = self
+ .tabs
+ .iter()
+ .filter(|t| !self.tab_is_self_only(t.position, own))
+ .collect();
+ tabs.sort_by_key(|t| t.position);
+ tabs
+ }
+
+ fn tab_is_self_only(&self, tab_position: usize, own: Option<PaneId>) -> bool {
+ let Some(panes) = self.panes_by_tab_position.get(&tab_position) else {
+ return false;
+ };
+ let Some(own) = own else {
+ return false;
+ };
+ let visible: Vec<&PaneInfo> = panes.iter().filter(|p| !p.is_suppressed).collect();
+ if visible.is_empty() {
+ return false;
+ }
+ visible.iter().all(|p| pane_info_matches(p, own))
+ }
+
+ pub fn current_tab(&self) -> Option<&TabInfo> {
+ let visible = self.tabs_in_order();
+ if let Some(pos) = self.selected_tab_position {
+ if let Some(t) = visible.iter().find(|t| t.position == pos) {
+ return Some(*t);
+ }
+ }
+ visible.first().copied()
+ }
+
+ pub fn current_tab_panes(&self) -> Vec<&PaneInfo> {
+ let Some(tab) = self.current_tab() else {
+ return vec![];
+ };
+ self.panes_for_tab(tab.position)
+ }
+
+ pub fn panes_for_tab(&self, tab_position: usize) -> Vec<&PaneInfo> {
+ let own = self.own_plugin_pane_id;
+ let mut panes: Vec<&PaneInfo> = self
+ .panes_by_tab_position
+ .get(&tab_position)
+ .map(|v| v.iter().collect())
+ .unwrap_or_default();
+ panes.retain(|p| {
+ !p.is_suppressed
+ && p.is_selectable
+ && !own.map(|id| pane_info_matches(p, id)).unwrap_or(false)
+ });
+ panes.sort_by_key(|p| (p.is_floating, p.pane_y, p.pane_x, p.id));
+ panes
+ }
+
+ pub fn current_pane_viewport_len(&self) -> usize {
+ self.current_pane()
+ .as_ref()
+ .map(pane_id_of)
+ .and_then(|id| self.latest_pane_contents.get(&id))
+ .map(|c| c.viewport.len())
+ .unwrap_or(0)
+ }
+
+ pub fn current_pane_is_welcome(&self) -> bool {
+ self.current_pane()
+ .map(|p| p.is_plugin && p.plugin_url.as_deref() == Some(WELCOME_SCREEN_PLUGIN_ALIAS))
+ .unwrap_or(false)
+ }
+
+ // `is_focused` is server-global (true if any client focuses the pane), so
+ // selecting by it would make this client's viewport follow another
+ // connected client's focus; fall back to the first pane instead.
+ pub fn current_pane(&self) -> Option<PaneInfo> {
+ if let Some(selected) = self.selected_pane_id {
+ for pane in self.current_tab_panes() {
+ if pane_info_matches(pane, selected) {
+ return Some(pane.clone());
+ }
+ }
+ }
+ self.current_tab_panes().into_iter().next().cloned()
+ }
+}
+
+pub fn pane_info_matches(info: &PaneInfo, id: PaneId) -> bool {
+ match id {
+ PaneId::Terminal(tid) => !info.is_plugin && info.id == tid,
+ PaneId::Plugin(pid) => info.is_plugin && info.id == pid,
+ }
+}
+
+pub fn pane_id_of(info: &PaneInfo) -> PaneId {
+ if info.is_plugin {
+ PaneId::Plugin(info.id)
+ } else {
+ PaneId::Terminal(info.id)
+ }
+}
diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs
index 6563a226..278816e3 100644
--- a/default-plugins/session-manager/src/main.rs
+++ b/default-plugins/session-manager/src/main.rs
@@ -37,20 +37,20 @@ impl Default for ActiveScreen {
}
#[derive(Default)]
-struct State {
+pub(crate) struct State {
session_name: Option<String>,
- sessions: SessionList,
- resurrectable_sessions: ResurrectableSessions,
+ pub(crate) sessions: SessionList,
+ pub(crate) resurrectable_sessions: ResurrectableSessions,
search_term: String,
new_session_info: NewSessionInfo,
- renaming_session_name: Option<String>,
- error: Option<String>,
- active_screen: ActiveScreen,
- colors: Colors,
- is_welcome_screen: bool,
+ pub(crate) renaming_session_name: Option<String>,
+ pub(crate) error: Option<String>,
+ pub(crate) active_screen: ActiveScreen,
+ pub(crate) colors: Colors,
+ pub(crate) is_welcome_screen: bool,
is_multi_screen: bool,
- single_screen_state: SingleScreenState,
- show_kill_all_sessions_warning: bool,
+ pub(crate) single_screen_state: SingleScreenState,
+ pub(crate) show_kill_all_sessions_warning: bool,
request_ids: Vec<String>,
is_web_client: bool,
current_session_last_saved_time: Option<u64>,
@@ -150,7 +150,11 @@ impl ZellijPlugin for State {
},
Event::ModeUpdate(mode_info) => {
self.colors = Colors::new(mode_info.style.colors);
+ let was_web_client = self.is_web_client;
self.is_web_client = mode_info.is_web_client.unwrap_or(false);
+ if was_web_client != self.is_web_client {
+ self.refresh_session_list();
+ }
should_render = true;
},
Event::Key(key) => {
@@ -1404,7 +1408,13 @@ impl State {
},
}
}
- fn render_kill_all_sessions_warning(&self, rows: usize, columns: usize, x: usize, y: usize) {
+ pub(crate) fn render_kill_all_sessions_warning(
+ &self,
+ rows: usize,
+ columns: usize,
+ x: usize,
+ y: usize,
+ ) {
if rows == 0 || columns == 0 {
return;
}
diff --git a/example/default.kdl b/example/default.kdl
index 3ea42850..61aa4bb8 100644
--- a/example/default.kdl
+++ b/example/default.kdl
@@ -242,6 +242,7 @@ plugins {
configuration location="zellij:configuration"
plugin-manager location="zellij:plugin-manager"
about location="zellij:about"
+ mobile location="zellij:mobile"
}
// Plugins to load in the background when a new session starts
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 2b249b5a..6262df1f 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -87,6 +87,10 @@ fn workspace_members() -> &'static Vec<WorkspaceMember> {
build: true,
},
WorkspaceMember {
+ crate_name: "default-plugins/mobile",
+ build: true,
+ },
+ WorkspaceMember {
crate_name: "zellij-utils",
build: false,
},
diff --git a/zellij-client/assets/ime-bypass.js b/zellij-client/assets/ime-bypass.js
new file mode 100644
index 00000000..e2ef04b0
--- /dev/null
+++ b/zellij-client/assets/ime-bypass.js
@@ -0,0 +1,49 @@
+export function installImeBypass(term, sendFunction) {
+ if (typeof window.__zjImeBypass === "undefined") {
+ window.__zjImeBypass = {
+ installed: false,
+ sendFn: sendFunction,
+ lastKeyWasProcess: false,
+ };
+ }
+ window.__zjImeBypass.sendFn = sendFunction;
+
+ if (window.__zjImeBypass.installed) {
+ return;
+ }
+ window.__zjImeBypass.installed = true;
+ const state = window.__zjImeBypass;
+
+ document.addEventListener(
+ "keydown",
+ (ev) => {
+ state.lastKeyWasProcess = ev.key === "Process";
+ },
+ true
+ );
+
+ const attach = () => {
+ const ta = term && term._core && term._core.textarea;
+ if (!ta) {
+ setTimeout(attach, 50);
+ return;
+ }
+ ta.addEventListener(
+ "input",
+ (ev) => {
+ if (
+ state.lastKeyWasProcess &&
+ ev.inputType === "insertText" &&
+ !ev.isComposing &&
+ ev.data
+ ) {
+ state.sendFn(ev.data);
+ ev.target.value = "";
+ state.lastKeyWasProcess = false;
+ }
+ },
+ true
+ );
+ };
+ attach();
+}
diff --git a/zellij-client/assets/index.html b/zellij-client/assets/index.html
index c3fce356..638eda3a 100644
--- a/zellij-client/assets/index.html
+++ b/zellij-client/assets/index.html
@@ -33,6 +33,12 @@
<script type="module" src="assets/keyboard.js"></script>
<script type="module" src="assets/links.js"></script>
<script type="module" src="assets/terminal.js"></script>
+ <script type="module" src="assets/ime-bypass.js"></script>
+ <script type="module" src="assets/soft-keyboard.js"></script>
+ <script type="module" src="assets/key-handler.js"></script>
+ <script type="module" src="assets/mouse.js"></script>
+ <script type="module" src="assets/pinch.js"></script>
+ <script type="module" src="assets/touch.js"></script>
<script type="module" src="assets/input.js"></script>
<script type="module" src="assets/websockets.js"></script>
<script type="module" src="assets/index.js"></script>
diff --git a/zellij-client/assets/index.js b/zellij-client/assets/index.js
index bfc990fe..94a07442 100644
--- a/zellij-client/assets/index.js
+++ b/zellij-client/assets/index.js
@@ -12,18 +12,14 @@ document.addEventListener("DOMContentLoaded", async (event) => {
const { term, fitAddon } = initTerminal();
const sessionName = location.pathname.split("/").pop();
- let sendAnsiKey = (ansiKey) => {
- // This will be replaced by the WebSocket module
- };
-
- setupInputHandlers(term, sendAnsiKey);
+ let sendAnsiKey = (ansiKey) => {};
+
+ setupInputHandlers(term, fitAddon, sendAnsiKey);
document.title = sessionName;
const websockets = initWebSockets(webClientId, sessionName, term, fitAddon, sendAnsiKey);
-
- // Update sendAnsiKey to use the actual WebSocket function returned by initWebSockets
+
sendAnsiKey = websockets.sendAnsiKey;
-
- // Update the input handlers with the correct sendAnsiKey function
- setupInputHandlers(term, sendAnsiKey);
+
+ setupInputHandlers(term, fitAddon, sendAnsiKey);
});
diff --git a/zellij-client/assets/input.js b/zellij-client/assets/input.js
index d7fa4469..2c1849d6 100644
--- a/zellij-client/assets/input.js
+++ b/zellij-client/assets/input.js
@@ -1,175 +1,27 @@
-/**
- * Input handling for terminal events
- */
+import { installImeBypass } from "./ime-bypass.js";
+import {
+ installSoftKeyboardCapture,
+ suppressSoftKeyboardOnTouch,
+} from "./soft-keyboard.js";
+import { installCustomKeyHandler } from "./key-handler.js";
+import { installMouseHandlers } from "./mouse.js";
+import { createPinchController } from "./pinch.js";
+import { installTouchGestures } from "./touch.js";
-import { encode_kitty_key } from "./keyboard.js";
-import { isMac } from "./utils.js";
+export { setSoftKeyboard } from "./soft-keyboard.js";
-/**
- * Set up all input handlers for the terminal
- * @param {Terminal} term - The terminal instance
- * @param {function} sendFunction - Function to send data through WebSocket
- */
-export function setupInputHandlers(term, sendFunction) {
- // Mouse tracking state
- let prev_col = 0;
- let prev_row = 0;
-
- // Work around xterm.js's composition-based character drop under active IMEs
- // (ibus/fcitx on Linux, etc.). When an IME is attached, every keystroke comes
- // through with ev.key === "Process" and a phantom compositionstart/end wraps
- // the keystroke. xterm.js sometimes fails to forward the character through
- // its composition path under fast typing. We bypass that path only for this
- // case: use the textarea's standardized `input` event as the authoritative
- // source of the character, and clear the textarea so xterm.js's composition
- // handler sees no diff and cannot double-emit.
- //
- // - Non-IME typing (ev.key !== "Process") is untouched: xterm.js's keyboard
- // handler still emits onData directly, our listener does not trigger.
- // - Real IME composition (Japanese/Chinese) emits `insertCompositionText`,
- // not `insertText` — our filter ignores it; xterm.js handles it as today.
- // - Paste fires `insertFromPaste` — also ignored here.
+export function setupInputHandlers(term, fitAddon, sendFunction) {
installImeBypass(term, sendFunction);
+ installSoftKeyboardCapture(term, sendFunction);
+ suppressSoftKeyboardOnTouch(term);
+ installCustomKeyHandler(term, sendFunction);
- // Custom key event handler
- term.attachCustomKeyEventHandler((ev) => {
- if (ev.type === "keydown") {
- if (ev.key == "V" && ev.ctrlKey && ev.shiftKey) {
- // pass ctrl-shift-v onwards so that paste is interpreted by xterm.js
- return;
- }
- if (isMac() && ev.key == "v" && ev.metaKey) {
- // pass cmd-v onwards so that paste is interpreted by xterm.js
- return;
- }
- if (hasModifiersToHandle(ev)) {
- ev.preventDefault();
- encode_kitty_key(ev, sendFunction);
- return false;
- }
- // workarounds for https://github.com/xtermjs/xterm.js/blob/41e8ae395937011d6bf6c7cb618b851791aed395/src/common/input/Keyboard.ts#L158
- if (ev.key == "ArrowLeft" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3D");
- return false;
- }
- if (ev.key == "ArrowRight" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3C");
- return false;
- }
- if (ev.key == "ArrowUp" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3A");
- return false;
- }
- if (ev.key == "ArrowDown" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3B");
- return false;
- }
- if (
- (ev.key == "=" && ev.altKey) ||
- (ev.key == "+" && ev.altKey) ||
- (ev.key == "-" && ev.altKey)
- ) {
- // these are not properly handled by xterm.js, so we bypass it and encode them as kitty to make things easier
- ev.preventDefault();
- encode_kitty_key(ev, sendFunction);
- return false;
- }
- }
- return true;
- });
-
- // Mouse movement handler
- let terminal_element = document.getElementById("terminal");
- terminal_element.addEventListener("mousemove", function (event) {
- // this is a hack around: https://github.com/xtermjs/xterm.js/issues/1062
- // in short, xterm.js doesn't listen to mousemove at all and so even though
- // we send it a request for AnyEvent mouse handling, we don't get motion events in return
- // here we use some internal functions in a hopefully non-destructive way to calculate the
- // columns/rows to send from the x/y coordinates - it's safe to always send these because Zellij
- // always requests mouse AnyEvent handling
- if (event.buttons == 0) {
- // this means no mouse buttons are pressed and this is just a mouse movement
- let { col, row } = term._core._mouseService.getMouseReportCoords(
- event,
- terminal_element
- );
- if (prev_col != col || prev_row != row) {
- sendFunction(`\x1b[<35;${col + 1};${row + 1}M`);
- }
- prev_col = col;
- prev_row = row;
- }
- });
+ const terminalElement = document.getElementById("terminal");
+ installMouseHandlers(term, terminalElement, sendFunction);
- // Touch scroll handler (convert vertical swipes to mouse wheel events)
- let last_touch_y = null;
- let pending_scroll = 0;
- const touch_scroll_threshold = 24;
- const sendWheelEvent = (direction, touch) => {
- let { col, row } = term._core._mouseService.getMouseReportCoords(
- { clientX: touch.clientX, clientY: touch.clientY },
- terminal_element
- );
- const button = direction < 0 ? 65 : 64; // inverted: swipe up => wheel down
- sendFunction(`\x1b[<${button};${col + 1};${row + 1}M`);
- };
-
- terminal_element.addEventListener(
- "touchstart",
- (event) => {
- if (event.touches.length > 0) {
- last_touch_y = event.touches[0].clientY;
- pending_scroll = 0;
- }
- },
- { passive: true }
- );
-
- terminal_element.addEventListener(
- "touchmove",
- (event) => {
- if (event.touches.length === 0 || last_touch_y === null) {
- return;
- }
- event.preventDefault();
- const touch = event.touches[0];
- const delta = touch.clientY - last_touch_y;
- last_touch_y = touch.clientY;
- pending_scroll += delta;
- while (pending_scroll <= -touch_scroll_threshold) {
- sendWheelEvent(-1, touch);
- pending_scroll += touch_scroll_threshold;
- }
- while (pending_scroll >= touch_scroll_threshold) {
- sendWheelEvent(1, touch);
- pending_scroll -= touch_scroll_threshold;
- }
- },
- { passive: false }
- );
-
- terminal_element.addEventListener(
- "touchend",
- () => {
- last_touch_y = null;
- pending_scroll = 0;
- },
- { passive: true }
- );
-
- // Context menu handler
- document.addEventListener("contextmenu", function (event) {
- if (event.altKey) {
- // this is so that when the user does an alt-right-click to ungroup panes, the context menu will not appear
- event.preventDefault();
- }
- });
+ const pinch = createPinchController(term);
+ installTouchGestures({ term, terminalElement, sendFunction, pinch });
- // Terminal data handlers
term.onData((data) => {
sendFunction(data);
});
@@ -182,90 +34,3 @@ export function setupInputHandlers(term, sendFunction) {
sendFunction(buffer);
});
}
-
-/**
- * Install the IME-bypass input listener exactly once per page load.
- * The send-function reference is refreshed on every call so the real
- * WebSocket sender (installed after initWebSockets) replaces the initial
- * placeholder — see index.js where setupInputHandlers is called twice.
- */
-function installImeBypass(term, sendFunction) {
- if (typeof window.__zjImeBypass === "undefined") {
- window.__zjImeBypass = {
- installed: false,
- sendFn: sendFunction,
- lastKeyWasProcess: false,
- };
- }
- // Always point at the most-recently-provided sender.
- window.__zjImeBypass.sendFn = sendFunction;
-
- if (window.__zjImeBypass.installed) {
- return;
- }
- window.__zjImeBypass.installed = true;
- const state = window.__zjImeBypass;
-
- // Track whether the most recent keydown was IME-intercepted. Non-IME keys
- // report their actual key value ("a", "Enter", "ArrowLeft", ...) and go
- // through xterm.js's normal keyboard path; we do not want to interfere.
- document.addEventListener(
- "keydown",
- (ev) => {
- state.lastKeyWasProcess = ev.key === "Process";
- },
- true
- );
-
- // The xterm.js textarea is created asynchronously by term.open(). Poll
- // briefly until it's available, then attach our capture-phase input
- // listener.
- const attach = () => {
- const ta = term && term._core && term._core.textarea;
- if (!ta) {
- setTimeout(attach, 50);
- return;
- }
- ta.addEventListener(
- "input",
- (ev) => {
- if (
- state.lastKeyWasProcess &&
- ev.inputType === "insertText" &&
- !ev.isComposing &&
- ev.data
- ) {
- state.sendFn(ev.data);
- // Clear so xterm.js's composition diff at compositionend
- // finds no text to emit — prevents double-send.
- ev.target.value = "";
- state.lastKeyWasProcess = false;
- }
- },
- true
- );
- };
- attach();
-}
-
-/**
- * Check if a key event has modifiers and is not a modifier key itself
- * @param {KeyboardEvent} ev - The keyboard event
- * @returns {boolean} - True if the key has modifiers that need special handling
- */
-function hasModifiersToHandle(ev) {
- // Use key property for simpler modifier key detection
- const MODIFIER_KEYS = ["Shift", "Control", "Alt", "Meta"];
-
- // Count active modifiers
- const modifiers_count = [
- ev.altKey,
- ev.ctrlKey,
- ev.shiftKey,
- ev.metaKey,
- ].filter(Boolean).length;
-
- // Check if we have multiple modifiers or meta key, and it's not a modifier key itself
- const isModifierKey = MODIFIER_KEYS.includes(ev.key);
- return (modifiers_count > 1 || ev.metaKey) && !isModifierKey;
-}
diff --git a/zellij-client/assets/key-handler.js b/zellij-client/assets/key-handler.js
new file mode 100644
index 00000000..4af67b9f
--- /dev/null
+++ b/zellij-client/assets/key-handler.js
@@ -0,0 +1,64 @@
+import { encode_kitty_key } from "./keyboard.js";
+import { isMac } from "./utils.js";
+
+export function installCustomKeyHandler(term, sendFunction) {
+ term.attachCustomKeyEventHandler((ev) => {
+ if (ev.type === "keydown") {
+ if (ev.key == "V" && ev.ctrlKey && ev.shiftKey) {
+ return;
+ }
+ if (isMac() && ev.key == "v" && ev.metaKey) {
+ return;
+ }
+ if (hasModifiersToHandle(ev)) {
+ ev.preventDefault();
+ encode_kitty_key(ev, sendFunction);
+ return false;
+ }
+ // xterm.js mishandles Alt+Arrow; send Alt-modified SGR sequences directly:
+ // https://github.com/xtermjs/xterm.js/blob/41e8ae395937011d6bf6c7cb618b851791aed395/src/common/input/Keyboard.ts#L158
+ if (ev.key == "ArrowLeft" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3D");
+ return false;
+ }
+ if (ev.key == "ArrowRight" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3C");
+ return false;
+ }
+ if (ev.key == "ArrowUp" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3A");
+ return false;
+ }
+ if (ev.key == "ArrowDown" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3B");
+ return false;
+ }
+ if (
+ (ev.key == "=" && ev.altKey) ||
+ (ev.key == "+" && ev.altKey) ||
+ (ev.key == "-" && ev.altKey)
+ ) {
+ ev.preventDefault();
+ encode_kitty_key(ev, sendFunction);
+ return false;
+ }
+ }
+ return true;
+ });
+}
+
+function hasModifiersToHandle(ev) {
+ const MODIFIER_KEYS = ["Shift", "Control", "Alt", "Meta"];
+ const modifiers_count = [
+ ev.altKey,
+ ev.ctrlKey,
+ ev.shiftKey,
+ ev.metaKey,
+ ].filter(Boolean).length;
+ const isModifierKey = MODIFIER_KEYS.includes(ev.key);
+ return (modifiers_count > 1 || ev.metaKey) && !isModifierKey;
+}
diff --git a/zellij-client/assets/mouse.js b/zellij-client/assets/mouse.js
new file mode 100644
index 00000000..15b33c6a
--- /dev/null
+++ b/zellij-client/assets/mouse.js
@@ -0,0 +1,25 @@
+export function installMouseHandlers(term, terminalElement, sendFunction) {
+ let prev_col = 0;
+ let prev_row = 0;
+
+ // xterm.js doesn't emit mousemove (xtermjs/xterm.js#1062); synthesize SGR motion reports.
+ terminalElement.addEventListener("mousemove", function (event) {
+ if (event.buttons == 0) {
+ const { col, row } = term._core._mouseService.getMouseReportCoords(
+ event,
+ terminalElement
+ );
+ if (prev_col != col || prev_row != row) {
+ sendFunction(`\x1b[<35;${col + 1};${row + 1}M`);
+ }
+ prev_col = col;
+ prev_row = row;
+ }
+ });
+
+ document.addEventListener("contextmenu", function (event) {
+ if (event.altKey) {
+ event.preventDefault();
+ }
+ });
+}
diff --git a/zellij-client/assets/pinch.js b/zellij-client/assets/pinch.js
new file mode 100644
index 00000000..9dcbd6a8
--- /dev/null
+++ b/zellij-client/assets/pinch.js
@@ -0,0 +1,134 @@
+import { clampFontSize } from "./terminal.js";
+
+// xterm.js's WebGL renderer clears the framebuffer when it reassigns
+// canvas.width/height on a font-size change, and during a touch gesture the
+// repaint is rAF-throttled, so the cleared canvas can flash blank before the
+// repaint lands. This overlay pins a 2d-canvas snapshot over the live canvas.
+export function createPinchController(term) {
+ let pinchOverlay = null;
+ let pinchOverlayAwaitingRender = false;
+ let pinchOverlaySafetyTimer = null;
+
+ const destroy = () => {
+ if (pinchOverlay) {
+ pinchOverlay.remove();
+ pinchOverlay = null;
+ }
+ pinchOverlayAwaitingRender = false;
+ if (pinchOverlaySafetyTimer !== null) {
+ clearTimeout(pinchOverlaySafetyTimer);
+ pinchOverlaySafetyTimer = null;
+ }
+ };
+
+ const snapshot = () => {
+ destroy();
+ if (!term.element) return;
+ const sourceCanvases = term.element.querySelectorAll("canvas");
+ if (sourceCanvases.length === 0) return;
+ const ref = sourceCanvases[0];
+ const rect = ref.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
+
+ const overlay = document.createElement("canvas");
+ overlay.width = ref.width;
+ overlay.height = ref.height;
+ const outer = document.getElementById("terminal");
+ const bg = outer
+ ? window.getComputedStyle(outer).backgroundColor
+ : "transparent";
+ Object.assign(overlay.style, {
+ position: "fixed",
+ left: rect.left + "px",
+ top: rect.top + "px",
+ width: rect.width + "px",
+ height: rect.height + "px",
+ zIndex: "9999",
+ pointerEvents: "none",
+ background: bg,
+ });
+
+ const ctx = overlay.getContext("2d");
+ if (ctx) {
+ for (const c of sourceCanvases) {
+ try {
+ ctx.drawImage(c, 0, 0);
+ } catch (e) {
+ }
+ }
+ }
+
+ document.body.appendChild(overlay);
+ pinchOverlay = overlay;
+ };
+
+ const refresh = () => {
+ if (!pinchOverlay) return;
+ if (!term.element) return;
+ const sourceCanvases = term.element.querySelectorAll("canvas");
+ if (sourceCanvases.length === 0) return;
+ const ref = sourceCanvases[0];
+ const rect = ref.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
+
+ if (pinchOverlay.width !== ref.width) {
+ pinchOverlay.width = ref.width;
+ }
+ if (pinchOverlay.height !== ref.height) {
+ pinchOverlay.height = ref.height;
+ }
+ pinchOverlay.style.left = rect.left + "px";
+ pinchOverlay.style.top = rect.top + "px";
+ pinchOverlay.style.width = rect.width + "px";
+ pinchOverlay.style.height = rect.height + "px";
+
+ const ctx = pinchOverlay.getContext("2d");
+ if (ctx) {
+ ctx.clearRect(0, 0, pinchOverlay.width, pinchOverlay.height);
+ for (const c of sourceCanvases) {
+ try {
+ ctx.drawImage(c, 0, 0);
+ } catch (e) {
+ }
+ }
+ }
+ };
+
+ const armRemoval = () => {
+ if (!pinchOverlay) return;
+ pinchOverlayAwaitingRender = true;
+ if (pinchOverlaySafetyTimer !== null) {
+ clearTimeout(pinchOverlaySafetyTimer);
+ }
+ pinchOverlaySafetyTimer = setTimeout(() => {
+ pinchOverlaySafetyTimer = null;
+ destroy();
+ }, 600);
+ };
+
+ // Must not call fitAddon.fit() here: that would pre-sync term.rows/cols to the
+ // new dims, making the resize handler short-circuit and never notify the server.
+ const applyFontSize = (px) => {
+ const clamped = clampFontSize(px);
+ if (term.options.fontSize === clamped) {
+ return;
+ }
+ term.options.fontSize = clamped;
+ window.dispatchEvent(new CustomEvent("zellij:rendering-resize"));
+ };
+
+ if (term && typeof term.onRender === "function") {
+ term.onRender(() => {
+ if (!pinchOverlay) return;
+ refresh();
+ if (pinchOverlayAwaitingRender) {
+ pinchOverlayAwaitingRender = false;
+ requestAnimationFrame(() => {
+ destroy();
+ });
+ }
+ });
+ }
+
+ return { snapshot, refresh, applyFontSize, armRemoval, destroy };
+}
diff --git a/zellij-client/assets/soft-keyboard.js b/zellij-client/assets/soft-keyboard.js
new file mode 100644
index 00000000..25314453
--- /dev/null
+++ b/zellij-client/assets/soft-keyboard.js
@@ -0,0 +1,288 @@
+export function isCoarsePointerDevice() {
+ if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) {
+ return true;
+ }
+ if (typeof navigator !== "undefined" && navigator.maxTouchPoints > 0) {
+ return true;
+ }
+ if ("ontouchstart" in window) {
+ return true;
+ }
+ return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
+}
+
+export function installSoftKeyboardCapture(term, sendFunction) {
+ if (!isCoarsePointerDevice()) {
+ return;
+ }
+ if (typeof window.__zjSoftKbdCapture === "undefined") {
+ window.__zjSoftKbdCapture = {
+ installed: false,
+ sendFn: sendFunction,
+ element: null,
+ // document.activeElement returns the shadow host (not the input) for a
+ // closed shadow root, so focus state must be mirrored manually.
+ isFocused: false,
+ };
+ }
+ window.__zjSoftKbdCapture.sendFn = sendFunction;
+
+ if (window.__zjSoftKbdCapture.installed) {
+ return;
+ }
+ window.__zjSoftKbdCapture.installed = true;
+ const state = window.__zjSoftKbdCapture;
+
+ // The capture input steals focus on every tap, so xterm.js renders the cursor
+ // with cursorInactiveStyle; mirror the active style so it stays visible.
+ const syncInactiveCursorStyle = () => {
+ const active = term.options.cursorStyle || "block";
+ term.options.cursorInactiveStyle = active;
+ };
+ syncInactiveCursorStyle();
+ window.__zjSyncInactiveCursorStyle = syncInactiveCursorStyle;
+
+ // Firefox Android / GBoard no-op backspace on truly empty content, so keep the
+ // input padded with the caret in the middle to give backspace something to delete.
+ // Must NOT be a normal space: diff() strips PADDING_CHAR out of the inserted text,
+ // which would swallow a genuinely typed space (U+0020). U+00A0 is non-typeable.
+ const PADDING_CHAR = "\u00a0";
+ const PADDING_LEN = 8;
+ const CARET_OFFSET = PADDING_LEN / 2;
+ const BASELINE = PADDING_CHAR.repeat(PADDING_LEN);
+
+ const captureHost = document.createElement("div");
+ captureHost.id = "zj-mobile-capture-host";
+ captureHost.style.cssText =
+ "position:fixed;top:0;left:0;" +
+ "width:1px;height:1px;" +
+ "opacity:0;pointer-events:none;" +
+ "overflow:hidden;";
+ captureHost.setAttribute("aria-hidden", "true");
+ document.body.appendChild(captureHost);
+ // Closed shadow root hides the input from password managers, whose content
+ // scripts query input[type=password] and cannot pierce it.
+ const captureShadow = captureHost.attachShadow({ mode: "closed" });
+
+ const div = document.createElement("input");
+ div.id = "zj-mobile-capture";
+ // type="password" disables prediction/autocorrect/composition on every mobile
+ // keyboard vendor, turning each tap into one immediate character.
+ div.type = "password";
+ div.setAttribute("autocomplete", "new-password");
+ div.setAttribute("autocorrect", "off");
+ div.setAttribute("autocapitalize", "off");
+ div.setAttribute("spellcheck", "false");
+ div.setAttribute("inputmode", "text");
+ div.setAttribute("aria-hidden", "true");
+ div.setAttribute("data-1p-ignore", "true");
+ div.setAttribute("data-lpignore", "true");
+ div.setAttribute("data-bwignore", "true");
+ div.setAttribute("data-form-type", "other");
+ div.setAttribute("data-dashlane-ignore", "true");
+ div.tabIndex = -1;
+ // Kept 1x1 transparent rather than hidden: display:none / visibility:hidden
+ // would dismiss the OS keyboard.
+ div.style.cssText =
+ "position:fixed;top:0;left:0;" +
+ "width:1px;height:1px;" +
+ "opacity:0;pointer-events:none;" +
+ "border:0;padding:0;margin:0;" +
+ "background:transparent;color:transparent;" +
+ "caret-color:transparent;outline:none;" +
+ "white-space:pre;overflow:hidden;" +
+ "user-select:text;-webkit-user-select:text;";
+ captureShadow.appendChild(div);
+ state.element = div;
+
+ div.addEventListener("focus", () => { state.isFocused = true; });
+ div.addEventListener("blur", () => { state.isFocused = false; });
+
+ const setCaretToMiddle = () => {
+ try {
+ const pos = Math.min(CARET_OFFSET, div.value.length);
+ div.setSelectionRange(pos, pos);
+ } catch (_) {
+ }
+ };
+
+ const resetBaseline = () => {
+ div.value = BASELINE;
+ setCaretToMiddle();
+ };
+
+ resetBaseline();
+
+ // A single backspace tap fires two deleteContentBackward events ~5 ms apart;
+ // this dedupe window merges them into one \x7f.
+ let lastCh = null;
+ let lastChAt = 0;
+ const DEDUPE_MS = 8;
+ const dispatchCh = (ch) => {
+ const now = performance.now();
+ if (ch === lastCh && now - lastChAt < DEDUPE_MS) {
+ return;
+ }
+ lastCh = ch;
+ lastChAt = now;
+ state.sendFn(ch);
+ };
+
+ const diff = (a, b) => {
+ const minLen = Math.min(a.length, b.length);
+ let prefixLen = 0;
+ while (prefixLen < minLen && a[prefixLen] === b[prefixLen]) {
+ prefixLen++;
+ }
+ let suffixLen = 0;
+ const maxSuffix = minLen - prefixLen;
+ while (
+ suffixLen < maxSuffix &&
+ a[a.length - 1 - suffixLen] === b[b.length - 1 - suffixLen]
+ ) {
+ suffixLen++;
+ }
+ const deletedCount = a.length - prefixLen - suffixLen;
+ let inserted = b.slice(prefixLen, b.length - suffixLen);
+ if (inserted.indexOf(PADDING_CHAR) !== -1) {
+ inserted = inserted.split(PADDING_CHAR).join("");
+ }
+ return { deletedCount, inserted };
+ };
+
+ let lastText = BASELINE;
+
+ div.addEventListener("input", (ev) => {
+ const current = div.value;
+ if (current === lastText) {
+ return;
+ }
+ const { deletedCount, inserted } = diff(lastText, current);
+ for (let i = 0; i < deletedCount; i++) {
+ dispatchCh("\x7f");
+ }
+ for (const ch of inserted) {
+ dispatchCh(ch);
+ }
+ lastText = current;
+
+ if (ev.inputType === "deleteContentBackward") {
+ div.value = BASELINE;
+ lastText = BASELINE;
+ setCaretToMiddle();
+ }
+ });
+
+ div.addEventListener("keydown", (ev) => {
+ switch (ev.key) {
+ case "Enter":
+ ev.preventDefault();
+ state.sendFn("\r");
+ div.value = BASELINE;
+ lastText = BASELINE;
+ setCaretToMiddle();
+ return;
+ case "Tab":
+ ev.preventDefault();
+ state.sendFn("\t");
+ return;
+ case "Escape":
+ ev.preventDefault();
+ state.sendFn("\x1b");
+ return;
+ case "ArrowUp":
+ ev.preventDefault();
+ state.sendFn("\x1b[A");
+ return;
+ case "ArrowDown":
+ ev.preventDefault();
+ state.sendFn("\x1b[B");
+ return;
+ case "ArrowRight":
+ ev.preventDefault();
+ state.sendFn("\x1b[C");
+ return;
+ case "ArrowLeft":
+ ev.preventDefault();
+ state.sendFn("\x1b[D");
+ return;
+ }
+ });
+
+ // Mobile browsers honor programmatic focus() only inside a user gesture, so
+ // re-focus the capture on every gesture to keep the OS keyboard summoned.
+ const ensureCaptureFocused = () => {
+ if (!window.__zjSoftKbdEnabled) {
+ return;
+ }
+ if (state.isFocused) {
+ div.blur();
+ }
+ try {
+ div.focus({ preventScroll: true });
+ } catch (_) {
+ div.focus();
+ }
+ };
+ window.addEventListener("click", ensureCaptureFocused, { passive: true });
+ window.addEventListener("touchend", ensureCaptureFocused, { passive: true });
+ window.addEventListener("pointerdown", ensureCaptureFocused, {
+ capture: true,
+ passive: true,
+ });
+}
+
+// inputmode="none" stops the OS keyboard auto-popping on tap while the textarea
+// still processes hardware keypresses.
+export function suppressSoftKeyboardOnTouch(term) {
+ if (window.__zjSoftKbdSuppressed) {
+ return;
+ }
+ if (!isCoarsePointerDevice()) {
+ return;
+ }
+ window.__zjSoftKbdSuppressed = true;
+ window.__zjSoftKbdEnabled = true;
+ const apply = () => {
+ const ta = term && term._core && term._core.textarea;
+ if (!ta) {
+ setTimeout(apply, 50);
+ return;
+ }
+ ta.setAttribute("inputmode", "none");
+ };
+ apply();
+}
+
+export function setSoftKeyboard(term, on) {
+ if (!isCoarsePointerDevice()) {
+ return;
+ }
+ const ta = term && term._core && term._core.textarea;
+ if (!ta) {
+ return;
+ }
+ if (window.__zjSoftKbdEnabled === on) {
+ return;
+ }
+ window.__zjSoftKbdEnabled = on;
+ const capture = window.__zjSoftKbdCapture && window.__zjSoftKbdCapture.element;
+ if (on) {
+ if (capture) {
+ capture.focus();
+ } else {
+ ta.removeAttribute("inputmode");
+ ta.focus();
+ }
+ } else {
+ if (capture) {
+ capture.blur();
+ }
+ ta.setAttribute("inputmode", "none");
+ ta.focus();
+ }
+}
+
+export function toggleSoftKeyboard(term) {
+ setSoftKeyboard(term, !window.__zjSoftKbdEnabled);
+}
diff --git a/zellij-client/assets/style.css b/zellij-client/assets/style.css
index f267d0a8..f25084ab 100644
--- a/zellij-client/assets/style.css
+++ b/zellij-client/assets/style.css
@@ -1,14 +1,33 @@
:root {
+ /* `100vh` is the fallback for browsers without `dvh` support. On
+ iOS Safari this is the *layout* viewport — it includes the
+ URL bar height, so the terminal element would be taller than
+ the visible canvas and xterm.js's grid would be initialised
+ too tall (the keyboard would render below the visible area).
+ The `@supports` block below upgrades to `100dvh` (dynamic
+ viewport) on every modern browser, which automatically tracks
+ URL-bar / soft-keyboard occlusion. The runtime
+ `updateViewportVars` handler still overrides `--dynamic-vh`
+ from `visualViewport.height` when pinch / visualViewport
+ resize events fire. */
--dynamic-vh: 100vh;
--dynamic-vw: 100vw;
}
+@supports (height: 100dvh) {
+ :root {
+ --dynamic-vh: 100dvh;
+ --dynamic-vw: 100dvw;
+ }
+}
+
html,
body {
height: var(--dynamic-vh, 100vh);
width: var(--dynamic-vw, 100vw);
margin: 0;
overflow: hidden;
+ background: #000; /* TODO: this should be embedded as the user's theme background */
}
#terminal {
diff --git a/zellij-client/assets/terminal.js b/zellij-client/assets/terminal.js
index bdaf637e..0607d00c 100644
--- a/zellij-client/assets/terminal.js
+++ b/zellij-client/assets/terminal.js
@@ -1,20 +1,29 @@
-/**
- * Terminal initialization and management
- */
-
import { build_link_handler } from "./links.js";
-/**
- * Initialize the terminal with all required addons and configuration
- * @returns {object} Object containing term and fitAddon instances
- */
+// drawImage on a composited WebGL canvas returns blank pixels unless
+// preserveDrawingBuffer is set (needed by the pinch overlay snapshot); this must
+// run before xterm.js's WebglAddon creates its context.
+function ensurePreserveDrawingBuffer() {
+ if (window.__zjPreserveDrawingBuffer) return;
+ window.__zjPreserveDrawingBuffer = true;
+ const orig = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = function (type, options) {
+ if (type === "webgl" || type === "webgl2") {
+ options = Object.assign({}, options || {}, {
+ preserveDrawingBuffer: true,
+ });
+ }
+ return orig.call(this, type, options);
+ };
+}
+
export function initTerminal() {
+ ensurePreserveDrawingBuffer();
const term = new Terminal({
fontFamily: "Monospace",
allowProposedApi: true,
scrollback: 0,
});
- // for debugging
window.term = term;
const fitAddon = new FitAddon.FitAddon();
const clipboardAddon = new ClipboardAddon.ClipboardAddon();
@@ -31,7 +40,6 @@ export function initTerminal() {
term.loadAddon(clipboardAddon);
term.loadAddon(webLinksAddon);
webglAddon.onContextLoss((e) => {
- // TODO: reload, or?
webglAddon.dispose();
});
term.loadAddon(webglAddon);
@@ -40,3 +48,28 @@ export function initTerminal() {
term.focus();
return { term, fitAddon };
}
+
+export const MIN_FONT_SIZE_PX = 6;
+export const MAX_FONT_SIZE_PX = 96;
+
+export function applyFontSize(term, fitAddon, requestedPx) {
+ const requested =
+ typeof requestedPx === "number" && requestedPx > 0
+ ? requestedPx
+ : term.options.fontSize || 12;
+ const effective = clampFontSize(requested);
+ if (term.options.fontSize !== effective) {
+ term.options.fontSize = effective;
+ }
+ try {
+ fitAddon.fit();
+ } catch (e) {
+ }
+}
+
+export function clampFontSize(px) {
+ const n = Math.round(Number(px) || 0);
+ if (n < MIN_FONT_SIZE_PX) return MIN_FONT_SIZE_PX;
+ if (n > MAX_FONT_SIZE_PX) return MAX_FONT_SIZE_PX;
+ return n;
+}
diff --git a/zellij-client/assets/touch.js b/zellij-client/assets/touch.js
new file mode 100644
index 00000000..7cec67dc
--- /dev/null
+++ b/zellij-client/assets/touch.js
@@ -0,0 +1,266 @@
+import { clampFontSize } from "./terminal.js";
+import { toggleSoftKeyboard } from "./soft-keyboard.js";
+
+const SGR_COORD_BASE = 1;
+export function installTouchGestures({ term, terminalElement, sendFunction, pinch }) {
+ let last_touch_y = null;
+ let last_touch_x = null;
+ let pending_scroll = 0;
+ let pending_h_scroll = 0;
+ let touch_origin = null;
+ let touch_moved = false;
+ let long_press_fired = false;
+ let long_press_timer = null;
+ let two_finger_gesture_t = null;
+ let pinch_initial_distance = null;
+ let pinch_initial_font_size = null;
+ let pinch_active = false;
+
+ const touch_scroll_threshold = 24;
+ const click_move_threshold = 16;
+ const long_press_duration_ms = 500;
+ const two_finger_tap_max_ms = 600;
+ const pinch_activation_threshold = 18;
+
+ const reportCoords = (clientX, clientY) =>
+ term._core._mouseService.getMouseReportCoords(
+ { clientX, clientY },
+ terminalElement
+ );
+
+ const cancelLongPress = () => {
+ if (long_press_timer !== null) {
+ clearTimeout(long_press_timer);
+ long_press_timer = null;
+ }
+ };
+
+ const sendSgrButton = (button, col, row) => {
+ const sgrCol = col + SGR_COORD_BASE;
+ const sgrRow = row + SGR_COORD_BASE;
+ sendFunction(`\x1b[<${button};${sgrCol};${sgrRow}M`);
+ sendFunction(`\x1b[<${button};${sgrCol};${sgrRow}m`);
+ };
+
+ const sendWheelEvent = (direction, touch) => {
+ const { col, row } = reportCoords(touch.clientX, touch.clientY);
+ const swipedUp = direction < 0;
+ const button = swipedUp ? 65 : 64;
+ sendFunction(`\x1b[<${button};${col + SGR_COORD_BASE};${row + SGR_COORD_BASE}M`);
+ };
+
+ // Scroll direction follows the content, not the finger: swiping the content
+ // leftward reveals the right edge, so finger-left maps to wheel-right.
+ const sendHorizontalWheelEvent = (direction, touch) => {
+ const { col, row } = reportCoords(touch.clientX, touch.clientY);
+ const button = direction < 0 ? 66 : 67;
+ sendFunction(`\x1b[<${button};${col + SGR_COORD_BASE};${row + SGR_COORD_BASE}M`);
+ };
+
+ const touchPairDistance = (touches) => {
+ if (touches.length < 2) {
+ return 0;
+ }
+ const dx = touches[0].clientX - touches[1].clientX;
+ const dy = touches[0].clientY - touches[1].clientY;
+ return Math.hypot(dx, dy);
+ };
+
+ terminalElement.addEventListener(
+ "touchstart",
+ (event) => {
+ if (event.touches.length === 2 && two_finger_gesture_t === null) {
+ event.preventDefault();
+ two_finger_gesture_t = performance.now();
+ cancelLongPress();
+ touch_origin = null;
+ touch_moved = false;
+ long_press_fired = false;
+ pinch_initial_distance = touchPairDistance(event.touches);
+ pinch_initial_font_size = clampFontSize(
+ term.options.fontSize || 16
+ );
+ pinch_active = false;
+ return;
+ }
+ if (two_finger_gesture_t !== null) {
+ event.preventDefault();
+ return;
+ }
+ if (event.touches.length > 0) {
+ // iOS/Android honor focus() as a keyboard summon only inside a
+ // still-active, not-yet-prevented gesture, so this must precede
+ // the preventDefault below.
+ if (
+ window.__zjSoftKbdEnabled &&
+ window.__zjSoftKbdCapture &&
+ window.__zjSoftKbdCapture.element &&
+ !window.__zjSoftKbdCapture.isFocused
+ ) {
+ try {
+ window.__zjSoftKbdCapture.element.focus({
+ preventScroll: true,
+ });
+ } catch (_) {
+ window.__zjSoftKbdCapture.element.focus();
+ }
+ }
+ // preventDefault on touchstart cancels the synthetic mouse cascade
+ // browsers fire ~300 ms later, which would otherwise make xterm.js
+ // send a second SGR click for the same gesture.
+ event.preventDefault();
+ const touch = event.touches[0];
+ last_touch_y = touch.clientY;
+ last_touch_x = touch.clientX;
+ pending_scroll = 0;
+ pending_h_scroll = 0;
+ // Capture cell coords now: on iOS the soft keyboard sliding up
+ // between touchstart and touchend re-fits the grid.
+ const { col, row } = reportCoords(touch.clientX, touch.clientY);
+ touch_origin = {
+ x: touch.clientX,
+ y: touch.clientY,
+ col,
+ row,
+ t: performance.now(),
+ };
+ touch_moved = false;
+ long_press_fired = false;
+ cancelLongPress();
+ long_press_timer = setTimeout(() => {
+ long_press_timer = null;
+ if (touch_origin === null || touch_moved) {
+ return;
+ }
+ long_press_fired = true;
+ sendSgrButton(2, touch_origin.col, touch_origin.row);
+ if (typeof navigator.vibrate === "function") {
+ navigator.vibrate(10);
+ }
+ }, long_press_duration_ms);
+ }
+ },
+ { passive: false }
+ );
+
+ terminalElement.addEventListener(
+ "touchmove",
+ (event) => {
+ if (
+ event.touches.length === 2 &&
+ pinch_initial_distance !== null &&
+ pinch_initial_distance > 0
+ ) {
+ event.preventDefault();
+ const dist = touchPairDistance(event.touches);
+ if (
+ !pinch_active &&
+ Math.abs(dist - pinch_initial_distance) >
+ pinch_activation_threshold
+ ) {
+ pinch_active = true;
+ pinch.snapshot();
+ }
+ if (pinch_active) {
+ const ratio = dist / pinch_initial_distance;
+ pinch.applyFontSize(pinch_initial_font_size * ratio);
+ }
+ return;
+ }
+
+ if (event.touches.length === 0 || last_touch_y === null) {
+ return;
+ }
+ event.preventDefault();
+ const touch = event.touches[0];
+
+ if (touch_origin !== null && !touch_moved) {
+ const dx = touch.clientX - touch_origin.x;
+ const dy = touch.clientY - touch_origin.y;
+ if (Math.hypot(dx, dy) > click_move_threshold) {
+ touch_moved = true;
+ cancelLongPress();
+ }
+ }
+
+ const delta_y = touch.clientY - last_touch_y;
+ const delta_x =
+ last_touch_x === null ? 0 : touch.clientX - last_touch_x;
+ last_touch_y = touch.clientY;
+ last_touch_x = touch.clientX;
+ pending_scroll += delta_y;
+ pending_h_scroll += delta_x;
+ while (pending_scroll <= -touch_scroll_threshold) {
+ sendWheelEvent(-1, touch);
+ pending_scroll += touch_scroll_threshold;
+ }
+ while (pending_scroll >= touch_scroll_threshold) {
+ sendWheelEvent(1, touch);
+ pending_scroll -= touch_scroll_threshold;
+ }
+ while (pending_h_scroll <= -touch_scroll_threshold) {
+ sendHorizontalWheelEvent(-1, touch);
+ pending_h_scroll += touch_scroll_threshold;
+ }
+ while (pending_h_scroll >= touch_scroll_threshold) {
+ sendHorizontalWheelEvent(1, touch);
+ pending_h_scroll -= touch_scroll_threshold;
+ }
+ },
+ { passive: false }
+ );
+
+ terminalElement.addEventListener(
+ "touchend",
+ (event) => {
+ if (two_finger_gesture_t !== null && event.touches.length === 0) {
+ const elapsed = performance.now() - two_finger_gesture_t;
+ const wasPinch = pinch_active;
+ two_finger_gesture_t = null;
+ pinch_active = false;
+ pinch_initial_distance = null;
+ pinch_initial_font_size = null;
+ if (wasPinch) {
+ pinch.armRemoval();
+ return;
+ }
+ if (elapsed < two_finger_tap_max_ms) {
+ toggleSoftKeyboard(term);
+ }
+ return;
+ }
+ cancelLongPress();
+ if (touch_origin !== null && !touch_moved && !long_press_fired) {
+ sendSgrButton(0, touch_origin.col, touch_origin.row);
+ }
+ last_touch_y = null;
+ last_touch_x = null;
+ pending_scroll = 0;
+ pending_h_scroll = 0;
+ touch_origin = null;
+ touch_moved = false;
+ long_press_fired = false;
+ },
+ { passive: true }
+ );
+
+ terminalElement.addEventListener(
+ "touchcancel",
+ () => {
+ cancelLongPress();
+ last_touch_y = null;
+ last_touch_x = null;
+ pending_scroll = 0;
+ pending_h_scroll = 0;
+ touch_origin = null;
+ touch_moved = false;
+ long_press_fired = false;
+ two_finger_gesture_t = null;
+ pinch_initial_distance = null;
+ pinch_initial_font_size = null;
+ pinch_active = false;
+ pinch.destroy();
+ },
+ { passive: true }
+ );
+}
diff --git a/zellij-client/assets/websockets.js b/zellij-client/assets/websockets.js
index d6e98029..f7e2933d 100644
--- a/zellij-client/assets/websockets.js
+++ b/zellij-client/assets/websockets.js
@@ -1,17 +1,11 @@
-/**
- * WebSocket management for terminal and control connections
- */
-
import { handleReconnection, handleDisconnected, markConnectionEstablished } from "./connection.js";
import { getBaseUrl, getWebSocketBaseUrl } from "./utils.js";
+import { setSoftKeyboard } from "./input.js";
+import { applyFontSize } from "./terminal.js";
+
+const NATURAL_MIN_TOTAL_ROWS = 25;
+const MOBILE_LEGIBLE_FLOOR_PX = 16;
-/**
- * Read cell pixel dimensions from xterm.js. Tries the internal
- * _renderService first (matches what the vendored FitAddon uses) and
- * falls back to a DOM measurement of .xterm-char-measure-element — a
- * hidden helper element xterm.js creates explicitly for character
- * measurement. Returns null if neither path yields usable numbers.
- */
function getCellPixelDimensions(term) {
try {
const cell =
@@ -34,28 +28,21 @@ function getCellPixelDimensions(term) {
return null;
}
-/**
- * Send both control messages that describe the client's display state
- * to the Zellij server: TerminalResize (grid rows/cols) and
- * TerminalMetrics (pixel dimensions used to answer host-terminal
- * queries such as CSI 14t / 16t and OSC 11;?).
- *
- * Single chokepoint so the protocol contract lives in one place — any
- * site that updates terminal size or theme must call this helper, and
- * any future field added to the protocol is added here once. The
- * server's TerminalResize handler is idempotent, so calling this even
- * when the grid hasn't changed (e.g. after a theme reload that only
- * shifts font metrics) is safe.
- */
-function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols) {
+function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause) {
if (!wsControl || !ownWebClientId) {
return;
}
+ const resizeType =
+ cause === "RenderingPreference"
+ ? "TerminalResizeRendering"
+ : cause === "Settled"
+ ? "TerminalSizeSettled"
+ : "TerminalResize";
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
- type: "TerminalResize",
+ type: resizeType,
rows,
cols,
},
@@ -79,15 +66,6 @@ function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols) {
);
}
-/**
- * Initialize both terminal and control WebSocket connections
- * @param {string} webClientId - Client ID from authentication
- * @param {string} sessionName - Session name from URL
- * @param {Terminal} term - Terminal instance
- * @param {FitAddon} fitAddon - Terminal fit addon
- * @param {function} sendAnsiKey - Function to send ANSI key sequences
- * @returns {object} Object containing WebSocket instances and cleanup function
- */
export function initWebSockets(
webClientId,
sessionName,
@@ -177,7 +155,6 @@ export function initWebSockets(
}
};
- // Update sendAnsiKey to use the actual WebSocket
const originalSendAnsiKey = sendAnsiKey;
sendAnsiKey = (ansiKey) => {
if (ownWebClientId !== "") {
@@ -185,7 +162,6 @@ export function initWebSockets(
}
};
- // Setup resize handler
setupResizeHandler(
term,
fitAddon,
@@ -209,13 +185,6 @@ export function initWebSockets(
};
}
-/**
- * Start the control WebSocket and set up its handlers
- * @param {WebSocket} wsControl - Control WebSocket instance
- * @param {Terminal} term - Terminal instance
- * @param {FitAddon} fitAddon - Terminal fit addon
- * @param {string} ownWebClientId - Own web client ID
- */
function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
wsControl.onopen = function (event) {
const fitDimensions = fitAddon.proposeDimensions();
@@ -233,6 +202,7 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
mac_option_is_meta,
cursor_style,
cursor_inactive_style,
+ font_size,
} = msg;
term.options.fontFamily = font;
term.options.theme = theme;
@@ -250,27 +220,51 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
if (cursor_inactive_style !== "undefined") {
term.options.cursorInactiveStyle = cursor_inactive_style;
}
+ if (typeof window.__zjSyncInactiveCursorStyle === "function") {
+ window.__zjSyncInactiveCursorStyle();
+ }
+ const isMobileViewport =
+ (window.matchMedia &&
+ window.matchMedia("(pointer: coarse)").matches &&
+ window.innerWidth < 600) ||
+ /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
+ const hasExplicitFontSize =
+ typeof font_size === "number" && font_size > 0;
+ const baseFontPx = hasExplicitFontSize
+ ? font_size
+ : isMobileViewport
+ ? 24
+ : 12;
+ applyFontSize(term, fitAddon, baseFontPx);
+ const needsMobileDownscale =
+ !hasExplicitFontSize &&
+ isMobileViewport &&
+ term.rows < NATURAL_MIN_TOTAL_ROWS;
+ if (needsMobileDownscale) {
+ const downscaledPx = Math.max(
+ Math.floor(
+ (baseFontPx * term.rows) / NATURAL_MIN_TOTAL_ROWS
+ ),
+ MOBILE_LEGIBLE_FLOOR_PX
+ );
+ if (downscaledPx < baseFontPx) {
+ applyFontSize(term, fitAddon, downscaledPx);
+ }
+ }
const body = document.querySelector("body");
body.style.background = theme.background || "black";
const terminal = document.getElementById("terminal");
terminal.style.background = theme.background;
- const fitDimensions = fitAddon.proposeDimensions();
- if (fitDimensions === undefined) {
- console.warn("failed to get new fit dimensions");
- return;
- }
-
- const { rows, cols } = fitDimensions;
- if (rows !== term.rows || cols !== term.cols) {
- term.resize(cols, rows);
- }
- // Always emit a size update on SetConfig: even if the grid
- // didn't change, font metrics may have shifted and the
- // pixel-cell measurements in TerminalMetrics need to
- // refresh so host-terminal queries get accurate values.
- sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
+ sendSizeUpdate(
+ wsControl,
+ ownWebClientId,
+ term,
+ term.rows,
+ term.cols,
+ "Settled"
+ );
} else if (msg.type === "QueryTerminalSize") {
const fitDimensions = fitAddon.proposeDimensions();
const { rows, cols } = fitDimensions;
@@ -292,6 +286,9 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
const { new_session_name } = msg;
const baseUrl = getBaseUrl();
window.location.href = `${baseUrl}/${encodeURIComponent(new_session_name)}`;
+ } else if (msg.type === "SetSoftKeyboard") {
+ const { on } = msg;
+ setSoftKeyboard(term, !!on);
}
};
@@ -304,13 +301,6 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
};
}
-/**
- * Set up window resize event handler
- * @param {Terminal} term - Terminal instance
- * @param {FitAddon} fitAddon - Terminal fit addon
- * @param {function} getWsControl - Function that returns control WebSocket
- * @param {function} getOwnWebClientId - Function that returns own web client ID
- */
export function setupResizeHandler(
term,
fitAddon,
@@ -318,6 +308,8 @@ export function setupResizeHandler(
getOwnWebClientId
) {
let resizeScheduled = false;
+ let pendingViewportSignal = false;
+ let pendingRenderingSignal = false;
const updateViewportVars = () => {
const root = document.documentElement;
@@ -328,7 +320,7 @@ export function setupResizeHandler(
root.style.setProperty("--dynamic-vw", `${width}px`);
};
- const resizeTerminal = () => {
+ const resizeTerminal = (cause) => {
const ownWebClientId = getOwnWebClientId();
if (ownWebClientId === "") {
return;
@@ -345,31 +337,102 @@ export function setupResizeHandler(
return;
}
+ const wsControl = getWsControl();
term.resize(cols, rows);
- const wsControl = getWsControl();
- sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
+ sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause);
};
- const handleViewportChange = () => {
+ const handleViewportChange = (cause) => {
updateViewportVars();
- resizeTerminal();
+ resizeTerminal(cause);
};
- const scheduleResize = () => {
+ const scheduleResize = (cause) => {
+ if (cause === "RenderingPreference") {
+ pendingRenderingSignal = true;
+ } else {
+ pendingViewportSignal = true;
+ }
if (resizeScheduled) {
return;
}
resizeScheduled = true;
requestAnimationFrame(() => {
+ const tickCause =
+ pendingRenderingSignal && !pendingViewportSignal
+ ? "RenderingPreference"
+ : "Viewport";
+ pendingViewportSignal = false;
+ pendingRenderingSignal = false;
resizeScheduled = false;
- handleViewportChange();
+ handleViewportChange(tickCause);
});
};
+ const scheduleViewportResize = () => scheduleResize("Viewport");
+ const scheduleRenderingResize = () => scheduleResize("RenderingPreference");
+
updateViewportVars();
- addEventListener("resize", scheduleResize);
+ addEventListener("resize", scheduleViewportResize);
if (window.visualViewport) {
- window.visualViewport.addEventListener("resize", scheduleResize);
+ window.visualViewport.addEventListener(
+ "resize",
+ scheduleViewportResize
+ );
}
+ addEventListener("zellij:rendering-resize", scheduleRenderingResize);
+
+ setupSoftKeyboardVisibilityTracker(getWsControl, getOwnWebClientId);
+}
+
+function setupSoftKeyboardVisibilityTracker(getWsControl, getOwnWebClientId) {
+ if (!window.visualViewport) {
+ return;
+ }
+ const VIEWPORT_DELTA_THRESHOLD_PX = 150;
+ let lastViewportHeight = window.visualViewport.height;
+ let kbdVisible = false;
+
+ const onResize = () => {
+ const newHeight = window.visualViewport.height;
+ const delta = newHeight - lastViewportHeight;
+ let newKbdVisible = kbdVisible;
+ if (delta < -VIEWPORT_DELTA_THRESHOLD_PX) {
+ newKbdVisible = true;
+ } else if (delta > VIEWPORT_DELTA_THRESHOLD_PX) {
+ newKbdVisible = false;
+ }
+ lastViewportHeight = newHeight;
+ if (newKbdVisible === kbdVisible) {
+ return;
+ }
+ kbdVisible = newKbdVisible;
+
+ if (!kbdVisible) {
+ const capture =
+ window.__zjSoftKbdCapture &&
+ window.__zjSoftKbdCapture.element;
+ if (capture && window.__zjSoftKbdCapture.isFocused) {
+ capture.blur();
+ }
+ }
+
+ const wsControl = getWsControl();
+ const ownWebClientId = getOwnWebClientId();
+ if (!wsControl || ownWebClientId === "") {
+ return;
+ }
+ wsControl.send(
+ JSON.stringify({
+ web_client_id: ownWebClientId,
+ payload: {
+ type: "SoftKeyboardVisibilityChanged",
+ visible: kbdVisible,
+ },
+ })
+ );
+ };
+
+ window.visualViewport.addEventListener("resize", onResize);
}
diff --git a/zellij-client/src/input_handler.rs b/zellij-client/src/input_handler.rs
index cc4b4d75..45e8d9ce 100644
--- a/zellij-client/src/input_handler.rs
+++ b/zellij-client/src/input_handler.rs
@@ -46,6 +46,10 @@ fn termwiz_mouse_convert(original_event: &mut MouseEvent, event: &TermwizMouseEv
&& button_bits.contains(MouseButtons::WHEEL_POSITIVE);
original_event.wheel_down = button_bits.contains(MouseButtons::VERT_WHEEL)
&& !button_bits.contains(MouseButtons::WHEEL_POSITIVE);
+ original_event.wheel_right = button_bits.contains(MouseButtons::HORZ_WHEEL)
+ && button_bits.contains(MouseButtons::WHEEL_POSITIVE);
+ original_event.wheel_left = button_bits.contains(MouseButtons::HORZ_WHEEL)
+ && !button_bits.contains(MouseButtons::WHEEL_POSITIVE);
let mods = &event.modifiers;
original_event.shift = mods.contains(Modifiers::SHIFT);
@@ -73,6 +77,8 @@ pub fn from_termwiz(old_event: &mut MouseEvent, event: TermwizMouseEvent) -> Mou
|| (new_event.middle && !old_event.middle)
|| new_event.wheel_up
|| new_event.wheel_down
+ || new_event.wheel_left
+ || new_event.wheel_right
{
// This is a mouse Press event.
new_event.event_type = MouseEventType::Press;
diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs
index a1fcf387..d674e022 100644
--- a/zellij-client/src/lib.rs
+++ b/zellij-client/src/lib.rs
@@ -156,7 +156,7 @@ use zellij_utils::{
envs,
errors::{ClientContext, ContextType, ErrorInstruction},
input::{cli_assets::CliAssets, config::Config, options::Options},
- ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg},
+ ipc::{ClientToServerMsg, ExitReason, ResizeCause, ServerToClientMsg},
pane_size::Size,
vendored::termwiz::input::InputEvent,
};
@@ -214,6 +214,7 @@ impl From<ServerToClientMsg> for ClientInstruction {
// Subscribe-only messages — not handled by regular interactive clients
ServerToClientMsg::PaneRenderUpdate { .. } => ClientInstruction::UnblockInputThread,
ServerToClientMsg::SubscribedPaneClosed { .. } => ClientInstruction::UnblockInputThread,
+ ServerToClientMsg::SetSoftKeyboard { .. } => ClientInstruction::UnblockInputThread,
}
}
}
@@ -596,6 +597,9 @@ pub async fn run_remote_client_terminal_loop(
Ok(WebServerToWebClientControlMessage::SwitchedSession{ .. }) => {
// no-op
}
+ Ok(WebServerToWebClientControlMessage::SetSoftKeyboard{ .. }) => {
+ // no-op
+ }
Err(e) => {
log::error!("Failed to deserialize control message: {}", e);
}
@@ -1051,6 +1055,7 @@ pub fn start_client(
move || {
os_api.send_to_server(ClientToServerMsg::TerminalResize {
new_size: os_api.get_terminal_size(),
+ cause: ResizeCause::Viewport,
});
}
}),
@@ -1192,6 +1197,7 @@ pub fn start_client(
ClientInstruction::QueryTerminalSize => {
os_input.send_to_server(ClientToServerMsg::TerminalResize {
new_size: os_input.get_terminal_size(),
+ cause: ResizeCause::Viewport,
});
},
ClientInstruction::StartWebServer => {
diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs
index 6dfe96bf..f6d3fade 100644
--- a/zellij-client/src/unit/terminal_loop_tests.rs
+++ b/zellij-client/src/unit/terminal_loop_tests.rs
@@ -20,7 +20,6 @@ use zellij_utils::errors::ErrorContext;
use zellij_utils::ipc::{ClientToServerMsg, ServerToClientMsg};
use zellij_utils::pane_size::Size;
-/// Mock stdin that allows tests to inject input data
struct MockAsyncStdin {
rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<Vec<u8>>>>,
}
@@ -30,12 +29,11 @@ impl AsyncStdin for MockAsyncStdin {
async fn read(&mut self) -> io::Result<Vec<u8>> {
match self.rx.lock().await.recv().await {
Some(data) => Ok(data),
- None => Ok(Vec::new()), // EOF
+ None => Ok(Vec::new()),
}
}
}
-/// Mock signal listener that allows tests to inject signals
struct MockAsyncSignals {
rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<SignalEvent>>>,
}
@@ -47,7 +45,6 @@ impl AsyncSignals for MockAsyncSignals {
}
}
-/// Mock ClientOsApi for testing
#[derive(Clone)]
struct TestClientOsApi {
stdout_buffer: Arc<Mutex<Vec<u8>>>,
@@ -223,7 +220,6 @@ mod mock_ws_server {
axum::serve(listener, app).await.unwrap();
});
- // Wait for server to start
tokio::time::sleep(Duration::from_millis(100)).await;
let server = MockWsServer {
@@ -257,7 +253,6 @@ mod mock_ws_server {
let recv_task = tokio::spawn(async move {
while let Some(Ok(axum_msg)) = ws_rx.next().await {
- // Convert axum::extract::ws::Message to tokio_tungstenite::tungstenite::Message
let tungstenite_msg = match axum_msg {
axum::extract::ws::Message::Text(text) => Message::Text(text.to_string()),
axum::extract::ws::Message::Binary(data) => Message::Binary(data.to_vec()),
@@ -282,7 +277,6 @@ mod mock_ws_server {
let msg = state.server_rx.lock().unwrap().try_recv();
match msg {
Ok(tungstenite_msg) => {
- // Convert tokio_tungstenite::tungstenite::Message to axum::extract::ws::Message
let axum_msg = match tungstenite_msg {
Message::Text(text) => axum::extract::ws::Message::Text(text.into()),
Message::Binary(data) => axum::extract::ws::Message::Binary(data.into()),
@@ -300,7 +294,7 @@ mod mock_ws_server {
axum::extract::ws::Message::Close(None)
}
},
- Message::Frame(_) => continue, // Skip raw frames
+ Message::Frame(_) => continue,
};
if ws_tx.send(axum_msg).await.is_err() {
break;
@@ -319,10 +313,8 @@ mod mock_ws_server {
#[tokio::test]
#[serial]
async fn test_stdin_forwarded_to_terminal_websocket() {
- // Setup mock WebSocket server
let (port, server, _server_handle) = mock_ws_server::MockWsServer::start().await;
- // Create WebSocket connections
let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port);
let control_url = format!("ws://127.0.0.1:{}/ws/control", port);
@@ -353,21 +345,17 @@ async fn test_stdin_forwarded_to_terminal_websocket() {
web_client_id: "test-stdin".to_string(),
};
- // Create mock OS API with controllable stdin
let (stdin_tx, stdin_rx) = mpsc::unbounded_channel();
let (_signal_tx, signal_rx) = mpsc::unbounded_channel();
let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx));
- // Spawn the async loop
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send stdin data
let test_data = b"hello from stdin\n".to_vec();
stdin_tx.send(test_data.clone()).unwrap();
- // Verify terminal WebSocket received the data
tokio::time::sleep(Duration::from_millis(200)).await;
let received = tokio::time::timeout(
Duration::from_secs(1),
@@ -382,7 +370,6 @@ async fn test_stdin_forwarded_to_terminal_websocket() {
_ => panic!("Expected Binary message, got: {:?}", received),
}
- // Cleanup: send EOF via stdin
drop(stdin_tx);
let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
@@ -435,7 +422,6 @@ async fn test_terminal_output_written_to_stdout() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send terminal output from server
let test_output = "Hello from terminal";
server
.terminal_to_client_tx
@@ -444,7 +430,6 @@ async fn test_terminal_output_written_to_stdout() {
tokio::time::sleep(Duration::from_millis(200)).await;
- // Verify stdout received the output
let stdout = stdout_buffer.lock().unwrap().clone();
let stdout_str = String::from_utf8_lossy(&stdout);
assert!(
@@ -454,7 +439,6 @@ async fn test_terminal_output_written_to_stdout() {
stdout_str
);
- // Cleanup
server
.terminal_to_client_tx
.send(Message::Close(None))
@@ -508,22 +492,18 @@ async fn test_resize_signal_sends_control_message() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Wait for initial resize message to be sent on startup
tokio::time::sleep(Duration::from_millis(200)).await;
- // Consume the initial resize message
let _ = tokio::time::timeout(
Duration::from_millis(500),
server.client_to_control_rx.lock().unwrap().recv(),
)
.await;
- // Send resize signal
signal_tx.send(SignalEvent::Resize).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
- // Verify control WebSocket received resize message
let received = tokio::time::timeout(
Duration::from_secs(1),
server.client_to_control_rx.lock().unwrap().recv(),
@@ -548,7 +528,6 @@ async fn test_resize_signal_sends_control_message() {
_ => panic!("Expected Text message, got: {:?}", received),
}
- // Cleanup
signal_tx.send(SignalEvent::Quit).unwrap();
let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
@@ -599,10 +578,8 @@ async fn test_quit_signal_exits_loop() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send quit signal
signal_tx.send(SignalEvent::Quit).unwrap();
- // Verify loop exits cleanly
let result = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
.expect("Loop didn't exit within timeout")
@@ -654,13 +631,11 @@ async fn test_websocket_close_exits_loop() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send close message
server
.terminal_to_client_tx
.send(Message::Close(None))
.unwrap();
- // Verify loop exits cleanly
let result = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
.expect("Loop didn't exit within timeout")
@@ -714,17 +689,14 @@ async fn test_control_message_handling() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Wait for initial resize message to be sent on startup
tokio::time::sleep(Duration::from_millis(200)).await;
- // Consume the initial resize message
let _ = tokio::time::timeout(
Duration::from_millis(500),
server.client_to_control_rx.lock().unwrap().recv(),
)
.await;
- // Send QueryTerminalSize control message
let query_msg = WebServerToWebClientControlMessage::QueryTerminalSize;
server
.control_to_client_tx
@@ -733,7 +705,6 @@ async fn test_control_message_handling() {
tokio::time::sleep(Duration::from_millis(200)).await;
- // Verify we receive a resize response
let received = tokio::time::timeout(
Duration::from_secs(1),
server.client_to_control_rx.lock().unwrap().recv(),
@@ -746,16 +717,16 @@ async fn test_control_message_handling() {
Message::Text(text) => {
let parsed: WebClientToWebServerControlMessage =
serde_json::from_str(&text).expect("Failed to parse");
- let WebClientToWebServerControlMessagePayload::TerminalResize(size) = parsed.payload
- else {
- panic!("Expected TerminalResize, got: {:?}", parsed.payload);
+ let size = match parsed.payload {
+ WebClientToWebServerControlMessagePayload::TerminalResize(size) => size,
+ WebClientToWebServerControlMessagePayload::TerminalResizeRendering(size) => size,
+ other => panic!("Expected TerminalResize, got: {:?}", other),
};
assert_eq!(size, terminal_size);
},
_ => panic!("Expected Text message, got: {:?}", received),
}
- // Test Log message (should not crash)
let log_msg = WebServerToWebClientControlMessage::Log {
lines: vec!["Test log".to_string()],
};
@@ -766,7 +737,6 @@ async fn test_control_message_handling() {
tokio::time::sleep(Duration::from_millis(100)).await;
- // Cleanup
server
.terminal_to_client_tx
.send(Message::Close(None))
diff --git a/zellij-client/src/web_client/control_message.rs b/zellij-client/src/web_client/control_message.rs
index e6c46444..8ab333f9 100644
--- a/zellij-client/src/web_client/control_message.rs
+++ b/zellij-client/src/web_client/control_message.rs
@@ -12,7 +12,10 @@ pub struct WebClientToWebServerControlMessage {
#[serde(tag = "type")]
pub enum WebClientToWebServerControlMessagePayload {
TerminalResize(Size),
+ TerminalResizeRendering(Size),
+ TerminalSizeSettled(Size),
TerminalMetrics(TerminalMetricsPayload),
+ SoftKeyboardVisibilityChanged { visible: bool },
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -31,6 +34,7 @@ pub enum WebServerToWebClientControlMessage {
Log { lines: Vec<String> },
LogError { lines: Vec<String> },
SwitchedSession { new_session_name: String },
+ SetSoftKeyboard { on: bool },
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -43,6 +47,8 @@ pub struct SetConfigPayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_style: Option<String>,
pub mac_option_is_meta: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub font_size: Option<u16>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
@@ -159,6 +165,8 @@ impl From<&Config> for SetConfigPayload {
.as_ref()
.map(|s| s.to_string());
+ let font_size = config.web_client.font_size;
+
SetConfigPayload {
font,
theme,
@@ -166,6 +174,7 @@ impl From<&Config> for SetConfigPayload {
mac_option_is_meta,
cursor_style,
cursor_inactive_style,
+ font_size,
}
}
}
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index a3b419ef..9daa8fa8 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -178,6 +178,11 @@ pub fn zellij_server_listener(
WebServerToWebClientControlMessage::QueryTerminalSize,
);
},
+ Some(ServerToClientMsg::SetSoftKeyboard{on}) => {
+ client_connection_bus.send_control(
+ WebServerToWebClientControlMessage::SetSoftKeyboard { on },
+ );
+ },
Some(ServerToClientMsg::Log{lines}) => {
client_connection_bus.send_control(
WebServerToWebClientControlMessage::Log { lines },
diff --git a/zellij-client/src/web_client/unit/web_client_tests.rs b/zellij-client/src/web_client/unit/web_client_tests.rs
index 726e73a6..28c2e87c 100644
--- a/zellij-client/src/web_client/unit/web_client_tests.rs
+++ b/zellij-client/src/web_client/unit/web_client_tests.rs
@@ -44,7 +44,6 @@ mod web_client_tests {
.await
{
Ok(Ok(_)) => {
- // server ready
return Ok(());
},
Ok(Err(e)) => {
@@ -113,7 +112,6 @@ mod web_client_tests {
server_handle.abort();
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -190,7 +188,6 @@ mod web_client_tests {
server_handle.abort();
revoke_token(test_token_name).expect("Failed to revoke test token");
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -428,7 +425,7 @@ mod web_client_tests {
let messages = mock_api.get_sent_messages();
for msg in messages {
match msg {
- ClientToServerMsg::TerminalResize { new_size: _ } => {
+ ClientToServerMsg::TerminalResize { .. } => {
found_resize = true;
},
ClientToServerMsg::Key { .. }
@@ -465,12 +462,6 @@ mod web_client_tests {
#[tokio::test]
#[serial]
async fn test_terminal_metrics_translates_to_pixel_dimensions() {
- // Simulates a browser sending a TerminalMetrics control message
- // (the payload our websockets.js sendTerminalMetrics() helper
- // produces) and verifies the web server translates it into
- // ClientToServerMsg::TerminalPixelDimensions with field-for-field
- // accuracy. This guards the wire-format contract end-to-end
- // through serde + the match arm in websocket_handlers.rs.
let _ = delete_db();
let test_token_name = "test_token_terminal_metrics";
@@ -563,7 +554,6 @@ mod web_client_tests {
serde_json::from_str(&client_response.text().unwrap()).unwrap();
let web_client_id = client_data["web_client_id"].as_str().unwrap().to_string();
- // Open the control WebSocket and consume the initial SetConfig.
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -577,9 +567,6 @@ mod web_client_tests {
.await
.expect("Timeout waiting for initial control message");
- // The control channel only registers the client_id after the
- // first inbound message. Send a TerminalResize first to mirror
- // what the browser does at startup, then the TerminalMetrics.
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -594,11 +581,6 @@ mod web_client_tests {
.await
.expect("Failed to send TerminalResize");
- // Send the actual TerminalMetrics payload — this is what the
- // browser-side sendTerminalMetrics() helper writes onto the wire.
- // Hand-construct the JSON to lock in the exact field names the
- // browser uses, rather than going through the serde Serialize
- // impl (which would mask any rename mismatch).
let metrics_json = serde_json::json!({
"web_client_id": web_client_id,
"payload": {
@@ -614,11 +596,8 @@ mod web_client_tests {
.await
.expect("Failed to send TerminalMetrics");
- // Give the server a moment to process both messages.
tokio::time::sleep(Duration::from_millis(500)).await;
- // Inspect the captured ClientToServerMsg traffic on the mock OS
- // API for this web client.
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut found_pixel_dims: Option<ClientToServerMsg> = None;
for (_, mock_api) in mock_apis.iter() {
@@ -802,13 +781,10 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and get session token
let session_token = login_and_get_session_token(port, &auth_token).await;
- // Create client session
let web_client_id = create_client_session(port, &session_token).await;
- // Establish control WebSocket connection
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -820,12 +796,10 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig message
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next())
.await
.expect("Timeout waiting for initial control message");
- // Send resize message to establish proper connection
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -839,7 +813,6 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -854,10 +827,8 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Trigger server shutdown
server_handle.abort();
- // Verify control WebSocket receives close frame
let control_close_result = timeout(Duration::from_secs(3), control_stream.next()).await;
match control_close_result {
Ok(Some(Ok(Message::Close(_)))) => {
@@ -880,7 +851,6 @@ mod web_client_tests {
},
}
- // Verify terminal WebSocket receives close frame or connection ends
let terminal_close_result = timeout(Duration::from_secs(3), terminal_stream.next()).await;
match terminal_close_result {
Ok(Some(Ok(Message::Close(_)))) => {
@@ -904,7 +874,6 @@ mod web_client_tests {
}
revoke_token(test_token_name).expect("Failed to revoke test token");
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -949,14 +918,11 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and get session token
let session_token = login_and_get_session_token(port, &auth_token).await;
- // Create multiple client sessions
let client_id_1 = create_client_session(port, &session_token).await;
let client_id_2 = create_client_session(port, &session_token).await;
- // Establish WebSocket connections for both clients
let control_ws_url_1 = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws_1, _) = timeout(
Duration::from_secs(5),
@@ -979,11 +945,9 @@ mod web_client_tests {
let (mut control_sink_2, mut control_stream_2) = control_ws_2.split();
- // Wait for initial messages and establish connections
let _initial_msg_1 = timeout(Duration::from_secs(2), control_stream_1.next()).await;
let _initial_msg_2 = timeout(Duration::from_secs(2), control_stream_2.next()).await;
- // Send messages to establish proper connections
let resize_msg_1 = WebClientToWebServerControlMessage {
web_client_id: client_id_1.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -1010,7 +974,6 @@ mod web_client_tests {
.await
.expect("Failed to send resize message for client 2");
- // Establish terminal connections
let terminal_ws_url_1 = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, client_id_1
@@ -1025,7 +988,6 @@ mod web_client_tests {
let (_terminal_sink_1, _terminal_stream_1) = terminal_ws_1.split();
- // Verify both clients are initially present by checking mock APIs
tokio::time::sleep(Duration::from_millis(200)).await;
let initial_api_count = factory_for_verification.mock_apis.lock().unwrap().len();
assert!(
@@ -1033,13 +995,10 @@ mod web_client_tests {
"Should have at least 2 client APIs created"
);
- // Close connection for client 1 by closing WebSocket
let _ = control_sink_1.close().await;
- // Allow time for cleanup
tokio::time::sleep(Duration::from_millis(500)).await;
- // Verify client 2 is still functional by sending another message
let resize_msg_2_again = WebClientToWebServerControlMessage {
web_client_id: client_id_2.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -1059,7 +1018,6 @@ mod web_client_tests {
Err(e) => println!("Client 2 send failed (may be expected): {:?}", e),
}
- // Verify messages were received by checking mock APIs
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut total_resize_messages: usize = 0;
@@ -1123,11 +1081,9 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and create session
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1142,16 +1098,13 @@ mod web_client_tests {
let (mut terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Send some data to ensure connection is active and render loop is running
terminal_sink
.send(Message::Text("test input\n".to_string()))
.await
.expect("Failed to send terminal input");
- // Allow connection to stabilize and render loop to start
tokio::time::sleep(Duration::from_millis(500)).await;
- // Trigger shutdown by aborting server - this should trigger cancellation tokens
server_handle.abort();
let mut connection_terminated = false;
@@ -1188,15 +1141,12 @@ mod web_client_tests {
connection_terminated = true;
},
Err(_) => {
- // Timeout on this iteration, continue monitoring
println!("Timeout on stream.next(), continuing to monitor...");
},
}
}
- // If connection hasn't terminated through normal means, check if it's due to server shutdown
if !connection_terminated {
- // Try one more time to see if the connection is actually closed
match timeout(Duration::from_millis(100), terminal_stream.next()).await {
Ok(None) => {
println!("✓ Terminal WebSocket stream ended after server abort");
@@ -1210,8 +1160,6 @@ mod web_client_tests {
},
_ => {
println!("Connection still active after server abort - this may indicate the cancellation token isn't working as expected in test environment");
- // In test environment, server abort might not trigger cancellation tokens immediately
- // We'll consider the test successful if we've aborted the server
termination_reason = "server_aborted";
connection_terminated = true;
},
@@ -1269,11 +1217,9 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and create session
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1288,19 +1234,15 @@ mod web_client_tests {
let (mut terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Send terminal input to ensure connection is established
terminal_sink
.send(Message::Text("echo test\n".to_string()))
.await
.expect("Failed to send terminal input");
- // Allow connection to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
- // Create a mock API and simulate different exit scenarios by sending exit message
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
if let Some((_, mock_api)) = mock_apis.iter().next() {
- // Simulate ClientExited message being sent
mock_api
.messages_to_server
.lock()
@@ -1309,10 +1251,8 @@ mod web_client_tests {
}
drop(mock_apis);
- // Close the WebSocket connection to trigger cleanup
let _ = terminal_sink.close().await;
- // Monitor for connection termination
let close_result = timeout(Duration::from_secs(3), terminal_stream.next()).await;
match close_result {
Ok(Some(Ok(Message::Close(_)))) => {
@@ -1332,7 +1272,6 @@ mod web_client_tests {
},
}
- // Verify that ClientExited message was processed
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut found_client_exited = false;
@@ -1353,7 +1292,6 @@ mod web_client_tests {
server_handle.abort();
revoke_token(test_token_name).expect("Failed to revoke test token");
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -1362,13 +1300,10 @@ mod web_client_tests {
async fn test_read_only_token_cannot_create_new_session() {
let _ = delete_db();
- // Create read-only token
let (auth_token, _) = create_token(Some("test-readonly".to_string()), true)
.expect("Failed to create read-only token");
- // Setup mocks
let mock_session_manager = Arc::new(MockSessionManager::new());
- // Do NOT mark any session as existing - we want to verify read-only cannot create new sessions
let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new());
let session_manager_for_verification = mock_session_manager.clone();
@@ -1400,7 +1335,6 @@ mod web_client_tests {
.await
.expect("Server should start successfully");
- // Login and create client
let session_token = login_and_get_session_token(port, &auth_token).await;
let session_url = format!("http://127.0.0.1:{}/session", port);
@@ -1431,22 +1365,16 @@ mod web_client_tests {
assert_eq!(is_read_only, true, "Client should be marked as read-only");
- // Try to connect via control WebSocket
- // This will trigger the server_listener which should close the connection
- // because read-only client is trying to attach to non-existent session
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
- // The connection might fail or close immediately
let _ws_result = timeout(
Duration::from_secs(3),
connect_async_with_cookie(&control_ws_url, &session_token),
)
.await;
- // Give time for server_listener to process and close connection
tokio::time::sleep(Duration::from_millis(500)).await;
- // Verify no session was created
assert!(
!session_manager_for_verification.was_session_created("default"),
"No session should be created for read-only token attempting to create new session"
@@ -1462,11 +1390,9 @@ mod web_client_tests {
async fn test_read_only_token_uses_watcher_message_type() {
let _ = delete_db();
- // Create both regular and read-only tokens
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
let (readonly_token, _) = create_token(Some("readonly".to_string()), true).unwrap();
- // Setup mocks - mark all sessions as existing so clients use AttachClient/AttachWatcherClient
let mock_session_manager = Arc::new(MockSessionManager::with_all_sessions_existing());
let session_manager_for_verification = mock_session_manager.clone();
@@ -1500,11 +1426,9 @@ mod web_client_tests {
.await
.expect("Server should start");
- // First, attach with REGULAR token to establish baseline
let regular_session_token = login_and_get_session_token(port, &regular_token).await;
let regular_web_client_id = create_client_session(port, &regular_session_token).await;
- // Connect control websocket
let regular_control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (regular_control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1516,7 +1440,6 @@ mod web_client_tests {
let (mut regular_control_sink, _regular_control_stream) = regular_control_ws.split();
- // Connect terminal websocket to trigger server_listener
let regular_terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, regular_web_client_id
@@ -1531,11 +1454,8 @@ mod web_client_tests {
let (mut regular_terminal_sink, _regular_terminal_stream) = regular_terminal_ws.split();
- // Wait for attachment to complete
tokio::time::sleep(Duration::from_millis(500)).await;
- // VERIFY: Regular client should use AttachClient (not FirstClientConnected, since session exists)
- // Check what sessions we actually have
let regular_msg = {
let all_messages = session_manager_for_verification
.first_messages_sent
@@ -1559,11 +1479,9 @@ mod web_client_tests {
regular_msg
);
- // Now attach with READ-ONLY token
let readonly_session_token = login_and_get_session_token(port, &readonly_token).await;
let readonly_web_client_id = create_client_session(port, &readonly_session_token).await;
- // Connect control websocket
let readonly_control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (readonly_control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1575,7 +1493,6 @@ mod web_client_tests {
let (mut readonly_control_sink, _readonly_control_stream) = readonly_control_ws.split();
- // Connect terminal websocket to trigger server_listener
let readonly_terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, readonly_web_client_id
@@ -1590,23 +1507,19 @@ mod web_client_tests {
let (mut readonly_terminal_sink, _readonly_terminal_stream) = readonly_terminal_ws.split();
- // Wait for attachment to complete
tokio::time::sleep(Duration::from_millis(500)).await;
- // VERIFY: Read-only client should use AttachWatcherClient
let readonly_msg = {
let all_messages = session_manager_for_verification
.first_messages_sent
.lock()
.unwrap();
- // Should have at least 2 messages now (regular + readonly)
assert!(
all_messages.len() >= 2,
"Should have at least 2 messages (regular and readonly)"
);
- // Get the second message (readonly client)
let (_readonly_session_name, msg) = all_messages
.get(1)
.expect("Should have message for read-only client");
@@ -1620,7 +1533,6 @@ mod web_client_tests {
readonly_msg
);
- // Verify the terminal size is passed correctly
if let ClientToServerMsg::AttachWatcherClient { terminal_size, .. } = readonly_msg {
assert!(terminal_size.rows > 0 && terminal_size.cols > 0);
}
@@ -1639,12 +1551,9 @@ mod web_client_tests {
async fn test_regular_token_uses_first_client_connected_for_new_session() {
let _ = delete_db();
- // Create regular token
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
- // Setup mocks
let mock_session_manager = Arc::new(MockSessionManager::new());
- // Do NOT mark session as existing - we want to create a new one
let session_manager_for_verification = mock_session_manager.clone();
let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new());
@@ -1676,11 +1585,9 @@ mod web_client_tests {
.await
.expect("Server should start");
- // Login and create client
let session_token = login_and_get_session_token(port, &regular_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Connect control websocket
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1692,7 +1599,6 @@ mod web_client_tests {
let (mut control_sink, _control_stream) = control_ws.split();
- // Connect terminal websocket to trigger server_listener
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1707,17 +1613,13 @@ mod web_client_tests {
let (mut terminal_sink, _terminal_stream) = terminal_ws.split();
- // Wait for session creation to complete
tokio::time::sleep(Duration::from_millis(500)).await;
- // VERIFY: Regular client creating new session should use FirstClientConnected
- // The session name will be "default" or a generated name
let all_messages = session_manager_for_verification
.first_messages_sent
.lock()
.unwrap();
- // Find the first message (should be FirstClientConnected)
let msg = all_messages
.first()
.map(|(_, msg)| msg)
@@ -1729,7 +1631,6 @@ mod web_client_tests {
msg
);
- // Verify session was marked as created
let sessions_created = session_manager_for_verification
.sessions_created
.lock()
@@ -1751,11 +1652,9 @@ mod web_client_tests {
async fn test_read_only_status_tracked_in_connection_table() {
let _ = delete_db();
- // Create tokens
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
let (readonly_token, _) = create_token(Some("readonly".to_string()), true).unwrap();
- // Setup mocks
let mock_session_manager = Arc::new(MockSessionManager::new());
let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new());
@@ -1787,7 +1686,6 @@ mod web_client_tests {
.await
.expect("Server should start");
- // Create multiple clients with different tokens
let regular_session_token = login_and_get_session_token(port, &regular_token).await;
let session_url = format!("http://127.0.0.1:{}/session", port);
@@ -1840,7 +1738,6 @@ mod web_client_tests {
serde_json::from_str(&readonly_response.text().unwrap()).unwrap();
let readonly_is_read_only = readonly_client_data["is_read_only"].as_bool().unwrap();
- // Verify is_read_only flag in responses
assert_eq!(
regular_is_read_only, false,
"Regular client should not be read-only"
@@ -1856,7 +1753,6 @@ mod web_client_tests {
tokio::time::sleep(Duration::from_millis(100)).await;
}
- // Helper function to login and get session token
async fn login_and_get_session_token(port: u16, auth_token: &str) -> String {
let login_url = format!("http://127.0.0.1:{}/command/login", port);
let login_payload = serde_json::json!({
@@ -1891,7 +1787,6 @@ mod web_client_tests {
.to_string()
}
- // Helper function to create client session
async fn create_client_session(port: u16, session_token: &str) -> String {
let session_url = format!("http://127.0.0.1:{}/session", port);
let mut client_response = timeout(
@@ -1964,7 +1859,6 @@ mod web_client_tests {
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1979,7 +1873,6 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Establish control WebSocket connection
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1991,7 +1884,6 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig and send resize to register client_id on the control channel
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
@@ -2005,10 +1897,8 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Allow connection to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
- // Queue KickedByHost exit message into the mock API
{
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
assert!(
@@ -2022,8 +1912,6 @@ mod web_client_tests {
}
}
- // Wait for terminal WebSocket to close with code 4001
- // Use a polling loop to handle any non-close messages that may arrive first
let mut terminal_got_4001 = false;
let mut terminal_closed = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
@@ -2040,7 +1928,6 @@ mod web_client_tests {
break;
},
Ok(Some(Ok(_other))) => {
- // skip non-close messages (e.g. buffered render data)
continue;
},
Ok(Some(Err(_))) | Ok(None) => {
@@ -2048,7 +1935,6 @@ mod web_client_tests {
break;
},
Err(_) => {
- // timeout on this iteration, continue polling
continue;
},
}
@@ -2062,7 +1948,6 @@ mod web_client_tests {
"Terminal WebSocket should close with code 4001 when kicked by host"
);
- // Wait for control WebSocket to close with code 4001 (skip any pending non-Close messages)
let mut control_got_4001 = false;
let mut control_closed = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
@@ -2079,7 +1964,6 @@ mod web_client_tests {
break;
},
Ok(Some(Ok(_msg))) => {
- // Skip non-Close messages
continue;
},
Ok(Some(Err(_))) | Ok(None) => {
@@ -2148,7 +2032,6 @@ mod web_client_tests {
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -2163,7 +2046,6 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Establish control WebSocket connection
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -2175,7 +2057,6 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig and send resize to register client_id on the control channel
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
@@ -2189,10 +2070,8 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Allow connection to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
- // Queue Normal exit message into the mock API
{
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
assert!(
@@ -2206,8 +2085,6 @@ mod web_client_tests {
}
}
- // Wait for terminal WebSocket to close with NORMAL (1000) close code
- // Use a polling loop to handle any non-close messages that may arrive first
let mut terminal_got_normal = false;
let mut terminal_closed = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
@@ -2220,7 +2097,6 @@ mod web_client_tests {
break;
},
Ok(Some(Ok(Message::Close(None)))) => {
- // Close with no code also counts as a normal close
terminal_got_normal = true;
terminal_closed = true;
break;
@@ -2262,9 +2138,6 @@ mod web_client_tests {
),
tokio_tungstenite::tungstenite::Error,
> {
- // Manually construct WebSocket request with required headers since we need to add a custom cookie.
- // When building the request manually, we must include all the standard WebSocket handshake headers
- // that would normally be added automatically by the WebSocket client library.
let request = Request::builder()
.uri(url)
.header("Cookie", format!("session_token={}", session_token))
@@ -2278,8 +2151,6 @@ mod web_client_tests {
connect_async(request).await
}
- // ========== Task 1: Security Headers Tests ==========
-
#[tokio::test]
#[serial]
async fn test_security_headers_present_on_all_responses() {
@@ -2362,7 +2233,6 @@ mod web_client_tests {
csp.contains("default-src 'self'"),
"CSP should contain default-src 'self'"
);
- // HSTS should NOT be present when is_https is false
assert!(
response
.headers()
@@ -2458,14 +2328,11 @@ mod web_client_tests {
tokio::time::sleep(Duration::from_millis(100)).await;
}
- // ========== Task 10: web_client_id binding tests ==========
-
#[tokio::test]
#[serial]
async fn test_control_websocket_rejects_foreign_web_client_id() {
let _ = delete_db();
- // Create a read-write token and a read-only token
let (rw_token, _) = create_token(Some("rw_token".to_string()), false).unwrap();
let (ro_token, _) = create_token(Some("ro_token".to_string()), true).unwrap();
@@ -2501,15 +2368,12 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login with read-write token and create a client session
let rw_session_token = login_and_get_session_token(port, &rw_token).await;
let rw_web_client_id = create_client_session(port, &rw_session_token).await;
- // Login with read-only token and create a client session
let ro_session_token = login_and_get_session_token(port, &ro_token).await;
let _ro_web_client_id = create_client_session(port, &ro_session_token).await;
- // Connect a control WebSocket using the read-only session token
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -2521,10 +2385,8 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig message
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
- // Send a TerminalResize message with the READ-WRITE user's web_client_id
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: rw_web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -2538,18 +2400,11 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // The WebSocket should be closed by the server
let close_result = timeout(Duration::from_secs(3), control_stream.next()).await;
match close_result {
- Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) => {
- // Expected: connection was closed
- },
- Err(_) => {
- // Timeout - connection may have been silently dropped
- },
+ Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) => {},
+ Err(_) => {},
Ok(Some(Ok(_))) => {
- // If we get another message, the connection wasn't closed - this is a failure
- // but let's check if it closes after
let second_result = timeout(Duration::from_secs(2), control_stream.next()).await;
assert!(
matches!(
@@ -2561,7 +2416,6 @@ mod web_client_tests {
},
}
- // Verify that NO TerminalResize message was forwarded on behalf of the rw client
tokio::time::sleep(Duration::from_millis(200)).await;
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
for (_, mock_api) in mock_apis.iter() {
@@ -2634,10 +2488,8 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig message
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
- // Send a TerminalResize message with the CORRECT web_client_id
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -2651,10 +2503,8 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Allow message processing
tokio::time::sleep(Duration::from_millis(500)).await;
- // Verify the resize was forwarded
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut found_resize = false;
for (_, mock_api) in mock_apis.iter() {
@@ -2715,14 +2565,12 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Create two sessions with different tokens
let rw_session_token = login_and_get_session_token(port, &rw_token).await;
let rw_web_client_id = create_client_session(port, &rw_session_token).await;
let ro_session_token = login_and_get_session_token(port, &ro_token).await;
let _ro_web_client_id = create_client_session(port, &ro_session_token).await;
- // Try to connect terminal WebSocket using ro session token but rw web_client_id
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, rw_web_client_id
@@ -2735,25 +2583,15 @@ mod web_client_tests {
match ws_result {
Ok(Ok((ws, _))) => {
- // Connection was established but should be closed immediately
let (_sink, mut stream) = ws.split();
let msg = timeout(Duration::from_secs(2), stream.next()).await;
match msg {
- Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) | Err(_) => {
- // Expected: connection was rejected/closed
- },
- Ok(Some(Ok(_))) => {
- // Unexpected: received data on a connection that should have been rejected
- // The server may close after initial processing
- },
+ Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) | Err(_) => {},
+ Ok(Some(Ok(_))) => {},
}
},
- Ok(Err(_)) => {
- // Connection failed - expected behavior
- },
- Err(_) => {
- // Timeout - acceptable
- },
+ Ok(Err(_)) => {},
+ Err(_) => {},
}
server_handle.abort();
@@ -2761,8 +2599,6 @@ mod web_client_tests {
tokio::time::sleep(Duration::from_millis(100)).await;
}
- // ========== Task 8: HTML-escape base_url test ==========
-
#[tokio::test]
#[serial]
async fn test_base_url_with_special_characters_is_escaped() {
@@ -3150,7 +2986,6 @@ impl SessionManager for MockSessionManager {
_zellij_ipc_pipe: &PathBuf,
first_message: ClientToServerMsg,
) {
- // Track the message that was sent
self.first_messages_sent
.lock()
.unwrap()
diff --git a/zellij-client/src/web_client/websocket_handlers.rs b/zellij-client/src/web_client/websocket_handlers.rs
index 969911cc..11c81a9e 100644
--- a/zellij-client/src/web_client/websocket_handlers.rs
+++ b/zellij-client/src/web_client/websocket_handlers.rs
@@ -21,7 +21,7 @@ use std::sync::{atomic::AtomicBool, Arc};
use tokio_util::sync::CancellationToken;
use zellij_utils::{
input::mouse::MouseEvent,
- ipc::{ClientToServerMsg, PixelDimensions},
+ ipc::{ClientToServerMsg, PixelDimensions, ResizeCause},
pane_size::SizeInPixels,
};
@@ -76,11 +76,29 @@ async fn handle_ws_control(
};
let client_msg = match deserialized_msg.payload {
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
- ClientToServerMsg::TerminalResize { new_size: size }
+ ClientToServerMsg::TerminalResize {
+ new_size: size,
+ cause: ResizeCause::Viewport,
+ }
+ },
+ WebClientToWebServerControlMessagePayload::TerminalResizeRendering(size) => {
+ ClientToServerMsg::TerminalResize {
+ new_size: size,
+ cause: ResizeCause::RenderingPreference,
+ }
+ },
+ WebClientToWebServerControlMessagePayload::TerminalSizeSettled(size) => {
+ ClientToServerMsg::TerminalResize {
+ new_size: size,
+ cause: ResizeCause::SizeSettled,
+ }
},
WebClientToWebServerControlMessagePayload::TerminalMetrics(metrics) => {
terminal_metrics_to_ipc(metrics)
},
+ WebClientToWebServerControlMessagePayload::SoftKeyboardVisibilityChanged {
+ visible,
+ } => ClientToServerMsg::SoftKeyboardVisibilityChanged { visible },
};
let _ = client_connection.send_to_server(client_msg);
diff --git a/zellij-server/src/background_jobs.rs b/zellij-server/src/background_jobs.rs
index 0ea1704d..c01df526 100644
--- a/zellij-server/src/background_jobs.rs
+++ b/zellij-server/src/background_jobs.rs
@@ -74,6 +74,7 @@ pub enum BackgroundJob {
StopFlashPaneBell(Vec<PaneId>),
FlashTabBell(usize), // usize = tab_id
StopFlashTabBell(usize), // usize = tab_id
+ MobileGateTimeout(ClientId),
Exit,
}
@@ -102,12 +103,14 @@ impl From<&BackgroundJob> for BackgroundJobContext {
BackgroundJob::StopFlashPaneBell(..) => BackgroundJobContext::StopFlashPaneBell,
BackgroundJob::FlashTabBell(..) => BackgroundJobContext::FlashTabBell,
BackgroundJob::StopFlashTabBell(..) => BackgroundJobContext::StopFlashTabBell,
+ BackgroundJob::MobileGateTimeout(..) => BackgroundJobContext::MobileGateTimeout,
BackgroundJob::Exit => BackgroundJobContext::Exit,
}
}
}
static LONG_FLASH_DURATION_MS: u64 = 1000;
+static MOBILE_GATE_FALLBACK_LIFT_TIMEOUT_MS: u64 = 5000;
static FLASH_DURATION_MS: u64 = 400; // Doherty threshold
static PLUGIN_ANIMATION_OFFSET_DURATION_MD: u64 = 500;
static SESSION_METADATA_WRITE_INTERVAL_MS: u64 = 1000;
@@ -621,6 +624,19 @@ pub(crate) fn background_jobs_main(
.senders
.send_to_screen(ScreenInstruction::SetTabBellFlash(tab_id, false));
},
+ BackgroundJob::MobileGateTimeout(client_id) => {
+ runtime.spawn({
+ let senders = bus.senders.clone();
+ async move {
+ tokio::time::sleep(Duration::from_millis(
+ MOBILE_GATE_FALLBACK_LIFT_TIMEOUT_MS,
+ ))
+ .await;
+ let _ =
+ senders.send_to_screen(ScreenInstruction::ForceMobileUngate(client_id));
+ }
+ });
+ },
BackgroundJob::Exit => {
for loading_plugin in loading_plugins.values() {
loading_plugin.store(false, Ordering::SeqCst);
diff --git a/zellij-server/src/lib.rs b/zellij-server/src/lib.rs
index 29aabdd0..841fcce6 100644
--- a/zellij-server/src/lib.rs
+++ b/zellij-server/src/lib.rs
@@ -14,6 +14,7 @@ pub mod tab;
pub mod background_jobs;
mod global_async_runtime;
mod logging_pipe;
+mod mobile_mode;
mod pane_groups;
mod plugins;
mod pty;
@@ -984,6 +985,22 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
is_web_client,
);
+ let should_enter_mobile = should_enter_mobile_on_connect(
+ &runtime_config_options,
+ is_web_client,
+ client_attributes.size,
+ );
+ if should_enter_mobile {
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
+ .unwrap();
+ }
+
let default_shell = runtime_config_options.default_shell.map(|shell| {
TerminalAction::RunCommand(RunCommand {
command: shell,
@@ -1080,6 +1097,17 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
.senders
.send_to_plugin(PluginInstruction::AddClient(client_id))
.unwrap();
+
+ if should_enter_mobile {
+ session_data
+ .read()
+ .unwrap()
+ .as_ref()
+ .unwrap()
+ .senders
+ .send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
+ .unwrap();
+ }
},
ServerInstruction::AttachClient(
cli_assets,
@@ -1123,6 +1151,19 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
client_attributes.size,
is_web_client,
);
+
+ let should_enter_mobile = should_enter_mobile_on_connect(
+ &runtime_config_options,
+ is_web_client,
+ client_attributes.size,
+ );
+ if should_enter_mobile {
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
+ .unwrap();
+ }
+
session_data
.senders
.send_to_screen(ScreenInstruction::AddClient(
@@ -1149,6 +1190,13 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
None,
))
.unwrap();
+
+ if should_enter_mobile {
+ session_data
+ .senders
+ .send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
+ .unwrap();
+ }
},
ServerInstruction::AttachWatcherClient(client_id, terminal_size, is_web_client) => {
// the client_id was inserted into clients upon ipc tunnel initialization
@@ -2197,6 +2245,21 @@ fn should_show_startup_tip(
}
}
+fn should_enter_mobile_on_connect(options: &Options, is_web_client: bool, viewport: Size) -> bool {
+ let mobile_layout = options.mobile_layout.unwrap_or_default();
+ if is_web_client {
+ mobile_layout.may_route_web_client_to_mobile()
+ } else {
+ mobile_layout.should_route_to_mobile(
+ is_web_client,
+ viewport.cols,
+ viewport.rows,
+ options.mobile_threshold_cols.unwrap_or(60),
+ options.mobile_threshold_rows.unwrap_or(30),
+ )
+ }
+}
+
fn report_changes_in_config_file(
config_file_path: PathBuf,
to_server: SenderWithContext<ServerInstruction>,
diff --git a/zellij-server/src/mobile_mode.rs b/zellij-server/src/mobile_mode.rs
new file mode 100644
index 00000000..a59aded0
--- /dev/null
+++ b/zellij-server/src/mobile_mode.rs
@@ -0,0 +1,733 @@
+use std::collections::{BTreeMap, HashMap, HashSet};
+
+use zellij_utils::errors::prelude::*;
+use zellij_utils::input::layout::{Run, RunPluginOrAlias, TiledPaneLayout};
+use zellij_utils::pane_size::Size;
+
+use crate::panes::PaneId;
+use crate::tab::Tab;
+use crate::ClientId;
+
+pub(crate) const FIT_RESIZE_MAX_ITERS: usize = 3;
+
+const MOBILE_PLUGIN_URL: &str = "zellij:mobile";
+
+#[derive(Debug, Default)]
+pub(crate) struct MobileState {
+ mobile_tab_for_client: HashMap<ClientId, usize>,
+ tab_before_mobile_for_client: HashMap<ClientId, usize>,
+ auto_entered_clients: HashSet<ClientId>,
+ fit_override_for_tab: HashMap<usize, FitOverride>,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct FitOverride {
+ owning_client: ClientId,
+ fullscreened_pane: PaneId,
+ embedded_content_size: Size,
+ pane_was_fullscreen_before_fit: bool,
+}
+
+const CLEAR_SCREEN: &str = "\u{1b}[2J\u{1b}[H";
+
+#[derive(Debug, Default)]
+pub(crate) struct MobileRenderGate {
+ awaiting_first_render: HashSet<ClientId>,
+ clients_with_settled_size: HashSet<ClientId>,
+ last_paint_size: HashMap<ClientId, Size>,
+}
+
+impl MobileRenderGate {
+ pub(crate) fn gate(&mut self, client_id: ClientId) {
+ self.awaiting_first_render.insert(client_id);
+ }
+
+ pub(crate) fn is_gated(&self, client_id: ClientId) -> bool {
+ self.awaiting_first_render.contains(&client_id)
+ }
+
+ pub(crate) fn is_empty(&self) -> bool {
+ self.awaiting_first_render.is_empty()
+ }
+
+ pub(crate) fn gated_clients(&self) -> Vec<ClientId> {
+ self.awaiting_first_render.iter().copied().collect()
+ }
+
+ pub(crate) fn ungate(&mut self, client_id: ClientId) {
+ self.awaiting_first_render.remove(&client_id);
+ self.clients_with_settled_size.remove(&client_id);
+ self.last_paint_size.remove(&client_id);
+ }
+
+ pub(crate) fn record_settled_size(&mut self, client_id: ClientId) {
+ self.clients_with_settled_size.insert(client_id);
+ }
+
+ pub(crate) fn record_paint_size(&mut self, client_id: ClientId, size: Size) {
+ self.last_paint_size.insert(client_id, size);
+ }
+
+ pub(crate) fn try_reveal(
+ &mut self,
+ client_id: ClientId,
+ is_web_client: bool,
+ reported_size: Option<Size>,
+ ) -> bool {
+ if !self.is_gated(client_id) {
+ return false;
+ }
+ let paint_size = self.last_paint_size.get(&client_id).copied();
+ let size_settled = self.clients_with_settled_size.contains(&client_id);
+ let ready_to_reveal = match paint_size {
+ None => false,
+ Some(paint_size) if is_web_client => size_settled && reported_size == Some(paint_size),
+ Some(_) => true,
+ };
+ if ready_to_reveal {
+ self.ungate(client_id);
+ }
+ ready_to_reveal
+ }
+
+ pub(crate) fn blank_gated_clients(&self, serialized_output: &mut HashMap<ClientId, String>) {
+ for (client_id, bytes) in serialized_output.iter_mut() {
+ if self.is_gated(*client_id) {
+ *bytes = CLEAR_SCREEN.to_string();
+ }
+ }
+ }
+}
+
+impl MobileState {
+ pub(crate) fn is_in_mobile_mode(&self, client_id: ClientId) -> bool {
+ self.mobile_tab_for_client.contains_key(&client_id)
+ }
+
+ pub(crate) fn mobile_tab_id(&self, client_id: ClientId) -> Option<usize> {
+ self.mobile_tab_for_client.get(&client_id).copied()
+ }
+
+ pub(crate) fn mobile_tab_ids(&self) -> HashSet<usize> {
+ self.mobile_tab_for_client.values().copied().collect()
+ }
+
+ pub(crate) fn mobile_tab_count(&self) -> usize {
+ self.mobile_tab_for_client.len()
+ }
+
+ pub(crate) fn set_previous_tab(&mut self, client_id: ClientId, previous_tab: Option<usize>) {
+ if let Some(previous_tab) = previous_tab {
+ self.tab_before_mobile_for_client
+ .insert(client_id, previous_tab);
+ }
+ }
+
+ pub(crate) fn register_tab(&mut self, client_id: ClientId, mobile_tab_id: usize) {
+ self.mobile_tab_for_client.insert(client_id, mobile_tab_id);
+ }
+
+ pub(crate) fn begin_exit(&mut self, client_id: ClientId) -> Option<(usize, Option<usize>)> {
+ let mobile_tab_id = self.mobile_tab_for_client.remove(&client_id)?;
+ self.auto_entered_clients.remove(&client_id);
+ let previous_tab = self.tab_before_mobile_for_client.remove(&client_id);
+ Some((mobile_tab_id, previous_tab))
+ }
+
+ pub(crate) fn forget_client(&mut self, client_id: ClientId) {
+ self.mobile_tab_for_client.remove(&client_id);
+ self.tab_before_mobile_for_client.remove(&client_id);
+ self.auto_entered_clients.remove(&client_id);
+ }
+
+ pub(crate) fn was_auto_entered(&self, client_id: ClientId) -> bool {
+ self.auto_entered_clients.contains(&client_id)
+ }
+
+ pub(crate) fn mark_auto_entered(&mut self, client_id: ClientId) {
+ self.auto_entered_clients.insert(client_id);
+ }
+
+ pub(crate) fn mobile_tab_layout() -> Result<TiledPaneLayout> {
+ let mut mobile_tab_layout = TiledPaneLayout::default();
+ let mobile_plugin = RunPluginOrAlias::from_url(MOBILE_PLUGIN_URL, &None, None, None)
+ .map_err(|e| anyhow!("invalid mobile plugin url: {e}"))?;
+ mobile_tab_layout.run = Some(Run::Plugin(mobile_plugin));
+ mobile_tab_layout.borderless = Some(true);
+ Ok(mobile_tab_layout)
+ }
+
+ pub(crate) fn clear_shadow_focus(&self, client_id: ClientId, tabs: &mut BTreeMap<usize, Tab>) {
+ let mobile_tab_id = self.mobile_tab_id(client_id);
+ for tab in tabs.values_mut() {
+ if Some(tab.id) == mobile_tab_id {
+ continue;
+ }
+ tab.clear_shadow_focus(client_id);
+ }
+ }
+
+ pub(crate) fn apply_shadow_focus(
+ &self,
+ client_id: ClientId,
+ pane_id: PaneId,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) -> ShadowFocusOutcome {
+ let mobile_tab_id = self.mobile_tab_id(client_id);
+ for tab in tabs.values() {
+ if Some(tab.id) == mobile_tab_id {
+ continue;
+ }
+ if tab.has_shadow_focus_on(client_id, pane_id) {
+ return ShadowFocusOutcome::AlreadyApplied;
+ }
+ }
+
+ self.clear_shadow_focus(client_id, tabs);
+ for tab in tabs.values_mut() {
+ if Some(tab.id) == mobile_tab_id {
+ continue;
+ }
+ if tab.set_shadow_focus(client_id, pane_id) {
+ break;
+ }
+ }
+ ShadowFocusOutcome::NewlyApplied
+ }
+
+ pub(crate) fn has_fit(&self, tab_id: usize) -> bool {
+ self.fit_override_for_tab.contains_key(&tab_id)
+ }
+
+ pub(crate) fn compute_fit_size(
+ &self,
+ tab_id: usize,
+ tabs: &BTreeMap<usize, Tab>,
+ ) -> Option<Size> {
+ let fit = self.fit_override_for_tab.get(&tab_id)?;
+ let target_tab = tabs.get(&tab_id)?;
+ let viewport = target_tab.get_viewport();
+ let display_area = target_tab.get_display_area();
+ let tab_bar_rows = display_area.rows.saturating_sub(viewport.rows);
+ let tab_bar_cols = display_area.cols.saturating_sub(viewport.cols);
+ let (frame_rows, frame_cols) = target_tab
+ .get_pane_with_id(fit.fullscreened_pane)
+ .map(|pane| {
+ (
+ pane.rows().saturating_sub(pane.get_content_rows()),
+ pane.cols().saturating_sub(pane.get_content_columns()),
+ )
+ })
+ .unwrap_or((0, 0));
+
+ Some(Size {
+ rows: fit.embedded_content_size.rows + tab_bar_rows + frame_rows,
+ cols: fit.embedded_content_size.cols + tab_bar_cols + frame_cols,
+ })
+ }
+
+ pub(crate) fn set_fit(
+ &mut self,
+ client_id: ClientId,
+ tab_id: usize,
+ pane_id: PaneId,
+ embedded_content_size: Size,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) {
+ if let Some(existing_fit) = self.fit_override_for_tab.get_mut(&tab_id) {
+ existing_fit.embedded_content_size = embedded_content_size;
+ existing_fit.owning_client = client_id;
+ existing_fit.fullscreened_pane = pane_id;
+ return;
+ }
+
+ let pane_was_fullscreen_before_fit = tabs
+ .get(&tab_id)
+ .map(|tab| tab.is_fullscreen_active() && tab.fullscreen_pane_id() == Some(pane_id))
+ .unwrap_or(false);
+ if !pane_was_fullscreen_before_fit {
+ Self::toggle_fullscreen(tabs, tab_id, pane_id);
+ }
+ self.fit_override_for_tab.insert(
+ tab_id,
+ FitOverride {
+ owning_client: client_id,
+ fullscreened_pane: pane_id,
+ embedded_content_size,
+ pane_was_fullscreen_before_fit,
+ },
+ );
+ }
+
+ pub(crate) fn clear_fit_owned_by(
+ &mut self,
+ client_id: ClientId,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) -> Option<usize> {
+ let tab_id = self
+ .fit_override_for_tab
+ .iter()
+ .find(|(_, fit)| fit.owning_client == client_id)
+ .map(|(&tab_id, _)| tab_id)?;
+ let fit = self.fit_override_for_tab.remove(&tab_id)?;
+ if !fit.pane_was_fullscreen_before_fit {
+ Self::toggle_fullscreen(tabs, tab_id, fit.fullscreened_pane);
+ }
+ Some(tab_id)
+ }
+
+ pub(crate) fn clear_fit_for_pane(&mut self, pane_id: PaneId) -> Option<usize> {
+ let tab_id = self
+ .fit_override_for_tab
+ .iter()
+ .find(|(_, fit)| fit.fullscreened_pane == pane_id)
+ .map(|(&tab_id, _)| tab_id)?;
+ self.fit_override_for_tab.remove(&tab_id);
+ Some(tab_id)
+ }
+
+ pub(crate) fn remove_fit_for_tab(&mut self, tab_id: usize) {
+ self.fit_override_for_tab.remove(&tab_id);
+ }
+
+ pub(crate) fn clear_fits_owned_by(
+ &mut self,
+ client_id: ClientId,
+ tabs: &mut BTreeMap<usize, Tab>,
+ ) -> Vec<usize> {
+ let owned_fits: Vec<(usize, FitOverride)> = self
+ .fit_override_for_tab
+ .iter()
+ .filter(|(_, fit)| fit.owning_client == client_id)
+ .map(|(&tab_id, fit)| (tab_id, *fit))
+ .collect();
+ let mut tabs_to_recompute = Vec::with_capacity(owned_fits.len());
+ for (tab_id, fit) in owned_fits {
+ self.fit_override_for_tab.remove(&tab_id);
+ if !fit.pane_was_fullscreen_before_fit {
+ Self::toggle_fullscreen(tabs, tab_id, fit.fullscreened_pane);
+ }
+ tabs_to_recompute.push(tab_id);
+ }
+ tabs_to_recompute
+ }
+
+ fn toggle_fullscreen(tabs: &mut BTreeMap<usize, Tab>, tab_id: usize, pane_id: PaneId) {
+ if let Some(tab) = tabs.get_mut(&tab_id) {
+ if tab.has_pane_with_pid(&pane_id) {
+ tab.toggle_pane_fullscreen(pane_id);
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ShadowFocusOutcome {
+ NewlyApplied,
+ AlreadyApplied,
+}
+
+#[cfg(test)]
+impl MobileState {
+ pub(crate) fn previous_tab(&self, client_id: ClientId) -> Option<usize> {
+ self.tab_before_mobile_for_client.get(&client_id).copied()
+ }
+
+ pub(crate) fn fit_count(&self) -> usize {
+ self.fit_override_for_tab.len()
+ }
+
+ pub(crate) fn fit_owner(&self, tab_id: usize) -> Option<ClientId> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.owning_client)
+ }
+
+ pub(crate) fn fit_pane(&self, tab_id: usize) -> Option<PaneId> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.fullscreened_pane)
+ }
+
+ pub(crate) fn fit_embedded_size(&self, tab_id: usize) -> Option<Size> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.embedded_content_size)
+ }
+
+ pub(crate) fn fit_pane_was_fullscreen_before(&self, tab_id: usize) -> Option<bool> {
+ self.fit_override_for_tab
+ .get(&tab_id)
+ .map(|fit| fit.pane_was_fullscreen_before_fit)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CLIENT_A: ClientId = 1;
+ const CLIENT_B: ClientId = 2;
+
+ fn embedded_size() -> Size {
+ Size { rows: 24, cols: 80 }
+ }
+
+ fn no_tabs() -> BTreeMap<usize, Tab> {
+ BTreeMap::new()
+ }
+
+ fn settled_size() -> Size {
+ Size { rows: 25, cols: 31 }
+ }
+
+ #[test]
+ fn try_reveal_is_false_for_an_ungated_client() {
+ let mut gate = MobileRenderGate::default();
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ }
+
+ #[test]
+ fn web_client_is_not_revealed_before_size_settles() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ // Paint arrived but no settled size has been reported yet.
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ assert!(gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn web_client_is_not_revealed_without_a_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ assert!(gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn web_client_is_not_revealed_when_reported_size_differs_from_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ let stale = Size { rows: 38, cols: 46 };
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(stale)));
+ assert!(gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn web_client_is_revealed_on_settled_matching_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ assert!(gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ assert!(!gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn non_web_client_is_revealed_on_any_paint() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, embedded_size());
+ // No settled size, mismatched reported size — still revealed for non-web.
+ assert!(gate.try_reveal(CLIENT_A, false, None));
+ assert!(!gate.is_gated(CLIENT_A));
+ }
+
+ #[test]
+ fn blank_gated_clients_replaces_only_gated_output() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ let mut output = HashMap::new();
+ output.insert(CLIENT_A, "real-a".to_string());
+ output.insert(CLIENT_B, "real-b".to_string());
+ gate.blank_gated_clients(&mut output);
+ assert_eq!(output.get(&CLIENT_A).unwrap(), CLEAR_SCREEN);
+ assert_eq!(output.get(&CLIENT_B).unwrap(), "real-b");
+ }
+
+ #[test]
+ fn ungate_clears_settled_and_paint_state() {
+ let mut gate = MobileRenderGate::default();
+ gate.gate(CLIENT_A);
+ gate.record_settled_size(CLIENT_A);
+ gate.record_paint_size(CLIENT_A, settled_size());
+ gate.ungate(CLIENT_A);
+ assert!(!gate.is_gated(CLIENT_A));
+ // A fresh gate must not inherit the prior settled/paint state.
+ gate.gate(CLIENT_A);
+ assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
+ }
+
+ #[test]
+ fn client_is_not_in_mobile_mode_by_default() {
+ let state = MobileState::default();
+ assert!(!state.is_in_mobile_mode(CLIENT_A));
+ assert_eq!(state.mobile_tab_count(), 0);
+ assert!(state.mobile_tab_ids().is_empty());
+ }
+
+ #[test]
+ fn registering_a_tab_puts_the_client_in_mobile_mode() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ assert!(state.is_in_mobile_mode(CLIENT_A));
+ assert_eq!(state.mobile_tab_count(), 1);
+ assert!(state.mobile_tab_ids().contains(&7));
+ }
+
+ #[test]
+ fn mobile_tab_ids_collects_every_clients_tab() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.register_tab(CLIENT_B, 9);
+ let ids = state.mobile_tab_ids();
+ assert!(ids.contains(&7));
+ assert!(ids.contains(&9));
+ assert_eq!(ids.len(), 2);
+ }
+
+ #[test]
+ fn begin_exit_returns_the_mobile_and_previous_tabs() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.set_previous_tab(CLIENT_A, Some(3));
+ assert_eq!(state.begin_exit(CLIENT_A), Some((7, Some(3))));
+ }
+
+ #[test]
+ fn set_previous_tab_ignores_none() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.set_previous_tab(CLIENT_A, None);
+ assert_eq!(state.begin_exit(CLIENT_A), Some((7, None)));
+ }
+
+ #[test]
+ fn begin_exit_returns_none_when_not_in_mobile_mode() {
+ let mut state = MobileState::default();
+ assert_eq!(state.begin_exit(CLIENT_A), None);
+ }
+
+ #[test]
+ fn begin_exit_clears_membership_and_auto_entry() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.mark_auto_entered(CLIENT_A);
+ state.set_previous_tab(CLIENT_A, Some(3));
+
+ state.begin_exit(CLIENT_A);
+
+ assert!(!state.is_in_mobile_mode(CLIENT_A));
+ assert!(!state.was_auto_entered(CLIENT_A));
+ assert_eq!(state.begin_exit(CLIENT_A), None);
+ }
+
+ #[test]
+ fn forget_client_drops_all_per_client_bookkeeping() {
+ let mut state = MobileState::default();
+ state.register_tab(CLIENT_A, 7);
+ state.set_previous_tab(CLIENT_A, Some(3));
+ state.mark_auto_entered(CLIENT_A);
+
+ state.forget_client(CLIENT_A);
+
+ assert!(!state.is_in_mobile_mode(CLIENT_A));
+ assert!(!state.was_auto_entered(CLIENT_A));
+ }
+
+ #[test]
+ fn auto_entered_marker_round_trips() {
+ let mut state = MobileState::default();
+ assert!(!state.was_auto_entered(CLIENT_A));
+ state.mark_auto_entered(CLIENT_A);
+ assert!(state.was_auto_entered(CLIENT_A));
+ }
+
+ #[test]
+ fn mobile_tab_layout_is_a_borderless_plugin() {
+ let layout = MobileState::mobile_tab_layout().expect("layout builds");
+ assert_eq!(layout.borderless, Some(true));
+ assert!(matches!(layout.run, Some(Run::Plugin(_))));
+ }
+
+ #[test]
+ fn no_fit_override_by_default() {
+ let state = MobileState::default();
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn set_fit_records_an_override() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert!(state.has_fit(5));
+ }
+
+ #[test]
+ fn refitting_a_tab_reassigns_ownership() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ state.set_fit(
+ CLIENT_B,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ assert_eq!(state.clear_fit_owned_by(CLIENT_A, &mut tabs), None);
+ assert_eq!(state.clear_fit_owned_by(CLIENT_B, &mut tabs), Some(5));
+ }
+
+ #[test]
+ fn compute_fit_size_is_none_without_a_target_tab() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert_eq!(state.compute_fit_size(5, &tabs), None);
+ }
+
+ #[test]
+ fn compute_fit_size_is_none_without_a_fit() {
+ let state = MobileState::default();
+ let tabs = no_tabs();
+ assert_eq!(state.compute_fit_size(5, &tabs), None);
+ }
+
+ #[test]
+ fn clear_fit_owned_by_removes_and_returns_the_tab() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ assert_eq!(state.clear_fit_owned_by(CLIENT_A, &mut tabs), Some(5));
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fit_owned_by_is_none_when_client_owns_nothing() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert_eq!(state.clear_fit_owned_by(CLIENT_B, &mut tabs), None);
+ assert!(state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fit_for_pane_matches_by_fullscreened_pane() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ assert_eq!(state.clear_fit_for_pane(PaneId::Terminal(11)), Some(5));
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fit_for_pane_is_none_when_no_fit_targets_it() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ assert_eq!(state.clear_fit_for_pane(PaneId::Terminal(99)), None);
+ assert!(state.has_fit(5));
+ }
+
+ #[test]
+ fn remove_fit_for_tab_drops_the_override() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 5,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ state.remove_fit_for_tab(5);
+ assert!(!state.has_fit(5));
+ }
+
+ #[test]
+ fn clear_fits_owned_by_removes_only_the_clients_fits() {
+ let mut state = MobileState::default();
+ let mut tabs = no_tabs();
+ state.set_fit(
+ CLIENT_A,
+ 1,
+ PaneId::Terminal(11),
+ embedded_size(),
+ &mut tabs,
+ );
+ state.set_fit(
+ CLIENT_A,
+ 2,
+ PaneId::Terminal(12),
+ embedded_size(),
+ &mut tabs,
+ );
+ state.set_fit(
+ CLIENT_B,
+ 3,
+ PaneId::Terminal(13),
+ embedded_size(),
+ &mut tabs,
+ );
+
+ let mut cleared = state.clear_fits_owned_by(CLIENT_A, &mut tabs);
+ cleared.sort_unstable();
+
+ assert_eq!(cleared, vec![1, 2]);
+ assert!(!state.has_fit(1));
+ assert!(!state.has_fit(2));
+ assert!(state.has_fit(3));
+ }
+}
diff --git a/zellij-server/src/panes/active_panes.rs b/zellij-server/src/panes/active_panes.rs
index b0b09b06..01ce65aa 100644
--- a/zellij-server/src/panes/active_panes.rs
+++ b/zellij-server/src/panes/active_panes.rs
@@ -1,11 +1,12 @@
use crate::tab::Pane;
use crate::{os_input_output::ServerOsApi, panes::PaneId, ClientId};
-use std::collections::{BTreeMap, HashMap};
+use std::collections::{BTreeMap, HashMap, HashSet};
#[derive(Clone)]
pub struct ActivePanes {
active_panes: HashMap<ClientId, PaneId>,
+ shadow_clients: HashSet<ClientId>,
os_api: Box<dyn ServerOsApi>,
}
@@ -20,9 +21,20 @@ impl ActivePanes {
let os_api = os_api.clone();
ActivePanes {
active_panes: HashMap::new(),
+ shadow_clients: HashSet::new(),
os_api,
}
}
+ pub fn is_shadow_client(&self, client_id: &ClientId) -> bool {
+ self.shadow_clients.contains(client_id)
+ }
+ pub fn iter_shadow_clients(&self) -> impl Iterator<Item = &ClientId> {
+ self.shadow_clients.iter()
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.shadow_clients.contains(&client_id)
+ && self.active_panes.get(&client_id) == Some(&pane_id)
+ }
pub fn get(&self, client_id: &ClientId) -> Option<&PaneId> {
self.active_panes.get(client_id)
}
@@ -32,15 +44,25 @@ impl ActivePanes {
pane_id: PaneId,
panes: &mut BTreeMap<PaneId, Box<dyn Pane>>,
) {
+ self.shadow_clients.remove(&client_id);
self.unfocus_pane_for_client(client_id, panes);
self.active_panes.insert(client_id, pane_id);
self.focus_pane(pane_id, panes);
}
+ pub fn insert_silent(&mut self, client_id: ClientId, pane_id: PaneId) {
+ self.active_panes.insert(client_id, pane_id);
+ self.shadow_clients.insert(client_id);
+ }
+ pub fn remove_silent(&mut self, client_id: &ClientId) -> Option<PaneId> {
+ self.shadow_clients.remove(client_id);
+ self.active_panes.remove(client_id)
+ }
pub fn clear(&mut self, panes: &mut BTreeMap<PaneId, Box<dyn Pane>>) {
for pane_id in self.active_panes.values() {
self.unfocus_pane(*pane_id, panes);
}
self.active_panes.clear();
+ self.shadow_clients.clear();
}
pub fn is_empty(&self) -> bool {
self.active_panes.is_empty()
@@ -59,6 +81,7 @@ impl ActivePanes {
if let Some(pane_id_to_unfocus) = self.active_panes.get(&client_id) {
self.unfocus_pane(*pane_id_to_unfocus, panes);
}
+ self.shadow_clients.remove(client_id);
self.active_panes.remove(client_id)
}
pub fn unfocus_all_panes(&self, panes: &mut BTreeMap<PaneId, Box<dyn Pane>>) {
diff --git a/zellij-server/src/panes/floating_panes/mod.rs b/zellij-server/src/panes/floating_panes/mod.rs
index ba7b61c5..438e77fe 100644
--- a/zellij-server/src/panes/floating_panes/mod.rs
+++ b/zellij-server/src/panes/floating_panes/mod.rs
@@ -258,6 +258,21 @@ impl FloatingPanes {
pub fn panes_contain(&self, pane_id: &PaneId) -> bool {
self.panes.contains_key(pane_id)
}
+ pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) {
+ self.active_panes.insert_silent(client_id, pane_id);
+ }
+ pub fn clear_shadow_focus(&mut self, client_id: ClientId) -> Option<PaneId> {
+ self.active_panes.remove_silent(&client_id)
+ }
+ pub fn is_shadow_focus_client(&self, client_id: &ClientId) -> bool {
+ self.active_panes.is_shadow_client(client_id)
+ }
+ pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
+ self.active_panes.iter_shadow_clients().copied().collect()
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.active_panes.has_shadow_focus_on(client_id, pane_id)
+ }
pub fn find_room_for_new_pane(&mut self) -> Option<PaneGeom> {
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
@@ -454,7 +469,11 @@ impl FloatingPanes {
let mut active_panes = active_panes.clone();
let multiple_users_exist_in_session =
{ self.connected_clients_in_app.borrow().len() > 1 };
- active_panes.retain(|c_id, _| self.connected_clients.borrow().contains(c_id));
+ let connected = self.connected_clients.borrow();
+ active_panes.retain(|c_id, _| {
+ connected.contains(c_id) || self.active_panes.is_shadow_client(c_id)
+ });
+ drop(connected);
let pane_is_selectable = pane.selectable();
let show_help_text = active_panes.iter().any(|(client_id, pane_id)| {
pane_id == &pane.pid() && help_text_visible.get(client_id).copied().unwrap_or(false)
@@ -903,8 +922,14 @@ impl FloatingPanes {
.last()
.map(|(pane_id, _pane)| **pane_id);
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for (client_id, active_pane_id) in active_panes {
if active_pane_id == pane_id {
+ if !connected_clients.contains(&client_id) {
+ self.active_panes.remove_silent(&client_id);
+ continue;
+ }
match next_active_pane_id {
Some(next_active_pane_id) => {
self.active_panes
@@ -1185,10 +1210,17 @@ impl FloatingPanes {
.filter(|(_cid, pid)| **pid == from_pane_id)
.map(|(cid, _pid)| *cid)
.collect();
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for client_id in clients_in_pane {
- self.active_panes.remove(&client_id, &mut self.panes);
- self.active_panes
- .insert(client_id, to_pane_id, &mut self.panes);
+ if connected_clients.contains(&client_id) {
+ self.active_panes.remove(&client_id, &mut self.panes);
+ self.active_panes
+ .insert(client_id, to_pane_id, &mut self.panes);
+ } else {
+ self.active_panes.remove_silent(&client_id);
+ self.active_panes.insert_silent(client_id, to_pane_id);
+ }
}
}
pub fn reapply_pane_focus(&mut self) {
diff --git a/zellij-server/src/panes/grid.rs b/zellij-server/src/panes/grid.rs
index 9f6134cb..0a659849 100644
--- a/zellij-server/src/panes/grid.rs
+++ b/zellij-server/src/panes/grid.rs
@@ -3325,7 +3325,7 @@ impl Grid {
let s: String = (&row.columns).into_iter().map(|x| x.character).collect();
viewport.push(s);
}
- if get_full_scrollback {
+ let mut contents = if get_full_scrollback {
let mut lines_above_viewport: Vec<String> = Vec::with_capacity(self.lines_above.len());
for row in &self.lines_above {
let s: String = (&row.columns).into_iter().map(|x| x.character).collect();
@@ -3352,7 +3352,9 @@ impl Grid {
)
} else {
PaneContents::new(viewport, self.selection.start, self.selection.end)
- }
+ };
+ contents.cursor = self.visible_cursor_in_viewport();
+ contents
}
pub fn pane_contents_with_ansi(
&self,
@@ -3394,7 +3396,7 @@ impl Grid {
viewport.push(extract_row_with_ansi(row));
}
- if get_full_scrollback {
+ let mut contents = if get_full_scrollback {
let mut lines_above_viewport: Vec<String> = Vec::with_capacity(self.lines_above.len());
for row in &self.lines_above {
lines_above_viewport.push(extract_row_with_ansi(row));
@@ -3418,7 +3420,14 @@ impl Grid {
)
} else {
PaneContents::new(viewport, self.selection.start, self.selection.end)
- }
+ };
+ contents.cursor = self.visible_cursor_in_viewport();
+ contents
+ }
+
+ fn visible_cursor_in_viewport(&self) -> Option<(usize, usize)> {
+ self.cursor_coordinates()
+ .and_then(|(x, y, is_visible)| if is_visible { Some((x, y)) } else { None })
}
}
diff --git a/zellij-server/src/panes/plugin_pane.rs b/zellij-server/src/panes/plugin_pane.rs
index 10e7186c..6f2d5980 100644
--- a/zellij-server/src/panes/plugin_pane.rs
+++ b/zellij-server/src/panes/plugin_pane.rs
@@ -595,6 +595,24 @@ impl Pane for PluginPane {
)]))
.unwrap();
}
+ fn scroll_left(&mut self, count: usize, client_id: ClientId) {
+ self.send_plugin_instructions
+ .send(PluginInstruction::Update(vec![(
+ Some(self.pid),
+ Some(client_id),
+ Event::Mouse(Mouse::ScrollLeft(count)),
+ )]))
+ .unwrap();
+ }
+ fn scroll_right(&mut self, count: usize, client_id: ClientId) {
+ self.send_plugin_instructions
+ .send(PluginInstruction::Update(vec![(
+ Some(self.pid),
+ Some(client_id),
+ Event::Mouse(Mouse::ScrollRight(count)),
+ )]))
+ .unwrap();
+ }
fn clear_screen(&mut self) {
// do nothing
}
diff --git a/zellij-server/src/panes/tiled_panes/mod.rs b/zellij-server/src/panes/tiled_panes/mod.rs
index afb9608c..ff68f750 100644
--- a/zellij-server/src/panes/tiled_panes/mod.rs
+++ b/zellij-server/src/panes/tiled_panes/mod.rs
@@ -1032,7 +1032,10 @@ impl TiledPanes {
} else {
self.active_panes
.iter()
- .filter(|(client_id, _pane_id)| connected_clients.contains(client_id))
+ .filter(|(client_id, _pane_id)| {
+ connected_clients.contains(client_id)
+ || self.active_panes.is_shadow_client(client_id)
+ })
.map(|(client_id, pane_id)| (*client_id, *pane_id))
.collect()
};
@@ -2443,10 +2446,19 @@ impl TiledPanes {
{
self.expand_pane_in_stack(next_active_pane_id);
}
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for (client_id, active_pane_id) in active_panes {
if active_pane_id == pane_id {
- self.active_panes
- .insert(client_id, next_active_pane_id, &mut self.panes);
+ if connected_clients.contains(&client_id) {
+ self.active_panes.insert(
+ client_id,
+ next_active_pane_id,
+ &mut self.panes,
+ );
+ } else {
+ self.active_panes.remove_silent(&client_id);
+ }
}
}
},
@@ -2667,6 +2679,21 @@ impl TiledPanes {
pub fn set_active_panes(&mut self, active_panes: ActivePanes) {
self.active_panes = active_panes;
}
+ pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) {
+ self.active_panes.insert_silent(client_id, pane_id);
+ }
+ pub fn clear_shadow_focus(&mut self, client_id: ClientId) -> Option<PaneId> {
+ self.active_panes.remove_silent(&client_id)
+ }
+ pub fn is_shadow_focus_client(&self, client_id: &ClientId) -> bool {
+ self.active_panes.is_shadow_client(client_id)
+ }
+ pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
+ self.active_panes.iter_shadow_clients().copied().collect()
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.active_panes.has_shadow_focus_on(client_id, pane_id)
+ }
pub fn move_client_focus_to_existing_panes(&mut self) {
let existing_pane_ids: Vec<PaneId> = self.panes.keys().copied().collect();
let nonexisting_panes_that_are_focused = self
@@ -2686,10 +2713,17 @@ impl TiledPanes {
.filter(|(_cid, pid)| **pid == from_pane_id)
.map(|(cid, _pid)| *cid)
.collect();
+ let connected_clients: HashSet<ClientId> =
+ self.connected_clients.borrow().iter().copied().collect();
for client_id in clients_in_pane {
- self.active_panes.remove(&client_id, &mut self.panes);
- self.active_panes
- .insert(client_id, to_pane_id, &mut self.panes);
+ if connected_clients.contains(&client_id) {
+ self.active_panes.remove(&client_id, &mut self.panes);
+ self.active_panes
+ .insert(client_id, to_pane_id, &mut self.panes);
+ } else {
+ self.active_panes.remove_silent(&client_id);
+ self.active_panes.insert_silent(client_id, to_pane_id);
+ }
}
}
fn reset_boundaries(&mut self) {
diff --git a/zellij-server/src/plugins/mod.rs b/zellij-server/src/plugins/mod.rs
index 69c80e77..5cc5835b 100644
--- a/zellij-server/src/plugins/mod.rs
+++ b/zellij-server/src/plugins/mod.rs
@@ -113,6 +113,8 @@ pub enum PluginInstruction {
plugin_ids: Vec<PluginId>,
done_receiving_permissions: bool,
},
+ HoldMobileRender(ClientId),
+ ReleaseMobileRender(ClientId),
ApplyCachedWorkerMessages(PluginId),
PostMessagesToPluginWorker(
PluginId,
@@ -239,6 +241,8 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::NewTab(..) => PluginContext::NewTab,
PluginInstruction::OverrideLayout(..) => PluginContext::OverrideLayout,
PluginInstruction::ApplyCachedEvents { .. } => PluginContext::ApplyCachedEvents,
+ PluginInstruction::HoldMobileRender(..) => PluginContext::HoldMobileRender,
+ PluginInstruction::ReleaseMobileRender(..) => PluginContext::ReleaseMobileRender,
PluginInstruction::ApplyCachedWorkerMessages(..) => {
PluginContext::ApplyCachedWorkerMessages
},
@@ -732,6 +736,12 @@ pub(crate) fn plugin_thread_main(
PluginInstruction::ApplyCachedWorkerMessages(plugin_id) => {
wasm_bridge.apply_cached_worker_messages(plugin_id)?;
},
+ PluginInstruction::HoldMobileRender(client_id) => {
+ wasm_bridge.hold_mobile_render(client_id);
+ },
+ PluginInstruction::ReleaseMobileRender(client_id) => {
+ wasm_bridge.release_mobile_render(client_id, shutdown_send.clone())?;
+ },
PluginInstruction::PostMessagesToPluginWorker(
plugin_id,
client_id,
diff --git a/zellij-server/src/plugins/wasm_bridge.rs b/zellij-server/src/plugins/wasm_bridge.rs
index aaaf1971..13e5ff82 100644
--- a/zellij-server/src/plugins/wasm_bridge.rs
+++ b/zellij-server/src/plugins/wasm_bridge.rs
@@ -177,6 +177,7 @@ pub struct WasmBridge {
plugin_ids_waiting_for_permission_request: HashSet<PluginId>,
cached_events_for_pending_plugins: HashMap<PluginId, Vec<EventOrPipeMessage>>,
cached_resizes_for_pending_plugins: HashMap<PluginId, (usize, usize)>, // (rows, columns)
+ clients_holding_mobile_render_until_size_settled: HashSet<ClientId>,
cached_worker_messages: HashMap<PluginId, Vec<(ClientId, String, String, String)>>, // Vec<clientid,
// worker_name,
// message,
@@ -242,6 +243,7 @@ impl WasmBridge {
path_to_default_shell,
watcher,
next_plugin_id: 0,
+ clients_holding_mobile_render_until_size_settled: HashSet::new(),
cached_events_for_pending_plugins: HashMap::new(),
plugin_ids_waiting_for_permission_request: HashSet::new(),
cached_resizes_for_pending_plugins: HashMap::new(),
@@ -1214,6 +1216,69 @@ impl WasmBridge {
}
Ok(())
}
+ fn plugin_render_is_held_until_size_settled(&self, plugin_id: PluginId) -> bool {
+ let clients_of_plugin: Vec<ClientId> = self
+ .plugin_map
+ .lock()
+ .unwrap()
+ .running_plugins()
+ .iter()
+ .filter(|(pid, _client_id, _running_plugin)| *pid == plugin_id)
+ .map(|(_pid, client_id, _running_plugin)| *client_id)
+ .collect();
+ !clients_of_plugin.is_empty()
+ && clients_of_plugin.iter().all(|client_id| {
+ self.clients_holding_mobile_render_until_size_settled
+ .contains(client_id)
+ })
+ }
+
+ pub fn hold_mobile_render(&mut self, client_id: ClientId) {
+ self.clients_holding_mobile_render_until_size_settled
+ .insert(client_id);
+ }
+
+ pub fn release_mobile_render(
+ &mut self,
+ client_id: ClientId,
+ shutdown_sender: Sender<()>,
+ ) -> Result<()> {
+ if !self
+ .clients_holding_mobile_render_until_size_settled
+ .remove(&client_id)
+ {
+ return Ok(());
+ }
+ let plugins_held_for_client: Vec<PluginId> = self
+ .plugin_map
+ .lock()
+ .unwrap()
+ .running_plugins()
+ .iter()
+ .filter(|(pid, cid, _)| {
+ *cid == client_id
+ && (self.cached_events_for_pending_plugins.contains_key(pid)
+ || self.cached_resizes_for_pending_plugins.contains_key(pid))
+ })
+ .map(|(pid, _cid, _)| *pid)
+ .collect();
+ for plugin_id in &plugins_held_for_client {
+ self.drain_pending_plugin_resize_before_events(*plugin_id, shutdown_sender.clone())?;
+ }
+ Ok(())
+ }
+
+ fn drain_pending_plugin_resize_before_events(
+ &mut self,
+ plugin_id: PluginId,
+ shutdown_sender: Sender<()>,
+ ) -> Result<()> {
+ if let Some((rows, columns)) = self.cached_resizes_for_pending_plugins.remove(&plugin_id) {
+ self.resize_plugin(plugin_id, columns, rows, shutdown_sender.clone())?;
+ }
+ self.apply_cached_events(vec![plugin_id], true, shutdown_sender)
+ }
+
pub fn apply_cached_events(
&mut self,
plugin_ids: Vec<PluginId>,
@@ -1222,6 +1287,9 @@ impl WasmBridge {
) -> Result<()> {
let mut applied_plugin_paths = HashSet::new();
for plugin_id in plugin_ids {
+ if self.plugin_render_is_held_until_size_settled(plugin_id) {
+ continue;
+ }
if !done_receiving_permissions
&& self
.plugin_ids_waiting_for_permission_request
@@ -1247,6 +1315,8 @@ impl WasmBridge {
Ok(())
}
pub fn remove_client(&mut self, client_id: ClientId) {
+ self.clients_holding_mobile_render_until_size_settled
+ .remove(&client_id);
self.connected_clients
.lock()
.unwrap()
@@ -1287,6 +1357,7 @@ impl WasmBridge {
.map(|prev_pane_contents| {
prev_pane_contents.viewport != new_pane_contents.viewport
|| prev_pane_contents.selected_text != new_pane_contents.selected_text
+ || prev_pane_contents.cursor != new_pane_contents.cursor
})
.unwrap_or(true);
if has_changed {
@@ -1305,7 +1376,6 @@ impl WasmBridge {
pane_render_report: PaneRenderReport,
shutdown_sender: Sender<()>,
) -> Result<()> {
- // Plain content (existing behavior)
let changed_panes_per_client = self.get_changed_panes_per_client(
&pane_render_report.all_pane_contents,
self.previous_pane_render_report
@@ -1317,7 +1387,6 @@ impl WasmBridge {
self.update_plugins(updates, shutdown_sender.clone())?;
}
- // ANSI content (new behavior)
if !pane_render_report.all_pane_contents_with_ansi.is_empty() {
let changed_ansi_panes_per_client = self.get_changed_panes_per_client(
&pane_render_report.all_pane_contents_with_ansi,
@@ -2100,6 +2169,7 @@ fn check_event_permission(
| Event::AvailableLayoutInfo(..)
| Event::PluginConfigurationChanged(..)
| Event::HighlightClicked { .. }
+ | Event::SoftKeyboardVisibilityChanged(..)
| Event::InputReceived => PermissionType::ReadApplicationState,
Event::WebServerStatus(..) => PermissionType::StartWebServer,
Event::PaneRenderReport(..) | Event::PaneRenderReportWithAnsi(..) => {
diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs
index da59c6ef..ce53592e 100644
--- a/zellij-server/src/plugins/zellij_exports.rs
+++ b/zellij-server/src/plugins/zellij_exports.rs
@@ -27,10 +27,10 @@ use zellij_utils::data::{
FocusOrCreateTabResponse, GetFocusedPaneInfoResponse, GetPaneCwdResponse, GetPanePidResponse,
GetPaneRunningCommandResponse, HttpVerb, KeyWithModifier, KillSessionsResponse, LayoutInfo,
LayoutMetadata, LayoutParsingError, MessageToPlugin, NewPanePlacement, NewTabResponse,
- OpenCommandPaneBackgroundResponse, OpenCommandPaneFloatingNearPluginResponse,
- OpenCommandPaneFloatingResponse, OpenCommandPaneInPlaceOfPaneIdResponse,
- OpenCommandPaneInPlaceOfPluginResponse, OpenCommandPaneInPlaceResponse,
- OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
+ NewTabUnfocusedResponse, NewTiledPaneInTabResponse, OpenCommandPaneBackgroundResponse,
+ OpenCommandPaneFloatingNearPluginResponse, OpenCommandPaneFloatingResponse,
+ OpenCommandPaneInPlaceOfPaneIdResponse, OpenCommandPaneInPlaceOfPluginResponse,
+ OpenCommandPaneInPlaceResponse, OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
OpenEditPaneInPlaceOfPaneIdResponse, OpenFileFloatingNearPluginResponse,
OpenFileFloatingResponse, OpenFileInPlaceOfPluginResponse, OpenFileInPlaceResponse,
OpenFileNearPluginResponse, OpenFileResponse, OpenPaneInNewTabResponse,
@@ -53,6 +53,7 @@ use zellij_utils::web_server_commands::shutdown_all_webserver_instances;
use crate::{panes::PaneId, screen::ScreenInstruction};
use kdl::KdlDocument;
+use zellij_utils::pane_size::Size;
use prost::Message;
use zellij_utils::{
@@ -86,7 +87,8 @@ use zellij_utils::{
ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse,
ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetSessionListResponse,
ProtobufGetTabInfoResponse, ProtobufHideFloatingPanesResponse,
- ProtobufKillSessionsResponse, ProtobufNewTabResponse, ProtobufNewTabsResponse,
+ ProtobufKillSessionsResponse, ProtobufNewTabResponse, ProtobufNewTabUnfocusedResponse,
+ ProtobufNewTabsResponse, ProtobufNewTiledPaneInTabResponse,
ProtobufOpenCommandPaneBackgroundResponse,
ProtobufOpenCommandPaneFloatingNearPluginResponse,
ProtobufOpenCommandPaneFloatingResponse,
@@ -296,6 +298,12 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
context,
} => edit_layout(env, layout_name, context),
PluginCommand::NewTab { name, cwd } => new_tab(env, name, cwd),
+ PluginCommand::NewTabUnfocused { name, cwd } => {
+ new_tab_unfocused(env, name, cwd)
+ },
+ PluginCommand::NewTiledPaneInTab { tab_position } => {
+ new_tiled_pane_in_tab(env, tab_position)
+ },
PluginCommand::GoToNextTab => go_to_next_tab(env),
PluginCommand::GoToPreviousTab => go_to_previous_tab(env),
PluginCommand::Resize(resize_payload) => resize(env, resize_payload),
@@ -447,6 +455,14 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
PluginCommand::WatchFilesystem => watch_filesystem(env),
PluginCommand::ListWindowsVolumes => list_windows_volumes(env),
PluginCommand::GetSessionList => get_session_list(env),
+ PluginCommand::SetSoftKeyboard(on) => set_soft_keyboard(env, on),
+ PluginCommand::SetTabFit { tab_id, fit } => set_tab_fit(
+ env,
+ tab_id,
+ fit.map(|(pane_id, size)| (pane_id.into(), size)),
+ ),
+ PluginCommand::ExitMobileMode => exit_mobile_mode(env),
+ PluginCommand::SetShadowFocus(pane_id) => set_shadow_focus(env, pane_id.into()),
PluginCommand::DumpSessionLayout { tab_index } => {
dump_session_layout(env, tab_index)
},
@@ -2806,6 +2822,62 @@ fn new_tab(env: &PluginEnv, name: Option<String>, cwd: Option<String>) {
.non_fatal();
}
+fn new_tab_unfocused(env: &PluginEnv, name: Option<String>, cwd: Option<String>) {
+ let cwd = cwd.map(|c| translate_plugin_path(env, PathBuf::from(c)));
+ let action = Action::NewTab {
+ tiled_layout: None,
+ floating_layouts: vec![],
+ swap_tiled_layouts: None,
+ swap_floating_layouts: None,
+ tab_name: name,
+ should_change_focus_to_new_tab: false,
+ cwd,
+ initial_panes: None,
+ first_pane_unblock_condition: None,
+ };
+ let error_msg = || format!("Failed to open new tab (unfocused)");
+ let result = apply_action!(action, error_msg, env);
+
+ let tab_id: NewTabUnfocusedResponse = result.and_then(|r| r.affected_tab_id);
+
+ let response = ProtobufNewTabUnfocusedResponse::from(tab_id);
+ wasi_write_object(env, &response.encode_to_vec())
+ .with_context(|| format!("failed to write new_tab_unfocused response"))
+ .non_fatal();
+}
+
+fn new_tiled_pane_in_tab(env: &PluginEnv, tab_position: usize) {
+ let error_msg = || format!("failed to open new tiled pane in tab {}", tab_position);
+ let default_shell = env.default_shell.clone().unwrap_or_else(|| {
+ TerminalAction::RunCommand(RunCommand {
+ command: env.path_to_default_shell.clone(),
+ use_terminal_title: true,
+ ..Default::default()
+ })
+ });
+ let run_command_action: Option<RunCommandAction> = match default_shell {
+ TerminalAction::RunCommand(run_command) => Some(run_command.into()),
+ _ => None,
+ };
+ let action = Action::NewTiledPane {
+ direction: None,
+ command: run_command_action,
+ pane_name: None,
+ near_current_pane: false,
+ borderless: None,
+ tab_id: Some(tab_position),
+ };
+ let result = apply_action!(action, error_msg, env);
+
+ let pane_id: NewTiledPaneInTabResponse =
+ result.and_then(|r| r.affected_pane_id).map(|p| p.into());
+
+ let response = ProtobufNewTiledPaneInTabResponse::from(pane_id);
+ wasi_write_object(env, &response.encode_to_vec())
+ .with_context(|| format!("failed to write new_tiled_pane_in_tab response"))
+ .non_fatal();
+}
+
fn go_to_next_tab(env: &PluginEnv) {
let action = Action::GoToNextTab;
let error_msg = || format!("Failed to go to next tab");
@@ -3901,6 +3973,56 @@ fn list_windows_volumes(_env: &PluginEnv) {
log::error!("ListWindowsVolumes is only supported on Windows");
}
+fn set_soft_keyboard(env: &PluginEnv, on: bool) {
+ env.senders
+ .send_to_screen(ScreenInstruction::SetSoftKeyboard {
+ client_id: env.client_id,
+ on,
+ })
+ .with_context(|| {
+ format!(
+ "failed to dispatch SetSoftKeyboard for plugin {}",
+ env.plugin_id
+ )
+ })
+ .non_fatal();
+}
+
+fn set_shadow_focus(env: &PluginEnv, pane_id: PaneId) {
+ env.senders
+ .send_to_screen(ScreenInstruction::SetShadowFocus(env.client_id, pane_id))
+ .with_context(|| {
+ format!(
+ "failed to dispatch SetShadowFocus for plugin {}",
+ env.plugin_id
+ )
+ })
+ .non_fatal();
+}
+
+fn set_tab_fit(env: &PluginEnv, tab_id: usize, fit: Option<(PaneId, Size)>) {
+ env.senders
+ .send_to_screen(ScreenInstruction::SetTabFit {
+ client_id: env.client_id,
+ tab_id,
+ fit,
+ })
+ .with_context(|| format!("failed to dispatch SetTabFit for plugin {}", env.plugin_id))
+ .non_fatal();
+}
+
+fn exit_mobile_mode(env: &PluginEnv) {
+ env.senders
+ .send_to_screen(ScreenInstruction::ExitMobileMode(env.client_id, None))
+ .with_context(|| {
+ format!(
+ "failed to dispatch ExitMobileMode for plugin {}",
+ env.plugin_id
+ )
+ })
+ .non_fatal();
+}
+
#[cfg(windows)]
fn list_windows_volumes(env: &PluginEnv) {
let send_plugin_instructions = env.senders.to_plugin.clone();
@@ -5307,6 +5429,7 @@ fn check_command_permission(
| PluginCommand::OpenTerminalInPlaceOfPlugin(..)
| PluginCommand::OpenPluginPaneInNewTab { .. }
| PluginCommand::OpenPluginPaneFloating { .. }
+ | PluginCommand::NewTiledPaneInTab { .. }
| PluginCommand::OpenTerminalPaneInPlaceOfPaneId(..) => {
PermissionType::OpenTerminalsOrPlugins
},
@@ -5331,6 +5454,7 @@ fn check_command_permission(
| PluginCommand::NewTabsWithLayout(..)
| PluginCommand::NewTabsWithLayoutInfo(..)
| PluginCommand::NewTab { .. }
+ | PluginCommand::NewTabUnfocused { .. }
| PluginCommand::GoToNextTab
| PluginCommand::GoToPreviousTab
| PluginCommand::Resize(..)
@@ -5428,7 +5552,10 @@ fn check_command_permission(
| PluginCommand::ShowFloatingPanes { .. }
| PluginCommand::HideFloatingPanes { .. }
| PluginCommand::SetPaneRegexHighlights(..)
- | PluginCommand::ClearPaneHighlights(..) => PermissionType::ChangeApplicationState,
+ | PluginCommand::ClearPaneHighlights(..)
+ | PluginCommand::SetSoftKeyboard(..)
+ | PluginCommand::SetTabFit { .. }
+ | PluginCommand::ExitMobileMode => PermissionType::ChangeApplicationState,
PluginCommand::UnblockCliPipeInput(..)
| PluginCommand::BlockCliPipeInput(..)
| PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes,
diff --git a/zellij-server/src/route.rs b/zellij-server/src/route.rs
index fa359d2c..4a3ffcc9 100644
--- a/zellij-server/src/route.rs
+++ b/zellij-server/src/route.rs
@@ -28,7 +28,7 @@ use zellij_utils::{
actions::{Action, SearchDirection, SearchOption},
command::TerminalAction,
},
- ipc::{ClientToServerMsg, ExitReason, IpcReceiverWithContext, ServerToClientMsg},
+ ipc::{ClientToServerMsg, ExitReason, IpcReceiverWithContext, ResizeCause, ServerToClientMsg},
};
use crate::ClientId;
@@ -1252,6 +1252,14 @@ pub(crate) fn route_action(
.with_context(err_context)?;
},
Action::ToggleMouseMode => {}, // Handled client side
+ Action::ToggleMobileMode => {
+ senders
+ .send_to_screen(ScreenInstruction::ToggleMobileMode(
+ client_id,
+ Some(NotificationEnd::new(completion_tx)),
+ ))
+ .with_context(err_context)?;
+ },
Action::PreviousSwapLayout => {
senders
.send_to_screen(ScreenInstruction::PreviousSwapLayout(
@@ -2195,7 +2203,7 @@ pub(crate) fn route_thread_main(
should_break = true;
}
},
- ClientToServerMsg::TerminalResize { new_size } => {
+ ClientToServerMsg::TerminalResize { new_size, .. } => {
// For watchers: send size to Screen for rendering adjustments, but
// this does not affect the screen size
send_to_screen_or_retry_queue!(
@@ -2359,7 +2367,7 @@ pub(crate) fn route_thread_main(
}
}
},
- ClientToServerMsg::TerminalResize { new_size } => {
+ ClientToServerMsg::TerminalResize { new_size, cause } => {
// Check if this is a watcher or regular client
if is_watcher {
// For watchers: send size to Screen for tracking, don't affect screen size
@@ -2386,6 +2394,51 @@ pub(crate) fn route_thread_main(
client_id, new_size,
))
});
+ if matches!(cause, ResizeCause::Viewport) {
+ let mobile_options =
+ session_data.read().ok().and_then(|guard| {
+ guard.as_ref().map(|s| {
+ let config = s
+ .session_configuration
+ .get_client_configuration(&client_id);
+ (
+ config
+ .options
+ .mobile_layout
+ .unwrap_or_default(),
+ config
+ .options
+ .mobile_threshold_cols
+ .unwrap_or(60),
+ config
+ .options
+ .mobile_threshold_rows
+ .unwrap_or(30),
+ )
+ })
+ });
+ if let Some((mobile_layout, threshold_cols, threshold_rows)) =
+ mobile_options
+ {
+ let _ = senders.as_ref().map(|s| {
+ s.send_to_screen(
+ ScreenInstruction::ReevaluateMobileMode {
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ },
+ )
+ });
+ }
+ } else if matches!(cause, ResizeCause::SizeSettled) {
+ let _ = senders.as_ref().map(|s| {
+ s.send_to_screen(ScreenInstruction::MobileSizeSettled(
+ client_id,
+ ))
+ });
+ }
}
},
ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
@@ -2606,6 +2659,15 @@ pub(crate) fn route_thread_main(
retry_queue
);
},
+ ClientToServerMsg::SoftKeyboardVisibilityChanged { visible } => {
+ if let Some(senders) = senders.as_ref() {
+ let _ = senders.send_to_plugin(PluginInstruction::Update(vec![(
+ None,
+ Some(client_id),
+ Event::SoftKeyboardVisibilityChanged(visible),
+ )]));
+ }
+ },
}
Ok(should_break)
};
diff --git a/zellij-server/src/screen.rs b/zellij-server/src/screen.rs
index 4bf44e34..95c47f5b 100644
--- a/zellij-server/src/screen.rs
+++ b/zellij-server/src/screen.rs
@@ -51,7 +51,7 @@ use zellij_utils::input::command::RunCommand;
use zellij_utils::input::config::Config;
use zellij_utils::input::keybinds::Keybinds;
use zellij_utils::input::mouse::{MouseEvent, MouseEventType};
-use zellij_utils::input::options::Clipboard;
+use zellij_utils::input::options::{Clipboard, MobileLayoutConfiguration};
use zellij_utils::ipc::{ExitReason, ServerToClientMsg};
use zellij_utils::pane_size::{PaneGeom, Size, SizeInPixels};
use zellij_utils::shared::clean_string_from_control_and_linebreak;
@@ -93,6 +93,8 @@ use zellij_utils::{
ipc::{ClientAttributes, PixelDimensions},
};
+use crate::mobile_mode::{MobileRenderGate, MobileState, ShadowFocusOutcome, FIT_RESIZE_MAX_ITERS};
+
/// Parses a namespaced OSC 99 response and extracts the original pane ID
/// and un-namespaced response bytes.
///
@@ -500,8 +502,6 @@ pub enum ScreenInstruction {
completion_tx: Option<NotificationEnd>,
},
TerminalResize(Size),
- /// Update a regular client's known viewport size and recompute the size of
- /// that client's active tab. `(client_id, new_size)`.
RecomputeTabSize(ClientId, Size),
TerminalPixelDimensions(PixelDimensions),
TerminalBackgroundColor(String),
@@ -562,11 +562,14 @@ pub enum ScreenInstruction {
AddClient(
ClientId,
bool, // is_web_client
- Size, // client viewport size — used for per-tab sizing
+ Size, // client viewport size
Option<usize>, // tab position to focus
Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus
),
RemoveClient(ClientId),
+ SuppressRenderUntilMobile(ClientId),
+ MobileSizeSettled(ClientId),
+ ForceMobileUngate(ClientId),
UpdateSearch(Vec<u8>, ClientId, Option<NotificationEnd>),
SearchDown(ClientId, Option<NotificationEnd>),
SearchUp(ClientId, Option<NotificationEnd>),
@@ -775,6 +778,11 @@ pub enum ScreenInstruction {
PageScrollUpInPaneId(PaneId),
PageScrollDownInPaneId(PaneId),
TogglePaneIdFullscreen(PaneId),
+ SetTabFit {
+ client_id: ClientId,
+ tab_id: usize,
+ fit: Option<(PaneId, Size)>,
+ },
TogglePaneEmbedOrEjectForPaneId(PaneId),
CloseTabWithIndex(usize),
BreakPanesToNewTab {
@@ -871,6 +879,21 @@ pub enum ScreenInstruction {
PreviousSwapLayoutWithTabId(usize, Option<NotificationEnd>),
NextSwapLayoutWithTabId(usize, Option<NotificationEnd>),
MoveTabWithTabId(usize, Direction, Option<NotificationEnd>),
+ EnterMobileMode(ClientId, Option<NotificationEnd>),
+ ExitMobileMode(ClientId, Option<NotificationEnd>),
+ ToggleMobileMode(ClientId, Option<NotificationEnd>),
+ ReevaluateMobileMode {
+ client_id: ClientId,
+ new_size: Size,
+ mobile_layout: MobileLayoutConfiguration,
+ threshold_cols: u16,
+ threshold_rows: u16,
+ },
+ SetSoftKeyboard {
+ client_id: ClientId,
+ on: bool,
+ },
+ SetShadowFocus(ClientId, PaneId),
}
impl From<&ScreenInstruction> for ScreenContext {
@@ -1018,6 +1041,11 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::ToggleTab(..) => ScreenContext::ToggleTab,
ScreenInstruction::AddClient(..) => ScreenContext::AddClient,
ScreenInstruction::RemoveClient(..) => ScreenContext::RemoveClient,
+ ScreenInstruction::SuppressRenderUntilMobile(..) => {
+ ScreenContext::SuppressRenderUntilMobile
+ },
+ ScreenInstruction::MobileSizeSettled(..) => ScreenContext::MobileSizeSettled,
+ ScreenInstruction::ForceMobileUngate(..) => ScreenContext::ForceMobileUngate,
ScreenInstruction::UpdateSearch(..) => ScreenContext::UpdateSearch,
ScreenInstruction::SearchDown(..) => ScreenContext::SearchDown,
ScreenInstruction::SearchUp(..) => ScreenContext::SearchUp,
@@ -1105,6 +1133,7 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenInstruction::PageScrollUpInPaneId(..) => ScreenContext::PageScrollUpInPaneId,
ScreenInstruction::PageScrollDownInPaneId(..) => ScreenContext::PageScrollDownInPaneId,
ScreenInstruction::TogglePaneIdFullscreen(..) => ScreenContext::TogglePaneIdFullscreen,
+ ScreenInstruction::SetTabFit { .. } => ScreenContext::SetTabFit,
ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(..) => {
ScreenContext::TogglePaneEmbedOrEjectForPaneId
},
@@ -1228,6 +1257,12 @@ impl From<&ScreenInstruction> for ScreenContext {
ScreenContext::NextSwapLayoutWithTabId
},
ScreenInstruction::MoveTabWithTabId(..) => ScreenContext::MoveTabWithTabId,
+ ScreenInstruction::EnterMobileMode(..) => ScreenContext::EnterMobileMode,
+ ScreenInstruction::ExitMobileMode(..) => ScreenContext::ExitMobileMode,
+ ScreenInstruction::ToggleMobileMode(..) => ScreenContext::ToggleMobileMode,
+ ScreenInstruction::ReevaluateMobileMode { .. } => ScreenContext::ReevaluateMobileMode,
+ ScreenInstruction::SetSoftKeyboard { .. } => ScreenContext::SetSoftKeyboard,
+ ScreenInstruction::SetShadowFocus(..) => ScreenContext::SetShadowFocus,
}
}
}
@@ -1314,7 +1349,6 @@ impl RenderBlocker {
}
}
-/// State information for a watcher client
#[derive(Debug, Clone)]
pub(crate) struct WatcherState {
size: Size,
@@ -1376,7 +1410,6 @@ pub(crate) struct Screen {
connected_clients: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
/// The indices of this [`Screen`]'s active [`Tab`]s.
active_tab_ids: BTreeMap<ClientId, usize>,
- /// Per-regular-client viewport sizes, used to compute per-tab sizing.
client_sizes: HashMap<ClientId, Size>,
global_last_active_tab_id: usize,
tab_history: BTreeMap<ClientId, Vec<usize>>,
@@ -1429,42 +1462,15 @@ pub(crate) struct Screen {
pane_render_subscribers: HashMap<ClientId, PaneRenderSubscription>,
plugins_need_ansi_pane_contents: bool,
background_plugin_subscriptions: HashMap<(PluginId, ClientId), HashSet<EventType>>,
- /// Monotonic counter used to tag each forwarded host-terminal query
- /// with a unique token. 0 is reserved as a sentinel (see
- /// `STARTUP_SENTINEL_TOKEN`); real forwards start at 1.
next_forward_token: u32,
- /// Map of forwarded-query token → the originating pane plus the
- /// raw query bytes. When the client sends back the reply for
- /// `token`, the server looks up the pane here and writes the
- /// reply bytes to its pty. The retained `query_bytes` feed the
- /// cache-fallback synthesis: if the reply comes back empty
- /// (detached session, client crash, host timeout), we use the
- /// cached bg/fg/pixel/palette state to answer in the host's
- /// stead.
pending_forwarded_queries: HashMap<u32, PendingForwardEntry>,
- /// Serialization queue for forwarded queries. Invariant: at most one
- /// forward is in flight to the client at a time (enforced by
- /// `forward_in_flight`). When the reply (or timeout) closes the
- /// active slot, the next queued forward is dispatched.
forward_queue: VecDeque<PendingForward>,
- /// Token of the forward currently in flight to the (single)
- /// connected client, if any. Used to:
- /// * serialize forwarded queries globally (only one at a time);
- /// * gate slot release on `handle_forwarded_reply_from_host` to a
- /// token-equality match, so a late server-side timeout for an
- /// already-answered forward cannot clobber a queued forward that
- /// the real reply just dispatched.
forward_in_flight_token: Option<u32>,
- /// Last-known host terminal color-palette theme mode (CSI 2031 /
- /// DSR 997). `None` until the host first reports. Used both for
- /// auto-theme switching and to dedupe duplicate notifications.
host_terminal_theme_mode: Option<HostTerminalThemeMode>,
- /// Resolved styling to apply when `host_terminal_theme_mode == Dark`.
- /// `None` disables auto-switch. Refreshed on each reconfigure.
host_theme_dark_styling: Option<Styling>,
- /// Resolved styling to apply when `host_terminal_theme_mode == Light`.
- /// `None` disables auto-switch. Refreshed on each reconfigure.
host_theme_light_styling: Option<Styling>,
+ mobile_state: MobileState,
+ mobile_render_gate: MobileRenderGate,
}
/// A pending forward waiting to be dispatched once the current in-flight
@@ -1619,6 +1625,56 @@ impl Screen {
host_terminal_theme_mode: None,
host_theme_dark_styling: None,
host_theme_light_styling: None,
+ mobile_state: MobileState::default(),
+ mobile_render_gate: MobileRenderGate::default(),
+ }
+ }
+
+ fn client_is_web(&self, client_id: ClientId) -> bool {
+ self.connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false)
+ }
+
+ fn try_lift_mobile_gate(&mut self, client_id: ClientId) -> bool {
+ let is_web_client = self.client_is_web(client_id);
+ let reported_size = self.client_sizes.get(&client_id).copied();
+ let lifted = self
+ .mobile_render_gate
+ .try_reveal(client_id, is_web_client, reported_size);
+ if lifted {
+ self.force_render_mobile_tab(client_id);
+ }
+ lifted
+ }
+
+ fn force_render_mobile_tab(&mut self, client_id: ClientId) {
+ if let Some(tab_id) = self.mobile_state.mobile_tab_id(client_id) {
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ tab.set_should_clear_display_before_rendering();
+ tab.set_force_render();
+ }
+ }
+ }
+
+ fn ungate_clients_for_mobile_plugin(&mut self, plugin_id: u32) {
+ if self.mobile_render_gate.is_empty() {
+ return;
+ }
+ let Some(tab_id) = self
+ .tabs
+ .values()
+ .find(|tab| tab.has_plugin(plugin_id))
+ .map(|tab| tab.id)
+ else {
+ return;
+ };
+ for client_id in self.mobile_render_gate.gated_clients() {
+ if self.mobile_state.mobile_tab_id(client_id) == Some(tab_id) {
+ self.mobile_render_gate.ungate(client_id);
+ }
}
}
@@ -1664,6 +1720,204 @@ impl Screen {
self.tabs.get(&id).map(|t| t.position)
}
+ pub fn tab_visible_to(&self, client_id: ClientId, tab_id: usize) -> bool {
+ match self.tabs.get(&tab_id) {
+ Some(tab) => match &tab.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ },
+ None => false,
+ }
+ }
+
+ pub fn is_in_mobile_mode(&self, client_id: ClientId) -> bool {
+ self.mobile_state.is_in_mobile_mode(client_id)
+ }
+
+ pub(crate) fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> Result<()> {
+ if let ShadowFocusOutcome::NewlyApplied =
+ self.mobile_state
+ .apply_shadow_focus(client_id, pane_id, &mut self.tabs)
+ {
+ self.log_and_report_session_state()?;
+ self.render(None)?;
+ }
+ Ok(())
+ }
+
+ pub fn enter_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to enter mobile mode for client {client_id}");
+
+ if self.mobile_state.is_in_mobile_mode(client_id) {
+ return Ok(());
+ }
+
+ let prior = self.active_tab_ids.get(&client_id).copied();
+ self.mobile_state.set_previous_tab(client_id, prior);
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
+
+ let tab_layout = MobileState::mobile_tab_layout().with_context(err_context)?;
+
+ let tab_id = self.get_new_tab_id();
+ self.new_tab(
+ tab_id,
+ (vec![], vec![]),
+ Some("Mobile".to_string()),
+ Some(client_id),
+ )
+ .with_context(err_context)?;
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ let mut set = HashSet::new();
+ set.insert(client_id);
+ tab.visible_to = Some(set);
+ }
+ self.mobile_state.register_tab(client_id, tab_id);
+
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+
+ let client_is_gated = self.mobile_render_gate.is_gated(client_id);
+ if client_is_gated {
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::HoldMobileRender(client_id))
+ .with_context(err_context)?;
+ }
+
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::NewTab(
+ None,
+ None,
+ Some(tab_layout),
+ vec![],
+ tab_id,
+ None,
+ false,
+ true,
+ (client_id, is_web_client),
+ None,
+ ))
+ .with_context(err_context)?;
+
+ Ok(())
+ }
+
+ pub fn exit_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ let err_context = || format!("failed to exit mobile mode for client {client_id}");
+
+ self.mobile_render_gate.ungate(client_id);
+ self.bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+
+ let Some((mobile_tab_id, prior_tab_id)) = self.mobile_state.begin_exit(client_id) else {
+ return Ok(());
+ };
+
+ let destination = prior_tab_id
+ .filter(|id| self.tabs.contains_key(id) && self.tab_visible_to(client_id, *id))
+ .or_else(|| {
+ let mobile_tab_ids = self.mobile_state.mobile_tab_ids();
+ self.tabs
+ .values()
+ .filter(|t| {
+ t.id != mobile_tab_id
+ && !mobile_tab_ids.contains(&t.id)
+ && match &t.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ }
+ })
+ .min_by_key(|t| t.position)
+ .map(|t| t.id)
+ });
+
+ if let Some(dest_id) = destination {
+ if let Some(dest_pos) = self.get_tab_position_by_id(dest_id) {
+ self.switch_active_tab(dest_pos, None, true, client_id)
+ .with_context(err_context)?;
+ }
+ }
+
+ if self.tabs.contains_key(&mobile_tab_id) {
+ self.close_tab_by_id(mobile_tab_id)
+ .with_context(err_context)?;
+ }
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
+
+ Ok(())
+ }
+
+ pub fn toggle_mobile_mode(&mut self, client_id: ClientId) -> Result<()> {
+ if self.is_in_mobile_mode(client_id) {
+ self.exit_mobile_mode(client_id)
+ } else {
+ self.enter_mobile_mode(client_id)
+ }
+ }
+
+ pub fn reevaluate_mobile_mode(
+ &mut self,
+ client_id: ClientId,
+ new_size: Size,
+ mobile_layout: MobileLayoutConfiguration,
+ threshold_cols: u16,
+ threshold_rows: u16,
+ ) -> Result<()> {
+ let is_web_client = self
+ .connected_clients
+ .borrow()
+ .get(&client_id)
+ .copied()
+ .unwrap_or(false);
+ let should_be_mobile = mobile_layout.should_route_to_mobile(
+ is_web_client,
+ new_size.cols,
+ new_size.rows,
+ threshold_cols,
+ threshold_rows,
+ );
+ let is_mobile = self.is_in_mobile_mode(client_id);
+ let was_auto = self.mobile_state.was_auto_entered(client_id);
+ if should_be_mobile && !is_mobile {
+ self.enter_mobile_mode(client_id)?;
+ self.mobile_state.mark_auto_entered(client_id);
+ self.render(None)?;
+ } else if !should_be_mobile && is_mobile && was_auto {
+ self.exit_mobile_mode(client_id)?;
+ self.render(None)?;
+ }
+ if !should_be_mobile && self.mobile_render_gate.is_gated(client_id) {
+ self.mobile_render_gate.ungate(client_id);
+ self.render(None)?;
+ }
+ Ok(())
+ }
+
+ fn visible_tab_positions_for_client(&self, client_id: ClientId) -> Vec<usize> {
+ let mut positions: Vec<usize> = self
+ .tabs
+ .values()
+ .filter(|t| match &t.visible_to {
+ None => true,
+ Some(set) => set.contains(&client_id),
+ })
+ .map(|t| t.position)
+ .collect();
+ positions.sort_unstable();
+ positions
+ }
+
fn move_clients_from_closed_tab(
&mut self,
client_ids_and_mode_infos: Vec<(ClientId, ModeInfo)>,
@@ -1870,9 +2124,6 @@ impl Screen {
.send_to_background_jobs(BackgroundJob::StopFlashTabBell(tab_id));
}
- // Both the source and destination tabs may have changed
- // their viewer sets; per-tab sizing is independent so each
- // is recomputed against its current viewers.
self.recompute_tab_size(current_tab_index)
.with_context(err_context)?;
self.recompute_tab_size(new_tab_index)
@@ -1891,7 +2142,11 @@ impl Screen {
/// A helper function to switch to a new tab with specified name. Return true if tab [name] has
/// been created, else false.
fn switch_active_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> {
- match self.tabs.values().find(|t| t.name == name) {
+ match self
+ .tabs
+ .values()
+ .find(|t| t.name == name && self.tab_visible_to(client_id, t.id))
+ {
Some(new_tab) => {
self.switch_active_tab(new_tab.position, None, true, client_id)?;
Ok(true)
@@ -1919,7 +2174,14 @@ impl Screen {
match self.get_active_tab(client_id) {
Ok(active_tab) => {
let active_tab_pos = active_tab.position;
- let new_tab_pos = (active_tab_pos + 1) % self.tabs.len();
+ let visible = self.visible_tab_positions_for_client(client_id);
+ if visible.is_empty() {
+ return Ok(());
+ }
+ let new_tab_pos = match visible.iter().position(|p| *p > active_tab_pos) {
+ Some(idx) => visible[idx],
+ None => visible[0],
+ };
return self.switch_active_tab(
new_tab_pos,
should_change_pane_focus,
@@ -1952,10 +2214,13 @@ impl Screen {
match self.get_active_tab(client_id) {
Ok(active_tab) => {
let active_tab_pos = active_tab.position;
- let new_tab_pos = if active_tab_pos == 0 {
- self.tabs.len() - 1
- } else {
- active_tab_pos - 1
+ let visible = self.visible_tab_positions_for_client(client_id);
+ if visible.is_empty() {
+ return Ok(());
+ }
+ let new_tab_pos = match visible.iter().rev().find(|p| **p < active_tab_pos) {
+ Some(p) => *p,
+ None => *visible.last().unwrap(),
};
return self.switch_active_tab(
@@ -1972,7 +2237,13 @@ impl Screen {
}
pub fn go_to_tab(&mut self, tab_index: usize, client_id: ClientId) -> Result<()> {
- self.switch_active_tab(tab_index.saturating_sub(1), None, true, client_id)
+ let target_pos = tab_index.saturating_sub(1);
+ if let Some(target_id) = self.get_tab_id_at_position(target_pos) {
+ if !self.tab_visible_to(client_id, target_id) {
+ return Ok(());
+ }
+ }
+ self.switch_active_tab(target_pos, None, true, client_id)
}
pub fn go_to_tab_name(&mut self, name: String, client_id: ClientId) -> Result<bool> {
@@ -1983,6 +2254,7 @@ impl Screen {
let err_context = || format!("failed to close tab at index {tab_id:?}");
let mut tab_to_close = self.tabs.remove(&tab_id).with_context(err_context)?;
+ self.mobile_state.remove_fit_for_tab(tab_id);
let mut pane_ids = tab_to_close.get_all_pane_ids();
// here we extract the suppressed panes (these are background panes that don't care which
@@ -2083,22 +2355,33 @@ impl Screen {
}
}
- /// Record the viewport size most recently reported by `client_id`. Used as
- /// input to per-tab size computation; does not by itself trigger a resize.
pub fn set_client_size(&mut self, client_id: ClientId, size: Size) {
self.client_sizes.insert(client_id, size);
}
- /// Recompute the size of `tab_id` from the viewports of every client whose
- /// `active_tab_ids` entry equals `tab_id`. `rows` and `cols` are sorted
- /// independently — each axis takes the minimum across viewers. If the tab
- /// has no viewers the size is left untouched (the tab retains its most
- /// recent viewer-derived dimensions). When the computed size differs from
- /// the tab's current size, the tab is resized (via `resize_whole_tab`)
- /// and a force-render is scheduled.
pub fn recompute_tab_size(&mut self, tab_id: usize) -> Result<()> {
let err_context = || format!("failed to recompute size for tab {tab_id}");
+ if self.mobile_state.has_fit(tab_id) {
+ for _ in 0..FIT_RESIZE_MAX_ITERS {
+ let Some(new_size) = self.compute_fit_size(tab_id) else {
+ break;
+ };
+ let Some(tab) = self.tabs.get_mut(&tab_id) else {
+ break;
+ };
+ if tab.size == new_size {
+ break;
+ }
+ tab.resize_whole_tab(new_size).with_context(err_context)?;
+ }
+ if let Some(tab) = self.tabs.get_mut(&tab_id) {
+ tab.set_should_clear_display_before_rendering();
+ tab.set_force_render();
+ }
+ return Ok(());
+ }
+
let mut rows: Vec<usize> = Vec::new();
let mut cols: Vec<usize> = Vec::new();
for (client_id, active_tab) in self.active_tab_ids.iter() {
@@ -2127,6 +2410,45 @@ impl Screen {
Ok(())
}
+ pub(crate) fn compute_fit_size(&self, tab_id: usize) -> Option<Size> {
+ self.mobile_state.compute_fit_size(tab_id, &self.tabs)
+ }
+
+ pub fn set_tab_fit(
+ &mut self,
+ client_id: ClientId,
+ tab_id: usize,
+ pane_id: PaneId,
+ size: Size,
+ ) -> Result<()> {
+ self.mobile_state
+ .set_fit(client_id, tab_id, pane_id, size, &mut self.tabs);
+ self.recompute_tab_size(tab_id)
+ }
+
+ pub fn exit_fit_mode(&mut self, client_id: ClientId) -> Result<bool> {
+ match self
+ .mobile_state
+ .clear_fit_owned_by(client_id, &mut self.tabs)
+ {
+ Some(tab_id) => {
+ self.recompute_tab_size(tab_id)?;
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
+ pub(crate) fn clear_fit_for_closed_pane(&mut self, pane_id: PaneId) -> Result<bool> {
+ match self.mobile_state.clear_fit_for_pane(pane_id) {
+ Some(tab_id) => {
+ self.recompute_tab_size(tab_id)?;
+ Ok(true)
+ },
+ None => Ok(false),
+ }
+ }
+
pub fn update_pixel_dimensions(&mut self, pixel_dimensions: PixelDimensions) {
self.pixel_dimensions.merge(pixel_dimensions);
if let Some(character_cell_size) = self.pixel_dimensions.character_cell_size {
@@ -2524,6 +2846,49 @@ impl Screen {
}
}
+ if self.plugins_need_ansi_pane_contents {
+ let all_regular_clients: Vec<ClientId> = self
+ .connected_clients
+ .borrow()
+ .keys()
+ .copied()
+ .filter(|id| !self.watcher_clients.contains_key(id))
+ .collect();
+ if !all_regular_clients.is_empty() {
+ for tab in self.tabs.values() {
+ for pane_id in tab.get_static_and_floating_pane_ids() {
+ if let Some(pane) = tab.get_pane_with_id(pane_id) {
+ match pane_id {
+ PaneId::Terminal(_) => {
+ let contents =
+ pane.pane_contents_with_ansi(None, false, None);
+ output.add_pane_contents_with_ansi(
+ &all_regular_clients,
+ pane_id,
+ contents,
+ );
+ },
+ PaneId::Plugin(_) => {
+ for client_id in &all_regular_clients {
+ let contents = pane.pane_contents_with_ansi(
+ Some(*client_id),
+ false,
+ None,
+ );
+ output.add_pane_contents_with_ansi(
+ &[*client_id],
+ pane_id,
+ contents,
+ );
+ }
+ },
+ }
+ }
+ }
+ }
+ }
+ }
+
let pane_render_report = output.drain_pane_render_report();
// Subscriber delivery — gated behind is_empty() for zero overhead
@@ -2592,12 +2957,16 @@ impl Screen {
}
if non_watcher_output_was_dirty || has_bell {
- let serialized_output = output.serialize().context(err_context)?;
- let _ = self
- .bus
- .senders
- .send_to_server(ServerInstruction::Render(Some(serialized_output)))
- .context(err_context);
+ let mut serialized_output = output.serialize().context(err_context)?;
+ self.mobile_render_gate
+ .blank_gated_clients(&mut serialized_output);
+ if !serialized_output.is_empty() {
+ let _ = self
+ .bus
+ .senders
+ .send_to_server(ServerInstruction::Render(Some(serialized_output)))
+ .context(err_context);
+ }
}
if bell_state_changed {
@@ -2917,6 +3286,7 @@ impl Screen {
});
let tab_name = tab_name.unwrap_or_else(|| String::new());
+ let mobile_tab_count = self.mobile_state.mobile_tab_count();
let position = self.tabs.len();
let mut tab = Tab::new(
@@ -2962,6 +3332,7 @@ impl Screen {
self.mouse_click_through,
self.web_server_ip,
self.web_server_port,
+ mobile_tab_count,
);
for (client_id, mode_info) in &self.mode_info {
tab.change_mode_info(mode_info.clone(), *client_id);
@@ -3052,6 +3423,12 @@ impl Screen {
None
};
+ if !self.active_tab_ids.contains_key(&client_id) {
+ self.connected_clients
+ .borrow_mut()
+ .insert(client_id, is_web_client);
+ }
+
// apply the layout to the new tab
self.tabs
.get_mut(&tab_id)
@@ -3084,9 +3461,6 @@ impl Screen {
.with_context(err_context)?;
}
- // The new tab was just resized to `self.size` above as a default; if
- // any clients have been moved onto it, recompute to fit their actual
- // viewports.
self.recompute_tab_size(tab_id).with_context(err_context)?;
self.log_and_report_session_state()
@@ -3133,8 +3507,6 @@ impl Screen {
.with_context(|| err_context(tab_index))?
.add_client(client_id, None)
.with_context(|| err_context(tab_index))?;
- // Resize the newly-active tab to the min of all viewers (this client
- // may shrink it, or may be the first viewer of an empty tab).
self.recompute_tab_size(tab_index)
.with_context(|| err_context(tab_index))?;
Ok(())
@@ -3160,12 +3532,32 @@ impl Screen {
}
}
- for (_, tab) in self.tabs.iter_mut() {
+ self.mobile_state.forget_client(client_id);
+ self.mobile_render_gate.ungate(client_id);
+
+ let to_recompute = self
+ .mobile_state
+ .clear_fits_owned_by(client_id, &mut self.tabs);
+ for tab_id in to_recompute {
+ self.recompute_tab_size(tab_id).with_context(err_context)?;
+ }
+
+ let mut tabs_to_garbage_collect: Vec<usize> = vec![];
+ for (tab_id, tab) in self.tabs.iter_mut() {
tab.remove_client(client_id);
if tab.has_no_connected_clients() {
tab.visible(false).with_context(err_context)?;
}
+ if let Some(set) = tab.visible_to.as_mut() {
+ set.remove(&client_id);
+ if set.is_empty() {
+ tabs_to_garbage_collect.push(*tab_id);
+ }
+ }
}
+
+ self.mobile_state
+ .clear_shadow_focus(client_id, &mut self.tabs);
let previously_active_tab_id = self.active_tab_ids.get(&client_id).copied();
if let Some(prev_tab_id) = previously_active_tab_id {
self.global_last_active_tab_id = prev_tab_id;
@@ -3177,12 +3569,15 @@ impl Screen {
self.connected_clients.borrow_mut().remove(&client_id);
self.client_sizes.remove(&client_id);
self.pane_render_subscribers.remove(&client_id);
- // The vacated tab may have lost its smallest viewer; recompute so it
- // can grow back to fit the remaining clients (no-op if none remain).
if let Some(prev_tab_id) = previously_active_tab_id {
self.recompute_tab_size(prev_tab_id)
.with_context(err_context)?;
}
+ for tab_id in tabs_to_garbage_collect {
+ if self.tabs.contains_key(&tab_id) {
+ self.close_tab_by_id(tab_id).with_context(err_context)?;
+ }
+ }
self.log_and_report_session_state()
.with_context(err_context)
}
@@ -3223,13 +3618,18 @@ impl Screen {
let mut plugin_updates = vec![];
let mut tab_infos_for_screen_state = BTreeMap::new();
for tab in self.tabs.values() {
- let all_focused_clients: Vec<ClientId> = self
+ let mut all_focused_clients: Vec<ClientId> = self
.active_tab_ids
.iter()
.filter(|(_c_id, tab_position)| **tab_position == tab.id)
.map(|(c_id, _)| c_id)
.copied()
.collect();
+ for s_id in tab.shadow_focus_clients() {
+ if !all_focused_clients.contains(&s_id) {
+ all_focused_clients.push(s_id);
+ }
+ }
let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
let tab_viewport = tab.get_viewport();
let tab_display_area = tab.get_display_area();
@@ -3263,17 +3663,29 @@ impl Screen {
for (client_id, active_tab_index) in self.active_tab_ids.iter() {
let mut plugin_tab_updates = vec![];
for tab in self.tabs.values() {
+ if let Some(set) = &tab.visible_to {
+ if !set.contains(client_id) {
+ continue;
+ }
+ }
let other_focused_clients: Vec<ClientId> = if self.session_is_mirrored {
vec![]
} else {
- self.active_tab_ids
+ let mut clients: Vec<ClientId> = self
+ .active_tab_ids
.iter()
.filter(|(c_id, tab_position)| {
**tab_position == tab.id && *c_id != client_id
})
.map(|(c_id, _)| c_id)
.copied()
- .collect()
+ .collect();
+ for s_id in tab.shadow_focus_clients() {
+ if s_id != *client_id && !clients.contains(&s_id) {
+ clients.push(s_id);
+ }
+ }
+ clients
};
let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
let tab_viewport = tab.get_viewport();
@@ -5231,13 +5643,18 @@ impl Screen {
fn get_tab_info(&self, tab_id: usize) -> Option<TabInfo> {
// Look up tab by its stable ID
self.tabs.get(&tab_id).map(|tab| {
- let all_focused_clients: Vec<ClientId> = self
+ let mut all_focused_clients: Vec<ClientId> = self
.active_tab_ids
.iter()
.filter(|(_c_id, active_tab_id)| **active_tab_id == tab.id)
.map(|(c_id, _)| c_id)
.copied()
.collect();
+ for s_id in tab.shadow_focus_clients() {
+ if !all_focused_clients.contains(&s_id) {
+ all_focused_clients.push(s_id);
+ }
+ }
let (active_swap_layout_name, is_swap_layout_dirty) = tab.swap_layout_info();
let tab_viewport = tab.get_viewport();
let tab_display_area = tab.get_display_area();
@@ -5821,16 +6238,36 @@ pub(crate) fn screen_thread_main(
for plugin_render_asset in plugin_render_assets.iter_mut() {
let plugin_id = plugin_render_asset.plugin_id;
let client_id = plugin_render_asset.client_id;
- let vte_bytes = plugin_render_asset.bytes.drain(..).collect();
+ let vte_bytes: Vec<u8> = plugin_render_asset.bytes.drain(..).collect();
+ let painted_content = !vte_bytes.is_empty();
+ let mobile_tab_id = screen.mobile_state.mobile_tab_id(client_id);
+ let mut painted_tab_id = None;
+ let mut painted_size = None;
let all_tabs = screen.get_tabs_mut();
for tab in all_tabs.values_mut() {
if tab.has_plugin(plugin_id) {
tab.handle_plugin_bytes(plugin_id, client_id, vte_bytes)
.context("failed to process plugin bytes")?;
+ painted_tab_id = Some(tab.id);
+ painted_size =
+ tab.get_pane_with_id(PaneId::Plugin(plugin_id))
+ .map(|pane| Size {
+ rows: pane.get_content_rows(),
+ cols: pane.get_content_columns(),
+ });
break;
}
}
+ if painted_content && painted_tab_id == mobile_tab_id && mobile_tab_id.is_some()
+ {
+ if let Some(painted_size) = painted_size {
+ screen
+ .mobile_render_gate
+ .record_paint_size(client_id, painted_size);
+ }
+ screen.try_lift_mobile_gate(client_id);
+ }
screen.render_blocker.remove_blocking_plugin(plugin_id);
}
screen.render(Some(plugin_render_assets))?;
@@ -7471,9 +7908,6 @@ pub(crate) fn screen_thread_main(
tab_position_to_focus,
pane_id_to_focus,
) => {
- // Record the client's viewport BEFORE add_client so that
- // add_client's internal recompute sees this client's size and
- // sizes the destination tab against all of its viewers.
screen.set_client_size(client_id, client_size);
screen.add_client(client_id, is_web_client)?;
let pane_id = pane_id_to_focus.map(|(pane_id, is_plugin)| {
@@ -7514,6 +7948,37 @@ pub(crate) fn screen_thread_main(
screen.log_and_report_session_state()?;
screen.render(None)?;
},
+ ScreenInstruction::SuppressRenderUntilMobile(client_id) => {
+ screen.mobile_render_gate.gate(client_id);
+ screen
+ .bus
+ .senders
+ .send_to_background_jobs(BackgroundJob::MobileGateTimeout(client_id))
+ .non_fatal();
+ },
+ ScreenInstruction::MobileSizeSettled(client_id) => {
+ screen.mobile_render_gate.record_settled_size(client_id);
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+ if screen.try_lift_mobile_gate(client_id) {
+ screen.render(None)?;
+ }
+ },
+ ScreenInstruction::ForceMobileUngate(client_id) => {
+ if screen.mobile_render_gate.is_gated(client_id) {
+ screen
+ .bus
+ .senders
+ .send_to_plugin(PluginInstruction::ReleaseMobileRender(client_id))
+ .non_fatal();
+ screen.mobile_render_gate.ungate(client_id);
+ screen.force_render_mobile_tab(client_id);
+ screen.render(None)?;
+ }
+ },
ScreenInstruction::UpdateSearch(
c,
client_id,
@@ -8161,6 +8626,9 @@ pub(crate) fn screen_thread_main(
screen.log_and_report_session_state()?;
},
ScreenInstruction::UpdatePluginLoadingStage(pid, loading_indication) => {
+ if loading_indication.is_error() {
+ screen.ungate_clients_for_mobile_plugin(pid);
+ }
let found_plugin =
screen.update_plugin_loading_stage(pid, loading_indication.clone());
if !found_plugin {
@@ -9000,7 +9468,7 @@ pub(crate) fn screen_thread_main(
for tab in all_tabs.values_mut() {
if tab.has_pane_with_pid(&pane_id) {
if let PaneId::Terminal(terminal_pane_id) = pane_id {
- tab.scroll_terminal_up(terminal_pane_id);
+ tab.scroll_terminal_up(terminal_pane_id)?;
} else {
// this is because to do this with plugins, we need the client_id -
// which we do not have (yet?) in this context...
@@ -9018,7 +9486,7 @@ pub(crate) fn screen_thread_main(
for tab in all_tabs.values_mut() {
if tab.has_pane_with_pid(&pane_id) {
if let PaneId::Terminal(terminal_pane_id) = pane_id {
- tab.scroll_terminal_down(terminal_pane_id);
+ tab.scroll_terminal_down(terminal_pane_id)?;
} else {
// this is because to do this with plugins, we need the client_id -
// which we do not have (yet?) in this context...
@@ -9111,6 +9579,22 @@ pub(crate) fn screen_thread_main(
}
screen.render(None)?;
},
+ ScreenInstruction::SetTabFit {
+ client_id,
+ tab_id,
+ fit,
+ } => {
+ let changed = match fit {
+ Some((pane_id, size)) => {
+ screen.set_tab_fit(client_id, tab_id, pane_id, size)?;
+ true
+ },
+ None => screen.exit_fit_mode(client_id)?,
+ };
+ if changed {
+ screen.render(None)?;
+ }
+ },
ScreenInstruction::TogglePaneEmbedOrEjectForPaneId(pane_id) => {
let all_tabs = screen.get_tabs_mut();
for tab in all_tabs.values_mut() {
@@ -9491,9 +9975,16 @@ pub(crate) fn screen_thread_main(
},
ScreenInstruction::NotifyPaneClosedToSubscribers { pane_id } => {
screen.notify_pane_closed_to_subscribers(pane_id);
+ screen.clear_fit_for_closed_pane(pane_id.into())?;
},
ScreenInstruction::PluginSubscribedToAnsiPaneContents(has_subscribers) => {
+ let previously_subscribed = screen.plugins_need_ansi_pane_contents;
screen.plugins_need_ansi_pane_contents = has_subscribers;
+ if has_subscribers && !previously_subscribed {
+ if let Err(e) = screen.render(None) {
+ log::error!("Failed to render after ANSI subscription enabled: {:?}", e);
+ }
+ }
},
ScreenInstruction::UpdateBackgroundPluginSubscriptions(
plugin_id,
@@ -9969,6 +10460,47 @@ pub(crate) fn screen_thread_main(
));
}
},
+ ScreenInstruction::EnterMobileMode(client_id, _completion_tx) => {
+ if let Err(e) = screen.enter_mobile_mode(client_id) {
+ screen.mobile_render_gate.ungate(client_id);
+ screen.render(None)?;
+ return Err(e);
+ }
+ screen.mobile_state.mark_auto_entered(client_id);
+ screen.render(None)?;
+ },
+ ScreenInstruction::ExitMobileMode(client_id, _completion_tx) => {
+ screen.exit_mobile_mode(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ToggleMobileMode(client_id, _completion_tx) => {
+ screen.toggle_mobile_mode(client_id)?;
+ screen.render(None)?;
+ },
+ ScreenInstruction::ReevaluateMobileMode {
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ } => {
+ screen.reevaluate_mobile_mode(
+ client_id,
+ new_size,
+ mobile_layout,
+ threshold_cols,
+ threshold_rows,
+ )?;
+ },
+ ScreenInstruction::SetSoftKeyboard { client_id, on } => {
+ if let Some(os_input) = &screen.bus.os_input {
+ let _ = os_input
+ .send_to_client(client_id, ServerToClientMsg::SetSoftKeyboard { on });
+ }
+ },
+ ScreenInstruction::SetShadowFocus(client_id, pane_id) => {
+ screen.set_shadow_focus(client_id, pane_id)?;
+ },
}
}
Ok(())
diff --git a/zellij-server/src/tab/mod.rs b/zellij-server/src/tab/mod.rs
index 83db1249..a1184635 100644
--- a/zellij-server/src/tab/mod.rs
+++ b/zellij-server/src/tab/mod.rs
@@ -158,11 +158,8 @@ pub(crate) struct Tab {
pub position: usize,
pub name: String,
pub prev_name: String,
- /// The tab's current viewport size, computed as `min(rows)` and `min(cols)`
- /// independently across the clients whose `active_tab_id` equals this tab.
- /// When the tab has no viewers it retains its most recent value (the
- /// recompute path simply skips empty tabs).
pub size: Size,
+ pub visible_to: Option<HashSet<ClientId>>,
tiled_panes: TiledPanes,
floating_panes: FloatingPanes,
suppressed_panes: SuppressedPanes,
@@ -306,6 +303,8 @@ pub trait Pane {
}
fn scroll_up(&mut self, count: usize, client_id: ClientId);
fn scroll_down(&mut self, count: usize, client_id: ClientId);
+ fn scroll_left(&mut self, _count: usize, _client_id: ClientId) {}
+ fn scroll_right(&mut self, _count: usize, _client_id: ClientId) {}
fn clear_scroll(&mut self);
fn is_scrolled(&self) -> bool;
fn active_at(&self) -> Instant;
@@ -763,9 +762,10 @@ impl Tab {
mouse_click_through: bool,
web_server_ip: IpAddr,
web_server_port: u16,
+ mobile_tab_count: usize,
) -> Self {
let name = if name.is_empty() {
- format!("Tab #{}", id + 1)
+ format!("Tab #{}", (id + 1).saturating_sub(mobile_tab_count))
} else {
name
};
@@ -825,6 +825,7 @@ impl Tab {
name: name.clone(),
prev_name: name,
size: initial_size,
+ visible_to: None,
max_panes,
viewport,
display_area,
@@ -2690,6 +2691,36 @@ impl Tab {
pub fn has_non_suppressed_pane_with_pid(&self, pid: &PaneId) -> bool {
self.tiled_panes.panes_contain(pid) || self.floating_panes.panes_contain(pid)
}
+ pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> bool {
+ if self.tiled_panes.panes_contain(&pane_id) {
+ self.tiled_panes.set_shadow_focus(client_id, pane_id);
+ true
+ } else if self.floating_panes.panes_contain(&pane_id) {
+ self.floating_panes.set_shadow_focus(client_id, pane_id);
+ true
+ } else {
+ false
+ }
+ }
+ pub fn clear_shadow_focus(&mut self, client_id: ClientId) {
+ if self.tiled_panes.is_shadow_focus_client(&client_id) {
+ self.tiled_panes.clear_shadow_focus(client_id);
+ }
+ if self.floating_panes.is_shadow_focus_client(&client_id) {
+ self.floating_panes.clear_shadow_focus(client_id);
+ }
+ }
+ pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
+ let mut out = self.tiled_panes.shadow_focus_clients();
+ out.extend(self.floating_panes.shadow_focus_clients());
+ out.sort_unstable();
+ out.dedup();
+ out
+ }
+ pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
+ self.tiled_panes.has_shadow_focus_on(client_id, pane_id)
+ || self.floating_panes.has_shadow_focus_on(client_id, pane_id)
+ }
pub fn handle_pty_bytes(&mut self, pid: u32, bytes: VteBytes) -> Result<()> {
if self.is_pending {
self.pending_instructions
@@ -3210,6 +3241,9 @@ impl Tab {
pub fn is_fullscreen_active(&self) -> bool {
self.tiled_panes.fullscreen_is_active()
}
+ pub fn fullscreen_pane_id(&self) -> Option<PaneId> {
+ self.tiled_panes.fullscreen_pane_id()
+ }
pub fn are_floating_panes_visible(&self) -> bool {
self.floating_panes.panes_are_visible()
}
@@ -3262,6 +3296,10 @@ impl Tab {
self.floating_panes.set_force_render(); // we do this to make sure pinned panes are
// rendered even if their surface is not visible
}
+ #[cfg(test)]
+ pub fn should_clear_display_before_rendering(&self) -> bool {
+ self.should_clear_display_before_rendering
+ }
pub fn is_sync_panes_active(&self) -> bool {
self.synchronize_is_active
}
@@ -4464,13 +4502,28 @@ impl Tab {
}
}
- pub fn scroll_terminal_up(&mut self, terminal_pane_id: u32) {
- if let Some(terminal_pane) = self.get_pane_with_id_mut(PaneId::Terminal(terminal_pane_id)) {
- let fictitious_client_id = 1; // this is not checked for terminal panes and we
- // don't have an actual client id here
- // TODO: traits were a mistake
- terminal_pane.scroll_up(1, fictitious_client_id);
+ pub fn scroll_terminal_up(&mut self, terminal_pane_id: u32) -> Result<()> {
+ let pane_id = PaneId::Terminal(terminal_pane_id);
+ let err_context = || format!("failed to scroll up pane {pane_id:?}");
+ let fictitious_client_id = 1;
+ let synthetic_position = Position::new(0, 0);
+ let (mouse_sgr, is_alt) = match self.get_pane_with_id(pane_id) {
+ Some(pane) => (
+ pane.mouse_scroll_up(&synthetic_position),
+ pane.is_alternate_mode_active(),
+ ),
+ None => return Ok(()),
+ };
+ if let Some(sgr) = mouse_sgr {
+ self.write_to_pane_id(&None, sgr.into_bytes(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else if is_alt {
+ self.write_to_pane_id(&None, b"\x1b[A".to_vec(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.scroll_up(1, fictitious_client_id);
}
+ Ok(())
}
pub fn scroll_active_terminal_down(&mut self, client_id: ClientId) -> Result<()> {
@@ -4488,13 +4541,37 @@ impl Tab {
Ok(())
}
- pub fn scroll_terminal_down(&mut self, terminal_pane_id: u32) {
- if let Some(terminal_pane) = self.get_pane_with_id_mut(PaneId::Terminal(terminal_pane_id)) {
- let fictitious_client_id = 1; // this is not checked for terminal panes and we
- // don't have an actual client id here
- // TODO: traits were a mistake
- terminal_pane.scroll_down(1, fictitious_client_id);
+ pub fn scroll_terminal_down(&mut self, terminal_pane_id: u32) -> Result<()> {
+ let pane_id = PaneId::Terminal(terminal_pane_id);
+ let err_context = || format!("failed to scroll down pane {pane_id:?}");
+ let fictitious_client_id = 1;
+ let synthetic_position = Position::new(0, 0);
+ let (mouse_sgr, is_alt) = match self.get_pane_with_id(pane_id) {
+ Some(pane) => (
+ pane.mouse_scroll_down(&synthetic_position),
+ pane.is_alternate_mode_active(),
+ ),
+ None => return Ok(()),
+ };
+ if let Some(sgr) = mouse_sgr {
+ self.write_to_pane_id(&None, sgr.into_bytes(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else if is_alt {
+ self.write_to_pane_id(&None, b"\x1b[B".to_vec(), false, pane_id, None, None)
+ .with_context(err_context)?;
+ } else {
+ let needs_flush = if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
+ pane.scroll_down(1, fictitious_client_id);
+ !pane.is_scrolled()
+ } else {
+ false
+ };
+ if needs_flush {
+ self.process_pending_vte_events(terminal_pane_id)
+ .with_context(err_context)?;
+ }
}
+ Ok(())
}
pub fn scroll_active_terminal_up_page(&mut self, client_id: ClientId) {
diff --git a/zellij-server/src/tab/mouse_handler.rs b/zellij-server/src/tab/mouse_handler.rs
index 97f4ce27..0397a7f4 100644
--- a/zellij-server/src/tab/mouse_handler.rs
+++ b/zellij-server/src/tab/mouse_handler.rs
@@ -12,9 +12,6 @@ use crate::ClientId;
use super::{Pane, Tab};
-/// Remove the hover pane tracking for `client_id` and clear the hover position
-/// on the previously hovered pane (if any). Returns `true` if a pane was
-/// cleared.
fn clear_hover_for_client(tab: &mut Tab, client_id: ClientId) -> bool {
if let Some(prev_pid) = tab.mouse_hover_pane_id.remove(&client_id) {
if let Some(pane) = tab.get_pane_with_id_mut(prev_pid) {
@@ -148,6 +145,14 @@ enum MouseAction {
pane_id: PaneId,
lines: usize,
},
+ ScrollLeft {
+ pane_id: PaneId,
+ cols: usize,
+ },
+ ScrollRight {
+ pane_id: PaneId,
+ cols: usize,
+ },
ResizeScrollUp {
pane_id: PaneId,
},
@@ -261,14 +266,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::TopLeft => {
let mut strategies = vec![];
- // Top edge
let resize_y = if delta_y < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Up),
invert_on_boundaries: false,
});
- // Left edge
let resize_x = if delta_x < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -279,14 +282,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::TopRight => {
let mut strategies = vec![];
- // Top edge
let resize_y = if delta_y < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Up),
invert_on_boundaries: false,
});
- // Right edge
let resize_x = if delta_x > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -297,14 +298,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::BottomLeft => {
let mut strategies = vec![];
- // Bottom edge
let resize_y = if delta_y > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Down),
invert_on_boundaries: false,
});
- // Left edge
let resize_x = if delta_x < 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -315,14 +314,12 @@ fn edge_and_delta_to_strategies(
},
PaneEdge::BottomRight => {
let mut strategies = vec![];
- // Bottom edge
let resize_y = if delta_y > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_y,
direction: Some(Down),
invert_on_boundaries: false,
});
- // Right edge
let resize_x = if delta_x > 0 { Increase } else { Decrease };
strategies.push(ResizeStrategy {
resize: resize_x,
@@ -446,10 +443,8 @@ impl MouseHandler {
) -> Result<()> {
let err_context = || format!("failed to start pane resize for pane {pane_id:?}");
- // Determine if floating or tiled
let is_floating = tab.floating_panes.panes_contain(&pane_id);
- // Get current pane geometry
let start_geom = if is_floating {
tab.floating_panes
.get_pane(pane_id)
@@ -478,18 +473,14 @@ impl MouseHandler {
current_position: Position,
_client_id: ClientId,
) -> Result<bool> {
- // bool -> state changed
let err_context = || "failed to continue pane resize with mouse";
- // Extract needed values from resize_state to avoid borrow issues
let (pane_id, edge, is_floating, delta_x, delta_y) =
if let Some(resize_state) = &tab.pane_being_resized_with_mouse {
- // Calculate delta from start_position to current_position
let delta_x = current_position.column() as isize
- resize_state.start_position.column() as isize;
let delta_y = current_position.line() - resize_state.start_position.line();
- // Only proceed if there's a meaningful delta
if delta_x == 0 && delta_y == 0 {
return Ok(false);
}
@@ -505,10 +496,8 @@ impl MouseHandler {
return Ok(true);
};
- // Convert edge + delta to ResizeStrategy
let strategies = edge_and_delta_to_strategies(edge, delta_x, delta_y);
- // Apply appropriate resize function
if is_floating {
Self::resize_floating_pane_with_strategies(
tab,
@@ -527,7 +516,6 @@ impl MouseHandler {
.with_context(err_context)?;
}
- // Update start_position to current position (incremental)
if let Some(resize_state) = tab.pane_being_resized_with_mouse.as_mut() {
resize_state.start_position = current_position;
}
@@ -542,10 +530,8 @@ impl MouseHandler {
final_position: Position,
client_id: ClientId,
) -> Result<bool> {
- // bool -> never_resized
let err_context = || "failed to stop pane resize with mouse";
- // Perform final resize with any remaining delta
let start_geom = tab
.pane_being_resized_with_mouse
.as_ref()
@@ -564,7 +550,6 @@ impl MouseHandler {
_ => false,
};
- // Clear resize state
tab.pane_being_resized_with_mouse = None;
Ok(never_resized)
@@ -595,21 +580,18 @@ impl MouseHandler {
) -> Result<()> {
let err_context = || format!("failed to resize tiled pane {pane_id:?}");
- // Calculate percentage based on total viewport size for 1:1 cell mapping
- // Formula: (delta_cells / total_viewport_cells) * 100.0 = percentage
- // This ensures 1 cell of mouse movement = exactly 1 cell of pane resize
let viewport = tab.viewport.borrow();
let viewport_cols = viewport.cols;
let viewport_rows = viewport.rows;
let change_by_percent = (
if viewport_cols > 0 {
- (change_by.0 / viewport_cols as f64) * 100.0 // cols
+ (change_by.0 / viewport_cols as f64) * 100.0
} else {
0.0
},
if viewport_rows > 0 {
- (change_by.1 / viewport_rows as f64) * 100.0 // rows
+ (change_by.1 / viewport_rows as f64) * 100.0
} else {
0.0
},
@@ -632,8 +614,6 @@ impl MouseHandler {
let err_context = || format!("failed to resize tiled pane {pane_id:?}");
tab.tiled_panes
- // we override the resize percent because it's better UX to have smaller increments
- // with the mouse wheel
.stacked_resize_pane_with_id(pane_id, strategy, Some((5.0, 5.0)))
.with_context(err_context)?;
@@ -663,7 +643,6 @@ impl MouseHandler {
match action {
MouseAction::GroupToggle(pane_id) => {
- // Check if Alt+Click lands on a plugin highlight
if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
let relative_position = pane.relative_position(&event.position);
if let Some((hit_plugin_id, pattern, matched_string, context)) =
@@ -682,7 +661,6 @@ impl MouseHandler {
return Ok(MouseEffect::state_changed());
}
}
- // No highlight hit — fall through to pane grouping
Ok(MouseEffect::group_toggle(pane_id))
},
MouseAction::GroupAdd(pane_id) => Ok(MouseEffect::group_add(pane_id)),
@@ -775,6 +753,30 @@ impl MouseHandler {
Self::handle_scrollwheel_down(tab, &event.position, lines, client_id)
.with_context(err_context)
},
+ MouseAction::ScrollLeft { pane_id, cols } => {
+ let scroll_right = false;
+ Self::handle_scrollwheel_horizontal(
+ tab,
+ pane_id,
+ &event.position,
+ cols,
+ scroll_right,
+ client_id,
+ )
+ .with_context(err_context)
+ },
+ MouseAction::ScrollRight { pane_id, cols } => {
+ let scroll_right = true;
+ Self::handle_scrollwheel_horizontal(
+ tab,
+ pane_id,
+ &event.position,
+ cols,
+ scroll_right,
+ client_id,
+ )
+ .with_context(err_context)
+ },
MouseAction::ResizeScrollUp { pane_id } => {
Self::handle_resize_scroll_up(tab, pane_id, client_id).with_context(err_context)
},
@@ -873,21 +875,15 @@ impl MouseHandler {
) -> Result<MouseEffect> {
let err_context = || "failed to focus pane and click through";
- // Step 1: Focus the pane (same as execute_focus_pane, but without the
- // floating-pane move-on-click behavior — we want to send the click into
- // the pane, not start moving it)
clear_hover_for_client(tab, client_id);
Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?;
- // Handle unselectable panes the same way execute_focus_pane does
if let Some(pane_at_position) = Self::unselectable_pane_at_position(tab, &position) {
let relative_position = pane_at_position.relative_position(&position);
pane_at_position.start_selection(&relative_position, client_id);
return Ok(MouseEffect::state_changed());
}
- // Step 2: Now that the pane is focused, dispatch the click as if the
- // user clicked on the (now-active) pane.
let active_pane_id = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))
@@ -901,7 +897,6 @@ impl MouseHandler {
let terminal_wants_mouse = pane.terminal_emulator_wants_mouse();
if terminal_wants_mouse {
- // Terminal wants mouse events — send the click to it
let relative_position = pane.relative_position(&click_event.position);
let mut event_for_pane = click_event;
event_for_pane.position = relative_position;
@@ -912,7 +907,6 @@ impl MouseHandler {
}
}
} else {
- // Terminal does not want mouse — start text selection
if let Some(pane) = tab.get_pane_with_id_mut(active_pane_id) {
let relative_position = pane.relative_position(&position);
pane.start_selection(&relative_position, client_id);
@@ -1025,26 +1019,20 @@ impl MouseHandler {
) -> Result<MouseEffect> {
let err_context = || format!("failed to focus pane on hover for client {client_id}");
- // Only focus selectable panes
let is_selectable = tab
.get_pane_with_id(pane_id)
.map(|p| p.selectable())
.unwrap_or(false);
if !is_selectable {
- // Fall back to normal hover behavior for unselectable panes
return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id);
}
- // When floating panes are visible, only focus floating panes — tiled panes
- // require an explicit click
let floating_visible = tab.floating_panes.panes_are_visible();
let is_floating = tab.floating_panes.get_pane(pane_id).is_some();
if floating_visible && !is_floating {
return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id);
}
- // Skip stacked one-liner panes (collapsed panes in a stack) — only the
- // expanded main pane in a stack should be focusable via hover
let is_stacked_one_liner = tab
.get_pane_with_id(pane_id)
.map(|p| {
@@ -1056,16 +1044,13 @@ impl MouseHandler {
return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id);
}
- // Skip if already focused
let active_pane_id = tab.get_active_pane_id(client_id);
if active_pane_id == Some(pane_id) {
return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id);
}
- // Focus the pane
Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?;
- // Clear hover state since the pane is now focused
clear_hover_for_client(tab, client_id);
Ok(MouseEffect::state_changed())
@@ -1099,10 +1084,6 @@ impl MouseHandler {
},
}
- // Clear hover position on previously hovered pane when the hovered
- // pane has changed (or cursor left all panes). Hover position is
- // intentionally not set on unfocused panes so that hover-only plugin
- // highlights only activate on the focused pane.
if let Some(prev_pane_id) = previous_hover_pane_id {
if Some(prev_pane_id) != pane_id {
if let Some(pane) = tab.get_pane_with_id_mut(prev_pane_id) {
@@ -1151,11 +1132,6 @@ impl MouseHandler {
if clear_hover_for_client(tab, client_id) {
should_render = true;
}
- // Update hover position on the active pane during motion events
- // so that on_hover highlights work for the focused pane too.
- // Skip when the terminal application has mouse tracking enabled,
- // because hover highlights are suppressed in that case and the
- // update would only trigger unnecessary re-renders.
if event.event_type == MouseEventType::Motion {
if let Some(pane) = tab.get_pane_with_id_mut(pane_id) {
if !pane.terminal_emulator_wants_mouse() {
@@ -1270,6 +1246,18 @@ impl MouseHandler {
return Ok(MouseAction::NoAction);
}
+ if event.wheel_left || event.wheel_right {
+ if let Some(pane_id) = ctx.pane_id_at_position {
+ if event.wheel_left {
+ return Ok(MouseAction::ScrollLeft { pane_id, cols: 4 });
+ }
+ if event.wheel_right {
+ return Ok(MouseAction::ScrollRight { pane_id, cols: 4 });
+ }
+ }
+ return Ok(MouseAction::NoAction);
+ }
+
let is_ctrl_left_press =
event.ctrl && event.left && event.event_type == MouseEventType::Press;
if is_ctrl_left_press {
@@ -1531,8 +1519,7 @@ impl MouseHandler {
tab.write_to_terminal_at(mouse_event.into_bytes(), point, client_id)
.with_context(err_context)?;
} else if pane.is_alternate_mode_active() {
- // faux scrolling, send UP n times
- // do n separate writes to make sure the sequence gets adjusted for cursor keys mode
+ // separate writes so each sequence gets adjusted for cursor keys mode
for _ in 0..lines {
tab.write_to_terminal_at("\u{1b}[A".as_bytes().to_owned(), point, client_id)
.with_context(err_context)?;
@@ -1562,8 +1549,7 @@ impl MouseHandler {
tab.write_to_terminal_at(mouse_event.into_bytes(), point, client_id)
.with_context(err_context)?;
} else if pane.is_alternate_mode_active() {
- // faux scrolling, send DOWN n times
- // do n separate writes to make sure the sequence gets adjusted for cursor keys mode
+ // separate writes so each sequence gets adjusted for cursor keys mode
for _ in 0..lines {
tab.write_to_terminal_at("\u{1b}[B".as_bytes().to_owned(), point, client_id)
.with_context(err_context)?;
@@ -1581,6 +1567,32 @@ impl MouseHandler {
Ok(MouseEffect::default())
}
+ pub(crate) fn handle_scrollwheel_horizontal(
+ tab: &mut Tab,
+ pane_id: PaneId,
+ point: &Position,
+ cols: usize,
+ scroll_right: bool,
+ client_id: ClientId,
+ ) -> Result<MouseEffect> {
+ let err_context = || {
+ format!(
+ "failed to handle horizontal scrollwheel at position {point:?} for client {client_id}"
+ )
+ };
+ if !matches!(pane_id, PaneId::Plugin(_)) {
+ return Ok(MouseEffect::default());
+ }
+ if let Some(pane) = Self::get_pane_at(tab, point, false).with_context(err_context)? {
+ if scroll_right {
+ pane.scroll_right(cols, client_id);
+ } else {
+ pane.scroll_left(cols, client_id);
+ }
+ }
+ Ok(MouseEffect::default())
+ }
+
fn handle_resize_scroll_up(
tab: &mut Tab,
pane_id: PaneId,
@@ -1601,7 +1613,6 @@ impl MouseHandler {
.with_context(err_context)?;
tab.swap_layouts.set_is_floating_damaged();
} else {
- // we only resize the active pane for tiled panes (better ux)
let active_pane_id = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))?;
@@ -1635,7 +1646,6 @@ impl MouseHandler {
.with_context(err_context)?;
tab.swap_layouts.set_is_floating_damaged();
} else {
- // we only resize the active pane for tiled panes (better ux)
let active_pane_id = tab
.get_active_pane_id(client_id)
.ok_or_else(|| anyhow!("Failed to find active pane"))?;
diff --git a/zellij-server/src/tab/unit/tab_integration_tests.rs b/zellij-server/src/tab/unit/tab_integration_tests.rs
index 46e796b6..80b6324e 100644
--- a/zellij-server/src/tab/unit/tab_integration_tests.rs
+++ b/zellij-server/src/tab/unit/tab_integration_tests.rs
@@ -277,6 +277,7 @@ fn create_new_tab(size: Size, default_mode: ModeInfo) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -364,6 +365,7 @@ fn create_new_tab_without_pane_frames(size: Size, default_mode: ModeInfo) -> Tab
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -466,6 +468,7 @@ fn create_new_tab_with_swap_layouts(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
let (
base_layout,
@@ -569,6 +572,7 @@ fn create_new_tab_with_os_api(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -658,6 +662,7 @@ fn create_new_tab_with_layout(size: Size, default_mode: ModeInfo, layout: &str)
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
let pane_ids = tab_layout
.extract_run_instructions()
@@ -761,6 +766,7 @@ fn create_new_tab_with_mock_pty_writer(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -855,6 +861,7 @@ fn create_new_tab_with_sixel_support(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -12562,6 +12569,7 @@ fn create_new_tab_with_plugin_receiver(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -14346,6 +14354,7 @@ fn create_new_tab_with_server_receiver(
false, // mouse_click_through
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8080,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -14804,14 +14813,10 @@ fn osc99_query_flag_roundtrip() {
#[test]
fn hidden_cursor_still_emits_cup_for_host_terminal_positioning() {
- // When an app hides the cursor the host
- // terminal still needs a CUP sequence so that IME composition candidates and
- // other cursor-position-dependent features appear in the correct location.
let size = Size { cols: 80, rows: 24 };
let client_id = 1;
let mut tab = create_new_tab(size, ModeInfo::default());
- // Move cursor to a known position, then hide it
tab.handle_pty_bytes(1, Vec::from("\u{1b}[10;20H\u{1b}[?25l".as_bytes()))
.unwrap();
@@ -14820,26 +14825,17 @@ fn hidden_cursor_still_emits_cup_for_host_terminal_positioning() {
let serialized = output.serialize().unwrap();
let client_output = serialized.get(&client_id).unwrap();
- // The output must contain a hide-cursor instruction
assert!(
client_output.contains("\u{1b}[?25l"),
"Expected hide-cursor sequence in output"
);
- // The output must contain a CUP sequence positioning the cursor where the app
- // left it (row 10, col 20 — 1-indexed in the pane, plus the content offset
- // from the pane frame). The key point is that a CUP is emitted at all, even
- // though the cursor is hidden.
- //
- // With draw_pane_frames=true the content offset is (1, 1), so the absolute
- // position is row 11, col 21.
assert!(
client_output.contains("\u{1b}[11;21H"),
"Expected CUP sequence for hidden cursor positioning, got: {:?}",
client_output
);
- // The output must NOT contain a show-cursor instruction
assert!(
!client_output.contains("\u{1b}[?25h"),
"Show-cursor sequence must not be present when app has hidden the cursor"
diff --git a/zellij-server/src/tab/unit/tab_tests.rs b/zellij-server/src/tab/unit/tab_tests.rs
index 338035de..1ce0ed72 100644
--- a/zellij-server/src/tab/unit/tab_tests.rs
+++ b/zellij-server/src/tab/unit/tab_tests.rs
@@ -1,10 +1,12 @@
use super::Tab;
use crate::pane_groups::PaneGroups;
use crate::panes::sixel::SixelImageStore;
+use crate::pty_writer::PtyWriteInstruction;
use crate::screen::CopyOptions;
use crate::{os_input_output::ServerOsApi, panes::PaneId, thread_bus::ThreadSenders, ClientId};
use std::net::{IpAddr, Ipv4Addr};
use std::path::PathBuf;
+use zellij_utils::channels::{unbounded, Receiver, SenderWithContext};
use zellij_utils::data::{Direction, NewPanePlacement, Resize, ResizeStrategy, WebSharing};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::layout::{SplitDirection, SplitSize, TiledPaneLayout};
@@ -217,6 +219,7 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -303,6 +306,7 @@ fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
let mut new_terminal_ids = vec![];
for i in 0..layout.extract_run_instructions().len() {
@@ -395,6 +399,7 @@ fn create_new_tab_with_cell_size(
false, // mouse_click_through
web_server_ip,
web_server_port,
+ 0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -16307,3 +16312,397 @@ pub fn cli_rename_active_pane_then_interactive_esc_restores() {
let pane = tab.get_pane_with_id(pane_id).unwrap();
assert_eq!(pane.current_title(), "spark");
}
+
+fn install_pty_writer_capture(
+ tab: &mut Tab,
+) -> Receiver<(PtyWriteInstruction, zellij_utils::errors::ErrorContext)> {
+ let (tx, rx) = unbounded();
+ tab.senders
+ .replace_to_pty_writer(SenderWithContext::new(tx));
+ rx
+}
+
+fn drain_pty_writer(
+ rx: &Receiver<(PtyWriteInstruction, zellij_utils::errors::ErrorContext)>,
+) -> Vec<PtyWriteInstruction> {
+ let mut out = Vec::new();
+ while let Ok((msg, _ctx)) = rx.try_recv() {
+ out.push(msg);
+ }
+ out
+}
+
+#[test]
+pub fn scroll_terminal_up_forwards_sgr_when_pane_tracks_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1000h\x1b[?1006h".to_vec())
+ .unwrap();
+
+ tab.scroll_terminal_up(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ let s = String::from_utf8_lossy(bytes);
+ assert!(
+ s.starts_with("\u{1b}[<64;"),
+ "expected SGR wheel-up (button 64) prefix, got {s:?}"
+ );
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_down_forwards_sgr_when_pane_tracks_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1000h\x1b[?1006h".to_vec())
+ .unwrap();
+
+ tab.scroll_terminal_down(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ let s = String::from_utf8_lossy(bytes);
+ assert!(
+ s.starts_with("\u{1b}[<65;"),
+ "expected SGR wheel-down (button 65) prefix, got {s:?}"
+ );
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_up_emits_arrow_up_in_alt_mode_without_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1049h".to_vec()).unwrap();
+
+ tab.scroll_terminal_up(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ assert_eq!(bytes.as_slice(), b"\x1b[A");
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_down_emits_arrow_down_in_alt_mode_without_mouse() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+ tab.handle_pty_bytes(1, b"\x1b[?1049h".to_vec()).unwrap();
+
+ tab.scroll_terminal_down(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(!writes.is_empty(), "expected at least one pty write");
+ match &writes[0] {
+ PtyWriteInstruction::Write(bytes, terminal_id, _) => {
+ assert_eq!(*terminal_id, 1);
+ assert_eq!(bytes.as_slice(), b"\x1b[B");
+ },
+ other => panic!("expected PtyWriteInstruction::Write, got {other:?}"),
+ }
+}
+
+#[test]
+pub fn scroll_terminal_up_walks_scrollback_in_regular_mode_no_pty_write() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_up(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(
+ writes.is_empty(),
+ "expected no pty writes in regular mode, got {writes:?}"
+ );
+}
+
+#[test]
+pub fn scroll_terminal_down_walks_scrollback_in_regular_mode_no_pty_write() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_down(1).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(
+ writes.is_empty(),
+ "expected no pty writes in regular mode, got {writes:?}"
+ );
+}
+
+#[test]
+pub fn scroll_terminal_up_nonexistent_pane_id_is_a_noop() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_up(999).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(writes.is_empty());
+}
+
+#[test]
+pub fn scroll_terminal_down_nonexistent_pane_id_is_a_noop() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ let rx = install_pty_writer_capture(&mut tab);
+
+ tab.scroll_terminal_down(999).unwrap();
+
+ let writes = drain_pty_writer(&rx);
+ assert!(writes.is_empty());
+}
+
+#[test]
+pub fn set_shadow_focus_returns_true_when_pane_is_in_tab() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ let placed = tab.set_shadow_focus(99, PaneId::Terminal(2));
+
+ assert!(
+ placed,
+ "pane is in the tab — should report a successful placement"
+ );
+ assert!(
+ tab.has_shadow_focus_on(99, PaneId::Terminal(2)),
+ "shadow focus must be recorded on the target pane",
+ );
+}
+
+#[test]
+pub fn set_shadow_focus_returns_false_when_pane_not_in_tab() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+
+ let placed = tab.set_shadow_focus(99, PaneId::Terminal(42));
+
+ assert!(!placed, "no pane 42 in this tab — placement must fail");
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(42)),
+ "nothing should have been recorded",
+ );
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "no shadow markers should exist",
+ );
+}
+
+#[test]
+pub fn clear_shadow_focus_removes_marker_and_entry() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ assert_eq!(tab.shadow_focus_clients(), vec![99]);
+
+ tab.clear_shadow_focus(99);
+
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "marker should be gone after clear",
+ );
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
+ "the active_panes entry should be gone too",
+ );
+}
+
+#[test]
+pub fn clear_shadow_focus_is_a_noop_when_absent() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+
+ tab.clear_shadow_focus(99);
+
+ assert!(
+ tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
+ "real client's focus on pane 1 must survive an unrelated clear",
+ );
+}
+
+#[test]
+pub fn has_shadow_focus_on_is_pane_specific() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ tab.set_shadow_focus(99, PaneId::Terminal(2));
+
+ assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
+ "must NOT match a different pane id",
+ );
+ assert!(
+ !tab.has_shadow_focus_on(7, PaneId::Terminal(2)),
+ "must NOT match a different client id",
+ );
+}
+
+#[test]
+pub fn has_shadow_focus_on_does_not_match_real_focus() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let tab = create_new_tab(size, true);
+
+ assert!(
+ tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
+ "precondition: client 1 has real focus on pane 1",
+ );
+ assert!(
+ !tab.has_shadow_focus_on(1, PaneId::Terminal(1)),
+ "real focus must not register as shadow focus",
+ );
+}
+
+#[test]
+pub fn shadow_focus_clients_lists_only_marked_clients() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ tab.set_shadow_focus(100, PaneId::Terminal(2));
+
+ let mut clients = tab.shadow_focus_clients();
+ clients.sort_unstable();
+ assert_eq!(clients, vec![99, 100]);
+}
+
+#[test]
+pub fn real_focus_supersedes_shadow_marker() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ assert_eq!(tab.shadow_focus_clients(), vec![99]);
+
+ tab.tiled_panes.focus_pane(PaneId::Terminal(1), 99);
+
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "real focus must clear any prior shadow marker for the same client",
+ );
+ assert!(
+ tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
+ "real focus must still be recorded",
+ );
+}
+
+#[test]
+pub fn moving_shadow_focus_updates_pane_target() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+
+ tab.set_shadow_focus(99, PaneId::Terminal(1));
+ tab.set_shadow_focus(99, PaneId::Terminal(2));
+
+ assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
+ assert!(
+ !tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
+ "previous shadow target must be overwritten",
+ );
+ assert_eq!(
+ tab.shadow_focus_clients(),
+ vec![99],
+ "still only one shadow client",
+ );
+}
+
+#[test]
+pub fn closing_a_pane_silently_drops_shadow_clients() {
+ let size = Size {
+ cols: 121,
+ rows: 20,
+ };
+ let mut tab = create_new_tab(size, true);
+ tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
+ .unwrap();
+ tab.set_shadow_focus(99, PaneId::Terminal(2));
+ assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
+
+ tab.close_pane(PaneId::Terminal(2), false, None);
+
+ assert!(
+ tab.shadow_focus_clients().is_empty(),
+ "shadow client must be dropped when its pane is closed",
+ );
+}
diff --git a/zellij-server/src/unit/screen_tests.rs b/zellij-server/src/unit/screen_tests.rs
index 18837be6..3e0a65db 100644
--- a/zellij-server/src/unit/screen_tests.rs
+++ b/zellij-server/src/unit/screen_tests.rs
@@ -9469,17 +9469,6 @@ fn empty_reply_with_paused_pane_drains_buffer_without_phantom_write() {
);
}
-// --- per-tab viewport sizing (Stage 1 of mobile_view_plan.md) -------------
-
-// All five tests below exercise `Screen::recompute_tab_size`, which the
-// production code invokes from `ScreenInstruction::AddClient`,
-// `ScreenInstruction::RecomputeTabSize` (runtime resize), `remove_client`,
-// and `switch_active_tab`. Tests drive the helper directly so the assertions
-// don't depend on instruction-bus plumbing.
-
-/// Variant of `create_new_screen` with `session_is_mirrored = false`. The
-/// payoff of per-tab sizing only shows up when clients can sit on different
-/// tabs; mirrored mode forces every client to share the active tab.
fn create_non_mirrored_screen(size: Size) -> Screen {
let mut bus: Bus<ScreenInstruction> = Bus::empty();
let fake_os_input = FakeInputOutput::default();
@@ -9554,11 +9543,8 @@ fn recompute_tab_size_takes_independent_min_across_axes() {
};
let mut screen = create_new_screen(initial_size, true, true);
new_tab(&mut screen, 1, 0);
- // Drop the second client onto the same tab as client 1.
screen.add_client(2, false).expect("TEST");
- // Wide-but-short and narrow-but-tall: rows and cols must min independently,
- // not be paired by client.
screen.set_client_size(
1,
Size {
@@ -9582,13 +9568,9 @@ fn recompute_tab_size_isolates_tabs_with_different_viewers() {
cols: 200,
rows: 60,
};
- // Non-mirrored: per-tab sizing's whole point is letting clients sit on
- // different tabs without one dragging the other's grid.
let mut screen = create_non_mirrored_screen(initial_size);
new_tab(&mut screen, 1, 0);
new_tab(&mut screen, 2, 1);
- // Client 1 is on tab 1 (the most recent). Pull client 2 onto tab 0 so
- // tabs have one viewer each.
screen.add_client(2, false).expect("TEST");
screen.set_client_size(1, Size { cols: 80, rows: 24 });
screen.set_client_size(
@@ -9626,8 +9608,6 @@ fn switching_tabs_recomputes_source_and_destination() {
new_tab(&mut screen, 2, 1);
screen.add_client(2, false).expect("TEST");
- // Both clients start on tab 1 (most recently created). Give them
- // different sizes; the smaller wins on tab 1.
screen.set_client_size(1, Size { cols: 80, rows: 24 });
screen.set_client_size(
2,
@@ -9643,8 +9623,6 @@ fn switching_tabs_recomputes_source_and_destination() {
"Both clients on tab 1 → it sizes to the smaller"
);
- // Move the smaller client to tab 0. Tab 1 must grow back to fit its
- // remaining viewer; tab 0 must shrink to fit the arriving one.
screen.switch_active_tab(0, None, true, 1).expect("TEST");
assert_eq!(
@@ -9664,10 +9642,6 @@ fn switching_tabs_recomputes_source_and_destination() {
#[test]
fn break_pane_to_new_tab_recomputes_source_and_destination() {
- // `break_pane_to_new_tab` extracts the active pane and switches the
- // requesting client to an adjacent tab via `switch_tab_next`/`prev`. That
- // routes through `switch_active_tab`, so per-tab sizing must follow the
- // moving client.
let initial_size = Size {
cols: 200,
rows: 60,
@@ -9677,7 +9651,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
new_tab(&mut screen, 2, 1);
screen.add_client(2, false).expect("TEST");
- // Park each client on its own tab with its own viewport.
screen.set_client_size(1, Size { cols: 80, rows: 24 });
screen.set_client_size(
2,
@@ -9686,8 +9659,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
rows: 50,
},
);
- // After new_tab(2), client 1 is on tab id=1 (the new one). Pull it back
- // onto tab id=0 so the two clients are split across the two tabs.
screen.switch_active_tab(0, None, true, 1).expect("TEST");
assert_eq!(
screen.tabs.get(&0).unwrap().size,
@@ -9703,9 +9674,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
"Pre-condition: tab 1 sized to client 2"
);
- // Give tab 0 a second pane so break_pane_to_new_tab has something to
- // extract — but the per-tab sizing assertions are about clients, not
- // panes; the second pane is just to satisfy the function's preconditions.
{
let active_tab = screen.get_active_tab_mut(1).unwrap();
active_tab
@@ -9722,14 +9690,10 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
.unwrap();
}
- // Move client 1's active pane rightward into tab 1. The function
- // synchronously calls switch_tab_next → switch_active_tab(client 1, tab 1).
screen
.break_pane_to_new_tab(Direction::Right, 1)
.expect("TEST");
- // Tab 1 now has both clients → recomputes to the smaller viewport.
- // Tab 0 has no viewers → its size is left untouched.
assert_eq!(
screen.tabs.get(&1).unwrap().size,
Size { cols: 80, rows: 24 },
@@ -9744,9 +9708,6 @@ fn break_pane_to_new_tab_recomputes_source_and_destination() {
#[test]
fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
- // `break_multiple_panes_to_tab_with_index` with `should_change_focus = true`
- // calls `go_to_tab` on the requesting client. That hits `switch_active_tab`,
- // so the source tab grows back and the destination shrinks to fit.
let initial_size = Size {
cols: 200,
rows: 60,
@@ -9756,7 +9717,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
new_tab(&mut screen, 2, 1);
screen.add_client(2, false).expect("TEST");
- // Both clients on tab 1 (the most recent), small client wins.
screen.set_client_size(1, Size { cols: 80, rows: 24 });
screen.set_client_size(
2,
@@ -9765,7 +9725,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
rows: 50,
},
);
- // Park client 2 alone on tab 0 so the two tabs are differently sized.
screen.switch_active_tab(0, None, true, 2).expect("TEST");
assert_eq!(
screen.tabs.get(&0).unwrap().size,
@@ -9781,7 +9740,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
"Pre-condition: tab 1 sized to client 1"
);
- // Add a movable pane on tab 1 (where client 1 lives).
let pane_to_move = PaneId::Terminal(42);
{
let active_tab = screen.get_active_tab_mut(1).unwrap();
@@ -9799,11 +9757,6 @@ fn moving_panes_between_tabs_with_focus_change_recomputes_both() {
.unwrap();
}
- // Move the pane onto tab at position 0 AND switch client 1's focus there.
- // Client 1 leaves tab 1 → tab 1 grows back to client 2's size... wait,
- // client 2 already left tab 1 above. Tab 1 will be empty, so it keeps its
- // last size. Tab 0 gains client 1 alongside client 2 → recomputes to the
- // smaller of the two viewports.
screen
.break_multiple_panes_to_tab_with_index(vec![pane_to_move], 0, true, 1)
.expect("TEST");
@@ -9830,7 +9783,6 @@ fn detaching_client_grows_vacated_tab_back() {
new_tab(&mut screen, 1, 0);
screen.add_client(2, false).expect("TEST");
- // Tab 0 has both clients; the smaller one defines its size.
screen.set_client_size(
1,
Size {
@@ -9846,7 +9798,6 @@ fn detaching_client_grows_vacated_tab_back() {
"Smaller viewer wins while both clients are on the tab"
);
- // The smaller client leaves; the tab must grow to fit the remaining one.
screen.remove_client(2).expect("TEST");
assert_eq!(
screen.tabs.get(&0).unwrap().size,
@@ -9857,3 +9808,1276 @@ fn detaching_client_grows_vacated_tab_back() {
"Tab grows back to fit the remaining viewer after the smaller one detaches"
);
}
+
+fn add_second_pane_to_active_tab(screen: &mut Screen, pid: u32) {
+ let active_tab = screen.get_active_tab_mut(1).unwrap();
+ active_tab
+ .new_pane(
+ PaneId::Terminal(pid),
+ None,
+ None,
+ false,
+ true,
+ NewPanePlacement::default(),
+ Some(1),
+ None,
+ )
+ .unwrap();
+}
+
+fn setup_mobile_fit(screen: &mut Screen, client: ClientId, mobile_tab_idx: usize, plugin_id: u32) {
+ let run = RunPluginOrAlias::from_url("zellij:mobile", &None, None, None).unwrap();
+ // Bus::empty() has no to_plugin sender, but PluginPane::new requires one; leak the
+ // receiver so the injected sender's channel stays alive for the test's lifetime.
+ let (to_plugin, plugin_receiver): ChannelWithContext<PluginInstruction> = channels::unbounded();
+ Box::leak(Box::new(plugin_receiver));
+ screen
+ .bus
+ .senders
+ .replace_to_plugin(SenderWithContext::new(to_plugin));
+ screen
+ .new_tab(
+ mobile_tab_idx,
+ (vec![], vec![]),
+ Some("Mobile".to_string()),
+ Some(client),
+ )
+ .expect("TEST");
+ let tab = screen.tabs.get_mut(&mobile_tab_idx).expect("mobile tab");
+ tab.new_pane(
+ PaneId::Plugin(plugin_id),
+ None,
+ Some(Run::Plugin(run)),
+ false,
+ true,
+ NewPanePlacement::NoPreference {
+ borderless: Some(true),
+ },
+ Some(client),
+ None,
+ )
+ .expect("TEST");
+ screen.mobile_state.register_tab(client, mobile_tab_idx);
+}
+
+#[test]
+fn fit_override_resizes_tab() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+
+ let mobile_client = 1;
+ setup_mobile_fit(&mut screen, mobile_client, 9, 100);
+ let size = Size { rows: 12, cols: 60 };
+ screen
+ .set_tab_fit(mobile_client, 0, PaneId::Terminal(1), size)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab adopts the server-derived fit size"
+ );
+ assert_eq!(screen.mobile_state.fit_owner(0), Some(mobile_client));
+ assert_eq!(screen.mobile_state.fit_pane(0), Some(PaneId::Terminal(1)));
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(size));
+}
+
+#[test]
+fn fit_cleared_when_target_pane_closes() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+
+ let mobile_client = 1;
+ setup_mobile_fit(&mut screen, mobile_client, 9, 100);
+ screen
+ .set_tab_fit(
+ mobile_client,
+ 0,
+ PaneId::Terminal(1),
+ Size { rows: 12, cols: 60 },
+ )
+ .expect("TEST");
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Pre-condition: fit installed on tab 0"
+ );
+
+ assert!(
+ !screen
+ .clear_fit_for_closed_pane(PaneId::Terminal(2))
+ .expect("TEST"),
+ "Closing a non-target pane does not clear the fit"
+ );
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Fit survives an unrelated pane close"
+ );
+
+ screen
+ .tabs
+ .get_mut(&0)
+ .unwrap()
+ .close_pane(PaneId::Terminal(1), false, None);
+ assert!(
+ screen
+ .clear_fit_for_closed_pane(PaneId::Terminal(1))
+ .expect("TEST"),
+ "Closing the target pane clears the fit"
+ );
+ assert!(
+ !screen.mobile_state.has_fit(0),
+ "Fit override is dropped once its target pane is gone"
+ );
+}
+
+#[test]
+fn fit_override_captures_fullscreen_state() {
+ let initial_size = Size { cols: 80, rows: 20 };
+
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ {
+ let tab = screen.get_active_tab_mut(1).unwrap();
+ tab.toggle_pane_fullscreen(PaneId::Terminal(1));
+ assert!(tab.is_fullscreen_active());
+ }
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert!(
+ screen
+ .mobile_state
+ .fit_pane_was_fullscreen_before(0)
+ .expect("fit installed"),
+ "Pre-existing fullscreen recorded so exit/disconnect won't toggle it off"
+ );
+
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ assert!(
+ !screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Pre-condition: no fullscreen"
+ );
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert!(
+ !screen
+ .mobile_state
+ .fit_pane_was_fullscreen_before(0)
+ .expect("fit installed"),
+ "Fit toggled fullscreen on itself; exit path will revert it"
+ );
+ assert!(
+ screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Entering fit fullscreens the target pane"
+ );
+}
+
+#[test]
+fn exit_fit_reverts_size_and_fullscreen() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ screen.set_client_size(1, Size { cols: 80, rows: 20 });
+ screen.recompute_tab_size(0).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Pre-condition: override installed"
+ );
+ assert!(screen.tabs.get(&0).unwrap().is_fullscreen_active());
+
+ assert!(screen.exit_fit_mode(1).expect("TEST"));
+
+ assert!(screen.mobile_state.fit_count() == 0, "Override cleared");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ Size { cols: 80, rows: 20 },
+ "Tab grew back to its lone viewer's size"
+ );
+ assert!(
+ !screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Fit-induced fullscreen reverted on exit"
+ );
+}
+
+#[test]
+fn exit_fit_preserves_pre_fit_fullscreen() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ {
+ let tab = screen.get_active_tab_mut(1).unwrap();
+ tab.toggle_pane_fullscreen(PaneId::Terminal(1));
+ }
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+
+ screen.exit_fit_mode(1).expect("TEST");
+
+ assert!(screen.mobile_state.fit_count() == 0, "Override cleared");
+ assert!(
+ screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Pre-existing fullscreen preserved across exit"
+ );
+}
+
+#[test]
+fn set_tab_fit_update_changes_tab_size() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let before = screen.tabs.get(&0).unwrap().size;
+
+ let new_size = Size { rows: 11, cols: 60 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), new_size)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab tracks the server-derived size for the new embedded size"
+ );
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size.rows,
+ before.rows - 1,
+ "One fewer embedded row shrinks the target tab by one row"
+ );
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(new_size));
+}
+
+#[test]
+fn disconnect_clears_fit_for_owning_client() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen.add_client(2, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 80, rows: 20 });
+ screen.recompute_tab_size(0).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap()
+ );
+
+ screen.remove_client(1).expect("TEST");
+
+ assert!(
+ screen.mobile_state.fit_count() == 0,
+ "Override owned by the leaving client cleared"
+ );
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ Size { cols: 80, rows: 20 },
+ "Tab grew back to fit the remaining viewer (client 2)"
+ );
+ assert!(
+ !screen.tabs.get(&0).unwrap().is_fullscreen_active(),
+ "Fit-induced fullscreen reverted on disconnect"
+ );
+}
+
+#[test]
+fn disconnect_only_clears_own_fits() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_non_mirrored_screen(initial_size);
+ new_tab(&mut screen, 1, 0);
+ new_tab(&mut screen, 2, 1);
+ screen.add_client(2, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 40, rows: 10 });
+ screen.switch_active_tab(0, None, true, 1).expect("TEST");
+ screen.switch_active_tab(1, None, true, 2).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 8, 100);
+ setup_mobile_fit(&mut screen, 2, 9, 101);
+ let size = Size { rows: 12, cols: 60 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), size)
+ .expect("TEST");
+ screen
+ .set_tab_fit(2, 1, PaneId::Terminal(2), size)
+ .expect("TEST");
+ let surviving_size = screen.compute_fit_size(1).unwrap();
+
+ screen.remove_client(1).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.has_fit(0),
+ "Disconnecting client's entry cleared"
+ );
+ assert_eq!(
+ screen.mobile_state.fit_owner(1),
+ Some(2),
+ "Other client's entry survives"
+ );
+ assert_eq!(screen.mobile_state.fit_embedded_size(1), Some(size));
+ assert_eq!(
+ screen.tabs.get(&1).unwrap().size,
+ surviving_size,
+ "Tab still pinned to surviving client's override"
+ );
+}
+
+#[test]
+fn override_emits_display_clear() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen.add_client(2, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 80, rows: 20 });
+ screen.recompute_tab_size(0).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+
+ assert!(
+ screen
+ .tabs
+ .get(&0)
+ .unwrap()
+ .should_clear_display_before_rendering(),
+ "Tab must request a display-clear so a larger desktop viewer's \
+ viewport outside the fit area is wiped before the next render"
+ );
+}
+
+#[test]
+fn recompute_tab_size_short_circuit() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen.add_client(2, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(2, Size { cols: 80, rows: 20 });
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let override_size = screen.compute_fit_size(0).unwrap();
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ override_size,
+ "Pre-condition: override active"
+ );
+
+ screen.set_client_size(
+ 1,
+ Size {
+ cols: 100,
+ rows: 30,
+ },
+ );
+ screen.set_client_size(
+ 2,
+ Size {
+ cols: 120,
+ rows: 40,
+ },
+ );
+ screen.recompute_tab_size(0).expect("TEST");
+
+ let after = screen.tabs.get(&0).unwrap().size;
+ assert_eq!(
+ after,
+ screen.compute_fit_size(0).unwrap(),
+ "Override wins against the min-of-viewers path"
+ );
+ assert!(
+ after.rows < 30 || after.cols < 100,
+ "Override is smaller than the min-of-viewers size"
+ );
+}
+
+#[test]
+fn fit_collision_last_writer_wins() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ screen.add_client(2, false).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 8, 100);
+ setup_mobile_fit(&mut screen, 2, 9, 101);
+
+ let size_a = Size { rows: 12, cols: 60 };
+ let size_b = Size { rows: 10, cols: 50 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), size_a)
+ .expect("TEST");
+ screen
+ .set_tab_fit(2, 0, PaneId::Terminal(2), size_b)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.mobile_state.fit_owner(0),
+ Some(2),
+ "Second writer owns the entry"
+ );
+ assert_eq!(screen.mobile_state.fit_pane(0), Some(PaneId::Terminal(2)));
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(size_b));
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab size matches the second writer's override"
+ );
+}
+
+#[test]
+fn fit_update_after_collision_reclaims_ownership() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ screen.add_client(2, false).expect("TEST");
+ setup_mobile_fit(&mut screen, 1, 8, 100);
+ setup_mobile_fit(&mut screen, 2, 9, 101);
+
+ let size = Size { rows: 12, cols: 60 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), size)
+ .expect("TEST");
+ screen
+ .set_tab_fit(2, 0, PaneId::Terminal(2), size)
+ .expect("TEST");
+ assert_eq!(
+ screen.mobile_state.fit_owner(0),
+ Some(2),
+ "Pre-condition: client 2 displaced client 1"
+ );
+
+ let reclaim_size = Size { rows: 9, cols: 40 };
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), reclaim_size)
+ .expect("TEST");
+
+ assert_eq!(
+ screen.mobile_state.fit_owner(0),
+ Some(1),
+ "Ownership reattributed to caller"
+ );
+ assert_eq!(screen.mobile_state.fit_embedded_size(0), Some(reclaim_size));
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ screen.compute_fit_size(0).unwrap(),
+ "Tab size follows the reclaimed override"
+ );
+
+ screen.remove_client(2).expect("TEST");
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Override survives the original (now non-owning) client's disconnect"
+ );
+ screen.remove_client(1).expect("TEST");
+ assert!(
+ screen.mobile_state.fit_count() == 0,
+ "Override clears when the reclaim-owner disconnects"
+ );
+}
+
+#[test]
+fn fit_tab_close_cleans_state() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ new_tab(&mut screen, 2, 1);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Pre-condition: fit installed"
+ );
+
+ screen.close_tab_by_id(0).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.has_fit(0),
+ "fit_states entry removed when its tab is closed"
+ );
+ assert!(
+ !screen.tabs.contains_key(&0),
+ "Pre-condition: tab is actually gone"
+ );
+}
+
+#[test]
+fn disconnect_safe_with_orphan_fit() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ add_second_pane_to_active_tab(&mut screen, 2);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+
+ {
+ let tab = screen.get_active_tab_mut(1).unwrap();
+ tab.close_pane(PaneId::Terminal(1), false, None);
+ }
+ assert!(
+ screen.mobile_state.has_fit(0),
+ "Pre-condition: orphan entry present (cleanup deliberately skipped)"
+ );
+
+ screen
+ .remove_client(1)
+ .expect("Disconnect must not panic on dead pane_id");
+ assert!(
+ screen.mobile_state.fit_count() == 0,
+ "Disconnect cleanup clears the orphan"
+ );
+}
+
+#[test]
+fn fit_three_viewers_override_persists() {
+ let initial_size = Size {
+ cols: 200,
+ rows: 60,
+ };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen.add_client(2, false).expect("TEST");
+ screen.add_client(3, false).expect("TEST");
+ screen.set_client_size(1, Size { cols: 40, rows: 10 });
+ screen.set_client_size(
+ 2,
+ Size {
+ cols: 100,
+ rows: 30,
+ },
+ );
+ screen.set_client_size(
+ 3,
+ Size {
+ cols: 200,
+ rows: 60,
+ },
+ );
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+ screen
+ .tabs
+ .get_mut(&9)
+ .unwrap()
+ .resize_whole_tab(Size { cols: 40, rows: 10 })
+ .expect("TEST");
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let override_size = screen.compute_fit_size(0).unwrap();
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ override_size,
+ "Override wins over the three viewers' minimum"
+ );
+ assert!(
+ override_size.rows < 60 && override_size.cols < 200,
+ "Override is smaller than the desktop viewers' size"
+ );
+
+ screen.remove_client(2).expect("TEST");
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ override_size,
+ "Override survives non-owner disconnect"
+ );
+ assert!(screen.mobile_state.has_fit(0));
+
+ screen.remove_client(1).expect("TEST");
+ assert!(screen.mobile_state.fit_count() == 0);
+ assert_eq!(
+ screen.tabs.get(&0).unwrap().size,
+ Size {
+ cols: 200,
+ rows: 60
+ },
+ "Tab grew to fit the surviving desktop client"
+ );
+}
+
+#[test]
+fn fit_resize_repush_updates_tab() {
+ let initial_size = Size { cols: 80, rows: 20 };
+ let mut screen = create_new_screen(initial_size, true, true);
+ new_tab(&mut screen, 1, 0);
+ setup_mobile_fit(&mut screen, 1, 9, 100);
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 12, cols: 60 })
+ .expect("TEST");
+ let before = screen.tabs.get(&0).unwrap().size;
+
+ screen
+ .set_tab_fit(1, 0, PaneId::Terminal(1), Size { rows: 11, cols: 60 })
+ .expect("TEST");
+
+ let after = screen.tabs.get(&0).unwrap().size;
+ assert_eq!(
+ after,
+ screen.compute_fit_size(0).unwrap(),
+ "Target tab tracks the re-derived fit size after the re-push"
+ );
+ assert_eq!(
+ after.rows,
+ before.rows - 1,
+ "One fewer embedded row shrinks the target tab by exactly one row"
+ );
+ assert_eq!(
+ screen.mobile_state.fit_embedded_size(0),
+ Some(Size { rows: 11, cols: 60 }),
+ "Stored embedded size follows the re-push"
+ );
+}
+
+#[test]
+pub fn render_report_writes_per_client_plugin_pane_contents_in_fallback() {
+ use crate::plugins::PluginRenderAsset;
+ use zellij_utils::data::PaneId as DataPaneId;
+
+ let size = Size { cols: 80, rows: 20 };
+ let mut initial_layout = TiledPaneLayout::default();
+ let mut plugin_pane_layout = TiledPaneLayout::default();
+ plugin_pane_layout.run = Some(Run::Plugin(RunPluginOrAlias::RunPlugin(RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: RunPluginLocation::File(PathBuf::from("/path/to/fake/plugin")),
+ configuration: Default::default(),
+ ..Default::default()
+ })));
+ initial_layout.children_split_direction = SplitDirection::Vertical;
+ initial_layout.children = vec![plugin_pane_layout];
+
+ let mut mock_screen = MockScreen::new(size);
+ let screen_thread = mock_screen.run(Some(initial_layout), vec![]);
+
+ let received_plugin_instructions = Arc::new(Mutex::new(vec![]));
+ let plugin_receiver = mock_screen.plugin_receiver.take().unwrap();
+ let plugin_thread = log_actions_in_thread!(
+ received_plugin_instructions,
+ PluginInstruction::Exit,
+ plugin_receiver
+ );
+
+ let first_client_id: ClientId = mock_screen.main_client_id;
+ let second_client_id: ClientId = 2;
+ let _ = mock_screen.to_screen.send(ScreenInstruction::AddClient(
+ second_client_id,
+ false,
+ size,
+ None,
+ None,
+ ));
+ std::thread::sleep(std::time::Duration::from_millis(50));
+
+ let plugin_id: u32 = 1;
+ let bytes_for_client_1 = b"client one render payload".to_vec();
+ let bytes_for_client_2 = b"client two render payload".to_vec();
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::PluginBytes(vec![
+ PluginRenderAsset::new(plugin_id, first_client_id, bytes_for_client_1.clone()),
+ PluginRenderAsset::new(plugin_id, second_client_id, bytes_for_client_2.clone()),
+ ]));
+ std::thread::sleep(std::time::Duration::from_millis(50));
+
+ let _ = mock_screen
+ .to_screen
+ .send(ScreenInstruction::PluginSubscribedToAnsiPaneContents(true));
+ std::thread::sleep(std::time::Duration::from_millis(200));
+
+ mock_screen.teardown(vec![plugin_thread, screen_thread]);
+
+ let captured = received_plugin_instructions.lock().unwrap();
+ let last_report = captured
+ .iter()
+ .rev()
+ .find_map(|i| match i {
+ PluginInstruction::PaneRenderReport(r) => Some(r.clone()),
+ _ => None,
+ })
+ .expect("expected at least one PaneRenderReport on the plugin bus");
+
+ let plugin_pane_id = DataPaneId::Plugin(plugin_id);
+ let client_1_contents = last_report
+ .all_pane_contents_with_ansi
+ .get(&first_client_id)
+ .and_then(|p| p.get(&plugin_pane_id))
+ .cloned()
+ .expect("client 1 must have plugin-pane contents in the report");
+ let client_2_contents = last_report
+ .all_pane_contents_with_ansi
+ .get(&second_client_id)
+ .and_then(|p| p.get(&plugin_pane_id))
+ .cloned()
+ .expect("client 2 must have plugin-pane contents in the report");
+
+ assert!(
+ !client_1_contents.viewport.is_empty(),
+ "client 1's plugin-pane viewport must not be empty — the fallback \
+ must read from the client's per-client grid, not return \
+ Default::default() via a `None` client_id lookup"
+ );
+ assert!(
+ !client_2_contents.viewport.is_empty(),
+ "client 2's plugin-pane viewport must not be empty — same reason \
+ as client 1, but this is the path that was broken pre-fix for \
+ the mobile-UI case"
+ );
+ assert_ne!(
+ client_1_contents.viewport, client_2_contents.viewport,
+ "per-client plugin grids must surface distinct viewports when \
+ distinct PluginBytes were delivered to each client"
+ );
+
+ let joined_viewport_1 = client_1_contents.viewport.join("");
+ let joined_viewport_2 = client_2_contents.viewport.join("");
+ assert!(
+ joined_viewport_1.contains("client one render payload"),
+ "client 1's viewport must contain its bytes; got {:?}",
+ joined_viewport_1
+ );
+ assert!(
+ joined_viewport_2.contains("client two render payload"),
+ "client 2's viewport must contain its bytes; got {:?}",
+ joined_viewport_2
+ );
+}
+
+const MOBILE_BASE_SIZE: Size = Size {
+ cols: 121,
+ rows: 40,
+};
+const MOBILE_SMALL: Size = Size { cols: 40, rows: 20 };
+const MOBILE_LARGE: Size = Size {
+ cols: 200,
+ rows: 200,
+};
+const MOBILE_THRESHOLDS: (u16, u16) = (60, 30);
+
+fn setup_mobile_screen() -> Screen {
+ let mut screen = create_new_screen(MOBILE_BASE_SIZE, true, true);
+ new_tab(&mut screen, 1, 0);
+ screen
+}
+
+#[test]
+fn reevaluate_mobile_routes_web_client_in_web_mode() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 10;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "web client + small viewport in Web mode must enter mobile",
+ );
+ assert!(
+ screen.mobile_state.was_auto_entered(client),
+ "auto-entry must be marked so a later resize can auto-demote",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_skips_terminal_client_in_web_mode() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 11;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "terminal client in Web mode must NOT enter mobile even with small viewport",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_routes_terminal_client_in_always_mode() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 12;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Always,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "Always mode must route terminal clients too on size match",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_zero_threshold_forces_entry() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 13;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_LARGE,
+ MobileLayoutConfiguration::Always,
+ 0,
+ 0,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "0 breakpoint under Always must route regardless of viewport size",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_never_never_routes() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 14;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Never,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "Never must never auto-route, even for a small web client",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_auto_demotes_after_growth() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 15;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(screen.is_in_mobile_mode(client));
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_LARGE,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "auto-entered client must auto-demote when viewport grows",
+ );
+}
+
+#[test]
+fn reevaluate_mobile_preserves_manual_entry_when_viewport_grows() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 16;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen.enter_mobile_mode(client).expect("TEST");
+ assert!(screen.is_in_mobile_mode(client));
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "manual entry must not be marked as auto",
+ );
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_LARGE,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "manually-entered client must NOT be auto-demoted on resize",
+ );
+}
+
+#[test]
+fn enter_mobile_mode_populates_consolidated_state() {
+ let mut screen = setup_mobile_screen();
+ let client = 20;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen.enter_mobile_mode(client).expect("TEST");
+
+ assert!(
+ screen.mobile_state.is_in_mobile_mode(client),
+ "entering mobile mode must record the client's mobile tab",
+ );
+ assert_eq!(
+ screen.mobile_state.previous_tab(client),
+ Some(0),
+ "the seeded tab 0 must be stashed as the prior tab",
+ );
+ assert!(
+ screen.is_in_mobile_mode(client),
+ "is_in_mobile_mode must reflect the new mobile_state.tabs entry",
+ );
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "manual enter_mobile_mode must not mark the client as auto-entered",
+ );
+}
+
+#[test]
+fn exit_mobile_mode_clears_all_consolidated_state() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 21;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(screen.mobile_state.is_in_mobile_mode(client));
+ assert!(screen.mobile_state.was_auto_entered(client));
+
+ screen.exit_mobile_mode(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.is_in_mobile_mode(client),
+ "exit must remove the client's mobile tab entry",
+ );
+ assert!(
+ !screen.mobile_state.previous_tab(client).is_some(),
+ "exit must drop the stashed prior-tab entry",
+ );
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "exit must clear the auto-entered marker",
+ );
+ assert!(
+ !screen.is_in_mobile_mode(client),
+ "client must no longer be in mobile mode after exit",
+ );
+}
+
+#[test]
+fn remove_client_clears_all_consolidated_state() {
+ use zellij_utils::input::options::MobileLayoutConfiguration;
+ let mut screen = setup_mobile_screen();
+ let client = 22;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen
+ .reevaluate_mobile_mode(
+ client,
+ MOBILE_SMALL,
+ MobileLayoutConfiguration::Web,
+ MOBILE_THRESHOLDS.0,
+ MOBILE_THRESHOLDS.1,
+ )
+ .expect("TEST");
+ assert!(screen.mobile_state.is_in_mobile_mode(client));
+ assert!(screen.mobile_state.was_auto_entered(client));
+
+ screen.remove_client(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.is_in_mobile_mode(client),
+ "disconnect must remove the client's mobile tab entry",
+ );
+ assert!(
+ !screen.mobile_state.previous_tab(client).is_some(),
+ "disconnect must drop the stashed prior-tab entry",
+ );
+ assert!(
+ !screen.mobile_state.was_auto_entered(client),
+ "disconnect must clear the auto-entered marker",
+ );
+}
+
+#[test]
+fn mobile_state_tracks_clients_independently() {
+ let mut screen = setup_mobile_screen();
+ let client_a = 23;
+ let client_b = 24;
+ screen
+ .add_client(client_a, /* is_web_client */ true)
+ .expect("TEST");
+ screen
+ .add_client(client_b, /* is_web_client */ true)
+ .expect("TEST");
+
+ screen.enter_mobile_mode(client_a).expect("TEST");
+ screen.enter_mobile_mode(client_b).expect("TEST");
+
+ let tab_a = screen
+ .mobile_state
+ .mobile_tab_id(client_a)
+ .expect("client A must have a mobile tab");
+ let tab_b = screen
+ .mobile_state
+ .mobile_tab_id(client_b)
+ .expect("client B must have a mobile tab");
+ assert_ne!(
+ tab_a, tab_b,
+ "each mobile client must get its own dedicated tab",
+ );
+
+ screen.remove_client(client_a).expect("TEST");
+
+ assert!(
+ !screen.mobile_state.is_in_mobile_mode(client_a),
+ "removed client's entry must be gone",
+ );
+ assert!(
+ screen.mobile_state.is_in_mobile_mode(client_b),
+ "the other client's mobile state must be untouched",
+ );
+}
+
+#[test]
+fn render_gate_can_be_set_and_lifted() {
+ let mut screen = setup_mobile_screen();
+ let client = 40;
+
+ assert!(!screen.mobile_render_gate.is_gated(client));
+ screen.mobile_render_gate.gate(client);
+ assert!(screen.mobile_render_gate.is_gated(client));
+ screen.mobile_render_gate.ungate(client);
+ assert!(!screen.mobile_render_gate.is_gated(client));
+}
+
+#[test]
+fn remove_client_lifts_render_gate() {
+ let mut screen = setup_mobile_screen();
+ let client = 41;
+ screen
+ .add_client(client, /* is_web_client */ false)
+ .expect("TEST");
+ screen.mobile_render_gate.gate(client);
+
+ screen.remove_client(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_render_gate.is_gated(client),
+ "a removed client must never stay gated",
+ );
+}
+
+#[test]
+fn exit_mobile_mode_lifts_render_gate() {
+ let mut screen = setup_mobile_screen();
+ let client = 42;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ screen.enter_mobile_mode(client).expect("TEST");
+ screen.mobile_render_gate.gate(client);
+
+ screen.exit_mobile_mode(client).expect("TEST");
+
+ assert!(
+ !screen.mobile_render_gate.is_gated(client),
+ "leaving mobile mode must never leave the client gated",
+ );
+}
+
+#[test]
+fn first_mobile_plugin_paint_lifts_only_the_owning_client() {
+ let mut screen = setup_mobile_screen();
+ let owner = 43;
+ let other = 44;
+ let mobile_tab_idx = 9;
+ let plugin_id = 900;
+ screen
+ .add_client(owner, /* is_web_client */ true)
+ .expect("TEST");
+ setup_mobile_fit(&mut screen, owner, mobile_tab_idx, plugin_id);
+ screen.mobile_render_gate.gate(owner);
+ screen.mobile_render_gate.gate(other);
+
+ screen.ungate_clients_for_mobile_plugin(plugin_id);
+
+ assert!(
+ !screen.mobile_render_gate.is_gated(owner),
+ "the client whose mobile plugin painted must be ungated",
+ );
+ assert!(
+ screen.mobile_render_gate.is_gated(other),
+ "a client unrelated to the painting plugin must stay gated",
+ );
+}
+
+fn capture_plugin_channel(screen: &mut Screen) -> Receiver<(PluginInstruction, ErrorContext)> {
+ let (to_plugin, plugin_receiver): ChannelWithContext<PluginInstruction> = channels::unbounded();
+ screen
+ .bus
+ .senders
+ .replace_to_plugin(SenderWithContext::new(to_plugin));
+ plugin_receiver
+}
+
+fn drain_plugin_instructions(
+ receiver: &Receiver<(PluginInstruction, ErrorContext)>,
+) -> Vec<PluginInstruction> {
+ let mut instructions = vec![];
+ while let Ok((instruction, _err_ctx)) = receiver.try_recv() {
+ instructions.push(instruction);
+ }
+ instructions
+}
+
+#[test]
+fn entering_mobile_while_gated_holds_the_plugin_render() {
+ let mut screen = setup_mobile_screen();
+ let client = 50;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ let plugin_receiver = capture_plugin_channel(&mut screen);
+
+ screen.mobile_render_gate.gate(client); // SuppressRenderUntilMobile does this
+ screen.enter_mobile_mode(client).expect("TEST");
+
+ let sent = drain_plugin_instructions(&plugin_receiver);
+ assert!(
+ sent.iter()
+ .any(|i| matches!(i, PluginInstruction::HoldMobileRender(c) if *c == client)),
+ "entering mobile while gated must hold the plugin render; sent={sent:?}",
+ );
+}
+
+#[test]
+fn entering_mobile_without_a_gate_does_not_hold() {
+ let mut screen = setup_mobile_screen();
+ let client = 51;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ let plugin_receiver = capture_plugin_channel(&mut screen);
+
+ // No SuppressRenderUntilMobile: the client is not gated.
+ screen.enter_mobile_mode(client).expect("TEST");
+
+ let sent = drain_plugin_instructions(&plugin_receiver);
+ assert!(
+ !sent
+ .iter()
+ .any(|i| matches!(i, PluginInstruction::HoldMobileRender(_))),
+ "a non-gated client must not hold the plugin render; sent={sent:?}",
+ );
+}
+
+#[test]
+fn exiting_mobile_releases_the_plugin_render() {
+ let mut screen = setup_mobile_screen();
+ let client = 52;
+ screen
+ .add_client(client, /* is_web_client */ true)
+ .expect("TEST");
+ screen.enter_mobile_mode(client).expect("TEST");
+ let plugin_receiver = capture_plugin_channel(&mut screen);
+
+ screen.exit_mobile_mode(client).expect("TEST");
+
+ let sent = drain_plugin_instructions(&plugin_receiver);
+ assert!(
+ sent.iter()
+ .any(|i| matches!(i, PluginInstruction::ReleaseMobileRender(c) if *c == client)),
+ "exiting mobile must release the plugin render; sent={sent:?}",
+ );
+}
diff --git a/zellij-tile/src/prelude.rs b/zellij-tile/src/prelude.rs
index 21d1317f..d4a84e6a 100644
--- a/zellij-tile/src/prelude.rs
+++ b/zellij-tile/src/prelude.rs
@@ -4,3 +4,4 @@ pub use zellij_utils::consts::VERSION;
pub use zellij_utils::data::*;
pub use zellij_utils::errors::prelude::*;
pub use zellij_utils::input::actions;
+pub use zellij_utils::pane_size::Size;
diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs
index e766655e..a7c1589f 100644
--- a/zellij-tile/src/shim.rs
+++ b/zellij-tile/src/shim.rs
@@ -7,6 +7,7 @@ use std::{
use zellij_utils::data::*;
use zellij_utils::errors::prelude::*;
use zellij_utils::input::actions::Action;
+use zellij_utils::pane_size::Size;
pub use zellij_utils::plugin_api;
use zellij_utils::plugin_api::event::ProtobufPaneScrollbackResponse;
use zellij_utils::plugin_api::generated_api::api::plugin_command::{
@@ -26,7 +27,8 @@ use zellij_utils::plugin_api::plugin_command::{
ProtobufGetPanePidResponse, ProtobufGetPaneRunningCommandResponse,
ProtobufGetSessionEnvironmentVariablesResponse, ProtobufGetSessionListResponse,
ProtobufGetTabInfoResponse, ProtobufHideFloatingPanesResponse, ProtobufKillSessionsResponse,
- ProtobufNewTabResponse, ProtobufNewTabsResponse, ProtobufOpenCommandPaneBackgroundResponse,
+ ProtobufNewTabResponse, ProtobufNewTabUnfocusedResponse, ProtobufNewTabsResponse,
+ ProtobufNewTiledPaneInTabResponse, ProtobufOpenCommandPaneBackgroundResponse,
ProtobufOpenCommandPaneFloatingNearPluginResponse, ProtobufOpenCommandPaneFloatingResponse,
ProtobufOpenCommandPaneInPlaceOfPaneIdResponse, ProtobufOpenCommandPaneInPlaceOfPluginResponse,
ProtobufOpenCommandPaneInPlaceResponse, ProtobufOpenCommandPaneNearPluginResponse,
@@ -961,6 +963,33 @@ where
NewTabResponse::try_from(response).unwrap()
}
+pub fn new_tab_unfocused<S: AsRef<str>>(name: Option<S>, cwd: Option<S>) -> Option<usize>
+where
+ S: ToString,
+{
+ let name = name.map(|s| s.to_string());
+ let cwd = cwd.map(|s| s.to_string());
+ let plugin_command = PluginCommand::NewTabUnfocused { name, cwd };
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+
+ let response =
+ ProtobufNewTabUnfocusedResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
+ NewTabUnfocusedResponse::try_from(response).unwrap()
+}
+
+pub fn new_tiled_pane_in_tab(tab_position: usize) -> Option<PaneId> {
+ let plugin_command = PluginCommand::NewTiledPaneInTab { tab_position };
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+
+ let response =
+ ProtobufNewTiledPaneInTabResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
+ NewTiledPaneInTabResponse::try_from(response).unwrap()
+}
+
/// Opens a new tab with a command pane running `command_to_run`.
/// Returns `(tab_id, pane_id)` of the created tab and pane, or `None` if unavailable.
pub fn open_command_pane_in_new_tab(
@@ -1649,6 +1678,34 @@ pub fn scan_host_folder<S: AsRef<Path>>(folder_to_scan: &S) {
unsafe { host_run_plugin_command() };
}
+pub fn set_soft_keyboard(on: bool) {
+ let plugin_command = PluginCommand::SetSoftKeyboard(on);
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
+pub fn set_tab_fit(tab_id: usize, fit: Option<(PaneId, Size)>) {
+ let plugin_command = PluginCommand::SetTabFit { tab_id, fit };
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
+pub fn exit_mobile_mode() {
+ let plugin_command = PluginCommand::ExitMobileMode;
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
+pub fn set_shadow_focus(pane_id: PaneId) {
+ let plugin_command = PluginCommand::SetShadowFocus(pane_id);
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
/// Start watching the host folder for filesystem changes (Note: somewhat unstable at the time
/// being)
pub fn watch_filesystem() {
@@ -2900,7 +2957,11 @@ pub fn clear_pane_highlights(pane_id: PaneId) {
unsafe { host_run_plugin_command() };
}
+#[cfg(target_arch = "wasm32")]
#[link(wasm_import_module = "zellij")]
extern "C" {
fn host_run_plugin_command();
}
+
+#[cfg(not(target_arch = "wasm32"))]
+unsafe fn host_run_plugin_command() {}
diff --git a/zellij-utils/assets/config/default.kdl b/zellij-utils/assets/config/default.kdl
index 3ea42850..61aa4bb8 100644
--- a/zellij-utils/assets/config/default.kdl
+++ b/zellij-utils/assets/config/default.kdl
@@ -242,6 +242,7 @@ plugins {
configuration location="zellij:configuration"
plugin-manager location="zellij:plugin-manager"
about location="zellij:about"
+ mobile location="zellij:mobile"
}
// Plugins to load in the background when a new session starts
diff --git a/zellij-utils/assets/plugins/mobile.wasm b/zellij-utils/assets/plugins/mobile.wasm
new file mode 100755
index 00000000..d31d600e
--- /dev/null
+++ b/zellij-utils/assets/plugins/mobile.wasm
Binary files differ
diff --git a/zellij-utils/assets/prost/api.action.rs b/zellij-utils/assets/prost/api.action.rs
index 17bdc9c0..8d331b60 100644
--- a/zellij-utils/assets/prost/api.action.rs
+++ b/zellij-utils/assets/prost/api.action.rs
@@ -793,6 +793,10 @@ pub struct MouseEventPayload {
pub line: i64,
#[prost(int64, tag="11")]
pub column: i64,
+ #[prost(bool, tag="12")]
+ pub wheel_left: bool,
+ #[prost(bool, tag="13")]
+ pub wheel_right: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
diff --git a/zellij-utils/assets/prost/api.event.rs b/zellij-utils/assets/prost/api.event.rs
index 38976384..eadb9394 100644
--- a/zellij-utils/assets/prost/api.event.rs
+++ b/zellij-utils/assets/prost/api.event.rs
@@ -9,7 +9,7 @@ pub struct EventNameList {
pub struct Event {
#[prost(enumeration="EventType", tag="1")]
pub name: i32,
- #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40")]
+ #[prost(oneof="event::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41")]
pub payload: ::core::option::Option<event::Payload>,
}
/// Nested message and enum types in `Event`.
@@ -95,6 +95,8 @@ pub mod event {
CommandChangedPayload(super::CommandChangedPayload),
#[prost(message, tag="40")]
HostTerminalThemeChangedPayload(super::HostTerminalThemeChangedPayload),
+ #[prost(message, tag="41")]
+ SoftKeyboardVisibilityChangedPayload(super::SoftKeyboardVisibilityChangedPayload),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -105,6 +107,12 @@ pub struct HostTerminalThemeChangedPayload {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SoftKeyboardVisibilityChangedPayload {
+ #[prost(bool, tag="1")]
+ pub visible: bool,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct CwdChangedPayload {
#[prost(message, optional, tag="1")]
pub pane_id: ::core::option::Option<PaneId>,
@@ -703,6 +711,8 @@ pub struct PaneContents {
pub lines_above_viewport: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
#[prost(string, repeated, tag="4")]
pub lines_below_viewport: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
+ #[prost(message, optional, tag="5")]
+ pub cursor: ::core::option::Option<super::action::Position>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -839,6 +849,7 @@ pub enum EventType {
InitialKeybinds = 44,
CommandChanged = 45,
HostTerminalThemeChanged = 46,
+ SoftKeyboardVisibilityChanged = 47,
}
impl EventType {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -893,6 +904,7 @@ impl EventType {
EventType::InitialKeybinds => "InitialKeybinds",
EventType::CommandChanged => "CommandChanged",
EventType::HostTerminalThemeChanged => "HostTerminalThemeChanged",
+ EventType::SoftKeyboardVisibilityChanged => "SoftKeyboardVisibilityChanged",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -944,6 +956,7 @@ impl EventType {
"InitialKeybinds" => Some(Self::InitialKeybinds),
"CommandChanged" => Some(Self::CommandChanged),
"HostTerminalThemeChanged" => Some(Self::HostTerminalThemeChanged),
+ "SoftKeyboardVisibilityChanged" => Some(Self::SoftKeyboardVisibilityChanged),
_ => None,
}
}
@@ -1069,6 +1082,8 @@ pub enum MouseEventName {
MouseHold = 4,
MouseRelease = 5,
MouseHover = 6,
+ MouseScrollLeft = 7,
+ MouseScrollRight = 8,
}
impl MouseEventName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -1084,6 +1099,8 @@ impl MouseEventName {
MouseEventName::MouseHold => "MouseHold",
MouseEventName::MouseRelease => "MouseRelease",
MouseEventName::MouseHover => "MouseHover",
+ MouseEventName::MouseScrollLeft => "MouseScrollLeft",
+ MouseEventName::MouseScrollRight => "MouseScrollRight",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -1096,6 +1113,8 @@ impl MouseEventName {
"MouseHold" => Some(Self::MouseHold),
"MouseRelease" => Some(Self::MouseRelease),
"MouseHover" => Some(Self::MouseHover),
+ "MouseScrollLeft" => Some(Self::MouseScrollLeft),
+ "MouseScrollRight" => Some(Self::MouseScrollRight),
_ => None,
}
}
diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs
index 1cb4e5d0..b43bd004 100644
--- a/zellij-utils/assets/prost/api.plugin_command.rs
+++ b/zellij-utils/assets/prost/api.plugin_command.rs
@@ -3,7 +3,7 @@
pub struct PluginCommand {
#[prost(enumeration="CommandName", tag="1")]
pub name: i32,
- #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163")]
+ #[prost(oneof="plugin_command::Payload", tags="2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 167, 168, 169")]
pub payload: ::core::option::Option<plugin_command::Payload>,
}
/// Nested message and enum types in `PluginCommand`.
@@ -309,6 +309,16 @@ pub mod plugin_command {
KillSessionsAndReplyPayload(super::KillSessionsPayload),
#[prost(string, tag="163")]
DeleteDeadSessionAndReplyPayload(::prost::alloc::string::String),
+ #[prost(message, tag="164")]
+ SetSoftKeyboardPayload(super::SetSoftKeyboardPayload),
+ #[prost(message, tag="165")]
+ SetTabFitPayload(super::SetTabFitPayload),
+ #[prost(message, tag="167")]
+ SetShadowFocusPayload(super::PaneId),
+ #[prost(message, tag="168")]
+ NewTabUnfocusedPayload(super::NewTabUnfocusedPayload),
+ #[prost(message, tag="169")]
+ NewTiledPaneInTabPayload(super::NewTiledPaneInTabPayload),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -321,6 +331,20 @@ pub struct NewTabPayload {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct NewTabUnfocusedPayload {
+ #[prost(string, optional, tag="1")]
+ pub name: ::core::option::Option<::prost::alloc::string::String>,
+ #[prost(string, optional, tag="2")]
+ pub cwd: ::core::option::Option<::prost::alloc::string::String>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct NewTiledPaneInTabPayload {
+ #[prost(uint32, tag="1")]
+ pub tab_position: u32,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DumpSessionLayoutPayload {
#[prost(uint32, optional, tag="1")]
pub tab_index: ::core::option::Option<u32>,
@@ -1483,6 +1507,29 @@ pub mod new_tab_response {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct NewTabUnfocusedResponse {
+ #[prost(oneof="new_tab_unfocused_response::Result", tags="1, 2")]
+ pub result: ::core::option::Option<new_tab_unfocused_response::Result>,
+}
+/// Nested message and enum types in `NewTabUnfocusedResponse`.
+pub mod new_tab_unfocused_response {
+ #[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Oneof)]
+ pub enum Result {
+ #[prost(uint64, tag="1")]
+ TabId(u64),
+ #[prost(bool, tag="2")]
+ None(bool),
+ }
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct NewTiledPaneInTabResponse {
+ #[prost(message, optional, tag="1")]
+ pub pane_id: ::core::option::Option<PaneId>,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct NewTabsResponse {
/// Only includes successfully created tabs
#[prost(uint64, repeated, tag="1")]
@@ -1955,6 +2002,30 @@ pub struct DeleteAllDeadSessionsResponse {
#[prost(string, optional, tag="1")]
pub error: ::core::option::Option<::prost::alloc::string::String>,
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SetSoftKeyboardPayload {
+ #[prost(bool, tag="1")]
+ pub on: bool,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct Size {
+ #[prost(uint32, tag="1")]
+ pub rows: u32,
+ #[prost(uint32, tag="2")]
+ pub cols: u32,
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SetTabFitPayload {
+ #[prost(uint32, tag="1")]
+ pub tab_id: u32,
+ #[prost(message, optional, tag="2")]
+ pub pane_id: ::core::option::Option<PaneId>,
+ #[prost(message, optional, tag="3")]
+ pub size: ::core::option::Option<Size>,
+}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum CommandName {
@@ -2152,6 +2223,12 @@ pub enum CommandName {
KillSessionsAndReply = 212,
DeleteDeadSessionAndReply = 213,
DeleteAllDeadSessionsAndReply = 214,
+ SetSoftKeyboard = 215,
+ SetTabFit = 216,
+ SetShadowFocus = 219,
+ NewTabUnfocused = 220,
+ NewTiledPaneInTab = 221,
+ ExitMobileMode = 222,
}
impl CommandName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -2354,6 +2431,12 @@ impl CommandName {
CommandName::KillSessionsAndReply => "KillSessionsAndReply",
CommandName::DeleteDeadSessionAndReply => "DeleteDeadSessionAndReply",
CommandName::DeleteAllDeadSessionsAndReply => "DeleteAllDeadSessionsAndReply",
+ CommandName::SetSoftKeyboard => "SetSoftKeyboard",
+ CommandName::SetTabFit => "SetTabFit",
+ CommandName::SetShadowFocus => "SetShadowFocus",
+ CommandName::NewTabUnfocused => "NewTabUnfocused",
+ CommandName::NewTiledPaneInTab => "NewTiledPaneInTab",
+ CommandName::ExitMobileMode => "ExitMobileMode",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -2553,6 +2636,12 @@ impl CommandName {
"KillSessionsAndReply" => Some(Self::KillSessionsAndReply),
"DeleteDeadSessionAndReply" => Some(Self::DeleteDeadSessionAndReply),
"DeleteAllDeadSessionsAndReply" => Some(Self::DeleteAllDeadSessionsAndReply),
+ "SetSoftKeyboard" => Some(Self::SetSoftKeyboard),
+ "SetTabFit" => Some(Self::SetTabFit),
+ "SetShadowFocus" => Some(Self::SetShadowFocus),
+ "NewTabUnfocused" => Some(Self::NewTabUnfocused),
+ "NewTiledPaneInTab" => Some(Self::NewTiledPaneInTab),
+ "ExitMobileMode" => Some(Self::ExitMobileMode),
_ => None,
}
}
diff --git a/zellij-utils/assets/prost_ipc/client_server_contract.rs b/zellij-utils/assets/prost_ipc/client_server_contract.rs
index 142609f7..4ef94968 100644
--- a/zellij-utils/assets/prost_ipc/client_server_contract.rs
+++ b/zellij-utils/assets/prost_ipc/client_server_contract.rs
@@ -119,7 +119,7 @@ pub struct RgbColor {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Action {
- #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139")]
+ #[prost(oneof="action::ActionType", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140")]
pub action_type: ::core::option::Option<action::ActionType>,
}
/// Nested message and enum types in `Action`.
@@ -407,6 +407,8 @@ pub mod action {
SetLightTheme(super::SetLightThemeAction),
#[prost(message, tag="139")]
ToggleTheme(super::ToggleThemeAction),
+ #[prost(message, tag="140")]
+ ToggleMobileMode(super::ToggleMobileModeAction),
}
}
// Action message definitions (all 92 variants)
@@ -586,6 +588,10 @@ pub struct ToggleMouseModeAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct ToggleMobileModeAction {
+}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
pub struct PreviousSwapLayoutAction {
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -1706,6 +1712,10 @@ pub struct MouseEvent {
pub ctrl: bool,
#[prost(message, optional, tag="10")]
pub position: ::core::option::Option<Position>,
+ #[prost(bool, tag="11")]
+ pub wheel_left: bool,
+ #[prost(bool, tag="12")]
+ pub wheel_right: bool,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -1949,6 +1959,12 @@ pub struct Options {
pub theme_dark: ::core::option::Option<::prost::alloc::string::String>,
#[prost(string, optional, tag="47")]
pub theme_light: ::core::option::Option<::prost::alloc::string::String>,
+ #[prost(enumeration="MobileLayout", optional, tag="48")]
+ pub mobile_layout: ::core::option::Option<i32>,
+ #[prost(uint32, optional, tag="49")]
+ pub mobile_threshold_cols: ::core::option::Option<u32>,
+ #[prost(uint32, optional, tag="50")]
+ pub mobile_threshold_rows: ::core::option::Option<u32>,
}
/// Pane-targeting action messages
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -2876,10 +2892,42 @@ impl WebSharing {
}
}
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+#[repr(i32)]
+pub enum MobileLayout {
+ Unspecified = 0,
+ Web = 1,
+ Always = 2,
+ Never = 3,
+}
+impl MobileLayout {
+ /// String value of the enum field names used in the ProtoBuf definition.
+ ///
+ /// The values are not transformed in any way and thus are considered stable
+ /// (if the ProtoBuf definition does not change) and safe for programmatic use.
+ pub fn as_str_name(&self) -> &'static str {
+ match self {
+ MobileLayout::Unspecified => "MOBILE_LAYOUT_UNSPECIFIED",
+ MobileLayout::Web => "MOBILE_LAYOUT_WEB",
+ MobileLayout::Always => "MOBILE_LAYOUT_ALWAYS",
+ MobileLayout::Never => "MOBILE_LAYOUT_NEVER",
+ }
+ }
+ /// Creates an enum from field names used in the ProtoBuf definition.
+ pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
+ match value {
+ "MOBILE_LAYOUT_UNSPECIFIED" => Some(Self::Unspecified),
+ "MOBILE_LAYOUT_WEB" => Some(Self::Web),
+ "MOBILE_LAYOUT_ALWAYS" => Some(Self::Always),
+ "MOBILE_LAYOUT_NEVER" => Some(Self::Never),
+ _ => None,
+ }
+ }
+}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ClientToServerMsg {
- #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20")]
+ #[prost(oneof="client_to_server_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21")]
pub message: ::core::option::Option<client_to_server_msg::Message>,
}
/// Nested message and enum types in `ClientToServerMsg`.
@@ -2927,6 +2975,8 @@ pub mod client_to_server_msg {
ForwardedReplyFromHost(super::ForwardedReplyFromHostMsg),
#[prost(message, tag="20")]
HostTerminalThemeChanged(super::HostTerminalThemeChangedMsg),
+ #[prost(message, tag="21")]
+ SoftKeyboardVisibilityChanged(super::SoftKeyboardVisibilityChangedMsg),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -2964,6 +3014,8 @@ pub struct ColorRegistersMsg {
pub struct TerminalResizeMsg {
#[prost(message, optional, tag="1")]
pub new_size: ::core::option::Option<Size>,
+ #[prost(enumeration="ResizeCause", tag="2")]
+ pub cause: i32,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -3072,6 +3124,41 @@ pub struct HostTerminalThemeChangedMsg {
#[prost(enumeration="HostTerminalThemeIndication", tag="1")]
pub mode: i32,
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SoftKeyboardVisibilityChangedMsg {
+ #[prost(bool, tag="1")]
+ pub visible: bool,
+}
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
+#[repr(i32)]
+pub enum ResizeCause {
+ Viewport = 0,
+ RenderingPreference = 1,
+ SizeSettled = 2,
+}
+impl ResizeCause {
+ /// String value of the enum field names used in the ProtoBuf definition.
+ ///
+ /// The values are not transformed in any way and thus are considered stable
+ /// (if the ProtoBuf definition does not change) and safe for programmatic use.
+ pub fn as_str_name(&self) -> &'static str {
+ match self {
+ ResizeCause::Viewport => "RESIZE_CAUSE_VIEWPORT",
+ ResizeCause::RenderingPreference => "RESIZE_CAUSE_RENDERING_PREFERENCE",
+ ResizeCause::SizeSettled => "RESIZE_CAUSE_SIZE_SETTLED",
+ }
+ }
+ /// Creates an enum from field names used in the ProtoBuf definition.
+ pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
+ match value {
+ "RESIZE_CAUSE_VIEWPORT" => Some(Self::Viewport),
+ "RESIZE_CAUSE_RENDERING_PREFERENCE" => Some(Self::RenderingPreference),
+ "RESIZE_CAUSE_SIZE_SETTLED" => Some(Self::SizeSettled),
+ _ => None,
+ }
+ }
+}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum HostTerminalThemeIndication {
@@ -3101,7 +3188,7 @@ impl HostTerminalThemeIndication {
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ServerToClientMsg {
- #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16")]
+ #[prost(oneof="server_to_client_msg::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17")]
pub message: ::core::option::Option<server_to_client_msg::Message>,
}
/// Nested message and enum types in `ServerToClientMsg`.
@@ -3141,6 +3228,8 @@ pub mod server_to_client_msg {
SubscribedPaneClosed(super::SubscribedPaneClosedMsg),
#[prost(message, tag="16")]
ForwardQueryToHost(super::ForwardQueryToHostMsg),
+ #[prost(message, tag="17")]
+ SetSoftKeyboard(super::SetSoftKeyboardMsg),
}
}
#[allow(clippy::derive_partial_eq_without_eq)]
@@ -3248,3 +3337,9 @@ pub struct ForwardQueryToHostMsg {
#[prost(bytes="vec", tag="2")]
pub query_bytes: ::prost::alloc::vec::Vec<u8>,
}
+#[allow(clippy::derive_partial_eq_without_eq)]
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct SetSoftKeyboardMsg {
+ #[prost(bool, tag="1")]
+ pub on: bool,
+}
diff --git a/zellij-utils/src/client_server_contract/client_to_server.proto b/zellij-utils/src/client_server_contract/client_to_server.proto
index 834feccd..bc7c0c06 100644
--- a/zellij-utils/src/client_server_contract/client_to_server.proto
+++ b/zellij-utils/src/client_server_contract/client_to_server.proto
@@ -25,6 +25,7 @@ message ClientToServerMsg {
DesktopNotificationResponseMsg desktop_notification_response = 18;
ForwardedReplyFromHostMsg forwarded_reply_from_host = 19;
HostTerminalThemeChangedMsg host_terminal_theme_changed = 20;
+ SoftKeyboardVisibilityChangedMsg soft_keyboard_visibility_changed = 21;
}
}
@@ -50,6 +51,13 @@ message ColorRegistersMsg {
message TerminalResizeMsg {
Size new_size = 1;
+ ResizeCause cause = 2;
+}
+
+enum ResizeCause {
+ RESIZE_CAUSE_VIEWPORT = 0;
+ RESIZE_CAUSE_RENDERING_PREFERENCE = 1;
+ RESIZE_CAUSE_SIZE_SETTLED = 2;
}
message FirstClientConnectedMsg {
@@ -125,3 +133,7 @@ enum HostTerminalThemeIndication {
HOST_TERMINAL_THEME_INDICATION_DARK = 0;
HOST_TERMINAL_THEME_INDICATION_LIGHT = 1;
}
+
+message SoftKeyboardVisibilityChangedMsg {
+ bool visible = 1;
+}
diff --git a/zellij-utils/src/client_server_contract/common_types.proto b/zellij-utils/src/client_server_contract/common_types.proto
index b592eb8d..1dfc3994 100644
--- a/zellij-utils/src/client_server_contract/common_types.proto
+++ b/zellij-utils/src/client_server_contract/common_types.proto
@@ -277,6 +277,7 @@ message Action {
SetDarkThemeAction set_dark_theme = 137;
SetLightThemeAction set_light_theme = 138;
ToggleThemeAction toggle_theme = 139;
+ ToggleMobileModeAction toggle_mobile_mode = 140;
}
}
@@ -332,6 +333,7 @@ message CopyAction {}
message ConfirmAction {}
message DenyAction {}
message ToggleMouseModeAction {}
+message ToggleMobileModeAction {}
message PreviousSwapLayoutAction {}
message NextSwapLayoutAction {}
message TabLayoutInfo {
@@ -1012,6 +1014,8 @@ message MouseEvent {
bool alt = 8;
bool ctrl = 9;
Position position = 10;
+ bool wheel_left = 11;
+ bool wheel_right = 12;
}
enum MouseEventType {
@@ -1174,6 +1178,9 @@ message Options {
optional bool mouse_click_through = 45;
optional string theme_dark = 46;
optional string theme_light = 47;
+ optional MobileLayout mobile_layout = 48;
+ optional uint32 mobile_threshold_cols = 49;
+ optional uint32 mobile_threshold_rows = 50;
}
enum OnForceClose {
@@ -1201,6 +1208,13 @@ enum WebSharing {
WEB_SHARING_DISABLED = 3;
}
+enum MobileLayout {
+ MOBILE_LAYOUT_UNSPECIFIED = 0;
+ MOBILE_LAYOUT_WEB = 1;
+ MOBILE_LAYOUT_ALWAYS = 2;
+ MOBILE_LAYOUT_NEVER = 3;
+}
+
// Pane-targeting action messages
message ScrollUpByPaneIdAction { PaneId pane_id = 1; }
message ScrollDownByPaneIdAction { PaneId pane_id = 1; }
diff --git a/zellij-utils/src/client_server_contract/server_to_client.proto b/zellij-utils/src/client_server_contract/server_to_client.proto
index b2fc3bd6..a8c4779d 100644
--- a/zellij-utils/src/client_server_contract/server_to_client.proto
+++ b/zellij-utils/src/client_server_contract/server_to_client.proto
@@ -21,6 +21,7 @@ message ServerToClientMsg {
PaneRenderUpdateMsg pane_render_update = 14;
SubscribedPaneClosedMsg subscribed_pane_closed = 15;
ForwardQueryToHostMsg forward_query_to_host = 16;
+ SetSoftKeyboardMsg set_soft_keyboard = 17;
}
}
@@ -94,3 +95,7 @@ message ForwardQueryToHostMsg {
uint32 token = 1;
bytes query_bytes = 2;
}
+
+message SetSoftKeyboardMsg {
+ bool on = 1;
+}
diff --git a/zellij-utils/src/consts.rs b/zellij-utils/src/consts.rs
index 0e8b5112..2b3982b0 100644
--- a/zellij-utils/src/consts.rs
+++ b/zellij-utils/src/consts.rs
@@ -171,16 +171,12 @@ mod not_wasm {
add_plugin!(assets, "multiple-select.wasm");
add_plugin!(assets, "layout-manager.wasm");
add_plugin!(assets, "link.wasm");
+ add_plugin!(assets, "mobile.wasm");
assets
};
}
}
-/// Check if a filesystem entry is an IPC socket.
-///
-/// On Unix, this checks `FileTypeExt::is_socket()`. On non-Unix platforms,
-/// this checks `is_file()` to detect marker files created by `ipc_bind()`
-/// and `ipc_bind_async()` alongside kernel-level named pipes.
#[cfg(unix)]
pub fn is_ipc_socket(file_type: &std::fs::FileType) -> bool {
use std::os::unix::fs::FileTypeExt;
@@ -192,10 +188,6 @@ pub fn is_ipc_socket(file_type: &std::fs::FileType) -> bool {
file_type.is_file()
}
-/// Connect to an IPC socket at the given path.
-///
-/// On Unix, this uses Unix domain sockets via `GenericFilePath`.
-/// On Windows, this uses named pipes via `GenericNamespaced`.
#[cfg(unix)]
pub fn ipc_connect(path: &std::path::Path) -> std::io::Result<interprocess::local_socket::Stream> {
use interprocess::local_socket::{prelude::*, GenericFilePath, Stream as LocalSocketStream};
@@ -211,11 +203,6 @@ pub fn ipc_connect(path: &std::path::Path) -> std::io::Result<interprocess::loca
LocalSocketStream::connect(ns_name)
}
-/// Create an IPC listener bound to the given path.
-///
-/// On Unix, this uses Unix domain sockets via `GenericFilePath`.
-/// On Windows, this uses named pipes via `GenericNamespaced` and creates
-/// a marker file for session discovery.
#[cfg(unix)]
pub fn ipc_bind(path: &std::path::Path) -> std::io::Result<interprocess::local_socket::Listener> {
use interprocess::local_socket::{prelude::*, GenericFilePath, ListenerOptions};
@@ -233,11 +220,6 @@ pub fn ipc_bind(path: &std::path::Path) -> std::io::Result<interprocess::local_s
Ok(listener)
}
-/// Create an async (tokio) IPC listener bound to the given path.
-///
-/// On Unix, this uses Unix domain sockets via `GenericFilePath`.
-/// On Windows, this uses named pipes via `GenericNamespaced` and creates
-/// a marker file for session discovery.
#[cfg(unix)]
pub fn ipc_bind_async(
path: &std::path::Path,
@@ -259,9 +241,6 @@ pub fn ipc_bind_async(
Ok(listener)
}
-/// Connect to the reply pipe for a given IPC path (Windows only).
-///
-/// Uses `path-reply` as the named pipe for the server→client direction.
#[cfg(windows)]
pub fn ipc_connect_reply(
path: &std::path::Path,
@@ -272,9 +251,6 @@ pub fn ipc_connect_reply(
LocalSocketStream::connect(ns_name)
}
-/// Create an IPC listener for the reply pipe (Windows only).
-///
-/// Binds to `path-reply` as the named pipe for the server→client direction.
#[cfg(windows)]
pub fn ipc_bind_reply(
path: &std::path::Path,
@@ -297,12 +273,7 @@ mod unix_only {
use nix::unistd::Uid;
use std::env::temp_dir;
- // Maximum length of a Unix domain socket path (from sockaddr_un.sun_path).
- // macOS (and other BSDs) use 104, Linux/Android/Solaris use 108.
- // The not(target_os = "macos") fallback of 108 is used for all other Unix
- // platforms — this is correct for Linux/Android/Solaris and only 4 bytes
- // over for BSDs, which would cause a slightly late error rather than a
- // missed one.
+ // Maximum sockaddr_un.sun_path length: 104 on macOS/BSD, 108 on Linux/Android/Solaris.
#[cfg(target_os = "macos")]
pub const ZELLIJ_SOCK_MAX_LENGTH: usize = 104;
#[cfg(not(target_os = "macos"))]
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index 6f10a054..16ff560e 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -5,7 +5,7 @@ use crate::input::keybinds::Keybinds;
use crate::input::layout::{
Layout, PercentOrFixed, Run, RunPlugin, RunPluginLocation, RunPluginOrAlias,
};
-use crate::pane_size::PaneGeom;
+use crate::pane_size::{PaneGeom, Size};
use crate::position::Position;
use crate::shared::{colors as default_colors, eightbit_to_rgb};
use clap::ArgEnum;
@@ -894,8 +894,10 @@ impl fmt::Display for ResizeStrategy {
// left click) and the `ScrollUp` and `ScrollDown` events could probably be
// merged into a single `Scroll(isize)` event.
pub enum Mouse {
- ScrollUp(usize), // number of lines
- ScrollDown(usize), // number of lines
+ ScrollUp(usize), // number of lines
+ ScrollDown(usize), // number of lines
+ ScrollLeft(usize),
+ ScrollRight(usize),
LeftClick(isize, usize), // line and column
RightClick(isize, usize), // line and column
Hold(isize, usize), // line and column
@@ -1028,6 +1030,7 @@ pub enum Event {
InitialKeybinds(KeybindsVec),
/// The host terminal indicated its color palette theme mode (CSI 2031 / DSR 997).
HostTerminalThemeChanged(HostTerminalThemeMode),
+ SoftKeyboardVisibilityChanged(bool),
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -2432,6 +2435,7 @@ pub struct PaneContents {
pub lines_below_viewport: Vec<String>,
pub viewport: Vec<String>,
pub selected_text: Option<SelectedText>,
+ pub cursor: Option<(usize, usize)>,
}
/// Extract text from a line between two column positions, accounting for wide characters
@@ -2526,6 +2530,7 @@ impl PaneContents {
selected_text: SelectedText::from_positions(selection_start, selection_end),
lines_above_viewport,
lines_below_viewport,
+ cursor: None,
}
}
@@ -3366,6 +3371,13 @@ pub enum PluginCommand {
name: Option<String>,
cwd: Option<String>,
},
+ NewTabUnfocused {
+ name: Option<String>,
+ cwd: Option<String>,
+ },
+ NewTiledPaneInTab {
+ tab_position: usize,
+ },
GoToNextTab,
GoToPreviousTab,
Resize(Resize),
@@ -3610,6 +3622,13 @@ pub enum PluginCommand {
KillSessionsAndReply(Vec<String>), // one or more session names; sends a response back
DeleteDeadSessionAndReply(String), // session name; sends a response back
DeleteAllDeadSessionsAndReply, // no payload; sends a response back
+ SetSoftKeyboard(bool),
+ SetTabFit {
+ tab_id: usize,
+ fit: Option<(PaneId, Size)>,
+ },
+ SetShadowFocus(PaneId),
+ ExitMobileMode,
}
// Response type for plugin API methods that open a pane in a new tab
@@ -3621,6 +3640,7 @@ pub struct OpenPaneInNewTabResponse {
// Response types for plugin API methods that create tabs
pub type NewTabResponse = Option<usize>;
+pub type NewTabUnfocusedResponse = Option<usize>;
pub type NewTabsResponse = Vec<usize>;
pub type FocusOrCreateTabResponse = Option<usize>;
pub type BreakPanesToNewTabResponse = Option<usize>;
@@ -3641,6 +3661,7 @@ pub type OpenTerminalInPlaceResponse = Option<PaneId>;
pub type OpenTerminalNearPluginResponse = Option<PaneId>;
pub type OpenTerminalFloatingNearPluginResponse = Option<PaneId>;
pub type OpenTerminalInPlaceOfPluginResponse = Option<PaneId>;
+pub type NewTiledPaneInTabResponse = Option<PaneId>;
pub type OpenCommandPaneResponse = Option<PaneId>;
pub type OpenCommandPaneFloatingResponse = Option<PaneId>;
diff --git a/zellij-utils/src/errors.rs b/zellij-utils/src/errors.rs
index 6fa15141..8e4bc66c 100644
--- a/zellij-utils/src/errors.rs
+++ b/zellij-utils/src/errors.rs
@@ -331,6 +331,9 @@ pub enum ScreenContext {
ToggleTab,
AddClient,
RemoveClient,
+ SuppressRenderUntilMobile,
+ MobileSizeSettled,
+ ForceMobileUngate,
UpdateSearch,
SearchDown,
SearchUp,
@@ -399,6 +402,7 @@ pub enum ScreenContext {
PageScrollUpInPaneId,
PageScrollDownInPaneId,
TogglePaneIdFullscreen,
+ SetTabFit,
TogglePaneEmbedOrEjectForPaneId,
CloseTabWithIndex,
BreakPanesToNewTab,
@@ -462,6 +466,12 @@ pub enum ScreenContext {
PluginSubscribedToAnsiPaneContents,
UpdateBackgroundPluginSubscriptions,
BroadcastModeUpdate,
+ EnterMobileMode,
+ ExitMobileMode,
+ ToggleMobileMode,
+ ReevaluateMobileMode,
+ SetSoftKeyboard,
+ SetShadowFocus,
}
/// Stack call representations corresponding to the different types of [`PtyInstruction`]s.
@@ -516,6 +526,8 @@ pub enum PluginContext {
NewTab,
OverrideLayout,
ApplyCachedEvents,
+ HoldMobileRender,
+ ReleaseMobileRender,
ApplyCachedWorkerMessages,
PostMessageToPluginWorker,
PostMessageToPlugin,
@@ -637,6 +649,7 @@ pub enum BackgroundJobContext {
StopFlashPaneBell,
FlashTabBell,
StopFlashTabBell,
+ MobileGateTimeout,
Exit,
}
diff --git a/zellij-utils/src/input/actions.rs b/zellij-utils/src/input/actions.rs
index 7f6fabfe..cf5fc448 100644
--- a/zellij-utils/src/input/actions.rs
+++ b/zellij-utils/src/input/actions.rs
@@ -412,6 +412,7 @@ pub enum Action {
option: SearchOption,
},
ToggleMouseMode,
+ ToggleMobileMode,
PreviousSwapLayout,
NextSwapLayout,
/// Override the layout of the active tab
diff --git a/zellij-utils/src/input/mod.rs b/zellij-utils/src/input/mod.rs
index 74091850..c103e580 100644
--- a/zellij-utils/src/input/mod.rs
+++ b/zellij-utils/src/input/mod.rs
@@ -163,112 +163,149 @@ mod not_wasm {
let alt = modifiers.contains(KeyModifiers::ALT);
let ctrl = modifiers.contains(KeyModifiers::CONTROL);
- let (event_type, left, right, middle, wheel_up, wheel_down) = match event.kind {
- MouseEventKind::Down(CButton::Left) => (
- mouse::MouseEventType::Press,
- true,
- false,
- false,
- false,
- false,
- ),
- MouseEventKind::Down(CButton::Right) => (
- mouse::MouseEventType::Press,
- false,
- true,
- false,
- false,
- false,
- ),
- MouseEventKind::Down(CButton::Middle) => (
- mouse::MouseEventType::Press,
- false,
- false,
- true,
- false,
- false,
- ),
- MouseEventKind::Up(CButton::Left) => (
- mouse::MouseEventType::Release,
- true,
- false,
- false,
- false,
- false,
- ),
- MouseEventKind::Up(CButton::Right) => (
- mouse::MouseEventType::Release,
- false,
- true,
- false,
- false,
- false,
- ),
- MouseEventKind::Up(CButton::Middle) => (
- mouse::MouseEventType::Release,
- false,
- false,
- true,
- false,
- false,
- ),
- MouseEventKind::Drag(CButton::Left) => (
- mouse::MouseEventType::Motion,
- true,
- false,
- false,
- false,
- false,
- ),
- MouseEventKind::Drag(CButton::Right) => (
- mouse::MouseEventType::Motion,
- false,
- true,
- false,
- false,
- false,
- ),
- MouseEventKind::Drag(CButton::Middle) => (
- mouse::MouseEventType::Motion,
- false,
- false,
- true,
- false,
- false,
- ),
- MouseEventKind::Moved => (
- mouse::MouseEventType::Motion,
- false,
- false,
- false,
- false,
- false,
- ),
- MouseEventKind::ScrollUp => (
- mouse::MouseEventType::Press,
- false,
- false,
- false,
- true,
- false,
- ),
- MouseEventKind::ScrollDown => (
- mouse::MouseEventType::Press,
- false,
- false,
- false,
- false,
- true,
- ),
- MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => (
- mouse::MouseEventType::Motion,
- false,
- false,
- false,
- false,
- false,
- ),
- };
+ let (event_type, left, right, middle, wheel_up, wheel_down, wheel_left, wheel_right) =
+ match event.kind {
+ MouseEventKind::Down(CButton::Left) => (
+ mouse::MouseEventType::Press,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Down(CButton::Right) => (
+ mouse::MouseEventType::Press,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Down(CButton::Middle) => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Up(CButton::Left) => (
+ mouse::MouseEventType::Release,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Up(CButton::Right) => (
+ mouse::MouseEventType::Release,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Up(CButton::Middle) => (
+ mouse::MouseEventType::Release,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Drag(CButton::Left) => (
+ mouse::MouseEventType::Motion,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Drag(CButton::Right) => (
+ mouse::MouseEventType::Motion,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Drag(CButton::Middle) => (
+ mouse::MouseEventType::Motion,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::Moved => (
+ mouse::MouseEventType::Motion,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::ScrollUp => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ ),
+ MouseEventKind::ScrollDown => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false,
+ false,
+ ),
+ MouseEventKind::ScrollLeft => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false,
+ ),
+ MouseEventKind::ScrollRight => (
+ mouse::MouseEventType::Press,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ ),
+ };
mouse::MouseEvent {
event_type,
@@ -277,6 +314,8 @@ mod not_wasm {
middle,
wheel_up,
wheel_down,
+ wheel_left,
+ wheel_right,
shift,
alt,
ctrl,
diff --git a/zellij-utils/src/input/mouse.rs b/zellij-utils/src/input/mouse.rs
index a8fcb5a7..231dc589 100644
--- a/zellij-utils/src/input/mouse.rs
+++ b/zellij-utils/src/input/mouse.rs
@@ -16,6 +16,10 @@ pub struct MouseEvent {
pub middle: bool,
pub wheel_up: bool,
pub wheel_down: bool,
+ #[serde(default)]
+ pub wheel_left: bool,
+ #[serde(default)]
+ pub wheel_right: bool,
// Keyboard modifier flags can be encoded with events too. They
// are not often passed on the wire (instead used for
@@ -51,6 +55,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -66,6 +72,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -81,6 +89,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -96,6 +106,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -111,6 +123,8 @@ impl MouseEvent {
middle: true,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -126,6 +140,8 @@ impl MouseEvent {
middle: true,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -141,6 +157,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -156,6 +174,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -171,6 +191,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -186,6 +208,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -201,6 +225,8 @@ impl MouseEvent {
middle: true,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -216,6 +242,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: true,
ctrl: false,
@@ -231,6 +259,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: true,
ctrl: false,
@@ -246,6 +276,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: true,
@@ -261,6 +293,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: true,
@@ -276,6 +310,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: true,
@@ -291,6 +327,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: true,
ctrl: false,
@@ -306,6 +344,8 @@ impl MouseEvent {
middle: false,
wheel_up: true,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -321,6 +361,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: true,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: false,
@@ -336,6 +378,8 @@ impl MouseEvent {
middle: false,
wheel_up: true,
wheel_down: false,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: true,
@@ -351,6 +395,8 @@ impl MouseEvent {
middle: false,
wheel_up: false,
wheel_down: true,
+ wheel_left: false,
+ wheel_right: false,
shift: false,
alt: false,
ctrl: true,
@@ -358,6 +404,38 @@ impl MouseEvent {
};
event
}
+ pub fn new_scroll_left_event(position: Position) -> Self {
+ MouseEvent {
+ event_type: MouseEventType::Press,
+ left: false,
+ right: false,
+ middle: false,
+ wheel_up: false,
+ wheel_down: false,
+ wheel_left: true,
+ wheel_right: false,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ position,
+ }
+ }
+ pub fn new_scroll_right_event(position: Position) -> Self {
+ MouseEvent {
+ event_type: MouseEventType::Press,
+ left: false,
+ right: false,
+ middle: false,
+ wheel_up: false,
+ wheel_down: false,
+ wheel_left: false,
+ wheel_right: true,
+ shift: false,
+ alt: false,
+ ctrl: false,
+ position,
+ }
+ }
}
impl Default for MouseEvent {
diff --git a/zellij-utils/src/input/options.rs b/zellij-utils/src/input/options.rs
index 854ef271..8f91cb47 100644
--- a/zellij-utils/src/input/options.rs
+++ b/zellij-utils/src/input/options.rs
@@ -16,6 +16,65 @@ pub enum OnForceClose {
Detach,
}
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Serialize, ArgEnum)]
+pub enum MobileLayoutConfiguration {
+ #[serde(alias = "web")]
+ Web,
+ #[serde(alias = "always")]
+ Always,
+ #[serde(alias = "never")]
+ Never,
+}
+
+impl Default for MobileLayoutConfiguration {
+ fn default() -> Self {
+ Self::Web
+ }
+}
+
+impl FromStr for MobileLayoutConfiguration {
+ type Err = String;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "Web" | "web" => Ok(Self::Web),
+ "Always" | "always" => Ok(Self::Always),
+ "Never" | "never" => Ok(Self::Never),
+ _ => Err(format!("No such mobile_layout: {}", s)),
+ }
+ }
+}
+
+impl MobileLayoutConfiguration {
+ pub fn should_route_to_mobile(
+ self,
+ is_web_client: bool,
+ viewport_cols: usize,
+ viewport_rows: usize,
+ threshold_cols: u16,
+ threshold_rows: u16,
+ ) -> bool {
+ let cols_reported = viewport_cols > 0;
+ let rows_reported = viewport_rows > 0;
+ let cols_match =
+ cols_reported && (threshold_cols == 0 || viewport_cols <= threshold_cols as usize);
+ let rows_match =
+ rows_reported && (threshold_rows == 0 || viewport_rows <= threshold_rows as usize);
+ let size_match = cols_match || rows_match;
+ match self {
+ MobileLayoutConfiguration::Always => size_match,
+ MobileLayoutConfiguration::Never => false,
+ MobileLayoutConfiguration::Web => is_web_client && size_match,
+ }
+ }
+
+ pub fn may_route_web_client_to_mobile(self) -> bool {
+ matches!(
+ self,
+ MobileLayoutConfiguration::Web | MobileLayoutConfiguration::Always
+ )
+ }
+}
+
impl Default for OnForceClose {
fn default() -> Self {
Self::Detach
@@ -278,6 +337,21 @@ pub struct Options {
/// NOTE: This only applies to web clients at the moment.
#[clap(long)]
pub client_async_worker_tasks: Option<usize>,
+
+ /// When a newly-attaching client should land in the mobile UI plugin (web, always, never)
+ #[clap(long, arg_enum, hide_possible_values = true, value_parser)]
+ #[serde(default)]
+ pub mobile_layout: Option<MobileLayoutConfiguration>,
+
+ /// Column breakpoint for mobile_layout (0 to always match)
+ #[clap(long, value_parser)]
+ #[serde(default)]
+ pub mobile_threshold_cols: Option<u16>,
+
+ /// Row breakpoint for mobile_layout (0 to always match)
+ #[clap(long, value_parser)]
+ #[serde(default)]
+ pub mobile_threshold_rows: Option<u16>,
}
#[derive(ArgEnum, Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
@@ -384,6 +458,9 @@ impl Options {
let client_async_worker_tasks = other
.client_async_worker_tasks
.or(self.client_async_worker_tasks);
+ let mobile_layout = other.mobile_layout.or(self.mobile_layout);
+ let mobile_threshold_cols = other.mobile_threshold_cols.or(self.mobile_threshold_cols);
+ let mobile_threshold_rows = other.mobile_threshold_rows.or(self.mobile_threshold_rows);
Options {
simplified_ui,
@@ -433,6 +510,9 @@ impl Options {
enforce_https_for_localhost,
post_command_discovery_hook,
client_async_worker_tasks,
+ mobile_layout,
+ mobile_threshold_cols,
+ mobile_threshold_rows,
}
}
@@ -519,6 +599,9 @@ impl Options {
let client_async_worker_tasks = other
.client_async_worker_tasks
.or(self.client_async_worker_tasks);
+ let mobile_layout = other.mobile_layout.or(self.mobile_layout);
+ let mobile_threshold_cols = other.mobile_threshold_cols.or(self.mobile_threshold_cols);
+ let mobile_threshold_rows = other.mobile_threshold_rows.or(self.mobile_threshold_rows);
Options {
simplified_ui,
@@ -568,6 +651,9 @@ impl Options {
enforce_https_for_localhost,
post_command_discovery_hook,
client_async_worker_tasks,
+ mobile_layout,
+ mobile_threshold_cols,
+ mobile_threshold_rows,
}
}
@@ -579,3 +665,206 @@ impl Options {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SMALL: (usize, usize) = (40, 20);
+ const WIDE_SHORT: (usize, usize) = (200, 20);
+ const TALL_NARROW: (usize, usize) = (40, 200);
+ const LARGE: (usize, usize) = (200, 200);
+
+ fn route(
+ layout: MobileLayoutConfiguration,
+ is_web: bool,
+ viewport: (usize, usize),
+ thresholds: (u16, u16),
+ ) -> bool {
+ layout.should_route_to_mobile(is_web, viewport.0, viewport.1, thresholds.0, thresholds.1)
+ }
+
+ #[test]
+ fn never_never_routes_to_mobile() {
+ for &is_web in &[true, false] {
+ for &viewport in &[SMALL, WIDE_SHORT, TALL_NARROW, LARGE] {
+ for &thresholds in &[(60, 30), (0, 0), (0, 30), (60, 0)] {
+ assert!(
+ !route(MobileLayoutConfiguration::Never, is_web, viewport, thresholds),
+ "Never must never route (is_web={is_web} viewport={viewport:?} thresholds={thresholds:?})",
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn web_requires_web_client() {
+ assert!(route(MobileLayoutConfiguration::Web, true, SMALL, (60, 30)));
+ assert!(!route(
+ MobileLayoutConfiguration::Web,
+ false,
+ SMALL,
+ (60, 30)
+ ));
+ }
+
+ #[test]
+ fn web_respects_size_in_either_dimension() {
+ assert!(route(
+ MobileLayoutConfiguration::Web,
+ true,
+ TALL_NARROW,
+ (60, 30)
+ ));
+ assert!(route(
+ MobileLayoutConfiguration::Web,
+ true,
+ WIDE_SHORT,
+ (60, 30)
+ ));
+ assert!(!route(
+ MobileLayoutConfiguration::Web,
+ true,
+ LARGE,
+ (60, 30)
+ ));
+ }
+
+ #[test]
+ fn always_routes_any_client_on_size_match() {
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ true,
+ SMALL,
+ (60, 30)
+ ));
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ false,
+ SMALL,
+ (60, 30)
+ ));
+ assert!(!route(
+ MobileLayoutConfiguration::Always,
+ true,
+ LARGE,
+ (60, 30)
+ ));
+ assert!(!route(
+ MobileLayoutConfiguration::Always,
+ false,
+ LARGE,
+ (60, 30)
+ ));
+ }
+
+ #[test]
+ fn zero_threshold_makes_dimension_unconditional() {
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ false,
+ LARGE,
+ (0, 30)
+ ));
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ false,
+ LARGE,
+ (60, 0)
+ ));
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ false,
+ LARGE,
+ (0, 0)
+ ));
+ assert!(route(MobileLayoutConfiguration::Web, true, LARGE, (0, 0)));
+ assert!(!route(MobileLayoutConfiguration::Web, false, LARGE, (0, 0)));
+ }
+
+ #[test]
+ fn zero_viewport_is_treated_as_unknown() {
+ assert!(!route(
+ MobileLayoutConfiguration::Always,
+ true,
+ (0, 0),
+ (60, 30)
+ ));
+ assert!(!route(
+ MobileLayoutConfiguration::Web,
+ true,
+ (0, 0),
+ (60, 30)
+ ));
+ assert!(!route(
+ MobileLayoutConfiguration::Always,
+ true,
+ (0, 0),
+ (0, 0)
+ ));
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ true,
+ (40, 0),
+ (60, 30)
+ ));
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ true,
+ (0, 20),
+ (60, 30)
+ ));
+ }
+
+ #[test]
+ fn boundary_inclusive_at_threshold() {
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ false,
+ (60, 200),
+ (60, 30)
+ ));
+ assert!(route(
+ MobileLayoutConfiguration::Always,
+ false,
+ (200, 30),
+ (60, 30)
+ ));
+ assert!(!route(
+ MobileLayoutConfiguration::Always,
+ false,
+ (61, 31),
+ (60, 30)
+ ));
+ }
+
+ #[test]
+ fn from_str_accepts_canonical_and_lowercase() {
+ assert_eq!(
+ "web".parse::<MobileLayoutConfiguration>(),
+ Ok(MobileLayoutConfiguration::Web)
+ );
+ assert_eq!(
+ "Web".parse::<MobileLayoutConfiguration>(),
+ Ok(MobileLayoutConfiguration::Web)
+ );
+ assert_eq!(
+ "always".parse::<MobileLayoutConfiguration>(),
+ Ok(MobileLayoutConfiguration::Always)
+ );
+ assert_eq!(
+ "never".parse::<MobileLayoutConfiguration>(),
+ Ok(MobileLayoutConfiguration::Never)
+ );
+ assert!("auto".parse::<MobileLayoutConfiguration>().is_err());
+ }
+
+ #[test]
+ fn default_is_web() {
+ assert_eq!(
+ MobileLayoutConfiguration::default(),
+ MobileLayoutConfiguration::Web
+ );
+ }
+}
diff --git a/zellij-utils/src/input/plugins.rs b/zellij-utils/src/input/plugins.rs
index 31e48b43..7f9bf427 100644
--- a/zellij-utils/src/input/plugins.rs
+++ b/zellij-utils/src/input/plugins.rs
@@ -69,6 +69,7 @@ impl PluginConfig {
|| tag == "multiple-select"
|| tag == "layout-manager"
|| tag == "link"
+ || tag == "mobile"
{
Some(PluginConfig {
path: PathBuf::from(&tag),
diff --git a/zellij-utils/src/input/web_client.rs b/zellij-utils/src/input/web_client.rs
index de09efad..9607adc0 100644
--- a/zellij-utils/src/input/web_client.rs
+++ b/zellij-utils/src/input/web_client.rs
@@ -239,6 +239,7 @@ pub struct WebClientConfig {
pub cursor_style: Option<CursorStyle>,
pub mac_option_is_meta: bool,
pub base_url: Option<String>,
+ pub font_size: Option<u16>,
}
impl Default for WebClientConfig {
@@ -251,6 +252,7 @@ impl Default for WebClientConfig {
cursor_style: None,
mac_option_is_meta: true, // TODO: yes? no?
base_url: None,
+ font_size: None,
}
}
}
@@ -289,6 +291,24 @@ impl WebClientConfig {
web_client_config.base_url = Some(base_url.to_owned());
}
+ if let Some(font_size_node) = kdl_get_child!(kdl, "font_size") {
+ let raw = font_size_node
+ .entries()
+ .iter()
+ .find_map(|e| e.value().as_i64());
+ if let Some(value) = raw {
+ if value > 0 && value <= u16::MAX as i64 {
+ web_client_config.font_size = Some(value as u16);
+ } else {
+ return Err(ConfigError::new_kdl_error(
+ format!("font_size must be between 1 and {}", u16::MAX),
+ font_size_node.span().offset(),
+ font_size_node.span().len(),
+ ));
+ }
+ }
+ }
+
Ok(web_client_config)
}
@@ -338,6 +358,12 @@ impl WebClientConfig {
web_client_children.nodes_mut().push(base_url_node);
}
+ if let Some(font_size) = self.font_size {
+ let mut font_size_node = KdlNode::new("font_size");
+ font_size_node.push(KdlValue::Base10(font_size as i64));
+ web_client_children.nodes_mut().push(font_size_node);
+ }
+
web_client_node.set_children(web_client_children);
web_client_node
}
@@ -351,6 +377,7 @@ impl WebClientConfig {
merged.cursor_style = other.cursor_style;
merged.mac_option_is_meta = other.mac_option_is_meta;
merged.base_url = other.base_url;
+ merged.font_size = other.font_size;
merged
}
}
diff --git a/zellij-utils/src/ipc.rs b/zellij-utils/src/ipc.rs
index a837a0af..d5edbbb4 100644
--- a/zellij-utils/src/ipc.rs
+++ b/zellij-utils/src/ipc.rs
@@ -81,6 +81,14 @@ pub struct ColorRegister {
pub color: String,
}
+#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ResizeCause {
+ #[default]
+ Viewport,
+ RenderingPreference,
+ SizeSettled,
+}
+
impl PixelDimensions {
pub fn merge(&mut self, other: PixelDimensions) {
if let Some(text_area_size) = other.text_area_size {
@@ -113,6 +121,8 @@ pub enum ClientToServerMsg {
},
TerminalResize {
new_size: Size,
+ #[serde(default)]
+ cause: ResizeCause,
},
FirstClientConnected {
cli_assets: CliAssets,
@@ -156,21 +166,16 @@ pub enum ClientToServerMsg {
DesktopNotificationResponse {
raw_bytes: Vec<u8>,
},
- /// Raw reply bytes observed by the client inside a forwarding window
- /// closed by the Primary-DA barrier. The server routes these bytes
- /// verbatim to the pane whose app issued the whitelisted query
- /// (tracked on the server by `token`).
ForwardedReplyFromHost {
token: u32,
reply_bytes: Vec<u8>,
},
- /// The host terminal reported a color-palette theme mode (DSR 997 reply
- /// to `CSI ? 996 n`, or unsolicited notification while `CSI ? 2031 h`
- /// is enabled). The server propagates this to the active configured
- /// theme and to subscribed plugins/panes.
HostTerminalThemeChanged {
mode: HostTerminalThemeMode,
},
+ SoftKeyboardVisibilityChanged {
+ visible: bool,
+ },
}
// Types of messages sent from the server to the client
@@ -201,6 +206,9 @@ pub enum ServerToClientMsg {
output: String,
},
QueryTerminalSize,
+ SetSoftKeyboard {
+ on: bool,
+ },
StartWebServer,
RenamedSession {
name: String,
@@ -215,10 +223,6 @@ pub enum ServerToClientMsg {
SubscribedPaneClosed {
pane_id: PaneId,
},
- /// Instruct the client to write `query_bytes` followed by the
- /// Primary-DA barrier to stdout, open a forwarding window keyed by
- /// `token`, and reply with a `ForwardedReplyFromHost` once the
- /// barrier closes or the window times out.
ForwardQueryToHost {
token: u32,
query_bytes: Vec<u8>,
diff --git a/zellij-utils/src/ipc/protobuf_conversion.rs b/zellij-utils/src/ipc/protobuf_conversion.rs
index 8025d66f..d1bfb7d4 100644
--- a/zellij-utils/src/ipc/protobuf_conversion.rs
+++ b/zellij-utils/src/ipc/protobuf_conversion.rs
@@ -10,8 +10,9 @@ use crate::{
HostTerminalThemeIndication as ProtoHostTerminalThemeIndication,
InputMode as ProtoInputMode, KeyMsg, KillSessionMsg, LayoutMetadata as ProtoLayoutMetadata,
LogErrorMsg, LogMsg, PaneMetadata as ProtoPaneMetadata, PaneRenderUpdateMsg,
- QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg,
- ServerToClientMsg as ProtoServerToClientMsg, StartWebServerMsg, SubscribeToPaneRendersMsg,
+ QueryTerminalSizeMsg, RenamedSessionMsg, RenderMsg, ResizeCause as ProtoResizeCause,
+ ServerToClientMsg as ProtoServerToClientMsg, SetSoftKeyboardMsg,
+ SoftKeyboardVisibilityChangedMsg, StartWebServerMsg, SubscribeToPaneRendersMsg,
SubscribedPaneClosedMsg, SwitchSessionMsg, TabMetadata as ProtoTabMetadata,
TerminalPixelDimensionsMsg, TerminalResizeMsg, UnblockCliPipeInputMsg,
UnblockInputThreadMsg, WebServerStartedMsg,
@@ -19,7 +20,7 @@ use crate::{
data::{HostTerminalThemeMode, InputMode, PaneId},
errors::prelude::*,
ipc::{
- ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions,
+ ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions, ResizeCause,
ServerToClientMsg,
},
};
@@ -51,9 +52,11 @@ impl From<ClientToServerMsg> for ProtoClientToServerMsg {
color_registers: color_registers.into_iter().map(|cr| cr.into()).collect(),
})
},
- ClientToServerMsg::TerminalResize { new_size } => {
+ ClientToServerMsg::TerminalResize { new_size, cause } => {
+ let cause_proto: ProtoResizeCause = cause.into();
client_to_server_msg::Message::TerminalResize(TerminalResizeMsg {
new_size: Some(new_size.into()),
+ cause: cause_proto as i32,
})
},
ClientToServerMsg::FirstClientConnected {
@@ -146,6 +149,11 @@ impl From<ClientToServerMsg> for ProtoClientToServerMsg {
},
)
},
+ ClientToServerMsg::SoftKeyboardVisibilityChanged { visible } => {
+ client_to_server_msg::Message::SoftKeyboardVisibilityChanged(
+ SoftKeyboardVisibilityChangedMsg { visible },
+ )
+ },
};
ProtoClientToServerMsg {
@@ -193,11 +201,15 @@ impl TryFrom<ProtoClientToServerMsg> for ClientToServerMsg {
})
},
Some(client_to_server_msg::Message::TerminalResize(resize)) => {
+ let cause = ProtoResizeCause::from_i32(resize.cause)
+ .unwrap_or(ProtoResizeCause::Viewport)
+ .into();
Ok(ClientToServerMsg::TerminalResize {
new_size: resize
.new_size
.ok_or_else(|| anyhow!("Missing new_size"))?
.try_into()?,
+ cause,
})
},
Some(client_to_server_msg::Message::FirstClientConnected(first_client)) => {
@@ -287,6 +299,11 @@ impl TryFrom<ProtoClientToServerMsg> for ClientToServerMsg {
mode: proto_mode.into(),
})
},
+ Some(client_to_server_msg::Message::SoftKeyboardVisibilityChanged(msg)) => {
+ Ok(ClientToServerMsg::SoftKeyboardVisibilityChanged {
+ visible: msg.visible,
+ })
+ },
None => Err(anyhow!("Empty ClientToServerMsg message")),
}
}
@@ -372,6 +389,9 @@ impl From<ServerToClientMsg> for ProtoServerToClientMsg {
query_bytes,
})
},
+ ServerToClientMsg::SetSoftKeyboard { on } => {
+ server_to_client_msg::Message::SetSoftKeyboard(SetSoftKeyboardMsg { on })
+ },
};
ProtoServerToClientMsg {
@@ -484,6 +504,9 @@ impl TryFrom<ProtoServerToClientMsg> for ServerToClientMsg {
query_bytes: msg.query_bytes,
})
},
+ Some(server_to_client_msg::Message::SetSoftKeyboard(msg)) => {
+ Ok(ServerToClientMsg::SetSoftKeyboard { on: msg.on })
+ },
None => Err(anyhow!("Empty ServerToClientMsg message")),
}
}
@@ -732,6 +755,17 @@ impl From<crate::input::options::Options>
visual_bell: options.visual_bell,
focus_follows_mouse: options.focus_follows_mouse,
mouse_click_through: options.mouse_click_through,
+ mobile_layout: options.mobile_layout.map(|m| {
+ use crate::client_server_contract::client_server_contract::MobileLayout as ProtoMobileLayout;
+ use crate::input::options::MobileLayoutConfiguration;
+ match m {
+ MobileLayoutConfiguration::Web => ProtoMobileLayout::Web as i32,
+ MobileLayoutConfiguration::Always => ProtoMobileLayout::Always as i32,
+ MobileLayoutConfiguration::Never => ProtoMobileLayout::Never as i32,
+ }
+ }),
+ mobile_threshold_cols: options.mobile_threshold_cols.map(|v| v as u32),
+ mobile_threshold_rows: options.mobile_threshold_rows.map(|v| v as u32),
}
}
}
@@ -744,8 +778,8 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options>
options: crate::client_server_contract::client_server_contract::Options,
) -> Result<Self> {
use crate::client_server_contract::client_server_contract::{
- Clipboard as ProtoClipboard, OnForceClose as ProtoOnForceClose,
- WebSharing as ProtoWebSharing,
+ Clipboard as ProtoClipboard, MobileLayout as ProtoMobileLayout,
+ OnForceClose as ProtoOnForceClose, WebSharing as ProtoWebSharing,
};
Ok(Self {
@@ -829,6 +863,23 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Options>
visual_bell: options.visual_bell,
focus_follows_mouse: options.focus_follows_mouse,
mouse_click_through: options.mouse_click_through,
+ mobile_layout: options
+ .mobile_layout
+ .map(|m| match ProtoMobileLayout::from_i32(m) {
+ Some(ProtoMobileLayout::Web) => {
+ Ok(crate::input::options::MobileLayoutConfiguration::Web)
+ },
+ Some(ProtoMobileLayout::Always) => {
+ Ok(crate::input::options::MobileLayoutConfiguration::Always)
+ },
+ Some(ProtoMobileLayout::Never) => {
+ Ok(crate::input::options::MobileLayoutConfiguration::Never)
+ },
+ _ => Err(anyhow!("Invalid MobileLayout value: {}", m)),
+ })
+ .transpose()?,
+ mobile_threshold_cols: options.mobile_threshold_cols.map(|v| v as u16),
+ mobile_threshold_rows: options.mobile_threshold_rows.map(|v| v as u16),
})
}
}
@@ -1419,6 +1470,9 @@ impl From<crate::input::actions::Action>
crate::input::actions::Action::ToggleMouseMode => {
ActionType::ToggleMouseMode(ToggleMouseModeAction {})
},
+ crate::input::actions::Action::ToggleMobileMode => ActionType::ToggleMobileMode(
+ crate::client_server_contract::client_server_contract::ToggleMobileModeAction {},
+ ),
crate::input::actions::Action::PreviousSwapLayout => {
ActionType::PreviousSwapLayout(PreviousSwapLayoutAction {})
},
@@ -2284,6 +2338,7 @@ impl TryFrom<crate::client_server_contract::client_server_contract::Action>
})
},
ActionType::ToggleMouseMode(_) => Ok(crate::input::actions::Action::ToggleMouseMode),
+ ActionType::ToggleMobileMode(_) => Ok(crate::input::actions::Action::ToggleMobileMode),
ActionType::PreviousSwapLayout(_) => {
Ok(crate::input::actions::Action::PreviousSwapLayout)
},
@@ -3013,6 +3068,26 @@ impl TryFrom<ProtoExitReason> for ExitReason {
}
}
+impl From<ResizeCause> for ProtoResizeCause {
+ fn from(cause: ResizeCause) -> Self {
+ match cause {
+ ResizeCause::Viewport => ProtoResizeCause::Viewport,
+ ResizeCause::RenderingPreference => ProtoResizeCause::RenderingPreference,
+ ResizeCause::SizeSettled => ProtoResizeCause::SizeSettled,
+ }
+ }
+}
+
+impl From<ProtoResizeCause> for ResizeCause {
+ fn from(cause: ProtoResizeCause) -> Self {
+ match cause {
+ ProtoResizeCause::Viewport => ResizeCause::Viewport,
+ ProtoResizeCause::RenderingPreference => ResizeCause::RenderingPreference,
+ ProtoResizeCause::SizeSettled => ResizeCause::SizeSettled,
+ }
+ }
+}
+
impl From<HostTerminalThemeMode> for ProtoHostTerminalThemeIndication {
fn from(mode: HostTerminalThemeMode) -> Self {
match mode {
@@ -3580,6 +3655,8 @@ impl From<crate::input::mouse::MouseEvent>
middle: event.middle,
wheel_up: event.wheel_up,
wheel_down: event.wheel_down,
+ wheel_left: event.wheel_left,
+ wheel_right: event.wheel_right,
shift: event.shift,
alt: event.alt,
ctrl: event.ctrl,
@@ -4167,6 +4244,8 @@ impl TryFrom<crate::client_server_contract::client_server_contract::MouseEvent>
middle: event.middle,
wheel_up: event.wheel_up,
wheel_down: event.wheel_down,
+ wheel_left: event.wheel_left,
+ wheel_right: event.wheel_right,
shift: event.shift,
alt: event.alt,
ctrl: event.ctrl,
diff --git a/zellij-utils/src/ipc/tests/roundtrip_tests.rs b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
index 4847b679..280805ab 100644
--- a/zellij-utils/src/ipc/tests/roundtrip_tests.rs
+++ b/zellij-utils/src/ipc/tests/roundtrip_tests.rs
@@ -13,9 +13,10 @@ use crate::input::layout::{
TiledPaneLayout,
};
use crate::input::mouse::{MouseEvent, MouseEventType};
-use crate::input::options::{Clipboard, OnForceClose, Options};
+use crate::input::options::{Clipboard, MobileLayoutConfiguration, OnForceClose, Options};
use crate::ipc::{
- ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions, ServerToClientMsg,
+ ClientToServerMsg, ColorRegister, ExitReason, PaneReference, PixelDimensions, ResizeCause,
+ ServerToClientMsg,
};
use crate::pane_size::{Size, SizeInPixels};
use crate::position::Position;
@@ -24,12 +25,6 @@ use std::path::PathBuf;
#[test]
fn server_client_contract() {
- // here we test all possible values of each of the nested types in the server/client contract
- // in the context of its message.
- //
- // we do a "roundtrip" test, meaning we take each message, encode it to protobuf bytes and
- // decode it back to its rust type and then assert its equality with the original type to make
- // sure they are identical and did not lose any information
test_client_messages();
test_server_messages();
}
@@ -378,12 +373,18 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::TerminalResize {
new_size: Size { cols: 80, rows: 24 },
+ cause: ResizeCause::Viewport,
});
test_client_roundtrip!(ClientToServerMsg::TerminalResize {
new_size: Size {
cols: 200,
rows: 50
},
+ cause: ResizeCause::Viewport,
+ });
+ test_client_roundtrip!(ClientToServerMsg::TerminalResize {
+ new_size: Size { cols: 40, rows: 38 },
+ cause: ResizeCause::RenderingPreference,
});
test_client_roundtrip!(ClientToServerMsg::FirstClientConnected {
cli_assets: CliAssets::default(),
@@ -478,6 +479,9 @@ fn test_client_messages() {
visual_bell: Some(true),
focus_follows_mouse: Some(false),
mouse_click_through: Some(false),
+ mobile_layout: Some(MobileLayoutConfiguration::Always),
+ mobile_threshold_cols: Some(72),
+ mobile_threshold_rows: Some(40),
}),
layout: None,
terminal_window_size: Size { rows: 80, cols: 42 },
@@ -670,7 +674,6 @@ fn test_client_messages() {
is_web_client: true,
});
test_client_roundtrip!(ClientToServerMsg::AttachClient {
- // cli_assets tested extensively ijn FirstClientConnected, we can skip it here
cli_assets: CliAssets::default(),
tab_position_to_focus: None,
pane_to_focus: None,
@@ -725,7 +728,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::Write {
- // KeyWithModifier is tested extensively in the Key ClientToServerMsg
key_with_modifier: Some(KeyWithModifier::new(BareKey::Char('a'))),
bytes: "a".as_bytes().to_vec(),
is_kitty_keyboard_protocol: true,
@@ -744,7 +746,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::SwitchToMode {
- // InputMode is tested extensively in the Options conversion tests above
input_mode: InputMode::Locked,
},
terminal_id: Some(1),
@@ -753,7 +754,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::SwitchModeForAllClients {
- // InputMode is tested extensively in the Options conversion tests above
input_mode: InputMode::Locked,
},
terminal_id: Some(1),
@@ -834,7 +834,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::MoveFocus {
- // Direction is tested extensively elsewhere
direction: Direction::Up,
},
terminal_id: Some(1),
@@ -2235,7 +2234,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::Run {
- // RunCommandAction serialization roundtrip is tested extensively upwards of here
command: RunCommandAction {
command: PathBuf::from("/path/to/command"),
args: vec![],
@@ -2260,7 +2258,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::LaunchOrFocusPlugin {
- // RunPluginOrAlias serialization roundtrip is tested extensively upwards
plugin: RunPluginOrAlias::RunPlugin(RunPlugin::default()),
should_float: true,
move_to_focused_tab: true,
@@ -2275,7 +2272,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::LaunchOrFocusPlugin {
- // RunPluginOrAlias serialization roundtrip is tested extensively upwards
plugin: RunPluginOrAlias::Alias(PluginAlias::default()),
should_float: false,
move_to_focused_tab: false,
@@ -2290,7 +2286,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::LaunchPlugin {
- // RunPluginOrAlias serialization roundtrip is tested extensively upwards
plugin: RunPluginOrAlias::RunPlugin(RunPlugin::default()),
should_float: true,
should_open_in_place: true,
@@ -2305,7 +2300,6 @@ fn test_client_messages() {
});
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::LaunchPlugin {
- // RunPluginOrAlias serialization roundtrip is tested extensively upwards
plugin: RunPluginOrAlias::Alias(PluginAlias::default()),
should_float: false,
should_open_in_place: false,
@@ -2327,6 +2321,8 @@ fn test_client_messages() {
middle: true,
wheel_up: true,
wheel_down: true,
+ wheel_left: true,
+ wheel_right: true,
shift: true,
alt: true,
ctrl: true,
@@ -2346,6 +2342,8 @@ fn test_client_messages() {
middle: true,
wheel_up: true,
wheel_down: true,
+ wheel_left: true,
+ wheel_right: true,
shift: true,
alt: true,
ctrl: true,
@@ -2365,6 +2363,8 @@ fn test_client_messages() {
middle: true,
wheel_up: true,
wheel_down: true,
+ wheel_left: true,
+ wheel_right: true,
shift: true,
alt: true,
ctrl: true,
@@ -2530,7 +2530,6 @@ fn test_client_messages() {
client_id: Some(100),
is_cli_client: true,
});
- // tab_id roundtrip tests - verify tab_id survives serialization
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::NewTiledPane {
direction: Some(Direction::Right),
@@ -3280,7 +3279,6 @@ fn test_client_messages() {
scrollback: Some(0),
ansi: true,
});
- // Pane-targeting roundtrips
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::ScrollUpByPaneId {
pane_id: PaneId::Terminal(1),
@@ -3416,7 +3414,6 @@ fn test_client_messages() {
client_id: Some(100),
is_cli_client: true,
});
- // ANSI flag roundtrip tests
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::DumpScreen {
file_path: Some("/path".to_owned()),
@@ -3500,7 +3497,6 @@ fn test_client_messages() {
client_id: Some(100),
is_cli_client: true,
});
- // Tab-targeting roundtrips
test_client_roundtrip!(ClientToServerMsg::Action {
action: Action::UndoRenameTabByTabId { id: 5 },
terminal_id: Some(1),
@@ -3720,7 +3716,6 @@ fn test_server_messages() {
#[test]
fn set_pane_color_wire_roundtrip() {
- // Test actual byte-level encode/decode, not just struct conversion
use prost::Message;
let original = ClientToServerMsg::Action {
@@ -3734,21 +3729,17 @@ fn set_pane_color_wire_roundtrip() {
is_cli_client: true,
};
- // Rust -> proto struct
let proto: crate::client_server_contract::client_server_contract::ClientToServerMsg =
original.clone().into();
- // proto struct -> bytes (what goes over the wire)
let bytes = proto.encode_to_vec();
- // bytes -> proto struct (what the server does)
let decoded_proto =
crate::client_server_contract::client_server_contract::ClientToServerMsg::decode(
&bytes[..],
)
.expect("Failed to decode protobuf bytes");
- // proto struct -> Rust
let roundtrip: ClientToServerMsg = decoded_proto
.try_into()
.expect("Failed to convert decoded protobuf back to Rust");
@@ -3782,14 +3773,12 @@ fn rename_active_pane_wire_roundtrip() {
let proto: crate::client_server_contract::client_server_contract::ClientToServerMsg =
original.clone().into();
- // proto struct → bytes → proto struct (the actual wire path)
let mut buf = Vec::new();
proto.encode(&mut buf).expect("Failed to encode protobuf");
let decoded =
crate::client_server_contract::client_server_contract::ClientToServerMsg::decode(&buf[..])
.expect("Failed to decode protobuf bytes");
- // proto struct → Rust
let roundtrip: ClientToServerMsg = decoded
.try_into()
.expect("Failed to convert decoded protobuf back to Rust");
diff --git a/zellij-utils/src/ipc/tests/socket_tests.rs b/zellij-utils/src/ipc/tests/socket_tests.rs
index d0e9ce65..b1f8c15d 100644
--- a/zellij-utils/src/ipc/tests/socket_tests.rs
+++ b/zellij-utils/src/ipc/tests/socket_tests.rs
@@ -1,5 +1,5 @@
use crate::ipc::{
- ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ServerToClientMsg,
+ ClientToServerMsg, IpcReceiverWithContext, IpcSenderWithContext, ResizeCause, ServerToClientMsg,
};
use crate::pane_size::Size;
use interprocess::local_socket::{prelude::*, ListenerOptions};
@@ -162,9 +162,7 @@ fn bidirectional_communication_via_fd_duplication() {
let stream = listener.incoming().next().unwrap().expect("accept failed");
let mut sender: IpcSenderWithContext<ServerToClientMsg> = IpcSenderWithContext::new(stream);
- // Create a receiver from the same socket via dup()
let mut receiver: IpcReceiverWithContext<ClientToServerMsg> = sender.get_receiver();
-
sender
.send_server_msg(ServerToClientMsg::Connected)
.expect("send failed");
@@ -181,7 +179,6 @@ fn bidirectional_communication_via_fd_duplication() {
let stream = connect_stream(&name);
let mut sender: IpcSenderWithContext<ClientToServerMsg> = IpcSenderWithContext::new(stream);
- // Create a receiver from the same socket via dup()
let mut receiver: IpcReceiverWithContext<ServerToClientMsg> = sender.get_receiver();
let msg = receiver.recv_server_msg();
@@ -220,6 +217,7 @@ fn multiple_messages_in_sequence() {
rows: 50,
cols: 120,
},
+ cause: ResizeCause::Viewport,
})
.expect("send 2 failed");
sender
@@ -237,9 +235,10 @@ fn multiple_messages_in_sequence() {
let (msg2, _) = receiver.recv_client_msg().expect("missing message 2");
match msg2 {
- ClientToServerMsg::TerminalResize { new_size } => {
+ ClientToServerMsg::TerminalResize { new_size, cause } => {
assert_eq!(new_size.rows, 50);
assert_eq!(new_size.cols, 120);
+ assert_eq!(cause, ResizeCause::Viewport);
},
other => panic!("expected TerminalResize, got: {:?}", other),
}
@@ -264,7 +263,6 @@ fn receiver_returns_none_on_closed_connection() {
sender
.send_client_msg(ClientToServerMsg::ConnStatus)
.expect("send failed");
- // sender drops here, closing the connection
}
});
@@ -277,16 +275,10 @@ fn receiver_returns_none_on_closed_connection() {
let msg = receiver.recv_client_msg();
assert!(msg.is_some(), "should receive the sent message");
- // After the sender is dropped, subsequent reads should return None
let msg = receiver.recv_client_msg();
assert!(msg.is_none(), "should return None after connection closed");
}
-// --- Session discovery tests ---
-// These test the OS-specific mechanics used by get_sessions() in sessions.rs:
-// FileTypeExt::is_socket() for identifying socket files, and the assert_socket
-// probing pattern (connect, send ConnStatus, expect Connected).
-
#[cfg(unix)]
#[test]
fn is_socket_identifies_bound_unix_socket() {
@@ -318,12 +310,9 @@ fn is_socket_rejects_regular_file() {
#[test]
fn session_probe_accepts_responding_socket() {
- // Simulates the assert_socket() pattern from sessions.rs:
- // A real Zellij server responds to ConnStatus with Connected.
let (_guard, name) = new_ipc();
let listener = bind_listener(&name);
- // Spawn a fake "server" that responds to ConnStatus with Connected
let server = std::thread::spawn(move || {
let stream = listener.incoming().next().unwrap().expect("accept failed");
let mut receiver: IpcReceiverWithContext<ClientToServerMsg> =
@@ -338,7 +327,6 @@ fn session_probe_accepts_responding_socket() {
.expect("send failed");
});
- // Client-side probing (mirrors assert_socket in sessions.rs)
let stream = connect_stream(&name);
let mut sender: IpcSenderWithContext<ClientToServerMsg> = IpcSenderWithContext::new(stream);
sender
@@ -357,15 +345,11 @@ fn session_probe_accepts_responding_socket() {
#[test]
fn session_probe_rejects_dead_socket() {
- // Simulates discovering a stale socket file with no listener.
- // get_sessions() filters these out via assert_socket() which tries to connect.
let (_guard, name) = new_ipc();
- // Bind and immediately drop the listener to create a stale socket file
{
let _listener = bind_listener(&name);
}
- // Listener is dropped -- the socket file may still exist but nobody is listening
let result = try_connect(&name);
assert!(
@@ -380,21 +364,17 @@ fn socket_directory_enumeration_finds_sockets() {
use interprocess::local_socket::GenericFilePath;
use std::os::unix::fs::FileTypeExt;
- // Simulates the readdir + is_socket() filtering pattern from get_sessions().
let dir = tempfile::TempDir::new().expect("failed to create temp dir");
- // Create a socket
let sock_path = dir.path().join("test-session");
let _listener = ListenerOptions::new()
.name(sock_path.as_path().to_fs_name::<GenericFilePath>().unwrap())
.create_sync()
.expect("bind failed");
- // Create a regular file (should be filtered out)
let file_path = dir.path().join("not-a-session");
std::fs::write(&file_path, b"data").expect("write failed");
- // Enumerate the directory, filtering for sockets (same pattern as get_sessions)
let entries: Vec<String> = std::fs::read_dir(dir.path())
.expect("read_dir failed")
.filter_map(|entry| {
diff --git a/zellij-utils/src/kdl/mod.rs b/zellij-utils/src/kdl/mod.rs
index c7cc5366..0164ef92 100644
--- a/zellij-utils/src/kdl/mod.rs
+++ b/zellij-utils/src/kdl/mod.rs
@@ -81,6 +81,7 @@ macro_rules! parse_kdl_action_arguments {
"Confirm" => Ok(Action::Confirm),
"Deny" => Ok(Action::Deny),
"ToggleMouseMode" => Ok(Action::ToggleMouseMode),
+ "ToggleMobileMode" => Ok(Action::ToggleMobileMode),
"PreviousSwapLayout" => Ok(Action::PreviousSwapLayout),
"NextSwapLayout" => Ok(Action::NextSwapLayout),
"Clear" => Ok(Action::ClearScreen),
@@ -1240,6 +1241,7 @@ impl Action {
Some(node)
},
Action::ToggleMouseMode => Some(KdlNode::new("ToggleMouseMode")),
+ Action::ToggleMobileMode => Some(KdlNode::new("ToggleMobileMode")),
Action::PreviousSwapLayout => Some(KdlNode::new("PreviousSwapLayout")),
Action::NextSwapLayout => Some(KdlNode::new("NextSwapLayout")),
Action::BreakPane => Some(KdlNode::new("BreakPane")),
@@ -1582,6 +1584,9 @@ impl TryFrom<(&KdlNode, &Options)> for Action {
"ToggleMouseMode" => {
parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
},
+ "ToggleMobileMode" => {
+ parse_kdl_action_arguments!(action_name, action_arguments, kdl_action)
+ },
"Detach" => parse_kdl_action_arguments!(action_name, action_arguments, kdl_action),
"SwitchSession" => {
let name = kdl_get_string_property_or_child_value!(kdl_action, "name")
@@ -2824,6 +2829,39 @@ impl Options {
let mouse_click_through =
kdl_property_first_arg_as_bool_or_error!(kdl_options, "mouse_click_through")
.map(|(v, _)| v);
+ let mobile_layout =
+ match kdl_property_first_arg_as_string_or_error!(kdl_options, "mobile_layout") {
+ Some((value, entry)) => {
+ use crate::input::options::MobileLayoutConfiguration;
+ match value.parse::<MobileLayoutConfiguration>() {
+ Ok(v) => Some(v),
+ Err(e) => return Err(kdl_parsing_error!(e, entry)),
+ }
+ },
+ None => None,
+ };
+ let mobile_threshold_cols =
+ match kdl_property_first_arg_as_i64_or_error!(kdl_options, "mobile_threshold_cols") {
+ Some((value, _)) if value >= 0 => Some(value as u16),
+ Some((value, entry)) => {
+ return Err(kdl_parsing_error!(
+ format!("mobile_threshold_cols must be >= 0, found '{}'", value),
+ entry
+ ));
+ },
+ None => None,
+ };
+ let mobile_threshold_rows =
+ match kdl_property_first_arg_as_i64_or_error!(kdl_options, "mobile_threshold_rows") {
+ Some((value, _)) if value >= 0 => Some(value as u16),
+ Some((value, entry)) => {
+ return Err(kdl_parsing_error!(
+ format!("mobile_threshold_rows must be >= 0, found '{}'", value),
+ entry
+ ));
+ },
+ None => None,
+ };
Ok(Options {
simplified_ui,
@@ -2873,6 +2911,9 @@ impl Options {
enforce_https_for_localhost,
post_command_discovery_hook,
client_async_worker_tasks,
+ mobile_layout,
+ mobile_threshold_cols,
+ mobile_threshold_rows,
})
}
pub fn from_string(stringified_keybindings: &String) -> Result<Self, ConfigError> {
@@ -4218,6 +4259,94 @@ impl Options {
None
}
}
+ fn mobile_layout_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ use crate::input::options::MobileLayoutConfiguration;
+ let comment_text = format!(
+ "{}\n{}\n{}\n{}\n{}\n{}\n{}",
+ " ",
+ "// When attached clients land in the mobile UI plugin.",
+ "// Options:",
+ "// - \"web\" (Default — web clients only, gated on mobile_threshold_cols/rows)",
+ "// - \"always\" (any client, gated on mobile_threshold_cols/rows)",
+ "// - \"never\"",
+ "// ",
+ );
+ let create_node = |value: MobileLayoutConfiguration| -> KdlNode {
+ let mut node = KdlNode::new("mobile_layout");
+ let s = match value {
+ MobileLayoutConfiguration::Web => "web",
+ MobileLayoutConfiguration::Always => "always",
+ MobileLayoutConfiguration::Never => "never",
+ };
+ node.push(KdlValue::String(s.to_string()));
+ node
+ };
+ if let Some(value) = self.mobile_layout {
+ let mut node = create_node(value);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(MobileLayoutConfiguration::Web);
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
+ fn mobile_threshold_cols_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}",
+ " ",
+ "// Column breakpoint for mobile_layout (web/always). 0 = always match.",
+ "// Default: 60",
+ );
+ let create_node = |value: u16| -> KdlNode {
+ let mut node = KdlNode::new("mobile_threshold_cols");
+ node.push(KdlValue::Base10(value as i64));
+ node
+ };
+ if let Some(value) = self.mobile_threshold_cols {
+ let mut node = create_node(value);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(60);
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
+ fn mobile_threshold_rows_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
+ let comment_text = format!(
+ "{}\n{}\n{}",
+ " ",
+ "// Row breakpoint for mobile_layout (web/always). 0 = always match.",
+ "// Default: 30",
+ );
+ let create_node = |value: u16| -> KdlNode {
+ let mut node = KdlNode::new("mobile_threshold_rows");
+ node.push(KdlValue::Base10(value as i64));
+ node
+ };
+ if let Some(value) = self.mobile_threshold_rows {
+ let mut node = create_node(value);
+ if add_comments {
+ node.set_leading(format!("{}\n", comment_text));
+ }
+ Some(node)
+ } else if add_comments {
+ let mut node = create_node(30);
+ node.set_leading(format!("{}\n// ", comment_text));
+ Some(node)
+ } else {
+ None
+ }
+ }
fn client_async_worker_tasks_to_kdl(&self, add_comments: bool) -> Option<KdlNode> {
let comment_text = r#"
// Number of async worker tasks to spawn per active client.
@@ -4396,6 +4525,15 @@ impl Options {
{
nodes.push(client_async_worker_tasks);
}
+ if let Some(mobile_layout) = self.mobile_layout_to_kdl(add_comments) {
+ nodes.push(mobile_layout);
+ }
+ if let Some(mobile_threshold_cols) = self.mobile_threshold_cols_to_kdl(add_comments) {
+ nodes.push(mobile_threshold_cols);
+ }
+ if let Some(mobile_threshold_rows) = self.mobile_threshold_rows_to_kdl(add_comments) {
+ nodes.push(mobile_threshold_rows);
+ }
nodes
}
}
@@ -7111,6 +7249,9 @@ fn config_options_to_string() {
support_kitty_keyboard_protocol false
web_server true
web_sharing "disabled"
+ mobile_layout "always"
+ mobile_threshold_cols 72
+ mobile_threshold_rows 0
"##;
let document: KdlDocument = fake_config.parse().unwrap();
let deserialized = Options::from_kdl(&document).unwrap();
@@ -7158,6 +7299,9 @@ fn config_options_to_string_with_comments() {
support_kitty_keyboard_protocol false
web_server true
web_sharing "disabled"
+ mobile_layout "always"
+ mobile_threshold_cols 72
+ mobile_threshold_rows 0
"##;
let document: KdlDocument = fake_config.parse().unwrap();
let deserialized = Options::from_kdl(&document).unwrap();
@@ -7192,6 +7336,37 @@ fn config_options_to_string_without_options() {
}
#[test]
+fn mobile_layout_kdl_round_trip_for_every_variant() {
+ use crate::input::options::MobileLayoutConfiguration;
+ let cases = [
+ ("web", MobileLayoutConfiguration::Web, (60, 30)),
+ ("always", MobileLayoutConfiguration::Always, (0, 0)),
+ ("never", MobileLayoutConfiguration::Never, (40, 0)),
+ ];
+ for (value, expected, (cols, rows)) in cases {
+ let fake_config = format!(
+ r##"
+ mobile_layout "{value}"
+ mobile_threshold_cols {cols}
+ mobile_threshold_rows {rows}
+ "##
+ );
+ let document: KdlDocument = fake_config.parse().unwrap();
+ let parsed = Options::from_kdl(&document).unwrap();
+ assert_eq!(parsed.mobile_layout, Some(expected), "case: {value}");
+ assert_eq!(parsed.mobile_threshold_cols, Some(cols), "case: {value}");
+ assert_eq!(parsed.mobile_threshold_rows, Some(rows), "case: {value}");
+
+ let mut serialized = Options::to_kdl(&parsed, false);
+ let mut fake_document = KdlDocument::new();
+ fake_document.nodes_mut().append(&mut serialized);
+ let reparsed =
+ Options::from_kdl(&fake_document.to_string().parse::<KdlDocument>().unwrap()).unwrap();
+ assert_eq!(parsed, reparsed, "round-trip mismatch for {value}");
+ }
+}
+
+#[test]
fn config_options_to_string_with_some_options() {
let fake_config = r##"
default_layout "compact"
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
index 6eda59b9..1ab39935 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string.snap
@@ -261,6 +261,7 @@ plugins {
filepicker location="zellij:strider" {
cwd "/"
}
+ mobile location="zellij:mobile"
plugin-manager location="zellij:plugin-manager"
session-manager location="zellij:session-manager"
status-bar location="zellij:status-bar"
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
index c49fa8f9..5dbede33 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__bare_config_from_default_assets_to_string_with_comments.snap
@@ -264,6 +264,7 @@ plugins {
filepicker location="zellij:strider" {
cwd "/"
}
+ mobile location="zellij:mobile"
plugin-manager location="zellij:plugin-manager"
session-manager location="zellij:session-manager"
status-bar location="zellij:status-bar"
@@ -582,4 +583,19 @@ web_client {
// typically work best. Set to 0 to use the number of (physical) CPU cores.
// Note: This only applies to web clients at the moment.
// client_async_worker_tasks 4
-
+
+// When attached clients land in the mobile UI plugin.
+// Options:
+// - "web" (Default — web clients only, gated on mobile_threshold_cols/rows)
+// - "always" (any client, gated on mobile_threshold_cols/rows)
+// - "never"
+//
+// mobile_layout "web"
+
+// Column breakpoint for mobile_layout (web/always). 0 = always match.
+// Default: 60
+// mobile_threshold_cols 60
+
+// Row breakpoint for mobile_layout (web/always). 0 = always match.
+// Default: 30
+// mobile_threshold_rows 30
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap
index 6c78ea96..43c82f7e 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string.snap
@@ -31,3 +31,6 @@ disable_session_metadata true
support_kitty_keyboard_protocol false
web_server true
web_sharing "disabled"
+mobile_layout "always"
+mobile_threshold_cols 72
+mobile_threshold_rows 0
diff --git a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
index eda40849..347a2074 100644
--- a/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
+++ b/zellij-utils/src/kdl/snapshots/zellij_utils__kdl__config_options_to_string_with_comments.snap
@@ -301,4 +301,19 @@ web_sharing "disabled"
// typically work best. Set to 0 to use the number of (physical) CPU cores.
// Note: This only applies to web clients at the moment.
// client_async_worker_tasks 4
-
+
+// When attached clients land in the mobile UI plugin.
+// Options:
+// - "web" (Default — web clients only, gated on mobile_threshold_cols/rows)
+// - "always" (any client, gated on mobile_threshold_cols/rows)
+// - "never"
+//
+mobile_layout "always"
+
+// Column breakpoint for mobile_layout (web/always). 0 = always match.
+// Default: 60
+mobile_threshold_cols 72
+
+// Row breakpoint for mobile_layout (web/always). 0 = always match.
+// Default: 30
+mobile_threshold_rows 0
diff --git a/zellij-utils/src/pane_size.rs b/zellij-utils/src/pane_size.rs
index 4ba87326..454e73c5 100644
--- a/zellij-utils/src/pane_size.rs
+++ b/zellij-utils/src/pane_size.rs
@@ -68,7 +68,7 @@ pub struct Offset {
pub left: usize,
}
-#[derive(Clone, Copy, Default, PartialEq, Debug, Serialize, Deserialize)]
+#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct Size {
pub rows: usize,
pub cols: usize,
@@ -80,6 +80,14 @@ pub struct SizeInPixels {
pub width: usize,
}
+#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, Serialize, Deserialize)]
+pub struct Insets {
+ pub top: usize,
+ pub bottom: usize,
+ pub left: usize,
+ pub right: usize,
+}
+
#[derive(Eq, Clone, Copy, PartialEq, Debug, Serialize, Deserialize, Hash)]
pub struct Dimension {
pub constraint: Constraint,
diff --git a/zellij-utils/src/plugin_api/action.proto b/zellij-utils/src/plugin_api/action.proto
index 28a8337e..7eedf393 100644
--- a/zellij-utils/src/plugin_api/action.proto
+++ b/zellij-utils/src/plugin_api/action.proto
@@ -586,6 +586,8 @@ message MouseEventPayload {
bool ctrl = 9;
int64 line = 10;
int64 column = 11;
+ bool wheel_left = 12;
+ bool wheel_right = 13;
}
message RunCommandAction {
diff --git a/zellij-utils/src/plugin_api/action.rs b/zellij-utils/src/plugin_api/action.rs
index cd3e1522..34b3adce 100644
--- a/zellij-utils/src/plugin_api/action.rs
+++ b/zellij-utils/src/plugin_api/action.rs
@@ -1943,7 +1943,8 @@ impl TryFrom<Action> for ProtobufAction {
| Action::SaveSession
| Action::ListTabs { .. }
| Action::CurrentTabInfo { .. }
- | Action::SetPaneColor { .. } => Err("Unsupported action"),
+ | Action::SetPaneColor { .. }
+ | Action::ToggleMobileMode => Err("Unsupported action"),
}
}
}
@@ -2097,6 +2098,8 @@ impl TryFrom<ProtobufMouseEventPayload> for MouseEvent {
middle: protobuf_event.middle as bool,
wheel_up: protobuf_event.wheel_up as bool,
wheel_down: protobuf_event.wheel_down as bool,
+ wheel_left: protobuf_event.wheel_left as bool,
+ wheel_right: protobuf_event.wheel_right as bool,
shift: protobuf_event.shift as bool,
alt: protobuf_event.alt as bool,
ctrl: protobuf_event.ctrl as bool,
@@ -2119,6 +2122,8 @@ impl TryFrom<MouseEvent> for ProtobufMouseEventPayload {
middle: event.middle as bool,
wheel_up: event.wheel_up as bool,
wheel_down: event.wheel_down as bool,
+ wheel_left: event.wheel_left as bool,
+ wheel_right: event.wheel_right as bool,
shift: event.shift as bool,
alt: event.alt as bool,
ctrl: event.ctrl as bool,
diff --git a/zellij-utils/src/plugin_api/event.proto b/zellij-utils/src/plugin_api/event.proto
index 5334f434..2567c657 100644
--- a/zellij-utils/src/plugin_api/event.proto
+++ b/zellij-utils/src/plugin_api/event.proto
@@ -69,6 +69,7 @@ enum EventType {
InitialKeybinds = 44;
CommandChanged = 45;
HostTerminalThemeChanged = 46;
+ SoftKeyboardVisibilityChanged = 47;
}
message EventNameList {
@@ -117,6 +118,7 @@ message Event {
InitialKeybindsPayload initial_keybinds_payload = 38;
CommandChangedPayload command_changed_payload = 39;
HostTerminalThemeChangedPayload host_terminal_theme_changed_payload = 40;
+ SoftKeyboardVisibilityChangedPayload soft_keyboard_visibility_changed_payload = 41;
}
}
@@ -124,6 +126,10 @@ message HostTerminalThemeChangedPayload {
HostTerminalThemeIndication mode = 1;
}
+message SoftKeyboardVisibilityChangedPayload {
+ bool visible = 1;
+}
+
enum HostTerminalThemeIndication {
Dark = 0;
Light = 1;
@@ -305,6 +311,8 @@ enum MouseEventName {
MouseHold = 4;
MouseRelease = 5;
MouseHover = 6;
+ MouseScrollLeft = 7;
+ MouseScrollRight = 8;
}
message TabUpdatePayload {
@@ -512,6 +520,7 @@ message PaneContents {
optional SelectedText selected_text = 2;
repeated string lines_above_viewport = 3;
repeated string lines_below_viewport = 4;
+ optional action.Position cursor = 5;
}
message PaneScrollbackResponse {
diff --git a/zellij-utils/src/plugin_api/event.rs b/zellij-utils/src/plugin_api/event.rs
index af421fac..5ba106d0 100644
--- a/zellij-utils/src/plugin_api/event.rs
+++ b/zellij-utils/src/plugin_api/event.rs
@@ -26,6 +26,7 @@ pub use super::generated_api::api::{
PluginConfigurationChangedPayload as ProtobufPluginConfigurationChangedPayload,
PluginInfo as ProtobufPluginInfo, ResurrectableSession as ProtobufResurrectableSession,
SelectedText as ProtobufSelectedText, SessionManifest as ProtobufSessionManifest,
+ SoftKeyboardVisibilityChangedPayload as ProtobufSoftKeyboardVisibilityChangedPayload,
SyntaxError as ProtobufSyntaxError, TabInfo as ProtobufTabInfo,
TabMetadata as ProtobufTabMetadata, UserActionPayload as ProtobufUserActionPayload,
WebServerStatusPayload as ProtobufWebServerStatusPayload, WebSharing as ProtobufWebSharing,
@@ -588,6 +589,14 @@ impl TryFrom<ProtobufEvent> for Event {
},
_ => Err("Malformed payload for HostTerminalThemeChanged Event"),
},
+ Some(ProtobufEventType::SoftKeyboardVisibilityChanged) => {
+ match protobuf_event.payload {
+ Some(ProtobufEventPayload::SoftKeyboardVisibilityChangedPayload(p)) => {
+ Ok(Event::SoftKeyboardVisibilityChanged(p.visible))
+ },
+ _ => Err("Malformed payload for SoftKeyboardVisibilityChanged Event"),
+ }
+ },
None => Err("Unknown Protobuf Event"),
}
}
@@ -1129,6 +1138,15 @@ impl TryFrom<Event> for ProtobufEvent {
payload: Some(event::Payload::HostTerminalThemeChangedPayload(payload)),
})
},
+ Event::SoftKeyboardVisibilityChanged(visible) => {
+ let payload = ProtobufSoftKeyboardVisibilityChangedPayload { visible };
+ Ok(ProtobufEvent {
+ name: ProtobufEventType::SoftKeyboardVisibilityChanged as i32,
+ payload: Some(event::Payload::SoftKeyboardVisibilityChangedPayload(
+ payload,
+ )),
+ })
+ },
Event::InitialKeybinds(keybinds) => {
let mut protobuf_keybinds: Vec<ProtobufInputModeKeybinds> = vec![];
for (input_mode, input_mode_keybinds) in keybinds {
@@ -1624,6 +1642,22 @@ impl TryFrom<MouseEventPayload> for Mouse {
),
_ => Err("Malformed payload for mouse hover"),
},
+ Some(MouseEventName::MouseScrollLeft) => {
+ match mouse_event_payload.mouse_event_payload {
+ Some(mouse_event_payload::MouseEventPayload::LineCount(line_count)) => {
+ Ok(Mouse::ScrollLeft(line_count as usize))
+ },
+ _ => Err("Malformed payload for mouse scroll left"),
+ }
+ },
+ Some(MouseEventName::MouseScrollRight) => {
+ match mouse_event_payload.mouse_event_payload {
+ Some(mouse_event_payload::MouseEventPayload::LineCount(line_count)) => {
+ Ok(Mouse::ScrollRight(line_count as usize))
+ },
+ _ => Err("Malformed payload for mouse scroll right"),
+ }
+ },
None => Err("Malformed payload for MouseEventName"),
}
}
@@ -1690,6 +1724,18 @@ impl TryFrom<Mouse> for MouseEventPayload {
},
)),
}),
+ Mouse::ScrollLeft(cols) => Ok(MouseEventPayload {
+ mouse_event_name: MouseEventName::MouseScrollLeft as i32,
+ mouse_event_payload: Some(mouse_event_payload::MouseEventPayload::LineCount(
+ cols as u32,
+ )),
+ }),
+ Mouse::ScrollRight(cols) => Ok(MouseEventPayload {
+ mouse_event_name: MouseEventName::MouseScrollRight as i32,
+ mouse_event_payload: Some(mouse_event_payload::MouseEventPayload::LineCount(
+ cols as u32,
+ )),
+ }),
}
}
}
@@ -2078,6 +2124,9 @@ impl TryFrom<ProtobufEventType> for EventType {
ProtobufEventType::HighlightClicked => EventType::HighlightClicked,
ProtobufEventType::InitialKeybinds => EventType::InitialKeybinds,
ProtobufEventType::HostTerminalThemeChanged => EventType::HostTerminalThemeChanged,
+ ProtobufEventType::SoftKeyboardVisibilityChanged => {
+ EventType::SoftKeyboardVisibilityChanged
+ },
})
}
}
@@ -2132,6 +2181,9 @@ impl TryFrom<EventType> for ProtobufEventType {
EventType::HighlightClicked => ProtobufEventType::HighlightClicked,
EventType::InitialKeybinds => ProtobufEventType::InitialKeybinds,
EventType::HostTerminalThemeChanged => ProtobufEventType::HostTerminalThemeChanged,
+ EventType::SoftKeyboardVisibilityChanged => {
+ ProtobufEventType::SoftKeyboardVisibilityChanged
+ },
})
}
}
@@ -2986,12 +3038,16 @@ impl TryFrom<ProtobufPaneContents> for PaneContents {
.selected_text
.map(|st| st.try_into())
.transpose()?;
+ let cursor = protobuf_contents
+ .cursor
+ .map(|p| (p.column as usize, p.line as usize));
Ok(PaneContents {
viewport: protobuf_contents.viewport,
selected_text,
lines_above_viewport: protobuf_contents.lines_above_viewport,
lines_below_viewport: protobuf_contents.lines_below_viewport,
+ cursor,
})
}
}
@@ -3003,12 +3059,17 @@ impl TryFrom<PaneContents> for ProtobufPaneContents {
.selected_text
.map(|st| st.try_into())
.transpose()?;
+ let cursor = pane_contents.cursor.map(|(x, y)| ProtobufPosition {
+ line: y as i64,
+ column: x as i64,
+ });
Ok(ProtobufPaneContents {
viewport: pane_contents.viewport,
selected_text,
lines_above_viewport: pane_contents.lines_above_viewport,
lines_below_viewport: pane_contents.lines_below_viewport,
+ cursor,
})
}
}
@@ -3104,6 +3165,7 @@ fn serialize_pane_render_report_with_ansi_event_with_data() {
selected_text: None,
lines_above_viewport: vec![],
lines_below_viewport: vec![],
+ cursor: None,
},
);
let event = Event::PaneRenderReportWithAnsi(pane_contents_map);
diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto
index 729159e3..c65bcbfd 100644
--- a/zellij-utils/src/plugin_api/plugin_command.proto
+++ b/zellij-utils/src/plugin_api/plugin_command.proto
@@ -207,6 +207,12 @@ enum CommandName {
KillSessionsAndReply = 212;
DeleteDeadSessionAndReply = 213;
DeleteAllDeadSessionsAndReply = 214;
+ SetSoftKeyboard = 215;
+ SetTabFit = 216;
+ SetShadowFocus = 219;
+ NewTabUnfocused = 220;
+ NewTiledPaneInTab = 221;
+ ExitMobileMode = 222;
}
message PluginCommand {
@@ -361,6 +367,11 @@ message PluginCommand {
GetSessionListPayload get_session_list_payload = 161;
KillSessionsPayload kill_sessions_and_reply_payload = 162;
string delete_dead_session_and_reply_payload = 163;
+ SetSoftKeyboardPayload set_soft_keyboard_payload = 164;
+ SetTabFitPayload set_tab_fit_payload = 165;
+ PaneId set_shadow_focus_payload = 167;
+ NewTabUnfocusedPayload new_tab_unfocused_payload = 168;
+ NewTiledPaneInTabPayload new_tiled_pane_in_tab_payload = 169;
}
}
@@ -369,6 +380,15 @@ message NewTabPayload {
optional string cwd = 2;
}
+message NewTabUnfocusedPayload {
+ optional string name = 1;
+ optional string cwd = 2;
+}
+
+message NewTiledPaneInTabPayload {
+ uint32 tab_position = 1;
+}
+
message DumpSessionLayoutPayload {
optional uint32 tab_index = 1;
}
@@ -1057,6 +1077,17 @@ message NewTabResponse {
}
}
+message NewTabUnfocusedResponse {
+ oneof result {
+ uint64 tab_id = 1;
+ bool none = 2;
+ }
+}
+
+message NewTiledPaneInTabResponse {
+ optional PaneId pane_id = 1;
+}
+
message NewTabsResponse {
repeated uint64 tab_ids = 1; // Only includes successfully created tabs
}
@@ -1328,3 +1359,18 @@ message DeleteDeadSessionResponse {
message DeleteAllDeadSessionsResponse {
optional string error = 1; // absent => success
}
+
+message SetSoftKeyboardPayload {
+ bool on = 1;
+}
+
+message Size {
+ uint32 rows = 1;
+ uint32 cols = 2;
+}
+
+message SetTabFitPayload {
+ uint32 tab_id = 1;
+ PaneId pane_id = 2;
+ Size size = 3;
+}
diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs
index fa77739a..42b0774f 100644
--- a/zellij-utils/src/plugin_api/plugin_command.rs
+++ b/zellij-utils/src/plugin_api/plugin_command.rs
@@ -13,9 +13,9 @@ pub use super::generated_api::api::{
get_focused_pane_info_response, get_pane_cwd_response, get_pane_pid_response,
get_pane_running_command_response, get_session_list_response, hide_floating_panes_response,
highlight_style::Style as ProtobufHighlightStyleVariant, new_tab_response,
- parse_layout_response, plugin_command::Payload, rename_layout_response,
- save_layout_response, save_session_response, show_floating_panes_response,
- BreakPanesToNewTabPayload,
+ new_tab_unfocused_response, parse_layout_response, plugin_command::Payload,
+ rename_layout_response, save_layout_response, save_session_response,
+ show_floating_panes_response, BreakPanesToNewTabPayload,
BreakPanesToNewTabResponse as ProtobufBreakPanesToNewTabResponse,
BreakPanesToTabWithIdPayload,
BreakPanesToTabWithIdResponse as ProtobufBreakPanesToTabWithIdResponse,
@@ -65,8 +65,10 @@ pub use super::generated_api::api::{
KillSessionsResponse as ProtobufKillSessionsResponse, ListTokensResponse,
LoadNewPluginPayload, MessageToPluginPayload, MovePaneWithPaneIdInDirectionPayload,
MovePaneWithPaneIdPayload, MovePayload, NewPluginArgs as ProtobufNewPluginArgs,
- NewTabPayload, NewTabResponse as ProtobufNewTabResponse,
+ NewTabPayload, NewTabResponse as ProtobufNewTabResponse, NewTabUnfocusedPayload,
+ NewTabUnfocusedResponse as ProtobufNewTabUnfocusedResponse,
NewTabsResponse as ProtobufNewTabsResponse, NewTabsWithLayoutInfoPayload,
+ NewTiledPaneInTabPayload, NewTiledPaneInTabResponse as ProtobufNewTiledPaneInTabResponse,
OpenCommandPaneBackgroundResponse as ProtobufOpenCommandPaneBackgroundResponse,
OpenCommandPaneFloatingNearPluginPayload,
OpenCommandPaneFloatingNearPluginResponse as ProtobufOpenCommandPaneFloatingNearPluginResponse,
@@ -119,13 +121,15 @@ pub use super::generated_api::api::{
ScrollToBottomInPaneIdPayload, ScrollToTopInPaneIdPayload, ScrollUpInPaneIdPayload,
SessionListSnapshot as ProtobufSessionListSnapshot, SetFloatingPanePinnedPayload,
SetPaneBorderlessPayload, SetPaneColorPayload, SetPaneRegexHighlightsPayload,
- SetSelfMouseSelectionSupportPayload, SetTimeoutPayload, ShowCursorPayload,
+ SetSelfMouseSelectionSupportPayload,
+ SetSoftKeyboardPayload as ProtobufSetSoftKeyboardPayload,
+ SetTabFitPayload as ProtobufSetTabFitPayload, SetTimeoutPayload, ShowCursorPayload,
ShowFloatingPanesPayload as ProtobufShowFloatingPanesPayload,
ShowFloatingPanesResponse as ProtobufShowFloatingPanesResponse, ShowPaneWithIdPayload,
- StackPanesPayload, SubscribePayload, SwitchSessionPayload, SwitchTabToIdPayload,
- SwitchTabToPayload, TogglePaneBorderlessPayload, TogglePaneEmbedOrEjectForPaneIdPayload,
- TogglePaneIdFullscreenPayload, UnsubscribePayload, WebRequestPayload,
- WriteCharsToPaneIdPayload, WriteToPaneIdPayload,
+ Size as ProtobufSize, StackPanesPayload, SubscribePayload, SwitchSessionPayload,
+ SwitchTabToIdPayload, SwitchTabToPayload, TogglePaneBorderlessPayload,
+ TogglePaneEmbedOrEjectForPaneIdPayload, TogglePaneIdFullscreenPayload, UnsubscribePayload,
+ WebRequestPayload, WriteCharsToPaneIdPayload, WriteToPaneIdPayload,
},
plugin_permission::PermissionType as ProtobufPermissionType,
resize::ResizeAction as ProtobufResizeAction,
@@ -141,11 +145,26 @@ use crate::data::{
};
use crate::input::actions::Action;
use crate::input::layout::PercentOrFixed;
+use crate::pane_size::Size;
use std::collections::BTreeMap;
use std::convert::TryFrom;
use std::path::PathBuf;
+fn size_from_protobuf(size: ProtobufSize) -> Size {
+ Size {
+ rows: size.rows as usize,
+ cols: size.cols as usize,
+ }
+}
+
+fn size_to_protobuf(size: Size) -> ProtobufSize {
+ ProtobufSize {
+ rows: size.rows as u32,
+ cols: size.cols as u32,
+ }
+}
+
impl Into<FloatingPaneCoordinates> for ProtobufFloatingPaneCoordinates {
fn into(self) -> FloatingPaneCoordinates {
FloatingPaneCoordinates {
@@ -900,6 +919,27 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
}),
_ => Err("Mismatched payload for NewTab"),
},
+ Some(CommandName::NewTabUnfocused) => match protobuf_plugin_command.payload {
+ Some(Payload::NewTabUnfocusedPayload(payload)) => {
+ Ok(PluginCommand::NewTabUnfocused {
+ name: payload.name,
+ cwd: payload.cwd,
+ })
+ },
+ None => Ok(PluginCommand::NewTabUnfocused {
+ name: None,
+ cwd: None,
+ }),
+ _ => Err("Mismatched payload for NewTabUnfocused"),
+ },
+ Some(CommandName::NewTiledPaneInTab) => match protobuf_plugin_command.payload {
+ Some(Payload::NewTiledPaneInTabPayload(payload)) => {
+ Ok(PluginCommand::NewTiledPaneInTab {
+ tab_position: payload.tab_position as usize,
+ })
+ },
+ _ => Err("Mismatched payload for NewTiledPaneInTab"),
+ },
Some(CommandName::GoToNextTab) => {
if protobuf_plugin_command.payload.is_some() {
return Err("GoToNextTab should not have a payload");
@@ -1448,6 +1488,38 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
Some(CommandName::DeleteAllDeadSessionsAndReply) => {
Ok(PluginCommand::DeleteAllDeadSessionsAndReply)
},
+ Some(CommandName::SetSoftKeyboard) => match protobuf_plugin_command.payload {
+ Some(Payload::SetSoftKeyboardPayload(payload)) => {
+ Ok(PluginCommand::SetSoftKeyboard(payload.on))
+ },
+ _ => Err("Mismatched payload for SetSoftKeyboard"),
+ },
+ Some(CommandName::SetTabFit) => match protobuf_plugin_command.payload {
+ Some(Payload::SetTabFitPayload(payload)) => {
+ let fit = match (payload.pane_id, payload.size) {
+ (Some(pane_id), Some(size)) => {
+ Some((pane_id.try_into()?, size_from_protobuf(size)))
+ },
+ (None, None) => None,
+ _ => return Err("Malformed set_tab_fit_payload payload"),
+ };
+ Ok(PluginCommand::SetTabFit {
+ tab_id: payload.tab_id as usize,
+ fit,
+ })
+ },
+ _ => Err("Mismatched payload for SetTabFit"),
+ },
+ Some(CommandName::ExitMobileMode) => match protobuf_plugin_command.payload {
+ Some(_) => Err("ExitMobileMode should have no payload, found a payload"),
+ None => Ok(PluginCommand::ExitMobileMode),
+ },
+ Some(CommandName::SetShadowFocus) => match protobuf_plugin_command.payload {
+ Some(Payload::SetShadowFocusPayload(pane_id)) => {
+ Ok(PluginCommand::SetShadowFocus(pane_id.try_into()?))
+ },
+ _ => Err("Mismatched payload for SetShadowFocus"),
+ },
Some(CommandName::DumpSessionLayout) => match protobuf_plugin_command.payload {
Some(Payload::DumpSessionLayoutPayload(payload)) => {
Ok(PluginCommand::DumpSessionLayout {
@@ -2890,6 +2962,21 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
name: CommandName::NewTab as i32,
payload: Some(Payload::NewTabPayload(NewTabPayload { name, cwd })),
}),
+ PluginCommand::NewTabUnfocused { name, cwd } => Ok(ProtobufPluginCommand {
+ name: CommandName::NewTabUnfocused as i32,
+ payload: Some(Payload::NewTabUnfocusedPayload(NewTabUnfocusedPayload {
+ name,
+ cwd,
+ })),
+ }),
+ PluginCommand::NewTiledPaneInTab { tab_position } => Ok(ProtobufPluginCommand {
+ name: CommandName::NewTiledPaneInTab as i32,
+ payload: Some(Payload::NewTiledPaneInTabPayload(
+ NewTiledPaneInTabPayload {
+ tab_position: tab_position as u32,
+ },
+ )),
+ }),
PluginCommand::GoToNextTab => Ok(ProtobufPluginCommand {
name: CommandName::GoToNextTab as i32,
payload: None,
@@ -3315,6 +3402,32 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
name: CommandName::DeleteAllDeadSessionsAndReply as i32,
payload: None,
}),
+ PluginCommand::SetSoftKeyboard(on) => Ok(ProtobufPluginCommand {
+ name: CommandName::SetSoftKeyboard as i32,
+ payload: Some(Payload::SetSoftKeyboardPayload(
+ ProtobufSetSoftKeyboardPayload { on },
+ )),
+ }),
+ PluginCommand::SetTabFit { tab_id, fit } => {
+ let (pane_id, size) = match fit {
+ Some((pane_id, size)) => {
+ (Some(pane_id.try_into()?), Some(size_to_protobuf(size)))
+ },
+ None => (None, None),
+ };
+ Ok(ProtobufPluginCommand {
+ name: CommandName::SetTabFit as i32,
+ payload: Some(Payload::SetTabFitPayload(ProtobufSetTabFitPayload {
+ tab_id: tab_id as u32,
+ pane_id,
+ size,
+ })),
+ })
+ },
+ PluginCommand::ExitMobileMode => Ok(ProtobufPluginCommand {
+ name: CommandName::ExitMobileMode as i32,
+ payload: None,
+ }),
PluginCommand::DumpSessionLayout { tab_index } => Ok(ProtobufPluginCommand {
name: CommandName::DumpSessionLayout as i32,
payload: tab_index.map(|idx| {
@@ -3437,6 +3550,10 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
name: CommandName::SendSigintToPaneId as i32,
payload: Some(Payload::SendSigintToPaneIdPayload(pane_id.try_into()?)),
}),
+ PluginCommand::SetShadowFocus(pane_id) => Ok(ProtobufPluginCommand {
+ name: CommandName::SetShadowFocus as i32,
+ payload: Some(Payload::SetShadowFocusPayload(pane_id.try_into()?)),
+ }),
PluginCommand::SendSigkillToPaneId(pane_id) => Ok(ProtobufPluginCommand {
name: CommandName::SendSigkillToPaneId as i32,
payload: Some(Payload::SendSigkillToPaneIdPayload(pane_id.try_into()?)),
@@ -4433,7 +4550,8 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
// Conversion implementations for tab creation response types
use crate::data::{
BreakPanesToNewTabResponse, BreakPanesToTabWithIdResponse, BreakPanesToTabWithIndexResponse,
- FocusOrCreateTabResponse, NewTabResponse, NewTabsResponse, OpenCommandPaneBackgroundResponse,
+ FocusOrCreateTabResponse, NewTabResponse, NewTabUnfocusedResponse, NewTabsResponse,
+ NewTiledPaneInTabResponse, OpenCommandPaneBackgroundResponse,
OpenCommandPaneFloatingNearPluginResponse, OpenCommandPaneFloatingResponse,
OpenCommandPaneInPlaceOfPaneIdResponse, OpenCommandPaneInPlaceOfPluginResponse,
OpenCommandPaneInPlaceResponse, OpenCommandPaneNearPluginResponse, OpenCommandPaneResponse,
@@ -4468,6 +4586,47 @@ impl From<NewTabResponse> for ProtobufNewTabResponse {
}
}
+impl TryFrom<ProtobufNewTabUnfocusedResponse> for NewTabUnfocusedResponse {
+ type Error = &'static str;
+ fn try_from(protobuf: ProtobufNewTabUnfocusedResponse) -> Result<Self, Self::Error> {
+ match protobuf.result {
+ Some(new_tab_unfocused_response::Result::TabId(id)) => Ok(Some(id as usize)),
+ Some(new_tab_unfocused_response::Result::None(_)) | None => Ok(None),
+ }
+ }
+}
+
+impl From<NewTabUnfocusedResponse> for ProtobufNewTabUnfocusedResponse {
+ fn from(response: NewTabUnfocusedResponse) -> Self {
+ match response {
+ Some(tab_id) => ProtobufNewTabUnfocusedResponse {
+ result: Some(new_tab_unfocused_response::Result::TabId(tab_id as u64)),
+ },
+ None => ProtobufNewTabUnfocusedResponse {
+ result: Some(new_tab_unfocused_response::Result::None(true)),
+ },
+ }
+ }
+}
+
+impl TryFrom<ProtobufNewTiledPaneInTabResponse> for NewTiledPaneInTabResponse {
+ type Error = &'static str;
+ fn try_from(protobuf: ProtobufNewTiledPaneInTabResponse) -> Result<Self, Self::Error> {
+ match protobuf.pane_id {
+ Some(pane_id) => Ok(Some(pane_id.try_into()?)),
+ None => Ok(None),
+ }
+ }
+}
+
+impl From<NewTiledPaneInTabResponse> for ProtobufNewTiledPaneInTabResponse {
+ fn from(response: NewTiledPaneInTabResponse) -> Self {
+ ProtobufNewTiledPaneInTabResponse {
+ pane_id: response.map(|p| p.try_into().unwrap()),
+ }
+ }
+}
+
impl TryFrom<ProtobufNewTabsResponse> for NewTabsResponse {
type Error = &'static str;
fn try_from(protobuf: ProtobufNewTabsResponse) -> Result<Self, Self::Error> {
@@ -5053,3 +5212,47 @@ impl From<OpenPluginPaneFloatingResponse> for ProtobufOpenPluginPaneFloatingResp
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::data::{PaneId, PluginCommand};
+ use crate::pane_size::Size;
+
+ #[test]
+ fn set_tab_fit_set_protobuf_round_trip() {
+ let original = PluginCommand::SetTabFit {
+ tab_id: 7,
+ fit: Some((PaneId::Terminal(3), Size { rows: 24, cols: 80 })),
+ };
+ let protobuf: ProtobufPluginCommand = original.try_into().expect("encode");
+ let decoded: PluginCommand = protobuf.try_into().expect("decode");
+ match decoded {
+ PluginCommand::SetTabFit { tab_id, fit } => {
+ assert_eq!(tab_id, 7);
+ assert_eq!(
+ fit,
+ Some((PaneId::Terminal(3), Size { rows: 24, cols: 80 }))
+ );
+ },
+ other => panic!("expected SetTabFit, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn set_tab_fit_clear_protobuf_round_trip() {
+ let original = PluginCommand::SetTabFit {
+ tab_id: 7,
+ fit: None,
+ };
+ let protobuf: ProtobufPluginCommand = original.try_into().expect("encode");
+ let decoded: PluginCommand = protobuf.try_into().expect("decode");
+ match decoded {
+ PluginCommand::SetTabFit { tab_id, fit } => {
+ assert_eq!(tab_id, 7);
+ assert_eq!(fit, None);
+ },
+ other => panic!("expected SetTabFit, got {:?}", other),
+ }
+ }
+}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
index a13669e5..0d51e89b 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_config_options.snap
@@ -52,4 +52,7 @@ Options {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
index 9a0e7eda..1b0c154a 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__cli_arguments_override_layout_options.snap
@@ -52,4 +52,7 @@ Options {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
index 52734174..08b89aac 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments-3.snap
@@ -50,4 +50,7 @@ Options {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
index 2b17d5a3..a8a144a8 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__default_config_with_no_cli_arguments.snap
@@ -6028,6 +6028,9 @@ Config {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
},
themes: {},
plugins: PluginAliases {
@@ -6082,6 +6085,18 @@ Config {
"/",
),
},
+ "mobile": RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "mobile",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
"plugin-manager": RunPlugin {
_allow_exec_host_cmd: false,
location: Zellij(
@@ -6189,5 +6204,6 @@ Config {
cursor_style: None,
mac_option_is_meta: true,
base_url: None,
+ font_size: None,
},
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
index 1f1e8268..4792ab0c 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_env_vars_override_config_env_vars.snap
@@ -6028,6 +6028,9 @@ Config {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
},
themes: {},
plugins: PluginAliases {
@@ -6082,6 +6085,18 @@ Config {
"/",
),
},
+ "mobile": RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "mobile",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
"plugin-manager": RunPlugin {
_allow_exec_host_cmd: false,
location: Zellij(
@@ -6193,5 +6208,6 @@ Config {
cursor_style: None,
mac_option_is_meta: true,
base_url: None,
+ font_size: None,
},
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
index 8ad32dc7..8f5baed6 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_keybinds_override_config_keybinds.snap
@@ -137,6 +137,9 @@ Config {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
},
themes: {},
plugins: PluginAliases {
@@ -191,6 +194,18 @@ Config {
"/",
),
},
+ "mobile": RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "mobile",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
"plugin-manager": RunPlugin {
_allow_exec_host_cmd: false,
location: Zellij(
@@ -298,5 +313,6 @@ Config {
cursor_style: None,
mac_option_is_meta: true,
base_url: None,
+ font_size: None,
},
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
index 74927ae6..40822e6e 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_options_override_config_options.snap
@@ -52,4 +52,7 @@ Options {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
index 28c7b32d..2cb1c9af 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_themes_override_config_themes.snap
@@ -6028,6 +6028,9 @@ Config {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
},
themes: {
"other-theme-from-config": Theme {
@@ -7853,6 +7856,18 @@ Config {
"/",
),
},
+ "mobile": RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "mobile",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
"plugin-manager": RunPlugin {
_allow_exec_host_cmd: false,
location: Zellij(
@@ -7960,5 +7975,6 @@ Config {
cursor_style: None,
mac_option_is_meta: true,
base_url: None,
+ font_size: None,
},
}
diff --git a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
index 43f29c48..431c8fc4 100644
--- a/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
+++ b/zellij-utils/src/snapshots/zellij_utils__setup__setup_test__layout_ui_config_overrides_config_ui_config.snap
@@ -6028,6 +6028,9 @@ Config {
enforce_https_for_localhost: None,
post_command_discovery_hook: None,
client_async_worker_tasks: None,
+ mobile_layout: None,
+ mobile_threshold_cols: None,
+ mobile_threshold_rows: None,
},
themes: {},
plugins: PluginAliases {
@@ -6082,6 +6085,18 @@ Config {
"/",
),
},
+ "mobile": RunPlugin {
+ _allow_exec_host_cmd: false,
+ location: Zellij(
+ PluginTag(
+ "mobile",
+ ),
+ ),
+ configuration: PluginUserConfiguration(
+ {},
+ ),
+ initial_cwd: None,
+ },
"plugin-manager": RunPlugin {
_allow_exec_host_cmd: false,
location: Zellij(
@@ -6189,5 +6204,6 @@ Config {
cursor_style: None,
mac_option_is_meta: true,
base_url: None,
+ font_size: None,
},
}