Skip to main content

max / makeover-immediate

0.5.0: the field renderer The missing middle of the forms chain. makeover-layout has carried Field, FieldKind and Arrangement for six releases and this crate carried none of it: its whole public surface was Palette, FrameStyle, paint_bevel and frame, so a description saying "text field, labelled, required, with this hint" had no way to become a widget and audiofiles' forms stayed hand-rolled. field() draws one field as the column makeover-webview already established -- label, control, hint, error -- and group() lays a set of them out, with the extended disclosure left to the app because it belongs to the form and not to any field. The webview emitter's bug fixes come along rather than being re-found: a select handed a value none of its options carries keeps that value on screen instead of silently reading as the first option. Three differences from the webview renderer, all forced by the mode: - The value arrives as a &mut through Filling. There is no DOM to read back out of, which is the same reason the description does not carry it. - A text control is drawn as a well and a select is not. A well is what the user looks into; a select and a checkbox are pressed. - State::Focus is not drawn. egui already paints one focus stroke and the description asks for one ring, so a second would break the rule it came from. State::Disabled is drawn, since egui has no opinion until told. Breaking: Palette gains content, content_muted and danger. This is the first thing here that draws text, and a hint and an error message cannot be rendered without them. Requires makeover-layout 0.8.0 for Field::placeholder and Field::options.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 18:50 UTC
Signed with PGP, not checked
Commit: 96f28ea80905dbedb80726cff4967c43bab564df
Parent: 9ab2185
2 files changed, +470 insertions, -4 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.4.1"
3 + version = "0.5.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"
@@ -8,7 +8,7 @@
8 8
9 9 [dependencies]
10 10 egui = { version = "0.35", default-features = false }
11 - makeover-layout = "0.6.0"
11 + makeover-layout = "0.8.0"
12 12
13 13 [lints.rust]
14 14 unused = "warn"
M src/lib.rs +468 -2
@@ -33,11 +33,43 @@
33 33 //! cascade carry it. An immediate-mode renderer has nowhere to put that, so
34 34 //! every call site decides. [`makeover_layout::Depth::pressed`] is what keeps
35 35 //! the decision from being re-derived per widget.
36 + //!
37 + //! # Forms
38 + //!
39 + //! 0.5.0 adds the field vocabulary on top of the depth vocabulary:
40 + //! [`makeover_layout::Field`] rendered to egui widgets, in [`field`], and a set
41 + //! of them laid down a column in [`group`]. Before it, a description saying
42 + //! "text field, labelled, required, with this hint" had no way to become a
43 + //! widget here, and audiofiles' forms stayed hand-rolled.
44 + //!
45 + //! `makeover-webview` got there first and its form emitter is the precedent
46 + //! followed rather than re-derived, including the parts that are bug fixes: a
47 + //! select handed a value none of its options carries keeps that value visible
48 + //! instead of silently reading as the first option, which is a save-the-wrong-
49 + //! thing bug goingson hit for real.
50 + //!
51 + //! What differs is forced by the mode and not chosen:
52 + //!
53 + //! - **The value arrives as a `&mut`.** [`Filling`] borrows the app's own field
54 + //! and the widget writes through it. There is no DOM to read back out of,
55 + //! which is also why the description deliberately does not carry the value.
56 + //! - **A text control is drawn as a well and a select is not.** The description
57 + //! holds that a well is for anything the user looks *into*, and a text field
58 + //! is its own example; a select and a checkbox are pressed rather than looked
59 + //! into, so they keep egui's own control painting.
60 + //! - **[`makeover_layout::State::Focus`] is not drawn here.** egui already
61 + //! paints exactly one focus stroke, and the description's rule is one ring
62 + //! rather than a ring per primitive, so adding a second would break the rule
63 + //! it came from. [`makeover_layout::State::Disabled`] *is* drawn, because egui
64 + //! has no opinion about it until told.
36 65
37 66 #![forbid(unsafe_code)]
38 67
39 - use egui::{Color32, CornerRadius, Margin, Painter, Rect, Shape, Stroke, Ui};
40 - use makeover_layout::{Bevel, Depth, Edge, Fill};
68 + use egui::{
69 + Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Stroke,
70 + TextEdit, Ui,
71 + };
72 + use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State};
41 73
42 74 /// The resolved colours this renderer needs, as flat values.
43 75 ///
@@ -75,6 +107,26 @@
75 107 pub bevel_light: Color32,
76 108 /// `bevel-dark`.
77 109 pub bevel_dark: Color32,
110 + /// `content`.
111 + ///
112 + /// Ordinary text. Added 0.5.0 with the field renderer, which is the first
113 + /// thing here that draws any: until then this crate painted surfaces and
114 + /// edges and let the caller's own egui visuals answer for text.
115 + pub content: Color32,
116 + /// `content-muted`.
117 + ///
118 + /// A field's hint, and what
119 + /// [`makeover_layout::State::Disabled`](makeover_layout::State::Disabled)
120 + /// resolves to. Both readings come from the description rather than from
121 + /// here: `State::Disabled` names this intent by token.
122 + pub content_muted: Color32,
123 + /// `danger`.
124 + ///
125 + /// A field's error message. The one [`makeover_layout::Tone`] this renderer
126 + /// needs so far, and it is here rather than as a whole resolved tone set
127 + /// because notices are not drawn here yet and a palette should carry what
128 + /// is used.
129 + pub danger: Color32,
78 130 }
79 131
80 132 impl Palette {
@@ -211,6 +263,285 @@
211 263 framed.inner
212 264 }
213 265
266 + /// The geometry a field group is drawn with.
267 + ///
268 + /// Values again, for the reason [`FrameStyle`] is: every number here belongs to
269 + /// `makeover-geometry` and arrives already resolved.
270 + #[derive(Debug, Clone, Copy, PartialEq)]
271 + pub struct FieldStyle {
272 + /// The well a text control sits in.
273 + pub frame: FrameStyle,
274 + /// Between a field's own parts: its label, its control, its hint and its
275 + /// error.
276 + pub gap: f32,
277 + /// Between one field and the next.
278 + pub group_gap: f32,
279 + /// What marks a required field, appended to its label.
280 + ///
281 + /// A knob rather than a constant, because it is the one piece of *copy* in
282 + /// this crate and copy is not a renderer's call. A webview does not need it
283 + /// at all — it emits the `required` attribute and the browser answers — so
284 + /// this renderer is the first place where a compulsory field either shows
285 + /// that it is or silently does not.
286 + pub required_marker: &'static str,
287 + }
288 +
289 + impl Default for FieldStyle {
290 + /// The default frame, no gaps, and an asterisk.
291 + fn default() -> Self {
292 + Self {
293 + frame: FrameStyle::default(),
294 + gap: 0.0,
295 + group_gap: 0.0,
296 + required_marker: "*",
297 + }
298 + }
299 + }
300 +
301 + /// What the field currently holds, borrowed from wherever the app keeps it.
302 + ///
303 + /// The immediate-mode counterpart of `makeover_webview::form::Value`, and the
304 + /// place the two renderers are forced apart: there the value is read back out
305 + /// of the DOM after the fact, and here the widget writes through this borrow as
306 + /// it is edited. Same reason the description carries neither.
307 + ///
308 + /// An enum rather than a bag of options, on the reasoning
309 + /// `makeover_webview::form::Value` records: a checkbox holding a string is
310 + /// unsayable here, where a struct would let it be said and then have to cope.
311 + #[derive(Debug, Default)]
312 + pub enum Filling<'a> {
313 + /// Nothing to edit. The control is drawn and does not answer.
314 + #[default]
315 + Absent,
316 + /// The buffer behind anything that takes typed text, a select included:
317 + /// what a select holds is the `value` of one of its [`Choice`]s.
318 + ///
319 + /// [`Choice`]: makeover_layout::Choice
320 + Text(&'a mut String),
321 + /// A checkbox, on or off.
322 + On(&'a mut bool),
323 + }
324 +
325 + /// The label, marked if the field is compulsory.
326 + fn label_text(field: &Field<'_>, style: &FieldStyle) -> String {
327 + if field.required {
328 + format!("{} {}", field.label, style.required_marker)
329 + } else {
330 + field.label.to_owned()
331 + }
332 + }
333 +
334 + /// The three shapes a control comes in here, which is fewer than there are
335 + /// kinds.
336 + ///
337 + /// [`FieldKind`] is `#[non_exhaustive]` and grows; this does not, because the
338 + /// ways egui has of asking for a value do not. Reducing the open set to this
339 + /// closed one in one total function is what keeps a new kind from needing a new
340 + /// arm at every match below.
341 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
342 + enum Control {
343 + /// Typed into, so it is drawn as a well: the user looks into it.
344 + Typed,
345 + /// Picked from. Pressed rather than looked into, so egui's own control
346 + /// painting stands.
347 + Chosen,
348 + /// Held on or off.
349 + Toggled,
350 + }
351 +
352 + /// Which shape a kind takes.
353 + ///
354 + /// The wildcard falls to [`Control::Typed`] on purpose: a kind added to the
355 + /// description since this renderer was built degrades to a text box, which
356 + /// accepts any value the others would, rather than to nothing drawn at all.
357 + const fn control_shape(kind: FieldKind) -> Control {
358 + match kind {
359 + FieldKind::Select => Control::Chosen,
360 + FieldKind::Checkbox => Control::Toggled,
361 + _ => Control::Typed,
362 + }
363 + }
364 +
365 + /// What a select shows for the value it currently holds.
366 + ///
367 + /// A value no option carries stays on screen as itself rather than reading as
368 + /// whichever option happens to be first. goingson saved a backup retention of
369 + /// 10 against a 1/3/7/14/0 list and the browser silently showed it as 1, so the
370 + /// next save wrote a value nobody chose; `makeover-webview` grew the fix as a
371 + /// stray `<option>` and this is the same fix in the shape egui allows.
372 + fn shown_label<'a>(options: &'a [Choice<'a>], value: &'a str) -> &'a str {
373 + options
374 + .iter()
375 + .find(|opt| opt.value == value)
376 + .map_or(value, |opt| opt.label)
377 + }
378 +
379 + /// The control alone, without its label, hint or error.
380 + fn control(
381 + ui: &mut Ui,
382 + field: &Field<'_>,
383 + filling: Filling<'_>,
384 + palette: &Palette,
385 + style: &FieldStyle,
386 + ) -> Response {
387 + // The mismatch path: described as one thing and filled as another. Nothing
388 + // here can fix it, so it is drawn as the empty, inert version of what was
389 + // described — visible on screen, in the way an empty select is at the
390 + // webview renderer, rather than reported in a log nobody reads.
391 + let mut discard = String::new();
392 + let mut off = false;
393 +
394 + match control_shape(field.kind) {
395 + Control::Typed => {
396 + let text = match filling {
397 + Filling::Text(text) => text,
398 + _ => &mut discard,
399 + };
400 + // An empty frame and no margin: the well is this crate's, and egui's
401 + // own control background and padding would sit underneath it saying
402 + // something different about both.
403 + let mut edit = if matches!(field.kind, FieldKind::Textarea) {
404 + TextEdit::multiline(text)
405 + } else {
406 + TextEdit::singleline(text)
407 + }
408 + .frame(egui::Frame::NONE)
409 + .margin(Margin::ZERO)
410 + .text_color(palette.content)
411 + .password(field.kind.confidential());
412 + if let Some(ghost) = field.placeholder {
413 + edit = edit.hint_text(RichText::new(ghost).color(palette.content_muted));
414 + }
415 + frame(ui, Depth::Well, palette, style.frame, |ui| ui.add(edit))
416 + }
417 + Control::Toggled => {
418 + let on = match filling {
419 + Filling::On(on) => on,
420 + _ => &mut off,
421 + };
422 + ui.checkbox(on, RichText::new(field.label).color(palette.content))
423 + }
424 + Control::Chosen => {
425 + let value = match filling {
426 + Filling::Text(text) => text,
427 + _ => &mut discard,
428 + };
429 + let shown = shown_label(field.options, value);
430 + ComboBox::from_id_salt(field.name)
431 + .selected_text(RichText::new(shown).color(palette.content))
432 + .show_ui(ui, |ui| {
433 + for opt in field.options {
434 + ui.selectable_value(
435 + value,
436 + opt.value.to_owned(),
437 + RichText::new(opt.label).color(palette.content),
438 + );
439 + }
440 + })
441 + .response
442 + }
443 + }
444 + }
445 +
446 + /// One field, as the column the app drops into its form.
447 + ///
448 + /// The anatomy is `makeover-webview`'s, so the two renderers put a form
449 + /// together the same way: label, control, hint, error, top to bottom, with a
450 + /// checkbox labelling itself instead of taking a label above.
451 + ///
452 + /// Returns [`None`] for a [`FieldKind::Hidden`] field, which is what
453 + /// [`FieldKind::visible`] means and is the honest answer here: a webview still
454 + /// emits an input for it because the form submits, and an immediate-mode
455 + /// renderer has no form and no submission, so a hidden field is a value the app
456 + /// already holds and there is nothing to draw or to respond to.
457 + ///
458 + /// `state` is the description's interaction axis.
459 + /// [`State::Disabled`] greys the field and stops it answering, through
460 + /// [`State::suppresses_interaction`] rather than through a second reading of
461 + /// what disabled means. [`State::Focus`] is deliberately not acted on: egui
462 + /// paints its own focus stroke and the description asks for one ring, not one
463 + /// per renderer that happens to have opinions.
464 + pub fn field(
465 + ui: &mut Ui,
466 + field: &Field<'_>,
467 + filling: Filling<'_>,
468 + state: Option<State>,
469 + palette: &Palette,
470 + style: &FieldStyle,
471 + ) -> Option<Response> {
472 + if !field.kind.visible() {
473 + return None;
474 + }
475 + let enabled = !state.is_some_and(State::suppresses_interaction);
476 + let text = if enabled {
477 + palette.content
478 + } else {
479 + palette.content_muted
480 + };
481 +
482 + let response = ui
483 + .vertical(|ui| {
484 + ui.spacing_mut().item_spacing.y = style.gap;
485 +
486 + // A checkbox labels itself, on the right of the box.
487 + // `FieldKind::labels_itself` is the description saying so, and both
488 + // webview apps special-cased it inline before it did.
489 + if !field.kind.labels_itself() {
490 + ui.label(RichText::new(label_text(field, style)).color(text));
491 + }
492 +
493 + let response = ui
494 + .add_enabled_ui(enabled, |ui| control(ui, field, filling, palette, style))
495 + .inner;
496 +
497 + // Standing help first, then what is wrong now. Both, in that order,
498 + // for the reason the webview renderer names both in
499 + // `aria-describedby`: an error appearing must not take the hint
500 + // away with it.
501 + if let Some(hint) = field.hint {
502 + ui.label(RichText::new(hint).color(palette.content_muted));
503 + }
504 + if let Some(error) = field.error {
505 + ui.label(RichText::new(error).color(palette.danger));
506 + }
507 + response
508 + })
509 + .inner;
510 +
511 + Some(response)
512 + }
513 +
514 + /// A set of fields, laid down a column.
515 + ///
516 + /// `show_extended` is the disclosure, and it is a parameter rather than state
517 + /// held here because the disclosure belongs to the *form* and not to any field:
518 + /// [`Field::extended`] marks which fields are behind one, and the app owns
519 + /// whether it is open. That is the same division `makeover-webview` draws when
520 + /// it marks the group `data-extended` and emits no control to toggle it.
521 + ///
522 + /// `draw` is called once per field that should be visible, in order. Taking a
523 + /// callback rather than a slice of [`Filling`]s is what keeps the app's own
524 + /// values borrowed one at a time: a form's fields usually live in different
525 + /// structs, and a parallel array would have to be built each frame and kept in
526 + /// step with the description by hand.
527 + pub fn group<'a>(
528 + ui: &mut Ui,
529 + fields: &'a [Field<'a>],
530 + show_extended: bool,
531 + style: &FieldStyle,
532 + mut draw: impl FnMut(&mut Ui, &'a Field<'a>),
533 + ) {
534 + ui.vertical(|ui| {
535 + ui.spacing_mut().item_spacing.y = style.group_gap;
536 + for f in fields {
537 + if f.extended && !show_extended {
538 + continue;
539 + }
540 + draw(ui, f);
541 + }
542 + });
543 + }
544 +
214 545 #[cfg(test)]
215 546 mod tests {
216 547 use super::*;
@@ -224,6 +555,9 @@
224 555 sunken: Color32::from_rgb(4, 4, 4),
225 556 bevel_light: Color32::WHITE,
226 557 bevel_dark: Color32::BLACK,
558 + content: Color32::from_rgb(5, 5, 5),
559 + content_muted: Color32::from_rgb(6, 6, 6),
560 + danger: Color32::from_rgb(7, 7, 7),
227 561 }
228 562 }
229 563
@@ -281,6 +615,153 @@
281 615 assert!(Depth::Flat.bevel().is_none());
282 616 }
283 617
618 + #[test]
619 + fn a_select_keeps_a_value_none_of_its_options_carries() {
620 + // The save-the-wrong-thing bug, asserted at the second renderer so it
621 + // is not re-found there. goingson's own numbers.
622 + let options = [
623 + Choice::plain("1"),
624 + Choice::plain("3"),
625 + Choice::plain("7"),
626 + Choice::plain("14"),
627 + ];
628 + assert_eq!(shown_label(&options, "10"), "10");
629 + // And a value that does match reads as its label, not as itself.
630 + let spelled = [Choice {
631 + value: "7",
632 + label: "One week",
633 + }];
634 + assert_eq!(shown_label(&spelled, "7"), "One week");
635 + }
636 +
637 + #[test]
638 + fn only_a_required_field_is_marked() {
639 + let style = FieldStyle::default();
640 + let plain = Field::new(FieldKind::Text, "title", "Title");
641 + assert_eq!(label_text(&plain, &style), "Title");
642 +
643 + let required = Field {
644 + required: true,
645 + ..plain
646 + };
647 + assert_eq!(label_text(&required, &style), "Title *");
648 +
649 + // The marker is copy and the app owns it, which is why it is a knob.
650 + let house = FieldStyle {
651 + required_marker: "(required)",
652 + ..style
653 + };
654 + assert_eq!(label_text(&required, &house), "Title (required)");
655 + }
656 +
657 + #[test]
658 + fn a_select_and_a_checkbox_are_pressed_and_everything_else_is_typed_into() {
659 + // What decides whether the control gets a well. A well is for what the
660 + // user looks into, and only one of these is.
661 + assert_eq!(control_shape(FieldKind::Select), Control::Chosen);
662 + assert_eq!(control_shape(FieldKind::Checkbox), Control::Toggled);
663 + for k in [
664 + FieldKind::Text,
665 + FieldKind::Secret,
666 + FieldKind::Number,
667 + FieldKind::Email,
668 + FieldKind::Url,
669 + FieldKind::Tel,
670 + FieldKind::Textarea,
671 + ] {
672 + assert_eq!(control_shape(k), Control::Typed, "{k:?} is typed into");
673 + }
674 + }
675 +
676 + #[test]
677 + fn a_hidden_field_draws_nothing_and_answers_nothing() {
678 + // Where the two renderers legitimately part: a webview still emits an
679 + // input because the form submits, and there is no form here.
680 + let f = Field::new(FieldKind::Hidden, "id", "Id");
681 + let p = palette(Color32::from_rgb(9, 9, 9));
682 + egui::__run_test_ui(|ui| {
683 + let drawn = field(ui, &f, Filling::Absent, None, &p, &FieldStyle::default());
684 + assert!(drawn.is_none());
685 + });
686 + }
687 +
688 + #[test]
689 + fn a_disabled_field_stops_answering_and_a_focused_one_does_not() {
690 + let f = Field::new(FieldKind::Text, "title", "Title");
691 + let p = palette(Color32::from_rgb(9, 9, 9));
692 + let style = FieldStyle::default();
693 + egui::__run_test_ui(|ui| {
694 + let mut text = String::from("x");
695 + let disabled = field(
696 + ui,
697 + &f,
698 + Filling::Text(&mut text),
699 + Some(State::Disabled),
700 + &p,
701 + &style,
702 + )
703 + .unwrap();
704 + assert!(!disabled.enabled());
705 +
706 + let mut text = String::from("x");
707 + let focused = field(
708 + ui,
709 + &f,
710 + Filling::Text(&mut text),
711 + Some(State::Focus),
712 + &p,
713 + &style,
714 + )
715 + .unwrap();
716 + assert!(focused.enabled(), "focus is a thing you can still click");
717 + });
718 + }
719 +
720 + #[test]
721 + fn a_field_described_one_way_and_filled_another_is_drawn_inert() {
722 + // No panic and no write-through. A checkbox handed a string cannot be
723 + // filled, so it is drawn off and left alone.
724 + let f = Field::new(FieldKind::Checkbox, "done", "Done");
725 + let p = palette(Color32::from_rgb(9, 9, 9));
726 + let mut text = String::from("untouched");
727 + egui::__run_test_ui(|ui| {
728 + let drawn = field(
729 + ui,
730 + &f,
731 + Filling::Text(&mut text),
732 + None,
733 + &p,
734 + &FieldStyle::default(),
735 + );
736 + assert!(drawn.is_some());
737 + });
738 + assert_eq!(text, "untouched");
739 + }
740 +
741 + #[test]
742 + fn the_disclosure_belongs_to_the_form_and_not_to_the_field() {
743 + let fields = [
744 + Field::new(FieldKind::Text, "title", "Title"),
745 + Field {
746 + extended: true,
747 + ..Field::new(FieldKind::Text, "notes", "Notes")
748 + },
749 + ];
Lines truncated