Skip to main content

max / audiofiles

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