use crate::geometry::Insets;
use crate::style::{Color, Font, Rgba, Weight};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OsFamily {
MacOs,
Windows,
Gnome,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ThemeMode {
#[default]
Auto,
Light,
Dark,
}
impl ThemeMode {
fn is_dark(self, system_is_dark: bool) -> bool {
match self {
ThemeMode::Auto => system_is_dark,
ThemeMode::Light => false,
ThemeMode::Dark => true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Preset {
OldSchoolTerminal,
HighContrast,
Solarized,
Nord,
}
impl Preset {
pub fn theme(self) -> Theme {
match self {
Preset::OldSchoolTerminal => Theme {
background: Color::rgb(0, 0, 0),
label: Color::rgb(51, 255, 51),
secondary_label: Color::rgb(0, 170, 0),
accent: Color::rgb(0, 200, 0),
separator: Color::rgb(0, 90, 0),
row_highlight: Color::Accent,
row_font: Font::mono(13.0, Weight::Regular),
header_font: Font::mono(13.0, Weight::Bold),
row_height: 22.0,
corner_radius: 0.0,
padding: Insets::symmetric(6.0, 4.0),
column_gap: 10.0,
},
Preset::HighContrast => Theme {
background: Color::rgb(0, 0, 0),
label: Color::rgb(255, 255, 255),
secondary_label: Color::rgb(220, 220, 220),
accent: Color::rgb(255, 255, 0),
separator: Color::rgb(255, 255, 255),
row_highlight: Color::Accent,
row_font: Font::system(13.0, Weight::Regular),
header_font: Font::system(13.0, Weight::Bold),
row_height: 24.0,
corner_radius: 0.0,
padding: Insets::symmetric(8.0, 5.0),
column_gap: 12.0,
},
Preset::Solarized => Theme {
background: Color::rgb(0, 43, 54), label: Color::rgb(147, 161, 161), secondary_label: Color::rgb(88, 110, 117), accent: Color::rgb(38, 139, 210), separator: Color::rgb(7, 54, 66), row_highlight: Color::Accent,
row_font: Font::system(13.0, Weight::Regular),
header_font: Font::system(13.0, Weight::Bold),
row_height: 24.0,
corner_radius: 6.0,
padding: Insets::symmetric(8.0, 5.0),
column_gap: 12.0,
},
Preset::Nord => Theme {
background: Color::rgb(46, 52, 64), label: Color::rgb(216, 222, 233), secondary_label: Color::rgb(143, 153, 172), accent: Color::rgb(136, 192, 208), separator: Color::rgb(59, 66, 82), row_highlight: Color::Accent,
row_font: Font::system(13.0, Weight::Regular),
header_font: Font::system(13.0, Weight::Bold),
row_height: 24.0,
corner_radius: 8.0,
padding: Insets::symmetric(8.0, 5.0),
column_gap: 12.0,
},
}
}
}
#[derive(Clone, Debug)]
pub enum ThemeSource {
System(ThemeMode),
MacOs(ThemeMode),
Windows(ThemeMode),
Gnome(ThemeMode),
Preset(Preset),
Custom(Box<Theme>),
}
impl Default for ThemeSource {
fn default() -> Self {
ThemeSource::System(ThemeMode::Auto)
}
}
impl ThemeSource {
pub fn injects_system(&self) -> bool {
matches!(self, ThemeSource::System(_))
}
pub fn resolve(&self, host: OsFamily, system_is_dark: bool) -> Theme {
match self {
ThemeSource::System(mode) => Theme::for_family(host, mode.is_dark(system_is_dark)),
ThemeSource::MacOs(mode) => {
Theme::for_family(OsFamily::MacOs, mode.is_dark(system_is_dark))
}
ThemeSource::Windows(mode) => {
Theme::for_family(OsFamily::Windows, mode.is_dark(system_is_dark))
}
ThemeSource::Gnome(mode) => {
Theme::for_family(OsFamily::Gnome, mode.is_dark(system_is_dark))
}
ThemeSource::Preset(p) => p.theme(),
ThemeSource::Custom(theme) => (**theme).clone(),
}
}
}
#[derive(Clone, Debug)]
pub struct Theme {
pub background: Color,
pub label: Color,
pub secondary_label: Color,
pub accent: Color,
pub separator: Color,
pub row_highlight: Color,
pub row_font: Font,
pub header_font: Font,
pub row_height: f32,
pub corner_radius: f32,
pub padding: Insets,
pub column_gap: f32,
}
impl Default for Theme {
fn default() -> Self {
Theme::light()
}
}
const ACCENT_FALLBACK: Rgba = Rgba::opaque(0, 122, 255);
impl Theme {
pub fn light() -> Self {
Theme {
background: Color::rgb(246, 246, 246),
label: Color::rgb(0, 0, 0),
secondary_label: Color::rgb(140, 140, 140),
accent: Color::Accent,
separator: Color::rgb(210, 210, 210),
row_highlight: Color::Accent,
row_font: Font::system(13.0, Weight::Regular),
header_font: Font::system(13.0, Weight::Bold),
row_height: 22.0,
corner_radius: 8.0,
padding: Insets::symmetric(6.0, 5.0),
column_gap: 10.0,
}
}
pub fn dark() -> Self {
Theme {
background: Color::rgb(40, 40, 40),
label: Color::rgb(255, 255, 255),
secondary_label: Color::rgb(150, 150, 150),
separator: Color::rgb(70, 70, 70),
..Theme::light()
}
}
pub fn native() -> Self {
Theme {
background: Color::Rgba(246, 246, 246, 209),
..Theme::light()
}
}
pub fn native_dark() -> Self {
Theme {
background: Color::Rgba(40, 40, 40, 204),
..Theme::dark()
}
}
pub fn macos(dark: bool) -> Theme {
let base = if dark { Theme::dark() } else { Theme::light() };
Theme {
background: if dark {
Color::Rgba(40, 40, 40, 204)
} else {
Color::Rgba(246, 246, 246, 209)
},
corner_radius: 6.0,
row_height: 22.0,
padding: Insets::symmetric(6.0, 5.0),
column_gap: 10.0,
row_font: Font::system(13.0, Weight::Regular),
header_font: Font::system(13.0, Weight::Bold),
..base
}
}
pub fn windows(dark: bool) -> Theme {
let base = if dark { Theme::dark() } else { Theme::light() };
Theme {
background: if dark {
Color::Rgba(43, 43, 43, 214)
} else {
Color::Rgba(249, 249, 249, 214)
},
corner_radius: 8.0,
row_height: 28.0,
padding: Insets::symmetric(6.0, 4.0),
column_gap: 12.0,
row_font: Font::system(14.0, Weight::Regular),
header_font: Font::system(14.0, Weight::Bold),
..base
}
}
pub fn gnome(dark: bool) -> Theme {
if dark {
Theme {
background: Color::rgb(48, 48, 48),
label: Color::rgb(255, 255, 255),
secondary_label: Color::rgb(154, 153, 150),
separator: Color::rgb(61, 61, 61),
accent: Color::Accent,
row_highlight: Color::Accent,
row_font: Font::system(11.0, Weight::Regular),
header_font: Font::system(11.0, Weight::Bold),
row_height: 30.0,
corner_radius: 12.0,
padding: Insets::symmetric(6.0, 6.0),
column_gap: 12.0,
}
} else {
Theme {
background: Color::rgb(250, 250, 250),
label: Color::rgb(0, 0, 0),
secondary_label: Color::rgb(119, 118, 123),
separator: Color::rgb(226, 226, 226),
accent: Color::Accent,
row_highlight: Color::Accent,
row_font: Font::system(11.0, Weight::Regular),
header_font: Font::system(11.0, Weight::Bold),
row_height: 30.0,
corner_radius: 12.0,
padding: Insets::symmetric(6.0, 6.0),
column_gap: 12.0,
}
}
}
pub fn for_family(family: OsFamily, dark: bool) -> Theme {
match family {
OsFamily::MacOs => Theme::macos(dark),
OsFamily::Windows => Theme::windows(dark),
OsFamily::Gnome => Theme::gnome(dark),
}
}
pub fn make_opaque(&mut self) {
let (r, g, b) = match self.background {
Color::Rgba(r, g, b, _) => (r, g, b),
_ => return,
};
self.background = Color::Rgba(r, g, b, 255);
}
pub fn resolve(&self, color: Color) -> Rgba {
match color {
Color::Rgba(r, g, b, a) => Rgba::new(r, g, b, a),
Color::Label => literal(self.label, Rgba::BLACK),
Color::SecondaryLabel => literal(self.secondary_label, Rgba::opaque(140, 140, 140)),
Color::Accent => literal(self.accent, ACCENT_FALLBACK),
Color::Separator => literal(self.separator, Rgba::opaque(210, 210, 210)),
Color::SystemRed => Rgba::opaque(255, 59, 48),
Color::SystemOrange => Rgba::opaque(255, 149, 0),
Color::SystemGreen => Rgba::opaque(52, 199, 89),
Color::SystemYellow => Rgba::opaque(255, 204, 0),
}
}
}
fn literal(color: Color, fallback: Rgba) -> Rgba {
match color {
Color::Rgba(r, g, b, a) => Rgba::new(r, g, b, a),
_ => fallback,
}
}
#[derive(Clone, Debug, Default)]
pub struct MenuOptions {
pub min_width: Option<f32>,
pub max_width: Option<f32>,
pub theme: ThemeSource,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literal_color_passes_through() {
let t = Theme::light();
assert_eq!(t.resolve(Color::Rgba(1, 2, 3, 4)), Rgba::new(1, 2, 3, 4));
assert_eq!(t.resolve(Color::rgb(10, 20, 30)), Rgba::opaque(10, 20, 30));
}
#[test]
fn label_resolves_per_theme() {
assert_eq!(Theme::light().resolve(Color::Label), Rgba::BLACK);
assert_eq!(Theme::dark().resolve(Color::Label), Rgba::WHITE);
}
#[test]
fn separator_differs_between_light_and_dark() {
let light = Theme::light().resolve(Color::Separator);
let dark = Theme::dark().resolve(Color::Separator);
assert_ne!(light, dark);
}
#[test]
fn per_os_themes_are_distinct() {
let mac = Theme::macos(false);
let win = Theme::windows(false);
let gnome = Theme::gnome(false);
assert_eq!(mac.corner_radius, 6.0);
assert_eq!(win.corner_radius, 8.0);
assert_eq!(gnome.corner_radius, 12.0);
assert!(win.row_height > mac.row_height);
assert!(gnome.row_height > mac.row_height);
assert!(matches!(mac.background, Color::Rgba(_, _, _, a) if a < 255));
assert!(matches!(win.background, Color::Rgba(_, _, _, a) if a < 255));
assert!(matches!(gnome.background, Color::Rgba(_, _, _, 255)));
assert_ne!(
Theme::gnome(true).resolve(Color::Label),
Theme::gnome(false).resolve(Color::Label)
);
assert_ne!(
Theme::macos(true).resolve(Color::Separator),
Theme::macos(false).resolve(Color::Separator)
);
}
#[test]
fn system_palette_applies_only_set_fields() {
use crate::platform::SystemPalette;
let mut theme = Theme::macos(false);
let base_bg = theme.background;
let pal = SystemPalette {
label: Some((10, 20, 30, 255)),
separator: Some((1, 2, 3, 40)),
..SystemPalette::default()
};
pal.apply_to(&mut theme);
assert_eq!(theme.resolve(Color::Label), Rgba::new(10, 20, 30, 255));
assert_eq!(theme.resolve(Color::Separator), Rgba::new(1, 2, 3, 40));
assert_eq!(theme.background, base_bg);
}
#[test]
fn system_colors_are_fixed_regardless_of_theme() {
assert_eq!(
Theme::light().resolve(Color::SystemRed),
Theme::dark().resolve(Color::SystemRed)
);
assert_eq!(
Theme::light().resolve(Color::SystemRed),
Rgba::opaque(255, 59, 48)
);
}
#[test]
fn accent_does_not_recurse_and_has_a_fallback() {
assert_eq!(Theme::light().resolve(Color::Accent), ACCENT_FALLBACK);
}
#[test]
fn custom_accent_is_honored() {
let mut theme = Theme::light();
theme.accent = Color::rgb(200, 0, 100);
assert_eq!(theme.resolve(Color::Accent), Rgba::opaque(200, 0, 100));
}
fn background_alpha(theme: &Theme) -> u8 {
match theme.background {
Color::Rgba(_, _, _, a) => a,
other => panic!("expected a literal Rgba background, got {other:?}"),
}
}
#[test]
fn light_and_dark_backgrounds_are_opaque() {
assert_eq!(background_alpha(&Theme::light()), 255);
assert_eq!(background_alpha(&Theme::dark()), 255);
}
#[test]
fn native_backgrounds_are_translucent() {
assert!(background_alpha(&Theme::native()) < 255);
assert!(background_alpha(&Theme::native_dark()) < 255);
}
#[test]
fn native_mirrors_light_and_dark_for_non_background_fields() {
let light = Theme::light();
let native = Theme::native();
assert_eq!(native.label, light.label);
assert_eq!(native.accent, light.accent);
assert_eq!(native.separator, light.separator);
assert_eq!(native.row_highlight, light.row_highlight);
assert_eq!(native.row_font, light.row_font);
assert_eq!(native.corner_radius, light.corner_radius);
let dark = Theme::dark();
let native_dark = Theme::native_dark();
assert_eq!(native_dark.label, dark.label);
assert_eq!(native_dark.separator, dark.separator);
}
#[test]
fn system_source_resolves_to_host_family_and_appearance() {
let dark = ThemeSource::System(ThemeMode::Auto).resolve(OsFamily::MacOs, true);
assert_eq!(dark.resolve(Color::Label), Rgba::WHITE);
assert!(matches!(dark.background, Color::Rgba(_, _, _, a) if a < 255));
let light = ThemeSource::System(ThemeMode::Auto).resolve(OsFamily::MacOs, false);
assert_eq!(light.resolve(Color::Label), Rgba::BLACK);
}
#[test]
fn theme_source_resolves() {
assert_eq!(
ThemeSource::System(ThemeMode::Auto)
.resolve(OsFamily::MacOs, true)
.resolve(Color::Label),
Rgba::WHITE
);
assert_eq!(
ThemeSource::System(ThemeMode::Auto)
.resolve(OsFamily::MacOs, false)
.resolve(Color::Label),
Rgba::BLACK
);
assert_eq!(
ThemeSource::System(ThemeMode::Dark)
.resolve(OsFamily::MacOs, false)
.resolve(Color::Label),
Rgba::WHITE
);
let win = ThemeSource::Windows(ThemeMode::Light).resolve(OsFamily::MacOs, true);
assert_eq!(win.corner_radius, 8.0);
assert!(!ThemeSource::Windows(ThemeMode::Light).injects_system());
assert!(ThemeSource::System(ThemeMode::Auto).injects_system());
}
#[test]
fn presets_render_distinct_skins() {
assert!(!ThemeSource::Preset(Preset::Nord).injects_system());
let term = Preset::OldSchoolTerminal.theme();
assert_eq!(term.resolve(Color::Label), Rgba::opaque(51, 255, 51));
assert!(matches!(term.background, Color::Rgba(0, 0, 0, 255)));
let nord = Preset::Nord.theme();
assert_ne!(nord.resolve(Color::Label), term.resolve(Color::Label));
}
}