tlc: MC .ini skins as ONLY theme source — drop all hardcoded palettes

Removes every hand-coded Theme constant. julia256 is the new default; sand256 is the light fallback. All 39 bundled MC skins are now reachable by name, and legacy aliases (default-dark, mc-classic, nord, etc.) resolve to real .ini skins.

Architecture:

  - DEFAULT_THEME / LIGHT_THEME are LazyLock<Theme> initialised on first use.

  - parse_theme now reads ALL MC sections: core, statusbar, buttonbar, dialog, error, filehighlight, viewer.

  - shadow field comes from [core] shadow (MC's gray;black pattern).

  - render_drop_shadow matches MC's tty_draw_box_shadow algorithm exactly: two background-recolor rectangles (2-col right strip + 1-row bottom strip offset by 2 cols), no glyph change.

Removes redbear_tui_theme import — TLC no longer has its own palette.
This commit is contained in:
2026-06-20 14:34:42 +03:00
parent efbd295cce
commit 03b5f88bf6
4 changed files with 199 additions and 518 deletions
+1 -1
View File
@@ -246,7 +246,7 @@ fn default_undo_depth() -> usize {
32_768
}
fn default_skin_name() -> String {
"default-dark".into()
"julia256".into()
}
fn default_keepalive() -> u32 {
30
+119 -480
View File
@@ -31,274 +31,61 @@
//! (the selection dialog switches skins without restarting the
//! process).
use std::sync::LazyLock;
use ratatui::style::Color;
use redbear_tui_theme::{Rgb as ThemeRgb, REDBEAR_DARK, REDBEAR_LIGHT};
/// Convert a `redbear_tui_theme::Rgb` into a `ratatui::style::Color::Rgb`.
const fn as_color(c: ThemeRgb) -> Color {
Color::Rgb(c.0, c.1, c.2)
}
use super::mc_skin;
/// Construct a [`Color::Rgb`] from a hex constant at compile time.
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
/// Built-in dark theme (the Red Bear default).
pub const DEFAULT_THEME: Theme = Theme {
name: "default-dark",
background: as_color(REDBEAR_DARK.background),
foreground: as_color(REDBEAR_DARK.text),
selection_bg: as_color(REDBEAR_DARK.selection_bg),
selection_fg: as_color(REDBEAR_DARK.selection_fg),
cursor_bg: as_color(REDBEAR_DARK.cursor_bg),
cursor_fg: as_color(REDBEAR_DARK.cursor_fg),
marked_bg: as_color(REDBEAR_DARK.marked_bg),
marked_fg: as_color(REDBEAR_DARK.marked_fg),
directory: as_color(REDBEAR_DARK.directory),
executable: as_color(REDBEAR_DARK.executable),
symlink: as_color(REDBEAR_DARK.symlink),
device: as_color(REDBEAR_DARK.device),
hidden: as_color(REDBEAR_DARK.hidden),
accent: as_color(REDBEAR_DARK.accent),
status_bg: as_color(REDBEAR_DARK.status_bg),
status_fg: as_color(REDBEAR_DARK.text),
buttonbar_bg: as_color(REDBEAR_DARK.buttonbar_bg),
buttonbar_fg: as_color(REDBEAR_DARK.buttonbar_fg),
title_bg: as_color(REDBEAR_DARK.title_bg),
title_fg: as_color(REDBEAR_DARK.title_accent),
border: as_color(REDBEAR_DARK.border),
error: as_color(REDBEAR_DARK.error),
warning: as_color(REDBEAR_DARK.warning),
info: as_color(REDBEAR_DARK.info),
/// Fallback theme used only when both the bundled MC catalogue and
/// the user-skin directory fail to resolve the requested name. Every
/// field is `Color::Reset` so the terminal uses its default colors.
/// This is the ONLY hand-written theme in TLC — every other Theme is
/// built by parsing a Midnight Commander `.ini` skin at compile time
/// via [`mc_skin::theme_by_name`].
pub const FALLBACK_THEME: Theme = Theme {
name: "fallback",
background: Color::Reset,
foreground: Color::Reset,
selection_bg: Color::Reset,
selection_fg: Color::Reset,
cursor_bg: Color::Reset,
cursor_fg: Color::Reset,
marked_bg: Color::Reset,
marked_fg: Color::Reset,
directory: Color::Reset,
executable: Color::Reset,
symlink: Color::Reset,
device: Color::Reset,
hidden: Color::Reset,
accent: Color::Reset,
status_bg: Color::Reset,
status_fg: Color::Reset,
buttonbar_bg: Color::Reset,
buttonbar_fg: Color::Reset,
title_bg: Color::Reset,
title_fg: Color::Reset,
border: Color::Reset,
error: Color::Reset,
warning: Color::Reset,
info: Color::Reset,
shadow: Color::Reset,
};
/// The Red Bear default theme. Derived from the MC `julia256` skin
/// at first access. See [`mc_skin::theme_by_name`] for the parser.
pub static DEFAULT_THEME: LazyLock<Theme> =
LazyLock::new(|| mc_skin::theme_by_name("julia256").unwrap_or(FALLBACK_THEME));
/// Built-in light theme (selected when `cfg.skin.name = "default-light"`).
pub const LIGHT_THEME: Theme = Theme {
name: "default-light",
background: as_color(REDBEAR_LIGHT.background),
foreground: as_color(REDBEAR_LIGHT.text),
selection_bg: as_color(REDBEAR_LIGHT.selection_bg),
selection_fg: as_color(REDBEAR_LIGHT.selection_fg),
cursor_bg: as_color(REDBEAR_LIGHT.cursor_bg),
cursor_fg: as_color(REDBEAR_LIGHT.cursor_fg),
marked_bg: as_color(REDBEAR_LIGHT.marked_bg),
marked_fg: as_color(REDBEAR_LIGHT.marked_fg),
directory: as_color(REDBEAR_LIGHT.directory),
executable: as_color(REDBEAR_LIGHT.executable),
symlink: as_color(REDBEAR_LIGHT.symlink),
device: as_color(REDBEAR_LIGHT.device),
hidden: as_color(REDBEAR_LIGHT.hidden),
accent: as_color(REDBEAR_LIGHT.accent),
status_bg: as_color(REDBEAR_LIGHT.status_bg),
status_fg: as_color(REDBEAR_LIGHT.text),
buttonbar_bg: as_color(REDBEAR_LIGHT.buttonbar_bg),
buttonbar_fg: as_color(REDBEAR_LIGHT.buttonbar_fg),
title_bg: as_color(REDBEAR_LIGHT.title_bg),
title_fg: as_color(REDBEAR_LIGHT.title_accent),
border: as_color(REDBEAR_LIGHT.border),
error: as_color(REDBEAR_LIGHT.error),
warning: as_color(REDBEAR_LIGHT.warning),
info: as_color(REDBEAR_LIGHT.info),
};
pub static LIGHT_THEME: LazyLock<Theme> =
LazyLock::new(|| mc_skin::theme_by_name("sand256").unwrap_or(FALLBACK_THEME));
/// Midnight Commander Classic — the iconic blue/cyan TUI look
/// associated with MC's pre-4.8 default appearance.
///
/// Color values are derived from the historical MC palette:
/// deep blue background (`#0000AA`), white text, bright cyan
/// cursor/border, and bright yellow for marked entries.
pub const MC_CLASSIC_THEME: Theme = Theme {
name: "mc-classic",
background: rgb(0x00, 0x00, 0xAA),
foreground: rgb(0xFF, 0xFF, 0xFF),
selection_bg: rgb(0x55, 0x55, 0xFF),
selection_fg: rgb(0xFF, 0xFF, 0xFF),
cursor_bg: rgb(0x55, 0xFF, 0xFF),
cursor_fg: rgb(0x00, 0x00, 0x00),
marked_bg: rgb(0x00, 0x00, 0xAA),
marked_fg: rgb(0xFF, 0xFF, 0x00),
directory: rgb(0x55, 0xFF, 0xFF),
executable: rgb(0x55, 0xFF, 0x55),
symlink: rgb(0xFF, 0x55, 0xFF),
device: rgb(0xFF, 0x55, 0x55),
hidden: rgb(0x80, 0x80, 0x80),
accent: rgb(0xFF, 0xFF, 0x00),
status_bg: rgb(0x00, 0x00, 0xAA),
status_fg: rgb(0xFF, 0xFF, 0xFF),
buttonbar_bg: rgb(0x00, 0x00, 0x55),
buttonbar_fg: rgb(0xFF, 0xFF, 0xFF),
title_bg: rgb(0x00, 0x00, 0xAA),
title_fg: rgb(0xFF, 0xFF, 0xFF),
border: rgb(0x55, 0xFF, 0xFF),
error: rgb(0xFF, 0x55, 0x55),
warning: rgb(0xFF, 0xFF, 0x00),
info: rgb(0x55, 0xFF, 0xFF),
};
/// Midnight Commander Dark — a gray-toned MC skin inspired by MC's
/// built-in `dark` skin. Medium-dark gray panels with light text,
/// bright cyan directories, bright green executables, and yellow
/// marked files. No blue anywhere — this is the "gray MC" look.
pub const MC_DARK_THEME: Theme = Theme {
name: "mc-dark",
background: rgb(0x40, 0x40, 0x40), // panel background (dark gray)
foreground: rgb(0xD0, 0xD0, 0xD0), // panel text (light gray)
selection_bg: rgb(0x60, 0x60, 0x60), // selection (lighter gray)
selection_fg: rgb(0xFF, 0xFF, 0xFF), // selected text (white)
cursor_bg: rgb(0x70, 0x70, 0x70), // cursor line (medium gray)
cursor_fg: rgb(0xFF, 0xFF, 0xFF), // cursor text (white)
marked_bg: rgb(0x40, 0x40, 0x40), // marked bg = panel bg
marked_fg: rgb(0xFF, 0xFF, 0x00), // marked text (yellow)
directory: rgb(0x00, 0xFF, 0xFF), // directories (bright cyan)
executable: rgb(0x00, 0xFF, 0x00), // executables (bright green)
symlink: rgb(0xFF, 0x80, 0xFF), // symlinks (magenta)
device: rgb(0xFF, 0x80, 0x80), // devices (light red)
hidden: rgb(0x80, 0x80, 0x80), // hidden (gray)
accent: rgb(0xFF, 0xFF, 0x00),
status_bg: rgb(0x30, 0x30, 0x30), // status bar (darker gray)
status_fg: rgb(0xFF, 0xFF, 0xFF), // status text (white)
buttonbar_bg: rgb(0x30, 0x30, 0x30), // button bar (darker gray)
buttonbar_fg: rgb(0xC0, 0xC0, 0xC0), // button text (light gray)
title_bg: rgb(0x50, 0x50, 0x50), // title bar (medium gray)
title_fg: rgb(0xFF, 0xFF, 0xFF), // title text (white)
border: rgb(0x60, 0x60, 0x60), // borders (light gray)
error: rgb(0xFF, 0x60, 0x60), // errors (red)
warning: rgb(0xFF, 0xFF, 0x00), // warnings (yellow)
info: rgb(0x00, 0xFF, 0xFF), // info (cyan)
};
/// Midnight Commander Dark Gray — the darkest gray MC skin, inspired
/// by MC's `nicedark` skin. Near-black panels, dimmed text, subtle
/// borders. All accent colors are desaturated for a muted, low-glare
/// appearance — the "darkest gray MC" look.
pub const MC_DARK_GRAY_THEME: Theme = Theme {
name: "mc-dark-gray",
background: rgb(0x1E, 0x1E, 0x1E), // panel background (near-black)
foreground: rgb(0xB0, 0xB0, 0xB0), // panel text (dimmed light gray)
selection_bg: rgb(0x33, 0x33, 0x33), // selection (dark gray)
selection_fg: rgb(0xE0, 0xE0, 0xE0), // selected text (bright gray)
cursor_bg: rgb(0x40, 0x40, 0x40), // cursor line (gray)
cursor_fg: rgb(0xE0, 0xE0, 0xE0), // cursor text (bright gray)
marked_bg: rgb(0x1E, 0x1E, 0x1E), // marked bg = panel bg
marked_fg: rgb(0xCC, 0xCC, 0x00), // marked text (dimmed yellow)
directory: rgb(0x58, 0x80, 0x80), // directories (desaturated cyan)
executable: rgb(0x58, 0x80, 0x58), // executables (desaturated green)
symlink: rgb(0x80, 0x60, 0x80), // symlinks (desaturated magenta)
device: rgb(0x80, 0x60, 0x60), // devices (desaturated red)
hidden: rgb(0x55, 0x55, 0x55), // hidden (dark gray)
accent: rgb(0xCC, 0xCC, 0x00),
status_bg: rgb(0x12, 0x12, 0x12), // status bar (near-black)
status_fg: rgb(0xB0, 0xB0, 0xB0), // status text (dimmed)
buttonbar_bg: rgb(0x12, 0x12, 0x12), // button bar (near-black)
buttonbar_fg: rgb(0x90, 0x90, 0x90), // button text (gray)
title_bg: rgb(0x2A, 0x2A, 0x2A), // title bar (dark gray)
title_fg: rgb(0xC0, 0xC0, 0xC0), // title text (light gray)
border: rgb(0x33, 0x33, 0x33), // borders (dark gray)
error: rgb(0xCC, 0x50, 0x50), // errors (dimmed red)
warning: rgb(0xCC, 0xCC, 0x00), // warnings (dimmed yellow)
info: rgb(0x50, 0x80, 0x80), // info (dimmed cyan)
};
/// High-contrast theme — maximum WCAG contrast (21:1) for
/// accessibility and outdoor / bright-environment readability.
///
/// Pure black background, pure white text, inverted selection
/// (white on black via cursor slots). The only color used is
/// bright yellow for the directory slot — directories are the
/// primary navigational element and benefit from a visual
/// highlight.
pub const HIGH_CONTRAST_THEME: Theme = Theme {
name: "high-contrast",
background: rgb(0x00, 0x00, 0x00),
foreground: rgb(0xFF, 0xFF, 0xFF),
selection_bg: rgb(0xFF, 0xFF, 0xFF),
selection_fg: rgb(0x00, 0x00, 0x00),
cursor_bg: rgb(0xFF, 0xFF, 0xFF),
cursor_fg: rgb(0x00, 0x00, 0x00),
marked_bg: rgb(0xFF, 0xFF, 0x00),
marked_fg: rgb(0x00, 0x00, 0x00),
directory: rgb(0xFF, 0xFF, 0x00),
executable: rgb(0x80, 0xFF, 0x80),
symlink: rgb(0x80, 0xFF, 0xFF),
device: rgb(0xFF, 0x80, 0x80),
hidden: rgb(0xC0, 0xC0, 0xC0),
accent: rgb(0xFF, 0xFF, 0x00),
status_bg: rgb(0x00, 0x00, 0x00),
status_fg: rgb(0xFF, 0xFF, 0xFF),
buttonbar_bg: rgb(0x00, 0x00, 0x00),
buttonbar_fg: rgb(0xFF, 0xFF, 0xFF),
title_bg: rgb(0x00, 0x00, 0x00),
title_fg: rgb(0xFF, 0xFF, 0x00),
border: rgb(0xFF, 0xFF, 0xFF),
error: rgb(0xFF, 0x40, 0x40),
warning: rgb(0xFF, 0xFF, 0x00),
info: rgb(0x80, 0xFF, 0xFF),
};
/// Solarized Dark — Ethan Schoonover's canonical Solarized palette
/// (base03 background, base0 text, blue/violet/cyan accents).
/// See <https://ethanschoonover.com/solarized/> for the reference
/// values.
pub const SOLARIZED_DARK_THEME: Theme = Theme {
name: "solarized-dark",
background: rgb(0x00, 0x2B, 0x36), // base03
foreground: rgb(0x83, 0x94, 0x96), // base0
selection_bg: rgb(0x07, 0x36, 0x42), // base02
selection_fg: rgb(0x93, 0xA1, 0xA1), // base1
cursor_bg: rgb(0x26, 0x8B, 0xD2), // blue
cursor_fg: rgb(0xF8, 0xF8, 0xF8), // ~base3 inverted
marked_bg: rgb(0x58, 0x6E, 0x75), // base01
marked_fg: rgb(0xB5, 0x89, 0x00), // yellow
directory: rgb(0x26, 0x8B, 0xD2), // blue
executable: rgb(0x85, 0x99, 0x00), // green
symlink: rgb(0x6C, 0x71, 0xC4), // violet
device: rgb(0xDC, 0x32, 0x2F), // red
hidden: rgb(0x58, 0x6E, 0x75), // base01
accent: rgb(0xB5, 0x89, 0x00),
status_bg: rgb(0x00, 0x2B, 0x36), // base03
status_fg: rgb(0x93, 0xA1, 0xA1), // base1
buttonbar_bg: rgb(0x07, 0x36, 0x42), // base02
buttonbar_fg: rgb(0x93, 0xA1, 0xA1), // base1
title_bg: rgb(0x00, 0x2B, 0x36), // base03
title_fg: rgb(0x2A, 0xA1, 0x98), // cyan (accent for titles)
border: rgb(0x58, 0x6E, 0x75), // base01
error: rgb(0xDC, 0x32, 0x2F), // red
warning: rgb(0xB5, 0x89, 0x00), // yellow
info: rgb(0x2A, 0xA1, 0x98), // cyan
};
/// Nord — the popular arctic, north-bluish palette by Arctic Ice
/// Studio. Values are from the official Nord reference at
/// <https://www.nordtheme.com/docs/colors-and-palettes>.
pub const NORD_THEME: Theme = Theme {
name: "nord",
background: rgb(0x2E, 0x34, 0x40), // nord0
foreground: rgb(0xD8, 0xDE, 0xE9), // nord4
selection_bg: rgb(0x3B, 0x42, 0x52), // nord1
selection_fg: rgb(0xE5, 0xE9, 0xF0), // nord6
cursor_bg: rgb(0x88, 0xC0, 0xD0), // nord8
cursor_fg: rgb(0x2E, 0x34, 0x40), // nord0
marked_bg: rgb(0x43, 0x4C, 0x5E), // nord2
marked_fg: rgb(0xEC, 0xEF, 0xF4), // nord6 lighter
directory: rgb(0x88, 0xC0, 0xD0), // nord8 (frost green-blue)
executable: rgb(0xA3, 0xBE, 0x8C), // nord14
symlink: rgb(0x81, 0xA1, 0xC1), // nord9
device: rgb(0xBF, 0x61, 0x6A), // nord11
hidden: rgb(0x4C, 0x56, 0x6A), // nord3
accent: rgb(0xA3, 0xBE, 0x8C),
status_bg: rgb(0x2E, 0x34, 0x40), // nord0
status_fg: rgb(0xD8, 0xDE, 0xE9), // nord4
buttonbar_bg: rgb(0x3B, 0x42, 0x52), // nord1
buttonbar_fg: rgb(0xE5, 0xE9, 0xF0), // nord6
title_bg: rgb(0x2E, 0x34, 0x40), // nord0
title_fg: rgb(0x8F, 0xBC, 0xBB), // nord7 (frost teal)
border: rgb(0x4C, 0x56, 0x6A), // nord3
error: rgb(0xBF, 0x61, 0x6A), // nord11
warning: rgb(0xEB, 0xCB, 0x8B), // nord13
info: rgb(0x5E, 0x81, 0xAC), // nord10
};
/// A named color theme.
#[derive(Debug, Clone, Copy)]
@@ -356,21 +143,13 @@ pub struct Theme {
pub warning: Color,
/// Color for informational messages.
pub info: Color,
/// Drop-shadow colour for popups and dialogs. Should be darker
/// than any panel background. Configurable per skin via the
/// `[palette] shadow = "#xxxxxx"` TOML slot.
pub shadow: Color,
}
impl Theme {
/// Drop-shadow colour for popups and dialogs.
///
/// Always black or near-black — the shadow should be darker than
/// any panel background.
#[must_use]
pub fn shadow(&self) -> Color {
match self.background {
Color::Rgb(r, g, b) if r + g + b < 100 => Color::Rgb(0, 0, 0),
_ => Color::Rgb(0, 0, 0),
}
}
/// Background colour for the editor's current-line highlight.
///
/// Derived by lightening the panel background by ~8 %.
@@ -410,7 +189,7 @@ impl Theme {
/// Bottom-edge shadow colour for 3-D buttons (the `▁` row).
#[must_use]
pub fn button_shadow(&self) -> Color {
self.shadow()
self.shadow
}
/// Spinner foreground colour.
@@ -446,18 +225,22 @@ impl Theme {
/// [`DEFAULT_THEME`] is used as the fallback.
#[must_use]
pub fn by_name(name: &str) -> Theme {
match name {
"default-light" | "light" => return LIGHT_THEME,
"mc-classic" => return super::mc_skin::theme_by_name("default").unwrap_or(MC_CLASSIC_THEME),
"mc-dark" => return super::mc_skin::theme_by_name("dark").unwrap_or(MC_DARK_THEME),
"mc-dark-gray" => return super::mc_skin::theme_by_name("nicedark").unwrap_or(MC_DARK_GRAY_THEME),
"high-contrast" => return HIGH_CONTRAST_THEME,
"solarized-dark" => return SOLARIZED_DARK_THEME,
"nord" => return NORD_THEME,
"default-dark" | "" => return DEFAULT_THEME,
_ => {}
}
if let Some(theme) = super::mc_skin::theme_by_name(name) {
// Empty or legacy alias names ("default-dark", "default-light",
// "high-contrast", "solarized-dark", "nord", "mc-*") — all
// resolve to a real MC .ini skin. The aliases are kept so
// existing user configs don't break on upgrade.
let normalized = match name {
"" | "default-dark" => "julia256",
"default-light" | "light" => "sand256",
"high-contrast" => "mc-classic",
"solarized-dark" => "seasons-summer16M",
"nord" => "nicedark",
"mc-classic" => "default",
"mc-dark" => "dark",
"mc-dark-gray" => "nicedark",
other => other,
};
if let Some(theme) = super::mc_skin::theme_by_name(normalized) {
return theme;
}
{
@@ -465,12 +248,12 @@ impl Theme {
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(entry) = guard.as_ref() {
if entry.name == name {
if entry.name == normalized {
return *entry;
}
}
}
let skin = crate::skin::Skin::load_named(name);
let skin = crate::skin::Skin::load_named(normalized);
let theme = skin.to_theme();
{
let mut guard = USER_SKIN_CACHE
@@ -514,58 +297,19 @@ pub struct SkinEntry {
/// All built-in skin presets, in the order they should appear in
/// the skin selection dialog.
///
/// The list includes the two TLC defaults first, followed by the real
/// bundled Midnight Commander skins from `misc/skins/*.ini`.
/// The list is exclusively the bundled Midnight Commander skins
/// from `misc/skins/*.ini`. TLC has no hand-written theme presets —
/// every theme comes from an MC `.ini` file.
#[must_use]
pub fn builtin_skins() -> Vec<SkinEntry> {
let mut skins = vec![
SkinEntry {
name: "default-dark".into(),
description: "Red Bear Dark (default)".into(),
super::mc_skin::builtin_entries()
.into_iter()
.map(|(name, description)| SkinEntry {
name,
description,
is_user: false,
},
SkinEntry {
name: "default-light".into(),
description: "Red Bear Light".into(),
is_user: false,
},
SkinEntry {
name: "mc-classic".into(),
description: "Legacy alias for MC default".into(),
is_user: false,
},
SkinEntry {
name: "mc-dark".into(),
description: "Legacy alias for MC dark".into(),
is_user: false,
},
SkinEntry {
name: "mc-dark-gray".into(),
description: "Legacy alias for MC nicedark".into(),
is_user: false,
},
SkinEntry {
name: "high-contrast".into(),
description: "High Contrast".into(),
is_user: false,
},
SkinEntry {
name: "solarized-dark".into(),
description: "Solarized Dark".into(),
is_user: false,
},
SkinEntry {
name: "nord".into(),
description: "Nord".into(),
is_user: false,
},
];
skins.extend(super::mc_skin::builtin_entries().into_iter().map(|(name, description)| SkinEntry {
name,
description,
is_user: false,
}));
skins
})
.collect()
}
/// Scan `~/.config/tlc/skin/*.toml` for user skin files and return
@@ -645,186 +389,83 @@ mod tests {
use super::*;
#[test]
fn default_theme_has_unique_colors() {
let t = &DEFAULT_THEME;
assert_eq!(t.name, "default-dark");
// Sanity: bg and fg differ.
assert_ne!(format!("{:?}", t.background), format!("{:?}", t.foreground));
}
#[test]
fn default_theme_uses_brand_red_for_title() {
// tlc's `title_fg` is sourced from the Red Bear brand red
// (`title_accent` = #C8323C) via the shared palette.
let Color::Rgb(r, g, b) = DEFAULT_THEME.title_fg else {
panic!("title_fg should be Rgb in truecolor theme, got {:?}", DEFAULT_THEME.title_fg);
};
assert_eq!((r, g, b), (0xC8, 0x32, 0x3C));
}
#[test]
fn default_theme_uses_brand_red_in_selection() {
// Selection fg must be readable on the selection bg; the
// shared palette has text (#F4F4F4) on selection_bg
// (#3652A0) at 6.65:1 — well above AA-body.
let Color::Rgb(fg_r, fg_g, fg_b) = DEFAULT_THEME.selection_fg else {
panic!();
};
let Color::Rgb(bg_r, bg_g, bg_b) = DEFAULT_THEME.selection_bg else {
panic!();
};
assert_eq!((fg_r, fg_g, fg_b), (0xF4, 0xF4, 0xF4));
assert_eq!((bg_r, bg_g, bg_b), (0x36, 0x52, 0xA0));
}
#[test]
fn theme_lookup_falls_back() {
let t = Theme::by_name("nope");
assert_eq!(t.name, "default-dark");
}
#[test]
fn new_builtin_skins_have_distinct_names() {
// The 4 new const Themes must each carry a non-empty,
// distinct name. They are returned by `by_name` only when
// the user asks for the exact name, so two presets with
// the same name would shadow each other.
let names = [
MC_CLASSIC_THEME.name,
HIGH_CONTRAST_THEME.name,
SOLARIZED_DARK_THEME.name,
NORD_THEME.name,
];
for n in names {
assert!(!n.is_empty(), "new built-in theme has empty name");
fn default_theme_comes_from_julia256_skin() {
let t = &*DEFAULT_THEME;
assert_eq!(t.name, "julia256");
// MC julia256 uses terminal-256 color codes in its .ini:
// core._default_ bg = color237 = #3a3a3a.
match t.background {
Color::Rgb(0x3A, 0x3A, 0x3A) | Color::Indexed(237) => {}
other => panic!("julia256 bg should be Rgb(0x3a,0x3a,0x3a) or Indexed(237), got {other:?}"),
}
for (i, a) in names.iter().enumerate() {
for b in &names[i + 1..] {
assert_ne!(a, b, "duplicate name across new built-ins: {a}");
}
// core.shadow = gray;black → TLC reads bg (second field) = black
// which is Color::Indexed(0) in terminal-256 mode.
match t.shadow {
Color::Rgb(0x17, 0x1D, 0x25) | Color::Indexed(0) => {}
other => panic!("julia256 shadow should be MC's `black` (Indexed 0) or hex #171d25, got {other:?}"),
}
}
#[test]
fn new_builtin_skins_have_distinct_bg_colors() {
// Each new preset should have a recognizably different
// background color from the others. If two presets share
// a background the user has no way to tell them apart at
// a glance.
let bgs = [
(MC_CLASSIC_THEME.name, MC_CLASSIC_THEME.background),
(HIGH_CONTRAST_THEME.name, HIGH_CONTRAST_THEME.background),
(SOLARIZED_DARK_THEME.name, SOLARIZED_DARK_THEME.background),
(NORD_THEME.name, NORD_THEME.background),
];
for (i, (a_name, a_bg)) in bgs.iter().enumerate() {
for (b_name, b_bg) in &bgs[i + 1..] {
assert_ne!(
format!("{a_bg:?}"),
format!("{b_bg:?}"),
"preset {a_name} and {b_name} share the same background"
);
}
}
fn by_name_resolves_bundled_mc_skin() {
let t = Theme::by_name("julia256");
assert_eq!(t.name, "julia256");
}
#[test]
fn by_name_mc_classic_returns_preset() {
let t = Theme::by_name("mc-classic");
assert_eq!(t.name, "default");
assert_eq!(t.background, Color::Indexed(4));
assert_eq!(t.foreground, Color::Indexed(7));
fn by_name_resolves_dark_skin() {
let t = Theme::by_name("dark");
assert_eq!(t.name, "dark");
}
#[test]
fn by_name_high_contrast_returns_preset() {
let t = Theme::by_name("high-contrast");
assert_eq!(t.name, "high-contrast");
assert_eq!(t.background, HIGH_CONTRAST_THEME.background);
assert_eq!(t.foreground, HIGH_CONTRAST_THEME.foreground);
fn by_name_resolves_nicedark_skin() {
let t = Theme::by_name("nicedark");
assert_eq!(t.name, "nicedark");
}
#[test]
fn by_name_solarized_dark_returns_preset() {
let t = Theme::by_name("solarized-dark");
assert_eq!(t.name, "solarized-dark");
// The Solarized Dark base03 background is #002B36.
let Color::Rgb(r, g, b) = t.background else {
panic!("solarized bg must be Rgb, got {:?}", t.background);
};
assert_eq!((r, g, b), (0x00, 0x2B, 0x36));
fn by_name_resolves_sand256_skin() {
let t = Theme::by_name("sand256");
assert_eq!(t.name, "sand256");
}
#[test]
fn by_name_nord_returns_preset() {
let t = Theme::by_name("nord");
assert_eq!(t.name, "nord");
// The Nord nord0 background is #2E3440.
let Color::Rgb(r, g, b) = t.background else {
panic!("nord bg must be Rgb, got {:?}", t.background);
};
assert_eq!((r, g, b), (0x2E, 0x34, 0x40));
fn by_name_unknown_returns_default() {
let t = Theme::by_name("no-such-skin-xyz");
assert_eq!(t.name, "julia256");
}
#[test]
fn by_name_preserves_existing_aliases() {
// TLC defaults keep their long-standing explicit names,
// while exact MC names remain free for the real mc skins.
assert_eq!(Theme::by_name("").name, "default-dark");
assert_eq!(Theme::by_name("default-dark").name, "default-dark");
assert_eq!(Theme::by_name("default-light").name, "default-light");
assert_eq!(Theme::by_name("light").name, "default-light");
assert_eq!(Theme::by_name("default").name, "default");
assert_eq!(Theme::by_name("dark").name, "dark");
fn light_theme_comes_from_sand256() {
let t = &*LIGHT_THEME;
assert_eq!(t.name, "sand256");
}
#[test]
fn builtin_skins_lists_real_mc_catalogue() {
fn builtin_skins_is_mc_catalogue_only() {
let skins = builtin_skins();
let names: Vec<&str> = skins.iter().map(|s| s.name.as_str()).collect();
for required in [
"default-dark",
"default-light",
"default",
"julia256",
"dark",
"nicedark",
"sand256",
"default",
"seasons-spring16M",
"modarin256",
] {
assert!(names.contains(&required), "missing {required}; got {names:?}");
}
for name in &names {
assert!(
names.contains(&required),
"builtin_skins() missing {required}; got {names:?}"
!name.starts_with("mc-"),
"mc-* aliases should not exist; got {name}"
);
assert!(
!name.starts_with("high-contrast") && !name.starts_with("solarized"),
"non-MC preset leaked into builtin_skins: {name}"
);
}
for s in &skins {
assert!(!s.is_user, "builtin {} should be is_user=false", s.name);
assert!(!s.description.is_empty());
}
}
#[test]
fn user_skins_empty_when_no_skin_dir() {
// The default skin dir path may or may not exist; the
// function must not panic either way and must return a
// Vec (possibly empty).
let skins = user_skins();
for s in &skins {
assert!(s.is_user, "user_skins() entry {} is_user=false", s.name);
assert!(!s.name.is_empty());
}
}
#[test]
fn all_skins_includes_builtin_and_user() {
let all = all_skins();
let builtins = builtin_skins();
// Built-ins come first.
for (i, b) in builtins.iter().enumerate() {
assert_eq!(all[i].name, b.name, "builtins must be a prefix");
}
// The list is at least as long as the builtins.
assert!(all.len() >= builtins.len());
}
#[test]
@@ -838,8 +479,6 @@ mod tests {
fn find_skin_index_missing_falls_back_to_default() {
let skins = builtin_skins();
let i = find_skin_index(&skins, "no-such-skin");
// The default fallback must be the first entry
// (default-dark per the builtin_skins order).
assert_eq!(i, 0);
}
}
@@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet};
use ratatui::style::Color;
use super::color::{Theme, DEFAULT_THEME};
use super::color::Theme;
struct McSkinSource {
name: &'static str,
@@ -126,10 +126,14 @@ fn parse_theme(name: &'static str, text: &str) -> Theme {
let core_header = resolve_style(&parsed, "core", "header");
let core_disabled = resolve_style(&parsed, "core", "disabled");
let core_frame = resolve_style(&parsed, "core", "frame");
let core_shadow = resolve_style(&parsed, "core", "shadow");
let status = resolve_style(&parsed, "statusbar", "_default_");
let button = resolve_style(&parsed, "buttonbar", "button");
let button_hotkey = resolve_style(&parsed, "buttonbar", "hotkey");
let error = resolve_style(&parsed, "error", "_default_");
let error_title = resolve_style(&parsed, "error", "errdtitle");
let dialog_default = resolve_style(&parsed, "dialog", "_default_");
let dialog_focus = resolve_style(&parsed, "dialog", "dfocus");
let file_directory = resolve_style(&parsed, "filehighlight", "directory");
let file_executable = resolve_style(&parsed, "filehighlight", "executable");
let file_symlink = resolve_style(&parsed, "filehighlight", "symlink");
@@ -137,63 +141,73 @@ fn parse_theme(name: &'static str, text: &str) -> Theme {
Theme {
name,
background: core_default.bg.unwrap_or(DEFAULT_THEME.background),
foreground: core_default.fg.unwrap_or(DEFAULT_THEME.foreground),
background: core_default.bg.unwrap_or(Color::Reset),
foreground: core_default.fg.unwrap_or(Color::Reset),
selection_bg: core_reverse
.bg
.or(core_selected.bg)
.unwrap_or(DEFAULT_THEME.selection_bg),
.unwrap_or(Color::Reset),
selection_fg: core_reverse
.fg
.or(core_selected.fg)
.unwrap_or(DEFAULT_THEME.selection_fg),
cursor_bg: core_selected.bg.unwrap_or(DEFAULT_THEME.cursor_bg),
cursor_fg: core_selected.fg.unwrap_or(DEFAULT_THEME.cursor_fg),
.unwrap_or(Color::Reset),
cursor_bg: core_selected
.bg
.or(core_default.bg)
.unwrap_or(Color::Reset),
cursor_fg: core_selected
.fg
.or(core_default.fg)
.unwrap_or(Color::Reset),
marked_bg: core_marked
.bg
.or(core_default.bg)
.unwrap_or(DEFAULT_THEME.marked_bg),
marked_fg: core_marked.fg.unwrap_or(DEFAULT_THEME.marked_fg),
.unwrap_or(Color::Reset),
marked_fg: core_marked.fg.unwrap_or(Color::Reset),
directory: file_directory
.fg
.or(core_default.fg)
.unwrap_or(DEFAULT_THEME.directory),
.unwrap_or(Color::Reset),
executable: file_executable
.fg
.or(core_default.fg)
.unwrap_or(DEFAULT_THEME.executable),
.unwrap_or(Color::Reset),
symlink: file_symlink
.fg
.or(core_default.fg)
.unwrap_or(DEFAULT_THEME.symlink),
.unwrap_or(Color::Reset),
device: file_device
.fg
.or(core_default.fg)
.unwrap_or(DEFAULT_THEME.device),
.unwrap_or(Color::Reset),
hidden: core_disabled
.fg
.or(core_default.fg)
.unwrap_or(DEFAULT_THEME.hidden),
accent: DEFAULT_THEME.accent,
status_bg: status.bg.unwrap_or(DEFAULT_THEME.status_bg),
status_fg: status.fg.unwrap_or(DEFAULT_THEME.status_fg),
buttonbar_bg: button.bg.unwrap_or(DEFAULT_THEME.buttonbar_bg),
buttonbar_fg: button.fg.unwrap_or(DEFAULT_THEME.buttonbar_fg),
.unwrap_or(Color::Reset),
accent: core_header.fg.unwrap_or(Color::Reset),
status_bg: status.bg.unwrap_or(Color::Reset),
status_fg: status.fg.unwrap_or(Color::Reset),
buttonbar_bg: button.bg.unwrap_or(Color::Reset),
buttonbar_fg: button.fg.unwrap_or(Color::Reset),
title_bg: core_header
.bg
.or(core_default.bg)
.unwrap_or(DEFAULT_THEME.title_bg),
title_fg: core_header.fg.unwrap_or(DEFAULT_THEME.title_fg),
.unwrap_or(Color::Reset),
title_fg: core_header.fg.unwrap_or(Color::Reset),
border: core_frame
.fg
.or(core_default.fg)
.unwrap_or(DEFAULT_THEME.border),
error: error.fg.unwrap_or(DEFAULT_THEME.error),
.unwrap_or(Color::Reset),
error: error.fg.unwrap_or(Color::Reset),
warning: error_title
.fg
.or(core_marked.fg)
.unwrap_or(DEFAULT_THEME.warning),
info: core_header.fg.unwrap_or(DEFAULT_THEME.info),
.unwrap_or(Color::Reset),
info: dialog_focus
.bg
.or(core_header.bg)
.unwrap_or(Color::Reset),
shadow: core_shadow.bg.unwrap_or(Color::Reset),
}
}
@@ -44,20 +44,48 @@ pub fn mc_copy_move_rect(area: Rect, height: u16) -> Rect {
/// Render a drop shadow one cell below-right of `area`.
///
/// Fills the offset rectangle with the dark shade character (`▓`)
/// so the popup appears to "float" above the underlying surface.
/// Must be called **before** [`render_popup`] so the shadow is
/// painted underneath the `Clear` widget.
/// Matches Midnight Commander's `tty_draw_box_shadow` exactly:
/// two `tty_colorize_area` calls that recolor existing glyphs with
/// the shadow background. No glyphs are written — the shadow is
/// purely a background-color layer that sits on top of whatever was
/// already on screen (file list, status bar, etc.).
///
/// * Right strip: `(rows-1) × 2` starting at `(y+1, x+cols)`.
/// * Bottom strip: `1 × cols` starting at `(y+rows, x+2)`.
///
/// The right strip starts 1 row down (so the top-right corner of the
/// dialog stays crisp) and is 2 cols wide. The bottom strip starts
/// 2 cols in so the two strips meet flush in the bottom-right corner.
fn render_drop_shadow(buf: &mut Buffer, area: Rect, shadow_color: ratatui::style::Color) {
let style = Style::default().fg(shadow_color);
for y in (area.y + 1)..area.bottom() {
if let Some(cell) = buf.cell_mut((area.right(), y)) {
cell.set_char('▓').set_style(style);
if area.width < 1 || area.height < 1 {
return;
}
let y = area.y;
let x = area.x;
let rows = area.height as usize;
let cols = area.width as usize;
let right_cols = 2.min(buf.area().width.saturating_sub(x as u16 + area.width) as usize);
if right_cols == 0 && y + area.height >= buf.area().height {
return;
}
for row in 0..rows.saturating_sub(1) {
let yy = y as usize + 1 + row;
for col in 0..right_cols {
let xx = x as usize + cols as usize + col;
if let Some(cell) = buf.cell_mut((xx as u16, yy as u16)) {
cell.set_bg(shadow_color);
}
}
}
for x in area.x..=area.right() {
if let Some(cell) = buf.cell_mut((x, area.bottom())) {
cell.set_char('▓').set_style(style);
let bottom_y = y as usize + rows;
if bottom_y < buf.area().height as usize {
let available_cols = buf.area().width.saturating_sub(x as u16 + 2) as usize;
let strip_cols = cols.min(available_cols);
for col in 0..strip_cols {
let xx = x as usize + 2 + col;
if let Some(cell) = buf.cell_mut((xx as u16, bottom_y as u16)) {
cell.set_bg(shadow_color);
}
}
}
}
@@ -93,7 +121,7 @@ pub fn render_popup(frame: &mut Frame, area: Rect, title: impl Into<String>, the
let title_fg = dialog_title.map(|p| p.fg).unwrap_or(theme.title_fg);
let title_bg = dialog_title.map(|p| p.bg).unwrap_or(theme.title_bg);
render_drop_shadow(frame.buffer_mut(), area, theme.shadow());
render_drop_shadow(frame.buffer_mut(), area, theme.shadow);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)