//! The described things that are not fields, tables or frames. //! //! A meter, a token, a control, a figure, and a wait. `makeover-tui` has had //! most of these since its //! own `widget` module and this crate has not, which is the gap that showed up //! the moment anything tried to draw a whole `quasi_router::Screen` in egui: //! the screen walk had a renderer for the containers and nothing for four of the //! nodes inside them, so the drawing would have landed in the consumer, one copy //! per app. That is the divergence this suite exists to end, so it lands here. //! //! # What "in egui" changes, and what it does not //! //! The semantics are `makeover-tui`'s, deliberately: a meter is a bar and a //! reading, a badge is round and a chip is square, a control names its key where //! the description gave one, and a figure puts the movement on the value rather //! than on the caption. Those are description-level readings and they do not get //! a second opinion per host. //! //! What differs is forced by the target rather than chosen. A terminal spends a //! whole cell on a character and returns a `Line` for the caller to place; egui //! paints an arbitrary rect and answers a [`Response`], so every function here //! draws into the `Ui` it is given and hands back what the user did to it. That //! is also why nothing here takes a `focused` flag the way `makeover-tui`'s //! `act` does: egui owns focus, which is the rule the crate header states. use egui::{Align, Layout, Response, RichText, Sense, Ui, Vec2}; use makeover_layout::{Act, Awaiting, Figure, Meter, State, Token, Tone}; use makeover_timing::activity_blink; use std::time::Duration; use crate::Palette; /// The sizes a widget cannot derive from the description. /// /// Every number a caller might reasonably want different, in one place, on the /// footing [`FrameStyle`](crate::FrameStyle) and [`FieldStyle`](crate::FieldStyle) /// already establish: this crate owns no sizes. #[derive(Debug, Clone, Copy, PartialEq)] pub struct WidgetStyle { /// How tall a meter's bar is drawn. pub meter_height: f32, /// How wide a meter's bar runs, or `None` to take the width on offer. /// /// `None` is the honest default in immediate mode: a bar in a side panel and /// a bar in a wide pane are the same description, and the available width is /// the only thing either of them knows. pub meter_width: Option, /// The corner radius on a meter's trough and on a token. pub radius: u8, /// Inside a token, around its label. pub token_padding: Vec2, /// Between a figure's value and its caption. pub figure_gap: f32, /// How much larger a figure's value is drawn than the body text. /// /// A multiplier rather than a size, so a figure scales with whatever text /// style the app has set rather than pinning a point size this crate has no /// business choosing. pub figure_scale: f32, /// The side of the activity mark, square. /// /// Small on purpose. The mark says one thing and a reader should have to /// look at it to read it, which is the difference between an indicator and /// an animation competing with the content it sits beside. pub mark_size: f32, } impl Default for WidgetStyle { /// Bars at 6pt taking the width on offer, a figure at double text size, and /// the activity mark a square a little larger than a bar is tall. fn default() -> Self { Self { meter_height: 6.0, meter_width: None, radius: 3, token_padding: Vec2::new(6.0, 2.0), figure_gap: 2.0, figure_scale: 2.0, mark_size: 8.0, } } } /// A proportion as a bar and a reading. /// /// The reading is built here from the two numbers and the noun, for the reason /// `makeover-tui` states: [`Meter::label`] carries the noun alone, so each /// renderer picks its own sentence order rather than the description picking one /// for all of them. /// /// **A bar that has run over is drawn full and reads over.** `done` may exceed /// `total` and that is the case worth drawing, per `Meter`'s own docs: the fill /// is clamped because a rect cannot be longer than itself, and the reading is /// not, because "9/6" is the fact the user needs. Clamping both would hide the /// overrun entirely, which is the bug goingson's `is_over_estimate` flag exists /// to recover from on the other side. /// /// A zero `total` is no set rather than a complete one, so it draws empty. pub fn meter(ui: &mut Ui, meter: &Meter<'_>, palette: &Palette, style: &WidgetStyle) -> Response { let width = style .meter_width .unwrap_or_else(|| ui.available_width().max(1.0)); ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover()); // The trough is the sunken surface rather than a tint of the tone: a // bar is a thing set into the page with something in it, which is what // `Fill::Sunken` means, and tinting the empty half would read as a // second, paler proportion. ui.painter().rect_filled(rect, style.radius, palette.sunken); let share = if meter.total == 0 { 0.0 } else { (f64::from(meter.done) / f64::from(meter.total)).min(1.0) }; #[expect( clippy::cast_possible_truncation, reason = "a share is 0..=1 and the product is a width in points" )] let filled = (f64::from(rect.width()) * share) as f32; if filled > 0.0 { let mut fill = rect; fill.set_width(filled); ui.painter() .rect_filled(fill, style.radius, palette.tone(meter.tone)); } let reading = match meter.label { Some(label) => format!("{}/{} {label}", meter.done, meter.total), None => format!("{}/{}", meter.done, meter.total), }; ui.label(RichText::new(reading).color(palette.content_muted)); response }) .inner } /// A badge or a chip. /// /// Round for a badge, square for a chip, which is `makeover-tui`'s reading and /// `makeover-webview`'s before it. The shape carries the difference because /// colour is already spent on the tone. /// /// **A chip answers a click and a badge does not**, which is /// [`Token::interactive`] and is the whole difference between the members. The /// `Response` comes back either way, so a caller that presses a badge is /// pressing something this function said was not interactive; the sense is what /// makes egui agree. /// /// `latched` is a chip that is switched on, and it fills rather than outlines. A /// terminal has to collide latched with focus because it has one spare axis for /// two facts; egui does not, so it does not. /// /// A chip's removable half is not drawn, on `makeover-tui`'s reasoning: a second /// control inside a token is a question for whoever owns the interaction rather /// than for a drawing. pub fn token( ui: &mut Ui, label: &str, kind: Token, tone: Tone, latched: bool, palette: &Palette, style: &WidgetStyle, ) -> Response { let painted = palette.tone(tone); let radius = match kind { // Round enough to read as a pill whatever the height turns out to be. Token::Badge => u8::MAX, Token::Chip { .. } => style.radius, }; let sense = if kind.interactive() { Sense::click() } else { Sense::hover() }; // Laid out before the rect is allocated, because a token is exactly as wide // as what it says plus its padding: there is no box to fit text into here, // the way a table cell has one. let ink = if latched { palette.page } else { painted }; let galley = ui.painter().layout_no_wrap( label.to_owned(), egui::TextStyle::Body.resolve(ui.style()), ink, ); let size = galley.size() + style.token_padding * 2.0; let (rect, response) = ui.allocate_exact_size(size, sense); if latched { ui.painter().rect_filled(rect, radius, painted); } else { ui.painter().rect_stroke( rect, radius, egui::Stroke::new(1.0, painted), egui::StrokeKind::Inside, ); } ui.painter() .galley(rect.center() - galley.size() / 2.0, galley, ink); // Say what was drawn, because painting it says nothing. // // A token allocates its rect and paints the text straight onto it, so // nothing reached the accessibility tree at all until 2026-08-22: an // interactive chip was a control a mouse could press and a screen reader // could not find, and a badge was text nobody could read out. The filter // panel's twenty-four key pills were the site -- a whole way of filtering, // absent. // // A chip that latches says so through `selected`, which is what a screen // reader announces as pressed. That is `latched`'s whole meaning: the key // is held down. let role = if kind.interactive() { egui::WidgetType::Button } else { egui::WidgetType::Label }; response.widget_info(|| { let mut info = egui::WidgetInfo::labeled(role, ui.is_enabled(), label); if kind.interactive() { info.selected = Some(latched); } info }); response } /// A control. /// /// The key the description named is drawn beside the label where there is one, /// which is [`Act::key`] finally being read by a second renderer: it was written /// for a terminal, and a desktop app has keys too. /// /// **A disabled control is drawn and does not answer**, through /// [`State::suppresses_interaction`] rather than a second reading of what /// disabled means, and it takes [`Palette::content_muted`] because that is the /// intent `State::Disabled` resolves to. egui is told through `add_enabled`, so /// its own focus walk skips it: a control that is drawn and not reachable is /// exactly what `disabled` means on every host, and here the host already has /// the machinery. pub fn act(ui: &mut Ui, act: &Act<'_>, palette: &Palette, _style: &WidgetStyle) -> Response { let disabled = act.state.is_some_and(State::suppresses_interaction); let label = match act.key { Some(key) => format!("{} ({key})", act.label), None => act.label.to_owned(), }; let colour = if disabled { palette.content_muted } else { palette.tone(act.tone) }; let drawn = ui.add_enabled( !disabled, egui::Button::new(RichText::new(label).color(colour)), ); // Standing help, as a hover, which is honest on this host in a way it is // not on a terminal: egui has a pointer. `makeover_tui` says the same // sentence as a muted row under the control. // // Drawn here rather than by the caller as of `Act::hint` (0.40.0). quasi's // egui renderer was doing exactly this outside the widget because // `layout::Act` carried no hint, so a host that was not quasi got nothing. match act.hint { Some(hint) => drawn.on_hover_text(hint), None => drawn, } } /// A figure: the value, then what it counts under it. /// /// The tone lands on the value and its change rather than on the caption, which /// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the /// movement that reads as good or bad. `makeover-tui` says the same thing with a /// bold span; here it is a larger one, because egui can size text and a terminal /// cannot. pub fn figure( ui: &mut Ui, figure: &Figure<'_>, palette: &Palette, style: &WidgetStyle, ) -> Response { ui.with_layout(Layout::top_down(Align::Min), |ui| { let value = match figure.change { Some(change) => format!("{} {change}", figure.value), None => figure.value.to_owned(), }; let size = egui::TextStyle::Body.resolve(ui.style()).size * style.figure_scale; let shown = ui.label( RichText::new(value) .color(palette.tone(figure.tone)) .size(size) .strong(), ); ui.add_space(style.figure_gap); ui.label(RichText::new(figure.caption).color(palette.content_muted)); shown }) .inner } /// What a host can see about a wait that is running. /// /// Both halves are optional because both are the host's to observe and neither /// is derivable from the description. `makeover_layout::Awaiting` says how big /// the payload is; nothing in a description can say how much of it has landed, /// because that is a fact about a transfer in flight. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Progress { /// How much has arrived, in whatever unit the description counted. /// /// `None` means nothing is watching the transfer, which is the common case /// and is what keeps the bar from being drawn out of a total alone. pub delivered: Option, /// How long the wait has lasted so far. /// /// The one time value a wait is allowed to show. Never a remaining time and /// never a rate: see [`awaiting`]. pub elapsed: Option, } /// The activity mark: one small square, blinking. /// /// Rule 2 of wiki `loading-and-progress-standard`, and the thing that replaced /// `Ui::spinner` here. A spinner turns at a rate it invented and reads as /// progress; this claims nothing beyond "something is happening", which is the /// whole of what an unmeasured wait knows. /// /// **`reduced` stills the mark rather than removing it.** egui has no /// `prefers-reduced-motion`, so the preference arrives as a bool from whatever /// the host asked its own platform, exactly as `makeover_timing::activity_blink` /// is shaped for. A still mark still says something is happening; hiding it /// would answer a request nobody made. /// /// The cadence is `makeover_timing::Cadence::Activity` and is not a number this /// crate chooses, so a browser, a terminal and an egui window blink together. /// /// Repaint is asked for at the next flip rather than every frame: a blinking /// mark should not turn a window that is otherwise idle into one that renders /// continuously. pub fn activity(ui: &mut Ui, reduced: bool, palette: &Palette, style: &WidgetStyle) -> Response { let (rect, response) = ui.allocate_exact_size(Vec2::splat(style.mark_size), Sense::hover()); let lit = match activity_blink(reduced) { // Still, and lit. The state the mark holds when nothing may move. None => true, Some(half) => { let half = half.as_secs_f64(); // A cadence of zero would divide by nothing and blink infinitely // fast, which is the one value the token cannot mean. if half <= 0.0 { true } else { let phase = ui.input(|input| input.time).rem_euclid(half * 2.0); let lit = phase < half; let next = if lit { half } else { half * 2.0 } - phase; ui.ctx() .request_repaint_after(Duration::from_secs_f64(next.max(0.0))); lit } } }; // Lit is the accent, dark is the trough it sits in. Not "drawn and not // drawn": a mark that vanishes half the time is a hole in the layout, and // the reader loses where to look between blinks. let colour = if lit { palette.action } else { palette.sunken }; ui.painter().rect_filled(rect, style.radius, colour); response } /// A wait, drawn from what is actually known about it. /// /// The branch is `Awaiting::is_determinate` and one more question the /// description cannot answer: whether anything is watching the transfer. A bar /// needs both a total and a numerator, so a described amount with no /// [`Progress::delivered`] beside it draws the mark, not an empty trough that /// implies someone is counting. /// /// **What the bar may not do**, from rule 1 of wiki /// `loading-and-progress-standard` and from `Awaiting`'s own docs: what is done /// over what there is, plus the time it has taken. Never a remaining time, an /// arrival time, or a rate extrapolated forward. A prediction is wrong the /// moment the transfer stalls, and being confidently wrong is worse than being /// honestly indeterminate. /// /// The reading is the two raw numbers, as [`meter`] does it. The unit is the /// app's — bytes for an upload, rows for an import — and a renderer that /// guessed at one would be formatting a quantity it was deliberately not told /// about. pub fn awaiting( ui: &mut Ui, awaiting: Awaiting, progress: Progress, reduced: bool, palette: &Palette, style: &WidgetStyle, ) -> Response { let (Some(total), Some(done)) = (awaiting.amount, progress.delivered) else { return activity(ui, reduced, palette, style); }; let width = style .meter_width .unwrap_or_else(|| ui.available_width().max(1.0)); ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover()); ui.painter().rect_filled(rect, style.radius, palette.sunken); // A total of zero is no payload rather than a finished one, which is // `meter`'s reading of the same case. Over-delivery clamps for the same // reason it does there: a rect cannot be longer than itself. let share = if total == 0 { 0.0 } else { #[expect( clippy::cast_precision_loss, reason = "a byte count past 2^53 is not a wait anyone is watching a bar for" )] let share = (done as f64 / total as f64).min(1.0); share }; #[expect( clippy::cast_possible_truncation, reason = "a share is 0..=1 and the product is a width in points" )] let filled = (f64::from(rect.width()) * share) as f32; if filled > 0.0 { let mut fill = rect; fill.set_width(filled); ui.painter().rect_filled(fill, style.radius, palette.action); } let reading = match progress.elapsed { Some(elapsed) => format!("{done}/{total} {}s", elapsed.as_secs()), None => format!("{done}/{total}"), }; ui.label(RichText::new(reading).color(palette.content_muted)); response }) .inner } #[cfg(test)] mod tests { use super::*; /// What the accessibility tree says a widget drew. fn announced( draw: impl FnMut(&mut Ui), ) -> Vec<( egui::accesskit::Role, String, Option, )> { let ctx = egui::Context::default(); ctx.enable_accesskit(); let mut draw = draw; let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(600.0, 400.0), )), ..Default::default() }; let _ = ctx.run_ui(input(), &mut draw); let out = ctx.run_ui(input(), &mut draw); out.platform_output .accesskit_update .expect("accesskit is on") .nodes .iter() .map(|(_, node)| { ( node.role(), node.label() .or_else(|| node.value()) .unwrap_or_default() .to_owned(), node.toggled(), ) }) .collect() } #[test] fn a_chip_is_announced_as_a_control_and_says_whether_it_is_held() { // A token paints its own text onto its own rect, so before 2026-08-22 // it reached the tree as nothing: pressable by a mouse and invisible to // everything else. let p = palette(); let style = WidgetStyle::default(); let drawn = announced(|ui| { token( ui, "C#", Token::Chip { removable: false }, Tone::Neutral, true, &p, &style, ); }); let chip = drawn .iter() .find(|(role, name, _)| *role == egui::accesskit::Role::Button && name == "C#") .unwrap_or_else(|| panic!("the chip is not in the tree: {drawn:?}")); assert_eq!( chip.2, Some(egui::accesskit::Toggled::True), "a latched chip is held down and says so: {drawn:?}" ); } #[test] fn a_badge_is_announced_as_the_text_it_is() { // Not a control, and not nothing either: a badge is a word on the // screen and painting it is not the same as saying it. let p = palette(); let style = WidgetStyle::default(); let drawn = announced(|ui| { token(ui, "wav", Token::Badge, Tone::Neutral, false, &p, &style); }); assert!( drawn .iter() .any(|(role, name, _)| *role == egui::accesskit::Role::Label && name == "wav"), "{drawn:?}" ); assert!( !drawn .iter() .any(|(role, _, _)| *role == egui::accesskit::Role::Button), "a badge answers nothing and must not claim to: {drawn:?}" ); } fn palette() -> Palette { use egui::Color32; Palette { page: Color32::from_rgb(1, 1, 1), raised: Color32::from_rgb(2, 2, 2), overlay: Color32::from_rgb(3, 3, 3), well: Color32::from_rgb(4, 4, 4), sunken: Color32::from_rgb(5, 5, 5), bevel_light: Color32::from_rgb(6, 6, 6), bevel_dark: Color32::from_rgb(7, 7, 7), elevation: Color32::from_black_alpha(40), content: Color32::from_rgb(20, 20, 20), content_secondary: Color32::from_rgb(120, 120, 120), content_muted: Color32::from_rgb(21, 21, 21), action: Color32::from_rgb(22, 22, 22), danger: Color32::from_rgb(23, 23, 23), success: Color32::from_rgb(24, 24, 24), warning: Color32::from_rgb(25, 25, 25), info: Color32::from_rgb(26, 26, 26), } } #[test] fn every_tone_resolves_and_no_two_share_a_colour() { // The reason the three status intents arrived together: a resolver // missing one has to invent a colour for it. let p = palette(); let all = [ p.tone(Tone::Neutral), p.tone(Tone::Info), p.tone(Tone::Success), p.tone(Tone::Warning), p.tone(Tone::Danger), ]; for (i, a) in all.iter().enumerate() { for b in &all[i + 1..] { assert_ne!(a, b, "two tones resolved to one colour"); } } assert_eq!(p.tone(Tone::Neutral), p.content, "neutral is ordinary text"); } #[test] fn a_meter_draws_and_an_overrun_does_not_panic() { // `done` may exceed `total`, which is the case Meter's own docs call // the one worth drawing. The fill clamps; the reading does not. let p = palette(); let style = WidgetStyle::default(); egui::__run_test_ui(|ui| { meter(ui, &Meter::new(3, 6), &p, &style); meter(ui, &Meter::new(9, 6), &p, &style); // No set, rather than a complete one. meter(ui, &Meter::new(0, 0), &p, &style); // The overflow `makeover-layout` pins on its own side. meter(ui, &Meter::new(u32::MAX, u32::MAX), &p, &style); }); } #[test] fn a_wait_draws_a_bar_only_when_something_is_counting_it() { // The described total is half of what a bar needs. Without a numerator // the honest drawing is the mark, not an empty trough implying that // someone is watching bytes land. let p = palette(); let style = WidgetStyle::default(); egui::__run_test_ui(|ui| { awaiting( ui, Awaiting::unmeasured(), Progress::default(), false, &p, &style, ); awaiting( ui, Awaiting::of(41_943_040), Progress::default(), false, &p, &style, ); awaiting( ui, Awaiting::of(41_943_040), Progress { delivered: Some(10_485_760), elapsed: Some(Duration::from_secs(3)), }, false, &p, &style, ); // A zero payload is no payload, and over-delivery clamps. awaiting( ui, Awaiting::of(0), Progress { delivered: Some(9), elapsed: None, }, false, &p, &style, ); awaiting( ui, Awaiting::of(4), Progress { delivered: Some(9), elapsed: None, }, false, &p, &style, ); }); } #[test] fn reduced_motion_stills_the_mark_and_does_not_remove_it() { // `activity_blink(true)` is None, which means lit and still. A renderer // that drew nothing would have answered a request nobody made. let p = palette(); let style = WidgetStyle::default(); egui::__run_test_ui(|ui| { let still = activity(ui, true, &p, &style); let blinking = activity(ui, false, &p, &style); assert_eq!( still.rect.size(), blinking.rect.size(), "the mark occupies the same space either way" ); }); } #[test] fn a_chip_answers_a_click_and_a_badge_does_not() { // `Token::interactive` is the whole difference between the members, and // the sense is what makes egui agree with it. let p = palette(); let style = WidgetStyle::default(); egui::__run_test_ui(|ui| { let badge = token(ui, "beta", Token::Badge, Tone::Info, false, &p, &style); assert!(!badge.sense.senses_click(), "a badge answers no click"); let chip = token( ui, "drums", Token::Chip { removable: false }, Tone::Neutral, false, &p, &style, ); assert!(chip.sense.senses_click(), "a chip answers a click"); }); } #[test] fn a_disabled_control_is_drawn_and_does_not_answer() { // Present, visible, and not answering. Through // `State::suppresses_interaction` rather than a second reading here. let p = palette(); let style = WidgetStyle::default(); egui::__run_test_ui(|ui| { let live = act(ui, &Act::new("Save"), &p, &style); assert!(live.enabled()); let gone = act(ui, &Act::new("Save").state(State::Disabled), &p, &style); assert!(!gone.enabled(), "a disabled control still answers"); }); } #[test] fn a_control_shows_the_key_the_description_named() { // `Act::key` was written for a terminal before there was one. A desktop // app has keys too, so this is its second reader. let p = palette(); let style = WidgetStyle::default(); egui::__run_test_ui(|ui| { act(ui, &Act::new("New").key("n"), &p, &style); act(ui, &Act::new("New"), &p, &style); }); } #[test] fn a_figure_draws_its_movement_beside_its_value() { let p = palette(); let style = WidgetStyle::default(); egui::__run_test_ui(|ui| { figure(ui, &Figure::new("17", "Current streak"), &p, &style); figure( ui, &Figure::new("17", "Current streak") .change("+3") .tone(Tone::Success), &p, &style, ); }); } }