rat_widget/
file_dialog.rs

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