Skip to main content

max / makeover-immediate

Draw the four nodes that were not fields, tables or frames `widget`: a meter, a token, a control, a figure. makeover-tui has had all four for releases and this crate had none, which stayed invisible while the only consumer called `field` and `table` directly. It stops being invisible the moment anything draws a whole quasi_router::Screen in egui: these are ordinary nodes, so a screen walk would have drawn them itself, one copy per consumer, which is the divergence the suite exists to end. The semantics are makeover-tui's on purpose. A meter clamps its fill and not its reading, so an overrun still reads over; a badge is round and answers no click while a chip is square and does; a control names the key the description gave it, which is Act::key finding a second reader; a figure puts the movement on the value rather than the caption. What differs is forced by the mode: every function draws into the Ui it is given and answers a Response, and none takes a focused flag, because egui owns focus. Palette grows the three status intents. They arrive together rather than one per widget for the reason Palette::fill is an Option: Tone is five members wide and a resolver missing one has to invent a colour, which is the substitution 0.2.0 spent a release removing. Breaking: Palette gains fields. 0.17.2 -> 0.18.0.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-13 16:48 UTC
Signed with PGP, not checked
Commit: 98b60849db1d2d1bb3bf6f9ff04a7e62e5566a2f
Parent: 9ef0f62
4 files changed, +438 insertions, -6 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.17.2"
3 + version = "0.18.0"
4 4 edition = "2024"
5 5 description = "The immediate-mode renderer for makeover-layout. Immediate mode is the constraint that matters, not the library: no cascade, no retained tree, one stroke per widget. Backed by egui."
6 6 license = "MIT"
M src/lib.rs +54 -5
@@ -94,6 +94,20 @@
94 94 //! description and from its style. A row count, a selection and a scroll request
95 95 //! are none of them style, and none of them survive the frame.
96 96 //!
97 + //! # 0.18.0: the nodes that were not fields, tables or frames
98 + //!
99 + //! [`widget`] draws a meter, a token, a control and a figure. `makeover-tui` has
100 + //! had all four for releases and this crate had none of them, which stayed
101 + //! invisible while the only consumer was an app calling [`field`] and [`table`]
102 + //! directly. It stopped being invisible the moment anything tried to draw a
103 + //! whole `quasi_router::Screen` in egui: the four are ordinary nodes, so a
104 + //! screen walk would have had to draw them itself, one copy per consumer.
105 + //!
106 + //! [`Palette`] grows the three status intents with it. They arrive together
107 + //! rather than one per widget for the reason [`Palette::fill`] is an `Option`:
108 + //! `Tone` is five members wide, and a resolver missing one has to invent a
109 + //! colour, which is the substitution 0.2.0 spent a release removing.
110 + //!
97 111 //! # Forms
98 112 //!
99 113 //! 0.5.0 adds the field vocabulary on top of the depth vocabulary:
@@ -146,10 +160,11 @@
146 160 Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Stroke,
147 161 TextEdit, Ui,
148 162 };
149 - use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State};
163 + use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State, Tone};
150 164
151 165 /// Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
152 166 pub mod table;
167 + pub mod widget;
153 168
154 169 /// The resolved colours this renderer needs, as flat values.
155 170 ///
@@ -230,11 +245,20 @@
230 245 pub action: Color32,
231 246 /// `danger`.
232 247 ///
233 - /// A field's error message. The one [`makeover_layout::Tone`] this renderer
234 - /// needs so far, and it is here rather than as a whole resolved tone set
235 - /// because notices are not drawn here yet and a palette should carry what
236 - /// is used.
248 + /// A field's error message, a destructive control, a bar that has run over.
237 249 pub danger: Color32,
250 + /// `success`.
251 + ///
252 + /// Added 0.18.0 with [`widget`], which is the first thing here that draws a
253 + /// [`Tone`]. The three status intents arrive together and not one at a
254 + /// time: [`Tone`] is five members wide and a resolver missing one has to
255 + /// invent a colour for it, which is the substitution this crate spent
256 + /// 0.2.0 removing from [`Palette::fill`].
257 + pub success: Color32,
258 + /// `warning`.
259 + pub warning: Color32,
260 + /// `info`.
261 + pub info: Color32,
238 262 }
239 263
240 264 impl Palette {
@@ -262,6 +286,28 @@
262 286 }
263 287 }
264 288
289 + /// The colour a [`Tone`] reads as.
290 + ///
291 + /// Total, unlike [`fill`](Self::fill), and the difference is not an
292 + /// inconsistency. `Fill` is `#[non_exhaustive]` and `Tone` is not: the
293 + /// description layer settled tone at five members and grows surfaces, so a
294 + /// total function here cannot be made to invent a colour by an upstream
295 + /// release the way a total `fill` could.
296 + ///
297 + /// [`Tone::Neutral`] is [`content`](Self::content) rather than a colour of
298 + /// its own, which is what "an ordinary fact" means: a neutral badge is text
299 + /// in a box, not a fifth status.
300 + #[must_use]
301 + pub const fn tone(&self, tone: Tone) -> Color32 {
302 + match tone {
303 + Tone::Neutral => self.content,
304 + Tone::Info => self.info,
305 + Tone::Success => self.success,
306 + Tone::Warning => self.warning,
307 + Tone::Danger => self.danger,
308 + }
309 + }
310 +
265 311 /// The cast shadow for a surface that overlays the page.
266 312 ///
267 313 /// What "overlaying" means in immediate mode, answered rather than skipped.
@@ -762,6 +808,9 @@
762 808 content_muted: Color32::from_rgb(6, 6, 6),
763 809 action: Color32::from_rgb(7, 7, 7),
764 810 danger: Color32::from_rgb(8, 8, 8),
811 + success: Color32::from_rgb(9, 9, 9),
812 + warning: Color32::from_rgb(10, 10, 10),
813 + info: Color32::from_rgb(11, 11, 11),
765 814 }
766 815 }
767 816
@@ -431,6 +431,9 @@
431 431 content_muted: Color32::from_rgb(7, 7, 7),
432 432 action: Color32::from_rgb(8, 8, 8),
433 433 danger: Color32::from_rgb(9, 9, 9),
434 + success: Color32::from_rgb(10, 10, 10),
435 + warning: Color32::from_rgb(11, 11, 11),
436 + info: Color32::from_rgb(12, 12, 12),
434 437 }
435 438 }
436 439
A src/widget.rs +380
@@ -1,0 +1,380 @@
1 + //! The described things that are not fields, tables or frames.
2 + //!
3 + //! A meter, a token, a control, a figure. `makeover-tui` has had these since its
4 + //! own `widget` module and this crate has not, which is the gap that showed up
5 + //! the moment anything tried to draw a whole `quasi_router::Screen` in egui:
6 + //! the screen walk had a renderer for the containers and nothing for four of the
7 + //! nodes inside them, so the drawing would have landed in the consumer, one copy
8 + //! per app. That is the divergence this suite exists to end, so it lands here.
9 + //!
10 + //! # What "in egui" changes, and what it does not
11 + //!
12 + //! The semantics are `makeover-tui`'s, deliberately: a meter is a bar and a
13 + //! reading, a badge is round and a chip is square, a control names its key where
14 + //! the description gave one, and a figure puts the movement on the value rather
15 + //! than on the caption. Those are description-level readings and they do not get
16 + //! a second opinion per host.
17 + //!
18 + //! What differs is forced by the target rather than chosen. A terminal spends a
19 + //! whole cell on a character and returns a `Line` for the caller to place; egui
20 + //! paints an arbitrary rect and answers a [`Response`], so every function here
21 + //! draws into the `Ui` it is given and hands back what the user did to it. That
22 + //! is also why nothing here takes a `focused` flag the way `makeover-tui`'s
23 + //! `act` does: egui owns focus, which is the rule the crate header states.
24 +
25 + use egui::{Align, Layout, Response, RichText, Sense, Ui, Vec2};
26 + use makeover_layout::{Act, Figure, Meter, State, Token, Tone};
27 +
28 + use crate::Palette;
29 +
30 + /// The sizes a widget cannot derive from the description.
31 + ///
32 + /// Every number a caller might reasonably want different, in one place, on the
33 + /// footing [`FrameStyle`](crate::FrameStyle) and [`FieldStyle`](crate::FieldStyle)
34 + /// already establish: this crate owns no sizes.
35 + #[derive(Debug, Clone, Copy, PartialEq)]
36 + pub struct WidgetStyle {
37 + /// How tall a meter's bar is drawn.
38 + pub meter_height: f32,
39 + /// How wide a meter's bar runs, or `None` to take the width on offer.
40 + ///
41 + /// `None` is the honest default in immediate mode: a bar in a side panel and
42 + /// a bar in a wide pane are the same description, and the available width is
43 + /// the only thing either of them knows.
44 + pub meter_width: Option<f32>,
45 + /// The corner radius on a meter's trough and on a token.
46 + pub radius: u8,
47 + /// Inside a token, around its label.
48 + pub token_padding: Vec2,
49 + /// Between a figure's value and its caption.
50 + pub figure_gap: f32,
51 + /// How much larger a figure's value is drawn than the body text.
52 + ///
53 + /// A multiplier rather than a size, so a figure scales with whatever text
54 + /// style the app has set rather than pinning a point size this crate has no
55 + /// business choosing.
56 + pub figure_scale: f32,
57 + }
58 +
59 + impl Default for WidgetStyle {
60 + /// Bars at 6pt taking the width on offer, and a figure at double text size.
61 + fn default() -> Self {
62 + Self {
63 + meter_height: 6.0,
64 + meter_width: None,
65 + radius: 3,
66 + token_padding: Vec2::new(6.0, 2.0),
67 + figure_gap: 2.0,
68 + figure_scale: 2.0,
69 + }
70 + }
71 + }
72 +
73 + /// A proportion as a bar and a reading.
74 + ///
75 + /// The reading is built here from the two numbers and the noun, for the reason
76 + /// `makeover-tui` states: [`Meter::label`] carries the noun alone, so each
77 + /// renderer picks its own sentence order rather than the description picking one
78 + /// for all of them.
79 + ///
80 + /// **A bar that has run over is drawn full and reads over.** `done` may exceed
81 + /// `total` and that is the case worth drawing, per `Meter`'s own docs: the fill
82 + /// is clamped because a rect cannot be longer than itself, and the reading is
83 + /// not, because "9/6" is the fact the user needs. Clamping both would hide the
84 + /// overrun entirely, which is the bug goingson's `is_over_estimate` flag exists
85 + /// to recover from on the other side.
86 + ///
87 + /// A zero `total` is no set rather than a complete one, so it draws empty.
88 + pub fn meter(ui: &mut Ui, meter: &Meter<'_>, palette: &Palette, style: &WidgetStyle) -> Response {
89 + let width = style
90 + .meter_width
91 + .unwrap_or_else(|| ui.available_width().max(1.0));
92 + ui.horizontal(|ui| {
93 + let (rect, response) =
94 + ui.allocate_exact_size(Vec2::new(width, style.meter_height), Sense::hover());
95 + // The trough is the sunken surface rather than a tint of the tone: a
96 + // bar is a thing set into the page with something in it, which is what
97 + // `Fill::Sunken` means, and tinting the empty half would read as a
98 + // second, paler proportion.
99 + ui.painter()
100 + .rect_filled(rect, style.radius, palette.sunken);
101 + let share = if meter.total == 0 {
102 + 0.0
103 + } else {
104 + (f64::from(meter.done) / f64::from(meter.total)).min(1.0)
105 + };
106 + #[expect(
107 + clippy::cast_possible_truncation,
108 + reason = "a share is 0..=1 and the product is a width in points"
109 + )]
110 + let filled = (f64::from(rect.width()) * share) as f32;
111 + if filled > 0.0 {
112 + let mut fill = rect;
113 + fill.set_width(filled);
114 + ui.painter()
115 + .rect_filled(fill, style.radius, palette.tone(meter.tone));
116 + }
117 + let reading = match meter.label {
118 + Some(label) => format!("{}/{} {label}", meter.done, meter.total),
119 + None => format!("{}/{}", meter.done, meter.total),
120 + };
121 + ui.label(RichText::new(reading).color(palette.content_muted));
122 + response
123 + })
124 + .inner
125 + }
126 +
127 + /// A badge or a chip.
128 + ///
129 + /// Round for a badge, square for a chip, which is `makeover-tui`'s reading and
130 + /// `makeover-webview`'s before it. The shape carries the difference because
131 + /// colour is already spent on the tone.
132 + ///
133 + /// **A chip answers a click and a badge does not**, which is
134 + /// [`Token::interactive`] and is the whole difference between the members. The
135 + /// `Response` comes back either way, so a caller that presses a badge is
136 + /// pressing something this function said was not interactive; the sense is what
137 + /// makes egui agree.
138 + ///
139 + /// `latched` is a chip that is switched on, and it fills rather than outlines. A
140 + /// terminal has to collide latched with focus because it has one spare axis for
141 + /// two facts; egui does not, so it does not.
142 + ///
143 + /// A chip's removable half is not drawn, on `makeover-tui`'s reasoning: a second
144 + /// control inside a token is a question for whoever owns the interaction rather
145 + /// than for a drawing.
146 + pub fn token(
147 + ui: &mut Ui,
148 + label: &str,
149 + kind: Token,
150 + tone: Tone,
151 + latched: bool,
152 + palette: &Palette,
153 + style: &WidgetStyle,
154 + ) -> Response {
155 + let painted = palette.tone(tone);
156 + let radius = match kind {
157 + // Round enough to read as a pill whatever the height turns out to be.
158 + Token::Badge => u8::MAX,
159 + Token::Chip { .. } => style.radius,
160 + };
161 + let sense = if kind.interactive() {
162 + Sense::click()
163 + } else {
164 + Sense::hover()
165 + };
166 +
167 + // Laid out before the rect is allocated, because a token is exactly as wide
168 + // as what it says plus its padding: there is no box to fit text into here,
169 + // the way a table cell has one.
170 + let ink = if latched { palette.page } else { painted };
171 + let galley = ui.painter().layout_no_wrap(
172 + label.to_owned(),
173 + egui::TextStyle::Body.resolve(ui.style()),
174 + ink,
175 + );
176 + let size = galley.size() + style.token_padding * 2.0;
177 + let (rect, response) = ui.allocate_exact_size(size, sense);
178 +
179 + if latched {
180 + ui.painter().rect_filled(rect, radius, painted);
181 + } else {
182 + ui.painter().rect_stroke(
183 + rect,
184 + radius,
185 + egui::Stroke::new(1.0, painted),
186 + egui::StrokeKind::Inside,
187 + );
188 + }
189 + ui.painter()
190 + .galley(rect.center() - galley.size() / 2.0, galley, ink);
191 + response
192 + }
193 +
194 + /// A control.
195 + ///
196 + /// The key the description named is drawn beside the label where there is one,
197 + /// which is [`Act::key`] finally being read by a second renderer: it was written
198 + /// for a terminal, and a desktop app has keys too.
199 + ///
200 + /// **A disabled control is drawn and does not answer**, through
201 + /// [`State::suppresses_interaction`] rather than a second reading of what
202 + /// disabled means, and it takes [`Palette::content_muted`] because that is the
203 + /// intent `State::Disabled` resolves to. egui is told through `add_enabled`, so
204 + /// its own focus walk skips it: a control that is drawn and not reachable is
205 + /// exactly what `disabled` means on every host, and here the host already has
206 + /// the machinery.
207 + pub fn act(ui: &mut Ui, act: &Act<'_>, palette: &Palette, _style: &WidgetStyle) -> Response {
208 + let disabled = act
209 + .state
210 + .is_some_and(State::suppresses_interaction);
211 + let label = match act.key {
212 + Some(key) => format!("{} ({key})", act.label),
213 + None => act.label.to_owned(),
214 + };
215 + let colour = if disabled {
216 + palette.content_muted
217 + } else {
218 + palette.tone(act.tone)
219 + };
220 + ui.add_enabled(
221 + !disabled,
222 + egui::Button::new(RichText::new(label).color(colour)),
223 + )
224 + }
225 +
226 + /// A figure: the value, then what it counts under it.
227 + ///
228 + /// The tone lands on the value and its change rather than on the caption, which
229 + /// is what [`Figure::tone`] means: the figure is an ordinary fact and it is the
230 + /// movement that reads as good or bad. `makeover-tui` says the same thing with a
231 + /// bold span; here it is a larger one, because egui can size text and a terminal
232 + /// cannot.
233 + pub fn figure(ui: &mut Ui, figure: &Figure<'_>, palette: &Palette, style: &WidgetStyle) -> Response {
234 + ui.with_layout(Layout::top_down(Align::Min), |ui| {
235 + let value = match figure.change {
236 + Some(change) => format!("{} {change}", figure.value),
237 + None => figure.value.to_owned(),
238 + };
239 + let size = egui::TextStyle::Body.resolve(ui.style()).size * style.figure_scale;
240 + let shown = ui.label(
241 + RichText::new(value)
242 + .color(palette.tone(figure.tone))
243 + .size(size)
244 + .strong(),
245 + );
246 + ui.add_space(style.figure_gap);
247 + ui.label(RichText::new(figure.caption).color(palette.content_muted));
248 + shown
249 + })
250 + .inner
251 + }
252 +
253 + #[cfg(test)]
254 + mod tests {
255 + use super::*;
256 +
257 + fn palette() -> Palette {
258 + use egui::Color32;
259 + Palette {
260 + page: Color32::from_rgb(1, 1, 1),
261 + raised: Color32::from_rgb(2, 2, 2),
262 + overlay: Color32::from_rgb(3, 3, 3),
263 + well: Color32::from_rgb(4, 4, 4),
264 + sunken: Color32::from_rgb(5, 5, 5),
265 + bevel_light: Color32::from_rgb(6, 6, 6),
266 + bevel_dark: Color32::from_rgb(7, 7, 7),
267 + elevation: Color32::from_black_alpha(40),
268 + content: Color32::from_rgb(20, 20, 20),
269 + content_muted: Color32::from_rgb(21, 21, 21),
270 + action: Color32::from_rgb(22, 22, 22),
271 + danger: Color32::from_rgb(23, 23, 23),
272 + success: Color32::from_rgb(24, 24, 24),
273 + warning: Color32::from_rgb(25, 25, 25),
274 + info: Color32::from_rgb(26, 26, 26),
275 + }
276 + }
277 +
278 + #[test]
279 + fn every_tone_resolves_and_no_two_share_a_colour() {
280 + // The reason the three status intents arrived together: a resolver
281 + // missing one has to invent a colour for it.
282 + let p = palette();
283 + let all = [
284 + p.tone(Tone::Neutral),
285 + p.tone(Tone::Info),
286 + p.tone(Tone::Success),
287 + p.tone(Tone::Warning),
288 + p.tone(Tone::Danger),
289 + ];
290 + for (i, a) in all.iter().enumerate() {
291 + for b in &all[i + 1..] {
292 + assert_ne!(a, b, "two tones resolved to one colour");
293 + }
294 + }
295 + assert_eq!(p.tone(Tone::Neutral), p.content, "neutral is ordinary text");
296 + }
297 +
298 + #[test]
299 + fn a_meter_draws_and_an_overrun_does_not_panic() {
300 + // `done` may exceed `total`, which is the case Meter's own docs call
301 + // the one worth drawing. The fill clamps; the reading does not.
302 + let p = palette();
303 + let style = WidgetStyle::default();
304 + egui::__run_test_ui(|ui| {
305 + meter(ui, &Meter::new(3, 6), &p, &style);
306 + meter(ui, &Meter::new(9, 6), &p, &style);
307 + // No set, rather than a complete one.
308 + meter(ui, &Meter::new(0, 0), &p, &style);
309 + // The overflow `makeover-layout` pins on its own side.
310 + meter(ui, &Meter::new(u32::MAX, u32::MAX), &p, &style);
311 + });
312 + }
313 +
314 + #[test]
315 + fn a_chip_answers_a_click_and_a_badge_does_not() {
316 + // `Token::interactive` is the whole difference between the members, and
317 + // the sense is what makes egui agree with it.
318 + let p = palette();
319 + let style = WidgetStyle::default();
320 + egui::__run_test_ui(|ui| {
321 + let badge = token(ui, "beta", Token::Badge, Tone::Info, false, &p, &style);
322 + assert!(!badge.sense.senses_click(), "a badge answers no click");
323 +
324 + let chip = token(
325 + ui,
326 + "drums",
327 + Token::Chip { removable: false },
328 + Tone::Neutral,
329 + false,
330 + &p,
331 + &style,
332 + );
333 + assert!(chip.sense.senses_click(), "a chip answers a click");
334 + });
335 + }
336 +
337 + #[test]
338 + fn a_disabled_control_is_drawn_and_does_not_answer() {
339 + // Present, visible, and not answering. Through
340 + // `State::suppresses_interaction` rather than a second reading here.
341 + let p = palette();
342 + let style = WidgetStyle::default();
343 + egui::__run_test_ui(|ui| {
344 + let live = act(ui, &Act::new("Save"), &p, &style);
345 + assert!(live.enabled());
346 +
347 + let gone = act(ui, &Act::new("Save").state(State::Disabled), &p, &style);
348 + assert!(!gone.enabled(), "a disabled control still answers");
349 + });
350 + }
351 +
352 + #[test]
353 + fn a_control_shows_the_key_the_description_named() {
354 + // `Act::key` was written for a terminal before there was one. A desktop
355 + // app has keys too, so this is its second reader.
356 + let p = palette();
357 + let style = WidgetStyle::default();
358 + egui::__run_test_ui(|ui| {
359 + act(ui, &Act::new("New").key("n"), &p, &style);
360 + act(ui, &Act::new("New"), &p, &style);
361 + });
362 + }
363 +
364 + #[test]
365 + fn a_figure_draws_its_movement_beside_its_value() {
366 + let p = palette();
367 + let style = WidgetStyle::default();
368 + egui::__run_test_ui(|ui| {
369 + figure(ui, &Figure::new("17", "Current streak"), &p, &style);
370 + figure(
371 + ui,
372 + &Figure::new("17", "Current streak")
373 + .change("+3")
374 + .tone(Tone::Success),
375 + &p,
376 + &style,
377 + );
378 + });
379 + }
380 + }