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