aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/shell/config.rs2
-rw-r--r--src/client/shell/render.rs2
-rw-r--r--src/client/shell/state.rs1
-rw-r--r--src/client/shell/tabs.rs15
-rw-r--r--src/client/shell/tabs_tmux.rs252
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]");
+ }
+}