aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/mobile/src
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-06-10 16:50:55 +0200
committerGitHub <noreply@github.com>2026-06-10 16:50:55 +0200
commitb6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch)
tree38c77a381cfb937b428752a588c09a45c81434c9 /default-plugins/mobile/src
parentfeat: PWA support for the web client (#5184) (diff)
downloadsl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.gz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.xz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.zip
Mobile layout (#5241)
* phase 2+3 * initial mobile plugin (phases 4 + 5 + 6) * some bugfixes * implement stage 7 * mobile interface improvements * improve mobile pane/tab picker * more ui improvements and fixes * various mobile fixes * some mobile fixes * mobile keyboard triggering fix * some cleanups * initial plugin keyboard implementation * improve keyboard hit zones * improve keyboard * pinch resize * pinching fixes * implement scrolling + panning * initial keyboard resizing * fix: mobile first render issues * keyboard responsiveness * fix some issues with the keyboard * implement fit * fix mobile scrolling * add tilde cell to compact * improve mobile keyboard * fix starting mobile terminal font to natural keyboard size * fix mobile resize blank screen flash * shadow focus for mobile * more keyboard fixes * improve mobile status bar * fix: render plugins to mobile clients * allow opening new panes/tabs from the mobile plugin * allow configuring * fix dot slash * improve mobile native soft keyboard interaction * switch to native keyboard - initial implementation * dirty hacks, but phone keyboard works now * adjust keyboard help strip * fix cursor in mobile * fix keyboard and help line * rename keyboard to modifier bar * allow starting new session * make clicking session name bring us to new session menu * allow switching to desktop from the mobile ui * mobile welcome screen initial implementation * mobile welcome screen fixes * various welcome screen fixes * more mobile fixes * moar mobile fixes * mobile ui redesign * fix: swallowed backspace bug * switch session screen improvement * touch up some interfaces * refactor: move mobile state to own struct * refactor: move fit resizing to the server * remove dead flash code * some cleanups * refactor: clear fit on close * refactor: improve fit api * refactor: pane sync functions * refactor: mouse * refactor: break into modules * some cleanups for the sessions screen * fix scrolling and other stuff * refactor: sessions screen * refactor: panes screen * refactor: new session screen * refactor: menu screen * refactor: render * cleanups * refactor: plugin structure * remove unused welcome screen path * Rename api * refactor input.js * cleanup useless comments * fit/mobile refactoring * take out the garbage * remove more comments * remove mobile flash on startup/attach * fix: various mobile startup issues * fix space bugs * cleanup * rustfmt * fix windows build * adjust modifier bar ui * add pr
Diffstat (limited to 'default-plugins/mobile/src')
-rw-r--r--default-plugins/mobile/src/ansi.rs235
-rw-r--r--default-plugins/mobile/src/click.rs131
-rw-r--r--default-plugins/mobile/src/components/mod.rs2
-rw-r--r--default-plugins/mobile/src/components/modifier_bar.rs681
-rw-r--r--default-plugins/mobile/src/components/top_bar.rs412
-rw-r--r--default-plugins/mobile/src/fit.rs146
-rw-r--r--default-plugins/mobile/src/frame.rs142
-rw-r--r--default-plugins/mobile/src/input.rs28
-rw-r--r--default-plugins/mobile/src/keys.rs195
-rw-r--r--default-plugins/mobile/src/main.rs577
-rw-r--r--default-plugins/mobile/src/mouse.rs180
-rw-r--r--default-plugins/mobile/src/navigation.rs14
-rw-r--r--default-plugins/mobile/src/pane_sync.rs99
-rw-r--r--default-plugins/mobile/src/render.rs125
-rw-r--r--default-plugins/mobile/src/screens/menu.rs217
-rw-r--r--default-plugins/mobile/src/screens/mod.rs20
-rw-r--r--default-plugins/mobile/src/screens/new_session.rs233
-rw-r--r--default-plugins/mobile/src/screens/panes.rs879
-rw-r--r--default-plugins/mobile/src/screens/sessions.rs458
-rw-r--r--default-plugins/mobile/src/screens/viewport.rs239
-rw-r--r--default-plugins/mobile/src/state.rs24
-rw-r--r--default-plugins/mobile/src/workspace.rs122
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 &regions {
+ assert_eq!(r.row_start, 5);
+ assert_eq!(r.row_end, 6);
+ assert_eq!(r.col_start, cursor);
+ cursor = r.col_end;
+ }
+ assert_eq!(cursor, 90);
+ }
+
+ #[test]
+ fn render_modifier_bar_tiles_at_compact_width() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 30, &mut regions);
+ assert_eq!(regions.len(), BAR_CELL_COUNT);
+ regions.sort_by_key(|r| r.col_start);
+ let mut cursor = 0usize;
+ for r in &regions {
+ assert_eq!(r.col_start, cursor);
+ cursor = r.col_end;
+ }
+ assert_eq!(cursor, 30);
+ }
+
+ #[test]
+ fn render_modifier_bar_drops_minus_at_narrow() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 21, &mut regions);
+ assert_eq!(regions.len(), 8);
+ for r in &regions {
+ if let ClickAction::Keyboard(id) = r.action {
+ assert_ne!(id, CELL_MINUS);
+ }
+ }
+ }
+
+ #[test]
+ fn render_modifier_bar_drops_minus_and_tab_at_minimum() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 17, &mut regions);
+ assert_eq!(regions.len(), 7);
+ for r in &regions {
+ if let ClickAction::Keyboard(id) = r.action {
+ assert_ne!(id, CELL_MINUS);
+ assert_ne!(id, CELL_TAB);
+ }
+ }
+ }
+
+ #[test]
+ fn render_modifier_bar_noop_when_too_narrow() {
+ let mods = KeyboardModifiers::default();
+ let mut regions = Vec::new();
+ render_modifier_bar(&mods, 0, 16, &mut regions);
+ assert!(regions.is_empty());
+ }
+}
diff --git a/default-plugins/mobile/src/components/top_bar.rs b/default-plugins/mobile/src/components/top_bar.rs
new file mode 100644
index 00000000..c873c2e6
--- /dev/null
+++ b/default-plugins/mobile/src/components/top_bar.rs
@@ -0,0 +1,412 @@
+use std::ops::Range;
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::ansi::pad_or_truncate;
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::screens::ActiveScreen;
+use crate::workspace::Workspace;
+
+const PREFIX: &str = "Zellij ";
+const HAMBURGER: &str = "\u{2630}";
+const SESSION_PANE_SEP: &str = " ";
+
+const HAMBURGER_SLOP_CELLS: usize = 3;
+
+pub(crate) fn render(
+ ws: &Workspace,
+ frame: &mut Frame,
+ active: ActiveScreen,
+ row: usize,
+ cols: usize,
+) {
+ let pane_name = current_pane_name(ws);
+ let session_name = ws.session_name.clone();
+ let content_max = cols.saturating_sub(HAMBURGER_SLOP_CELLS + width(HAMBURGER));
+ let plan = Plan::compute(
+ session_name.as_deref().map(width),
+ width(&pane_name),
+ width(PREFIX),
+ width(SESSION_PANE_SEP),
+ content_max,
+ );
+
+ let mut bar = BarBuilder::default();
+ if plan.show_prefix {
+ bar.push(PREFIX);
+ }
+
+ let session_seg = session_name
+ .as_ref()
+ .filter(|_| plan.session_target > 0)
+ .map(|session| {
+ let seg = bar.push(&pad_or_truncate(session, plan.session_target));
+ bar.push(SESSION_PANE_SEP);
+ seg
+ });
+
+ let pane_seg = bar.push(&pad_or_truncate(&pane_name, plan.pane_target));
+ let pane_end = bar.cells;
+
+ let pad = cols
+ .saturating_sub(bar.cells + width(HAMBURGER))
+ .max(HAMBURGER_SLOP_CELLS);
+ bar.push(&" ".repeat(pad));
+
+ let hamburger_start = bar.cells;
+ let hamburger_seg = bar.push(HAMBURGER);
+
+ paint(
+ &bar.text,
+ row,
+ cols,
+ &pane_seg,
+ &hamburger_seg,
+ session_seg.as_ref(),
+ );
+
+ let (pane_action, session_action) = actions_for(active);
+ let session_cells = session_seg.map(|s| (s.cells.start, s.cells.end));
+ for region in click_regions(
+ row,
+ cols,
+ pane_end,
+ hamburger_start,
+ pane_action,
+ session_cells,
+ session_action,
+ ) {
+ frame.click_regions.push(region);
+ }
+}
+
+fn paint(
+ bar: &str,
+ row: usize,
+ cols: usize,
+ pane_seg: &Segment,
+ hamburger_seg: &Segment,
+ session_seg: Option<&Segment>,
+) {
+ let mut text = Text::new(bar)
+ .selected()
+ .color_range(2, pane_seg.chars.clone())
+ .color_range(3, hamburger_seg.chars.clone());
+ if let Some(seg) = session_seg {
+ text = text.color_range(0, seg.chars.clone());
+ }
+ print_text_with_coordinates(text, 0, row, Some(cols), None);
+}
+
+fn actions_for(active: ActiveScreen) -> (ClickAction, ClickAction) {
+ if active == ActiveScreen::Viewport {
+ (ClickAction::ExpandPanes, ClickAction::ExpandSessions)
+ } else {
+ (ClickAction::CollapseSelector, ClickAction::CollapseSelector)
+ }
+}
+
+fn current_pane_name(ws: &Workspace) -> String {
+ ws.current_pane()
+ .map(|p| {
+ if p.title.is_empty() {
+ format!("#{}", p.id)
+ } else {
+ p.title.clone()
+ }
+ })
+ .unwrap_or_else(|| "—".to_string())
+}
+
+fn width(s: &str) -> usize {
+ UnicodeWidthStr::width(s)
+}
+
+#[derive(Default)]
+struct BarBuilder {
+ text: String,
+ chars: usize,
+ cells: usize,
+}
+
+struct Segment {
+ chars: Range<usize>,
+ cells: Range<usize>,
+}
+
+impl BarBuilder {
+ fn push(&mut self, s: &str) -> Segment {
+ let chars = self.chars..self.chars + s.chars().count();
+ let cells = self.cells..self.cells + width(s);
+ self.text.push_str(s);
+ self.chars = chars.end;
+ self.cells = cells.end;
+ Segment { chars, cells }
+ }
+}
+
+struct Plan {
+ show_prefix: bool,
+ session_target: usize,
+ pane_target: usize,
+}
+
+impl Plan {
+ fn compute(
+ session_w: Option<usize>,
+ pane_w: usize,
+ prefix_w: usize,
+ sep_w: usize,
+ content_max: usize,
+ ) -> Self {
+ let Some(session_w) = session_w else {
+ return Plan {
+ show_prefix: true,
+ session_target: 0,
+ pane_target: pane_w.min(content_max.saturating_sub(prefix_w)),
+ };
+ };
+ if prefix_w + session_w + sep_w + pane_w <= content_max {
+ return Plan {
+ show_prefix: true,
+ session_target: session_w,
+ pane_target: pane_w,
+ };
+ }
+ if session_w + sep_w + pane_w <= content_max {
+ return Plan {
+ show_prefix: false,
+ session_target: session_w,
+ pane_target: pane_w,
+ };
+ }
+ let available = content_max.saturating_sub(sep_w);
+ let half = available / 2;
+ let (session_target, pane_target) = if session_w <= half {
+ (session_w, available.saturating_sub(session_w))
+ } else if pane_w <= half {
+ (available.saturating_sub(pane_w), pane_w)
+ } else {
+ (half, available.saturating_sub(half))
+ };
+ Plan {
+ show_prefix: false,
+ session_target,
+ pane_target,
+ }
+ }
+}
+
+pub fn click_regions(
+ row: usize,
+ cols: usize,
+ pane_end: usize,
+ hamburger_start: usize,
+ pane_action: ClickAction,
+ session_cells: Option<(usize, usize)>,
+ session_action: ClickAction,
+) -> Vec<ClickRegion> {
+ let mut regions = Vec::with_capacity(5);
+ push_content_regions(
+ &mut regions,
+ row,
+ pane_end,
+ session_cells,
+ pane_action,
+ session_action,
+ );
+
+ regions.push(ClickRegion::tight(
+ row,
+ hamburger_start,
+ cols,
+ ClickAction::ToggleMenu,
+ ));
+ let hamburger_center = (hamburger_start.min(cols.saturating_sub(1)), row);
+ regions.push(ClickRegion::slop(
+ row,
+ pane_end,
+ cols,
+ ClickAction::ToggleMenu,
+ hamburger_center,
+ ));
+ regions
+}
+
+fn push_content_regions(
+ regions: &mut Vec<ClickRegion>,
+ row: usize,
+ pane_end: usize,
+ session_cells: Option<(usize, usize)>,
+ pane_action: ClickAction,
+ session_action: ClickAction,
+) {
+ let Some((session_start, session_end)) = clamp_session(session_cells, pane_end) else {
+ if pane_end > 0 {
+ regions.push(ClickRegion::tight(row, 0, pane_end, pane_action));
+ }
+ return;
+ };
+ if session_start > 0 {
+ regions.push(ClickRegion::tight(
+ row,
+ 0,
+ session_start,
+ pane_action.clone(),
+ ));
+ }
+ regions.push(ClickRegion::tight(
+ row,
+ session_start,
+ session_end,
+ session_action,
+ ));
+ if session_end < pane_end {
+ regions.push(ClickRegion::tight(row, session_end, pane_end, pane_action));
+ }
+}
+
+fn clamp_session(session_cells: Option<(usize, usize)>, pane_end: usize) -> Option<(usize, usize)> {
+ let (start, end) = session_cells?;
+ let (start, end) = (start.min(pane_end), end.min(pane_end));
+ (start < end).then_some((start, end))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::state::State;
+
+ #[test]
+ fn partition_with_slop() {
+ let cols = 80;
+ let pane_end = 40;
+ let hamburger_start = 79;
+ let regions = click_regions(
+ 0,
+ cols,
+ pane_end,
+ hamburger_start,
+ ClickAction::ExpandPanes,
+ None,
+ ClickAction::ExpandSessions,
+ );
+
+ assert_eq!(regions.len(), 3);
+ assert!(matches!(regions[0].action, ClickAction::ExpandPanes));
+ assert_eq!(regions[0].priority, 0);
+ assert_eq!(regions[0].col_start, 0);
+ assert_eq!(regions[0].col_end, pane_end);
+ assert!(matches!(regions[1].action, ClickAction::ToggleMenu));
+ assert_eq!(regions[1].priority, 0);
+ assert_eq!(regions[1].col_start, hamburger_start);
+ assert_eq!(regions[1].col_end, cols);
+ assert!(matches!(regions[2].action, ClickAction::ToggleMenu));
+ assert_eq!(regions[2].priority, 1);
+ assert_eq!(regions[2].col_start, pane_end);
+ assert_eq!(regions[2].col_end, cols);
+
+ let mut state = State::default();
+ state.frame.click_regions = regions.clone();
+ assert_eq!(
+ state.frame.click_to_action(0, 0),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, pane_end + 5),
+ Some(ClickAction::ToggleMenu),
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, hamburger_start),
+ Some(ClickAction::ToggleMenu)
+ );
+ }
+
+ #[test]
+ fn pane_action_collapses_in_selector_mode() {
+ let regions = click_regions(
+ 0,
+ 80,
+ 40,
+ 79,
+ ClickAction::CollapseSelector,
+ None,
+ ClickAction::CollapseSelector,
+ );
+ assert!(matches!(regions[0].action, ClickAction::CollapseSelector));
+ let mut state = State::default();
+ state.frame.click_regions = regions;
+ assert_eq!(
+ state.frame.click_to_action(0, 0),
+ Some(ClickAction::CollapseSelector)
+ );
+ }
+
+ #[test]
+ fn session_sub_region_dispatches_expand_sessions() {
+ let regions = click_regions(
+ 0,
+ 80,
+ 17,
+ 79,
+ ClickAction::ExpandPanes,
+ Some((7, 11)),
+ ClickAction::ExpandSessions,
+ );
+
+ assert_eq!(regions.len(), 5);
+ assert_eq!((regions[0].col_start, regions[0].col_end), (0, 7));
+ assert!(matches!(regions[0].action, ClickAction::ExpandPanes));
+ assert_eq!((regions[1].col_start, regions[1].col_end), (7, 11));
+ assert!(matches!(regions[1].action, ClickAction::ExpandSessions));
+ assert_eq!((regions[2].col_start, regions[2].col_end), (11, 17));
+ assert!(matches!(regions[2].action, ClickAction::ExpandPanes));
+
+ let mut state = State::default();
+ state.frame.click_regions = regions;
+ assert_eq!(
+ state.frame.click_to_action(0, 3),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 9),
+ Some(ClickAction::ExpandSessions)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 11),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 14),
+ Some(ClickAction::ExpandPanes)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 30),
+ Some(ClickAction::ToggleMenu)
+ );
+ assert_eq!(
+ state.frame.click_to_action(0, 79),
+ Some(ClickAction::ToggleMenu)
+ );
+ }
+
+ #[test]
+ fn session_at_left_edge_skips_empty_prefix_region() {
+ let regions = click_regions(
+ 0,
+ 40,
+ 11,
+ 39,
+ ClickAction::ExpandPanes,
+ Some((0, 4)),
+ ClickAction::ExpandSessions,
+ );
+
+ assert_eq!(regions.len(), 4);
+ assert_eq!((regions[0].col_start, regions[0].col_end), (0, 4));
+ assert!(matches!(regions[0].action, ClickAction::ExpandSessions));
+ assert_eq!((regions[1].col_start, regions[1].col_end), (4, 11));
+ assert!(matches!(regions[1].action, ClickAction::ExpandPanes));
+ }
+}
diff --git a/default-plugins/mobile/src/fit.rs b/default-plugins/mobile/src/fit.rs
new file mode 100644
index 00000000..b5ea284d
--- /dev/null
+++ b/default-plugins/mobile/src/fit.rs
@@ -0,0 +1,146 @@
+use zellij_tile::prelude::*;
+
+use crate::frame::{chrome_offsets, Frame};
+use crate::workspace::{pane_id_of, Workspace};
+
+#[derive(Default)]
+pub struct Fit {
+ pub active: bool,
+ pub tab_id: Option<usize>,
+ pub last_sent_size: Option<Size>,
+}
+
+impl Fit {
+ pub fn toggle(&mut self, ws: &Workspace, frame: &Frame, suppress_top_bar: bool) -> bool {
+ if self.active {
+ let tab_id = self.tab_id.unwrap_or_default();
+ self.active = false;
+ self.tab_id = None;
+ self.last_sent_size = None;
+ set_tab_fit(tab_id, None);
+ true
+ } else {
+ let Some(pane) = ws.current_pane() else {
+ return false;
+ };
+ let Some(tab) = ws.current_tab().cloned() else {
+ return false;
+ };
+ self.active = true;
+ self.tab_id = Some(tab.tab_id);
+ let size = embedded_size(frame, suppress_top_bar);
+ set_tab_fit(tab.tab_id, Some((pane_id_of(&pane), size)));
+ self.last_sent_size = Some(size);
+ true
+ }
+ }
+
+ pub fn clear_if_active(&mut self) {
+ if self.active {
+ let tab_id = self.tab_id.unwrap_or_default();
+ self.active = false;
+ self.tab_id = None;
+ self.last_sent_size = None;
+ set_tab_fit(tab_id, None);
+ }
+ }
+
+ pub fn reset_local(&mut self) {
+ self.active = false;
+ self.tab_id = None;
+ self.last_sent_size = None;
+ }
+
+ pub fn notify_size(&mut self, ws: &Workspace, frame: &Frame, suppress_top_bar: bool) {
+ if !self.active {
+ return;
+ }
+ let Some(tab_id) = self.tab_id else {
+ return;
+ };
+ let Some(pane) = ws.current_pane() else {
+ return;
+ };
+ let size = embedded_size(frame, suppress_top_bar);
+ if self.last_sent_size == Some(size) {
+ return;
+ }
+ set_tab_fit(tab_id, Some((pane_id_of(&pane), size)));
+ self.last_sent_size = Some(size);
+ }
+}
+
+pub fn embedded_size(frame: &Frame, suppress_top_bar: bool) -> Size {
+ let (body_top, bar_height) = chrome_offsets(
+ frame.last_render_rows,
+ suppress_top_bar,
+ frame.soft_keyboard_visible,
+ );
+ Size {
+ rows: frame
+ .last_render_rows
+ .saturating_sub(bar_height)
+ .saturating_sub(body_top),
+ cols: frame.last_render_cols,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frame_with_dims(rows: usize, cols: usize, soft_keyboard: bool) -> Frame {
+ let mut frame = Frame::default();
+ frame.last_render_rows = rows;
+ frame.last_render_cols = cols;
+ frame.soft_keyboard_visible = soft_keyboard;
+ frame
+ }
+
+ #[test]
+ fn embedded_size_default_top_bar_only() {
+ let frame = frame_with_dims(20, 80, false);
+ assert_eq!(embedded_size(&frame, false), Size { rows: 19, cols: 80 });
+ }
+
+ #[test]
+ fn embedded_size_soft_keyboard_adds_bottom() {
+ let frame = frame_with_dims(20, 80, true);
+ assert_eq!(embedded_size(&frame, false), Size { rows: 18, cols: 80 });
+ }
+
+ #[test]
+ fn embedded_size_suppressed_top_bar() {
+ let frame = frame_with_dims(20, 80, false);
+ assert_eq!(embedded_size(&frame, true), Size { rows: 20, cols: 80 });
+ }
+
+ #[test]
+ fn notify_size_gated_off() {
+ let ws = Workspace::default();
+ let frame = frame_with_dims(20, 80, false);
+ let mut fit = Fit::default();
+ fit.active = false;
+ fit.tab_id = Some(7);
+ fit.notify_size(&ws, &frame, false);
+ assert_eq!(fit.last_sent_size, None);
+ fit.active = true;
+ fit.tab_id = None;
+ fit.notify_size(&ws, &frame, false);
+ assert!(fit.active);
+ assert_eq!(fit.last_sent_size, None);
+ }
+
+ #[test]
+ fn clear_if_active_round_trip() {
+ let mut fit = Fit::default();
+ fit.active = true;
+ fit.tab_id = Some(7);
+ fit.clear_if_active();
+ assert!(!fit.active);
+ assert_eq!(fit.tab_id, None);
+ fit.clear_if_active();
+ assert!(!fit.active);
+ assert_eq!(fit.tab_id, None);
+ }
+}
diff --git a/default-plugins/mobile/src/frame.rs b/default-plugins/mobile/src/frame.rs
new file mode 100644
index 00000000..9428de27
--- /dev/null
+++ b/default-plugins/mobile/src/frame.rs
@@ -0,0 +1,142 @@
+use zellij_tile::prelude::*;
+
+use crate::click::{slop_key, ClickAction, ClickRegion};
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub enum LastEmittedCursor {
+ #[default]
+ Unknown,
+ Sent(Option<(usize, usize)>),
+}
+
+#[derive(Default)]
+pub struct Frame {
+ pub click_regions: Vec<ClickRegion>,
+ pub last_render_rows: usize,
+ pub last_render_cols: usize,
+ pub last_emitted_cursor: LastEmittedCursor,
+ pub soft_keyboard_visible: bool,
+}
+
+impl Frame {
+ pub fn emit_cursor(&mut self, new_pos: Option<(usize, usize)>) {
+ let needs_emit = match self.last_emitted_cursor {
+ LastEmittedCursor::Unknown => true,
+ LastEmittedCursor::Sent(prev) => prev != new_pos,
+ };
+ if needs_emit {
+ show_cursor(new_pos);
+ self.last_emitted_cursor = LastEmittedCursor::Sent(new_pos);
+ }
+ }
+
+ pub fn click_to_action(&self, row: usize, col: usize) -> Option<ClickAction> {
+ for region in &self.click_regions {
+ if region.priority == 0
+ && region.row_start <= row
+ && row < region.row_end
+ && col >= region.col_start
+ && col < region.col_end
+ {
+ return Some(region.action.clone());
+ }
+ }
+ let mut best: Option<(&ClickRegion, u64)> = None;
+ for region in &self.click_regions {
+ if region.priority == 0 {
+ continue;
+ }
+ if row < region.row_start || row >= region.row_end {
+ continue;
+ }
+ if col < region.col_start || col >= region.col_end {
+ continue;
+ }
+ let Some((cx, cy)) = region.center else {
+ continue;
+ };
+ let dx = (cx as i64 - col as i64).unsigned_abs();
+ let dy = (cy as i64 - row as i64).unsigned_abs();
+ let dist_sq = dx * dx + dy * dy;
+ best = Some(match best {
+ None => (region, dist_sq),
+ Some((cur, cur_d)) if dist_sq < cur_d => (region, dist_sq),
+ Some((cur, cur_d)) if dist_sq == cur_d => {
+ let cur_key = slop_key(cur);
+ let new_key = slop_key(region);
+ if new_key < cur_key {
+ (region, dist_sq)
+ } else {
+ (cur, cur_d)
+ }
+ },
+ Some(prev) => prev,
+ });
+ }
+ best.map(|(r, _)| r.action.clone())
+ }
+}
+
+pub fn chrome_offsets(
+ rows: usize,
+ suppress_top_bar: bool,
+ soft_keyboard_visible: bool,
+) -> (usize, usize) {
+ let body_top = if suppress_top_bar { 0 } else { 1 };
+ let bar_height = if soft_keyboard_visible && rows.saturating_sub(body_top) >= 2 {
+ 1
+ } else {
+ 0
+ };
+ (body_top, bar_height)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::components::modifier_bar::CellId;
+
+ fn kb(id: u16) -> ClickAction {
+ ClickAction::Keyboard(CellId(id))
+ }
+
+ #[test]
+ fn tight_wins_over_overlapping_slop() {
+ let mut f = Frame::default();
+ f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
+ f.click_regions
+ .push(ClickRegion::slop(5, 9, 14, kb(1), (11, 5)));
+ f.click_regions.push(ClickRegion::tight(5, 13, 16, kb(2)));
+ f.click_regions
+ .push(ClickRegion::slop(5, 12, 17, kb(2), (14, 5)));
+ assert_eq!(f.click_to_action(5, 12), Some(kb(1)));
+ assert_eq!(f.click_to_action(5, 13), Some(kb(2)));
+ }
+
+ #[test]
+ fn slop_resolves_by_nearest_center() {
+ let mut f = Frame::default();
+ f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
+ for r in 4..=6 {
+ f.click_regions
+ .push(ClickRegion::slop(r, 9, 14, kb(1), (11, 5)));
+ }
+ f.click_regions.push(ClickRegion::tight(7, 10, 13, kb(2)));
+ for r in 6..=8 {
+ f.click_regions
+ .push(ClickRegion::slop(r, 9, 14, kb(2), (11, 7)));
+ }
+ assert_eq!(f.click_to_action(6, 11), Some(kb(1)));
+ assert_eq!(f.click_to_action(8, 11), Some(kb(2)));
+ }
+
+ #[test]
+ fn miss_returns_none() {
+ let mut f = Frame::default();
+ f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
+ f.click_regions
+ .push(ClickRegion::slop(5, 9, 14, kb(1), (11, 5)));
+ assert!(f.click_to_action(0, 0).is_none());
+ assert!(f.click_to_action(5, 20).is_none());
+ }
+}
diff --git a/default-plugins/mobile/src/input.rs b/default-plugins/mobile/src/input.rs
new file mode 100644
index 00000000..d797f19c
--- /dev/null
+++ b/default-plugins/mobile/src/input.rs
@@ -0,0 +1,28 @@
+use zellij_tile::prelude::*;
+
+use crate::components::modifier_bar::{CellId, ModifierBarController, TapOutcome};
+
+#[derive(Default)]
+pub struct Input {
+ pub modifier_bar: ModifierBarController,
+ pub ctrl_held: bool,
+ pub alt_held: bool,
+}
+
+impl Input {
+ pub fn merge_held_modifiers(&self, key: &KeyWithModifier) -> KeyWithModifier {
+ let mut merged = key.clone();
+ if self.ctrl_held {
+ merged.key_modifiers.insert(KeyModifier::Ctrl);
+ }
+ if self.alt_held {
+ merged.key_modifiers.insert(KeyModifier::Alt);
+ }
+ merged
+ }
+
+ pub fn handle_tap(&mut self, cell: CellId) -> TapOutcome {
+ self.modifier_bar
+ .handle_tap(cell, &mut self.ctrl_held, &mut self.alt_held)
+ }
+}
diff --git a/default-plugins/mobile/src/keys.rs b/default-plugins/mobile/src/keys.rs
new file mode 100644
index 00000000..ba385686
--- /dev/null
+++ b/default-plugins/mobile/src/keys.rs
@@ -0,0 +1,195 @@
+use zellij_tile::prelude::*;
+
+pub fn serialize_key(key: &KeyWithModifier) -> Vec<u8> {
+ let ctrl = key.key_modifiers.contains(&KeyModifier::Ctrl);
+ let alt = key.key_modifiers.contains(&KeyModifier::Alt);
+ let shift = key.key_modifiers.contains(&KeyModifier::Shift);
+
+ match key.bare_key {
+ BareKey::Char(c) => serialize_char(c, ctrl, alt, shift),
+ BareKey::Enter => prefix_alt(alt, b"\r"),
+ BareKey::Tab => {
+ if shift {
+ let back_tab = b"\x1b[Z";
+ prefix_alt(alt, back_tab)
+ } else {
+ prefix_alt(alt, b"\t")
+ }
+ },
+ BareKey::Backspace => {
+ if ctrl {
+ prefix_alt(alt, b"\x08")
+ } else {
+ prefix_alt(alt, b"\x7f")
+ }
+ },
+ BareKey::Esc => prefix_alt(alt, b"\x1b"),
+ BareKey::Up => arrow_or_modified(b'A', ctrl, alt, shift),
+ BareKey::Down => arrow_or_modified(b'B', ctrl, alt, shift),
+ BareKey::Right => arrow_or_modified(b'C', ctrl, alt, shift),
+ BareKey::Left => arrow_or_modified(b'D', ctrl, alt, shift),
+ BareKey::Home => arrow_or_modified(b'H', ctrl, alt, shift),
+ BareKey::End => arrow_or_modified(b'F', ctrl, alt, shift),
+ BareKey::Insert => tilde_or_modified(2, ctrl, alt, shift),
+ BareKey::Delete => tilde_or_modified(3, ctrl, alt, shift),
+ BareKey::PageUp => tilde_or_modified(5, ctrl, alt, shift),
+ BareKey::PageDown => tilde_or_modified(6, ctrl, alt, shift),
+ BareKey::F(n) => serialize_function_key(n, ctrl, alt, shift),
+ BareKey::CapsLock
+ | BareKey::ScrollLock
+ | BareKey::NumLock
+ | BareKey::PrintScreen
+ | BareKey::Pause
+ | BareKey::Menu => Vec::new(),
+ }
+}
+
+fn serialize_char(c: char, ctrl: bool, alt: bool, _shift: bool) -> Vec<u8> {
+ if ctrl {
+ if let Some(byte) = ctrl_byte(c) {
+ return prefix_alt(alt, &[byte]);
+ }
+ }
+ let mut buf = [0u8; 4];
+ let encoded = c.encode_utf8(&mut buf).as_bytes();
+ prefix_alt(alt, encoded)
+}
+
+fn ctrl_byte(c: char) -> Option<u8> {
+ let lower = c.to_ascii_lowercase();
+ match lower {
+ '@' | ' ' => Some(0x00),
+ c if ('a'..='z').contains(&c) => Some((c as u8) - b'`'),
+ '[' => Some(0x1b),
+ '\\' => Some(0x1c),
+ ']' => Some(0x1d),
+ '^' => Some(0x1e),
+ '_' | '?' => Some(0x1f),
+ _ => None,
+ }
+}
+
+fn arrow_or_modified(letter: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ if ctrl || shift {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[1;{}{}", modifier, letter as char).into_bytes()
+ } else {
+ prefix_alt(alt, &[0x1b, b'[', letter])
+ }
+}
+
+fn tilde_or_modified(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ if ctrl || shift {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[{};{}~", n, modifier).into_bytes()
+ } else {
+ prefix_alt(alt, format!("\x1b[{}~", n).as_bytes())
+ }
+}
+
+fn serialize_function_key(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ let any_mod = ctrl || shift;
+ match n {
+ 1..=4 => {
+ let letter = match n {
+ 1 => 'P',
+ 2 => 'Q',
+ 3 => 'R',
+ 4 => 'S',
+ _ => unreachable!(),
+ };
+ if any_mod {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[1;{}{}", modifier, letter).into_bytes()
+ } else {
+ prefix_alt(alt, format!("\x1bO{}", letter).as_bytes())
+ }
+ },
+ 5 => format_tilde_fkey(15, ctrl, alt, shift),
+ 6 => format_tilde_fkey(17, ctrl, alt, shift),
+ 7 => format_tilde_fkey(18, ctrl, alt, shift),
+ 8 => format_tilde_fkey(19, ctrl, alt, shift),
+ 9 => format_tilde_fkey(20, ctrl, alt, shift),
+ 10 => format_tilde_fkey(21, ctrl, alt, shift),
+ 11 => format_tilde_fkey(23, ctrl, alt, shift),
+ 12 => format_tilde_fkey(24, ctrl, alt, shift),
+ _ => Vec::new(),
+ }
+}
+
+fn format_tilde_fkey(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
+ if ctrl || shift {
+ let modifier = encode_modifiers(ctrl, alt, shift);
+ format!("\x1b[{};{}~", n, modifier).into_bytes()
+ } else {
+ prefix_alt(alt, format!("\x1b[{}~", n).as_bytes())
+ }
+}
+
+fn encode_modifiers(ctrl: bool, alt: bool, shift: bool) -> u8 {
+ let mut m = 1u8;
+ if shift {
+ m += 1;
+ }
+ if alt {
+ m += 2;
+ }
+ if ctrl {
+ m += 4;
+ }
+ m
+}
+
+fn prefix_alt(alt: bool, body: &[u8]) -> Vec<u8> {
+ if alt {
+ let mut out = Vec::with_capacity(body.len() + 1);
+ out.push(0x1b);
+ out.extend_from_slice(body);
+ out
+ } else {
+ body.to_vec()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeSet;
+
+ fn key_with(bare: BareKey, mods: &[KeyModifier]) -> KeyWithModifier {
+ let mut set = BTreeSet::new();
+ for m in mods {
+ set.insert(*m);
+ }
+ KeyWithModifier {
+ bare_key: bare,
+ key_modifiers: set,
+ }
+ }
+
+ #[test]
+ fn f1_unmodified() {
+ assert_eq!(serialize_key(&key_with(BareKey::F(1), &[])), b"\x1bOP");
+ }
+
+ #[test]
+ fn f12_unmodified() {
+ assert_eq!(serialize_key(&key_with(BareKey::F(12), &[])), b"\x1b[24~");
+ }
+
+ #[test]
+ fn ctrl_c() {
+ assert_eq!(
+ serialize_key(&key_with(BareKey::Char('c'), &[KeyModifier::Ctrl])),
+ vec![0x03]
+ );
+ }
+
+ #[test]
+ fn alt_x() {
+ assert_eq!(
+ serialize_key(&key_with(BareKey::Char('x'), &[KeyModifier::Alt])),
+ vec![0x1b, b'x']
+ );
+ }
+}
diff --git a/default-plugins/mobile/src/main.rs b/default-plugins/mobile/src/main.rs
new file mode 100644
index 00000000..a61fe8d5
--- /dev/null
+++ b/default-plugins/mobile/src/main.rs
@@ -0,0 +1,577 @@
+mod ansi;
+mod click;
+mod components;
+mod fit;
+mod frame;
+mod input;
+mod keys;
+mod mouse;
+mod navigation;
+mod pane_sync;
+mod render;
+mod screens;
+mod state;
+mod workspace;
+
+use std::collections::BTreeMap;
+use zellij_tile::prelude::*;
+
+use crate::components::modifier_bar::{CellId, TapOutcome};
+use crate::screens::ActiveScreen;
+use crate::state::State;
+use crate::workspace::pane_id_of;
+
+register_plugin!(State);
+
+impl ZellijPlugin for State {
+ fn load(&mut self, _configuration: BTreeMap<String, String>) {
+ let ids = get_plugin_ids();
+ self.workspace.own_plugin_pane_id = Some(PaneId::Plugin(ids.plugin_id));
+
+ subscribe(&[
+ EventType::ModeUpdate,
+ EventType::TabUpdate,
+ EventType::PaneUpdate,
+ EventType::Key,
+ EventType::Mouse,
+ EventType::PaneRenderReportWithAnsi,
+ EventType::SessionUpdate,
+ EventType::SoftKeyboardVisibilityChanged,
+ EventType::Timer,
+ ]);
+ }
+
+ fn update(&mut self, event: Event) -> bool {
+ let should_render = match event {
+ Event::ModeUpdate(mode_info) => {
+ self.workspace.mode_info = Some(mode_info);
+ true
+ },
+ Event::TabUpdate(tabs) => {
+ self.workspace.tabs = tabs;
+ if self.workspace.selected_tab_position.is_none() {
+ self.workspace.selected_tab_position =
+ self.workspace.tabs_in_order().first().map(|t| t.position);
+ }
+ if let Some(pos) = self.workspace.selected_tab_position {
+ let still_visible = self
+ .workspace
+ .tabs_in_order()
+ .iter()
+ .any(|t| t.position == pos);
+ if !still_visible {
+ self.workspace.selected_tab_position =
+ self.workspace.tabs_in_order().first().map(|t| t.position);
+ }
+ }
+ sync_shadow_focus(self);
+ true
+ },
+ Event::PaneUpdate(manifest) => {
+ pane_sync::refresh_pane_manifest(self, manifest);
+ pane_sync::reconcile_selected_tab(self);
+ pane_sync::resolve_pending_new_tab(self);
+ pane_sync::ensure_pane_selected(self);
+ sync_shadow_focus(self);
+ pane_sync::maybe_take_over_welcome(self);
+ true
+ },
+ Event::SessionUpdate(sessions, _) => {
+ if let Some(current) = sessions.iter().find(|s| s.is_current_session) {
+ self.workspace.session_name = Some(current.name.clone());
+ }
+ let filtered = filter_sessions_for_client(sessions, self);
+ self.sessions.sessions = filtered;
+ true
+ },
+ Event::Timer(_) => {
+ self.empty_welcome_list_grace_elapsed = true;
+ true
+ },
+ Event::PaneRenderReportWithAnsi(map) => {
+ let now = unix_now();
+ for id in map.keys() {
+ self.workspace.pane_last_activity.insert(*id, now);
+ }
+ self.workspace.latest_pane_contents.extend(map);
+ if self.active == ActiveScreen::Panes {
+ refresh_pane_titles(self);
+ }
+ true
+ },
+ Event::Mouse(mouse) => {
+ match mouse {
+ Mouse::ScrollUp(lines) => {
+ let up = true;
+ return mouse::scroll_or_pan(self, lines, up);
+ },
+ Mouse::ScrollDown(lines) => {
+ let up = false;
+ return mouse::scroll_or_pan(self, lines, up);
+ },
+ Mouse::ScrollRight(cols) => {
+ let right = true;
+ return mouse::pan_horizontally(self, cols, right);
+ },
+ Mouse::ScrollLeft(cols) => {
+ let right = false;
+ return mouse::pan_horizontally(self, cols, right);
+ },
+ Mouse::LeftClick(..) => {
+ if let Some((line, col)) = mouse.position() {
+ return mouse::handle_left_click(self, line, col);
+ }
+ },
+ _ => {},
+ }
+ false
+ },
+ Event::Key(key) => match self.active {
+ ActiveScreen::NewSessionPrompt => {
+ self.new_session.handle_key(&mut self.active, key)
+ },
+ ActiveScreen::Sessions => {
+ self.sessions
+ .handle_key(&mut self.active, &mut self.navigation, key)
+ },
+ ActiveScreen::Panes => {
+ if let Some((tab_position, pane_id)) = self.panes.handle_key(
+ &mut self.active,
+ &mut self.navigation,
+ &self.workspace,
+ key,
+ ) {
+ self.select_pane(tab_position, pane_id);
+ }
+ true
+ },
+ ActiveScreen::Viewport => {
+ if key.bare_key == BareKey::Esc && self.menu.open {
+ self.menu.open = false;
+ true
+ } else {
+ self.viewport
+ .handle_key(&self.workspace, &mut self.input, key)
+ }
+ },
+ },
+ Event::SoftKeyboardVisibilityChanged(visible) => {
+ if self.frame.soft_keyboard_visible == visible {
+ return false;
+ }
+ self.frame.soft_keyboard_visible = visible;
+ true
+ },
+ _ => false,
+ };
+ if self.fit.active {
+ let suppress_top_bar =
+ self.sessions.is_welcome_screen || self.active == ActiveScreen::Sessions;
+ self.fit
+ .notify_size(&self.workspace, &self.frame, suppress_top_bar);
+ }
+ should_render && self.is_ready_to_render()
+ }
+
+ fn render(&mut self, rows: usize, cols: usize) {
+ if rows == 0 || cols == 0 || !self.is_ready_to_render() {
+ return;
+ }
+ render::render(self, rows, cols);
+ }
+}
+
+impl State {
+ fn is_ready_to_render(&self) -> bool {
+ if self.workspace.tabs.is_empty() && self.workspace.panes_by_tab_position.is_empty() {
+ return false;
+ }
+ if self.showing_empty_welcome_list_within_grace() {
+ return false;
+ }
+ if self.active == ActiveScreen::Viewport {
+ return self.viewport_pane_has_content();
+ }
+ true
+ }
+
+ fn showing_empty_welcome_list_within_grace(&self) -> bool {
+ self.sessions.is_welcome_screen
+ && self.sessions.sessions.is_empty()
+ && !self.empty_welcome_list_grace_elapsed
+ }
+
+ fn viewport_pane_has_content(&self) -> bool {
+ let Some(pane) = self.workspace.current_pane() else {
+ return false;
+ };
+ self.workspace
+ .latest_pane_contents
+ .get(&pane_id_of(&pane))
+ .map(|contents| !contents.viewport.is_empty())
+ .unwrap_or(false)
+ }
+
+ pub fn open_sessions(&mut self) -> bool {
+ self.menu.open = false;
+ self.navigation.selector_scroll_offset = 0;
+ self.sessions.welcome_search.clear();
+ if let Ok(snapshot) = get_session_list() {
+ let filtered = filter_sessions_for_client(snapshot.live_sessions, self);
+ self.sessions.sessions = filtered;
+ }
+ self.active = ActiveScreen::Sessions;
+ true
+ }
+
+ pub fn open_panes(&mut self) -> bool {
+ self.menu.open = false;
+ self.navigation.selector_scroll_offset = 0;
+ self.panes.panes_search.clear();
+ refresh_pane_titles(self);
+ self.active = ActiveScreen::Panes;
+ true
+ }
+
+ pub fn collapse_selector(&mut self) -> bool {
+ self.active = ActiveScreen::Viewport;
+ self.sessions.welcome_search.clear();
+ self.panes.panes_search.clear();
+ self.navigation.selector_scroll_offset = 0;
+ true
+ }
+
+ pub fn select_pane(&mut self, tab_position: usize, pane_id: PaneId) -> bool {
+ self.fit.clear_if_active();
+ self.workspace.selected_tab_position = Some(tab_position);
+ self.workspace.selected_pane_id = Some(pane_id);
+ self.viewport.reset_pan();
+ self.active = ActiveScreen::Viewport;
+ sync_shadow_focus(self);
+ true
+ }
+
+ pub fn toggle_fit(&mut self) -> bool {
+ let suppress_top_bar =
+ self.sessions.is_welcome_screen || self.active == ActiveScreen::Sessions;
+ self.fit
+ .toggle(&self.workspace, &self.frame, suppress_top_bar)
+ }
+
+ pub fn new_pane_in_tab(&mut self, tab_position: usize) -> bool {
+ self.fit.clear_if_active();
+ if let Some(new_id) = new_tiled_pane_in_tab(tab_position) {
+ self.workspace.selected_tab_position = Some(tab_position);
+ self.workspace.selected_pane_id = Some(new_id);
+ self.viewport.reset_pan();
+ self.active = ActiveScreen::Viewport;
+ sync_shadow_focus(self);
+ }
+ true
+ }
+
+ pub fn new_tab(&mut self) -> bool {
+ self.fit.clear_if_active();
+ if let Some(tab_position) = new_tab_unfocused::<&str>(None, None) {
+ self.workspace.pending_new_tab_position = Some(tab_position);
+ }
+ true
+ }
+
+ pub fn open_new_session_prompt(&mut self) -> bool {
+ self.new_session.open(&mut self.active)
+ }
+
+ pub fn cancel_new_session_prompt(&mut self) -> bool {
+ self.new_session.cancel(&mut self.active)
+ }
+
+ pub fn accept_new_session_prompt(&mut self) -> bool {
+ self.new_session.accept(&mut self.active)
+ }
+
+ pub fn keyboard_tap(&mut self, cell: CellId) -> bool {
+ let outcome = self.input.handle_tap(cell);
+ match outcome {
+ TapOutcome::SendBytes(bytes) => {
+ if let Some(pane) = self.workspace.current_pane() {
+ if !bytes.is_empty() {
+ write_to_pane_id(bytes, pane_id_of(&pane));
+ }
+ }
+ },
+ TapOutcome::Toggled | TapOutcome::NoOp => {},
+ }
+ true
+ }
+}
+
+pub fn filter_sessions_for_client(sessions: Vec<SessionInfo>, state: &State) -> Vec<SessionInfo> {
+ let is_web_client = state
+ .workspace
+ .mode_info
+ .as_ref()
+ .and_then(|m| m.is_web_client)
+ .unwrap_or(false);
+ sessions
+ .into_iter()
+ .filter(|s| !is_welcome_session(s))
+ .filter(|s| !is_web_client || s.web_clients_allowed)
+ .collect()
+}
+
+fn is_welcome_session(session: &SessionInfo) -> bool {
+ session
+ .panes
+ .panes
+ .values()
+ .flatten()
+ .any(|p| p.is_plugin && p.plugin_url.as_deref() == Some("welcome-screen"))
+}
+
+pub fn sync_shadow_focus(state: &State) {
+ if let Some(pane) = state.workspace.current_pane() {
+ set_shadow_focus(pane_id_of(&pane));
+ }
+}
+
+pub fn unix_now() -> u64 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+pub fn refresh_pane_titles(state: &mut State) {
+ let pane_ids: Vec<PaneId> = state
+ .workspace
+ .panes_by_tab_position
+ .values()
+ .flat_map(|panes| panes.iter().map(pane_id_of))
+ .collect();
+ for id in pane_ids {
+ let Some(fresh) = get_pane_info(id) else {
+ continue;
+ };
+ for panes in state.workspace.panes_by_tab_position.values_mut() {
+ for p in panes.iter_mut() {
+ if pane_id_of(p) == id {
+ p.title = fresh.title.clone();
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::click::{self, ClickAction};
+ use zellij_tile::prelude::{PaneInfo, SessionInfo, TabInfo};
+
+ fn fit_ready_state() -> State {
+ let mut state = State::default();
+ let tab = TabInfo {
+ position: 0,
+ name: "shell".to_string(),
+ tab_id: 7,
+ display_area_rows: 24,
+ display_area_columns: 80,
+ viewport_rows: 22,
+ viewport_columns: 80,
+ ..TabInfo::default()
+ };
+ state.workspace.tabs.push(tab);
+ state.workspace.selected_tab_position = Some(0);
+ let pane = PaneInfo {
+ id: 3,
+ is_plugin: false,
+ is_selectable: true,
+ pane_rows: 22,
+ pane_columns: 80,
+ pane_content_rows: 20,
+ pane_content_columns: 78,
+ ..PaneInfo::default()
+ };
+ state.workspace.panes_by_tab_position.insert(0, vec![pane]);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(3));
+ state
+ }
+
+ #[test]
+ fn dispatch_toggle_fit_on_path_seeds_fields() {
+ let mut state = fit_ready_state();
+ assert!(!state.fit.active, "Pre-condition: fit is off");
+ let consumed = click::dispatch(&mut state, ClickAction::ToggleFit);
+ assert!(consumed);
+ assert!(state.fit.active);
+ assert_eq!(state.fit.tab_id, Some(7));
+ }
+
+ #[test]
+ fn dispatch_toggle_fit_off_path_clears_fields() {
+ let mut state = fit_ready_state();
+ state.fit.active = true;
+ state.fit.tab_id = Some(7);
+ let consumed = click::dispatch(&mut state, ClickAction::ToggleFit);
+ assert!(consumed);
+ assert!(!state.fit.active);
+ assert_eq!(state.fit.tab_id, None);
+ }
+
+ #[test]
+ fn pane_update_clears_fit_when_selected_pane_disappears() {
+ let mut state = fit_ready_state();
+ state.fit.active = true;
+ state.fit.tab_id = Some(7);
+
+ let replacement_pane = PaneInfo {
+ id: 99,
+ is_plugin: false,
+ is_selectable: true,
+ ..PaneInfo::default()
+ };
+ let mut panes = std::collections::HashMap::new();
+ panes.insert(0_usize, vec![replacement_pane]);
+ let manifest = PaneManifest { panes };
+
+ state.update(Event::PaneUpdate(manifest));
+
+ assert!(!state.fit.active, "Local fit mirror cleared");
+ assert_eq!(state.fit.tab_id, None);
+ }
+
+ #[test]
+ fn pane_update_resolves_pending_new_tab() {
+ let mut state = State::default();
+ let mut tab0 = TabInfo::default();
+ tab0.position = 0;
+ state.workspace.tabs.push(tab0);
+ let mut pane0 = PaneInfo::default();
+ pane0.id = 1;
+ pane0.is_plugin = false;
+ pane0.is_selectable = true;
+ state.workspace.panes_by_tab_position.insert(0, vec![pane0]);
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(1));
+ state.active = ActiveScreen::Panes;
+ state.workspace.pending_new_tab_position = Some(1);
+
+ let mut new_tab = TabInfo::default();
+ new_tab.position = 1;
+ state.workspace.tabs.push(new_tab);
+ let mut new_pane = PaneInfo::default();
+ new_pane.id = 7;
+ new_pane.is_plugin = false;
+ new_pane.is_selectable = true;
+ let mut panes_map = std::collections::HashMap::new();
+ panes_map.insert(
+ 0_usize,
+ vec![PaneInfo {
+ id: 1,
+ is_plugin: false,
+ is_selectable: true,
+ ..PaneInfo::default()
+ }],
+ );
+ panes_map.insert(1_usize, vec![new_pane]);
+ let manifest = PaneManifest { panes: panes_map };
+
+ state.update(Event::PaneUpdate(manifest));
+
+ assert_eq!(state.workspace.selected_tab_position, Some(1));
+ assert_eq!(state.workspace.selected_pane_id, Some(PaneId::Terminal(7)));
+ assert_eq!(state.active, ActiveScreen::Viewport);
+ assert_eq!(state.workspace.pending_new_tab_position, None);
+ }
+
+ #[test]
+ fn pane_update_keeps_pending_when_target_tab_empty() {
+ let mut state = State::default();
+ let mut tab0 = TabInfo::default();
+ tab0.position = 0;
+ state.workspace.tabs.push(tab0);
+ let mut pane0 = PaneInfo::default();
+ pane0.id = 1;
+ pane0.is_plugin = false;
+ pane0.is_selectable = true;
+ state.workspace.panes_by_tab_position.insert(0, vec![pane0]);
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(1));
+ state.workspace.pending_new_tab_position = Some(5);
+
+ let mut panes_map = std::collections::HashMap::new();
+ panes_map.insert(
+ 0_usize,
+ vec![PaneInfo {
+ id: 1,
+ is_plugin: false,
+ is_selectable: true,
+ ..PaneInfo::default()
+ }],
+ );
+ let manifest = PaneManifest { panes: panes_map };
+ state.update(Event::PaneUpdate(manifest));
+
+ assert_eq!(state.workspace.pending_new_tab_position, Some(5));
+ assert_eq!(state.workspace.selected_tab_position, Some(0));
+ }
+
+ fn welcome_state(session_names: &[&str]) -> State {
+ let mut state = fit_ready_state();
+ state.active = ActiveScreen::Sessions;
+ state.sessions.is_welcome_screen = true;
+ state.sessions.sessions = session_names
+ .iter()
+ .map(|name| SessionInfo {
+ name: name.to_string(),
+ ..SessionInfo::default()
+ })
+ .collect();
+ state
+ }
+
+ #[test]
+ fn welcome_empty_list_is_not_ready_within_grace() {
+ let mut state = welcome_state(&[]);
+ state.empty_welcome_list_grace_elapsed = false;
+ assert!(!state.is_ready_to_render());
+ }
+
+ #[test]
+ fn welcome_empty_list_is_ready_after_grace() {
+ let mut state = welcome_state(&[]);
+ state.empty_welcome_list_grace_elapsed = true;
+ assert!(state.is_ready_to_render());
+ }
+
+ #[test]
+ fn welcome_non_empty_list_is_ready_within_grace() {
+ let mut state = welcome_state(&["alpha"]);
+ state.empty_welcome_list_grace_elapsed = false;
+ assert!(state.is_ready_to_render());
+ }
+
+ #[test]
+ fn timer_event_elapses_welcome_grace() {
+ let mut state = welcome_state(&[]);
+ assert!(!state.empty_welcome_list_grace_elapsed);
+ state.update(Event::Timer(0.0));
+ assert!(state.empty_welcome_list_grace_elapsed);
+ assert!(state.is_ready_to_render());
+ }
+
+ #[test]
+ fn viewport_without_a_pane_is_not_ready() {
+ let mut state = State::default();
+ state.workspace.tabs.push(TabInfo {
+ position: 0,
+ ..TabInfo::default()
+ });
+ state.workspace.selected_tab_position = Some(0);
+ state.active = ActiveScreen::Viewport;
+ assert!(state.workspace.current_pane().is_none());
+ assert!(!state.is_ready_to_render());
+ }
+}
diff --git a/default-plugins/mobile/src/mouse.rs b/default-plugins/mobile/src/mouse.rs
new file mode 100644
index 00000000..1112d213
--- /dev/null
+++ b/default-plugins/mobile/src/mouse.rs
@@ -0,0 +1,180 @@
+use std::collections::BTreeMap;
+use zellij_tile::prelude::*;
+
+use crate::click;
+use crate::screens::ActiveScreen;
+use crate::state::State;
+use crate::workspace::pane_id_of;
+
+pub(crate) fn scroll_or_pan(state: &mut State, lines: usize, up: bool) -> bool {
+ if state.active != ActiveScreen::Viewport {
+ handle_selector_scroll(state, lines, up)
+ } else {
+ handle_scroll_pan(state, lines, up)
+ }
+}
+
+pub(crate) fn pan_horizontally(state: &mut State, cols: usize, right: bool) -> bool {
+ if !pan_is_allowed(state) {
+ return false;
+ }
+ state.viewport.viewport_h_pan = if right {
+ state.viewport.viewport_h_pan.saturating_add(cols)
+ } else {
+ state.viewport.viewport_h_pan.saturating_sub(cols)
+ };
+ true
+}
+
+pub(crate) fn handle_left_click(state: &mut State, line: usize, col: usize) -> bool {
+ if let Some(action) = state.frame.click_to_action(line, col) {
+ return click::dispatch(state, action);
+ }
+ if state.menu.open {
+ state.menu.open = false;
+ return true;
+ }
+ forward_click_to_pane(state, line, col)
+}
+
+fn forward_click_to_pane(state: &mut State, line: usize, col: usize) -> bool {
+ let Some((pane_row, pane_col)) = state.viewport.click_in_viewport(line, col) else {
+ return false;
+ };
+ let Some(pane) = state.workspace.current_pane() else {
+ return false;
+ };
+ if pane.is_plugin {
+ let mut args = BTreeMap::new();
+ args.insert("row".to_string(), pane_row.to_string());
+ args.insert("col".to_string(), pane_col.to_string());
+ let message = MessageToPlugin::new("mobile_viewport_click")
+ .with_destination_plugin_id(pane.id)
+ .with_args(args);
+ pipe_message_to_plugin(message);
+ } else {
+ let bytes = sgr_left_click(pane_row, pane_col);
+ write_to_pane_id(bytes, pane_id_of(&pane));
+ }
+ false
+}
+
+fn apply_v_pan(old_pan: usize, max_pan: usize, lines: usize, up: bool) -> (usize, usize) {
+ if up {
+ let desired = old_pan.saturating_add(lines);
+ let new_pan = desired.min(max_pan);
+ let absorbed = new_pan - old_pan;
+ (new_pan, lines - absorbed)
+ } else {
+ let new_pan = old_pan.saturating_sub(lines);
+ let absorbed = old_pan - new_pan;
+ (new_pan, lines - absorbed)
+ }
+}
+
+fn handle_selector_scroll(state: &mut State, lines: usize, up: bool) -> bool {
+ let step = lines.min(1);
+ let old = state.navigation.selector_scroll_offset;
+ state.navigation.selector_scroll_offset = if up {
+ old.saturating_sub(step)
+ } else {
+ old.saturating_add(step)
+ };
+ state.navigation.selector_scroll_offset != old
+}
+
+fn handle_scroll_pan(state: &mut State, lines: usize, up: bool) -> bool {
+ if !pan_is_allowed(state) {
+ return false;
+ }
+ let Some(max_v_pan) = state.viewport.max_viewport_v_pan(&state.workspace) else {
+ if up {
+ state.viewport.viewport_v_pan = state.viewport.viewport_v_pan.saturating_add(lines);
+ } else {
+ state.viewport.viewport_v_pan = state.viewport.viewport_v_pan.saturating_sub(lines);
+ }
+ return true;
+ };
+ let old_pan = state.viewport.viewport_v_pan;
+ let (new_pan, overflow) = apply_v_pan(old_pan, max_v_pan, lines, up);
+ let pan_moved = new_pan != old_pan;
+ state.viewport.viewport_v_pan = new_pan;
+ if overflow > 0 {
+ if let Some(pane) = state.workspace.current_pane() {
+ let pane_id = pane_id_of(&pane);
+ for _ in 0..overflow {
+ if up {
+ scroll_up_in_pane_id(pane_id);
+ } else {
+ scroll_down_in_pane_id(pane_id);
+ }
+ }
+ }
+ }
+ pan_moved
+}
+
+fn pan_is_allowed(state: &State) -> bool {
+ if state.active != ActiveScreen::Viewport {
+ return false;
+ }
+ if state.workspace.current_pane().is_none() {
+ return false;
+ }
+ let len = state.workspace.current_pane_viewport_len();
+ if len == 0 {
+ return false;
+ }
+ true
+}
+
+fn sgr_left_click(pane_row: usize, pane_col: usize) -> Vec<u8> {
+ let col = pane_col + 1;
+ let row = pane_row + 1;
+ format!("\x1b[<0;{};{}M\x1b[<0;{};{}m", col, row, col, row).into_bytes()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn apply_v_pan_up_fully_absorbed() {
+ assert_eq!(apply_v_pan(0, 100, 3, true), (3, 0));
+ assert_eq!(apply_v_pan(50, 100, 3, true), (53, 0));
+ }
+
+ #[test]
+ fn apply_v_pan_up_partial_overflow() {
+ assert_eq!(apply_v_pan(99, 100, 3, true), (100, 2));
+ assert_eq!(apply_v_pan(98, 100, 5, true), (100, 3));
+ }
+
+ #[test]
+ fn apply_v_pan_up_fully_overflowed() {
+ assert_eq!(apply_v_pan(100, 100, 3, true), (100, 3));
+ }
+
+ #[test]
+ fn apply_v_pan_up_zero_max() {
+ assert_eq!(apply_v_pan(0, 0, 3, true), (0, 3));
+ assert_eq!(apply_v_pan(0, 0, 0, true), (0, 0));
+ }
+
+ #[test]
+ fn apply_v_pan_down_partial_overflow() {
+ assert_eq!(apply_v_pan(2, 100, 3, false), (0, 1));
+ assert_eq!(apply_v_pan(5, 100, 3, false), (2, 0));
+ }
+
+ #[test]
+ fn apply_v_pan_down_fully_overflowed() {
+ assert_eq!(apply_v_pan(0, 100, 3, false), (0, 3));
+ }
+
+ #[test]
+ fn apply_v_pan_zero_lines() {
+ assert_eq!(apply_v_pan(5, 100, 0, true), (5, 0));
+ assert_eq!(apply_v_pan(5, 100, 0, false), (5, 0));
+ }
+}
diff --git a/default-plugins/mobile/src/navigation.rs b/default-plugins/mobile/src/navigation.rs
new file mode 100644
index 00000000..424ce88d
--- /dev/null
+++ b/default-plugins/mobile/src/navigation.rs
@@ -0,0 +1,14 @@
+use fuzzy_matcher::skim::SkimMatcherV2;
+
+#[derive(Default)]
+pub struct Navigation {
+ pub selector_scroll_offset: usize,
+ pub fuzzy_matcher: Option<SkimMatcherV2>,
+}
+
+impl Navigation {
+ pub fn matcher(&mut self) -> &mut SkimMatcherV2 {
+ self.fuzzy_matcher
+ .get_or_insert_with(|| SkimMatcherV2::default().use_cache(true))
+ }
+}
diff --git a/default-plugins/mobile/src/pane_sync.rs b/default-plugins/mobile/src/pane_sync.rs
new file mode 100644
index 00000000..4b6abd15
--- /dev/null
+++ b/default-plugins/mobile/src/pane_sync.rs
@@ -0,0 +1,99 @@
+use zellij_tile::prelude::*;
+
+use crate::filter_sessions_for_client;
+use crate::screens::ActiveScreen;
+use crate::state::State;
+use crate::workspace::pane_id_of;
+
+const EMPTY_WELCOME_LIST_GRACE_SECS: f64 = 0.4;
+
+pub fn refresh_pane_manifest(state: &mut State, manifest: PaneManifest) {
+ state.workspace.panes_by_tab_position = manifest.panes;
+ let live_pane_ids: std::collections::HashSet<PaneId> = state
+ .workspace
+ .panes_by_tab_position
+ .values()
+ .flat_map(|panes| panes.iter().map(pane_id_of))
+ .collect();
+ state
+ .workspace
+ .latest_pane_contents
+ .retain(|id, _| live_pane_ids.contains(id));
+ state
+ .workspace
+ .pane_last_activity
+ .retain(|id, _| live_pane_ids.contains(id));
+ if let Some(selected) = state.workspace.selected_pane_id {
+ if !live_pane_ids.contains(&selected) {
+ state.fit.reset_local();
+ }
+ }
+}
+
+pub fn reconcile_selected_tab(state: &mut State) {
+ let Some(pos) = state.workspace.selected_tab_position else {
+ state.workspace.selected_tab_position =
+ state.workspace.tabs_in_order().first().map(|t| t.position);
+ return;
+ };
+ let still_visible = state
+ .workspace
+ .tabs_in_order()
+ .iter()
+ .any(|t| t.position == pos);
+ if !still_visible {
+ state.fit.clear_if_active();
+ state.workspace.selected_tab_position =
+ state.workspace.tabs_in_order().first().map(|t| t.position);
+ state.workspace.selected_pane_id = None;
+ }
+}
+
+pub fn resolve_pending_new_tab(state: &mut State) {
+ let Some(target) = state.workspace.pending_new_tab_position else {
+ return;
+ };
+ let Some(id) = state
+ .workspace
+ .panes_for_tab(target)
+ .into_iter()
+ .next()
+ .map(pane_id_of)
+ else {
+ return;
+ };
+ state.workspace.selected_tab_position = Some(target);
+ state.workspace.selected_pane_id = Some(id);
+ state.viewport.reset_pan();
+ state.active = ActiveScreen::Viewport;
+ state.workspace.pending_new_tab_position = None;
+}
+
+pub fn ensure_pane_selected(state: &mut State) {
+ if state.workspace.selected_pane_id.is_none() {
+ if let Some(pane) = state.workspace.current_tab_panes().into_iter().next() {
+ state.workspace.selected_pane_id = Some(pane_id_of(pane));
+ }
+ }
+}
+
+pub fn maybe_take_over_welcome(state: &mut State) {
+ if state.sessions.is_welcome_screen
+ || state.active != ActiveScreen::Viewport
+ || !state.workspace.current_pane_is_welcome()
+ {
+ return;
+ }
+ if let Some(pane) = state.workspace.current_pane() {
+ close_plugin_pane(pane.id);
+ }
+ state.active = ActiveScreen::Sessions;
+ state.navigation.selector_scroll_offset = 0;
+ state.menu.open = false;
+ state.sessions.is_welcome_screen = true;
+ if let Ok(snapshot) = get_session_list() {
+ let filtered = filter_sessions_for_client(snapshot.live_sessions, state);
+ state.sessions.sessions = filtered;
+ }
+ set_timeout(EMPTY_WELCOME_LIST_GRACE_SECS);
+}
diff --git a/default-plugins/mobile/src/render.rs b/default-plugins/mobile/src/render.rs
new file mode 100644
index 00000000..c03a4570
--- /dev/null
+++ b/default-plugins/mobile/src/render.rs
@@ -0,0 +1,125 @@
+use crate::ansi::{move_to, RESET};
+use crate::components::{modifier_bar, top_bar};
+use crate::frame::chrome_offsets;
+use crate::screens::ActiveScreen;
+use crate::state::State;
+
+pub fn render(state: &mut State, rows: usize, cols: usize) {
+ reset_frame(state, rows, cols);
+ if rows < 4 || cols < 8 {
+ render_too_small(state, rows, cols);
+ return;
+ }
+
+ let suppress_top_bar = top_bar_suppressed(state);
+ let (body_top, bar_height) =
+ chrome_offsets(rows, suppress_top_bar, state.frame.soft_keyboard_visible);
+ let body_bottom = rows.saturating_sub(bar_height);
+ let viewport_height = body_bottom.saturating_sub(body_top);
+
+ state
+ .frame
+ .emit_cursor(viewport_cursor(state, body_top, viewport_height, cols));
+
+ disable_autowrap();
+ if !suppress_top_bar && cols > 0 {
+ top_bar::render(&state.workspace, &mut state.frame, state.active, 0, cols);
+ }
+ if body_bottom > body_top {
+ render_active_screen(state, body_top, body_bottom, cols);
+ render_menu_overlay(state, body_top, body_bottom, cols);
+ }
+ if bar_height > 0 {
+ render_modifier_bar(state, body_bottom, cols);
+ }
+ enable_autowrap();
+}
+
+fn reset_frame(state: &mut State, rows: usize, cols: usize) {
+ state.frame.click_regions.clear();
+ state.viewport.viewport_region = None;
+ state.frame.last_render_rows = rows;
+ state.frame.last_render_cols = cols;
+}
+
+fn render_too_small(state: &mut State, rows: usize, cols: usize) {
+ state.frame.emit_cursor(None);
+ print!("{}\x1b[2J{}mobile {}x{}", RESET, move_to(0, 0), rows, cols);
+}
+
+fn top_bar_suppressed(state: &State) -> bool {
+ state.sessions.is_welcome_screen || state.active == ActiveScreen::Sessions
+}
+
+fn viewport_cursor(
+ state: &State,
+ body_top: usize,
+ viewport_height: usize,
+ cols: usize,
+) -> Option<(usize, usize)> {
+ if state.active != ActiveScreen::Viewport {
+ return None;
+ }
+ let lines = state.workspace.current_pane_viewport_len();
+ let max_v_pan = lines.saturating_sub(viewport_height);
+ let skip = max_v_pan - state.viewport.viewport_v_pan.min(max_v_pan);
+ state.viewport.compute_cursor_position(
+ &state.workspace,
+ body_top,
+ viewport_height,
+ cols,
+ skip,
+ state.viewport.viewport_h_pan,
+ )
+}
+
+fn render_active_screen(state: &mut State, body_top: usize, body_bottom: usize, cols: usize) {
+ let frame = &mut state.frame;
+ match state.active {
+ ActiveScreen::Viewport => {
+ state
+ .viewport
+ .render(&state.workspace, frame, body_top, body_bottom, cols)
+ },
+ ActiveScreen::Sessions => {
+ state
+ .sessions
+ .render(&mut state.navigation, frame, body_top, body_bottom, cols)
+ },
+ ActiveScreen::Panes => state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ frame,
+ body_top,
+ body_bottom,
+ cols,
+ ),
+ ActiveScreen::NewSessionPrompt => {
+ state.new_session.render(frame, body_top, body_bottom, cols)
+ },
+ }
+}
+
+fn render_menu_overlay(state: &mut State, body_top: usize, body_bottom: usize, cols: usize) {
+ if state.menu.open && state.active == ActiveScreen::Viewport {
+ state
+ .menu
+ .render(&state.fit, &mut state.frame, body_top, body_bottom, cols);
+ }
+}
+
+fn render_modifier_bar(state: &mut State, body_bottom: usize, cols: usize) {
+ let armed = modifier_bar::KeyboardModifiers {
+ ctrl_armed: state.input.ctrl_held,
+ alt_armed: state.input.alt_held,
+ };
+ modifier_bar::render_modifier_bar(&armed, body_bottom, cols, &mut state.frame.click_regions);
+}
+
+fn disable_autowrap() {
+ print!("\x1b[?7l");
+}
+
+fn enable_autowrap() {
+ print!("\x1b[?7h");
+}
diff --git a/default-plugins/mobile/src/screens/menu.rs b/default-plugins/mobile/src/screens/menu.rs
new file mode 100644
index 00000000..e016f133
--- /dev/null
+++ b/default-plugins/mobile/src/screens/menu.rs
@@ -0,0 +1,217 @@
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::fit::Fit;
+use crate::frame::Frame;
+use crate::screens::ActiveScreen;
+
+const H_PAD: usize = 1;
+const SEPARATOR_CHAR: char = '\u{2500}';
+
+const ENTRIES: [HamburgerEntry; 5] = [
+ HamburgerEntry::Item(HamburgerItem::Fit),
+ HamburgerEntry::Item(HamburgerItem::ChangePane),
+ HamburgerEntry::Item(HamburgerItem::ChangeSession),
+ HamburgerEntry::Separator,
+ HamburgerEntry::Item(HamburgerItem::SwitchToDesktop),
+];
+
+#[derive(Default)]
+pub struct MenuScreen {
+ pub open: bool,
+}
+
+impl MenuScreen {
+ pub fn toggle(&mut self, active: &mut ActiveScreen) -> bool {
+ if self.open {
+ self.open = false;
+ } else {
+ *active = ActiveScreen::Viewport;
+ self.open = true;
+ }
+ true
+ }
+
+ pub fn render(
+ &self,
+ fit: &Fit,
+ frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ let Some(layout) = MenuLayout::compute(&ENTRIES, cols) else {
+ return;
+ };
+
+ let max_visible = row_end.saturating_sub(row_start);
+ for (i, entry) in ENTRIES.iter().take(max_visible).enumerate() {
+ let row = row_start + i;
+ match entry {
+ HamburgerEntry::Item(item) => draw_item_row(frame, fit, item, &layout, row),
+ HamburgerEntry::Separator => draw_separator_row(&layout, row),
+ }
+ }
+ }
+}
+
+enum HamburgerItem {
+ Fit,
+ ChangePane,
+ ChangeSession,
+ SwitchToDesktop,
+}
+
+impl HamburgerItem {
+ fn label(&self) -> &'static str {
+ match self {
+ HamburgerItem::Fit => "Fit to Screen",
+ HamburgerItem::ChangePane => "Change Pane",
+ HamburgerItem::ChangeSession => "Change Session",
+ HamburgerItem::SwitchToDesktop => "Switch to Desktop",
+ }
+ }
+
+ fn action(&self) -> ClickAction {
+ match self {
+ HamburgerItem::Fit => ClickAction::ToggleFit,
+ HamburgerItem::ChangePane => ClickAction::ExpandPanes,
+ HamburgerItem::ChangeSession => ClickAction::ExpandSessions,
+ HamburgerItem::SwitchToDesktop => ClickAction::ExitMobileMode,
+ }
+ }
+
+ fn is_armed(&self, fit: &Fit) -> bool {
+ matches!(self, HamburgerItem::Fit) && fit.active
+ }
+}
+
+enum HamburgerEntry {
+ Item(HamburgerItem),
+ Separator,
+}
+
+impl HamburgerEntry {
+ fn label_width(&self) -> Option<usize> {
+ match self {
+ HamburgerEntry::Item(item) => Some(UnicodeWidthStr::width(item.label())),
+ HamburgerEntry::Separator => None,
+ }
+ }
+}
+
+struct MenuLayout {
+ menu_x: usize,
+ menu_w: usize,
+ label_max: usize,
+}
+
+impl MenuLayout {
+ fn compute(entries: &[HamburgerEntry], cols: usize) -> Option<Self> {
+ let label_max = entries
+ .iter()
+ .filter_map(HamburgerEntry::label_width)
+ .max()
+ .unwrap_or(0);
+ let menu_w = label_max + 2 * H_PAD;
+ if label_max == 0 || menu_w > cols {
+ return None;
+ }
+ Some(MenuLayout {
+ menu_x: cols - menu_w,
+ menu_w,
+ label_max,
+ })
+ }
+
+ fn padded_row(&self, content: &str, content_w: usize) -> String {
+ let pad = " ".repeat(H_PAD);
+ let trailing = " ".repeat(self.label_max.saturating_sub(content_w));
+ format!("{pad}{content}{trailing}{pad}")
+ }
+}
+
+fn draw_item_row(
+ frame: &mut Frame,
+ fit: &Fit,
+ item: &HamburgerItem,
+ layout: &MenuLayout,
+ row: usize,
+) {
+ let label = item.label();
+ let text = layout.padded_row(label, UnicodeWidthStr::width(label));
+ let label_chars = H_PAD..H_PAD + label.chars().count();
+
+ let mut t = Text::new(&text).selected();
+ t = if item.is_armed(fit) {
+ t.success_color_range(label_chars)
+ } else {
+ t.color_range(3, label_chars)
+ };
+ print_text_with_coordinates(t, layout.menu_x, row, Some(layout.menu_w), None);
+
+ frame.click_regions.push(ClickRegion::tight(
+ row,
+ layout.menu_x,
+ layout.menu_x + layout.menu_w,
+ item.action(),
+ ));
+}
+
+fn draw_separator_row(layout: &MenuLayout, row: usize) {
+ let rule: String = std::iter::repeat(SEPARATOR_CHAR)
+ .take(layout.label_max)
+ .collect();
+ let text = layout.padded_row(&rule, layout.label_max);
+ print_text_with_coordinates(
+ Text::new(&text).selected(),
+ layout.menu_x,
+ row,
+ Some(layout.menu_w),
+ None,
+ );
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::state::State;
+
+ #[test]
+ fn hamburger_menu_emits_four_click_regions_with_separator_above_exit() {
+ let mut state = State::default();
+ let cols = 40;
+ state.menu.render(&state.fit, &mut state.frame, 0, 20, cols);
+
+ assert_eq!(state.frame.click_regions.len(), 4);
+ let actions: Vec<ClickAction> = state
+ .frame
+ .click_regions
+ .iter()
+ .map(|r| r.action.clone())
+ .collect();
+ assert!(matches!(actions[0], ClickAction::ToggleFit));
+ assert!(matches!(actions[1], ClickAction::ExpandPanes));
+ assert!(matches!(actions[2], ClickAction::ExpandSessions));
+ assert!(matches!(actions[3], ClickAction::ExitMobileMode));
+
+ let rows: Vec<usize> = state
+ .frame
+ .click_regions
+ .iter()
+ .map(|r| r.row_start)
+ .collect();
+ assert_eq!(rows, vec![0, 1, 2, 4]);
+
+ let menu_x = state.frame.click_regions[0].col_start;
+ let menu_end = state.frame.click_regions[0].col_end;
+ for c in menu_x..menu_end {
+ assert_eq!(
+ state.frame.click_to_action(3, c),
+ None,
+ "separator row should be non-interactive at col {c}",
+ );
+ }
+ }
+}
diff --git a/default-plugins/mobile/src/screens/mod.rs b/default-plugins/mobile/src/screens/mod.rs
new file mode 100644
index 00000000..d643611c
--- /dev/null
+++ b/default-plugins/mobile/src/screens/mod.rs
@@ -0,0 +1,20 @@
+pub mod menu;
+pub mod new_session;
+pub mod panes;
+pub mod sessions;
+pub mod viewport;
+
+pub use menu::MenuScreen;
+pub use new_session::NewSessionPromptScreen;
+pub use panes::PanesScreen;
+pub use sessions::SessionsScreen;
+pub use viewport::ViewportScreen;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum ActiveScreen {
+ #[default]
+ Viewport,
+ Sessions,
+ Panes,
+ NewSessionPrompt,
+}
diff --git a/default-plugins/mobile/src/screens/new_session.rs b/default-plugins/mobile/src/screens/new_session.rs
new file mode 100644
index 00000000..cc9e7626
--- /dev/null
+++ b/default-plugins/mobile/src/screens/new_session.rs
@@ -0,0 +1,233 @@
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::screens::ActiveScreen;
+
+const H_PAD: usize = 1;
+const RESERVED_INPUT_CHARS: usize = 20;
+const ELLIPSIS: &str = "\u{2026}";
+const DEFAULT_BUTTON_GAP: usize = 6;
+const BLOCK_ROWS: usize = 5;
+const TITLE: &str = "New Session";
+const INPUT_LABEL: &str = "Name: ";
+const CANCEL_LABEL: &str = "[Cancel]";
+const ACCEPT_LABEL: &str = "[Accept]";
+
+#[derive(Default)]
+pub struct NewSessionPromptScreen {
+ pub pending_session_name: String,
+ pub new_session_view_offset: usize,
+ pub new_session_content_w: usize,
+}
+
+impl NewSessionPromptScreen {
+ fn reset(&mut self) {
+ self.pending_session_name.clear();
+ self.new_session_view_offset = 0;
+ self.new_session_content_w = 0;
+ }
+
+ pub fn open(&mut self, active: &mut ActiveScreen) -> bool {
+ self.reset();
+ *active = ActiveScreen::NewSessionPrompt;
+ true
+ }
+
+ pub fn cancel(&mut self, active: &mut ActiveScreen) -> bool {
+ self.reset();
+ *active = ActiveScreen::Sessions;
+ true
+ }
+
+ pub fn accept(&mut self, active: &mut ActiveScreen) -> bool {
+ let name = std::mem::take(&mut self.pending_session_name);
+ let arg = if name.is_empty() {
+ None
+ } else {
+ Some(name.as_str())
+ };
+ switch_session(arg);
+ self.new_session_view_offset = 0;
+ self.new_session_content_w = 0;
+ *active = ActiveScreen::Viewport;
+ true
+ }
+
+ pub fn handle_key(&mut self, active: &mut ActiveScreen, key: KeyWithModifier) -> bool {
+ match key.bare_key {
+ BareKey::Esc => {
+ self.cancel(active);
+ },
+ BareKey::Enter => {
+ self.accept(active);
+ },
+ BareKey::Backspace => {
+ self.pending_session_name.pop();
+ },
+ BareKey::Char(c) => {
+ self.pending_session_name.push(c);
+ },
+ _ => {},
+ }
+ true
+ }
+
+ pub fn render(&mut self, frame: &mut Frame, row_start: usize, row_end: usize, cols: usize) {
+ let body_height = row_end.saturating_sub(row_start);
+ if body_height == 0 || cols == 0 {
+ return;
+ }
+
+ let input = self.visible_input_row(cols);
+ let layout = self.layout(&input, cols, row_start, row_end, body_height);
+ draw_title(&layout);
+ draw_input(&input, &layout);
+ draw_buttons(frame, &layout);
+ }
+
+ fn visible_input_row(&mut self, cols: usize) -> String {
+ const CURSOR_CELL: usize = 1;
+ let max_input_total_w = cols.saturating_sub(2 * H_PAD);
+ let max_chars_no_ellipsis = max_input_total_w
+ .saturating_sub(INPUT_LABEL.len())
+ .saturating_sub(CURSOR_CELL);
+ let max_chars_with_ellipsis =
+ max_chars_no_ellipsis.saturating_sub(UnicodeWidthStr::width(ELLIPSIS));
+
+ let buffer_chars = self.pending_session_name.chars().count();
+ let view_offset = if buffer_chars > max_chars_no_ellipsis {
+ let min_offset = buffer_chars.saturating_sub(max_chars_with_ellipsis);
+ self.new_session_view_offset
+ .max(min_offset)
+ .min(buffer_chars)
+ } else {
+ 0
+ };
+ self.new_session_view_offset = view_offset;
+
+ let visible: String = self
+ .pending_session_name
+ .chars()
+ .skip(view_offset)
+ .collect();
+ if view_offset > 0 {
+ format!("{}{}{}_", INPUT_LABEL, ELLIPSIS, visible)
+ } else {
+ format!("{}{}_", INPUT_LABEL, visible)
+ }
+ }
+
+ fn layout(
+ &mut self,
+ input: &str,
+ cols: usize,
+ row_start: usize,
+ row_end: usize,
+ body_height: usize,
+ ) -> PromptLayout {
+ let default_buttons_w = UnicodeWidthStr::width(CANCEL_LABEL)
+ + DEFAULT_BUTTON_GAP
+ + UnicodeWidthStr::width(ACCEPT_LABEL);
+ let default_input_w = INPUT_LABEL.len() + RESERVED_INPUT_CHARS + 1;
+ let default_content_w = UnicodeWidthStr::width(TITLE)
+ .max(default_input_w)
+ .max(default_buttons_w);
+
+ let target_content_w = default_content_w.max(UnicodeWidthStr::width(input));
+ let content_w = self.new_session_content_w.max(target_content_w);
+ self.new_session_content_w = content_w;
+
+ let box_w = (content_w + 2 * H_PAD).min(cols);
+ let content_x = cols.saturating_sub(box_w) / 2 + H_PAD;
+
+ let top = if body_height >= BLOCK_ROWS {
+ row_start + (body_height - BLOCK_ROWS) / 2
+ } else {
+ row_start
+ };
+
+ PromptLayout {
+ content_x,
+ content_w: box_w.saturating_sub(2 * H_PAD),
+ row_title: top,
+ row_input: top + 2,
+ row_buttons: top + 4,
+ row_end,
+ }
+ }
+}
+
+struct PromptLayout {
+ content_x: usize,
+ content_w: usize,
+ row_title: usize,
+ row_input: usize,
+ row_buttons: usize,
+ row_end: usize,
+}
+
+impl PromptLayout {
+ fn centered_x(&self, label_w: usize) -> usize {
+ self.content_x + self.content_w.saturating_sub(label_w) / 2
+ }
+
+ fn right_x(&self, label_w: usize) -> usize {
+ self.content_x + self.content_w.saturating_sub(label_w)
+ }
+}
+
+fn draw_title(layout: &PromptLayout) {
+ if layout.row_title < layout.row_end {
+ let x = layout.centered_x(UnicodeWidthStr::width(TITLE));
+ print_text_with_coordinates(
+ Text::new(TITLE).color_range(3, ..),
+ x,
+ layout.row_title,
+ None,
+ None,
+ );
+ }
+}
+
+fn draw_input(input: &str, layout: &PromptLayout) {
+ if layout.row_input < layout.row_end {
+ print_text_with_coordinates(
+ Text::new(input),
+ layout.content_x,
+ layout.row_input,
+ None,
+ None,
+ );
+ }
+}
+
+fn draw_buttons(frame: &mut Frame, layout: &PromptLayout) {
+ if layout.row_buttons >= layout.row_end {
+ return;
+ }
+ let cancel_w = UnicodeWidthStr::width(CANCEL_LABEL);
+ let accept_w = UnicodeWidthStr::width(ACCEPT_LABEL);
+ let cancel_x = layout.content_x;
+ let accept_x = layout.right_x(accept_w);
+ let gap = " ".repeat(accept_x.saturating_sub(cancel_x + cancel_w));
+ let buttons = format!("{}{}{}", CANCEL_LABEL, gap, ACCEPT_LABEL);
+ let buttons_text = Text::new(&buttons)
+ .error_color_substring(CANCEL_LABEL)
+ .success_color_substring(ACCEPT_LABEL);
+ print_text_with_coordinates(buttons_text, cancel_x, layout.row_buttons, None, None);
+
+ frame.click_regions.push(ClickRegion::tight(
+ layout.row_buttons,
+ cancel_x,
+ cancel_x + cancel_w,
+ ClickAction::CancelNewSessionPrompt,
+ ));
+ frame.click_regions.push(ClickRegion::tight(
+ layout.row_buttons,
+ accept_x,
+ accept_x + accept_w,
+ ClickAction::AcceptNewSessionPrompt,
+ ));
+}
diff --git a/default-plugins/mobile/src/screens/panes.rs b/default-plugins/mobile/src/screens/panes.rs
new file mode 100644
index 00000000..bef10c5e
--- /dev/null
+++ b/default-plugins/mobile/src/screens/panes.rs
@@ -0,0 +1,879 @@
+use fuzzy_matcher::FuzzyMatcher;
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::navigation::Navigation;
+use crate::screens::ActiveScreen;
+use crate::workspace::{pane_id_of, Workspace};
+
+const CARD_BULLET: &str = "- ";
+const CARD_INDENT_W: usize = 2;
+const META_SEPARATOR: &str = ", ";
+const TITLE: &str = "Switch Pane";
+const PROMPT_LABEL: &str = "Pane: ";
+const CURRENT_PANE_LABEL: &str = "[CURRENT PANE]";
+const NEW_TAB_LABEL: &str = "+ New Tab";
+const NEW_PANE_LABEL: &str = "+ New Pane";
+const FOOTER_GAP: usize = 4;
+
+#[derive(Default)]
+pub struct PanesScreen {
+ pub panes_search: String,
+}
+
+impl PanesScreen {
+ pub fn handle_key(
+ &mut self,
+ active: &mut ActiveScreen,
+ nav: &mut Navigation,
+ ws: &Workspace,
+ key: KeyWithModifier,
+ ) -> Option<(usize, PaneId)> {
+ match key.bare_key {
+ BareKey::Esc => {
+ if !self.panes_search.is_empty() {
+ self.panes_search.clear();
+ nav.selector_scroll_offset = 0;
+ } else {
+ *active = ActiveScreen::Viewport;
+ }
+ None
+ },
+ BareKey::Enter => {
+ if let Some((tab_position, pane_id)) = self.panes_top_match(ws, nav) {
+ self.panes_search.clear();
+ nav.selector_scroll_offset = 0;
+ Some((tab_position, pane_id))
+ } else {
+ None
+ }
+ },
+ BareKey::Backspace => {
+ self.panes_search.pop();
+ nav.selector_scroll_offset = 0;
+ None
+ },
+ BareKey::Char(c) => {
+ self.panes_search.push(c);
+ nav.selector_scroll_offset = 0;
+ None
+ },
+ _ => None,
+ }
+ }
+
+ pub fn panes_top_match(
+ &mut self,
+ ws: &Workspace,
+ nav: &mut Navigation,
+ ) -> Option<(usize, PaneId)> {
+ let tabs: Vec<TabInfo> = ws.tabs_in_order().into_iter().cloned().collect();
+ let mut entries: Vec<(String, usize, PaneId)> = Vec::new();
+ for tab in &tabs {
+ for pane in ws.panes_for_tab(tab.position) {
+ entries.push((pane_title(pane), tab.position, pane_id_of(pane)));
+ }
+ }
+ if entries.is_empty() {
+ return None;
+ }
+ let search = self.panes_search.clone();
+ if search.is_empty() {
+ let first = entries.into_iter().next()?;
+ return Some((first.1, first.2));
+ }
+ let matcher = nav.matcher();
+ let mut best: Option<(i64, String, usize, PaneId)> = None;
+ for (title, tab_pos, pane_id) in entries.into_iter() {
+ if let Some((score, _)) = matcher.fuzzy_indices(&title, &search) {
+ let take = match &best {
+ None => true,
+ Some((bs, bn, _, _)) => score > *bs || (score == *bs && &title < bn),
+ };
+ if take {
+ best = Some((score, title, tab_pos, pane_id));
+ }
+ }
+ }
+ best.map(|(_, _, tab_pos, pane_id)| (tab_pos, pane_id))
+ }
+
+ pub fn render(
+ &mut self,
+ ws: &Workspace,
+ nav: &mut Navigation,
+ frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ if row_start < row_end {
+ draw_back_button(frame, row_start);
+ }
+
+ let body_start = row_start.saturating_add(1);
+ let body_end = row_end.saturating_sub(1);
+ let body_height = body_end.saturating_sub(body_start);
+ if body_height == 0 || cols == 0 {
+ return;
+ }
+
+ let new_pane_target_tab = ws.current_tab().map(|t| t.position);
+ let search = self.panes_search.clone();
+ let cards = self.ordered_cards(ws, nav, &search);
+ let layout = PickerLayout::compute(
+ nav,
+ &cards,
+ body_start,
+ body_end,
+ body_height,
+ cols,
+ new_pane_target_tab.is_some(),
+ );
+ let visible: Vec<&PaneCard> = cards
+ .iter()
+ .skip(layout.offset)
+ .take(layout.visible_count)
+ .collect();
+
+ draw_title(&layout);
+ draw_prompt(&search, &layout);
+ draw_scroll_indicators(&layout);
+ draw_cards(frame, &visible, &layout);
+ draw_footer(frame, new_pane_target_tab, &layout);
+ }
+
+ fn ordered_cards(&self, ws: &Workspace, nav: &mut Navigation, search: &str) -> Vec<PaneCard> {
+ let cards = self.collect_cards(ws);
+ if search.is_empty() {
+ return cards;
+ }
+ let matcher = nav.matcher();
+ let mut scored: Vec<(i64, PaneCard)> = cards
+ .into_iter()
+ .filter_map(|mut card| {
+ matcher
+ .fuzzy_indices(&card.title_label, search)
+ .map(|(score, indices)| {
+ card.title_indices = indices;
+ (score, card)
+ })
+ })
+ .collect();
+ scored.sort_by(|a, b| {
+ b.0.cmp(&a.0)
+ .then_with(|| a.1.title_label.cmp(&b.1.title_label))
+ });
+ scored.into_iter().map(|(_, card)| card).collect()
+ }
+
+ fn collect_cards(&self, ws: &Workspace) -> Vec<PaneCard> {
+ let now = crate::unix_now();
+ let current_tab_position = ws.current_tab().map(|t| t.position);
+ let current_pane_id = ws.current_pane().as_ref().map(pane_id_of);
+ let tabs: Vec<TabInfo> = ws.tabs_in_order().into_iter().cloned().collect();
+
+ let mut cards = Vec::new();
+ for tab in &tabs {
+ for pane in ws.panes_for_tab(tab.position) {
+ let id = pane_id_of(pane);
+ let is_current =
+ current_tab_position == Some(tab.position) && current_pane_id == Some(id);
+ cards.push(PaneCard {
+ title_label: pane_title(pane),
+ tab_label: tab.name.clone(),
+ activity_label: pane_activity_label(ws, id, is_current, now),
+ action: ClickAction::SelectPane {
+ tab_position: tab.position,
+ pane_id: id,
+ },
+ title_indices: Vec::new(),
+ is_current,
+ });
+ }
+ }
+ cards
+ }
+}
+
+fn pane_title(pane: &PaneInfo) -> String {
+ if pane.title.is_empty() {
+ format!("#{}", pane.id)
+ } else {
+ pane.title.clone()
+ }
+}
+
+fn pane_activity_label(ws: &Workspace, id: PaneId, is_current: bool, now: u64) -> String {
+ if is_current {
+ CURRENT_PANE_LABEL.to_string()
+ } else {
+ crate::ansi::format_time_ago(ws.pane_last_activity.get(&id).copied(), now)
+ }
+}
+
+struct PaneCard {
+ title_label: String,
+ tab_label: String,
+ activity_label: String,
+ action: ClickAction,
+ title_indices: Vec<usize>,
+ is_current: bool,
+}
+
+impl PaneCard {
+ fn meta_width(&self) -> usize {
+ UnicodeWidthStr::width(self.tab_label.as_str())
+ + META_SEPARATOR.len()
+ + UnicodeWidthStr::width(self.activity_label.as_str())
+ }
+
+ fn content_width(&self) -> usize {
+ UnicodeWidthStr::width(self.title_label.as_str()).max(self.meta_width())
+ }
+
+ fn draw(
+ &self,
+ frame: &mut Frame,
+ card_x: usize,
+ content_x: usize,
+ row_title: usize,
+ row_meta: usize,
+ row_end: usize,
+ ) {
+ print_text_with_coordinates(Text::new(CARD_BULLET), card_x, row_title, None, None);
+
+ let mut title_text = Text::new(&self.title_label).color_range(2, ..);
+ if !self.title_indices.is_empty() {
+ title_text = title_text.color_indices(3, self.title_indices.clone());
+ }
+ print_text_with_coordinates(title_text, content_x, row_title, None, None);
+
+ if row_meta < row_end {
+ self.draw_meta_row(content_x, row_meta);
+ }
+
+ frame.click_regions.push(ClickRegion::tight_range(
+ row_title,
+ row_meta + 1,
+ card_x,
+ content_x + self.content_width(),
+ self.action.clone(),
+ ));
+ }
+
+ fn draw_meta_row(&self, content_x: usize, row: usize) {
+ // The host reapplies bold per color-range without composing state, so a
+ // single Text would bleed the tab segment's bold onto the activity
+ // segment; emit each segment as its own Text.
+ let tab_w = UnicodeWidthStr::width(self.tab_label.as_str());
+ print_text_with_coordinates(
+ Text::new(&self.tab_label).color_range(1, ..),
+ content_x,
+ row,
+ None,
+ None,
+ );
+
+ let sep_x = content_x + tab_w;
+ print_text_with_coordinates(Text::new(META_SEPARATOR), sep_x, row, None, None);
+
+ let activity_x = sep_x + META_SEPARATOR.len();
+ let activity_text = if self.is_current {
+ Text::new(&self.activity_label).color_range(0, ..)
+ } else {
+ Text::new(&self.activity_label).unbold_all()
+ };
+ print_text_with_coordinates(activity_text, activity_x, row, None, None);
+ }
+}
+
+struct PickerLayout {
+ cols: usize,
+ row_end: usize,
+ top_y: usize,
+ block_height: usize,
+ card_x: usize,
+ content_x: usize,
+ offset: usize,
+ visible_count: usize,
+ hidden_above: usize,
+ hidden_below: usize,
+ new_tab_x: usize,
+ new_tab_w: usize,
+ new_pane_x: usize,
+ new_pane_w: usize,
+}
+
+impl PickerLayout {
+ fn compute(
+ nav: &mut Navigation,
+ cards: &[PaneCard],
+ body_start: usize,
+ body_end: usize,
+ body_height: usize,
+ cols: usize,
+ show_new_pane: bool,
+ ) -> Self {
+ let total_cards = cards.len();
+ const CHROME_ROWS: usize = 6;
+ const ROWS_PER_CARD: usize = 2;
+ let max_visible_cards =
+ (body_height.saturating_sub(CHROME_ROWS) / ROWS_PER_CARD).min(total_cards);
+ let max_offset = total_cards.saturating_sub(max_visible_cards);
+ let offset = nav.selector_scroll_offset.min(max_offset);
+ nav.selector_scroll_offset = offset;
+ let visible_count = total_cards.saturating_sub(offset).min(max_visible_cards);
+
+ let block_height = if visible_count == 0 {
+ 5.min(body_height)
+ } else {
+ (6 + 2 * visible_count).min(body_height)
+ };
+ let top_y = body_start + body_height.saturating_sub(block_height) / 2;
+
+ let content_w = cards
+ .iter()
+ .skip(offset)
+ .take(visible_count)
+ .map(PaneCard::content_width)
+ .max()
+ .unwrap_or(0);
+ let card_x = cols.saturating_sub(CARD_INDENT_W + content_w) / 2;
+ let content_x = card_x + CARD_INDENT_W;
+
+ let new_tab_w = UnicodeWidthStr::width(NEW_TAB_LABEL);
+ let new_pane_w = UnicodeWidthStr::width(NEW_PANE_LABEL);
+ let (new_tab_x, new_pane_x) = if show_new_pane {
+ let total = new_tab_w + FOOTER_GAP + new_pane_w;
+ let block_x = cols.saturating_sub(total) / 2;
+ (block_x, block_x + new_tab_w + FOOTER_GAP)
+ } else {
+ (cols.saturating_sub(new_tab_w) / 2, 0)
+ };
+
+ PickerLayout {
+ cols,
+ row_end: body_end,
+ top_y,
+ block_height,
+ card_x,
+ content_x,
+ offset,
+ visible_count,
+ hidden_above: offset,
+ hidden_below: total_cards.saturating_sub(offset + visible_count),
+ new_tab_x,
+ new_tab_w,
+ new_pane_x,
+ new_pane_w,
+ }
+ }
+
+ fn centered_x(&self, label_w: usize) -> usize {
+ self.cols.saturating_sub(label_w) / 2
+ }
+
+ fn prompt_x(&self) -> usize {
+ if self.visible_count > 0 {
+ self.content_x
+ } else {
+ self.new_tab_x
+ }
+ }
+}
+
+fn draw_back_button(frame: &mut Frame, row: usize) {
+ let back_label = "[← BACK]";
+ let back_w = UnicodeWidthStr::width(back_label);
+ print_text_with_coordinates(Text::new(back_label).color_range(3, ..), 0, row, None, None);
+ frame.click_regions.push(ClickRegion::tight(
+ row,
+ 0,
+ back_w,
+ ClickAction::CollapseSelector,
+ ));
+}
+
+fn draw_title(layout: &PickerLayout) {
+ let title_y = layout.top_y;
+ if title_y < layout.row_end {
+ let title_x = layout.centered_x(UnicodeWidthStr::width(TITLE));
+ print_text_with_coordinates(Text::new(TITLE), title_x, title_y, None, None);
+ }
+}
+
+fn draw_prompt(search: &str, layout: &PickerLayout) {
+ let prompt_y = layout.top_y + 2;
+ if prompt_y >= layout.row_end {
+ return;
+ }
+ let prompt_full = format!("{}{}_", PROMPT_LABEL, search);
+ let label_chars = PROMPT_LABEL.chars().count();
+ let total_chars = prompt_full.chars().count();
+ let prompt_text = Text::new(&prompt_full).color_range(3, label_chars..total_chars);
+ print_text_with_coordinates(prompt_text, layout.prompt_x(), prompt_y, None, None);
+}
+
+fn draw_scroll_indicators(layout: &PickerLayout) {
+ if layout.visible_count == 0 {
+ return;
+ }
+ if layout.hidden_above > 0 {
+ let y = layout.top_y + 3;
+ if y < layout.row_end {
+ let label = format!("\u{2191} [+{}]", layout.hidden_above);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+ if layout.hidden_below > 0 {
+ let y = layout.top_y + 4 + 2 * layout.visible_count;
+ if y < layout.row_end {
+ let label = format!("\u{2193} [+{}]", layout.hidden_below);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+}
+
+fn draw_cards(frame: &mut Frame, visible: &[&PaneCard], layout: &PickerLayout) {
+ let first_card_y = layout.top_y + 4;
+ for (i, card) in visible.iter().enumerate() {
+ let row_title = first_card_y + i * 2;
+ if row_title >= layout.row_end {
+ break;
+ }
+ card.draw(
+ frame,
+ layout.card_x,
+ layout.content_x,
+ row_title,
+ row_title + 1,
+ layout.row_end,
+ );
+ }
+}
+
+fn draw_footer(frame: &mut Frame, new_pane_target_tab: Option<usize>, layout: &PickerLayout) {
+ let footer_y = layout.top_y + layout.block_height.saturating_sub(1);
+ if footer_y >= layout.row_end {
+ return;
+ }
+ print_text_with_coordinates(
+ Text::new(NEW_TAB_LABEL).color_range(3, ..),
+ layout.new_tab_x,
+ footer_y,
+ None,
+ None,
+ );
+ frame.click_regions.push(ClickRegion::tight(
+ footer_y,
+ layout.new_tab_x,
+ layout.new_tab_x + layout.new_tab_w,
+ ClickAction::NewTab,
+ ));
+
+ if let Some(tab_position) = new_pane_target_tab {
+ print_text_with_coordinates(
+ Text::new(NEW_PANE_LABEL).color_range(3, ..),
+ layout.new_pane_x,
+ footer_y,
+ None,
+ None,
+ );
+ frame.click_regions.push(ClickRegion::tight(
+ footer_y,
+ layout.new_pane_x,
+ layout.new_pane_x + layout.new_pane_w,
+ ClickAction::NewPaneInTab { tab_position },
+ ));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::state::State;
+ fn state_with_tabs_and_panes(tab_count: usize) -> State {
+ use zellij_tile::prelude::TabInfo;
+ let mut state = State::default();
+ for i in 0..tab_count {
+ let mut tab = TabInfo::default();
+ tab.position = i;
+ tab.name = format!("Tab {}", i);
+ state.workspace.tabs.push(tab);
+ let mut pane = PaneInfo::default();
+ pane.id = (100 + i) as u32;
+ pane.is_plugin = false;
+ pane.is_selectable = true;
+ pane.is_suppressed = false;
+ state.workspace.panes_by_tab_position.insert(i, vec![pane]);
+ }
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(100));
+ state
+ }
+ #[test]
+ fn panes_menu_one_tab_emits_four_click_regions() {
+ let mut state = state_with_tabs_and_panes(1);
+ let cols = 40;
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ cols,
+ );
+ assert_eq!(state.frame.click_regions.len(), 4);
+ let actions: Vec<ClickAction> = state
+ .frame
+ .click_regions
+ .iter()
+ .map(|r| r.action.clone())
+ .collect();
+ assert!(matches!(actions[0], ClickAction::CollapseSelector));
+ assert!(matches!(
+ actions[1],
+ ClickAction::SelectPane {
+ tab_position: 0,
+ pane_id: PaneId::Terminal(100)
+ }
+ ));
+ assert!(matches!(actions[2], ClickAction::NewTab));
+ assert!(matches!(
+ actions[3],
+ ClickAction::NewPaneInTab { tab_position: 0 }
+ ));
+ }
+
+ #[test]
+ fn panes_menu_two_tabs_emits_single_footer_new_pane() {
+ let mut state = state_with_tabs_and_panes(2);
+ let cols = 40;
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ cols,
+ );
+ assert_eq!(state.frame.click_regions.len(), 5);
+
+ let new_panes: Vec<usize> = state
+ .frame
+ .click_regions
+ .iter()
+ .filter_map(|r| match &r.action {
+ ClickAction::NewPaneInTab { tab_position } => Some(*tab_position),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(new_panes, vec![0]);
+
+ let new_tabs = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::NewTab))
+ .count();
+ assert_eq!(new_tabs, 1);
+ }
+
+ #[test]
+ fn footer_affordances_share_a_row_and_order_left_to_right() {
+ let mut state = state_with_tabs_and_panes(1);
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let new_tab = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| matches!(r.action, ClickAction::NewTab))
+ .expect("expected a NewTab region")
+ .clone();
+ let new_pane = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| matches!(r.action, ClickAction::NewPaneInTab { .. }))
+ .expect("expected a NewPaneInTab region")
+ .clone();
+ assert_eq!(
+ new_tab.row_start, new_pane.row_start,
+ "footer affordances must share a row",
+ );
+ assert!(
+ new_tab.col_end <= new_pane.col_start,
+ "+ New Tab ({}..{}) must sit left of + New Pane ({}..)",
+ new_tab.col_start,
+ new_tab.col_end,
+ new_pane.col_start,
+ );
+ }
+
+ #[test]
+ fn click_on_new_pane_row_resolves_to_action() {
+ let mut state = state_with_tabs_and_panes(2);
+ state.workspace.selected_tab_position = Some(1);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(101));
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let new_pane_region = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| matches!(r.action, ClickAction::NewPaneInTab { tab_position: 1 }))
+ .expect("expected NewPaneInTab targeting the selected tab")
+ .clone();
+ assert_eq!(
+ state
+ .frame
+ .click_to_action(new_pane_region.row_start, new_pane_region.col_start,),
+ Some(ClickAction::NewPaneInTab { tab_position: 1 })
+ );
+ }
+
+ #[test]
+ fn panes_menu_back_button_at_top_left_collapses_selector() {
+ let mut state = state_with_tabs_and_panes(1);
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let first = state
+ .frame
+ .click_regions
+ .first()
+ .expect("expected at least one click region");
+ assert_eq!(first.row_start, 0);
+ assert_eq!(first.col_start, 0);
+ assert!(matches!(first.action, ClickAction::CollapseSelector));
+ assert_eq!(
+ state.frame.click_to_action(0, 0),
+ Some(ClickAction::CollapseSelector)
+ );
+ }
+
+ #[test]
+ fn pane_card_click_region_spans_two_rows() {
+ let mut state = state_with_tabs_and_panes(1);
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 40,
+ );
+ let pane_region = state
+ .frame
+ .click_regions
+ .iter()
+ .find(|r| {
+ matches!(
+ r.action,
+ ClickAction::SelectPane {
+ tab_position: 0,
+ pane_id: PaneId::Terminal(100),
+ }
+ )
+ })
+ .expect("expected a SelectPane region")
+ .clone();
+ assert_eq!(
+ pane_region.row_end - pane_region.row_start,
+ 2,
+ "pane card click region should cover two rows",
+ );
+ let expected = Some(ClickAction::SelectPane {
+ tab_position: 0,
+ pane_id: PaneId::Terminal(100),
+ });
+ assert_eq!(
+ state
+ .frame
+ .click_to_action(pane_region.row_start, pane_region.col_start),
+ expected,
+ "tap on title row should select the pane",
+ );
+ assert_eq!(
+ state
+ .frame
+ .click_to_action(pane_region.row_start + 1, pane_region.col_start,),
+ expected,
+ "tap on activity row should select the pane",
+ );
+ }
+
+ #[test]
+ fn panes_menu_fuzzy_filter_keeps_footer_visible() {
+ let mut state = state_with_tabs_and_panes(2);
+ for (i, panes) in state.workspace.panes_by_tab_position.iter_mut() {
+ for pane in panes.iter_mut() {
+ pane.title = format!("alpha-{}", i);
+ }
+ }
+ state.panes.panes_search = "alpha".to_string();
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 60,
+ );
+
+ let new_tab_count = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::NewTab))
+ .count();
+ assert_eq!(
+ new_tab_count, 1,
+ "+ New Tab must stay visible during search"
+ );
+ let new_pane_count = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::NewPaneInTab { .. }))
+ .count();
+ assert_eq!(
+ new_pane_count, 1,
+ "+ New Pane must stay visible during search",
+ );
+ assert_eq!(state.frame.click_regions.len(), 5);
+ }
+
+ #[test]
+ fn current_pane_card_span_includes_current_pane_label() {
+ let mut state = state_with_tabs_and_panes(2);
+ state.workspace.selected_tab_position = Some(0);
+ state.workspace.selected_pane_id = Some(PaneId::Terminal(100));
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 60,
+ );
+
+ let select_regions: Vec<&ClickRegion> = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::SelectPane { .. }))
+ .collect();
+ assert_eq!(select_regions.len(), 2);
+
+ let (current, other): (&&ClickRegion, &&ClickRegion) = {
+ let current = select_regions
+ .iter()
+ .find(|r| {
+ matches!(
+ r.action,
+ ClickAction::SelectPane {
+ pane_id: PaneId::Terminal(100),
+ ..
+ }
+ )
+ })
+ .expect("expected a SelectPane region for pane 100");
+ let other = select_regions
+ .iter()
+ .find(|r| {
+ matches!(
+ r.action,
+ ClickAction::SelectPane {
+ pane_id: PaneId::Terminal(101),
+ ..
+ }
+ )
+ })
+ .expect("expected a SelectPane region for pane 101");
+ (current, other)
+ };
+
+ let current_span = current.col_end.saturating_sub(current.col_start);
+ assert_eq!(current_span, 23);
+
+ let other_span = other.col_end.saturating_sub(other.col_start);
+ assert_eq!(other_span, 10);
+
+ assert_eq!(
+ current.col_start, other.col_start,
+ "both pane cards must anchor at the card column's left edge",
+ );
+ }
+
+ #[test]
+ fn panes_menu_fuzzy_filter_narrows_to_matching_pane() {
+ let mut state = state_with_tabs_and_panes(2);
+ state.workspace.panes_by_tab_position.get_mut(&0).unwrap()[0].title = "alpha".to_string();
+ state.workspace.panes_by_tab_position.get_mut(&1).unwrap()[0].title = "bravo".to_string();
+ state.panes.panes_search = "brv".to_string();
+ state.panes.render(
+ &state.workspace,
+ &mut state.navigation,
+ &mut state.frame,
+ 0,
+ 20,
+ 60,
+ );
+
+ let select_panes: Vec<ClickAction> = state
+ .frame
+ .click_regions
+ .iter()
+ .filter(|r| matches!(r.action, ClickAction::SelectPane { .. }))
+ .map(|r| r.action.clone())
+ .collect();
+ assert_eq!(
+ select_panes.len(),
+ 1,
+ "only the 'bravo' pane should survive the 'brv' filter",
+ );
+ assert!(matches!(
+ select_panes[0],
+ ClickAction::SelectPane {
+ tab_position: 1,
+ pane_id: PaneId::Terminal(101),
+ }
+ ));
+ }
+}
diff --git a/default-plugins/mobile/src/screens/sessions.rs b/default-plugins/mobile/src/screens/sessions.rs
new file mode 100644
index 00000000..237e104e
--- /dev/null
+++ b/default-plugins/mobile/src/screens/sessions.rs
@@ -0,0 +1,458 @@
+use fuzzy_matcher::FuzzyMatcher;
+use unicode_width::UnicodeWidthStr;
+use zellij_tile::prelude::*;
+
+use crate::click::{ClickAction, ClickRegion};
+use crate::frame::Frame;
+use crate::navigation::Navigation;
+use crate::screens::ActiveScreen;
+
+const CARD_BULLET: &str = "- ";
+const CARD_INDENT_W: usize = 2;
+const NEW_SESSION_LABEL: &str = "+ New Session";
+
+#[derive(Default)]
+pub struct SessionsScreen {
+ pub sessions: Vec<SessionInfo>,
+ pub welcome_search: String,
+ pub is_welcome_screen: bool,
+}
+
+impl SessionsScreen {
+ pub fn select_session(&self, active: &mut ActiveScreen, name: &str) -> bool {
+ switch_session(Some(name));
+ *active = ActiveScreen::Viewport;
+ true
+ }
+
+ pub fn handle_key(
+ &mut self,
+ active: &mut ActiveScreen,
+ nav: &mut Navigation,
+ key: KeyWithModifier,
+ ) -> bool {
+ match key.bare_key {
+ BareKey::Esc => {
+ if !self.welcome_search.is_empty() {
+ self.welcome_search.clear();
+ nav.selector_scroll_offset = 0;
+ } else if !self.is_welcome_screen {
+ *active = ActiveScreen::Viewport;
+ }
+ },
+ BareKey::Enter => {
+ if let Some(name) = self.top_match_name(nav) {
+ switch_session(Some(&name));
+ *active = ActiveScreen::Viewport;
+ }
+ },
+ BareKey::Backspace => {
+ self.welcome_search.pop();
+ nav.selector_scroll_offset = 0;
+ },
+ BareKey::Char(c) => {
+ self.welcome_search.push(c);
+ nav.selector_scroll_offset = 0;
+ },
+ _ => {},
+ }
+ true
+ }
+
+ pub fn top_match_name(&mut self, nav: &mut Navigation) -> Option<String> {
+ let search = self.welcome_search.clone();
+ if search.is_empty() {
+ return self.selectable_sessions().map(|s| s.name.clone()).min();
+ }
+ let matcher = nav.matcher();
+ let mut best: Option<(i64, String)> = None;
+ for s in self.selectable_sessions() {
+ if let Some((score, _)) = matcher.fuzzy_indices(&s.name, &search) {
+ let take = match &best {
+ None => true,
+ Some((bs, bn)) => score > *bs || (score == *bs && &s.name < bn),
+ };
+ if take {
+ best = Some((score, s.name.clone()));
+ }
+ }
+ }
+ best.map(|(_, name)| name)
+ }
+
+ fn selectable_sessions(&self) -> impl Iterator<Item = &SessionInfo> {
+ self.sessions.iter().filter(|s| !s.is_current_session)
+ }
+
+ fn header(&self) -> (&'static str, bool) {
+ if self.is_welcome_screen {
+ ("Hi from Zellij!", false)
+ } else {
+ ("Switch Session", true)
+ }
+ }
+
+ pub fn render(
+ &mut self,
+ nav: &mut Navigation,
+ frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ let (title, show_back_button) = self.header();
+
+ if show_back_button && row_start < row_end {
+ draw_back_button(frame, row_start);
+ }
+
+ let body_start = if show_back_button {
+ row_start.saturating_add(1)
+ } else {
+ row_start
+ };
+ let body_end = row_end.saturating_sub(1);
+ let body_height = body_end.saturating_sub(body_start);
+ if body_height == 0 || cols == 0 {
+ return;
+ }
+
+ let search = self.welcome_search.clone();
+ let cards = self.ordered_cards(nav, &search);
+ let layout = PickerLayout::compute(nav, &cards, body_start, body_end, body_height, cols);
+ let visible: Vec<&Card> = cards
+ .iter()
+ .skip(layout.offset)
+ .take(layout.visible_count)
+ .collect();
+
+ draw_title(title, &layout);
+ draw_prompt(&search, &layout);
+ draw_scroll_indicators(&layout);
+ draw_cards(frame, &visible, &layout);
+ draw_new_session_button(frame, &layout);
+ }
+
+ fn ordered_cards(&self, nav: &mut Navigation, search: &str) -> Vec<Card> {
+ self.scored_session_order(nav, search)
+ .into_iter()
+ .map(|(idx, name_indices)| Card::from_session(&self.sessions[idx], name_indices))
+ .collect()
+ }
+
+ fn scored_session_order(&self, nav: &mut Navigation, search: &str) -> Vec<(usize, Vec<usize>)> {
+ if search.is_empty() {
+ let mut indexed: Vec<(usize, &str)> = self
+ .sessions
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| !s.is_current_session)
+ .map(|(i, s)| (i, s.name.as_str()))
+ .collect();
+ indexed.sort_by(|a, b| a.1.cmp(b.1));
+ return indexed.into_iter().map(|(i, _)| (i, Vec::new())).collect();
+ }
+
+ let matcher = nav.matcher();
+ let mut scored: Vec<(usize, i64, Vec<usize>)> = self
+ .sessions
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| !s.is_current_session)
+ .filter_map(|(i, s)| {
+ matcher
+ .fuzzy_indices(&s.name, search)
+ .map(|(score, indices)| (i, score, indices))
+ })
+ .collect();
+ scored.sort_by(|a, b| {
+ b.1.cmp(&a.1)
+ .then_with(|| self.sessions[a.0].name.cmp(&self.sessions[b.0].name))
+ });
+ scored
+ .into_iter()
+ .map(|(i, _, indices)| (i, indices))
+ .collect()
+ }
+}
+
+struct Card {
+ name_label: String,
+ counts_label: String,
+ action: ClickAction,
+ tab_range: std::ops::Range<usize>,
+ pane_range: std::ops::Range<usize>,
+ client_range: std::ops::Range<usize>,
+ name_indices: Vec<usize>,
+}
+
+impl Card {
+ fn from_session(s: &SessionInfo, name_indices: Vec<usize>) -> Self {
+ let pane_count: usize = s
+ .panes
+ .panes
+ .values()
+ .map(|panes| {
+ panes
+ .iter()
+ .filter(|p| p.is_selectable && !p.is_suppressed)
+ .count()
+ })
+ .sum();
+ let name = s.name.clone();
+ let tab_str = format!("{}", s.tabs.len());
+ let pane_str = format!("{}", pane_count);
+ let conn_str = format!("{}", s.connected_clients);
+ let client_word = if s.connected_clients == 1 {
+ "client"
+ } else {
+ "clients"
+ };
+ let counts_label = format!(
+ "{} tabs, {} panes, {} {}",
+ tab_str, pane_str, conn_str, client_word
+ );
+ // Color ranges mirror the session-manager welcome screen
+ // (UnifiedResultsRenderCache::rebuild): tab count color 1, pane and
+ // client counts color 2.
+ let tab_end = tab_str.len();
+ let pane_offset = tab_str.len() + " tabs, ".len();
+ let pane_end = pane_offset + pane_str.len();
+ let conn_offset = pane_end + " panes, ".len();
+ let conn_end = conn_offset + conn_str.len();
+ Card {
+ name_label: name.clone(),
+ counts_label,
+ action: ClickAction::SelectSession(name),
+ tab_range: 0..tab_end,
+ pane_range: pane_offset..pane_end,
+ client_range: conn_offset..conn_end,
+ name_indices,
+ }
+ }
+
+ fn content_width(&self) -> usize {
+ UnicodeWidthStr::width(self.name_label.as_str())
+ .max(UnicodeWidthStr::width(self.counts_label.as_str()))
+ }
+
+ fn draw(
+ &self,
+ frame: &mut Frame,
+ card_x: usize,
+ content_x: usize,
+ row_name: usize,
+ row_counts: usize,
+ row_end: usize,
+ ) {
+ print_text_with_coordinates(Text::new(CARD_BULLET), card_x, row_name, None, None);
+
+ let mut name_text = Text::new(&self.name_label).color_range(0, ..);
+ if !self.name_indices.is_empty() {
+ name_text = name_text.color_indices(3, self.name_indices.clone());
+ }
+ print_text_with_coordinates(name_text, content_x, row_name, None, None);
+
+ if row_counts < row_end {
+ let counts_text = Text::new(&self.counts_label)
+ .color_range(1, self.tab_range.clone())
+ .color_range(2, self.pane_range.clone())
+ .color_range(2, self.client_range.clone());
+ print_text_with_coordinates(counts_text, content_x, row_counts, None, None);
+ }
+
+ frame.click_regions.push(ClickRegion::tight_range(
+ row_name,
+ row_counts + 1,
+ card_x,
+ content_x + self.content_width(),
+ self.action.clone(),
+ ));
+ }
+}
+
+struct PickerLayout {
+ cols: usize,
+ row_end: usize,
+ top_y: usize,
+ block_height: usize,
+ card_x: usize,
+ content_x: usize,
+ new_session_x: usize,
+ new_session_w: usize,
+ offset: usize,
+ visible_count: usize,
+ hidden_above: usize,
+ hidden_below: usize,
+}
+
+impl PickerLayout {
+ fn compute(
+ nav: &mut Navigation,
+ cards: &[Card],
+ body_start: usize,
+ body_end: usize,
+ body_height: usize,
+ cols: usize,
+ ) -> Self {
+ let total_cards = cards.len();
+ const CHROME_ROWS: usize = 6;
+ const ROWS_PER_CARD: usize = 2;
+ let max_visible_cards =
+ (body_height.saturating_sub(CHROME_ROWS) / ROWS_PER_CARD).min(total_cards);
+ let max_offset = total_cards.saturating_sub(max_visible_cards);
+ let offset = nav.selector_scroll_offset.min(max_offset);
+ nav.selector_scroll_offset = offset;
+ let visible_count = total_cards.saturating_sub(offset).min(max_visible_cards);
+
+ let block_height = if visible_count == 0 {
+ 5.min(body_height)
+ } else {
+ (6 + 2 * visible_count).min(body_height)
+ };
+ let top_y = body_start + body_height.saturating_sub(block_height) / 2;
+
+ let content_w = cards
+ .iter()
+ .skip(offset)
+ .take(visible_count)
+ .map(Card::content_width)
+ .max()
+ .unwrap_or(0);
+ let card_w = CARD_INDENT_W + content_w;
+ let card_x = cols.saturating_sub(card_w) / 2;
+ let content_x = card_x + CARD_INDENT_W;
+
+ let new_session_w = UnicodeWidthStr::width(NEW_SESSION_LABEL);
+ let new_session_x = cols.saturating_sub(new_session_w) / 2;
+
+ PickerLayout {
+ cols,
+ row_end: body_end,
+ top_y,
+ block_height,
+ card_x,
+ content_x,
+ new_session_x,
+ new_session_w,
+ offset,
+ visible_count,
+ hidden_above: offset,
+ hidden_below: total_cards.saturating_sub(offset + visible_count),
+ }
+ }
+
+ fn centered_x(&self, label_w: usize) -> usize {
+ self.cols.saturating_sub(label_w) / 2
+ }
+}
+
+fn draw_back_button(frame: &mut Frame, row: usize) {
+ let back_label = "[← BACK]";
+ let back_w = UnicodeWidthStr::width(back_label);
+ print_text_with_coordinates(Text::new(back_label).color_range(3, ..), 0, row, None, None);
+ frame.click_regions.push(ClickRegion::tight(
+ row,
+ 0,
+ back_w,
+ ClickAction::CollapseSelector,
+ ));
+}
+
+fn draw_title(title: &str, layout: &PickerLayout) {
+ let title_y = layout.top_y;
+ if title_y < layout.row_end {
+ let title_x = layout.centered_x(UnicodeWidthStr::width(title));
+ print_text_with_coordinates(Text::new(title), title_x, title_y, None, None);
+ }
+}
+
+fn draw_prompt(search: &str, layout: &PickerLayout) {
+ let prompt_y = layout.top_y + 2;
+ if prompt_y >= layout.row_end {
+ return;
+ }
+ let prompt_label = "Session: ";
+ let prompt_full = format!("{}{}_", prompt_label, search);
+ let prompt_x = if layout.visible_count > 0 {
+ layout.content_x
+ } else {
+ layout.new_session_x
+ };
+ let label_chars = prompt_label.chars().count();
+ let total_chars = prompt_full.chars().count();
+ let prompt_text = Text::new(&prompt_full).color_range(3, label_chars..total_chars);
+ print_text_with_coordinates(prompt_text, prompt_x, prompt_y, None, None);
+}
+
+fn draw_scroll_indicators(layout: &PickerLayout) {
+ if layout.visible_count == 0 {
+ return;
+ }
+ if layout.hidden_above > 0 {
+ let y = layout.top_y + 3;
+ if y < layout.row_end {
+ let label = format!("\u{2191} [+{}]", layout.hidden_above);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+ if layout.hidden_below > 0 {
+ let y = layout.top_y + 4 + 2 * layout.visible_count;
+ if y < layout.row_end {
+ let label = format!("\u{2193} [+{}]", layout.hidden_below);
+ let w = UnicodeWidthStr::width(label.as_str());
+ print_text_with_coordinates(
+ Text::new(&label).color_range(1, ..),
+ layout.centered_x(w),
+ y,
+ None,
+ None,
+ );
+ }
+ }
+}
+
+fn draw_cards(frame: &mut Frame, visible: &[&Card], layout: &PickerLayout) {
+ let first_card_y = layout.top_y + 4;
+ for (i, card) in visible.iter().enumerate() {
+ let row_name = first_card_y + i * 2;
+ if row_name >= layout.row_end {
+ break;
+ }
+ card.draw(
+ frame,
+ layout.card_x,
+ layout.content_x,
+ row_name,
+ row_name + 1,
+ layout.row_end,
+ );
+ }
+}
+
+fn draw_new_session_button(frame: &mut Frame, layout: &PickerLayout) {
+ let new_session_y = layout.top_y + layout.block_height.saturating_sub(1);
+ if new_session_y >= layout.row_end {
+ return;
+ }
+ print_text_with_coordinates(
+ Text::new(NEW_SESSION_LABEL).color_range(3, ..),
+ layout.new_session_x,
+ new_session_y,
+ None,
+ None,
+ );
+ frame.click_regions.push(ClickRegion::tight(
+ new_session_y,
+ layout.new_session_x,
+ layout.new_session_x + layout.new_session_w,
+ ClickAction::OpenNewSessionPrompt,
+ ));
+}
diff --git a/default-plugins/mobile/src/screens/viewport.rs b/default-plugins/mobile/src/screens/viewport.rs
new file mode 100644
index 00000000..96c78554
--- /dev/null
+++ b/default-plugins/mobile/src/screens/viewport.rs
@@ -0,0 +1,239 @@
+use zellij_tile::prelude::*;
+
+use crate::ansi::{move_to, slice_ansi_visible, visible_width, RESET};
+use crate::click::ViewportRegion;
+use crate::frame::Frame;
+use crate::input::Input;
+use crate::keys;
+use crate::workspace::{pane_id_of, Workspace};
+
+const DISABLE_AUTOWRAP: &str = "\x1b[?7l";
+const ENABLE_AUTOWRAP: &str = "\x1b[?7h";
+
+#[derive(Default)]
+pub struct ViewportScreen {
+ pub viewport_v_pan: usize,
+ pub viewport_h_pan: usize,
+ pub viewport_region: Option<ViewportRegion>,
+}
+
+impl ViewportScreen {
+ pub fn reset_pan(&mut self) {
+ self.viewport_v_pan = 0;
+ self.viewport_h_pan = 0;
+ }
+
+ pub fn handle_key(&self, ws: &Workspace, input: &mut Input, key: KeyWithModifier) -> bool {
+ if let Some(pane) = ws.current_pane() {
+ let key = if input.ctrl_held || input.alt_held {
+ input.merge_held_modifiers(&key)
+ } else {
+ key
+ };
+ let bytes = keys::serialize_key(&key);
+ if !bytes.is_empty() {
+ write_to_pane_id(bytes, pane_id_of(&pane));
+ }
+ }
+ let consumed = input.ctrl_held || input.alt_held;
+ input.ctrl_held = false;
+ input.alt_held = false;
+ consumed
+ }
+
+ pub fn max_viewport_v_pan(&self, ws: &Workspace) -> Option<usize> {
+ let region = self.viewport_region?;
+ let embed_height = region.row_end.saturating_sub(region.row_start);
+ Some(ws.current_pane_viewport_len().saturating_sub(embed_height))
+ }
+
+ pub fn click_in_viewport(&self, row: usize, col: usize) -> Option<(usize, usize)> {
+ let region = self.viewport_region?;
+ if row < region.row_start || row >= region.row_end {
+ return None;
+ }
+ if col >= region.cols {
+ return None;
+ }
+ let pane_row = region.skip + (row - region.row_start);
+ let pane_col = region.h_offset + col;
+ Some((pane_row, pane_col))
+ }
+
+ pub fn compute_cursor_position(
+ &self,
+ ws: &Workspace,
+ viewport_top: usize,
+ viewport_height: usize,
+ cols: usize,
+ skip: usize,
+ h_offset: usize,
+ ) -> Option<(usize, usize)> {
+ if viewport_height == 0 {
+ return None;
+ }
+ let pane = ws.current_pane()?;
+ let pane_id = pane_id_of(&pane);
+ let (cursor_x, cursor_y) = ws.latest_pane_contents.get(&pane_id)?.cursor?;
+ if cursor_y < skip {
+ return None;
+ }
+ let row_in_slice = cursor_y - skip;
+ if row_in_slice >= viewport_height {
+ return None;
+ }
+ if cursor_x < h_offset {
+ return None;
+ }
+ let plugin_x = cursor_x - h_offset;
+ if plugin_x >= cols {
+ return None;
+ }
+ let plugin_y = viewport_top + row_in_slice;
+ Some((plugin_x, plugin_y))
+ }
+
+ pub fn render(
+ &mut self,
+ ws: &Workspace,
+ _frame: &mut Frame,
+ row_start: usize,
+ row_end: usize,
+ cols: usize,
+ ) {
+ let height = row_end - row_start;
+ if height == 0 {
+ return;
+ }
+
+ let pane = ws.current_pane();
+ let pane_id = pane.as_ref().map(pane_id_of);
+ let viewport_lines: Vec<String> = pane_id
+ .and_then(|id| ws.latest_pane_contents.get(&id))
+ .map(|c| c.viewport.clone())
+ .unwrap_or_default();
+
+ let max_v_pan = self
+ .max_viewport_v_pan(ws)
+ .unwrap_or_else(|| viewport_lines.len().saturating_sub(height));
+ self.viewport_v_pan = self.viewport_v_pan.min(max_v_pan);
+ let skip = max_v_pan.saturating_sub(self.viewport_v_pan);
+ let pane_width = pane
+ .as_ref()
+ .map(|p| p.pane_content_columns)
+ .filter(|&w| w > 0)
+ .unwrap_or_else(|| {
+ viewport_lines
+ .iter()
+ .map(|l| visible_width(l))
+ .max()
+ .unwrap_or(0)
+ });
+ let max_h_pan = pane_width.saturating_sub(cols);
+ self.viewport_h_pan = self.viewport_h_pan.min(max_h_pan);
+ let h_offset = self.viewport_h_pan;
+
+ self.viewport_region = Some(ViewportRegion {
+ row_start,
+ row_end,
+ cols,
+ skip,
+ h_offset,
+ });
+
+ print!("{}", DISABLE_AUTOWRAP);
+
+ for i in 0..height {
+ let row = row_start + i;
+ print!("{}{}", RESET, move_to(row, 0));
+ if let Some(line) = viewport_lines.get(skip + i) {
+ if h_offset == 0 {
+ print!("{}", line);
+ } else {
+ let sliced = slice_ansi_visible(line, h_offset, cols);
+ print!("{}", sliced);
+ }
+ } else if i == 0 && pane_id.is_none() {
+ print!("{}(no pane selected)", RESET);
+ } else if i == 0 && viewport_lines.is_empty() {
+ print!("{}(awaiting first render…)", RESET);
+ }
+ let raw_width = viewport_lines
+ .get(skip + i)
+ .map(|l| visible_width(l))
+ .unwrap_or(0);
+ let printed_width = raw_width.saturating_sub(h_offset).min(cols);
+ if printed_width < cols {
+ print!("{}\x1b[K", RESET);
+ }
+ }
+
+ print!("{}", ENABLE_AUTOWRAP);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::click::ViewportRegion;
+
+ fn state_with_viewport(
+ viewport_len: usize,
+ embed_height: Option<usize>,
+ ) -> (Workspace, ViewportScreen) {
+ let mut ws = Workspace::default();
+ let mut tab = TabInfo::default();
+ tab.position = 0;
+ ws.tabs.push(tab);
+ ws.selected_tab_position = Some(0);
+
+ let mut pane = PaneInfo::default();
+ pane.id = 42;
+ pane.is_plugin = false;
+ pane.is_selectable = true;
+ pane.is_suppressed = false;
+ ws.panes_by_tab_position.insert(0, vec![pane]);
+ ws.selected_pane_id = Some(PaneId::Terminal(42));
+
+ let mut contents = PaneContents::default();
+ contents.viewport = vec![String::new(); viewport_len];
+ ws.latest_pane_contents
+ .insert(PaneId::Terminal(42), contents);
+
+ let mut vp = ViewportScreen::default();
+ if let Some(h) = embed_height {
+ vp.viewport_region = Some(ViewportRegion {
+ row_start: 0,
+ row_end: h,
+ cols: 80,
+ skip: 0,
+ h_offset: 0,
+ });
+ }
+ (ws, vp)
+ }
+
+ #[test]
+ fn max_viewport_v_pan_none_without_region() {
+ let (ws, vp) = state_with_viewport(100, None);
+ assert_eq!(vp.max_viewport_v_pan(&ws), None);
+ }
+
+ #[test]
+ fn max_viewport_v_pan_some_typical() {
+ let (ws, vp) = state_with_viewport(100, Some(20));
+ assert_eq!(vp.max_viewport_v_pan(&ws), Some(80));
+ }
+
+ #[test]
+ fn max_viewport_v_pan_saturates_when_embed_larger() {
+ let (ws, vp) = state_with_viewport(10, Some(20));
+ assert_eq!(vp.max_viewport_v_pan(&ws), Some(0));
+ }
+
+ #[test]
+ fn max_viewport_v_pan_empty_cache() {
+ let (ws, vp) = state_with_viewport(0, Some(20));
+ assert_eq!(vp.max_viewport_v_pan(&ws), Some(0));
+ }
+}
diff --git a/default-plugins/mobile/src/state.rs b/default-plugins/mobile/src/state.rs
new file mode 100644
index 00000000..ec575cf1
--- /dev/null
+++ b/default-plugins/mobile/src/state.rs
@@ -0,0 +1,24 @@
+use crate::fit::Fit;
+use crate::frame::Frame;
+use crate::input::Input;
+use crate::navigation::Navigation;
+use crate::screens::{
+ ActiveScreen, MenuScreen, NewSessionPromptScreen, PanesScreen, SessionsScreen, ViewportScreen,
+};
+use crate::workspace::Workspace;
+
+#[derive(Default)]
+pub struct State {
+ pub workspace: Workspace,
+ pub fit: Fit,
+ pub frame: Frame,
+ pub input: Input,
+ pub navigation: Navigation,
+ pub active: ActiveScreen,
+ pub viewport: ViewportScreen,
+ pub sessions: SessionsScreen,
+ pub panes: PanesScreen,
+ pub new_session: NewSessionPromptScreen,
+ pub menu: MenuScreen,
+ pub empty_welcome_list_grace_elapsed: bool,
+}
diff --git a/default-plugins/mobile/src/workspace.rs b/default-plugins/mobile/src/workspace.rs
new file mode 100644
index 00000000..3b59bf32
--- /dev/null
+++ b/default-plugins/mobile/src/workspace.rs
@@ -0,0 +1,122 @@
+use std::collections::HashMap;
+use zellij_tile::prelude::*;
+
+const WELCOME_SCREEN_PLUGIN_ALIAS: &str = "welcome-screen";
+
+#[derive(Default)]
+pub struct Workspace {
+ pub own_plugin_pane_id: Option<PaneId>,
+ pub tabs: Vec<TabInfo>,
+ pub panes_by_tab_position: HashMap<usize, Vec<PaneInfo>>,
+ pub selected_tab_position: Option<usize>,
+ pub selected_pane_id: Option<PaneId>,
+ pub latest_pane_contents: HashMap<PaneId, PaneContents>,
+ pub mode_info: Option<ModeInfo>,
+ pub session_name: Option<String>,
+ pub pane_last_activity: HashMap<PaneId, u64>,
+ pub pending_new_tab_position: Option<usize>,
+}
+
+impl Workspace {
+ pub fn tabs_in_order(&self) -> Vec<&TabInfo> {
+ let own = self.own_plugin_pane_id;
+ let mut tabs: Vec<&TabInfo> = self
+ .tabs
+ .iter()
+ .filter(|t| !self.tab_is_self_only(t.position, own))
+ .collect();
+ tabs.sort_by_key(|t| t.position);
+ tabs
+ }
+
+ fn tab_is_self_only(&self, tab_position: usize, own: Option<PaneId>) -> bool {
+ let Some(panes) = self.panes_by_tab_position.get(&tab_position) else {
+ return false;
+ };
+ let Some(own) = own else {
+ return false;
+ };
+ let visible: Vec<&PaneInfo> = panes.iter().filter(|p| !p.is_suppressed).collect();
+ if visible.is_empty() {
+ return false;
+ }
+ visible.iter().all(|p| pane_info_matches(p, own))
+ }
+
+ pub fn current_tab(&self) -> Option<&TabInfo> {
+ let visible = self.tabs_in_order();
+ if let Some(pos) = self.selected_tab_position {
+ if let Some(t) = visible.iter().find(|t| t.position == pos) {
+ return Some(*t);
+ }
+ }
+ visible.first().copied()
+ }
+
+ pub fn current_tab_panes(&self) -> Vec<&PaneInfo> {
+ let Some(tab) = self.current_tab() else {
+ return vec![];
+ };
+ self.panes_for_tab(tab.position)
+ }
+
+ pub fn panes_for_tab(&self, tab_position: usize) -> Vec<&PaneInfo> {
+ let own = self.own_plugin_pane_id;
+ let mut panes: Vec<&PaneInfo> = self
+ .panes_by_tab_position
+ .get(&tab_position)
+ .map(|v| v.iter().collect())
+ .unwrap_or_default();
+ panes.retain(|p| {
+ !p.is_suppressed
+ && p.is_selectable
+ && !own.map(|id| pane_info_matches(p, id)).unwrap_or(false)
+ });
+ panes.sort_by_key(|p| (p.is_floating, p.pane_y, p.pane_x, p.id));
+ panes
+ }
+
+ pub fn current_pane_viewport_len(&self) -> usize {
+ self.current_pane()
+ .as_ref()
+ .map(pane_id_of)
+ .and_then(|id| self.latest_pane_contents.get(&id))
+ .map(|c| c.viewport.len())
+ .unwrap_or(0)
+ }
+
+ pub fn current_pane_is_welcome(&self) -> bool {
+ self.current_pane()
+ .map(|p| p.is_plugin && p.plugin_url.as_deref() == Some(WELCOME_SCREEN_PLUGIN_ALIAS))
+ .unwrap_or(false)
+ }
+
+ // `is_focused` is server-global (true if any client focuses the pane), so
+ // selecting by it would make this client's viewport follow another
+ // connected client's focus; fall back to the first pane instead.
+ pub fn current_pane(&self) -> Option<PaneInfo> {
+ if let Some(selected) = self.selected_pane_id {
+ for pane in self.current_tab_panes() {
+ if pane_info_matches(pane, selected) {
+ return Some(pane.clone());
+ }
+ }
+ }
+ self.current_tab_panes().into_iter().next().cloned()
+ }
+}
+
+pub fn pane_info_matches(info: &PaneInfo, id: PaneId) -> bool {
+ match id {
+ PaneId::Terminal(tid) => !info.is_plugin && info.id == tid,
+ PaneId::Plugin(pid) => info.is_plugin && info.id == pid,
+ }
+}
+
+pub fn pane_id_of(info: &PaneInfo) -> PaneId {
+ if info.is_plugin {
+ PaneId::Plugin(info.id)
+ } else {
+ PaneId::Terminal(info.id)
+ }
+}