Skip to main content

max / audiofiles

Platinum phase 3: bevelled buttons and inset wells `bevel_button(ui, label, style)` is the one button in the app: raised at rest, inset while `is_pointer_button_down_on()`. `ButtonStyle` carries a weight (Primary strong, Secondary default, Danger in `danger`), a small flag and an enabled flag; the five named helpers are one line each, and no caller changed a line to get the inversion. That is the payoff for hard rules 3-4 having been enforced. `press_bevel` states the rule once and every interactive primitive in widgets.rs now goes through it: the five buttons, toolbar_toggle, segmented_control, tag_chip, and the removable chip's x. Hover and keyboard focus deliberately do not invert -- a bevel that moved on hover would claim a press that had not happened. toolbar_toggle stops carrying its state in colour alone. Active is inset with a sunken fill; the label colour agrees rather than doing the work. `text_field(ui, edit)` adds a TextEdit and paints an Inset bevel over the response rect, after, so the widget's fill lands first. All 21 TextEdit sites across nine files route through it -- a well is what "put something here" looks like in this idiom, and eighteen of twenty-one bevelled is worse than none. Floating surfaces get both halves: `bevel_floating` paints a raised bevel into the window's own layer after `show` returns, and the theme sets window/popup shadow to `blur: 0` at a 2pt offset, matching the light source the bevel already assumes. A blurred shadow is a different design language. Two deviations from the spec. `step_number` was on its re-point list but is a `ui.label`, not a button, and pointing a label at a button primitive would be wrong. `confirm_action_row` and `name_modal` were not on the list but were still building raw buttons, so they are on it now. 345 tests green, clippy at its prior warning count.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 18:19 UTC
Signed with PGP, not checked
Commit: cc4fdeda8d57d0580eb056ca95225a2b05db1ce2
Parent: 3915e92
11 files changed, +349 insertions, -79 deletions
@@ -159,8 +159,13 @@
159 159 rather than anything declarative. It paints two 3-point polylines inside `rect`
160 160 and nothing else: no fill, no clearing. Fill first, bevel after.
161 161
162 - Which surfaces take which is phases 3 and 4 of the conversion (wiki
163 - `af-platinum`); this section is the primitive only.
162 + Which surfaces take which: buttons and text fields below, containers in phase 4
163 + of the conversion (wiki `af-platinum`).
164 +
165 + **The inversion is the point.** A control that is raised at rest and inset while
166 + held is the whole reason this idiom is worth hand-painting. Anything in
167 + `widgets.rs` that answers a click must invert; a colour swap on press is not a
168 + substitute and does not count.
164 169
165 170 ---
166 171
@@ -181,11 +186,25 @@
181 186
182 187 ### Buttons
183 188
184 - - **`primary_button(ui, label) -> Response`**: bold weight, default fill. Singular per modal/dialog.
185 - - **`secondary_button(ui, label) -> Response`**: current default button. Cancel and peer actions use this.
186 - - **`danger_button(ui, label) -> Response`**: `danger` text or fill (TBD during impl). For Delete / Purge / Discard.
187 - - **`toolbar_toggle(ui, glyph_or_word, active, tooltip) -> bool`**: used six times in `toolbar.rs:138–204`. Active state colours via `action` / `content_muted`. Replace the emoji glyphs with words during the rollout (see brand rule).
188 - - **`icon_button(ui, glyph_or_word, tooltip) -> Response`**: small, non-toggle. (`x`, `+`, settings gear once renamed.)
189 + Every button in the app is one function with a style on it. The five named
190 + helpers below are the vocabulary; `bevel_button` is the implementation, and
191 + calling it directly is a sign the style you need should have a name.
192 +
193 + - **`bevel_button(ui, label, style) -> Response`**: the primitive. Raised at
194 + rest, `Inset` while `is_pointer_button_down_on()`. `style` is a `ButtonStyle`
195 + carrying a `ButtonWeight` (`Primary` strong, `Secondary` default, `Danger` in
196 + `danger`), a `small` flag, and an `enabled` flag. Disabled keeps the raised
197 + bevel and drops the label to `content_muted()`: still a button, just not one
198 + you can press right now.
199 + - **`primary_button(ui, label) -> Response`**: bold weight. Singular per modal/dialog.
200 + - **`secondary_button(ui, label) -> Response`**: default weight. Cancel and peer actions use this.
201 + - **`danger_button(ui, label) -> Response`**: `danger` label. For Delete / Purge / Discard.
202 + - **`danger_button_enabled(ui, label, enabled) -> Response`**: the same, disable-able.
203 + - **`danger_small_button(ui, label) -> Response`**: the same, tighter, for per-row affordances.
204 + - **`toolbar_toggle(ui, word, active, tooltip, count) -> bool`**: a button that stays
205 + in. Active is `Inset` with a `surface_sunken` fill, not a colour swap — the toolbar
206 + is where "which of these is on" has to be readable at a glance, and shape carries
207 + that better than hue does.
189 208
190 209 ### Pills, chips, badges
191 210
@@ -196,8 +215,17 @@
196 215
197 216 ### Inputs
198 217
199 - - **`inline_text_submit(ui, buf, opts) -> SubmitOutcome`**: text field that commits on Enter, cancels on button/Escape. `SubmitOutcome::{None, Submitted(String), Cancelled}`. Used by sidebar rename, sidebar create, detail tag add, toolbar save-collection.
200 - - **`search_field(ui, buf, hint, on_change)`**: search-box recipe used by the toolbar and the sidebar tag filter. Hint text, optional leading glyph (subject to brand rule).
218 + - **`text_field(ui, edit) -> Response`**: **every** `TextEdit` goes through this. Adds
219 + the edit, then paints an `Inset` bevel over the response rect — after, so the
220 + widget's own fill lands first and the 1px frame draws on top. A text field is a
221 + well you type into; that is the one shape in Platinum that means "put something
222 + here", and it has to be the same well everywhere or it means nothing.
223 +
224 + **Never `ui.add(egui::TextEdit::…)` directly.** The chokepoint is the point:
225 + there are eighteen of these across nine files, and a bevel applied to seventeen
226 + of them is worse than one applied to none.
227 + - **`inline_text_submit(ui, buf, opts) -> SubmitOutcome`**: text field that commits on Enter, cancels on button/Escape. `SubmitOutcome::{None, Submitted(String), Cancelled}`. Used by sidebar rename, sidebar create, detail tag add, toolbar save-collection. Not built yet; composes `text_field` when it is.
228 + - **`search_field(ui, buf, hint, on_change)`**: search-box recipe used by the toolbar and the sidebar tag filter. Hint text, optional leading glyph (subject to brand rule). Not built yet; composes `text_field` when it is.
201 229
202 230 ### Empty states
203 231
@@ -209,6 +237,13 @@
209 237 - **`confirm_modal(ctx, prompt, danger: bool, on_confirm, on_cancel)`**: unified destructive-confirm scaffold. Confirm label is `danger_button` when `danger=true`. Replaces both `draw_confirm_dialog` and `draw_unsafe_warning`'s ad-hoc Window.
210 238 - **`name_modal(...)`**: existing; keep, lift to `widgets.rs` so callers outside `overlays.rs` can use it.
211 239
240 + **Floating surfaces get both a raised bevel and a hard shadow.** `modal_window`,
241 + `modal_window_with_open` and `tool_window` paint a `Raised` bevel over the window
242 + rect after `.show()` returns, and the theme sets `window_shadow` / `popup_shadow`
243 + to an `egui::Shadow` with `blur: 0`. A blurred shadow is a different design
244 + language; Platinum's is a hard offset rectangle, and the bevel alone does not
245 + separate a floating surface from a raised one sitting flat in a panel.
246 +
212 247 ### Banners and notifications
213 248
214 249 - **`info_banner(ui, body)`**: frame with `surface_sunken()`, `radius_container()`, `space::group()` inset; used by the existing VFS first-run banner and any future inline tips.
@@ -377,7 +377,8 @@
377 377 }
378 378 });
379 379 if op_needs_value(cond.op) {
380 - ui.add(
380 + widgets::text_field(
381 + ui,
381 382 egui::TextEdit::singleline(&mut cond.value)
382 383 .desired_width(70.0)
383 384 .hint_text("value"),
@@ -441,7 +442,8 @@
441 442 };
442 443 }
443 444 if let RuleAction::AddTag(s) | RuleAction::RemoveTag(s) = action {
444 - ui.add(
445 + widgets::text_field(
446 + ui,
445 447 egui::TextEdit::singleline(s)
446 448 .desired_width(150.0)
447 449 .hint_text("instrument.drum.kick"),
@@ -599,7 +601,8 @@
599 601
600 602 ui.add_space(theme::space::bound());
601 603 ui.horizontal(|ui| {
602 - ui.add(
604 + widgets::text_field(
605 + ui,
603 606 egui::TextEdit::singleline(&mut state.classifier.new_policy_tag)
604 607 .desired_width(170.0)
605 608 .hint_text("tag to configure"),
@@ -746,7 +749,8 @@
746 749 play = Some(cluster.medoid_hash.clone());
747 750 }
748 751 if let Some(name) = state.classifier.cluster_names.get_mut(i) {
749 - ui.add(
752 + widgets::text_field(
753 + ui,
750 754 egui::TextEdit::singleline(name)
751 755 .desired_width(150.0)
752 756 .hint_text("tag for this group"),
@@ -829,7 +833,10 @@
829 833 });
830 834 ui.horizontal(|ui| {
831 835 if let Some(tag) = state.classifier.folder_tags.get_mut(i) {
832 - ui.add(egui::TextEdit::singleline(tag).desired_width(180.0));
836 + widgets::text_field(
837 + ui,
838 + egui::TextEdit::singleline(tag).desired_width(180.0),
839 + );
833 840 }
834 841 if ui.small_button("Apply").clicked() {
835 842 apply = Some(i);
@@ -875,7 +882,8 @@
875 882 .small()
876 883 .color(theme::content_muted()),
877 884 );
878 - ui.add(
885 + widgets::text_field(
886 + ui,
879 887 egui::TextEdit::singleline(&mut state.classifier.export_name)
880 888 .desired_width(180.0)
881 889 .hint_text("My classifier"),
@@ -218,7 +218,8 @@
218 218
219 219 // Tag input
220 220 ui.horizontal(|ui| {
221 - let resp = ui.add(
221 + let resp = widgets::text_field(
222 + ui,
222 223 egui::TextEdit::singleline(&mut state.detail.tag_input)
223 224 .hint_text("Add tag (use dots: genre.house)")
224 225 .desired_width(ui.available_width() - 40.0),
@@ -313,7 +313,8 @@
313 313 changed = true;
314 314 }
315 315 ui.horizontal(|ui| {
316 - let resp = ui.add(
316 + let resp = widgets::text_field(
317 + ui,
317 318 egui::TextEdit::singleline(&mut state.search.filter_tag_input)
318 319 .hint_text("Filter by tag")
319 320 .desired_width(ui.available_width() - 32.0),
@@ -405,7 +406,7 @@
405 406 egui::TextEdit::singleline(&mut state.collections_ui.collection_filter_name_input)
406 407 .hint_text(&auto_name)
407 408 .desired_width(ui.available_width() - 50.0);
408 - let resp = ui.add(edit);
409 + let resp = widgets::text_field(ui, edit);
409 410 let trimmed = state.collections_ui.collection_filter_name_input.trim();
410 411 let name = if trimmed.is_empty() {
411 412 auto_name
@@ -62,7 +62,8 @@
62 62 // M-2: search input at the top filters both columns case-insensitively.
63 63 ui.horizontal(|ui| {
64 64 ui.label("Filter:");
65 - ui.add(
65 + widgets::text_field(
66 + ui,
66 67 egui::TextEdit::singleline(&mut state.overlay.help_shortcut_search)
67 68 .hint_text("e.g. tag, search, Cmd")
68 69 .desired_width(220.0),
@@ -583,7 +584,8 @@
583 584 });
584 585 ui.add_space(theme::space::bound());
585 586
586 - let resp = ui.add(
587 + let resp = widgets::text_field(
588 + ui,
587 589 egui::TextEdit::singleline(tag_input)
588 590 .hint_text("e.g. genre.electronic")
589 591 .desired_width(300.0),
@@ -713,7 +715,8 @@
713 715 ui.add_space(theme::space::bound());
714 716
715 717 // M-6: substring filter (case-insensitive) over the directory paths.
716 - ui.add(
718 + widgets::text_field(
719 + ui,
717 720 egui::TextEdit::singleline(&mut state.import_wf.bulk_move_filter)
718 721 .hint_text("Filter folders...")
719 722 .desired_width(360.0),
@@ -813,7 +816,8 @@
813 816 });
814 817 ui.add_space(theme::space::bound());
815 818
816 - let resp = ui.add(
819 + let resp = widgets::text_field(
820 + ui,
817 821 egui::TextEdit::singleline(pattern_input)
818 822 .hint_text("{name}_{bpm}")
819 823 .desired_width(460.0),
@@ -552,7 +552,8 @@
552 552 ui.horizontal(|ui| {
553 553 let has_query = !state.search.tag_search.is_empty();
554 554 let reserved = if has_query { 56.0 } else { 4.0 };
555 - ui.add(
555 + widgets::text_field(
556 + ui,
556 557 egui::TextEdit::singleline(&mut state.search.tag_search)
557 558 .hint_text("Filter tags...")
558 559 .desired_width(ui.available_width() - reserved),
@@ -591,7 +592,10 @@
591 592 let Some((_, buf)) = state.collections_ui.tag_rename_target.as_mut() else {
592 593 return;
593 594 };
594 - let resp = ui.add(egui::TextEdit::singleline(buf).hint_text(old_tag.as_str()));
595 + let resp = widgets::text_field(
596 + ui,
597 + egui::TextEdit::singleline(buf).hint_text(old_tag.as_str()),
598 + );
595 599 if want_focus {
596 600 resp.request_focus();
597 601 }
@@ -392,7 +392,8 @@
392 392 // Read-only truncated URL display + Copy button. The URL itself is
393 393 // long (OAuth + PKCE + state) so truncation is necessary.
394 394 let mut shown = url.clone();
395 - ui.add(
395 + widgets::text_field(
396 + ui,
396 397 egui::TextEdit::singleline(&mut shown).desired_width(ui.available_width() - 70.0),
397 398 );
398 399 if ui.button("Copy").clicked() {
@@ -451,7 +452,8 @@
451 452 ui.add_space(theme::space::group());
452 453 ui.horizontal(|ui| {
453 454 ui.label("Password:");
454 - ui.add(
455 + widgets::text_field(
456 + ui,
455 457 egui::TextEdit::singleline(&mut state.sync.encryption_input)
456 458 .password(true)
457 459 .desired_width(200.0),
@@ -466,7 +468,8 @@
466 468 ui.add_space(theme::space::bound());
467 469 ui.horizontal(|ui| {
468 470 ui.label("Confirm: ");
469 - ui.add(
471 + widgets::text_field(
472 + ui,
470 473 egui::TextEdit::singleline(&mut state.sync.encryption_confirm_input)
471 474 .password(true)
472 475 .desired_width(200.0),
@@ -932,6 +932,23 @@
932 932 visuals.window_corner_radius = container;
933 933 visuals.menu_corner_radius = container;
934 934
935 + // Floating surfaces cast a hard shadow, not a blurred one. A blur is a
936 + // different design language and reads as a soft-shadow web card; Platinum's
937 + // is an offset rectangle in flat black, as if the window were a physical
938 + // card a couple of millimetres off the page. `blur: 0` is the whole of it.
939 + //
940 + // The offset is two points down and right, matching the light source the
941 + // bevel already assumes: lit from the top left, so the shadow falls to the
942 + // bottom right.
943 + let shadow = egui::Shadow {
944 + offset: [2, 2],
945 + blur: 0,
946 + spread: 0,
947 + color: Color32::from_black_alpha(96),
948 + };
949 + visuals.window_shadow = shadow;
950 + visuals.popup_shadow = shadow;
951 +
935 952 // Softer widget borders: thinner strokes on inactive/hover states
936 953 visuals.widgets.inactive.bg_stroke =
937 954 egui::Stroke::new(0.5, lerp_color(t.border, t.surface_overlay, 0.3));
@@ -32,7 +32,7 @@
32 32 let search_edit = egui::TextEdit::singleline(&mut state.search.search_query)
33 33 .hint_text("Search samples... (/)")
34 34 .desired_width(ui.available_width() - 160.0);
35 - let resp = ui.add(search_edit);
35 + let resp = widgets::text_field(ui, search_edit);
36 36
37 37 if state.focus_search {
38 38 resp.request_focus();
@@ -123,7 +123,7 @@
123 123 )
124 124 .hint_text("e.g. Kicks Under 120 BPM")
125 125 .desired_width(180.0);
126 - let resp = ui.add(edit);
126 + let resp = widgets::text_field(ui, edit);
127 127 if resp.gained_focus()
128 128 || state.collections_ui.collection_filter_name_input.is_empty()
129 129 {
@@ -110,7 +110,31 @@
110 110 .show(ui, |ui| add_contents(ui))
111 111 .inner
112 112 })
113 - .and_then(|r| r.inner)
113 + .and_then(bevel_floating)
114 + }
115 +
116 + /// Paint a floating surface's raised bevel and hand back its inner value.
117 + ///
118 + /// The bevel goes on after `Window::show` has returned, into the window's own
119 + /// layer, so it lands over the frame egui already drew rather than under it.
120 + /// A `Window` builds its frame from `window_stroke`, which is one stroke with
121 + /// no per-side control, so this is the only place the two-tone edge can come
122 + /// from.
123 + ///
124 + /// The hard drop shadow is the theme's half of the same job
125 + /// (`visuals.window_shadow`); a floating surface needs both, or it reads as a
126 + /// raised panel that happens to be on top of something.
127 + fn bevel_floating<R>(response: egui::InnerResponse<R>) -> R {
128 + let painter = response
129 + .response
130 + .ctx
131 + .layer_painter(response.response.layer_id);
132 + theme::bevel::paint(
133 + &painter,
134 + response.response.rect,
135 + theme::bevel::Bevel::Raised,
136 + );
137 + response.inner
114 138 }
115 139
116 140 /// Like `modal_window`, but with an optional `open` bool the user can toggle by
@@ -138,7 +162,7 @@
138 162 // per-frame panic in the shared modal scaffold if that ever changes.
139 163 window
140 164 .show(ctx, |ui| add_contents(ui))
141 - .and_then(|r| r.inner)
165 + .and_then(bevel_floating)
142 166 }
143 167
144 168 /// Render a `[Cancel] [primary]` action row at the bottom of a modal.
@@ -166,12 +190,13 @@
166 190 let confirmed = if danger {
167 191 danger_button_enabled(ui, confirm_label, can_confirm).clicked()
168 192 } else {
169 - // Not `primary_button`: that one has no disabled variant, and
170 - // adding one is a weight change to every modal, which is a
171 - // separate decision from the shape.
172 - ui.add_enabled(
173 - can_confirm,
174 - egui::Button::new(confirm_label).corner_radius(theme::radius_control()),
193 + // Secondary, not primary: the weight of the affirmative button in
194 + // a modal is a separate decision from its shape, and this row has
195 + // always drawn it at default weight.
196 + bevel_button(
197 + ui,
198 + confirm_label,
199 + ButtonStyle::secondary().enabled(can_confirm),
175 200 )
176 201 .clicked()
177 202 };
@@ -253,10 +278,10 @@
253 278 }
254 279 ui.add_space(theme::space::peer());
255 280 ui.horizontal(|ui| {
256 - if ui.button("Cancel").clicked() {
281 + if secondary_button(ui, "Cancel").clicked() {
257 282 outcome = NameModalOutcome::Cancelled;
258 283 }
259 - if ui.button(submit_label).clicked() {
284 + if secondary_button(ui, submit_label).clicked() {
260 285 outcome = NameModalOutcome::Submitted(input.trim().to_string());
261 286 }
262 287 });
@@ -359,11 +384,16 @@
359 384
360 385 // --- Toolbar toggle and segmented pills --------------------------------------
361 386
362 - /// Toolbar toggle button.
387 + /// Toolbar toggle: a button that stays in.
363 388 ///
364 - /// Active state colours the label `action`; inactive state colours it
365 - /// `content_muted`. Returns true on click. Optional `count` renders a parenthesised
366 - /// suffix (e.g. "Filters (3)") for active-with-count toolbar buttons.
389 + /// Active is `Inset` with a `surface_sunken` fill and an `action` label;
390 + /// inactive is `Raised` with `content_secondary`. The shape carries the state
391 + /// and the colour agrees with it, rather than the colour carrying it alone —
392 + /// the toolbar is where "which of these is on" has to be readable without
393 + /// stopping to look, and a held-in button says that at a glance.
394 + ///
395 + /// Returns true on click. Optional `count` renders a parenthesised suffix
396 + /// (e.g. "Filters (3)").
367 397 pub fn toolbar_toggle(
368 398 ui: &mut egui::Ui,
369 399 label: &str,
@@ -375,14 +405,28 @@
375 405 Some(n) if n > 0 => format!("{label} ({n})"),
376 406 _ => label.to_string(),
377 407 };
378 - let colour = if active {
379 - theme::action()
408 + let (fill, colour) = if active {
409 + (theme::surface_sunken(), theme::action())
380 410 } else {
381 - theme::content_muted()
411 + (theme::surface_raised(), theme::content_secondary())
382 412 };
383 - ui.button(egui::RichText::new(text).color(colour))
384 - .on_hover_text(tooltip)
385 - .clicked()
413 +
414 + let response = ui.add(
415 + egui::Button::new(egui::RichText::new(text).color(colour))
416 + .corner_radius(theme::radius_control())
417 + .fill(fill),
418 + );
419 +
420 + // Held in because it is on, or held in because a finger is on it. One
421 + // shape, two reasons, which is the whole economy of the idiom.
422 + let kind = if active {
423 + theme::bevel::Bevel::Inset
424 + } else {
425 + press_bevel(&response)
426 + };
427 + theme::bevel::paint(ui.painter(), response.rect, kind);
428 +
429 + response.on_hover_text(tooltip).clicked()
386 430 }
387 431
388 432 /// Mutually-exclusive segmented control.
@@ -425,10 +469,10 @@
425 469 // The selected segment is held in, and a segment under a held
426 470 // pointer shows the same thing. One rule, two reasons to be inset:
427 471 // this is what the light model buys over a colour swap.
428 - let kind = if is_active || resp.is_pointer_button_down_on() {
472 + let kind = if is_active {
429 473 theme::bevel::Bevel::Inset
430 474 } else {
431 - theme::bevel::Bevel::Raised
475 + press_bevel(&resp)
432 476 };
433 477 theme::bevel::paint(ui.painter(), resp.rect, kind);
434 478
@@ -450,54 +494,149 @@
450 494 // `confirm_action_row` (above) composes these for the standard modal pattern;
451 495 // reach for these directly only when building a non-modal action row.
452 496 //
453 - // All five ask for `radius_control` by name. Nothing else in the app does:
454 - // square is the default now (see `apply_theme`), and a rounded corner is this
455 - // codebase saying "this is a push button". A raw `ui.button` therefore comes
456 - // out square, which is correct for the toolbar and wrong for a dialog action
457 - // row -- reach for these rather than for `ui.button` when it is the latter.
497 + // All five are [`bevel_button`] with a style on it, so all five invert when
498 + // held and no caller changed a line to get that. They ask for `radius_control`
499 + // by name; nothing else in the app does, because square is the default now (see
500 + // `apply_theme`) and a rounded corner is this codebase saying "push button".
501 +
502 + /// Where a button sits in the action hierarchy.
503 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
504 + pub enum ButtonWeight {
505 + /// The one affirmative action in a row. Strong label.
506 + Primary,
507 + /// Peer actions and Cancel. Default weight.
508 + Secondary,
509 + /// Destructive. Label in `danger` so the consequence is read first.
510 + Danger,
511 + }
512 +
513 + /// Everything about a bevelled button that is not its label.
514 + #[derive(Debug, Clone, Copy)]
515 + pub struct ButtonStyle {
516 + pub weight: ButtonWeight,
517 + /// Tighter padding, for per-row affordances and context menus.
518 + pub small: bool,
519 + pub enabled: bool,
520 + }
521 +
522 + impl ButtonStyle {
523 + /// The one affirmative action in a row.
524 + pub fn primary() -> Self {
525 + Self {
526 + weight: ButtonWeight::Primary,
527 + small: false,
528 + enabled: true,
529 + }
530 + }
531 +
532 + /// Cancel and peer actions.
533 + pub fn secondary() -> Self {
534 + Self {
535 + weight: ButtonWeight::Secondary,
536 + ..Self::primary()
537 + }
538 + }
539 +
540 + /// Delete, Purge, Discard.
541 + pub fn danger() -> Self {
542 + Self {
543 + weight: ButtonWeight::Danger,
544 + ..Self::primary()
545 + }
546 + }
547 +
548 + /// Tighter padding.
549 + #[must_use]
550 + pub fn small(mut self) -> Self {
551 + self.small = true;
552 + self
553 + }
554 +
555 + /// Greyed and unclickable when `false`.
556 + #[must_use]
557 + pub fn enabled(mut self, enabled: bool) -> Self {
558 + self.enabled = enabled;
559 + self
560 + }
561 + }
562 +
563 + /// The button primitive: raised at rest, inset while held.
564 + ///
565 + /// The inversion is why this is hand-painted rather than themed. egui expresses
566 + /// a pressed button as a different fill, which reads as "a slightly darker
567 + /// button"; Platinum expresses it as the same object pushed in, which reads as
568 + /// a button being pressed. `Visuals` has no way to say the second, so the frame
569 + /// is painted here from the live response.
570 + ///
571 + /// Disabled keeps its raised bevel and drops the label to `content_muted()`. A
572 + /// disabled control that loses its shape stops looking like a control at all,
573 + /// and the user needs to know a button is there before they can wonder why it
574 + /// is off.
575 + ///
576 + /// Prefer the named helpers below. Reaching for this directly is a sign the
577 + /// style you want should have a name.
578 + pub fn bevel_button(ui: &mut egui::Ui, label: &str, style: ButtonStyle) -> egui::Response {
579 + let color = match (style.enabled, style.weight) {
580 + (false, _) => theme::content_muted(),
581 + (true, ButtonWeight::Danger) => theme::danger(),
582 + (true, _) => theme::content(),
583 + };
584 + let mut text = egui::RichText::new(label).color(color);
585 + if style.weight == ButtonWeight::Primary {
586 + text = text.strong();
587 + }
588 +
589 + let mut button = egui::Button::new(text).corner_radius(theme::radius_control());
590 + if style.small {
591 + button = button.small();
592 + }
593 +
594 + let response = ui.add_enabled(style.enabled, button);
595 + theme::bevel::paint(ui.painter(), response.rect, press_bevel(&response));
596 + response
597 + }
598 +
599 + /// `Inset` while the pointer is held on this widget, `Raised` otherwise.
600 + ///
601 + /// The one rule every interactive primitive in this file shares, so it is
602 + /// written once. Hover and keyboard focus deliberately do not invert: a bevel
603 + /// that moved on hover would say the control had been pressed when it had not.
604 + fn press_bevel(response: &egui::Response) -> theme::bevel::Bevel {
605 + if response.is_pointer_button_down_on() {
606 + theme::bevel::Bevel::Inset
607 + } else {
608 + theme::bevel::Bevel::Raised
609 + }
610 + }
458 611
459 612 /// Primary action button. Strong label weight.
460 613 pub fn primary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
461 - ui.add(
462 - egui::Button::new(egui::RichText::new(label).strong())
463 - .corner_radius(theme::radius_control()),
464 - )
614 + bevel_button(ui, label, ButtonStyle::primary())
465 615 }
466 616
467 617 /// Secondary / peer action button. Default weight. Use for Cancel and for any
468 618 /// action that isn't the primary focus of the row.
469 619 pub fn secondary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
470 - ui.add(egui::Button::new(label).corner_radius(theme::radius_control()))
620 + bevel_button(ui, label, ButtonStyle::secondary())
471 621 }
472 622
473 623 /// Destructive primary action. Label rendered in `danger` so the user
474 624 /// reads the consequence before clicking. Used for Delete, Purge, Discard.
475 625 pub fn danger_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
476 - ui.add(
477 - egui::Button::new(egui::RichText::new(label).color(theme::danger()))
478 - .corner_radius(theme::radius_control()),
479 - )
626 + bevel_button(ui, label, ButtonStyle::danger())
480 627 }
481 628
482 629 /// Destructive action that may be disabled (e.g. Delete-vault when only one
483 - /// vault remains). Same red colouring as [`danger_button`]; routes through
484 - /// `add_enabled` so disabled state and hover text work consistently.
630 + /// vault remains). Same red colouring as [`danger_button`] when live, and
631 + /// `content_muted` when not.
485 632 pub fn danger_button_enabled(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response {
486 - ui.add_enabled(
487 - enabled,
488 - egui::Button::new(egui::RichText::new(label).color(theme::danger()))
489 - .corner_radius(theme::radius_control()),
490 - )
633 + bevel_button(ui, label, ButtonStyle::danger().enabled(enabled))
491 634 }
492 635
493 636 /// Small destructive action (per-row Remove/Delete affordances, context-menu
494 637 /// items inside a tighter layout). Same colouring as [`danger_button`].
495 638 pub fn danger_small_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
496 - ui.add(
497 - egui::Button::new(egui::RichText::new(label).color(theme::danger()))
498 - .small()
499 - .corner_radius(theme::radius_control()),
500 - )
639 + bevel_button(ui, label, ButtonStyle::danger().small())
501 640 }
502 641
503 642 // --- Section headers ---------------------------------------------------------
@@ -545,6 +684,25 @@
545 684 });
546 685 }
547 686
687 + // --- Text fields -------------------------------------------------------------
688 +
689 + /// Add a `TextEdit` as an inset well.
690 + ///
691 + /// **Every `TextEdit` in the app goes through here.** A well is the one shape
692 + /// in Platinum that means "put something here", and it only means that if it is
693 + /// the same well every time: a bevel on seventeen of eighteen fields is worse
694 + /// than a bevel on none, because then the odd one out is saying something the
695 + /// author did not intend.
696 + ///
697 + /// The bevel is painted *after* `ui.add`, so the widget's own background lands
698 + /// first and the 1px frame draws on top of it. That ordering is why
699 + /// [`theme::bevel::paint`] fills nothing.
700 + pub fn text_field(ui: &mut egui::Ui, edit: egui::TextEdit<'_>) -> egui::Response {
701 + let response = ui.add(edit);
702 + theme::bevel::paint(ui.painter(), response.rect, theme::bevel::Bevel::Inset);
703 + response
704 + }
705 +
548 706 // --- Selectable rows ---------------------------------------------------------
549 707
550 708 /// Render a selectable label that truncates with an ellipsis instead of
@@ -704,7 +862,8 @@
704 862 };
705 863 ui.painter()
706 864 .rect_filled(rect, theme::radius_container(), bg);
707 - theme::bevel::paint(ui.painter(), rect, theme::bevel::Bevel::Raised);
865 + // A chip answers a click, so it inverts like everything else that does.
866 + theme::bevel::paint(ui.painter(), rect, press_bevel(&response));
708 867 ui.painter().text(
709 868 rect.center(),
710 869 egui::Align2::CENTER_CENTER,
@@ -737,9 +896,13 @@
737 896 } else {
738 897 theme::danger()
739 898 };
899 + // Not `danger_small_button`: the dimmed-until-hovered colour is this
900 + // widget's own affordance decision and does not belong in the button
901 + // vocabulary. The bevel rule still applies, so it is applied here.
740 902 let btn = ui
741 903 .add(egui::Button::new(egui::RichText::new("x").small().color(x_color)).small())
742 904 .on_hover_text("Remove tag");
905 + theme::bevel::paint(ui.painter(), btn.rect, press_bevel(&btn));
743 906 if btn.clicked() {
744 907 removed = true;
745 908 }
@@ -773,6 +936,40 @@
773 936 mod tests {
774 937 use super::*;
775 938
939 + // Button styles
940 + //
941 + // The styles are what the vocabulary is made of, and each named helper is
942 + // one of them. Painting needs a live render context and is not testable
943 + // here; which style each helper picks is, and that is the part that would
944 + // silently go wrong in a refactor.
945 +
946 + #[test]
947 + fn the_named_helpers_cover_the_weights() {
948 + assert_eq!(ButtonStyle::primary().weight, ButtonWeight::Primary);
949 + assert_eq!(ButtonStyle::secondary().weight, ButtonWeight::Secondary);
950 + assert_eq!(ButtonStyle::danger().weight, ButtonWeight::Danger);
951 + }
952 +
953 + #[test]
954 + fn styles_default_to_enabled_and_full_size() {
955 + for style in [
956 + ButtonStyle::primary(),
957 + ButtonStyle::secondary(),
958 + ButtonStyle::danger(),
959 + ] {
960 + assert!(style.enabled, "a button is live unless it says otherwise");
961 + assert!(!style.small);
962 + }
963 + }
964 +
965 + #[test]
966 + fn the_modifiers_compose_and_keep_the_weight() {
967 + let style = ButtonStyle::danger().small().enabled(false);
968 + assert_eq!(style.weight, ButtonWeight::Danger);
969 + assert!(style.small);
970 + assert!(!style.enabled);
971 + }
972 +
776 973 // format_bytes
777 974
778 975 #[test]
@@ -66,7 +66,7 @@
66 66 // where most folders share the same root taxonomy.
67 67 ui.horizontal(|ui| {
68 68 ui.label("Apply to all:");
69 - ui.add(
69 + widgets::text_field(ui,
70 70 egui::TextEdit::singleline(&mut state.import_wf.tag_folders_apply_all_input)
71 71 .hint_text("e.g. one-shots, kick")
72 72 .desired_width(240.0),