diff options
Diffstat (limited to 'src/client/shell')
| -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]"); + } +} |
