//! 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::entry::View; use crate::keys; use crate::layout; use crate::paint::{self, Painted}; use crate::screen::Screen; use crate::tree::{Tag, Tree, Value}; use std::collections::HashMap; /// Where one scroll area is, and whether it is following its own bottom. /// /// It lives here rather than in the tree because a re-render clears a node's /// props: glimmer writes what the component said and nothing else, which is /// right — the component's state is the truth — and it means a viewport that /// kept its position in a prop loses it the moment anything above it changes. /// A chat backlog changes on every message, which is exactly when a reader /// cares where they were. #[derive(Clone, Copy)] struct Scrolled { offset: u16, /// Following the bottom. A `:stick-to-bottom` viewport starts this way, /// stops when the reader scrolls up, and starts again when they come back /// down — which is the behaviour that lets a new message arrive without /// dragging the screen out from under someone reading history. pinned: bool, } 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, /// The row the focused entry is scrolled to, in rows of its own text. entry_top: usize, /// The column up and down are aiming for. /// /// Walking a caret down through rows of different lengths and back up has /// to come home to the column it left, so the column is remembered until /// something other than up or down moves the caret. Without it a step /// through a short row drags the caret to that row's end and leaves it /// there, which is the thing that makes an editor feel broken. goal: Option, painted: Painted, tick: u64, quit: bool, /// Scroll positions by `:scroll-key`, across re-renders. scrolls: HashMap, } 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, entry_top: 0, goal: None, painted: Painted::default(), tick: 0, quit: false, scrolls: HashMap::new(), } } 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 } /// The pictures the last frame wants on screen, for whoever can draw one. pub fn images(&self) -> &[crate::graphics::Placement] { &self.painted.images } /// 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.restore_scrolls(self.tree.root()); 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, max, _area) 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)); } // And remember it under its key, which is what survives the // re-render that is about to clear the prop. Being at the bottom // is what pins it there: a reader who scrolls back down has said // they want to follow again, and never has to say so twice. let key = self.scroll_key(node); let sticky = self.tree.props(node).bool("stick-to-bottom", false); self.scrolls.insert( key, Scrolled { offset, pinned: sticky && offset >= max, }, ); } } /// What a scroll area is remembered by. Its `:scroll-key` when it has one, /// because that is a name the caller chose and means the same viewport /// after a re-mount; its handle otherwise, which at least survives a /// re-render that leaves the node where it was. fn scroll_key(&self, node: u32) -> String { let props = self.tree.props(node); let key = props.str("scroll-key"); if key.is_empty() { format!("#{node}") } else { key.to_owned() } } /// Put every scroll area back where it was before the tree is painted. /// /// A pinned one is asked for an offset past the end and painting clamps it /// to the bottom, which is how it follows content that grew since the last /// frame without this having to measure anything. fn restore_scrolls(&mut self, id: u32) { if matches!(self.tree.tag(id), Tag::Scroll) { let key = self.scroll_key(id); let sticky = self.tree.props(id).bool("stick-to-bottom", false); let to = match self.scrolls.get(&key) { Some(state) if sticky && state.pinned => u16::MAX, Some(state) => state.offset, // Never seen: a sticky viewport opens at the bottom, which for // a backlog is the message that just arrived. None if sticky => u16::MAX, None => return, }; self.tree.set(id, "offset", Value::Num(to as f64)); } for child in self.tree.children(id) { self.restore_scrolls(child); } } fn paint_once(&mut self) { self.painted = paint::frame( &self.tree, &mut self.screen, self.focus, self.caret, self.entry_top, self.tick, ); self.entry_top = self.painted.entry_top; } /// 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(); self.goal = None; // And the new field is scrolled to wherever that put it, not to // wherever the last one happened to be. self.entry_top = usize::MAX; } 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; } // Before the focused widget is asked: a page is about the screen // rather than about whatever is being typed into, and an entry // that ignored these left them going out as an event nobody has a // handler for. "page-up" | "page-down" => { if self.page(name == "page-up") { 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 | Tag::Reaction => 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 }); } /// How the text in `node` sits in the cells it was painted into. /// /// The same layout the painter used, asked again rather than kept: it is a /// pure function of the text, the rect and the caret, and the alternative /// is two copies of the truth that drift apart on the frame where the text /// changed and the paint has not caught up. fn entry_view(&self, node: u32) -> View { let props = self.tree.props(node); let multiline = layout::entry_multiline(&props); let text = props.str("text").to_owned(); let pad = layout::inset(&self.tree.tag(node), &props); let (w, h) = self .painted .hits .iter() .find(|(id, _)| *id == node) .map(|(_, rect)| rect.shrink(pad)) .map_or((1, 1), |rect| (rect.w.max(1), rect.h.max(1))); let top = if node == self.focus { self.entry_top } else { usize::MAX }; View::of(&text, w, h, multiline, self.caret, top) } fn entry_key(&mut self, node: u32, name: &str) -> bool { let mut text: Vec = self.tree.props(node).str("text").chars().collect(); let multiline = layout::entry_multiline(&self.tree.props(node)); let mut at = self.caret.min(text.len()); let mut changed = false; // Only up and down keep the column they were aiming for; everything // else here has said where it wants the caret. let mut keep_goal = false; match name { "enter" => { let now: String = text.iter().collect(); self.tree.emit(node, "activate", now, 0.0); return true; } // A newline where Enter is spoken for. Shift+Enter is what every // chat box takes; Alt+Enter and Ctrl+J are the two a terminal that // cannot tell Shift+Enter from Enter still can. "shift+enter" | "alt+enter" | "ctrl+j" if multiline => { text.insert(at, '\n'); at += 1; changed = true; } "up" | "ctrl+p" | "down" | "ctrl+n" if multiline => { let up = matches!(name, "up" | "ctrl+p"); let view = self.entry_view(node); let (row, col) = view.caret_at(at); // Off the top of the first row, or the bottom of the last, is // not this field's key: it is a reader trying to leave. if (up && row == 0) || (!up && row + 1 >= view.lines.len()) { return false; } let goal = self.goal.unwrap_or(col).max(col); let to = if up { row - 1 } else { row + 1 }; at = view.at_col(to, goal); self.goal = Some(goal); keep_goal = true; } "left" | "ctrl+b" => at = at.saturating_sub(1), "right" | "ctrl+f" => at = (at + 1).min(text.len()), // Home and End are about the row the caret is on, which in a field // of one row is the whole of the text. "home" | "ctrl+a" => at = self.entry_view(node).caret_row(at).0, "end" | "ctrl+e" => at = self.entry_view(node).caret_row(at).1, "ctrl+home" => at = 0, "ctrl+end" if multiline => 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" => { let from = self.entry_view(node).caret_row(at).0; if from < at { text.drain(from..at); at = from; changed = true; } } "ctrl+k" => { let to = self.entry_view(node).caret_row(at).1; if to > at { text.drain(at..to); changed = true; } else if multiline && at < text.len() && text[at] == '\n' { // At the end of a row already: the kill takes the break, // which is how a line is joined to the one below it. text.remove(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 !keep_goal { self.goal = None; } 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) { // A pill is pressed the way a button is: the caller's `:on-click` // is what puts a reaction on or takes it off again. Tag::Button | Tag::Reaction => 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, on the row that was // clicked: the field knows where its own characters were // painted, so a click in the middle of the third wrapped row // is the character in the middle of the third wrapped row. let props = self.tree.props(node); let pad = layout::inset(&Tag::Entry, &props); let inner = rect.shrink(pad); let view = self.entry_view(node); self.caret = view.hit(x.saturating_sub(inner.x), y.saturating_sub(inner.y)); self.goal = None; } _ => {} } 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; }; self.scroll_by(node, by) } /// Page-up and page-down, for a reader with no pointer to point with. /// /// A terminal has no scrollbar to drag and the wheel is not on every desk, /// so these are the way back through a backlog; a page is the viewport /// less a row, which is the line that says where you were. /// /// The page belongs to the biggest thing on screen. There is no pointer to /// aim with and the focus is rarely inside the list — in frq it is the /// compose entry, under a backlog nobody would call the smaller half of /// the screen — so area is the question, and the reading list wins it. fn page(&mut self, up: bool) -> bool { let Some((node, height)) = self .painted .scrolled .iter() .max_by_key(|(_, _, _, area)| (area.w as u32) * (area.h as u32)) .map(|(node, _, _, area)| (*node, area.h)) else { return false; }; let rows = height.saturating_sub(1).max(1) as i32; self.scroll_by(node, if up { -rows } else { rows }) } /// Move one `:scroll` by `by` rows, and remember where that put it. /// /// Where it is now comes from what was remembered under its key rather /// than from the node, because the caller may have re-rendered it since /// the last frame: a render clears a node's props and sets them again, and /// a scroll caught between the two reads as an offset of zero. That is how /// a page-down a moment after a message arrived answered with the top of /// the buffer — it was a page down from a list that had forgotten where it /// was. The prop is still written, for the paint that is about to read it. fn scroll_by(&mut self, node: u32, by: i32) -> bool { let key = self.scroll_key(node); let now = self .scrolls .get(&key) .map(|state| state.offset as i32) .unwrap_or_else(|| 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)); // Unpin on the way up, and let the next frame decide whether this put // the reader back at the bottom — painting is what knows how far down // that is. self.scrolls.insert( key, Scrolled { offset: to as u16, pinned: false, }, ); self.tree.emit(node, "scroll", String::new(), to); true } /// The innermost `:scroll` whose painted area holds this cell. /// /// Its own area, not the screen's. Asking whether the pointer was anywhere /// on the terminal is a question every scroll answers yes to, so the first /// one the walk reached took every wheel: on a wide screen that is the /// chats list, and a reader wheeling over the conversation beside it moved /// the sidebar instead — which reads as a backlog that will not scroll. 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, and where, which is enough for a // wheel. let painted = self .painted .scrolled .iter() .find(|(n, _, _, _)| *n == id) .map(|(_, _, _, area)| *area); if let Some(area) = painted { if matches!(self.tree.tag(id), Tag::Scroll) && area.contains(x, y) { return Some(id); } } None } }