Skip to main content

max / audiofiles

25.6 KB · 598 lines History Blame Raw
1 //! Left sidebar: VFS roots and tag tree.
2
3 use std::collections::BTreeMap;
4
5 use egui;
6
7 use crate::state::BrowserState;
8 use super::theme;
9 use super::widgets;
10
11 /// A node in the tag tree built from dot-separated tag names.
12 struct TagNode {
13 children: BTreeMap<String, TagNode>,
14 is_leaf: bool,
15 }
16
17 impl TagNode {
18 fn new() -> Self {
19 Self {
20 children: BTreeMap::new(),
21 is_leaf: false,
22 }
23 }
24
25 /// Insert a tag into the tree by splitting on `.`.
26 fn insert(&mut self, tag: &str) {
27 let mut current = self;
28 let segments: Vec<&str> = tag.split('.').collect();
29 for (i, seg) in segments.iter().enumerate() {
30 current = current.children.entry((*seg).to_string()).or_insert_with(TagNode::new);
31 if i == segments.len() - 1 {
32 current.is_leaf = true;
33 }
34 }
35 }
36 }
37
38 /// Build a tag tree from a sorted list of dotted tag strings.
39 fn build_tag_tree(tags: &[String]) -> BTreeMap<String, TagNode> {
40 let mut root = TagNode::new();
41 for tag in tags {
42 root.insert(tag);
43 }
44 root.children
45 }
46
47 /// Check if this path or any descendant is active in required_tags.
48 fn any_descendant_active(prefix: &str, required_tags: &[String]) -> bool {
49 required_tags.iter().any(|t| t == prefix || t.starts_with(&format!("{prefix}.")))
50 }
51
52 /// Wire the per-tag right-click menu: Filter / Rename / Remove from every
53 /// sample. Destructive removal routes through `pending_confirm`; rename opens
54 /// an inline edit row (`tag_rename_target`).
55 fn tag_context_menu(response: egui::Response, tag: &str, state: &mut BrowserState) {
56 response.context_menu(|ui| {
57 if ui.button("Rename tag…").clicked() {
58 state.tag_rename_target = Some((tag.to_string(), tag.to_string()));
59 state.focus_inline_editor = true;
60 // M-12: compute affected-sample count + descendant tags now so the
61 // modal can show the consequences before the user commits. Descendants
62 // are not propagated by `rename_tag_globally` (exact-match-only).
63 let count = state.backend.count_samples_with_tag(tag).unwrap_or(0);
64 let prefix = format!("{tag}.");
65 let descendants: Vec<String> = state
66 .all_tags
67 .iter()
68 .filter(|t| t.starts_with(&prefix))
69 .cloned()
70 .collect();
71 state.tag_rename_preview = Some((count, descendants));
72 ui.close();
73 }
74 if widgets::danger_button(ui, "Remove from all samples…").clicked() {
75 state.pending_confirm = Some(crate::state::ConfirmAction::RemoveTagGlobally {
76 tag: tag.to_string(),
77 });
78 ui.close();
79 }
80 });
81 }
82
83 /// Draw a single tag tree node recursively.
84 fn draw_tag_node(
85 ui: &mut egui::Ui,
86 prefix: &str,
87 segment: &str,
88 node: &TagNode,
89 state: &mut BrowserState,
90 ) {
91 let full_path = if prefix.is_empty() {
92 segment.to_string()
93 } else {
94 format!("{prefix}.{segment}")
95 };
96
97 let is_active = state.search_filter.required_tags.contains(&full_path);
98 let has_active_descendant = any_descendant_active(&full_path, &state.search_filter.required_tags);
99
100 if node.children.is_empty() {
101 // Pure leaf — no children, no disclosure widget. Whole row toggles filter.
102 let hover = if is_active {
103 format!("Remove \"{full_path}\" filter")
104 } else {
105 format!("Filter by \"{full_path}\"")
106 };
107 let resp = widgets::selectable_tag(ui, is_active, segment).on_hover_text(hover);
108 if resp.clicked() {
109 if is_active {
110 state.search_filter.required_tags.retain(|t| t != &full_path);
111 } else {
112 state.search_filter.required_tags.push(full_path.clone());
113 }
114 state.apply_search();
115 }
116 tag_context_menu(resp, &full_path, state);
117 } else {
118 // Parent node — render the disclosure chevron as a distinct hit target
119 // from the label, so the user can expand the tree without committing
120 // to a filter (and vice versa). Parents that are themselves tagged
121 // (`is_leaf == true`) render the label as a clickable filter; parents
122 // that are purely organizational render the label as a plain marker
123 // (filtering by them would match zero samples).
124 let id = ui.make_persistent_id(&full_path);
125 // M-5: top-level tag nodes default to open so the user sees the
126 // taxonomy they've already built without click-by-click expansion.
127 // Deeper nodes still default closed to keep deep trees scannable.
128 // egui's persistent state means user toggles override this anyway.
129 let cstate_default_open = prefix.is_empty();
130 let mut cstate = egui::collapsing_header::CollapsingState::load_with_default_open(
131 ui.ctx(),
132 id,
133 cstate_default_open,
134 );
135 let header_resp = ui
136 .horizontal(|ui| {
137 cstate.show_toggle_button(ui, egui::collapsing_header::paint_default_icon);
138 if node.is_leaf {
139 let hover = if is_active {
140 format!("Remove \"{full_path}\" filter")
141 } else {
142 format!("Filter by \"{full_path}\" (exact)")
143 };
144 let resp = widgets::selectable_tag(ui, is_active, segment).on_hover_text(hover);
145 if resp.clicked() {
146 if is_active {
147 state.search_filter.required_tags.retain(|t| t != &full_path);
148 } else {
149 state.search_filter.required_tags.push(full_path.clone());
150 }
151 state.apply_search();
152 }
153 tag_context_menu(resp, &full_path, state);
154 } else {
155 // Organizational parent: colour by descendant-active state,
156 // but the label is not interactive — there are no samples
157 // tagged at this exact path to filter to.
158 let color = if has_active_descendant {
159 theme::accent_blue()
160 } else {
161 theme::text_secondary()
162 };
163 ui.label(egui::RichText::new(segment).color(color));
164 }
165 })
166 .response;
167 cstate.show_body_indented(&header_resp, ui, |ui| {
168 for (child_seg, child_node) in &node.children {
169 draw_tag_node(ui, &full_path, child_seg, child_node, state);
170 }
171 });
172 }
173 }
174
175 /// Draw the sidebar panel content: vault picker, VFS list, tags section.
176 pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
177 // Library selector — switches between top-level libraries (separate
178 // databases). Only shown when more than one library is registered;
179 // single-library installs see only the inner "Vaults" list below.
180 if state.settings.list.len() > 1 {
181 ui.horizontal(|ui| {
182 ui.label(egui::RichText::new("Library").small().color(theme::text_muted()));
183 egui::ComboBox::from_id_salt("library_picker")
184 .selected_text(&state.settings.name)
185 .width(ui.available_width() - 8.0)
186 .show_ui(ui, |ui| {
187 let mut switch_to: Option<(std::path::PathBuf, String)> = None;
188 for (name, path, reachable) in &state.settings.list {
189 let is_active = path == &state.data_dir;
190 let label = if *reachable {
191 name.clone()
192 } else {
193 format!("{name} (offline)")
194 };
195 if ui.selectable_label(is_active, &label).clicked() && !is_active && *reachable {
196 switch_to = Some((path.clone(), name.clone()));
197 }
198 }
199 if let Some((path, name)) = switch_to {
200 // Guard against accidentally cancelling in-flight work.
201 if state.has_in_flight_work() {
202 state.pending_confirm = Some(
203 crate::state::ConfirmAction::SwitchLibrary {
204 path,
205 library_name: name,
206 },
207 );
208 } else {
209 state.settings.pending_action =
210 Some(crate::state::VaultAction::SwitchVault(path));
211 }
212 }
213 ui.separator();
214 if ui.button("Settings...").clicked() {
215 state.settings.show_manager = true;
216 }
217 });
218 });
219 ui.add_space(theme::space::SM);
220 ui.separator();
221 } else if !state.settings.list.is_empty() {
222 // Single library — just show a "Settings..." link
223 ui.horizontal(|ui| {
224 ui.label(egui::RichText::new(&state.settings.name).small().color(theme::text_muted()));
225 if ui.small_button("Settings").on_hover_text("Open library settings").clicked() {
226 state.settings.show_manager = true;
227 }
228 });
229 ui.add_space(theme::space::SM);
230 ui.separator();
231 }
232
233 let vfs_list = state.vfs_list.clone();
234 let vfs_count = vfs_list.len();
235
236 // The "Vaults" section header carries weight only when there are multiple
237 // VFS roots to navigate between. A single-row "Vaults" section is just
238 // padding — drop the header in that case and let the row speak for itself.
239 if vfs_count > 1 {
240 widgets::section_header(ui, "Vaults");
241 } else {
242 ui.add_space(theme::space::SM);
243 }
244
245 if state.show_vfs_banner {
246 widgets::info_banner(
247 ui,
248 "A vault is your sample collection. Files stay where they are \u{2014} audiofiles just indexes them.",
249 );
250 if ui.small_button("Got it").clicked() {
251 state.show_vfs_banner = false;
252 let _ = state.backend.set_config("vfs_explained", "1");
253 }
254 ui.add_space(theme::space::SM);
255 }
256
257 // "+ New Vault" pinned above the list so it's reachable without scrolling
258 // when the list gets long.
259 if ui.button("+ New Vault").on_hover_text("Create a new vault to organize samples").clicked() {
260 state.show_vfs_create = true;
261 state.vfs_create_input.clear();
262 }
263 ui.add_space(theme::space::SM);
264
265 // VFS roots as vertical list
266 for (i, vfs) in vfs_list.iter().enumerate() {
267 let active = i == state.current_vfs_idx;
268 let resp = widgets::selectable_row(ui, active, &vfs.name)
269 .on_hover_text(format!("Switch to {} vault", vfs.name));
270 if resp.clicked() && !active {
271 // Active-row re-click would silently reset navigation (clears
272 // current_dir, breadcrumb, selection). Make it a no-op so the click
273 // matches user expectation; a dedicated "Go to root" path can still
274 // reset if needed.
275 if i != state.current_vfs_idx {
276 state.select_vfs(i);
277 }
278 }
279 let vfs_id = vfs.id;
280 let vfs_name = vfs.name.clone();
281 resp.context_menu(|ui| {
282 if ui.button("Rename").clicked() {
283 state.vfs_rename_target = Some((vfs_id, vfs_name.clone()));
284 ui.close();
285 }
286 // Always render Delete so the user can see the capability exists;
287 // disable when removing it would leave zero vaults. Routed through
288 // the shared danger affordance for consistency with collection/tag
289 // deletes (P2).
290 let delete_enabled = vfs_count > 1;
291 let delete_resp = widgets::danger_button_enabled(ui, "Delete", delete_enabled);
292 let delete_resp = if !delete_enabled {
293 delete_resp.on_disabled_hover_text(
294 "Create another vault first — audiofiles needs at least one.",
295 )
296 } else {
297 delete_resp
298 };
299 if delete_resp.clicked() {
300 state.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs { vfs_id, vfs_name });
301 ui.close();
302 }
303 });
304 }
305
306 ui.add_space(theme::space::LG);
307 ui.separator();
308
309 // Collections section (manual + dynamic/saved-search)
310 ui.collapsing("Collections", |ui| {
311 if state.collections.is_empty() && !state.show_collection_create {
312 ui.horizontal(|ui| {
313 ui.label(egui::RichText::new("No collections yet.").color(theme::text_muted()));
314 if ui.link(egui::RichText::new("Create one").color(theme::accent_blue())).clicked() {
315 state.show_collection_create = true;
316 state.collection_create_input.clear();
317 state.focus_inline_editor = true;
318 }
319 });
320 } else {
321 let collections = state.collections.clone();
322 let active_id = state.active_collection;
323 let mut delete_id: Option<(audiofiles_core::CollectionId, String)> = None;
324 for coll in &collections {
325 let is_active = active_id == Some(coll.id);
326 // Dynamic collections re-apply their saved filter; manual collections
327 // hold a fixed sample set. Distinguish with a text suffix instead of a
328 // glyph (per the no-emoji brand rule, and for accessibility).
329 let suffix = if coll.is_dynamic() {
330 " (auto)".to_string()
331 } else {
332 format!(" ({})", coll.member_count)
333 };
334 let label_text = format!("{}{}", coll.name, suffix);
335 let hover = if coll.is_dynamic() {
336 format!("Apply \"{}\" saved search (auto-updates when samples match)", coll.name)
337 } else {
338 format!("Show \"{}\" contents", coll.name)
339 };
340 let resp = widgets::selectable_row_secondary(ui, is_active, label_text)
341 .on_hover_text(hover);
342 if resp.clicked() {
343 if is_active {
344 state.deactivate_collection();
345 } else if let Some(ref filter) = coll.filter {
346 state.activate_dynamic_collection(coll.id, filter);
347 } else {
348 state.activate_collection(coll.id);
349 }
350 }
351 let coll_id = coll.id;
352 let coll_name = coll.name.clone();
353 resp.context_menu(|ui| {
354 if ui.button("Rename").clicked() {
355 state.collection_rename_target = Some((coll_id, coll_name.clone()));
356 state.focus_inline_editor = true;
357 ui.close();
358 }
359 if widgets::danger_button(ui, "Delete").clicked() {
360 delete_id = Some((coll_id, coll_name.clone()));
361 ui.close();
362 }
363 });
364 }
365 if let Some((id, name)) = delete_id {
366 state.pending_confirm = Some(
367 crate::state::ConfirmAction::DeleteCollection { coll_id: id, coll_name: name },
368 );
369 }
370 }
371
372 // Inline rename modal
373 if let Some((rename_id, _)) = state.collection_rename_target.clone() {
374 // Show the original name as context so the user retains the reference
375 // even if they clear the input to type a fresh value.
376 let original = state.collections.iter()
377 .find(|c| c.id == rename_id)
378 .map(|c| c.name.clone());
379 if let Some(orig) = original {
380 ui.label(
381 egui::RichText::new(format!("Renaming: {orig}"))
382 .small()
383 .color(theme::text_muted()),
384 );
385 }
386 let want_focus = std::mem::take(&mut state.focus_inline_editor);
387 ui.horizontal(|ui| {
388 let Some((_, buf)) = state.collection_rename_target.as_mut() else { return; };
389 let resp = ui.text_edit_singleline(buf);
390 if want_focus {
391 resp.request_focus();
392 }
393 let mut commit = false;
394 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
395 commit = true;
396 }
397 if ui.button("Cancel").clicked()
398 || ui.input(|i| i.key_pressed(egui::Key::Escape))
399 {
400 state.collection_rename_target = None;
401 return;
402 }
403 if ui.button("Rename").clicked() {
404 commit = true;
405 }
406 if commit {
407 let new_name = buf.trim().to_string();
408 if !new_name.is_empty() {
409 let _ = state.backend.rename_collection(rename_id, &new_name);
410 state.refresh_collections();
411 }
412 state.collection_rename_target = None;
413 }
414 });
415 }
416
417 // Inline create input
418 if state.show_collection_create {
419 let want_focus = std::mem::take(&mut state.focus_inline_editor);
420 ui.horizontal(|ui| {
421 let resp = ui.text_edit_singleline(&mut state.collection_create_input);
422 if want_focus {
423 resp.request_focus();
424 }
425 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
426 let name = state.collection_create_input.trim().to_string();
427 if !name.is_empty() {
428 match state.backend.create_collection(&name, None) {
429 Ok(_) => {
430 state.status = format!("Created collection: {name}");
431 }
432 Err(e) => {
433 state.status = format!("Failed to create collection: {e}");
434 }
435 }
436 state.refresh_collections();
437 }
438 state.collection_create_input.clear();
439 state.show_collection_create = false;
440 }
441 if ui.button("Cancel").clicked()
442 || ui.input(|i| i.key_pressed(egui::Key::Escape))
443 {
444 state.collection_create_input.clear();
445 state.show_collection_create = false;
446 }
447 });
448 } else if ui.small_button("+").on_hover_text("Create a new collection").clicked() {
449 state.show_collection_create = true;
450 state.collection_create_input.clear();
451 state.focus_inline_editor = true;
452 }
453 });
454
455 ui.add_space(theme::space::SM);
456
457 // Tags section — tree view for dot-separated tags
458 ui.collapsing("Tags", |ui| {
459 if state.all_tags.is_empty() {
460 ui.label(egui::RichText::new("No tags yet").color(theme::text_muted()));
461 } else {
462 // Compute the filtered set up front so the count indicator can
463 // render alongside the filter input.
464 let total = state.all_tags.len();
465 let query = state.tag_search.to_lowercase();
466 let filtered_tags: Vec<String> = if query.is_empty() {
467 state.all_tags.as_ref().clone()
468 } else {
469 state.all_tags.iter()
470 .filter(|t| t.to_lowercase().contains(&query))
471 .cloned()
472 .collect()
473 };
474 // Tag filter input — pair with a Clear button when populated so the
475 // user doesn't have to select-all-and-delete. Mirrors the sample
476 // search bar's Clear affordance in the toolbar.
477 ui.horizontal(|ui| {
478 let has_query = !state.tag_search.is_empty();
479 let reserved = if has_query { 56.0 } else { 4.0 };
480 ui.add(
481 egui::TextEdit::singleline(&mut state.tag_search)
482 .hint_text("Filter tags...")
483 .desired_width(ui.available_width() - reserved),
484 );
485 if has_query && ui.small_button("Clear").on_hover_text("Clear tag filter").clicked() {
486 state.tag_search.clear();
487 }
488 });
489 // Result count, only shown while a filter is active.
490 if !state.tag_search.is_empty() {
491 ui.label(
492 egui::RichText::new(format!("{} of {} tags", filtered_tags.len(), total))
493 .small()
494 .color(theme::text_muted()),
495 );
496 }
497 ui.add_space(theme::space::SM);
498
499 // Inline tag rename. Above the tree so the user sees both the
500 // original tag they're renaming and the tree it sits in.
501 if let Some((old_tag, _)) = state.tag_rename_target.clone() {
502 let mut commit: Option<(String, String)> = None;
503 let mut cancel = false;
504 let want_focus = std::mem::take(&mut state.focus_inline_editor);
505 ui.horizontal(|ui| {
506 ui.label(
507 egui::RichText::new(format!("Renaming tag: {old_tag} \u{2192}"))
508 .small()
509 .color(theme::text_muted()),
510 );
511 let Some((_, buf)) = state.tag_rename_target.as_mut() else { return };
512 let resp = ui.add(
513 egui::TextEdit::singleline(buf).hint_text(old_tag.as_str()),
514 );
515 if want_focus {
516 resp.request_focus();
517 }
518 if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
519 let new_name = buf.trim().to_string();
520 if !new_name.is_empty() && new_name != old_tag {
521 commit = Some((old_tag.clone(), new_name));
522 } else {
523 cancel = true;
524 }
525 }
526 if ui.button("Cancel").clicked()
527 || ui.input(|i| i.key_pressed(egui::Key::Escape))
528 {
529 cancel = true;
530 }
531 if ui.button("Rename").clicked() {
532 let new_name = buf.trim().to_string();
533 if !new_name.is_empty() && new_name != old_tag {
534 commit = Some((old_tag.clone(), new_name));
535 }
536 }
537 });
538 // M-12: preview the consequences before commit. Exact-match
539 // semantics mean descendants like `drums.kick` are NOT renamed
540 // when the user renames `drums`; surface that warning so the
541 // user can choose to rename each descendant individually if
542 // they want the whole subtree to move.
543 if let Some((count, descendants)) = state.tag_rename_preview.clone() {
544 let summary = format!(
545 "Affects {} sample{}.",
546 count,
547 if count == 1 { "" } else { "s" },
548 );
549 ui.label(
550 egui::RichText::new(summary)
551 .small()
552 .color(theme::text_muted()),
553 );
554 if !descendants.is_empty() {
555 let preview: Vec<&str> = descendants
556 .iter()
557 .take(3)
558 .map(|s| s.as_str())
559 .collect();
560 let extra = descendants.len().saturating_sub(preview.len());
561 let list = if extra == 0 {
562 preview.join(", ")
563 } else {
564 format!("{}, +{} more", preview.join(", "), extra)
565 };
566 ui.label(
567 egui::RichText::new(format!(
568 "Descendant tags will not be renamed: {list}"
569 ))
570 .small()
571 .color(theme::accent_yellow()),
572 );
573 }
574 }
575 if let Some((old, new)) = commit {
576 state.rename_tag_globally(&old, &new);
577 state.tag_rename_target = None;
578 state.tag_rename_preview = None;
579 } else if cancel {
580 state.tag_rename_target = None;
581 state.tag_rename_preview = None;
582 }
583 ui.add_space(theme::space::SM);
584 }
585
586 if filtered_tags.is_empty() {
587 ui.label(egui::RichText::new("No matching tags").color(theme::text_muted()));
588 } else {
589 let tree = build_tag_tree(&filtered_tags);
590 for (segment, node) in &tree {
591 draw_tag_node(ui, "", segment, node, state);
592 }
593 }
594 }
595 });
596
597 }
598