//! 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 std::collections::BTreeMap; use egui::{RichText, Ui}; use makeover_immediate::widget; use makeover_immediate::{Filling, field, frame}; use quasi_router::layout; use quasi_router::{ Act, Action, Bar, Clock, Image, Node, Params, Placed, RegionKind, Rest, Row, Slot, }; use crate::view::Asking; 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. /// /// Every member is named, and the containers are listed one by one rather than /// swept up. /// /// # Why there is a catch-all anyway /// /// [`Node`] is `#[non_exhaustive]`, so one is compulsory: a node added upstream /// does not stop this crate compiling, and the member after this one is not a /// lockstep release across three renderers. /// /// The catch-all goes to [`undrawn`], which draws a line saying the renderer /// does not know this node yet, so a screen quietly drawing less than it /// describes is the one outcome that cannot happen. /// /// The discipline the paragraph above asks for still applies, it is not the /// compiler's job: name every member, and treat a member that reaches the /// catch-all as one still owed an arm. Neither half of the split may end in a /// wildcard that *does* something, routing to `container` or to `leaf`, since /// that is what turns an unknown node into a panic. 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::Code { .. } | Node::Figure(_) | Node::Image(_) | Node::Meter(_) | Node::Since { .. } | Node::Until { .. } | Node::Age { .. } => leaf(pass.immediate, ui, node), // A notice with something to do about it is addressed, so it cannot go // to the leaf walk, which has no `Pass` to fire through. The text half // is still `leaf`'s, so there is one notice drawing and not two. Node::Notice { act, .. } => { leaf(pass.immediate, ui, node); if let Some(act) = act { act_node(pass, ui, act); } } Node::Act(act) => { act_node(pass, ui, act); } 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 mut pressed = widget::token( ui, &tag.label, tag.kind, tag.tone, tag.latched, &pass.immediate.palette, &pass.immediate.widget, ); // `436bc223`: the detail behind the label. egui has hover, so this // renderer draws it -- `on_hover_text` is the same affordance the // webview spends `title` on, and a token is already a `Response`, // so nothing in makeover-immediate had to grow a parameter for it. if let Some(hint) = &tag.hint { pressed = pressed.on_hover_text(hint); } 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); } } // The containers, named rather than swept up, so that a member added // upstream stops this match compiling instead of reaching `container` // and panicking there. Node::Field(_) | Node::Region(_) | Node::Form { .. } | Node::Table { .. } | Node::Timeline { .. } | Node::Stats { .. } => container(pass, ui, node), // A member added since this renderer last learned the vocabulary. It // says so and draws nothing else; see this function's header for what // `#[non_exhaustive]` cost here and what it bought. _ => undrawn(pass.immediate, ui), } } /// 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), ); } // `19d7602d`. The runs arrived classified, so all this owes is a // colour each and a monospace face. egui lays out a horizontal run of // coloured labels, which is what a code line is. // // The palette has no syntax colours and will not grow any: a // highlighting palette is held fixed while a theme changes, which is // the opposite of what a palette is for. So this spends the status // colours, on the terminal renderer's reasoning and with the same // base16 Tomorrow pairing. Node::Code { runs, inline, .. } => { let colour = |syntax: layout::Syntax| match syntax { layout::Syntax::Comment => immediate.palette.content_muted, layout::Syntax::String => immediate.palette.success, layout::Syntax::Keyword => immediate.palette.action, layout::Syntax::Constant => immediate.palette.warning, layout::Syntax::Entity => immediate.palette.info, layout::Syntax::Variable => immediate.palette.danger, layout::Syntax::Support => immediate.palette.content_secondary, // Plain, and any class added since this renderer last learned // the vocabulary: ordinary code, drawn and uncoloured. _ => immediate.palette.content, }; let draw_runs = |ui: &mut Ui| { ui.spacing_mut().item_spacing.x = 0.0; for run in runs { ui.label( RichText::new(run.text.clone()) .monospace() .color(colour(run.syntax)), ); } }; // A block owns its lines and an inline literal sits in one, which // is the same distinction the node's `inline` flag makes for // containment. egui has no wrapping run of styled text, so a block // is a vertical of horizontals per source line and an inline is one // horizontal. if *inline { ui.horizontal(draw_runs); } else { ui.vertical(draw_runs); } } Node::Figure(figure) => { widget::figure( ui, &figure.as_layout(), &immediate.palette, &immediate.widget, ); } Node::Image(picture) => { image(immediate, ui, picture); } 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, ); } // The bars are borrowed here for the reason every compound member is: // the description owns them and `makeover-immediate` draws the borrowed // ones. Node::Chart { axis, bars, .. } => { let borrowed: Vec<_> = bars.iter().map(Bar::as_layout).collect(); widget::chart( ui, &axis.as_layout(), &borrowed, &immediate.palette, &immediate.widget, ); } // The readouts derived from the current time. The instant is the // description's and the words are this crate's, made fresh here because // an immediate host redraws from the description every frame -- which // is the half of this ruling egui gets for free. The half it does not // is asking for the next frame, and that is `Runtime::show`. Node::Since { at } => clock_label(immediate, ui, Clock::Since, *at), Node::Until { at } => clock_label(immediate, ui, Clock::Until, *at), Node::Age { at } => clock_label(immediate, ui, Clock::Age, *at), // An addressed node reaching here is this crate's own routing bug, so // it stays a panic and the members are named to keep it one. Sweeping // them into the arm below would turn a wrong branch in `draw` into a // screen that quietly drew a placeholder where a button was. Node::Act(_) | Node::Link { .. } | Node::Token(_) | Node::StandIn { .. } | Node::Field(_) | Node::Form { .. } | Node::Table { .. } | Node::Timeline { .. } | Node::Stats { .. } | Node::Region(_) => unreachable!("an addressed node reached the leaf walk"), // A member added since this renderer last learned the vocabulary. // // This arm is why the one above lists its members. A table cell walks // its own node match and ends `other => leaf(..)`, so an unknown member // inside a cell arrives here rather than at [`draw`] -- and until this // existed it arrived at the `unreachable!` and panicked the frame, // which is the same failure the note on [`draw`] records against // `Node::Image` and `Node::Timeline`. _ => undrawn(immediate, ui), } } /// A time-derived readout, as one label. /// /// The clock is read here rather than passed in, which is the shape the ruling /// asks for: the renderer owns it. A test that needs a fixed answer calls /// [`crate::clock::text`] with a `now` of its own. fn clock_label(immediate: &Immediate, ui: &mut Ui, clock: Clock, at: std::time::SystemTime) { let words = crate::clock::text(clock, at, std::time::SystemTime::now()); ui.label(RichText::new(words).color(immediate.palette.content)); } /// What a node this renderer has not learned yet draws instead of itself. /// /// One muted line, the same the description's own [`Node::StandIn`] gets, /// because it is the same situation said by the renderer instead of by the /// handler: something is here and you are not seeing it. /// /// Saying so rather than drawing nothing is the whole point. The failure this /// crate has already had twice is a screen that silently drew less than it /// described, and a blank where a node was is indistinguishable from a screen /// that never carried one. fn undrawn(immediate: &Immediate, ui: &mut Ui) { ui.label( RichText::new("(not drawn: this renderer does not know this yet)") .italics() .color(immediate.palette.content_muted), ); } /// What a [`RegionKind::Handover`] with no fill says. /// /// [`undrawn`]'s sibling, for the other way a region ends up empty. A handover /// is a fill the app owes every host, so this renderer having none is a hole /// rather than a finished region. /// /// [`RegionKind::Ceded`] gets no equivalent on purpose: nothing is owed there, /// so drawing nothing is correct and a notice would invent a gap the app has /// already ruled on. fn unfilled(immediate: &Immediate, ui: &mut Ui) { ui.label( RichText::new("(not drawn: this host has no fill for this)") .italics() .color(immediate.palette.content_muted), ); } /// The controls for a set that arrived in parts. /// /// Back, position, forward, in a row, which is the order the other two renderers /// put them in. A direction the description gave no address for is drawn /// disabled rather than left out: an immediate-mode pass rebuilds this row every /// frame, so a button that appeared on page two would move the position label /// under the pointer mid-session. /// /// Where the description offered jumps the numbered pages take the position's /// place, as the row of controls this host draws everything else as. fn rest_controls(pass: &mut Pass<'_>, ui: &mut Ui, rest: &Rest) { let paging = rest.as_layout(); ui.horizontal(|ui| { if ui .add_enabled(rest.back.is_some(), egui::Button::new("Prev")) .clicked() && let Some(action) = &rest.back { pass.fire(action, Params::new(), None); } if rest.jumps.is_empty() { ui.label(match (paging.page(), paging.pages_total()) { (Some(page), Some(total)) => format!("{page} / {total}"), _ => match paging.total() { Some(total) => format!("{} of {total}", paging.shown()), None => "Showing what arrived".to_owned(), }, }); } else { // The offered pages in place of the readout, for the other two // renderers' reason: the strip says which page and how many // already, and printing the position beside it is a control // arguing with itself. for jump in &rest.jumps { if jump.here { // A label and not a disabled button. A control that // reloads the page it is on is an affordance that does // nothing, and this is the one page in the strip that is a // readout rather than somewhere to go. ui.label(jump.page.to_string()); continue; } if ui.button(jump.page.to_string()).clicked() { pass.fire(&jump.action, Params::new(), None); } } } if ui .add_enabled(rest.forward.is_some(), egui::Button::new("Next")) .clicked() && let Some(action) = &rest.forward { pass.fire(action, Params::new(), None); } }); } /// A picture, at a source the description does not carry. /// /// Drawn here rather than handed down to `makeover-immediate`, and that is the /// same call `quasi-webview` makes for the same reason: the source is the /// member the drawing turns on, `makeover-layout` deliberately has no notion of /// an address, and what is left below the source is a box and a line of text. /// `makeover-webview` contributes `picture_rules` and writes no `` either. fn image(immediate: &Immediate, ui: &mut Ui, picture: &Image) { let available = ui.available_width(); let mut shown = egui::Image::new(&picture.src) // A picture the screen needs now advertises that it is coming; one the // description says can wait does not put a spinner in the flow for it. .show_loading_spinner(matches!(picture.loading, layout::Loading::Eager)); // What egui paints where the bytes do not arrive. An empty `alt` is a claim // that the picture adds nothing to the text beside it, so standing in for it // with anything at all would be worse than the gap -- the argument // `layout::Image::speaks` carries, and the call `quasi-tui` makes too. if picture.speaks() { shown = shown.alt_text(picture.alt.clone()); } // `Fit::Natural` and `Fit::Contain` are both what happens below: the whole // picture, at its own proportions, inside the width on offer. // // `Fit::Cover` is not, and cannot be here. Cropping needs a box to crop to, // and a node in an egui vertical flow is given a width and an unbounded // height -- there is no shape to fill. The webview gets that shape from the // stylesheet and this host has no stylesheet. Drawing it whole is wrong by // one member and drawing it cropped to a rectangle nobody described is // wrong by more, so it draws whole. Filed. shown = match picture.intrinsic { // The dimensions doing the one job they exist for: the box is the right // shape before a byte arrives, so nothing below it moves when the // texture lands. Never scaled up -- a 5120-wide screenshot is not // asking for a 5120-wide window. Some(extent) if extent.width > 0 && extent.height > 0 => { let width = available.min(f64_ish(extent.width)); let height = width * f64_ish(extent.height) / f64_ish(extent.width); shown.fit_to_exact_size(egui::vec2(width, height)) } // Not known, so nothing can be held. egui sizes the texture once it has // it and whatever is below moves once, which is the honest outcome and // the one `layout::Image::intrinsic` exists to let an app avoid. _ => shown.max_width(available).maintain_aspect_ratio(true), }; ui.add(shown); // Content that happens to sit under a picture, so it reads the same whether // or not the picture arrived. Not muted, unlike the alt text, which is // standing in for something rather than being it. if let Some(caption) = &picture.caption { ui.label(RichText::new(caption).color(immediate.palette.content_muted)); } } /// A picture's pixel count as a drawing measure. /// /// `as` on a `u32` is a lossy cast a pedantic lint is right to want justified, /// and the justification is that it is a texture dimension: `f32` is exact to /// 16.7 million and no picture is that wide. #[expect( clippy::cast_precision_loss, reason = "a texture dimension is exact in f32 far past any real picture" )] fn f64_ish(pixels: u32) -> f32 { pixels as f32 } /// How tall one tick of a track is, in lines of body text. /// /// The least that lets the ruler label itself without the labels touching, and /// every other measure on the axis falls out of it. A pixel height is /// presentation and `layout::Track` deliberately carries none, so this is the /// renderer choosing, the way the webview's stylesheet chooses. const TICK_LINES: f32 = 1.5; /// The gutter a ruler writes its labels in, in labels' widths. const RULER_PADDING: f32 = 8.0; /// Rows placed by when they happen. /// /// The lane packing, the gridlines and how tall a slot is are all this /// renderer's, and `layout::Track` says so: it carries the window, the /// granularity and the unit, and nothing measured in pixels. What travels is /// the two integers per entry that order alone cannot say. fn timeline( pass: &mut Pass<'_>, ui: &mut Ui, track: layout::Track, entries: &[Placed], focus: Option, ) { let text_height = ui.text_style_height(&egui::TextStyle::Body); let slots = track.slots(); let per_tick = if track.slot == 0 || track.tick == 0 { 0 } else { track.tick / track.slot }; let slot_height = if per_tick == 0 { text_height } else { text_height * TICK_LINES / f32::from(per_tick) }; let height = f32::from(slots) * slot_height; // The gutter is as wide as the labels going in it, measured rather than // guessed: a day strip writes `31` and a clock writes `00:00`, and a // constant wide enough for one wastes half of itself on the other. let sample = tick_label(track.unit, track.span.from()); let ruler = ui .painter() .layout_no_wrap( sample, egui::TextStyle::Body.resolve(ui.style()), pass.immediate.palette.content_muted, ) .rect .width() + RULER_PADDING; let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width(), height), egui::Sense::hover(), ); let painter = ui.painter().clone(); let palette = pass.immediate.palette; // The ruler. A rule per slot and a label per tick, both the axis describing // itself, so neither is an entry and neither is reachable. for slot in 0..slots { let y = rect.top() + f32::from(slot) * slot_height; let ticked = per_tick > 0 && slot % per_tick == 0; painter.hline( rect.x_range(), y, egui::Stroke::new( 1.0, if ticked { palette.bevel_dark } else { palette.sunken }, ), ); if ticked { painter.text( egui::pos2(rect.left(), y), egui::Align2::LEFT_TOP, tick_label(track.unit, track.span.from() + slot * track.slot), egui::TextStyle::Body.resolve(ui.style()), palette.content_muted, ); } } // Lanes. Which lane an entry takes is a fact about how wide the box is and // not about the day, so it is worked out here rather than described: the // description said when things happen and `Placed::overlaps` turns that into // who collides. Greedy first-fit, the standard day-view packing -- an entry // takes the lowest lane no occupant of which it overlaps. O(n^2) worst case // over a day's worth of appointments, so the interval graph is not worth its // own bugs. let mut lanes: Vec = Vec::with_capacity(entries.len()); for (i, entry) in entries.iter().enumerate() { let mut lane = 0; while entries[..i] .iter() .zip(&lanes) .any(|(other, &taken)| taken == lane && entry.overlaps(other)) { lane += 1; } lanes.push(lane); } // One width for the whole track rather than per collision cluster, which is // the same call the webview makes and for the same reason: per-cluster is // denser and is a layout decision either renderer can revisit without the // description changing. let across = lanes.iter().copied().max().map_or(1, |most| most + 1); let body = rect.with_min_x(rect.left() + ruler); let lane_width = body.width() / f32::from(u16::try_from(across).unwrap_or(u16::MAX).max(1)); for (entry, &lane) in entries.iter().zip(&lanes) { let top = rect.top() + track.fraction(entry.placement.at()) * height; let bottom = rect.top() + track.fraction(entry.placement.end()) * height; let at = egui::Rect::from_min_size( egui::pos2( body.left() + f32::from(u16::try_from(lane).unwrap_or(u16::MAX)) * lane_width, top, ), // A placement is never zero-length, but a short one against a long // span still rounds to nothing, and a thing that happened is worth // a line whatever its duration. egui::vec2(lane_width, (bottom - top).max(text_height)), ); painter.rect_filled(at, 2.0, palette.well); ui.scope_builder(egui::UiBuilder::new().max_rect(at.shrink(2.0)), |ui| { // Each entry gets its own scoped `Ui`, so the position is only ever // 0 here and the id is distinct regardless. list_row(pass, ui, &entry.row, 0, false); }); } // "Show me 09:00" rather than a scroll offset, which is the whole of what // `focus` is: the app knows the interesting moment and the renderer knows // how to get there. Once per moment and not once per frame -- egui redraws // continuously, and a scroll request every frame is a track the user cannot // scroll away from. if let Some(minute) = focus { let id = ui.id().with("track-focus"); if ui.data(|data| data.get_temp::(id)) != Some(minute) { ui.data_mut(|data| data.insert_temp(id, minute)); let y = rect.top() + track.fraction(minute) * height; ui.scroll_to_rect( egui::Rect::from_min_size( egui::pos2(rect.left(), y), egui::vec2(rect.width(), text_height), ), Some(egui::Align::Center), ); } } } /// What a tick says about where it sits. /// /// The one thing on an axis that cannot be derived from the numbers, which is /// why `layout::Unit` exists: the geometry above is unit-agnostic and was /// correct while the ruler printed `00:00` over a month strip. fn tick_label(unit: layout::Unit, offset: u16) -> String { match unit { // Wall clock, wrapped, so a span running past midnight labels 02:00 // rather than 26:00. `Span` counts past 1440 deliberately so that it // needs no date, and how that reads to a person is the renderer's. layout::Unit::Minutes => format!("{:02}:{:02}", (offset / 60) % 24, offset % 60), // Day one, not day zero. A strip's offsets are zero-based like every // other axis here and nobody calls the first of the month the zeroth. layout::Unit::Days => format!("{}", offset + 1), // A unit added later lands here rather than silently taking the clock, // which is exactly how the clock-over-a-month defect shipped. _ => String::new(), } } /// 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); } // **A table that declared no columns is a list, and draws as one.** One // node since the 2026-09-06 collapse; the guard is where the two // arrangements part company in this renderer, and the arm below is the // grid. Both hold the same `Row`. Node::Table { columns, rows, more, .. } if columns.is_empty() => { // A row a shut branch covers is not drawn at all, which is the // whole of what folding is. `quasi_router::folded` reads it, so a // window and a terminal fold the same rows. // // The chevron column is spent on every row of a list that holds a // branch, leaf or not, so the labels line up under each other. let branches = rows.iter().any(|row| row.open.is_some()); let shown = unfolded(rows, pass.view); for (within, row) in rows.iter().enumerate() { // The row's own place among its siblings, kept even when a // branch above it is shut: it names this row's hit target, and // renumbering on a fold would hand one row the target of // another. if !shown.iter().any(|kept| std::ptr::eq(*kept, row)) { continue; } list_row(pass, ui, row, within, branches); } if let Some(rest) = more { rest_controls(pass, ui, rest); } } Node::Table { columns, rows, more, .. } => { table(pass, ui, columns, rows); // Under the table and belonging to it, the same row of controls a // list gets. if let Some(rest) = more { rest_controls(pass, ui, rest); } } Node::Timeline { track, entries, focus, .. } => { timeline(pass, ui, *track, entries, *focus); } 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) { // The set it acts on, read off the control. This was a parameter until // 2026-08-20 and both callers passed `None`, so `Act::over` reached this // renderer and did nothing: the count was never drawn, the control over an // empty set stayed live, and the ticks never left with the call. The two // other renderers read the member directly, which is why neither drifted. let over = act.over.as_deref(); // What the press asks for before it fires, drawn above the control. A // webview hides these behind the verb in a `details`; there is no such // affordance here that would not be a popup this renderer opened and closed // on its own, and an immediate-mode screen redraws every frame anyway, so // the boxes stand in the open. `as_asked` drops the write members: the value // is answered by the control below, not by a route of the box's own. for field in &act.asks { field_node(pass, ui, &field.as_asked()); } // A control over the screen's selection says how many it would act on, and // is disabled while that is none. Neither is sayable in the description: the // ticks are the host's until something submits them, so a screen built from // the store cannot know the number, and a commit control over an empty set // is otherwise offered, pressed, and answers "0 tasks completed" -- a screen // letting the user find out by trying. // // Disabled rather than hidden. `bulk-actions.js` hides its bar and can // afford to, because its rows keep their checkboxes; a bar that vanishes // takes with it the only evidence that bulk actions exist. let chosen = over.map(|_| pass.view.ticks().count()); let label = match chosen { Some(0) | None => act.label.clone(), Some(chosen) => format!("{} ({chosen})", act.label), }; // A control that has been pressed and not answered yet is drawn as what it // is: working, and not taking another press. The label is untouched, so the // control does not change width the moment it is pressed, and disabled is // the guard as well as the saying here -- a disabled widget reports no // click, so the second press does not exist rather than being discarded. let busy = pass.view.busy(&act.action); let described = layout::Act { label: &label, key: act.key.as_deref(), tone: act.tone, state: if chosen == Some(0) || busy { Some(layout::State::Disabled) } else { act.state }, // Carried across since makeover-layout 0.40.0, so `widget::act` draws // the hover this function used to apply to its answer. hint: act.hint.as_deref(), }; // `db998898`. What the control shows, above what it says, which is the tile // shape both measured sites have. Drawn here rather than handed down for // `Act::hint`'s reason: `makeover_layout::Act` carries no picture, that // crate declares `links`, and a member there is one version bump across 25 // manifests in 12 repos. // // Grouped so the picture and the button are one item in the flow and the // press stays the button's: a picture that answered a click would be this // renderer inventing an affordance the description did not ask for, since // `shows` says what a control looks like and never that the picture is a // second control. let pressed = match &act.shows { Some(picture) => { ui.vertical(|ui| { image(pass.immediate, ui, picture); widget::act( ui, &described, &pass.immediate.palette, &pass.immediate.widget, ) }) .inner } None => widget::act( ui, &described, &pass.immediate.palette, &pass.immediate.widget, ), }; // `ca7b5200`. Standing help, as a hover -- honest on this host in a way it // is not on a terminal, because egui has a pointer. // // `widget::act` does it since makeover-layout 0.40.0 moved `hint` down, so // nothing is applied here any more. It was applied outside the widget for // three releases because `makeover_layout::Act` carried no hint, and the // consequence was that a makeover host that was not quasi could not say it. // // `ae8e8836`. Where this control was drawn, for an `Outcome::Anchored` that // names it. Only a named control is noted -- `Act::id` is `None` on nearly // all of them -- so a screen anchoring nothing pays one `Option` check per // control and no allocation. if let Some(id) = &act.id { crate::geometry::note_act(ui, id, pressed.rect); } if pressed.clicked() && chosen != Some(0) && !busy { let mut payload = gathered(pass, over); payload.absorb(asked(pass, &act.asks)); deposit(pass, act); // `c3e145e0`. This host is the one of the three that needs no help: egui // owns the clipboard already, so the copy happens here rather than // reaching the host as a webview script or a `quasi_tui::Step`. // // Beside `deposit` and before the call, matching the other two: the // local half of a press happens whether or not anything is asked, and a // copying act asks nothing -- its action is local, so `fire` below has // no route to call. if let Some(value) = &act.copies { ui.ctx().copy_text(value.clone()); } pass.fire(&act.action, payload, act.confirm.as_deref()); } } /// Put the act's value into the box it named. /// /// [`Act::fills`](quasi_router::Act::fills) names a field on the same screen /// and the renderer decides where in it the value lands; here that is the end /// of what is already there. egui holds a text cursor per widget and this /// deliberately does not reach for it: the description names a destination and /// never a position, and a value that arrives at the end is what the /// vocabulary calls correct rather than a fallback. /// /// After the payload is gathered, matching the other two renderers: a webview's /// htmx listener sits on the control and its fill script on the document, so /// there the press sends what the boxes held before it. One description sending /// two different things on two hosts is the drift this stack exists to end. /// /// From the box's own buffer, which is what it is showing: [`View::buffer`] /// seeds one from the description the first time the field is drawn, and a /// control cannot be pressed on a frame before the screen holding it was drawn. /// So a deposit into a box somebody has typed in goes after their typing, and /// one into an untouched box goes after whatever the description offered rather /// than over it. /// /// [`View::buffer`]: crate::View::buffer fn deposit(pass: &mut Pass<'_>, act: &Act) { let Some(fill) = &act.fills else { return; }; let mut value = pass.view.edit(&fill.field).unwrap_or_default().to_owned(); value.push_str(&fill.value); pass.view.set(&fill.field, value); } /// A checkbox, which is the one field held as a bool rather than as text. /// /// Split out of [`field_node`] because it shares none of the rest: no buffer to /// outlive the frame, no consults, no suggestion list. `Node::SELECTED` is /// quasi's submission convention, so a tick travels as a string on the way out /// and is a bool only while it is on screen. fn checkbox_node( pass: &mut Pass<'_>, ui: &mut Ui, described: &quasi_router::Field, name: &str, offered: bool, ) { let mut on = pass .view .edit(name) .map_or(offered, |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 { return; } pass.view.set(name, if on { "on" } else { "" }); // A tick is a value that moved, so a region recomputing from a set of dials // hears it the same way it hears a typed one. pass.stirred.insert(name.to_owned()); if let Some(action) = &described.writes { let mut payload = Params::new(); payload.insert(name.to_owned(), if on { "on" } else { "" }.to_owned()); pass.fire(action, payload, None); } } /// The upper end of an interval moved. /// /// Its own function rather than a branch inside [`field_node`], which is long /// enough already, and the split falls where the two ends genuinely differ: /// only this one has to be remembered under a second name. /// /// It fires the same [`quasi_router::Field::writes`] the lower end does. A /// filter that moved is a filter that moved, whichever box the user dragged, /// and both ends travel in the payload because an interval is one answer -- a /// handler reading only the end that moved would narrow the filter to a single /// bound every time either box was touched. fn upper_moved( pass: &mut Pass<'_>, described: &quasi_router::Field, lower: (&str, &str), upper: (&str, &str), ) { let (lower_name, lower_value) = lower; let (upper_name, upper_value) = upper; pass.view.set(upper_name, upper_value.to_owned()); let Some(action) = &described.writes else { return; }; let mut payload = Params::new(); payload.insert(lower_name.to_owned(), lower_value.to_owned()); payload.insert(upper_name.to_owned(), upper_value.to_owned()); pass.fire(action, payload, None); } /// A question answered zero or more times: the slots, and the controls the /// reader adds and removes them with. /// /// Each slot is [`quasi_router::Field::instance`] put back through /// [`field_node`], so a slot is drawn by everything this renderer already does /// to a field and there is no second field emitter. /// /// How many slots stand is the view's, for the reason the buffers are: 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. Adding one asks nothing /// and neither does taking one away. fn repeat_node(pass: &mut Pass<'_>, ui: &mut Ui, described: &quasi_router::Field) { let Some(repeat) = described.repeats.clone() else { return; }; ui.label(RichText::new(described.label.as_str()).color(pass.immediate.palette.content)); // What is wrong with the *set*, which no slot's own message can carry. A // slot's error rides on the slot, through the field it is drawn as. if let Some(error) = &described.error { ui.label( RichText::new(error.as_str()).color(pass.immediate.palette.tone(layout::Tone::Danger)), ); } let standing = pass.view.standing(described); for at in 0..standing { // One box for an ordinary slot, one per part for a grouped one, and // the branch lives in the vocabulary rather than here so the three // renderers cannot disagree about what a slot is. let slots = described.instance_fields(at); let held = repeat.instances.get(at); let busy = held.is_some_and(|slot| slot.progress.busy()); ui.horizontal(|ui| { for slot in &slots { field_node(pass, ui, slot); } // A slot the work has not finished with is one the reader may not // pull out from under it. if repeat.fewer(standing) && !busy && ui.button(RichText::new(repeat.remove.as_str())).clicked() { pass.view.remove_slot(described, at); } }); // What is wrong with the slot as a whole, and how far the work on it // has got. A part's own message rides on the part, as a field's does. if let Some(held) = held { if let Some(error) = &held.error { ui.label( RichText::new(error.as_str()) .color(pass.immediate.palette.tone(layout::Tone::Danger)), ); } if let quasi_router::Progress::Working(Some(meter)) = &held.progress { widget::meter( ui, &meter.as_layout(), &pass.immediate.palette, &pass.immediate.widget, ); } } } // Nothing for a question whose slots come from another control, for the // reason the other two renderers draw nothing: there is no blank a reader // could fill. if let Some(label) = repeat.add.label().filter(|_| repeat.more(standing)) && ui.button(RichText::new(label)).clicked() { pass.view.add_slot(described); } } /// One field, filled from the view rather than from the description. /// Move each of a field's consult deadlines on, or cancel it. /// /// Split out of [`field_node`] while that was over clippy's line bound, and it /// is a whole idea on its own: a keystroke pushes every question this box raises /// further out, which is what makes it a debounce. fn push_deadlines(pass: &mut Pass<'_>, described: &quasi_router::Field, name: &str, buffer: &str) { for (at, consult) in questions(described) { if consult.asks_about(buffer) { pass.view.wait_to_consult( Asking::Field(name.to_owned()), at, std::time::Instant::now() + consult.after, ); } else { // Deleting back under the floor cancels a question that was already // waiting, rather than letting it fire against a value the // description says is too short to ask about. pass.view.consulted(Asking::Field(name.to_owned()), at); } } } fn field_node(pass: &mut Pass<'_>, ui: &mut Ui, described: &quasi_router::Field) { // A question that does not apply is left out, which is what this renderer // already does with a region that does not. What was typed into it is kept // in the view and still submitted, exactly as a browser sends a hidden // input. `8fdb814c`. if pass.hidden.field_out(&described.name) { return; } let name = described.name.clone(); let kind = described.kind; let offered = described.value.clone(); // A question answered N times is N slots, each an ordinary field of this // same function. `60d1753c`. if described.repeats.is_some() { repeat_node(pass, ui, described); return; } if kind == layout::FieldKind::Checkbox { checkbox_node(pass, ui, described, &name, offered.is_some()); 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(); // An interval edits two buffers under the two names it submits under. Its // upper end is a second buffer of the view's for the same reason the first // one is: what is being typed outlives the frame. let upper_name = described .upper_name .clone() .filter(|_| kind == layout::FieldKind::Interval); let mut upper = upper_name.as_ref().map(|upper_name| { pass.view .buffer(upper_name, described.upper_value.as_deref()) .clone() }); let upper_before = upper.clone(); let width = described.width; let response = sized(ui, width, |ui| { described.with_layout(|borrowed| { let filling = match upper.as_mut() { Some(upper) => Filling::Between { lower: &mut buffer, upper, }, None => Filling::Text(&mut buffer), }; field( ui, &borrowed, filling, None, &pass.immediate.palette, &pass.immediate.field, ) }) }); // Where the caret starts, on the arrival frame and no other. Focus is // egui's here, so this is a request rather than a placement, and the claim // is taken rather than read: a flag left standing would ask again every // frame and the reader could never move the caret off the box. See // `View::claims_caret`. if let Some(response) = response.as_ref() && pass.view.claims_caret(&name) { response.request_focus(); } if upper_before != upper && let Some((upper_name, upper)) = upper_name.as_ref().zip(upper.as_ref()) { // An interval's upper end is a value of its own, under its own name, // and moving it moves the interval. pass.stirred.insert(upper_name.clone()); upper_moved(pass, described, (&name, &buffer), (upper_name, upper)); } if buffer != before { pass.view.set(&name, buffer.clone()); // Said once here, read by every region that contains this box. A field // has no way to know which regions those are and a region has no way to // know its body moved, so the two meet on the frame. pass.stirred.insert(name.clone()); // Every keystroke pushes the deadline out, which is what makes this a // debounce: a slug typed in one go is asked about once, when it stops // moving. Per keystroke and NOT on settle, unlike `changes` below: a // question asked while typing is the whole point of a consult, and // `Consult::after` is the description's own wait. // // Each question keeps its own deadline. A box asking two routes at two // rates is MNW's discover search, and one deadline per field would have // the faster of the two cancel the slower. push_deadlines(pass, described, &name, &buffer); } // `8032fe61`, `de2376bd`. **When the value is complete, not on the way to // it.** This fired on `buffer != before`, so typing 30 into audiofiles' // bounded `row_height` posted a row height of 3 on the way -- outside the // 20-32 the field's own hint states -- and dragging a classifier threshold // wrote once per frame instead of once per drag. // // `quasi-webview` has always meant this: `Field::writes` is emitted as // `hx-trigger="change"`, and a browser raises `change` on blur or Enter for // a text control and on release for a range. Two shipped renderers // disagreeing about one member is the drift this stack exists to end, and // the host that was already right says what right is. Ruling on // quasicoherent `8032fe61`. // // What is NOT settled here is whether these controls should write with no // submit at all, which is Max's rule in wiki `explicit-commit-affordance` // and is that task's remaining half. let settled = response .as_ref() .is_some_and(|response| response.lost_focus() || response.drag_stopped()); // A control whose value is chosen in one go was already complete when it // changed, and a browser fires `change` for it immediately. Only what the // reader builds up -- typed into or dragged across -- has an "on the way". // A theme is picked in one gesture, the way an option is. let built_up = !(kind.offers_options() || kind.takes_files() || kind.offers_themes()); let complete = if built_up { settled } else { buffer != before }; if complete && described.writes.is_some() && pass.view.unwritten(&name, &buffer, offered.as_deref()) { pass.view.wrote(&name, &buffer); if let Some(action) = &described.writes { let mut payload = Params::new(); payload.insert(name.clone(), buffer.clone()); // Both ends, because an interval is one answer. A handler reading // only the end that moved would narrow the filter to a single // bound every time either box was touched. if let Some((upper_name, upper)) = upper_name.as_ref().zip(upper.as_ref()) { payload.insert(upper_name.clone(), upper.clone()); } pass.fire(action, payload, None); } } // Candidates for a value that no longer clears the floor are an answer to a // question the field would not ask now, so a delete takes them with it. if let Some(owned) = described.suggests.as_ref() && !owned.asks_about(&buffer) { pass.view.unsuggest(); } // Checked every frame rather than only on a keystroke, because the whole // point is what happens when the keystrokes stop. for (at, consult) in questions(described) { let Some(due) = pass.view.consult_due(Asking::Field(name.clone()), at) else { continue; }; let now = std::time::Instant::now(); if now >= due { pass.view.consulted(Asking::Field(name.clone()), at); // This box's value, plus whatever else the question said it // carries. No ticks: a consult asks about this box, never about a // set of rows. let mut payload = pass.view.contributed(&consult.sends); payload.insert(name.clone(), buffer.clone()); pass.fire(&consult.action, payload, None); } else { // An idle app stops repainting, and a deadline nobody wakes up for // is a question never asked. This is the one place the renderer // needs a clock, and egui already owns one. ui.ctx().request_repaint_after(due - now); } } suggestions(pass, &name, &mut buffer, ui); } /// Every question this field asks, the one it owns first. /// /// The owned question is keyed just past the end of /// [`Field::consults`](quasi_router::Field::consults), so it gets a deadline of /// its own for the reason each consult does — a box asking two routes at two /// rates would otherwise have the faster question cancel the slower — and no /// index can collide with a consult's. fn questions(field: &quasi_router::Field) -> impl Iterator { field.consults.iter().enumerate().chain( field .suggests .iter() .map(|owned| (field.consults.len(), owned)), ) } /// The candidates under the box, and what picking one does. /// /// Drawn where the list is described to be — under the field that owns it, in /// flow — because an immediate-mode frame has no z-order to float in and a /// popup here would be a window this renderer opened on its own. The webview /// floats and the terminal paints over; each host's answer is its own, and /// what they share is the description. /// /// Picking writes [`Candidate::value`](quasi_router::Candidate::value) into the /// box and closes the list. It costs exactly what a settled value costs, which /// is why a [`Field::writes`](quasi_router::Field::writes) route fires: the /// buffer this writes into is compared against its previous contents on the /// next frame, the same way a keystroke is, so no second write path exists here /// to keep in step. /// /// That is the default and not the definition. A candidate carrying /// [`picks`](quasi_router::Candidate::picks) has that action fired instead, /// and nothing is written -- which is the whole of MNW's search box, where a /// pick navigates and the typed value is discarded. /// /// [`detail`](quasi_router::Candidate::detail) is drawn after the label and /// dimmed, which is this host's answer to the same question the terminal /// answers with the rest of the row and a webview with a second line. fn suggestions(pass: &mut Pass<'_>, name: &str, buffer: &mut String, ui: &mut egui::Ui) { let Some(options) = pass.view.suggesting(name) else { return; }; // Cloned rather than borrowed across the loop: the list is read off the // view and picking writes back to it, and a candidate is two short strings. let options = options.to_vec(); let mut picked = None; for candidate in options { let mut text = egui::text::LayoutJob::default(); text.append( &candidate.label, 0.0, egui::TextFormat::simple(egui::FontId::default(), ui.visuals().text_color()), ); if let Some(detail) = &candidate.detail { text.append( detail, 8.0, egui::TextFormat::simple(egui::FontId::default(), ui.visuals().weak_text_color()), ); } if ui.add(egui::Button::new(text).frame(false)).clicked() { picked = Some(candidate.clone()); } } let Some(candidate) = picked else { return; }; pass.view.unsuggest(); // Performed as written, with no payload rule invented here: the action // carries its own params and the view it was offered under, exactly as a // control's does. if let Some(action) = candidate.picks { pass.fire(&action, Params::new(), None); return; } buffer.clone_from(&candidate.value); pass.view.set(name, candidate.value); } /// How wide a control asks to be, in the one unit egui takes. /// /// `Fill` is what an egui text field does already, and is the default, so a /// described field that says nothing draws exactly as it did before the member /// existed: `TextEdit`'s desired width is infinite, so in a row it takes what is /// left. /// /// The other two need a number the description does not carry, which is the same /// place `Column` leaves this renderer and the terminal one -- see `quasi-tui`'s /// table sizing, whose `fallback` is a guess "until the vocabulary carries a /// measure". This is that guess for a field: a guess rather than nothing, /// because the alternative is `Content` and `Fill` drawing identically, which /// would make the member unfalsifiable here. const ASKED: f32 = 220.0; /// Draw within whatever the control asked for. fn sized(ui: &mut Ui, width: layout::Width, draw: impl FnOnce(&mut Ui) -> R) -> R { match width { layout::Width::Fill => draw(ui), _ => { ui.scope(|ui| { ui.set_max_width(ASKED.min(ui.available_width())); draw(ui) }) .inner } } } /// The values a control asked for before it fired, read as a submit reads them. /// /// `Act::asks`. Empty for a control that asked for nothing, which is nearly all /// of them. fn asked(pass: &Pass<'_>, asks: &[quasi_router::Field]) -> Params { if asks.is_empty() { return Params::new(); } let (names, described) = submitted(pass, asks); pass.view.submission(&names, &described) } /// The names a set of fields submits under, and what the description offered /// for each. /// /// One name per field, except for a question answered N times, which is N /// names: `60d1753c`. How many those are is the view's, because the slots the /// reader added are not in the description, which is why this is not a /// function of the fields alone. fn submitted( pass: &Pass<'_>, fields: &[quasi_router::Field], ) -> (Vec, BTreeMap) { let mut names = Vec::new(); let mut described = BTreeMap::new(); for field in fields { for at in 0..pass.view.standing(field) { let slots = if field.repeats.is_some() { field.instance_fields(at) } else { vec![field.clone()] }; for slot in slots { if let Some(value) = slot.value { described.insert(slot.name.clone(), value); } names.push(slot.name); } } } (names, described) } /// The ticks a control writing over a selection sends, and nothing for one that /// does not. /// /// Under `Node::TICKED`, which is the name every renderer sends them under and /// what the vocabulary documents. fn gathered(pass: &Pass<'_>, over: Option<&str>) -> Params { over.map_or_else(Params::new, |_| { pass.view.gathering(quasi_router::Node::TICKED) }) } /// 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() { // A question answered N times sends N values under N indexed names, in // one submission with the rest of the form. That is the whole of what // the member is for. `60d1753c`. let (names, described) = submitted(pass, fields); let payload = pass.view.submission(&names, &described); pass.fire(action, payload, None); } } /// The rows a shut branch is not covering, as the reader has left the outline. /// /// [`quasi_router::folded_by`] does the reading, so a window folds what a /// terminal and a browser fold. The reader's own answer comes first and the /// description's stands until there is one -- `Row::open` says where an outline /// starts, not where it stays. fn unfolded<'a, T: quasi_router::Outline>(rows: &'a [T], view: &crate::View) -> Vec<&'a T> { let hidden = quasi_router::folded_by(rows.iter().map(|row| { let open = row.open().map(|described| view.open(&row.key(), described)); (row.depth(), open) })); rows.iter() .zip(hidden) .filter(|(_, hidden)| !*hidden) .map(|(row, _)| row) .collect() } /// A row's place in an outline, drawn before its words: the indent, then the /// chevron when the row is a branch. /// /// Answers the key and the state a press would flip, so a caller can collect /// the fold and apply it once the pass is free. The chevron's rect comes back /// with it, because a caller claiming the row's own hit target has to start /// after it -- egui gives an overlapping rect to whichever widget claimed it /// last, and a chevron the row swallows is the one thing `Row::open` says must /// not happen. /// /// The room is spent on a leaf too, or its label sits left of its own parent's. fn outline_lead( ui: &mut Ui, view: &crate::View, row: &impl quasi_router::Outline, ) -> (Option<(String, bool)>, Option) { let step = ui.spacing().indent; let depth = f32::from(row.depth().level); if row.depth().is_nested() { ui.add_space(step * depth); } let Some(described) = row.open() else { ui.add_space(step); return (None, None); }; let key = row.key(); let open = view.open(&key, described); let pressed = ui.small_button(if open { "\u{25bc}" } else { "\u{25b6}" }); let folded = pressed.clicked().then_some((key, open)); (folded, Some(pressed.rect)) } /// One row of a list. /// /// `within` is the row's place among its siblings, and it is there only to name /// the row's interact rect. See the id below for why the value cannot do it /// alone. fn list_row(pass: &mut Pass<'_>, ui: &mut Ui, row: &Row, within: usize, branches: bool) { // Whether the press that just happened was on the chevron rather than on // the row. Collected here because the row claims the whole strip below, // and a fold that also opened the row would be one press doing two things. let mut folded = None; // Where the chevron landed, so the row's own hit target can start after it. // egui gives an overlapping rect to whichever widget claimed it last, and // the row claims the whole strip below -- so without this the row swallows // every press meant for the chevron, which is the one thing `Row::open` // says must not happen. let mut chevron = None; let strip = ui.horizontal(|ui| { // The row's own indent, then its disclosure. `ccaa7e4b`, and the // chevron is **a separate hit target from the label** on purpose: this // is the shipped egui sidebar's own behaviour rather than an // improvement on it, since pressing a tag filters by it and pressing // its chevron does not. if branches { (folded, chevron) = outline_lead(ui, pass.view, row); } // 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); } } } // `Part::worth` is not read here, and unlike `Flow` that is a statement // about this renderer rather than a deferral. A terminal wraps one // shared flow, so a trailing fact costs the row a whole extra line and // dropping it by worth buys back that line. Here the parts are a // horizontal strip and each elides itself, so nothing is lost to *the // run* being too tall -- what is lost is whatever sits past the right // edge once an earlier part has taken the width. That is a budget // problem rather than a worth problem, and `budget` below is the // answer to it. let spacing = ui.spacing().item_spacing.x; for (index, part) in row.cells.iter().enumerate() { let after = tail_width(ui, pass.immediate, &row.cells[index + 1..], spacing); row_part(pass, ui, part, after); } }); // Where this row landed, for a host reading a gesture the description does // not carry. Noted for every row rather than only pressable ones: "is the // pointer over a row" has to be answerable for the row that answers // nothing, which is the case a drag guard needs. See `crate::geometry`. crate::geometry::note_row( ui, row.value.as_deref(), within, strip.response.rect, row.chosen == Some(true), ); // Opening the row, and asking what else it offers, both land on the strip // the row just drew. // // **The rect is the `horizontal`'s own response, not `ui.min_rect()`.** That // was the rect until 2026-08-17, and every row of a list is drawn into one // shared `Ui` -- a column-less table loops `list_row` over it -- so `min_rect` grew // with each row and the fourth row's target covered the first four. Four // overlapping rects, one pointer, and the row that answered was whichever // egui hit last rather than the one under the cursor. The `horizontal` // answers this row's strip and nothing above it. // // One `interact` for both gestures, claimed whenever the row has either: a // row that only offers a menu still needs somewhere to right-click. // `Sense::click()` covers the secondary button -- egui reads a context click // off the same sense -- so nothing about the opening gesture changes. // The fold, applied after the strip: the view is behind the pass, which the // closure above cannot hold. `folded` also stands in for "the press was the // chevron's", so the row below is not opened by it. if let Some((key, open)) = folded { pass.view.fold(&key, open); return; } if row.activate.is_some() || !row.menu.is_empty() { // **The id falls back to the row's position, and has to.** It was // `("row", value.unwrap_or(""))` until 2026-08-17, and `Row::value` is // only set by `ticking`, so every row of a list without checkboxes was // `("row", "")` -- one id for all of them. egui answers a repeated id // with a "First use / Second use" error label and one shared // interaction, so the second row onwards could not be clicked at all. // Sibling of the `min_rect` defect fixed above and invisible for the // same reason: nothing here could press a row until this file could // measure text. // // The value still wins where there is one, because it survives a // reorder and an index does not. let id = match row.value.as_deref() { Some(value) => ui.id().with(("row", value)), None => ui.id().with(("row-at", within)), }; // Everything the row drew except its chevron. See `chevron` above. let mut target = strip.response.rect; if let Some(chevron) = chevron { target.min.x = target.min.x.max(chevron.max.x); } let response = ui.interact(target, id, egui::Sense::click()); // Say what was claimed, so the row exists for something other than a // pointer. // // A bare `ui.interact` registers no `WidgetInfo`, and egui builds its // accessibility tree from `WidgetInfo` alone, so until 2026-08-22 a row // that opens or carries a menu contributed **no node at all**. Not // mislabelled: absent. It worked under a mouse and did not exist for a // keyboard or a screen reader, which on audiofiles' tag queue meant // there was no way but the mouse to open one of the tag groups the // screen navigates by. Found by a harness reading what the renderer // drew. // // Named by the row's first text, which is the same thing a reader would // call the row and the same thing `Row::new` takes. let named = row_name(row); response.widget_info(|| { egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &named) }); // The menu first. A right-click lands on the same rect as a left-click, // and firing `activate` off it would open the row the user was asking // what they could do to. if let Some((action, confirm)) = row_menu(pass.immediate, &response, &row.menu) { pass.fire(&action, Params::new(), confirm.as_deref()); } else if let Some(action) = &row.activate && response.clicked() { pass.fire(action, choosing(ui, row.chosen), None); } } } /// Say that a table row can be opened, once, on its first column. /// /// The press is claimed per cell, and saying so per cell would put a button in /// the tree for every column of every row -- five buttons all called "kick.wav" /// on a five-column table, which is noise rather than access. The first column /// is where the row's name already is, so that is where it is announced; /// pressing any cell still opens it, exactly as before. /// /// Second half of `461582b5`. The list half is [`row`], where a row has one /// rect and one place to say this. fn announce_row(ui: &Ui, response: &egui::Response, index: usize, row: &Row) { if index != 0 || (row.activate.is_none() && row.menu.is_empty()) { return; } let named = row_name(row); response.widget_info(|| { egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &named) }); } /// What a table row is called: the first thing in its first cell. /// The first thing a run of leaves says. fn row_leaf_text(parts: &[Node]) -> String { parts .iter() .find_map(|part| match part { Node::Text { text, .. } | Node::Heading { text, .. } | Node::Link { text, .. } => { Some(text.clone()) } _ => None, }) .unwrap_or_default() } /// What a row is called: the first thing it says. /// /// For [`egui::WidgetInfo`], which needs one string where a row is a run of /// leaves. The first textual part is what `Row::new` takes and what a reader /// would call the row, so there is nothing to invent here. /// /// One function since the 2026-09-05 collapse. `cells_name` was its counterpart /// for the other container and asked the same question of the same type, cell /// by cell rather than over a flattened copy. This keeps the cell-by-cell /// walk: it is the one that does not clone every node in the row, and this /// runs per frame. fn row_name(row: &quasi_router::Row) -> String { row.cells .iter() .find_map(|cell| { let said = row_leaf_text(&cell.content); (!said.is_empty()).then_some(said) }) .unwrap_or_default() } /// 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::Cell, after: Option) { // The cap, settled by `7bfb554a`: a run is a line, and a part takes the // lines its flow allows and no more. Only the text leaves need it -- a // token, a control and a meter are single-line widgets already, and putting // them through a galley would be saying something about them that the // description did not. // A cell holds a run since the 2026-09-05 collapse, so this walks it. The // flow is the cell's, which is the column's unless the description narrowed // it, and it applies to every text leaf in the run. let flow = part.room(); for node in &part.content { match node { Node::Text { text, .. } => capped(pass.immediate, ui, text, flow, after), Node::Rich { source, .. } => { capped( pass.immediate, ui, &docengine::render_plain(source), flow, after, ); } other => draw(pass, ui, other), } } } /// What the rest of the run needs, if this crate can say. /// /// `None` the moment one part cannot be measured. A budget built on a guess /// about an unknown widget is worse than no budget: too small and the flexible /// part is elided for room nobody used, too large and the guess bought nothing. /// The parts that can be measured are the ones whose width is their text -- /// which is every part a row has ever held in practice. fn tail_width( ui: &Ui, immediate: &Immediate, rest: &[quasi_router::Cell], spacing: f32, ) -> Option { if rest.is_empty() { return None; } let mut total = 0.0; for part in rest { total += intrinsic_width(ui, immediate, part)? + spacing; } Some(total) } /// The width a cell wants when nothing is squeezing it. /// /// A cell holds a run since the 2026-09-05 collapse, so this is the sum of its /// leaves. `None` if any leaf cannot say, which is the same answer the single /// node gave before: a run containing something unmeasurable is unmeasurable. fn intrinsic_width(ui: &Ui, immediate: &Immediate, part: &quasi_router::Cell) -> Option { let mut total = 0.0; for node in &part.content { total += leaf_width(ui, immediate, node)?; } Some(total) } /// The width one leaf wants when nothing is squeezing it. fn leaf_width(ui: &Ui, immediate: &Immediate, node: &Node) -> Option { match node { Node::Text { text, .. } => Some(text_width(ui, text)), Node::Rich { source, .. } => Some(text_width(ui, &docengine::render_plain(source))), // A token is its words plus `token_padding` on both sides, which is // `widget::token`'s own arithmetic rather than a guess at it. Taking // the button padding here instead would be close enough to look right // and wrong enough to lose the last part in the run. Node::Token(tag) => { Some(text_width(ui, &tag.label) + immediate.widget.token_padding.x * 2.0) } Node::Act(act) => { let drawn = act.as_layout(); let label = match drawn.key { Some(key) => format!("{} ({key})", drawn.label), None => drawn.label.to_owned(), }; // A control is an `egui::Button`, so this padding really is the // style's button padding. Some(text_width(ui, &label) + ui.spacing().button_padding.x * 2.0) } _ => None, } } /// How wide this text is laid out with nothing in its way. fn text_width(ui: &Ui, text: &str) -> f32 { let job = egui::text::LayoutJob::single_section( text.to_owned(), egui::TextFormat { font_id: egui::TextStyle::Body.resolve(ui.style()), ..Default::default() }, ); // Measured in the frame that draws, never remembered between frames. The // renderer's standing promise is that the same description at the same // width is the same picture, and a measurement carried over from a // narrower frame is exactly how that would stop being true. ui.painter().layout_job(job).rect.width() } /// The width a flexible part may take, once the rest of the run is accounted. /// /// Reserving only when the reservation leaves something behind, which is the /// rule quasi-tui arrived at from the other end: a budget that cannot fit the /// tail anyway would elide the primary to make room for parts that are still /// past the edge, spending the one thing on screen for nothing. fn budget(available: f32, after: Option) -> f32 { match after { Some(reserved) if reserved < available => available - reserved, _ => available, } } /// A text leaf drawn under a line budget. /// /// `ui.label(RichText)` grows to as many rows as the words need, which is what /// `leaf` does everywhere outside a run and is right there: a block may be as /// tall as it is. Inside a run it is the unbounded behaviour `Flow` exists to /// end, so this is the same text through a `LayoutJob`, whose wrapping carries /// both the budget and the ellipsis. /// /// `max_width` has to be set from the `Ui`: a job defaults to infinite width, /// so a budget without it would never wrap and never elide, and the cap would /// silently do nothing. fn capped(immediate: &Immediate, ui: &mut Ui, text: &str, flow: layout::Flow, after: Option) { let mut job = egui::text::LayoutJob::single_section( text.to_owned(), egui::TextFormat { font_id: egui::TextStyle::Body.resolve(ui.style()), color: immediate.palette.content, ..Default::default() }, ); job.wrap = egui::text::TextWrapping { max_width: budget(ui.available_width(), after), max_rows: flow.lines() as usize, // Words, unless one line is all there is: a single row elided at a word // boundary can lose most of the row, which is the case egui's own docs // name for breaking anywhere. break_anywhere: flow.lines() == 1, overflow_character: Some('\u{2026}'), }; // Laid out here rather than handed to `ui.label`, and this is the whole // difference between a budget that works and one that reads as though it // does. A `Label` re-lays a job it is given, overwriting `max_width` with // the `Ui`'s available width: the row's first part then elided at the full // pane, took all of it, and the parts after it were left with nothing -- // which is the defect this budget was written to fix, surviving the fix. // A galley is already laid out, so a `Label` built from one draws exactly // what was measured. let galley = ui.painter().layout_job(job); ui.add(egui::Label::new(galley)); } /// The cell just drawn, sensed for the two gestures a table row answers. /// /// **`ui.response()` cannot answer either of them**, and was what stood here /// The response a `Ui` gives for itself carries the sense it was built with, /// and `UiBuilder`'s default is `Sense::hover`, so `clicked` and the secondary /// click `Response::context_menu` reads are both permanently false. A table /// row could not be opened and its menu could not be raised, in the one /// consumer whose file list is a `Node::Table`. /// /// Interacting for the sense we need is the move `list_row` already makes. The /// id needs no disambiguator the way a list row's does: `makeover_immediate` /// gives each cell its own `Ui`, so `ui.id()` differs per cell already. fn cell_row_response(ui: &mut Ui) -> egui::Response { ui.interact( ui.min_rect(), ui.id().with("cell-row"), egui::Sense::click(), ) } /// What a press on a chosen-able row meant, as a payload. /// /// The renderer's half of `Row::chosen`: the description says which rows are /// chosen and the app owns the set, and this is the part only this side can /// answer -- what the press *meant*, read off the modifiers this host has. /// /// The mapping is the desktop's, unchanged since the Macintosh Finder: a plain /// click chooses the row alone, ctrl (command on macOS) toggles it into the set, /// and shift takes everything between the app's pointer and here. egui's /// `Modifiers::command` is already the platform's own answer to which of ctrl /// and command means "the modifier key", so nothing here is per-OS. /// /// **Shift wins over command** when both are held, which `Choosing` rules on /// rather than leaving to a renderer. audiofiles' own shipped list read them /// the other way round (`ui/file_list.rs::handle_click`, before `49b7429`), so /// this is a change to that app of the rarest press it has. /// /// That is what makes this additive rather than a change to every row /// activation in the tree. fn choosing(ui: &Ui, chosen: Option) -> Params { if chosen.is_none() { return Params::new(); } let modifiers = ui.ctx().input(|input| input.modifiers); let meant = if modifiers.shift { quasi_router::Choosing::Through } else if modifiers.command { quasi_router::Choosing::Also } else { quasi_router::Choosing::Only }; Params::new().with( quasi_router::Node::CHOOSING.to_owned(), meant.as_str().to_owned(), ) } /// What a row offers, on the gesture this host means by asking. /// /// A list row and a table row are one type, so a menu draws through one /// function. The description's own instruction is that a menu /// is "reached by right-click on a pointer host, long-press on a touch one, and /// a key in a terminal"; egui is a pointer host, and `Response::context_menu` is /// its right-click, so there is nothing for this renderer to invent. /// /// A menu dropped silently, with no arm and no comment, is the failure mode the /// wildcard arms in this file are written to avoid: the description says what /// the row offers, and a renderer that quietly offers nothing is the one /// outcome that must not happen. /// /// Returns what was pressed rather than firing it. Every collecting site in this /// file has the same reason -- the closure holds `&mut Ui` and firing wants the /// pass mutably -- and a menu inside a table cell is the case where it is forced, /// so both callers do it the one way. /// /// A disabled act draws and does not answer, which is `widget::act`'s own /// contract; a menu that omitted it would be a menu whose length changed with /// state, and the reader loses the place they had learned. fn row_menu( immediate: &crate::Immediate, response: &egui::Response, menu: &[Act], ) -> Option<(Action, Option)> { if menu.is_empty() { return None; } let mut picked = None; response.context_menu(|ui| { for act in menu { let pressed = widget::act(ui, &act.as_layout(), &immediate.palette, &immediate.widget); if pressed.clicked() && act.state != Some(layout::State::Disabled) { picked = Some((act.action.clone(), act.confirm.clone())); // The menu closes on the press, not on the answer. A described // act may raise a confirmation before anything happens, and a // menu still standing behind that dialog is two surfaces asking // at once. ui.close(); } } }); picked } /// What the tick column calls itself. /// /// `makeover_immediate::table` addresses a cell by its column's name, so the /// one this renderer adds needs one. Never shown as a heading. const TICK_COLUMN: &str = "select"; /// 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`. /// The columns as `makeover-immediate` wants them, with the tick column in /// front when the table has ticks. /// /// Split out of [`table`] rather than inlined, which is where it was: a tick /// takes no column in the description, so this renderer adds one, and that is a /// self-contained fact about the two vocabularies meeting. fn table_columns<'a>(columns: &'a [quasi_router::Column], ticks: bool) -> Vec> { let mut borrowed: Vec> = Vec::with_capacity(columns.len() + usize::from(ticks)); if ticks { borrowed.push(layout::Column { width: layout::Width::Fixed, priority: layout::Priority::Essential, ..layout::Column::new(TICK_COLUMN) }); } borrowed.extend(columns.iter().map(|c| c.as_layout())); borrowed } /// One table row's tick, and the value to toggle when it was pressed. /// /// Drawn from the set the view holds rather than from the description, which is /// the rule every renderer follows for a tick: the description says what /// arrived and the view says what the user has done since. The view always, /// because a table row's tick is a member of the screen's set by construction -- /// unlike a list row, there is no case where the description's flag is the /// answer. /// /// Split out of [`table`] to keep that function under the line cap, and it is /// the right piece to split: a tick is the one cell in a table that is not a /// value in the grid. fn tick_cell(ui: &mut Ui, view: &crate::View, row: &Row) -> Option { let (Some(_), Some(value)) = (row.selected, row.value.as_ref()) else { return None; }; let mut on = view.is_ticked(value); ui.checkbox(&mut on, "").changed().then(|| value.clone()) } fn table(pass: &mut Pass<'_>, ui: &mut Ui, columns: &[quasi_router::Column], rows: &[Row]) { // A tick takes no column in the description, so this renderer adds one: // `makeover_immediate::table` addresses cells by column, and a checkbox // drawn outside that has no track to sit in. Essential, so narrowing never // takes the affordance away, and it is named rather than blank because two // unnamed columns would be one column twice. // The rows a shut branch is not covering, which is what the table has this // frame. Taken before anything is counted, so an index into the body is an // index into what was drawn. `quasi_router::folded_by` is the reading, so a // window and a terminal fold the same rows. let branches = rows.iter().any(|row| row.open.is_some()); let shown = unfolded(rows, pass.view); let rows = shown.as_slice(); let ticks = rows.iter().any(|row| row.selected.is_some()); let borrowed = table_columns(columns, ticks); // What is drawn as a selected row: the app's pointer, and every row of a // live selection. `Body::selected` takes a predicate rather than a set, // which is what lets both facts answer through one call -- an app whose // selection is a range does not have to build a collection to be asked. // // `1894e95d`. Before this, `Row::current` was the only thing that lit a // row, so a five-hundred-row Cmd+A in audiofiles drew the same as a one-row // click. A chosen row and the current row look alike here on purpose: the // pointer sits inside the selection nearly always, and a second mark for // "and this is the one the detail pane is showing" is a distinction the // detail pane is already making. let current = |at: usize| { rows.get(at) .is_some_and(|row| row.current || row.chosen == Some(true)) }; 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; // Same reason as `fired`: ticking is a write to the view, and the view is // behind the pass the closure cannot hold. let mut toggled: Option = None; // A menu press, kept apart from `fired` because it carries a confirmation // and because it wins: a right-click lands on the same cell a left-click // does, so collecting both in one slot would let opening the row beat the // menu the user actually asked for. let mut menued: Option<(Action, Option)> = None; // A press on a branch's chevron, collected for `toggled`'s reason: folding // writes to the view and the view is behind the pass. let mut folded: Option<(String, bool)> = 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 }; // Labelled, so the rect below is measured whichever way this // cell left. `600c9e42`. 'cell: { if ticks && column.name == TICK_COLUMN { if let Some(value) = tick_cell(ui, pass.view, row) { toggled = Some(value); } break 'cell; } let Some(index) = columns.iter().position(|c| c.name == column.name) else { break 'cell; }; let Some(cell) = row.cells.get(index) else { break 'cell; }; // The outline, in the first column and nowhere else. A table // has no gutter to indent in and the indent is not a value in // the grid, so it rides in front of the row's leading cell -- // which is the one the eye reads the hierarchy from anyway. if index == 0 && branches { let (pressed, _) = outline_lead(ui, pass.view, *row); folded = folded.take().or(pressed); } // Which side of a change this line is on, when the table is a // diff. `19d7602d`. A sign in front of the leading cell, on the // terminal renderer's reasoning: this renderer has no per-row // background to tint either, and the sign is what a reader of // diffs already reads. if index == 0 && let Some(change) = row.change { let (sign, colour) = match change { layout::Change::Added => ("+", pass.immediate.palette.success), layout::Change::Removed => ("-", pass.immediate.palette.danger), // Including a kind this renderer has not learned: an // unchanged line, which draws and loses only the sign. _ => (" ", pass.immediate.palette.content_muted), }; ui.label(RichText::new(sign).monospace().color(colour)); } for node in &cell.content { // 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. // // The menu hangs off the same response, and therefore off every cell: // `makeover_immediate::table` calls this per cell and answers no // row-wide rect back, so "right-click the row" is "right-click any of // its cells". That is the honest reading of what this renderer can // see, and it is also what a user expects of a table row. let response = cell_row_response(ui); announce_row(ui, &response, index, row); if let Some((action, confirm)) = row_menu(pass.immediate, &response, &row.menu) { menued = Some((action, confirm)); } else if let Some(action) = &row.activate && response.clicked() { fired = Some((action.clone(), choosing(ui, row.chosen))); } } // After it has drawn: a `Ui`'s `min_rect` is empty until // something is in it, so measuring first recorded a sliver at the // cell's left edge. Per cell, so the row answers under any of them. crate::geometry::note_row( ui, row.value.as_deref(), at, ui.min_rect(), row.chosen == Some(true), ); }, ); if let Some(value) = toggled { pass.view.tick(&value); } // 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. let reorder = reordered .and_then(|pressed| columns.iter().find(|column| column.name == pressed.name)) .and_then(|column| column.reorder.clone()); table_pressed(pass, folded, menued, fired, reorder); } /// What a press on a table did, once the closure that saw it has let go of the /// `Ui` and the pass is free again. fn table_pressed( pass: &mut Pass<'_>, folded: Option<(String, bool)>, menued: Option<(Action, Option)>, fired: Option<(Action, Params)>, reorder: Option, ) { // The fold, instead of either press below rather than beside them: a // chevron is a separate hit target from the row, so folding a branch never // also opens it. `ccaa7e4b`. The reorder below still stands, because a // heading is not a row. if let Some((key, open)) = folded { pass.view.fold(&key, open); } else { // The menu before the row, matching the order inside the closure: // `fire` takes the first press of the frame, so whichever of the two is // offered first wins, and the menu is the one the user asked for by // name. if let Some((action, confirm)) = menued { pass.fire(&action, Params::new(), confirm.as_deref()); } if let Some((action, payload)) = fired { pass.fire(&action, payload, None); } } if let Some(action) = 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) { // `ae8e8836`. Scoped so there is a rect to note, and noted whether or not // anything is ever anchored here: nothing in a description says which // regions an app anchors to. A scope adds no spacing and no frame -- it is // a child `Ui` over the same available space -- so the drawing is what it // was before this wrapper existed. // // Outside the early returns in `region_body`, deliberately. A region that is // pending or failed still occupies space and can still be anchored to, and a // menu that could not open over a region that had not loaded yet would be a // rule nobody stated. let drawn = ui.scope(|ui| region_body(pass, ui, slot)); crate::geometry::note_region(ui, &slot.id, drawn.response.rect); } /// What a region asks when the questions inside it move. /// /// The browser puts one trigger on the region and lets the document gather /// what it contains; here the containment is walked instead, through /// [`Slot::questions`](quasi_router::Slot::questions), so the two hosts gather /// the same set from the same walk rather than from two readings of the word /// "inside". /// /// Two halves, and both are needed every frame. A dial that moved pushes the /// deadline out, which is what makes it a debounce; and a deadline that has /// come due fires, which has to be checked whether or not anything moved this /// frame -- the whole point is what happens once the moving stops. fn region_consults(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot) { if slot.consults.is_empty() { return; } let inside = slot.questions(); let moved = inside .iter() .find(|field| pass.stirred.contains(&field.name)) .map(|field| { pass.view .buffer(&field.name, field.value.as_deref()) .clone() }); for (at, consult) in slot.consults.iter().enumerate() { // The floor is read against the value that moved, never against the // gathered set: `Consult::asks_about` says so, and a set has no length // a reader could predict. if let Some(moved) = &moved { if consult.asks_about(moved) { pass.view.wait_to_consult( Asking::Region(slot.id.clone()), at, std::time::Instant::now() + consult.after, ); } else { // Deleting back under the floor cancels a question already // waiting, exactly as it does for a box's own. pass.view.consulted(Asking::Region(slot.id.clone()), at); } } let Some(due) = pass.view.consult_due(Asking::Region(slot.id.clone()), at) else { continue; }; let now = std::time::Instant::now(); if now < due { // An idle app stops repainting, and a deadline nobody wakes up for // is a question never asked. ui.ctx().request_repaint_after(due - now); continue; } pass.view.consulted(Asking::Region(slot.id.clone()), at); // What rides along from outside the region goes in first, so a dial // inside it wins where a name sits on both sides. let mut payload = pass.view.contributed(&consult.sends); for field in &inside { // What the reader has typed, falling back to what the description // offered, which is the order a submit reads a form in: an // untouched dial still sends what it is showing. payload.insert( field.name.clone(), pass.view .buffer(&field.name, field.value.as_deref()) .clone(), ); } pass.fire(&consult.action, payload, None); } } /// The region itself, inside the scope that measures it. fn region_body(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot) { // A region that does not apply right now is not drawn at all. `079a011e`: // the region names the control and the value that bring it out, and this // renderer answers it from the view it is already holding -- no request, // and nothing re-rendered on a form the reader is midway through. if pass.hidden.out(&slot.id) { return; } // 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 => { // Wiki `loading-and-progress-standard`, rule 2. This was // `ui.spinner()` until `5eccb6aa`, drawn identically whether the // region was waiting on a measured payload or on nothing anyone // could count -- and a turning arc reads as progress, which is the // claim an unmeasured wait has not earned. // // The amount, when there is one, is what the region's own feeding // action described. The numerator is the host's and is usually // absent, which is what keeps a bar from being drawn out of a total // alone. widget::awaiting( ui, slot.awaiting().unwrap_or_default(), pass.view.progress_at(std::time::Instant::now()), pass.immediate.reduced_motion(), pass.immediate.palette(), &pass.immediate.widget, ); 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. _ => {} } let cutoff = cutoff(ui.available_width()); run(pass, ui, slot, cutoff); if slot.showing().selective() { showing_body(pass, ui, slot, cutoff); } else if let Some(repeating) = slot.repeating.as_deref() { repeating_body(pass, ui, slot, repeating); } else { for placed in slot.body.iter().filter(|placed| placed.kept_at(cutoff)) { draw(pass, ui, &placed.node); } } // After the body, because what the region asks about is what the body is // holding and half of it would not have been drawn yet from anywhere else. region_consults(pass, ui, slot); // The host's drawing under the described blocks, and only for a bespoke // region: a fill named against a pane is a host reaching into a region the // description already owns. The ordering is the arrangement // `Containment::Opaque` describes -- a heading the description owns above a // canvas it does not. if let RegionKind::Handover { .. } | RegionKind::Ceded { .. } = slot.kind { if let Some(fill) = pass.immediate.fill(&slot.id) { fill(pass.immediate, ui); } else if slot.kind.as_layout().owed() { // The half the split exists for. Before it, a region the app had // ruled undescribable and one nobody had filled yet were the same // value here, and both drew as nothing at all. unfilled(pass.immediate, ui); } } } /// A region whose children are answers to one question. /// /// Each slot under its number, with the control that takes it away, and the /// control that adds one under the lot. audiofiles' rule editor is the /// consumer and this is its host, so this is the arm the ruling was measured /// against: a condition is three questions that only mean anything together, /// which is the shape `Repeat` could not say. /// /// Everything is derived as nodes and drawn through [`draw`], so nothing here /// invents styling: a slot's number is the same heading a described one gets, /// and both controls take everything [`act_node`] knows about tone, waiting and /// confirmation. /// /// Nothing is narrowed by [`cutoff`]. A slot of a repeating question is not an /// optional member of a run -- dropping the third condition at a narrow width /// would hide an answer the reader gave, which is a different thing from /// dropping a toolbar button they can still reach from a menu. fn repeating_body( pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot, repeating: &quasi_router::Repeating, ) { let standing = slot.body.len(); for (at, placed) in slot.body.iter().enumerate() { // One-based, because it is read by a person. draw( pass, ui, &Node::section(format!("{} {}", repeating.one, at + 1)), ); draw(pass, ui, &placed.node); // The child's own, because only the child knows which slot it is. The // boundary is drawn rather than hidden, which is the call audiofiles' // editor already made for its last condition -- what changed is that // `Repeating::least` says it once instead of the app disabling its own // button. if let Node::Region(child) = &placed.node && let Some(removes) = &child.removes { draw( pass, ui, &Node::Act(bounded(removes, repeating.may_remove(standing))), ); } } draw( pass, ui, &Node::Act(bounded(&repeating.add, repeating.may_add(standing))), ); } /// One control, at whatever the floor or the ceiling says. fn bounded(act: &quasi_router::Act, allowed: bool) -> quasi_router::Act { if allowed { act.clone() } else { act.clone().disabled() } } /// A region showing one child at a time, and the chrome that moves between them. /// /// The derivation quasi-webview and quasi-tui both make, arriving here third. /// Nothing reads [`RegionKind::Widget`]'s name: a carousel, a tab group and a /// disclosure are one region that shows some of its children, and which idiom /// comes out falls out of what the children carry. /// /// **This renderer drew none of it until now.** `region_body` walked the whole /// body whatever [`layout::Showing`] said, so a described tab group came out as /// every panel stacked with no strip -- the same shape of defect as `run`'s, /// where a description said something and this renderer silently drew something /// else. The two findings this module's header recorded as gaps in the /// *description* ("a tabbed arrangement does not say which tab is showing", "a /// tab has no label") were both answered by `Showing` and [`Slot::label`] /// before this; what was left was nobody here reading them. /// /// # The three shapes, and what picks between them /// /// quasi-webview's, unchanged, because the picking is the vocabulary's and not /// the host's: /// /// - One dismissible child with a label is a summary line that opens. /// - Children carrying labels get a strip of them. /// - Anything else gets previous, position, next. /// /// # Where the bytes come from /// /// A child carrying [`Slot::fed_by`] is a panel behind a route, and pressing its /// tab calls it; the answer lands as a fragment naming that child. A child /// holding its content already is moved to locally with no request, which is /// what a carousel is. Presence is the only thing that picks between the two, /// exactly as in the browser. fn showing_body(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot, cutoff: layout::Priority) { let at = pass.view.shown(slot); let labels = slot.labels(); let total = slot.body.len(); // A named single child that can close is a disclosure, and the check comes // first because such a child is also a labelled one: a strip of one tab is // not what a summary line is. let disclosure = slot.showing().dismissible() && total == 1 && labels.len() == 1; if disclosure { if ui.selectable_label(at.is_some(), labels[0]).clicked() { pass.view.disclose(slot); } } else if !labels.is_empty() { // A strip sits above the panes it opens. The folder semantic, and the // same placement the other two renderers derive. ui.horizontal(|ui| { for (index, label) in labels.iter().enumerate() { if ui.selectable_label(at == Some(index), *label).clicked() { pass.view.show(&slot.id, index); // The panel's address, when the panel is a route rather // than content already here. No target rides with it: the // router answers with a fragment naming the slot it // changed, so the party that knows stays the party that // says. if let Some(action) = fed_child(slot, index) { pass.fire(action, Params::new(), None); } } } }); } // One child, or none at all: `Showing::AtMostOne` closed is the only way to // reach `None` here, and drawing nothing is what closed means. if let Some(index) = at && let Some(placed) = slot.body.get(index) && placed.kept_at(cutoff) { draw(pass, ui, &placed.node); } // A counter row sits under the content it counts, and only where there was // no strip to put above it. The position reads back one step: it says where // you are among the children and it is not one of them. if labels.is_empty() && !disclosure { ui.horizontal(|ui| { if ui.button("Prev").clicked() { pass.view.show_by(slot, -1); } ui.label(format!("{} / {total}", at.map_or(0, |index| index + 1))); if ui.button("Next").clicked() { pass.view.show_by(slot, 1); } }); } } /// The route a child of a showing region is fetched from, if it is fetched. /// /// quasi-webview's function of the same name, verbatim. Only a region can carry /// [`Slot::fed_by`], so a child that is a bare node is content already here by /// construction. fn fed_child(slot: &Slot, at: usize) -> Option<&Action> { match &slot.body.get(at)?.node { Node::Region(child) => child.fed_by.as_deref(), _ => None, } } /// The region's leading row: the members the description said share it. /// /// Ruling: wiki `layout-room-and-fallback`. A member nobody can see is worse /// than one drawn in the wrong direction, so the drop is what made this a /// defect rather than a shortfall. /// /// # What each fallback gets, and what it costs /// /// The room is measured here, per frame, from the width egui is offering, the /// same reading `cutoff` already takes for a region's body. Nothing is /// authored and nothing is remembered between frames. /// /// [`Fallback::Wrap`](layout::Fallback::Wrap) and /// [`Fallback::Stack`](layout::Fallback::Stack) are both `horizontal_wrapped`. /// egui breaks the row when the next member does not fit and measures each /// member from its own galley, which is the derived minimum the ruling asks /// for. The two differ in the webview by whether a wrapped member fills its /// line; egui has no equivalent knob on a wrapped layout, so this renderer /// answers both the same way and says so rather than authoring a width. /// /// # A member that asks to fill /// /// [`layout::Width::Fill`] members share what the content-sized ones did not /// take, equally, which is that type's own rule and the same answer the /// webview's `flex: 1 1 0` gives. Each is allocated an equal share of what is /// left where it stands, so the division is exact when the fills follow the /// members that take what they need. Both shapes the description reaches for /// are that: a pane beside a pane, and a row of peers. /// /// [`layout::Width::Fixed`] takes no allocation. A run carries no size, so /// there is nothing to fix a member at. /// /// [`Shed`](layout::Fallback::Shed) and [`Menu`](layout::Fallback::Menu) drop /// by [`layout::Priority`] through [`Run::kept_at`], which the webview cannot /// do at all -- there is no `@container (inline-size < min-content)` -- and /// this renderer can, because it is holding the width. `Menu` then puts what /// it shed behind one control, so every member stays reachable; `Shed` does /// not, which is what the description asked for when it chose the word. fn run(pass: &mut Pass<'_>, ui: &mut Ui, slot: &Slot, cutoff: layout::Priority) { let Some(run) = slot.run.as_ref() else { return; }; let kept = run.kept_at(cutoff); ui.horizontal_wrapped(|ui| { let mut fills = kept .iter() .filter(|placed| matches!(placed.width, layout::Width::Fill)) .count(); for placed in &kept { if !matches!(placed.width, layout::Width::Fill) { draw(pass, ui, &placed.node); continue; } // An equal share of what is left where it stands. Exact when the // fills come after the members that take what they need, which is // both shapes the description reaches for -- a pane beside a pane, // and a row of peers -- and an approximation when a fill is written // before a content member, because an immediate-mode library has // not measured that member yet and a sizing pass to find out would // be the remembered measurement the ruling forbids. let share = ui.available_width() / fills as f32; fills = fills.saturating_sub(1); let height = ui.available_height(); ui.allocate_ui(egui::vec2(share, height), |ui| { draw(pass, ui, &placed.node); }); } if matches!(run.fallback, layout::Fallback::Menu) { let shed: Vec<&quasi_router::Ranked> = run .members .iter() .filter(|member| !member.kept_at(cutoff)) .collect(); if !shed.is_empty() { // The label is the count rather than a name, because the // description did not give the row one and inventing "More // actions" here would be this renderer writing copy. ui.menu_button(format!("{} more", shed.len()), |ui| { for placed in shed { draw(pass, ui, &placed.node); } }); } } }); } /// The cutoff a region narrows to at this width, in points. /// /// egui measures in the same unit a browser's media query does, so the /// boundaries here are `makeover-geometry`'s size classes verbatim rather than /// a second set of numbers: a described screen narrows at the same width in a /// window and in a webview, which is the point of the classes being quoted in /// one place. The terminal renderer has to convert, because a cell is not a /// point, and says so where it does. /// /// Read off the width egui is offering right now and nothing else. That is /// what "Any width, one answer" costs in an immediate-mode library, and it is /// almost nothing -- the temptation the rule guards against is the memory /// store beside it, where a cutoff worked out once would be cheap to keep and /// would make the layout a function of the frame that put it there. pub(crate) fn cutoff(width: f32) -> layout::Priority { if width < 600.0 { layout::Priority::Essential } else if width < 840.0 { layout::Priority::Secondary } else { layout::Priority::Optional } }