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
253
254
255
256
//! What a tab looks like once it has been projected for display.
//!
//! This module holds the *data* that crosses the session→terminal boundary,
//! and deliberately nothing else. The layout and rendering that consume it
//! (#1669 PR-B / roadmap 16.2) land separately and depend on this, not the
//! other way round: the surface protocol must not have to know how a bar is
//! drawn in order to carry one.
//!
//! Why a projection rather than the live `TabSet`: the tabs are session state,
//! the bar is terminal chrome, and after the execution relocation (#1718)
//! those live on different threads. Sending a snapshot keeps the terminal from
//! reaching into a session's mutable state to draw a frame — the same reason
//! `set_runtime_context` sends values rather than lending a handle.
#![cfg_attr(not(feature = "rich-tui"), allow(dead_code))]
/// One tab, projected for rendering.
///
/// Labels are carried, never referenced: they are recomputed by the session
/// each loop head from the conversation store, so a `/rename` shows up on the
/// next prompt and a title cannot go stale on the far side of the channel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TabCell {
/// 1-based, what the operator types and what `<n>gt` means.
pub number: usize,
/// Freshly computed title, or `#shortid`.
pub label: String,
pub active: bool,
/// This tab's pin could not be established, so it is refusing turns.
/// Carried because the bar is the only always-visible surface, so a
/// degraded tab must be legible there and not only on the switch that
/// produced it.
pub degraded: bool,
/// Work arrived for an inactive tab since it was last visited.
pub pending: bool,
}
/// How many rows the bar wants: **0 or 1**, never more.
pub(crate) fn bar_rows(cells: &[TabCell]) -> u16 {
u16::from(cells.len() >= 2)
}
/// One cell's rendered text, e.g. `1:build`, `2!:deploy`, `3*:notes`.
fn cell_text(c: &TabCell) -> String {
let mut s = c.number.to_string();
if c.degraded {
s.push('!');
}
if c.pending && !c.active {
s.push('*');
}
s.push(':');
s.push_str(&c.label);
s
}
/// Which cells fit in `width`, as a window that always contains the active one.
///
/// Returns `(start, end)` as a half-open range over `cells`. Widening the
/// terminal can only ever show more, never fewer — the window grows left from
/// the active cell once the right side is exhausted, which is what makes
/// `A→B→A` land on the same view rather than drifting.
pub(crate) fn visible_window(cells: &[TabCell], width: u16) -> (usize, usize) {
if cells.is_empty() || width == 0 {
return (0, 0);
}
let active = cells.iter().position(|c| c.active).unwrap_or(0);
let w = width as usize;
let cost = |i: usize| cell_text(&cells[i]).chars().count() + 1; // + separator
// Always show the active cell, even if it alone overflows — truncation
// inside the cell is better than an empty bar.
let mut used = cost(active);
let (mut start, mut end) = (active, active + 1);
// Grow right first (reading order), then left.
loop {
let grew_right = end < cells.len() && used + cost(end) <= w;
if grew_right {
used += cost(end);
end += 1;
continue;
}
let grew_left = start > 0 && used + cost(start - 1) <= w;
if grew_left {
start -= 1;
used += cost(start);
continue;
}
break;
}
(start, end)
}
/// The bar's text for `width` columns, or `None` when it should not render.
///
/// `…` marks tabs scrolled off either side, so the operator can tell "3 tabs"
/// from "3 of 9".
pub(crate) fn layout_tab_cells(cells: &[TabCell], width: u16) -> Option<String> {
if bar_rows(cells) == 0 || width == 0 {
return None;
}
// The ellipses cost columns too. Reserve for them and re-window until the
// reservation stops changing — it converges in at most two passes, since
// `reserve` only ever takes 0, 1 or 2. Without this the line was built
// one-or-two columns too wide and the final clip ate the very marker that
// says "there are more tabs this way", which is the one character the
// operator most needs.
let mut reserve = 0u16;
let (start, end) = loop {
let (s, e) = visible_window(cells, width.saturating_sub(reserve));
let need = u16::from(s > 0) + u16::from(e < cells.len());
if need <= reserve {
break (s, e);
}
reserve = need;
};
let mut out = String::new();
if start > 0 {
out.push('…');
}
for (i, c) in cells[start..end].iter().enumerate() {
if i > 0 || start > 0 {
out.push(' ');
}
out.push_str(&cell_text(c));
}
if end < cells.len() {
out.push('…');
}
// A single over-wide cell can still exceed the width; clip rather than wrap,
// since wrapping would silently steal a second row from the input.
if out.chars().count() > width as usize {
out = out.chars().take(width as usize).collect();
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn cell(n: usize, label: &str, active: bool) -> TabCell {
TabCell {
number: n,
label: label.to_string(),
active,
degraded: false,
pending: false,
}
}
#[test]
fn a_single_tab_renders_no_row_at_all() {
let one = vec![cell(1, "solo", true)];
assert_eq!(bar_rows(&one), 0, "not an empty row — NO row");
assert_eq!(layout_tab_cells(&one, 80), None);
// And zero tabs is the same, defensively.
assert_eq!(bar_rows(&[]), 0);
assert_eq!(layout_tab_cells(&[], 80), None);
}
#[test]
fn two_tabs_render_one_row() {
let cells = vec![cell(1, "a", true), cell(2, "b", false)];
assert_eq!(bar_rows(&cells), 1);
assert_eq!(layout_tab_cells(&cells, 80).unwrap(), "1:a 2:b");
}
#[test]
fn the_active_cell_is_always_visible_however_narrow() {
let cells: Vec<TabCell> = (1..=9).map(|n| cell(n, "conversation", n == 9)).collect();
let line = layout_tab_cells(&cells, 20).unwrap();
assert!(
line.contains("9:"),
"a bar that can hide the tab you are looking at is worse than none: {line}"
);
// Even at width 1 the active cell wins over showing nothing.
let narrow = layout_tab_cells(&cells, 1).unwrap();
assert_eq!(narrow.chars().count(), 1);
}
#[test]
fn overflow_is_marked_on_the_side_it_happened() {
let cells: Vec<TabCell> = (1..=9).map(|n| cell(n, "xxxxx", n == 5)).collect();
let line = layout_tab_cells(&cells, 24).unwrap();
assert!(line.starts_with('…'), "tabs hidden to the left: {line}");
assert!(line.ends_with('…'), "tabs hidden to the right: {line}");
// First tab active → nothing hidden on the left.
let first: Vec<TabCell> = (1..=9).map(|n| cell(n, "xxxxx", n == 1)).collect();
let line = layout_tab_cells(&first, 24).unwrap();
assert!(!line.starts_with('…'), "{line}");
assert!(line.ends_with('…'), "{line}");
}
#[test]
fn the_line_never_exceeds_the_width() {
for width in 1u16..40 {
let cells: Vec<TabCell> = (1..=6)
.map(|n| cell(n, "a-long-conversation-title", n == 3))
.collect();
let line = layout_tab_cells(&cells, width).unwrap();
assert!(
line.chars().count() <= width as usize,
"width {width} overflowed with {line:?}"
);
}
}
#[test]
fn a_degraded_tab_is_marked_in_the_bar() {
let mut cells = vec![cell(1, "a", true), cell(2, "b", false)];
cells[1].degraded = true;
let line = layout_tab_cells(&cells, 80).unwrap();
assert!(line.contains("2!:b"), "degraded tabs carry `!`: {line}");
}
#[test]
fn a_pending_inactive_tab_is_badged_but_the_active_one_is_not() {
let mut cells = vec![cell(1, "a", true), cell(2, "b", false)];
cells[0].pending = true;
cells[1].pending = true;
let line = layout_tab_cells(&cells, 80).unwrap();
assert!(
line.contains("1:a"),
"the ACTIVE tab is never badged — you are looking at it: {line}"
);
assert!(line.contains("2*:b"), "{line}");
}
#[test]
fn widening_never_shows_fewer_tabs() {
let cells: Vec<TabCell> = (1..=8).map(|n| cell(n, "conv", n == 4)).collect();
let mut last = 0usize;
for width in 4u16..80 {
let (s, e) = visible_window(&cells, width);
let shown = e - s;
assert!(
shown >= last,
"widening from {} to {width} showed fewer tabs ({last} -> {shown})",
width - 1
);
last = shown;
}
}
#[test]
fn the_window_is_stable_for_a_given_active_tab_and_width() {
// Layout is a pure function of (cells, width): the same inputs must
// land the same view, which is what makes A→B→A return to the frame
// the operator left rather than drifting.
let cells: Vec<TabCell> = (1..=7).map(|n| cell(n, "conv", n == 5)).collect();
let a = layout_tab_cells(&cells, 30);
let b = layout_tab_cells(&cells, 30);
assert_eq!(a, b);
}
}