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
//! **The tab strip** (DESIGN §4.44 item 2; bl-03c1): over the transcript, one
//! tab per conversation open in the aimed workspace.
//!
//! Each tab is the conversation's name — the asking `?` in the attention
//! accent before it while it asks (`crate::ui::convs::mark`), the selected one
//! in body ink over a brand hairline and the rest in weak ink — and a close
//! `×` in faint ink after it. The strip stands only while a tab stands: a wall
//! with nothing open is the transcript's own empty column.
//!
//! **Nothing here decides what is open.** The tabs are [`Model::tabs`], filled
//! by [`Model::select`] alone; a click on a tab IS that select, so it opens
//! nothing it was not already, and the close is [`Model::close_tab`].
use crate::reply::convs::ConvRow;
use crate::ui::{Model, theme};
/// What a tab is called: the row's name where the seat holds the row (the
/// listing's, or a start's claim before the listing knows it), else its id.
fn row_of(model: &Model, root_id: &str) -> Option<ConvRow> {
model
.pending()
.into_iter()
.chain(model.convs.iter().cloned())
.find(|row| row.root_id == root_id)
}
/// **Paint the strip**, if any tab of the aimed wall stands.
pub fn render(ui: &mut egui::Ui, model: &mut Model) {
let strip = model.strip();
if strip.is_empty() {
return;
}
let mut chosen = None;
let mut closed = None;
// **Wrapped, each run whole** (`extend`): a strip with more tabs than the
// width breaks between tabs, never inside a name.
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::XS;
for root_id in &strip {
let row = row_of(model, root_id);
let selected = model.conversation.as_deref() == Some(root_id.as_str());
// **Asking outranks nothing here but is said first** (§2, *the
// eye lands on green*): the switcher's own glyph and ink.
if let Some(row) = row.as_ref().filter(|row| row.attention > 0) {
let (glyph, ink) = crate::ui::convs::mark(row);
ui.add(egui::Label::new(egui::RichText::new(glyph).color(ink)).extend());
}
let name = row.map_or_else(|| root_id.clone(), |row| row.display);
let ink = if selected {
theme::INK
} else {
theme::INK_WEAK
};
let tab = ui
.add(
egui::Label::new(egui::RichText::new(&name).color(ink))
.selectable(false)
.extend()
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
// **Selecting reads its transcript and opens its follow lane**, as
// a switcher row's select does (`crate::ui::convs::row`).
crate::ui::act::tag(
&tab,
&[crate::verbs::TRANSCRIPT.word, crate::verbs::FOLLOW.word],
);
// **The hairline is a filled rect**, so the probe's fill walk can
// read it back (`crate::paint_probe::fills_of`).
if selected {
let y = tab.rect.max.y + theme::space::XXS;
let line = egui::Rect::from_x_y_ranges(tab.rect.x_range(), y..=y + 1.0);
ui.painter().rect_filled(line, 0.0, theme::BRAND);
}
if tab.clicked() {
chosen = Some(root_id.clone());
}
// **The close posts nothing**, so it wears no `act:` tag.
let close = ui
.add(
egui::Label::new(
egui::RichText::new(theme::glyph::CLOSE).color(theme::INK_FAINT),
)
.selectable(false)
.extend()
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if close.clicked() {
closed = Some(root_id.clone());
}
ui.add_space(theme::space::M);
}
});
ui.add_space(theme::space::XS);
if let Some(root_id) = chosen {
model.select(&root_id);
}
if let Some(root_id) = closed {
model.close_tab(&root_id);
}
}
#[cfg(test)]
mod tests;