1use 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#[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#[derive(Debug, Clone)]
75pub struct FileDialogStyle {
76 pub style: Style,
77 pub list: Option<ListStyle>,
79 pub roots: Option<ListStyle>,
81 pub text: Option<TextStyle>,
83 pub button: Option<ButtonStyle>,
85 pub block: Option<Block<'static>>,
87 pub no_block: Option<bool>,
89
90 pub non_exhaustive: NonExhaustive,
91}
92
93#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
95#[allow(dead_code)]
96enum Mode {
97 #[default]
98 Open,
99 Save,
100 Dir,
101}
102
103#[expect(clippy::type_complexity)]
105pub struct FileDialogState {
106 pub area: Rect,
109 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 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
139 pub enum FileOutcome {
140 Continue,
142 Unchanged,
145 Changed,
150 Cancel,
152 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 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, 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 pub fn new() -> Self {
307 Self::default()
308 }
309
310 pub fn ok_text(mut self, txt: &'a str) -> Self {
312 self.ok_text = txt;
313 self
314 }
315
316 pub fn cancel_text(mut self, txt: &'a str) -> Self {
318 self.cancel_text = txt;
319 self
320 }
321
322 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 pub fn no_block(mut self) -> Self {
331 self.block = None;
332 self.no_block = true;
333 self
334 }
335
336 pub fn style(mut self, style: Style) -> Self {
338 self.style = style;
339 self
340 }
341
342 pub fn list_style(mut self, style: ListStyle) -> Self {
344 self.list_style = Some(style);
345 self
346 }
347
348 pub fn roots_style(mut self, style: ListStyle) -> Self {
350 self.roots_style = Some(style);
351 self
352 }
353
354 pub fn text_style(mut self, style: TextStyle) -> Self {
356 self.text_style = Some(style);
357 self
358 }
359
360 pub fn button_style(mut self, style: ButtonStyle) -> Self {
362 self.button_style = Some(style);
363 self
364 }
365
366 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), Constraint::Percentage(80),
544 ]),
545 Layout::vertical([
546 Constraint::Length(1), Constraint::Fill(1),
548 ]),
549 );
550
551 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 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 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 pub fn set_filter(&mut self, filter: impl Fn(&Path) -> bool + 'static) {
709 self.filter = Some(Box::new(filter));
710 }
711
712 pub fn set_last_path(&mut self, last: &Path) {
717 self.path = last.into();
718 }
719
720 pub fn use_default_roots(&mut self, roots: bool) {
722 self.no_default_roots = !roots;
723 }
724
725 pub fn no_default_roots(&mut self) {
727 self.no_default_roots = true;
728 }
729
730 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 pub fn clear_roots(&mut self) {
738 self.roots.clear();
739 }
740
741 pub fn default_roots(&mut self, start: &Path, last: &Path) {
743 if last.exists() {
744 self.roots.push((
745 OsString::from("Last"), last.into(),
747 ));
748 }
749 self.roots.push((
750 OsString::from("Start"), 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 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 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 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 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 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 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 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 fn close_cancel(&mut self) -> FileOutcome {
1067 self.active = false;
1068 self.dir_state.cancel();
1069 FileOutcome::Cancel
1070 }
1071
1072 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 }
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 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
1475pub 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