notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }