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 | |
| 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')
| -rw-r--r-- | src/client/shell/config.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/render.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/tabs.rs | 15 | ||||
| -rw-r--r-- | src/client/shell/tabs_tmux.rs | 252 |
5 files changed, 271 insertions, 1 deletions
diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index bfa1fb2f..9884e534 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -120,6 +120,7 @@ impl ClientShellConfig { sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, mobile_width_threshold: config.ui.mobile_width_threshold, tab_bar_position: config.ui.tab_bar_position, + tab_style: config.ui.tab_style, hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, spaces: config.ui.sidebar.spaces.clone(), agents: config.ui.sidebar.agents.clone(), @@ -322,6 +323,7 @@ impl ClientShellConfig { self.sidebar_collapsed_mode = ui.sidebar_collapsed_mode; self.mobile_width_threshold = ui.mobile_width_threshold; self.tab_bar_position = ui.tab_bar_position; + self.tab_style = ui.tab_style; self.hide_tab_bar_when_single_tab = ui.hide_tab_bar_when_single_tab; self.spaces = ui.sidebar.spaces.clone(); self.agents = ui.sidebar.agents.clone(); diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 248216c1..f3629486 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -6,6 +6,8 @@ mod overlays; pub(in crate::client::shell) mod sidebar; #[path = "../shell/tabs.rs"] mod tabs; +#[path = "../shell/tabs_tmux.rs"] +mod tabs_tmux; pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel}; pub(super) use super::aggregate_navigation::navigator_rows as client_navigator_rows; diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index c170c9b0..0e6da441 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -20,6 +20,7 @@ pub(crate) struct ClientShellConfig { pub(super) sidebar_collapsed_mode: SidebarCollapsedModeConfig, pub(super) mobile_width_threshold: u16, pub(super) tab_bar_position: TabBarPositionConfig, + pub(super) tab_style: crate::config::TabStyleConfig, pub(super) hide_tab_bar_when_single_tab: bool, pub(super) spaces: SpacesSidebarConfig, pub(super) agents: crate::config::AgentsSidebarConfig, diff --git a/src/client/shell/tabs.rs b/src/client/shell/tabs.rs index 3fdcf7dc..a8e584d4 100644 --- a/src/client/shell/tabs.rs +++ b/src/client/shell/tabs.rs @@ -21,14 +21,24 @@ pub(crate) fn render_tab_bar( .iter() .filter(|tab| Some(tab.workspace_id.as_str()) == snapshot.focused_workspace_id.as_deref()) .collect::<Vec<_>>(); + // tmuxrdr: ui.tab_style = "tmux" draws tmux's status line (tabs_tmux.rs). + let tmux = (config.tab_style == crate::config::TabStyleConfig::Tmux) + .then(|| super::tabs_tmux::TmuxTabs::new(snapshot, &tabs)); let desired_widths = tabs .iter() - .map(|tab| { + .enumerate() + .map(|(index, tab)| { + if let Some(tmux) = &tmux { + return tmux.width(index); + } let label = tab_label(tab); display_width(&label).saturating_add(4).max(MIN_TAB_WIDTH) }) .collect::<Vec<_>>(); let content = tab_bar_content_area(snapshot, area); + let content = tmux.as_ref().map_or(content, |tmux| { + tmux.render_session(buffer, content, palette) + }); let mouse_chrome = config.mouse_capture; let new_tab_width = if mouse_chrome { NEW_TAB_WIDTH } else { 0 }; let desired_total = desired_widths @@ -122,6 +132,9 @@ pub(crate) fn render_tab_bar( left = left as usize, right_padding = padding.saturating_sub(left) as usize, ); + let (text, style) = tmux + .as_ref() + .map_or((text, style), |tmux| tmux.cell(index, tab, palette)); put_text(buffer, rect.x, rect.y, rect.width, &text, style); hits.tabs.push((rect, tab.tab_id.clone())); first_visible.get_or_insert(index); 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]"); + } +} |
