Skip to main content

max / quasi

21.1 KB · 578 lines History Blame Raw
1 //! Nodes to egui.
2 //!
3 //! Every function here takes a piece of [`quasi_router`]'s screen tree and draws
4 //! it into a `Ui`. Nothing returns a `Result`: a description that exists is
5 //! renderable by construction, which is the property the owned mirror in
6 //! `quasi-router` was built to have.
7 //!
8 //! # Where the drawing goes
9 //!
10 //! Down, into `makeover-immediate`. A meter, a token, a control, a figure, a
11 //! field and a table are all its, and this module is the walk that decides which
12 //! one a node is and where it sits. The split is the same one `quasi-tui` keeps
13 //! against `makeover-tui`, and it is what stops a second copy of the vocabulary's
14 //! drawing existing per host.
15 //!
16 //! What is left here is what a `Screen` adds over a node: the address a control
17 //! carries, the values a form gathers, and the selection a row's tick joins.
18 //! None of that is `makeover-layout`'s, so none of it can be down there.
19 //!
20 //! # The walk is split in two, and the table is why
21 //!
22 //! [`draw`] is exhaustive over `Node` and dispatches to [`leaf`] for the nodes
23 //! that carry no address, and to [`container`] for the ones that hold others or
24 //! reach a screen's own facts.
25 //!
26 //! The split is forced rather than tidy: `makeover_immediate::table` draws a cell
27 //! through a closure that already holds the `Ui` and the renderer, and a closure
28 //! cannot also hold the pass mutably. So a cell draws its ordinary nodes through
29 //! `leaf`, and collects the two that carry an address to fire after the table
30 //! has finished with the borrow.
31
32 use egui::{RichText, Ui};
33 use makeover_immediate::widget;
34 use makeover_immediate::{Filling, field, frame};
35 use quasi_router::layout;
36 use quasi_router::{Act, Action, Cells, Node, Params, Row, Slot};
37
38 use crate::{Immediate, Pass};
39
40 /// What a column is assumed to need when nothing measured it.
41 ///
42 /// `Sizing::lengths` is how an app says a column's longest value is wider than
43 /// its name, and a described table carries no such measurement: the description
44 /// says what a column *is*, not how long its contents turned out. So every
45 /// column falls back to this, and `egui_extras` sizes the remainder.
46 const CELL_WIDTH: f32 = 120.0;
47
48 /// Draw one node.
49 ///
50 /// Exhaustive, with no catch-all arm, because [`Node`] carries no
51 /// `#[non_exhaustive]` and that is deliberate upstream: a node added to the
52 /// vocabulary should stop every renderer compiling until each has decided what
53 /// it looks like. The same argument `Outcome` documents, one layer down. A
54 /// wildcard here would convert that into a screen that silently draws less than
55 /// it describes.
56 pub(crate) fn draw(pass: &mut Pass<'_>, ui: &mut Ui, node: &Node) {
57 match node {
58 // The nodes that carry no address. Split out so a table cell can
59 // draw them: the cell closure holds the `Ui` and cannot also hold
60 // the pass mutably, and these need nothing but the palette.
61 Node::Heading { .. }
62 | Node::Text { .. }
63 | Node::Rich { .. }
64 | Node::Figure(_)
65 | Node::Notice { .. }
66 | Node::Meter(_) => leaf(pass.immediate, ui, node),
67
68 Node::Act(act) => {
69 act_node(pass, ui, act, None);
70 }
71
72 Node::Link { text, action, .. } => {
73 // A link is a link and not a button: egui has `Link`, and a control
74 // that navigates should not look like one that writes.
75 if ui
76 .link(RichText::new(text).color(pass.immediate.palette.action))
77 .clicked()
78 {
79 pass.fire(action, Params::new(), None);
80 }
81 }
82
83 Node::Token(tag) => {
84 let pressed = widget::token(
85 ui,
86 &tag.label,
87 tag.kind,
88 tag.tone,
89 tag.latched,
90 &pass.immediate.palette,
91 &pass.immediate.widget,
92 );
93 if let Some(action) = &tag.action
94 && pressed.clicked()
95 {
96 pass.fire(action, Params::new(), None);
97 }
98 }
99
100 Node::StandIn { message, act, .. } => {
101 ui.label(RichText::new(message).color(pass.immediate.palette.content_muted));
102 if let Some(act) = act {
103 act_node(pass, ui, act, None);
104 }
105 }
106
107 other => container(pass, ui, other),
108 }
109 }
110
111 /// The nodes that carry no address.
112 ///
113 /// Everything here needs the palette and nothing else, which is what makes a
114 /// table cell able to draw one: the cell closure already holds the `Ui` and the
115 /// renderer, and cannot also hold the pass mutably.
116 ///
117 /// The catch-all is unreachable through [`draw`], which is exhaustive and sends
118 /// only these here. It is a private split of one walk rather than a second walk,
119 /// so the guarantee that a new `Node` member stops the build lives up there.
120 fn leaf(immediate: &Immediate, ui: &mut Ui, node: &Node) {
121 match node {
122 Node::Heading { level, text } => {
123 let size = ui.text_style_height(&egui::TextStyle::Body)
124 * match level {
125 layout::Heading::Page => 1.6,
126 layout::Heading::Section => 1.3,
127 layout::Heading::Subsection => 1.1,
128 };
129 ui.label(
130 RichText::new(text)
131 .size(size)
132 .strong()
133 .color(immediate.palette.content),
134 );
135 }
136
137 Node::Text { text, .. } => {
138 ui.label(RichText::new(text).color(immediate.palette.content));
139 }
140
141 // Markdown arrives as source, so that every renderer answers it its own
142 // way. This one has no rich text of its own worth the name, so it takes
143 // the plain rendering: `**bold**` reads as `bold` rather than as four
144 // characters of syntax, which is the outcome `Node::Text` would have
145 // given anyway and is the honest floor until egui grows a markdown
146 // widget worth adopting.
147 Node::Rich { source, .. } => {
148 ui.label(
149 RichText::new(docengine::render_plain(source)).color(immediate.palette.content),
150 );
151 }
152
153 Node::Figure(figure) => {
154 widget::figure(
155 ui,
156 &figure.as_layout(),
157 &immediate.palette,
158 &immediate.widget,
159 );
160 }
161
162 Node::Notice { tone, text, .. } => {
163 // The tone carries it, and the surface says it is a thing set on
164 // the page rather than part of the flow. Where a toast lands
165 // against a banner is renderer policy and this renderer has one
166 // place to put either, which is where the caller drew it.
167 frame(
168 ui,
169 layout::Depth::Raised,
170 &immediate.palette,
171 immediate.frame,
172 |ui| {
173 ui.label(RichText::new(text).color(immediate.palette.tone(*tone)));
174 },
175 );
176 }
177
178 Node::Meter(meter) => {
179 widget::meter(
180 ui,
181 &meter.as_layout(),
182 &immediate.palette,
183 &immediate.widget,
184 );
185 }
186
187 _ => unreachable!("an addressed node reached the leaf walk"),
188 }
189 }
190
191 /// The nodes that hold other nodes, or that a screen's own facts reach into.
192 ///
193 /// Split from [`draw`] where the line fell naturally rather than to satisfy a
194 /// lint: everything above is a leaf that needs the palette and nothing else,
195 /// and everything here needs the view, the selection or a nested walk.
196 fn container(pass: &mut Pass<'_>, ui: &mut Ui, node: &Node) {
197 match node {
198 Node::Field(described) => {
199 field_node(pass, ui, described);
200 }
201
202 Node::Region(slot) => {
203 region(pass, ui, slot);
204 }
205
206 Node::Form {
207 fields,
208 submit,
209 action,
210 ..
211 } => {
212 form(pass, ui, fields, submit, action);
213 }
214
215 Node::List { rows, more, .. } => {
216 for row in rows {
217 list_row(pass, ui, row);
218 }
219 if let Some(rest) = more {
220 // The count where the router knew one. `Rest::remaining` is
221 // often `None`, which is the honest case: a list that cannot
222 // say how many more there are still has a way to ask for them.
223 let label = match rest.remaining {
224 Some(n) => format!("Show {n} more"),
225 None => "Show more".to_owned(),
226 };
227 if ui.button(label).clicked() {
228 pass.fire(&rest.action, Params::new(), None);
229 }
230 }
231 }
232
233 Node::Table { columns, rows } => {
234 table(pass, ui, columns, rows);
235 }
236
237 Node::Select {
238 options,
239 chosen,
240 action,
241 ..
242 } => {
243 select(pass, ui, options, chosen.as_deref(), action.as_ref());
244 }
245
246 Node::Stats { figures } => {
247 // Across rather than down, which is the one thing a strip says: a
248 // terminal stacks them because it has no width to spare, and a
249 // window does.
250 ui.horizontal(|ui| {
251 for (figure, address) in figures {
252 let shown = widget::figure(
253 ui,
254 &figure.as_layout(),
255 &pass.immediate.palette,
256 &pass.immediate.widget,
257 );
258 // The one of goingson's five figure sites that renders its
259 // value as a button: the description's half is the
260 // vocabulary's and the optional address is quasi's.
261 if let Some(action) = address
262 && shown.interact(egui::Sense::click()).clicked()
263 {
264 pass.fire(action, Params::new(), None);
265 }
266 }
267 });
268 }
269
270 // Every leaf is answered by `draw`, which is exhaustive, so this
271 // reaches nothing. It is here because the split is this crate's and
272 // not the vocabulary's: `Node` still has no wildcard anywhere, and a
273 // member added upstream still stops `draw` compiling.
274 _ => unreachable!("a leaf reached the container walk"),
275 }
276 }
277
278 /// A control, with whatever the screen wants gathered behind it.
279 fn act_node(pass: &mut Pass<'_>, ui: &mut Ui, act: &Act, over: Option<&str>) {
280 let described = act.as_layout();
281 let pressed = widget::act(
282 ui,
283 &described,
284 &pass.immediate.palette,
285 &pass.immediate.widget,
286 );
287 if pressed.clicked() {
288 let payload = over.map_or_else(Params::new, |under| pass.view.gathering(under));
289 pass.fire(&act.action, payload, act.confirm.as_deref());
290 }
291 }
292
293 /// One field, filled from the view rather than from the description.
294 fn field_node(pass: &mut Pass<'_>, ui: &mut Ui, described: &quasi_router::Field) {
295 let name = described.name.clone();
296 let kind = described.kind;
297 let offered = described.value.clone();
298
299 if kind == layout::FieldKind::Checkbox {
300 let mut on = pass
301 .view
302 .edit(&name)
303 .map_or(offered.is_some(), |value| !value.is_empty());
304 let before = on;
305 described.with_layout(|borrowed| {
306 field(
307 ui,
308 &borrowed,
309 Filling::On(&mut on),
310 None,
311 &pass.immediate.palette,
312 &pass.immediate.field,
313 );
314 });
315 if on != before {
316 pass.view.set(&name, if on { "on" } else { "" });
317 if let Some(action) = &described.changes {
318 let payload = Params::new().with(name, if on { "on" } else { "" }.to_owned());
319 pass.fire(action, payload, None);
320 }
321 }
322 return;
323 }
324
325 // The buffer has to outlive the frame, so it is the view's. Taken out and
326 // put back rather than borrowed across the closure, because the closure
327 // also needs the palette off `pass`.
328 let mut buffer = pass.view.buffer(&name, offered.as_deref()).clone();
329 let before = buffer.clone();
330 described.with_layout(|borrowed| {
331 field(
332 ui,
333 &borrowed,
334 Filling::Text(&mut buffer),
335 None,
336 &pass.immediate.palette,
337 &pass.immediate.field,
338 );
339 });
340 if buffer != before {
341 pass.view.set(&name, buffer.clone());
342 if let Some(action) = &described.changes {
343 let payload = Params::new().with(name, buffer);
344 pass.fire(action, payload, None);
345 }
346 }
347 }
348
349 /// A form: its fields, then the one control that answers all of them.
350 fn form(
351 pass: &mut Pass<'_>,
352 ui: &mut Ui,
353 fields: &[quasi_router::Field],
354 submit: &str,
355 action: &Action,
356 ) {
357 for described in fields {
358 field_node(pass, ui, described);
359 }
360 if ui.button(RichText::new(submit)).clicked() {
361 let names: Vec<String> = fields.iter().map(|f| f.name.clone()).collect();
362 let described = fields
363 .iter()
364 .filter_map(|f| f.value.clone().map(|v| (f.name.clone(), v)))
365 .collect();
366 let payload = pass.view.submission(&names, &described);
367 pass.fire(action, payload, None);
368 }
369 }
370
371 /// One row of a list.
372 fn list_row(pass: &mut Pass<'_>, ui: &mut Ui, row: &Row) {
373 ui.horizontal(|ui| {
374 // The tick, where the row can carry one. `toggle` first, because a row
375 // carrying one has said the tick *is* the write and that beats the
376 // screen's staged set.
377 if let Some(ticked) = row.selected {
378 let mut on = row
379 .value
380 .as_ref()
381 .map_or(ticked, |value| pass.view.is_ticked(value));
382 if ui.checkbox(&mut on, "").changed() {
383 if let Some(action) = &row.toggle {
384 pass.fire(action, Params::new(), None);
385 } else if let Some(value) = &row.value {
386 pass.view.tick(value);
387 }
388 }
389 }
390
391 for part in &row.parts {
392 row_part(pass, ui, part);
393 }
394 });
395
396 // Opening the row is the row itself, and it is a control rather than a
397 // click on the whole strip: egui has no `:hover` affordance to say a strip
398 // is pressable, so the primary text is the target the way a list row's
399 // anchor is in the webview.
400 if let Some(action) = &row.activate
401 && ui
402 .interact(
403 ui.min_rect(),
404 ui.id().with(("row", row.value.as_deref().unwrap_or(""))),
405 egui::Sense::click(),
406 )
407 .clicked()
408 {
409 pass.fire(action, Params::new(), None);
410 }
411 }
412
413 /// One part of a row's run.
414 ///
415 /// A part is a role and a node, so the drawing is `draw` again: the role says
416 /// where it sits in the run and the node says what it is. That is the property
417 /// the containment migration bought every renderer, and it is why a row does not
418 /// need a second switch over member types here.
419 fn row_part(pass: &mut Pass<'_>, ui: &mut Ui, part: &quasi_router::Part) {
420 draw(pass, ui, &part.node);
421 }
422
423 /// A set of choices, one of which is picked.
424 fn select(
425 pass: &mut Pass<'_>,
426 ui: &mut Ui,
427 options: &[(quasi_router::Choice, Option<Action>)],
428 chosen: Option<&str>,
429 action: Option<&Action>,
430 ) {
431 ui.horizontal(|ui| {
432 for (choice, own) in options {
433 let picked = chosen == Some(choice.value.as_str());
434 if ui.selectable_label(picked, &choice.label).clicked() {
435 // An option naming its own route beats the strip's, which is
436 // the half `makeover-layout` added the pair for: a tab strip
437 // addressing one panel out of fifteen cannot be one route with
438 // a value substituted in.
439 if let Some(action) = own.as_ref().or(action) {
440 let payload =
441 Params::new().with(Node::SELECTED.to_owned(), choice.value.clone());
442 pass.fire(action, payload, None);
443 }
444 }
445 }
446 });
447 }
448
449 /// A described table.
450 ///
451 /// The narrowing, the header carets and the tracks are all
452 /// `makeover_immediate::table`'s; what is here is the walk that turns a
453 /// described cell into the nodes inside it, and the two facts a `Screen` adds
454 /// over a `Column`: the address a row opens, and the address a heading reorders
455 /// by.
456 ///
457 /// **A cell is a run of nodes, so drawing one is [`draw`] again.** That is the
458 /// property the containment migration bought every renderer, and it is why a
459 /// button in a cell needs no special case here: it is a `Node::Act` like any
460 /// other, and it fires through the same `Pass`.
461 fn table(pass: &mut Pass<'_>, ui: &mut Ui, columns: &[quasi_router::Column], rows: &[Cells]) {
462 let borrowed: Vec<layout::Column<'_>> = columns.iter().map(|c| c.as_layout()).collect();
463
464 // Every row's `current` flag, read by index. `Body::selected` takes a
465 // predicate rather than a set, so an app whose selection is a range does not
466 // have to build a collection to be asked.
467 let current = |at: usize| rows.get(at).is_some_and(|row| row.current);
468 let body = makeover_immediate::table::Body {
469 rows: rows.len(),
470 selected: Some(&current),
471 scroll_to: None,
472 };
473 let sizing = makeover_immediate::table::Sizing {
474 lengths: &[],
475 fallback: CELL_WIDTH,
476 };
477
478 // What the user pressed, collected rather than fired inside the closure:
479 // the closure holds `&mut Ui` and the pass at once, and firing needs the
480 // pass mutably.
481 let mut fired: Option<(Action, Params)> = None;
482
483 let reordered = makeover_immediate::table::table(
484 ui,
485 &borrowed,
486 &body,
487 &sizing,
488 &pass.immediate.palette,
489 &pass.immediate.table,
490 |ui, column, at| {
491 let Some(row) = rows.get(at) else { return };
492 let Some(index) = columns.iter().position(|c| c.name == column.name) else {
493 return;
494 };
495 let Some(cell) = row.values.get(index) else {
496 return;
497 };
498 for node in &cell.parts {
499 // A cell's contents are ordinary nodes, but a press inside one
500 // cannot reach the pass from here. Only the two that carry an
501 // address are collected; everything else draws.
502 match node {
503 Node::Act(act) if act.state != Some(layout::State::Disabled) => {
504 if widget::act(
505 ui,
506 &act.as_layout(),
507 &pass.immediate.palette,
508 &pass.immediate.widget,
509 )
510 .clicked()
511 {
512 fired = Some((act.action.clone(), Params::new()));
513 }
514 }
515 Node::Link { text, action } => {
516 if ui
517 .link(RichText::new(text).color(pass.immediate.palette.action))
518 .clicked()
519 {
520 fired = Some((action.clone(), Params::new()));
521 }
522 }
523 other => leaf(pass.immediate, ui, other),
524 }
525 }
526 // Opening the row itself, from whichever cell was clicked. A table
527 // row has no single element to hang it on the way a list row hangs
528 // it on its primary text.
529 if let Some(action) = &row.activate
530 && ui.response().clicked()
531 {
532 fired = Some((action.clone(), Params::new()));
533 }
534 },
535 );
536
537 if let Some((action, payload)) = fired {
538 pass.fire(&action, payload, None);
539 }
540
541 // A heading that was pressed reorders by that column, and the column says
542 // what that calls. `sortable` is `reorder.is_some()`, so a column with no
543 // address answers no press.
544 if let Some(column) = reordered
545 && let Some(described) = columns.iter().find(|c| c.name == column.name)
546 && let Some(action) = &described.reorder
547 {
548 pass.fire(action, Params::new(), None);
549 }
550 }
551
552 /// A region, drawn as the surface its kind names.
553 pub(crate) fn region(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot) {
554 // Readiness first: a region that is not ready has nothing to draw and says
555 // so, which is the whole of what the axis is for.
556 match slot.readiness {
557 layout::Readiness::Pending => {
558 ui.spinner();
559 return;
560 }
561 layout::Readiness::Failed => {
562 ui.label(
563 RichText::new("This did not load.")
564 .color(pass.immediate.palette.tone(layout::Tone::Danger)),
565 );
566 return;
567 }
568 // `Empty` is drawn: what says a region is empty is a `Node::StandIn`
569 // inside it, per `703f4cd2`, because a column with a heading and no
570 // rows still has content.
571 _ => {}
572 }
573
574 for node in &slot.body {
575 draw(pass, ui, node);
576 }
577 }
578