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