//! Nodes to egui. //! //! Every function here takes a piece of [`quasi_router`]'s screen tree and draws //! it into a `Ui`. Nothing returns a `Result`: a description that exists is //! renderable by construction, which is the property the owned mirror in //! `quasi-router` was built to have. //! //! # Where the drawing goes //! //! Down, into `makeover-immediate`. A meter, a token, a control, a figure, a //! field and a table are all its, and this module is the walk that decides which //! one a node is and where it sits. The split is the same one `quasi-tui` keeps //! against `makeover-tui`, and it is what stops a second copy of the vocabulary's //! drawing existing per host. //! //! What is left here is what a `Screen` adds over a node: the address a control //! carries, the values a form gathers, and the selection a row's tick joins. //! None of that is `makeover-layout`'s, so none of it can be down there. //! //! # The walk is split in two, and the table is why //! //! [`draw`] is exhaustive over `Node` and dispatches to [`leaf`] for the nodes //! that carry no address, and to [`container`] for the ones that hold others or //! reach a screen's own facts. //! //! The split is forced rather than tidy: `makeover_immediate::table` draws a cell //! through a closure that already holds the `Ui` and the renderer, and a closure //! cannot also hold the pass mutably. So a cell draws its ordinary nodes through //! `leaf`, and collects the two that carry an address to fire after the table //! has finished with the borrow. use egui::{RichText, Ui}; use makeover_immediate::widget; use makeover_immediate::{Filling, field, frame}; use quasi_router::layout; use quasi_router::{Act, Action, Cells, Node, Params, Row, Slot}; use crate::{Immediate, Pass}; /// What a column is assumed to need when nothing measured it. /// /// `Sizing::lengths` is how an app says a column's longest value is wider than /// its name, and a described table carries no such measurement: the description /// says what a column *is*, not how long its contents turned out. So every /// column falls back to this, and `egui_extras` sizes the remainder. const CELL_WIDTH: f32 = 120.0; /// Draw one node. /// /// Exhaustive, with no catch-all arm, because [`Node`] carries no /// `#[non_exhaustive]` and that is deliberate upstream: a node added to the /// vocabulary should stop every renderer compiling until each has decided what /// it looks like. The same argument `Outcome` documents, one layer down. A /// wildcard here would convert that into a screen that silently draws less than /// it describes. pub(crate) fn draw(pass: &mut Pass<'_>, ui: &mut Ui, node: &Node) { match node { // The nodes that carry no address. Split out so a table cell can // draw them: the cell closure holds the `Ui` and cannot also hold // the pass mutably, and these need nothing but the palette. Node::Heading { .. } | Node::Text { .. } | Node::Rich { .. } | Node::Figure(_) | Node::Notice { .. } | Node::Meter(_) => leaf(pass.immediate, ui, node), Node::Act(act) => { act_node(pass, ui, act, None); } Node::Link { text, action, .. } => { // A link is a link and not a button: egui has `Link`, and a control // that navigates should not look like one that writes. if ui .link(RichText::new(text).color(pass.immediate.palette.action)) .clicked() { pass.fire(action, Params::new(), None); } } Node::Token(tag) => { let pressed = widget::token( ui, &tag.label, tag.kind, tag.tone, tag.latched, &pass.immediate.palette, &pass.immediate.widget, ); if let Some(action) = &tag.action && pressed.clicked() { pass.fire(action, Params::new(), None); } } Node::StandIn { message, act, .. } => { ui.label(RichText::new(message).color(pass.immediate.palette.content_muted)); if let Some(act) = act { act_node(pass, ui, act, None); } } other => container(pass, ui, other), } } /// The nodes that carry no address. /// /// Everything here needs the palette and nothing else, which is what makes a /// table cell able to draw one: the cell closure already holds the `Ui` and the /// renderer, and cannot also hold the pass mutably. /// /// The catch-all is unreachable through [`draw`], which is exhaustive and sends /// only these here. It is a private split of one walk rather than a second walk, /// so the guarantee that a new `Node` member stops the build lives up there. fn leaf(immediate: &Immediate, ui: &mut Ui, node: &Node) { match node { Node::Heading { level, text } => { let size = ui.text_style_height(&egui::TextStyle::Body) * match level { layout::Heading::Page => 1.6, layout::Heading::Section => 1.3, layout::Heading::Subsection => 1.1, }; ui.label( RichText::new(text) .size(size) .strong() .color(immediate.palette.content), ); } Node::Text { text, .. } => { ui.label(RichText::new(text).color(immediate.palette.content)); } // Markdown arrives as source, so that every renderer answers it its own // way. This one has no rich text of its own worth the name, so it takes // the plain rendering: `**bold**` reads as `bold` rather than as four // characters of syntax, which is the outcome `Node::Text` would have // given anyway and is the honest floor until egui grows a markdown // widget worth adopting. Node::Rich { source, .. } => { ui.label( RichText::new(docengine::render_plain(source)).color(immediate.palette.content), ); } Node::Figure(figure) => { widget::figure( ui, &figure.as_layout(), &immediate.palette, &immediate.widget, ); } Node::Notice { tone, text, .. } => { // The tone carries it, and the surface says it is a thing set on // the page rather than part of the flow. Where a toast lands // against a banner is renderer policy and this renderer has one // place to put either, which is where the caller drew it. frame( ui, layout::Depth::Raised, &immediate.palette, immediate.frame, |ui| { ui.label(RichText::new(text).color(immediate.palette.tone(*tone))); }, ); } Node::Meter(meter) => { widget::meter( ui, &meter.as_layout(), &immediate.palette, &immediate.widget, ); } _ => unreachable!("an addressed node reached the leaf walk"), } } /// The nodes that hold other nodes, or that a screen's own facts reach into. /// /// Split from [`draw`] where the line fell naturally rather than to satisfy a /// lint: everything above is a leaf that needs the palette and nothing else, /// and everything here needs the view, the selection or a nested walk. fn container(pass: &mut Pass<'_>, ui: &mut Ui, node: &Node) { match node { Node::Field(described) => { field_node(pass, ui, described); } Node::Region(slot) => { region(pass, ui, slot); } Node::Form { fields, submit, action, .. } => { form(pass, ui, fields, submit, action); } Node::List { rows, more, .. } => { for row in rows { list_row(pass, ui, row); } if let Some(rest) = more { // The count where the router knew one. `Rest::remaining` is // often `None`, which is the honest case: a list that cannot // say how many more there are still has a way to ask for them. let label = match rest.remaining { Some(n) => format!("Show {n} more"), None => "Show more".to_owned(), }; if ui.button(label).clicked() { pass.fire(&rest.action, Params::new(), None); } } } Node::Table { columns, rows } => { table(pass, ui, columns, rows); } Node::Select { options, chosen, action, .. } => { select(pass, ui, options, chosen.as_deref(), action.as_ref()); } Node::Stats { figures } => { // Across rather than down, which is the one thing a strip says: a // terminal stacks them because it has no width to spare, and a // window does. ui.horizontal(|ui| { for (figure, address) in figures { let shown = widget::figure( ui, &figure.as_layout(), &pass.immediate.palette, &pass.immediate.widget, ); // The one of goingson's five figure sites that renders its // value as a button: the description's half is the // vocabulary's and the optional address is quasi's. if let Some(action) = address && shown.interact(egui::Sense::click()).clicked() { pass.fire(action, Params::new(), None); } } }); } // Every leaf is answered by `draw`, which is exhaustive, so this // reaches nothing. It is here because the split is this crate's and // not the vocabulary's: `Node` still has no wildcard anywhere, and a // member added upstream still stops `draw` compiling. _ => unreachable!("a leaf reached the container walk"), } } /// A control, with whatever the screen wants gathered behind it. fn act_node(pass: &mut Pass<'_>, ui: &mut Ui, act: &Act, over: Option<&str>) { let described = act.as_layout(); let pressed = widget::act( ui, &described, &pass.immediate.palette, &pass.immediate.widget, ); if pressed.clicked() { let payload = over.map_or_else(Params::new, |under| pass.view.gathering(under)); pass.fire(&act.action, payload, act.confirm.as_deref()); } } /// One field, filled from the view rather than from the description. fn field_node(pass: &mut Pass<'_>, ui: &mut Ui, described: &quasi_router::Field) { let name = described.name.clone(); let kind = described.kind; let offered = described.value.clone(); if kind == layout::FieldKind::Checkbox { let mut on = pass .view .edit(&name) .map_or(offered.is_some(), |value| !value.is_empty()); let before = on; described.with_layout(|borrowed| { field( ui, &borrowed, Filling::On(&mut on), None, &pass.immediate.palette, &pass.immediate.field, ); }); if on != before { pass.view.set(&name, if on { "on" } else { "" }); if let Some(action) = &described.changes { let payload = Params::new().with(name, if on { "on" } else { "" }.to_owned()); pass.fire(action, payload, None); } } return; } // The buffer has to outlive the frame, so it is the view's. Taken out and // put back rather than borrowed across the closure, because the closure // also needs the palette off `pass`. let mut buffer = pass.view.buffer(&name, offered.as_deref()).clone(); let before = buffer.clone(); described.with_layout(|borrowed| { field( ui, &borrowed, Filling::Text(&mut buffer), None, &pass.immediate.palette, &pass.immediate.field, ); }); if buffer != before { pass.view.set(&name, buffer.clone()); if let Some(action) = &described.changes { let payload = Params::new().with(name, buffer); pass.fire(action, payload, None); } } } /// A form: its fields, then the one control that answers all of them. fn form( pass: &mut Pass<'_>, ui: &mut Ui, fields: &[quasi_router::Field], submit: &str, action: &Action, ) { for described in fields { field_node(pass, ui, described); } if ui.button(RichText::new(submit)).clicked() { let names: Vec = fields.iter().map(|f| f.name.clone()).collect(); let described = fields .iter() .filter_map(|f| f.value.clone().map(|v| (f.name.clone(), v))) .collect(); let payload = pass.view.submission(&names, &described); pass.fire(action, payload, None); } } /// One row of a list. fn list_row(pass: &mut Pass<'_>, ui: &mut Ui, row: &Row) { ui.horizontal(|ui| { // The tick, where the row can carry one. `toggle` first, because a row // carrying one has said the tick *is* the write and that beats the // screen's staged set. if let Some(ticked) = row.selected { let mut on = row .value .as_ref() .map_or(ticked, |value| pass.view.is_ticked(value)); if ui.checkbox(&mut on, "").changed() { if let Some(action) = &row.toggle { pass.fire(action, Params::new(), None); } else if let Some(value) = &row.value { pass.view.tick(value); } } } for part in &row.parts { row_part(pass, ui, part); } }); // Opening the row is the row itself, and it is a control rather than a // click on the whole strip: egui has no `:hover` affordance to say a strip // is pressable, so the primary text is the target the way a list row's // anchor is in the webview. if let Some(action) = &row.activate && ui .interact( ui.min_rect(), ui.id().with(("row", row.value.as_deref().unwrap_or(""))), egui::Sense::click(), ) .clicked() { pass.fire(action, Params::new(), None); } } /// One part of a row's run. /// /// A part is a role and a node, so the drawing is `draw` again: the role says /// where it sits in the run and the node says what it is. That is the property /// the containment migration bought every renderer, and it is why a row does not /// need a second switch over member types here. fn row_part(pass: &mut Pass<'_>, ui: &mut Ui, part: &quasi_router::Part) { draw(pass, ui, &part.node); } /// A set of choices, one of which is picked. fn select( pass: &mut Pass<'_>, ui: &mut Ui, options: &[(quasi_router::Choice, Option)], chosen: Option<&str>, action: Option<&Action>, ) { ui.horizontal(|ui| { for (choice, own) in options { let picked = chosen == Some(choice.value.as_str()); if ui.selectable_label(picked, &choice.label).clicked() { // An option naming its own route beats the strip's, which is // the half `makeover-layout` added the pair for: a tab strip // addressing one panel out of fifteen cannot be one route with // a value substituted in. if let Some(action) = own.as_ref().or(action) { let payload = Params::new().with(Node::SELECTED.to_owned(), choice.value.clone()); pass.fire(action, payload, None); } } } }); } /// A described table. /// /// The narrowing, the header carets and the tracks are all /// `makeover_immediate::table`'s; what is here is the walk that turns a /// described cell into the nodes inside it, and the two facts a `Screen` adds /// over a `Column`: the address a row opens, and the address a heading reorders /// by. /// /// **A cell is a run of nodes, so drawing one is [`draw`] again.** That is the /// property the containment migration bought every renderer, and it is why a /// button in a cell needs no special case here: it is a `Node::Act` like any /// other, and it fires through the same `Pass`. fn table(pass: &mut Pass<'_>, ui: &mut Ui, columns: &[quasi_router::Column], rows: &[Cells]) { let borrowed: Vec> = columns.iter().map(|c| c.as_layout()).collect(); // Every row's `current` flag, read by index. `Body::selected` takes a // predicate rather than a set, so an app whose selection is a range does not // have to build a collection to be asked. let current = |at: usize| rows.get(at).is_some_and(|row| row.current); let body = makeover_immediate::table::Body { rows: rows.len(), selected: Some(¤t), scroll_to: None, }; let sizing = makeover_immediate::table::Sizing { lengths: &[], fallback: CELL_WIDTH, }; // What the user pressed, collected rather than fired inside the closure: // the closure holds `&mut Ui` and the pass at once, and firing needs the // pass mutably. let mut fired: Option<(Action, Params)> = None; let reordered = makeover_immediate::table::table( ui, &borrowed, &body, &sizing, &pass.immediate.palette, &pass.immediate.table, |ui, column, at| { let Some(row) = rows.get(at) else { return }; let Some(index) = columns.iter().position(|c| c.name == column.name) else { return; }; let Some(cell) = row.values.get(index) else { return; }; for node in &cell.parts { // A cell's contents are ordinary nodes, but a press inside one // cannot reach the pass from here. Only the two that carry an // address are collected; everything else draws. match node { Node::Act(act) if act.state != Some(layout::State::Disabled) => { if widget::act( ui, &act.as_layout(), &pass.immediate.palette, &pass.immediate.widget, ) .clicked() { fired = Some((act.action.clone(), Params::new())); } } Node::Link { text, action } => { if ui .link(RichText::new(text).color(pass.immediate.palette.action)) .clicked() { fired = Some((action.clone(), Params::new())); } } other => leaf(pass.immediate, ui, other), } } // Opening the row itself, from whichever cell was clicked. A table // row has no single element to hang it on the way a list row hangs // it on its primary text. if let Some(action) = &row.activate && ui.response().clicked() { fired = Some((action.clone(), Params::new())); } }, ); if let Some((action, payload)) = fired { pass.fire(&action, payload, None); } // A heading that was pressed reorders by that column, and the column says // what that calls. `sortable` is `reorder.is_some()`, so a column with no // address answers no press. if let Some(column) = reordered && let Some(described) = columns.iter().find(|c| c.name == column.name) && let Some(action) = &described.reorder { pass.fire(action, Params::new(), None); } } /// A region, drawn as the surface its kind names. pub(crate) fn region(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot) { // Readiness first: a region that is not ready has nothing to draw and says // so, which is the whole of what the axis is for. match slot.readiness { layout::Readiness::Pending => { ui.spinner(); return; } layout::Readiness::Failed => { ui.label( RichText::new("This did not load.") .color(pass.immediate.palette.tone(layout::Tone::Danger)), ); return; } // `Empty` is drawn: what says a region is empty is a `Node::StandIn` // inside it, per `703f4cd2`, because a column with a heading and no // rows still has content. _ => {} } for node in &slot.body { draw(pass, ui, node); } }