@@ -2,6 +2,7 @@ |
| 2 | 2 | |
| 3 | 3 | use gartk_core::{Key, Rect}; |
| 4 | 4 | use gartk_render::{Renderer, TextStyle}; |
| 5 | +use std::fs; |
| 5 | 6 | use std::path::PathBuf; |
| 6 | 7 | |
| 7 | 8 | /// Address bar for editing the current path. |
@@ -18,6 +19,12 @@ pub struct AddressBar { |
| 18 | 19 | padding: u32, |
| 19 | 20 | /// Blink counter for cursor animation. |
| 20 | 21 | blink_counter: u32, |
| 22 | + /// Tab completion candidates. |
| 23 | + completions: Vec<String>, |
| 24 | + /// Current completion index. |
| 25 | + completion_index: usize, |
| 26 | + /// Text that triggered completion (to detect changes). |
| 27 | + completion_base: String, |
| 21 | 28 | } |
| 22 | 29 | |
| 23 | 30 | /// Frames per blink cycle (on + off). |
@@ -33,6 +40,9 @@ impl AddressBar { |
| 33 | 40 | active: false, |
| 34 | 41 | padding: 8, |
| 35 | 42 | blink_counter: 0, |
| 43 | + completions: Vec::new(), |
| 44 | + completion_index: 0, |
| 45 | + completion_base: String::new(), |
| 36 | 46 | } |
| 37 | 47 | } |
| 38 | 48 | |
@@ -117,12 +127,14 @@ impl AddressBar { |
| 117 | 127 | if self.cursor > 0 { |
| 118 | 128 | self.cursor -= 1; |
| 119 | 129 | self.text.remove(self.cursor); |
| 130 | + self.completions.clear(); |
| 120 | 131 | } |
| 121 | 132 | true |
| 122 | 133 | } |
| 123 | 134 | Key::Delete => { |
| 124 | 135 | if self.cursor < self.text.len() { |
| 125 | 136 | self.text.remove(self.cursor); |
| 137 | + self.completions.clear(); |
| 126 | 138 | } |
| 127 | 139 | true |
| 128 | 140 | } |
@@ -146,15 +158,105 @@ impl AddressBar { |
| 146 | 158 | self.cursor = self.text.len(); |
| 147 | 159 | true |
| 148 | 160 | } |
| 161 | + Key::Tab => { |
| 162 | + self.complete(); |
| 163 | + true |
| 164 | + } |
| 149 | 165 | Key::Char(c) => { |
| 150 | 166 | self.text.insert(self.cursor, *c); |
| 151 | 167 | self.cursor += 1; |
| 168 | + // Reset completions when text changes |
| 169 | + self.completions.clear(); |
| 152 | 170 | true |
| 153 | 171 | } |
| 154 | 172 | _ => false, |
| 155 | 173 | } |
| 156 | 174 | } |
| 157 | 175 | |
| 176 | + /// Perform tab completion on the current text. |
| 177 | + fn complete(&mut self) { |
| 178 | + // If we already have completions and base matches, cycle to next |
| 179 | + if !self.completions.is_empty() && self.text == self.completion_base { |
| 180 | + self.completion_index = (self.completion_index + 1) % self.completions.len(); |
| 181 | + self.text = self.completions[self.completion_index].clone(); |
| 182 | + self.cursor = self.text.len(); |
| 183 | + self.completion_base = self.text.clone(); |
| 184 | + return; |
| 185 | + } |
| 186 | + |
| 187 | + // Expand ~ to home directory for completion |
| 188 | + let expanded = if self.text.starts_with('~') { |
| 189 | + if let Some(home) = dirs::home_dir() { |
| 190 | + let rest = self.text.strip_prefix('~').unwrap_or(""); |
| 191 | + home.to_string_lossy().to_string() + rest |
| 192 | + } else { |
| 193 | + self.text.clone() |
| 194 | + } |
| 195 | + } else { |
| 196 | + self.text.clone() |
| 197 | + }; |
| 198 | + |
| 199 | + let path = PathBuf::from(&expanded); |
| 200 | + |
| 201 | + // Determine the directory to search and the prefix to match |
| 202 | + let (search_dir, prefix) = if path.is_dir() && expanded.ends_with('/') { |
| 203 | + // Path is a directory ending with /, list its contents |
| 204 | + (path.clone(), String::new()) |
| 205 | + } else if let Some(parent) = path.parent() { |
| 206 | + // Get the filename prefix to match |
| 207 | + let prefix = path.file_name() |
| 208 | + .map(|s| s.to_string_lossy().to_string()) |
| 209 | + .unwrap_or_default(); |
| 210 | + (parent.to_path_buf(), prefix) |
| 211 | + } else { |
| 212 | + return; |
| 213 | + }; |
| 214 | + |
| 215 | + // Find matching entries |
| 216 | + let mut matches = Vec::new(); |
| 217 | + if let Ok(entries) = fs::read_dir(&search_dir) { |
| 218 | + for entry in entries.filter_map(|e| e.ok()) { |
| 219 | + let name = entry.file_name().to_string_lossy().to_string(); |
| 220 | + if prefix.is_empty() || name.starts_with(&prefix) { |
| 221 | + let full_path = search_dir.join(&name); |
| 222 | + // Add trailing slash for directories |
| 223 | + let display = if full_path.is_dir() { |
| 224 | + full_path.to_string_lossy().to_string() + "/" |
| 225 | + } else { |
| 226 | + full_path.to_string_lossy().to_string() |
| 227 | + }; |
| 228 | + |
| 229 | + // Convert back to ~ notation if applicable |
| 230 | + let display = if let Some(home) = dirs::home_dir() { |
| 231 | + if let Ok(stripped) = PathBuf::from(&display).strip_prefix(&home) { |
| 232 | + format!("~/{}", stripped.to_string_lossy()) |
| 233 | + } else { |
| 234 | + display |
| 235 | + } |
| 236 | + } else { |
| 237 | + display |
| 238 | + }; |
| 239 | + |
| 240 | + matches.push(display); |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + // Sort matches |
| 246 | + matches.sort(); |
| 247 | + |
| 248 | + if matches.is_empty() { |
| 249 | + return; |
| 250 | + } |
| 251 | + |
| 252 | + // Store completions and apply first one |
| 253 | + self.completions = matches; |
| 254 | + self.completion_index = 0; |
| 255 | + self.text = self.completions[0].clone(); |
| 256 | + self.cursor = self.text.len(); |
| 257 | + self.completion_base = self.text.clone(); |
| 258 | + } |
| 259 | + |
| 158 | 260 | /// Render the address bar. |
| 159 | 261 | pub fn render(&mut self, renderer: &Renderer) -> anyhow::Result<()> { |
| 160 | 262 | if !self.active { |