aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tabs_tmux.rs
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-05 15:45:17 +0300
committerIgor Soarez <igor@soarez.org>2026-10-05 15:45:17 +0300
commit9a12d7fb0eae23c016d3e941a16765084fbf0eaa (patch)
tree1ad3a3584fffdf6ed317b766ffb99684a1465510 /src/client/shell/tabs_tmux.rs
parent10fb1ab5ac9091e59957e97c3e75ea6bc288b8c5 (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.rs252
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]");
+ }
+}