1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
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]");
}
}
|