Skip to main content

max / quasi

21.1 KB · 518 lines History Blame Raw
1 //! The immediate-mode renderer for [`quasi_router`]: a described screen in, an
2 //! egui frame out.
3 //!
4 //! <!-- wiki: quasi-overview -->
5 //!
6 //! The third renderer, and the one the stack was missing. `quasi-webview` and
7 //! `quasi-tui` have consumed a [`Screen`] since the beginning; nothing did in
8 //! egui, so an egui app describing a screen had nowhere to send it. What looked
9 //! like the egui renderer was `makeover-immediate`, which draws described
10 //! *nodes* and takes no dependency on `quasi-router` at all: it is the peer of
11 //! `makeover-webview` and `makeover-tui`, one layer below this.
12 //!
13 //! | Layer | webview | terminal | egui |
14 //! |---|---|---|---|
15 //! | nodes, from `makeover-layout` | `makeover-webview` | `makeover-tui` | `makeover-immediate` |
16 //! | screens, from `quasi-router` | `quasi-webview` | `quasi-tui` | **this crate** |
17 //!
18 //! Named for the mode and not the library, the way `makeover-immediate` is: what
19 //! separates this renderer from the other two is that there is no retained tree
20 //! and no cascade, and egui is the backend it is written against.
21 //!
22 //! # What immediate mode gives this renderer for free
23 //!
24 //! `quasi-tui` holds five things a browser provides quietly: what is typed, what
25 //! has focus, how far a pane is scrolled, what is ticked, and where back goes.
26 //! egui provides three of them, so this crate is smaller than the terminal's
27 //! rather than larger:
28 //!
29 //! - **Reach and focus are egui's**, entirely. Its own id stack decides what is
30 //! reachable and its own state decides what holds the keyboard, which is the
31 //! rule `makeover-immediate`'s header already states for the ring. There is no
32 //! `focus.rs` here and there should not be one.
33 //! - **Scroll is egui's**, through `ScrollArea`.
34 //! - **What is typed and what is ticked are not.** A described field is built
35 //! from the description every frame, so the buffer behind it has to outlive
36 //! the frame and belongs to the app. That is [`View`], and it is the same
37 //! discovery the terminal made for the same reason.
38 //!
39 //! # Where a description stops being enough
40 //!
41 //! The three findings `quasi-tui`'s `region` module records apply here unchanged,
42 //! because they are about the description rather than about terminals: a tabbed
43 //! arrangement does not say which tab is showing, a tab has no label, and
44 //! nothing says a region's share beyond [`Arrangement::share`]. Nothing new is
45 //! invented here to paper over them; the same guesses are made and named.
46
47 #![forbid(unsafe_code)]
48
49 mod clock;
50 mod geometry;
51 mod node;
52 mod region;
53 mod reveal;
54 mod runtime;
55 mod view;
56
57 #[cfg(test)]
58 mod tests;
59
60 pub use clock::{COARSE, LINGER, TICK, cadence};
61 pub use geometry::{RowAt, row_at};
62 pub use runtime::{Handed, Runtime, Step};
63 pub use view::View;
64
65 /// How often this renderer looks again at a [`Slot::live`] region.
66 ///
67 /// The description says the contents move and never says how often to look;
68 /// the rate is picked here, once, so every live region this crate draws moves
69 /// at one speed. The webview's own number, so a screen described once and
70 /// drawn twice does not go stale at two different rates.
71 ///
72 /// Longer than a frame on purpose. egui repaints when something asks it to,
73 /// and audiofiles' answer to that was to re-read every frame; this is the rate
74 /// that replaces it. [`Runtime::show`] asks for the next repaint itself, so a
75 /// host draws a live screen without owning a clock.
76 ///
77 /// [`Slot::live`]: quasi_router::Slot::live
78 pub const CADENCE: std::time::Duration = std::time::Duration::from_secs(10);
79
80 /// This renderer's class, and why it may ignore
81 /// [`Destination::Local`](quasi_router::Destination::Local).
82 ///
83 /// And this renderer is the one the ruling was reasoned from: egui redraws
84 /// every frame from memory, so an immediate-mode host has no need to model
85 /// "happens without a request" as a separate kind of thing. Making it carry
86 /// the distinction anyway would be work spent satisfying a model rather than a
87 /// need, with audiofiles paying for it.
88 ///
89 /// The one place the mark is read anyway is [`Runtime::send`], which declines a
90 /// local action instead of treating it as an address to hand the host.
91 ///
92 /// [`Runtime::send`]: Runtime
93 pub const CLASS: quasi_router::Renderer = quasi_router::Renderer::Client;
94
95 use std::collections::HashMap;
96 use std::sync::Arc;
97
98 use egui::Ui;
99 use makeover_immediate::table::TableStyle;
100 use makeover_immediate::widget::WidgetStyle;
101 use makeover_immediate::{FieldStyle, FrameStyle, Palette};
102 use quasi_router::{
103 Act, Action, Band, Chrome, Frame, Message, Node, Params, Place, Role, Screen, layout,
104 };
105
106 /// A banner this renderer raises about itself.
107 ///
108 /// A description bug reported to the user rather than swallowed: a fragment
109 /// naming a region that is not there would otherwise look like a control that
110 /// does nothing.
111 pub(crate) fn layout_notice(text: String) -> Message {
112 Message {
113 kind: layout::Notice::Banner,
114 tone: layout::Tone::Danger,
115 text,
116 undo: None,
117 }
118 }
119
120 /// What a host draws inside a bespoke region.
121 ///
122 /// egui's counterpart to `Webview::fills` and to [`quasi_tui::Fill`], in this
123 /// host's currency: a browser takes markup, a terminal takes cells, and this
124 /// takes a closure that draws into the `Ui` where the region is. Decision 4 is
125 /// the same on all three sides -- the renderer hands the space over and never
126 /// looks at what went in it.
127 ///
128 /// One method and no measure, which is where this differs from the terminal's
129 /// trait rather than an oversight: egui lays out in the order it is told and
130 /// nothing here asks a region how tall it is, so a fill that draws is a fill
131 /// that has said everything the layout needs.
132 ///
133 /// Handed the renderer so a fill can paint in the palette the screen around it
134 /// is painted in. Never handed the [`View`], which holds what the user has done
135 /// to the *described* screen: a fill's state is the host's, the same way an
136 /// island's is in a browser.
137 ///
138 /// [`quasi_tui::Fill`]: https://docs.rs/quasi-tui
139 pub type Fill = Arc<dyn Fn(&Immediate, &mut Ui) + Send + Sync>;
140
141 /// An egui renderer for a described screen.
142 ///
143 /// Holds what a drawing needs and no screen state: the resolved palette, the
144 /// styles derived from it, and what to put inside a bespoke region. A host
145 /// makes one and keeps it, the same way it keeps a `Tui`.
146 #[derive(Clone)]
147 pub struct Immediate {
148 palette: Palette,
149 frame: FrameStyle,
150 field: FieldStyle,
151 widget: WidgetStyle,
152 table: TableStyle,
153 fills: HashMap<String, Fill>,
154 reduced_motion: bool,
155 }
156
157 /// The fills by name, since a drawing is not printable.
158 impl std::fmt::Debug for Immediate {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("Immediate")
161 .field("palette", &self.palette)
162 .field("frame", &self.frame)
163 .field("field", &self.field)
164 .field("widget", &self.widget)
165 .field("table", &self.table)
166 .field("fills", &self.fills.keys().collect::<Vec<_>>())
167 .field("reduced_motion", &self.reduced_motion)
168 .finish()
169 }
170 }
171
172 impl Immediate {
173 /// A renderer drawing in this palette, with default styling.
174 #[must_use]
175 pub fn new(palette: Palette) -> Self {
176 Self {
177 palette,
178 frame: FrameStyle::default(),
179 field: FieldStyle::default(),
180 widget: WidgetStyle::default(),
181 table: TableStyle::default(),
182 fills: HashMap::new(),
183 reduced_motion: false,
184 }
185 }
186
187 /// Draw for a reader who has asked for less motion, chaining.
188 ///
189 /// egui has no `prefers-reduced-motion` to read, which is the whole reason
190 /// this is a setting and not a media query: the preference is the host's to
191 /// obtain from its own platform, and this is where it lands so that a
192 /// drawing can honour it.
193 ///
194 /// One flag rather than one per animated thing. The activity mark is the
195 /// only thing here that moves today, and a second flag beside it would be
196 /// two ways to answer one question.
197 ///
198 /// **It stills the mark rather than removing it.** A reader asking for less
199 /// motion has asked for the movement to stop, not for the information to go
200 /// away; see `makeover_timing::activity_blink`.
201 #[must_use]
202 pub const fn with_reduced_motion(mut self, reduced: bool) -> Self {
203 self.reduced_motion = reduced;
204 self
205 }
206
207 /// Whether this renderer is drawing for a reader who asked for less motion.
208 #[must_use]
209 pub const fn reduced_motion(&self) -> bool {
210 self.reduced_motion
211 }
212
213 /// Fill the bespoke region with this slot id, chaining.
214 ///
215 /// Keyed by [`Slot::id`] and not by the bespoke name, for the reason the
216 /// other two renderers are: a screen of N rows each carrying a fill shares
217 /// one name and has N ids.
218 ///
219 /// Repeated calls for one id replace. A slot with no entry draws the blocks
220 /// the description put in it and nothing else; an entry naming an id the
221 /// screen does not have is ignored rather than drawn anywhere.
222 ///
223 /// [`Slot::id`]: quasi_router::Slot
224 #[must_use]
225 pub fn with_fill(
226 mut self,
227 slot_id: impl Into<String>,
228 fill: impl Fn(&Immediate, &mut Ui) + Send + Sync + 'static,
229 ) -> Self {
230 self.fills.insert(slot_id.into(), Arc::new(fill));
231 self
232 }
233
234 /// What this renderer puts inside the bespoke region with this id.
235 #[must_use]
236 pub fn fill(&self, slot_id: &str) -> Option<&Fill> {
237 self.fills.get(slot_id)
238 }
239
240 /// The colours this renderer draws in.
241 #[must_use]
242 pub const fn palette(&self) -> &Palette {
243 &self.palette
244 }
245
246 /// Use these frame, field, widget and table styles, chaining.
247 #[must_use]
248 pub fn styled(
249 mut self,
250 frame: FrameStyle,
251 field: FieldStyle,
252 widget: WidgetStyle,
253 table: TableStyle,
254 ) -> Self {
255 self.frame = frame;
256 self.field = field;
257 self.widget = widget;
258 self.table = table;
259 self
260 }
261
262 /// Draw a whole screen, and answer what the user did to it.
263 ///
264 /// The title is not drawn, for the reason `quasi-tui` gives: a window title
265 /// is the host's to set, the same way a webview host puts it in `<title>`
266 /// rather than in the document. [`Screen::discovery`] is declined outright,
267 /// since nothing crawls a desktop window.
268 ///
269 /// [`Row::address`] is declined too, and this says so rather than leaving
270 /// it to be inferred: a member read by one renderer and silently ignored by
271 /// two is how the row types drifted apart in the first place. An
272 /// address is where a document holds a row, which is a fact about having a
273 /// document and an address bar; a window has neither. The identity this
274 /// host does answer with is [`Row::value`], which `row_at` uses to say
275 /// which row the pointer is over.
276 ///
277 /// [`Row::address`]: quasi_router::Row::address
278 /// [`Row::value`]: quasi_router::Row::value
279 ///
280 /// `None` is the ordinary frame. egui redraws continuously, so most frames
281 /// are a user doing nothing, and a renderer that answered a request per
282 /// frame would call the router sixty times a second.
283 pub fn screen(&self, ui: &mut Ui, screen: &Screen, view: &mut View) -> Option<Fired> {
284 self.framed(ui, screen, &Frame::new(), view)
285 }
286
287 /// Draw a screen inside the frame a mount put around it.
288 ///
289 /// # Where the frame goes
290 ///
291 /// The bottom, and the verbs below the status line, which is the reading
292 /// order the measured window has: what happened, then what to do next. egui
293 /// has no stylesheet to defer the question to, so this is the renderer
294 /// deciding, the same way it decides that notices go at the top.
295 pub fn framed(
296 &self,
297 ui: &mut Ui,
298 screen: &Screen,
299 frame: &Frame,
300 view: &mut View,
301 ) -> Option<Fired> {
302 self.chromed(ui, screen, frame, &Chrome::new(), view)
303 }
304
305 /// Draw a screen inside its frame, under the panel the app keeps on screen.
306 ///
307 /// # Where the panel goes
308 ///
309 /// Last, under the frame. The lifetimes stack that way -- the screen is
310 /// replaced by every navigation, the frame outlives the screen inside it,
311 /// and the panel outlives both -- and an immediate-mode host lays out in
312 /// the order it is told, so "under" is what drawing it last means. egui has
313 /// no stylesheet to defer the question to, which is why this renderer
314 /// answers it at all.
315 pub fn chromed(
316 &self,
317 ui: &mut Ui,
318 screen: &Screen,
319 frame: &Frame,
320 chrome: &Chrome,
321 view: &mut View,
322 ) -> Option<Fired> {
323 let hidden = reveal::hidden(screen, chrome, view);
324 let mut pass = Pass {
325 immediate: self,
326 view,
327 hidden: &hidden,
328 fired: None,
329 stirred: std::collections::BTreeSet::new(),
330 };
331
332 // The band above everything, notices included. It is where the user is
333 // and what the app is called, and a window read top to bottom puts that
334 // first: a header that appeared under a toast would move when the toast
335 // went.
336 //
337 // Only when the app declared one. `Chrome::nav` on its own is not drawn
338 // by this renderer and was not before the band existed, so an app that
339 // declares no band gets the window it always got.
340 //
341 // `Disclose` is ignored, which is what the vocabulary says a renderer
342 // with no notion of "not enough room" does. egui has a width and could
343 // in principle answer it; hiding the places behind a control would be
344 // this renderer inventing a gesture on a window the reader can resize.
345 if let Some(band) = &chrome.band {
346 band_ui(&mut pass, ui, band, &chrome.nav, screen.place.as_deref());
347 }
348
349 // Notices first and at the top, because a notice belongs to the screen
350 // rather than to a place in it. A webview leaves where they land to the
351 // stylesheet; egui has none, so this is the renderer deciding, and the
352 // top is the one place a message about the whole screen can go without
353 // claiming a region.
354 //
355 // Unless the mount says it has a place for one to rest, which is what a
356 // status line is. `Frame::holds` is the rule and it is the router's, so
357 // the three renderers cannot each decide it.
358 for notice in screen.notices.iter().filter(|one| !frame.holds(one)) {
359 node::draw(&mut pass, ui, notice);
360 }
361
362 region::screen_regions(&mut pass, ui, screen);
363
364 // After the screen rather than beside it. An immediate-mode host lays
365 // out in the order it is told, so "under the screen" is what drawing it
366 // last means, and there is no rect to reserve the way a terminal has to.
367 if !frame.bare() {
368 for notice in screen.notices.iter().filter(|one| frame.holds(one)) {
369 node::draw(&mut pass, ui, notice);
370 }
371 for verb in &frame.verbs {
372 node::draw(&mut pass, ui, &Node::Act(verb.clone()));
373 }
374 }
375
376 // Activity before status, so the app's condition is the last thing in
377 // the column. An immediate-mode host lays out in the order it is told,
378 // so this is the whole of the placement decision. Within a role it is
379 // declaration order, which is what the description gave.
380 for role in [Role::Activity, Role::Status] {
381 for panel in chrome.panels.iter().filter(|panel| panel.role == role) {
382 node::draw(&mut pass, ui, &panel.content);
383 }
384 }
385
386 pass.fired
387 }
388 }
389
390 /// The header band: the brand and the places on one row, the search under it.
391 ///
392 /// One row for the brand and the places because that is what a header is, and a
393 /// second for the box because a search field given a share of a row is a box
394 /// too narrow to read what is in it.
395 ///
396 /// A [`Place`] is drawn as the control it is. The description says these are
397 /// addresses with names, and this renderer has no tab widget to draw them in;
398 /// what it has is the same button every other act is drawn as, which at least
399 /// cannot disagree with the rest of the window about what a control looks like.
400 ///
401 /// # Which place is showing is not marked here, and the reason is a hole
402 ///
403 /// [`Screen::place`] says which one it is and the other two renderers draw it:
404 /// a webview writes `aria-current` and a terminal reverses the tab. There is no
405 /// third spelling here, because `makeover_layout::State` carries one member and
406 /// it is `Disabled`. Marking it with a [`Tone`](quasi_router::layout::Tone)
407 /// would be saying "this one is a success", which is a different claim in the
408 /// same slot. So it goes unmarked until the vocabulary can say it, rather than
409 /// being said wrong.
410 fn band_ui(pass: &mut Pass<'_>, ui: &mut Ui, band: &Band, places: &[Place], at: Option<&str>) {
411 ui.horizontal(|ui| {
412 if let Some(brand) = &band.brand {
413 // The whole name, mark included. egui draws one run of text with
414 // one style, and a renderer that dropped the mark would be drawing
415 // a name the app is not called.
416 node::draw(
417 pass,
418 ui,
419 &Node::Act(Act::new(brand.name.clone(), brand.action.clone())),
420 );
421 }
422 for place in places {
423 node::draw(
424 pass,
425 ui,
426 &Node::Act(Act::new(place.label.clone(), place.action.clone())),
427 );
428 }
429 });
430 // The sub-places of the one the reader is in, and nobody else's: a window
431 // holding every sub-place of every tab is a window that is mostly
432 // navigation. The same rule quasi-tui states for its second row.
433 if let Some(open) = places
434 .iter()
435 .find(|place| at.is_some_and(|key| place.holds(key)))
436 .filter(|place| !place.within.is_empty())
437 {
438 ui.horizontal(|ui| {
439 for inner in &open.within {
440 node::draw(
441 pass,
442 ui,
443 &Node::Act(Act::new(inner.label.clone(), inner.action.clone())),
444 );
445 }
446 });
447 }
448 if let Some(search) = &band.search {
449 // The same field emitter every question goes through. A search box in
450 // the header is not a second kind of box, and this crate has no second
451 // field drawer to make it one.
452 node::draw(pass, ui, &Node::Field(Box::new(search.clone())));
453 }
454 ui.separator();
455 }
456
457 /// What the user set off this frame.
458 ///
459 /// An action and everything gathered to send with it. Separate from
460 /// [`Step`](runtime::Step) because a drawing does not know whether an action
461 /// needs asking about first: that is [`Act::confirm`](quasi_router::Act), and
462 /// the runtime is what holds the question while it is answered.
463 #[derive(Debug, Clone, PartialEq, Eq)]
464 pub struct Fired {
465 /// What to call.
466 pub action: Action,
467 /// What to send with it: a form's values, a selection's members.
468 pub payload: Params,
469 /// What to ask before doing it, if the description said to ask.
470 pub confirm: Option<String>,
471 }
472
473 /// One frame's drawing, and what came out of it.
474 ///
475 /// Threaded through the walk rather than returned up it: a press can happen at
476 /// any depth, and every level returning an `Option` would make each node's
477 /// drawing responsible for propagating one.
478 pub(crate) struct Pass<'a> {
479 pub(crate) immediate: &'a Immediate,
480 pub(crate) view: &'a mut View,
481 /// The regions and questions that do not apply right now.
482 ///
483 /// Answered once, before the drawing starts, because the view is held
484 /// mutably here and a question asked mid-draw could not read it.
485 pub(crate) hidden: &'a crate::reveal::Hidden<'a>,
486 pub(crate) fired: Option<Fired>,
487 /// The questions whose value moved during this frame, by name.
488 ///
489 /// What a [`Slot::consults`](quasi_router::Slot::consults) needs and an
490 /// immediate-mode frame has nowhere else to keep: a region asks when a
491 /// dial *inside it* moves, the dials are drawn before the region's drawing
492 /// has finished, and a field cannot know which regions contain it. So the
493 /// fields say what moved and each region reads the set once its body is
494 /// drawn.
495 ///
496 /// One frame's worth. A `Pass` is one frame, which is the whole of why this
497 /// is here and the deadline it starts is on the view.
498 pub(crate) stirred: std::collections::BTreeSet<String>,
499 }
500
501 impl Pass<'_> {
502 /// Record what the user set off.
503 ///
504 /// First press wins. Two controls cannot be activated in one frame by a
505 /// user, so a second one here is a description that put two `Response`s
506 /// under one click, and taking the first is the same rule
507 /// `Chrome::bound` uses for a key claimed twice.
508 pub(crate) fn fire(&mut self, action: &Action, payload: Params, confirm: Option<&str>) {
509 if self.fired.is_none() {
510 self.fired = Some(Fired {
511 action: action.clone(),
512 payload,
513 confirm: confirm.map(ToOwned::to_owned),
514 });
515 }
516 }
517 }
518