Skip to main content

max / audiofiles

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