hefesto_widgets/popups/file_browser_popup/
mod.rs1use std::path::{Path, PathBuf};
2
3use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::{Color, Style},
7 widgets::StatefulWidget,
8};
9
10use crate::badge::BadgeStack;
11use crate::popup::{Popup, PopupSize};
12use crate::popups::auto_sized::AutoSized;
13use crate::popups::choose_popup::ChoosePopupState;
14use crate::popups::file_browser::FileBrowser;
15use crate::{BorderType, DotGridConfig, DotPattern};
16
17#[derive(Clone)]
18pub struct FileEntry {
19 pub name: String,
20 pub is_dir: bool,
21 pub path: PathBuf,
22}
23
24const DEFAULT_DIR_ICON: &str = "📁 ";
25const DEFAULT_FILE_ICON: &str = "📄 ";
26
27impl Default for FileBrowserState {
28 fn default() -> Self {
29 Self {
30 entries: Vec::new(),
31 items: Vec::new(),
32 cwd: PathBuf::new(),
33 choose_popup_state: ChoosePopupState::default(),
34 show_hidden: false,
35 }
36 }
37}
38
39#[derive(Clone)]
40pub struct FileBrowserState {
41 pub entries: Vec<FileEntry>,
42 pub items: Vec<(String, Style)>,
43 pub cwd: PathBuf,
44 pub choose_popup_state: ChoosePopupState,
45 pub show_hidden: bool,
46}
47
48impl FileBrowserState {
49 pub fn navigate_to(&mut self, path: &Path) {
50 if let Ok(read_dir) = std::fs::read_dir(path) {
51 let mut entries: Vec<FileEntry> = read_dir
52 .filter_map(|e| e.ok())
53 .filter(|e| {
54 if self.show_hidden {
55 true
56 } else {
57 let name = e.file_name();
58 let name = name.to_string_lossy();
59 !name.starts_with('.')
60 }
61 })
62 .map(|e| {
63 let path = e.path();
64 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
65 FileEntry {
66 name: e.file_name().to_string_lossy().to_string(),
67 is_dir,
68 path,
69 }
70 })
71 .collect();
72
73 entries.sort_by(|a, b| {
74 if a.is_dir != b.is_dir {
75 b.is_dir.cmp(&a.is_dir)
76 } else {
77 a.name.to_lowercase().cmp(&b.name.to_lowercase())
78 }
79 });
80
81 if let Some(parent) = path.parent() {
82 entries.insert(
83 0,
84 FileEntry {
85 name: "..".to_string(),
86 is_dir: true,
87 path: parent.to_path_buf(),
88 },
89 );
90 }
91
92 self.items = entries
93 .iter()
94 .map(|e| {
95 let text = if e.is_dir {
96 if e.name == ".." {
97 "↑ ..".to_string()
98 } else {
99 format!("{}{}", DEFAULT_DIR_ICON, e.name)
100 }
101 } else {
102 format!("{}{}", DEFAULT_FILE_ICON, e.name)
103 };
104 let style = if e.is_dir {
105 Style::new().fg(Color::Cyan)
106 } else {
107 Style::new().fg(Color::White)
108 };
109 (text, style)
110 })
111 .collect();
112
113 self.entries = entries;
114 self.cwd = path.to_path_buf();
115 self.choose_popup_state = ChoosePopupState::default();
116 self.choose_popup_state.scroll_list_state.follow = false;
117 self.choose_popup_state.scroll_list_state.select(Some(0));
118 }
119 }
120
121 pub fn go_up(&mut self) {
122 let parent = self.cwd.parent().map(|p| p.to_path_buf());
123 if let Some(p) = parent {
124 self.navigate_to(&p);
125 }
126 }
127
128 pub fn enter_directory(&mut self) {
129 let target = self.selected_entry().map(|e| (e.is_dir, e.path.clone()));
130 if let Some((true, path)) = target {
131 self.navigate_to(&path);
132 }
133 }
134
135 pub fn selected_entry(&self) -> Option<&FileEntry> {
136 if self.choose_popup_state.show_filter && !self.choose_popup_state.text_input.content.is_empty() {
137 self.filtered_indices().get(self.choose_popup_state.cursor).and_then(|&i| self.entries.get(i))
138 } else {
139 self.entries.get(self.choose_popup_state.cursor)
140 }
141 }
142
143 pub fn current_path(&self) -> Option<PathBuf> {
144 self.selected_entry().map(|e| e.path.clone())
145 }
146
147 pub fn select(&mut self, index: usize) {
148 let count = self.visible_count();
149 self.choose_popup_state.cursor = index.min(count.saturating_sub(1));
150 self.choose_popup_state.scroll_list_state.select(Some(self.choose_popup_state.cursor));
151 }
152
153 pub fn next(&mut self) {
154 let count = self.visible_count();
155 self.choose_popup_state.next(count);
156 }
157
158 pub fn previous(&mut self) {
159 self.choose_popup_state.previous();
160 }
161
162 pub fn first(&mut self) {
163 self.choose_popup_state.first();
164 }
165
166 pub fn last(&mut self) {
167 let count = self.visible_count();
168 self.choose_popup_state.last(count);
169 }
170
171 pub fn toggle(&mut self, idx: usize) {
172 self.choose_popup_state.toggle(idx);
173 }
174
175 pub fn toggle_cursor(&mut self) {
176 self.choose_popup_state.toggle_cursor();
177 }
178
179 pub fn chosen_indices(&self) -> &std::collections::HashSet<usize> {
180 &self.choose_popup_state.chosen_indices
181 }
182
183 pub fn chosen_paths(&self) -> Vec<PathBuf> {
184 self.choose_popup_state
185 .chosen_indices
186 .iter()
187 .filter_map(|&i| self.entries.get(i).map(|e| e.path.clone()))
188 .collect()
189 }
190
191 pub fn show_filter(&self) -> bool {
194 self.choose_popup_state.show_filter
195 }
196
197 pub fn set_show_filter(&mut self, show: bool) {
198 self.choose_popup_state.show_filter = show;
199 }
200
201 pub fn insert_filter_char(&mut self, c: char) {
202 self.choose_popup_state.insert_filter_char(c);
203 }
204
205 pub fn delete_before_filter(&mut self) {
206 self.choose_popup_state.delete_before_filter();
207 }
208
209 pub fn delete_at_filter(&mut self) {
210 self.choose_popup_state.delete_at_filter();
211 }
212
213 pub fn filter_cursor_left(&mut self) {
214 self.choose_popup_state.filter_cursor_left();
215 }
216
217 pub fn filter_cursor_right(&mut self) {
218 self.choose_popup_state.filter_cursor_right();
219 }
220
221 pub fn filter_cursor_home(&mut self) {
222 self.choose_popup_state.filter_cursor_home();
223 }
224
225 pub fn filter_cursor_end(&mut self) {
226 self.choose_popup_state.filter_cursor_end();
227 }
228
229 pub fn filtered_indices(&self) -> Vec<usize> {
230 self.choose_popup_state.filtered_indices(&self.items)
231 }
232
233 pub fn visible_count(&self) -> usize {
234 self.choose_popup_state.visible_count(&self.items)
235 }
236
237 pub fn original_index(&self) -> Option<usize> {
238 self.choose_popup_state.original_index(&self.items)
239 }
240
241 pub fn filter_content(&self) -> &str {
242 &self.choose_popup_state.text_input.content
243 }
244
245 pub fn max_selected(&self) -> Option<usize> {
246 self.choose_popup_state.max_selected
247 }
248
249 pub fn set_max_selected(&mut self, max: Option<usize>) {
250 self.choose_popup_state.max_selected = max;
251 }
252
253 pub fn show_hidden(&self) -> bool {
254 self.show_hidden
255 }
256
257 pub fn set_show_hidden(&mut self, show: bool) {
258 self.show_hidden = show;
259 }
260}
261
262#[derive(Clone)]
263pub struct FileBrowserPopup {
264 file_browser: FileBrowser,
265 width: PopupSize,
266 height: PopupSize,
267 border_color: Option<Color>,
268 border_type: BorderType,
269 padding: u16,
270 header: bool,
271 position: Option<(u16, u16)>,
272 origin: Option<(u16, u16)>,
273 bg_color: Option<Color>,
274 no_dot_grid: bool,
275 dot_grid: Option<DotGridConfig>,
276 badges: Option<BadgeStack<'static>>,
277}
278
279impl FileBrowserPopup {
280 pub fn new() -> Self {
281 Self {
282 file_browser: FileBrowser::new(),
283 width: PopupSize::Auto,
284 height: PopupSize::Auto,
285 border_color: None,
286 border_type: BorderType::Rounded,
287 padding: 0,
288 header: false,
289 position: None,
290 origin: None,
291 bg_color: None,
292 no_dot_grid: false,
293 dot_grid: None,
294 badges: None,
295 }
296 }
297
298 pub fn dir_style(mut self, style: Style) -> Self {
301 self.file_browser = self.file_browser.dir_style(style);
302 self
303 }
304
305 pub fn file_style(mut self, style: Style) -> Self {
306 self.file_browser = self.file_browser.file_style(style);
307 self
308 }
309
310 pub fn highlight_style(mut self, style: Style) -> Self {
311 self.file_browser = self.file_browser.highlight_style(style);
312 self
313 }
314
315 pub fn dir_icon(mut self, icon: &str) -> Self {
316 self.file_browser = self.file_browser.dir_icon(icon);
317 self
318 }
319
320 pub fn file_icon(mut self, icon: &str) -> Self {
321 self.file_browser = self.file_browser.file_icon(icon);
322 self
323 }
324
325 pub fn max_selected(mut self, max: usize) -> Self {
326 self.file_browser = self.file_browser.max_selected(max);
327 self
328 }
329
330 pub fn no_max_selected(mut self) -> Self {
331 self.file_browser = self.file_browser.no_max_selected();
332 self
333 }
334
335 pub fn width(mut self, w: PopupSize) -> Self {
338 self.width = w;
339 self
340 }
341
342 pub fn border_color(mut self, color: Color) -> Self {
343 self.border_color = Some(color);
344 self
345 }
346
347 pub fn border_type(mut self, bt: BorderType) -> Self {
348 self.border_type = bt;
349 self
350 }
351
352 pub fn bg_color(mut self, color: Color) -> Self {
354 self.bg_color = Some(color);
355 self
356 }
357
358 pub fn padding(mut self, p: u16) -> Self {
359 self.padding = p;
360 self
361 }
362
363 pub fn header(mut self) -> Self {
364 self.header = true;
365 self
366 }
367
368 pub fn height(mut self, h: PopupSize) -> Self {
369 self.height = h;
370 self
371 }
372
373 pub fn position(mut self, x: u16, y: u16) -> Self {
374 self.position = Some((x, y));
375 self
376 }
377
378 pub fn origin(mut self, x: u16, y: u16) -> Self {
379 self.origin = Some((x, y));
380 self
381 }
382
383 pub fn no_background(mut self) -> Self {
384 self.no_dot_grid = true;
385 self
386 }
387
388 pub fn background_dots(mut self, color: Color, symbol: &str, density: u16) -> Self {
389 self.dot_grid = Some(DotGridConfig { color, symbol: symbol.to_string(), density_x: density, density_y: density, pattern: DotPattern::default() });
390 self
391 }
392
393 pub fn background_pattern(mut self, pattern: DotPattern) -> Self {
394 if let Some(ref mut dg) = self.dot_grid {
395 dg.pattern = pattern;
396 } else {
397 self.no_dot_grid = false;
398 self.dot_grid = Some(DotGridConfig { pattern, ..DotGridConfig::default() });
399 }
400 self
401 }
402
403 pub fn background_spacing(mut self, density_x: u16, density_y: u16) -> Self {
404 if let Some(ref mut dg) = self.dot_grid {
405 dg.density_x = density_x;
406 dg.density_y = density_y;
407 } else {
408 self.no_dot_grid = false;
409 self.dot_grid = Some(DotGridConfig {
410 density_x,
411 density_y,
412 ..DotGridConfig::default()
413 });
414 }
415 self
416 }
417
418 pub fn badges(mut self, badges: BadgeStack<'static>) -> Self {
419 self.badges = Some(badges);
420 self
421 }
422}
423
424impl Default for FileBrowserPopup {
425 fn default() -> Self {
426 Self::new()
427 }
428}
429
430impl AutoSized for FileBrowserPopup {
431 type State = FileBrowserState;
432
433 fn auto_height(&self, state: &Self::State, area: Rect) -> u16 {
434 let max_visible = area.height.saturating_sub(7).min(20).max(3) as usize;
435 let visible = state.entries.len().min(max_visible);
436 visible as u16 + 5
437 }
438}
439
440impl FileBrowserPopup {
441 pub fn resolve_rect(&self, area: Rect, state: &FileBrowserState) -> Rect {
446 let border_color = self.border_color.unwrap_or(Color::White);
447 let mut popup = Popup::new(border_color)
448 .padding(self.padding)
449 .width(self.width)
450 .border_type(self.border_type);
451
452 if self.height == PopupSize::Auto {
453 popup = popup.height(PopupSize::Fixed(self.auto_height(state, area)));
454 } else {
455 popup = popup.height(self.height);
456 }
457 if let Some((x, y)) = self.position {
458 popup = popup.position(x, y);
459 }
460 if let Some((x, y)) = self.origin {
461 popup = popup.origin(x, y);
462 }
463 popup.resolve_rect(area)
464 }
465}
466
467impl StatefulWidget for FileBrowserPopup {
468 type State = FileBrowserState;
469
470 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
471 let auto_h = if self.height == PopupSize::Auto {
472 Some(self.auto_height(state, area))
473 } else {
474 None
475 };
476
477 let path_str = state.cwd.to_string_lossy().to_string();
478 let border_color = self.border_color.unwrap_or(Color::White);
479 let mut popup = Popup::new(border_color)
480 .padding(self.padding)
481 .width(self.width)
482 .border_type(self.border_type);
483
484 if let Some(h) = auto_h {
485 popup = popup.height(PopupSize::Fixed(h));
486 }
487 if !path_str.is_empty() {
488 popup = popup.title(&path_str);
489 }
490 if let Some(bg) = self.bg_color {
491 popup = popup.bg_color(bg);
492 }
493 if self.header {
494 popup = popup.header();
495 }
496 if let Some((x, y)) = self.position {
497 popup = popup.position(x, y);
498 }
499 if let Some((x, y)) = self.origin {
500 popup = popup.origin(x, y);
501 }
502 if self.no_dot_grid {
503 popup = popup.no_background();
504 }
505 if let Some(ref dg) = self.dot_grid {
506 popup = popup
507 .background_dots(dg.color, &dg.symbol, dg.density_x)
508 .background_spacing(dg.density_x, dg.density_y);
509 }
510 if self.height != PopupSize::Auto {
511 popup = popup.height(self.height);
512 }
513
514 if let Some(ref badges) = self.badges {
515 badges.render_all(area, buf);
516 }
517 let inner = popup.render_inner(area, buf);
518 self.file_browser.render(inner, buf, state);
519 }
520}
521
522#[cfg(test)]
523mod tests;