diff options
Diffstat (limited to 'src/client/shell/tabs_tmux.rs')
| -rw-r--r-- | src/client/shell/tabs_tmux.rs | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/src/client/shell/tabs_tmux.rs b/src/client/shell/tabs_tmux.rs new file mode 100644 index 00000000..76e49742 --- /dev/null +++ b/src/client/shell/tabs_tmux.rs @@ -0,0 +1,252 @@ +//! tmuxrdr: the tab row drawn the way tmux draws its status line, for +//! `ui.tab_style = "tmux"`. tmux 3.7c's defaults, as `tmux show -g` reports +//! them: status-left "[#{session_name}] " cut to status-left-length 10; +//! window-status-format "#I:#W#{?window_flags,#{window_flags}, }"; one space +//! between windows; left-justified; plain text in the status line's colours. +//! +//! herdr's render_tab_bar (tabs.rs) keeps the layout, scrolling, mouse hits +//! and the + button, and asks this module for each tab's width, text and +//! style, and to draw the session at the left -- three one-line hooks there. +//! +//! #I is the tab's place in the tab bar, from 1 (base-index 1, as this +//! machine's tmux.conf sets it). #W is the window name: a tab named by hand +//! keeps its name, any other is named after its active pane's directory, as +//! the tmux.conf has it (automatic-rename-format '#{b:pane_current_path}'). +//! The server sends only the pane focused overall, not each tab's active one, +//! so this remembers the pane each tab last had focused while it was shown, +//! and falls back to the tab's first pane. It also remembers each workspace's +//! previous tab, for tmux's last-window flag "-". All of it lives in this +//! client process and needs nothing from the server, so it works against a +//! stock herdr server as well. + +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +use unicode_width::UnicodeWidthChar; + +use crate::protocol::ClientShellPane; + +use super::*; + +/// tmux's status-left-length default. +const STATUS_LEFT_LENGTH: u16 = 10; + +/// What this client has seen, across renders. Keyed by herdr's ids, which are +/// unique within a server; a client showing several machines could see the +/// same id twice, and would then mix their history -- a flag, not a failure. +#[derive(Default)] +struct Seen { + /// workspace -> the tab last seen focused in it + current: HashMap<String, String>, + /// workspace -> the tab focused before that: tmux's last window + last: HashMap<String, String>, + /// tab -> the pane last seen focused in it + active_pane: HashMap<String, String>, +} + +impl Seen { + fn observe(&mut self, snapshot: &ClientShellSnapshot) { + let (Some(workspace), Some(tab)) = ( + snapshot.focused_workspace_id.as_ref(), + snapshot.focused_tab_id.as_ref(), + ) else { + return; + }; + if let Some(previous) = self.current.insert(workspace.clone(), tab.clone()) { + if previous != *tab { + self.last.insert(workspace.clone(), previous); + } + } + if let Some(pane) = snapshot.focused_pane_id.as_ref() { + self.active_pane.insert(tab.clone(), pane.clone()); + } + } +} + +static SEEN: LazyLock<Mutex<Seen>> = LazyLock::new(Default::default); + +/// The tab row's labels for one render, in the order render_tab_bar has its +/// tabs, and the session text for the left. +pub(super) struct TmuxTabs { + labels: Vec<String>, + session: String, +} + +impl TmuxTabs { + pub(super) fn new(snapshot: &ClientShellSnapshot, tabs: &[&ClientShellTab]) -> Self { + let mut seen = SEEN.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + seen.observe(snapshot); + let workspace = snapshot.focused_workspace_id.as_deref(); + let last = workspace.and_then(|workspace| seen.last.get(workspace)); + let labels = tabs + .iter() + .enumerate() + .map(|(index, tab)| { + let name = if tab.custom_label { + tab.label.clone() + } else { + window_name(snapshot, tab, &seen).unwrap_or_else(|| tab.label.clone()) + }; + window_status( + index + 1, + &name, + tab.focused, + last == Some(&tab.tab_id), + tab.zoomed, + ) + }) + .collect(); + let session_name = snapshot + .workspaces + .iter() + .find(|candidate| Some(candidate.workspace_id.as_str()) == workspace) + .map_or("", |candidate| candidate.label.as_str()); + Self { + labels, + session: truncate_cells(&format!("[{session_name}] "), STATUS_LEFT_LENGTH), + } + } + + pub(super) fn width(&self, index: usize) -> u16 { + display_width(&self.labels[index]) + } + + pub(super) fn cell( + &self, + index: usize, + tab: &ClientShellTab, + palette: &Palette, + ) -> (String, Style) { + // tmux's status line: no fill of its own, the current window brighter. + let fg = if tab.focused { + palette.text + } else { + palette.overlay1 + }; + ( + self.labels[index].clone(), + Style::default().fg(fg).bg(palette.panel_bg), + ) + } + + /// Draws the session at the left of `content` and returns what is left. + pub(super) fn render_session( + &self, + buffer: &mut Buffer, + content: Rect, + palette: &Palette, + ) -> Rect { + let width = display_width(&self.session).min(content.width); + put_text( + buffer, + content.x, + content.y, + width, + &self.session, + Style::default().fg(palette.overlay1).bg(palette.panel_bg), + ); + Rect { + x: content.x.saturating_add(width), + width: content.width.saturating_sub(width), + ..content + } + } +} + +/// tmux's "#I:#W#{?window_flags,#{window_flags}, }": its flags in tmux's +/// order -- current "*" or last "-", then zoomed "Z" -- or a space when none. +fn window_status(index: usize, name: &str, current: bool, last: bool, zoomed: bool) -> String { + let mut flags = String::new(); + if current { + flags.push('*'); + } else if last { + flags.push('-'); + } + if zoomed { + flags.push('Z'); + } + if flags.is_empty() { + flags.push(' '); + } + format!("{index}:{name}{flags}") +} + +/// #{b:pane_current_path} for a tab: its active pane's foreground directory, +/// basename only. +fn window_name( + snapshot: &ClientShellSnapshot, + tab: &ClientShellTab, + seen: &Seen, +) -> Option<String> { + let remembered = if tab.focused { + snapshot.focused_pane_id.as_deref() + } else { + seen.active_pane.get(&tab.tab_id).map(String::as_str) + }; + let in_tab = |pane: &&ClientShellPane| pane.tab_id == tab.tab_id; + let pane = remembered + .and_then(|id| { + snapshot + .panes + .iter() + .filter(in_tab) + .find(|pane| pane.pane_id == id) + }) + .or_else(|| snapshot.panes.iter().find(in_tab))?; + let dir = pane.foreground_cwd.as_deref().or(pane.cwd.as_deref())?; + Some(basename(dir)) +} + +/// basename(3): the last component; "/" for the root. +fn basename(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + return "/".into(); + } + trimmed.rsplit('/').next().unwrap_or(trimmed).to_string() +} + +/// The longest prefix of `text` that fits in `cells` columns, as tmux cuts +/// status-left. +fn truncate_cells(text: &str, cells: u16) -> String { + let mut used = 0_u16; + let mut out = String::new(); + for ch in text.chars() { + let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if used + width > cells { + break; + } + used += width; + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::{basename, truncate_cells, window_status}; + + #[test] + fn window_status_matches_tmux_default_format() { + assert_eq!(window_status(1, "zsh", true, false, false), "1:zsh*"); + assert_eq!(window_status(2, "vim", false, true, false), "2:vim-"); + assert_eq!(window_status(3, "logs", false, false, false), "3:logs "); + assert_eq!(window_status(4, "x", true, false, true), "4:x*Z"); + assert_eq!(window_status(5, "x", false, true, true), "5:x-Z"); + assert_eq!(window_status(6, "x", false, false, true), "6:xZ"); + } + + #[test] + fn basename_is_tmux_b_modifier() { + assert_eq!(basename("/Users/sz/x"), "x"); + assert_eq!(basename("/private/tmp/"), "tmp"); + assert_eq!(basename("/"), "/"); + } + + #[test] + fn session_is_cut_to_status_left_length() { + assert_eq!(truncate_cells("[0] ", 10), "[0] "); + assert_eq!(truncate_cells("[delta-channels] ", 10), "[delta-cha"); + assert_eq!(truncate_cells("[日本語ab] ", 10), "[日本語ab]"); + } +} |
