//! The half a webview host never writes. //! //! `quasi-axum` and `quasi-tauri` both answer a request with markup and stop. //! Everything between one request and the next — which control is under the //! caret, what the user has typed into it, what a key means, where the back //! button goes — is the browser's, and neither adapter contains a line of it. //! A terminal has no browser under it, so this is that half, written out. //! //! # It does not own the router //! //! [`Runtime`] turns keys into [`Request`]s and applies [`Response`]s, and it //! never calls a handler. The host holds the router and the state and does the //! calling, which keeps this free of the state type and makes every binding //! below testable without standing up an app. //! //! ```text //! key ──► Runtime::key ──► Step::Call(request) //! │ //! host: router.handle(&state, request) //! │ //! Runtime::apply ◄── Response //! ``` //! //! # The bindings are this renderer's, and the description reaches two of them //! //! Nothing in a description says what Tab does, so the table below is policy. //! The two exceptions are the two the vocabulary already carries: [`Act::key`] //! names the key that reaches a control, and [`Act::confirm`] names the question //! to ask before doing it. Both were drawn and declined by the drawing half, //! and this is where they are honoured. //! //! | Key | What it does | //! |---|---| //! | Tab, Down | the next reachable thing | //! | `BackTab`, Up | the previous one | //! | Enter | call what is under the caret | //! | Space | tick the row under the caret | //! | `PageUp`, `PageDown` | scroll the region the caret is in | //! | Backspace | take a character back out of a field | //! | printable | type into a field, or reach the control that named the key | //! | Escape | back, or dismiss the question | use makeover_layout as layout; use quasi_router::{ Action, Chrome, Message, Method, Node, Outcome, Params, Request, Response, Screen, Slot, }; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use crate::focus::{Reach, Spot}; use crate::{Tui, View}; /// A key, named the way this crate wants to talk about one. /// /// Not crossterm's, deliberately. A host maps its own events onto this in a /// dozen lines, and in exchange the bindings below are testable without a /// terminal and this crate does not make every consumer take a backend it might /// not be using. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Key { /// A character the user typed. Char(char), /// Confirm, follow, submit. Enter, /// Forward through the reachable things. Tab, /// Backward through them. BackTab, /// Take a character back. Backspace, /// Out, back, never mind. Escape, /// Up one reachable thing. Up, /// Down one reachable thing. Down, /// A screen's worth backwards. PageUp, /// A screen's worth forwards. PageDown, /// Back one child, in a region showing one at a time. Left, /// On one child, in a region showing one at a time. Right, } /// What the host should do about a key. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Step { /// Nothing left to do but redraw. Idle, /// Ask the router this, then hand the answer to [`Runtime::apply`]. Call(Request), /// Ask this question. The next key answers it: `y` or Enter does the thing, /// anything else does not. Ask(String), /// Somewhere outside the app. The host opens it, and nothing comes back. Open(String), } /// A screen and what the user has done to it. /// /// The pair is the unit an overlay needs: an overlay has its own reach, its own /// focus, its own edits and its own scroll, so it holds a [`View`] of its own /// rather than borrowing the one underneath. Sharing it is the bug the overlay /// tests exist to catch — dismissing a palette would take the user's typing and /// scroll position with it. #[derive(Debug, Clone)] struct Layer { screen: Screen, view: View, } /// A screen, what the user has done to it, and how they got here. #[derive(Debug, Clone)] pub struct Runtime { screen: Screen, view: View, /// What the app offers from every screen. Matched before this runtime's own /// key table, so a screen cannot capture the key that opens the palette. chrome: Chrome, /// The layers this one is drawn over, outermost first. /// /// Empty on an ordinary screen. `screen` and `view` above are always the /// ACTIVE layer, so every key, every gather and every draw works on the /// overlay once one is open, with no second code path. /// /// Separate from `history` on purpose: an overlay is not a place. Opening /// one pushes here and leaves history alone, and dismissing one pops here /// and reveals the screen the user never left. under: Vec, /// The places behind this one, most recent last. /// /// Requests rather than addresses, because going back means asking again /// and a request is what asking takes. [`Address`](quasi_router::Address) /// carries a string for a browser's address bar, which a terminal does not /// have. history: Vec, /// The request that produced the screen currently showing. here: Option, /// A control waiting on its own question being answered. asked: Option<(Action, Params)>, /// Something to say once the screen it belongs to has arrived. saying: Option, } impl Runtime { /// Start on this screen, with nothing typed and nothing behind it. #[must_use] pub fn new(screen: Screen) -> Self { let mut runtime = Self { screen, view: View::new(), chrome: Chrome::new(), under: Vec::new(), history: Vec::new(), here: None, asked: None, saying: None, }; runtime.view.seed(&runtime.screen); runtime } /// Declare what the app offers from every screen. /// /// Held beside the screen rather than arriving with one, which is what /// makes it chrome: the bindings outlive every answer this runtime applies. #[must_use] pub fn with_chrome(mut self, chrome: Chrome) -> Self { self.chrome = chrome; self } /// Whether an overlay is open over the screen. #[must_use] pub const fn overlaid(&self) -> bool { !self.under.is_empty() } /// The screen being shown. /// /// The overlay's, when one is open. That is what "being shown" means, and /// it is what every key this runtime handles is working on. #[must_use] pub const fn screen(&self) -> &Screen { &self.screen } /// What the user has done to it. #[must_use] pub const fn view(&self) -> &View { &self.view } /// Everything reachable on it, in focus order. #[must_use] pub fn reaches(&self) -> Vec { crate::focus::reaches(&self.screen) } /// Whether the caret is in a field, which is what decides whether a /// printable key is a shortcut or a character. /// /// A host wanting `q` to quit asks this first. Quitting is the host's and /// not a binding here, because a key that closes the app is a fact about /// the app rather than about the screen. #[must_use] pub fn editing(&self) -> bool { self.focused().is_some_and(|spot| spot.field().is_some()) } /// Whether a question is waiting to be answered. #[must_use] pub const fn asking(&self) -> bool { self.asked.is_some() } /// Draw it. pub fn draw(&self, tui: &Tui, area: Rect, buf: &mut Buffer) { // What is under it first, outermost first, then this one over the top. // An overlay that painted only itself would be a screen swap wearing // another name. for layer in &self.under { tui.screen(&layer.screen, &layer.view, area, buf); } let area = if self.under.is_empty() { area } else { let inset = Self::overlay_area(area); // Clear what is under it inside its own bounds, so the overlay // reads as being over the screen rather than mixed into it. for y in inset.top()..inset.bottom() { for x in inset.left()..inset.right() { buf[(x, y)].reset(); } } inset }; tui.screen(&self.screen, &self.view, area, buf); } /// Where an overlay sits inside the screen it is over. /// /// Inset on all four sides so the screen underneath stays visible around /// it, which is the whole visual claim an overlay makes. Proportional /// rather than fixed: a palette 4 rows from the edge of an 80x24 terminal /// is a different thing from one 4 rows from the edge of a 200x60. fn overlay_area(area: Rect) -> Rect { let pad_x = (area.width / 8) .max(1) .min(area.width.saturating_sub(2) / 2); let pad_y = (area.height / 8) .max(1) .min(area.height.saturating_sub(2) / 2); Rect { x: area.x + pad_x, y: area.y + pad_y, width: area.width.saturating_sub(pad_x * 2), height: area.height.saturating_sub(pad_y * 2), } } /// Put a message on the screen, from the host rather than from a route. /// /// The host has things to say that no handler knows about: a route that /// failed, an address it will not open, a device that is not there. Without /// this they would go to stderr, which on a terminal app is underneath the /// alternate screen and therefore nowhere. pub fn say(&mut self, text: impl Into) { self.screen.notices.push(Node::Notice { kind: layout::Notice::Banner, tone: layout::Tone::Danger, text: text.into(), }); } /// What is under the caret. #[must_use] pub fn focused(&self) -> Option { let mut reaches = crate::focus::reaches(&self.screen); if self.view.focus() >= reaches.len() { return None; } Some(reaches.swap_remove(self.view.focus()).spot) } /// Take a key, and say what the host should do about it. pub fn key(&mut self, key: Key) -> Step { // A question owns the keyboard until it is answered. Anything that is // not yes is no, which is the safe way round for a prompt that is only // ever raised by something destructive. if let Some((action, payload)) = self.asked.take() { return match key { // The selection was gathered when the question was raised, not // now. Nothing can tick while a prompt owns the keyboard, so // the two are the same set -- and reading it here would mean // the answer depended on state the user could not see. Key::Char('y' | 'Y') | Key::Enter => Self::send(&action, payload), _ => Step::Idle, }; } // The app's own keys, before this runtime's table and before any // screen's `Act::key`. An affordance available everywhere is not // available everywhere if a screen can capture its key. // // Not while typing: a field has the keyboard, and a binding on a // printable key would otherwise be unreachable as a character. A // binding naming a key no field can consume still lands. if !self.editing() || !matches!(key, Key::Char(_)) { let pressed = Self::key_name(key); if let Some(binding) = pressed.as_deref().and_then(|name| self.chrome.bound(name)) { return Self::call(&binding.action.clone()); } } let reaches = crate::focus::reaches(&self.screen); let count = reaches.len(); let here = reaches .get(self.view.focus()) .map(|reach| reach.spot.clone()); match key { Key::Tab | Key::Down => { self.view.advance(1, count); Step::Idle } Key::BackTab | Key::Up => { self.view.advance(-1, count); Step::Idle } Key::PageDown | Key::PageUp => { // The region the caret is in, because it is the one the user is // working in. A screen with focus nowhere scrolls nothing, // which is honest: there is no "the pane" on a screen with // several. if let Some(reach) = reaches.get(self.view.focus()) { let rows = if matches!(key, Key::PageDown) { 10 } else { -10 }; self.view.scroll_by(&reach.region, rows); } Step::Idle } Key::Left | Key::Right => { // The region the caret is in, when that region shows one child // at a time, and otherwise the first one on the screen that // does. The fallback is not a convenience: a carousel's frames // are pictures, so there is nothing reachable inside one and // focus can never be in it. Without this the one widget that // asked for these keys could not be reached by them. if let Some(slot) = self.moving(&reaches) { let steps = if matches!(key, Key::Right) { 1 } else { -1 }; let slot = slot.clone(); self.view.show_by(&slot, steps); } Step::Idle } // An overlay first: Escape closes what is on top before it goes // back, which is what Escape means everywhere else it is bound. Key::Escape => { if self.dismiss() { Step::Idle } else { self.back() } } Key::Backspace => { if let Some(field) = here.as_ref().and_then(Spot::field) { self.view.backspace(field); } Step::Idle } Key::Enter => match here { Some(Spot::Act { action, confirm, over, .. }) => { let payload = self.gathering(over.as_deref()); match confirm { Some(prompt) => { self.asked = Some((action, payload)); Step::Ask(prompt) } None => Self::send(&action, payload), } } Some(Spot::Submit { action, names }) => { let payload = self.view.submission( &names, &reaches .iter() .map(|reach| reach.spot.clone()) .collect::>(), ); Self::send(&action, payload) } // A field takes Enter and does nothing with it. A browser // submits the form around it, and doing that here would fire a // write from the first box the user finished typing in; the // submit is one Tab away and says what it does. Some(Spot::Field(_)) | None => Step::Idle, Some(other) => match other.enters() { Some(action) => Self::call(&action.clone()), None => Step::Idle, }, }, Key::Char(' ') if !self.editing() => match here { // A tick is a write when the description says it is, and // staged selection when it does not. `toggle` first, because a // row carrying one has said the tick *is* the write and that // claim beats the screen's set. Some(Spot::Row { toggle: Some(action), .. }) => Self::call(&action), // Otherwise it joins or leaves the set the screen names. The // hole `5f2b8753` was filed for was here: this used to be // `Step::Idle`, so the box was drawn, the key was bound, and // pressing it did nothing. // // Still idle when a row names no value or the screen holds no // set, which is the same description bug one step earlier. A // key bound to nothing is what this stopped doing, so it does // not start doing it again by accepting a tick that cannot be // read back. Some(Spot::Row { ticked: Some(_), value: Some(value), .. }) if self.screen.selection.is_some() => { self.view.tick(&value); Step::Idle } _ => Step::Idle, }, Key::Char(ch) => { if let Some(field) = here.as_ref().and_then(Spot::field).cloned() { self.type_into(&field, ch); return self.after_typing(here.as_ref()); } // Not in a field, so the key is a shortcut if any control on // the screen claimed it. `Act::key` is text rather than a // modelled chord, so this is a string comparison against what // the description wrote, and a name this renderer does not // understand simply never matches. let pressed = ch.to_string(); let claimed = reaches.iter().find_map(|reach| match &reach.spot { Spot::Act { action, key: Some(key), over, .. } if *key == pressed => Some((action.clone(), over.clone())), _ => None, }); match claimed { Some((action, over)) => { let payload = self.gathering(over.as_deref()); Self::send(&action, payload) } None => Step::Idle, } } } } /// Put what the router answered onto the screen. /// /// Answers with a follow-up request when the response says to go somewhere /// else, which the host performs the same way it performed the first one. /// `request` is what was asked, because whether an answer is a place is /// derived from it: a read that answered a whole screen is somewhere you /// can come back to, and a write is not. pub fn apply(&mut self, request: &Request, response: Response) -> Option { let Response { outcome, notice, address, invalidates, } = response; self.saying = notice.or(self.saying.take()); match outcome { // Invalidations are not applied to a whole screen, matching what an // HTTP host does with them and for the same reason: every region is // being replaced already, so naming one of them again says nothing // the new screen does not. // Over what is already there. The layer underneath is put away // whole -- its screen and the view holding everything the user did // to it -- and comes back untouched when the overlay is dismissed. // // `remember` is deliberately not called: an overlay is not a place, // so history is left exactly as it was and Escape from the overlay // reveals rather than navigates. Outcome::Over(screen) => { let under = Layer { screen: std::mem::replace(&mut self.screen, screen), view: std::mem::replace(&mut self.view, View::new()), }; self.under.push(under); self.view.seed(&self.screen); self.announce(); None } // A whole screen replaces everything, including any overlay open // over it. A route that answers with a screen is a navigation, and // navigating with a palette still floating over the destination is // the state nobody asked for. Outcome::Screen(screen) => { self.under.clear(); self.remember(request, address.as_ref()); self.screen = screen; self.view.reset(); // The rows a new screen says are already ticked. After this the // user's ticks are the truth, which is why it is applied once // on arrival rather than read on every draw. self.view.seed(&self.screen); self.announce(); None } Outcome::Fragment { region, node } => { // A region that is not there is the description bug // `Screen::replace` describes, and a terminal can say so // rather than swallowing it: the region it named is gone, and // drawing nothing would look like a control that does nothing. // // The slots the answer invalidated go in the same way. On a // terminal that is the whole of what invalidation means: the // next frame redraws everything, so putting the new contents // on the screen is putting them in front of the user. What a // webview needs an out-of-band swap for, this gets for free. let mut missing: Vec = Vec::new(); if !self.screen.replace(®ion, node) { missing.push(region); } for stale in invalidates { if !self.screen.replace(&stale.region, stale.node) { missing.push(stale.region); } } if !missing.is_empty() { // One message naming all of them, rather than a banner per // region where only the last would survive. let named = missing .iter() .map(|region| format!("`{region}`")) .collect::>() .join(", "); let subject = if missing.len() == 1 { "is" } else { "are" }; self.saying = Some(Message { kind: layout::Notice::Banner, tone: layout::Tone::Danger, text: format!("nothing on this screen {subject} called {named}"), undo: None, }); } self.view.prune(&self.screen); self.announce(); None } Outcome::Goto(action) => match Self::call(&action) { Step::Call(request) => Some(request), // An external destination is the host's to open, and there is // nothing to come back for. _ => None, }, } } /// Go back, if there is anywhere to go. /// A key as the text a [`Chrome`] binding names it by. /// /// The same string comparison `Act::key` gets, for the same reason: the /// vocabulary of keys is the host's, and this host's names are these. A /// modifier this renderer cannot receive is a name that never matches, /// which is what a binding for another host should do here. fn key_name(key: Key) -> Option { Some(match key { Key::Char(ch) => ch.to_string(), Key::Enter => "enter".into(), Key::Escape => "escape".into(), Key::Tab => "tab".into(), Key::BackTab => "backtab".into(), Key::Up => "up".into(), Key::Down => "down".into(), Key::PageUp => "pageup".into(), Key::PageDown => "pagedown".into(), Key::Backspace => "backspace".into(), Key::Left => "left".into(), Key::Right => "right".into(), }) } /// The region the arrow keys move, if the screen has one. /// /// The one the caret is in when that region shows one child at a time, and /// otherwise the first such region in draw order. Two rules rather than one /// because focus is not always a usable answer here: a carousel holds /// pictures, nothing in it is reachable, and a rule that only ever asked /// where the caret was would leave the widget that wanted these keys unable /// to be reached by them. /// /// A screen with two of these and no focus in either moves the first, which /// is arbitrary and is said out loud rather than hidden. Nothing in the tree /// has two yet; the screen that does is the one that will want a reachable /// control instead, and that is a `Spot` rather than a rule here. fn moving<'a>(&'a self, reaches: &[crate::focus::Reach]) -> Option<&'a Slot> { let here = reaches .get(self.view.focus()) .and_then(|reach| self.find(&reach.region)) .filter(|slot| slot.showing.selective()); here.or_else(|| self.screen.slots.iter().find_map(Self::selective)) } /// This slot or the first under it that shows one child at a time. fn selective(slot: &Slot) -> Option<&Slot> { if slot.showing.selective() { return Some(slot); } slot.body.iter().find_map(|node| match node { Node::Region(inner) => Self::selective(inner), _ => None, }) } /// The slot under this address, anywhere on the screen. fn find(&self, region: &str) -> Option<&Slot> { self.screen.slots.iter().find_map(|slot| slot.find(region)) } /// Close the overlay on top, if there is one. /// /// The layer underneath comes back exactly as it was left: its own focus, /// its own edits, its own scroll. That is the whole reason a layer carries /// its own [`View`], and history is not touched because an overlay was /// never a place. fn dismiss(&mut self) -> bool { match self.under.pop() { Some(layer) => { self.screen = layer.screen; self.view = layer.view; true } None => false, } } fn back(&mut self) -> Step { match self.history.pop() { Some(request) => { self.here = Some(request.clone()); Step::Call(request) } None => Step::Idle, } } /// Note where we were, before we leave it. /// /// The derivation the response's own documentation describes: a read that /// answered a screen is a place, everything else is not, and /// [`Address`](quasi_router::Address) is the override for the two cases the /// derivation cannot reach. fn remember(&mut self, request: &Request, address: Option<&quasi_router::Address>) { let place = match address { Some(quasi_router::Address::Enters(_)) => true, Some(quasi_router::Address::Unchanged) => false, Some(quasi_router::Address::Replaces(_)) => { self.here = Some(request.clone()); return; } None => request.method == Method::Get, }; if place && let Some(previous) = self.here.replace(request.clone()) { self.history.push(previous); } } /// Put whatever the response wanted said onto the screen it belongs to. fn announce(&mut self) { // A message's `undo` is dropped, and that is a decline rather than an // oversight: `Node::Notice` has nowhere to hang a control, so the way // back that the response offered has no cell to sit in. Filed. if let Some(Message { kind, tone, text, .. }) = self.saying.take() { self.screen.notices.push(Node::Notice { kind, tone, text }); } } /// Type into a field, honouring what the description says it will take. fn type_into(&mut self, field: &crate::FieldSpot, ch: char) { if matches!(field.kind, layout::FieldKind::Checkbox) { // A checkbox holds one of two values, so a key does not type into // it: any key flips it, which is what space does to one in a // browser and is the only sentence a box with two states can hear. let ticked = self.view.typed(field) == Node::SELECTED; let next = if ticked { String::new() } else { Node::SELECTED.to_string() }; self.view.set(&field.name, next); return; } // `Field::max_length` is a rule the description carries and every // renderer emits in its host's idiom. A browser stops accepting // characters, and so does this. if let Some(limit) = field.max_length && self.view.typed(field).chars().count() >= limit as usize { return; } self.view.push(field, ch); } /// What a keystroke in a field costs, when the field writes as it changes. fn after_typing(&mut self, here: Option<&Spot>) -> Step { match here.and_then(Spot::field).and_then(|field| { field .changes .clone() .map(|action| (action, field.name.clone())) }) { // A field that writes on every change writes on every keystroke // here, which is what `Field::changes` says and is wrong for a text // box: a webview debounces on `input` and nothing in the // description says a delay is allowed. Filed rather than debounced // to a number this renderer made up. Some((action, name)) => { let value = self.view.edit(&name).unwrap_or_default().to_string(); let payload = Params::new().with(name, value); Self::send(&action, payload) } None => Step::Idle, } } /// The ticks a control acting over a selection sends with its call. /// /// Empty for a control that names no selection. A control that names one /// sends the whole set, whatever it called it: see [`Act::over`] for why /// the name is not matched against the screen's, which is that a webview /// rendering a fragment has no screen to match it against and the two /// hosts would then disagree about a typo. /// /// Empty is also what a control over a set nobody ticked sends, and the two /// are deliberately the same. A handler receives a bulk action over /// nothing, which is a case it has to handle regardless. /// /// [`Act::over`]: quasi_router::Act::over fn gathering(&self, over: Option<&str>) -> Params { let mut payload = Params::new(); if over.is_some() { for value in self.view.ticks() { payload.insert(Node::TICKED.to_owned(), value.to_owned()); } } payload } /// An action as something the host can ask. fn call(action: &Action) -> Step { Self::send(action, Params::new()) } /// An action, plus values the control is sending that are not on it. fn send(action: &Action, extra: Params) -> Step { let Some(path) = action.destination.route() else { return Step::Open(action.destination.as_str().to_string()); }; let mut payload = extra; payload.absorb(action.params.clone()); Step::Call(Request { method: action.method, path: path.to_string(), captures: Params::new(), payload, carried: action.carried.clone(), }) } }