//! 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]"); } }