rat_widget/
file_dialog.rs

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