| 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 |
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 |
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 |
|
- |
};
|