diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-06-10 16:50:55 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-10 16:50:55 +0200 |
| commit | b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch) | |
| tree | 38c77a381cfb937b428752a588c09a45c81434c9 | |
| parent | feat: PWA support for the web client (#5184) (diff) | |
| download | sl0ppij-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
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) @@ -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" @@ -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 ®ions { + 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 ®ions { + 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 ®ions { + 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 ®ions { + 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, ®ular_token).await; let regular_web_client_id = create_client_session(port, ®ular_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, ®ular_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, ®ular_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 Binary files differnew file mode 100755 index 00000000..d31d600e --- /dev/null +++ b/zellij-utils/assets/plugins/mobile.wasm 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, }, } |
