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