| 1 | //! Welcome menu for workspace selection |
| 2 | //! |
| 3 | //! Displays when fackr is launched without arguments, allowing the user to: |
| 4 | //! - Select the current directory as workspace |
| 5 | //! - Choose from recently opened workspaces |
| 6 | //! - Browse for a directory |
| 7 | |
| 8 | use anyhow::Result; |
| 9 | use crossterm::event::{self, Event}; |
| 10 | use std::path::PathBuf; |
| 11 | |
| 12 | use crate::input::{Key, Modifiers}; |
| 13 | use crate::render::Screen; |
| 14 | use crate::workspace::{recents_get, Recent}; |
| 15 | |
| 16 | /// Result of the welcome menu interaction |
| 17 | #[derive(Debug)] |
| 18 | pub enum WelcomeResult { |
| 19 | /// User selected a workspace |
| 20 | Selected(PathBuf), |
| 21 | /// User quit without selecting |
| 22 | Quit, |
| 23 | } |
| 24 | |
| 25 | /// Welcome menu state |
| 26 | pub struct WelcomeMenu { |
| 27 | /// Current directory option (always shown at top) |
| 28 | current_dir: PathBuf, |
| 29 | /// Recent workspaces |
| 30 | recents: Vec<Recent>, |
| 31 | /// Currently selected index (0 = current dir, 1+ = recents) |
| 32 | selected: usize, |
| 33 | /// Scroll offset for the list |
| 34 | scroll: usize, |
| 35 | } |
| 36 | |
| 37 | impl WelcomeMenu { |
| 38 | pub fn new() -> Self { |
| 39 | let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); |
| 40 | let recents = recents_get(); |
| 41 | |
| 42 | Self { |
| 43 | current_dir, |
| 44 | recents, |
| 45 | selected: 0, |
| 46 | scroll: 0, |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | /// Total number of items (current dir + recents) |
| 51 | pub fn item_count(&self) -> usize { |
| 52 | 1 + self.recents.len() |
| 53 | } |
| 54 | |
| 55 | /// Get the selected path |
| 56 | pub fn selected_path(&self) -> PathBuf { |
| 57 | if self.selected == 0 { |
| 58 | self.current_dir.clone() |
| 59 | } else { |
| 60 | self.recents[self.selected - 1].path.clone() |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | /// Move selection up |
| 65 | pub fn move_up(&mut self) { |
| 66 | if self.selected > 0 { |
| 67 | self.selected -= 1; |
| 68 | self.ensure_visible(); |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /// Move selection down |
| 73 | pub fn move_down(&mut self) { |
| 74 | if self.selected + 1 < self.item_count() { |
| 75 | self.selected += 1; |
| 76 | self.ensure_visible(); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | /// Move selection to top |
| 81 | pub fn move_to_top(&mut self) { |
| 82 | self.selected = 0; |
| 83 | self.scroll = 0; |
| 84 | } |
| 85 | |
| 86 | /// Move selection to bottom |
| 87 | pub fn move_to_bottom(&mut self) { |
| 88 | if self.item_count() > 0 { |
| 89 | self.selected = self.item_count() - 1; |
| 90 | self.ensure_visible(); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// Ensure selected item is visible |
| 95 | fn ensure_visible(&mut self) { |
| 96 | // We'll update scroll based on visible area in render |
| 97 | } |
| 98 | |
| 99 | /// Update scroll to ensure selection is visible within given visible_rows |
| 100 | pub fn update_viewport(&mut self, visible_rows: usize) { |
| 101 | if visible_rows == 0 { |
| 102 | return; |
| 103 | } |
| 104 | if self.selected < self.scroll { |
| 105 | self.scroll = self.selected; |
| 106 | } else if self.selected >= self.scroll + visible_rows { |
| 107 | self.scroll = self.selected - visible_rows + 1; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// Get items to display, returns (label, path_display, is_selected, is_current_dir) |
| 112 | pub fn visible_items(&self) -> Vec<(String, String, bool, bool)> { |
| 113 | let mut items = Vec::new(); |
| 114 | |
| 115 | // Current directory is always first |
| 116 | let current_label = self |
| 117 | .current_dir |
| 118 | .file_name() |
| 119 | .map(|s| s.to_string_lossy().to_string()) |
| 120 | .unwrap_or_else(|| self.current_dir.to_string_lossy().to_string()); |
| 121 | let current_path = self.current_dir.to_string_lossy().to_string(); |
| 122 | items.push(( |
| 123 | format!(" {} (current directory)", current_label), |
| 124 | current_path, |
| 125 | self.selected == 0, |
| 126 | true, |
| 127 | )); |
| 128 | |
| 129 | // Recent workspaces |
| 130 | for (i, recent) in self.recents.iter().enumerate() { |
| 131 | let path_display = recent.path.to_string_lossy().to_string(); |
| 132 | items.push(( |
| 133 | format!(" {}", recent.label), |
| 134 | path_display, |
| 135 | self.selected == i + 1, |
| 136 | false, |
| 137 | )); |
| 138 | } |
| 139 | |
| 140 | items |
| 141 | } |
| 142 | |
| 143 | /// Get current scroll offset |
| 144 | pub fn scroll(&self) -> usize { |
| 145 | self.scroll |
| 146 | } |
| 147 | |
| 148 | /// Handle a key press, returns Some(result) if menu should close |
| 149 | pub fn handle_key(&mut self, key: Key, _mods: Modifiers) -> Option<WelcomeResult> { |
| 150 | match key { |
| 151 | Key::Up | Key::Char('k') => { |
| 152 | self.move_up(); |
| 153 | None |
| 154 | } |
| 155 | Key::Down | Key::Char('j') => { |
| 156 | self.move_down(); |
| 157 | None |
| 158 | } |
| 159 | Key::Home => { |
| 160 | self.move_to_top(); |
| 161 | None |
| 162 | } |
| 163 | Key::End => { |
| 164 | self.move_to_bottom(); |
| 165 | None |
| 166 | } |
| 167 | Key::Enter => Some(WelcomeResult::Selected(self.selected_path())), |
| 168 | Key::Escape | Key::Char('q') => Some(WelcomeResult::Quit), |
| 169 | _ => None, |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | /// Run the welcome menu, returns selected path or None if user quit |
| 174 | /// Assumes screen is already in raw mode |
| 175 | pub fn run(screen: &mut Screen) -> Result<Option<PathBuf>> { |
| 176 | let mut menu = WelcomeMenu::new(); |
| 177 | |
| 178 | loop { |
| 179 | // Update viewport based on visible area |
| 180 | let visible_rows = screen.rows.saturating_sub(10) as usize; |
| 181 | menu.update_viewport(visible_rows); |
| 182 | |
| 183 | // Render |
| 184 | screen.render_welcome(&menu.visible_items(), menu.scroll())?; |
| 185 | |
| 186 | // Wait for input |
| 187 | if let Event::Key(key_event) = event::read()? { |
| 188 | let (key, mods) = Key::from_crossterm(key_event); |
| 189 | if let Some(result) = menu.handle_key(key, mods) { |
| 190 | return match result { |
| 191 | WelcomeResult::Selected(path) => Ok(Some(path)), |
| 192 | WelcomeResult::Quit => Ok(None), |
| 193 | }; |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 |