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
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
|
//! The session: a tree, a screen, and where the focus and the caret are.
//!
//! This is the whole backend minus the terminal. It paints into a grid, takes
//! keys and clicks by name, and answers events — so the entire widget layer,
//! keyboard navigation included, runs in a test with no TTY, no raw mode and no
//! display. `tui_headless` opens exactly this and nothing else.
//!
//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a
//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller
//! synthesising one for a test writes the name directly.
use crate::keys;
use crate::paint::{self, Painted};
use crate::screen::Screen;
use crate::tree::{Tag, Tree, Value};
pub struct Ui {
pub tree: Tree,
pub screen: Screen,
/// The node the focus ring is on, 0 for none.
focus: u32,
/// The caret in the focused entry, in characters from the start.
caret: usize,
painted: Painted,
tick: u64,
quit: bool,
}
impl Ui {
pub fn new(width: u16, height: u16) -> Self {
Self {
tree: Tree::new(),
screen: Screen::new(width.max(1), height.max(1)),
focus: 0,
caret: 0,
painted: Painted::default(),
tick: 0,
quit: false,
}
}
pub fn resize(&mut self, width: u16, height: u16) {
self.screen.resize(width.max(1), height.max(1));
}
pub fn should_close(&self) -> bool {
self.quit
}
pub fn quit(&mut self) {
self.quit = true;
}
pub fn focus(&self) -> u32 {
self.focus
}
pub fn cursor(&self) -> Option<(u16, u16)> {
self.painted.cursor
}
/// Paint one frame, then settle the things painting decided: what is
/// focusable now, and how far each scroll area really is.
pub fn frame(&mut self) {
self.tick = self.tick.wrapping_add(1);
self.paint_once();
if self.settle_focus() {
// Focus is decided by what the paint found, so the frame that
// gives it away has to be drawn again — otherwise the first frame
// of a screen shows nothing focused and the second one does.
self.paint_once();
}
for (node, offset) in self.painted.scrolled.clone() {
// Painting clamps the viewport to the content; write the clamped
// value back so the caller's next `+1` starts from the truth.
if self.tree.props(node).cells("offset", 0) != offset {
self.tree.set(node, "offset", Value::Num(offset as f64));
}
}
}
fn paint_once(&mut self) {
self.painted = paint::frame(
&self.tree,
&mut self.screen,
self.focus,
self.caret,
self.tick,
);
}
/// Put the focus somewhere real. Answers whether it moved.
fn settle_focus(&mut self) -> bool {
let was = self.focus;
// A focused widget that has since been unmounted — or dimmed — leaves
// the ring, and focus lands on the first thing that is still there
// rather than on nothing.
if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
self.focus = 0;
}
if self.focus == 0 {
let wants = self
.painted
.ring
.iter()
.find(|id| self.tree.props(**id).bool("autofocus", false))
.copied();
if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
self.set_focus(id);
}
}
self.focus != was
}
fn set_focus(&mut self, id: u32) {
if self.focus == id {
return;
}
self.focus = id;
// The caret goes to the end of whatever it just entered, which is where
// someone tabbing into a field with text in it expects to type.
self.caret = self.tree.props(id).str("text").chars().count();
}
fn move_focus(&mut self, forward: bool) {
if self.painted.ring.is_empty() {
return;
}
let ring = self.painted.ring.clone();
let at = ring.iter().position(|id| *id == self.focus);
let next = match (at, forward) {
(Some(i), true) => (i + 1) % ring.len(),
(Some(i), false) => (i + ring.len() - 1) % ring.len(),
(None, true) => 0,
(None, false) => ring.len() - 1,
};
self.set_focus(ring[next]);
}
// ── keys ────────────────────────────────────────────────────────────────
/// Handle one key by name. Answers false when nothing here wanted it, in
/// which case it has been emitted as a `key` event for the caller to route.
pub fn key(&mut self, name: &str) -> bool {
if matches!(name, "ctrl+c" | "ctrl+q") {
self.quit = true;
return true;
}
match name {
"tab" => {
self.move_focus(true);
return true;
}
"shift+tab" | "backtab" => {
self.move_focus(false);
return true;
}
"esc" => {
// Esc belongs to the topmost overlay when there is one: that is
// what closes a modal everywhere else.
if let Some(overlay) = self.topmost_overlay() {
self.tree.emit(overlay, "close", String::new(), 0.0);
return true;
}
}
_ => {}
}
let focus = self.focus;
let handled = match self.tree.tag(focus) {
Tag::Entry => self.entry_key(focus, name),
Tag::Button => self.activate_key(focus, name, "click"),
Tag::CheckButton => {
if matches!(name, "enter" | "space") {
self.toggle(focus);
true
} else {
false
}
}
Tag::Listbox => self.listbox_key(focus, name),
_ => false,
};
if !handled {
// Unhandled keys go to the caller as an event on the focused node,
// or on the window when nothing has focus. glimmer bubbles from
// there; it holds the handlers and knows the tree.
let target = if focus != 0 { focus } else { self.tree.root() };
self.tree.emit(target, "key", name.to_owned(), 0.0);
}
handled
}
fn topmost_overlay(&self) -> Option<u32> {
fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
if matches!(tree.tag(id), Tag::Overlay) {
*found = Some(id);
}
for child in tree.children(id) {
walk(tree, child, found);
}
}
let mut found = None;
walk(&self.tree, self.tree.root(), &mut found);
found
}
fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
if matches!(name, "enter" | "space") {
self.tree.emit(node, event, String::new(), 0.0);
true
} else {
false
}
}
fn toggle(&mut self, node: u32) {
let now = !self.tree.props(node).bool("active", false);
// The widget does not own its value, but it does keep working when the
// caller ignores the event: the new state is written back here, and the
// next prop write from the reconciler is what settles it.
self.tree.set(node, "active", Value::Bool(now));
self.tree
.emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
}
fn entry_key(&mut self, node: u32, name: &str) -> bool {
let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
let mut at = self.caret.min(text.len());
let mut changed = false;
match name {
"enter" => {
let now: String = text.iter().collect();
self.tree.emit(node, "activate", now, 0.0);
return true;
}
"left" | "ctrl+b" => at = at.saturating_sub(1),
"right" | "ctrl+f" => at = (at + 1).min(text.len()),
"home" | "ctrl+a" => at = 0,
"end" | "ctrl+e" => at = text.len(),
"alt+b" => at = keys::word_left(&text, at),
"alt+f" => at = keys::word_right(&text, at),
"backspace" => {
if at > 0 {
text.remove(at - 1);
at -= 1;
changed = true;
}
}
"delete" | "ctrl+d" => {
if at < text.len() {
text.remove(at);
changed = true;
}
}
"ctrl+w" | "alt+backspace" => {
let from = keys::word_left(&text, at);
if from < at {
text.drain(from..at);
at = from;
changed = true;
}
}
"ctrl+u" => {
if at > 0 {
text.drain(0..at);
at = 0;
changed = true;
}
}
"ctrl+k" => {
if at < text.len() {
text.truncate(at);
changed = true;
}
}
"space" => {
text.insert(at, ' ');
at += 1;
changed = true;
}
other => {
// A single character with no modifier on it is text.
let mut chars = other.chars();
match (chars.next(), chars.next()) {
(Some(ch), None) if !ch.is_control() => {
text.insert(at, ch);
at += 1;
changed = true;
}
_ => return false,
}
}
}
self.caret = at;
if changed {
let now: String = text.iter().collect();
self.tree.set(node, "text", Value::Str(now.clone()));
self.tree.emit(node, "change", now, 0.0);
}
true
}
fn listbox_key(&mut self, node: u32, name: &str) -> bool {
let count = self.tree.child_count(node) as i64;
if count == 0 {
return false;
}
let page = self
.painted
.hits
.iter()
.find(|(id, _)| *id == node)
.map_or(1, |(_, rect)| rect.h.max(1) as i64);
let at = self.tree.props(node).num("selected", 0.0) as i64;
let to = match name {
"down" | "j" | "ctrl+n" => at + 1,
"up" | "k" | "ctrl+p" => at - 1,
"page-down" | "ctrl+d" => at + page,
"page-up" | "ctrl+u" => at - page,
"home" | "g" => 0,
"end" | "G" => count - 1,
"enter" | "space" => {
let index = at.clamp(0, count - 1);
let item = self.tree.child_at(node, index as usize);
let label = self.tree.props(item).label().to_owned();
self.tree.emit(node, "activate", label, index as f64);
return true;
}
_ => return false,
};
self.select(node, to.clamp(0, count - 1));
true
}
fn select(&mut self, node: u32, index: i64) {
if self.tree.props(node).num("selected", -1.0) as i64 == index {
return;
}
self.tree.set(node, "selected", Value::Num(index as f64));
let item = self.tree.child_at(node, index as usize);
let label = self.tree.props(item).label().to_owned();
self.tree.emit(node, "select", label, index as f64);
}
// ── mouse ───────────────────────────────────────────────────────────────
/// A click at a cell. Focuses whatever is under it and activates it, which
/// is the whole of button 1 in a terminal: there is no press and release to
/// tell apart at this level.
pub fn click(&mut self, x: u16, y: u16) -> bool {
let Some((node, rect)) = self
.painted
.hits
.iter()
.find(|(_, rect)| rect.contains(x, y))
.copied()
else {
return false;
};
self.set_focus(node);
match self.tree.tag(node) {
Tag::Button => self.tree.emit(node, "click", String::new(), 0.0),
Tag::CheckButton => self.toggle(node),
Tag::Listbox => {
let row = (y - rect.y) as i64;
let count = self.tree.child_count(node) as i64;
if count > 0 {
self.select(node, row.clamp(0, count - 1));
}
}
Tag::Entry => {
// Put the caret where it was clicked, not at the end.
let text = self.tree.props(node).str("text").chars().count();
self.caret = ((x - rect.x) as usize).min(text);
}
_ => {}
}
true
}
/// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
/// under the pointer, which is the one a reader means.
pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
return false;
};
let now = self.tree.props(node).cells("offset", 0) as i32;
let to = (now + by).max(0) as f64;
self.tree.set(node, "offset", Value::Num(to));
self.tree.emit(node, "scroll", String::new(), to);
true
}
/// The innermost `:scroll` whose painted area holds this cell.
fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
for child in self.tree.children(id) {
if let Some(inner) = self.scroll_at(child, x, y) {
return Some(inner);
}
}
// Scroll areas take no focus, so they are not in the hit list; the
// frame records the ones it painted, which is enough for a wheel.
let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id);
if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
{
return Some(id);
}
None
}
}
|