max / audiofiles
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 --- |