Skip to main content

max / audiofiles

16.8 KB · 455 lines History Blame Raw
1 //! Browser GUI: thin dispatcher that routes to UI submodules based on the current workflow state.
2
3 use egui;
4
5 use crate::state::{BrowserState, ImportMode};
6 use crate::ui::{detail, edit_panel, export_screens, file_list, filter_panel, footer, forge_panel, import_screens, instrument_panel, overlays, sidebar, theme, toolbar};
7 use audiofiles_core::vfs::NodeType;
8
9 /// Top-level draw function called each frame from the update closure.
10 ///
11 /// Pass `sync_manager: None` when sync is not configured (e.g. from the CLAP plugin).
12 pub fn draw_browser(
13 ui: &mut egui::Ui,
14 state: &mut BrowserState,
15 sync_manager: Option<&audiofiles_sync::SyncManager>,
16 ) {
17 let ctx = ui.ctx().clone();
18 let ctx = &ctx;
19 theme::apply_theme(ctx);
20
21 match &state.import_mode {
22 ImportMode::None => {
23 // Poll edit worker events during normal browsing
24 if state.poll_workers() {
25 ctx.request_repaint();
26 }
27 handle_keyboard(ctx, state);
28 draw_normal_browser(ui, state, sync_manager);
29 }
30 ImportMode::ConfigureImport { .. } => {
31 import_screens::draw_configure_import(ui, state);
32 }
33 ImportMode::Importing { .. }
34 | ImportMode::Analyzing { .. }
35 | ImportMode::Exporting { .. }
36 | ImportMode::Cleaning { .. } => {
37 if state.poll_workers() {
38 ctx.request_repaint();
39 }
40 match &state.import_mode {
41 ImportMode::Importing { .. } => {
42 import_screens::draw_import_progress(ui, state);
43 }
44 ImportMode::Analyzing { .. } => {
45 import_screens::draw_analysis_progress(ui, state);
46 }
47 ImportMode::Exporting { .. } => {
48 export_screens::draw_export_progress(ui, state);
49 }
50 ImportMode::Cleaning { .. } => {
51 import_screens::draw_cleanup_progress(ui, state);
52 }
53 // poll_workers may have transitioned the mode
54 ImportMode::TagFolders { .. } => {
55 import_screens::draw_tag_folders(ui, state);
56 }
57 ImportMode::ConfigureAnalysis { .. } => {
58 import_screens::draw_configure_analysis(ui, state);
59 }
60 ImportMode::ReviewSuggestions { .. } => {
61 import_screens::draw_review_suggestions(ui, state);
62 }
63 ImportMode::ExportComplete { .. } => {
64 export_screens::draw_export_complete(ui, state);
65 }
66 ImportMode::ReviewErrors => {
67 import_screens::draw_review_errors(ui, state);
68 }
69 _ => {}
70 }
71 }
72 ImportMode::TagFolders { .. } => {
73 import_screens::draw_tag_folders(ui, state);
74 }
75 ImportMode::ConfigureAnalysis { .. } => {
76 import_screens::draw_configure_analysis(ui, state);
77 }
78 ImportMode::ReviewSuggestions { .. } => {
79 import_screens::draw_review_suggestions(ui, state);
80 }
81 ImportMode::ConfigureExport { .. } => {
82 export_screens::draw_configure_export(ui, state);
83 }
84 ImportMode::ExportComplete { .. } => {
85 export_screens::draw_export_complete(ui, state);
86 }
87 ImportMode::ReviewErrors => {
88 import_screens::draw_review_errors(ui, state);
89 }
90 ImportMode::OperationCancelled { .. } => {
91 import_screens::draw_operation_cancelled(ui, state);
92 }
93 }
94
95 // Scrim behind genuine modals (not the floating tool windows). Painted once
96 // before any modal so it sits below the topmost modal but blocks pointer
97 // input to the live UI underneath (P2).
98 let modal_active = state.pending_confirm.is_some()
99 || state.bulk_modal.is_some()
100 || state.show_help
101 || state.show_vfs_create
102 || state.vfs_rename_target.is_some()
103 || state.show_dir_create
104 || state.dir_rename_target.is_some()
105 || state.show_loose_files_warning
106 || state.pending_import_preflight.is_some();
107 if modal_active {
108 crate::ui::widgets::modal_scrim(ctx);
109 }
110
111 // Overlays drawn on top of any screen
112 if state.pending_confirm.is_some() {
113 overlays::draw_confirm_dialog(ctx, state);
114 }
115 if state.bulk_modal.is_some() {
116 overlays::draw_bulk_modal(ctx, state);
117 }
118 if state.show_help {
119 overlays::draw_help_overlay(ctx, state);
120 }
121 if state.show_vfs_create {
122 overlays::draw_vfs_create_modal(ctx, state);
123 }
124 if state.vfs_rename_target.is_some() {
125 overlays::draw_vfs_rename_modal(ctx, state);
126 }
127 if state.show_dir_create {
128 overlays::draw_dir_create_modal(ctx, state);
129 }
130 if state.dir_rename_target.is_some() {
131 overlays::draw_dir_rename_modal(ctx, state);
132 }
133 if state.show_loose_files_warning {
134 overlays::draw_loose_files_warning(ctx, state);
135 }
136 if state.pending_import_preflight.is_some() {
137 overlays::draw_import_preflight(ctx, state);
138 }
139
140 // Settings window
141 if state.settings.show_manager {
142 crate::ui::settings_panel::draw_settings_panel(ctx, state);
143 }
144
145 // Sync panel overlay
146 if state.sync.show_panel {
147 if let Some(sync) = sync_manager {
148 crate::ui::sync_panel::draw_sync_panel(ctx, state, sync);
149 } else {
150 crate::ui::sync_panel::draw_sync_not_configured(ctx, state);
151 }
152 }
153
154 // Floating sample editor window
155 if state.edit.show_window {
156 edit_panel::draw_edit_window(ctx, state);
157 }
158
159 // Floating sample forge window
160 if state.forge.show_window {
161 forge_panel::draw_forge_window(ctx, state);
162 }
163 }
164
165 /// Draw the main browser layout: toolbar, footer, sidebar, detail panel, and file list.
166 fn draw_normal_browser(
167 ui: &mut egui::Ui,
168 state: &mut BrowserState,
169 sync_manager: Option<&audiofiles_sync::SyncManager>,
170 ) {
171 let ctx = ui.ctx().clone();
172
173 // Top toolbar (breadcrumb + search)
174 egui::Panel::top("toolbar")
175 .exact_size(56.0)
176 .show_inside(ui, |ui| {
177 toolbar::draw_toolbar(ui, state, sync_manager);
178 });
179
180 // Bottom footer
181 egui::Panel::bottom("footer").show_inside(ui, |ui| {
182 let ctx = ui.ctx().clone();
183 footer::draw_footer(ui, &ctx, state);
184 });
185
186 // Floating MIDI/instrument window
187 if state.show_midi_window {
188 instrument_panel::draw_midi_window(&ctx, state);
189 }
190
191 // Left sidebar (or filter panel)
192 if state.filter_panel_open {
193 egui::Panel::left("filter_panel")
194 .default_size(200.0)
195 .size_range(160.0..=300.0)
196 .show_inside(ui, |ui| {
197 egui::ScrollArea::vertical().show(ui, |ui| {
198 filter_panel::draw_filter_panel(ui, state);
199 });
200 });
201 } else if state.sidebar_visible {
202 egui::Panel::left("sidebar")
203 .default_size(180.0)
204 .size_range(120.0..=280.0)
205 .show_inside(ui, |ui| {
206 sidebar::draw_sidebar(ui, state);
207 });
208 }
209
210 // Right detail panel (auto-hide below 700px).
211 // 700.0 is the minimum window width at which the detail panel is shown.
212 // Below this, the file list alone needs the full width to remain usable.
213 if state.detail_visible && ctx.content_rect().width() >= 700.0 {
214 egui::Panel::right("detail")
215 .default_size(250.0)
216 .size_range(200.0..=400.0)
217 .show_inside(ui, |ui| {
218 egui::ScrollArea::vertical().show(ui, |ui| {
219 detail::draw_detail(ui, state);
220 });
221 });
222 }
223
224 // Central file list
225 egui::CentralPanel::default().show_inside(ui, |ui| {
226 file_list::draw_file_list(ui, state, sync_manager);
227 });
228 }
229
230 /// Process keyboard shortcuts.
231 fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) {
232 // Don't handle keyboard shortcuts if a text field has focus
233 if ctx.memory(|m| m.focused().is_some()) {
234 // Still handle Escape to clear search
235 ctx.input(|input| {
236 if input.key_pressed(egui::Key::Escape)
237 && !state.search_query.is_empty()
238 {
239 state.search_query.clear();
240 state.apply_search();
241 }
242 });
243 return;
244 }
245
246 ctx.input(|input| {
247 // Escape: dismiss dialogs in priority order
248 if input.key_pressed(egui::Key::Escape) {
249 if state.settings.show_manager {
250 state.settings.show_manager = false;
251 } else if state.forge.show_window {
252 state.close_forge_window();
253 } else if state.edit.show_window {
254 state.close_edit_window();
255 } else if state.sync.show_panel {
256 state.sync.show_panel = false;
257 } else if state.bulk_modal.is_some() {
258 state.close_bulk_modal();
259 } else if state.pending_import_preflight.is_some() {
260 state.cancel_import_preflight();
261 } else if state.pending_confirm.is_some() {
262 state.dismiss_confirm();
263 } else if state.show_help {
264 state.show_help = false;
265 } else if matches!(
266 state.import_mode,
267 ImportMode::ConfigureImport { .. }
268 | ImportMode::TagFolders { .. }
269 | ImportMode::ConfigureAnalysis { .. }
270 | ImportMode::ReviewSuggestions { .. }
271 | ImportMode::ConfigureExport { .. }
272 | ImportMode::ReviewErrors
273 ) {
274 // Safe wizard screens (no in-flight work) — Escape backs out. This
275 // covers both the import wizard and the export configuration screen.
276 // Active modes (Importing/Analyzing/Cleaning/Exporting) require the
277 // explicit Cancel button to avoid losing in-progress work.
278 state.cancel_import();
279 } else if matches!(state.import_mode, ImportMode::OperationCancelled { .. }) {
280 // Cancel-acknowledgement (C-3) dismisses on Escape just like the
281 // Done button — the file work has already been cancelled.
282 state.import_mode = ImportMode::None;
283 } else if !state.search_query.is_empty() {
284 state.search_query.clear();
285 state.apply_search();
286 }
287 return;
288 }
289
290 if input.key_pressed(egui::Key::F1) {
291 state.show_help = !state.show_help;
292 }
293
294 if input.key_pressed(egui::Key::F2)
295 && state.selection.count() > 1
296 {
297 state.open_bulk_rename_modal();
298 if state.bulk_modal.is_some() {
299 state.update_rename_previews();
300 }
301 }
302
303 if input.key_pressed(egui::Key::Delete) {
304 state.confirm_delete_selected();
305 }
306
307 // Cmd+A: select all (skip the ".." parent row when present — it's not a sample
308 // and must never be part of a bulk operation).
309 if input.modifiers.command && input.key_pressed(egui::Key::A) && !input.modifiers.shift {
310 let len = state.visible_len();
311 let start = if state.current_dir.is_some() { 1 } else { 0 };
312 state.selection.select_all_from(start, len);
313 return;
314 }
315
316 // Cmd+Shift+I: invert selection (over sample rows; parent row excluded).
317 if input.modifiers.command && input.modifiers.shift && input.key_pressed(egui::Key::I) {
318 state.invert_selection();
319 return;
320 }
321
322 // Tab: focus the detail-panel tag input (opens the detail panel if hidden).
323 if input.key_pressed(egui::Key::Tab) && !input.modifiers.shift {
324 if !state.detail_visible {
325 state.set_detail_visible(true);
326 }
327 state.focus_tag_input = true;
328 return;
329 }
330
331 // Cmd+Z: undo
332 if input.modifiers.command && input.key_pressed(egui::Key::Z) {
333 state.undo();
334 return;
335 }
336
337 // Cmd+T: bulk tag
338 if input.modifiers.command && input.key_pressed(egui::Key::T) {
339 if state.selection.count() > 1 {
340 state.open_bulk_tag_modal();
341 }
342 return;
343 }
344
345 let shift = input.modifiers.shift;
346
347 if input.key_pressed(egui::Key::ArrowDown) || input.key_pressed(egui::Key::J) {
348 if shift {
349 state.selection.extend_down(state.visible_len());
350 state.scroll_to_row = Some(state.selection.focus);
351 } else {
352 state.select_next();
353 state.autoplay_current();
354 }
355 }
356 if input.key_pressed(egui::Key::ArrowUp) || input.key_pressed(egui::Key::K) {
357 if shift {
358 state.selection.extend_up();
359 state.scroll_to_row = Some(state.selection.focus);
360 } else {
361 state.select_prev();
362 state.autoplay_current();
363 }
364 }
365 if input.key_pressed(egui::Key::Enter) || input.key_pressed(egui::Key::ArrowRight) {
366 if let Some(node) = state.selected_node() {
367 match node.node.node_type {
368 NodeType::Directory => state.enter_directory(),
369 NodeType::Sample => {
370 if let Some(hash) = &node.node.sample_hash {
371 let hash = hash.clone();
372 state.trigger_preview(&hash);
373 }
374 }
375 }
376 } else if state.current_dir.is_some() && state.selection.focus == 0 {
377 state.go_up();
378 }
379 }
380 if input.key_pressed(egui::Key::Backspace) || input.key_pressed(egui::Key::ArrowLeft) {
381 // Two-step Backspace: while in similarity / duplicate mode, the
382 // first press exits the mode; a follow-up press then falls through
383 // to "go up one folder." Matches the "Esc closes the dialog, then
384 // Esc closes the parent" muscle memory.
385 if state.similarity_search_hash.is_some() {
386 state.clear_similarity_search();
387 } else {
388 state.go_up();
389 }
390 }
391 if input.key_pressed(egui::Key::Space) {
392 state.toggle_preview();
393 }
394 // "/" focuses the search bar
395 if input.key_pressed(egui::Key::Slash) {
396 state.focus_search = true;
397 }
398 // "I" toggles floating MIDI/instrument window
399 if input.key_pressed(egui::Key::I) {
400 state.show_midi_window = !state.show_midi_window;
401 }
402 // "E" toggles floating sample editor window
403 if input.key_pressed(egui::Key::E) {
404 if state.edit.show_window {
405 state.close_edit_window();
406 } else if let Some(node) = state.selected_node()
407 && let Some(hash) = &node.node.sample_hash {
408 let hash = hash.clone();
409 state.open_edit_window(&hash);
410 }
411 }
412 // "F" toggles the floating Sample Forge window for the selected sample
413 if input.key_pressed(egui::Key::F) && !shift {
414 if state.forge.show_window {
415 state.close_forge_window();
416 } else if let Some(node) = state.selected_node()
417 && let Some(hash) = &node.node.sample_hash {
418 let hash = hash.clone();
419 state.open_forge_window(&hash);
420 }
421 }
422 // "L" toggles loop
423 if input.key_pressed(egui::Key::L) {
424 state.toggle_loop();
425 }
426 // "S" toggles sidebar
427 if input.key_pressed(egui::Key::S) {
428 state.toggle_sidebar();
429 }
430 // "D" toggles detail panel
431 if input.key_pressed(egui::Key::D) && !shift {
432 state.toggle_detail();
433 }
434 // Shift+F: find similar
435 if shift && input.key_pressed(egui::Key::F)
436 && let Some(node) = state.selected_node()
437 && let Some(hash) = &node.node.sample_hash {
438 let hash = hash.clone();
439 state.find_similar(&hash);
440 }
441 // Shift+D: find duplicates
442 if shift && input.key_pressed(egui::Key::D)
443 && let Some(node) = state.selected_node()
444 && let Some(hash) = &node.node.sample_hash {
445 let hash = hash.clone();
446 state.find_near_duplicates(&hash);
447 }
448 // Cmd+Shift+M: bulk move (Cmd+M alone conflicts with macOS minimize)
449 if input.modifiers.command && input.modifiers.shift && input.key_pressed(egui::Key::M)
450 && state.selection.count() > 1 {
451 state.open_bulk_move_modal();
452 }
453 });
454 }
455