From 9a12d7fb0eae23c016d3e941a16765084fbf0eaa Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Mon, 5 Oct 2026 15:45:17 +0300 Subject: 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. --- src/client/shell/config.rs | 2 + src/client/shell/render.rs | 2 + src/client/shell/state.rs | 1 + src/client/shell/tabs.rs | 15 ++- src/client/shell/tabs_tmux.rs | 252 ++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 3 + src/config/model.rs | 3 + src/config/tmuxrdr.rs | 16 +++ 8 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/client/shell/tabs_tmux.rs create mode 100644 src/config/tmuxrdr.rs 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::>(); + // 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::>(); 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, + /// workspace -> the tab focused before that: tmux's last window + last: HashMap, + /// tab -> the pane last seen focused in it + active_pane: HashMap, +} + +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> = 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, + 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 { + 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]"); + } +} diff --git a/src/config.rs b/src/config.rs index 8550097d..ad555246 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,9 @@ mod tab_bar; mod theme; mod window_title; mod write; +// tmuxrdr: its own options. +mod tmuxrdr; +pub use self::tmuxrdr::TabStyleConfig; pub use self::{ io::{ diff --git a/src/config/model.rs b/src/config/model.rs index 0e2e57ce..69ba27ca 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -984,6 +984,8 @@ pub struct UiConfig { pub hide_tab_bar_when_single_tab: bool, /// Desktop tab row placement. Default: top. pub tab_bar_position: TabBarPositionConfig, + /// tmuxrdr: how the tab row draws its tabs. Default: herdr. + pub tab_style: super::TabStyleConfig, /// Ordered entries shown at the right edge of the desktop tab row. Empty by default. pub tab_bar_right: Vec, /// Text inserted between visible right-side tab bar entries. Default: one space. @@ -1217,6 +1219,7 @@ impl Default for UiConfig { show_agent_labels_on_pane_borders: false, hide_tab_bar_when_single_tab: false, tab_bar_position: TabBarPositionConfig::Top, + tab_style: Default::default(), tab_bar_right: Vec::new(), tab_bar_right_separator: " ".into(), window_title: super::window_title::default_window_title(), diff --git a/src/config/tmuxrdr.rs b/src/config/tmuxrdr.rs new file mode 100644 index 00000000..3cd36642 --- /dev/null +++ b/src/config/tmuxrdr.rs @@ -0,0 +1,16 @@ +//! tmuxrdr's own config options. In a file of their own, so the patch to +//! herdr's config model stays a field and its default. + +use serde::{Deserialize, Serialize}; + +/// How the tab row draws its tabs: `ui.tab_style`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TabStyleConfig { + /// herdr's: centred, padded blocks; a named tab shows its name only. + #[default] + Herdr, + /// tmux's status line: `[session] 1:name* 2:name-`, plain text, one space + /// apart -- src/client/shell/tabs_tmux.rs. + Tmux, +} -- cgit v1.2.3