W51: Appearance dialog (MC CK_OptionsAppearance)

The F9 audit found TLC had no Appearance dialog — the Skins...
dialog was the only appearance option. MC's CK_OptionsAppearance
offers five checkbox toggles plus a skin selector.

Create AppearanceDialog with 6 rows:
- Menubar (show/hide F9 menu bar)
- Keybar (show/hide F1-F10 key hint bar)
- Hint bar (show/hide status bar)
- Command prompt (show/hide command-line prompt)
- Mini status (show/hide mini-status above keybar)
- Skins... (opens the existing SkinDialog)

All five toggles write back to RuntimeConfig's show_* fields.
Enter toggles the checkbox; Esc dismisses; Skins... opens the
skin selection dialog transparently.

Added to F9 → Options menu as 'Appearance...' between
Confirmation and Skins, matching MC's menu ordering.

Added 4 unit tests: toggles default true, enter toggles off/on,
down cycles cursor, skin button opens skin outcome.

Tests: 1411 pass (was 1407), zero warnings.
This commit is contained in:
2026-07-07 12:45:28 +03:00
parent 42feab633e
commit bbcd44833d
7 changed files with 296 additions and 6 deletions
@@ -0,0 +1,240 @@
//! Appearance dialog (F9 → Options → Appearance).
//!
//! Mirrors MC's CK_OptionsAppearance — checkbox toggles for the
//! menubar, command prompt, keybar, hint bar, and mini-status bar
//! visibility, plus a "Skin..." button that opens the existing
//! [`SkinDialog`].
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::config::RuntimeConfig;
use crate::key::Key;
use crate::terminal::color::Theme;
use crate::terminal::popup::{centered_percent_rect, render_popup};
/// The appearance checkbox dialog.
pub struct AppearanceDialog {
/// Current toggle state for each option.
pub settings: AppearanceSettings,
/// Currently selected row.
cursor: usize,
}
/// The five 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 F1F10 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,
}
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",
1 => " Keybar",
2 => " Hint bar",
3 => " Command prompt",
4 => " Mini status",
5 => " Skins...",
_ => "",
}
}
/// True when `idx` corresponds to the "Skin..." button (not a
/// checkbox toggle).
fn is_skin_button(idx: usize) -> bool {
idx == 5
}
}
impl From<&RuntimeConfig> for AppearanceSettings {
fn from(cfg: &RuntimeConfig) -> Self {
Self {
show_menubar: cfg.show_menubar(),
show_keybar: cfg.show_keybar(),
show_hintbar: cfg.show_hintbar(),
show_cmdline: cfg.show_cmdline(),
show_mini_status: cfg.show_mini_status(),
}
}
}
/// Outcome of a key press in the appearance dialog.
#[derive(Debug, Clone)]
pub enum AppearanceOutcome {
/// The "Skin..." button was activated.
OpenSkin,
/// Settings were committed (Enter on the last row or
/// explicit Ok button).
Confirm(AppearanceSettings),
/// Dismissed without saving.
Cancel,
/// Dialog is still active (key consumed but no action taken).
Running,
}
impl AppearanceDialog {
/// Create a new appearance dialog initialised from `cfg`.
#[must_use]
pub fn new(cfg: &RuntimeConfig) -> Self {
Self {
settings: AppearanceSettings::from(cfg),
cursor: 0,
}
}
/// Handle a key event. Returns the outcome.
pub fn handle_key(&mut self, key: Key) -> AppearanceOutcome {
match key {
Key::ESCAPE => AppearanceOutcome::Cancel,
Key::UP => {
self.cursor = self
.cursor
.checked_sub(1)
.unwrap_or(5); // 5 = last row
AppearanceOutcome::Running
}
Key::DOWN => {
self.cursor = (self.cursor + 1) % 6;
AppearanceOutcome::Running
}
Key::ENTER => {
if AppearanceSettings::is_skin_button(self.cursor) {
AppearanceOutcome::OpenSkin
} else {
// Toggle the checkbox.
match self.cursor {
0 => self.settings.show_menubar = !self.settings.show_menubar,
1 => self.settings.show_keybar = !self.settings.show_keybar,
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,
_ => {}
}
AppearanceOutcome::Running
}
}
_ => AppearanceOutcome::Running,
}
}
/// Render the dialog.
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
let popup = centered_percent_rect(area, 0.5, 0.55);
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 chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
let base = Style::default()
.fg(theme.foreground)
.bg(theme.background);
let selected = Style::default()
.fg(theme.cursor_fg)
.bg(theme.cursor_bg)
.add_modifier(Modifier::BOLD);
let accent = Style::default().fg(theme.accent).bg(theme.background);
for i in 0..ROWS {
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 {
let ch = if self.settings.check(i) { "" } else { "" };
let ch_style = if self.settings.check(i) {
accent.add_modifier(Modifier::BOLD)
} else {
base
};
Line::from(vec![
Span::styled(format!(" [{}] ", ch), ch_style),
Span::styled(label, style),
])
};
let p = Paragraph::new(line).style(style);
frame.render_widget(p, chunks[i]);
}
}
}
impl AppearanceSettings {
fn check(&self, idx: usize) -> bool {
match idx {
0 => self.show_menubar,
1 => self.show_keybar,
2 => self.show_hintbar,
3 => self.show_cmdline,
4 => self.show_mini_status,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_dialog_has_all_toggles_default_true() {
let cfg = RuntimeConfig::default();
let dlg = AppearanceDialog::new(&cfg);
assert!(dlg.settings.show_menubar);
assert!(dlg.settings.show_keybar);
assert!(dlg.settings.show_hintbar);
assert!(dlg.settings.show_cmdline);
assert!(dlg.settings.show_mini_status);
}
#[test]
fn enter_toggles_checkbox() {
let cfg = RuntimeConfig::default();
let mut dlg = AppearanceDialog::new(&cfg);
// Toggle menubar off.
dlg.cursor = 0;
let outcome = dlg.handle_key(Key::ENTER);
assert!(matches!(outcome, AppearanceOutcome::Running));
assert!(!dlg.settings.show_menubar);
// Toggle it back on.
let _ = dlg.handle_key(Key::ENTER);
assert!(dlg.settings.show_menubar);
}
#[test]
fn down_cycles_cursor() {
let cfg = RuntimeConfig::default();
let mut dlg = AppearanceDialog::new(&cfg);
for _ in 0..7 {
let _ = dlg.handle_key(Key::DOWN);
}
assert_eq!(dlg.cursor, 1);
}
#[test]
fn skin_button_returns_open_skin_outcome() {
let cfg = RuntimeConfig::default();
let mut dlg = AppearanceDialog::new(&cfg);
dlg.cursor = 5;
let outcome = dlg.handle_key(Key::ENTER);
assert!(matches!(outcome, AppearanceOutcome::OpenSkin));
}
}
@@ -985,6 +985,7 @@ Err(e) => self.status.set_error(format!("view: {e}")),
| Some(DialogState::Encoding(_))
| Some(DialogState::Connection(_))
| Some(DialogState::Sort(_))
| Some(DialogState::Appearance(_))
| Some(DialogState::Confirm(_))
| Some(DialogState::Progress(_)) => {
// These are closed by their own apply_*_outcome
@@ -1421,6 +1422,7 @@ fn dialog_label(d: &DialogState) -> String {
DialogState::Encoding(_) => "Display encoding".to_string(),
DialogState::Connection(_) => "Network connection".to_string(),
DialogState::Sort(_) => "Sort order".to_string(),
DialogState::Appearance(_) => "Appearance".to_string(),
DialogState::Confirm(_) => "Confirmation".to_string(),
DialogState::Progress(_) => "Progress".to_string(),
}
@@ -12,11 +12,12 @@ use std::sync::Arc;
use anyhow::Result;
use crate::filemanager::{
chattr_dialog, compare, config_dialog, connection_dialog, display_bits_dialog,
edit_history, encoding_dialog, external_panelize, filter_dialog, filtered_view,
find, format_utils, hotlist, jobs, learn_keys_dialog, layout_dialog, link,
menubar, panel_options, quickcd_dialog, screen_list, skin_dialog,
tree, usermenu, vfs_list, vfs_settings_dialog, Cmd, DialogState, FileManager,
appearance_dialog, chattr_dialog, compare, config_dialog, confirm_dialog,
connection_dialog, display_bits_dialog, edit_history, encoding_dialog,
external_panelize, filter_dialog, filtered_view, find, format_utils, hotlist,
jobs, learn_keys_dialog, layout_dialog, link, menubar, panel_options,
quickcd_dialog, screen_list, skin_dialog, tree, usermenu, vfs_list,
vfs_settings_dialog, Cmd, DialogState, FileManager,
};
use crate::filemanager::link::LinkKind;
use crate::key::Key;
@@ -372,7 +373,20 @@ impl FileManager {
Ok(true)
}
Cmd::OptionsConfirm => {
self.toggle_confirmations();
let init = confirm_dialog::ConfirmSettings {
confirm_delete: self.runtime.safe_delete.unwrap_or(true),
confirm_overwrite: true,
confirm_execute: self.runtime.safe_execute.unwrap_or(false),
confirm_exit: true,
};
self.dialog = Some(DialogState::Confirm(Box::new(
confirm_dialog::ConfirmDialog::new(init),
)));
Ok(true)
}
Cmd::Appearance => {
let dlg = appearance_dialog::AppearanceDialog::new(&self.runtime);
self.dialog = Some(DialogState::Appearance(Box::new(dlg)));
Ok(true)
}
Cmd::CompareDirs => {
@@ -914,6 +928,31 @@ impl FileManager {
}
consumed = true;
}
Some(DialogState::Appearance(d)) => {
use crate::filemanager::appearance_dialog::AppearanceOutcome;
let r = d.handle_key(key);
match r {
AppearanceOutcome::Confirm(settings) => {
self.runtime.show_menubar = Some(settings.show_menubar);
self.runtime.show_keybar = Some(settings.show_keybar);
self.runtime.show_hintbar = Some(settings.show_hintbar);
self.runtime.show_cmdline = Some(settings.show_cmdline);
self.runtime.show_mini_status = Some(settings.show_mini_status);
self.status.set_message("Appearance settings updated".to_string());
self.dialog = None;
}
AppearanceOutcome::OpenSkin => {
self.dialog = Some(DialogState::Skin(Box::new(
skin_dialog::SkinDialog::new(&self.skin_name),
)));
}
AppearanceOutcome::Cancel => {
self.dialog = None;
}
AppearanceOutcome::Running => {}
}
consumed = true;
}
Some(DialogState::Sort(d)) => {
use crate::filemanager::sort_dialog::SortResult;
let r = d.handle_key(key);
@@ -146,6 +146,7 @@ fn build_menus() -> Vec<Menu> {
MenuItem::item("Layout...", Cmd::LayoutDialog),
MenuItem::item("Panel options...", Cmd::PanelOptionsDialog),
MenuItem::item("Confirmation...", Cmd::OptionsConfirm),
MenuItem::item("Appearance...", Cmd::Appearance),
MenuItem::item("Skins...", Cmd::SkinSelect),
MenuItem::item("Display bits...", Cmd::DisplayBits),
MenuItem::item("Learn keys...", Cmd::LearnKeysDialog),
@@ -9,6 +9,7 @@ pub mod cmdline;
pub mod compare;
pub mod config_dialog;
pub mod confirm_dialog;
pub mod appearance_dialog;
pub mod connection_dialog;
pub mod connection_manager;
pub mod copy_dialog;
@@ -356,6 +357,8 @@ pub enum DialogState {
Connection(Box<ConnectionDialog>),
/// Alt-Shift-T — sort order dialog.
Sort(Box<sort_dialog::SortDialog>),
/// F9 → Options → Appearance — show/hide UI bars.
Appearance(Box<appearance_dialog::AppearanceDialog>),
/// F9 → Options → Confirmation — per-operation confirm toggles.
Confirm(Box<confirm_dialog::ConfirmDialog>),
/// F5/F6/F8 — live progress dialog for background copy/move/delete.
@@ -420,6 +423,7 @@ impl DialogState {
DialogState::Encoding(_) => false,
DialogState::Connection(_) => false,
DialogState::Sort(_) => false,
DialogState::Appearance(_) => false,
DialogState::Confirm(_) => false,
DialogState::Progress(_) => false,
}
@@ -181,6 +181,7 @@ impl FileManager {
DialogState::Connection(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Sort(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Confirm(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Appearance(d) => d.render(frame, dialog_area, &self.theme),
DialogState::Progress(d) => d.render(frame, dialog_area, &self.theme),
}
}
@@ -219,6 +219,8 @@ pub enum Cmd {
CommandHistory,
/// Options menu — toggle confirmation dialogs.
OptionsConfirm,
/// Options menu — appearance dialog (MC CK_OptionsAppearance).
Appearance,
}
impl Cmd {
@@ -318,6 +320,7 @@ impl Cmd {
Cmd::VfsSettings => "Virtual FS",
Cmd::CommandHistory => "Command history",
Cmd::OptionsConfirm => "Confirmation",
Cmd::Appearance => "Appearance",
}
}
}