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    /// Set the last path. This will be shown in the roots list.
672    /// And it will be the preferred start directory instead of
673    /// the one given [Self::open_dialog], [Self::directory_dialog]
674    /// and [Self::save_dialog].
675    pub fn set_last_path(&mut self, last: &Path) {
676        self.path = last.into();
677    }
678
679    /// Use the default set of roots.
680    pub fn use_default_roots(&mut self, roots: bool) {
681        self.use_default_roots = roots;
682    }
683
684    /// Add a root path.
685    pub fn add_root(&mut self, name: impl AsRef<str>, path: impl Into<PathBuf>) {
686        self.roots
687            .push((OsString::from(name.as_ref()), path.into()))
688    }
689
690    /// Clear all roots.
691    pub fn clear_roots(&mut self) {
692        self.roots.clear();
693    }
694
695    /// Append the default roots.
696    pub fn default_roots(&mut self, start: &Path, last: &Path) {
697        if last.exists() {
698            self.roots.push((
699                OsString::from("Last"), //
700                last.into(),
701            ));
702        }
703        self.roots.push((
704            OsString::from("Start"), //
705            start.into(),
706        ));
707
708        #[cfg(feature = "user_directories")]
709        {
710            if let Some(home) = home_dir() {
711                self.roots.push((OsString::from("Home"), home));
712            }
713            if let Some(documents) = document_dir() {
714                self.roots.push((OsString::from("Documents"), documents));
715            }
716        }
717
718        #[cfg(feature = "user_directories")]
719        {
720            let disks = Disks::new_with_refreshed_list();
721            for d in disks.list() {
722                self.roots
723                    .push((d.name().to_os_string(), d.mount_point().to_path_buf()));
724            }
725        }
726
727        self.root_state.select(Some(0));
728    }
729
730    /// Show as directory-dialog.
731    pub fn directory_dialog(&mut self, path: impl AsRef<Path>) -> Result<(), io::Error> {
732        let path = path.as_ref();
733        let old_path = self.path.clone();
734
735        self.active = true;
736        self.mode = Mode::Dir;
737        self.save_name = None;
738        self.save_ext = None;
739        self.dirs.clear();
740        self.files.clear();
741        self.path = Default::default();
742        if self.use_default_roots {
743            self.clear_roots();
744            self.default_roots(path, &old_path);
745            if old_path.exists() {
746                self.set_path(&old_path)?;
747            } else {
748                self.set_path(path)?;
749            }
750        } else {
751            self.set_path(path)?;
752        }
753        self.build_focus().focus(&self.dir_state);
754        Ok(())
755    }
756
757    /// Show as open-dialog.
758    pub fn open_dialog(&mut self, path: impl AsRef<Path>) -> Result<(), io::Error> {
759        let path = path.as_ref();
760        let old_path = self.path.clone();
761
762        self.active = true;
763        self.mode = Mode::Open;
764        self.save_name = None;
765        self.save_ext = None;
766        self.dirs.clear();
767        self.files.clear();
768        self.path = Default::default();
769        if self.use_default_roots {
770            self.clear_roots();
771            self.default_roots(path, &old_path);
772            if old_path.exists() {
773                self.set_path(&old_path)?;
774            } else {
775                self.set_path(path)?;
776            }
777        } else {
778            self.set_path(path)?;
779        }
780        self.build_focus().focus(&self.file_state);
781        Ok(())
782    }
783
784    /// Show as save-dialog.
785    pub fn save_dialog(
786        &mut self,
787        path: impl AsRef<Path>,
788        name: impl AsRef<str>,
789    ) -> Result<(), io::Error> {
790        self.save_dialog_ext(path, name, "")
791    }
792
793    /// Show as save-dialog.
794    pub fn save_dialog_ext(
795        &mut self,
796        path: impl AsRef<Path>,
797        name: impl AsRef<str>,
798        ext: impl AsRef<str>,
799    ) -> Result<(), io::Error> {
800        let path = path.as_ref();
801        let old_path = self.path.clone();
802
803        self.active = true;
804        self.mode = Mode::Save;
805        self.save_name = Some(OsString::from(name.as_ref()));
806        self.save_ext = Some(OsString::from(ext.as_ref()));
807        self.dirs.clear();
808        self.files.clear();
809        self.path = Default::default();
810        if self.use_default_roots {
811            self.clear_roots();
812            self.default_roots(path, &old_path);
813            if old_path.exists() {
814                self.set_path(&old_path)?;
815            } else {
816                self.set_path(path)?;
817            }
818        } else {
819            self.set_path(path)?;
820        }
821        self.build_focus().focus(&self.save_name_state);
822        Ok(())
823    }
824
825    fn find_parent(&self, path: &Path) -> Option<PathBuf> {
826        if path == Path::new(".") || path.file_name().is_none() {
827            let parent = path.join("..");
828            let canon_parent = parent.canonicalize().ok();
829            let canon_path = path.canonicalize().ok();
830            if canon_parent == canon_path {
831                None
832            } else if parent.exists() && parent.is_dir() {
833                Some(parent)
834            } else {
835                None
836            }
837        } else if let Some(parent) = path.parent() {
838            if parent.exists() && parent.is_dir() {
839                Some(parent.to_path_buf())
840            } else {
841                None
842            }
843        } else {
844            None
845        }
846    }
847
848    // change the path
849    fn set_path(&mut self, path: &Path) -> Result<FileOutcome, io::Error> {
850        let old = self.path.clone();
851        let path = path.to_path_buf();
852
853        if old != path {
854            let mut dirs = Vec::new();
855            let mut files = Vec::new();
856
857            if self.find_parent(&path).is_some() {
858                dirs.push(OsString::from(".."));
859            }
860
861            for r in path.read_dir()? {
862                let Ok(r) = r else {
863                    continue;
864                };
865
866                if let Ok(meta) = r.metadata() {
867                    if meta.is_dir() {
868                        dirs.push(r.file_name());
869                    } else if meta.is_file() {
870                        if let Some(filter) = self.filter.as_ref() {
871                            if filter(&r.path()) {
872                                files.push(r.file_name());
873                            }
874                        } else {
875                            files.push(r.file_name());
876                        }
877                    }
878                }
879            }
880
881            self.path = path;
882            self.dirs = dirs;
883            self.files = files;
884
885            self.path_state.set_text(self.path.to_string_lossy());
886            self.path_state.move_to_line_end(false);
887
888            self.dir_state.cancel();
889            if !self.dirs.is_empty() {
890                self.dir_state.list.select(Some(0));
891            } else {
892                self.dir_state.list.select(None);
893            }
894            self.dir_state.list.set_offset(0);
895            if !self.files.is_empty() {
896                self.file_state.select(Some(0));
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
901                        .set_text(self.files[0].to_string_lossy());
902                }
903            } else {
904                self.file_state.select(None);
905                if let Some(name) = &self.save_name {
906                    self.save_name_state.set_text(name.to_string_lossy());
907                } else {
908                    self.save_name_state.set_text("");
909                }
910            }
911            self.file_state.set_offset(0);
912
913            Ok(FileOutcome::Changed)
914        } else {
915            Ok(FileOutcome::Unchanged)
916        }
917    }
918
919    fn use_path_input(&mut self) -> Result<FileOutcome, io::Error> {
920        let path = PathBuf::from(self.path_state.text());
921        if !path.exists() || !path.is_dir() {
922            self.path_state.invalid = true;
923        } else {
924            self.path_state.invalid = false;
925            self.set_path(&path)?;
926        }
927
928        Ok(FileOutcome::Changed)
929    }
930
931    fn chdir(&mut self, dir: &OsString) -> Result<FileOutcome, io::Error> {
932        if dir == &OsString::from("..") {
933            if let Some(parent) = self.find_parent(&self.path) {
934                self.set_path(&parent)
935            } else {
936                Ok(FileOutcome::Unchanged)
937            }
938        } else {
939            self.set_path(&self.path.join(dir))
940        }
941    }
942
943    fn chroot_selected(&mut self) -> Result<FileOutcome, io::Error> {
944        if let Some(select) = self.root_state.selected() {
945            if let Some(d) = self.roots.get(select).cloned() {
946                self.set_path(&d.1)?;
947                return Ok(FileOutcome::Changed);
948            }
949        }
950        Ok(FileOutcome::Unchanged)
951    }
952
953    fn chdir_selected(&mut self) -> Result<FileOutcome, io::Error> {
954        if let Some(select) = self.dir_state.list.selected() {
955            if let Some(dir) = self.dirs.get(select).cloned() {
956                self.chdir(&dir)?;
957                return Ok(FileOutcome::Changed);
958            }
959        }
960        Ok(FileOutcome::Unchanged)
961    }
962
963    /// Set the selected file to the new name field.
964    fn name_selected(&mut self) -> Result<FileOutcome, io::Error> {
965        if let Some(select) = self.file_state.selected() {
966            if let Some(file) = self.files.get(select).cloned() {
967                let name = file.to_string_lossy();
968                self.save_name_state.set_text(name);
969                return Ok(FileOutcome::Changed);
970            }
971        }
972        Ok(FileOutcome::Unchanged)
973    }
974
975    /// Start creating a directory.
976    fn start_edit_dir(&mut self) -> FileOutcome {
977        if !self.dir_state.is_editing() {
978            self.build_focus().focus(&self.dir_state);
979
980            self.dirs.push(OsString::from(""));
981            self.dir_state.editor.edit_dir.set_text("");
982            self.dir_state.edit_new(self.dirs.len() - 1);
983
984            FileOutcome::Changed
985        } else {
986            FileOutcome::Continue
987        }
988    }
989
990    fn cancel_edit_dir(&mut self) -> FileOutcome {
991        if self.dir_state.is_editing() {
992            self.dir_state.cancel();
993            self.dirs.remove(self.dirs.len() - 1);
994            FileOutcome::Changed
995        } else {
996            FileOutcome::Continue
997        }
998    }
999
1000    fn commit_edit_dir(&mut self) -> Result<FileOutcome, io::Error> {
1001        if self.dir_state.is_editing() {
1002            let name = self.dir_state.editor.edit_dir.text().trim();
1003            let path = self.path.join(name);
1004            if fs::create_dir(&path).is_err() {
1005                self.dir_state.editor.edit_dir.invalid = true;
1006                Ok(FileOutcome::Changed)
1007            } else {
1008                self.dir_state.commit();
1009                if self.mode == Mode::Save {
1010                    self.build_focus().focus_no_lost(&self.save_name_state);
1011                }
1012                self.set_path(&path)
1013            }
1014        } else {
1015            Ok(FileOutcome::Unchanged)
1016        }
1017    }
1018
1019    /// Cancel the dialog.
1020    fn close_cancel(&mut self) -> FileOutcome {
1021        self.active = false;
1022        self.dir_state.cancel();
1023        FileOutcome::Cancel
1024    }
1025
1026    /// Choose the selected and close the dialog.
1027    fn choose_selected(&mut self) -> FileOutcome {
1028        if self.mode == Mode::Open {
1029            if let Some(select) = self.file_state.selected() {
1030                if let Some(file) = self.files.get(select).cloned() {
1031                    self.active = false;
1032                    return FileOutcome::Ok(self.path.join(file));
1033                }
1034            }
1035        } else if self.mode == Mode::Save {
1036            let mut path = self.path.join(self.save_name_state.text().trim());
1037            if let Some(ext) = &self.save_ext {
1038                if !ext.is_empty() {
1039                    path.set_extension(ext);
1040                }
1041            }
1042            self.active = false;
1043            return FileOutcome::Ok(path);
1044        } else if self.mode == Mode::Dir {
1045            if let Some(select) = self.dir_state.list.selected() {
1046                if let Some(dir) = self.dirs.get(select).cloned() {
1047                    self.active = false;
1048                    if dir != ".." {
1049                        return FileOutcome::Ok(self.path.join(dir));
1050                    } else {
1051                        return FileOutcome::Ok(self.path.clone());
1052                    }
1053                }
1054            }
1055        }
1056        FileOutcome::Unchanged
1057    }
1058}
1059
1060impl HasScreenCursor for FileDialogState {
1061    fn screen_cursor(&self) -> Option<(u16, u16)> {
1062        if self.active {
1063            self.path_state
1064                .screen_cursor()
1065                .or_else(|| self.save_name_state.screen_cursor())
1066                .or_else(|| self.dir_state.screen_cursor())
1067        } else {
1068            None
1069        }
1070    }
1071}
1072
1073impl FileDialogState {
1074    fn build_focus(&self) -> Focus {
1075        let mut fb = FocusBuilder::default();
1076        fb.widget(&self.dir_state);
1077        if self.mode == Mode::Save || self.mode == Mode::Open {
1078            fb.widget(&self.file_state);
1079        }
1080        if self.mode == Mode::Save {
1081            fb.widget(&self.save_name_state);
1082        }
1083        fb.widget(&self.ok_state);
1084        fb.widget(&self.cancel_state);
1085        fb.widget(&self.new_state);
1086        fb.widget(&self.root_state);
1087        fb.widget(&self.path_state);
1088        fb.build()
1089    }
1090}
1091
1092impl HandleEvent<crossterm::event::Event, Dialog, Result<FileOutcome, io::Error>>
1093    for FileDialogState
1094{
1095    fn handle(
1096        &mut self,
1097        event: &crossterm::event::Event,
1098        _qualifier: Dialog,
1099    ) -> Result<FileOutcome, io::Error> {
1100        if !self.active {
1101            return Ok(FileOutcome::Continue);
1102        }
1103
1104        let mut focus = self.build_focus();
1105
1106        let mut f: FileOutcome = focus.handle(event, Regular).into();
1107        let mut r = FileOutcome::Continue;
1108
1109        f = f.or_else(|| match event {
1110            ct_event!(keycode press F(1)) => {
1111                if !self.root_state.is_focused() {
1112                    focus.focus(&self.root_state);
1113                    FileOutcome::Changed
1114                } else {
1115                    FileOutcome::Continue
1116                }
1117            }
1118            ct_event!(keycode press F(2)) => {
1119                if !self.dir_state.is_focused() {
1120                    focus.focus(&self.dir_state);
1121                    FileOutcome::Changed
1122                } else {
1123                    FileOutcome::Continue
1124                }
1125            }
1126            ct_event!(keycode press F(3)) => {
1127                if !self.file_state.is_focused() {
1128                    focus.focus(&self.file_state);
1129                    FileOutcome::Changed
1130                } else {
1131                    FileOutcome::Continue
1132                }
1133            }
1134            ct_event!(keycode press F(4)) => {
1135                if !self.path_state.is_focused() {
1136                    focus.focus(&self.path_state);
1137                    FileOutcome::Changed
1138                } else {
1139                    FileOutcome::Continue
1140                }
1141            }
1142            ct_event!(keycode press F(5)) => {
1143                if !self.save_name_state.is_focused() {
1144                    focus.focus(&self.save_name_state);
1145                    FileOutcome::Changed
1146                } else {
1147                    FileOutcome::Continue
1148                }
1149            }
1150            _ => FileOutcome::Continue,
1151        });
1152
1153        r = r.or_else_try(|| {
1154            handle_path(self, event)?
1155                .or_else_try(|| {
1156                    if self.mode == Mode::Save {
1157                        handle_name(self, event)
1158                    } else {
1159                        Ok(FileOutcome::Continue)
1160                    }
1161                })?
1162                .or_else_try(|| handle_files(self, event))?
1163                .or_else_try(|| handle_dirs(self, event))?
1164                .or_else_try(|| handle_roots(self, event))?
1165                .or_else_try(|| handle_new(self, event))?
1166                .or_else_try(|| handle_cancel(self, event))?
1167                .or_else_try(|| handle_ok(self, event))
1168        })?;
1169
1170        Ok(max(max(f, r), FileOutcome::Unchanged))
1171    }
1172}
1173
1174fn handle_new(
1175    state: &mut FileDialogState,
1176    event: &crossterm::event::Event,
1177) -> Result<FileOutcome, io::Error> {
1178    try_flow!(match state.new_state.handle(event, Regular) {
1179        ButtonOutcome::Pressed => {
1180            state.start_edit_dir()
1181        }
1182        r => Outcome::from(r).into(),
1183    });
1184    try_flow!(match event {
1185        ct_event!(key press CONTROL-'n') => {
1186            state.start_edit_dir()
1187        }
1188        _ => FileOutcome::Continue,
1189    });
1190    Ok(FileOutcome::Continue)
1191}
1192
1193fn handle_ok(
1194    state: &mut FileDialogState,
1195    event: &crossterm::event::Event,
1196) -> Result<FileOutcome, io::Error> {
1197    try_flow!(match state.ok_state.handle(event, Regular) {
1198        ButtonOutcome::Pressed => state.choose_selected(),
1199        r => Outcome::from(r).into(),
1200    });
1201    Ok(FileOutcome::Continue)
1202}
1203
1204fn handle_cancel(
1205    state: &mut FileDialogState,
1206    event: &crossterm::event::Event,
1207) -> Result<FileOutcome, io::Error> {
1208    try_flow!(match state.cancel_state.handle(event, Regular) {
1209        ButtonOutcome::Pressed => {
1210            state.close_cancel()
1211        }
1212        r => Outcome::from(r).into(),
1213    });
1214    try_flow!(match event {
1215        ct_event!(keycode press Esc) => {
1216            state.close_cancel()
1217        }
1218        _ => FileOutcome::Continue,
1219    });
1220    Ok(FileOutcome::Continue)
1221}
1222
1223fn handle_name(
1224    state: &mut FileDialogState,
1225    event: &crossterm::event::Event,
1226) -> Result<FileOutcome, io::Error> {
1227    try_flow!(Outcome::from(state.save_name_state.handle(event, Regular)));
1228    if state.save_name_state.is_focused() {
1229        try_flow!(match event {
1230            ct_event!(keycode press Enter) => {
1231                state.choose_selected()
1232            }
1233            _ => FileOutcome::Continue,
1234        });
1235    }
1236    Ok(FileOutcome::Continue)
1237}
1238
1239fn handle_path(
1240    state: &mut FileDialogState,
1241    event: &crossterm::event::Event,
1242) -> Result<FileOutcome, io::Error> {
1243    try_flow!(Outcome::from(state.path_state.handle(event, Regular)));
1244    if state.path_state.is_focused() {
1245        try_flow!(match event {
1246            ct_event!(keycode press Enter) => {
1247                state.use_path_input()?;
1248                state.build_focus().focus_no_lost(&state.dir_state.list);
1249                FileOutcome::Changed
1250            }
1251            _ => FileOutcome::Continue,
1252        });
1253    }
1254    on_lost!(
1255        state.path_state => {
1256            state.use_path_input()?
1257        }
1258    );
1259    Ok(FileOutcome::Continue)
1260}
1261
1262fn handle_roots(
1263    state: &mut FileDialogState,
1264    event: &crossterm::event::Event,
1265) -> Result<FileOutcome, io::Error> {
1266    try_flow!(match state.root_state.handle(event, Regular) {
1267        Outcome::Changed => {
1268            state.chroot_selected()?
1269        }
1270        r => r.into(),
1271    });
1272    Ok(FileOutcome::Continue)
1273}
1274
1275fn handle_dirs(
1276    state: &mut FileDialogState,
1277    event: &crossterm::event::Event,
1278) -> Result<FileOutcome, io::Error> {
1279    // capture F2. starts edit/selects dir otherwise.
1280    if matches!(event, ct_event!(keycode press F(2))) {
1281        return Ok(FileOutcome::Continue);
1282    }
1283
1284    try_flow!(match state.dir_state.handle(event, Regular) {
1285        EditOutcome::Edit => {
1286            state.chdir_selected()?
1287        }
1288        EditOutcome::Cancel => {
1289            state.cancel_edit_dir()
1290        }
1291        EditOutcome::Commit | EditOutcome::CommitAndAppend | EditOutcome::CommitAndEdit => {
1292            state.commit_edit_dir()?
1293        }
1294        r => {
1295            Outcome::from(r).into()
1296        }
1297    });
1298    if state.dir_state.list.is_focused() {
1299        try_flow!(handle_nav(&mut state.dir_state.list, &state.dirs, event));
1300    }
1301    Ok(FileOutcome::Continue)
1302}
1303
1304fn handle_files(
1305    state: &mut FileDialogState,
1306    event: &crossterm::event::Event,
1307) -> Result<FileOutcome, io::Error> {
1308    if state.file_state.is_focused() {
1309        try_flow!(match event {
1310            ct_event!(mouse any for m)
1311                if state
1312                    .file_state
1313                    .mouse
1314                    .doubleclick(state.file_state.inner, m) =>
1315            {
1316                state.choose_selected()
1317            }
1318            ct_event!(keycode press Enter) => {
1319                state.choose_selected()
1320            }
1321            _ => FileOutcome::Continue,
1322        });
1323        try_flow!(
1324            match handle_nav(&mut state.file_state, &state.files, event) {
1325                FileOutcome::Changed => {
1326                    if state.mode == Mode::Save {
1327                        state.name_selected()?
1328                    } else {
1329                        FileOutcome::Changed
1330                    }
1331                }
1332                r => r,
1333            }
1334        );
1335    }
1336    try_flow!(match state.file_state.handle(event, Regular).into() {
1337        FileOutcome::Changed => {
1338            if state.mode == Mode::Save {
1339                state.name_selected()?
1340            } else {
1341                FileOutcome::Changed
1342            }
1343        }
1344        r => r,
1345    });
1346    Ok(FileOutcome::Continue)
1347}
1348
1349fn handle_nav(
1350    list: &mut ListState<RowSelection>,
1351    nav: &[OsString],
1352    event: &crossterm::event::Event,
1353) -> FileOutcome {
1354    flow!(match event {
1355        ct_event!(key press c) => {
1356            let next = find_next_by_key(*c, list.selected().unwrap_or(0), nav);
1357            if let Some(next) = next {
1358                list.move_to(next).into()
1359            } else {
1360                FileOutcome::Unchanged
1361            }
1362        }
1363        _ => FileOutcome::Continue,
1364    });
1365    FileOutcome::Continue
1366}
1367
1368#[allow(clippy::question_mark)]
1369fn find_next_by_key(c: char, start: usize, names: &[OsString]) -> Option<usize> {
1370    let Some(c) = c.to_lowercase().next() else {
1371        return None;
1372    };
1373
1374    let mut idx = start;
1375    let mut selected = None;
1376    loop {
1377        idx += 1;
1378        if idx >= names.len() {
1379            idx = 0;
1380        }
1381        if idx == start {
1382            break;
1383        }
1384
1385        let nav = names[idx].to_string_lossy();
1386
1387        let initials = nav
1388            .split([' ', '_', '-'])
1389            .flat_map(|v| v.chars().next())
1390            .flat_map(|c| c.to_lowercase().next())
1391            .collect::<Vec<_>>();
1392        if initials.contains(&c) {
1393            selected = Some(idx);
1394            break;
1395        }
1396    }
1397
1398    selected
1399}