|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|