//! What the user has done to a screen since it arrived. //! //! Two of the five things `quasi-tui`'s `View` holds, and the other three are //! egui's. That is the whole difference between the two crates' state, and it is //! worth saying which is which so the next reader does not go looking for the //! missing ones: //! //! | Fact | terminal | here | //! |---|---|---| //! | what is typed | `View` | **`View`** | //! | what is ticked | `View` | **`View`** | //! | what has focus | `View` | egui's id stack | //! | how far a pane is scrolled | `View` | `egui::ScrollArea` | //! | where back goes | `Runtime` | [`Runtime`](crate::Runtime) | //! //! **Why typing is not egui's, when focus is.** egui holds widget state against //! an id, and a described field is rebuilt from the description every frame; its //! `TextEdit` needs a `&mut String` that outlives the frame, and the description //! deliberately does not carry the value. So the buffer is the app's, held here. //! That is the same conclusion `makeover-immediate`'s `Filling` reached one layer //! down and for the same reason. use std::collections::{BTreeMap, BTreeSet}; use std::time::Instant; use quasi_router::{Action, Candidate, Node, Params, Screen}; /// The host's side of an outstanding wait. /// /// Two `Option`s rather than an `Option` of a pair, because they arrive at /// different moments: the clock starts when the call goes out and a delivery /// count arrives only if something is watching. Most waits never get one. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct Awaited { /// When the call went out. since: Option, /// How much has arrived, in the unit the description counted. delivered: Option, } /// What a waiting consult belongs to. /// /// Two things ask questions while the reader works — a box about its own /// value, and a region about the values inside it — and both address /// themselves with a string. The strings live in different namespaces and /// nothing stops them colliding, so the namespace is part of the key rather /// than a convention nobody enforces. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum Asking { /// A box's own question, by [`Field::name`](quasi_router::Field::name). Field(String), /// A region's question, by [`Slot::id`](quasi_router::Slot::id). Region(String), } /// What the user has done to a screen since it arrived. /// /// A host makes one beside the screen it is holding and keeps the two together. /// Empty is the honest starting state and it draws exactly what the description /// says. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct View { /// What has been typed, by [`Field::name`](quasi_router::Field::name). /// /// Absent means untouched, which is different from present and empty: one /// draws the description's value and the other draws a box the user has /// cleared. edits: BTreeMap, /// What has been ticked, by [`Row::value`](quasi_router::Row::value). ticked: BTreeSet, /// Which branches the reader has folded or unfolded, by /// [`Outline::key`](quasi_router::Outline::key). /// /// Absent means the description's own answer stands, so a screen arriving /// with a branch shut draws it shut until the reader says otherwise. opened: BTreeMap, /// Which child each region showing one at a time is showing, by /// [`Slot::id`](quasi_router::Slot::id). /// /// Absent means the description's own answer stands, which is `opened`'s /// rule one region-kind along: a screen arriving on its second tab draws /// its second tab until the reader presses something else. /// /// `Option` where quasi-tui's is a bare `usize`, and the difference /// is real rather than a spelling. `Showing::AtMostOne` closed is `None`, /// and a terminal's prev/next row cannot reach it -- there is no key that /// means "close" once a strip has been walked onto a child. A summary line /// here is one control that opens and shuts, so closed has to be a state /// the reader can put a region back into. shown: BTreeMap>, /// The value a field's [`writes`](quasi_router::Field::writes) last fired /// with, by field name. /// /// `changes` fires when the value is complete rather than on the way to /// it, so "complete" needs a baseline to be different from: a blur that /// changed nothing must not write, which is what a browser's `change` /// event already promises. Absent means nothing has been fired for that /// field, and the description's own value is the baseline. /// /// Beside `edits` rather than derived from it, because they answer /// different questions: `edits` is what is on screen and this is what the /// route has been told. written: BTreeMap, /// When a consult is due, by what asks it and the consult's place in that /// asker's list. /// /// `N8`. The second half of the key is the cost the ruling named: a box may /// ask two routes at two rates -- MNW's discover search asks its suggestion /// route after 200ms and its results route after 150 -- and one deadline /// per field would have the faster question cancel the slower one. /// /// A deadline rather than an elapsed count, so a keystroke arriving during /// the wait moves it forward by overwriting the entry: that is the whole of /// debouncing, and it needs no tick counter and no second clock. /// /// Here rather than in `Pass` because a `Pass` is one frame and the wait /// spans many. It is the same reason `edits` lives here. /// /// Keyed by [`Asking`] rather than by a bare name, so a region and a field /// that happen to share a string keep their own deadlines: a /// [`Slot::id`](quasi_router::Slot::id) and a /// [`Field::name`](quasi_router::Field::name) are addresses in two /// namespaces and nothing keeps them apart, so a map keyed on the string /// would have had a panel's recompute cancelled by a box that shared its /// name. awaiting: BTreeMap<(Asking, usize), Instant>, /// The question the caret is owed, until the frame that gives it to it. /// /// [`Screen::opens_at`](quasi_router::Screen::opens_at) read once on /// arrival, and taken by the field that claims it. Focus is egui's here -- /// the table at the top of this module says so -- so this is not the caret, /// it is the one-frame instruction to ask egui for it. /// /// Taken rather than read, so it happens on the arrival frame and no /// other. A flag left standing would ask for focus every frame and the /// reader could never move the caret off the box. opens: Option, /// The action this screen is waiting on, when one is outstanding. /// /// Only an action carrying [`Action::awaiting`] lands here: the /// description says which calls are worth locking a control for, and this /// renderer does not decide that a route is slow. Beside `awaiting` above /// and not folded into it, which is the distinction the ruling turns on: a /// consult is a wait this renderer chose to impose, and this is a wait the /// description declared. outstanding: Option, /// When the outstanding call went out, and how much of it has landed. /// /// Wiki `loading-and-progress-standard`, rule 1. `Action::awaiting` carries /// the size of the payload and nothing can carry how much of it has /// arrived: that is a fact about bytes in flight, so it is the host's to /// report and it lands here beside the action it belongs to. /// /// Both are `None` for a host that is not watching, and that is the honest /// common case rather than a gap. A bar drawn out of a total alone would be /// claiming somebody is counting. progress: Awaited, /// The candidates a field's suggestion route answered with. /// /// [`Field::suggests`](quasi_router::Field::suggests) is answered with a /// list belonging to a control rather than a region, so there is nowhere /// in the screen to land it: it lands here, beside what has been typed /// into the box it belongs to. /// /// One at a time and named by its field, for `quasi-tui`'s reason: a list /// belongs to the box being typed into, and keeping a second field's stale /// candidates would draw a list under a box nobody is in. suggesting: Option<(String, Vec)>, /// How many slots a repeating question stands in, by /// [`Field::name`](quasi_router::Field::name), where the reader has changed /// it. /// /// And the same discovery as everything else here: the description says /// how many answers it was given, and how many boxes there are *now* is a /// fact about what the reader has done since. A browser owns it as /// elements in the document, so nobody had to name it there; there is no /// document here. /// /// Absent means the description's own count still stands, which is what /// makes an untouched screen draw what the handler said. slots: BTreeMap, } impl View { /// Nothing typed and nothing ticked. #[must_use] pub fn new() -> Self { Self::default() } /// What a field is showing: what was typed, or what the description offers. #[must_use] pub fn showing<'a>(&'a self, name: &str, described: Option<&'a str>) -> &'a str { self.edits .get(name) .map_or(described.unwrap_or_default(), String::as_str) } /// How many slots this repeating question stands in right now. /// /// `described` is what the description offered, which is /// [`Field::slots`](quasi_router::Field::slots). What the reader has added /// or taken away wins, and the answer is held to the question's own floor /// and ceiling so that no walk can offer a slot the description refuses. #[must_use] pub fn standing(&self, field: &quasi_router::Field) -> usize { let Some(repeat) = &field.repeats else { return 1; }; let described = repeat.standing(); let count = self.slots.get(&field.name).copied().unwrap_or(described); count .max(repeat.least) .min(repeat.most.unwrap_or(usize::MAX)) } /// Add a slot to a repeating question, if its ceiling allows another. /// /// No request, which is the third of the three things the member is for: /// the reader creates and destroys slots, and the box that appears is a box /// nothing was asked for. pub fn add_slot(&mut self, field: &quasi_router::Field) { let Some(repeat) = &field.repeats else { return; }; let standing = self.standing(field); if !repeat.more(standing) { return; } self.slots.insert(field.name.clone(), standing + 1); } /// Take one slot out of a repeating question, if its floor allows one /// fewer. /// /// The answers after it move up, buffer and all, because the names are /// positional: leaving them where they were would submit a hole under the /// name the reader had just emptied and drop the last answer off the end. /// That is the same renumbering `repeat.js` does in the browser. pub fn remove_slot(&mut self, field: &quasi_router::Field, at: usize) { let Some(repeat) = &field.repeats else { return; }; let standing = self.standing(field); if at >= standing || !repeat.fewer(standing) { return; } // Every name the slot submits under, which is one for an ordinary // repeating question and one per part for a grouped one. Read off // `Field::instance_fields` rather than spelled here, so this moves the // same names the drawing and the submission use. for slot in at..standing - 1 { for (here, next) in field .instance_fields(slot) .iter() .zip(field.instance_fields(slot + 1).iter()) { let carried = self .edits .get(&next.name) .cloned() .or_else(|| next.value.clone()); match carried { Some(value) => { self.edits.insert(here.name.clone(), value); } None => { self.edits.remove(&here.name); } } } } for last in field.instance_fields(standing - 1) { self.edits.remove(&last.name); } self.slots.insert(field.name.clone(), standing - 1); } /// The buffer a text control writes through, seeded from the description. /// /// `&mut` because that is what an immediate-mode text control takes: there /// is no DOM to read the value back out of afterwards. Seeding on first /// touch rather than up front is what keeps "untouched" distinguishable /// from "cleared". pub fn buffer(&mut self, name: &str, described: Option<&str>) -> &mut String { self.edits .entry(name.to_owned()) .or_insert_with(|| described.unwrap_or_default().to_owned()) } /// What has been typed into a field, if anything has. #[must_use] pub fn edit(&self, name: &str) -> Option<&str> { self.edits.get(name).map(String::as_str) } /// Put a value in, as a host restoring one would. pub fn set(&mut self, name: impl Into, value: impl Into) { self.edits.insert(name.into(), value.into()); } /// Whether this value is one the field's `changes` has already fired with. /// /// The baseline a completed value is compared against. Falls back to what /// the description offered, so the first blur on a box nobody touched /// writes nothing. #[must_use] pub fn unwritten(&self, name: &str, value: &str, described: Option<&str>) -> bool { match self.written.get(name) { Some(written) => written != value, None => described.unwrap_or_default() != value, } } /// Remember what a field's `changes` fired with. pub fn wrote(&mut self, name: &str, value: &str) { self.written.insert(name.to_owned(), value.to_owned()); } /// The open suggestion list, if it belongs to this field. #[must_use] pub fn suggesting(&self, name: &str) -> Option<&[Candidate]> { self.suggesting .as_ref() .filter(|(field, _)| field == name) .map(|(_, options)| options.as_slice()) } /// What a field's suggestion route answered with. /// /// An empty answer closes the list rather than opening an empty one: a /// route with nothing to suggest and a route that was never asked leave the /// screen in the same state. pub fn suggested(&mut self, name: impl Into, options: Vec) { let name = name.into(); self.suggesting = (!options.is_empty()).then_some((name, options)); } /// Put the list away. pub fn unsuggest(&mut self) { self.suggesting = None; } /// Start, or restart, the wait before a consult is asked. /// /// Called on every keystroke, which is what makes it a debounce rather /// than a delay: the second keystroke overwrites the first one's deadline, /// so a name typed in one go is asked about once. A region's question is /// restarted by a keystroke in any of the boxes it contains, which is the /// same sentence one turn out. pub fn wait_to_consult(&mut self, asked: Asking, at: usize, until: Instant) { self.awaiting.insert((asked, at), until); } /// When this consult is due, if one is waiting. #[must_use] pub fn consult_due(&self, asked: Asking, at: usize) -> Option { self.awaiting.get(&(asked, at)).copied() } /// Forget the wait, because the question has now been asked. pub fn consulted(&mut self, asked: Asking, at: usize) { self.awaiting.remove(&(asked, at)); } /// The values other controls contribute to a question, by name. /// /// [`Consult::sends`](quasi_router::Consult::sends). Read out of what has /// been drawn, which is where a described field's own offer lands the first /// time it is drawn, so an untouched filter still sends what it is showing. /// /// A name nothing on the screen carries contributes nothing rather than an /// empty value, so a route can tell "not on this screen" from "on it and /// blank". #[must_use] pub fn contributed(&self, names: &[String]) -> Params { let mut params = Params::new(); for name in names { if let Some(value) = self.edits.get(name) { params = params.with(name.clone(), value.clone()); } } params } /// The action this view is waiting on. #[must_use] pub const fn outstanding(&self) -> Option<&Action> { self.outstanding.as_ref() } /// Say that this action is outstanding, or that nothing is. /// /// The runtime's to set, from what the description marked as awaiting. /// /// Starts the clock, and clears whatever the last wait had delivered. A /// wait's elapsed time is the one time value it may show, and it is only /// honest if it belongs to this call rather than to the one before it. pub(crate) fn await_on(&mut self, action: Option) { self.await_on_at(action, Instant::now()); } /// [`await_on`](Self::await_on) with the clock handed in, for tests. pub(crate) fn await_on_at(&mut self, action: Option, now: Instant) { self.progress = Awaited { since: action.as_ref().map(|_| now), delivered: None, }; self.outstanding = action; } /// Say how much of the outstanding call has arrived. /// /// The host's to call, as often as it likes, from whatever it is watching: /// bytes off a socket, rows out of an import. In the unit the description /// counted, which is the app's business either way -- see /// `makeover_layout::Awaiting::amount`. /// /// Ignored when nothing is outstanding. A delivery count with no wait /// attached would be drawn against the next call to go out, which is a /// number belonging to the wrong wait. pub fn delivered(&mut self, amount: u64) { if self.outstanding.is_some() { self.progress.delivered = Some(amount); } } /// What is known about the wait that is running, at `now`. /// /// Empty when nothing is outstanding, which is what makes the drawing /// degrade to the activity mark rather than to a bar of nothing. #[must_use] pub fn progress_at(&self, now: Instant) -> makeover_immediate::widget::Progress { makeover_immediate::widget::Progress { delivered: self.progress.delivered, elapsed: self .progress .since .map(|since| now.saturating_duration_since(since)), } } /// Whether this is the control that was pressed and has not been answered. /// /// What the drawing consults to disable it, which in an immediate-mode /// renderer is also the whole of the guard: a disabled control reports no /// click, so the second press does not exist rather than being discarded. #[must_use] pub fn busy(&self, action: &Action) -> bool { self.outstanding.as_ref() == Some(action) } /// Whether a branch is open, given what its description says. /// /// The description says where the outline starts and the reader says where /// it is now, the way the shipped egui sidebar this describes already /// worked -- expansion was egui's own state and the app never held it. See /// [`Row::open`](quasi_router::Row::open) for why folding a branch asks /// the app nothing. #[must_use] pub fn open(&self, key: &str, described: bool) -> bool { self.opened.get(key).copied().unwrap_or(described) } /// Fold an open branch, or open a shut one. pub fn fold(&mut self, key: &str, described: bool) { let open = self.open(key, described); self.opened.insert(key.to_string(), !open); } /// Which child a region is showing, given what its description says. /// /// [`open`](Self::open)'s shape, and the same split: the description says /// which child a screen arrives on and the reader says which one it is on /// now. Clamped to the body, for [`Slot::current`](quasi_router::Slot::current)'s reason -- a stale /// index left behind by a screen with more children would report a region /// that vanished. #[must_use] pub fn shown(&self, slot: &quasi_router::Slot) -> Option { match self.shown.get(&slot.id) { Some(at) => at.map(|at| at.min(slot.body.len().saturating_sub(1))), None => slot.current(), } } /// Show a particular child of a region. pub fn show(&mut self, region: &str, at: usize) { self.shown.insert(region.to_string(), Some(at)); } /// Move a region to another of its children, wrapping at both ends. /// /// quasi-tui's rule, and for its reason: neither renderer has anything to /// show you that you are on the last frame, so a next control that stops /// dead reads as a broken control rather than as the end of the gallery. /// /// A closed region opens on its first child, which is the only reading of /// "next" that does anything from closed. pub fn show_by(&mut self, slot: &quasi_router::Slot, steps: isize) { let count = slot.body.len(); if count == 0 { return; } let at = match self.shown(slot) { Some(at) => (at as isize + steps).rem_euclid(count as isize) as usize, None => 0, }; self.show(&slot.id, at); } /// Open a shut disclosure, or shut an open one. /// /// Only reachable for [`Showing::AtMostOne`](quasi_router::layout::Showing::AtMostOne): /// closing is what that member means and nothing else can be put into the /// closed state. pub fn disclose(&mut self, slot: &quasi_router::Slot) { let next = self.shown(slot).is_none().then_some(0); self.shown.insert(slot.id.clone(), next); } /// Whether a row's value is in the screen's selection. #[must_use] pub fn is_ticked(&self, value: &str) -> bool { self.ticked.contains(value) } /// Add or remove a row's value from the selection. pub fn tick(&mut self, value: &str) { if !self.ticked.remove(value) { self.ticked.insert(value.to_owned()); } } /// Everything ticked, in a stable order. pub fn ticks(&self) -> impl Iterator { self.ticked.iter().map(String::as_str) } /// The rows a new screen says are already ticked. /// /// Applied once on arrival rather than read on every draw: after this the /// user's ticks are the truth, and a description that kept overriding them /// would undo a tick the moment anything redrew. /// /// This walked `Node::List` alone until the 2026-09-06 collapse, so a /// *table* arriving with rows already ticked seeded nothing and the ticks /// drew empty. One node now, and one walk reaches both arrangements. pub fn seed(&mut self, screen: &Screen) { for slot in &screen.slots { for placed in slot.body.iter() { if let Node::Table { rows, .. } = &placed.node { for row in rows { if let (Some(true), Some(value)) = (row.selected, row.value.as_ref()) { self.ticked.insert(value.clone()); } } } } } } /// Owe the caret to the question this screen opens at. /// /// Arrival behaviour, beside [`seed`](Self::seed) and for its reason: a /// screen cannot pull the caret back on a redraw, so this is read once and /// spent on the next frame. See /// [`Screen::opens_at`](quasi_router::Screen::opens_at). pub fn open_at(&mut self, screen: &Screen) { self.opens.clone_from(&screen.opens_at); } /// Whether this box is the one owed the caret, taking the claim with it. /// /// Taken rather than read: the frame that asks egui for focus is the only /// frame that should, and a claim two boxes could both answer would be two /// widgets fighting over the caret every frame. pub(crate) fn claims_caret(&mut self, name: &str) -> bool { if self.opens.as_deref() == Some(name) { self.opens = None; return true; } false } /// Forget everything, for a screen that has been replaced. /// /// The outstanding consults go with the rest. A wait that survived the /// screen would ask about a field that is no longer on it, and land the /// answer in a region that no longer exists. pub fn reset(&mut self) { self.edits.clear(); self.ticked.clear(); // What the last screen was owed. The next one says for itself, and a // name carried across would put the caret in whatever box on the new // screen happened to share it. self.opens = None; // Which frame a region was on belongs to the screen it was on, and a // region id is only unique within one. quasi-tui clears this here for // the same reason. self.shown.clear(); self.awaiting.clear(); self.suggesting = None; // A screen that has arrived is the answer to whatever was outstanding, // or is somewhere else entirely. self.outstanding = None; self.progress = Awaited::default(); } /// The values a form submits, by the names it declared. /// /// Every declared name is sent, including the ones nothing was typed into, /// because a form that omits an untouched field is a form that cannot clear /// one. What is sent for those is whatever the description offered. #[must_use] pub fn submission(&self, names: &[String], described: &BTreeMap) -> Params { let mut params = Params::new(); for name in names { let value = self .edits .get(name) .or_else(|| described.get(name)) .map_or("", String::as_str); params = params.with(name.clone(), value.to_owned()); } params } /// What the screen's selection sends with an action taken over it. #[must_use] pub fn gathering(&self, under: &str) -> Params { let mut params = Params::new(); for value in self.ticks() { params = params.with(under.to_owned(), value.to_owned()); } params } }