Skip to main content

makeover_immediate/
lib.rs

1//! The immediate-mode renderer for [`makeover_layout`].
2//!
3//! <!-- wiki: makeover-immediate -->
4//!
5//! Named for the mode, not the library, the way `makeover-tui` is named for
6//! the target and not for ratatui. Immediate mode is the constraint that
7//! actually separates this renderer from the other two, and egui is the
8//! backend it is written against.
9//!
10//! It is the harshest renderer the description has to survive: no
11//! `box-shadow`, no `inset`, no cascade, no retained tree to mutate, and
12//! `Visuals.widgets.*.bg_stroke` is a single stroke with no per-side control.
13//! A two-tone lit edge is not something egui can be configured into producing,
14//! so it gets painted by hand here, once, instead of in every consuming app.
15//!
16//! # What this crate does and does not own
17//!
18//! It owns the *expression*: two mitred polylines for a bevel and a `Frame`
19//! for a filled region. It owns no colours and no sizes, and no longer owns a
20//! substitution: it briefly supplied the page for a well, which was a stand-in
21//! for `surface-well` before makeover derived it, and every consumer reads the
22//! real token now. [`Palette`] is supplied by the caller,
23//! already resolved, and every radius, margin and stroke width arrives in
24//! [`FrameStyle`].
25//!
26//! That split is why the crate has no dependency on `makeover` itself: the app
27//! already resolves a theme, and coupling a renderer to a colour crate's
28//! version would buy nothing.
29//!
30//! # The cascade is the real difference
31//!
32//! A stylesheet can say "a pressed button inverts its bevel" once and let the
33//! cascade carry it. An immediate-mode renderer has nowhere to put that, so
34//! every call site decides. [`makeover_layout::Depth::pressed`] is what keeps
35//! the decision from being re-derived per widget.
36//!
37//! # 0.11.0: the overlay becomes reachable
38//!
39//! 0.10.0 answered what overlaying means in immediate mode with
40//! [`Palette::cast`], and nothing could ask: the description had no
41//! `Depth::Overlay` until `makeover-layout` 0.14.0, so the answer sat beside a
42//! question that could not be posed. [`frame`] now hands the cast shadow to the
43//! `egui::Frame` for any depth whose fill is [`Fill::Overlay`], keyed off the
44//! fill rather than the variant.
45//!
46//! The same release brings `makeover_layout::CellPart`, which 0.11.0 carried
47//! and did not draw. [`table`] draws it, below.
48//!
49//! # 0.12.0: the table
50//!
51//! [`table`] is the vocabulary 0.11.0 took without using. The consumer is
52//! audiofiles, whose file list is the only table in the tree exercising all four
53//! of what the description says about one at once: sortable headings with
54//! carets, fixed and remainder tracks, and buttons inside cells.
55//!
56//! Two things it forces, both named where they land:
57//!
58//! - **`egui_extras`**, this crate's first dependency past egui. egui has no
59//!   table, and `Grid` gives no per-column sizing, no sticky header and no
60//!   scroll sync, which is why audiofiles reached for `egui_extras` rather than
61//!   building on `Grid`. A third answer here would reimplement that crate worse.
62//! - **[`Palette::action`]**, on the footing [`Palette::content`] arrived on: a
63//!   link in a cell is the first thing here needing the action intent.
64//!
65//! Narrowing works differently from the terminal's and the module header says
66//! why: a content column cannot be measured before the app's closure has drawn
67//! it, so `egui_extras` sizes it and the declared floor budgets it.
68//!
69//! # 0.13.0: what the adoption found missing
70//!
71//! 0.12.0 shipped [`table`] before audiofiles had taken it, and taking it found
72//! three things the file list already did that the function could not say. All
73//! three are host idiom rather than description, which is why they land here and
74//! not in `makeover-layout`, and all three are answered on a handle the app
75//! never sees: the `egui_extras` row and builder this crate owns. That is
76//! [`table::cell`]'s reasoning again: what the app cannot reach, the renderer
77//! owes it.
78//!
79//! - **A selected row.** [`table::Body::selected`], a predicate asked per row,
80//!   because `set_selected` is a method on the row. Without it a file list has
81//!   no way to show what is selected, which is most of what a file list does.
82//! - **Scrolling a row into view.** [`table::Body::scroll_to`], because
83//!   `scroll_to_row` is a method on the builder. A keyboard cursor that moves
84//!   off-screen and stays there is the bug this prevents.
85//! - **Dragging a divider.** [`table::TableStyle::resizable`], which passes the
86//!   test `sticky_header` failed in 0.12.0: egui_extras offers two settings here
87//!   and a renderer can honestly make either choice.
88//!
89//! A fourth was found and is not a knob. Cells are centred on the row's centre
90//! line, always, because there is no second honest answer and egui's own default
91//! (top-aligned) is the one thing it cannot be.
92//!
93//! [`table::Body`] is also what splits a table's per-frame facts from its
94//! description and from its style. A row count, a selection and a scroll request
95//! are none of them style, and none of them survive the frame.
96//!
97//! # Forms
98//!
99//! 0.5.0 adds the field vocabulary on top of the depth vocabulary:
100//! [`makeover_layout::Field`] rendered to egui widgets, in [`field`], and a set
101//! of them laid down a column in [`group`]. Before it, a description saying
102//! "text field, labelled, required, with this hint" had no way to become a
103//! widget here, and audiofiles' forms stayed hand-rolled.
104//!
105//! `makeover-webview` got there first and its form emitter is the precedent
106//! followed rather than re-derived, including the parts that are bug fixes: a
107//! select handed a value none of its options carries keeps that value visible
108//! instead of silently reading as the first option, which is a save-the-wrong-
109//! thing bug goingson hit for real.
110//!
111//! What differs is forced by the mode and not chosen:
112//!
113//! - **The value arrives as a `&mut`.** [`Filling`] borrows the app's own field
114//!   and the widget writes through it. There is no DOM to read back out of,
115//!   which is also why the description deliberately does not carry the value.
116//! - **A text control is drawn as a well and a select is not.** The description
117//!   holds that a well is for anything the user looks *into*, and a text field
118//!   is its own example; a select and a checkbox are pressed rather than looked
119//!   into, so they keep egui's own control painting.
120//! - **Focus is not describable, and egui owns all of it here.** **Reach**,
121//!   **focus** and the **focus ring** are this renderer's three answers and
122//!   egui already has all three: its own id stack decides what is reachable,
123//!   its own state decides what holds the keyboard, and it paints exactly one
124//!   ring. A description states none of them — `makeover_layout` removed the
125//!   member that used to try in 0.19.0 — and drawing a second ring on top of
126//!   egui's would break the one-ring rule it would have come from. The terms
127//!   are defined once in `makeover_layout`'s crate header, "Reach, focus and
128//!   the focus ring". [`makeover_layout::State::Disabled`] *is* drawn, because
129//!   egui has no opinion about it until told.
130//! - **App-level chrome is not drawn here, and it is not this crate's to
131//!   draw.** `quasi-router` names the affordances that outlive one screen: a
132//!   `Chrome` of key bindings, and an `Outcome::Over` for a screen drawn over
133//!   another. Both are answered by `quasi-webview` and `quasi-tui`, and neither
134//!   is answerable here, because this crate depends on `makeover-layout` and
135//!   not on `quasi-router` — it is the peer of `makeover-webview` and
136//!   `makeover-tui`, one layer below the renderers that consume a `Screen`.
137//!   What is missing is the egui crate at *that* layer, which does not exist:
138//!   nothing renders a quasi `Screen` in egui at all, and chrome is one item on
139//!   the list such a crate would owe. Said here because this is where a reader
140//!   looks for it, and because the silent version reads as "egui does not need
141//!   a palette" rather than "nobody has built the renderer yet".
142
143#![forbid(unsafe_code)]
144
145use egui::{
146    Color32, ComboBox, CornerRadius, Margin, Painter, Rect, Response, RichText, Shape, Stroke,
147    TextEdit, Ui,
148};
149use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State};
150
151/// Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
152pub mod table;
153
154/// The resolved colours this renderer needs, as flat values.
155///
156/// Built by the app from whatever it already uses to resolve a theme, then
157/// held and reused. Deliberately not a trait and not string-keyed: a bevel is
158/// painted per widget per frame, and a map lookup per edge is a cost with
159/// nothing to show for it.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub struct Palette {
162    /// `surface-page`.
163    pub page: Color32,
164    /// `surface-raised`.
165    pub raised: Color32,
166    /// `surface-overlay`.
167    pub overlay: Color32,
168    /// `surface-well`.
169    ///
170    /// Required, not optional. makeover derives it for every theme from 2.3.0,
171    /// so a resolved palette without a well is not a thing that exists here.
172    /// It was an `Option` while that was untrue, and this renderer substituted
173    /// the page; `makeover-tui` keeps its own `Option` for a different reason,
174    /// since a terminal can have the colour and still be unable to show it.
175    pub well: Color32,
176    /// `surface-sunken`.
177    ///
178    /// A surface set back from the one it sits on, by colour and nothing else.
179    /// Not a well: a well is a hole with an edge, and this has no edge. An
180    /// immediate-mode renderer paints an arbitrary rect, so unlike
181    /// `makeover-tui` it has no excuse for declining this one.
182    ///
183    /// Required rather than optional, on the same footing as `well`: all 31
184    /// themes makeover embeds author it.
185    pub sunken: Color32,
186    /// `bevel-light`.
187    pub bevel_light: Color32,
188    /// `bevel-dark`.
189    pub bevel_dark: Color32,
190    /// `elevation`.
191    ///
192    /// What a surface that floats OVER the page is cast onto it with. The one
193    /// intent here that is about a surface's relationship to the page rather
194    /// than about the surface, which is why it is a translucent near-black on
195    /// every theme rather than something read off the palette's own ramp.
196    ///
197    /// **Only for a surface that overlays.** A menu, a tooltip, a modal. A
198    /// surface *in* the layout takes a bevel, and reaching for this on a panel
199    /// or a card is how a pre-Platinum look survives a conversion under a new
200    /// name.
201    ///
202    /// egui has a real answer for this where a terminal does not: see
203    /// [`Palette::cast`], which is the shadow to hand an
204    /// [`egui::Frame`](egui::Frame).
205    pub elevation: Color32,
206    /// `content`.
207    ///
208    /// Ordinary text. Added 0.5.0 with the field renderer, which is the first
209    /// thing here that draws any: until then this crate painted surfaces and
210    /// edges and let the caller's own egui visuals answer for text.
211    pub content: Color32,
212    /// `content-muted`.
213    ///
214    /// A field's hint, and what
215    /// [`makeover_layout::State::Disabled`](makeover_layout::State::Disabled)
216    /// resolves to. Both readings come from the description rather than from
217    /// here: `State::Disabled` names this intent by token.
218    pub content_muted: Color32,
219    /// `action-primary`.
220    ///
221    /// What a control is drawn in. Added 0.12.0 with the table renderer, for the
222    /// reason `content` was added 0.5.0 with the field renderer: a link in a
223    /// cell is the first thing here that needs the action intent, and a palette
224    /// should carry what is used.
225    ///
226    /// This is the intent [`CellPart`](makeover_layout::CellPart) exists to
227    /// separate. A cell holding a control took the cell's text colour until the
228    /// description could say otherwise, which is the drift `makeover-layout`
229    /// 0.14.0 named and `makeover-webview` 0.25.0 fixed on its own side.
230    pub action: Color32,
231    /// `danger`.
232    ///
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.
237    pub danger: Color32,
238}
239
240impl Palette {
241    /// Resolve a surface intent, or `None` for one this renderer does not know.
242    ///
243    /// A plain lookup. There is still no substitution: the old one existed only
244    /// while `surface-well` was underived, and every consumer reads the real
245    /// token now.
246    ///
247    /// `Option` since 0.3.0, because [`Fill`] became `#[non_exhaustive]` in
248    /// `makeover-layout` 0.4.0 and a total function over an open enum can only
249    /// stay total by inventing a colour for a member it has never heard of.
250    /// That is the substitution this crate spent 0.2.0 removing, so the return
251    /// type moved instead. Every member the description has today is answered
252    /// with `Some`.
253    #[must_use]
254    pub const fn fill(&self, fill: Fill) -> Option<Color32> {
255        match fill {
256            Fill::Page => Some(self.page),
257            Fill::Raised => Some(self.raised),
258            Fill::Overlay => Some(self.overlay),
259            Fill::Well => Some(self.well),
260            Fill::Sunken => Some(self.sunken),
261            _ => None,
262        }
263    }
264
265    /// The cast shadow for a surface that overlays the page.
266    ///
267    /// What "overlaying" means in immediate mode, answered rather than skipped.
268    /// egui already paints shadows for its menus and windows through
269    /// [`egui::Frame::shadow`], so the honest port is to hand that machinery the
270    /// theme's tone instead of egui's own default, not to invent a painter here
271    /// the way [`paint_bevel`] had to.
272    ///
273    /// The geometry matches what `makeover-webview` composes, in points rather
274    /// than pixels: a small downward offset and a wide soft blur. A Platinum-era
275    /// menu sits just off the page rather than hovering above it.
276    ///
277    /// ```no_run
278    /// # let palette: makeover_immediate::Palette = unimplemented!();
279    /// # let ui: &mut egui::Ui = unimplemented!();
280    /// egui::Frame::popup(ui.style())
281    ///     .shadow(palette.cast())
282    ///     .show(ui, |ui| { ui.label("over the page"); });
283    /// ```
284    #[must_use]
285    pub const fn cast(&self) -> egui::Shadow {
286        egui::Shadow {
287            offset: [0, 2],
288            blur: 24,
289            spread: 0,
290            color: self.elevation,
291        }
292    }
293
294    /// Resolve a bevel edge intent.
295    #[must_use]
296    pub const fn edge(&self, edge: Edge) -> Color32 {
297        match edge {
298            Edge::Light => self.bevel_light,
299            Edge::Dark => self.bevel_dark,
300        }
301    }
302}
303
304/// The geometry a framed region is drawn with.
305///
306/// Every field is a value, which is why they all arrive from the caller:
307/// radius and border width belong to `makeover-geometry`, and margins come
308/// from its relational gaps.
309#[derive(Debug, Clone, Copy, PartialEq)]
310pub struct FrameStyle {
311    /// Corner radius. Square under the Platinum default.
312    pub radius: CornerRadius,
313    /// Inner margin between the frame and its contents.
314    pub margin: Margin,
315    /// Bevel stroke width, in points.
316    pub stroke: f32,
317}
318
319impl Default for FrameStyle {
320    /// A one-point square frame with no inner margin.
321    fn default() -> Self {
322        Self {
323            radius: CornerRadius::ZERO,
324            margin: Margin::ZERO,
325            stroke: 1.0,
326        }
327    }
328}
329
330/// Paint a two-tone edge just inside `rect`.
331///
332/// Fill first, bevel after: this adds two polylines and nothing else, so it
333/// composes over whatever is already there. That is what lets it go over an
334/// [`egui::TextEdit`] after `ui.add`, where the widget's own fill has landed.
335///
336/// Two three-point polylines meeting at opposite corners, rather than four
337/// segments, so egui mitres the corner joins instead of leaving a notch.
338///
339/// The dark polyline is drawn second, so the two corners where the runs meet
340/// take its tone. That is the right answer here rather than a concession.
341/// [`makeover_layout::Bevel`] holds those corners to belong to both edges, and
342/// a renderer with room to divide one should; at the default one-point stroke
343/// the corner is a one-point square, so the division is sub-pixel and
344/// antialiasing resolves it to the same blend the mitre already gives. Splitting
345/// it would add a seam and no information. `makeover-tui` does split, because a
346/// terminal cell is large enough that not splitting costs a visible cell of edge
347/// weight — the same rule, at a resolution where it has something to say.
348pub fn paint_bevel(painter: &Painter, rect: Rect, bevel: Bevel, palette: &Palette, stroke: f32) {
349    let (top_left, bottom_right) = bevel.edges();
350
351    // Inset by half a stroke so the line lands inside `rect` rather than
352    // straddling its edge, which on a fractional-scale display is the
353    // difference between one crisp pixel and two dim ones.
354    let r = rect.shrink(stroke / 2.0);
355
356    painter.add(Shape::line(
357        vec![r.left_bottom(), r.left_top(), r.right_top()],
358        Stroke::new(stroke, palette.edge(top_left)),
359    ));
360    painter.add(Shape::line(
361        vec![r.right_top(), r.right_bottom(), r.left_bottom()],
362        Stroke::new(stroke, palette.edge(bottom_right)),
363    ));
364}
365
366/// Draw a region at a given [`Depth`]: its fill and its edge, together.
367///
368/// [`Depth::Flat`] gets neither, and inherits whatever it sits on. That is the
369/// difference between level-with and painted-the-same-colour, and it is the
370/// reason `Depth::fill` returns an [`Option`] rather than defaulting to the
371/// page.
372pub fn frame<R>(
373    ui: &mut Ui,
374    depth: Depth,
375    palette: &Palette,
376    style: FrameStyle,
377    add_contents: impl FnOnce(&mut Ui) -> R,
378) -> R {
379    let mut f = egui::Frame::new()
380        .corner_radius(style.radius)
381        .inner_margin(style.margin);
382    // Two ways there is no fill to paint, and they collapse to the same
383    // outcome: the depth names none (Depth::Flat), or it names one this
384    // renderer cannot resolve. Either way the frame goes unfilled and the
385    // bevel below carries the depth on its own, which is the rule this
386    // module already documents for Flat.
387    if let Some(fill) = depth.fill().and_then(|f| palette.fill(f)) {
388        f = f.fill(fill);
389    }
390    // A surface that overlays the page is cast onto it. [`Palette::cast`] has
391    // answered what that means here since 0.10.0 and nothing could reach it: a
392    // description had no way to say Overlay until makeover-layout 0.14.0, so
393    // the answer sat beside the question. Keyed off the fill rather than the
394    // variant, so it stays right for whatever else the description calls an
395    // overlay later.
396    if depth.fill() == Some(Fill::Overlay) {
397        f = f.shadow(palette.cast());
398    }
399    let framed = f.show(ui, add_contents);
400    if let Some(bevel) = depth.bevel() {
401        paint_bevel(
402            ui.painter(),
403            framed.response.rect,
404            bevel,
405            palette,
406            style.stroke,
407        );
408    }
409    framed.inner
410}
411
412/// The geometry a field group is drawn with.
413///
414/// Values again, for the reason [`FrameStyle`] is: every number here belongs to
415/// `makeover-geometry` and arrives already resolved.
416#[derive(Debug, Clone, Copy, PartialEq)]
417pub struct FieldStyle {
418    /// The well a text control sits in.
419    pub frame: FrameStyle,
420    /// Between a field's own parts: its label, its control, its hint and its
421    /// error.
422    pub gap: f32,
423    /// Between one field and the next.
424    pub group_gap: f32,
425    /// What marks a required field, appended to its label.
426    ///
427    /// A knob rather than a constant, because it is the one piece of *copy* in
428    /// this crate and copy is not a renderer's call. A webview does not need it
429    /// at all — it emits the `required` attribute and the browser answers — so
430    /// this renderer is the first place where a compulsory field either shows
431    /// that it is or silently does not.
432    pub required_marker: &'static str,
433}
434
435impl Default for FieldStyle {
436    /// The default frame, no gaps, and an asterisk.
437    fn default() -> Self {
438        Self {
439            frame: FrameStyle::default(),
440            gap: 0.0,
441            group_gap: 0.0,
442            required_marker: "*",
443        }
444    }
445}
446
447/// What the field currently holds, borrowed from wherever the app keeps it.
448///
449/// The immediate-mode counterpart of `makeover_webview::form::Value`, and the
450/// place the two renderers are forced apart: there the value is read back out
451/// of the DOM after the fact, and here the widget writes through this borrow as
452/// it is edited. Same reason the description carries neither.
453///
454/// An enum rather than a bag of options, on the reasoning
455/// `makeover_webview::form::Value` records: a checkbox holding a string is
456/// unsayable here, where a struct would let it be said and then have to cope.
457#[derive(Debug, Default)]
458pub enum Filling<'a> {
459    /// Nothing to edit. The control is drawn and does not answer.
460    #[default]
461    Absent,
462    /// The buffer behind anything that takes typed text, a select included:
463    /// what a select holds is the `value` of one of its [`Choice`]s.
464    ///
465    /// [`Choice`]: makeover_layout::Choice
466    Text(&'a mut String),
467    /// A checkbox, on or off.
468    On(&'a mut bool),
469}
470
471/// The label, marked if the field is compulsory.
472fn label_text(field: &Field<'_>, style: &FieldStyle) -> String {
473    if field.required {
474        format!("{} {}", field.label, style.required_marker)
475    } else {
476        field.label.to_owned()
477    }
478}
479
480/// The four shapes a control comes in here, which is fewer than there are
481/// kinds.
482///
483/// [`FieldKind`] is `#[non_exhaustive]` and grows; this does not, because the
484/// ways egui has of asking for a value do not. Reducing the open set to this
485/// closed one in one total function is what keeps a new kind from needing a new
486/// arm at every match below.
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum Control {
489    /// Typed into, so it is drawn as a well: the user looks into it.
490    Typed,
491    /// Picked from a control that shows one option at a time. Pressed rather
492    /// than looked into, so egui's own control painting stands.
493    Chosen,
494    /// Picked from options that are all on screen at once.
495    ///
496    /// Apart from [`Chosen`](Self::Chosen) because the description holds them
497    /// apart, and holding them apart is the whole content of
498    /// [`FieldKind::Radio`]: same question, and an answer the user can read
499    /// without opening anything.
500    Listed,
501    /// Held on or off.
502    Toggled,
503}
504
505/// Which shape a kind takes.
506///
507/// The wildcard falls to [`Control::Typed`] on purpose: a kind added to the
508/// description since this renderer was built degrades to a text box, which
509/// accepts any value the others would, rather than to nothing drawn at all.
510///
511/// `FieldKind::File` lands there as of makeover-layout 0.11.0, and it is left
512/// there rather than grown a shape of its own. egui's honest answer is a button
513/// that opens a native picker, which is a fifth control and a file-dialog
514/// dependency; no consumer of this crate asks for a file field yet. Same
515/// position this crate took on `Meter` at 0.10.0: the membership test is that
516/// every renderer *could* answer honestly, not that each one does on the day.
517/// A path in a text box is not nothing, and it is what an app that needs this
518/// tomorrow gets today.
519///
520/// `FieldKind::Date` and `FieldKind::DateTime` land there too, as of
521/// makeover-layout 0.15.0, on the same footing and with one thing owed. A
522/// calendar is a sixth control and bare `egui` has none, so a typed value is
523/// the honest answer here; what the app gets is the format the description
524/// names, `makeover_layout::DATE_FORMAT` and `DATETIME_FORMAT`, which is why
525/// those are constants rather than a sentence. audiofiles is the only consumer
526/// of this crate and asks for neither today. A calendar popup is the upgrade
527/// whenever one does.
528const fn control_shape(kind: FieldKind) -> Control {
529    match kind {
530        FieldKind::Select => Control::Chosen,
531        FieldKind::Radio => Control::Listed,
532        FieldKind::Checkbox => Control::Toggled,
533        _ => Control::Typed,
534    }
535}
536
537/// What a select shows for the value it currently holds.
538///
539/// A value no option carries stays on screen as itself rather than reading as
540/// whichever option happens to be first. goingson saved a backup retention of
541/// 10 against a 1/3/7/14/0 list and the browser silently showed it as 1, so the
542/// next save wrote a value nobody chose; `makeover-webview` grew the fix as a
543/// stray `<option>` and this is the same fix in the shape egui allows.
544fn shown_label<'a>(options: &'a [Choice<'a>], value: &'a str) -> &'a str {
545    options
546        .iter()
547        .find(|opt| opt.value == value)
548        .map_or(value, |opt| opt.label)
549}
550
551/// The control alone, without its label, hint or error.
552fn control(
553    ui: &mut Ui,
554    field: &Field<'_>,
555    filling: Filling<'_>,
556    palette: &Palette,
557    style: &FieldStyle,
558) -> Response {
559    // The mismatch path: described as one thing and filled as another. Nothing
560    // here can fix it, so it is drawn as the empty, inert version of what was
561    // described — visible on screen, in the way an empty select is at the
562    // webview renderer, rather than reported in a log nobody reads.
563    let mut discard = String::new();
564    let mut off = false;
565
566    match control_shape(field.kind) {
567        Control::Typed => {
568            let text = match filling {
569                Filling::Text(text) => text,
570                _ => &mut discard,
571            };
572            // An empty frame and no margin: the well is this crate's, and egui's
573            // own control background and padding would sit underneath it saying
574            // something different about both.
575            let mut edit = if matches!(field.kind, FieldKind::Textarea) {
576                TextEdit::multiline(text)
577            } else {
578                TextEdit::singleline(text)
579            }
580            .frame(egui::Frame::NONE)
581            .margin(Margin::ZERO)
582            .text_color(palette.content)
583            .password(field.kind.confidential());
584            if let Some(ghost) = field.placeholder {
585                edit = edit.hint_text(RichText::new(ghost).color(palette.content_muted));
586            }
587            frame(ui, Depth::Well, palette, style.frame, |ui| ui.add(edit))
588        }
589        Control::Toggled => {
590            let on = match filling {
591                Filling::On(on) => on,
592                _ => &mut off,
593            };
594            ui.checkbox(on, RichText::new(field.label).color(palette.content))
595        }
596        Control::Listed => {
597            let value = match filling {
598                Filling::Text(text) => text,
599                _ => &mut discard,
600            };
601            // No `shown_label` counterpart, and none is needed: a value no
602            // option carries leaves every button unfilled, which is already
603            // the honest report on screen. The select needs the fix because it
604            // has one slot and must put *something* in it.
605            let group = ui.vertical(|ui| {
606                let mut answered: Option<Response> = None;
607                for opt in field.options {
608                    let picked = ui.radio_value(
609                        value,
610                        opt.value.to_owned(),
611                        RichText::new(opt.label).color(palette.content),
612                    );
613                    answered = Some(match answered {
614                        Some(prev) => prev.union(picked),
615                        None => picked,
616                    });
617                }
618                answered
619            });
620            // A group described with no options answers as its own empty area
621            // rather than as no response at all, which keeps the caller's
622            // `.changed()` chain working on a field whose option list has not
623            // loaded yet.
624            group.inner.unwrap_or(group.response)
625        }
626        Control::Chosen => {
627            let value = match filling {
628                Filling::Text(text) => text,
629                _ => &mut discard,
630            };
631            let shown = shown_label(field.options, value);
632            ComboBox::from_id_salt(field.name)
633                .selected_text(RichText::new(shown).color(palette.content))
634                .show_ui(ui, |ui| {
635                    for opt in field.options {
636                        ui.selectable_value(
637                            value,
638                            opt.value.to_owned(),
639                            RichText::new(opt.label).color(palette.content),
640                        );
641                    }
642                })
643                .response
644        }
645    }
646}
647
648/// One field, as the column the app drops into its form.
649///
650/// The anatomy is `makeover-webview`'s, so the two renderers put a form
651/// together the same way: label, control, hint, error, top to bottom, with a
652/// checkbox labelling itself instead of taking a label above.
653///
654/// Returns [`None`] for a [`FieldKind::Hidden`] field, which is what
655/// [`FieldKind::visible`] means and is the honest answer here: a webview still
656/// emits an input for it because the form submits, and an immediate-mode
657/// renderer has no form and no submission, so a hidden field is a value the app
658/// already holds and there is nothing to draw or to respond to.
659///
660/// `state` is the description's interaction axis.
661/// [`State::Disabled`] greys the field and stops it answering, through
662/// [`State::suppresses_interaction`] rather than through a second reading of
663/// what disabled means. Focus is not on that axis and never reaches here: egui
664/// owns reach, focus and the ring for this renderer, and one ring means not a
665/// second one per renderer that happens to have opinions.
666pub fn field(
667    ui: &mut Ui,
668    field: &Field<'_>,
669    filling: Filling<'_>,
670    state: Option<State>,
671    palette: &Palette,
672    style: &FieldStyle,
673) -> Option<Response> {
674    if !field.kind.visible() {
675        return None;
676    }
677    let enabled = !state.is_some_and(State::suppresses_interaction);
678    let text = if enabled {
679        palette.content
680    } else {
681        palette.content_muted
682    };
683
684    let response = ui
685        .vertical(|ui| {
686            ui.spacing_mut().item_spacing.y = style.gap;
687
688            // A checkbox labels itself, on the right of the box.
689            // `FieldKind::labels_itself` is the description saying so, and both
690            // webview apps special-cased it inline before it did.
691            if !field.kind.labels_itself() {
692                ui.label(RichText::new(label_text(field, style)).color(text));
693            }
694
695            let response = ui
696                .add_enabled_ui(enabled, |ui| control(ui, field, filling, palette, style))
697                .inner;
698
699            // Standing help first, then what is wrong now. Both, in that order,
700            // for the reason the webview renderer names both in
701            // `aria-describedby`: an error appearing must not take the hint
702            // away with it.
703            if let Some(hint) = field.hint {
704                ui.label(RichText::new(hint).color(palette.content_muted));
705            }
706            if let Some(error) = field.error {
707                ui.label(RichText::new(error).color(palette.danger));
708            }
709            response
710        })
711        .inner;
712
713    Some(response)
714}
715
716/// A set of fields, laid down a column.
717///
718/// `show_extended` is the disclosure, and it is a parameter rather than state
719/// held here because the disclosure belongs to the *form* and not to any field:
720/// [`Field::extended`] marks which fields are behind one, and the app owns
721/// whether it is open. That is the same division `makeover-webview` draws when
722/// it marks the group `data-extended` and emits no control to toggle it.
723///
724/// `draw` is called once per field that should be visible, in order. Taking a
725/// callback rather than a slice of [`Filling`]s is what keeps the app's own
726/// values borrowed one at a time: a form's fields usually live in different
727/// structs, and a parallel array would have to be built each frame and kept in
728/// step with the description by hand.
729pub fn group<'a>(
730    ui: &mut Ui,
731    fields: &'a [Field<'a>],
732    show_extended: bool,
733    style: &FieldStyle,
734    mut draw: impl FnMut(&mut Ui, &'a Field<'a>),
735) {
736    ui.vertical(|ui| {
737        ui.spacing_mut().item_spacing.y = style.group_gap;
738        for f in fields {
739            if f.extended && !show_extended {
740                continue;
741            }
742            draw(ui, f);
743        }
744    });
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    fn palette(well: Color32) -> Palette {
752        Palette {
753            page: Color32::from_rgb(1, 1, 1),
754            raised: Color32::from_rgb(2, 2, 2),
755            overlay: Color32::from_rgb(3, 3, 3),
756            well,
757            sunken: Color32::from_rgb(4, 4, 4),
758            bevel_light: Color32::WHITE,
759            bevel_dark: Color32::BLACK,
760            elevation: Color32::from_black_alpha(46),
761            content: Color32::from_rgb(5, 5, 5),
762            content_muted: Color32::from_rgb(6, 6, 6),
763            action: Color32::from_rgb(7, 7, 7),
764            danger: Color32::from_rgb(8, 8, 8),
765        }
766    }
767
768    /// The cast is egui's own shadow type carrying the theme's tone, which is
769    /// the whole of what this crate had to decide for it: unlike a bevel, egui
770    /// already knows how to paint one.
771    #[test]
772    fn the_cast_hands_egui_the_themes_tone() {
773        let p = palette(Color32::from_rgb(9, 9, 9));
774        let cast = p.cast();
775        assert_eq!(cast.color, p.elevation);
776        assert!(cast.blur > 0, "a cast shadow is soft");
777        assert_eq!(cast.offset, [0, 2], "it falls downward and only a little");
778    }
779
780    #[test]
781    fn a_well_resolves_to_its_own_token() {
782        // No substitution left. The page-filled well was a stand-in for a
783        // token that did not exist yet; it exists now.
784        let w = Color32::from_rgb(9, 9, 9);
785        let p = palette(w);
786        assert_eq!(p.fill(Fill::Well), Some(w));
787        assert_ne!(p.fill(Fill::Well), Some(p.page));
788    }
789
790    #[test]
791    fn every_intent_is_a_plain_lookup() {
792        let p = palette(Color32::from_rgb(9, 9, 9));
793        assert_eq!(p.fill(Fill::Page), Some(p.page));
794        assert_eq!(p.fill(Fill::Raised), Some(p.raised));
795        assert_eq!(p.fill(Fill::Overlay), Some(p.overlay));
796    }
797
798    /// Sunken is its own colour, not the well's and not the page's. The two
799    /// are authored in opposite directions and an earlier cut of the
800    /// description conflated them.
801    #[test]
802    fn sunken_is_neither_the_well_nor_the_page() {
803        let p = palette(Color32::from_rgb(9, 9, 9));
804        assert_eq!(p.fill(Fill::Sunken), Some(p.sunken));
805        assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Well));
806        assert_ne!(p.fill(Fill::Sunken), p.fill(Fill::Page));
807    }
808
809    #[test]
810    fn a_raised_region_never_resolves_to_the_well_fill() {
811        // The cross-app bug, asserted at the renderer boundary this time.
812        let p = palette(Color32::from_rgb(9, 9, 9));
813        let raised = Depth::Raised.fill().and_then(|f| p.fill(f));
814        let well = Depth::Well.fill().and_then(|f| p.fill(f));
815        assert_eq!(raised, Some(p.raised));
816        assert_ne!(raised, well);
817    }
818
819    #[test]
820    fn an_overlay_is_cast_onto_the_page_and_takes_no_edge() {
821        // makeover-layout 0.14.0 is what made this reachable. The answer was
822        // already here at 0.10.0 and the question could not be asked.
823        let p = palette(Color32::from_rgb(9, 9, 9));
824        assert_eq!(
825            Depth::Overlay.fill().and_then(|f| p.fill(f)),
826            Some(p.overlay)
827        );
828        assert_eq!(Depth::Overlay.bevel(), None);
829        // The shadow `frame` reaches for is the theme's tone rather than
830        // egui's default, which is the whole reason `cast` exists.
831        assert_eq!(p.cast().color, p.elevation);
832    }
833
834    #[test]
835    fn the_lit_edge_swaps_when_a_card_is_pressed() {
836        let p = palette(Color32::from_rgb(9, 9, 9));
837        let (tl, _) = Depth::Raised.bevel().unwrap().edges();
838        let (ptl, _) = Depth::Raised.pressed().bevel().unwrap().edges();
839        assert_eq!(p.edge(tl), p.bevel_light);
840        assert_eq!(p.edge(ptl), p.bevel_dark);
841    }
842
843    #[test]
844    fn flat_asks_for_neither_fill_nor_edge() {
845        assert!(Depth::Flat.fill().is_none());
846        assert!(Depth::Flat.bevel().is_none());
847    }
848
849    #[test]
850    fn a_select_keeps_a_value_none_of_its_options_carries() {
851        // The save-the-wrong-thing bug, asserted at the second renderer so it
852        // is not re-found there. goingson's own numbers.
853        let options = [
854            Choice::plain("1"),
855            Choice::plain("3"),
856            Choice::plain("7"),
857            Choice::plain("14"),
858        ];
859        assert_eq!(shown_label(&options, "10"), "10");
860        // And a value that does match reads as its label, not as itself.
861        let spelled = [Choice {
862            value: "7",
863            label: "One week",
864        }];
865        assert_eq!(shown_label(&spelled, "7"), "One week");
866    }
867
868    #[test]
869    fn only_a_required_field_is_marked() {
870        let style = FieldStyle::default();
871        let plain = Field::new(FieldKind::Text, "title", "Title");
872        assert_eq!(label_text(&plain, &style), "Title");
873
874        let required = Field {
875            required: true,
876            ..plain
877        };
878        assert_eq!(label_text(&required, &style), "Title *");
879
880        // The marker is copy and the app owns it, which is why it is a knob.
881        let house = FieldStyle {
882            required_marker: "(required)",
883            ..style
884        };
885        assert_eq!(label_text(&required, &house), "Title (required)");
886    }
887
888    #[test]
889    fn a_select_and_a_checkbox_are_pressed_and_everything_else_is_typed_into() {
890        // What decides whether the control gets a well. A well is for what the
891        // user looks into, and only one of these is.
892        assert_eq!(control_shape(FieldKind::Select), Control::Chosen);
893        assert_eq!(control_shape(FieldKind::Radio), Control::Listed);
894        assert_eq!(control_shape(FieldKind::Checkbox), Control::Toggled);
895        for k in [
896            FieldKind::Text,
897            FieldKind::Secret,
898            FieldKind::Number,
899            FieldKind::Email,
900            FieldKind::Url,
901            FieldKind::Tel,
902            FieldKind::Textarea,
903        ] {
904            assert_eq!(control_shape(k), Control::Typed, "{k:?} is typed into");
905        }
906    }
907
908    #[test]
909    fn the_two_option_taking_kinds_are_drawn_differently_on_purpose() {
910        // The description holds Select and Radio apart, and a renderer that
911        // collapsed them would silently answer a question the app did not ask:
912        // audiofiles' storage style is irreversible and its alternatives have
913        // to be readable without opening anything. Asserting the two shapes
914        // differ is asserting that distinction survives the trip.
915        assert!(FieldKind::Select.offers_options());
916        assert!(FieldKind::Radio.offers_options());
917        assert_ne!(
918            control_shape(FieldKind::Select),
919            control_shape(FieldKind::Radio)
920        );
921    }
922
923    #[test]
924    fn a_hidden_field_draws_nothing_and_answers_nothing() {
925        // Where the two renderers legitimately part: a webview still emits an
926        // input because the form submits, and there is no form here.
927        let f = Field::new(FieldKind::Hidden, "id", "Id");
928        let p = palette(Color32::from_rgb(9, 9, 9));
929        egui::__run_test_ui(|ui| {
930            let drawn = field(ui, &f, Filling::Absent, None, &p, &FieldStyle::default());
931            assert!(drawn.is_none());
932        });
933    }
934
935    #[test]
936    fn a_disabled_field_stops_answering_and_an_unstated_one_does_not() {
937        let f = Field::new(FieldKind::Text, "title", "Title");
938        let p = palette(Color32::from_rgb(9, 9, 9));
939        let style = FieldStyle::default();
940        egui::__run_test_ui(|ui| {
941            let mut text = String::from("x");
942            let disabled = field(
943                ui,
944                &f,
945                Filling::Text(&mut text),
946                Some(State::Disabled),
947                &p,
948                &style,
949            )
950            .unwrap();
951            assert!(!disabled.enabled());
952
953            // Stating no state is the ordinary case and answers. Focus used to
954            // be the counter-example here; it is egui's now and a description
955            // cannot state it at all.
956            let mut text = String::from("x");
957            let plain = field(ui, &f, Filling::Text(&mut text), None, &p, &style).unwrap();
958            assert!(plain.enabled(), "an unstated field still answers");
959        });
960    }
961
962    #[test]
963    fn a_field_described_one_way_and_filled_another_is_drawn_inert() {
964        // No panic and no write-through. A checkbox handed a string cannot be
965        // filled, so it is drawn off and left alone.
966        let f = Field::new(FieldKind::Checkbox, "done", "Done");
967        let p = palette(Color32::from_rgb(9, 9, 9));
968        let mut text = String::from("untouched");
969        egui::__run_test_ui(|ui| {
970            let drawn = field(
971                ui,
972                &f,
973                Filling::Text(&mut text),
974                None,
975                &p,
976                &FieldStyle::default(),
977            );
978            assert!(drawn.is_some());
979        });
980        assert_eq!(text, "untouched");
981    }
982
983    #[test]
984    fn the_disclosure_belongs_to_the_form_and_not_to_the_field() {
985        let fields = [
986            Field::new(FieldKind::Text, "title", "Title"),
987            Field {
988                extended: true,
989                ..Field::new(FieldKind::Text, "notes", "Notes")
990            },
991        ];
992        let style = FieldStyle::default();
993
994        let mut closed = Vec::new();
995        egui::__run_test_ui(|ui| {
996            group(ui, &fields, false, &style, |_, f| closed.push(f.name));
997        });
998        assert_eq!(closed, ["title"]);
999
1000        let mut open = Vec::new();
1001        egui::__run_test_ui(|ui| {
1002            group(ui, &fields, true, &style, |_, f| open.push(f.name));
1003        });
1004        assert_eq!(open, ["title", "notes"]);
1005    }
1006
1007    #[test]
1008    fn the_default_frame_is_square_and_one_point() {
1009        let d = FrameStyle::default();
1010        assert_eq!(d.radius, CornerRadius::ZERO);
1011        assert_eq!(d.margin, Margin::ZERO);
1012        assert!((d.stroke - 1.0).abs() < f32::EPSILON);
1013    }
1014}