Skip to main content

max / makeover-immediate

Seed makeover-egui: the first renderer Paints makeover-layout's Depth with egui primitives. Owns the expression and nothing else: Palette arrives resolved, every radius, margin and stroke width arrives in FrameStyle, and there is no dependency on makeover because the app already resolves a theme. The bevel is two mitred polylines added after the fill, so it composes over a widget that has already painted itself, which is what lets it go over a TextEdit after ui.add. Fill::fallback earns its place here: a consumer on makeover 2.2.0 has no surface-well token, and the renderer supplies the page fill rather than each app inventing an interim. 6 tests, clippy clean at -D warnings. Unpublished, path dep on makeover-layout while both are.
Author: Max Johnson <me@maxj.phd> · 2026-07-28 21:20 UTC
Signed with PGP, not checked
Commit: b2fdc9cd70f1360b0f2bf2289dee87d0ea2e814b
6 files changed, +364 insertions, -0 deletions
A .gitignore +5
@@ -1,0 +1,5 @@
1 + /target
2 +
3 + # Claude Code instructions (project-local; not for the public repo)
4 + CLAUDE.md
5 + /Cargo.lock
A Cargo.toml +45
@@ -1,0 +1,45 @@
1 + [package]
2 + name = "makeover-egui"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "The egui renderer for makeover-layout. Paints a renderer-agnostic description with egui primitives: no box-shadow, no inset, no cascade, one stroke per widget."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-egui"
8 +
9 + [dependencies]
10 + egui = { version = "0.35", default-features = false }
11 + # Path dep while both are unpublished. Becomes a version dep when
12 + # makeover-layout 0.1.0 goes to crates.io, which waits on a second renderer.
13 + makeover-layout = { path = "../makeover-layout" }
14 +
15 + [lints.rust]
16 + unused = "warn"
17 + unreachable_pub = "warn"
18 +
19 + [lints.clippy]
20 + pedantic = { level = "warn", priority = -1 }
21 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
22 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
23 + # else in `pedantic` stays a warning. Keep this block identical across repos.
24 + module_name_repetitions = "allow"
25 + # Doc lints. No docs-completeness push is underway.
26 + missing_errors_doc = "allow"
27 + missing_panics_doc = "allow"
28 + doc_markdown = "allow"
29 + # Numeric casts. Endemic and mostly intentional in size and byte math.
30 + cast_possible_truncation = "allow"
31 + cast_sign_loss = "allow"
32 + cast_precision_loss = "allow"
33 + cast_possible_wrap = "allow"
34 + cast_lossless = "allow"
35 + # Subjective structure and style nags. High churn, low signal.
36 + must_use_candidate = "allow"
37 + too_many_lines = "allow"
38 + struct_excessive_bools = "allow"
39 + similar_names = "allow"
40 + items_after_statements = "allow"
41 + single_match_else = "allow"
42 + # Frequent false-positives in TUI and router-heavy code.
43 + match_same_arms = "allow"
44 + unnecessary_wraps = "allow"
45 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +51
@@ -1,0 +1,51 @@
1 + # makeover-egui
2 +
3 + The egui renderer for [`makeover-layout`](https://makenot.work/git/max/makeover-layout).
4 +
5 + egui is the harshest renderer the description has to survive: no `box-shadow`,
6 + no `inset`, no cascade, no retained tree to mutate, and one stroke per widget
7 + with no per-side control. A two-tone lit edge is not something egui can be
8 + configured into producing, so it is painted by hand here, once, rather than in
9 + every consuming app.
10 +
11 + ## What it owns
12 +
13 + The expression, and nothing else. Two mitred polylines for a bevel, a `Frame`
14 + for a filled region, and the decision of what to do when an intent has no colour
15 + yet.
16 +
17 + It owns no colours and no sizes. `Palette` is handed in already resolved, and
18 + every radius, margin and stroke width arrives in `FrameStyle`. That is why there
19 + is no dependency on `makeover`: the app already resolves a theme, and pinning a
20 + renderer to a colour crate's version would buy nothing.
21 +
22 + ## Usage
23 +
24 + ```rust
25 + use makeover_egui::{frame, FrameStyle, Palette};
26 + use makeover_layout::Depth;
27 +
28 + frame(ui, Depth::Raised, &palette, style, |ui| {
29 + ui.label("a card laid on the panel it sits in");
30 + });
31 + ```
32 +
33 + A pressed control is `Depth::Raised.pressed()`. A stylesheet can say that once
34 + and let the cascade carry it; an immediate-mode renderer has nowhere to put it,
35 + so every call site decides, which is exactly why the description states the rule
36 + instead of each widget re-deriving it.
37 +
38 + ## Status
39 +
40 + Unpublished, and depends on `makeover-layout` by path while both are. The
41 + sequencing decision is that neither goes to crates.io until a second renderer
42 + has exercised the vocabulary, since a description with one renderer is an API
43 + with extra steps.
44 +
45 + First consumer is audiofiles, the only Make Creative app that is egui and the
46 + only one with no CSS. Design lives in the wiki note `makeover-egui`; the backlog
47 + is in GoingsOn under the project of the same name.
48 +
49 + ## Licence
50 +
51 + MIT.
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "stable"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
A src/lib.rs +238
@@ -1,0 +1,238 @@
1 + //! The egui renderer for [`makeover_layout`].
2 + //!
3 + //! <!-- wiki: makeover-egui -->
4 + //!
5 + //! egui is the harshest renderer the description has to survive: no
6 + //! `box-shadow`, no `inset`, no cascade, no retained tree to mutate, and
7 + //! `Visuals.widgets.*.bg_stroke` is a single stroke with no per-side control.
8 + //! A two-tone lit edge is not something egui can be configured into producing,
9 + //! so it gets painted by hand here, once, instead of in every consuming app.
10 + //!
11 + //! # What this crate does and does not own
12 + //!
13 + //! It owns the *expression*: two mitred polylines for a bevel, a `Frame` for a
14 + //! filled region, and the decision of what to do when an intent has no colour
15 + //! yet. It owns no colours and no sizes. [`Palette`] is supplied by the caller,
16 + //! already resolved, and every radius, margin and stroke width arrives in
17 + //! [`FrameStyle`].
18 + //!
19 + //! That split is why the crate has no dependency on `makeover` itself: the app
20 + //! already resolves a theme, and coupling a renderer to a colour crate's
21 + //! version would buy nothing.
22 + //!
23 + //! # The cascade is the real difference
24 + //!
25 + //! A stylesheet can say "a pressed button inverts its bevel" once and let the
26 + //! cascade carry it. An immediate-mode renderer has nowhere to put that, so
27 + //! every call site decides. [`makeover_layout::Depth::pressed`] is what keeps
28 + //! the decision from being re-derived per widget.
29 +
30 + #![forbid(unsafe_code)]
31 +
32 + use egui::{Color32, CornerRadius, Margin, Painter, Rect, Shape, Stroke, Ui};
33 + use makeover_layout::{Bevel, Depth, Edge, Fill};
34 +
35 + /// The resolved colours this renderer needs, as flat values.
36 + ///
37 + /// Built by the app from whatever it already uses to resolve a theme, then
38 + /// held and reused. Deliberately not a trait and not string-keyed: a bevel is
39 + /// painted per widget per frame, and a map lookup per edge is a cost with
40 + /// nothing to show for it.
41 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 + pub struct Palette {
43 + /// `surface-page`.
44 + pub page: Color32,
45 + /// `surface-raised`.
46 + pub raised: Color32,
47 + /// `surface-overlay`.
48 + pub overlay: Color32,
49 + /// `surface-well`, absent on makeover before 2.3.0.
50 + pub well: Option<Color32>,
51 + /// `bevel-light`.
52 + pub bevel_light: Color32,
53 + /// `bevel-dark`.
54 + pub bevel_dark: Color32,
55 + }
56 +
57 + impl Palette {
58 + /// Resolve a surface intent.
59 + ///
60 + /// Honours [`Fill::fallback`], so a consumer whose makeover predates
61 + /// `surface-well` gets a page-filled well rather than a panic or a hole.
62 + /// The description says what the region *is*; supplying the fallback is
63 + /// this renderer's job, not the caller's.
64 + #[must_use]
65 + pub fn fill(&self, fill: Fill) -> Color32 {
66 + match fill {
67 + Fill::Page => self.page,
68 + Fill::Raised => self.raised,
69 + Fill::Overlay => self.overlay,
70 + Fill::Well => match self.well {
71 + Some(c) => c,
72 + None => match fill.fallback() {
73 + Some(f) => self.fill(f),
74 + None => self.page,
75 + },
76 + },
77 + }
78 + }
79 +
80 + /// Resolve a bevel edge intent.
81 + #[must_use]
82 + pub const fn edge(&self, edge: Edge) -> Color32 {
83 + match edge {
84 + Edge::Light => self.bevel_light,
85 + Edge::Dark => self.bevel_dark,
86 + }
87 + }
88 + }
89 +
90 + /// The geometry a framed region is drawn with.
91 + ///
92 + /// Every field is a value, which is why they all arrive from the caller:
93 + /// radius and border width belong to `makeover-geometry`, and margins come
94 + /// from its relational gaps.
95 + #[derive(Debug, Clone, Copy, PartialEq)]
96 + pub struct FrameStyle {
97 + /// Corner radius. Square under the Platinum default.
98 + pub radius: CornerRadius,
99 + /// Inner margin between the frame and its contents.
100 + pub margin: Margin,
101 + /// Bevel stroke width, in points.
102 + pub stroke: f32,
103 + }
104 +
105 + impl Default for FrameStyle {
106 + /// A one-point square frame with no inner margin.
107 + fn default() -> Self {
108 + Self {
109 + radius: CornerRadius::ZERO,
110 + margin: Margin::ZERO,
111 + stroke: 1.0,
112 + }
113 + }
114 + }
115 +
116 + /// Paint a two-tone edge just inside `rect`.
117 + ///
118 + /// Fill first, bevel after: this adds two polylines and nothing else, so it
119 + /// composes over whatever is already there. That is what lets it go over an
120 + /// [`egui::TextEdit`] after `ui.add`, where the widget's own fill has landed.
121 + ///
122 + /// Two three-point polylines meeting at opposite corners, rather than four
123 + /// segments, so egui mitres the corner joins instead of leaving a notch.
124 + pub fn paint_bevel(painter: &Painter, rect: Rect, bevel: Bevel, palette: &Palette, stroke: f32) {
125 + let (top_left, bottom_right) = bevel.edges();
126 +
127 + // Inset by half a stroke so the line lands inside `rect` rather than
128 + // straddling its edge, which on a fractional-scale display is the
129 + // difference between one crisp pixel and two dim ones.
130 + let r = rect.shrink(stroke / 2.0);
131 +
132 + painter.add(Shape::line(
133 + vec![r.left_bottom(), r.left_top(), r.right_top()],
134 + Stroke::new(stroke, palette.edge(top_left)),
135 + ));
136 + painter.add(Shape::line(
137 + vec![r.right_top(), r.right_bottom(), r.left_bottom()],
138 + Stroke::new(stroke, palette.edge(bottom_right)),
139 + ));
140 + }
141 +
142 + /// Draw a region at a given [`Depth`]: its fill and its edge, together.
143 + ///
144 + /// [`Depth::Flat`] gets neither, and inherits whatever it sits on. That is the
145 + /// difference between level-with and painted-the-same-colour, and it is the
146 + /// reason `Depth::fill` returns an [`Option`] rather than defaulting to the
147 + /// page.
148 + pub fn frame<R>(
149 + ui: &mut Ui,
150 + depth: Depth,
151 + palette: &Palette,
152 + style: FrameStyle,
153 + add_contents: impl FnOnce(&mut Ui) -> R,
154 + ) -> R {
155 + let mut f = egui::Frame::new()
156 + .corner_radius(style.radius)
157 + .inner_margin(style.margin);
158 + if let Some(fill) = depth.fill() {
159 + f = f.fill(palette.fill(fill));
160 + }
161 + let framed = f.show(ui, add_contents);
162 + if let Some(bevel) = depth.bevel() {
163 + paint_bevel(
164 + ui.painter(),
165 + framed.response.rect,
166 + bevel,
167 + palette,
168 + style.stroke,
169 + );
170 + }
171 + framed.inner
172 + }
173 +
174 + #[cfg(test)]
175 + mod tests {
176 + use super::*;
177 +
178 + fn palette(well: Option<Color32>) -> Palette {
179 + Palette {
180 + page: Color32::from_rgb(1, 1, 1),
181 + raised: Color32::from_rgb(2, 2, 2),
182 + overlay: Color32::from_rgb(3, 3, 3),
183 + well,
184 + bevel_light: Color32::WHITE,
185 + bevel_dark: Color32::BLACK,
186 + }
187 + }
188 +
189 + #[test]
190 + fn a_well_falls_back_to_the_page_before_makeover_2_3() {
191 + let p = palette(None);
192 + assert_eq!(p.fill(Fill::Well), p.page);
193 + // and uses the real token once the theme carries one
194 + let w = Color32::from_rgb(9, 9, 9);
195 + assert_eq!(palette(Some(w)).fill(Fill::Well), w);
196 + }
197 +
198 + #[test]
199 + fn every_other_intent_resolves_without_a_fallback() {
200 + let p = palette(None);
201 + assert_eq!(p.fill(Fill::Page), p.page);
202 + assert_eq!(p.fill(Fill::Raised), p.raised);
203 + assert_eq!(p.fill(Fill::Overlay), p.overlay);
204 + }
205 +
206 + #[test]
207 + fn a_raised_region_never_resolves_to_the_well_fill() {
208 + // The cross-app bug, asserted at the renderer boundary this time.
209 + let p = palette(Some(Color32::from_rgb(9, 9, 9)));
210 + let raised = Depth::Raised.fill().map(|f| p.fill(f));
211 + let well = Depth::Well.fill().map(|f| p.fill(f));
212 + assert_eq!(raised, Some(p.raised));
213 + assert_ne!(raised, well);
214 + }
215 +
216 + #[test]
217 + fn the_lit_edge_swaps_when_a_card_is_pressed() {
218 + let p = palette(None);
219 + let (tl, _) = Depth::Raised.bevel().unwrap().edges();
220 + let (ptl, _) = Depth::Raised.pressed().bevel().unwrap().edges();
221 + assert_eq!(p.edge(tl), p.bevel_light);
222 + assert_eq!(p.edge(ptl), p.bevel_dark);
223 + }
224 +
225 + #[test]
226 + fn flat_asks_for_neither_fill_nor_edge() {
227 + assert!(Depth::Flat.fill().is_none());
228 + assert!(Depth::Flat.bevel().is_none());
229 + }
230 +
231 + #[test]
232 + fn the_default_frame_is_square_and_one_point() {
233 + let d = FrameStyle::default();
234 + assert_eq!(d.radius, CornerRadius::ZERO);
235 + assert_eq!(d.margin, Margin::ZERO);
236 + assert!((d.stroke - 1.0).abs() < f32::EPSILON);
237 + }
238 + }