| 197 |
197 |
|
let has_parent = state.nav.current_dir.is_some();
|
| 198 |
198 |
|
let contents = state.nav.contents.clone();
|
| 199 |
199 |
|
let offset = usize::from(has_parent);
|
| 200 |
|
- |
let col_cfg = &state.column_config;
|
| 201 |
|
- |
|
| 202 |
|
- |
// Build columns dynamically based on config
|
| 203 |
|
- |
// Icon is merged into the Name column; Play button is merged into the last data column.
|
| 204 |
|
- |
let mut table = TableBuilder::new(ui)
|
| 205 |
|
- |
.striped(true)
|
| 206 |
|
- |
.resizable(true)
|
| 207 |
|
- |
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
|
| 208 |
|
- |
.column(Column::remainder().at_least(120.0)); // Name (includes icon)
|
| 209 |
|
- |
|
| 210 |
|
- |
// Scroll to focused row when keyboard navigation requests it.
|
| 211 |
|
- |
if let Some(row) = state.nav.scroll_to_row.take() {
|
| 212 |
|
- |
table = table.scroll_to_row(row, None);
|
| 213 |
|
- |
}
|
| 214 |
|
- |
|
| 215 |
|
- |
if col_cfg.show_duration {
|
| 216 |
|
- |
table = table.column(Column::exact(60.0));
|
| 217 |
|
- |
}
|
| 218 |
|
- |
if col_cfg.show_classification {
|
| 219 |
|
- |
table = table.column(Column::exact(80.0));
|
| 220 |
|
- |
}
|
| 221 |
|
- |
if col_cfg.show_bpm {
|
| 222 |
|
- |
table = table.column(Column::exact(50.0));
|
| 223 |
|
- |
}
|
| 224 |
|
- |
if col_cfg.show_key {
|
| 225 |
|
- |
table = table.column(Column::exact(70.0));
|
| 226 |
|
- |
}
|
| 227 |
|
- |
if col_cfg.show_peak_db {
|
| 228 |
|
- |
table = table.column(Column::exact(60.0));
|
| 229 |
|
- |
}
|
| 230 |
|
- |
if col_cfg.show_tags {
|
| 231 |
|
- |
table = table.column(Column::exact(120.0));
|
| 232 |
|
- |
}
|
| 233 |
|
- |
table = table.column(Column::exact(36.0)); // Play button
|
| 234 |
|
- |
|
| 235 |
|
- |
// Snapshot column visibility flags into local bools. The `col_cfg` borrow
|
| 236 |
|
- |
// from `state` can't survive into the table-builder closures which also
|
| 237 |
|
- |
// borrow `state`, so we copy the flags here.
|
| 238 |
|
- |
let show_classification = col_cfg.show_classification;
|
| 239 |
|
- |
let show_bpm = col_cfg.show_bpm;
|
| 240 |
|
- |
let show_key = col_cfg.show_key;
|
| 241 |
|
- |
let show_duration = col_cfg.show_duration;
|
| 242 |
|
- |
let show_peak_db = col_cfg.show_peak_db;
|
| 243 |
|
- |
let show_tags = col_cfg.show_tags;
|
|
200 |
+ |
// 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_classification = state.column_config.show_classification;
|
|
204 |
+ |
let show_bpm = state.column_config.show_bpm;
|
|
205 |
+ |
let show_key = state.column_config.show_key;
|
|
206 |
+ |
let show_duration = state.column_config.show_duration;
|
|
207 |
+ |
let show_peak_db = state.column_config.show_peak_db;
|
|
208 |
+ |
let show_tags = state.column_config.show_tags;
|
| 244 |
209 |
|
|
| 245 |
210 |
|
// Snapshot sort state so the header closure doesn't borrow `state` mutably.
|
| 246 |
211 |
|
let sort_col = state.nav.sort_column;
|
| 250 |
215 |
|
// by name" silently in that view would scramble the ranking. Disable
|
| 251 |
216 |
|
// header clicks instead so the score order stays trustworthy.
|
| 252 |
217 |
|
let sort_enabled = state.search.similarity_search_hash.is_none();
|
|
218 |
+ |
// Declared outside the well because the sort toggle is applied after it,
|
|
219 |
+ |
// once every borrow the table took has ended.
|
| 253 |
220 |
|
let clicked_col = std::cell::Cell::new(None::<SortColumn>);
|
| 254 |
221 |
|
|
| 255 |
|
- |
table
|
| 256 |
|
- |
.header(20.0, |mut header| {
|
| 257 |
|
- |
header.col(|ui| {
|
| 258 |
|
- |
if draw_sort_header(
|
| 259 |
|
- |
ui,
|
| 260 |
|
- |
"Name",
|
| 261 |
|
- |
SortColumn::Name,
|
| 262 |
|
- |
sort_col,
|
| 263 |
|
- |
&sort_dir,
|
| 264 |
|
- |
sort_enabled,
|
| 265 |
|
- |
) {
|
| 266 |
|
- |
clicked_col.set(Some(SortColumn::Name));
|
| 267 |
|
- |
}
|
| 268 |
|
- |
});
|
| 269 |
|
- |
if show_duration {
|
| 270 |
|
- |
header.col(|ui| {
|
| 271 |
|
- |
if draw_sort_header(
|
| 272 |
|
- |
ui,
|
| 273 |
|
- |
"Dur",
|
| 274 |
|
- |
SortColumn::Duration,
|
| 275 |
|
- |
sort_col,
|
| 276 |
|
- |
&sort_dir,
|
| 277 |
|
- |
sort_enabled,
|
| 278 |
|
- |
) {
|
| 279 |
|
- |
clicked_col.set(Some(SortColumn::Duration));
|
| 280 |
|
- |
}
|
| 281 |
|
- |
});
|
| 282 |
|
- |
}
|
| 283 |
|
- |
if show_classification {
|
| 284 |
|
- |
header.col(|ui| {
|
| 285 |
|
- |
if draw_sort_header(
|
| 286 |
|
- |
ui,
|
| 287 |
|
- |
"Class",
|
| 288 |
|
- |
SortColumn::Classification,
|
| 289 |
|
- |
sort_col,
|
| 290 |
|
- |
&sort_dir,
|
| 291 |
|
- |
sort_enabled,
|
| 292 |
|
- |
) {
|
| 293 |
|
- |
clicked_col.set(Some(SortColumn::Classification));
|
| 294 |
|
- |
}
|
| 295 |
|
- |
});
|
| 296 |
|
- |
}
|
| 297 |
|
- |
if show_bpm {
|
| 298 |
|
- |
header.col(|ui| {
|
| 299 |
|
- |
if draw_sort_header(
|
| 300 |
|
- |
ui,
|
| 301 |
|
- |
"BPM",
|
| 302 |
|
- |
SortColumn::Bpm,
|
| 303 |
|
- |
sort_col,
|
| 304 |
|
- |
&sort_dir,
|
| 305 |
|
- |
sort_enabled,
|
| 306 |
|
- |
) {
|
| 307 |
|
- |
clicked_col.set(Some(SortColumn::Bpm));
|
| 308 |
|
- |
}
|
| 309 |
|
- |
});
|
| 310 |
|
- |
}
|
| 311 |
|
- |
if show_key {
|
| 312 |
|
- |
header.col(|ui| {
|
| 313 |
|
- |
if draw_sort_header(
|
| 314 |
|
- |
ui,
|
| 315 |
|
- |
"Key",
|
| 316 |
|
- |
SortColumn::Key,
|
| 317 |
|
- |
sort_col,
|
| 318 |
|
- |
&sort_dir,
|
| 319 |
|
- |
sort_enabled,
|
| 320 |
|
- |
) {
|
| 321 |
|
- |
clicked_col.set(Some(SortColumn::Key));
|
| 322 |
|
- |
}
|
| 323 |
|
- |
});
|
| 324 |
|
- |
}
|
| 325 |
|
- |
if show_peak_db {
|
| 326 |
|
- |
header.col(|ui| {
|
| 327 |
|
- |
ui.label(egui::RichText::new("Peak").color(theme::content_secondary()));
|
| 328 |
|
- |
});
|
| 329 |
|
- |
}
|
| 330 |
|
- |
if show_tags {
|
| 331 |
|
- |
header.col(|ui| {
|
| 332 |
|
- |
ui.label(egui::RichText::new("Tags").color(theme::content_secondary()));
|
| 333 |
|
- |
});
|
| 334 |
|
- |
}
|
| 335 |
|
- |
header.col(|ui| {
|
| 336 |
|
- |
ui.label(egui::RichText::new("Play").color(theme::content_muted()));
|
| 337 |
|
- |
});
|
| 338 |
|
- |
})
|
| 339 |
|
- |
.body(|body| {
|
| 340 |
|
- |
// Virtualized: egui lays out only the visible rows, so a folder with
|
| 341 |
|
- |
// thousands of children no longer re-lays-out every row each frame.
|
| 342 |
|
- |
// Display index 0 is the ".." parent entry when present; the rest map
|
| 343 |
|
- |
// into `contents`.
|
| 344 |
|
- |
let parent_rows = usize::from(has_parent);
|
| 345 |
|
- |
body.rows(row_height, parent_rows + contents.len(), |mut row| {
|
| 346 |
|
- |
let display_idx = row.index();
|
| 347 |
|
- |
if has_parent && display_idx == 0 {
|
| 348 |
|
- |
let selected = state.nav.selection.contains(0);
|
| 349 |
|
- |
row.set_selected(selected);
|
| 350 |
|
- |
row.col(|ui| {
|
| 351 |
|
- |
// Parent ".." entry: render muted so it reads as
|
| 352 |
|
- |
// navigation rather than a sample row, and is visually
|
| 353 |
|
- |
// distinct when scanning a selection with Cmd+A.
|
| 354 |
|
- |
let resp = ui.selectable_label(
|
| 355 |
|
- |
selected,
|
| 356 |
|
- |
egui::RichText::new(" Up").color(theme::content_secondary()),
|
| 357 |
|
- |
);
|
| 358 |
|
- |
if resp.clicked() {
|
| 359 |
|
- |
handle_click(state, 0, ui);
|
| 360 |
|
- |
}
|
| 361 |
|
- |
if resp.double_clicked() {
|
| 362 |
|
- |
state.go_up();
|
| 363 |
|
- |
}
|
| 364 |
|
- |
});
|
| 365 |
|
- |
if show_duration {
|
| 366 |
|
- |
row.col(|_ui| {});
|
| 367 |
|
- |
}
|
| 368 |
|
- |
if show_classification {
|
| 369 |
|
- |
row.col(|_ui| {});
|
| 370 |
|
- |
}
|
| 371 |
|
- |
if show_bpm {
|
| 372 |
|
- |
row.col(|_ui| {});
|
| 373 |
|
- |
}
|
| 374 |
|
- |
if show_key {
|
| 375 |
|
- |
row.col(|_ui| {});
|
| 376 |
|
- |
}
|
| 377 |
|
- |
if show_peak_db {
|
| 378 |
|
- |
row.col(|_ui| {});
|
| 379 |
|
- |
}
|
| 380 |
|
- |
if show_tags {
|
| 381 |
|
- |
row.col(|_ui| {});
|
| 382 |
|
- |
}
|
| 383 |
|
- |
row.col(|_ui| {});
|
| 384 |
|
- |
} else {
|
| 385 |
|
- |
let i = display_idx - parent_rows;
|
| 386 |
|
- |
let node = &contents[i];
|
| 387 |
|
- |
let row_idx = i + offset;
|
| 388 |
|
- |
let selected = state.nav.selection.contains(row_idx);
|
| 389 |
|
- |
row.set_selected(selected);
|
|
222 |
+ |
// The table body is a well: it is the thing the user looks into, and the
|
|
223 |
+ |
// panel chrome around it sits on top. See `docs/design-system.md`.
|
|
224 |
+ |
widgets::inset_well(ui, |ui| {
|
|
225 |
+ |
// Build columns dynamically based on config
|
|
226 |
+ |
// Icon is merged into the Name column; Play button is merged into the last data column.
|
|
227 |
+ |
let mut table = TableBuilder::new(ui)
|
|
228 |
+ |
.striped(true)
|
|
229 |
+ |
.resizable(true)
|
|
230 |
+ |
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
|
|
231 |
+ |
.column(Column::remainder().at_least(120.0)); // Name (includes icon)
|
| 390 |
232 |
|
|
| 391 |
|
- |
// Name (with inline icon)
|
| 392 |
|
- |
row.col(|ui| {
|
| 393 |
|
- |
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
| 394 |
|
- |
let drag_blocked = os_drag_blocked;
|
| 395 |
|
- |
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
| 396 |
|
- |
let drag_blocked = false;
|
| 397 |
|
- |
draw_name_column(
|
|
233 |
+ |
// Scroll to focused row when keyboard navigation requests it.
|
|
234 |
+ |
if let Some(row) = state.nav.scroll_to_row.take() {
|
|
235 |
+ |
table = table.scroll_to_row(row, None);
|
|
236 |
+ |
}
|
|
237 |
+ |
|
|
238 |
+ |
if show_duration {
|
|
239 |
+ |
table = table.column(Column::exact(60.0));
|
|
240 |
+ |
}
|
|
241 |
+ |
if show_classification {
|
|
242 |
+ |
table = table.column(Column::exact(80.0));
|
|
243 |
+ |
}
|
|
244 |
+ |
if show_bpm {
|
|
245 |
+ |
table = table.column(Column::exact(50.0));
|
|
246 |
+ |
}
|
|
247 |
+ |
if show_key {
|
|
248 |
+ |
table = table.column(Column::exact(70.0));
|
|
249 |
+ |
}
|
|
250 |
+ |
if show_peak_db {
|
|
251 |
+ |
table = table.column(Column::exact(60.0));
|
|
252 |
+ |
}
|
|
253 |
+ |
if show_tags {
|
|
254 |
+ |
table = table.column(Column::exact(120.0));
|
|
255 |
+ |
}
|
|
256 |
+ |
table = table.column(Column::exact(36.0)); // Play button
|
|
257 |
+ |
|
|
258 |
+ |
table
|
|
259 |
+ |
.header(20.0, |mut header| {
|
|
260 |
+ |
header.col(|ui| {
|
|
261 |
+ |
if draw_sort_header(
|
|
262 |
+ |
ui,
|
|
263 |
+ |
"Name",
|
|
264 |
+ |
SortColumn::Name,
|
|
265 |
+ |
sort_col,
|
|
266 |
+ |
&sort_dir,
|
|
267 |
+ |
sort_enabled,
|
|
268 |
+ |
) {
|
|
269 |
+ |
clicked_col.set(Some(SortColumn::Name));
|
|
270 |
+ |
}
|
|
271 |
+ |
});
|
|
272 |
+ |
if show_duration {
|
|
273 |
+ |
header.col(|ui| {
|
|
274 |
+ |
if draw_sort_header(
|
| 398 |
275 |
|
ui,
|
| 399 |
|
- |
state,
|
| 400 |
|
- |
node,
|
| 401 |
|
- |
row_idx,
|
| 402 |
|
- |
selected,
|
| 403 |
|
- |
drag_blocked,
|
| 404 |
|
- |
sync_manager,
|
| 405 |
|
- |
);
|
| 406 |
|
- |
});
|
| 407 |
|
- |
|
| 408 |
|
- |
// Analysis columns (duration, classification, BPM, key, peak dB, tags)
|
| 409 |
|
- |
draw_analysis_columns(
|
| 410 |
|
- |
&mut row,
|
| 411 |
|
- |
node,
|
| 412 |
|
- |
AnalysisColumnFlags {
|
| 413 |
|
- |
duration: show_duration,
|
| 414 |
|
- |
classification: show_classification,
|
| 415 |
|
- |
bpm: show_bpm,
|
| 416 |
|
- |
key: show_key,
|
| 417 |
|
- |
peak_db: show_peak_db,
|
| 418 |
|
- |
tags: show_tags,
|
| 419 |
|
- |
},
|
| 420 |
|
- |
);
|
| 421 |
|
- |
|
| 422 |
|
- |
// Play (or Download for cloud-only) button. C-1: cloud-only
|
| 423 |
|
- |
// samples used to render an empty cell, leaving the row
|
| 424 |
|
- |
// looking half-broken. The Download button surfaces the
|
| 425 |
|
- |
// recovery path that previously lived only in the
|
| 426 |
|
- |
// right-click context menu.
|
| 427 |
|
- |
row.col(|ui| {
|
| 428 |
|
- |
if node.node.node_type != NodeType::Sample {
|
| 429 |
|
- |
return;
|
| 430 |
|
- |
}
|
| 431 |
|
- |
let Some(hash) = node.node.sample_hash.as_ref() else {
|
| 432 |
|
- |
return;
|
| 433 |
|
- |
};
|
| 434 |
|
- |
if node.cloud_only {
|
| 435 |
|
- |
if let Some(sync) = sync_manager
|
| 436 |
|
- |
&& ui
|
| 437 |
|
- |
.button("Download")
|
| 438 |
|
- |
.on_hover_text("Fetch this sample from the cloud")
|
| 439 |
|
- |
.clicked()
|
| 440 |
|
- |
{
|
| 441 |
|
- |
let hash_str = hash.to_string();
|
| 442 |
|
- |
if sync.download_sample(&hash_str) {
|
| 443 |
|
- |
state.status = format!("Downloading {}...", node.node.name);
|
| 444 |
|
- |
} else {
|
| 445 |
|
- |
state.status =
|
| 446 |
|
- |
"Sync not ready: open the Sync panel first".to_string();
|
| 447 |
|
- |
}
|
| 448 |
|
- |
}
|
| 449 |
|
- |
} else {
|
| 450 |
|
- |
let is_playing = state.preview.previewing_hash.as_deref() == Some(hash)
|
| 451 |
|
- |
&& state.shared.preview.lock().playing;
|
| 452 |
|
- |
let btn_text = if is_playing { "Stop" } else { "Play" };
|
| 453 |
|
- |
let hover = if is_playing {
|
| 454 |
|
- |
"Stop preview (Space)"
|
| 455 |
|
- |
} else {
|
| 456 |
|
- |
"Play preview (Space)"
|
| 457 |
|
- |
};
|
| 458 |
|
- |
if ui.button(btn_text).on_hover_text(hover).clicked() {
|
| 459 |
|
- |
if is_playing {
|
| 460 |
|
- |
state.stop_preview();
|
| 461 |
|
- |
} else {
|
| 462 |
|
- |
let hash = hash.clone();
|
| 463 |
|
- |
state.trigger_preview(&hash);
|
| 464 |
|
- |
}
|
| 465 |
|
- |
}
|
|
276 |
+ |
"Dur",
|
|
277 |
+ |
SortColumn::Duration,
|
|
278 |
+ |
sort_col,
|
|
279 |
+ |
&sort_dir,
|
|
280 |
+ |
sort_enabled,
|
|
281 |
+ |
) {
|
|
282 |
+ |
clicked_col.set(Some(SortColumn::Duration));
|
| 466 |
283 |
|
}
|
| 467 |
284 |
|
});
|
| 468 |
285 |
|
}
|
|
286 |
+ |
if show_classification {
|
|
287 |
+ |
header.col(|ui| {
|
|
288 |
+ |
if draw_sort_header(
|
|
289 |
+ |
ui,
|
|
290 |
+ |
"Class",
|
|
291 |
+ |
SortColumn::Classification,
|
|
292 |
+ |
sort_col,
|
|
293 |
+ |
&sort_dir,
|
|
294 |
+ |
sort_enabled,
|
|
295 |
+ |
) {
|
|
296 |
+ |
clicked_col.set(Some(SortColumn::Classification));
|
|
297 |
+ |
}
|
|
298 |
+ |
});
|
|
299 |
+ |
}
|
|
300 |
+ |
if show_bpm {
|
|
301 |
+ |
header.col(|ui| {
|
|
302 |
+ |
if draw_sort_header(
|
|
303 |
+ |
ui,
|
|
304 |
+ |
"BPM",
|
|
305 |
+ |
SortColumn::Bpm,
|
|
306 |
+ |
sort_col,
|
|
307 |
+ |
&sort_dir,
|
|
308 |
+ |
sort_enabled,
|
|
309 |
+ |
) {
|
|
310 |
+ |
clicked_col.set(Some(SortColumn::Bpm));
|
|
311 |
+ |
}
|
|
312 |
+ |
});
|
|
313 |
+ |
}
|
|
314 |
+ |
if show_key {
|
|
315 |
+ |
header.col(|ui| {
|
|
316 |
+ |
if draw_sort_header(
|
|
317 |
+ |
ui,
|
|
318 |
+ |
"Key",
|
|
319 |
+ |
SortColumn::Key,
|
|
320 |
+ |
sort_col,
|
|
321 |
+ |
&sort_dir,
|
|
322 |
+ |
sort_enabled,
|
|
323 |
+ |
) {
|
|
324 |
+ |
clicked_col.set(Some(SortColumn::Key));
|
|
325 |
+ |
}
|
|
326 |
+ |
});
|
|
327 |
+ |
}
|
|
328 |
+ |
if show_peak_db {
|
|
329 |
+ |
header.col(|ui| {
|
|
330 |
+ |
ui.label(egui::RichText::new("Peak").color(theme::content_secondary()));
|
|
331 |
+ |
});
|
|
332 |
+ |
}
|
|
333 |
+ |
if show_tags {
|
|
334 |
+ |
header.col(|ui| {
|
|
335 |
+ |
ui.label(egui::RichText::new("Tags").color(theme::content_secondary()));
|
|
336 |
+ |
});
|
|
337 |
+ |
}
|
|
338 |
+ |
header.col(|ui| {
|
|
339 |
+ |
ui.label(egui::RichText::new("Play").color(theme::content_muted()));
|
|
340 |
+ |
});
|
|
341 |
+ |
})
|
|
342 |
+ |
.body(|body| {
|
|
343 |
+ |
// Virtualized: egui lays out only the visible rows, so a folder with
|
|
344 |
+ |
// thousands of children no longer re-lays-out every row each frame.
|
|
345 |
+ |
// Display index 0 is the ".." parent entry when present; the rest map
|
|
346 |
+ |
// into `contents`.
|
|
347 |
+ |
let parent_rows = usize::from(has_parent);
|
|
348 |
+ |
body.rows(row_height, parent_rows + contents.len(), |mut row| {
|
|
349 |
+ |
let display_idx = row.index();
|
|
350 |
+ |
if has_parent && display_idx == 0 {
|
|
351 |
+ |
let selected = state.nav.selection.contains(0);
|
|
352 |
+ |
row.set_selected(selected);
|
|
353 |
+ |
row.col(|ui| {
|
|
354 |
+ |
// Parent ".." entry: render muted so it reads as
|
|
355 |
+ |
// navigation rather than a sample row, and is visually
|
|
356 |
+ |
// distinct when scanning a selection with Cmd+A.
|
|
357 |
+ |
let resp = ui.selectable_label(
|
|
358 |
+ |
selected,
|
|
359 |
+ |
egui::RichText::new(" Up").color(theme::content_secondary()),
|
|
360 |
+ |
);
|
|
361 |
+ |
if resp.clicked() {
|
|
362 |
+ |
handle_click(state, 0, ui);
|
|
363 |
+ |
}
|
|
364 |
+ |
if resp.double_clicked() {
|
|
365 |
+ |
state.go_up();
|
|
366 |
+ |
}
|
|
367 |
+ |
});
|
|
368 |
+ |
if show_duration {
|
|
369 |
+ |
row.col(|_ui| {});
|
|
370 |
+ |
}
|
|
371 |
+ |
if show_classification {
|
|
372 |
+ |
row.col(|_ui| {});
|
|
373 |
+ |
}
|
|
374 |
+ |
if show_bpm {
|
|
375 |
+ |
row.col(|_ui| {});
|
|
376 |
+ |
}
|
|
377 |
+ |
if show_key {
|
|
378 |
+ |
row.col(|_ui| {});
|
|
379 |
+ |
}
|
|
380 |
+ |
if show_peak_db {
|
|
381 |
+ |
row.col(|_ui| {});
|
|
382 |
+ |
}
|
|
383 |
+ |
if show_tags {
|
|
384 |
+ |
row.col(|_ui| {});
|
|
385 |
+ |
}
|
|
386 |
+ |
row.col(|_ui| {});
|
|
387 |
+ |
} else {
|
|
388 |
+ |
let i = display_idx - parent_rows;
|
|
389 |
+ |
let node = &contents[i];
|
|
390 |
+ |
let row_idx = i + offset;
|
|
391 |
+ |
let selected = state.nav.selection.contains(row_idx);
|
|
392 |
+ |
row.set_selected(selected);
|
|
393 |
+ |
|
|
394 |
+ |
// Name (with inline icon)
|
|
395 |
+ |
row.col(|ui| {
|
|
396 |
+ |
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
|
397 |
+ |
let drag_blocked = os_drag_blocked;
|
|
398 |
+ |
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
399 |
+ |
let drag_blocked = false;
|
|
400 |
+ |
draw_name_column(
|
|
401 |
+ |
ui,
|
|
402 |
+ |
state,
|
|
403 |
+ |
node,
|
|
404 |
+ |
row_idx,
|
|
405 |
+ |
selected,
|
|
406 |
+ |
drag_blocked,
|
|
407 |
+ |
sync_manager,
|
|
408 |
+ |
);
|
|
409 |
+ |
});
|
|
410 |
+ |
|
|
411 |
+ |
// Analysis columns (duration, classification, BPM, key, peak dB, tags)
|
|
412 |
+ |
draw_analysis_columns(
|
|
413 |
+ |
&mut row,
|
|
414 |
+ |
node,
|
|
415 |
+ |
AnalysisColumnFlags {
|
|
416 |
+ |
duration: show_duration,
|
|
417 |
+ |
classification: show_classification,
|
|
418 |
+ |
bpm: show_bpm,
|
|
419 |
+ |
key: show_key,
|
|
420 |
+ |
peak_db: show_peak_db,
|
|
421 |
+ |
tags: show_tags,
|
|
422 |
+ |
},
|
|
423 |
+ |
);
|
|
424 |
+ |
|
|
425 |
+ |
// Play (or Download for cloud-only) button. C-1: cloud-only
|
|
426 |
+ |
// samples used to render an empty cell, leaving the row
|
|
427 |
+ |
// looking half-broken. The Download button surfaces the
|
|
428 |
+ |
// recovery path that previously lived only in the
|
|
429 |
+ |
// right-click context menu.
|
|
430 |
+ |
row.col(|ui| {
|
|
431 |
+ |
if node.node.node_type != NodeType::Sample {
|
|
432 |
+ |
return;
|
|
433 |
+ |
}
|
|
434 |
+ |
let Some(hash) = node.node.sample_hash.as_ref() else {
|
|
435 |
+ |
return;
|
|
436 |
+ |
};
|
|
437 |
+ |
if node.cloud_only {
|
|
438 |
+ |
if let Some(sync) = sync_manager
|
|
439 |
+ |
&& ui
|
|
440 |
+ |
.button("Download")
|
|
441 |
+ |
.on_hover_text("Fetch this sample from the cloud")
|
|
442 |
+ |
.clicked()
|
|
443 |
+ |
{
|
|
444 |
+ |
let hash_str = hash.to_string();
|
|
445 |
+ |
if sync.download_sample(&hash_str) {
|
|
446 |
+ |
state.status = format!("Downloading {}...", node.node.name);
|