rat_widget/
file_dialog.rs

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