diff --git a/local/recipes/tui/tlc/source/src/config.rs b/local/recipes/tui/tlc/source/src/config.rs index 2fe911b2af..69b338e7b6 100644 --- a/local/recipes/tui/tlc/source/src/config.rs +++ b/local/recipes/tui/tlc/source/src/config.rs @@ -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 diff --git a/local/recipes/tui/tlc/source/src/terminal/color.rs b/local/recipes/tui/tlc/source/src/terminal/color.rs index 5019ff14cf..b19ddae74b 100644 --- a/local/recipes/tui/tlc/source/src/terminal/color.rs +++ b/local/recipes/tui/tlc/source/src/terminal/color.rs @@ -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 = + 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 = + 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 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 -/// . -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 { - 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); } } diff --git a/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs b/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs index 33cd069423..6402952b79 100644 --- a/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs +++ b/local/recipes/tui/tlc/source/src/terminal/mc_skin.rs @@ -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), } } diff --git a/local/recipes/tui/tlc/source/src/terminal/popup.rs b/local/recipes/tui/tlc/source/src/terminal/popup.rs index 80364fd77a..1220494187 100644 --- a/local/recipes/tui/tlc/source/src/terminal/popup.rs +++ b/local/recipes/tui/tlc/source/src/terminal/popup.rs @@ -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, 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)