directory_picker.rs (14417B)
1 use crate::ui::keybind_hint::paint_keybind_hint; 2 use crate::ui::path_utils::abbreviate_path; 3 use egui::{RichText, Vec2}; 4 use std::path::PathBuf; 5 6 /// Maximum number of recent directories to store 7 const MAX_RECENT_DIRECTORIES: usize = 10; 8 9 /// Actions that can be triggered from the directory picker 10 #[derive(Debug, Clone)] 11 pub enum DirectoryPickerAction { 12 /// User selected a directory 13 DirectorySelected(PathBuf), 14 /// User cancelled the picker 15 Cancelled, 16 /// User requested to browse for a directory (opens native dialog) 17 BrowseRequested, 18 } 19 20 /// State for the directory picker modal 21 pub struct DirectoryPicker { 22 /// List of recently used directories 23 pub recent_directories: Vec<PathBuf>, 24 /// Whether the picker is currently open 25 pub is_open: bool, 26 /// Text input for manual path entry 27 path_input: String, 28 /// Pending async folder picker result 29 pending_folder_pick: Option<std::sync::mpsc::Receiver<Option<PathBuf>>>, 30 } 31 32 impl Default for DirectoryPicker { 33 fn default() -> Self { 34 Self::new() 35 } 36 } 37 38 impl DirectoryPicker { 39 pub fn new() -> Self { 40 Self { 41 recent_directories: Vec::new(), 42 is_open: false, 43 path_input: String::new(), 44 pending_folder_pick: None, 45 } 46 } 47 48 /// Open the picker 49 pub fn open(&mut self) { 50 self.is_open = true; 51 self.path_input.clear(); 52 } 53 54 /// Close the picker 55 pub fn close(&mut self) { 56 self.is_open = false; 57 self.pending_folder_pick = None; 58 } 59 60 /// Add a directory to the recent list 61 pub fn add_recent(&mut self, path: PathBuf) { 62 // Remove if already exists (we'll re-add at front) 63 self.recent_directories.retain(|p| p != &path); 64 // Add to front 65 self.recent_directories.insert(0, path); 66 // Trim to max size 67 self.recent_directories.truncate(MAX_RECENT_DIRECTORIES); 68 } 69 70 /// Check for pending folder picker result 71 fn check_pending_pick(&mut self) -> Option<PathBuf> { 72 if let Some(rx) = &self.pending_folder_pick { 73 match rx.try_recv() { 74 Ok(Some(path)) => { 75 self.pending_folder_pick = None; 76 return Some(path); 77 } 78 Ok(None) => { 79 // User cancelled the dialog 80 self.pending_folder_pick = None; 81 } 82 Err(std::sync::mpsc::TryRecvError::Disconnected) => { 83 self.pending_folder_pick = None; 84 } 85 Err(std::sync::mpsc::TryRecvError::Empty) => { 86 // Still waiting 87 } 88 } 89 } 90 None 91 } 92 93 /// Render the directory picker as a full-panel overlay 94 /// `has_sessions` indicates whether there are existing sessions (enables cancel) 95 pub fn overlay_ui( 96 &mut self, 97 ui: &mut egui::Ui, 98 has_sessions: bool, 99 ) -> Option<DirectoryPickerAction> { 100 // Check for pending folder pick result first 101 if let Some(path) = self.check_pending_pick() { 102 return Some(DirectoryPickerAction::DirectorySelected(path)); 103 } 104 105 let mut action = None; 106 let is_narrow = notedeck::ui::is_narrow(ui.ctx()); 107 let ctrl_held = ui.input(|i| i.modifiers.ctrl); 108 109 // Handle keyboard shortcuts for recent directories (Ctrl+1-9) 110 // Only trigger when Ctrl is held to avoid intercepting TextEdit input 111 if ctrl_held { 112 for (idx, path) in self.recent_directories.iter().take(9).enumerate() { 113 let key = match idx { 114 0 => egui::Key::Num1, 115 1 => egui::Key::Num2, 116 2 => egui::Key::Num3, 117 3 => egui::Key::Num4, 118 4 => egui::Key::Num5, 119 5 => egui::Key::Num6, 120 6 => egui::Key::Num7, 121 7 => egui::Key::Num8, 122 8 => egui::Key::Num9, 123 _ => continue, 124 }; 125 if ui.input(|i| i.key_pressed(key)) { 126 return Some(DirectoryPickerAction::DirectorySelected(path.clone())); 127 } 128 } 129 } 130 131 // Handle Ctrl+B key for browse (track whether we need to trigger it) 132 // Only trigger when Ctrl is held to avoid intercepting TextEdit input 133 let trigger_browse = ctrl_held 134 && ui.input(|i| i.key_pressed(egui::Key::B)) 135 && self.pending_folder_pick.is_none(); 136 137 // Full panel frame 138 egui::Frame::new() 139 .fill(ui.visuals().panel_fill) 140 .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20)) 141 .show(ui, |ui| { 142 // Header 143 ui.horizontal(|ui| { 144 // Only show back button if there are existing sessions 145 if has_sessions { 146 if ui.button("< Back").clicked() { 147 action = Some(DirectoryPickerAction::Cancelled); 148 } 149 ui.add_space(16.0); 150 } 151 ui.heading("Select Working Directory"); 152 }); 153 154 ui.add_space(16.0); 155 156 // Centered content (max width for desktop) 157 let max_content_width = if is_narrow { 158 ui.available_width() 159 } else { 160 500.0 161 }; 162 let available_height = ui.available_height(); 163 164 ui.allocate_ui_with_layout( 165 egui::vec2(max_content_width, available_height), 166 egui::Layout::top_down(egui::Align::LEFT), 167 |ui| { 168 // Recent directories section 169 if !self.recent_directories.is_empty() { 170 ui.label(RichText::new("Recent Directories").strong()); 171 ui.add_space(8.0); 172 173 // Use more vertical space on mobile 174 let scroll_height = if is_narrow { 175 (ui.available_height() - 150.0).max(100.0) 176 } else { 177 300.0 178 }; 179 180 egui::ScrollArea::vertical() 181 .max_height(scroll_height) 182 .show(ui, |ui| { 183 for (idx, path) in 184 self.recent_directories.clone().iter().enumerate() 185 { 186 let display = abbreviate_path(path); 187 188 // Full-width button style with larger touch targets on mobile 189 let button_height = if is_narrow { 44.0 } else { 32.0 }; 190 let hint_width = 191 if ctrl_held && idx < 9 { 24.0 } else { 0.0 }; 192 let button_width = ui.available_width() - hint_width - 4.0; 193 194 ui.horizontal(|ui| { 195 let button = egui::Button::new( 196 RichText::new(&display).monospace(), 197 ) 198 .min_size(Vec2::new(button_width, button_height)) 199 .fill(ui.visuals().widgets.inactive.weak_bg_fill); 200 201 let response = ui.add(button); 202 203 // Show keybind hint when Ctrl is held (for first 9 items) 204 if ctrl_held && idx < 9 { 205 let hint_text = format!("{}", idx + 1); 206 let hint_center = response.rect.right_center() 207 + egui::vec2(hint_width / 2.0 + 2.0, 0.0); 208 paint_keybind_hint( 209 ui, 210 hint_center, 211 &hint_text, 212 18.0, 213 ); 214 } 215 216 if response 217 .on_hover_text(path.display().to_string()) 218 .clicked() 219 { 220 action = 221 Some(DirectoryPickerAction::DirectorySelected( 222 path.clone(), 223 )); 224 } 225 }); 226 227 ui.add_space(4.0); 228 } 229 }); 230 231 ui.add_space(16.0); 232 ui.separator(); 233 ui.add_space(12.0); 234 } 235 236 // Browse button (larger touch target on mobile) 237 ui.horizontal(|ui| { 238 let browse_button = 239 egui::Button::new(RichText::new("Browse...").size(if is_narrow { 240 16.0 241 } else { 242 14.0 243 })) 244 .min_size(Vec2::new( 245 if is_narrow { 246 ui.available_width() - 28.0 247 } else { 248 120.0 249 }, 250 if is_narrow { 48.0 } else { 32.0 }, 251 )); 252 253 let response = ui.add(browse_button); 254 255 // Show keybind hint when Ctrl is held 256 if ctrl_held { 257 let hint_center = 258 response.rect.right_center() + egui::vec2(14.0, 0.0); 259 paint_keybind_hint(ui, hint_center, "B", 18.0); 260 } 261 262 #[cfg(any( 263 target_os = "windows", 264 target_os = "macos", 265 target_os = "linux" 266 ))] 267 if response 268 .on_hover_text("Open folder picker dialog (B)") 269 .clicked() 270 || trigger_browse 271 { 272 // Spawn async folder picker 273 let (tx, rx) = std::sync::mpsc::channel(); 274 let ctx_clone = ui.ctx().clone(); 275 std::thread::spawn(move || { 276 let result = rfd::FileDialog::new().pick_folder(); 277 let _ = tx.send(result); 278 ctx_clone.request_repaint(); 279 }); 280 self.pending_folder_pick = Some(rx); 281 } 282 283 // On platforms without rfd (e.g., Android), just show the button disabled 284 #[cfg(not(any( 285 target_os = "windows", 286 target_os = "macos", 287 target_os = "linux" 288 )))] 289 { 290 let _ = response; 291 let _ = trigger_browse; 292 } 293 }); 294 295 if self.pending_folder_pick.is_some() { 296 ui.horizontal(|ui| { 297 ui.spinner(); 298 ui.label("Opening dialog..."); 299 }); 300 } 301 302 ui.add_space(16.0); 303 304 // Manual path input 305 ui.label("Or enter path:"); 306 ui.add_space(4.0); 307 308 let response = ui.add( 309 egui::TextEdit::singleline(&mut self.path_input) 310 .hint_text("/path/to/project") 311 .desired_width(ui.available_width()), 312 ); 313 314 ui.add_space(8.0); 315 316 let go_button = egui::Button::new("Go").min_size(Vec2::new( 317 if is_narrow { 318 ui.available_width() 319 } else { 320 50.0 321 }, 322 if is_narrow { 44.0 } else { 28.0 }, 323 )); 324 325 if ui.add(go_button).clicked() 326 || response.lost_focus() 327 && ui.input(|i| i.key_pressed(egui::Key::Enter)) 328 { 329 let path = PathBuf::from(&self.path_input); 330 if path.exists() && path.is_dir() { 331 action = Some(DirectoryPickerAction::DirectorySelected(path)); 332 } 333 } 334 }, 335 ); 336 }); 337 338 // Handle Escape key (only if cancellation is allowed) 339 if has_sessions && ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) { 340 action = Some(DirectoryPickerAction::Cancelled); 341 } 342 343 action 344 } 345 }