Skip to main content

max / audiofiles

UX leftovers: footer errors, key grouping, inline-editor Escape More ux-audit panel polish: - Footer: error status stays visible (in accent_red) instead of fading out after 30s — a silently expiring error is the one message the user most needs to catch. Informational/success messages still fade. - Filter Key section: 24 stacked rows -> Major/Minor groups in horizontal_wrapped, compact note pills (store full key), matching the Classification section's grouping. - Instrument: surface the Multi-sample precondition inline while in Chromatic mode, so the permanently-disabled radio doesn't read as a dead-end. - Sidebar: Escape now cancels the inline collection-rename, collection- create, and tag-rename editors (handled in-row so it works even while the text field holds focus). - Waveform trim overlay derives from the theme background instead of hardcoded black, so it dims correctly on light themes. 806 tests green, 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-06 01:25 UTC
Commit: d934fb44bbcfd76158e8c076ba56b625369f5d41
Parent: e587cb4
5 files changed, +84 insertions, -27 deletions
@@ -186,21 +186,36 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
186 186 }
187 187 ui.add_space(theme::space::SM);
188 188
189 - let all_keys = [
190 - "C major", "C minor", "C# major", "C# minor", "D major", "D minor", "D# major", "D# minor",
191 - "E major", "E minor", "F major", "F minor", "F# major", "F# minor", "G major", "G minor",
192 - "G# major", "G# minor", "A major", "A minor", "A# major", "A# minor", "B major", "B minor",
189 + // Grouped Major | Minor, each wrapped, showing the compact note pill
190 + // ("C", "C#", …) while storing the full key — 24 stacked rows hid every
191 + // section below it and made hunting for one key a linear scan (p-1,
192 + // matches the Classification section's grouping).
193 + let key_groups: &[(&str, &[&str])] = &[
194 + ("Major", &[
195 + "C major", "C# major", "D major", "D# major", "E major", "F major",
196 + "F# major", "G major", "G# major", "A major", "A# major", "B major",
197 + ]),
198 + ("Minor", &[
199 + "C minor", "C# minor", "D minor", "D# minor", "E minor", "F minor",
200 + "F# minor", "G minor", "G# minor", "A minor", "A# minor", "B minor",
201 + ]),
193 202 ];
194 - for key in &all_keys {
195 - let active = state.search_filter.keys.contains(&key.to_string());
196 - if ui.selectable_label(active, *key).clicked() {
197 - if active {
198 - state.search_filter.keys.retain(|k| k != *key);
199 - } else {
200 - state.search_filter.keys.push(key.to_string());
203 + for (label, keys) in key_groups {
204 + ui.label(egui::RichText::new(*label).small().color(theme::text_muted()));
205 + ui.horizontal_wrapped(|ui| {
206 + for key in *keys {
207 + let note = key.split(' ').next().unwrap_or(key);
208 + let active = state.search_filter.keys.contains(&key.to_string());
209 + if ui.selectable_label(active, note).clicked() {
210 + if active {
211 + state.search_filter.keys.retain(|k| k != *key);
212 + } else {
213 + state.search_filter.keys.push(key.to_string());
214 + }
215 + changed = true;
216 + }
201 217 }
202 - changed = true;
203 - }
218 + });
204 219 }
205 220 });
206 221
@@ -13,6 +13,19 @@ const STATUS_FADE_AFTER: Duration = Duration::from_secs(5);
13 13 /// Status message hide threshold — after this, the status disappears entirely.
14 14 const STATUS_HIDE_AFTER: Duration = Duration::from_secs(30);
15 15
16 + /// Heuristic: does this status line report a failure? Error statuses are kept
17 + /// visible (never auto-hidden) and rendered in `accent_red`, since a silently
18 + /// expiring error is the one message the user most needs to catch.
19 + fn is_error_status(s: &str) -> bool {
20 + let l = s.to_ascii_lowercase();
21 + l.starts_with("failed")
22 + || l.contains("error")
23 + || l.starts_with("could not")
24 + || l.starts_with("couldn't")
25 + || l.starts_with("cannot")
26 + || l.starts_with("can't")
27 + }
28 +
16 29 /// Render a middle-dot section separator. Standardises the footer's
17 30 /// inter-section breaks on `\u{00B7}` (p-2) so the row reads as one
18 31 /// horizontal scan rather than a mix of vertical bars and dots.
@@ -169,23 +182,31 @@ pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserSt
169 182 .status_set_at
170 183 .map(|t| t.elapsed())
171 184 .unwrap_or_default();
172 - if elapsed < STATUS_HIDE_AFTER {
185 + // Errors stay up until replaced; informational/success messages
186 + // fade after 5s and hide after 30s.
187 + let is_error = is_error_status(&state.status);
188 + if is_error || elapsed < STATUS_HIDE_AFTER {
173 189 dot(ui);
174 - let color = if elapsed >= STATUS_FADE_AFTER {
190 + let color = if is_error {
191 + theme::accent_red()
192 + } else if elapsed >= STATUS_FADE_AFTER {
175 193 theme::text_muted()
176 194 } else {
177 195 theme::text_secondary()
178 196 };
179 197 ui.label(egui::RichText::new(&state.status).color(color));
180 198
181 - // Request a repaint at the next state transition so the fade
182 - // and hide land on time even when the UI is otherwise idle.
183 - let next_threshold = if elapsed < STATUS_FADE_AFTER {
184 - STATUS_FADE_AFTER - elapsed
185 - } else {
186 - STATUS_HIDE_AFTER - elapsed
187 - };
188 - ui.ctx().request_repaint_after(next_threshold);
199 + // Request a repaint at the next fade/hide transition so they
200 + // land on time even when the UI is idle. Errors don't expire,
201 + // so they need no scheduled repaint.
202 + if !is_error {
203 + let next_threshold = if elapsed < STATUS_FADE_AFTER {
204 + STATUS_FADE_AFTER - elapsed
205 + } else {
206 + STATUS_HIDE_AFTER - elapsed
207 + };
208 + ui.ctx().request_repaint_after(next_threshold);
209 + }
189 210 }
190 211 } else if state.show_first_launch_hint {
191 212 // Clear stamp once the message has gone away so the next post
@@ -197,6 +197,17 @@ fn draw_mode_controls(ui: &mut egui::Ui, state: &mut BrowserState) {
197 197 ui.checkbox(&mut state.instrument_locked, "Lock sample")
198 198 .on_hover_text("Keep the current sample loaded as the table selection changes");
199 199 });
200 +
201 + // Surface the Multi-sample precondition inline (not hover-only) while in
202 + // Chromatic mode, so the permanently-disabled radio doesn't read as a
203 + // dead-end. The mode flips automatically once a second sample is dropped.
204 + if state.shared.instrument.lock().config.mode == InstrumentMode::Chromatic {
205 + ui.label(
206 + egui::RichText::new("Multi-sample activates after you drop a second sample onto the keyboard.")
207 + .small()
208 + .color(theme::text_muted()),
209 + );
210 + }
200 211 }
201 212
202 213 /// Draw a 3-octave piano keyboard with click-to-play, active voice highlighting, and zone overlays.
@@ -388,7 +388,9 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
388 388 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
389 389 commit = true;
390 390 }
391 - if ui.button("Cancel").clicked() {
391 + if ui.button("Cancel").clicked()
392 + || ui.input(|i| i.key_pressed(egui::Key::Escape))
393 + {
392 394 state.collection_rename_target = None;
393 395 return;
394 396 }
@@ -426,7 +428,9 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
426 428 state.collection_create_input.clear();
427 429 state.show_collection_create = false;
428 430 }
429 - if ui.button("Cancel").clicked() {
431 + if ui.button("Cancel").clicked()
432 + || ui.input(|i| i.key_pressed(egui::Key::Escape))
433 + {
430 434 state.collection_create_input.clear();
431 435 state.show_collection_create = false;
432 436 }
@@ -504,7 +508,9 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
504 508 cancel = true;
505 509 }
506 510 }
507 - if ui.button("Cancel").clicked() {
511 + if ui.button("Cancel").clicked()
512 + || ui.input(|i| i.key_pressed(egui::Key::Escape))
513 + {
508 514 cancel = true;
509 515 }
510 516 if ui.button("Rename").clicked() {
@@ -244,7 +244,11 @@ pub fn piano_black_key() -> Color32 {
244 244 /// regions that will be removed. Painted on top of the rendered waveform; the
245 245 /// underlying peaks stay partly visible so the user retains spatial reference.
246 246 pub fn trim_mute_overlay() -> Color32 {
247 - Color32::from_rgba_premultiplied(0, 0, 0, 160)
247 + // Dim toward the theme's own background rather than hardcoded black, so the
248 + // trimmed-region wash reads correctly on light themes too (a black overlay
249 + // assumes a dark base).
250 + let bg = THEME.read().bg_primary;
251 + Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 160)
248 252 }
249 253
250 254 // --- Theme discovery ---