nandi/jolt-nativepublic Fork 0
3cfee15a9d938584f526f606f853771eaad7f56c
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

ui.rs · 411 lines · 14.4 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago1//! The session: a tree, a screen, and where the focus and the caret are.
2//!
3//! This is the whole backend minus the terminal. It paints into a grid, takes
4//! keys and clicks by name, and answers events — so the entire widget layer,
5//! keyboard navigation included, runs in a test with no TTY, no raw mode and no
6//! display. `tui_headless` opens exactly this and nothing else.
7//!
8//! Keys arrive already named (`"ctrl+u"`, `"page-down"`, `"a"`); turning a
9//! terminal's bytes into those names is [`crate::keys`]'s job, and a caller
10//! synthesising one for a test writes the name directly.
11
12use crate::keys;
13use crate::paint::{self, Painted};
14use crate::screen::Screen;
15use crate::tree::{Tag, Tree, Value};
16
17pub struct Ui {
18 pub tree: Tree,
19 pub screen: Screen,
20 /// The node the focus ring is on, 0 for none.
21 focus: u32,
22 /// The caret in the focused entry, in characters from the start.
23 caret: usize,
24 painted: Painted,
25 tick: u64,
26 quit: bool,
27}
28
29impl Ui {
30 pub fn new(width: u16, height: u16) -> Self {
31 Self {
32 tree: Tree::new(),
33 screen: Screen::new(width.max(1), height.max(1)),
34 focus: 0,
35 caret: 0,
36 painted: Painted::default(),
37 tick: 0,
38 quit: false,
39 }
40 }
41
42 pub fn resize(&mut self, width: u16, height: u16) {
43 self.screen.resize(width.max(1), height.max(1));
44 }
45
46 pub fn should_close(&self) -> bool {
47 self.quit
48 }
49
50 pub fn quit(&mut self) {
51 self.quit = true;
52 }
53
54 pub fn focus(&self) -> u32 {
55 self.focus
56 }
57
58 pub fn cursor(&self) -> Option<(u16, u16)> {
59 self.painted.cursor
60 }
61
62 /// Paint one frame, then settle the things painting decided: what is
63 /// focusable now, and how far each scroll area really is.
64 pub fn frame(&mut self) {
65 self.tick = self.tick.wrapping_add(1);
66 self.paint_once();
67 if self.settle_focus() {
68 // Focus is decided by what the paint found, so the frame that
69 // gives it away has to be drawn again — otherwise the first frame
70 // of a screen shows nothing focused and the second one does.
71 self.paint_once();
72 }
73 for (node, offset) in self.painted.scrolled.clone() {
74 // Painting clamps the viewport to the content; write the clamped
75 // value back so the caller's next `+1` starts from the truth.
76 if self.tree.props(node).cells("offset", 0) != offset {
77 self.tree.set(node, "offset", Value::Num(offset as f64));
78 }
79 }
80 }
81
82 fn paint_once(&mut self) {
83 self.painted = paint::frame(
84 &self.tree,
85 &mut self.screen,
86 self.focus,
87 self.caret,
88 self.tick,
89 );
90 }
91
92 /// Put the focus somewhere real. Answers whether it moved.
93 fn settle_focus(&mut self) -> bool {
94 let was = self.focus;
95 // A focused widget that has since been unmounted — or dimmed — leaves
96 // the ring, and focus lands on the first thing that is still there
97 // rather than on nothing.
98 if self.focus != 0 && !self.painted.ring.contains(&self.focus) {
99 self.focus = 0;
100 }
101 if self.focus == 0 {
102 let wants = self
103 .painted
104 .ring
105 .iter()
106 .find(|id| self.tree.props(**id).bool("autofocus", false))
107 .copied();
108 if let Some(id) = wants.or_else(|| self.painted.ring.first().copied()) {
109 self.set_focus(id);
110 }
111 }
112 self.focus != was
113 }
114
115 fn set_focus(&mut self, id: u32) {
116 if self.focus == id {
117 return;
118 }
119 self.focus = id;
120 // The caret goes to the end of whatever it just entered, which is where
121 // someone tabbing into a field with text in it expects to type.
122 self.caret = self.tree.props(id).str("text").chars().count();
123 }
124
125 fn move_focus(&mut self, forward: bool) {
126 if self.painted.ring.is_empty() {
127 return;
128 }
129 let ring = self.painted.ring.clone();
130 let at = ring.iter().position(|id| *id == self.focus);
131 let next = match (at, forward) {
132 (Some(i), true) => (i + 1) % ring.len(),
133 (Some(i), false) => (i + ring.len() - 1) % ring.len(),
134 (None, true) => 0,
135 (None, false) => ring.len() - 1,
136 };
137 self.set_focus(ring[next]);
138 }
139
140 // ── keys ────────────────────────────────────────────────────────────────
141
142 /// Handle one key by name. Answers false when nothing here wanted it, in
143 /// which case it has been emitted as a `key` event for the caller to route.
144 pub fn key(&mut self, name: &str) -> bool {
145 if matches!(name, "ctrl+c" | "ctrl+q") {
146 self.quit = true;
147 return true;
148 }
149 match name {
150 "tab" => {
151 self.move_focus(true);
152 return true;
153 }
154 "shift+tab" | "backtab" => {
155 self.move_focus(false);
156 return true;
157 }
158 "esc" => {
159 // Esc belongs to the topmost overlay when there is one: that is
160 // what closes a modal everywhere else.
161 if let Some(overlay) = self.topmost_overlay() {
162 self.tree.emit(overlay, "close", String::new(), 0.0);
163 return true;
164 }
165 }
166 _ => {}
167 }
168
169 let focus = self.focus;
170 let handled = match self.tree.tag(focus) {
171 Tag::Entry => self.entry_key(focus, name),
172 Tag::Button => self.activate_key(focus, name, "click"),
173 Tag::CheckButton => {
174 if matches!(name, "enter" | "space") {
175 self.toggle(focus);
176 true
177 } else {
178 false
179 }
180 }
181 Tag::Listbox => self.listbox_key(focus, name),
182 _ => false,
183 };
184 if !handled {
185 // Unhandled keys go to the caller as an event on the focused node,
186 // or on the window when nothing has focus. glimmer bubbles from
187 // there; it holds the handlers and knows the tree.
188 let target = if focus != 0 { focus } else { self.tree.root() };
189 self.tree.emit(target, "key", name.to_owned(), 0.0);
190 }
191 handled
192 }
193
194 fn topmost_overlay(&self) -> Option<u32> {
195 fn walk(tree: &Tree, id: u32, found: &mut Option<u32>) {
196 if matches!(tree.tag(id), Tag::Overlay) {
197 *found = Some(id);
198 }
199 for child in tree.children(id) {
200 walk(tree, child, found);
201 }
202 }
203 let mut found = None;
204 walk(&self.tree, self.tree.root(), &mut found);
205 found
206 }
207
208 fn activate_key(&mut self, node: u32, name: &str, event: &'static str) -> bool {
209 if matches!(name, "enter" | "space") {
210 self.tree.emit(node, event, String::new(), 0.0);
211 true
212 } else {
213 false
214 }
215 }
216
217 fn toggle(&mut self, node: u32) {
218 let now = !self.tree.props(node).bool("active", false);
219 // The widget does not own its value, but it does keep working when the
220 // caller ignores the event: the new state is written back here, and the
221 // next prop write from the reconciler is what settles it.
222 self.tree.set(node, "active", Value::Bool(now));
223 self.tree
224 .emit(node, "toggled", String::new(), if now { 1.0 } else { 0.0 });
225 }
226
227 fn entry_key(&mut self, node: u32, name: &str) -> bool {
228 let mut text: Vec<char> = self.tree.props(node).str("text").chars().collect();
229 let mut at = self.caret.min(text.len());
230 let mut changed = false;
231 match name {
232 "enter" => {
233 let now: String = text.iter().collect();
234 self.tree.emit(node, "activate", now, 0.0);
235 return true;
236 }
237 "left" | "ctrl+b" => at = at.saturating_sub(1),
238 "right" | "ctrl+f" => at = (at + 1).min(text.len()),
239 "home" | "ctrl+a" => at = 0,
240 "end" | "ctrl+e" => at = text.len(),
241 "alt+b" => at = keys::word_left(&text, at),
242 "alt+f" => at = keys::word_right(&text, at),
243 "backspace" => {
244 if at > 0 {
245 text.remove(at - 1);
246 at -= 1;
247 changed = true;
248 }
249 }
250 "delete" | "ctrl+d" => {
251 if at < text.len() {
252 text.remove(at);
253 changed = true;
254 }
255 }
256 "ctrl+w" | "alt+backspace" => {
257 let from = keys::word_left(&text, at);
258 if from < at {
259 text.drain(from..at);
260 at = from;
261 changed = true;
262 }
263 }
264 "ctrl+u" => {
265 if at > 0 {
266 text.drain(0..at);
267 at = 0;
268 changed = true;
269 }
270 }
271 "ctrl+k" => {
272 if at < text.len() {
273 text.truncate(at);
274 changed = true;
275 }
276 }
277 "space" => {
278 text.insert(at, ' ');
279 at += 1;
280 changed = true;
281 }
282 other => {
283 // A single character with no modifier on it is text.
284 let mut chars = other.chars();
285 match (chars.next(), chars.next()) {
286 (Some(ch), None) if !ch.is_control() => {
287 text.insert(at, ch);
288 at += 1;
289 changed = true;
290 }
291 _ => return false,
292 }
293 }
294 }
295 self.caret = at;
296 if changed {
297 let now: String = text.iter().collect();
298 self.tree.set(node, "text", Value::Str(now.clone()));
299 self.tree.emit(node, "change", now, 0.0);
300 }
301 true
302 }
303
304 fn listbox_key(&mut self, node: u32, name: &str) -> bool {
305 let count = self.tree.child_count(node) as i64;
306 if count == 0 {
307 return false;
308 }
309 let page = self
310 .painted
311 .hits
312 .iter()
313 .find(|(id, _)| *id == node)
314 .map_or(1, |(_, rect)| rect.h.max(1) as i64);
315 let at = self.tree.props(node).num("selected", 0.0) as i64;
316 let to = match name {
317 "down" | "j" | "ctrl+n" => at + 1,
318 "up" | "k" | "ctrl+p" => at - 1,
319 "page-down" | "ctrl+d" => at + page,
320 "page-up" | "ctrl+u" => at - page,
321 "home" | "g" => 0,
322 "end" | "G" => count - 1,
323 "enter" | "space" => {
324 let index = at.clamp(0, count - 1);
325 let item = self.tree.child_at(node, index as usize);
326 let label = self.tree.props(item).label().to_owned();
327 self.tree.emit(node, "activate", label, index as f64);
328 return true;
329 }
330 _ => return false,
331 };
332 self.select(node, to.clamp(0, count - 1));
333 true
334 }
335
336 fn select(&mut self, node: u32, index: i64) {
337 if self.tree.props(node).num("selected", -1.0) as i64 == index {
338 return;
339 }
340 self.tree.set(node, "selected", Value::Num(index as f64));
341 let item = self.tree.child_at(node, index as usize);
342 let label = self.tree.props(item).label().to_owned();
343 self.tree.emit(node, "select", label, index as f64);
344 }
345
346 // ── mouse ───────────────────────────────────────────────────────────────
347
348 /// A click at a cell. Focuses whatever is under it and activates it, which
349 /// is the whole of button 1 in a terminal: there is no press and release to
350 /// tell apart at this level.
351 pub fn click(&mut self, x: u16, y: u16) -> bool {
352 let Some((node, rect)) = self
353 .painted
354 .hits
355 .iter()
356 .find(|(_, rect)| rect.contains(x, y))
357 .copied()
358 else {
359 return false;
360 };
361 self.set_focus(node);
362 match self.tree.tag(node) {
363 Tag::Button => self.tree.emit(node, "click", String::new(), 0.0),
364 Tag::CheckButton => self.toggle(node),
365 Tag::Listbox => {
366 let row = (y - rect.y) as i64;
367 let count = self.tree.child_count(node) as i64;
368 if count > 0 {
369 self.select(node, row.clamp(0, count - 1));
370 }
371 }
372 Tag::Entry => {
373 // Put the caret where it was clicked, not at the end.
374 let text = self.tree.props(node).str("text").chars().count();
375 self.caret = ((x - rect.x) as usize).min(text);
376 }
377 _ => {}
378 }
379 true
380 }
381
382 /// The wheel, `by` rows — negative is up. It moves the innermost `:scroll`
383 /// under the pointer, which is the one a reader means.
384 pub fn wheel(&mut self, x: u16, y: u16, by: i32) -> bool {
385 let Some(node) = self.scroll_at(self.tree.root(), x, y) else {
386 return false;
387 };
388 let now = self.tree.props(node).cells("offset", 0) as i32;
389 let to = (now + by).max(0) as f64;
390 self.tree.set(node, "offset", Value::Num(to));
391 self.tree.emit(node, "scroll", String::new(), to);
392 true
393 }
394
395 /// The innermost `:scroll` whose painted area holds this cell.
396 fn scroll_at(&self, id: u32, x: u16, y: u16) -> Option<u32> {
397 for child in self.tree.children(id) {
398 if let Some(inner) = self.scroll_at(child, x, y) {
399 return Some(inner);
400 }
401 }
402 // Scroll areas take no focus, so they are not in the hit list; the
403 // frame records the ones it painted, which is enough for a wheel.
404 let painted = self.painted.scrolled.iter().any(|(n, _)| *n == id);
405 if painted && matches!(self.tree.tag(id), Tag::Scroll) && self.screen.rect().contains(x, y)
406 {
407 return Some(id);
408 }
409 None
410 }
411}