diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-06-10 16:50:55 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-10 16:50:55 +0200 |
| commit | b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch) | |
| tree | 38c77a381cfb937b428752a588c09a45c81434c9 /default-plugins/mobile/src/components | |
| parent | feat: PWA support for the web client (#5184) (diff) | |
| download | sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.gz sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.xz sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.zip | |
Mobile layout (#5241)
* phase 2+3
* initial mobile plugin (phases 4 + 5 + 6)
* some bugfixes
* implement stage 7
* mobile interface improvements
* improve mobile pane/tab picker
* more ui improvements and fixes
* various mobile fixes
* some mobile fixes
* mobile keyboard triggering fix
* some cleanups
* initial plugin keyboard implementation
* improve keyboard hit zones
* improve keyboard
* pinch resize
* pinching fixes
* implement scrolling + panning
* initial keyboard resizing
* fix: mobile first render issues
* keyboard responsiveness
* fix some issues with the keyboard
* implement fit
* fix mobile scrolling
* add tilde cell to compact
* improve mobile keyboard
* fix starting mobile terminal font to natural keyboard size
* fix mobile resize blank screen flash
* shadow focus for mobile
* more keyboard fixes
* improve mobile status bar
* fix: render plugins to mobile clients
* allow opening new panes/tabs from the mobile plugin
* allow configuring
* fix dot slash
* improve mobile native soft keyboard interaction
* switch to native keyboard - initial implementation
* dirty hacks, but phone keyboard works now
* adjust keyboard help strip
* fix cursor in mobile
* fix keyboard and help line
* rename keyboard to modifier bar
* allow starting new session
* make clicking session name bring us to new session menu
* allow switching to desktop from the mobile ui
* mobile welcome screen initial implementation
* mobile welcome screen fixes
* various welcome screen fixes
* more mobile fixes
* moar mobile fixes
* mobile ui redesign
* fix: swallowed backspace bug
* switch session screen improvement
* touch up some interfaces
* refactor: move mobile state to own struct
* refactor: move fit resizing to the server
* remove dead flash code
* some cleanups
* refactor: clear fit on close
* refactor: improve fit api
* refactor: pane sync functions
* refactor: mouse
* refactor: break into modules
* some cleanups for the sessions screen
* fix scrolling and other stuff
* refactor: sessions screen
* refactor: panes screen
* refactor: new session screen
* refactor: menu screen
* refactor: render
* cleanups
* refactor: plugin structure
* remove unused welcome screen path
* Rename api
* refactor input.js
* cleanup useless comments
* fit/mobile refactoring
* take out the garbage
* remove more comments
* remove mobile flash on startup/attach
* fix: various mobile startup issues
* fix space bugs
* cleanup
* rustfmt
* fix windows build
* adjust modifier bar ui
* add pr
Diffstat (limited to 'default-plugins/mobile/src/components')
| -rw-r--r-- | default-plugins/mobile/src/components/mod.rs | 2 | ||||
| -rw-r--r-- | default-plugins/mobile/src/components/modifier_bar.rs | 681 | ||||
| -rw-r--r-- | default-plugins/mobile/src/components/top_bar.rs | 412 |
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 ®ions { + assert_eq!(r.row_start, 5); + assert_eq!(r.row_end, 6); + assert_eq!(r.col_start, cursor); + cursor = r.col_end; + } + assert_eq!(cursor, 90); + } + + #[test] + fn render_modifier_bar_tiles_at_compact_width() { + let mods = KeyboardModifiers::default(); + let mut regions = Vec::new(); + render_modifier_bar(&mods, 0, 30, &mut regions); + assert_eq!(regions.len(), BAR_CELL_COUNT); + regions.sort_by_key(|r| r.col_start); + let mut cursor = 0usize; + for r in ®ions { + assert_eq!(r.col_start, cursor); + cursor = r.col_end; + } + assert_eq!(cursor, 30); + } + + #[test] + fn render_modifier_bar_drops_minus_at_narrow() { + let mods = KeyboardModifiers::default(); + let mut regions = Vec::new(); + render_modifier_bar(&mods, 0, 21, &mut regions); + assert_eq!(regions.len(), 8); + for r in ®ions { + if let ClickAction::Keyboard(id) = r.action { + assert_ne!(id, CELL_MINUS); + } + } + } + + #[test] + fn render_modifier_bar_drops_minus_and_tab_at_minimum() { + let mods = KeyboardModifiers::default(); + let mut regions = Vec::new(); + render_modifier_bar(&mods, 0, 17, &mut regions); + assert_eq!(regions.len(), 7); + for r in ®ions { + if let ClickAction::Keyboard(id) = r.action { + assert_ne!(id, CELL_MINUS); + assert_ne!(id, CELL_TAB); + } + } + } + + #[test] + fn render_modifier_bar_noop_when_too_narrow() { + let mods = KeyboardModifiers::default(); + let mut regions = Vec::new(); + render_modifier_bar(&mods, 0, 16, &mut regions); + assert!(regions.is_empty()); + } +} diff --git a/default-plugins/mobile/src/components/top_bar.rs b/default-plugins/mobile/src/components/top_bar.rs new file mode 100644 index 00000000..c873c2e6 --- /dev/null +++ b/default-plugins/mobile/src/components/top_bar.rs @@ -0,0 +1,412 @@ +use std::ops::Range; +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +use crate::ansi::pad_or_truncate; +use crate::click::{ClickAction, ClickRegion}; +use crate::frame::Frame; +use crate::screens::ActiveScreen; +use crate::workspace::Workspace; + +const PREFIX: &str = "Zellij "; +const HAMBURGER: &str = "\u{2630}"; +const SESSION_PANE_SEP: &str = " "; + +const HAMBURGER_SLOP_CELLS: usize = 3; + +pub(crate) fn render( + ws: &Workspace, + frame: &mut Frame, + active: ActiveScreen, + row: usize, + cols: usize, +) { + let pane_name = current_pane_name(ws); + let session_name = ws.session_name.clone(); + let content_max = cols.saturating_sub(HAMBURGER_SLOP_CELLS + width(HAMBURGER)); + let plan = Plan::compute( + session_name.as_deref().map(width), + width(&pane_name), + width(PREFIX), + width(SESSION_PANE_SEP), + content_max, + ); + + let mut bar = BarBuilder::default(); + if plan.show_prefix { + bar.push(PREFIX); + } + + let session_seg = session_name + .as_ref() + .filter(|_| plan.session_target > 0) + .map(|session| { + let seg = bar.push(&pad_or_truncate(session, plan.session_target)); + bar.push(SESSION_PANE_SEP); + seg + }); + + let pane_seg = bar.push(&pad_or_truncate(&pane_name, plan.pane_target)); + let pane_end = bar.cells; + + let pad = cols + .saturating_sub(bar.cells + width(HAMBURGER)) + .max(HAMBURGER_SLOP_CELLS); + bar.push(&" ".repeat(pad)); + + let hamburger_start = bar.cells; + let hamburger_seg = bar.push(HAMBURGER); + + paint( + &bar.text, + row, + cols, + &pane_seg, + &hamburger_seg, + session_seg.as_ref(), + ); + + let (pane_action, session_action) = actions_for(active); + let session_cells = session_seg.map(|s| (s.cells.start, s.cells.end)); + for region in click_regions( + row, + cols, + pane_end, + hamburger_start, + pane_action, + session_cells, + session_action, + ) { + frame.click_regions.push(region); + } +} + +fn paint( + bar: &str, + row: usize, + cols: usize, + pane_seg: &Segment, + hamburger_seg: &Segment, + session_seg: Option<&Segment>, +) { + let mut text = Text::new(bar) + .selected() + .color_range(2, pane_seg.chars.clone()) + .color_range(3, hamburger_seg.chars.clone()); + if let Some(seg) = session_seg { + text = text.color_range(0, seg.chars.clone()); + } + print_text_with_coordinates(text, 0, row, Some(cols), None); +} + +fn actions_for(active: ActiveScreen) -> (ClickAction, ClickAction) { + if active == ActiveScreen::Viewport { + (ClickAction::ExpandPanes, ClickAction::ExpandSessions) + } else { + (ClickAction::CollapseSelector, ClickAction::CollapseSelector) + } +} + +fn current_pane_name(ws: &Workspace) -> String { + ws.current_pane() + .map(|p| { + if p.title.is_empty() { + format!("#{}", p.id) + } else { + p.title.clone() + } + }) + .unwrap_or_else(|| "—".to_string()) +} + +fn width(s: &str) -> usize { + UnicodeWidthStr::width(s) +} + +#[derive(Default)] +struct BarBuilder { + text: String, + chars: usize, + cells: usize, +} + +struct Segment { + chars: Range<usize>, + cells: Range<usize>, +} + +impl BarBuilder { + fn push(&mut self, s: &str) -> Segment { + let chars = self.chars..self.chars + s.chars().count(); + let cells = self.cells..self.cells + width(s); + self.text.push_str(s); + self.chars = chars.end; + self.cells = cells.end; + Segment { chars, cells } + } +} + +struct Plan { + show_prefix: bool, + session_target: usize, + pane_target: usize, +} + +impl Plan { + fn compute( + session_w: Option<usize>, + pane_w: usize, + prefix_w: usize, + sep_w: usize, + content_max: usize, + ) -> Self { + let Some(session_w) = session_w else { + return Plan { + show_prefix: true, + session_target: 0, + pane_target: pane_w.min(content_max.saturating_sub(prefix_w)), + }; + }; + if prefix_w + session_w + sep_w + pane_w <= content_max { + return Plan { + show_prefix: true, + session_target: session_w, + pane_target: pane_w, + }; + } + if session_w + sep_w + pane_w <= content_max { + return Plan { + show_prefix: false, + session_target: session_w, + pane_target: pane_w, + }; + } + let available = content_max.saturating_sub(sep_w); + let half = available / 2; + let (session_target, pane_target) = if session_w <= half { + (session_w, available.saturating_sub(session_w)) + } else if pane_w <= half { + (available.saturating_sub(pane_w), pane_w) + } else { + (half, available.saturating_sub(half)) + }; + Plan { + show_prefix: false, + session_target, + pane_target, + } + } +} + +pub fn click_regions( + row: usize, + cols: usize, + pane_end: usize, + hamburger_start: usize, + pane_action: ClickAction, + session_cells: Option<(usize, usize)>, + session_action: ClickAction, +) -> Vec<ClickRegion> { + let mut regions = Vec::with_capacity(5); + push_content_regions( + &mut regions, + row, + pane_end, + session_cells, + pane_action, + session_action, + ); + + regions.push(ClickRegion::tight( + row, + hamburger_start, + cols, + ClickAction::ToggleMenu, + )); + let hamburger_center = (hamburger_start.min(cols.saturating_sub(1)), row); + regions.push(ClickRegion::slop( + row, + pane_end, + cols, + ClickAction::ToggleMenu, + hamburger_center, + )); + regions +} + +fn push_content_regions( + regions: &mut Vec<ClickRegion>, + row: usize, + pane_end: usize, + session_cells: Option<(usize, usize)>, + pane_action: ClickAction, + session_action: ClickAction, +) { + let Some((session_start, session_end)) = clamp_session(session_cells, pane_end) else { + if pane_end > 0 { + regions.push(ClickRegion::tight(row, 0, pane_end, pane_action)); + } + return; + }; + if session_start > 0 { + regions.push(ClickRegion::tight( + row, + 0, + session_start, + pane_action.clone(), + )); + } + regions.push(ClickRegion::tight( + row, + session_start, + session_end, + session_action, + )); + if session_end < pane_end { + regions.push(ClickRegion::tight(row, session_end, pane_end, pane_action)); + } +} + +fn clamp_session(session_cells: Option<(usize, usize)>, pane_end: usize) -> Option<(usize, usize)> { + let (start, end) = session_cells?; + let (start, end) = (start.min(pane_end), end.min(pane_end)); + (start < end).then_some((start, end)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::State; + + #[test] + fn partition_with_slop() { + let cols = 80; + let pane_end = 40; + let hamburger_start = 79; + let regions = click_regions( + 0, + cols, + pane_end, + hamburger_start, + ClickAction::ExpandPanes, + None, + ClickAction::ExpandSessions, + ); + + assert_eq!(regions.len(), 3); + assert!(matches!(regions[0].action, ClickAction::ExpandPanes)); + assert_eq!(regions[0].priority, 0); + assert_eq!(regions[0].col_start, 0); + assert_eq!(regions[0].col_end, pane_end); + assert!(matches!(regions[1].action, ClickAction::ToggleMenu)); + assert_eq!(regions[1].priority, 0); + assert_eq!(regions[1].col_start, hamburger_start); + assert_eq!(regions[1].col_end, cols); + assert!(matches!(regions[2].action, ClickAction::ToggleMenu)); + assert_eq!(regions[2].priority, 1); + assert_eq!(regions[2].col_start, pane_end); + assert_eq!(regions[2].col_end, cols); + + let mut state = State::default(); + state.frame.click_regions = regions.clone(); + assert_eq!( + state.frame.click_to_action(0, 0), + Some(ClickAction::ExpandPanes) + ); + assert_eq!( + state.frame.click_to_action(0, pane_end + 5), + Some(ClickAction::ToggleMenu), + ); + assert_eq!( + state.frame.click_to_action(0, hamburger_start), + Some(ClickAction::ToggleMenu) + ); + } + + #[test] + fn pane_action_collapses_in_selector_mode() { + let regions = click_regions( + 0, + 80, + 40, + 79, + ClickAction::CollapseSelector, + None, + ClickAction::CollapseSelector, + ); + assert!(matches!(regions[0].action, ClickAction::CollapseSelector)); + let mut state = State::default(); + state.frame.click_regions = regions; + assert_eq!( + state.frame.click_to_action(0, 0), + Some(ClickAction::CollapseSelector) + ); + } + + #[test] + fn session_sub_region_dispatches_expand_sessions() { + let regions = click_regions( + 0, + 80, + 17, + 79, + ClickAction::ExpandPanes, + Some((7, 11)), + ClickAction::ExpandSessions, + ); + + assert_eq!(regions.len(), 5); + assert_eq!((regions[0].col_start, regions[0].col_end), (0, 7)); + assert!(matches!(regions[0].action, ClickAction::ExpandPanes)); + assert_eq!((regions[1].col_start, regions[1].col_end), (7, 11)); + assert!(matches!(regions[1].action, ClickAction::ExpandSessions)); + assert_eq!((regions[2].col_start, regions[2].col_end), (11, 17)); + assert!(matches!(regions[2].action, ClickAction::ExpandPanes)); + + let mut state = State::default(); + state.frame.click_regions = regions; + assert_eq!( + state.frame.click_to_action(0, 3), + Some(ClickAction::ExpandPanes) + ); + assert_eq!( + state.frame.click_to_action(0, 9), + Some(ClickAction::ExpandSessions) + ); + assert_eq!( + state.frame.click_to_action(0, 11), + Some(ClickAction::ExpandPanes) + ); + assert_eq!( + state.frame.click_to_action(0, 14), + Some(ClickAction::ExpandPanes) + ); + assert_eq!( + state.frame.click_to_action(0, 30), + Some(ClickAction::ToggleMenu) + ); + assert_eq!( + state.frame.click_to_action(0, 79), + Some(ClickAction::ToggleMenu) + ); + } + + #[test] + fn session_at_left_edge_skips_empty_prefix_region() { + let regions = click_regions( + 0, + 40, + 11, + 39, + ClickAction::ExpandPanes, + Some((0, 4)), + ClickAction::ExpandSessions, + ); + + assert_eq!(regions.len(), 4); + assert_eq!((regions[0].col_start, regions[0].col_end), (0, 4)); + assert!(matches!(regions[0].action, ClickAction::ExpandSessions)); + assert_eq!((regions[1].col_start, regions[1].col_end), (4, 11)); + assert!(matches!(regions[1].action, ClickAction::ExpandPanes)); + } +} |
