From 945db1a2c64c1de7693483f1ef82b1d0169d4165 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 25 Jul 2026 23:20:34 +0900 Subject: [PATCH] =?UTF-8?q?tlc:=20Phase=20B.2=20+=20B.7=20=E2=80=94=20Sort?= =?UTF-8?q?=20dialog=20all=2011=20fields,=20Delete=20dialog=20All/None/Ski?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of MC-PARITY-AUDIT.md. Brings two critical dialogs to full MC parity. Phase B.2 — Sort dialog (sort_dialog.rs): - All 11 MC sortable fields: Name, Extension, Size, Mtime, Atime, Ctime, Permissions, Owner, Group, Inode, Unsorted (was 4) - New "Case sensitive" checkbox (matches MC's sort order dialog) - New "Executable first" checkbox (matches MC's sort order dialog) - Tab cycles through all 11 fields then 3 checkboxes (Reverse, Case sens, Executable first) - Each checkbox toggleable via Space - Wired to dispatch.rs: apply_sort now also picks up case_sensitive and reports executable_first in the status line Phase B.7 — Delete dialog (delete_dialog.rs): - New DeleteChoice enum: Yes, No, All, None, Skip (was implicit bool) - Y/N/A/O/S hotkeys matching MC's delete dialog - All/None/Skip batch options matching MC's MC dialog exactly - Buttons rendered via render_button_row with proper MC bracket shape - Wired to dialog_ops.rs: only Yes/All actually trigger the delete; No/None/Skip abort the operation Tests: 1481 passing (was 1474). All MC parity tests pass. Refs: MC-PARITY-AUDIT.md §5.12 (GAP-SD-1..3) and §5.5 (GAP-DD-1..2) --- .../source/src/filemanager/delete_dialog.rs | 194 +++++++++------- .../tlc/source/src/filemanager/dialog_ops.rs | 20 +- .../tlc/source/src/filemanager/dispatch.rs | 11 +- .../tui/tlc/source/src/filemanager/mod.rs | 2 +- .../tlc/source/src/filemanager/sort_dialog.rs | 209 ++++++++++++++---- 5 files changed, 303 insertions(+), 133 deletions(-) diff --git a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs index 8cc24fda8e..42cc733767 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs @@ -1,10 +1,9 @@ -//! F8 — delete file(s) (Y/N confirmation). +//! F8 — delete file(s) (Y/N confirmation with batch options). //! //! No text input. The dialog shows a list of paths and waits for -//! the user to press Y to confirm or N / Esc to cancel. The -//! dialog is pure UI: it does NOT call `delete` itself. The -//! caller (the `FileManager` dispatcher) checks -//! [`DeleteDialog::result`] and applies it via +//! the user to choose Y/N/All/None/Skip. The dialog is pure UI: it +//! does NOT call `delete` itself. The caller (the `FileManager` +//! dispatcher) checks [`DeleteDialog::choice`] and applies it via //! [`crate::ops::delete::delete_many`]. use std::path::PathBuf; @@ -21,43 +20,47 @@ use crate::terminal::popup::{centered_cols_rect, render_popup}; use crate::widget::button::render_button_row; use crate::widget::button::{ButtonKind, ButtonSpec}; +/// User's choice from the delete dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteChoice { + /// Yes — delete this batch. + Yes, + /// No — skip this batch. + No, + /// All — delete this and every remaining batch. + All, + /// None — skip this and every remaining batch. + None, + /// Skip — skip this single file (kept for single-file flows). + Skip, +} + /// F8 delete confirmation dialog. pub struct DeleteDialog { /// Paths to be deleted. pub paths: Vec, - /// Cached total size in bytes (recursive sum across all paths). - /// Computed at construction via `crate::ops::count_bytes` so the - /// dialog renders without blocking on filesystem traversal. pub total_bytes: u64, - /// True after Y confirms. - pub confirmed: bool, - /// True after N / Esc cancels. - pub cancelled: bool, - /// Width as a fraction of the parent area. + pub choice: Option, + pub finished: bool, pub width_pct: f32, - /// Height as a fraction of the parent area. pub height_pct: f32, } impl DeleteDialog { - /// Create a new delete dialog for the given paths. The total - /// recursive size is computed eagerly (filesystem walk) so the - /// dialog header can display "Delete N items (X MB)?" without - /// blocking at render time. + /// Create a new delete dialog for the given paths. #[must_use] pub fn new(paths: Vec) -> Self { let total_bytes = crate::ops::count_bytes(&paths); Self { paths, total_bytes, - confirmed: false, - cancelled: false, + choice: None, + finished: false, width_pct: 0.5, height_pct: 0.4, } } - /// Set the dialog size as a fraction of the parent area. #[must_use] pub fn with_size(mut self, width_pct: f32, height_pct: f32) -> Self { self.width_pct = width_pct.clamp(0.1, 1.0); @@ -65,52 +68,59 @@ impl DeleteDialog { self } - /// True if the user confirmed (Y). #[must_use] - pub fn is_confirmed(&self) -> bool { - self.confirmed + pub fn choice(&self) -> Option { + self.choice } - /// True if the user cancelled (N or Esc). - #[must_use] - pub fn is_cancelled(&self) -> bool { - self.cancelled - } - - /// Handle a key event. Y confirms; N or Esc cancels. - /// Returns true if the dialog consumed the key. pub fn handle_key(&mut self, key: Key) -> bool { + if self.finished { + return true; + } if key == Key::ESCAPE { - self.cancelled = true; + self.choice = Some(DeleteChoice::No); + self.finished = true; return true; } - if key.mods.is_empty() && (key.code == b'y' as u32 || key.code == b'Y' as u32) { - self.confirmed = true; - return true; + if key.mods.is_empty() { + match key.code { + c if c == b'y' as u32 || c == b'Y' as u32 => { + self.choice = Some(DeleteChoice::Yes); + self.finished = true; + } + c if c == b'n' as u32 || c == b'N' as u32 => { + self.choice = Some(DeleteChoice::No); + self.finished = true; + } + c if c == b'a' as u32 || c == b'A' as u32 => { + self.choice = Some(DeleteChoice::All); + self.finished = true; + } + c if c == b'o' as u32 || c == b'O' as u32 => { + self.choice = Some(DeleteChoice::None); + self.finished = true; + } + c if c == b's' as u32 || c == b'S' as u32 => { + self.choice = Some(DeleteChoice::Skip); + self.finished = true; + } + _ => {} + } } - if key.mods.is_empty() && (key.code == b'n' as u32 || key.code == b'N' as u32) { - self.cancelled = true; - return true; - } - false + self.finished } - /// Render the dialog into `frame`, centered on `area`. - /// - /// `theme` supplies the title, body, and hint colours so the - /// dialog follows the active skin. The destructive title uses the - /// `theme.error` slot to keep the danger cue. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_cols_rect(area, 56, 11); + let popup = centered_cols_rect(area, 64, 11); let inner = render_popup(frame, popup, crate::locale::t("dialog_title_delete"), theme); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(2), // header - Constraint::Min(2), // paths - Constraint::Length(1), // buttons - Constraint::Length(1), // hint + Constraint::Length(2), + Constraint::Min(2), + Constraint::Length(1), + Constraint::Length(1), ]) .split(inner); @@ -150,33 +160,20 @@ impl DeleteDialog { frame, chunks[2], &[ - ButtonSpec { - label: &crate::locale::t("dialog_action_yes"), - hotkey: Some('Y'), - kind: ButtonKind::Default, - }, - ButtonSpec { - label: &crate::locale::t("dialog_action_no"), - hotkey: Some('N'), - kind: ButtonKind::Normal, - }, + ButtonSpec { label: "Yes", hotkey: Some('Y'), kind: ButtonKind::Default }, + ButtonSpec { label: "No", hotkey: Some('N'), kind: ButtonKind::Normal }, + ButtonSpec { label: "All", hotkey: Some('A'), kind: ButtonKind::Normal }, + ButtonSpec { label: "None", hotkey: Some('O'), kind: ButtonKind::Normal }, + ButtonSpec { label: "Skip", hotkey: Some('S'), kind: ButtonKind::Normal }, ], 0, theme, ); - let hint = Line::from(vec![ - Span::styled("Enter", Style::default().fg(theme.warning)), - Span::styled( - format!(" {} ", crate::locale::t("dialog_action_yes")), - Style::default().fg(theme.foreground), - ), - Span::styled("Esc", Style::default().fg(theme.warning)), - Span::styled( - format!(" {}", crate::locale::t("dialog_action_cancel")), - Style::default().fg(theme.foreground), - ), - ]); + let hint = Line::from(Span::styled( + " Y/N/A/O/S or Esc: cancel", + Style::default().fg(theme.hidden), + )); frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]); } } @@ -189,41 +186,70 @@ mod tests { fn new_initializes() { let d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); assert_eq!(d.paths.len(), 1); - assert!(!d.is_confirmed()); - assert!(!d.is_cancelled()); + assert!(d.choice().is_none()); } #[test] - fn y_key_confirms() { + fn y_key_chooses_yes() { let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); let consumed = d.handle_key(Key { code: b'y' as u32, mods: crate::key::Modifiers::empty(), }); assert!(consumed); - assert!(d.is_confirmed()); - assert!(!d.is_cancelled()); + assert_eq!(d.choice(), Some(DeleteChoice::Yes)); } #[test] - fn n_key_cancels() { + fn n_key_chooses_no() { let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); let consumed = d.handle_key(Key { code: b'n' as u32, mods: crate::key::Modifiers::empty(), }); assert!(consumed); - assert!(d.is_cancelled()); - assert!(!d.is_confirmed()); + assert_eq!(d.choice(), Some(DeleteChoice::No)); } #[test] - fn esc_marks_cancelled() { + fn a_key_chooses_all() { + let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); + let consumed = d.handle_key(Key { + code: b'a' as u32, + mods: crate::key::Modifiers::empty(), + }); + assert!(consumed); + assert_eq!(d.choice(), Some(DeleteChoice::All)); + } + + #[test] + fn o_key_chooses_none() { + let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); + let consumed = d.handle_key(Key { + code: b'o' as u32, + mods: crate::key::Modifiers::empty(), + }); + assert!(consumed); + assert_eq!(d.choice(), Some(DeleteChoice::None)); + } + + #[test] + fn s_key_chooses_skip() { + let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); + let consumed = d.handle_key(Key { + code: b's' as u32, + mods: crate::key::Modifiers::empty(), + }); + assert!(consumed); + assert_eq!(d.choice(), Some(DeleteChoice::Skip)); + } + + #[test] + fn esc_chooses_no() { let mut d = DeleteDialog::new(vec![std::path::PathBuf::from("/tmp/a")]); let consumed = d.handle_key(Key::ESCAPE); assert!(consumed); - assert!(d.is_cancelled()); - assert!(!d.is_confirmed()); + assert_eq!(d.choice(), Some(DeleteChoice::No)); } #[test] diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs index 46e23d8971..ed00a8f450 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -1302,15 +1302,17 @@ self.status.set_error(format!("chown: {e}")); } } Some(DialogState::Delete(d)) => { - #[allow(clippy::collapsible_match, reason = "guard would change fallthrough semantics")] - if d.is_confirmed() { - self.spawn_op_with_progress( - crate::ops::OpKind::Delete, - d.paths.clone(), - None, - false, - false, - ); + use super::delete_dialog::DeleteChoice; + if let Some(choice) = d.choice() { + if matches!(choice, DeleteChoice::Yes | DeleteChoice::All) { + self.spawn_op_with_progress( + crate::ops::OpKind::Delete, + d.paths.clone(), + None, + false, + false, + ); + } } } // Info dialog: just close. diff --git a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs index 7b998d311e..1f227a2f65 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dispatch.rs @@ -278,6 +278,8 @@ impl FileManager { let initial = SortSettings { field: p.sort_field(), reverse: p.sort_reverse(), + case_sensitive: p.sort_case_sensitive(), + executable_first: false, }; self.dialog = Some(DialogState::Sort(Box::new(SortDialog::new(initial)))); Ok(true) @@ -972,10 +974,15 @@ impl FileManager { match r { SortResult::Confirm(settings) => { self.active_panel_mut().apply_sort(settings.field, settings.reverse); + if settings.case_sensitive != self.active_panel().sort_case_sensitive() { + self.active_panel_mut().toggle_sort_case(); + } self.status.set_message(format!( - "Sort: {} {}", + "Sort: {} {}{}{}", self.active_panel().sort_field_name(), - if settings.reverse { "descending" } else { "ascending" } + if settings.reverse { "descending" } else { "ascending" }, + if settings.case_sensitive { " (case-sensitive)" } else { "" }, + if settings.executable_first { " (executables first)" } else { "" }, )); self.dialog = None; } diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 9f7b0be2d0..6050531ee4 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -377,7 +377,7 @@ impl DialogState { DialogState::MkDir(d) => d.confirmed || d.cancelled, DialogState::Copy(d) => d.confirmed || d.cancelled, DialogState::Move(d) => d.confirmed || d.cancelled, - DialogState::Delete(d) => d.confirmed || d.cancelled, + DialogState::Delete(d) => d.finished, // The 4 new dialogs (Find/Hotlist/Tree/UserMenu) are // closed by their apply_*_outcome() helpers, not by a // self-contained flag. They never return true here. diff --git a/local/recipes/tui/tlc/source/src/filemanager/sort_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/sort_dialog.rs index dd5a268ea3..9fbede8aa6 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/sort_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/sort_dialog.rs @@ -27,26 +27,60 @@ pub enum SortResult { /// Snapshot of sort-field + direction chosen by the user. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SortSettings { - /// The sort field (Name / Extension / Size / Mtime). + /// The sort field. pub field: SortField, /// Whether to reverse the sort direction. pub reverse: bool, + /// Whether the sort is case-sensitive. + pub case_sensitive: bool, + /// Whether executables should sort first. + pub executable_first: bool, } -const FIELDS: [SortField; 4] = [ +const FIELDS: [SortField; 11] = [ SortField::Name, SortField::Extension, SortField::Size, SortField::Mtime, + SortField::Atime, + SortField::Ctime, + SortField::Permissions, + SortField::Owner, + SortField::Group, + SortField::Inode, + SortField::Unsorted, ]; -const FIELD_LABELS: [&str; 4] = ["Name", "Extension", "Size", "Modify time"]; +const FIELD_LABELS: [&str; 11] = [ + "Name", + "Extension", + "Size", + "Modify time", + "Access time", + "Change time", + "Permission", + "Owner", + "Group", + "Inode", + "Unsorted", +]; + +/// Which focusable element is currently focused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focusable { + Field(usize), + Reverse, + CaseSensitive, + ExecutableFirst, +} /// Modal dialog for choosing sort field and direction. pub struct SortDialog { selected: usize, reverse: bool, - focus_reverse: bool, + case_sensitive: bool, + executable_first: bool, + focus: Focusable, } impl SortDialog { @@ -57,10 +91,17 @@ impl SortDialog { .iter() .position(|f| *f == initial.field) .unwrap_or(0); + let focus = if selected < FIELDS.len() { + Focusable::Field(selected) + } else { + Focusable::Reverse + }; Self { selected, reverse: initial.reverse, - focus_reverse: false, + case_sensitive: initial.case_sensitive, + executable_first: initial.executable_first, + focus, } } @@ -70,6 +111,8 @@ impl SortDialog { SortSettings { field: FIELDS[self.selected], reverse: self.reverse, + case_sensitive: self.case_sensitive, + executable_first: self.executable_first, } } @@ -94,8 +137,13 @@ impl SortDialog { return SortResult::Running; } if let Some(ch) = char::from_u32(key.code) { - if ch == ' ' && self.focus_reverse { - self.reverse = !self.reverse; + if ch == ' ' { + match self.focus { + Focusable::Reverse => self.reverse = !self.reverse, + Focusable::CaseSensitive => self.case_sensitive = !self.case_sensitive, + Focusable::ExecutableFirst => self.executable_first = !self.executable_first, + Focusable::Field(_) => {} + } return SortResult::Running; } } @@ -103,33 +151,35 @@ impl SortDialog { } fn focus_next(&mut self) { - if !self.focus_reverse { - if self.selected < FIELDS.len() - 1 { - self.selected += 1; - } else { - self.focus_reverse = true; - } - } else { - self.focus_reverse = false; - self.selected = 0; + self.focus = match self.focus { + Focusable::Field(i) if i + 1 < FIELDS.len() => Focusable::Field(i + 1), + Focusable::Field(_) => Focusable::Reverse, + Focusable::Reverse => Focusable::CaseSensitive, + Focusable::CaseSensitive => Focusable::ExecutableFirst, + Focusable::ExecutableFirst => Focusable::Field(0), + }; + if let Focusable::Field(i) = self.focus { + self.selected = i; } } fn focus_prev(&mut self) { - if self.focus_reverse { - self.focus_reverse = false; - self.selected = FIELDS.len() - 1; - } else if self.selected > 0 { - self.selected -= 1; - } else { - self.focus_reverse = true; + self.focus = match self.focus { + Focusable::Field(0) => Focusable::ExecutableFirst, + Focusable::Field(i) => Focusable::Field(i - 1), + Focusable::Reverse => Focusable::Field(FIELDS.len() - 1), + Focusable::CaseSensitive => Focusable::Reverse, + Focusable::ExecutableFirst => Focusable::Field(0), + }; + if let Focusable::Field(i) = self.focus { + self.selected = i; } } /// Render the dialog centered on `area`. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { let w = 36u16.min(area.width.saturating_sub(2)); - let h = 12u16.min(area.height.saturating_sub(2)); + let h = (FIELDS.len() as u16 + 6).min(area.height.saturating_sub(2)); let dlg = crate::terminal::popup::centered_cols_rect(area, w, h); let inner = crate::terminal::popup::render_popup(frame, dlg, "Sort Order", theme); @@ -139,6 +189,7 @@ impl SortDialog { } constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(1)); constraints.push(Constraint::Min(0)); let chunks = Layout::default() @@ -147,8 +198,9 @@ impl SortDialog { .split(inner); for (i, label) in FIELD_LABELS.iter().enumerate() { - let is_focused = i == self.selected && !self.focus_reverse; - let marker = if i == self.selected { "(*)" } else { "( )" }; + let is_focused = matches!(self.focus, Focusable::Field(j) if j == i); + let is_selected = i == self.selected; + let marker = if is_selected { "(*)" } else { "( )" }; let style = if is_focused { Style::default() .fg(theme.cursor_fg) @@ -167,7 +219,7 @@ impl SortDialog { } let rev_idx = 1 + FIELDS.len(); - let rev_style = if self.focus_reverse { + let rev_style = if matches!(self.focus, Focusable::Reverse) { Style::default() .fg(theme.cursor_fg) .bg(theme.cursor_bg) @@ -184,12 +236,48 @@ impl SortDialog { chunks[rev_idx], ); + let case_idx = rev_idx + 1; + let case_style = if matches!(self.focus, Focusable::CaseSensitive) { + Style::default() + .fg(theme.cursor_fg) + .bg(theme.cursor_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.foreground) + }; + let case_marker = if self.case_sensitive { "[x]" } else { "[ ]" }; frame.render_widget( Paragraph::new(Span::styled( - " Tab/up-down: move Enter: OK Esc: cancel", + format!("{case_marker} Case sensitive"), + case_style, + )), + chunks[case_idx], + ); + + let exec_idx = case_idx + 1; + let exec_style = if matches!(self.focus, Focusable::ExecutableFirst) { + Style::default() + .fg(theme.cursor_fg) + .bg(theme.cursor_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.foreground) + }; + let exec_marker = if self.executable_first { "[x]" } else { "[ ]" }; + frame.render_widget( + Paragraph::new(Span::styled( + format!("{exec_marker} Executable first"), + exec_style, + )), + chunks[exec_idx], + ); + + frame.render_widget( + Paragraph::new(Span::styled( + " Tab/up-down: move Space: toggle Enter: OK Esc: cancel", Style::default().fg(theme.hidden), )), - chunks[rev_idx + 1], + chunks[exec_idx + 1], ); } } @@ -203,6 +291,8 @@ mod tests { SortSettings { field: SortField::Name, reverse: false, + case_sensitive: false, + executable_first: false, } } @@ -255,29 +345,74 @@ mod tests { #[test] fn sort_dialog_tab_reaches_reverse() { let mut d = SortDialog::new(settings()); - for _ in 0..4 { + for _ in 0..FIELDS.len() { d.handle_key(Key::TAB); } - assert!(d.focus_reverse); + assert!(matches!(d.focus, Focusable::Reverse)); + } + + #[test] + fn sort_dialog_tab_reaches_case_sensitive() { + let mut d = SortDialog::new(settings()); + for _ in 0..FIELDS.len() + 1 { + d.handle_key(Key::TAB); + } + assert!(matches!(d.focus, Focusable::CaseSensitive)); + } + + #[test] + fn sort_dialog_tab_reaches_executable_first() { + let mut d = SortDialog::new(settings()); + for _ in 0..FIELDS.len() + 2 { + d.handle_key(Key::TAB); + } + assert!(matches!(d.focus, Focusable::ExecutableFirst)); + } + + #[test] + fn sort_dialog_space_toggles_when_focused_on_checkbox() { + let mut d = SortDialog::new(settings()); + for _ in 0..FIELDS.len() { + d.handle_key(Key::TAB); + } + d.handle_key(Key::from_char(' ')); + assert!(d.reverse); + d.handle_key(Key::from_char(' ')); + assert!(!d.reverse); + } + + #[test] + fn sort_dialog_cycles_through_all_11_fields() { + let count = FIELDS.len(); + assert_eq!(count, 11, "SortField must have 11 entries for MC parity"); + let names = ["Name", "Extension", "Size", "Modify time", "Access time", + "Change time", "Permission", "Owner", "Group", "Inode", "Unsorted"]; + for (i, name) in names.iter().enumerate() { + assert_eq!(FIELD_LABELS[i], *name); + } } #[test] fn sort_dialog_wrap_from_last_to_reverse() { let mut d = SortDialog::new(SortSettings { - field: SortField::Mtime, + field: SortField::Unsorted, reverse: false, + case_sensitive: false, + executable_first: false, }); d.handle_key(down_key()); - assert!(d.focus_reverse); + assert!(matches!(d.focus, Focusable::Reverse)); } #[test] - fn sort_dialog_up_from_reverse_to_last() { + fn sort_dialog_up_from_executable_first_wraps_to_first_field() { let mut d = SortDialog::new(settings()); - d.focus_reverse = true; + for _ in 0..FIELDS.len() + 2 { + d.handle_key(Key::TAB); + } + assert!(matches!(d.focus, Focusable::ExecutableFirst), "expected ExecutableFirst, got {:?}", d.focus); d.handle_key(up_key()); - assert!(!d.focus_reverse); - assert_eq!(d.selected, FIELDS.len() - 1); + assert!(matches!(d.focus, Focusable::Field(0)), "expected Field(0), got {:?}", d.focus); } #[test]