aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/mobile/src/components
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/components
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/components')
-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
3 files changed, 1095 insertions, 0 deletions
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));
+ }
+}