max / audiofiles
16 files changed,
+318 insertions,
-2668 deletions
| @@ -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 | - | } |