Skip to main content

max / audiofiles

Take makeover-immediate's table in the file list The file list drove egui_extras::TableBuilder directly, which meant the column set was written three times over -- the Column::exact chain, the header row, and the per-row row.col() calls -- with nothing checking that the three agreed. It is one described list now, walked three times by the renderer. What converts: - draw_sort_header becomes the library's. It was the model for it, so the caret glyphs already matched. - The Column::exact/remainder chain becomes described Columns plus a Sizing carrying the same numbers, lifted rather than re-chosen. No width changes. - The Play/Download buttons are CellPart::Actions wrapped in table::cell, which is what stops a control taking the cell's text colour. - SortColumn/SortDirection stay ours. The library reports which heading was pressed and orders nothing, so sort_key is the one place a column name becomes a sort key. What is new is narrowing: the list dropped nothing at any width before, so the priorities are a real choice. Name and Play are Essential -- Play carries the Download button for a cloud-only sample, and dropping it would put that sample's only visible recovery path back in the right-click menu, which is the half-broken-looking row C-1 fixed. Dur, BPM and Key are Secondary; Peak and Tags, the two widest and least read, are Optional. One behaviour moves rather than converts. A disabled heading used to carry the hover explaining that similarity search ranks the results, which put the explanation on a control nobody has reason to point at. It now sits on the toolbar's "Similar to:" segment, where the mode itself lives. Pins move 0.10 -> 0.13.0 for makeover-immediate and 0.12 -> 0.15.0 for makeover-layout, both exact-patch as the suite pins. The two move together: makeover-immediate re-exports nothing, so the Column the app describes and the Column the renderer matches on have to be one type. Palette gains action, which is 0.12.0's whole breakage and is action-primary from the theme the app already resolves.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-12 00:20 UTC
Signed with PGP, not checked
Commit: d3f0ee6485ab3f4cfd9332486e7413c368fe0b30
Parent: dd81c34
5 files changed, +265 insertions, -240 deletions
M Cargo.lock +9 -8
@@ -3002,19 +3002,20 @@
3002 3002
3003 3003 [[package]]
3004 3004 name = "makeover-immediate"
3005 - version = "0.10.0"
3005 + version = "0.13.0"
3006 3006 source = "registry+https://github.com/rust-lang/crates.io-index"
3007 - checksum = "a375f617897a291c9298c62eb449ff702ee7acf16f9474e59f02805f1f359099"
3007 + checksum = "46ed357809f7950967adb94ad16bba592311cbb0bd19cb192ba43faf3d3b6b47"
3008 3008 dependencies = [
3009 3009 "egui",
3010 + "egui_extras",
3010 3011 "makeover-layout",
3011 3012 ]
3012 3013
3013 3014 [[package]]
3014 3015 name = "makeover-layout"
3015 - version = "0.12.0"
3016 + version = "0.15.0"
3016 3017 source = "registry+https://github.com/rust-lang/crates.io-index"
3017 - checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
3018 + checksum = "9f18920c6ac0fe10a8f7b45c2224d0ad39e4863151b3fbe0fe042c938b9b0a9d"
3018 3019
3019 3020 [[package]]
3020 3021 name = "matchers"
@@ -7311,6 +7312,10 @@
7311 7312 "winnow 1.0.4",
7312 7313 ]
7313 7314
7315 + [[patch.unused]]
7316 + name = "docengine"
7317 + version = "0.5.0"
7318 +
7314 7319 [[patch.unused]]
7315 7320 name = "kberg"
7316 7321 version = "0.1.0"
@@ -7342,7 +7347,3 @@
7342 7347 [[patch.unused]]
7343 7348 name = "quasi-webview"
7344 7349 version = "0.1.0"
7345 -
7346 - [[patch.unused]]
7347 - name = "docengine"
7348 - version = "0.4.0"
M Cargo.toml +7 -2
@@ -12,8 +12,13 @@
12 12 audiofiles-browser = { path = "crates/audiofiles-browser" }
13 13 audiofiles-sync = { path = "crates/audiofiles-sync" }
14 14 audiofiles-rhai = { path = "crates/audiofiles-rhai" }
15 - makeover-layout = "0.12"
16 - makeover-immediate = "0.10"
15 + # Exact patch rather than the minor, as the suite itself pins: a minor-only
16 + # requirement is satisfied by a lock holding an earlier patch, which then fails
17 + # to compile against an API added in a later one. The two move together --
18 + # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 + # the `Column` the renderer matches on have to be the same type.
20 + makeover-layout = "0.15.0"
21 + makeover-immediate = "0.13.0"
17 22 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
18 23 egui_extras = { version = "0.35", default-features = false }
19 24 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -1,10 +1,25 @@
1 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.
2 16
3 17 use egui;
4 - use egui_extras::{Column, TableBuilder};
5 18
6 19 use crate::state::{BrowserState, SortColumn, SortDirection};
7 20 use audiofiles_core::vfs::{NodeType, VfsNodeWithAnalysis};
21 + use makeover_immediate::table;
22 + use makeover_layout::{CellPart, Column, Priority, Sort, Width};
8 23
9 24 use super::file_list_menus::{
10 25 draw_background_context_menu, draw_context_menu, draw_multi_context_menu,
@@ -16,6 +31,162 @@
16 31 #[cfg(any(target_os = "macos", target_os = "windows"))]
17 32 use super::file_list_menus::start_os_drag;
18 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 +
19 190 /// Draw the sortable, multi-column file list.
20 191 pub fn draw_file_list(
21 192 ui: &mut egui::Ui,
@@ -196,265 +367,105 @@
196 367 let row_height = state.row_height;
197 368 let has_parent = state.nav.current_dir.is_some();
198 369 let contents = state.nav.contents.clone();
199 - let offset = usize::from(has_parent);
370 + let parent_rows = usize::from(has_parent);
200 371 // Snapshot column visibility into local bools. The `&state.column_config`
201 - // borrow cannot survive into the table-builder closures, which also borrow
202 - // `state`, and it cannot survive into the well closure below either.
203 - let show_bpm = state.column_config.show_bpm;
204 - let show_key = state.column_config.show_key;
205 - let show_duration = state.column_config.show_duration;
206 - let show_peak_db = state.column_config.show_peak_db;
207 - let show_tags = state.column_config.show_tags;
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 + };
208 381
209 - // Snapshot sort state so the header closure doesn't borrow `state` mutably.
382 + // Snapshot sort state so the description doesn't borrow `state` mutably.
210 383 let sort_col = state.nav.sort_column;
211 384 let sort_dir = state.nav.sort_direction.clone();
212 385 // While similarity / duplicate search is active, results come back sorted
213 386 // by similarity score, letting the user click a column header to "sort
214 - // by name" silently in that view would scramble the ranking. Disable
215 - // header clicks instead so the score order stays trustworthy.
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.
216 391 let sort_enabled = state.search.similarity_search_hash.is_none();
217 - // Declared outside the well because the sort toggle is applied after it,
218 - // once every borrow the table took has ended.
219 - let clicked_col = std::cell::Cell::new(None::<SortColumn>);
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();
220 419
221 420 // The table body is a well: it is the thing the user looks into, and the
222 421 // panel chrome around it sits on top. See `docs/design-system.md`.
223 - widgets::inset_well(ui, |ui| {
224 - // Build columns dynamically based on config
225 - // Icon is merged into the Name column; Play button is merged into the last data column.
226 - let mut table = TableBuilder::new(ui)
227 - .striped(true)
228 - .resizable(true)
229 - .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
230 - .column(Column::remainder().at_least(120.0)); // Name (includes icon)
231 -
232 - // Scroll to focused row when keyboard navigation requests it.
233 - if let Some(row) = state.nav.scroll_to_row.take() {
234 - table = table.scroll_to_row(row, None);
235 - }
236 -
237 - if show_duration {
238 - table = table.column(Column::exact(60.0));
239 - }
240 - if show_bpm {
241 - table = table.column(Column::exact(50.0));
242 - }
243 - if show_key {
244 - table = table.column(Column::exact(70.0));
245 - }
246 - if show_peak_db {
247 - table = table.column(Column::exact(60.0));
248 - }
249 - if show_tags {
250 - table = table.column(Column::exact(120.0));
251 - }
252 - table = table.column(Column::exact(36.0)); // Play button
253 -
254 - table
255 - .header(20.0, |mut header| {
256 - header.col(|ui| {
257 - if draw_sort_header(
258 - ui,
259 - "Name",
260 - SortColumn::Name,
261 - sort_col,
262 - &sort_dir,
263 - sort_enabled,
264 - ) {
265 - clicked_col.set(Some(SortColumn::Name));
266 - }
267 - });
268 - if show_duration {
269 - header.col(|ui| {
270 - if draw_sort_header(
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(
271 443 ui,
272 - "Dur",
273 - SortColumn::Duration,
274 - sort_col,
275 - &sort_dir,
276 - sort_enabled,
277 - ) {
278 - clicked_col.set(Some(SortColumn::Duration));
279 - }
280 - });
281 - }
282 - if show_bpm {
283 - header.col(|ui| {
284 - if draw_sort_header(
285 - ui,
286 - "BPM",
287 - SortColumn::Bpm,
288 - sort_col,
289 - &sort_dir,
290 - sort_enabled,
291 - ) {
292 - clicked_col.set(Some(SortColumn::Bpm));
293 - }
294 - });
295 - }
296 - if show_key {
297 - header.col(|ui| {
298 - if draw_sort_header(
299 - ui,
300 - "Key",
301 - SortColumn::Key,
302 - sort_col,
303 - &sort_dir,
304 - sort_enabled,
305 - ) {
306 - clicked_col.set(Some(SortColumn::Key));
307 - }
308 - });
309 - }
310 - if show_peak_db {
311 - header.col(|ui| {
312 - ui.label(egui::RichText::new("Peak").color(theme::content_secondary()));
313 - });
314 - }
315 - if show_tags {
316 - header.col(|ui| {
317 - ui.label(egui::RichText::new("Tags").color(theme::content_secondary()));
318 - });
319 - }
320 - header.col(|ui| {
321 - ui.label(egui::RichText::new("Play").color(theme::content_muted()));
322 - });
323 - })
324 - .body(|body| {
325 - // Virtualized: egui lays out only the visible rows, so a folder with
326 - // thousands of children no longer re-lays-out every row each frame.
327 - // Display index 0 is the ".." parent entry when present; the rest map
328 - // into `contents`.
329 - let parent_rows = usize::from(has_parent);
330 - body.rows(row_height, parent_rows + contents.len(), |mut row| {
331 - let display_idx = row.index();
332 - if has_parent && display_idx == 0 {
333 - let selected = state.nav.selection.contains(0);
334 - row.set_selected(selected);
335 - row.col(|ui| {
336 - // Parent ".." entry: render muted so it reads as
337 - // navigation rather than a sample row, and is visually
338 - // distinct when scanning a selection with Cmd+A.
339 - let resp = ui.selectable_label(
340 - selected,
341 - egui::RichText::new(" Up").color(theme::content_secondary()),
342 - );
343 - if resp.clicked() {
344 - handle_click(state, 0, ui);
345 - }
346 - if resp.double_clicked() {
347 - state.go_up();
348 - }
349 - });
350 - if show_duration {
351 - row.col(|_ui| {});
352 - }
353 - if show_bpm {
354 - row.col(|_ui| {});
355 - }
356 - if show_key {
357 - row.col(|_ui| {});
358 - }
359 - if show_peak_db {
360 - row.col(|_ui| {});
361 - }
362 - if show_tags {
363 - row.col(|_ui| {});
364 - }
365 - row.col(|_ui| {});
366 - } else {
367 - let i = display_idx - parent_rows;
368 - let node = &contents[i];
369 - let row_idx = i + offset;
370 - let selected = state.nav.selection.contains(row_idx);
371 - row.set_selected(selected);
372 -
373 - // Name (with inline icon)
374 - row.col(|ui| {
375 - #[cfg(any(target_os = "macos", target_os = "windows"))]
376 - let drag_blocked = os_drag_blocked;
377 - #[cfg(not(any(target_os = "macos", target_os = "windows")))]
378 - let drag_blocked = false;
379 - draw_name_column(
380 - ui,
381 - state,
382 - node,
383 - row_idx,
384 - selected,
385 - drag_blocked,
386 - sync_manager,
387 - );
388 - });
389 -
390 - // Analysis columns (duration, BPM, key, peak dB, tags)
391 - draw_analysis_columns(
392 - &mut row,
444 + state,
393 445 node,
394 - AnalysisColumnFlags {
395 - duration: show_duration,
396 - bpm: show_bpm,
397 - key: show_key,
398 - peak_db: show_peak_db,
399 - tags: show_tags,
400 - },
446 + index,
447 + is_selected(index),
448 + drag_blocked,
449 + sync_manager,
401 450 );
402 -
403 - // Play (or Download for cloud-only) button. C-1: cloud-only
404 - // samples used to render an empty cell, leaving the row
405 - // looking half-broken. The Download button surfaces the
406 - // recovery path that previously lived only in the
407 - // right-click context menu.
408 - row.col(|ui| {
409 - if node.node.node_type != NodeType::Sample {
410 - return;
411 - }
412 - let Some(hash) = node.node.sample_hash.as_ref() else {
413 - return;
414 - };
415 - if node.cloud_only {
416 - if let Some(sync) = sync_manager
417 - && ui
418 - .button("Download")
419 - .on_hover_text("Fetch this sample from the cloud")
420 - .clicked()
421 - {
422 - let hash_str = hash.to_string();
423 - if sync.download_sample(&hash_str) {
424 - state.status = format!("Downloading {}...", node.node.name);
425 - } else {
426 - state.status =
427 - "Sync not ready: open the Sync panel first".to_string();
428 - }
429 - }
430 - } else {
431 - let is_playing = state.preview.previewing_hash.as_deref()
432 - == Some(hash)
433 - && state.shared.preview.lock().playing;
434 - let btn_text = if is_playing { "Stop" } else { "Play" };
435 - let hover = if is_playing {
436 - "Stop preview (Space)"
437 - } else {
438 - "Play preview (Space)"
439 - };
Lines truncated
@@ -395,6 +395,7 @@
395 395 elevation: t.elevation,
396 396 content: t.content,
397 397 content_muted: t.content_muted,
398 + action: t.action,
398 399 danger: t.danger,
399 400 }
400 401 }
@@ -434,7 +434,14 @@
434 434 .as_deref()
435 435 .unwrap_or("sample");
436 436 ui.label("/");
437 - ui.label(widgets::accent_strong(format!("Similar to: {name}")));
437 + // Why the file list's headings stop responding in this mode. It used to
438 + // be a hover on the headings themselves, which meant the explanation
439 + // lived on a control the user had no reason to point at; it belongs
440 + // where the mode does.
441 + ui.label(widgets::accent_strong(format!("Similar to: {name}")))
442 + .on_hover_text(
443 + "Results are ranked by similarity, so column sort is off. Clear to sort again.",
444 + );
438 445 // M-9: Clear lives at the breadcrumb segment so the mode label and
439 446 // the exit affordance occupy one row, not two.
440 447 if ui