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 /default-plugins/mobile/src | |
| 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
Diffstat (limited to 'default-plugins/mobile/src')
22 files changed, 5159 insertions, 0 deletions
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) + } +} |
