//! 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 { fn walk(tree: &Tree, id: u32, found: &mut Option) { 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 = 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 { 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 } }