1use crate::_private::NonExhaustive;
6use crate::button::{Button, ButtonState, ButtonStyle};
7use crate::event::{ButtonOutcome, FileOutcome, TextOutcome};
8use crate::layout::{DialogItem, LayoutOuter, layout_as_grid};
9use crate::list::edit::{EditList, EditListState};
10use crate::list::selection::RowSelection;
11use crate::list::{List, ListState, ListStyle};
12use crate::util::{block_padding2, reset_buf_area};
13use crossterm::event::Event;
14#[cfg(feature = "user_directories")]
15use dirs::{document_dir, home_dir};
16use rat_event::{
17 ConsumedEvent, Dialog, HandleEvent, MouseOnly, Outcome, Regular, ct_event, event_flow,
18};
19use rat_focus::{Focus, FocusBuilder, FocusFlag, HasFocus, on_lost};
20use rat_ftable::event::EditOutcome;
21use rat_reloc::RelocatableState;
22use rat_scrolled::Scroll;
23use rat_text::text_input::{TextInput, TextInputState};
24use rat_text::{HasScreenCursor, TextStyle};
25use ratatui::buffer::Buffer;
26use ratatui::layout::{Alignment, Constraint, Direction, Flex, Layout, Position, Rect, Size};
27use ratatui::style::Style;
28use ratatui::text::Text;
29use ratatui::widgets::{Block, ListItem};
30use ratatui::widgets::{StatefulWidget, Widget};
31use std::cmp::max;
32use std::ffi::OsString;
33use std::fmt::{Debug, Formatter};
34use std::path::{Path, PathBuf};
35use std::{fs, io};
36#[cfg(feature = "user_directories")]
37use sysinfo::Disks;
38
39#[derive(Debug, Clone)]
60pub struct FileDialog<'a> {
61 style: Style,
62 block: Option<Block<'a>>,
63 list_style: Option<ListStyle>,
64 roots_style: Option<ListStyle>,
65 text_style: Option<TextStyle>,
66 button_style: Option<ButtonStyle>,
67
68 layout: LayoutOuter,
69
70 ok_text: &'a str,
71 cancel_text: &'a str,
72}
73
74#[derive(Debug, Clone)]
76pub struct FileDialogStyle {
77 pub style: Style,
78 pub block: Option<Block<'static>>,
80 pub border_style: Option<Style>,
81 pub title_style: Option<Style>,
82 pub layout: Option<LayoutOuter>,
84 pub list: Option<ListStyle>,
86 pub roots: Option<ListStyle>,
88 pub text: Option<TextStyle>,
90 pub button: Option<ButtonStyle>,
92
93 pub non_exhaustive: NonExhaustive,
94}
95
96#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
98#[allow(dead_code)]
99enum Mode {
100 #[default]
101 Open,
102 Save,
103 Dir,
104}
105
106#[expect(clippy::type_complexity)]
108pub struct FileDialogState {
109 pub area: Rect,
112 pub active: bool,
114
115 mode: Mode,
116
117 path: PathBuf,
118 save_name: Option<OsString>,
119 save_ext: Option<OsString>,
120 dirs: Vec<OsString>,
121 filter: Option<Box<dyn Fn(&Path) -> bool + 'static>>,
122 files: Vec<OsString>,
123 no_default_roots: bool,
124 roots: Vec<(OsString, PathBuf)>,
125
126 path_state: TextInputState,
127 root_state: ListState<RowSelection>,
128 dir_state: EditListState<EditDirNameState>,
129 file_state: ListState<RowSelection>,
130 save_name_state: TextInputState,
131 new_state: ButtonState,
132 cancel_state: ButtonState,
133 ok_state: ButtonState,
134}
135
136pub(crate) mod event {
137 use rat_event::{ConsumedEvent, Outcome};
138 use std::path::PathBuf;
139
140 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
142 pub enum FileOutcome {
143 Continue,
145 Unchanged,
148 Changed,
153 Cancel,
155 Ok(PathBuf),
157 }
158
159 impl ConsumedEvent for FileOutcome {
160 fn is_consumed(&self) -> bool {
161 !matches!(self, FileOutcome::Continue)
162 }
163 }
164
165 impl From<FileOutcome> for Outcome {
166 fn from(value: FileOutcome) -> Self {
167 match value {
168 FileOutcome::Continue => Outcome::Continue,
169 FileOutcome::Unchanged => Outcome::Unchanged,
170 FileOutcome::Changed => Outcome::Changed,
171 FileOutcome::Ok(_) => Outcome::Changed,
172 FileOutcome::Cancel => Outcome::Changed,
173 }
174 }
175 }
176
177 impl From<Outcome> for FileOutcome {
178 fn from(value: Outcome) -> Self {
179 match value {
180 Outcome::Continue => FileOutcome::Continue,
181 Outcome::Unchanged => FileOutcome::Unchanged,
182 Outcome::Changed => FileOutcome::Changed,
183 }
184 }
185 }
186
187 impl From<bool> for FileOutcome {
189 fn from(value: bool) -> Self {
190 if value {
191 FileOutcome::Changed
192 } else {
193 FileOutcome::Unchanged
194 }
195 }
196 }
197}
198
199impl Clone for FileDialogState {
200 fn clone(&self) -> Self {
201 Self {
202 area: self.area,
203 active: self.active,
204 mode: self.mode,
205 path: self.path.clone(),
206 save_name: self.save_name.clone(),
207 save_ext: self.save_ext.clone(),
208 dirs: self.dirs.clone(),
209 filter: None, files: self.files.clone(),
211 no_default_roots: self.no_default_roots,
212 roots: self.roots.clone(),
213 path_state: self.path_state.clone(),
214 root_state: self.root_state.clone(),
215 dir_state: self.dir_state.clone(),
216 file_state: self.file_state.clone(),
217 save_name_state: self.save_name_state.clone(),
218 new_state: self.new_state.clone(),
219 cancel_state: self.cancel_state.clone(),
220 ok_state: self.ok_state.clone(),
221 }
222 }
223}
224
225impl Debug for FileDialogState {
226 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
227 f.debug_struct("FileOpenState")
228 .field("active", &self.active)
229 .field("mode", &self.mode)
230 .field("path", &self.path)
231 .field("save_name", &self.save_name)
232 .field("dirs", &self.dirs)
233 .field("files", &self.files)
234 .field("no_default_roots", &self.no_default_roots)
235 .field("roots", &self.roots)
236 .field("path_state", &self.path_state)
237 .field("root_state", &self.root_state)
238 .field("dir_state", &self.dir_state)
239 .field("file_state", &self.file_state)
240 .field("name_state", &self.save_name_state)
241 .field("cancel_state", &self.cancel_state)
242 .field("ok_state", &self.ok_state)
243 .finish()
244 }
245}
246
247impl Default for FileDialogStyle {
248 fn default() -> Self {
249 FileDialogStyle {
250 style: Default::default(),
251 layout: Default::default(),
252 list: Default::default(),
253 roots: Default::default(),
254 button: Default::default(),
255 block: Default::default(),
256 border_style: Default::default(),
257 text: Default::default(),
258 non_exhaustive: NonExhaustive,
259 title_style: Default::default(),
260 }
261 }
262}
263
264impl Default for FileDialogState {
265 fn default() -> Self {
266 let mut s = Self {
267 area: Default::default(),
268 active: Default::default(),
269 mode: Default::default(),
270 path: Default::default(),
271 save_name: Default::default(),
272 save_ext: Default::default(),
273 dirs: Default::default(),
274 filter: Default::default(),
275 files: Default::default(),
276 no_default_roots: Default::default(),
277 roots: Default::default(),
278 path_state: Default::default(),
279 root_state: Default::default(),
280 dir_state: Default::default(),
281 file_state: Default::default(),
282 save_name_state: Default::default(),
283 new_state: Default::default(),
284 cancel_state: Default::default(),
285 ok_state: Default::default(),
286 };
287 s.dir_state.list.set_scroll_selection(true);
288 s.file_state.set_scroll_selection(true);
289 s
290 }
291}
292
293impl<'a> Default for FileDialog<'a> {
294 fn default() -> Self {
295 Self {
296 block: Default::default(),
297 style: Default::default(),
298 layout: Default::default(),
299 list_style: Default::default(),
300 roots_style: Default::default(),
301 text_style: Default::default(),
302 button_style: Default::default(),
303 ok_text: "Ok",
304 cancel_text: "Cancel",
305 }
306 }
307}
308
309impl<'a> FileDialog<'a> {
310 pub fn new() -> Self {
312 Self::default()
313 }
314
315 pub fn ok_text(mut self, txt: &'a str) -> Self {
317 self.ok_text = txt;
318 self
319 }
320
321 pub fn cancel_text(mut self, txt: &'a str) -> Self {
323 self.cancel_text = txt;
324 self
325 }
326
327 pub fn block(mut self, block: Block<'a>) -> Self {
329 self.block = Some(block);
330 self.block = self.block.map(|v| v.style(self.style));
331 self
332 }
333
334 pub fn style(mut self, style: Style) -> Self {
336 self.style = style;
337 self.block = self.block.map(|v| v.style(style));
338 self
339 }
340
341 pub fn list_style(mut self, style: ListStyle) -> Self {
343 self.list_style = Some(style);
344 self
345 }
346
347 pub fn roots_style(mut self, style: ListStyle) -> Self {
349 self.roots_style = Some(style);
350 self
351 }
352
353 pub fn text_style(mut self, style: TextStyle) -> Self {
355 self.text_style = Some(style);
356 self
357 }
358
359 pub fn button_style(mut self, style: ButtonStyle) -> Self {
361 self.button_style = Some(style);
362 self
363 }
364
365 pub fn left(mut self, left: Constraint) -> Self {
367 self.layout = self.layout.left(left);
368 self
369 }
370
371 pub fn top(mut self, top: Constraint) -> Self {
373 self.layout = self.layout.top(top);
374 self
375 }
376
377 pub fn right(mut self, right: Constraint) -> Self {
379 self.layout = self.layout.right(right);
380 self
381 }
382
383 pub fn bottom(mut self, bottom: Constraint) -> Self {
385 self.layout = self.layout.bottom(bottom);
386 self
387 }
388
389 pub fn position(mut self, pos: Position) -> Self {
391 self.layout = self.layout.position(pos);
392 self
393 }
394
395 pub fn width(mut self, width: Constraint) -> Self {
397 self.layout = self.layout.width(width);
398 self
399 }
400
401 pub fn height(mut self, height: Constraint) -> Self {
403 self.layout = self.layout.height(height);
404 self
405 }
406
407 pub fn size(mut self, size: Size) -> Self {
409 self.layout = self.layout.size(size);
410 self
411 }
412
413 pub fn styles(mut self, styles: FileDialogStyle) -> Self {
415 self.style = styles.style;
416 if styles.block.is_some() {
417 self.block = styles.block;
418 }
419 if let Some(border_style) = styles.border_style {
420 self.block = self.block.map(|v| v.border_style(border_style));
421 }
422 if let Some(title_style) = styles.title_style {
423 self.block = self.block.map(|v| v.title_style(title_style));
424 }
425 self.block = self.block.map(|v| v.style(self.style));
426 if let Some(layout) = styles.layout {
427 self.layout = layout;
428 }
429 if styles.list.is_some() {
430 self.list_style = styles.list;
431 }
432 if styles.roots.is_some() {
433 self.roots_style = styles.roots;
434 }
435 if styles.text.is_some() {
436 self.text_style = styles.text;
437 }
438 if styles.button.is_some() {
439 self.button_style = styles.button;
440 }
441 self
442 }
443}
444
445#[derive(Debug, Default)]
446struct EditDirName<'a> {
447 edit_dir: TextInput<'a>,
448}
449
450#[derive(Debug, Default, Clone)]
451struct EditDirNameState {
452 edit_dir: TextInputState,
453}
454
455impl StatefulWidget for EditDirName<'_> {
456 type State = EditDirNameState;
457
458 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
459 self.edit_dir.render(area, buf, &mut state.edit_dir);
460 }
461}
462
463impl HasScreenCursor for EditDirNameState {
464 fn screen_cursor(&self) -> Option<(u16, u16)> {
465 self.edit_dir.screen_cursor()
466 }
467}
468
469impl RelocatableState for EditDirNameState {
470 fn relocate(&mut self, shift: (i16, i16), clip: Rect) {
471 self.edit_dir.relocate(shift, clip);
472 }
473}
474
475impl HandleEvent<crossterm::event::Event, Regular, EditOutcome> for EditDirNameState {
476 fn handle(&mut self, event: &crossterm::event::Event, qualifier: Regular) -> EditOutcome {
477 match self.edit_dir.handle(event, qualifier) {
478 TextOutcome::Continue => EditOutcome::Continue,
479 TextOutcome::Unchanged => EditOutcome::Unchanged,
480 TextOutcome::Changed => EditOutcome::Changed,
481 TextOutcome::TextChanged => EditOutcome::Changed,
482 }
483 }
484}
485
486impl HandleEvent<crossterm::event::Event, MouseOnly, EditOutcome> for EditDirNameState {
487 fn handle(&mut self, event: &crossterm::event::Event, qualifier: MouseOnly) -> EditOutcome {
488 match self.edit_dir.handle(event, qualifier) {
489 TextOutcome::Continue => EditOutcome::Continue,
490 TextOutcome::Unchanged => EditOutcome::Unchanged,
491 TextOutcome::Changed => EditOutcome::Changed,
492 TextOutcome::TextChanged => EditOutcome::Changed,
493 }
494 }
495}
496
497impl HasFocus for EditDirNameState {
498 fn build(&self, builder: &mut FocusBuilder) {
499 builder.leaf_widget(self);
500 }
501
502 fn focus(&self) -> FocusFlag {
503 self.edit_dir.focus()
504 }
505
506 fn area(&self) -> Rect {
507 self.edit_dir.area()
508 }
509}
510
511impl StatefulWidget for FileDialog<'_> {
512 type State = FileDialogState;
513
514 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
515 state.area = area;
516
517 if !state.active {
518 return;
519 }
520
521 let block;
522 let block = if let Some(block) = self.block.as_ref() {
523 block
524 } else {
525 block = Block::bordered()
526 .title(match state.mode {
527 Mode::Open => " Open ",
528 Mode::Save => " Save ",
529 Mode::Dir => " Directory ",
530 })
531 .style(self.style);
532 &block
533 };
534
535 let layout = self.layout.layout_dialog(
536 area,
537 block_padding2(block),
538 [
539 Constraint::Percentage(20),
540 Constraint::Percentage(30),
541 Constraint::Percentage(50),
542 ],
543 0,
544 Flex::Center,
545 );
546 state.area = layout.area();
547
548 reset_buf_area(layout.area(), buf);
549 block.render(area, buf);
550
551 match state.mode {
552 Mode::Open => {
553 render_open(&self, layout.widget_for(DialogItem::Content), buf, state);
554 }
555 Mode::Save => {
556 render_save(&self, layout.widget_for(DialogItem::Content), buf, state);
557 }
558 Mode::Dir => {
559 render_open_dir(&self, layout.widget_for(DialogItem::Content), buf, state);
560 }
561 }
562
563 let mut l_n = layout.widget_for(DialogItem::Button(1));
564 l_n.width = 10;
565 Button::new(Text::from("New").alignment(Alignment::Center))
566 .styles_opt(self.button_style.clone())
567 .render(l_n, buf, &mut state.new_state);
568
569 let l_oc = Layout::horizontal([Constraint::Length(10), Constraint::Length(10)])
570 .spacing(1)
571 .flex(Flex::End)
572 .split(layout.widget_for(DialogItem::Button(2)));
573
574 Button::new(Text::from(self.cancel_text).alignment(Alignment::Center))
575 .styles_opt(self.button_style.clone())
576 .render(l_oc[0], buf, &mut state.cancel_state);
577
578 Button::new(Text::from(self.ok_text).alignment(Alignment::Center))
579 .styles_opt(self.button_style.clone())
580 .render(l_oc[1], buf, &mut state.ok_state);
581 }
582}
583
584fn render_open_dir(
585 widget: &FileDialog<'_>,
586 area: Rect,
587 buf: &mut Buffer,
588 state: &mut FileDialogState,
589) {
590 let l_grid = layout_as_grid(
591 area,
592 Layout::horizontal([
593 Constraint::Percentage(20), Constraint::Percentage(80),
595 ]),
596 Layout::vertical([
597 Constraint::Length(1), Constraint::Fill(1),
599 ]),
600 );
601
602 let mut l_path = l_grid.widget_for((1, 0));
604 l_path.width = l_path.width.saturating_sub(1);
605 TextInput::new()
606 .styles_opt(widget.text_style.clone())
607 .render(l_path, buf, &mut state.path_state);
608
609 List::default()
610 .items(state.roots.iter().map(|v| {
611 let s = v.0.to_string_lossy();
612 ListItem::from(format!("{}", s))
613 }))
614 .scroll(Scroll::new())
615 .styles_opt(widget.roots_style.clone())
616 .render(l_grid.widget_for((0, 1)), buf, &mut state.root_state);
617
618 EditList::new(
619 List::default()
620 .items(state.dirs.iter().map(|v| {
621 let s = v.to_string_lossy();
622 ListItem::from(s)
623 }))
624 .scroll(Scroll::new())
625 .styles_opt(widget.list_style.clone()),
626 EditDirName {
627 edit_dir: TextInput::new().styles_opt(widget.text_style.clone()),
628 },
629 )
630 .render(l_grid.widget_for((1, 1)), buf, &mut state.dir_state);
631}
632
633fn render_open(widget: &FileDialog<'_>, area: Rect, buf: &mut Buffer, state: &mut FileDialogState) {
634 let l_grid = layout_as_grid(
635 area,
636 Layout::horizontal([
637 Constraint::Percentage(20),
638 Constraint::Percentage(30),
639 Constraint::Percentage(50),
640 ]),
641 Layout::new(
642 Direction::Vertical,
643 [Constraint::Length(1), Constraint::Fill(1)],
644 ),
645 );
646
647 let mut l_path = l_grid.widget_for((1, 0)).union(l_grid.widget_for((2, 0)));
649 l_path.width = l_path.width.saturating_sub(1);
650 TextInput::new()
651 .styles_opt(widget.text_style.clone())
652 .render(l_path, buf, &mut state.path_state);
653
654 List::default()
655 .items(state.roots.iter().map(|v| {
656 let s = v.0.to_string_lossy();
657 ListItem::from(format!("{}", s))
658 }))
659 .scroll(Scroll::new())
660 .styles_opt(widget.roots_style.clone())
661 .render(l_grid.widget_for((0, 1)), buf, &mut state.root_state);
662
663 EditList::new(
664 List::default()
665 .items(state.dirs.iter().map(|v| {
666 let s = v.to_string_lossy();
667 ListItem::from(s)
668 }))
669 .scroll(Scroll::new())
670 .styles_opt(widget.list_style.clone()),
671 EditDirName {
672 edit_dir: TextInput::new().styles_opt(widget.text_style.clone()),
673 },
674 )
675 .render(l_grid.widget_for((1, 1)), buf, &mut state.dir_state);
676
677 List::default()
678 .items(state.files.iter().map(|v| {
679 let s = v.to_string_lossy();
680 ListItem::from(s)
681 }))
682 .scroll(Scroll::new())
683 .styles_opt(widget.list_style.clone())
684 .render(l_grid.widget_for((2, 1)), buf, &mut state.file_state);
685}
686
687fn render_save(widget: &FileDialog<'_>, area: Rect, buf: &mut Buffer, state: &mut FileDialogState) {
688 let l_grid = layout_as_grid(
689 area,
690 Layout::horizontal([
691 Constraint::Percentage(20),
692 Constraint::Percentage(30),
693 Constraint::Percentage(50),
694 ]),
695 Layout::new(
696 Direction::Vertical,
697 [
698 Constraint::Length(1),
699 Constraint::Fill(1),
700 Constraint::Length(1),
701 ],
702 ),
703 );
704
705 let mut l_path = l_grid.widget_for((1, 0)).union(l_grid.widget_for((2, 0)));
707 l_path.width = l_path.width.saturating_sub(1);
708 TextInput::new()
709 .styles_opt(widget.text_style.clone())
710 .render(l_path, buf, &mut state.path_state);
711
712 List::default()
713 .items(state.roots.iter().map(|v| {
714 let s = v.0.to_string_lossy();
715 ListItem::from(format!("{}", s))
716 }))
717 .scroll(Scroll::new())
718 .styles_opt(widget.roots_style.clone())
719 .render(l_grid.widget_for((0, 1)), buf, &mut state.root_state);
720
721 EditList::new(
722 List::default()
723 .items(state.dirs.iter().map(|v| {
724 let s = v.to_string_lossy();
725 ListItem::from(s)
726 }))
727 .scroll(Scroll::new())
728 .styles_opt(widget.list_style.clone()),
729 EditDirName {
730 edit_dir: TextInput::new().styles_opt(widget.text_style.clone()),
731 },
732 )
733 .render(l_grid.widget_for((1, 1)), buf, &mut state.dir_state);
734
735 List::default()
736 .items(state.files.iter().map(|v| {
737 let s = v.to_string_lossy();
738 ListItem::from(s)
739 }))
740 .scroll(Scroll::new())
741 .styles_opt(widget.list_style.clone())
742 .render(l_grid.widget_for((2, 1)), buf, &mut state.file_state);
743
744 TextInput::new()
745 .styles_opt(widget.text_style.clone())
746 .render(l_grid.widget_for((2, 2)), buf, &mut state.save_name_state);
747}
748
749impl FileDialogState {
750 pub fn new() -> Self {
751 Self::default()
752 }
753
754 pub fn active(&self) -> bool {
755 self.active
756 }
757
758 pub fn set_filter(&mut self, filter: impl Fn(&Path) -> bool + 'static) {
760 self.filter = Some(Box::new(filter));
761 }
762
763 pub fn set_last_path(&mut self, last: &Path) {
768 self.path = last.into();
769 }
770
771 pub fn use_default_roots(&mut self, roots: bool) {
773 self.no_default_roots = !roots;
774 }
775
776 pub fn no_default_roots(&mut self) {
778 self.no_default_roots = true;
779 }
780
781 pub fn add_root(&mut self, name: impl AsRef<str>, path: impl Into<PathBuf>) {
783 self.roots
784 .push((OsString::from(name.as_ref()), path.into()))
785 }
786
787 pub fn clear_roots(&mut self) {
789 self.roots.clear();
790 }
791
792 pub fn default_roots(&mut self, start: &Path, last: &Path) {
794 if last.exists() {
795 self.roots.push((
796 OsString::from("Last"), last.into(),
798 ));
799 }
800 self.roots.push((
801 OsString::from("Start"), start.into(),
803 ));
804
805 #[cfg(feature = "user_directories")]
806 {
807 if let Some(home) = home_dir() {
808 self.roots.push((OsString::from("Home"), home));
809 }
810 if let Some(documents) = document_dir() {
811 self.roots.push((OsString::from("Documents"), documents));
812 }
813 }
814
815 #[cfg(feature = "user_directories")]
816 {
817 let disks = Disks::new_with_refreshed_list();
818 for d in disks.list() {
819 self.roots
820 .push((d.name().to_os_string(), d.mount_point().to_path_buf()));
821 }
822 }
823
824 self.root_state.select(Some(0));
825 }
826
827 pub fn directory_dialog(&mut self, path: impl AsRef<Path>) -> Result<(), io::Error> {
829 let path = path.as_ref();
830 let old_path = self.path.clone();
831
832 self.active = true;
833 self.mode = Mode::Dir;
834 self.save_name = None;
835 self.save_ext = None;
836 self.dirs.clear();
837 self.files.clear();
838 self.path = Default::default();
839 if !self.no_default_roots {
840 self.clear_roots();
841 self.default_roots(path, &old_path);
842 if old_path.exists() {
843 self.set_path(&old_path)?;
844 } else {
845 self.set_path(path)?;
846 }
847 } else {
848 self.set_path(path)?;
849 }
850 self.build_focus().focus(&self.dir_state);
851 Ok(())
852 }
853
854 pub fn open_dialog(&mut self, path: impl AsRef<Path>) -> Result<(), io::Error> {
856 let path = path.as_ref();
857 let old_path = self.path.clone();
858
859 self.active = true;
860 self.mode = Mode::Open;
861 self.save_name = None;
862 self.save_ext = None;
863 self.dirs.clear();
864 self.files.clear();
865 self.path = Default::default();
866 if !self.no_default_roots {
867 self.clear_roots();
868 self.default_roots(path, &old_path);
869 if old_path.exists() {
870 self.set_path(&old_path)?;
871 } else {
872 self.set_path(path)?;
873 }
874 } else {
875 self.set_path(path)?;
876 }
877 self.build_focus().focus(&self.file_state);
878 Ok(())
879 }
880
881 pub fn save_dialog(
883 &mut self,
884 path: impl AsRef<Path>,
885 name: impl AsRef<str>,
886 ) -> Result<(), io::Error> {
887 self.save_dialog_ext(path, name, "")
888 }
889
890 pub fn save_dialog_ext(
892 &mut self,
893 path: impl AsRef<Path>,
894 name: impl AsRef<str>,
895 ext: impl AsRef<str>,
896 ) -> Result<(), io::Error> {
897 let path = path.as_ref();
898 let old_path = self.path.clone();
899
900 self.active = true;
901 self.mode = Mode::Save;
902 self.save_name = Some(OsString::from(name.as_ref()));
903 self.save_ext = Some(OsString::from(ext.as_ref()));
904 self.dirs.clear();
905 self.files.clear();
906 self.path = Default::default();
907 if !self.no_default_roots {
908 self.clear_roots();
909 self.default_roots(path, &old_path);
910 if old_path.exists() {
911 self.set_path(&old_path)?;
912 } else {
913 self.set_path(path)?;
914 }
915 } else {
916 self.set_path(path)?;
917 }
918 self.build_focus().focus(&self.save_name_state);
919 Ok(())
920 }
921
922 fn find_parent(&self, path: &Path) -> Option<PathBuf> {
923 if path == Path::new(".") || path.file_name().is_none() {
924 let parent = path.join("..");
925 let canon_parent = parent.canonicalize().ok();
926 let canon_path = path.canonicalize().ok();
927 if canon_parent == canon_path {
928 None
929 } else if parent.exists() && parent.is_dir() {
930 Some(parent)
931 } else {
932 None
933 }
934 } else if let Some(parent) = path.parent() {
935 if parent.exists() && parent.is_dir() {
936 Some(parent.to_path_buf())
937 } else {
938 None
939 }
940 } else {
941 None
942 }
943 }
944
945 fn set_path(&mut self, path: &Path) -> Result<FileOutcome, io::Error> {
947 let old = self.path.clone();
948 let path = path.to_path_buf();
949
950 if old != path {
951 let mut dirs = Vec::new();
952 let mut files = Vec::new();
953
954 if self.find_parent(&path).is_some() {
955 dirs.push(OsString::from(".."));
956 }
957
958 for r in path.read_dir()? {
959 let Ok(r) = r else {
960 continue;
961 };
962
963 if let Ok(meta) = r.metadata() {
964 if meta.is_dir() {
965 dirs.push(r.file_name());
966 } else if meta.is_file() {
967 if let Some(filter) = self.filter.as_ref() {
968 if filter(&r.path()) {
969 files.push(r.file_name());
970 }
971 } else {
972 files.push(r.file_name());
973 }
974 }
975 }
976 }
977
978 self.path = path;
979 self.dirs = dirs;
980 self.files = files;
981
982 self.path_state.set_text(self.path.to_string_lossy());
983 self.path_state.move_to_line_end(false);
984
985 self.dir_state.cancel();
986 if !self.dirs.is_empty() {
987 self.dir_state.list.select(Some(0));
988 } else {
989 self.dir_state.list.select(None);
990 }
991 self.dir_state.list.set_offset(0);
992 if !self.files.is_empty() {
993 self.file_state.select(Some(0));
994 if let Some(name) = &self.save_name {
995 self.save_name_state.set_text(name.to_string_lossy());
996 } else {
997 self.save_name_state
998 .set_text(self.files[0].to_string_lossy());
999 }
1000 } else {
1001 self.file_state.select(None);
1002 if let Some(name) = &self.save_name {
1003 self.save_name_state.set_text(name.to_string_lossy());
1004 } else {
1005 self.save_name_state.set_text("");
1006 }
1007 }
1008 self.file_state.set_offset(0);
1009
1010 Ok(FileOutcome::Changed)
1011 } else {
1012 Ok(FileOutcome::Unchanged)
1013 }
1014 }
1015
1016 fn use_path_input(&mut self) -> Result<FileOutcome, io::Error> {
1017 let path = PathBuf::from(self.path_state.text());
1018 if !path.exists() || !path.is_dir() {
1019 self.path_state.invalid = true;
1020 } else {
1021 self.path_state.invalid = false;
1022 self.set_path(&path)?;
1023 }
1024
1025 Ok(FileOutcome::Changed)
1026 }
1027
1028 fn chdir(&mut self, dir: &OsString) -> Result<FileOutcome, io::Error> {
1029 if dir == &OsString::from("..") {
1030 if let Some(parent) = self.find_parent(&self.path) {
1031 self.set_path(&parent)
1032 } else {
1033 Ok(FileOutcome::Unchanged)
1034 }
1035 } else {
1036 self.set_path(&self.path.join(dir))
1037 }
1038 }
1039
1040 fn chroot_selected(&mut self) -> Result<FileOutcome, io::Error> {
1041 if let Some(select) = self.root_state.selected() {
1042 if let Some(d) = self.roots.get(select).cloned() {
1043 self.set_path(&d.1)?;
1044 return Ok(FileOutcome::Changed);
1045 }
1046 }
1047 Ok(FileOutcome::Unchanged)
1048 }
1049
1050 fn chdir_selected(&mut self) -> Result<FileOutcome, io::Error> {
1051 if let Some(select) = self.dir_state.list.selected() {
1052 if let Some(dir) = self.dirs.get(select).cloned() {
1053 self.chdir(&dir)?;
1054 return Ok(FileOutcome::Changed);
1055 }
1056 }
1057 Ok(FileOutcome::Unchanged)
1058 }
1059
1060 fn name_selected(&mut self) -> Result<FileOutcome, io::Error> {
1062 if let Some(select) = self.file_state.selected() {
1063 if let Some(file) = self.files.get(select).cloned() {
1064 let name = file.to_string_lossy();
1065 self.save_name_state.set_text(name);
1066 return Ok(FileOutcome::Changed);
1067 }
1068 }
1069 Ok(FileOutcome::Unchanged)
1070 }
1071
1072 fn start_edit_dir(&mut self) -> FileOutcome {
1074 if !self.dir_state.is_editing() {
1075 self.build_focus().focus(&self.dir_state);
1076
1077 self.dirs.push(OsString::from(""));
1078 self.dir_state.editor.edit_dir.set_text("");
1079 self.dir_state.edit_new(self.dirs.len() - 1);
1080
1081 FileOutcome::Changed
1082 } else {
1083 FileOutcome::Continue
1084 }
1085 }
1086
1087 fn cancel_edit_dir(&mut self) -> FileOutcome {
1088 if self.dir_state.is_editing() {
1089 self.dir_state.cancel();
1090 self.dirs.remove(self.dirs.len() - 1);
1091 FileOutcome::Changed
1092 } else {
1093 FileOutcome::Continue
1094 }
1095 }
1096
1097 fn commit_edit_dir(&mut self) -> Result<FileOutcome, io::Error> {
1098 if self.dir_state.is_editing() {
1099 let name = self.dir_state.editor.edit_dir.text().trim();
1100 let path = self.path.join(name);
1101 if fs::create_dir(&path).is_err() {
1102 self.dir_state.editor.edit_dir.invalid = true;
1103 Ok(FileOutcome::Changed)
1104 } else {
1105 self.dir_state.commit();
1106 if self.mode == Mode::Save {
1107 self.build_focus().focus_no_lost(&self.save_name_state);
1108 }
1109 self.set_path(&path)
1110 }
1111 } else {
1112 Ok(FileOutcome::Unchanged)
1113 }
1114 }
1115
1116 fn close_cancel(&mut self) -> FileOutcome {
1118 self.active = false;
1119 self.dir_state.cancel();
1120 FileOutcome::Cancel
1121 }
1122
1123 fn choose_selected(&mut self) -> FileOutcome {
1125 if self.mode == Mode::Open {
1126 if let Some(select) = self.file_state.selected() {
1127 if let Some(file) = self.files.get(select).cloned() {
1128 self.active = false;
1129 return FileOutcome::Ok(self.path.join(file));
1130 }
1131 }
1132 } else if self.mode == Mode::Save {
1133 let mut path = self.path.join(self.save_name_state.text().trim());
1134 if let Some(ext) = &self.save_ext {
1135 if !ext.is_empty() {
1136 path.set_extension(ext);
1137 }
1138 }
1139 self.active = false;
1140 return FileOutcome::Ok(path);
1141 } else if self.mode == Mode::Dir {
1142 if let Some(select) = self.dir_state.list.selected() {
1143 if let Some(dir) = self.dirs.get(select).cloned() {
1144 self.active = false;
1145 if dir != ".." {
1146 return FileOutcome::Ok(self.path.join(dir));
1147 } else {
1148 return FileOutcome::Ok(self.path.clone());
1149 }
1150 }
1151 }
1152 }
1153 FileOutcome::Unchanged
1154 }
1155}
1156
1157impl HasScreenCursor for FileDialogState {
1158 fn screen_cursor(&self) -> Option<(u16, u16)> {
1159 if self.active {
1160 self.path_state
1161 .screen_cursor()
1162 .or_else(|| self.save_name_state.screen_cursor())
1163 .or_else(|| self.dir_state.screen_cursor())
1164 } else {
1165 None
1166 }
1167 }
1168}
1169
1170impl HasFocus for FileDialogState {
1171 fn build(&self, _builder: &mut FocusBuilder) {
1172 }
1174
1175 fn focus(&self) -> FocusFlag {
1176 unimplemented!("not available")
1177 }
1178
1179 fn area(&self) -> Rect {
1180 unimplemented!("not available")
1181 }
1182}
1183
1184impl RelocatableState for FileDialogState {
1185 fn relocate(&mut self, shift: (i16, i16), clip: Rect) {
1186 self.area.relocate(shift, clip);
1187 self.path_state.relocate(shift, clip);
1188 self.root_state.relocate(shift, clip);
1189 self.dir_state.relocate(shift, clip);
1190 self.file_state.relocate(shift, clip);
1191 self.save_name_state.relocate(shift, clip);
1192 self.new_state.relocate(shift, clip);
1193 self.cancel_state.relocate(shift, clip);
1194 self.ok_state.relocate(shift, clip);
1195 }
1196}
1197
1198impl FileDialogState {
1199 fn build_focus(&self) -> Focus {
1200 let mut fb = FocusBuilder::default();
1201 fb.widget(&self.dir_state);
1202 if self.mode == Mode::Save || self.mode == Mode::Open {
1203 fb.widget(&self.file_state);
1204 }
1205 if self.mode == Mode::Save {
1206 fb.widget(&self.save_name_state);
1207 }
1208 fb.widget(&self.ok_state);
1209 fb.widget(&self.cancel_state);
1210 fb.widget(&self.new_state);
1211 fb.widget(&self.root_state);
1212 fb.widget(&self.path_state);
1213 fb.build()
1214 }
1215}
1216
1217impl HandleEvent<crossterm::event::Event, Dialog, Result<FileOutcome, io::Error>>
1218 for FileDialogState
1219{
1220 fn handle(
1221 &mut self,
1222 event: &crossterm::event::Event,
1223 _qualifier: Dialog,
1224 ) -> Result<FileOutcome, io::Error> {
1225 if !self.active {
1226 return Ok(FileOutcome::Continue);
1227 }
1228
1229 let mut focus = self.build_focus();
1230
1231 let mut f: FileOutcome = focus.handle(event, Regular).into();
1232 let mut r = FileOutcome::Continue;
1233
1234 f = f.or_else(|| match event {
1235 ct_event!(keycode press F(1)) => {
1236 if !self.root_state.is_focused() {
1237 focus.focus(&self.root_state);
1238 FileOutcome::Changed
1239 } else {
1240 FileOutcome::Continue
1241 }
1242 }
1243 ct_event!(keycode press F(2)) => {
1244 if !self.dir_state.is_focused() {
1245 focus.focus(&self.dir_state);
1246 FileOutcome::Changed
1247 } else {
1248 FileOutcome::Continue
1249 }
1250 }
1251 ct_event!(keycode press F(3)) => {
1252 if !self.file_state.is_focused() {
1253 focus.focus(&self.file_state);
1254 FileOutcome::Changed
1255 } else {
1256 FileOutcome::Continue
1257 }
1258 }
1259 ct_event!(keycode press F(4)) => {
1260 if !self.path_state.is_focused() {
1261 focus.focus(&self.path_state);
1262 FileOutcome::Changed
1263 } else {
1264 FileOutcome::Continue
1265 }
1266 }
1267 ct_event!(keycode press F(5)) => {
1268 if !self.save_name_state.is_focused() {
1269 focus.focus(&self.save_name_state);
1270 FileOutcome::Changed
1271 } else {
1272 FileOutcome::Continue
1273 }
1274 }
1275 _ => FileOutcome::Continue,
1276 });
1277
1278 r = r.or_else_try(|| {
1279 handle_path(self, event)?
1280 .or_else_try(|| {
1281 if self.mode == Mode::Save {
1282 handle_name(self, event)
1283 } else {
1284 Ok(FileOutcome::Continue)
1285 }
1286 })?
1287 .or_else_try(|| handle_files(self, event))?
1288 .or_else_try(|| handle_dirs(self, event))?
1289 .or_else_try(|| handle_roots(self, event))?
1290 .or_else_try(|| handle_new(self, event))?
1291 .or_else_try(|| handle_cancel(self, event))?
1292 .or_else_try(|| handle_ok(self, event))
1293 })?;
1294
1295 Ok(max(max(f, r), FileOutcome::Unchanged))
1296 }
1297}
1298
1299fn handle_new(
1300 state: &mut FileDialogState,
1301 event: &crossterm::event::Event,
1302) -> Result<FileOutcome, io::Error> {
1303 event_flow!(match state.new_state.handle(event, Regular) {
1304 ButtonOutcome::Pressed => {
1305 state.start_edit_dir()
1306 }
1307 r => Outcome::from(r).into(),
1308 });
1309 event_flow!(match event {
1310 ct_event!(key press CONTROL-'n') => {
1311 state.start_edit_dir()
1312 }
1313 _ => FileOutcome::Continue,
1314 });
1315 Ok(FileOutcome::Continue)
1316}
1317
1318fn handle_ok(
1319 state: &mut FileDialogState,
1320 event: &crossterm::event::Event,
1321) -> Result<FileOutcome, io::Error> {
1322 event_flow!(match state.ok_state.handle(event, Regular) {
1323 ButtonOutcome::Pressed => state.choose_selected(),
1324 r => Outcome::from(r).into(),
1325 });
1326 Ok(FileOutcome::Continue)
1327}
1328
1329fn handle_cancel(
1330 state: &mut FileDialogState,
1331 event: &crossterm::event::Event,
1332) -> Result<FileOutcome, io::Error> {
1333 event_flow!(match state.cancel_state.handle(event, Regular) {
1334 ButtonOutcome::Pressed => {
1335 state.close_cancel()
1336 }
1337 r => Outcome::from(r).into(),
1338 });
1339 event_flow!(match event {
1340 ct_event!(keycode press Esc) => {
1341 state.close_cancel()
1342 }
1343 _ => FileOutcome::Continue,
1344 });
1345 Ok(FileOutcome::Continue)
1346}
1347
1348fn handle_name(
1349 state: &mut FileDialogState,
1350 event: &crossterm::event::Event,
1351) -> Result<FileOutcome, io::Error> {
1352 event_flow!(Outcome::from(state.save_name_state.handle(event, Regular)));
1353 if state.save_name_state.is_focused() {
1354 event_flow!(match event {
1355 ct_event!(keycode press Enter) => {
1356 state.choose_selected()
1357 }
1358 _ => FileOutcome::Continue,
1359 });
1360 }
1361 Ok(FileOutcome::Continue)
1362}
1363
1364fn handle_path(
1365 state: &mut FileDialogState,
1366 event: &crossterm::event::Event,
1367) -> Result<FileOutcome, io::Error> {
1368 event_flow!(Outcome::from(state.path_state.handle(event, Regular)));
1369 if state.path_state.is_focused() {
1370 event_flow!(match event {
1371 ct_event!(keycode press Enter) => {
1372 state.use_path_input()?;
1373 state.build_focus().focus_no_lost(&state.dir_state.list);
1374 FileOutcome::Changed
1375 }
1376 _ => FileOutcome::Continue,
1377 });
1378 }
1379 on_lost!(
1380 state.path_state => {
1381 state.use_path_input()?
1382 }
1383 );
1384 Ok(FileOutcome::Continue)
1385}
1386
1387fn handle_roots(
1388 state: &mut FileDialogState,
1389 event: &crossterm::event::Event,
1390) -> Result<FileOutcome, io::Error> {
1391 event_flow!(match state.root_state.handle(event, Regular) {
1392 Outcome::Changed => {
1393 state.chroot_selected()?
1394 }
1395 r => r.into(),
1396 });
1397 Ok(FileOutcome::Continue)
1398}
1399
1400fn handle_dirs(
1401 state: &mut FileDialogState,
1402 event: &crossterm::event::Event,
1403) -> Result<FileOutcome, io::Error> {
1404 if matches!(event, ct_event!(keycode press F(2))) {
1406 return Ok(FileOutcome::Continue);
1407 }
1408
1409 event_flow!(match state.dir_state.handle(event, Regular) {
1410 EditOutcome::Edit => {
1411 state.chdir_selected()?
1412 }
1413 EditOutcome::Cancel => {
1414 state.cancel_edit_dir()
1415 }
1416 EditOutcome::Commit | EditOutcome::CommitAndAppend | EditOutcome::CommitAndEdit => {
1417 state.commit_edit_dir()?
1418 }
1419 r => {
1420 Outcome::from(r).into()
1421 }
1422 });
1423 if state.dir_state.list.is_focused() {
1424 event_flow!(handle_nav(&mut state.dir_state.list, &state.dirs, event)?);
1425 }
1426 Ok(FileOutcome::Continue)
1427}
1428
1429fn handle_files(
1430 state: &mut FileDialogState,
1431 event: &crossterm::event::Event,
1432) -> Result<FileOutcome, io::Error> {
1433 if state.file_state.is_focused() {
1434 event_flow!(match event {
1435 ct_event!(mouse any for m)
1436 if state
1437 .file_state
1438 .mouse
1439 .doubleclick(state.file_state.inner, m) =>
1440 {
1441 state.choose_selected()
1442 }
1443 ct_event!(keycode press Enter) => {
1444 state.choose_selected()
1445 }
1446 _ => FileOutcome::Continue,
1447 });
1448 event_flow!(
1449 match handle_nav(&mut state.file_state, &state.files, event)? {
1450 FileOutcome::Changed => {
1451 if state.mode == Mode::Save {
1452 state.name_selected()?
1453 } else {
1454 FileOutcome::Changed
1455 }
1456 }
1457 r => r,
1458 }
1459 );
1460 }
1461 event_flow!(match state.file_state.handle(event, Regular).into() {
1462 FileOutcome::Changed => {
1463 if state.mode == Mode::Save {
1464 state.name_selected()?
1465 } else {
1466 FileOutcome::Changed
1467 }
1468 }
1469 r => r,
1470 });
1471 Ok(FileOutcome::Continue)
1472}
1473
1474fn handle_nav(
1475 list: &mut ListState<RowSelection>,
1476 nav: &[OsString],
1477 event: &crossterm::event::Event,
1478) -> Result<FileOutcome, io::Error> {
1479 event_flow!(match event {
1480 ct_event!(key press c) => {
1481 let next = find_next_by_key(*c, list.selected().unwrap_or(0), nav);
1482 if let Some(next) = next {
1483 list.move_to(next).into()
1484 } else {
1485 FileOutcome::Unchanged
1486 }
1487 }
1488 _ => FileOutcome::Continue,
1489 });
1490 Ok(FileOutcome::Continue)
1491}
1492
1493#[allow(clippy::question_mark)]
1494fn find_next_by_key(c: char, start: usize, names: &[OsString]) -> Option<usize> {
1495 let Some(c) = c.to_lowercase().next() else {
1496 return None;
1497 };
1498
1499 let mut idx = start;
1500 let mut selected = None;
1501 loop {
1502 idx += 1;
1503 if idx >= names.len() {
1504 idx = 0;
1505 }
1506 if idx == start {
1507 break;
1508 }
1509
1510 let nav = names[idx].to_string_lossy();
1511
1512 let initials = nav
1513 .split([' ', '_', '-'])
1514 .flat_map(|v| v.chars().next())
1515 .flat_map(|c| c.to_lowercase().next())
1516 .collect::<Vec<_>>();
1517 if initials.contains(&c) {
1518 selected = Some(idx);
1519 break;
1520 }
1521 }
1522
1523 selected
1524}
1525
1526pub fn handle_events(
1530 state: &mut FileDialogState,
1531 _focus: bool,
1532 event: &Event,
1533) -> Result<FileOutcome, io::Error> {
1534 HandleEvent::handle(state, event, Dialog)
1535}
1536
1537