//! The immediate-mode renderer for [`quasi_router`]: a described screen in, an //! egui frame out. //! //! //! //! The third renderer, and the one the stack was missing. `quasi-webview` and //! `quasi-tui` have consumed a [`Screen`] since the beginning; nothing did in //! egui, so an egui app describing a screen had nowhere to send it. What looked //! like the egui renderer was `makeover-immediate`, which draws described //! *nodes* and takes no dependency on `quasi-router` at all: it is the peer of //! `makeover-webview` and `makeover-tui`, one layer below this. //! //! | Layer | webview | terminal | egui | //! |---|---|---|---| //! | nodes, from `makeover-layout` | `makeover-webview` | `makeover-tui` | `makeover-immediate` | //! | screens, from `quasi-router` | `quasi-webview` | `quasi-tui` | **this crate** | //! //! Named for the mode and not the library, the way `makeover-immediate` is: what //! separates this renderer from the other two is that there is no retained tree //! and no cascade, and egui is the backend it is written against. //! //! # What immediate mode gives this renderer for free //! //! `quasi-tui` holds five things a browser provides quietly: what is typed, what //! has focus, how far a pane is scrolled, what is ticked, and where back goes. //! egui provides three of them, so this crate is smaller than the terminal's //! rather than larger: //! //! - **Reach and focus are egui's**, entirely. Its own id stack decides what is //! reachable and its own state decides what holds the keyboard, which is the //! rule `makeover-immediate`'s header already states for the ring. There is no //! `focus.rs` here and there should not be one. //! - **Scroll is egui's**, through `ScrollArea`. //! - **What is typed and what is ticked are not.** A described field is built //! from the description every frame, so the buffer behind it has to outlive //! the frame and belongs to the app. That is [`View`], and it is the same //! discovery the terminal made for the same reason. //! //! # Where a description stops being enough //! //! The three findings `quasi-tui`'s `region` module records apply here unchanged, //! because they are about the description rather than about terminals: a tabbed //! arrangement does not say which tab is showing, a tab has no label, and //! nothing says a region's share beyond [`Arrangement::share`]. Nothing new is //! invented here to paper over them; the same guesses are made and named. #![forbid(unsafe_code)] mod clock; mod geometry; mod node; mod region; mod reveal; mod runtime; mod view; #[cfg(test)] mod tests; pub use clock::{COARSE, LINGER, TICK, cadence}; pub use geometry::{RowAt, row_at}; pub use runtime::{Handed, Runtime, Step}; pub use view::View; /// How often this renderer looks again at a [`Slot::live`] region. /// /// The description says the contents move and never says how often to look; /// the rate is picked here, once, so every live region this crate draws moves /// at one speed. The webview's own number, so a screen described once and /// drawn twice does not go stale at two different rates. /// /// Longer than a frame on purpose. egui repaints when something asks it to, /// and audiofiles' answer to that was to re-read every frame; this is the rate /// that replaces it. [`Runtime::show`] asks for the next repaint itself, so a /// host draws a live screen without owning a clock. /// /// [`Slot::live`]: quasi_router::Slot::live pub const CADENCE: std::time::Duration = std::time::Duration::from_secs(10); /// This renderer's class, and why it may ignore /// [`Destination::Local`](quasi_router::Destination::Local). /// /// And this renderer is the one the ruling was reasoned from: egui redraws /// every frame from memory, so an immediate-mode host has no need to model /// "happens without a request" as a separate kind of thing. Making it carry /// the distinction anyway would be work spent satisfying a model rather than a /// need, with audiofiles paying for it. /// /// The one place the mark is read anyway is [`Runtime::send`], which declines a /// local action instead of treating it as an address to hand the host. /// /// [`Runtime::send`]: Runtime pub const CLASS: quasi_router::Renderer = quasi_router::Renderer::Client; use std::collections::HashMap; use std::sync::Arc; use egui::Ui; use makeover_immediate::table::TableStyle; use makeover_immediate::widget::WidgetStyle; use makeover_immediate::{FieldStyle, FrameStyle, Palette}; use quasi_router::{ Act, Action, Band, Chrome, Frame, Message, Node, Params, Place, Role, Screen, layout, }; /// A banner this renderer raises about itself. /// /// A description bug reported to the user rather than swallowed: a fragment /// naming a region that is not there would otherwise look like a control that /// does nothing. pub(crate) fn layout_notice(text: String) -> Message { Message { kind: layout::Notice::Banner, tone: layout::Tone::Danger, text, undo: None, } } /// What a host draws inside a bespoke region. /// /// egui's counterpart to `Webview::fills` and to [`quasi_tui::Fill`], in this /// host's currency: a browser takes markup, a terminal takes cells, and this /// takes a closure that draws into the `Ui` where the region is. Decision 4 is /// the same on all three sides -- the renderer hands the space over and never /// looks at what went in it. /// /// One method and no measure, which is where this differs from the terminal's /// trait rather than an oversight: egui lays out in the order it is told and /// nothing here asks a region how tall it is, so a fill that draws is a fill /// that has said everything the layout needs. /// /// Handed the renderer so a fill can paint in the palette the screen around it /// is painted in. Never handed the [`View`], which holds what the user has done /// to the *described* screen: a fill's state is the host's, the same way an /// island's is in a browser. /// /// [`quasi_tui::Fill`]: https://docs.rs/quasi-tui pub type Fill = Arc; /// An egui renderer for a described screen. /// /// Holds what a drawing needs and no screen state: the resolved palette, the /// styles derived from it, and what to put inside a bespoke region. A host /// makes one and keeps it, the same way it keeps a `Tui`. #[derive(Clone)] pub struct Immediate { palette: Palette, frame: FrameStyle, field: FieldStyle, widget: WidgetStyle, table: TableStyle, fills: HashMap, reduced_motion: bool, } /// The fills by name, since a drawing is not printable. impl std::fmt::Debug for Immediate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Immediate") .field("palette", &self.palette) .field("frame", &self.frame) .field("field", &self.field) .field("widget", &self.widget) .field("table", &self.table) .field("fills", &self.fills.keys().collect::>()) .field("reduced_motion", &self.reduced_motion) .finish() } } impl Immediate { /// A renderer drawing in this palette, with default styling. #[must_use] pub fn new(palette: Palette) -> Self { Self { palette, frame: FrameStyle::default(), field: FieldStyle::default(), widget: WidgetStyle::default(), table: TableStyle::default(), fills: HashMap::new(), reduced_motion: false, } } /// Draw for a reader who has asked for less motion, chaining. /// /// egui has no `prefers-reduced-motion` to read, which is the whole reason /// this is a setting and not a media query: the preference is the host's to /// obtain from its own platform, and this is where it lands so that a /// drawing can honour it. /// /// One flag rather than one per animated thing. The activity mark is the /// only thing here that moves today, and a second flag beside it would be /// two ways to answer one question. /// /// **It stills the mark rather than removing it.** A reader asking for less /// motion has asked for the movement to stop, not for the information to go /// away; see `makeover_timing::activity_blink`. #[must_use] pub const fn with_reduced_motion(mut self, reduced: bool) -> Self { self.reduced_motion = reduced; self } /// Whether this renderer is drawing for a reader who asked for less motion. #[must_use] pub const fn reduced_motion(&self) -> bool { self.reduced_motion } /// Fill the bespoke region with this slot id, chaining. /// /// Keyed by [`Slot::id`] and not by the bespoke name, for the reason the /// other two renderers are: a screen of N rows each carrying a fill shares /// one name and has N ids. /// /// Repeated calls for one id replace. A slot with no entry draws the blocks /// the description put in it and nothing else; an entry naming an id the /// screen does not have is ignored rather than drawn anywhere. /// /// [`Slot::id`]: quasi_router::Slot #[must_use] pub fn with_fill( mut self, slot_id: impl Into, fill: impl Fn(&Immediate, &mut Ui) + Send + Sync + 'static, ) -> Self { self.fills.insert(slot_id.into(), Arc::new(fill)); self } /// What this renderer puts inside the bespoke region with this id. #[must_use] pub fn fill(&self, slot_id: &str) -> Option<&Fill> { self.fills.get(slot_id) } /// The colours this renderer draws in. #[must_use] pub const fn palette(&self) -> &Palette { &self.palette } /// Use these frame, field, widget and table styles, chaining. #[must_use] pub fn styled( mut self, frame: FrameStyle, field: FieldStyle, widget: WidgetStyle, table: TableStyle, ) -> Self { self.frame = frame; self.field = field; self.widget = widget; self.table = table; self } /// Draw a whole screen, and answer what the user did to it. /// /// The title is not drawn, for the reason `quasi-tui` gives: a window title /// is the host's to set, the same way a webview host puts it in `` /// rather than in the document. [`Screen::discovery`] is declined outright, /// since nothing crawls a desktop window. /// /// [`Row::address`] is declined too, and this says so rather than leaving /// it to be inferred: a member read by one renderer and silently ignored by /// two is how the row types drifted apart in the first place. An /// address is where a document holds a row, which is a fact about having a /// document and an address bar; a window has neither. The identity this /// host does answer with is [`Row::value`], which `row_at` uses to say /// which row the pointer is over. /// /// [`Row::address`]: quasi_router::Row::address /// [`Row::value`]: quasi_router::Row::value /// /// `None` is the ordinary frame. egui redraws continuously, so most frames /// are a user doing nothing, and a renderer that answered a request per /// frame would call the router sixty times a second. pub fn screen(&self, ui: &mut Ui, screen: &Screen, view: &mut View) -> Option<Fired> { self.framed(ui, screen, &Frame::new(), view) } /// Draw a screen inside the frame a mount put around it. /// /// # Where the frame goes /// /// The bottom, and the verbs below the status line, which is the reading /// order the measured window has: what happened, then what to do next. egui /// has no stylesheet to defer the question to, so this is the renderer /// deciding, the same way it decides that notices go at the top. pub fn framed( &self, ui: &mut Ui, screen: &Screen, frame: &Frame, view: &mut View, ) -> Option<Fired> { self.chromed(ui, screen, frame, &Chrome::new(), view) } /// Draw a screen inside its frame, under the panel the app keeps on screen. /// /// # Where the panel goes /// /// Last, under the frame. The lifetimes stack that way -- the screen is /// replaced by every navigation, the frame outlives the screen inside it, /// and the panel outlives both -- and an immediate-mode host lays out in /// the order it is told, so "under" is what drawing it last means. egui has /// no stylesheet to defer the question to, which is why this renderer /// answers it at all. pub fn chromed( &self, ui: &mut Ui, screen: &Screen, frame: &Frame, chrome: &Chrome, view: &mut View, ) -> Option<Fired> { let hidden = reveal::hidden(screen, chrome, view); let mut pass = Pass { immediate: self, view, hidden: &hidden, fired: None, stirred: std::collections::BTreeSet::new(), }; // The band above everything, notices included. It is where the user is // and what the app is called, and a window read top to bottom puts that // first: a header that appeared under a toast would move when the toast // went. // // Only when the app declared one. `Chrome::nav` on its own is not drawn // by this renderer and was not before the band existed, so an app that // declares no band gets the window it always got. // // `Disclose` is ignored, which is what the vocabulary says a renderer // with no notion of "not enough room" does. egui has a width and could // in principle answer it; hiding the places behind a control would be // this renderer inventing a gesture on a window the reader can resize. if let Some(band) = &chrome.band { band_ui(&mut pass, ui, band, &chrome.nav, screen.place.as_deref()); } // Notices first and at the top, because a notice belongs to the screen // rather than to a place in it. A webview leaves where they land to the // stylesheet; egui has none, so this is the renderer deciding, and the // top is the one place a message about the whole screen can go without // claiming a region. // // Unless the mount says it has a place for one to rest, which is what a // status line is. `Frame::holds` is the rule and it is the router's, so // the three renderers cannot each decide it. for notice in screen.notices.iter().filter(|one| !frame.holds(one)) { node::draw(&mut pass, ui, notice); } region::screen_regions(&mut pass, ui, screen); // After the screen rather than beside it. An immediate-mode host lays // out in the order it is told, so "under the screen" is what drawing it // last means, and there is no rect to reserve the way a terminal has to. if !frame.bare() { for notice in screen.notices.iter().filter(|one| frame.holds(one)) { node::draw(&mut pass, ui, notice); } for verb in &frame.verbs { node::draw(&mut pass, ui, &Node::Act(verb.clone())); } } // Activity before status, so the app's condition is the last thing in // the column. An immediate-mode host lays out in the order it is told, // so this is the whole of the placement decision. Within a role it is // declaration order, which is what the description gave. for role in [Role::Activity, Role::Status] { for panel in chrome.panels.iter().filter(|panel| panel.role == role) { node::draw(&mut pass, ui, &panel.content); } } pass.fired } } /// The header band: the brand and the places on one row, the search under it. /// /// One row for the brand and the places because that is what a header is, and a /// second for the box because a search field given a share of a row is a box /// too narrow to read what is in it. /// /// A [`Place`] is drawn as the control it is. The description says these are /// addresses with names, and this renderer has no tab widget to draw them in; /// what it has is the same button every other act is drawn as, which at least /// cannot disagree with the rest of the window about what a control looks like. /// /// # Which place is showing is not marked here, and the reason is a hole /// /// [`Screen::place`] says which one it is and the other two renderers draw it: /// a webview writes `aria-current` and a terminal reverses the tab. There is no /// third spelling here, because `makeover_layout::State` carries one member and /// it is `Disabled`. Marking it with a [`Tone`](quasi_router::layout::Tone) /// would be saying "this one is a success", which is a different claim in the /// same slot. So it goes unmarked until the vocabulary can say it, rather than /// being said wrong. fn band_ui(pass: &mut Pass<'_>, ui: &mut Ui, band: &Band, places: &[Place], at: Option<&str>) { ui.horizontal(|ui| { if let Some(brand) = &band.brand { // The whole name, mark included. egui draws one run of text with // one style, and a renderer that dropped the mark would be drawing // a name the app is not called. node::draw( pass, ui, &Node::Act(Act::new(brand.name.clone(), brand.action.clone())), ); } for place in places { node::draw( pass, ui, &Node::Act(Act::new(place.label.clone(), place.action.clone())), ); } }); // The sub-places of the one the reader is in, and nobody else's: a window // holding every sub-place of every tab is a window that is mostly // navigation. The same rule quasi-tui states for its second row. if let Some(open) = places .iter() .find(|place| at.is_some_and(|key| place.holds(key))) .filter(|place| !place.within.is_empty()) { ui.horizontal(|ui| { for inner in &open.within { node::draw( pass, ui, &Node::Act(Act::new(inner.label.clone(), inner.action.clone())), ); } }); } if let Some(search) = &band.search { // The same field emitter every question goes through. A search box in // the header is not a second kind of box, and this crate has no second // field drawer to make it one. node::draw(pass, ui, &Node::Field(Box::new(search.clone()))); } ui.separator(); } /// What the user set off this frame. /// /// An action and everything gathered to send with it. Separate from /// [`Step`](runtime::Step) because a drawing does not know whether an action /// needs asking about first: that is [`Act::confirm`](quasi_router::Act), and /// the runtime is what holds the question while it is answered. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Fired { /// What to call. pub action: Action, /// What to send with it: a form's values, a selection's members. pub payload: Params, /// What to ask before doing it, if the description said to ask. pub confirm: Option<String>, } /// One frame's drawing, and what came out of it. /// /// Threaded through the walk rather than returned up it: a press can happen at /// any depth, and every level returning an `Option` would make each node's /// drawing responsible for propagating one. pub(crate) struct Pass<'a> { pub(crate) immediate: &'a Immediate, pub(crate) view: &'a mut View, /// The regions and questions that do not apply right now. /// /// Answered once, before the drawing starts, because the view is held /// mutably here and a question asked mid-draw could not read it. pub(crate) hidden: &'a crate::reveal::Hidden<'a>, pub(crate) fired: Option<Fired>, /// The questions whose value moved during this frame, by name. /// /// What a [`Slot::consults`](quasi_router::Slot::consults) needs and an /// immediate-mode frame has nowhere else to keep: a region asks when a /// dial *inside it* moves, the dials are drawn before the region's drawing /// has finished, and a field cannot know which regions contain it. So the /// fields say what moved and each region reads the set once its body is /// drawn. /// /// One frame's worth. A `Pass` is one frame, which is the whole of why this /// is here and the deadline it starts is on the view. pub(crate) stirred: std::collections::BTreeSet<String>, } impl Pass<'_> { /// Record what the user set off. /// /// First press wins. Two controls cannot be activated in one frame by a /// user, so a second one here is a description that put two `Response`s /// under one click, and taking the first is the same rule /// `Chrome::bound` uses for a key claimed twice. pub(crate) fn fire(&mut self, action: &Action, payload: Params, confirm: Option<&str>) { if self.fired.is_none() { self.fired = Some(Fired { action: action.clone(), payload, confirm: confirm.map(ToOwned::to_owned), }); } } }