tlc: Phase B.2 + B.7 — Sort dialog all 11 fields, Delete dialog All/None/Skip
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)
This commit is contained in:
@@ -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<PathBuf>,
|
||||
/// 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<DeleteChoice>,
|
||||
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<PathBuf>) -> 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<DeleteChoice> {
|
||||
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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user