max / audiofiles
6 files changed,
+86 insertions,
-31 deletions
| @@ -268,6 +268,7 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 268 | 268 | if input.key_pressed(egui::Key::ArrowDown) || input.key_pressed(egui::Key::J) { | |
| 269 | 269 | if shift { | |
| 270 | 270 | state.selection.extend_down(state.visible_len()); | |
| 271 | + | state.scroll_to_row = Some(state.selection.focus); | |
| 271 | 272 | } else { | |
| 272 | 273 | state.select_next(); | |
| 273 | 274 | state.autoplay_current(); | |
| @@ -276,6 +277,7 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 276 | 277 | if input.key_pressed(egui::Key::ArrowUp) || input.key_pressed(egui::Key::K) { | |
| 277 | 278 | if shift { | |
| 278 | 279 | state.selection.extend_up(); | |
| 280 | + | state.scroll_to_row = Some(state.selection.focus); | |
| 279 | 281 | } else { | |
| 280 | 282 | state.select_prev(); | |
| 281 | 283 | state.autoplay_current(); |
| @@ -601,6 +601,23 @@ impl BrowserState { | |||
| 601 | 601 | } | |
| 602 | 602 | } | |
| 603 | 603 | all_items | |
| 604 | + | } else if self.active_collection.is_some() { | |
| 605 | + | // Export collection contents | |
| 606 | + | self.contents.iter().filter_map(|node| { | |
| 607 | + | let hash = node.node.sample_hash.as_ref()?; | |
| 608 | + | let ext = self.backend.sample_extension(hash).unwrap_or_default(); | |
| 609 | + | Some(audiofiles_core::export::ExportItem { | |
| 610 | + | hash: hash.clone(), | |
| 611 | + | ext, | |
| 612 | + | relative_path: std::path::PathBuf::from(&node.node.name), | |
| 613 | + | name: node.node.name.clone(), | |
| 614 | + | bpm: node.bpm, | |
| 615 | + | musical_key: node.musical_key.clone(), | |
| 616 | + | classification: node.classification.clone(), | |
| 617 | + | duration: node.duration, | |
| 618 | + | tags: node.tags.clone(), | |
| 619 | + | }) | |
| 620 | + | }).collect() | |
| 604 | 621 | } else { | |
| 605 | 622 | // Export entire current VFS subtree | |
| 606 | 623 | self.backend.collect_export_items(vfs_id, self.current_dir) |
| @@ -172,6 +172,8 @@ pub struct BrowserState { | |||
| 172 | 172 | pub last_analysis_config: Option<AnalysisConfig>, | |
| 173 | 173 | /// Set by "/" keyboard shortcut to focus the search bar on the next frame. | |
| 174 | 174 | pub focus_search: bool, | |
| 175 | + | /// Set by keyboard navigation to scroll the file list to the focused row. | |
| 176 | + | pub scroll_to_row: Option<usize>, | |
| 175 | 177 | ||
| 176 | 178 | // Theme | |
| 177 | 179 | pub current_theme_id: String, | |
| @@ -346,6 +348,7 @@ impl BrowserState { | |||
| 346 | 348 | last_analysis_hashes: Vec::new(), | |
| 347 | 349 | last_analysis_config: None, | |
| 348 | 350 | focus_search: false, | |
| 351 | + | scroll_to_row: None, | |
| 349 | 352 | current_theme_id: theme_id, | |
| 350 | 353 | collections: collections_list, | |
| 351 | 354 | active_collection: None, |
| @@ -166,6 +166,7 @@ impl BrowserState { | |||
| 166 | 166 | if len > 0 && self.selection.focus < len - 1 { | |
| 167 | 167 | let next = self.selection.focus + 1; | |
| 168 | 168 | self.selection.set_single(next); | |
| 169 | + | self.scroll_to_row = Some(next); | |
| 169 | 170 | self.refresh_selected_tags(); | |
| 170 | 171 | self.refresh_selected_detail(); | |
| 171 | 172 | } | |
| @@ -176,6 +177,7 @@ impl BrowserState { | |||
| 176 | 177 | if self.selection.focus > 0 { | |
| 177 | 178 | let prev = self.selection.focus - 1; | |
| 178 | 179 | self.selection.set_single(prev); | |
| 180 | + | self.scroll_to_row = Some(prev); | |
| 179 | 181 | self.refresh_selected_tags(); | |
| 180 | 182 | self.refresh_selected_detail(); | |
| 181 | 183 | } | |
| @@ -204,8 +206,13 @@ impl BrowserState { | |||
| 204 | 206 | } | |
| 205 | 207 | ||
| 206 | 208 | /// Navigate to the parent directory, or do nothing if already at root. | |
| 209 | + | /// If a collection is active, exits collection view first. | |
| 207 | 210 | pub fn go_up(&mut self) { | |
| 208 | 211 | self.similarity_search_hash = None; | |
| 212 | + | if self.active_collection.is_some() { | |
| 213 | + | self.deactivate_collection(); | |
| 214 | + | return; | |
| 215 | + | } | |
| 209 | 216 | if let Some(current) = self.current_dir { | |
| 210 | 217 | if let Ok(node) = self.backend.get_node(current) { | |
| 211 | 218 | self.current_dir = node.parent_id; |
| @@ -71,6 +71,11 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 71 | 71 | .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) | |
| 72 | 72 | .column(Column::remainder().at_least(120.0)); // Name (includes icon) | |
| 73 | 73 | ||
| 74 | + | // Scroll to focused row when keyboard navigation requests it. | |
| 75 | + | if let Some(row) = state.scroll_to_row.take() { | |
| 76 | + | table = table.scroll_to_row(row, None); | |
| 77 | + | } | |
| 78 | + | ||
| 74 | 79 | if col_cfg.show_duration { | |
| 75 | 80 | table = table.column(Column::exact(60.0)); | |
| 76 | 81 | } |
| @@ -172,39 +172,60 @@ fn draw_breadcrumb(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 172 | 172 | ||
| 173 | 173 | ui.separator(); | |
| 174 | 174 | ||
| 175 | - | // Root link | |
| 176 | - | if ui | |
| 177 | - | .selectable_label(state.current_dir.is_none(), "/") | |
| 178 | - | .on_hover_text("Go to root") | |
| 179 | - | .clicked() | |
| 180 | - | && state.current_dir.is_some() | |
| 181 | - | { | |
| 182 | - | state.current_dir = None; | |
| 183 | - | state.breadcrumb.clear(); | |
| 184 | - | state.selection.clear(); | |
| 185 | - | state.refresh_contents(); | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | // Breadcrumb path segments — iterate by reference, defer mutation | |
| 189 | - | let mut nav_to: Option<(audiofiles_core::NodeId, usize)> = None; | |
| 190 | - | let breadcrumb_len = state.breadcrumb.len(); | |
| 191 | - | for (i, crumb) in state.breadcrumb.iter().enumerate() { | |
| 175 | + | // Collection view: show collection name instead of VFS path | |
| 176 | + | if let Some(active_id) = state.active_collection { | |
| 177 | + | let coll_name = state.collections.iter() | |
| 178 | + | .find(|c| c.id == active_id) | |
| 179 | + | .map(|c| c.name.clone()) | |
| 180 | + | .unwrap_or_else(|| "Collection".to_string()); | |
| 181 | + | if ui | |
| 182 | + | .selectable_label(false, "/") | |
| 183 | + | .on_hover_text("Return to browsing") | |
| 184 | + | .clicked() | |
| 185 | + | { | |
| 186 | + | state.deactivate_collection(); | |
| 187 | + | } | |
| 192 | 188 | ui.label("/"); | |
| 193 | - | let is_last = i == breadcrumb_len - 1; | |
| 194 | - | let crumb_hover = if is_last { | |
| 195 | - | format!("Current directory: {}", crumb.name) | |
| 196 | - | } else { | |
| 197 | - | format!("Navigate to {}", crumb.name) | |
| 198 | - | }; | |
| 199 | - | if ui.selectable_label(is_last, &crumb.name).on_hover_text(crumb_hover).clicked() && !is_last { | |
| 200 | - | nav_to = Some((crumb.id, i + 1)); | |
| 189 | + | ui.label( | |
| 190 | + | egui::RichText::new(&coll_name) | |
| 191 | + | .strong() | |
| 192 | + | .color(theme::accent_blue()), | |
| 193 | + | ); | |
| 194 | + | } else { | |
| 195 | + | // Root link | |
| 196 | + | if ui | |
| 197 | + | .selectable_label(state.current_dir.is_none(), "/") | |
| 198 | + | .on_hover_text("Go to root") | |
| 199 | + | .clicked() | |
| 200 | + | && state.current_dir.is_some() | |
| 201 | + | { | |
| 202 | + | state.current_dir = None; | |
| 203 | + | state.breadcrumb.clear(); | |
| 204 | + | state.selection.clear(); | |
| 205 | + | state.refresh_contents(); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // Breadcrumb path segments — iterate by reference, defer mutation | |
| 209 | + | let mut nav_to: Option<(audiofiles_core::NodeId, usize)> = None; | |
| 210 | + | let breadcrumb_len = state.breadcrumb.len(); | |
| 211 | + | for (i, crumb) in state.breadcrumb.iter().enumerate() { | |
| 212 | + | ui.label("/"); | |
| 213 | + | let is_last = i == breadcrumb_len - 1; | |
| 214 | + | let crumb_hover = if is_last { | |
| 215 | + | format!("Current directory: {}", crumb.name) | |
| 216 | + | } else { | |
| 217 | + | format!("Navigate to {}", crumb.name) | |
| 218 | + | }; | |
| 219 | + | if ui.selectable_label(is_last, &crumb.name).on_hover_text(crumb_hover).clicked() && !is_last { | |
| 220 | + | nav_to = Some((crumb.id, i + 1)); | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | if let Some((dir_id, truncate_at)) = nav_to { | |
| 224 | + | state.current_dir = Some(dir_id); | |
| 225 | + | state.breadcrumb.truncate(truncate_at); | |
| 226 | + | state.selection.clear(); | |
| 227 | + | state.refresh_contents(); | |
| 201 | 228 | } | |
| 202 | - | } | |
| 203 | - | if let Some((dir_id, truncate_at)) = nav_to { | |
| 204 | - | state.current_dir = Some(dir_id); | |
| 205 | - | state.breadcrumb.truncate(truncate_at); | |
| 206 | - | state.selection.clear(); | |
| 207 | - | state.refresh_contents(); | |
| 208 | 229 | } | |
| 209 | 230 | ||
| 210 | 231 | // Import + Export buttons + Sync + theme selector (right-aligned) |