diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-05 15:45:17 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-05 15:45:17 +0300 |
| commit | 9a12d7fb0eae23c016d3e941a16765084fbf0eaa (patch) | |
| tree | 1ad3a3584fffdf6ed317b766ffb99684a1465510 /src/client/shell/tabs_tmux.rs | |
| parent | 10fb1ab5ac9091e59957e97c3e75ea6bc288b8c5 (diff) | |
tmuxrdr: tmux's status line as the tab row (ui.tab_style = "tmux")
herdr draws each tab as a centred, padded block, and a named tab shows its
name without its number. With ui.tab_style = "tmux" the row is tmux 3.7c's
default status line: "[session] " cut to status-left-length 10, then each
window as "#I:#W#{?window_flags,#{window_flags}, }" -- current "*", last
"-", zoomed "Z" -- one space apart, plain text in the status line's colours.
Unnamed tabs are called after their active pane's directory, like
automatic-rename-format '#{b:pane_current_path}'.
The logic is in src/client/shell/tabs_tmux.rs; render_tab_bar keeps its
layout, scrolling, mouse hits and + button, with three one-line hooks for
width, text and style, and the session at the left. Each tab's active pane
and each workspace's previous tab are remembered by the client, so nothing
changes in the protocol and the client works against a stock herdr server.
The default, herdr, draws as before.
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]"); + } +} |
