tlc: Phase C.2/C.4/C.5/C.8 — Layout/Pattern/Confirm/Appearance dialogs MC parity
Phase C.2 — Layout dialog (layout_dialog.rs): - New LayoutSettings fields: split_horizontal, output_lines - New 'Split Horizontal' checkbox (Horizontal vs Vertical panel split) - New 'Output lines' field (numeric toggle 100 <-> 200) - from_runtime_config takes both RuntimeConfig + FilemanagerConfig to read the layout string Phase C.4 — Pattern dialog (pattern_dialog.rs): - New PatternOptions struct: files_only, shell_patterns, case_sensitive - New FocusField enum: Pattern/FilesOnly/ShellPatterns/CaseSensitive - 3 new checkboxes matching MC's 'Select group' dialog - PatternOutcome::Confirm now carries PatternOptions - Tab cycles between pattern input and checkboxes Phase C.5 — Confirmation dialog (confirm_dialog.rs): - New ConfirmSettings fields: confirm_hotlist_delete, confirm_history_cleanup - 2 new toggles matching MC's 'Confirmation' dialog Phase C.8 — Appearance dialog (appearance_dialog.rs): - New AppearanceSettings fields: shadows, console_paste, double_click_speed_ms - 3 new toggles matching MC's 'Appearance' dialog - Row 7 displays 'Output lines: 250 ms' style numeric field Tests: 1486 passing (was 1486). Tests updated for the expanded state models. Refs: MC-PARITY-AUDIT.md §5.10, §5.16, §5.13, §5.21 (GAP-LD-1..2, GAP-PD-1..3, GAP-XD-1..2, GAP-AD-1..3)
This commit is contained in:
@@ -24,24 +24,22 @@ pub struct AppearanceDialog {
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
/// The five appearance toggles + a Skin… button.
|
||||
/// The appearance toggles + a Skin… button.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppearanceSettings {
|
||||
/// Whether to show the F9 menu bar.
|
||||
pub show_menubar: bool,
|
||||
/// Whether to show the F1–F10 key hint bar.
|
||||
pub show_keybar: bool,
|
||||
/// Whether to show the hint/status bar.
|
||||
pub show_hintbar: bool,
|
||||
/// Whether to show the command-line prompt.
|
||||
pub show_cmdline: bool,
|
||||
/// Whether to show the mini-status line above the keybar.
|
||||
pub show_mini_status: bool,
|
||||
pub shadows: bool,
|
||||
pub console_paste: bool,
|
||||
pub double_click_speed_ms: u32,
|
||||
}
|
||||
|
||||
const ROW_COUNT: usize = 9;
|
||||
|
||||
impl AppearanceSettings {
|
||||
/// Index the toggles. 0..4 are the five checkboxes; 5 is the
|
||||
/// "Skin..." button.
|
||||
fn label(idx: usize) -> &'static str {
|
||||
match idx {
|
||||
0 => " Menubar",
|
||||
@@ -49,15 +47,16 @@ impl AppearanceSettings {
|
||||
2 => " Hint bar",
|
||||
3 => " Command prompt",
|
||||
4 => " Mini status",
|
||||
5 => " Skins...",
|
||||
5 => " Shadows",
|
||||
6 => " Console paste",
|
||||
7 => " Double click speed",
|
||||
8 => " Skins...",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `idx` corresponds to the "Skin..." button (not a
|
||||
/// checkbox toggle).
|
||||
fn is_skin_button(idx: usize) -> bool {
|
||||
idx == 5
|
||||
idx == 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +68,9 @@ impl From<&RuntimeConfig> for AppearanceSettings {
|
||||
show_hintbar: cfg.show_hintbar(),
|
||||
show_cmdline: cfg.show_cmdline(),
|
||||
show_mini_status: cfg.show_mini_status(),
|
||||
shadows: true,
|
||||
console_paste: true,
|
||||
double_click_speed_ms: 250,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,11 +107,11 @@ impl AppearanceDialog {
|
||||
self.cursor = self
|
||||
.cursor
|
||||
.checked_sub(1)
|
||||
.unwrap_or(5); // 5 = last row
|
||||
.unwrap_or(ROW_COUNT - 1);
|
||||
AppearanceOutcome::Running
|
||||
}
|
||||
Key::DOWN => {
|
||||
self.cursor = (self.cursor + 1) % 6;
|
||||
self.cursor = (self.cursor + 1) % ROW_COUNT;
|
||||
AppearanceOutcome::Running
|
||||
}
|
||||
Key::ENTER => {
|
||||
@@ -123,6 +125,9 @@ impl AppearanceDialog {
|
||||
2 => self.settings.show_hintbar = !self.settings.show_hintbar,
|
||||
3 => self.settings.show_cmdline = !self.settings.show_cmdline,
|
||||
4 => self.settings.show_mini_status = !self.settings.show_mini_status,
|
||||
5 => self.settings.shadows = !self.settings.shadows,
|
||||
6 => self.settings.console_paste = !self.settings.console_paste,
|
||||
7 => self.settings.double_click_speed_ms = if self.settings.double_click_speed_ms == 250 { 500 } else { 250 },
|
||||
_ => {}
|
||||
}
|
||||
AppearanceOutcome::Running
|
||||
@@ -138,8 +143,7 @@ impl AppearanceDialog {
|
||||
let title = " Appearance ";
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
|
||||
const ROWS: usize = 6;
|
||||
let constraints: Vec<Constraint> = (0..ROWS).map(|_| Constraint::Length(1)).collect();
|
||||
let constraints: Vec<Constraint> = (0..ROW_COUNT).map(|_| Constraint::Length(1)).collect();
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
@@ -154,11 +158,13 @@ impl AppearanceDialog {
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let accent = Style::default().fg(theme.accent).bg(theme.background);
|
||||
|
||||
for i in 0..ROWS {
|
||||
for i in 0..ROW_COUNT {
|
||||
let style = if i == self.cursor { selected } else { base };
|
||||
let label = AppearanceSettings::label(i);
|
||||
let line = if AppearanceSettings::is_skin_button(i) {
|
||||
Line::from(Span::styled(label, style))
|
||||
} else if i == 7 {
|
||||
Line::from(format!(" {} ms", self.settings.double_click_speed_ms))
|
||||
} else {
|
||||
let ch = if self.settings.check(i) { "✔" } else { "☐" };
|
||||
let ch_style = if self.settings.check(i) {
|
||||
@@ -185,6 +191,8 @@ impl AppearanceSettings {
|
||||
2 => self.show_hintbar,
|
||||
3 => self.show_cmdline,
|
||||
4 => self.show_mini_status,
|
||||
5 => self.shadows,
|
||||
6 => self.console_paste,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -223,7 +231,7 @@ mod tests {
|
||||
fn down_cycles_cursor() {
|
||||
let cfg = RuntimeConfig::default();
|
||||
let mut dlg = AppearanceDialog::new(&cfg);
|
||||
for _ in 0..7 {
|
||||
for _ in 0..ROW_COUNT as u32 + 1 {
|
||||
let _ = dlg.handle_key(Key::DOWN);
|
||||
}
|
||||
assert_eq!(dlg.cursor, 1);
|
||||
@@ -233,7 +241,7 @@ mod tests {
|
||||
fn skin_button_returns_open_skin_outcome() {
|
||||
let cfg = RuntimeConfig::default();
|
||||
let mut dlg = AppearanceDialog::new(&cfg);
|
||||
dlg.cursor = 5;
|
||||
dlg.cursor = 8;
|
||||
let outcome = dlg.handle_key(Key::ENTER);
|
||||
assert!(matches!(outcome, AppearanceOutcome::OpenSkin));
|
||||
}
|
||||
|
||||
@@ -16,22 +16,21 @@ const TOGGLE_LABELS: &[(&str, &str)] = &[
|
||||
("Confirm overwrite", "Ask before overwriting files"),
|
||||
("Confirm execute", "Ask before executing commands"),
|
||||
("Confirm exit", "Ask before quitting TLC"),
|
||||
("Confirm hotlist delete", "Ask before deleting hotlist entries"),
|
||||
("Confirm history cleanup", "Ask before clearing view/edit history"),
|
||||
];
|
||||
|
||||
const WIDTH: u16 = 52;
|
||||
const HEIGHT: u16 = 4 + TOGGLE_LABELS.len() as u16 + 3;
|
||||
|
||||
/// Confirmation toggle settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmSettings {
|
||||
/// Whether to prompt before deleting files.
|
||||
pub confirm_delete: bool,
|
||||
/// Whether to prompt before overwriting files.
|
||||
pub confirm_overwrite: bool,
|
||||
/// Whether to prompt before executing commands.
|
||||
pub confirm_execute: bool,
|
||||
/// Whether to prompt before quitting TLC.
|
||||
pub confirm_exit: bool,
|
||||
pub confirm_hotlist_delete: bool,
|
||||
pub confirm_history_cleanup: bool,
|
||||
}
|
||||
|
||||
impl ConfirmSettings {
|
||||
@@ -41,6 +40,8 @@ impl ConfirmSettings {
|
||||
1 => self.confirm_overwrite,
|
||||
2 => self.confirm_execute,
|
||||
3 => self.confirm_exit,
|
||||
4 => self.confirm_hotlist_delete,
|
||||
5 => self.confirm_history_cleanup,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -51,6 +52,8 @@ impl ConfirmSettings {
|
||||
1 => self.confirm_overwrite = val,
|
||||
2 => self.confirm_execute = val,
|
||||
3 => self.confirm_exit = val,
|
||||
4 => self.confirm_hotlist_delete = val,
|
||||
5 => self.confirm_history_cleanup = val,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -202,7 +205,9 @@ mod tests {
|
||||
confirm_delete: true,
|
||||
confirm_overwrite: true,
|
||||
confirm_execute: false,
|
||||
confirm_exit: false,
|
||||
confirm_exit: true,
|
||||
confirm_hotlist_delete: true,
|
||||
confirm_history_cleanup: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,13 +243,9 @@ mod tests {
|
||||
fn confirm_dialog_tab_cycles_cursor() {
|
||||
let mut d = ConfirmDialog::new(default_settings());
|
||||
assert_eq!(d.cursor, 0);
|
||||
d.handle_key(Key::TAB);
|
||||
assert_eq!(d.cursor, 1);
|
||||
d.handle_key(Key::TAB);
|
||||
assert_eq!(d.cursor, 2);
|
||||
d.handle_key(Key::TAB);
|
||||
assert_eq!(d.cursor, 3);
|
||||
d.handle_key(Key::TAB);
|
||||
for _ in 0..6 {
|
||||
d.handle_key(Key::TAB);
|
||||
}
|
||||
assert_eq!(d.cursor, 0);
|
||||
}
|
||||
|
||||
@@ -269,11 +270,11 @@ mod tests {
|
||||
#[test]
|
||||
fn confirm_settings_at_and_set_roundtrip() {
|
||||
let mut s = default_settings();
|
||||
assert!(!s.at(3));
|
||||
s.set(3, true);
|
||||
assert!(s.at(3));
|
||||
s.set(3, false);
|
||||
assert!(!s.at(3));
|
||||
assert!(!s.at(2));
|
||||
s.set(2, true);
|
||||
assert!(s.at(2));
|
||||
s.set(2, false);
|
||||
assert!(!s.at(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -341,7 +341,7 @@ impl FileManager {
|
||||
}
|
||||
Cmd::LayoutDialog => {
|
||||
self.dialog = Some(DialogState::Layout(Box::new(
|
||||
LayoutDialog::from_runtime_config(&self.runtime),
|
||||
LayoutDialog::from_runtime_config(&self.runtime, &self.cfg),
|
||||
)));
|
||||
Ok(true)
|
||||
}
|
||||
@@ -393,6 +393,8 @@ impl FileManager {
|
||||
confirm_overwrite: true,
|
||||
confirm_execute: self.runtime.safe_execute.unwrap_or(false),
|
||||
confirm_exit: true,
|
||||
confirm_hotlist_delete: true,
|
||||
confirm_history_cleanup: true,
|
||||
};
|
||||
self.dialog = Some(DialogState::Confirm(Box::new(
|
||||
confirm_dialog::ConfirmDialog::new(init),
|
||||
|
||||
@@ -37,61 +37,46 @@ pub enum LayoutResult {
|
||||
Running,
|
||||
}
|
||||
|
||||
/// The five layout booleans the dialog collects.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LayoutSettings {
|
||||
/// 50/50 split between the two panels.
|
||||
pub equal_split: bool,
|
||||
/// Show the F9 menu bar at the top of the screen.
|
||||
pub show_menubar: bool,
|
||||
/// Show the F-key hint bar at the bottom of the screen.
|
||||
pub show_keybar: bool,
|
||||
/// Show the one-line hint bar between the panels and the keybar.
|
||||
pub show_hintbar: bool,
|
||||
/// Show the command line at the bottom of the screen.
|
||||
pub show_cmdline: bool,
|
||||
pub split_horizontal: bool,
|
||||
pub output_lines: u32,
|
||||
}
|
||||
|
||||
impl LayoutSettings {
|
||||
/// Build a `LayoutSettings` snapshot from a [`RuntimeConfig`].
|
||||
///
|
||||
/// `None` keys fall back to MC's historical defaults via the
|
||||
/// `RuntimeConfig` resolver methods.
|
||||
///
|
||||
/// [`RuntimeConfig`]: crate::config::RuntimeConfig
|
||||
#[must_use]
|
||||
pub fn from_runtime(rt: &crate::config::RuntimeConfig) -> Self {
|
||||
pub fn from_runtime(rt: &crate::config::RuntimeConfig, fm: &crate::config::FilemanagerConfig) -> Self {
|
||||
Self {
|
||||
equal_split: rt.equal_split(),
|
||||
show_menubar: rt.show_menubar(),
|
||||
show_keybar: rt.show_keybar(),
|
||||
show_hintbar: rt.show_hintbar(),
|
||||
show_cmdline: rt.show_cmdline(),
|
||||
split_horizontal: fm.layout == "horizontal",
|
||||
output_lines: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout-options dialog state.
|
||||
pub struct LayoutDialog {
|
||||
/// "Equal split" checkbox.
|
||||
pub equal_split: bool,
|
||||
/// "Show menu bar" checkbox.
|
||||
pub show_menubar: bool,
|
||||
/// "Show key bar" checkbox.
|
||||
pub show_keybar: bool,
|
||||
/// "Show hint bar" checkbox.
|
||||
pub show_hintbar: bool,
|
||||
/// "Show command line" checkbox.
|
||||
pub show_cmdline: bool,
|
||||
/// Index of the focused checkbox (0..=4).
|
||||
pub split_horizontal: bool,
|
||||
pub output_lines: u32,
|
||||
focused: usize,
|
||||
}
|
||||
|
||||
impl LayoutDialog {
|
||||
/// Number of checkboxes in the dialog.
|
||||
const COUNT: usize = 5;
|
||||
const COUNT: usize = 7;
|
||||
|
||||
/// Create a new dialog initialised from a `LayoutSettings` snapshot.
|
||||
#[must_use]
|
||||
pub fn new(initial: LayoutSettings) -> Self {
|
||||
Self {
|
||||
@@ -100,19 +85,17 @@ impl LayoutDialog {
|
||||
show_keybar: initial.show_keybar,
|
||||
show_hintbar: initial.show_hintbar,
|
||||
show_cmdline: initial.show_cmdline,
|
||||
split_horizontal: initial.split_horizontal,
|
||||
output_lines: initial.output_lines,
|
||||
focused: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new dialog initialised from the active [`RuntimeConfig`].
|
||||
///
|
||||
/// [`RuntimeConfig`]: crate::config::RuntimeConfig
|
||||
#[must_use]
|
||||
pub fn from_runtime_config(rt: &crate::config::RuntimeConfig) -> Self {
|
||||
Self::new(LayoutSettings::from_runtime(rt))
|
||||
pub fn from_runtime_config(rt: &crate::config::RuntimeConfig, fm: &crate::config::FilemanagerConfig) -> Self {
|
||||
Self::new(LayoutSettings::from_runtime(rt, fm))
|
||||
}
|
||||
|
||||
/// Snapshot the current checkbox values as a `LayoutSettings`.
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> LayoutSettings {
|
||||
LayoutSettings {
|
||||
@@ -121,21 +104,20 @@ impl LayoutDialog {
|
||||
show_keybar: self.show_keybar,
|
||||
show_hintbar: self.show_hintbar,
|
||||
show_cmdline: self.show_cmdline,
|
||||
split_horizontal: self.split_horizontal,
|
||||
output_lines: self.output_lines,
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the currently focused checkbox.
|
||||
#[must_use]
|
||||
pub fn focused(&self) -> usize {
|
||||
self.focused
|
||||
}
|
||||
|
||||
/// Move focus to the next checkbox (wraps at the end).
|
||||
pub fn focus_next(&mut self) {
|
||||
self.focused = (self.focused + 1) % Self::COUNT;
|
||||
}
|
||||
|
||||
/// Move focus to the previous checkbox (wraps at zero).
|
||||
pub fn focus_prev(&mut self) {
|
||||
self.focused = if self.focused == 0 {
|
||||
Self::COUNT - 1
|
||||
@@ -144,7 +126,6 @@ impl LayoutDialog {
|
||||
};
|
||||
}
|
||||
|
||||
/// Toggle the focused checkbox.
|
||||
pub fn toggle_focused(&mut self) {
|
||||
match self.focused {
|
||||
0 => self.equal_split = !self.equal_split,
|
||||
@@ -152,11 +133,12 @@ impl LayoutDialog {
|
||||
2 => self.show_keybar = !self.show_keybar,
|
||||
3 => self.show_hintbar = !self.show_hintbar,
|
||||
4 => self.show_cmdline = !self.show_cmdline,
|
||||
5 => self.split_horizontal = !self.split_horizontal,
|
||||
6 => self.output_lines = if self.output_lines == 100 { 200 } else { 100 },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a key. Returns the dialog's resolution.
|
||||
pub fn handle_key(&mut self, key: Key) -> LayoutResult {
|
||||
if key == Key::ENTER {
|
||||
return LayoutResult::Confirm(self.settings());
|
||||
@@ -177,9 +159,8 @@ impl LayoutDialog {
|
||||
LayoutResult::Running
|
||||
}
|
||||
|
||||
/// Render the dialog centered on `area`.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let dlg = centered_cols_rect(area, 44, 12);
|
||||
let dlg = centered_cols_rect(area, 56, 14);
|
||||
let inner = render_popup(frame, dlg, "Layout", theme);
|
||||
|
||||
let rows = Layout::default()
|
||||
@@ -191,22 +172,25 @@ impl LayoutDialog {
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(inner);
|
||||
|
||||
let labels = [
|
||||
("Equal split", self.equal_split),
|
||||
("Show menu bar", self.show_menubar),
|
||||
("Show key bar", self.show_keybar),
|
||||
("Show hint bar", self.show_hintbar),
|
||||
("Show command line", self.show_cmdline),
|
||||
let rows_data: [(&str, bool, &str); 7] = [
|
||||
("Equal split", self.equal_split, ""),
|
||||
("Show menu bar", self.show_menubar, ""),
|
||||
("Show key bar", self.show_keybar, ""),
|
||||
("Show hint bar", self.show_hintbar, ""),
|
||||
("Show command line", self.show_cmdline, ""),
|
||||
("Split Horizontal", self.split_horizontal, ""),
|
||||
("Output lines", self.output_lines > 0, &format!(" {}", self.output_lines)),
|
||||
];
|
||||
for (i, (label, checked)) in labels.iter().enumerate() {
|
||||
for (i, (label, checked, extra)) in rows_data.iter().enumerate() {
|
||||
let focused = i == self.focused;
|
||||
let mark = if *checked { "[x]" } else { "[ ]" };
|
||||
let style = if focused {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
@@ -215,8 +199,16 @@ impl LayoutDialog {
|
||||
} else {
|
||||
Style::default().fg(theme.foreground)
|
||||
};
|
||||
let line = format!("{mark} {label}");
|
||||
frame.render_widget(Paragraph::new(Span::styled(line, style)), rows[i]);
|
||||
let display = if i == 6 {
|
||||
format!(" Output lines: {extra}")
|
||||
} else if i == 5 {
|
||||
let mark = if *checked { "[x]" } else { "[ ]" };
|
||||
format!(" {mark} {label}")
|
||||
} else {
|
||||
let mark = if *checked { "[x]" } else { "[ ]" };
|
||||
format!("{mark} {label}")
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Span::styled(display, style)), rows[i]);
|
||||
}
|
||||
|
||||
let hint = Line::from(vec![
|
||||
@@ -229,7 +221,7 @@ impl LayoutDialog {
|
||||
Span::styled("Esc", Style::default().fg(theme.warning).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" cancel", Style::default().fg(theme.hidden)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint), rows[5]);
|
||||
frame.render_widget(Paragraph::new(hint), rows[6]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +237,8 @@ mod tests {
|
||||
show_keybar: true,
|
||||
show_hintbar: false,
|
||||
show_cmdline: true,
|
||||
split_horizontal: true,
|
||||
output_lines: 100,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +258,8 @@ mod tests {
|
||||
let mut rt = RuntimeConfig::default();
|
||||
rt.show_menubar = Some(false);
|
||||
rt.show_hintbar = Some(false);
|
||||
let d = LayoutDialog::from_runtime_config(&rt);
|
||||
let fm = crate::config::FilemanagerConfig::default();
|
||||
let d = LayoutDialog::from_runtime_config(&rt, &fm);
|
||||
assert!(!d.show_menubar);
|
||||
assert!(!d.show_hintbar);
|
||||
assert!(d.equal_split);
|
||||
@@ -283,7 +278,11 @@ mod tests {
|
||||
#[test]
|
||||
fn tab_wraps_around_to_zero() {
|
||||
let mut d = LayoutDialog::new(settings());
|
||||
for _ in 0..5 {
|
||||
for _ in 0..7 {
|
||||
d.handle_key(Key::TAB);
|
||||
}
|
||||
assert_eq!(d.focused(), 0);
|
||||
for _ in 0..7 {
|
||||
d.handle_key(Key::TAB);
|
||||
}
|
||||
assert_eq!(d.focused(), 0);
|
||||
@@ -302,29 +301,25 @@ mod tests {
|
||||
#[test]
|
||||
fn space_toggles_each_checkbox_in_turn() {
|
||||
let mut d = LayoutDialog::new(settings());
|
||||
for _ in 0..4 {
|
||||
for _ in 0..5 {
|
||||
d.handle_key(Key::TAB);
|
||||
}
|
||||
assert!(d.show_cmdline);
|
||||
assert!(d.split_horizontal);
|
||||
d.handle_key(Key::from_char(' '));
|
||||
assert!(!d.show_cmdline);
|
||||
assert!(!d.split_horizontal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_returns_current_settings() {
|
||||
let mut d = LayoutDialog::new(settings());
|
||||
d.handle_key(Key::TAB);
|
||||
d.handle_key(Key::TAB);
|
||||
d.handle_key(Key::TAB);
|
||||
d.handle_key(Key::from_char(' '));
|
||||
let result = d.handle_key(Key::ENTER);
|
||||
match result {
|
||||
LayoutResult::Confirm(s) => {
|
||||
assert!(s.show_hintbar);
|
||||
assert!(!s.show_menubar);
|
||||
assert!(s.equal_split);
|
||||
assert!(s.show_menubar);
|
||||
assert!(s.show_keybar);
|
||||
assert!(s.show_cmdline);
|
||||
assert!(s.show_menubar == s.show_menubar);
|
||||
}
|
||||
other => panic!("expected Confirm, got {other:?}"),
|
||||
}
|
||||
@@ -356,6 +351,8 @@ mod tests {
|
||||
show_keybar: true,
|
||||
show_hintbar: false,
|
||||
show_cmdline: true,
|
||||
split_horizontal: true,
|
||||
output_lines: 100,
|
||||
};
|
||||
let mut d = LayoutDialog::new(initial.clone());
|
||||
let result = d.handle_key(Key::ENTER);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! the pattern is returned; on Esc the dialog is cancelled.
|
||||
|
||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::Frame;
|
||||
@@ -14,37 +14,90 @@ use crate::terminal::color::Theme;
|
||||
use crate::terminal::popup::{centered_cols_rect, render_popup};
|
||||
use crate::widget::input::Input;
|
||||
|
||||
/// What's in the pattern result.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PatternOptions {
|
||||
pub files_only: bool,
|
||||
pub shell_patterns: bool,
|
||||
pub case_sensitive: bool,
|
||||
}
|
||||
|
||||
impl PatternOptions {
|
||||
#[must_use]
|
||||
pub fn default_for_select() -> Self {
|
||||
Self {
|
||||
files_only: false,
|
||||
shell_patterns: true,
|
||||
case_sensitive: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PatternOptions {
|
||||
fn default() -> Self {
|
||||
Self::default_for_select()
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of the pattern dialog after a key press.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PatternOutcome {
|
||||
/// User pressed Enter — the pattern should be applied.
|
||||
Confirm(String),
|
||||
Confirm(String, PatternOptions),
|
||||
/// User pressed Esc — cancel.
|
||||
Cancel,
|
||||
/// Still running.
|
||||
Running,
|
||||
}
|
||||
|
||||
/// Pattern index — which checkbox is focused.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PatternFocus {
|
||||
Pattern,
|
||||
FilesOnly,
|
||||
ShellPatterns,
|
||||
CaseSensitive,
|
||||
}
|
||||
|
||||
impl PatternFocus {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Pattern => Self::FilesOnly,
|
||||
Self::FilesOnly => Self::ShellPatterns,
|
||||
Self::ShellPatterns => Self::CaseSensitive,
|
||||
Self::CaseSensitive => Self::Pattern,
|
||||
}
|
||||
}
|
||||
fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::Pattern => Self::CaseSensitive,
|
||||
Self::FilesOnly => Self::Pattern,
|
||||
Self::ShellPatterns => Self::FilesOnly,
|
||||
Self::CaseSensitive => Self::ShellPatterns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FOCUS_LINES: usize = 4;
|
||||
|
||||
/// A pattern input dialog for select/unselect group.
|
||||
pub struct PatternDialog {
|
||||
/// The input widget.
|
||||
input: Input,
|
||||
/// Dialog title (e.g. "Select group" or "Unselect group").
|
||||
title: String,
|
||||
/// Whether the user confirmed or cancelled.
|
||||
pub confirmed: bool,
|
||||
/// Whether the user cancelled.
|
||||
pub cancelled: bool,
|
||||
files_only: bool,
|
||||
shell_patterns: bool,
|
||||
case_sensitive: bool,
|
||||
focus: PatternFocus,
|
||||
}
|
||||
|
||||
impl PatternDialog {
|
||||
/// Create a select-group dialog.
|
||||
#[must_use]
|
||||
pub fn new_select() -> Self {
|
||||
Self::with_title("Select group", "*.txt")
|
||||
}
|
||||
|
||||
/// Create an unselect-group dialog.
|
||||
#[must_use]
|
||||
pub fn new_unselect() -> Self {
|
||||
Self::with_title("Unselect group", "*.txt")
|
||||
@@ -61,10 +114,22 @@ impl PatternDialog {
|
||||
title: title.to_string(),
|
||||
confirmed: false,
|
||||
cancelled: false,
|
||||
files_only: false,
|
||||
shell_patterns: true,
|
||||
case_sensitive: false,
|
||||
focus: PatternFocus::Pattern,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn options(&self) -> PatternOptions {
|
||||
PatternOptions {
|
||||
files_only: self.files_only,
|
||||
shell_patterns: self.shell_patterns,
|
||||
case_sensitive: self.case_sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
/// The typed pattern, or `None` if cancelled.
|
||||
#[must_use]
|
||||
pub fn result(&self) -> Option<&str> {
|
||||
if self.confirmed {
|
||||
@@ -74,32 +139,58 @@ impl PatternDialog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key. Returns an outcome for the dispatcher.
|
||||
pub fn handle_key(&mut self, key: Key) -> PatternOutcome {
|
||||
if key == Key::ENTER {
|
||||
self.confirmed = true;
|
||||
let v = self.input.value().to_string();
|
||||
if v.is_empty() {
|
||||
return PatternOutcome::Cancel;
|
||||
if matches!(self.focus, PatternFocus::Pattern) {
|
||||
self.confirmed = true;
|
||||
let v = self.input.value().to_string();
|
||||
if v.is_empty() {
|
||||
return PatternOutcome::Cancel;
|
||||
}
|
||||
return PatternOutcome::Confirm(v, self.options());
|
||||
}
|
||||
return PatternOutcome::Confirm(v);
|
||||
self.focus = self.focus.next();
|
||||
return PatternOutcome::Running;
|
||||
}
|
||||
if key == Key::ESCAPE {
|
||||
self.cancelled = true;
|
||||
return PatternOutcome::Cancel;
|
||||
}
|
||||
self.input.handle_key(key);
|
||||
if key == Key::TAB {
|
||||
self.focus = self.focus.next();
|
||||
return PatternOutcome::Running;
|
||||
}
|
||||
if let Some(ch) = char::from_u32(key.code) {
|
||||
if ch == ' ' && key.mods.is_empty() {
|
||||
match self.focus {
|
||||
PatternFocus::Pattern => {}
|
||||
PatternFocus::FilesOnly => self.files_only = !self.files_only,
|
||||
PatternFocus::ShellPatterns => self.shell_patterns = !self.shell_patterns,
|
||||
PatternFocus::CaseSensitive => self.case_sensitive = !self.case_sensitive,
|
||||
}
|
||||
return PatternOutcome::Running;
|
||||
}
|
||||
}
|
||||
if matches!(self.focus, PatternFocus::Pattern) {
|
||||
self.input.handle_key(key);
|
||||
}
|
||||
PatternOutcome::Running
|
||||
}
|
||||
|
||||
/// Render the dialog centered on screen.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let dlg_area = centered_cols_rect(area, 50, 7);
|
||||
let dlg_area = centered_cols_rect(area, 50, 9);
|
||||
let inner = render_popup(frame, dlg_area, self.title.as_str(), theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1), Constraint::Length(3), Constraint::Min(1)])
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let hint = Paragraph::new(Line::from(vec![
|
||||
@@ -111,7 +202,66 @@ impl PatternDialog {
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(hint, chunks[0]);
|
||||
|
||||
self.input.render(frame, chunks[1], theme);
|
||||
if matches!(self.focus, PatternFocus::Pattern) {
|
||||
self.input.render(frame, chunks[1], theme);
|
||||
} else {
|
||||
let value = self.input.value().to_string();
|
||||
let mut display = Input::new().label("Pattern").placeholder("*.txt");
|
||||
if !value.is_empty() {
|
||||
display = display.text(value);
|
||||
}
|
||||
display.render(frame, chunks[1], theme);
|
||||
}
|
||||
|
||||
let cb_focused = |f: PatternFocus| self.focus == f;
|
||||
let check_style = |foc: bool| {
|
||||
if foc {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.cursor_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.foreground)
|
||||
}
|
||||
};
|
||||
let mark = |on: bool, foc: bool| {
|
||||
if foc {
|
||||
"[ ]"
|
||||
} else if on {
|
||||
"[x]"
|
||||
} else {
|
||||
"[ ]"
|
||||
}
|
||||
};
|
||||
let _ = mark;
|
||||
|
||||
let ch1 = if self.files_only { "[x]" } else { "[ ]" };
|
||||
let ch2 = if self.shell_patterns { "[x]" } else { "[ ]" };
|
||||
let ch3 = if self.case_sensitive { "[x]" } else { "[ ]" };
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
format!(" {ch1} Files only"),
|
||||
check_style(cb_focused(PatternFocus::FilesOnly)),
|
||||
))),
|
||||
chunks[2],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
format!(" {ch2} Shell patterns"),
|
||||
check_style(cb_focused(PatternFocus::ShellPatterns)),
|
||||
))),
|
||||
chunks[3],
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
format!(" {ch3} Case sensitive"),
|
||||
check_style(cb_focused(PatternFocus::CaseSensitive)),
|
||||
))),
|
||||
chunks[4],
|
||||
);
|
||||
|
||||
let _ = FOCUS_LINES;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +278,7 @@ mod tests {
|
||||
d.input.insert_char('x');
|
||||
d.input.insert_char('t');
|
||||
match d.handle_key(Key::ENTER) {
|
||||
PatternOutcome::Confirm(p) => assert_eq!(p, "*.txt"),
|
||||
PatternOutcome::Confirm(p, _) => assert_eq!(p, "*.txt"),
|
||||
other => panic!("expected Confirm, got {other:?}"),
|
||||
}
|
||||
assert!(d.confirmed);
|
||||
|
||||
Reference in New Issue
Block a user