Skip to main content

max / makeover-immediate

0.39.0: the theme picker as its own control shape makeover-layout 0.38.0's FieldKind::Theme (518c650a, ruled by Max on 70028e00). Control::Themed rather than the wildcard: a theme picker falling to Control::Typed would draw a text box over a resolved list, which is worse than the select every app had before the member existed. Apart from Control::Chosen rather than folded into it, and it is Listed's distinction: not a different question, a different amount of structure on screen. A theme picker's rows carry a group heading and a contrast mark, and both come from members Field::options does not have. Same walk the other two renderers do. The heading is a label rather than a selectable_value, because it is not pickable and egui has no inert row that still reads as a row.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01AAbx8dxVmuVeyKbKoRrUL2
Author: Max Johnson <me@maxj.phd> · 2026-08-28 19:09 UTC
Signed with PGP, not checked
Commit: b7caf165c008f3132510015d83acffe3b50fdf35
Parent: b0f5d91
2 files changed, +118 insertions, -3 deletions
M Cargo.toml +2 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.38.0"
3 + version = "0.39.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"
@@ -12,7 +12,7 @@
12 12 # Exact patch rather than the minor, as the rest of the suite pins: a
13 13 # minor-only requirement is satisfied by a consumer lock holding an earlier
14 14 # patch, which then fails to compile against an API added in a later one.
15 - makeover-layout = "0.37.0"
15 + makeover-layout = "0.38.0"
16 16 # The cadence the activity mark blinks at, and the motion-off seam beside it.
17 17 # Taken rather than chosen here: three renderers draw this mark and a number
18 18 # picked per renderer is three heartbeats for one wait.
M src/lib.rs +116 -1
@@ -240,7 +240,9 @@
240 240 Color32, ComboBox, CornerRadius, DragValue, Margin, Painter, Rect, Response, RichText, Shape,
241 241 Slider, Stroke, TextEdit, Ui,
242 242 };
243 - use makeover_layout::{Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State, Tone};
243 + use makeover_layout::{
244 + Bevel, Choice, Depth, Edge, Field, FieldKind, Fill, State, ThemeVariant, Tone,
245 + };
244 246 use std::ops::RangeInclusive;
245 247
246 248 /// Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
@@ -668,6 +670,18 @@
668 670 /// question means, so a well with a figure in it is not a quieter version
669 671 /// of this control, it is a different one.
670 672 Slid,
673 + /// Picked from a list of themes that arrives grouped and marked.
674 + ///
675 + /// Apart from [`Chosen`](Self::Chosen) rather than folded into it, and the
676 + /// distinction is the same one [`Listed`](Self::Listed) draws: it is not a
677 + /// different question, it is a different amount of structure on screen. A
678 + /// theme picker's rows carry a group heading and a contrast mark, and both
679 + /// come from members [`Field::options`] does not have, so a shared arm
680 + /// would be a `matches!` on the kind inside the loop rather than one arm
681 + /// less.
682 + ///
683 + /// Added with makeover-layout 0.38.0's [`FieldKind::Theme`].
684 + Themed,
671 685 /// Two values dragged across one axis, drawn as one question.
672 686 ///
673 687 /// Apart from [`Typed`](Self::Typed) for the reason
@@ -721,6 +735,7 @@
721 735 FieldKind::Checkbox => Control::Toggled,
722 736 FieldKind::Range => Control::Slid,
723 737 FieldKind::Interval => Control::Spanned,
738 + FieldKind::Theme => Control::Themed,
724 739 _ => Control::Typed,
725 740 }
726 741 }
@@ -1172,9 +1187,86 @@
1172 1187 })
1173 1188 .response
1174 1189 }
1190 + // The grouping comes out of the order rather than out of a group list:
1191 + // `Field::themes` arrives sorted by variant, so the run of one variant
1192 + // is the group and a heading opens whenever the variant changes. Same
1193 + // walk as `makeover-webview`'s `<optgroup>` emission, which is what
1194 + // keeps two renderers from disagreeing about where a group starts.
1195 + Control::Themed => {
1196 + let value = match filling {
1197 + Filling::Text(text) => text,
1198 + _ => &mut discard,
1199 + };
1200 + let shown = themed_text(field, value);
1201 + ComboBox::from_id_salt(field.name)
1202 + .selected_text(RichText::new(shown).color(palette.content))
1203 + .show_ui(ui, |ui| {
1204 + if let Some(follow) = field.follows {
1205 + // First and outside every heading. It names no theme
1206 + // and sits in no variant, so a heading over it would be
1207 + // inventing a fourth variant for one row.
1208 + ui.selectable_value(
1209 + value,
1210 + follow.value.to_owned(),
1211 + RichText::new(follow.label).color(option_color(
1212 + value,
1213 + follow.value,
1214 + palette,
1215 + )),
1216 + );
1217 + }
1218 + let mut open: Option<ThemeVariant> = None;
1219 + for theme in field.themes {
1220 + if open != Some(theme.variant) {
1221 + // A heading rather than a `selectable_value`: it is
1222 + // not pickable, and egui has no inert row that
1223 + // still reads as a row. `content_muted` is the tone
1224 + // for something that is not an answer, which is the
1225 + // ghost text's tone eight lines up.
1226 + if open.is_some() {
1227 + ui.separator();
1228 + }
1229 + ui.label(
1230 + RichText::new(theme.variant.heading()).color(palette.content_muted),
1231 + );
1232 + open = Some(theme.variant);
1233 + }
1234 + ui.selectable_value(
1235 + value,
1236 + theme.id.to_owned(),
1237 + RichText::new(format!("{} {}", theme.name, theme.contrast.badge()))
1238 + .color(option_color(value, theme.id, palette)),
1239 + );
1240 + }
1241 + })
1242 + .response
1243 + }
1175 1244 }
1176 1245 }
1177 1246
1247 + /// What a theme picker's closed control reads.
1248 + ///
1249 + /// [`chosen_text`]'s counterpart, and it is separate for the reason
1250 + /// [`Control::Themed`] is: the label lives on a [`makeover_layout::ThemeChoice`]
1251 + /// rather than on a [`Choice`], and the follow row is a third place to look.
1252 + ///
1253 + /// No placeholder arm. A theme picker is never unanswered in the way a select
1254 + /// is — an app that resolved a theme to paint this control with has one — and
1255 + /// falling back to the raw value is the honest report on a stored id whose
1256 + /// theme has since been deleted.
1257 + fn themed_text<'a>(field: &'a Field<'a>, value: &'a str) -> &'a str {
1258 + if let Some(follow) = field.follows
1259 + && follow.value == value
1260 + {
1261 + return follow.label;
1262 + }
1263 + field
1264 + .themes
1265 + .iter()
1266 + .find(|theme| theme.id == value)
1267 + .map_or(value, |theme| theme.name)
1268 + }
1269 +
1178 1270 /// One field, as the column the app drops into its form.
1179 1271 ///
1180 1272 /// The anatomy is `makeover-webview`'s, so the two renderers put a form
@@ -1641,6 +1733,10 @@
1641 1733 // What decides whether the control gets a well. A well is for what the
1642 1734 // user looks into, and only one of these is.
1643 1735 assert_eq!(control_shape(FieldKind::Select), Control::Chosen);
1736 + // Not the wildcard. A theme picker falling to `Control::Typed` would
1737 + // draw a text box over a resolved list, which is worse than the select
1738 + // every app had before the member existed.
1739 + assert_eq!(control_shape(FieldKind::Theme), Control::Themed);
1644 1740 assert_eq!(control_shape(FieldKind::Radio), Control::Listed);
1645 1741 assert_eq!(control_shape(FieldKind::Checkbox), Control::Toggled);
1646 1742 for k in [
@@ -1936,4 +2032,23 @@
1936 2032 assert_eq!(d.margin, Margin::ZERO);
1937 2033 assert!((d.stroke - 1.0).abs() < f32::EPSILON);
1938 2034 }
2035 +
2036 + #[test]
2037 + fn a_theme_picker_reads_its_chosen_theme_by_name() {
2038 + const THEMES: &[makeover_layout::ThemeChoice<'_>] = &[makeover_layout::ThemeChoice::new(
2039 + "carbonfox",
2040 + "Carbonfox",
2041 + ThemeVariant::Dark,
2042 + makeover_layout::Contrast::High,
2043 + )];
2044 + let field = Field::theme("theme", "Theme", THEMES)
2045 + .following(Choice::new("system", "Follow System"));
2046 +
2047 + assert_eq!(themed_text(&field, "carbonfox"), "Carbonfox");
2048 + assert_eq!(themed_text(&field, "system"), "Follow System");
2049 + // A stored id whose theme has been deleted reads as itself rather than
2050 + // as an empty box, which is the honest report on the config as it
2051 + // stands.
2052 + assert_eq!(themed_text(&field, "gone"), "gone");
2053 + }
1939 2054 }