Skip to main content

max / audiofiles

UX audit: panel persistence, sidebar elision, filter/subscribe polish Panel & persistence Majors from the ux-audit pass: - Persist shell layout: sidebar/detail/filter-panel visibility now save on toggle (set_config) and restore in the BrowserState ctor; current vault persists by id (not index) and the initial contents load for it. Routed all toggle sites through toggle_sidebar/detail/filter_panel helpers. - Sidebar long names: VFS/collection/tag rows truncate with an ellipsis (TextWrapMode::Truncate) instead of widening the panel, and show the full name on hover, via the shared selectable_* helpers. - Subscribe view: one cap slider feeding annual/monthly priced buttons (was two sliders secretly sharing a value); spinner + "Opening browser..." while the checkout round-trips. - Filter ranges: debounce the search query to drag-stop/blur instead of firing on every DragValue tick; the value still updates live. 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:10 UTC
Commit: 59017a6f65f773c48bf87cbd88f74a387d50dd55
Parent: f0fa1ab
7 files changed, +169 insertions, -58 deletions
@@ -299,7 +299,7 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
299 299 // Tab: focus the detail-panel tag input (opens the detail panel if hidden).
300 300 if input.key_pressed(egui::Key::Tab) && !input.modifiers.shift {
301 301 if !state.detail_visible {
302 - state.detail_visible = true;
302 + state.set_detail_visible(true);
303 303 }
304 304 state.focus_tag_input = true;
305 305 return;
@@ -393,11 +393,11 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
393 393 }
394 394 // "S" toggles sidebar
395 395 if input.key_pressed(egui::Key::S) {
396 - state.sidebar_visible = !state.sidebar_visible;
396 + state.toggle_sidebar();
397 397 }
398 398 // "D" toggles detail panel
399 399 if input.key_pressed(egui::Key::D) && !shift {
400 - state.detail_visible = !state.detail_visible;
400 + state.toggle_detail();
401 401 }
402 402 // Shift+F: find similar
403 403 if shift && input.key_pressed(egui::Key::F) {
@@ -346,7 +346,17 @@ impl BrowserState {
346 346 vfs_list = backend.list_vfs()?;
347 347 }
348 348
349 - let contents = backend.list_children_enriched(vfs_list[0].id, None)
349 + // Restore the last-selected vault by id (indices shift as vaults are
350 + // added/removed), so the initial contents below load for the right vault.
351 + let current_vfs_idx = backend
352 + .get_config("current_vfs_id")
353 + .ok()
354 + .flatten()
355 + .and_then(|s| s.parse::<i64>().ok())
356 + .and_then(|id| vfs_list.iter().position(|v| v.id.as_i64() == id))
357 + .unwrap_or(0);
358 +
359 + let contents = backend.list_children_enriched(vfs_list[current_vfs_idx].id, None)
350 360 .unwrap_or_else(|e| { error!("Failed to load initial contents: {e}"); Vec::new() });
351 361 let all_tags = backend.list_all_tags()
352 362 .unwrap_or_else(|e| { warn!("Failed to load tags: {e}"); Vec::new() });
@@ -403,11 +413,20 @@ impl BrowserState {
403 413 .join("audiofiles")
404 414 });
405 415
416 + // Restore shell layout state (persisted on toggle). Sidebar/detail
417 + // default to shown, filter panel to closed.
418 + let sidebar_visible =
419 + backend.get_config("sidebar_visible").ok().flatten().as_deref() != Some("0");
420 + let detail_visible =
421 + backend.get_config("detail_visible").ok().flatten().as_deref() != Some("0");
422 + let filter_panel_open =
423 + backend.get_config("filter_panel_open").ok().flatten().as_deref() == Some("1");
424 +
406 425 Ok(Self {
407 426 data_dir: data_dir.to_path_buf(),
408 427 backend,
409 428 vfs_list: Arc::new(vfs_list),
410 - current_vfs_idx: 0,
429 + current_vfs_idx,
411 430 current_dir: None,
412 431 breadcrumb: Vec::new(),
413 432 contents: Arc::new(contents),
@@ -418,13 +437,13 @@ impl BrowserState {
418 437 selected_analysis: None,
419 438 selected_waveform: None,
420 439 tag_input: String::new(),
421 - detail_visible: true,
422 - sidebar_visible: true,
440 + detail_visible,
441 + sidebar_visible,
423 442 sort_column: SortColumn::Name,
424 443 sort_direction: SortDirection::Ascending,
425 444 search_query: String::new(),
426 445 search_filter: SearchFilter::default(),
427 - filter_panel_open: false,
446 + filter_panel_open,
428 447 collection_filter_name_input: String::new(),
429 448 filter_tag_input: String::new(),
430 449 similarity_search_hash: None,
@@ -249,10 +249,50 @@ impl BrowserState {
249 249 self.breadcrumb.clear();
250 250 self.selection.clear();
251 251 self.similarity_search_hash = None;
252 - self.similarity_source_name = None;
252 + self.similarity_source_name = None;
253 + // Persist the selection by VFS id (not index — indices shift when
254 + // vaults are added or removed) so it restores on next launch.
255 + let _ = self.backend.set_config(
256 + "current_vfs_id",
257 + &self.vfs_list[self.current_vfs_idx].id.as_i64().to_string(),
258 + );
253 259 self.refresh_contents();
254 260 self.refresh_collections();
255 261 self.status = format!("Switched to: {}", self.vfs_list[self.current_vfs_idx].name);
256 262 }
257 263 }
264 +
265 + /// Toggle the left sidebar and persist the choice across restarts.
266 + pub fn toggle_sidebar(&mut self) {
267 + self.sidebar_visible = !self.sidebar_visible;
268 + let _ = self.backend.set_config(
269 + "sidebar_visible",
270 + if self.sidebar_visible { "1" } else { "0" },
271 + );
272 + }
273 +
274 + /// Toggle the right detail panel and persist the choice across restarts.
275 + pub fn toggle_detail(&mut self) {
276 + self.set_detail_visible(!self.detail_visible);
277 + }
278 +
279 + /// Set detail-panel visibility and persist it (used by both the explicit
280 + /// toggle and the auto-show on selection so the stored state never drifts).
281 + pub fn set_detail_visible(&mut self, visible: bool) {
282 + if self.detail_visible != visible {
283 + self.detail_visible = visible;
284 + let _ = self
285 + .backend
286 + .set_config("detail_visible", if visible { "1" } else { "0" });
287 + }
288 + }
289 +
290 + /// Toggle the filter panel and persist the choice across restarts.
291 + pub fn toggle_filter_panel(&mut self) {
292 + self.filter_panel_open = !self.filter_panel_open;
293 + let _ = self.backend.set_config(
294 + "filter_panel_open",
295 + if self.filter_panel_open { "1" } else { "0" },
296 + );
297 + }
258 298 }
@@ -6,6 +6,15 @@ use crate::state::BrowserState;
6 6 use super::theme;
7 7 use super::widgets;
8 8
9 + /// Debounce numeric range filters: re-run the search only when a DragValue
10 + /// drag finishes or the field loses keyboard focus, not on every intermediate
11 + /// tick. The filter value still updates live on `.changed()` (so the widget
12 + /// shows the new number); only the (potentially heavy) query is deferred to
13 + /// the settle point.
14 + fn requery_now(resp: &egui::Response) -> bool {
15 + resp.drag_stopped() || resp.lost_focus()
16 + }
17 +
9 18 /// Render the per-section "[clear]" mini-button used by every active filter
10 19 /// section. Returns true on click. The wrapping `if active` lives at the call
11 20 /// site so each section's clear semantics stay local.
@@ -46,19 +55,21 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
46 55 let mut min = state.search_filter.bpm_min.unwrap_or(0.0);
47 56 let mut max = state.search_filter.bpm_max.unwrap_or(300.0);
48 57 ui.label("Min");
49 - if ui.add(egui::DragValue::new(&mut min).speed(1.0).range(0.0..=300.0)).changed() {
58 + let r = ui.add(egui::DragValue::new(&mut min).speed(1.0).range(0.0..=300.0));
59 + if r.changed() {
50 60 if min > max { max = min; }
51 61 state.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
52 62 state.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
53 - changed = true;
54 63 }
64 + if requery_now(&r) { changed = true; }
55 65 ui.label("Max");
56 - if ui.add(egui::DragValue::new(&mut max).speed(1.0).range(0.0..=300.0)).changed() {
66 + let r = ui.add(egui::DragValue::new(&mut max).speed(1.0).range(0.0..=300.0));
67 + if r.changed() {
57 68 if max < min { min = max; }
58 69 state.search_filter.bpm_max = if max < 300.0 { Some(max) } else { None };
59 70 state.search_filter.bpm_min = if min > 0.0 { Some(min) } else { None };
60 - changed = true;
61 71 }
72 + if requery_now(&r) { changed = true; }
62 73 });
63 74 });
64 75
@@ -73,19 +84,21 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
73 84 let mut min = state.search_filter.duration_min.unwrap_or(0.0);
74 85 let mut max = state.search_filter.duration_max.unwrap_or(600.0);
75 86 ui.label("Min");
76 - if ui.add(egui::DragValue::new(&mut min).speed(0.1).range(0.0..=600.0)).changed() {
87 + let r = ui.add(egui::DragValue::new(&mut min).speed(0.1).range(0.0..=600.0));
88 + if r.changed() {
77 89 if min > max { max = min; }
78 90 state.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
79 91 state.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
80 - changed = true;
81 92 }
93 + if requery_now(&r) { changed = true; }
82 94 ui.label("Max");
83 - if ui.add(egui::DragValue::new(&mut max).speed(0.1).range(0.0..=600.0)).changed() {
95 + let r = ui.add(egui::DragValue::new(&mut max).speed(0.1).range(0.0..=600.0));
96 + if r.changed() {
84 97 if max < min { min = max; }
85 98 state.search_filter.duration_max = if max < 600.0 { Some(max) } else { None };
86 99 state.search_filter.duration_min = if min > 0.0 { Some(min) } else { None };
87 - changed = true;
88 100 }
101 + if requery_now(&r) { changed = true; }
89 102 });
90 103 });
91 104
@@ -100,19 +113,21 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) {
100 113 let mut min = state.search_filter.peak_db_min.unwrap_or(-96.0);
101 114 let mut max = state.search_filter.peak_db_max.unwrap_or(0.0);
102 115 ui.label("Min");
103 - if ui.add(egui::DragValue::new(&mut min).speed(0.5).range(-96.0..=0.0).suffix(" dB")).changed() {
116 + let r = ui.add(egui::DragValue::new(&mut min).speed(0.5).range(-96.0..=0.0).suffix(" dB"));
117 + if r.changed() {
104 118 if min > max { max = min; }
105 119 state.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
106 120 state.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
107 - changed = true;
108 121 }
122 + if requery_now(&r) { changed = true; }
109 123 ui.label("Max");
110 - if ui.add(egui::DragValue::new(&mut max).speed(0.5).range(-96.0..=0.0).suffix(" dB")).changed() {
124 + let r = ui.add(egui::DragValue::new(&mut max).speed(0.5).range(-96.0..=0.0).suffix(" dB"));
125 + if r.changed() {
111 126 if max < min { min = max; }
112 127 state.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None };
113 128 state.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None };
114 - changed = true;
115 129 }
130 + if requery_now(&r) { changed = true; }
116 131 });
117 132 });
118 133
@@ -247,28 +247,32 @@ fn draw_subscription_section(
247 247 );
248 248 ui.add_space(theme::space::SM);
249 249
250 - if let Some(cap) = draw_cap_picker(
251 - ui,
252 - state,
253 - &pricing,
254 - BillingInterval::Annual,
255 - "Subscribe (annual)",
256 - ) {
257 - state.sync.checkout_loading = true;
258 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
259 - sync.subscribe(cap, BillingInterval::Annual);
260 - }
261 - ui.add_space(theme::space::XS);
262 - if let Some(cap) = draw_cap_picker(
263 - ui,
264 - state,
265 - &pricing,
266 - BillingInterval::Monthly,
267 - "Subscribe (monthly)",
268 - ) {
269 - state.sync.checkout_loading = true;
270 - state.sync.checkout_loading_at = Some(std::time::Instant::now());
271 - sync.subscribe(cap, BillingInterval::Monthly);
250 + // One cap slider, then annual/monthly checkout buttons for that
251 + // single chosen cap — two priced choices, not two sliders that
252 + // secretly share a value.
253 + let cap_bytes = draw_cap_slider(ui, state, &pricing);
254 + ui.add_space(theme::space::SM);
255 +
256 + if state.sync.checkout_loading {
257 + ui.horizontal(|ui| {
258 + ui.spinner();
259 + ui.label(egui::RichText::new("Opening browser...").weak());
260 + });
261 + } else {
262 + let annual = format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Annual));
263 + let monthly = format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Monthly));
264 + ui.horizontal(|ui| {
265 + if widgets::primary_button(ui, &format!("Subscribe annual \u{2014} {annual}/yr")).clicked() {
266 + state.sync.checkout_loading = true;
267 + state.sync.checkout_loading_at = Some(std::time::Instant::now());
268 + sync.subscribe(cap_bytes, BillingInterval::Annual);
269 + }
270 + if widgets::secondary_button(ui, &format!("Monthly \u{2014} {monthly}/mo")).clicked() {
271 + state.sync.checkout_loading = true;
272 + state.sync.checkout_loading_at = Some(std::time::Instant::now());
273 + sync.subscribe(cap_bytes, BillingInterval::Monthly);
274 + }
275 + });
272 276 }
273 277 } else {
274 278 ui.label(egui::RichText::new("Loading pricing...").weak());
@@ -659,6 +663,23 @@ fn draw_ready(
659 663 }
660 664 }
661 665
666 + /// Draw just the storage-cap slider (GiB, logarithmic) plus a cap-size label,
667 + /// clamping the working value to the pricing range. Returns the chosen cap in
668 + /// bytes. Used by the subscribe view (one slider feeding two checkout buttons).
669 + fn draw_cap_slider(ui: &mut egui::Ui, state: &mut BrowserState, pricing: &AppPricing) -> i64 {
670 + let min_gib = (pricing.min_cap_bytes / GIB).max(1);
671 + let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib);
672 + state.sync.cap_picker_gib = state.sync.cap_picker_gib.clamp(min_gib, max_gib);
673 + ui.add(
674 + egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib)
675 + .logarithmic(true)
676 + .text("GiB"),
677 + );
678 + let cap_bytes = state.sync.cap_picker_gib * GIB;
679 + ui.label(egui::RichText::new(format_cap(cap_bytes)).strong());
680 + cap_bytes
681 + }
682 +
662 683 /// Cap-picker widget: slider in GiB + live price preview + action button.
663 684 /// Used both for initial subscribe and for queuing a cap change on an active
664 685 /// subscription. The slider's working value lives on `BrowserState::sync` so
@@ -138,7 +138,7 @@ fn draw_inline_panel_toggles(
138 138 detail_hidden: bool,
139 139 ) {
140 140 if widgets::toolbar_toggle(ui, "Sidebar", state.sidebar_visible, "Toggle sidebar (S)", None) {
141 - state.sidebar_visible = !state.sidebar_visible;
141 + state.toggle_sidebar();
142 142 }
143 143
144 144 // M-13: the Detail toggle conveys "active but hidden" via a muted colour
@@ -161,7 +161,7 @@ fn draw_inline_panel_toggles(
161 161 .on_hover_text(detail_tooltip)
162 162 .clicked()
163 163 {
164 - state.detail_visible = !state.detail_visible;
164 + state.toggle_detail();
165 165 }
166 166
167 167 if widgets::toolbar_toggle(ui, "Edit", state.edit.show_window, "Toggle sample editor (E)", None) {
@@ -184,7 +184,7 @@ fn draw_inline_panel_toggles(
184 184 "Toggle filter panel".to_string()
185 185 };
186 186 if widgets::toolbar_toggle(ui, "Filters", state.filter_panel_open, &hover, show_count.then_some(filter_count)) {
187 - state.filter_panel_open = !state.filter_panel_open;
187 + state.toggle_filter_panel();
188 188 }
189 189 }
190 190
@@ -205,7 +205,7 @@ fn draw_view_menu(ui: &mut egui::Ui, state: &mut BrowserState, detail_hidden: bo
205 205 .button(format!("{}Sidebar (S)", active_dot(state.sidebar_visible)))
206 206 .clicked()
207 207 {
208 - state.sidebar_visible = !state.sidebar_visible;
208 + state.toggle_sidebar();
209 209 ui.close();
210 210 }
211 211 let detail_label = if detail_hidden {
@@ -214,7 +214,7 @@ fn draw_view_menu(ui: &mut egui::Ui, state: &mut BrowserState, detail_hidden: bo
214 214 format!("{}Detail (D)", active_dot(state.detail_visible))
215 215 };
216 216 if ui.button(detail_label).clicked() {
217 - state.detail_visible = !state.detail_visible;
217 + state.toggle_detail();
218 218 ui.close();
219 219 }
220 220 if ui
@@ -248,7 +248,7 @@ fn draw_view_menu(ui: &mut egui::Ui, state: &mut BrowserState, detail_hidden: bo
248 248 format!("{}Filters", active_dot(state.filter_panel_open))
249 249 };
250 250 if ui.button(filters_label).clicked() {
251 - state.filter_panel_open = !state.filter_panel_open;
251 + state.toggle_filter_panel();
252 252 ui.close();
253 253 }
254 254 });
@@ -432,6 +432,22 @@ pub fn filter_section<R>(
432 432
433 433 // --- Selectable rows ---------------------------------------------------------
434 434
435 + /// Render a selectable label that truncates with an ellipsis instead of
436 + /// expanding the row (which would force the sidebar wider than its clip range),
437 + /// and shows the full `full_text` on hover so a clipped name is still readable.
438 + fn selectable_truncating(
439 + ui: &mut egui::Ui,
440 + active: bool,
441 + rich: egui::RichText,
442 + full_text: &str,
443 + ) -> egui::Response {
444 + let prev = ui.style().wrap_mode;
445 + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
446 + let resp = ui.selectable_label(active, rich);
447 + ui.style_mut().wrap_mode = prev;
448 + resp.on_hover_text(full_text)
449 + }
450 +
435 451 /// Primary selectable list row.
436 452 ///
437 453 /// Active state renders the label as `strong()` + `accent_blue`. Inactive state
@@ -440,11 +456,11 @@ pub fn filter_section<R>(
440 456 pub fn selectable_row(ui: &mut egui::Ui, active: bool, label: impl Into<String>) -> egui::Response {
441 457 let text = label.into();
442 458 let rich = if active {
443 - egui::RichText::new(text).strong().color(theme::accent_blue())
459 + egui::RichText::new(text.clone()).strong().color(theme::accent_blue())
444 460 } else {
445 - egui::RichText::new(text).color(theme::text_primary())
461 + egui::RichText::new(text.clone()).color(theme::text_primary())
446 462 };
447 - ui.selectable_label(active, rich)
463 + selectable_truncating(ui, active, rich, &text)
448 464 }
449 465
450 466 /// Secondary selectable list row.
@@ -459,11 +475,11 @@ pub fn selectable_row_secondary(
459 475 ) -> egui::Response {
460 476 let text = label.into();
461 477 let rich = if active {
462 - egui::RichText::new(text).strong().color(theme::accent_blue())
478 + egui::RichText::new(text.clone()).strong().color(theme::accent_blue())
463 479 } else {
464 - egui::RichText::new(text).color(theme::text_secondary())
480 + egui::RichText::new(text.clone()).color(theme::text_secondary())
465 481 };
466 - ui.selectable_label(active, rich)
482 + selectable_truncating(ui, active, rich, &text)
467 483 }
468 484
469 485 /// Tag-tree row.
@@ -474,11 +490,11 @@ pub fn selectable_row_secondary(
474 490 pub fn selectable_tag(ui: &mut egui::Ui, active: bool, label: impl Into<String>) -> egui::Response {
475 491 let text = label.into();
476 492 let rich = if active {
477 - egui::RichText::new(text).color(theme::accent_blue())
493 + egui::RichText::new(text.clone()).color(theme::accent_blue())
478 494 } else {
479 - egui::RichText::new(text).color(theme::text_secondary())
495 + egui::RichText::new(text.clone()).color(theme::text_secondary())
480 496 };
481 - ui.selectable_label(active, rich)
497 + selectable_truncating(ui, active, rich, &text)
482 498 }
483 499
484 500 /// Render a wizard step indicator: a horizontal row of step labels with the