aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tabs_tmux.rs
diff options
context:
space:
mode:
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]");
+ }
+}