Skip to main content

max / audiofiles

Flip the main window to the described screen The app's window is `quasi::shell` now. `ui/toolbar.rs` (748), `ui/sidebar.rs` (724), `ui/file_list.rs` (796), `ui/footer.rs` (403) and `ui/layout_strip.rs` (62) are gone: five `egui::Panel` calls into five modules, each of which knew where it went, become one `Screen` that says where its regions go. What went with them, measured during the port: the toolbar's twenty-line `trailing_width` round-trip through egui memory, four hard-coded pixel breakpoints (1000 for the footer's second row, 900 and 700 in the toolbar), and the footer's fade timer -- `status_set_at`, two constants, an `egui::Id` round-trip and a `request_repaint_after`. All renderer policy the description states instead. The toolbar says it is a row. Its members were going into the band's body, which is a column, and quasi-immediate drew them one per line as soon as it started reading runs at all; `across(Fallback::Shed)` plus a priority per member is what the header always claimed the toolbar did, and the panel toggles' `Panel::worth` is what the shed reads. The left column follows the toggles it already describes: the filter panel takes the sidebar's place while it is open, and the sidebar is a toggle, so `shell::screen` reads `Panel::Filters` and `Panel::Sidebar` rather than the host reading `sidebar_visible` a second time to decide the same thing. The filter and detail panels stay the app's arrangement -- both are described screens of their own, and both are placed by the window around this one. Two functions outlived their modules and were never those modules': which sample the editor opens on is `BrowserState::toggle_edit_window`, and whether a status line reports a failure is `state::is_error_status`. The described preview windows for `/` and `/files` go too. They existed to be compared against the shipped panels by looking at them, and there is nothing left to compare against. `the_file_list_offers_what_the_shipped_one_offers` is replaced rather than deleted: a parity test outlives the thing it proved parity with by exactly nothing, and what is worth keeping is that `/` is still the list with a toolbar above it and a sidebar beside it. Three test walkers learned to read `Slot::run`, which is the same silent drop this flip found in the renderer.
Author: Max Johnson <me@maxj.phd> · 2026-08-23 13:40 UTC
Signed with PGP, not checked
Commit: 49b74294cf1ece10337a862e0c63c0fed7d233b0
Parent: a402c7f
16 files changed, +318 insertions, -2668 deletions
M Cargo.lock +4 -4
@@ -7566,10 +7566,6 @@
7566 7566 name = "painhours"
7567 7567 version = "0.1.0"
7568 7568
7569 - [[patch.unused]]
7570 - name = "quasi-type"
7571 - version = "0.1.0"
7572 -
7573 7569 [[patch.unused]]
7574 7570 name = "quasi-axum"
7575 7571 version = "0.56.0"
@@ -7597,3 +7593,7 @@
7597 7593 [[patch.unused]]
7598 7594 name = "quasi-webview"
7599 7595 version = "0.56.0"
7596 +
7597 + [[patch.unused]]
7598 + name = "quasi-type"
7599 + version = "0.1.0"
@@ -3,10 +3,7 @@
3 3 use egui;
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 - use crate::ui::{
7 - export_screens, file_list, footer, instrument_panel, layout_strip, overlays, sidebar, theme,
8 - toolbar,
9 - };
6 + use crate::ui::{export_screens, instrument_panel, overlays, theme};
10 7 use audiofiles_core::vfs::NodeType;
11 8
12 9 /// Top-level draw function called each frame from the update closure.
@@ -152,28 +149,6 @@
152 149 // has neither this call nor the module it reaches.
153 150 #[cfg(feature = "quasi")]
154 151 crate::quasi::panel::draw_settings(ctx, state);
155 - // The described file list has no shipped window to share a toggle with
156 - // -- the real one is the main pane -- so Settings opens it too, which is
157 - // the only place a feature-gated affordance can go without adding one to
158 - // the shipped chrome.
159 - #[cfg(feature = "quasi")]
160 - {
161 - state.described.show_files = true;
162 - // And the main window, which is the one described screen that is
163 - // more than a single region: the file list with the status band
164 - // under it.
165 - state.described.show_shell = true;
166 - }
167 - }
168 -
169 - #[cfg(feature = "quasi")]
170 - if state.described.show_files {
171 - crate::quasi::panel::draw_files(ctx, state, sync_manager);
172 - }
173 -
174 - #[cfg(feature = "quasi")]
175 - if state.described.show_shell {
176 - crate::quasi::panel::draw_shell(ctx, state);
177 152 }
178 153
179 154 // The described export flow, beside whichever of the shipped export screens
@@ -213,7 +188,24 @@
213 188 }
214 189 }
215 190
216 - /// Draw the main browser layout: toolbar, footer, sidebar, detail panel, and file list.
191 + /// Draw the main browser layout: the described window, and the two panels the
192 + /// app arranges around it.
193 + ///
194 + /// **The window is a description now.** The toolbar above the list, the sidebar
195 + /// beside it and the status band under it were five `egui::Panel` calls into
196 + /// five modules, 2,733 lines, each of which knew where it went; they are one
197 + /// `Screen` that says so. What went with them, measured during the port: the
198 + /// toolbar's `trailing_width` round-trip through egui memory, four hard-coded
199 + /// pixel breakpoints (1000 for the footer's second row, 900 and 700 in the
200 + /// toolbar), and the footer's fade timer -- `status_set_at`, two constants, an
201 + /// `egui::Id` round-trip and a `request_repaint_after`. All of it renderer
202 + /// policy the description states instead.
203 + ///
204 + /// The two panels that remain are the app's arrangement rather than the
205 + /// screen's, which is why they are here and not in `shell::screen`: both hold a
206 + /// described screen of their own (`/filters`, `/detail`) and both are placed by
207 + /// the window around it. `shell::screen` reads the same toggles, so the filter
208 + /// panel takes the sidebar's place there and the sidebar is not drawn twice.
217 209 fn draw_normal_browser(
218 210 ui: &mut egui::Ui,
219 211 state: &mut BrowserState,
@@ -221,32 +213,12 @@
221 213 ) {
222 214 let ctx = ui.ctx().clone();
223 215
224 - // Top toolbar (breadcrumb + search)
225 - egui::Panel::top("toolbar").exact_size(56.0).show(ui, |ui| {
226 - toolbar::draw_toolbar(ui, state, sync_manager);
227 - });
228 -
229 - // Bottom footer
230 - egui::Panel::bottom("footer").show(ui, |ui| {
231 - let ctx = ui.ctx().clone();
232 - footer::draw_footer(ui, &ctx, state);
233 - });
234 -
235 - // Blob-layout migration strip. Declared after the footer so it stacks *above*
236 - // it, and only while a migration is actually running, so the app has no extra
237 - // furniture in the normal case.
238 - if state.layout_migration.is_some() {
239 - egui::Panel::bottom("layout_migration_strip").show(ui, |ui| {
240 - layout_strip::draw_layout_strip(ui, state);
241 - });
242 - }
243 -
244 216 // Floating MIDI/instrument window
245 217 if state.preview.show_midi_window {
246 218 instrument_panel::draw_midi_window(&ctx, state);
247 219 }
248 220
249 - // Left sidebar (or filter panel)
221 + // The filter panel, in the sidebar's place while it is open.
250 222 if state.search.filter_panel_open {
251 223 egui::Panel::left("filter_panel")
252 224 .default_size(200.0)
@@ -256,13 +228,6 @@
256 228 crate::quasi::panel::draw_filters(ui, state);
257 229 });
258 230 });
259 - } else if state.sidebar_visible {
260 - egui::Panel::left("sidebar")
261 - .default_size(180.0)
262 - .size_range(120.0..=280.0)
263 - .show(ui, |ui| {
264 - sidebar::draw_sidebar(ui, state);
265 - });
266 231 }
267 232
268 233 // Right detail panel (auto-hide below 700px).
@@ -279,9 +244,9 @@
279 244 });
280 245 }
281 246
282 - // Central file list
247 + // The window itself.
283 248 egui::CentralPanel::default().show(ui, |ui| {
284 - file_list::draw_file_list(ui, state, sync_manager);
249 + crate::quasi::panel::draw_shell_inline(ui, state, sync_manager);
285 250 });
286 251 }
287 252
@@ -2243,7 +2243,7 @@
2243 2243 }
2244 2244 Some((
2245 2245 self.state.status.clone(),
2246 - if crate::ui::footer::is_error_status(&self.state.status) {
2246 + if crate::state::is_error_status(&self.state.status) {
2247 2247 Saying::Failed
2248 2248 } else {
2249 2249 Saying::Ordinary
@@ -49,7 +49,6 @@
49 49 pub struct Described {
50 50 settings: Option<Runtime>,
51 51 sync: Option<Runtime>,
52 - files: Option<Runtime>,
53 52 export: Option<Runtime>,
54 53 detail: Option<Runtime>,
55 54 shell: Option<Runtime>,
@@ -63,14 +62,6 @@
63 62 naming: Option<Runtime>,
64 63 bulk: Option<Runtime>,
65 64 preflight: Option<Runtime>,
66 - /// Whether the described main window is open.
67 - pub show_shell: bool,
68 - /// Whether the described file list is open.
69 - ///
70 - /// Its own flag rather than the shipped list's, because the shipped list is
71 - /// always showing: it is the app's main pane and not a window. So this is
72 - /// the one described screen with no toggle to share, and it gets its own.
73 - pub show_files: bool,
74 65 /// Whether a described screen's subject moved while it was showing.
75 66 ///
76 67 /// **Set by the frame that changed something and read by the next one.** An
@@ -162,33 +153,6 @@
162 153 }
163 154 }
164 155
165 - /// Draw the described file list, and act on whatever was pressed.
166 - pub fn draw_files(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) {
167 - let intents = RefCell::new(Vec::new());
168 - let mut runtime = state.described.files.take();
169 - let stale = state.described.stale;
170 - let host = Host {
171 - state,
172 - sync,
173 - themes: themes(),
174 - intents: &intents,
175 - };
176 - let closed = window(
177 - ctx,
178 - "Samples (described)",
179 - &mut runtime,
180 - &host,
181 - "/files",
182 - stale,
183 - );
184 - state.described.files = runtime;
185 - apply(ctx, state, sync, intents.into_inner());
186 - if closed {
187 - state.described.show_files = false;
188 - state.described.files = None;
189 - }
190 - }
191 -
192 156 /// Draw the described export flow, and act on whatever was pressed.
193 157 ///
194 158 /// **Refreshed unconditionally**, where the other three refresh only after an
@@ -243,37 +207,35 @@
243 207 apply(ui.ctx(), state, None, intents.into_inner());
244 208 }
245 209
246 - /// Draw the described main window, and act on whatever was pressed.
210 + /// Draw the main window: the described shell, in the room the app has.
247 211 ///
248 - /// **Refreshed unconditionally**, and it is the third window to need that for a
249 - /// third reason. Settings and Sync move when something described is pressed;
250 - /// the export flow moves because a worker is writing files; this one moves
251 - /// because a sample is playing. The transport's position advances at the sample
252 - /// clock with nobody touching anything, which is the case `shell`'s header calls
253 - /// the sharper consumer of the `Meter` finding.
254 - pub fn draw_shell(ctx: &egui::Context, state: &mut BrowserState) {
212 + /// The app's window **is** this screen since 2026-08-23. `ui/toolbar.rs`,
213 + /// `ui/sidebar.rs`, `ui/file_list.rs`, `ui/footer.rs` and `ui/layout_strip.rs`
214 + /// were the five modules that used to draw it and are gone; what arranges the
215 + /// toolbar above the list and the status band under it is `shell::screen`,
216 + /// which says so, rather than five `egui::Panel` calls that each knew where
217 + /// they went.
218 + ///
219 + /// Inline rather than in a window, which is the whole difference: [`window`]
220 + /// puts a frame around a described screen and [`inline`] hands it the room it
221 + /// was given. The screen is the same one `/` has always answered.
222 + ///
223 + /// Refreshed unconditionally, and this window's reason is the sharpest of the
224 + /// four that need it: the transport advances at the sample clock with nobody
225 + /// touching anything, so a screen described once per press would show a
226 + /// position that had stopped being true.
227 + pub fn draw_shell_inline(ui: &mut egui::Ui, state: &mut BrowserState, sync: Option<&SyncManager>) {
255 228 let intents = RefCell::new(Vec::new());
256 229 let mut runtime = state.described.shell.take();
257 230 let host = Host {
258 231 state,
259 - sync: None,
232 + sync,
260 233 themes: themes(),
261 234 intents: &intents,
262 235 };
263 - let closed = window(
264 - ctx,
265 - "audiofiles (described)",
266 - &mut runtime,
267 - &host,
268 - "/",
269 - true,
270 - );
236 + inline(ui, &mut runtime, &host, "/", true);
271 237 state.described.shell = runtime;
272 - apply(ctx, state, None, intents.into_inner());
273 - if closed {
274 - state.described.show_shell = false;
275 - state.described.shell = None;
276 - }
238 + apply(ui.ctx(), state, sync, intents.into_inner());
277 239 }
278 240
279 241 /// Draw the described sample editor, and act on whatever was pressed.
@@ -834,7 +796,7 @@
834 796 Intent::TogglePanel(panel) => match panel {
835 797 super::Panel::Sidebar => state.toggle_sidebar(),
836 798 super::Panel::Detail => state.toggle_detail(),
837 - super::Panel::Edit => crate::ui::toolbar::toggle_edit_window(state),
799 + super::Panel::Edit => state.toggle_edit_window(),
838 800 super::Panel::Instrument => {
839 801 state.preview.show_midi_window = !state.preview.show_midi_window;
840 802 }
@@ -190,6 +190,15 @@
190 190 pub(super) fn described(screen: &Screen) -> Offering {
191 191 let mut out = Offering::default();
192 192 for slot in &screen.slots {
193 + // The row a region says its members share, then its body. Both, because
194 + // a control is as much of an offer for sitting in a toolbar's row as in
195 + // a pane's column -- and a walk that read only the body would have gone
196 + // quiet about the whole toolbar the day it said `across`.
197 + if let Some(run) = slot.run.as_ref() {
198 + for placed in &run.members {
199 + walk(&placed.node, &mut out);
200 + }
201 + }
193 202 for placed in &slot.body {
194 203 walk(&placed.node, &mut out);
195 204 }
@@ -749,25 +758,39 @@
749 758 }
750 759
751 760 #[test]
752 - fn the_file_list_offers_what_the_shipped_one_offers() {
753 - let (mut state, _dir) = fixture();
761 + fn the_window_is_the_list_and_the_two_bands_around_it() {
762 + // What replaced `the_file_list_offers_what_the_shipped_one_offers`. That
763 + // test compared the described list against `ui/file_list.rs`, which the
764 + // flip deleted along with the toolbar, the sidebar, the footer and the
765 + // migration strip; a parity test outlives the thing it was proving parity
766 + // with by exactly nothing.
767 + //
768 + // What is worth keeping is the composition. `/` is three modules' worth of
769 + // window in one screen, and the way it could quietly stop being that is a
770 + // region going missing rather than a control changing shape.
771 + let (state, _dir) = fixture();
772 + let window = super::panel::described_screen(&state, "/");
754 773
755 - let described = described(&super::panel::described_screen(&state, "/files"));
756 - let shipped = shipped(|ui| {
757 - crate::ui::file_list::draw_file_list(ui, &mut state, None);
758 - });
774 + let offering = described(&window);
775 + offering.addresses_resolve();
759 776
760 - described.addresses_resolve();
761 - // The one difference the flip introduces, and it is not settled: the
762 - // shipped heading is "Dur" because the column is fixed-width and narrow,
763 - // and the description says "Duration" because `Column::name` is both the
764 - // heading and the key a cell is addressed by, so the abbreviation and the
765 - // sort key cannot come apart. Filed against audiofiles rather than decided
766 - // here. Whichever way it goes, one of these two lines goes with it.
767 - Parity::strict()
768 - .dropping("Dur")
769 - .gaining("Duration")
770 - .assert(&described, &shipped);
777 + let offers = offering.show();
778 + // The toolbar's row, which reaches the walk only through `Slot::run`.
779 + assert!(
780 + offers.contains("Import"),
781 + "no toolbar in the window: {offers}"
782 + );
783 + // The list.
784 + assert!(
785 + offers.contains("kick.wav"),
786 + "no list in the window: {offers}"
787 + );
788 + // The sidebar, which is a region of this screen while the filter panel is
789 + // shut and the sidebar toggle is on -- both true in a fresh fixture.
790 + assert!(
791 + offers.contains("Vault"),
792 + "no sidebar in the window: {offers}"
793 + );
771 794 }
772 795
773 796 #[test]
@@ -111,7 +111,7 @@
111 111 Slot, Tag,
112 112 };
113 113
114 - use super::{Panels, Playing, Saying};
114 + use super::{Panel, Panels, Playing, Saying};
115 115
116 116 /// The band under the list.
117 117 const FOOT: &str = "shell-foot";
@@ -164,10 +164,27 @@
164 164 /// applying a tag filter, opening a collection — so the answer is the window
165 165 /// rather than the corner of it that was pressed.
166 166 pub(super) fn screen(state: &Panels<'_>) -> Screen {
167 - let screen = Screen::sidebar_content("audiofiles")
168 - .with(super::toolbar::body(state))
169 - .with(super::library::body(state))
170 - .with(super::files::body(state));
167 + let screen = Screen::sidebar_content("audiofiles").with(super::toolbar::body(state));
168 +
169 + // The left column is one of two things and sometimes neither, which is the
170 + // shipped window's own arrangement rather than a new one: the filter panel
171 + // takes the sidebar's place while it is open, and the sidebar itself is a
172 + // toggle. Said here because the toggles are already described -- `Panel`
173 + // carries both facts and the toolbar draws both chips -- so a host reading
174 + // `sidebar_visible` a second time to decide the layout would be the second
175 + // answer this layer exists to remove.
176 + //
177 + // The filter panel is a screen of its own (`/filters`) and stays one. It is
178 + // the host that puts it where the sidebar was, the same way it puts the
179 + // detail pane on the right: both are regions the window arranges, and
180 + // neither is a region of this screen.
181 + let showing = state.bar.showing();
182 + let screen = if showing.contains(&Panel::Filters) || !showing.contains(&Panel::Sidebar) {
183 + screen
184 + } else {
185 + screen.with(super::library::body(state))
186 + };
187 + let screen = screen.with(super::files::body(state));
171 188
172 189 // Above the footer, which is where the shipped strip declares itself, and
173 190 // only while there is something to say. See the header.
@@ -272,11 +272,20 @@
272 272 }
273 273
274 274 /// Every node on a screen, in order.
275 + ///
276 + /// The row a region says its members share, then its body, which is the order
277 + /// they are drawn in. A walk that read only the body went quiet about the whole
278 + /// toolbar the day it said `across`.
275 279 fn nodes(screen: &Screen) -> Vec<&Node> {
276 280 screen
277 281 .slots
278 282 .iter()
279 - .flat_map(|slot| &slot.body)
283 + .flat_map(|slot| {
284 + slot.run
285 + .iter()
286 + .flat_map(|run| run.members.iter())
287 + .chain(slot.body.iter())
288 + })
280 289 .map(|placed| &placed.node)
281 290 .collect()
282 291 }
@@ -297,6 +306,14 @@
297 306 }
298 307 let mut out = Vec::new();
299 308 for slot in &screen.slots {
309 + // The row first, then the body, which is the order they are drawn in.
310 + // A walk that read only the body went quiet about the whole toolbar the
311 + // day it said `across`.
312 + if let Some(run) = slot.run.as_ref() {
313 + for placed in &run.members {
314 + walk(&placed.node, &mut out);
315 + }
316 + }
300 317 for placed in &slot.body {
301 318 walk(&placed.node, &mut out);
302 319 }
@@ -1135,7 +1152,12 @@
1135 1152 screen
1136 1153 .slots
1137 1154 .iter()
1138 - .flat_map(|slot| &slot.body)
1155 + .flat_map(|slot| {
1156 + slot.run
1157 + .iter()
1158 + .flat_map(|run| run.members.iter())
1159 + .chain(slot.body.iter())
1160 + })
1139 1161 .filter_map(|placed| match &placed.node {
1140 1162 Node::Act(act) => Some(act.label.clone()),
1141 1163 _ => None,
@@ -4437,7 +4459,11 @@
4437 4459 // A filter is on or off, which is exactly what Token::Chip's `latched`
4438 4460 // says, and what a plain badge could not.
4439 4461 let library = FakeLibrary::stocked();
4440 - assert_eq!(latched(&browsed(&library)), ["drums.kick"]);
4462 + // Two latched chips on the window now, and only the second is this test's:
4463 + // the toolbar's Sidebar toggle latches because the sidebar is showing, which
4464 + // is what a latched chip is for. Named rather than filtered out, so a chip
4465 + // that stops latching fails here rather than silently.
4466 + assert_eq!(latched(&browsed(&library)), ["Sidebar", "drums.kick"]);
4441 4467
4442 4468 browsing(&library, Request::post("/tags/drums/filter")).unwrap();
4443 4469 assert_eq!(library.asked(), ["toggle drums"]);
@@ -4597,8 +4623,12 @@
4597 4623 }
4598 4624 }
4599 4625
4626 + // A fresh app shows its sidebar, and since the window's left column is
4627 + // described (2026-08-23) that is a fact about the screen rather than about
4628 + // the host: a fixture showing no panels describes a window with no sidebar
4629 + // in it.
4600 4630 fn showing(&self) -> Vec<Panel> {
4601 - Vec::new()
4631 + vec![Panel::Sidebar]
4602 4632 }
4603 4633
4604 4634 fn undoable(&self) -> bool {
@@ -4637,7 +4667,10 @@
4637 4667 filters: 0,
4638 4668 describes: String::new(),
4639 4669 },
4640 - showing: Vec::new(),
4670 + // The window's left column is described, so a fixture showing no
4671 + // panels describes a window with no sidebar in it. A fresh app has
4672 + // one; see `Still`.
4673 + showing: vec![Panel::Sidebar],
4641 4674 undoable: false,
4642 4675 asked: RefCell::new(Vec::new()),
4643 4676 }
@@ -5133,8 +5166,18 @@
5133 5166 .iter()
5134 5167 .find(|slot| slot.id == region)
5135 5168 .expect("the region is on the screen")
5136 - .body
5169 + .run
5137 5170 .iter()
5171 + .flat_map(|run| run.members.iter())
5172 + .chain(
5173 + screen
5174 + .slots
5175 + .iter()
5176 + .find(|slot| slot.id == region)
5177 + .expect("the region is on the screen")
5178 + .body
5179 + .iter(),
5180 + )
5138 5181 .map(|placed| {
5139 5182 let name = match &placed.node {
5140 5183 Node::Token(tag) => tag.label.clone(),
@@ -85,7 +85,7 @@
85 85 //! host draws it rather than what it holds, so no finding — but a vocabulary
86 86 //! that grows anchoring should know this was the first place it mattered.
87 87
88 - use quasi_router::layout::{FieldKind, Priority, Selector, Tone, Width};
88 + use quasi_router::layout::{self, FieldKind, Priority, Selector, Tone, Width};
89 89 use quasi_router::{
90 90 Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
91 91 Slot,
@@ -254,7 +254,17 @@
254 254
255 255 /// The toolbar, as a region something else holds.
256 256 pub fn body(state: &Panels<'_>) -> Slot {
257 - let bar = Slot::new(BAR, RegionKind::Band);
257 + // A row, said as one. The members were going into the band's body, which is
258 + // a column, and every renderer that read it drew a toolbar one control per
259 + // line; quasi-webview happened to look right because a stylesheet was
260 + // laying the band out and the description was not saying anything.
261 + //
262 + // `Shed` rather than `Wrap` because the shipped bar drops controls when it
263 + // runs out of room rather than growing a second line: the panel toggles
264 + // already carry what each is worth (`Panel::worth`), which is the declared
265 + // replacement for the `screen_w < 900.0` collapse, and a fallback that kept
266 + // everything would leave those ranks saying nothing.
267 + let bar = Slot::new(BAR, RegionKind::Band).across(layout::Fallback::Shed);
258 268 let bar = here(bar, state);
259 269 let bar = looking(bar, state);
260 270 panels(bar, state)
@@ -270,24 +280,36 @@
270 280 fn here(bar: Slot, state: &Panels<'_>) -> Slot {
271 281 match state.bar.place() {
272 282 Where::Folder { trail } => {
273 - let mut bar = bar.with(Node::Link {
274 - text: "/".to_owned(),
275 - action: Action::post("/here/root"),
276 - });
283 + let mut bar = bar.beside(
284 + Node::Link {
285 + text: "/".to_owned(),
286 + action: Action::post("/here/root"),
287 + },
288 + Priority::Essential,
289 + );
277 290 for (depth, crumb) in trail.iter().enumerate() {
278 291 // The last crumb is where you are, so it goes nowhere. Said as
279 292 // prose rather than as a link that does nothing, which is the
280 293 // shipped row's `selectable_label(is_last, ..)` made explicit.
281 294 if depth + 1 == trail.len() {
282 - bar = bar.with(Node::Text {
283 - text: crumb.name.clone(),
284 - tone: Tone::Info,
285 - });
295 + bar = bar.beside(
296 + Node::Text {
297 + text: crumb.name.clone(),
298 + tone: Tone::Info,
299 + },
300 + Priority::Essential,
301 + );
286 302 } else {
287 - bar = bar.with(Node::Link {
288 - text: crumb.name.clone(),
289 - action: Action::post(format!("/here/{}/{}", crumb.id, depth + 1)),
290 - });
303 + bar = bar.beside(
304 + Node::Link {
305 + text: crumb.name.clone(),
306 + action: Action::post(format!("/here/{}/{}", crumb.id, depth + 1)),
307 + },
308 + // A crumb further from where you are is the first thing
309 + // a narrow bar can lose: the path is still walkable
310 + // from the root, which never drops.
311 + Priority::Optional,
312 + );
291 313 }
292 314 }
293 315 bar
@@ -318,35 +340,48 @@
318 340 step.name.clone()
319 341 };
320 342 if at == last {
321 - bar = bar.with(Node::Text {
322 - text: says,
323 - tone: Tone::Info,
324 - });
343 + bar = bar.beside(
344 + Node::Text {
345 + text: says,
346 + tone: Tone::Info,
347 + },
348 + Priority::Essential,
349 + );
325 350 } else {
326 - bar = bar.with(Node::Link {
327 - text: says,
328 - action: Action::post(format!("/here/similar/{at}")),
329 - });
351 + bar = bar.beside(
352 + Node::Link {
353 + text: says,
354 + action: Action::post(format!("/here/similar/{at}")),
355 + },
356 + Priority::Optional,
357 + );
330 358 }
331 359 }
332 - bar.with(Node::Act(Act::new(
333 - "Back to browsing",
334 - Action::post("/here/leave"),
335 - )))
336 - .with(Node::text(
337 - "Results are ranked by similarity, so column sort is off.",
338 - ))
360 + bar.beside(
361 + Node::Act(Act::new("Back to browsing", Action::post("/here/leave"))),
362 + Priority::Essential,
363 + )
364 + .beside(
365 + Node::text("Results are ranked by similarity, so column sort is off."),
366 + Priority::Optional,
367 + )
339 368 }
340 369 }
341 370 }
342 371
343 372 /// A mode you are in, and the way out of it.
344 373 fn leaving(bar: Slot, says: String, out: &str) -> Slot {
345 - bar.with(Node::Text {
346 - text: says,
347 - tone: Tone::Info,
348 - })
349 - .with(Node::Act(Act::new(out, Action::post("/here/leave"))))
374 + bar.beside(
375 + Node::Text {
376 + text: says,
377 + tone: Tone::Info,
378 + },
379 + Priority::Essential,
380 + )
381 + .beside(
382 + Node::Act(Act::new(out, Action::post("/here/leave"))),
383 + Priority::Essential,
384 + )
350 385 }
351 386
352 387 /// What you are looking for.
@@ -354,52 +389,61 @@
354 389 let searching = state.bar.searching();
355 390
356 391 let mut bar = bar
357 - .with(Node::Field(Box::new(
358 - Field::new(FieldKind::Text, QUERY, "Search")
359 - .value(&searching.query)
360 - .hint("Search samples...")
361 - // What `trailing_width` was measuring for, said instead of
362 - // measured. quasi 0.17.0, settled by Max: fill is determined at
363 - // the description stage. It is the default, so this line changes
364 - // no pixel -- and it is the difference between a row that
365 - // happens to look right and one that says what it means.
366 - .width(Width::Fill)
367 - .changes(Action::post("/search")),
368 - )))
369 - .with(Node::Select {
370 - kind: Selector::Segmented,
371 - options: vec![
372 - (Choice::new("folder", "This folder"), None),
373 - (Choice::new("all", "Everywhere"), None),
374 - ],
375 - chosen: Some(
376 - if searching.everywhere {
377 - "all"
378 - } else {
379 - "folder"
380 - }
381 - .to_owned(),
382 - ),
383 - action: Some(Action::post("/search/scope")),
384 - });
392 + .beside(
393 + Node::Field(Box::new(
394 + Field::new(FieldKind::Text, QUERY, "Search")
395 + .value(&searching.query)
396 + .hint("Search samples...")
397 + // What `trailing_width` was measuring for, said instead of
398 + // measured. quasi 0.17.0, settled by Max: fill is determined at
399 + // the description stage. It is the default, so this line changes
400 + // no pixel -- and it is the difference between a row that
401 + // happens to look right and one that says what it means.
402 + .width(Width::Fill)
403 + .changes(Action::post("/search")),
404 + )),
405 + Priority::Essential,
406 + )
407 + .beside(
408 + Node::Select {
409 + kind: Selector::Segmented,
410 + options: vec![
411 + (Choice::new("folder", "This folder"), None),
412 + (Choice::new("all", "Everywhere"), None),
413 + ],
414 + chosen: Some(
415 + if searching.everywhere {
416 + "all"
417 + } else {
418 + "folder"
419 + }
420 + .to_owned(),
421 + ),
422 + action: Some(Action::post("/search/scope")),
423 + },
424 + Priority::Secondary,
425 + );
385 426
386 427 if searching.filtered {
387 428 bar = bar
388 - .with(Node::Figure(quasi_router::Figure::new(
389 - searching.results.to_string(),
390 - "results",
391 - )))
392 - .with(Node::Act(Act::new(
393 - "Save as collection",
394 - Action::get("/search/save"),
395 - )));
429 + .beside(
430 + Node::Figure(quasi_router::Figure::new(
431 + searching.results.to_string(),
432 + "results",
433 + )),
434 + Priority::Secondary,
435 + )
436 + .beside(
437 + Node::Act(Act::new("Save as collection", Action::get("/search/save"))),
438 + Priority::Optional,
439 + );
396 440 }
397 441
398 442 let mut undo = Act::new("Undo", Action::post("/undo")).key("ctrl+z");
399 443 if !state.bar.undoable() {
400 444 undo = undo.disabled();
401 445 }
402 - bar.with(Node::Act(undo))
446 + bar.beside(Node::Act(undo), Priority::Secondary)
403 447 }
404 448
405 449 /// What is showing, and the places the toolbar goes.
@@ -430,7 +474,7 @@
430 474 if panel == Panel::Filters && state.bar.searching().filters > 0 {
431 475 chip.label = format!("{} ({})", chip.label, state.bar.searching().filters);
432 476 }
433 - bar = bar.with_ranked(Node::Token(chip), panel.worth());
477 + bar = bar.beside(Node::Token(chip), panel.worth());
434 478 }
435 479
436 480 // Import holds at Essential, alone among the right-hand controls, and it is
@@ -438,8 +482,11 @@
438 482 // empty because it is the one action that does anything then. A control that
439 483 // is the only way to have any content is not one a narrow window drops.
440 484 let bar = bar
441 - .with(Node::Act(Act::new("Import", Action::get("/import/open"))))
442 - .with_ranked(
485 + .beside(
486 + Node::Act(Act::new("Import", Action::get("/import/open"))),
487 + Priority::Essential,
488 + )
489 + .beside(
443 490 Node::Act(Act::new("Export", Action::post("/export/begin"))),
444 491 Priority::Secondary,
445 492 );
@@ -448,15 +495,15 @@
448 495 // other route in, so they hold at Secondary. Help drops first because it
449 496 // is the one control here that keeps working when it is not on the screen:
450 497 // it carries `f1`, and a key is a route a narrow window cannot take away.
451 - bar.with_ranked(
498 + bar.beside(
452 499 Node::Act(Act::new("Settings", Action::get("/settings"))),
453 500 Priority::Secondary,
454 501 )
455 - .with_ranked(
502 + .beside(
456 503 Node::Act(Act::new("Cloud Sync", Action::get("/sync"))),
457 504 Priority::Secondary,
458 505 )
459 - .with_ranked(
506 + .beside(
460 507 Node::Act(Act::new("Help", Action::get("/help")).key("f1")),
461 508 Priority::Optional,
462 509 )
@@ -5,6 +5,24 @@
5 5 use super::{AnalysisConfig, BrowserState, PathBuf, split_name_ext};
6 6
7 7 impl BrowserState {
8 + /// Open the editor on the sample in focus, or close it if it is open.
9 + ///
10 + /// Lived in `ui/toolbar.rs` until the main window became a description
11 + /// (2026-08-23) and that module went with it. It was never the toolbar's:
12 + /// which sample the editor opens on, and whether opening one is possible at
13 + /// all, are facts about the app's state, and the described toggle asks the
14 + /// app rather than reimplementing the rule.
15 + pub fn toggle_edit_window(&mut self) {
16 + if self.edit.show_window {
17 + self.close_edit_window();
18 + } else if let Some(node) = self.selected_node()
19 + && let Some(hash) = &node.node.sample_hash
20 + {
21 + let hash = hash.clone();
22 + self.open_edit_window(&hash);
23 + }
24 + }
25 +
8 26 /// Open the floating sample editor for the given hash.
9 27 pub fn open_edit_window(&mut self, hash: &str) {
10 28 // Load analysis for total frames and result mode preference
@@ -578,6 +578,23 @@
578 578 pub tag_folders_apply_all_input: String,
579 579 }
580 580
581 + /// Does this status line report a failure?
582 + ///
583 + /// A heuristic over the app's own status strings, which is why it is the app's:
584 + /// it lived in `ui/footer.rs` until the main window became a description
585 + /// (2026-08-23), and the described shell asks it to pick a tone. An error is
586 + /// kept on screen rather than expiring, since a silently vanishing error is the
587 + /// one message a user most needs to catch.
588 + pub fn is_error_status(status: &str) -> bool {
589 + let lower = status.to_ascii_lowercase();
590 + lower.starts_with("failed")
591 + || lower.contains("error")
592 + || lower.starts_with("could not")
593 + || lower.starts_with("couldn\'t")
594 + || lower.starts_with("cannot")
595 + || lower.starts_with("can\'t")
596 + }
597 +
581 598 /// One step of the similarity trail: a sample that was asked about, and what
582 599 /// kind of question it was.
583 600 ///
@@ -4,14 +4,9 @@
4 4 pub mod color;
5 5 pub mod dialog;
6 6 pub mod export_screens;
7 - pub mod file_list;
8 7 pub mod file_list_menus;
9 - pub mod footer;
10 8 pub mod instrument_panel;
11 - pub mod layout_strip;
12 9 pub mod overlays;
13 10 pub mod settings_panel;
14 - pub mod sidebar;
15 11 pub mod theme;
16 - pub mod toolbar;
17 12 pub mod widgets;
@@ -1,796 +1,0 @@
1 - //! Main file list: multi-column sortable table with selection and playback controls.
2 - //!
3 - //! The table is described rather than built: [`describe`] emits
4 - //! `makeover_layout::Column`s and `makeover_immediate::table` turns them into
5 - //! tracks, headings, carets and a narrowing cutoff. What used to be three
6 - //! hand-written encodings that had to agree (the `Column::exact` chain, the
7 - //! header row, and the per-row `row.col()` calls) is one list, walked three
8 - //! times by the renderer.
9 - //!
10 - //! # What the description does not own
11 - //!
12 - //! Ordering. The library reports which heading was pressed and orders nothing,
13 - //! so [`SortColumn`] and [`SortDirection`] stay the app's and [`sort_key`] is
14 - //! the one place a column name becomes one. That split is the description's:
15 - //! what a press *calls* is an address, and it names none.
16 -
17 - use egui;
18 -
19 - use crate::state::{BrowserState, SortColumn, SortDirection};
20 - use audiofiles_core::vfs::{NodeType, VfsNodeWithAnalysis};
21 - use makeover_immediate::table;
22 - use makeover_layout::{CellPart, Column, Priority, Sort, Width};
23 -
24 - use super::file_list_menus::{
25 - draw_background_context_menu, draw_context_menu, draw_multi_context_menu,
26 - };
27 - use super::instrument_panel::DragPayload;
28 - use super::theme;
29 - use super::widgets;
30 -
31 - #[cfg(any(target_os = "macos", target_os = "windows"))]
32 - use super::file_list_menus::start_os_drag;
33 -
34 - /// The heading of each column, and the name its cells are addressed by.
35 - ///
36 - /// Consts rather than literals because each one is written three times: in the
37 - /// description, in the cell match, and in [`SIZING`]. A typo in any of them is a
38 - /// column that silently sizes wrong or draws nothing.
39 - const NAME: &str = "Name";
40 - const DUR: &str = "Dur";
41 - const BPM: &str = "BPM";
42 - const KEY: &str = "Key";
43 - const PEAK: &str = "Peak";
44 - const TAGS: &str = "Tags";
45 - const PLAY: &str = "Play";
46 -
47 - /// The lengths the description defers, in points.
48 - ///
49 - /// Lifted from the `Column::exact`/`remainder` chain this replaced rather than
50 - /// re-chosen, so the conversion changes no width. `Name` is the `at_least(120)`
51 - /// floor its remainder track carried.
52 - const SIZING: table::Sizing<'static> = table::Sizing {
53 - lengths: &[
54 - (NAME, 120.0),
55 - (DUR, 60.0),
56 - (BPM, 50.0),
57 - (KEY, 70.0),
58 - (PEAK, 60.0),
59 - (TAGS, 120.0),
60 - (PLAY, 36.0),
61 - ],
62 - fallback: 60.0,
63 - };
64 -
65 - /// Which of the optional analysis columns the user has switched on.
66 - ///
67 - /// Copyable so it can cross the cell closure, which also borrows `state`.
68 - #[derive(Clone, Copy)]
69 - struct ColumnFlags {
70 - duration: bool,
71 - bpm: bool,
72 - key: bool,
73 - peak_db: bool,
74 - tags: bool,
75 - }
76 -
77 - /// Describe the file list's columns for the current config and sort state.
78 - ///
79 - /// Two axes decide what the user sees and they are not the same axis. This one
80 - /// is the user's own choice, per column, from the config menu; narrowing is the
81 - /// renderer's, from the width available, and it reads
82 - /// [`Priority`](makeover_layout::Priority). A column switched off here is not
83 - /// described at all, which is the difference between "I don't want this" and
84 - /// "there is no room right now".
85 - ///
86 - /// The priorities are the one piece of real design in the conversion, since the
87 - /// list dropped nothing at any width before it:
88 - ///
89 - /// - **Essential: Name and Play.** Name is what identifies the row. Play carries
90 - /// the Download button for a cloud-only sample, and dropping it would put that
91 - /// sample's only visible recovery path back in the right-click menu, which is
92 - /// the exact half-broken-looking row C-1 fixed.
93 - /// - **Secondary: Dur, BPM and Key.** What a sample is musically. First to go
94 - /// once the optional pair has gone, and the pair a user narrowing hard is
95 - /// likeliest to still want.
96 - /// - **Optional: Peak and Tags.** The two widest and the two least often read,
97 - /// and the only two with no sort of their own.
98 - fn describe(
99 - visible: ColumnFlags,
100 - sort_col: SortColumn,
101 - direction: &SortDirection,
102 - sort_enabled: bool,
103 - ) -> Vec<Column<'static>> {
104 - // `std::mem::discriminant` so we can compare variants without requiring
105 - // `PartialEq` on the payload.
106 - let sorted_by = |column: SortColumn| -> Option<Sort> {
107 - if std::mem::discriminant(&sort_col) != std::mem::discriminant(&column) {
108 - return None;
109 - }
110 - Some(match direction {
111 - SortDirection::Ascending => Sort::Ascending,
112 - SortDirection::Descending => Sort::Descending,
113 - })
114 - };
115 - // Sorted and not sortable is a real combination and the one similarity
116 - // search puts the list in: still ordered by the key in force, and the user
117 - // cannot change it. The caret keeps saying so.
118 - let data = |name, width, priority, key: SortColumn| Column {
119 - name,
120 - width,
121 - priority,
122 - sortable: sort_enabled,
123 - sorted: sorted_by(key),
124 - };
125 -
126 - let mut columns = vec![data(
127 - NAME,
128 - Width::Fill,
129 - Priority::Essential,
130 - SortColumn::Name,
131 - )];
132 - if visible.duration {
133 - columns.push(data(
134 - DUR,
135 - Width::Fixed,
136 - Priority::Secondary,
137 - SortColumn::Duration,
138 - ));
139 - }
140 - if visible.bpm {
141 - columns.push(data(
142 - BPM,
143 - Width::Fixed,
144 - Priority::Secondary,
145 - SortColumn::Bpm,
146 - ));
147 - }
148 - if visible.key {
149 - columns.push(data(
150 - KEY,
151 - Width::Fixed,
152 - Priority::Secondary,
153 - SortColumn::Key,
154 - ));
155 - }
156 - // Peak and Tags have no sort of their own and never had one: they describe
157 - // as headings rather than as controls, which is what `sortable: false` says.
158 - let heading = |name, priority| Column {
159 - name,
160 - width: Width::Fixed,
161 - priority,
162 - sortable: false,
163 - sorted: None,
164 - };
165 - if visible.peak_db {
166 - columns.push(heading(PEAK, Priority::Optional));
167 - }
168 - if visible.tags {
169 - columns.push(heading(TAGS, Priority::Optional));
170 - }
171 - columns.push(heading(PLAY, Priority::Essential));
172 - columns
173 - }
174 -
175 - /// The sort key a pressed heading names, if it names one.
176 - ///
177 - /// The only place a column name becomes a [`SortColumn`]. The library reports
178 - /// the press and orders nothing, because what a press calls is an address and
179 - /// the description names none.
180 - fn sort_key(name: &str) -> Option<SortColumn> {
181 - match name {
182 - NAME => Some(SortColumn::Name),
183 - DUR => Some(SortColumn::Duration),
184 - BPM => Some(SortColumn::Bpm),
185 - KEY => Some(SortColumn::Key),
186 - _ => None,
187 - }
188 - }
189 -
190 - /// Draw the sortable, multi-column file list.
191 - pub fn draw_file_list(
192 - ui: &mut egui::Ui,
193 - state: &mut BrowserState,
194 - sync_manager: Option<&audiofiles_sync::SyncManager>,
195 - ) {
196 - // After an OS drag that ends outside the app window, macOS swallows the
197 - // mouse-up so egui's pointer state is stale (`resp.dragged()` stays true).
198 - // Block new OS drags until egui sees the pointer released or a 2s safety
199 - // timeout expires.
200 - #[cfg(any(target_os = "macos", target_os = "windows"))]
201 - let os_drag_blocked = if let Some(t) = state.os_drag_cooldown {
202 - let pointer_up = !ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary));
203 - if pointer_up || t.elapsed() > std::time::Duration::from_secs(2) {
204 - state.os_drag_cooldown = None;
205 - false
206 - } else {
207 - true
208 - }
209 - } else {
210 - false
211 - };
212 -
213 - // Empty state: no contents, at root level, no active search.
214 - // The first-run guided onboarding has a custom layout (numbered steps with
215 - // an inline Import link); other empty states route through `empty_state`.
216 - if state.nav.contents.is_empty()
217 - && state.nav.current_dir.is_none()
218 - && state.search.search_query.is_empty()
219 - && !state.search.search_filter.is_active()
220 - {
221 - if state.onboarding.show_first_launch_hint {
222 - ui.vertical_centered(|ui| {
223 - ui.add_space(ui.available_height() * 0.08);
224 - ui.label(
225 - egui::RichText::new("Welcome to audiofiles")
226 - .size(22.0)
227 - .color(theme::content()),
228 - );
229 - ui.add_space(theme::space::section());
230 - ui.label(
231 - egui::RichText::new("Three steps to your first sample:")
232 - .color(theme::content_secondary()),
233 - );
234 - ui.add_space(theme::space::group());
235 -
236 - ui.horizontal(|ui| {
237 - widgets::step_number(ui, 1);
238 - ui.label("Drop a folder of samples onto this window, or click ");
239 - if ui.link("Import").clicked() {
240 - state.dialogs.pick_folder(
241 - "Quick Import Folder",
242 - super::super::state::BrowserState::quick_import_folder,
243 - );
244 - }
245 - });
246 - ui.add_space(theme::space::bound());
247 -
248 - ui.horizontal(|ui| {
249 - widgets::step_number(ui, 2);
250 - ui.label("audiofiles will analyze BPM, key, and type automatically");
251 - });
252 - ui.add_space(theme::space::bound());
253 -
254 - ui.horizontal(|ui| {
255 - widgets::step_number(ui, 3);
256 - ui.label("Browse, filter, preview, and export to your hardware");
257 - });
258 -
259 - ui.add_space(theme::space::group());
260 - ui.label(
261 - egui::RichText::new(
262 - "Your files stay where they are \u{2014} audiofiles only indexes them.",
263 - )
264 - .small()
265 - .color(theme::content_muted()),
266 - );
267 - ui.add_space(theme::space::peer());
268 - ui.label(
269 - egui::RichText::new(
270 - "Press F1 for shortcuts \u{00B7} Right-click samples for options",
271 - )
272 - .small()
273 - .color(theme::content_muted()),
274 - );
275 - ui.add_space(theme::space::bound());
276 - if ui
277 - .link(
278 - egui::RichText::new("Dismiss")
279 - .small()
280 - .color(theme::content_muted()),
281 - )
282 - .on_hover_text("Hide this welcome. Re-enable from Settings.")
283 - .clicked()
284 - {
285 - state.dismiss_first_launch_hint();
286 - }
287 - });
288 - } else {
289 - let clicked = widgets::empty_state(
290 - ui,
291 - "No samples yet",
292 - Some("Drop audio files here, or import a folder to get started."),
293 - Some(widgets::EmptyStateCta {
294 - label: "Import folder...",
295 - tooltip: Some("Choose a folder of samples to import"),
296 - }),
297 - );
298 - if clicked {
299 - state.dialogs.pick_folder(
300 - "Import folder",
301 - super::super::state::BrowserState::quick_import_folder,
302 - );
303 - }
304 - // Quiet link to bring the welcome screen back if the user dismissed it.
305 - ui.vertical_centered(|ui| {
306 - ui.add_space(theme::space::group());
307 - if ui
308 - .link(
309 - egui::RichText::new("Show welcome")
310 - .small()
311 - .color(theme::content_muted()),
312 - )
313 - .clicked()
314 - {
315 - state.show_welcome();
316 - }
317 - });
318 - }
319 - return;
320 - }
321 -
322 - // Empty state: filters active but no results in this folder
323 - if state.nav.contents.is_empty()
324 - && (state.search.search_filter.is_active() || !state.search.search_query.is_empty())
325 - {
326 - let filter_count = state.search.search_filter.active_count();
327 - let hint = no_match_hint(filter_count, !state.search.search_query.is_empty());
328 - // C-3: label names every part of the action. The CTA clears both
329 - // filters and the search query, matching the toolbar's already-fixed
330 - // "Clear search and filters" rename from Phase 4 M-4.
331 - if widgets::empty_state(
332 - ui,
333 - "No matches in this folder",
334 - Some(&hint),
335 - Some(widgets::EmptyStateCta {
336 - label: "Clear search and filters",
337 - tooltip: None,
338 - }),
339 - ) {
340 - state.search.search_filter.clear();
341 - state.search.search_query.clear();
342 - state.apply_search();
343 - }
344 - return;
345 - }
346 -
347 - // Sync first-touch banner: surfaces once after the first import. Suppressed
348 - // while the welcome hint is up (user hasn't imported yet) and dismissed
349 - // permanently once the user clicks either button.
350 - if state.onboarding.show_sync_intro && !state.onboarding.show_first_launch_hint {
351 - widgets::info_banner(
352 - ui,
353 - "Your library is local. Set up cloud sync to back it up and use it on other devices.",
354 - );
355 - ui.horizontal(|ui| {
356 - if ui.button("Maybe later").clicked() {
357 - state.dismiss_sync_intro();
358 - }
359 - if ui.button("Set up sync").clicked() {
360 - state.sync.show_panel = true;
361 - state.dismiss_sync_intro();
362 - }
363 - });
364 - ui.add_space(theme::space::bound());
365 - }
366 -
367 - let row_height = state.row_height;
368 - let has_parent = state.nav.current_dir.is_some();
369 - let contents = state.nav.contents.clone();
370 - let parent_rows = usize::from(has_parent);
371 - // Snapshot column visibility into local bools. The `&state.column_config`
372 - // borrow cannot survive into the cell closure, which also borrows `state`,
373 - // and it cannot survive into the well closure below either.
374 - let visible = ColumnFlags {
375 - duration: state.column_config.show_duration,
376 - bpm: state.column_config.show_bpm,
377 - key: state.column_config.show_key,
378 - peak_db: state.column_config.show_peak_db,
379 - tags: state.column_config.show_tags,
380 - };
381 -
382 - // Snapshot sort state so the description doesn't borrow `state` mutably.
383 - let sort_col = state.nav.sort_column;
384 - let sort_dir = state.nav.sort_direction.clone();
385 - // While similarity / duplicate search is active, results come back sorted
386 - // by similarity score, letting the user click a column header to "sort
387 - // by name" silently in that view would scramble the ranking. Describe every
388 - // heading as not sortable instead, so the score order stays trustworthy.
389 - // The mode says so where it lives, on the toolbar's "Similar to:" segment;
390 - // a heading nobody has reason to point at is the wrong place to explain it.
391 - let sort_enabled = !state.search.in_similarity();
392 -
393 - let columns = describe(visible, sort_col, &sort_dir, sort_enabled);
394 -
395 - // Snapshot the selection too, for the same reason and one step further: the
396 - // predicate `Body::selected` holds is read while the cell closure below
397 - // mutates `state`, so it cannot be looking at `state`'s own set.
398 - let selected_rows = state.nav.selection.selected.clone();
399 - let is_selected = |index: usize| selected_rows.contains(&index);
400 - // Taken, not read: held, a scroll request would fight every scroll the user
401 - // makes with the mouse.
402 - let scroll_to = state.nav.scroll_to_row.take();
403 -
404 - let body = table::Body {
405 - // Display index 0 is the ".." parent entry when present; the rest map
406 - // into `contents`. Selection indices are the same numbers, which is why
407 - // `is_selected` needs no adjusting.
408 - rows: parent_rows + contents.len(),
409 - selected: Some(&is_selected),
410 - scroll_to,
411 - };
412 - let style = table::TableStyle {
413 - row_height,
414 - striped: true,
415 - resizable: true,
416 - ..Default::default()
417 - };
418 - let palette = theme::palette();
419 -
420 - // The table body is a well: it is the thing the user looks into, and the
421 - // panel chrome around it sits on top. See `docs/design-system.md`.
422 - let pressed = widgets::inset_well(ui, |ui| {
423 - table::table(
424 - ui,
425 - &columns,
426 - &body,
427 - &SIZING,
428 - &palette,
429 - &style,
430 - |ui, column, index| {
431 - if has_parent && index == 0 {
432 - draw_parent_cell(ui, state, column, is_selected(0));
433 - return;
434 - }
435 - let node = &contents[index - parent_rows];
436 - match column.name {
437 - NAME => {
438 - #[cfg(any(target_os = "macos", target_os = "windows"))]
439 - let drag_blocked = os_drag_blocked;
440 - #[cfg(not(any(target_os = "macos", target_os = "windows")))]
441 - let drag_blocked = false;
442 - draw_name_column(
443 - ui,
444 - state,
445 - node,
446 - index,
447 - is_selected(index),
448 - drag_blocked,
449 - sync_manager,
450 - );
451 - }
452 - // The one cell holding a control rather than text. Wrapped
453 - // so the button takes the action intent instead of
454 - // inheriting the cell's content colour, which is the drift
455 - // `CellPart` exists to end.
456 - PLAY => table::cell(ui, Some(CellPart::Actions), &palette, |ui| {
457 - draw_play_cell(ui, state, node, sync_manager);
458 - }),
459 - other => draw_analysis_cell(ui, node, other),
460 - }
461 - },
462 - )
463 - });
464 -
465 - // Applied after the table is fully drawn, so we don't conflict with the
466 - // borrows the cell closure took. The library reports the press and orders
467 - // nothing.
468 - if let Some(col) = pressed.and_then(|c| sort_key(c.name)) {
469 - state.toggle_sort(col);
470 - }
471 -
472 - // Background context menu: right-click on empty space below the rows.
473 - // Allocate the remaining vertical space as an invisible interactable area.
474 - let remaining = ui.available_rect_before_wrap();
475 - if remaining.height() > 0.0 {
476 - let bg_resp = ui.interact(
477 - remaining,
478 - ui.id().with("file_list_bg"),
479 - egui::Sense::click(),
480 - );
481 - // Click on empty space clears selection.
482 - if bg_resp.clicked() {
483 - state.nav.selection.clear();
484 - state.refresh_selected_tags();
485 - state.refresh_selected_detail();
486 - }
487 - bg_resp.context_menu(|ui| {
488 - draw_background_context_menu(ui, state);
489 - });
490 - }
491 - }
492 -
493 - /// Build the empty-results hint for a folder that has active filters and/or a
494 - /// search but no matching rows. `has_search` is whether the search query is
495 - /// non-empty; `filter_count` is how many filter facets are active.
496 - fn no_match_hint(filter_count: usize, has_search: bool) -> String {
497 - let plural = if filter_count == 1 { "" } else { "s" };
498 - if filter_count > 0 && has_search {
499 - format!("{filter_count} filter{plural} + search active")
500 - } else if filter_count > 0 {
Lines truncated
@@ -1,403 +1,0 @@
1 - //! Bottom footer: tag chips, transport controls, now-playing info, and status message.
2 -
3 - use std::time::{Duration, Instant};
4 -
5 - use egui;
6 - use makeover_timing::{Intent, Motion};
7 -
8 - use super::theme;
9 - use crate::state::BrowserState;
10 -
11 - /// How long the footer holds a status, given whether it reports a failure.
12 - ///
13 - /// The same statement [`makeover_timing::notice_lifetime`] makes about a
14 - /// notice, against the intent that names a status line rather than a toast:
15 - /// a message the user must not miss does not go away on its own, so an error
16 - /// gets no lifetime at all and stays until something replaces it.
17 - ///
18 - /// `Intent::Clear` rather than `Intent::Dismiss` because this is a status
19 - /// line, not a stacked notice. The footer is always on screen; the message in
20 - /// it is a receipt for what the user just did, so a glance that arrives late
21 - /// should find the line empty rather than reporting stale news.
22 - fn status_lifetime(is_error: bool) -> Option<Duration> {
23 - (!is_error).then(|| Intent::Clear.duration())
24 - }
25 -
26 - /// Heuristic: does this status line report a failure? Error statuses are kept
27 - /// visible (never auto-hidden) and rendered in `danger`, since a silently
28 - /// expiring error is the one message the user most needs to catch.
29 - pub(crate) fn is_error_status(s: &str) -> bool {
30 - let l = s.to_ascii_lowercase();
31 - l.starts_with("failed")
32 - || l.contains("error")
33 - || l.starts_with("could not")
34 - || l.starts_with("couldn't")
35 - || l.starts_with("cannot")
36 - || l.starts_with("can't")
37 - }
38 -
39 - /// Render a middle-dot section separator. Standardises the footer's
40 - /// inter-section breaks on `\u{00B7}` (p-2) so the row reads as one
41 - /// horizontal scan rather than a mix of vertical bars and dots.
42 - fn dot(ui: &mut egui::Ui) {
43 - ui.label(
44 - egui::RichText::new("\u{00B7}")
45 - .small()
46 - .color(theme::content_muted()),
47 - );
48 - }
49 -
50 - /// Draw the footer panel: transport, now-playing, tags, and status.
51 - ///
52 - /// M-8: when the window is too narrow to host every section in one row, split
53 - /// into two rows, top row carries transport + status (the actively-changing
54 - /// concerns), bottom row carries the more peripheral analysis-coverage /
55 - /// selection-count / preview-device fields. Threshold ~1000px matches the
56 - /// audit's recommendation and the empirical overflow point with the first-
57 - /// launch hint visible.
58 - pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserState) {
59 - ui.add_space(theme::space::bound());
60 -
61 - let narrow = ctx.content_rect().width() < 1000.0;
62 -
63 - // Transport row
64 - ui.horizontal(|ui| {
65 - let playback = state.shared.preview.lock();
66 - let playing = playback.playing;
67 - let (position_secs, total_secs, progress) = if let Some(ref buf) = playback.buffer {
68 - // Divide by 2: buffer is interleaved stereo (L, R, L, R, and so on),
69 - // so frame count = sample count / 2 channels.
70 - let total_frames = buf.data.len() / 2;
71 - let sr = buf.sample_rate as f64;
72 - let pos_s = playback.position_frac / sr;
73 - let tot_s = total_frames as f64 / sr;
74 - let prog = if total_frames > 0 {
75 - (playback.position_frac / total_frames as f64) as f32
76 - } else {
77 - 0.0
78 - };
79 - (pos_s as f32, tot_s as f32, prog)
80 - } else {
81 - (0.0, 0.0, 0.0)
82 - };
83 - drop(playback);
84 -
85 - if playing {
86 - if let Some(ref hash) = state.preview.previewing_hash {
87 - // Find name from contents
88 - let name = state
89 - .nav
90 - .contents
91 - .iter()
92 - .find(|n| n.node.sample_hash.as_deref() == Some(hash))
93 - .map_or("...", |n| n.node.name.as_str());
94 -
95 - ui.label(egui::RichText::new(format!("Playing: {name}")).color(theme::content()));
96 -
97 - // Visual progress bar
98 - let bar_width = 100.0;
99 - let bar_height = 12.0;
100 - let (rect, bar_resp) =
101 - ui.allocate_exact_size(egui::vec2(bar_width, bar_height), egui::Sense::click());
102 - if ui.is_rect_visible(rect) {
103 - // A Platinum progress bar is a well with something in it:
104 - // the track is inset, the fill is flat inside it, and both
105 - // are square. The rounded track read as a slider.
106 - ui.painter().rect_filled(
107 - rect,
108 - theme::radius_container(),
109 - theme::surface_page(),
110 - );
111 - let fill_rect = egui::Rect::from_min_size(
112 - rect.min,
113 - egui::vec2(rect.width() * progress, rect.height()),
114 - );
115 - ui.painter()
116 - .rect_filled(fill_rect, theme::radius_container(), theme::action());
117 - theme::bevel::paint(ui.painter(), rect, theme::bevel::Bevel::Inset);
118 - }
119 -
120 - // Click-to-seek on progress bar
121 - if bar_resp.clicked()
122 - && let Some(pos) = bar_resp.interact_pointer_pos()
123 - {
124 - let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
125 - let mut playback = state.shared.preview.lock();
126 - if let Some(ref buf) = playback.buffer {
127 - let total_frames = buf.data.len() / 2;
128 - playback.position_frac = normalized as f64 * total_frames as f64;
129 - }
130 - }
131 -
132 - // Time display
133 - ui.label(
134 - egui::RichText::new(format!(
135 - "{:.0}:{:02.0}/{:.0}:{:02.0}",
136 - position_secs / 60.0,
137 - position_secs % 60.0,
138 - total_secs / 60.0,
139 - total_secs % 60.0,
140 - ))
141 - .color(theme::content_secondary())
142 - .small(),
143 - );
144 - }
145 -
146 - if ui
147 - .small_button("Stop")
148 - .on_hover_text("Stop preview (Space)")
149 - .clicked()
150 - {
151 - state.stop_preview();
152 - }
153 -
154 - ctx.request_repaint();
155 - }
156 -
157 - // Selection count, on narrow, deferred to the second row.
158 - let sel_count = state.nav.selection.count();
159 - if !narrow && sel_count > 1 {
160 - dot(ui);
161 - ui.label(
162 - egui::RichText::new(format!("{sel_count} selected"))
163 - .color(theme::content_secondary()),
164 - );
165 - }
166 -
167 - // M-13: detail-panel-hidden warning moved to a hover tooltip on the
168 - // Detail toggle in toolbar.rs (where the action that triggered the
169 - // toggle originated). Footer no longer hosts the message.
170 -
171 - // Analysis coverage indicator, on narrow, deferred to the second row.
172 - if !narrow {
173 - dot(ui);
174 - draw_analysis_coverage(ui, state);
175 - }
176 -
177 - // Status message: hold for `Intent::Clear`, then leave over
178 - // `Motion::Fade` — the message recedes to muted for the length of the
179 - // departure and is then gone. An error has no lifetime and stays.
180 - // Stamp `status_set_at` lazily for any caller that wrote `state.status`
181 - // directly (legacy path); `post_status` callers stamp at write time.
182 - // Detect changes since last frame via egui memory so the timer resets
183 - // when an existing message is overwritten with a new one.
184 - if !state.status.is_empty() {
185 - let mem_id = egui::Id::new("footer_status_last_seen");
186 - let prev: Option<String> = ui.ctx().data(|d| d.get_temp(mem_id));
187 - let changed = prev.as_deref() != Some(state.status.as_str());
188 - if changed {
189 - state.status_set_at = Some(Instant::now());
190 - ui.ctx()
191 - .data_mut(|d| d.insert_temp(mem_id, state.status.clone()));
192 - } else if state.status_set_at.is_none() {
193 - state.status_set_at = Some(Instant::now());
194 - }
195 -
196 - let elapsed = state.status_set_at.map(|t| t.elapsed()).unwrap_or_default();
197 - let is_error = is_error_status(&state.status);
198 - let hold = status_lifetime(is_error);
199 - let gone_at = hold.map(|d| d + Motion::Fade.duration());
200 - let leaving = hold.is_some_and(|d| elapsed >= d);
201 - let gone = gone_at.is_some_and(|d| elapsed >= d);
202 - if !gone {
203 - dot(ui);
204 - let color = if is_error {
205 - theme::danger()
206 - } else if leaving {
207 - theme::content_muted()
208 - } else {
209 - theme::content_secondary()
210 - };
211 - ui.label(egui::RichText::new(&state.status).color(color));
212 -
213 - // Request a repaint at the next transition so the departure
214 - // lands on time even when the UI is idle. A message with no
215 - // lifetime never transitions, so it needs no scheduled repaint.
216 - if let Some(hold) = hold {
217 - let next_threshold = if leaving {
218 - gone_at.unwrap_or(hold).saturating_sub(elapsed)
219 - } else {
220 - hold.saturating_sub(elapsed)
221 - };
222 - ui.ctx().request_repaint_after(next_threshold);
223 - }
224 - }
225 - } else if state.onboarding.show_first_launch_hint {
226 - // Clear stamp once the message has gone away so the next post
227 - // starts a fresh timer.
228 - state.status_set_at = None;
229 - dot(ui);
230 - ui.label(
231 - egui::RichText::new("Right-click for options \u{00B7} F1 for shortcuts")
232 - .small()
233 - .color(theme::content_muted()),
234 - );
235 - if ui
236 - .small_button("Dismiss")
237 - .on_hover_text("Dismiss")
238 - .clicked()
239 - {
240 - state.dismiss_first_launch_hint();
241 - }
242 - } else {
243 - state.status_set_at = None;
244 - }
245 -
246 - // Preview output device, surfaced so a silent preview is diagnosable
247 - // without opening Settings. Right-aligned so it doesn't fight with the
248 - // status message on the left. Deferred to the second row when narrow.
249 - if !narrow {
250 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
251 - draw_preview_device(ui, state);
252 - });
253 - }
254 - });
255 -
256 - if narrow {
257 - // M-8 second row: selection count, analysis coverage, preview device.
258 - // Wrapped so a very narrow window flows them onto multiple sub-rows
259 - // instead of clipping.
260 - ui.horizontal_wrapped(|ui| {
261 - let sel_count = state.nav.selection.count();
262 - if sel_count > 1 {
263 - ui.label(
264 - egui::RichText::new(format!("{sel_count} selected"))
265 - .small()
266 - .color(theme::content_secondary()),
267 - );
268 - dot(ui);
269 - }
270 - draw_analysis_coverage(ui, state);
271 - dot(ui);
272 - draw_preview_device(ui, state);
273 - });
274 - }
275 -
276 - // Tags row for selected sample. m-13: render as plain muted text rather
277 - // than chip-styled labels, these are inert (informational only), so the
278 - // affordance contract should not invite a click. Sidebar / detail panel
279 - // remain the canonical clickable-tag surfaces.
280 - if !state.detail.selected_tags.is_empty() {
281 - ui.horizontal_wrapped(|ui| {
282 - ui.spacing_mut().item_spacing.x = theme::space::peer();
283 - for (i, tag) in state.detail.selected_tags.iter().enumerate() {
284 - if i > 0 {
285 - ui.label(
286 - egui::RichText::new("\u{00B7}")
287 - .small()
288 - .color(theme::content_muted()),
289 - );
290 - }
291 - ui.label(
292 - egui::RichText::new(tag)
293 - .small()
294 - .color(theme::content_muted()),
295 - );
296 - }
297 - });
298 - }
299 -
300 - ui.add_space(theme::space::hair());
301 - }
302 -
303 - /// Render the analysis coverage chips. Caller adds the leading separator
304 - /// (`dot(ui)`) when the section follows other content.
305 - fn draw_analysis_coverage(ui: &mut egui::Ui, state: &BrowserState) {
306 - let total_samples = state
307 - .nav
308 - .contents
309 - .iter()
310 - .filter(|n| n.node.sample_hash.is_some())
311 - .count();
312 - if total_samples == 0 {
313 - return;
314 - }
315 - let analyzed = state
316 - .nav
317 - .contents
318 - .iter()
319 - .filter(|n| n.node.sample_hash.is_some() && n.duration.is_some())
320 - .count();
321 - let untagged = state
322 - .nav
323 - .contents
324 - .iter()
325 - .filter(|n| n.node.sample_hash.is_some() && n.tags.is_empty())
326 - .count();
327 - if analyzed < total_samples {
328 - ui.label(
329 - egui::RichText::new(format!("{analyzed}/{total_samples} analyzed"))
330 - .small()
331 - .color(theme::content_muted()),
332 - );
333 - } else {
334 - ui.label(
335 - egui::RichText::new(format!("{total_samples} analyzed"))
336 - .small()
337 - .color(theme::success()),
338 - );
339 - }
340 - // m-12: suppress untagged count until analysis has produced output.
341 - if analyzed > 0 && untagged > 0 {
342 - ui.label(
343 - egui::RichText::new(format!("\u{00B7} {untagged} untagged"))
344 - .small()
345 - .color(theme::content_muted()),
346 - );
347 - }
348 - }
349 -
350 - /// Render the preview output device chip. Caller controls the layout
351 - /// direction (right-to-left on the wide footer, default on the narrow row).
352 - fn draw_preview_device(ui: &mut egui::Ui, state: &BrowserState) {
353 - let device_label = state.shared.preview_device_name.lock().clone().map_or_else(
354 - || "Preview: no device".to_string(),
355 - |name| format!("Preview: {name}"),
356 - );
357 - ui.label(
358 - egui::RichText::new(device_label)
359 - .small()
360 - .color(theme::content_muted()),
361 - )
362 - .on_hover_text("Audio output device used for sample preview");
363 - }
364 -
365 - #[cfg(test)]
366 - mod tests {
367 - use super::*;
368 -
369 - #[test]
370 - fn an_error_status_has_no_lifetime_and_an_ordinary_one_clears() {
371 - // A message the user must not miss does not go away on its own. This
372 - // is `notice_lifetime(false)`'s statement, against the intent that
373 - // names a status line.
374 - assert_eq!(status_lifetime(true), None);
375 - assert_eq!(status_lifetime(false), Some(Intent::Clear.duration()));
376 - // And it agrees with the crate, so a divergence is one edit away from
377 - // being caught rather than needing both numbers read side by side.
378 - assert_eq!(
379 - status_lifetime(true),
380 - makeover_timing::notice_lifetime(false)
381 - );
382 - }
383 -
384 - #[test]
385 - fn error_statuses_are_detected_case_insensitively() {
386 - assert!(is_error_status("Failed to import sample"));
387 - assert!(is_error_status("Import error: bad header"));
388 - assert!(is_error_status("Could not read file"));
389 - assert!(is_error_status("Couldn't open device"));
390 - assert!(is_error_status("Cannot delete the last vault"));
391 - assert!(is_error_status("Can't reach the sync server"));
392 - assert!(is_error_status("ERROR: disk full"));
393 - }
394 -
395 - #[test]
396 - fn informational_statuses_are_not_errors() {
397 - assert!(!is_error_status("Imported 42 samples"));
398 - assert!(!is_error_status("Downloading kick.wav..."));
399 - assert!(!is_error_status(""));
400 - // "error" only matches as a substring; unrelated words don't trip it.
401 - assert!(!is_error_status("Saved collection"));
402 - }
403 - }
@@ -1,62 +1,0 @@
1 - //! Blob-layout migration strip: a thin, cancellable progress row above the footer.
2 - //!
3 - //! Shown only while the background migration is relocating blobs from the legacy
4 - //! flat store root into hash-prefix shards. Deliberately a strip and not a modal or
5 - //! a full-screen mode: the migration auto-starts at vault open rather than being
6 - //! asked for, and the library stays completely usable while it runs, because reads
7 - //! resolve both layouts. Seizing the window for it would be the wrong trade.
8 - //!
9 - //! Cancelling is honest here. The sweep is resumable and records nothing until a
10 - //! pass verifies the root is clean, so stopping defers the remainder to the next
11 - //! open instead of abandoning or half-applying it.
12 -
13 - use egui;
14 -
15 - use super::theme;
16 - use crate::state::BrowserState;
17 -
18 - /// Draw the migration strip. No-op when no migration is running.
19 - pub fn draw_layout_strip(ui: &mut egui::Ui, state: &mut BrowserState) {
20 - let Some(progress) = state.layout_migration else {
21 - return;
22 - };
23 -
24 - ui.add_space(theme::space::hair());
25 - ui.horizontal(|ui| {
26 - ui.label(
27 - egui::RichText::new("Optimising storage layout")
28 - .small()
29 - .color(theme::content_secondary()),
30 - );
31 - ui.label(
32 - egui::RichText::new(format!("{} / {} files", progress.completed, progress.total))
33 - .small()
34 - .color(theme::content_muted()),
35 - );
36 -
37 - // The button is laid out first from the right so the bar takes the
38 - // remaining width instead of pushing the button off the row.
39 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
40 - if ui
41 - .small_button("Pause")
42 - .on_hover_text("Stop for now. Resumes the next time this vault opens.")
43 - .clicked()
44 - {
45 - if let Err(e) = state.backend.cancel_layout_migration() {
46 - tracing::warn!("failed to cancel layout migration: {e}");
47 - }
48 - // Clear the strip immediately rather than waiting for the
49 - // worker's terminal event: the click has to feel like it did
50 - // something, and the Complete event that follows sets the status
51 - // line and would clear this anyway.
52 - state.layout_migration = None;
53 - }
54 - ui.add(
55 - egui::ProgressBar::new(progress.fraction())
56 - .desired_height(theme::space::peer())
57 - .show_percentage(),
58 - );
59 - });
60 - });
61 - ui.add_space(theme::space::hair());
62 - }