//! The described things that are not fields, tables or frames. //! //! A meter, a token, a control, a figure. `makeover-tui` has had 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, Figure, Meter, State, Token, Tone}; 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, } impl Default for WidgetStyle { /// Bars at 6pt taking the width on offer, and a figure at double text size. 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, } } } /// 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); 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) }; ui.add_enabled( !disabled, egui::Button::new(RichText::new(label).color(colour)), ) } /// 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 } #[cfg(test)] mod tests { use super::*; 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_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_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, ); }); } }