Skip to main content

max / audiofiles

Platinum: rectangles instead of pills Square becomes the default and `radius_control` becomes opt-in. egui's five widget states are shared by every selectable row, tab, breadcrumb, combo box, checkbox, scrollbar and slider, so rounding them -- which is what phase 2 left in place -- turned each one into a pill. Now `apply_theme` sets all five to the container radius and the five button helpers ask for the control radius by name. Twenty-odd `selectable_label` sites go square without a call-site edit, and a new one cannot accidentally come back rounded. `toggle_pills` becomes `segmented_control` and is one now. The segments abut instead of sitting in a spaced row, each carries its own bevel, and the selected one is inset -- as is any segment under a held pointer, which is the first thing in the app to use the light model rather than only carry it. Two spaced pills read as two unrelated buttons; nothing about the old control said "pick exactly one". Tag chips are square raised tiles rather than rounded tokens. The footer's progress bar is a square inset well with a flat fill, which is what it was imitating with a 3px radius and a slider's shape. The theme swatch in the picker squares off too. `confirm_action_row` builds its buttons through the helpers, so a dialog action row still gets a corner. Weight is unchanged; a disabled variant of `primary_button` is a separate decision. The remaining radius literals are in `instrument_panel.rs`, which is domain rendering and out of scope by the conversion spec, plus one `0.0` on the full-bleed modal scrim. The charter now names both rather than stating a rule it does not keep. 342 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:11 UTC
Signed with PGP, not checked
Commit: 3915e9210487d0f21d2ce870d0d30fbc5d0fdb69
Parent: 694a7a1
8 files changed, +134 insertions, -47 deletions
@@ -121,10 +121,25 @@
121 121 A theme that still sets the old single `rounding` key gets it as the control
122 122 radius and square containers, which is the closest reading of what it asked for.
123 123
124 - **Never a `CornerRadius::same(N)` literal outside `theme.rs`.** Pick the token
125 - that says what the surface is; the number follows. This is the same rule as the
126 - spacing one and it exists for the same reason: a literal is a value edit waiting
127 - to happen, and the whole radius map moves at once or not at all.
124 + **Square is the default and `radius_control()` is opt-in.** `apply_theme` sets
125 + every one of egui's five widget states to the container radius, because those
126 + five are shared by every selectable row, tab, breadcrumb, combo box, checkbox,
127 + scrollbar and slider in the app — rounding them turned each one into a pill. The
128 + five button helpers in `widgets.rs` ask for `radius_control()` by name, and
129 + nothing else does. A raw `ui.button` comes out square, which is right for a
130 + toolbar and wrong for a dialog action row; use the helpers for the latter.
131 +
132 + **Never a radius literal in chrome.** Not `CornerRadius::same(N)`, and not the
133 + bare float that `Painter::rect_filled` takes. Pick the token that says what the
134 + surface is; the number follows. Same rule as the spacing one, for the same
135 + reason: a literal is a value edit waiting to happen, and the whole radius map
136 + moves at once or not at all.
137 +
138 + Two carve-outs, both real. `instrument_panel.rs` and `edit_panel.rs` are domain
139 + rendering — piano keys, zone bars, the waveform and its trim wash — and draw
140 + shapes that are not chrome and do not take chrome's tokens. And a literal `0.0`
141 + on a full-bleed fill (the modal scrim) is not a radius decision, because there
142 + is no corner on screen to round.
128 143
129 144 ### Bevel
130 145
@@ -177,7 +192,7 @@
177 192 - **`tag_chip(ui, tag) -> Response`**: existing.
178 193 - **`tag_chip_removable(ui, tag) -> bool`**: existing.
179 194 - **`classification_badge(ui, class)`**: existing.
180 - - **`toggle_pills(ui, current, options: &[(value, label, tooltip)]) -> Option<value>`**: segmented control for mutually-exclusive choices: Folder/All, Exact/Compatible, Add/Remove tag, help tabs.
195 + - **`segmented_control(ui, current, options: &[(value, label, tooltip)]) -> Option<value>`**: mutually-exclusive choices: Folder/All, Exact/Compatible, Add/Remove tag, help tabs. Segments abut with no gap and each carries its own bevel, so the group reads as one object with one part pushed in. Was `toggle_pills`, which spaced them and rounded them and therefore read as two unrelated buttons.
181 196
182 197 ### Inputs
183 198
@@ -242,7 +242,7 @@
242 242 changed = true;
243 243 }
244 244 use audiofiles_core::search::KeyFilterMode;
245 - if let Some(mode) = widgets::toggle_pills(
245 + if let Some(mode) = widgets::segmented_control(
246 246 ui,
247 247 &state.search.search_filter.key_mode,
248 248 &[
@@ -97,12 +97,21 @@
97 97 let (rect, bar_resp) =
98 98 ui.allocate_exact_size(egui::vec2(bar_width, bar_height), egui::Sense::click());
99 99 if ui.is_rect_visible(rect) {
100 - ui.painter().rect_filled(rect, 3.0, theme::surface_page());
100 + // A Platinum progress bar is a well with something in it:
101 + // the track is inset, the fill is flat inside it, and both
102 + // are square. The rounded track read as a slider.
103 + ui.painter().rect_filled(
104 + rect,
105 + theme::radius_container(),
106 + theme::surface_page(),
107 + );
101 108 let fill_rect = egui::Rect::from_min_size(
102 109 rect.min,
103 110 egui::vec2(rect.width() * progress, rect.height()),
104 111 );
105 - ui.painter().rect_filled(fill_rect, 3.0, theme::action());
112 + ui.painter()
113 + .rect_filled(fill_rect, theme::radius_container(), theme::action());
114 + theme::bevel::paint(ui.painter(), rect, theme::bevel::Bevel::Inset);
106 115 }
107 116
108 117 // Click-to-seek on progress bar
@@ -27,9 +27,9 @@
27 27 false,
28 28 Some(400.0),
29 29 |ui| {
30 - // m-12: toggle_pills gives the two tabs a stronger affordance
30 + // m-12: the segmented control gives the two tabs a stronger affordance
31 31 // contract than bare selectable_value (which reads as radio).
32 - if let Some(next) = widgets::toggle_pills(
32 + if let Some(next) = widgets::segmented_control(
33 33 ui,
34 34 &state.overlay.help_tab,
35 35 &[
@@ -528,7 +528,7 @@
528 528 if let Some((bg, accent, _fg)) = theme::theme_preview_colors(&t.id) {
529 529 let size = egui::vec2(12.0, 12.0);
530 530 let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
531 - ui.painter().rect_filled(rect, 2.0, bg);
531 + ui.painter().rect_filled(rect, theme::radius_container(), bg);
532 532 let accent_rect = egui::Rect::from_min_size(
533 533 rect.min + egui::vec2(6.0, 0.0),
534 534 egui::vec2(6.0, 12.0),
@@ -911,17 +911,24 @@
911 911 visuals.window_stroke = egui::Stroke::new(1.0, t.border);
912 912 visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, t.border);
913 913
914 - // The radius split. egui's `noninteractive` state is what panels, frames
915 - // and separators draw themselves with, so it takes the container radius;
916 - // the four interactive states are buttons and take the control radius.
917 - // Windows and menus are containers by any reading.
918 - let control = egui::CornerRadius::same(t.radius_control as u8);
914 + // Square by default, everywhere. Under Platinum a corner is a claim that
915 + // the thing is a push button, and egui's five widget states are shared by
916 + // every selectable row, tab, breadcrumb, combo box, checkbox, scrollbar
917 + // and slider in the app. Rounding all of them turned every list row into
918 + // a pill, which is the wrong idiom and was the loudest thing about how
919 + // the app looked.
920 + //
921 + // So `radius_control` is opt-in rather than the default: the button
922 + // helpers in `widgets.rs` ask for it by name, and nothing else gets it.
923 + // The alternative reading -- default round, square the exceptions --
924 + // needs a call-site edit for each of twenty-odd `selectable_label` sites
925 + // and silently re-rounds any new one.
919 926 let container = egui::CornerRadius::same(t.radius_container as u8);
920 927 visuals.widgets.noninteractive.corner_radius = container;
921 - visuals.widgets.inactive.corner_radius = control;
922 - visuals.widgets.hovered.corner_radius = control;
923 - visuals.widgets.active.corner_radius = control;
924 - visuals.widgets.open.corner_radius = control;
928 + visuals.widgets.inactive.corner_radius = container;
929 + visuals.widgets.hovered.corner_radius = container;
930 + visuals.widgets.active.corner_radius = container;
931 + visuals.widgets.open.corner_radius = container;
925 932 visuals.window_corner_radius = container;
926 933 visuals.menu_corner_radius = container;
927 934
@@ -72,7 +72,7 @@
72 72 .small()
73 73 .color(theme::content_muted()),
74 74 );
75 - if let Some(scope) = widgets::toggle_pills(
75 + if let Some(scope) = widgets::segmented_control(
76 76 ui,
77 77 &state.search.search_filter.scope,
78 78 &[
@@ -157,18 +157,25 @@
157 157 ) -> ConfirmOutcome {
158 158 let mut outcome = ConfirmOutcome::None;
159 159 ui.horizontal(|ui| {
160 - if ui.button("Cancel").clicked() {
160 + // Through the helpers rather than raw `ui.button`, so this row gets the
161 + // control radius. Square is the default now and a dialog action row is
162 + // the one place that is wrong: a push button is what a corner means.
163 + if secondary_button(ui, "Cancel").clicked() {
161 164 outcome = ConfirmOutcome::Cancelled;
162 165 }
163 - let label = if danger {
164 - egui::RichText::new(confirm_label).color(theme::danger())
166 + let confirmed = if danger {
167 + danger_button_enabled(ui, confirm_label, can_confirm).clicked()
165 168 } else {
166 - egui::RichText::new(confirm_label)
167 - };
168 - if ui
169 - .add_enabled(can_confirm, egui::Button::new(label))
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()),
175 + )
170 176 .clicked()
171 - {
177 + };
178 + if confirmed {
172 179 outcome = ConfirmOutcome::Confirmed;
173 180 }
174 181 });
@@ -378,26 +385,54 @@
378 385 .clicked()
379 386 }
380 387
381 - /// Mutually-exclusive segmented pill control.
388 + /// Mutually-exclusive segmented control.
382 389 ///
383 390 /// `options` is a list of `(value, label, tooltip)` triples. Returns
384 391 /// `Some(value)` if a non-current option was clicked, `None` otherwise.
385 392 /// Caller assigns the returned value to its state.
386 - pub fn toggle_pills<T: Clone + PartialEq>(
393 + ///
394 + /// The segments abut and each carries its own bevel, so the control reads as
395 + /// one object with one part pushed in. Two spaced pills read as two unrelated
396 + /// buttons that happen to sit near each other, which is what this was before
397 + /// and why nothing about it said "pick exactly one of these".
398 + pub fn segmented_control<T: Clone + PartialEq>(
387 399 ui: &mut egui::Ui,
388 400 current: &T,
389 401 options: &[(T, &str, &str)],
390 402 ) -> Option<T> {
391 403 let mut chosen = None;
392 404 ui.horizontal(|ui| {
405 + // No gap between segments. The gap is the whole difference between a
406 + // segmented control and a row of buttons.
407 + ui.spacing_mut().item_spacing.x = 0.0;
408 +
393 409 for (value, label, tooltip) in options {
394 410 let is_active = value == current;
395 - if ui
396 - .selectable_label(is_active, *label)
397 - .on_hover_text(*tooltip)
398 - .clicked()
399 - && !is_active
400 - {
411 + let (fill, text) = if is_active {
412 + (theme::surface_sunken(), theme::content())
413 + } else {
414 + (theme::surface_raised(), theme::content_secondary())
415 + };
416 +
417 + let resp = ui
418 + .add(
419 + egui::Button::new(egui::RichText::new(*label).color(text))
420 + .corner_radius(theme::radius_container())
421 + .fill(fill),
422 + )
423 + .on_hover_text(*tooltip);
424 +
425 + // The selected segment is held in, and a segment under a held
426 + // pointer shows the same thing. One rule, two reasons to be inset:
427 + // this is what the light model buys over a colour swap.
428 + let kind = if is_active || resp.is_pointer_button_down_on() {
429 + theme::bevel::Bevel::Inset
430 + } else {
431 + theme::bevel::Bevel::Raised
432 + };
433 + theme::bevel::paint(ui.painter(), resp.rect, kind);
434 +
435 + if resp.clicked() && !is_active {
401 436 chosen = Some(value.clone());
402 437 }
403 438 }
@@ -414,24 +449,34 @@
414 449 //
415 450 // `confirm_action_row` (above) composes these for the standard modal pattern;
416 451 // reach for these directly only when building a non-modal action row.
452 + //
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.
417 458
418 459 /// Primary action button. Strong label weight.
419 460 pub fn primary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
420 - ui.add(egui::Button::new(egui::RichText::new(label).strong()))
461 + ui.add(
462 + egui::Button::new(egui::RichText::new(label).strong())
463 + .corner_radius(theme::radius_control()),
464 + )
421 465 }
422 466
423 467 /// Secondary / peer action button. Default weight. Use for Cancel and for any
424 468 /// action that isn't the primary focus of the row.
425 469 pub fn secondary_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
426 - ui.button(label)
470 + ui.add(egui::Button::new(label).corner_radius(theme::radius_control()))
427 471 }
428 472
429 473 /// Destructive primary action. Label rendered in `danger` so the user
430 474 /// reads the consequence before clicking. Used for Delete, Purge, Discard.
431 475 pub fn danger_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
432 - ui.add(egui::Button::new(
433 - egui::RichText::new(label).color(theme::danger()),
434 - ))
476 + ui.add(
477 + egui::Button::new(egui::RichText::new(label).color(theme::danger()))
478 + .corner_radius(theme::radius_control()),
479 + )
435 480 }
436 481
437 482 /// Destructive action that may be disabled (e.g. Delete-vault when only one
@@ -440,14 +485,19 @@
440 485 pub fn danger_button_enabled(ui: &mut egui::Ui, label: &str, enabled: bool) -> egui::Response {
441 486 ui.add_enabled(
442 487 enabled,
443 - egui::Button::new(egui::RichText::new(label).color(theme::danger())),
488 + egui::Button::new(egui::RichText::new(label).color(theme::danger()))
489 + .corner_radius(theme::radius_control()),
444 490 )
445 491 }
446 492
447 493 /// Small destructive action (per-row Remove/Delete affordances, context-menu
448 494 /// items inside a tighter layout). Same colouring as [`danger_button`].
449 495 pub fn danger_small_button(ui: &mut egui::Ui, label: &str) -> egui::Response {
450 - ui.add(egui::Button::new(egui::RichText::new(label).color(theme::danger())).small())
496 + ui.add(
497 + egui::Button::new(egui::RichText::new(label).color(theme::danger()))
498 + .small()
499 + .corner_radius(theme::radius_control()),
500 + )
451 501 }
452 502
453 503 // --- Section headers ---------------------------------------------------------
@@ -627,8 +677,12 @@
627 677 /// Draw a tag as a small colored chip.
628 678 ///
629 679 /// Uses custom rendering (`allocate_exact_size` + `painter()`) instead of a standard
630 - /// egui widget because tag chips need a specific rounded-rect background, precise
631 - /// font size (11pt), and hover highlighting that standard `Label` doesn't provide.
680 + /// egui widget because tag chips need a specific background, precise font size
681 + /// (11pt), and hover highlighting that standard `Label` doesn't provide.
682 + ///
683 + /// Square and bevelled rather than rounded: a chip is a small raised tile here,
684 + /// not a pill. The rounded version read as a web token, which is the one visual
685 + /// language this app has no business borrowing from.
632 686 pub fn tag_chip(ui: &mut egui::Ui, tag: &str) -> egui::Response {
633 687 // Estimate width from character count * average glyph width + padding.
634 688 let (rect, response) = ui.allocate_exact_size(
@@ -648,7 +702,9 @@
648 702 } else {
649 703 theme::surface_raised()
650 704 };
651 - ui.painter().rect_filled(rect, 4.0, bg);
705 + ui.painter()
706 + .rect_filled(rect, theme::radius_container(), bg);
707 + theme::bevel::paint(ui.painter(), rect, theme::bevel::Bevel::Raised);
652 708 ui.painter().text(
653 709 rect.center(),
654 710 egui::Align2::CENTER_CENTER,