diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 6b75d19..524022a 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -5,10 +5,9 @@ use crate::data::{Loader, Source, Update}; use crate::nav::{Nav, Route, Section}; use crate::pages; use crate::receipts::Receipts; +use crate::schemes::{self, Filter, Scheme}; use crate::theme::{self, pal}; -use egui::{ - Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2, -}; +use egui::{Align, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2}; use egui_phosphor::regular as icon; use std::sync::Arc; @@ -42,13 +41,96 @@ impl Shell<'_> { } } +/// The height of a family's row in the color scheme menu, for laying the +/// families out in columns. +const FAMILY_ROW: f32 = 34.0; + +/// The width of a variant's row in a family's flyout: room for its colors +/// and the longest name. +const SCHEME_ROW_WIDTH: f32 = 200.0; + +/// Where the viewer keeps its color scheme ("Family/Variant"). +const SCHEME_KEY: &str = "scheme"; +/// Where it keeps its scheme menu's filter. +const FILTER_KEY: &str = "scheme_filter"; + +/// One variant in the color scheme menu: a strip of its colors and its +/// name, the whole row one button, selected when it is the one in force. +fn scheme_row(ui: &mut Ui, scheme: &Scheme, current: bool) -> egui::Response { + let p = pal(ui.ctx()); + let (rect, response) = ui.allocate_exact_size(vec2(SCHEME_ROW_WIDTH, 28.0), Sense::click()); + response.widget_info(|| { + egui::WidgetInfo::selected( + egui::WidgetType::SelectableLabel, + true, + current, + scheme.name, + ) + }); + if ui.is_rect_visible(rect) { + let painter = ui.painter(); + let fill = if current { + Some(p.selected) + } else if response.hovered() { + Some(p.hover) + } else { + None + }; + if let Some(fill) = fill { + painter.rect_filled(rect, CornerRadius::same(6), fill); + } + let strip = egui::Rect::from_min_size( + egui::pos2(rect.left() + 8.0, rect.center().y - 7.0), + vec2(50.0, 14.0), + ); + let w = strip.width() / 5.0; + for (i, c) in scheme.swatch().into_iter().enumerate() { + let r = egui::Rect::from_min_size( + strip.min + vec2(w * i as f32, 0.0), + vec2(w, strip.height()), + ); + painter.rect_filled(r, 0.0, c); + } + painter.rect_stroke( + strip, + 3.0, + Stroke::new(1.0, p.border), + egui::StrokeKind::Outside, + ); + painter.text( + egui::pos2(strip.right() + 10.0, rect.center().y), + egui::Align2::LEFT_CENTER, + scheme.name, + theme::regular(14.0), + p.text, + ); + } + response +} + +/// The scheme and menu filter kept from last time (`get` reads the +/// viewer's preferences): the scheme kept; before schemes, the light or +/// dark choice kept as "dark"; else the viewer's own for the system's +/// mode (schemes INVARIANT 4). +fn restored(get: impl Fn(&str) -> Option, system_dark: bool) -> (&'static Scheme, Filter) { + let dark = get("dark").map_or(system_dark, |v| v == "true"); + let scheme = get(SCHEME_KEY).map_or_else(|| schemes::own(dark), |k| Scheme::from_key(&k, dark)); + let filter = get(FILTER_KEY) + .and_then(|f| Filter::from_name(&f)) + .unwrap_or_default(); + (scheme, filter) +} + pub struct App { loader: Option, books: Option>, error: Option, nav: Nav, receipts: Receipts, - dark: bool, + /// The color scheme in force (schemes.rs). + scheme: &'static Scheme, + /// Which schemes its menu shows. + filter: Filter, state: PageState, palette: crate::palette::Palette, updated_at: f64, @@ -58,27 +140,29 @@ pub struct App { impl App { /// The live app over a books directory. pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self { - let dark = cc - .storage - .and_then(|s| s.get_string("dark")) - .map(|v| v == "true") - .unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode); - let mut app = Self::bare(&cc.egui_ctx, dark); + // The scheme kept from last time; before schemes, a light/dark + // choice; else the system's mode (schemes INVARIANT 4). + let (scheme, filter) = restored( + |key| cc.storage.and_then(|s| s.get_string(key)), + cc.egui_ctx.global_style().visuals.dark_mode, + ); + let mut app = Self::bare(&cc.egui_ctx, scheme); + app.filter = filter; app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone())); app } /// An app showing fixed books (tests and screenshots). pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self { - let mut app = Self::bare(ctx, dark); + let mut app = Self::bare(ctx, schemes::own(dark)); app.books = Some(Arc::new(books)); app.nav = Nav::new(start); app } - fn bare(ctx: &egui::Context, dark: bool) -> Self { + fn bare(ctx: &egui::Context, scheme: &'static Scheme) -> Self { theme::install_fonts(ctx); - theme::apply(ctx, dark); + theme::apply_scheme(ctx, scheme); egui_extras::install_image_loaders(ctx); Self { loader: None, @@ -86,7 +170,8 @@ impl App { error: None, nav: Nav::new(Route::Overview), receipts: Receipts::new(ctx), - dark, + scheme, + filter: Filter::default(), state: PageState::default(), palette: Default::default(), updated_at: -1e9, @@ -99,6 +184,115 @@ impl App { self.nav.go(r); } + /// Wear the color scheme kept as `key` ("Family/Variant"; tests). + pub fn set_scheme(&mut self, ctx: &egui::Context, key: &str) { + self.choose(ctx, Scheme::from_key(key, self.scheme.dark)); + } + + /// The color scheme in force, as kept ("Family/Variant"). + pub fn scheme_key(&self) -> String { + self.scheme.key() + } + + fn choose(&mut self, ctx: &egui::Context, scheme: &'static Scheme) { + self.scheme = scheme; + theme::apply_scheme(ctx, scheme); + } + + /// The color scheme button and its menu: a filter (all, light, dark), + /// then each family with a flyout of its variants. `compact`: the + /// narrow rail's icon alone. + fn scheme_menu(&mut self, ui: &mut Ui, compact: bool) { + let p = pal(ui.ctx()); + let text = if compact { + RichText::new(icon::PALETTE).size(16.0).color(p.muted) + } else { + RichText::new(format!("{} {}", icon::PALETTE, self.scheme.label())) + .font(theme::regular(12.5)) + .color(p.muted) + }; + let mut button = egui::Button::new(text).frame(false); + if compact { + button = button.min_size(vec2(32.0, 32.0)); + } + // Open while the filter changes; closed when a scheme is chosen. + let config = egui::containers::menu::MenuConfig::new() + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); + let mut chosen = None; + let (response, _) = egui::containers::menu::MenuButton::from_button(button) + .config(config) + .ui(ui, |ui| { + ui.set_min_width(200.0); + ui.horizontal(|ui| { + for f in [Filter::All, Filter::Light, Filter::Dark] { + ui.selectable_value(&mut self.filter, f, f.name()); + } + }); + ui.separator(); + // Every family on screen at once, in as many columns as + // the window's height needs: nothing hides below a scroll. + let families = schemes::families(self.filter); + let rows = (((ui.ctx().content_rect().height() - 160.0) / FAMILY_ROW).floor() + as usize) + .clamp(6, families.len().max(6)); + let font = theme::regular(14.0); + let check = ui + .painter() + .layout_no_wrap(icon::CHECK.into(), font.clone(), p.accent) + .size() + .x; + ui.horizontal_top(|ui| { + for column in families.chunks(rows) { + // Each family's button as wide as its column, its + // flyout opening beside the column. + ui.allocate_ui_with_layout( + vec2(170.0, 0.0), + Layout::top_down_justified(Align::LEFT), + |ui| { + ui.set_width(170.0); + for (family, variants) in column { + let here = *family == self.scheme.family + && self.filter.shows(self.scheme); + // Only the family in force is checked; the + // others are indented by the check's width, + // so names line up and a screen reader hears + // the check only where it is. + let mut label = egui::text::LayoutJob::default(); + if here { + label.append( + icon::CHECK, + 0.0, + egui::TextFormat::simple(font.clone(), p.accent), + ); + } + let indent = if here { 8.0 } else { check + 8.0 }; + label.append( + family, + indent, + egui::TextFormat::simple(font.clone(), p.text), + ); + ui.menu_button(label, |ui| { + for v in variants { + if scheme_row(ui, v, std::ptr::eq(*v, self.scheme)) + .clicked() + { + chosen = Some(*v); + ui.close(); + } + } + }); + } + }, + ); + } + }); + }); + response.on_hover_text(format!("Color scheme: {}", self.scheme.label())); + if let Some(v) = chosen { + self.choose(ui.ctx(), v); + } + } + /// Block until every receipt on screen has rendered (screenshots). pub fn settle(&mut self, ctx: &egui::Context) { self.receipts.settle(ctx); @@ -283,7 +477,7 @@ impl App { egui::Align2::CENTER_CENTER, initial, theme::bold(15.0), - Color32::WHITE, + schemes::on(p.accent), ); ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 0.0; @@ -374,25 +568,7 @@ impl App { } }); ui.with_layout(Layout::bottom_up(Align::Min), |ui| { - let label = if self.dark { - format!("{} Light mode", icon::SUN) - } else { - format!("{} Dark mode", icon::MOON) - }; - if ui - .add( - egui::Button::new( - RichText::new(label) - .font(theme::regular(12.5)) - .color(p.muted), - ) - .frame(false), - ) - .clicked() - { - self.dark = !self.dark; - theme::apply(ui.ctx(), self.dark); - } + self.scheme_menu(ui, false); ui.add_space(6.0); if let Some(b) = &books { live_status( @@ -433,7 +609,7 @@ impl App { egui::Align2::CENTER_CENTER, name.chars().next().unwrap_or('W'), theme::bold(15.0), - Color32::WHITE, + schemes::on(p.accent), ); ui.interact(r, ui.id().with("logo"), Sense::hover()) .on_hover_text(format!("{name} · Books")); @@ -487,23 +663,7 @@ impl App { } }); ui.with_layout(Layout::bottom_up(Align::Center), |ui| { - let (glyph, tip) = if self.dark { - (icon::SUN, "Light mode") - } else { - (icon::MOON, "Dark mode") - }; - if ui - .add( - egui::Button::new(RichText::new(glyph).size(16.0).color(p.muted)) - .frame(false) - .min_size(vec2(32.0, 32.0)), - ) - .on_hover_text(tip) - .clicked() - { - self.dark = !self.dark; - theme::apply(ui.ctx(), self.dark); - } + self.scheme_menu(ui, true); ui.add_space(6.0); if let Some(b) = &books { let live = self.loader.is_some() || self.live_label; @@ -831,6 +991,68 @@ impl eframe::App for App { } fn save(&mut self, storage: &mut dyn eframe::Storage) { - storage.set_string("dark", self.dark.to_string()); + storage.set_string(SCHEME_KEY, self.scheme.key()); + storage.set_string(FILTER_KEY, self.filter.name().to_string()); + storage.set_string("dark", self.scheme.dark.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn kept(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + move |k| map.get(k).cloned() + } + + #[test] + fn the_kept_scheme_and_filter_come_back() { + let (s, f) = restored( + kept(&[ + ("scheme", "Ayu/Mirage"), + ("scheme_filter", "Dark"), + ("dark", "false"), + ]), + false, + ); + assert_eq!(s.key(), "Ayu/Mirage", "the scheme wins over the old choice"); + assert_eq!(f, Filter::Dark); + } + + #[test] + fn before_schemes_the_light_or_dark_choice_is_kept() { + let (s, f) = restored(kept(&[("dark", "true")]), false); + assert!(std::ptr::eq(s, schemes::own(true))); + assert_eq!(f, Filter::All); + let (s, _) = restored(kept(&[("dark", "false")]), true); + assert!(std::ptr::eq(s, schemes::own(false))); + } + + #[test] + fn with_nothing_kept_the_systems_mode_decides() { + for dark in [false, true] { + let (s, f) = restored(kept(&[]), dark); + assert!(std::ptr::eq(s, schemes::own(dark))); + assert_eq!(f, Filter::All); + } + } + + #[test] + fn a_lost_scheme_or_filter_falls_back() { + let (s, f) = restored( + kept(&[ + ("scheme", "Gone/Away"), + ("scheme_filter", "Sepia"), + ("dark", "true"), + ]), + false, + ); + assert!(std::ptr::eq(s, schemes::own(true))); + assert_eq!(f, Filter::All); } } diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index b3638e0..7ebb9a6 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -14,7 +14,9 @@ pub mod fmt; pub mod nav; pub mod pages; pub mod palette; +mod prefs; mod receipts; +mod schemes; #[cfg(test)] mod test_support; mod theme; @@ -129,6 +131,9 @@ pub fn run( // Small on purpose: tiling window managers hand out small slots // regardless, and every screen lays itself out to fit. .with_min_inner_size([360.0, 400.0]), + // The platform's place for app settings, never the books' folder + // (prefs.rs). + persistence_path: prefs::prepare(), ..Default::default() }; Ok(eframe::run_native( diff --git a/crates/ledger-gui/src/pages/overview.rs b/crates/ledger-gui/src/pages/overview.rs index 199b2fd..1bb7f0b 100644 --- a/crates/ledger-gui/src/pages/overview.rs +++ b/crates/ledger-gui/src/pages/overview.rs @@ -97,10 +97,10 @@ fn change( prev: Decimal, up_is_good: bool, what: &str, + p: &theme::Palette, ) -> Option<(String, egui::Color32)> { let pct = fmt::pct_change(prev, now)?; let good = (pct >= 0.0) == up_is_good; - let p = &theme::LIGHT; let arrow = if pct >= 0.0 { "▲" } else { "▼" }; Some(( format!("{arrow} {:.0}% vs {what}", pct.abs()), @@ -153,8 +153,7 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { Kpi { label: "Cash on hand", value: fmt::dollars(k.cash), - sub: change(k.cash, k.cash_30d_ago, true, "30 days ago") - .map(|(t, c)| (t, color_for(c, p))), + sub: change(k.cash, k.cash_30d_ago, true, "30 days ago", &p), spark: Some(&cash_spark), how: "The balance of every cash account (bank, savings, cash on hand) at the end of today.", }, @@ -179,8 +178,8 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { k.expenses_prev_month, false, "same days last month", - ) - .map(|(t, c)| (t, color_for(c, p))), + &p, + ), spark: None, how: "Expenses dated this month (accrual basis: when incurred, not when paid).", }, @@ -195,8 +194,8 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { k.revenue_prev_month, true, "same days last month", - ) - .map(|(t, c)| (t, color_for(c, p))), + &p, + ), spark: None, how: "Revenue dated this month.", }, @@ -241,18 +240,6 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }); } -/// `change` picks from the light palette; map to the current one. -fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 { - let l = &theme::LIGHT; - if c == l.positive { - p.positive - } else if c == l.negative { - p.negative - } else { - p.muted - } -} - /// "Needs attention", full width: a summary of how many problems, to-dos, /// and notes there are, then each item as a row (its severity, the title /// that opens where to look, what's wrong, and the prompt for the agent at diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index e3a0140..0f90d87 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -530,7 +530,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }; let (out_t, out_c) = money(flow.paid, p.text); let (in_t, in_c) = money(flow.received, p.positive); - let (net_t, net_c) = paid_cell(flow.net(), p); + let (net_t, net_c) = paid_cell(flow.net(), &p); let resp = row( ui, w, diff --git a/crates/ledger-gui/src/prefs.rs b/crates/ledger-gui/src/prefs.rs new file mode 100644 index 0000000..59837be --- /dev/null +++ b/crates/ledger-gui/src/prefs.rs @@ -0,0 +1,148 @@ +//! Where the viewer keeps its preferences (window size, color scheme and +//! its filter): the platform's own place for an app's settings, in a +//! folder of the viewer's own. +//! +//! ```text +//! GOAL: the viewer's preferences live where the platform keeps app +//! settings, and never in the books' private folder. +//! SOURCE: owner, 2026-10-19 (preferences "in the OS's platform-native +//! location (like XDG on Linux)"); plan I-8 (the viewer writes +//! nothing under the data root; only eframe preferences under +//! $HOME). Found 2026-10-19: eframe's default, named after the app +//! id "wanderbooks", is $XDG_DATA_HOME/wanderbooks, the data +//! root's own default place. +//! INVARIANTS: +//! 1. Linux and the BSDs: $XDG_CONFIG_HOME/wanderbooks-viewer/viewer.ron +//! ($XDG_CONFIG_HOME only if absolute, else ~/.config); macOS: +//! ~/Library/Application Support/wanderbooks-viewer/viewer.ron; +//! Windows: %APPDATA%\wanderbooks-viewer\viewer.ron. +//! 2. Preferences eframe kept in its old place (app.ron in +//! $XDG_DATA_HOME/wanderbooks and the like) are moved once, when the +//! new place has none: only that one file, nothing else there is +//! read or touched. +//! ``` + +use std::path::{Path, PathBuf}; + +/// The viewer's own folder for its preferences. +pub const DIR: &str = "wanderbooks-viewer"; +/// Its preferences file. +pub const FILE: &str = "viewer.ron"; +/// eframe's own file name, in its old place. +const OLD_FILE: &str = "app.ron"; + +/// The preferences file for an account whose home is `home`, given +/// `$XDG_CONFIG_HOME` (or `%APPDATA%` on Windows) (INVARIANT 1). +pub fn path_in(home: &Path, config_home: Option<&Path>) -> PathBuf { + let base = if cfg!(target_os = "macos") { + home.join("Library").join("Application Support") + } else if cfg!(windows) { + config_home + .map(Path::to_path_buf) + .unwrap_or_else(|| home.join("AppData").join("Roaming")) + } else { + config_home + .filter(|p| p.is_absolute()) + .map(Path::to_path_buf) + .unwrap_or_else(|| home.join(".config")) + }; + base.join(DIR).join(FILE) +} + +/// The preferences file for this account, if it has a home. +pub fn path() -> Option { + let home = std::env::home_dir()?; + let var = if cfg!(windows) { + "APPDATA" + } else { + "XDG_CONFIG_HOME" + }; + let config = std::env::var_os(var).map(PathBuf::from); + Some(path_in(&home, config.as_deref())) +} + +/// Move preferences from `old_dir/app.ron` to `new` if `new` doesn't +/// exist yet (INVARIANT 2). Failures change nothing that matters: the +/// viewer starts with its defaults. +pub fn move_old(old_dir: &Path, new: &Path) { + let old = old_dir.join(OLD_FILE); + if new.exists() || !old.is_file() { + return; + } + let Some(dir) = new.parent() else { + return; + }; + if std::fs::create_dir_all(dir).is_err() { + return; + } + if std::fs::copy(&old, new).is_ok() { + let _ = std::fs::remove_file(&old); + } +} + +/// Where the viewer keeps its preferences, its old ones moved in +/// (INVARIANT 2): None when the account has no home. +pub fn prepare() -> Option { + let new = path()?; + if let Some(old_dir) = eframe::storage_dir("wanderbooks") { + move_old(&old_dir, &new); + } + Some(new) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn on_linux_it_is_the_xdg_config_folder_of_its_own() { + let home = Path::new("/home/someone"); + assert_eq!( + path_in(home, None), + Path::new("/home/someone/.config/wanderbooks-viewer/viewer.ron") + ); + assert_eq!( + path_in(home, Some(Path::new("/cfg"))), + Path::new("/cfg/wanderbooks-viewer/viewer.ron") + ); + // A relative XDG_CONFIG_HOME is ignored (the spec says so). + assert_eq!( + path_in(home, Some(Path::new("cfg"))), + Path::new("/home/someone/.config/wanderbooks-viewer/viewer.ron") + ); + // Never the data root's place. + assert!(!path_in(home, None).starts_with("/home/someone/.local/share/wanderbooks")); + } + + #[test] + fn old_preferences_are_moved_once_and_nothing_else_is_touched() { + let dir = crate::test_support::TempDir( + std::env::temp_dir().join(format!("ledger-gui-prefs-{}", std::process::id())), + ); + let _ = std::fs::remove_dir_all(&dir.0); + std::fs::create_dir_all(&dir.0).unwrap(); + let old = dir.0.join("old"); + std::fs::create_dir(&old).unwrap(); + std::fs::write(old.join("app.ron"), "(scheme: \"Ayu/Mirage\")").unwrap(); + std::fs::write(old.join("other"), "kept").unwrap(); + let new = dir.0.join("cfg/wanderbooks-viewer/viewer.ron"); + move_old(&old, &new); + assert_eq!( + std::fs::read_to_string(&new).unwrap(), + "(scheme: \"Ayu/Mirage\")" + ); + assert!(!old.join("app.ron").exists(), "moved, not copied"); + assert_eq!(std::fs::read_to_string(old.join("other")).unwrap(), "kept"); + // Once: new preferences are never overwritten by old ones. + std::fs::write(old.join("app.ron"), "older").unwrap(); + std::fs::write(&new, "newer").unwrap(); + move_old(&old, &new); + assert_eq!(std::fs::read_to_string(&new).unwrap(), "newer"); + assert!(old.join("app.ron").exists()); + // Nothing to move: nothing made. + let none = dir.0.join("none/viewer.ron"); + move_old(&dir.0.join("missing"), &none); + assert!(!none.exists() && !dir.0.join("none").exists()); + } +} diff --git a/crates/ledger-gui/src/schemes.rs b/crates/ledger-gui/src/schemes.rs new file mode 100644 index 0000000..9c1d6f5 --- /dev/null +++ b/crates/ledger-gui/src/schemes.rs @@ -0,0 +1,1005 @@ +//! Color schemes: the viewer's own (Wanderbooks Light and Dark) and twenty +//! families of popular editor schemes, each with all its variants, from +//! each scheme's official palette (table.rs names the sources). +//! +//! ```text +//! GOAL: the viewer can wear any of the well-known editor color schemes, +//! and whichever it wears, every figure stays plainly legible. +//! SOURCE: owner, 2026-10-19 ("full blown colorscheme support … all the top +//! neovim style colorschemes"; "on the order of 20 families, each +//! with a flyout of all its variants"; "a light/dark/all filter"). +//! INVARIANTS: +//! 1. A scheme only colors the viewer: it changes no figure, no text and +//! nothing in the books, and the choice is kept with the viewer's own +//! preferences. +//! 2. Every palette a scheme gives keeps every color that carries text +//! or a figure (text, secondary and faintest text, gains, losses, +//! warnings, links) at WCAG AA contrast (4.5:1) on every background +//! it is drawn on (grounds()); a scheme's own color that falls short +//! keeps its hue and saturation and is made lighter (dark schemes) or +//! darker (light ones) just far enough. Gains, losses and text stay +//! plainly different to the eye, and so do the chart series shown +//! together. The scheme holds whatever the system's light or dark +//! mode does later. +//! 3. Each scheme is light or dark as its background is; the filter +//! shows exactly the schemes of the mode chosen. +//! 4. A remembered scheme that no longer exists falls back to the +//! viewer's own scheme of the same mode. +//! 5. The menu lists the families alphabetically (case and accents +//! aside), and each family's variants alphabetically (owner, +//! 2026-10-19). +//! ``` + +use crate::theme::Palette; +use egui::Color32; + +mod table; + +/// A scheme's own colors, as its authors named them (see the table's +/// source, docs/color-schemes.md). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Core { + /// The main background. + pub bg: Color32, + /// A panel or sidebar background beside it. + pub dim: Color32, + /// A raised surface (floats, the status line). + pub raised: Color32, + /// The selection. + pub sel: Color32, + /// Borders and splits. + pub border: Color32, + /// The main text. + pub fg: Color32, + /// Secondary text. + pub muted: Color32, + /// Comments: the faintest text. + pub faint: Color32, + /// The scheme's signature accent. + pub accent: Color32, + /// Its named colors. + pub red: Color32, + pub green: Color32, + pub yellow: Color32, + pub orange: Color32, + pub blue: Color32, + pub purple: Color32, + pub cyan: Color32, +} + +/// One scheme: a family's variant. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Scheme { + /// Its family ("Catppuccin"). + pub family: &'static str, + /// Its variant ("Mocha"). + pub name: &'static str, + /// Whether it is dark. + pub dark: bool, + /// Its colors; `None` for the viewer's own, whose palettes are + /// hand-made (theme::LIGHT, theme::DARK). + pub core: Option, +} + +/// Which schemes the menu shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Filter { + /// Light and dark. + #[default] + All, + /// Light ones. + Light, + /// Dark ones. + Dark, +} + +impl Filter { + /// What it is called in the menu and in the viewer's preferences. + pub fn name(self) -> &'static str { + match self { + Filter::All => "All", + Filter::Light => "Light", + Filter::Dark => "Dark", + } + } + + /// The filter of that name, if any. + pub fn from_name(name: &str) -> Option { + [Filter::All, Filter::Light, Filter::Dark] + .into_iter() + .find(|f| f.name() == name) + } + + /// Whether it shows `scheme` (INVARIANT 3). + pub fn shows(self, scheme: &Scheme) -> bool { + match self { + Filter::All => true, + Filter::Light => !scheme.dark, + Filter::Dark => scheme.dark, + } + } +} + +/// The viewer's own family. +pub const OWN: &str = "Wanderbooks"; + +/// The viewer's own schemes. +static OWN_SCHEMES: [Scheme; 2] = [ + Scheme { + family: OWN, + name: "Light", + dark: false, + core: None, + }, + Scheme { + family: OWN, + name: "Dark", + dark: true, + core: None, + }, +]; + +/// Every scheme: the viewer's own first, then the families in menu order. +pub fn all() -> impl Iterator { + OWN_SCHEMES.iter().chain(table::SCHEMES.iter()) +} + +/// How a name sorts: without case or accents ("Rosé" as "rose"). +fn sort_key(name: &str) -> String { + name.chars() + .map(|c| match c { + 'à' | 'á' | 'â' | 'ä' | 'ã' | 'å' => 'a', + 'ç' => 'c', + 'è' | 'é' | 'ê' | 'ë' => 'e', + 'ì' | 'í' | 'î' | 'ï' => 'i', + 'ñ' => 'n', + 'ò' | 'ó' | 'ô' | 'ö' | 'õ' => 'o', + 'ù' | 'ú' | 'û' | 'ü' => 'u', + c => c.to_ascii_lowercase(), + }) + .collect() +} + +/// The families in alphabetical order (INVARIANT 5), each with its +/// variants that `filter` shows, also alphabetical; a family none of whose +/// variants it shows is left out. +pub fn families(filter: Filter) -> Vec<(&'static str, Vec<&'static Scheme>)> { + let mut out: Vec<(&'static str, Vec<&'static Scheme>)> = Vec::new(); + for s in all().filter(|s| filter.shows(s)) { + match out.iter_mut().find(|(family, _)| *family == s.family) { + Some((_, variants)) => variants.push(s), + None => out.push((s.family, vec![s])), + } + } + out.sort_by_key(|(family, _)| sort_key(family)); + for (_, variants) in &mut out { + variants.sort_by_key(|s| sort_key(s.name)); + } + out +} + +/// The viewer's own scheme for light or dark. +pub fn own(dark: bool) -> &'static Scheme { + &OWN_SCHEMES[usize::from(dark)] +} + +impl Scheme { + /// How it is kept in the viewer's preferences: "Family/Variant". + pub fn key(&self) -> String { + format!("{}/{}", self.family, self.name) + } + + /// The scheme kept as `key`, else the viewer's own of mode `dark` + /// (INVARIANT 4). + pub fn from_key(key: &str, dark: bool) -> &'static Scheme { + all().find(|s| s.key() == key).unwrap_or_else(|| own(dark)) + } + + /// How the menu names it. + pub fn label(&self) -> String { + if self.family == self.name { + self.name.to_string() + } else { + format!("{} {}", self.family, self.name) + } + } + + /// The palette the viewer paints with (INVARIANT 2), made once per + /// scheme. + pub fn palette(&self) -> Palette { + static MADE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let made = MADE.get_or_init(|| all().map(Scheme::make_palette).collect()); + all() + .position(|s| s == self) + .map_or_else(|| self.make_palette(), |i| made[i]) + } + + fn make_palette(&self) -> Palette { + readable(match &self.core { + None if self.dark => crate::theme::DARK, + None => crate::theme::LIGHT, + Some(core) => derive(core), + }) + } + + /// A few of its colors, for the menu's swatch. + pub fn swatch(&self) -> [Color32; 5] { + let p = self.palette(); + [p.bg, p.accent, p.positive, p.warning, p.negative] + } +} + +/// `a` moved toward `b` by `t` (0: `a`, 1: `b`). +pub fn mix(a: Color32, b: Color32, t: f32) -> Color32 { + let f = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8; + Color32::from_rgb(f(a.r(), b.r()), f(a.g(), b.g()), f(a.b(), b.b())) +} + +/// WCAG relative luminance. +pub fn luminance(c: Color32) -> f32 { + let lin = |v: u8| { + let s = f32::from(v) / 255.0; + if s <= 0.039_28 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) +} + +/// WCAG contrast ratio of two colors (1 to 21). +pub fn contrast(a: Color32, b: Color32) -> f32 { + let (x, y) = (luminance(a), luminance(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + +/// The least the readable colors reach on every background (INVARIANT 2). +pub const READABLE: f32 = 4.5; + +/// `c`, or `c` with its hue and saturation kept and its lightness moved +/// toward `toward` (black or white) just far enough to reach `min` +/// contrast on each of `on` (INVARIANT 2). +pub fn legible(c: Color32, toward: Color32, on: &[Color32], min: f32) -> Color32 { + let ok = |c: Color32| on.iter().all(|b| contrast(c, *b) >= min); + if ok(c) { + return c; + } + let (h, s, l) = hsl(c); + let end = if luminance(toward) > 0.5 { 1.0 } else { 0.0 }; + (1..=100) + .map(|i| from_hsl(h, s, l + (end - l) * i as f32 / 100.0)) + .find(|c| ok(*c)) + .unwrap_or(toward) +} + +/// Hue (0 to 1), saturation and lightness of `c`. +fn hsl(c: Color32) -> (f32, f32, f32) { + let [r, g, b] = [c.r(), c.g(), c.b()].map(|v| f32::from(v) / 255.0); + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let l = (max + min) / 2.0; + if max == min { + return (0.0, 0.0, l); + } + let d = max - min; + let s = if l > 0.5 { + d / (2.0 - max - min) + } else { + d / (max + min) + }; + let h = if max == r { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + (h / 6.0, s, l) +} + +/// The color of hue `h` (0 to 1), saturation `s` and lightness `l`. +fn from_hsl(h: f32, s: f32, l: f32) -> Color32 { + let l = l.clamp(0.0, 1.0); + if s == 0.0 { + let v = (l * 255.0).round() as u8; + return Color32::from_rgb(v, v, v); + } + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; + let p = 2.0 * l - q; + let channel = |t: f32| { + let t = t.rem_euclid(1.0); + let v = if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + }; + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + }; + Color32::from_rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0)) +} + +/// A color in CIELAB (D65): lightness, then the two color axes. +fn lab(c: Color32) -> [f32; 3] { + let lin = |v: u8| { + let s = f32::from(v) / 255.0; + if s <= 0.040_45 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + } + }; + let (r, g, b) = (lin(c.r()), lin(c.g()), lin(c.b())); + let x = (0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b) / 0.950_47; + let y = 0.212_672_9 * r + 0.715_152_2 * g + 0.072_175 * b; + let z = (0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b) / 1.088_83; + let f = |t: f32| { + if t > 216.0 / 24_389.0 { + t.cbrt() + } else { + (24_389.0 / 27.0 * t + 16.0) / 116.0 + } + }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// How different two colors look: CIE76 ΔE (about 2: just noticeable; +/// 20 and more: plainly different colors). +pub fn distance(a: Color32, b: Color32) -> f32 { + let (a, b) = (lab(a), lab(b)); + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + +/// How far apart a chart's series colors are kept, at least. +pub const SERIES_APART: f32 = 20.0; + +/// The chart's series colors (the charts use the first three together: +/// Net, Income, Spending): the scheme's own named colors in this order, +/// each one that looks like one already taken passed over (some schemes +/// name one color twice: Monokai Pro's cyan is its blue); when none is +/// left that is plainly different, the one most unlike those taken. +/// `muted` is last. Each is first made readable on the cards, as +/// `readable` would, so two that only part once lifted aren't taken for +/// different. +fn series(k: &Core, card: Color32, muted: Color32) -> [Color32; 6] { + let end = readable_end(card); + let mut left: Vec = [ + k.blue, k.cyan, k.yellow, k.purple, k.orange, k.green, k.red, k.accent, + ] + .into_iter() + .map(|c| legible(c, end, &[card], 3.0)) + .collect(); + let mut out: Vec = vec![left.remove(0)]; + while out.len() < 5 { + let nearest = |c: &Color32| { + out.iter() + .map(|t| distance(*t, *c)) + .fold(f32::INFINITY, f32::min) + }; + let at = left + .iter() + .position(|c| nearest(c) >= SERIES_APART) + .unwrap_or_else(|| { + (0..left.len()) + .max_by(|&i, &j| nearest(&left[i]).total_cmp(&nearest(&left[j]))) + .expect("eight colors for five places") + }); + out.push(left.remove(at)); + } + [out[0], out[1], out[2], out[3], out[4], muted] +} + +/// The viewer's palette from a scheme's colors. +fn derive(k: &Core) -> Palette { + // The page is the darker of the scheme's two backgrounds, the cards the + // lighter: cards stand out in light and dark schemes alike. A scheme + // with one background gets a second a shade toward its text: the cards + // in a dark scheme (lighter), the page in a light one (darker). + let (page, card) = if luminance(k.dim) <= luminance(k.bg) { + (k.dim, k.bg) + } else { + (k.bg, k.dim) + }; + let (page, card) = if page != card { + (page, card) + } else if luminance(k.bg) < luminance(k.fg) { + (page, mix(page, k.fg, 0.04)) + } else { + (mix(card, k.fg, 0.04), card) + }; + // Secondary text sits between the text and the comments; a scheme + // whose "subtle" tone is no dimmer than its text (Nord's) gets one + // in between. + let muted = if contrast(k.muted, page) >= contrast(k.fg, page) { + mix(k.fg, k.faint, 0.45) + } else { + k.muted + }; + let sidebar = mix(page, k.fg, 0.02); + let hover = mix(card, k.fg, 0.06); + let selected = mix(card, k.accent, 0.14); + // A tint light enough that amounts on it keep their own colors (at + // 0.22 it bleached Nord's red to a pink; review, 2026-10-19). + let new_highlight = mix(card, k.yellow, 0.10); + let soft = |c: Color32| mix(card, c, 0.14); + Palette { + bg: page, + surface: card, + sidebar, + hover, + selected, + border: mix(card, k.fg, 0.14), + text: k.fg, + muted, + faint: k.faint, + accent: k.accent, + accent_soft: soft(k.accent), + positive: k.green, + negative: k.red, + warning: k.orange, + warning_soft: soft(k.orange), + negative_soft: soft(k.red), + positive_soft: soft(k.green), + new_highlight, + series: series(k, card, muted), + } +} + +/// Every background text or a figure is drawn on: the page, cards, the +/// sidebar, hovered, selected and new rows, and the soft fills of pills +/// and badges. +pub fn grounds(p: &Palette) -> [Color32; 10] { + [ + p.bg, + p.surface, + p.sidebar, + p.hover, + p.selected, + p.new_highlight, + p.accent_soft, + p.warning_soft, + p.negative_soft, + p.positive_soft, + ] +} + +/// `p` with every color that carries text or a figure (the faintest +/// text included: it carries chart amounts and entry numbers) readable on +/// every background it is drawn on (INVARIANT 2): its lightness moved +/// toward black or white, whichever the cards are farther from, just far +/// enough. +fn readable(mut p: Palette) -> Palette { + let grounds = grounds(&p); + let end = readable_end(p.surface); + let read = |c: Color32| legible(c, end, &grounds, READABLE); + // The faintest text first, then each level above it reading at least + // STEP times better: text over secondary over faint, always. + p.faint = read(p.faint); + let above = |c: Color32, below: Color32| { + legible( + c, + end, + &grounds, + (worst(below, &grounds) * STEP).max(READABLE), + ) + }; + p.muted = above(p.muted, p.faint); + p.text = above(p.text, p.muted); + p.positive = read(p.positive); + p.negative = read(p.negative); + p.warning = read(p.warning); + p.accent = read(p.accent); + // Chart bars sit on the cards, and on the hover tint when pointed at. + let (surface, hover) = (p.surface, p.hover); + p.series = p.series.map(|c| legible(c, end, &[surface, hover], 3.0)); + p +} + +/// How much better each level of text reads than the one below it, at +/// least, on the ground it reads worst on: text over secondary text over +/// the faintest (review, 2026-10-19: lifting each alone made them alike). +pub const STEP: f32 = 1.2; + +/// The contrast `c` has on the ground it reads worst on. +pub fn worst(c: Color32, grounds: &[Color32]) -> f32 { + grounds + .iter() + .map(|g| contrast(c, *g)) + .fold(f32::INFINITY, f32::min) +} + +/// Black or white, whichever reads better on `c`: for text on a filled +/// color (the logo's initial on the accent). +pub fn on(c: Color32) -> Color32 { + readable_end(c) +} + +/// Black or white, whichever reads better on `c`. +fn readable_end(c: Color32) -> Color32 { + if contrast(Color32::BLACK, c) >= contrast(Color32::WHITE, c) { + Color32::BLACK + } else { + Color32::WHITE + } +} + +/// `Color32` from three bytes (the table's shorthand). +pub(crate) const fn rgb(r: u8, g: u8, b: u8) -> Color32 { + Color32::from_rgb(r, g, b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn twenty_families_each_with_all_its_variants() { + let families = families(Filter::All); + // The viewer's own, and twenty more. + assert!(families.len() > 20, "{} families", families.len()); + // Alphabetical, families and variants (INVARIANT 5). + let keys: Vec = families.iter().map(|f| sort_key(f.0)).collect(); + let mut sorted = keys.clone(); + sorted.sort(); + assert_eq!(keys, sorted); + assert_eq!(families[0].0, "Ayu"); + assert_eq!(families.last().unwrap().0, OWN); + for (family, variants) in &families { + let names: Vec = variants.iter().map(|s| sort_key(s.name)).collect(); + let mut sorted = names.clone(); + sorted.sort(); + assert_eq!(names, sorted, "{family}"); + } + let rose = families.iter().position(|f| f.0 == "Rosé Pine").unwrap(); + assert_eq!(families[rose - 1].0, "Oxocarbon", "Rosé sorts as Rose"); + let names: Vec<&str> = families.iter().map(|f| f.0).collect(); + for want in [ + "Catppuccin", + "Tokyo Night", + "Gruvbox", + "Kanagawa", + "Rosé Pine", + "Ayu", + "Nord", + "Dracula", + "One Dark", + "Solarized", + ] { + assert!(names.contains(&want), "{want} missing: {names:?}"); + } + let ayu = &families.iter().find(|f| f.0 == "Ayu").unwrap().1; + let ayu: Vec<&str> = ayu.iter().map(|s| s.name).collect(); + assert_eq!(ayu, ["Dark", "Light", "Mirage"]); + // A family appears once, its variants together; keys are unique. + let mut seen = std::collections::BTreeSet::new(); + for (family, _) in &families { + assert!(seen.insert(*family), "{family} twice"); + } + let mut keys = std::collections::BTreeSet::new(); + for s in all() { + assert!(keys.insert(s.key()), "{} twice", s.key()); + } + } + + #[test] + fn every_scheme_keeps_figures_and_text_readable() { + for s in all() { + let p = s.palette(); + for (what, c) in [ + ("text", p.text), + ("secondary text", p.muted), + ("faint text", p.faint), + ("positive amounts", p.positive), + ("negative amounts", p.negative), + ("warnings", p.warning), + ("links", p.accent), + ] { + for g in grounds(&p) { + let ratio = contrast(c, g); + assert!( + ratio >= READABLE, + "{}: {what} {c:?} on {g:?} is {ratio:.2}:1", + s.label() + ); + } + } + for c in p.series { + assert!( + contrast(c, p.surface) >= 3.0, + "{}: a chart series", + s.label() + ); + } + // The series the charts show together (Net, Income and + // Spending: 0, 1 and 2) look plainly different. + for i in 0..3 { + for j in 0..i { + let d = distance(p.series[i], p.series[j]); + assert!( + d >= SERIES_APART, + "{}: series {j} and {i} alike (ΔE {d:.1})", + s.label() + ); + } + } + // Gains, losses and plain text look different to the eye. + for (what, a, b) in [ + ("gains and losses", p.positive, p.negative), + ("gains and text", p.positive, p.text), + ("losses and text", p.negative, p.text), + ] { + let d = distance(a, b); + assert!(d >= 20.0, "{}: {what} alike (ΔE {d:.1})", s.label()); + } + // Text over secondary over faint, each plainly above the next. + let g = grounds(&p); + let (t, m, f) = (worst(p.text, &g), worst(p.muted, &g), worst(p.faint, &g)); + assert!( + t >= m * STEP * 0.999 && m >= f * STEP * 0.999, + "{}: text {t:.2}, secondary {m:.2}, faint {f:.2}", + s.label() + ); + for c in p.series { + assert!( + contrast(c, p.hover) >= 3.0, + "{}: a series on hover", + s.label() + ); + } + // The logo's initial reads on the accent. + assert!( + contrast(on(p.accent), p.accent) >= READABLE, + "{}", + s.label() + ); + // Gains and losses never look alike: their hues lie at least + // 30 degrees apart (or, for a scheme with greyish ones, the + // colors plainly differ). + let ((h0, s0, _), (h1, s1, _)) = (hsl(p.positive), hsl(p.negative)); + let apart = (h0 - h1).abs().min(1.0 - (h0 - h1).abs()); + let d = { + let f = |x: u8, y: u8| (f32::from(x) - f32::from(y)).powi(2); + let (a, b) = (p.positive, p.negative); + (f(a.r(), b.r()) + f(a.g(), b.g()) + f(a.b(), b.b())).sqrt() + }; + assert!( + (s0 > 0.15 && s1 > 0.15 && apart >= 30.0 / 360.0) || d > 60.0, + "{}: + {:?} and - {:?} alike", + s.label(), + p.positive, + p.negative + ); + // Cards stand out from the page. + assert_ne!(p.bg, p.surface, "{}", s.label()); + } + } + + #[test] + fn a_scheme_is_dark_exactly_when_its_background_is() { + for s in all() { + let p = s.palette(); + let dark_bg = luminance(p.bg) < luminance(p.text); + assert_eq!(s.dark, dark_bg, "{}", s.label()); + } + } + + #[test] + fn the_filter_shows_exactly_its_mode() { + let count = |f| families(f).iter().map(|(_, v)| v.len()).sum::(); + assert_eq!(count(Filter::All), all().count()); + assert_eq!(count(Filter::Light) + count(Filter::Dark), all().count()); + for (_, variants) in families(Filter::Light) { + assert!(variants.iter().all(|s| !s.dark)); + } + for (_, variants) in families(Filter::Dark) { + assert!(variants.iter().all(|s| s.dark)); + } + // A family with no variant of the mode isn't listed (Nord has no + // light one). + assert!(!families(Filter::Light).iter().any(|(f, _)| *f == "Nord")); + for f in [Filter::All, Filter::Light, Filter::Dark] { + assert_eq!(Filter::from_name(f.name()), Some(f)); + } + assert_eq!(Filter::from_name("Sepia"), None); + } + + #[test] + fn a_kept_scheme_comes_back_and_a_lost_one_falls_back() { + for s in all() { + assert!(std::ptr::eq(Scheme::from_key(&s.key(), !s.dark), s)); + } + assert!(std::ptr::eq(Scheme::from_key("Nope/Gone", true), own(true))); + assert!(std::ptr::eq(Scheme::from_key("", false), own(false))); + assert_eq!(own(false).palette().bg, crate::theme::LIGHT.bg); + assert_eq!(own(true).palette().bg, crate::theme::DARK.bg); + assert_eq!(Scheme::from_key("Ayu/Mirage", false).label(), "Ayu Mirage"); + assert_eq!(Scheme::from_key("Nord/Nord", false).label(), "Nord"); + } + + #[test] + fn hsl_round_trips_and_keeps_hue() { + for c in [ + Color32::from_rgb(0xe6, 0x7e, 0x80), + Color32::from_rgb(0xa7, 0xc0, 0x80), + Color32::from_rgb(0x12, 0x34, 0x56), + Color32::from_rgb(0x80, 0x80, 0x80), + Color32::WHITE, + Color32::BLACK, + ] { + let (h, s, l) = hsl(c); + assert_eq!(from_hsl(h, s, l), c); + } + // A red made lighter is still a red, not a pink-grey. + let red = Color32::from_rgb(0xe6, 0x7e, 0x80); + let dark = Color32::from_rgb(0x30, 0x30, 0x30); + let fixed = legible(red, Color32::WHITE, &[dark], 7.0); + assert!(contrast(fixed, dark) >= 7.0); + let ((h0, s0, _), (h1, s1, _)) = (hsl(red), hsl(fixed)); + assert!( + (h0 - h1).abs() < 0.02 && (s0 - s1).abs() < 0.05, + "{fixed:?}" + ); + } + + #[test] + fn a_color_already_readable_is_left_as_it_is() { + let white = Color32::WHITE; + let navy = Color32::from_rgb(0x00, 0x00, 0x80); + assert_eq!(legible(navy, Color32::BLACK, &[white], READABLE), navy); + // A pale yellow on white is darkened just enough. + let pale = Color32::from_rgb(0xFF, 0xEE, 0x88); + let fixed = legible(pale, Color32::BLACK, &[white], READABLE); + assert!(contrast(fixed, white) >= READABLE); + assert!(contrast(pale, white) < READABLE); + assert!((contrast(fixed, white) - READABLE) < 1.0, "moved too far"); + } +} + +/// Every scheme on the viewer's pages, as painted: the same words and +/// figures in the same places, each readable on what is under it. +#[cfg(test)] +mod painted { + use super::*; + use crate::nav::{ReportKind, Route}; + use crate::test_support::{Painted, painted, rects}; + use egui_kittest::Harness; + + /// The text that can be seen, and where it is, to the pixel. + fn words(texts: &[Seen], label: &str) -> Vec<(String, i32, i32)> { + // The scheme button names the scheme in force: that one differs. + let button = format!("{} {label}", egui_phosphor::regular::PALETTE); + texts + .iter() + .filter(|t| t.text != button) + .map(|t| { + ( + t.text.clone(), + t.pos.x.round() as i32, + t.pos.y.round() as i32, + ) + }) + .collect() + } + + /// `c` laid over `ground` (a translucent color as seen). + fn over(c: Color32, ground: Color32) -> Color32 { + let [r, g, b, a] = c.to_srgba_unmultiplied(); + let t = f32::from(a) / 255.0; + mix(ground, Color32::from_rgb(r, g, b), t) + } + + /// One run of painted text, its color as seen, and the ground under + /// it as painted by then. + struct Seen { + text: String, + pos: egui::Pos2, + color: Color32, + ground: Color32, + } + + /// Every run of text a frame painted where it can be seen, with the + /// ground under its middle: the fills painted before it, in paint + /// order, each cut to its clip, translucent ones laid over what was + /// below, all on `base` (the page). + fn seen(out: &egui::FullOutput, base: Color32) -> Vec { + fn walk( + s: &egui::Shape, + clip: egui::Rect, + base: Color32, + fills: &mut Vec<(egui::Rect, Color32)>, + out: &mut Vec, + ) { + match s { + egui::Shape::Rect(r) if r.fill.a() > 0 => { + fills.push((r.rect.intersect(clip), r.fill)); + } + egui::Shape::Text(t) => { + let rect = egui::Rect::from_min_size(t.pos, t.galley.size()); + let mid = rect.center(); + if !clip.contains(mid) { + return; + } + let ground = fills + .iter() + .filter(|(r, _)| r.contains(mid)) + .fold(base, |g, (_, c)| over(*c, g)); + let job = &t.galley.job; + for section in &job.sections { + let range = §ion.byte_range; + let text = &job.text[range.start.0..range.end.0]; + if text.trim().is_empty() { + continue; + } + let color = t.override_text_color.unwrap_or( + if section.format.color == Color32::PLACEHOLDER { + t.fallback_color + } else { + section.format.color + }, + ); + out.push(Seen { + text: text.to_string(), + pos: t.pos, + color: over(color, ground), + ground, + }); + } + } + egui::Shape::Vec(v) => { + for s in v { + walk(s, clip, base, fills, out); + } + } + _ => {} + } + } + let mut fills = Vec::new(); + let mut texts = Vec::new(); + for c in &out.shapes { + walk(&c.shape, c.clip_rect, base, &mut fills, &mut texts); + } + texts + } + + #[test] + fn the_system_turning_light_or_dark_changes_nothing() { + let (_dir, books) = crate::test_support::Build::new("schemes-flip").books(); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 1200.0)) + .build_eframe(|cc| crate::App::with_books(&cc.egui_ctx, books, false, Route::Overview)); + // As eframe starts: following the system (kittest pins a theme). + h.ctx + .options_mut(|o| o.theme_preference = egui::ThemePreference::System); + h.run_steps(3); + for (key, system) in [ + ("Catppuccin/Mocha", egui::Theme::Light), + ("Catppuccin/Latte", egui::Theme::Dark), + ("Wanderbooks/Dark", egui::Theme::Light), + ] { + let ctx = h.ctx.clone(); + h.state_mut().set_scheme(&ctx, key); + h.run_steps(2); + let before = painted(h.output()); + let fills_before: Vec = + rects(h.output()).into_iter().map(|(_, c)| c).collect(); + h.input_mut().system_theme = Some(system); + h.run_steps(3); + let p = Scheme::from_key(key, false).palette(); + let v = h.ctx.global_style().visuals.clone(); + assert_eq!(v.override_text_color, Some(p.text), "{key}"); + assert_eq!(v.panel_fill, p.bg, "{key}"); + let after = painted(h.output()); + let colors = |t: &[Painted]| t.iter().map(|t| t.color).collect::>(); + assert_eq!(colors(&after), colors(&before), "{key}: a color changed"); + let fills = + |r: Vec<(egui::Rect, Color32)>| r.into_iter().map(|(_, c)| c).collect::>(); + assert_eq!( + fills(rects(h.output())), + fills_before, + "{key}: a fill changed" + ); + } + } + + /// One page in every scheme; how many texts were checked. + fn page_in_every_scheme(source: &crate::data::Source, route: Route) -> usize { + let mut checked = 0usize; + let books = crate::data::load_once(source).unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1400.0, 2400.0)) + .build_eframe(|cc| crate::App::with_books(&cc.egui_ctx, books, false, route.clone())); + h.run_steps(4); + let mut first: Option> = None; + for s in all() { + let ctx = h.ctx.clone(); + h.state_mut().set_scheme(&ctx, &s.key()); + h.run_steps(3); + // Thumbnails render in the background: all of them first. + h.state_mut().settle(&ctx); + h.run_steps(2); + let texts = seen(h.output(), s.palette().bg); + let p = s.palette(); + let shown = crate::theme::pal(&h.ctx); + assert_eq!((shown.bg, shown.text), (p.bg, p.text), "{}", s.label()); + let now = words(&texts, &s.label()); + assert!(now.len() > 10, "{route:?}: little was painted"); + match &first { + None => first = Some(now), + Some(want) => assert_eq!(&now, want, "{route:?} in {}", s.label()), + } + // Each run of text against what was painted under it by + // then. + for t in texts { + let ratio = contrast(t.color, t.ground); + assert!( + ratio >= READABLE, + "{route:?} in {}: {:?} at {:?} is {ratio:.2}:1 ({:?} on {:?})", + s.label(), + t.text, + t.pos, + t.color, + t.ground + ); + checked += 1; + } + } + checked + } + + #[test] + fn every_scheme_paints_the_same_words_and_figures_readably() { + let dir = crate::test_support::TempDir( + std::env::temp_dir().join(format!("ledger-gui-schemes-painted-{}", std::process::id())), + ); + let _ = std::fs::remove_dir_all(&dir.0); + let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); + crate::demo::build(&dir.0, today).unwrap(); + let source = crate::data::Source { + data_dir: dir.0.join("books-data"), + books_toml: Some(dir.0.join("wanderbooks.toml")), + today: Some(today), + }; + let entry = crate::data::load_once(&source) + .unwrap() + .ledger + .posted_entries() + .next() + .map(|e| e.id()) + .expect("the demo books have entries"); + let routes = [ + Route::Entry(entry), + Route::Company, + Route::Assets, + Route::Documents, + Route::Overview, + Route::Cash, + Route::Profit, + Route::Transactions, + Route::Reports(ReportKind::ProfitAndLoss), + Route::Reports(ReportKind::BalanceSheet), + Route::Accounts, + Route::Parties, + Route::Journal, + ]; + // A page a thread: each checks every scheme. + let checked: usize = std::thread::scope(|scope| { + let threads: Vec<_> = routes + .into_iter() + .map(|route| { + let source = &source; + scope.spawn(move || page_in_every_scheme(source, route)) + }) + .collect(); + threads.into_iter().map(|t| t.join().unwrap()).sum() + }); + assert!(checked > 10_000, "{checked} texts checked"); + } +} diff --git a/crates/ledger-gui/src/schemes/table.rs b/crates/ledger-gui/src/schemes/table.rs new file mode 100644 index 0000000..840129a --- /dev/null +++ b/crates/ledger-gui/src/schemes/table.rs @@ -0,0 +1,1822 @@ +// Generated from each scheme's official palette (sources per family +// below; docs/color-schemes.md says how the columns were chosen). + +use super::{Core, Scheme, rgb}; + +/// Every scheme, family by family, in menu order. +pub(super) static SCHEMES: &[Scheme] = &[ + // Catppuccin: catppuccin/nvim lua/catppuccin/palettes/*.lua, groups/editor.lua + Scheme { + family: "Catppuccin", + name: "Latte", + dark: false, + core: Some(Core { + bg: rgb(0xef, 0xf1, 0xf5), + dim: rgb(0xe6, 0xe9, 0xef), + raised: rgb(0xcc, 0xd0, 0xda), + sel: rgb(0xbc, 0xc0, 0xcc), + border: rgb(0xbc, 0xc0, 0xcc), + fg: rgb(0x4c, 0x4f, 0x69), + muted: rgb(0x6c, 0x6f, 0x85), + faint: rgb(0x7c, 0x7f, 0x93), + accent: rgb(0x1e, 0x66, 0xf5), + red: rgb(0xd2, 0x0f, 0x39), + green: rgb(0x40, 0xa0, 0x2b), + yellow: rgb(0xdf, 0x8e, 0x1d), + orange: rgb(0xfe, 0x64, 0x0b), + blue: rgb(0x1e, 0x66, 0xf5), + purple: rgb(0x88, 0x39, 0xef), + cyan: rgb(0x17, 0x92, 0x99), + }), + }, + Scheme { + family: "Catppuccin", + name: "Frappé", + dark: true, + core: Some(Core { + bg: rgb(0x30, 0x34, 0x46), + dim: rgb(0x29, 0x2c, 0x3c), + raised: rgb(0x41, 0x45, 0x59), + sel: rgb(0x51, 0x57, 0x6d), + border: rgb(0x51, 0x57, 0x6d), + fg: rgb(0xc6, 0xd0, 0xf5), + muted: rgb(0xa5, 0xad, 0xce), + faint: rgb(0x94, 0x9c, 0xbb), + accent: rgb(0x8c, 0xaa, 0xee), + red: rgb(0xe7, 0x82, 0x84), + green: rgb(0xa6, 0xd1, 0x89), + yellow: rgb(0xe5, 0xc8, 0x90), + orange: rgb(0xef, 0x9f, 0x76), + blue: rgb(0x8c, 0xaa, 0xee), + purple: rgb(0xca, 0x9e, 0xe6), + cyan: rgb(0x81, 0xc8, 0xbe), + }), + }, + Scheme { + family: "Catppuccin", + name: "Macchiato", + dark: true, + core: Some(Core { + bg: rgb(0x24, 0x27, 0x3a), + dim: rgb(0x1e, 0x20, 0x30), + raised: rgb(0x36, 0x3a, 0x4f), + sel: rgb(0x49, 0x4d, 0x64), + border: rgb(0x49, 0x4d, 0x64), + fg: rgb(0xca, 0xd3, 0xf5), + muted: rgb(0xa5, 0xad, 0xcb), + faint: rgb(0x93, 0x9a, 0xb7), + accent: rgb(0x8a, 0xad, 0xf4), + red: rgb(0xed, 0x87, 0x96), + green: rgb(0xa6, 0xda, 0x95), + yellow: rgb(0xee, 0xd4, 0x9f), + orange: rgb(0xf5, 0xa9, 0x7f), + blue: rgb(0x8a, 0xad, 0xf4), + purple: rgb(0xc6, 0xa0, 0xf6), + cyan: rgb(0x8b, 0xd5, 0xca), + }), + }, + Scheme { + family: "Catppuccin", + name: "Mocha", + dark: true, + core: Some(Core { + bg: rgb(0x1e, 0x1e, 0x2e), + dim: rgb(0x18, 0x18, 0x25), + raised: rgb(0x31, 0x32, 0x44), + sel: rgb(0x45, 0x47, 0x5a), + border: rgb(0x45, 0x47, 0x5a), + fg: rgb(0xcd, 0xd6, 0xf4), + muted: rgb(0xa6, 0xad, 0xc8), + faint: rgb(0x93, 0x99, 0xb2), + accent: rgb(0x89, 0xb4, 0xfa), + red: rgb(0xf3, 0x8b, 0xa8), + green: rgb(0xa6, 0xe3, 0xa1), + yellow: rgb(0xf9, 0xe2, 0xaf), + orange: rgb(0xfa, 0xb3, 0x87), + blue: rgb(0x89, 0xb4, 0xfa), + purple: rgb(0xcb, 0xa6, 0xf7), + cyan: rgb(0x94, 0xe2, 0xd5), + }), + }, + // Tokyo Night: folke/tokyonight.nvim lua/tokyonight/colors/*.lua + Scheme { + family: "Tokyo Night", + name: "Night", + dark: true, + core: Some(Core { + bg: rgb(0x1a, 0x1b, 0x26), + dim: rgb(0x16, 0x16, 0x1e), + raised: rgb(0x29, 0x2e, 0x42), + sel: rgb(0x28, 0x34, 0x57), + border: rgb(0x3b, 0x42, 0x61), + fg: rgb(0xc0, 0xca, 0xf5), + muted: rgb(0xa9, 0xb1, 0xd6), + faint: rgb(0x56, 0x5f, 0x89), + accent: rgb(0x7a, 0xa2, 0xf7), + red: rgb(0xf7, 0x76, 0x8e), + green: rgb(0x9e, 0xce, 0x6a), + yellow: rgb(0xe0, 0xaf, 0x68), + orange: rgb(0xff, 0x9e, 0x64), + blue: rgb(0x7a, 0xa2, 0xf7), + purple: rgb(0xbb, 0x9a, 0xf7), + cyan: rgb(0x7d, 0xcf, 0xff), + }), + }, + Scheme { + family: "Tokyo Night", + name: "Storm", + dark: true, + core: Some(Core { + bg: rgb(0x24, 0x28, 0x3b), + dim: rgb(0x1f, 0x23, 0x35), + raised: rgb(0x29, 0x2e, 0x42), + sel: rgb(0x2e, 0x3c, 0x64), + border: rgb(0x3b, 0x42, 0x61), + fg: rgb(0xc0, 0xca, 0xf5), + muted: rgb(0xa9, 0xb1, 0xd6), + faint: rgb(0x56, 0x5f, 0x89), + accent: rgb(0x7a, 0xa2, 0xf7), + red: rgb(0xf7, 0x76, 0x8e), + green: rgb(0x9e, 0xce, 0x6a), + yellow: rgb(0xe0, 0xaf, 0x68), + orange: rgb(0xff, 0x9e, 0x64), + blue: rgb(0x7a, 0xa2, 0xf7), + purple: rgb(0xbb, 0x9a, 0xf7), + cyan: rgb(0x7d, 0xcf, 0xff), + }), + }, + Scheme { + family: "Tokyo Night", + name: "Moon", + dark: true, + core: Some(Core { + bg: rgb(0x22, 0x24, 0x36), + dim: rgb(0x1e, 0x20, 0x30), + raised: rgb(0x2f, 0x33, 0x4d), + sel: rgb(0x2d, 0x3f, 0x76), + border: rgb(0x3b, 0x42, 0x61), + fg: rgb(0xc8, 0xd3, 0xf5), + muted: rgb(0x82, 0x8b, 0xb8), + faint: rgb(0x63, 0x6d, 0xa6), + accent: rgb(0x82, 0xaa, 0xff), + red: rgb(0xff, 0x75, 0x7f), + green: rgb(0xc3, 0xe8, 0x8d), + yellow: rgb(0xff, 0xc7, 0x77), + orange: rgb(0xff, 0x96, 0x6c), + blue: rgb(0x82, 0xaa, 0xff), + purple: rgb(0xc0, 0x99, 0xff), + cyan: rgb(0x86, 0xe1, 0xfc), + }), + }, + Scheme { + family: "Tokyo Night", + name: "Day", + dark: false, + core: Some(Core { + bg: rgb(0xe1, 0xe2, 0xe7), + dim: rgb(0xd0, 0xd5, 0xe3), + raised: rgb(0xc4, 0xc8, 0xda), + sel: rgb(0xb7, 0xc1, 0xe3), + border: rgb(0xa8, 0xae, 0xcb), + fg: rgb(0x37, 0x60, 0xbf), + muted: rgb(0x61, 0x72, 0xb0), + faint: rgb(0x84, 0x8c, 0xb5), + accent: rgb(0x2e, 0x7d, 0xe9), + red: rgb(0xf5, 0x2a, 0x65), + green: rgb(0x58, 0x75, 0x39), + yellow: rgb(0x8c, 0x6c, 0x3e), + orange: rgb(0xb1, 0x5c, 0x00), + blue: rgb(0x2e, 0x7d, 0xe9), + purple: rgb(0x98, 0x54, 0xf1), + cyan: rgb(0x00, 0x71, 0x97), + }), + }, + // Gruvbox: ellisonleao/gruvbox.nvim lua/gruvbox.lua + Scheme { + family: "Gruvbox", + name: "Dark Hard", + dark: true, + core: Some(Core { + bg: rgb(0x1d, 0x20, 0x21), + dim: rgb(0x1d, 0x20, 0x21), + raised: rgb(0x3c, 0x38, 0x36), + sel: rgb(0x66, 0x5c, 0x54), + border: rgb(0x50, 0x49, 0x45), + fg: rgb(0xeb, 0xdb, 0xb2), + muted: rgb(0xbd, 0xae, 0x93), + faint: rgb(0x92, 0x83, 0x74), + accent: rgb(0x83, 0xa5, 0x98), + red: rgb(0xfb, 0x49, 0x34), + green: rgb(0xb8, 0xbb, 0x26), + yellow: rgb(0xfa, 0xbd, 0x2f), + orange: rgb(0xfe, 0x80, 0x19), + blue: rgb(0x83, 0xa5, 0x98), + purple: rgb(0xd3, 0x86, 0x9b), + cyan: rgb(0x8e, 0xc0, 0x7c), + }), + }, + Scheme { + family: "Gruvbox", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x28, 0x28, 0x28), + dim: rgb(0x1d, 0x20, 0x21), + raised: rgb(0x3c, 0x38, 0x36), + sel: rgb(0x66, 0x5c, 0x54), + border: rgb(0x50, 0x49, 0x45), + fg: rgb(0xeb, 0xdb, 0xb2), + muted: rgb(0xbd, 0xae, 0x93), + faint: rgb(0x92, 0x83, 0x74), + accent: rgb(0x83, 0xa5, 0x98), + red: rgb(0xfb, 0x49, 0x34), + green: rgb(0xb8, 0xbb, 0x26), + yellow: rgb(0xfa, 0xbd, 0x2f), + orange: rgb(0xfe, 0x80, 0x19), + blue: rgb(0x83, 0xa5, 0x98), + purple: rgb(0xd3, 0x86, 0x9b), + cyan: rgb(0x8e, 0xc0, 0x7c), + }), + }, + Scheme { + family: "Gruvbox", + name: "Dark Soft", + dark: true, + core: Some(Core { + bg: rgb(0x32, 0x30, 0x2f), + dim: rgb(0x28, 0x28, 0x28), + raised: rgb(0x3c, 0x38, 0x36), + sel: rgb(0x66, 0x5c, 0x54), + border: rgb(0x50, 0x49, 0x45), + fg: rgb(0xeb, 0xdb, 0xb2), + muted: rgb(0xbd, 0xae, 0x93), + faint: rgb(0x92, 0x83, 0x74), + accent: rgb(0x83, 0xa5, 0x98), + red: rgb(0xfb, 0x49, 0x34), + green: rgb(0xb8, 0xbb, 0x26), + yellow: rgb(0xfa, 0xbd, 0x2f), + orange: rgb(0xfe, 0x80, 0x19), + blue: rgb(0x83, 0xa5, 0x98), + purple: rgb(0xd3, 0x86, 0x9b), + cyan: rgb(0x8e, 0xc0, 0x7c), + }), + }, + Scheme { + family: "Gruvbox", + name: "Light Hard", + dark: false, + core: Some(Core { + bg: rgb(0xf9, 0xf5, 0xd7), + dim: rgb(0xf2, 0xe5, 0xbc), + raised: rgb(0xeb, 0xdb, 0xb2), + sel: rgb(0xbd, 0xae, 0x93), + border: rgb(0xd5, 0xc4, 0xa1), + fg: rgb(0x3c, 0x38, 0x36), + muted: rgb(0x66, 0x5c, 0x54), + faint: rgb(0x92, 0x83, 0x74), + accent: rgb(0x07, 0x66, 0x78), + red: rgb(0x9d, 0x00, 0x06), + green: rgb(0x79, 0x74, 0x0e), + yellow: rgb(0xb5, 0x76, 0x14), + orange: rgb(0xaf, 0x3a, 0x03), + blue: rgb(0x07, 0x66, 0x78), + purple: rgb(0x8f, 0x3f, 0x71), + cyan: rgb(0x42, 0x7b, 0x58), + }), + }, + Scheme { + family: "Gruvbox", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xfb, 0xf1, 0xc7), + dim: rgb(0xf2, 0xe5, 0xbc), + raised: rgb(0xeb, 0xdb, 0xb2), + sel: rgb(0xbd, 0xae, 0x93), + border: rgb(0xd5, 0xc4, 0xa1), + fg: rgb(0x3c, 0x38, 0x36), + muted: rgb(0x66, 0x5c, 0x54), + faint: rgb(0x92, 0x83, 0x74), + accent: rgb(0x07, 0x66, 0x78), + red: rgb(0x9d, 0x00, 0x06), + green: rgb(0x79, 0x74, 0x0e), + yellow: rgb(0xb5, 0x76, 0x14), + orange: rgb(0xaf, 0x3a, 0x03), + blue: rgb(0x07, 0x66, 0x78), + purple: rgb(0x8f, 0x3f, 0x71), + cyan: rgb(0x42, 0x7b, 0x58), + }), + }, + Scheme { + family: "Gruvbox", + name: "Light Soft", + dark: false, + core: Some(Core { + bg: rgb(0xf2, 0xe5, 0xbc), + dim: rgb(0xeb, 0xdb, 0xb2), + raised: rgb(0xd5, 0xc4, 0xa1), + sel: rgb(0xbd, 0xae, 0x93), + border: rgb(0xd5, 0xc4, 0xa1), + fg: rgb(0x3c, 0x38, 0x36), + muted: rgb(0x66, 0x5c, 0x54), + faint: rgb(0x92, 0x83, 0x74), + accent: rgb(0x07, 0x66, 0x78), + red: rgb(0x9d, 0x00, 0x06), + green: rgb(0x79, 0x74, 0x0e), + yellow: rgb(0xb5, 0x76, 0x14), + orange: rgb(0xaf, 0x3a, 0x03), + blue: rgb(0x07, 0x66, 0x78), + purple: rgb(0x8f, 0x3f, 0x71), + cyan: rgb(0x42, 0x7b, 0x58), + }), + }, + // Kanagawa: rebelot/kanagawa.nvim lua/kanagawa/colors.lua, themes.lua + Scheme { + family: "Kanagawa", + name: "Wave", + dark: true, + core: Some(Core { + bg: rgb(0x1f, 0x1f, 0x28), + dim: rgb(0x16, 0x16, 0x1d), + raised: rgb(0x2a, 0x2a, 0x37), + sel: rgb(0x22, 0x32, 0x49), + border: rgb(0x36, 0x36, 0x46), + fg: rgb(0xdc, 0xd7, 0xba), + muted: rgb(0xc8, 0xc0, 0x93), + faint: rgb(0x72, 0x71, 0x69), + accent: rgb(0x7e, 0x9c, 0xd8), + red: rgb(0xe8, 0x24, 0x24), + green: rgb(0x98, 0xbb, 0x6c), + yellow: rgb(0xe6, 0xc3, 0x84), + orange: rgb(0xff, 0xa0, 0x66), + blue: rgb(0x7e, 0x9c, 0xd8), + purple: rgb(0x95, 0x7f, 0xb8), + cyan: rgb(0x7a, 0xa8, 0x9f), + }), + }, + Scheme { + family: "Kanagawa", + name: "Dragon", + dark: true, + core: Some(Core { + bg: rgb(0x18, 0x16, 0x16), + dim: rgb(0x0d, 0x0c, 0x0c), + raised: rgb(0x28, 0x27, 0x27), + sel: rgb(0x22, 0x32, 0x49), + border: rgb(0x39, 0x38, 0x36), + fg: rgb(0xc5, 0xc9, 0xc5), + muted: rgb(0xa6, 0xa6, 0x9c), + faint: rgb(0x73, 0x7c, 0x73), + accent: rgb(0x8b, 0xa4, 0xb0), + red: rgb(0xc4, 0x74, 0x6e), + green: rgb(0x8a, 0x9a, 0x7b), + yellow: rgb(0xc4, 0xb2, 0x8a), + orange: rgb(0xb6, 0x92, 0x7b), + blue: rgb(0x8b, 0xa4, 0xb0), + purple: rgb(0x89, 0x92, 0xa7), + cyan: rgb(0x8e, 0xa4, 0xa2), + }), + }, + Scheme { + family: "Kanagawa", + name: "Lotus", + dark: false, + core: Some(Core { + bg: rgb(0xf2, 0xec, 0xbc), + dim: rgb(0xe5, 0xdd, 0xb0), + raised: rgb(0xe7, 0xdb, 0xa0), + sel: rgb(0xc9, 0xcb, 0xd1), + border: rgb(0xd5, 0xce, 0xa3), + fg: rgb(0x54, 0x54, 0x64), + muted: rgb(0x71, 0x6e, 0x61), + faint: rgb(0x8a, 0x89, 0x80), + accent: rgb(0x4d, 0x69, 0x9b), + red: rgb(0xc8, 0x40, 0x53), + green: rgb(0x6f, 0x89, 0x4e), + yellow: rgb(0x77, 0x71, 0x3f), + orange: rgb(0xcc, 0x6d, 0x00), + blue: rgb(0x4d, 0x69, 0x9b), + purple: rgb(0x62, 0x4c, 0x83), + cyan: rgb(0x59, 0x7b, 0x75), + }), + }, + // Rosé Pine: rose-pine/neovim lua/rose-pine/palette.lua + Scheme { + family: "Rosé Pine", + name: "Main", + dark: true, + core: Some(Core { + bg: rgb(0x19, 0x17, 0x24), + dim: rgb(0x1f, 0x1d, 0x2e), + raised: rgb(0x26, 0x23, 0x3a), + sel: rgb(0x40, 0x3d, 0x52), + border: rgb(0x52, 0x4f, 0x67), + fg: rgb(0xe0, 0xde, 0xf4), + muted: rgb(0x90, 0x8c, 0xaa), + faint: rgb(0x6e, 0x6a, 0x86), + accent: rgb(0xc4, 0xa7, 0xe7), + red: rgb(0xeb, 0x6f, 0x92), + green: rgb(0x9c, 0xcf, 0xd8), + yellow: rgb(0xf6, 0xc1, 0x77), + orange: rgb(0xeb, 0xbc, 0xba), + blue: rgb(0x31, 0x74, 0x8f), + purple: rgb(0xc4, 0xa7, 0xe7), + cyan: rgb(0x9c, 0xcf, 0xd8), + }), + }, + Scheme { + family: "Rosé Pine", + name: "Moon", + dark: true, + core: Some(Core { + bg: rgb(0x23, 0x21, 0x36), + dim: rgb(0x2a, 0x27, 0x3f), + raised: rgb(0x39, 0x35, 0x52), + sel: rgb(0x44, 0x41, 0x5a), + border: rgb(0x56, 0x52, 0x6e), + fg: rgb(0xe0, 0xde, 0xf4), + muted: rgb(0x90, 0x8c, 0xaa), + faint: rgb(0x6e, 0x6a, 0x86), + accent: rgb(0xc4, 0xa7, 0xe7), + red: rgb(0xeb, 0x6f, 0x92), + green: rgb(0x9c, 0xcf, 0xd8), + yellow: rgb(0xf6, 0xc1, 0x77), + orange: rgb(0xea, 0x9a, 0x97), + blue: rgb(0x3e, 0x8f, 0xb0), + purple: rgb(0xc4, 0xa7, 0xe7), + cyan: rgb(0x9c, 0xcf, 0xd8), + }), + }, + Scheme { + family: "Rosé Pine", + name: "Dawn", + dark: false, + core: Some(Core { + bg: rgb(0xfa, 0xf4, 0xed), + dim: rgb(0xff, 0xfa, 0xf3), + raised: rgb(0xf2, 0xe9, 0xe1), + sel: rgb(0xdf, 0xda, 0xd9), + border: rgb(0xce, 0xca, 0xcd), + fg: rgb(0x46, 0x42, 0x61), + muted: rgb(0x79, 0x75, 0x93), + faint: rgb(0x98, 0x93, 0xa5), + accent: rgb(0x90, 0x7a, 0xa9), + red: rgb(0xb4, 0x63, 0x7a), + green: rgb(0x56, 0x94, 0x9f), + yellow: rgb(0xea, 0x9d, 0x34), + orange: rgb(0xd7, 0x82, 0x7e), + blue: rgb(0x28, 0x69, 0x83), + purple: rgb(0x90, 0x7a, 0xa9), + cyan: rgb(0x56, 0x94, 0x9f), + }), + }, + // Nightfox: EdenEast/nightfox.nvim lua/nightfox/palette/*.lua + Scheme { + family: "Nightfox", + name: "Nightfox", + dark: true, + core: Some(Core { + bg: rgb(0x19, 0x23, 0x30), + dim: rgb(0x13, 0x1a, 0x24), + raised: rgb(0x21, 0x2e, 0x3f), + sel: rgb(0x2b, 0x3b, 0x51), + border: rgb(0x39, 0x50, 0x6d), + fg: rgb(0xcd, 0xce, 0xcf), + muted: rgb(0xae, 0xaf, 0xb0), + faint: rgb(0x73, 0x80, 0x91), + accent: rgb(0x71, 0x9c, 0xd6), + red: rgb(0xc9, 0x4f, 0x6d), + green: rgb(0x81, 0xb2, 0x9a), + yellow: rgb(0xdb, 0xc0, 0x74), + orange: rgb(0xf4, 0xa2, 0x61), + blue: rgb(0x71, 0x9c, 0xd6), + purple: rgb(0x9d, 0x79, 0xd6), + cyan: rgb(0x63, 0xcd, 0xcf), + }), + }, + Scheme { + family: "Nightfox", + name: "Duskfox", + dark: true, + core: Some(Core { + bg: rgb(0x23, 0x21, 0x36), + dim: rgb(0x19, 0x17, 0x26), + raised: rgb(0x2d, 0x2a, 0x45), + sel: rgb(0x43, 0x3c, 0x59), + border: rgb(0x4b, 0x46, 0x73), + fg: rgb(0xe0, 0xde, 0xf4), + muted: rgb(0xcd, 0xcb, 0xe0), + faint: rgb(0x81, 0x7c, 0x9c), + accent: rgb(0x56, 0x9f, 0xba), + red: rgb(0xeb, 0x6f, 0x92), + green: rgb(0xa3, 0xbe, 0x8c), + yellow: rgb(0xf6, 0xc1, 0x77), + orange: rgb(0xea, 0x9a, 0x97), + blue: rgb(0x56, 0x9f, 0xba), + purple: rgb(0xc4, 0xa7, 0xe7), + cyan: rgb(0x9c, 0xcf, 0xd8), + }), + }, + Scheme { + family: "Nightfox", + name: "Nordfox", + dark: true, + core: Some(Core { + bg: rgb(0x2e, 0x34, 0x40), + dim: rgb(0x23, 0x28, 0x31), + raised: rgb(0x39, 0x40, 0x4f), + sel: rgb(0x3e, 0x4a, 0x5b), + border: rgb(0x5a, 0x65, 0x7d), + fg: rgb(0xcd, 0xce, 0xcf), + muted: rgb(0xab, 0xb1, 0xbb), + faint: rgb(0x60, 0x72, 0x8a), + accent: rgb(0x81, 0xa1, 0xc1), + red: rgb(0xbf, 0x61, 0x6a), + green: rgb(0xa3, 0xbe, 0x8c), + yellow: rgb(0xeb, 0xcb, 0x8b), + orange: rgb(0xc9, 0x82, 0x6b), + blue: rgb(0x81, 0xa1, 0xc1), + purple: rgb(0xb4, 0x8e, 0xad), + cyan: rgb(0x88, 0xc0, 0xd0), + }), + }, + Scheme { + family: "Nightfox", + name: "Terafox", + dark: true, + core: Some(Core { + bg: rgb(0x15, 0x25, 0x28), + dim: rgb(0x0f, 0x1c, 0x1e), + raised: rgb(0x1d, 0x33, 0x37), + sel: rgb(0x29, 0x3e, 0x40), + border: rgb(0x2d, 0x4f, 0x56), + fg: rgb(0xe6, 0xea, 0xea), + muted: rgb(0xcb, 0xd9, 0xd8), + faint: rgb(0x6d, 0x7f, 0x8b), + accent: rgb(0x5a, 0x93, 0xaa), + red: rgb(0xe8, 0x5c, 0x51), + green: rgb(0x7a, 0xa4, 0xa1), + yellow: rgb(0xfd, 0xa4, 0x7f), + orange: rgb(0xff, 0x83, 0x49), + blue: rgb(0x5a, 0x93, 0xaa), + purple: rgb(0xad, 0x5c, 0x7c), + cyan: rgb(0xa1, 0xcd, 0xd8), + }), + }, + Scheme { + family: "Nightfox", + name: "Carbonfox", + dark: true, + core: Some(Core { + bg: rgb(0x16, 0x16, 0x16), + dim: rgb(0x0c, 0x0c, 0x0c), + raised: rgb(0x25, 0x25, 0x25), + sel: rgb(0x2a, 0x2a, 0x2a), + border: rgb(0x53, 0x53, 0x53), + fg: rgb(0xf2, 0xf4, 0xf8), + muted: rgb(0xb6, 0xb8, 0xbb), + faint: rgb(0x6e, 0x6f, 0x70), + accent: rgb(0x78, 0xa9, 0xff), + red: rgb(0xee, 0x53, 0x96), + green: rgb(0x25, 0xbe, 0x6a), + yellow: rgb(0x08, 0xbd, 0xba), + orange: rgb(0x3d, 0xdb, 0xd9), + blue: rgb(0x78, 0xa9, 0xff), + purple: rgb(0xbe, 0x95, 0xff), + cyan: rgb(0x33, 0xb1, 0xff), + }), + }, + Scheme { + family: "Nightfox", + name: "Dayfox", + dark: false, + core: Some(Core { + bg: rgb(0xf6, 0xf2, 0xee), + dim: rgb(0xe4, 0xdc, 0xd4), + raised: rgb(0xdb, 0xd1, 0xdd), + sel: rgb(0xe7, 0xd2, 0xbe), + border: rgb(0xaa, 0xb0, 0xad), + fg: rgb(0x3d, 0x2b, 0x5a), + muted: rgb(0x64, 0x3f, 0x61), + faint: rgb(0x83, 0x7a, 0x72), + accent: rgb(0x28, 0x48, 0xa9), + red: rgb(0xa5, 0x22, 0x2f), + green: rgb(0x39, 0x68, 0x47), + yellow: rgb(0xac, 0x54, 0x02), + orange: rgb(0x95, 0x5f, 0x61), + blue: rgb(0x28, 0x48, 0xa9), + purple: rgb(0x6e, 0x33, 0xce), + cyan: rgb(0x28, 0x79, 0x80), + }), + }, + Scheme { + family: "Nightfox", + name: "Dawnfox", + dark: false, + core: Some(Core { + bg: rgb(0xfa, 0xf4, 0xed), + dim: rgb(0xeb, 0xe5, 0xdf), + raised: rgb(0xeb, 0xe0, 0xdf), + sel: rgb(0xd0, 0xd8, 0xd8), + border: rgb(0xbd, 0xbf, 0xc9), + fg: rgb(0x57, 0x52, 0x79), + muted: rgb(0x62, 0x5c, 0x87), + faint: rgb(0x98, 0x93, 0xa5), + accent: rgb(0x28, 0x69, 0x83), + red: rgb(0xb4, 0x63, 0x7a), + green: rgb(0x61, 0x87, 0x74), + yellow: rgb(0xea, 0x9d, 0x34), + orange: rgb(0xd7, 0x82, 0x7e), + blue: rgb(0x28, 0x69, 0x83), + purple: rgb(0x90, 0x7a, 0xa9), + cyan: rgb(0x56, 0x94, 0x9f), + }), + }, + // Everforest: sainnhe/everforest autoload/everforest.vim + Scheme { + family: "Everforest", + name: "Dark Hard", + dark: true, + core: Some(Core { + bg: rgb(0x27, 0x2e, 0x33), + dim: rgb(0x1e, 0x23, 0x26), + raised: rgb(0x2e, 0x38, 0x3c), + sel: rgb(0x4c, 0x37, 0x43), + border: rgb(0x49, 0x51, 0x56), + fg: rgb(0xd3, 0xc6, 0xaa), + muted: rgb(0x9d, 0xa9, 0xa0), + faint: rgb(0x85, 0x92, 0x89), + accent: rgb(0x7f, 0xbb, 0xb3), + red: rgb(0xe6, 0x7e, 0x80), + green: rgb(0xa7, 0xc0, 0x80), + yellow: rgb(0xdb, 0xbc, 0x7f), + orange: rgb(0xe6, 0x98, 0x75), + blue: rgb(0x7f, 0xbb, 0xb3), + purple: rgb(0xd6, 0x99, 0xb6), + cyan: rgb(0x83, 0xc0, 0x92), + }), + }, + Scheme { + family: "Everforest", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x2d, 0x35, 0x3b), + dim: rgb(0x23, 0x2a, 0x2e), + raised: rgb(0x34, 0x3f, 0x44), + sel: rgb(0x54, 0x3a, 0x48), + border: rgb(0x4f, 0x58, 0x5e), + fg: rgb(0xd3, 0xc6, 0xaa), + muted: rgb(0x9d, 0xa9, 0xa0), + faint: rgb(0x85, 0x92, 0x89), + accent: rgb(0x7f, 0xbb, 0xb3), + red: rgb(0xe6, 0x7e, 0x80), + green: rgb(0xa7, 0xc0, 0x80), + yellow: rgb(0xdb, 0xbc, 0x7f), + orange: rgb(0xe6, 0x98, 0x75), + blue: rgb(0x7f, 0xbb, 0xb3), + purple: rgb(0xd6, 0x99, 0xb6), + cyan: rgb(0x83, 0xc0, 0x92), + }), + }, + Scheme { + family: "Everforest", + name: "Dark Soft", + dark: true, + core: Some(Core { + bg: rgb(0x33, 0x3c, 0x43), + dim: rgb(0x29, 0x31, 0x36), + raised: rgb(0x3a, 0x46, 0x4c), + sel: rgb(0x5c, 0x3f, 0x4f), + border: rgb(0x55, 0x5f, 0x66), + fg: rgb(0xd3, 0xc6, 0xaa), + muted: rgb(0x9d, 0xa9, 0xa0), + faint: rgb(0x85, 0x92, 0x89), + accent: rgb(0x7f, 0xbb, 0xb3), + red: rgb(0xe6, 0x7e, 0x80), + green: rgb(0xa7, 0xc0, 0x80), + yellow: rgb(0xdb, 0xbc, 0x7f), + orange: rgb(0xe6, 0x98, 0x75), + blue: rgb(0x7f, 0xbb, 0xb3), + purple: rgb(0xd6, 0x99, 0xb6), + cyan: rgb(0x83, 0xc0, 0x92), + }), + }, + Scheme { + family: "Everforest", + name: "Light Hard", + dark: false, + core: Some(Core { + bg: rgb(0xff, 0xfb, 0xef), + dim: rgb(0xf2, 0xef, 0xdf), + raised: rgb(0xf8, 0xf5, 0xe4), + sel: rgb(0xf0, 0xf2, 0xd4), + border: rgb(0xe8, 0xe5, 0xd5), + fg: rgb(0x5c, 0x6a, 0x72), + muted: rgb(0x82, 0x91, 0x81), + faint: rgb(0x93, 0x9f, 0x91), + accent: rgb(0x3a, 0x94, 0xc5), + red: rgb(0xf8, 0x55, 0x52), + green: rgb(0x8d, 0xa1, 0x01), + yellow: rgb(0xdf, 0xa0, 0x00), + orange: rgb(0xf5, 0x7d, 0x26), + blue: rgb(0x3a, 0x94, 0xc5), + purple: rgb(0xdf, 0x69, 0xba), + cyan: rgb(0x35, 0xa7, 0x7c), + }), + }, + Scheme { + family: "Everforest", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xfd, 0xf6, 0xe3), + dim: rgb(0xef, 0xeb, 0xd4), + raised: rgb(0xf4, 0xf0, 0xd9), + sel: rgb(0xea, 0xed, 0xc8), + border: rgb(0xe0, 0xdc, 0xc7), + fg: rgb(0x5c, 0x6a, 0x72), + muted: rgb(0x82, 0x91, 0x81), + faint: rgb(0x93, 0x9f, 0x91), + accent: rgb(0x3a, 0x94, 0xc5), + red: rgb(0xf8, 0x55, 0x52), + green: rgb(0x8d, 0xa1, 0x01), + yellow: rgb(0xdf, 0xa0, 0x00), + orange: rgb(0xf5, 0x7d, 0x26), + blue: rgb(0x3a, 0x94, 0xc5), + purple: rgb(0xdf, 0x69, 0xba), + cyan: rgb(0x35, 0xa7, 0x7c), + }), + }, + Scheme { + family: "Everforest", + name: "Light Soft", + dark: false, + core: Some(Core { + bg: rgb(0xf3, 0xea, 0xd3), + dim: rgb(0xe5, 0xdf, 0xc5), + raised: rgb(0xea, 0xe4, 0xca), + sel: rgb(0xe1, 0xe4, 0xbd), + border: rgb(0xd8, 0xd3, 0xba), + fg: rgb(0x5c, 0x6a, 0x72), + muted: rgb(0x82, 0x91, 0x81), + faint: rgb(0x93, 0x9f, 0x91), + accent: rgb(0x3a, 0x94, 0xc5), + red: rgb(0xf8, 0x55, 0x52), + green: rgb(0x8d, 0xa1, 0x01), + yellow: rgb(0xdf, 0xa0, 0x00), + orange: rgb(0xf5, 0x7d, 0x26), + blue: rgb(0x3a, 0x94, 0xc5), + purple: rgb(0xdf, 0x69, 0xba), + cyan: rgb(0x35, 0xa7, 0x7c), + }), + }, + // Nord: nordtheme/vim colors/nord.vim; nordtheme.com docs + Scheme { + family: "Nord", + name: "Nord", + dark: true, + core: Some(Core { + bg: rgb(0x2e, 0x34, 0x40), + dim: rgb(0x2e, 0x34, 0x40), + raised: rgb(0x3b, 0x42, 0x52), + sel: rgb(0x43, 0x4c, 0x5e), + border: rgb(0x4c, 0x56, 0x6a), + fg: rgb(0xd8, 0xde, 0xe9), + muted: rgb(0xe5, 0xe9, 0xf0), + faint: rgb(0x61, 0x6e, 0x88), + accent: rgb(0x88, 0xc0, 0xd0), + red: rgb(0xbf, 0x61, 0x6a), + green: rgb(0xa3, 0xbe, 0x8c), + yellow: rgb(0xeb, 0xcb, 0x8b), + orange: rgb(0xd0, 0x87, 0x70), + blue: rgb(0x81, 0xa1, 0xc1), + purple: rgb(0xb4, 0x8e, 0xad), + cyan: rgb(0x8f, 0xbc, 0xbb), + }), + }, + // Dracula: draculatheme.com/spec (Dracula, Alucard Classic) + Scheme { + family: "Dracula", + name: "Dracula", + dark: true, + core: Some(Core { + bg: rgb(0x28, 0x2a, 0x36), + dim: rgb(0x21, 0x22, 0x2c), + raised: rgb(0x34, 0x37, 0x46), + sel: rgb(0x44, 0x47, 0x5a), + border: rgb(0x44, 0x47, 0x5a), + fg: rgb(0xf8, 0xf8, 0xf2), + muted: rgb(0x62, 0x72, 0xa4), + faint: rgb(0x62, 0x72, 0xa4), + accent: rgb(0xbd, 0x93, 0xf9), + red: rgb(0xff, 0x55, 0x55), + green: rgb(0x50, 0xfa, 0x7b), + yellow: rgb(0xf1, 0xfa, 0x8c), + orange: rgb(0xff, 0xb8, 0x6c), + blue: rgb(0xbd, 0x93, 0xf9), + purple: rgb(0xbd, 0x93, 0xf9), + cyan: rgb(0x8b, 0xe9, 0xfd), + }), + }, + Scheme { + family: "Dracula", + name: "Alucard", + dark: false, + core: Some(Core { + bg: rgb(0xff, 0xfb, 0xeb), + dim: rgb(0xce, 0xcc, 0xc0), + raised: rgb(0xef, 0xed, 0xdc), + sel: rgb(0xcf, 0xcf, 0xde), + border: rgb(0xcf, 0xcf, 0xde), + fg: rgb(0x1f, 0x1f, 0x1f), + muted: rgb(0x6c, 0x66, 0x4b), + faint: rgb(0x6c, 0x66, 0x4b), + accent: rgb(0x64, 0x4a, 0xc9), + red: rgb(0xcb, 0x3a, 0x2a), + green: rgb(0x14, 0x71, 0x0a), + yellow: rgb(0x84, 0x6e, 0x15), + orange: rgb(0xa3, 0x4d, 0x14), + blue: rgb(0x64, 0x4a, 0xc9), + purple: rgb(0x64, 0x4a, 0xc9), + cyan: rgb(0x03, 0x6a, 0x96), + }), + }, + // One Dark: navarasu/onedark.nvim lua/onedark/palette.lua + Scheme { + family: "One Dark", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x28, 0x2c, 0x34), + dim: rgb(0x21, 0x25, 0x2b), + raised: rgb(0x31, 0x35, 0x3f), + sel: rgb(0x3b, 0x3f, 0x4c), + border: rgb(0x3b, 0x3f, 0x4c), + fg: rgb(0xab, 0xb2, 0xbf), + muted: rgb(0x84, 0x8b, 0x98), + faint: rgb(0x5c, 0x63, 0x70), + accent: rgb(0x61, 0xaf, 0xef), + red: rgb(0xe8, 0x66, 0x71), + green: rgb(0x98, 0xc3, 0x79), + yellow: rgb(0xe5, 0xc0, 0x7b), + orange: rgb(0xd1, 0x9a, 0x66), + blue: rgb(0x61, 0xaf, 0xef), + purple: rgb(0xc6, 0x78, 0xdd), + cyan: rgb(0x56, 0xb6, 0xc2), + }), + }, + Scheme { + family: "One Dark", + name: "Darker", + dark: true, + core: Some(Core { + bg: rgb(0x1f, 0x23, 0x29), + dim: rgb(0x18, 0x1b, 0x20), + raised: rgb(0x28, 0x2c, 0x34), + sel: rgb(0x32, 0x36, 0x41), + border: rgb(0x32, 0x36, 0x41), + fg: rgb(0xa0, 0xa8, 0xb7), + muted: rgb(0x7a, 0x81, 0x8e), + faint: rgb(0x53, 0x59, 0x65), + accent: rgb(0x4f, 0xa6, 0xed), + red: rgb(0xe5, 0x55, 0x61), + green: rgb(0x8e, 0xbd, 0x6b), + yellow: rgb(0xe2, 0xb8, 0x6b), + orange: rgb(0xcc, 0x90, 0x57), + blue: rgb(0x4f, 0xa6, 0xed), + purple: rgb(0xbf, 0x68, 0xd9), + cyan: rgb(0x48, 0xb0, 0xbd), + }), + }, + Scheme { + family: "One Dark", + name: "Cool", + dark: true, + core: Some(Core { + bg: rgb(0x24, 0x2b, 0x38), + dim: rgb(0x1e, 0x24, 0x2e), + raised: rgb(0x2d, 0x33, 0x43), + sel: rgb(0x36, 0x3c, 0x51), + border: rgb(0x36, 0x3c, 0x51), + fg: rgb(0xa5, 0xb0, 0xc5), + muted: rgb(0x7d, 0x89, 0x9f), + faint: rgb(0x54, 0x61, 0x78), + accent: rgb(0x5a, 0xb0, 0xf6), + red: rgb(0xef, 0x5f, 0x6b), + green: rgb(0x97, 0xca, 0x72), + yellow: rgb(0xeb, 0xc2, 0x75), + orange: rgb(0xd9, 0x9a, 0x5e), + blue: rgb(0x5a, 0xb0, 0xf6), + purple: rgb(0xca, 0x72, 0xe4), + cyan: rgb(0x4d, 0xbd, 0xcb), + }), + }, + Scheme { + family: "One Dark", + name: "Deep", + dark: true, + core: Some(Core { + bg: rgb(0x1a, 0x21, 0x2e), + dim: rgb(0x14, 0x1b, 0x24), + raised: rgb(0x21, 0x28, 0x3b), + sel: rgb(0x2a, 0x32, 0x4a), + border: rgb(0x2a, 0x32, 0x4a), + fg: rgb(0x93, 0xa4, 0xc3), + muted: rgb(0x6c, 0x7d, 0x9c), + faint: rgb(0x45, 0x55, 0x74), + accent: rgb(0x41, 0xa7, 0xfc), + red: rgb(0xf6, 0x58, 0x66), + green: rgb(0x8b, 0xcd, 0x5b), + yellow: rgb(0xef, 0xbd, 0x5d), + orange: rgb(0xdd, 0x90, 0x46), + blue: rgb(0x41, 0xa7, 0xfc), + purple: rgb(0xc7, 0x5a, 0xe8), + cyan: rgb(0x34, 0xbf, 0xd0), + }), + }, + Scheme { + family: "One Dark", + name: "Warm", + dark: true, + core: Some(Core { + bg: rgb(0x2c, 0x2d, 0x30), + dim: rgb(0x24, 0x26, 0x28), + raised: rgb(0x35, 0x37, 0x3b), + sel: rgb(0x40, 0x42, 0x47), + border: rgb(0x40, 0x42, 0x47), + fg: rgb(0xb1, 0xb4, 0xb9), + muted: rgb(0x8b, 0x8d, 0x91), + faint: rgb(0x64, 0x65, 0x68), + accent: rgb(0x68, 0xae, 0xe8), + red: rgb(0xe1, 0x6d, 0x77), + green: rgb(0x99, 0xbc, 0x80), + yellow: rgb(0xdf, 0xbe, 0x81), + orange: rgb(0xc9, 0x9a, 0x6e), + blue: rgb(0x68, 0xae, 0xe8), + purple: rgb(0xc2, 0x7f, 0xd7), + cyan: rgb(0x5f, 0xaf, 0xb9), + }), + }, + Scheme { + family: "One Dark", + name: "Warmer", + dark: true, + core: Some(Core { + bg: rgb(0x23, 0x23, 0x26), + dim: rgb(0x1b, 0x1c, 0x1e), + raised: rgb(0x2c, 0x2d, 0x31), + sel: rgb(0x37, 0x38, 0x3d), + border: rgb(0x37, 0x38, 0x3d), + fg: rgb(0xa7, 0xaa, 0xb0), + muted: rgb(0x81, 0x83, 0x87), + faint: rgb(0x5a, 0x5b, 0x5e), + accent: rgb(0x57, 0xa5, 0xe5), + red: rgb(0xde, 0x5d, 0x68), + green: rgb(0x8f, 0xb5, 0x73), + yellow: rgb(0xdb, 0xb6, 0x71), + orange: rgb(0xc4, 0x90, 0x60), + blue: rgb(0x57, 0xa5, 0xe5), + purple: rgb(0xbb, 0x70, 0xd2), + cyan: rgb(0x51, 0xa8, 0xb3), + }), + }, + Scheme { + family: "One Dark", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xfa, 0xfa, 0xfa), + dim: rgb(0xf0, 0xf0, 0xf0), + raised: rgb(0xe6, 0xe6, 0xe6), + sel: rgb(0xdc, 0xdc, 0xdc), + border: rgb(0xdc, 0xdc, 0xdc), + fg: rgb(0x38, 0x3a, 0x42), + muted: rgb(0x81, 0x83, 0x87), + faint: rgb(0xa0, 0xa1, 0xa7), + accent: rgb(0x40, 0x78, 0xf2), + red: rgb(0xe4, 0x56, 0x49), + green: rgb(0x50, 0xa1, 0x4f), + yellow: rgb(0x98, 0x68, 0x01), + orange: rgb(0xc1, 0x84, 0x01), + blue: rgb(0x40, 0x78, 0xf2), + purple: rgb(0xa6, 0x26, 0xa4), + cyan: rgb(0x01, 0x84, 0xbc), + }), + }, + // Solarized: altercation/solarized README, vim-colors-solarized + Scheme { + family: "Solarized", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x00, 0x2b, 0x36), + dim: rgb(0x00, 0x2b, 0x36), + raised: rgb(0x07, 0x36, 0x42), + sel: rgb(0x07, 0x36, 0x42), + border: rgb(0x65, 0x7b, 0x83), + fg: rgb(0x83, 0x94, 0x96), + muted: rgb(0x65, 0x7b, 0x83), + faint: rgb(0x58, 0x6e, 0x75), + accent: rgb(0x26, 0x8b, 0xd2), + red: rgb(0xdc, 0x32, 0x2f), + green: rgb(0x85, 0x99, 0x00), + yellow: rgb(0xb5, 0x89, 0x00), + orange: rgb(0xcb, 0x4b, 0x16), + blue: rgb(0x26, 0x8b, 0xd2), + purple: rgb(0x6c, 0x71, 0xc4), + cyan: rgb(0x2a, 0xa1, 0x98), + }), + }, + Scheme { + family: "Solarized", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xfd, 0xf6, 0xe3), + dim: rgb(0xfd, 0xf6, 0xe3), + raised: rgb(0xee, 0xe8, 0xd5), + sel: rgb(0xee, 0xe8, 0xd5), + border: rgb(0x83, 0x94, 0x96), + fg: rgb(0x65, 0x7b, 0x83), + muted: rgb(0x83, 0x94, 0x96), + faint: rgb(0x93, 0xa1, 0xa1), + accent: rgb(0x26, 0x8b, 0xd2), + red: rgb(0xdc, 0x32, 0x2f), + green: rgb(0x85, 0x99, 0x00), + yellow: rgb(0xb5, 0x89, 0x00), + orange: rgb(0xcb, 0x4b, 0x16), + blue: rgb(0x26, 0x8b, 0xd2), + purple: rgb(0x6c, 0x71, 0xc4), + cyan: rgb(0x2a, 0xa1, 0x98), + }), + }, + // Ayu: ayu-theme/vscode-ayu ayu-{dark,mirage,light}.json v1.5.0 + Scheme { + family: "Ayu", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x10, 0x14, 0x1c), + dim: rgb(0x0d, 0x10, 0x17), + raised: rgb(0x14, 0x18, 0x21), + sel: rgb(0x19, 0x31, 0x55), + border: rgb(0x1b, 0x1f, 0x29), + fg: rgb(0xbf, 0xbd, 0xb6), + muted: rgb(0x5a, 0x63, 0x78), + faint: rgb(0x5a, 0x66, 0x73), + accent: rgb(0xe6, 0xb4, 0x50), + red: rgb(0xf0, 0x71, 0x78), + green: rgb(0xaa, 0xd9, 0x4c), + yellow: rgb(0xff, 0xb4, 0x54), + orange: rgb(0xff, 0x8f, 0x40), + blue: rgb(0x59, 0xc2, 0xff), + purple: rgb(0xd2, 0xa6, 0xff), + cyan: rgb(0x95, 0xe6, 0xcb), + }), + }, + Scheme { + family: "Ayu", + name: "Mirage", + dark: true, + core: Some(Core { + bg: rgb(0x24, 0x29, 0x36), + dim: rgb(0x1f, 0x24, 0x30), + raised: rgb(0x28, 0x2e, 0x3b), + sel: rgb(0x2b, 0x47, 0x68), + border: rgb(0x17, 0x1b, 0x24), + fg: rgb(0xcc, 0xca, 0xc2), + muted: rgb(0x70, 0x7a, 0x8c), + faint: rgb(0x6e, 0x7c, 0x8f), + accent: rgb(0xff, 0xcc, 0x66), + red: rgb(0xf2, 0x87, 0x79), + green: rgb(0xd5, 0xff, 0x80), + yellow: rgb(0xff, 0xcd, 0x66), + orange: rgb(0xff, 0xa6, 0x59), + blue: rgb(0x73, 0xd0, 0xff), + purple: rgb(0xdf, 0xbf, 0xff), + cyan: rgb(0x95, 0xe6, 0xcb), + }), + }, + Scheme { + family: "Ayu", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xfc, 0xfc, 0xfc), + dim: rgb(0xf8, 0xf9, 0xfa), + raised: rgb(0xfa, 0xfa, 0xfa), + sel: rgb(0xd7, 0xe4, 0xf6), + border: rgb(0xea, 0xed, 0xef), + fg: rgb(0x5c, 0x61, 0x66), + muted: rgb(0x82, 0x8e, 0x9f), + faint: rgb(0xad, 0xae, 0xb1), + accent: rgb(0xf2, 0x97, 0x18), + red: rgb(0xf0, 0x71, 0x71), + green: rgb(0x86, 0xb3, 0x00), + yellow: rgb(0xeb, 0xa4, 0x00), + orange: rgb(0xfa, 0x85, 0x32), + blue: rgb(0x22, 0xa4, 0xe6), + purple: rgb(0xa3, 0x7a, 0xcc), + cyan: rgb(0x4c, 0xbf, 0x99), + }), + }, + // GitHub: projekt0n/github-nvim-theme lua/github-theme/palette/*.lua + Scheme { + family: "GitHub", + name: "Dark Default", + dark: true, + core: Some(Core { + bg: rgb(0x0d, 0x11, 0x17), + dim: rgb(0x01, 0x04, 0x09), + raised: rgb(0x16, 0x1b, 0x22), + sel: rgb(0x17, 0x33, 0x5a), + border: rgb(0x16, 0x1b, 0x22), + fg: rgb(0xe6, 0xed, 0xf3), + muted: rgb(0x7d, 0x85, 0x90), + faint: rgb(0x8b, 0x94, 0x9e), + accent: rgb(0x2f, 0x81, 0xf7), + red: rgb(0xf8, 0x51, 0x49), + green: rgb(0x3f, 0xb9, 0x50), + yellow: rgb(0xd2, 0x99, 0x22), + orange: rgb(0xdb, 0x6d, 0x28), + blue: rgb(0x58, 0xa6, 0xff), + purple: rgb(0xa3, 0x71, 0xf7), + cyan: rgb(0x76, 0xe3, 0xea), + }), + }, + Scheme { + family: "GitHub", + name: "Dark Dimmed", + dark: true, + core: Some(Core { + bg: rgb(0x22, 0x27, 0x2e), + dim: rgb(0x1c, 0x21, 0x28), + raised: rgb(0x2d, 0x33, 0x3b), + sel: rgb(0x36, 0x55, 0x7e), + border: rgb(0x44, 0x4c, 0x56), + fg: rgb(0xad, 0xba, 0xc7), + muted: rgb(0x90, 0x9d, 0xab), + faint: rgb(0x76, 0x83, 0x90), + accent: rgb(0x53, 0x9b, 0xf5), + red: rgb(0xe5, 0x53, 0x4b), + green: rgb(0x57, 0xab, 0x5a), + yellow: rgb(0xc6, 0x90, 0x26), + orange: rgb(0xcc, 0x6b, 0x2c), + blue: rgb(0x53, 0x9b, 0xf5), + purple: rgb(0x98, 0x6e, 0xe2), + cyan: rgb(0x76, 0xe3, 0xea), + }), + }, + Scheme { + family: "GitHub", + name: "Dark High Contrast", + dark: true, + core: Some(Core { + bg: rgb(0x0a, 0x0c, 0x10), + dim: rgb(0x01, 0x04, 0x09), + raised: rgb(0x27, 0x2b, 0x33), + sel: rgb(0x1a, 0x38, 0x58), + border: rgb(0xbd, 0xc4, 0xcc), + fg: rgb(0xf0, 0xf3, 0xf6), + muted: rgb(0xd9, 0xde, 0xe3), + faint: rgb(0xbd, 0xc4, 0xcc), + accent: rgb(0x40, 0x9e, 0xff), + red: rgb(0xff, 0x6a, 0x69), + green: rgb(0x09, 0xb4, 0x3a), + yellow: rgb(0xe0, 0x9b, 0x13), + orange: rgb(0xe7, 0x81, 0x1d), + blue: rgb(0x71, 0xb7, 0xff), + purple: rgb(0xb7, 0x80, 0xff), + cyan: rgb(0x76, 0xe3, 0xea), + }), + }, + Scheme { + family: "GitHub", + name: "Light Default", + dark: false, + core: Some(Core { + bg: rgb(0xff, 0xff, 0xff), + dim: rgb(0xf6, 0xf8, 0xfa), + raised: rgb(0xf6, 0xf8, 0xfa), + sel: rgb(0xda, 0xe9, 0xf9), + border: rgb(0xd0, 0xd7, 0xde), + fg: rgb(0x1f, 0x23, 0x28), + muted: rgb(0x32, 0x38, 0x3f), + faint: rgb(0x57, 0x60, 0x6a), + accent: rgb(0x09, 0x69, 0xda), + red: rgb(0xd1, 0x24, 0x2f), + green: rgb(0x1a, 0x7f, 0x37), + yellow: rgb(0x9a, 0x67, 0x00), + orange: rgb(0xbc, 0x4c, 0x00), + blue: rgb(0x09, 0x69, 0xda), + purple: rgb(0x82, 0x50, 0xdf), + cyan: rgb(0x1b, 0x7c, 0x83), + }), + }, + Scheme { + family: "GitHub", + name: "Light High Contrast", + dark: false, + core: Some(Core { + bg: rgb(0xff, 0xff, 0xff), + dim: rgb(0xe7, 0xec, 0xf0), + raised: rgb(0xe7, 0xec, 0xf0), + sel: rgb(0xcd, 0xdb, 0xf0), + border: rgb(0x20, 0x25, 0x2c), + fg: rgb(0x01, 0x04, 0x09), + muted: rgb(0x20, 0x25, 0x2c), + faint: rgb(0x4b, 0x53, 0x5d), + accent: rgb(0x03, 0x49, 0xb4), + red: rgb(0xd1, 0x24, 0x2f), + green: rgb(0x05, 0x5d, 0x20), + yellow: rgb(0x74, 0x45, 0x00), + orange: rgb(0x87, 0x38, 0x00), + blue: rgb(0x03, 0x49, 0xb4), + purple: rgb(0x62, 0x2c, 0xbc), + cyan: rgb(0x1b, 0x7c, 0x83), + }), + }, + // Material: marko-cerovac/material.nvim lua/material/colors/init.lua + Scheme { + family: "Material", + name: "Darker", + dark: true, + core: Some(Core { + bg: rgb(0x21, 0x21, 0x21), + dim: rgb(0x1a, 0x1a, 0x1a), + raised: rgb(0x32, 0x32, 0x32), + sel: rgb(0x40, 0x40, 0x40), + border: rgb(0x34, 0x34, 0x34), + fg: rgb(0xb0, 0xbe, 0xc5), + muted: rgb(0x8c, 0x8b, 0x8b), + faint: rgb(0x51, 0x51, 0x51), + accent: rgb(0x80, 0xcb, 0xc4), + red: rgb(0xf0, 0x71, 0x78), + green: rgb(0xc3, 0xe8, 0x8d), + yellow: rgb(0xff, 0xcb, 0x6b), + orange: rgb(0xf7, 0x8c, 0x6c), + blue: rgb(0x82, 0xaa, 0xff), + purple: rgb(0xc7, 0x92, 0xea), + cyan: rgb(0x89, 0xdd, 0xff), + }), + }, + Scheme { + family: "Material", + name: "Deep Ocean", + dark: true, + core: Some(Core { + bg: rgb(0x0f, 0x11, 0x1a), + dim: rgb(0x09, 0x0b, 0x10), + raised: rgb(0x1a, 0x1c, 0x25), + sel: rgb(0x1f, 0x22, 0x33), + border: rgb(0x23, 0x26, 0x37), + fg: rgb(0xa6, 0xac, 0xcd), + muted: rgb(0x71, 0x7c, 0xb4), + faint: rgb(0x46, 0x4b, 0x5d), + accent: rgb(0x80, 0xcb, 0xc4), + red: rgb(0xf0, 0x71, 0x78), + green: rgb(0xc3, 0xe8, 0x8d), + yellow: rgb(0xff, 0xcb, 0x6b), + orange: rgb(0xf7, 0x8c, 0x6c), + blue: rgb(0x82, 0xaa, 0xff), + purple: rgb(0xc7, 0x92, 0xea), + cyan: rgb(0x89, 0xdd, 0xff), + }), + }, + Scheme { + family: "Material", + name: "Oceanic", + dark: true, + core: Some(Core { + bg: rgb(0x25, 0x36, 0x3b), + dim: rgb(0x1c, 0x2c, 0x30), + raised: rgb(0x31, 0x45, 0x49), + sel: rgb(0x39, 0x5b, 0x65), + border: rgb(0x35, 0x50, 0x58), + fg: rgb(0xb0, 0xbe, 0xc5), + muted: rgb(0x7c, 0x9e, 0xad), + faint: rgb(0x54, 0x6e, 0x7a), + accent: rgb(0x80, 0xcb, 0xc4), + red: rgb(0xf0, 0x71, 0x78), + green: rgb(0xc3, 0xe8, 0x8d), + yellow: rgb(0xff, 0xcb, 0x6b), + orange: rgb(0xf7, 0x8c, 0x6c), + blue: rgb(0x82, 0xaa, 0xff), + purple: rgb(0xc7, 0x92, 0xea), + cyan: rgb(0x89, 0xdd, 0xff), + }), + }, + Scheme { + family: "Material", + name: "Palenight", + dark: true, + core: Some(Core { + bg: rgb(0x29, 0x2d, 0x3e), + dim: rgb(0x1b, 0x1e, 0x2b), + raised: rgb(0x41, 0x48, 0x63), + sel: rgb(0x44, 0x42, 0x67), + border: rgb(0x36, 0x43, 0x67), + fg: rgb(0xa6, 0xac, 0xcd), + muted: rgb(0x71, 0x7c, 0xb4), + faint: rgb(0x67, 0x6e, 0x95), + accent: rgb(0x80, 0xcb, 0xc4), + red: rgb(0xf0, 0x71, 0x78), + green: rgb(0xc3, 0xe8, 0x8d), + yellow: rgb(0xff, 0xcb, 0x6b), + orange: rgb(0xf7, 0x8c, 0x6c), + blue: rgb(0x82, 0xaa, 0xff), + purple: rgb(0xc7, 0x92, 0xea), + cyan: rgb(0x89, 0xdd, 0xff), + }), + }, + Scheme { + family: "Material", + name: "Lighter", + dark: false, + core: Some(Core { + bg: rgb(0xfa, 0xfa, 0xfa), + dim: rgb(0xff, 0xff, 0xff), + raised: rgb(0xe7, 0xe7, 0xe8), + sel: rgb(0x80, 0xcb, 0xc4), + border: rgb(0xd3, 0xe1, 0xe8), + fg: rgb(0x54, 0x6e, 0x7a), + muted: rgb(0x94, 0xa7, 0xb0), + faint: rgb(0xaa, 0xbf, 0xc9), + accent: rgb(0x00, 0xbc, 0xd4), + red: rgb(0xe5, 0x39, 0x35), + green: rgb(0x91, 0xb8, 0x59), + yellow: rgb(0xf6, 0xa4, 0x34), + orange: rgb(0xf7, 0x6d, 0x47), + blue: rgb(0x61, 0x82, 0xb8), + purple: rgb(0x7c, 0x4d, 0xff), + cyan: rgb(0x39, 0xad, 0xb5), + }), + }, + // Monokai Pro: loctvl842/monokai-pro.nvim lua/monokai-pro/palette/*.lua + Scheme { + family: "Monokai Pro", + name: "Pro", + dark: true, + core: Some(Core { + bg: rgb(0x2d, 0x2a, 0x2e), + dim: rgb(0x22, 0x1f, 0x22), + raised: rgb(0x40, 0x3e, 0x41), + sel: rgb(0x43, 0x41, 0x44), + border: rgb(0x5b, 0x59, 0x5c), + fg: rgb(0xfc, 0xfc, 0xfa), + muted: rgb(0xc1, 0xc0, 0xc0), + faint: rgb(0x72, 0x70, 0x72), + accent: rgb(0xff, 0xd8, 0x66), + red: rgb(0xff, 0x61, 0x88), + green: rgb(0xa9, 0xdc, 0x76), + yellow: rgb(0xff, 0xd8, 0x66), + orange: rgb(0xfc, 0x98, 0x67), + blue: rgb(0x78, 0xdc, 0xe8), + purple: rgb(0xab, 0x9d, 0xf2), + cyan: rgb(0x78, 0xdc, 0xe8), + }), + }, + Scheme { + family: "Monokai Pro", + name: "Classic", + dark: true, + core: Some(Core { + bg: rgb(0x27, 0x28, 0x22), + dim: rgb(0x1d, 0x1e, 0x19), + raised: rgb(0x3b, 0x3c, 0x35), + sel: rgb(0x3e, 0x3f, 0x38), + border: rgb(0x57, 0x58, 0x4f), + fg: rgb(0xfd, 0xff, 0xf1), + muted: rgb(0xc0, 0xc1, 0xb5), + faint: rgb(0x6e, 0x70, 0x66), + accent: rgb(0xe6, 0xdb, 0x74), + red: rgb(0xf9, 0x26, 0x72), + green: rgb(0xa6, 0xe2, 0x2e), + yellow: rgb(0xe6, 0xdb, 0x74), + orange: rgb(0xfd, 0x97, 0x1f), + blue: rgb(0x66, 0xd9, 0xef), + purple: rgb(0xae, 0x81, 0xff), + cyan: rgb(0x66, 0xd9, 0xef), + }), + }, + Scheme { + family: "Monokai Pro", + name: "Machine", + dark: true, + core: Some(Core { + bg: rgb(0x27, 0x31, 0x36), + dim: rgb(0x1d, 0x25, 0x28), + raised: rgb(0x3a, 0x44, 0x49), + sel: rgb(0x3d, 0x47, 0x4b), + border: rgb(0x54, 0x5f, 0x62), + fg: rgb(0xf2, 0xff, 0xfc), + muted: rgb(0xb8, 0xc4, 0xc3), + faint: rgb(0x6b, 0x76, 0x78), + accent: rgb(0xff, 0xed, 0x72), + red: rgb(0xff, 0x6d, 0x7e), + green: rgb(0xa2, 0xe5, 0x7b), + yellow: rgb(0xff, 0xed, 0x72), + orange: rgb(0xff, 0xb2, 0x70), + blue: rgb(0x7c, 0xd5, 0xf1), + purple: rgb(0xba, 0xa0, 0xf8), + cyan: rgb(0x7c, 0xd5, 0xf1), + }), + }, + Scheme { + family: "Monokai Pro", + name: "Octagon", + dark: true, + core: Some(Core { + bg: rgb(0x28, 0x2a, 0x3a), + dim: rgb(0x1e, 0x1f, 0x2b), + raised: rgb(0x3a, 0x3d, 0x4b), + sel: rgb(0x3d, 0x3f, 0x4e), + border: rgb(0x53, 0x57, 0x63), + fg: rgb(0xea, 0xf2, 0xf1), + muted: rgb(0xb2, 0xb9, 0xbd), + faint: rgb(0x69, 0x6d, 0x77), + accent: rgb(0xff, 0xd7, 0x6d), + red: rgb(0xff, 0x65, 0x7a), + green: rgb(0xba, 0xd7, 0x61), + yellow: rgb(0xff, 0xd7, 0x6d), + orange: rgb(0xff, 0x9b, 0x5e), + blue: rgb(0x9c, 0xd1, 0xbb), + purple: rgb(0xc3, 0x9a, 0xc9), + cyan: rgb(0x9c, 0xd1, 0xbb), + }), + }, + Scheme { + family: "Monokai Pro", + name: "Ristretto", + dark: true, + core: Some(Core { + bg: rgb(0x2c, 0x25, 0x25), + dim: rgb(0x21, 0x1c, 0x1c), + raised: rgb(0x40, 0x38, 0x38), + sel: rgb(0x43, 0x3b, 0x3b), + border: rgb(0x5b, 0x53, 0x53), + fg: rgb(0xff, 0xf1, 0xf3), + muted: rgb(0xc3, 0xb7, 0xb8), + faint: rgb(0x72, 0x69, 0x6a), + accent: rgb(0xf9, 0xcc, 0x6c), + red: rgb(0xfd, 0x68, 0x83), + green: rgb(0xad, 0xda, 0x78), + yellow: rgb(0xf9, 0xcc, 0x6c), + orange: rgb(0xf3, 0x8d, 0x70), + blue: rgb(0x85, 0xda, 0xcc), + purple: rgb(0xa8, 0xa9, 0xeb), + cyan: rgb(0x85, 0xda, 0xcc), + }), + }, + Scheme { + family: "Monokai Pro", + name: "Spectrum", + dark: true, + core: Some(Core { + bg: rgb(0x22, 0x22, 0x22), + dim: rgb(0x19, 0x19, 0x19), + raised: rgb(0x36, 0x35, 0x37), + sel: rgb(0x39, 0x38, 0x3a), + border: rgb(0x52, 0x50, 0x53), + fg: rgb(0xf7, 0xf1, 0xff), + muted: rgb(0xba, 0xb6, 0xc0), + faint: rgb(0x69, 0x67, 0x6c), + accent: rgb(0xfc, 0xe5, 0x66), + red: rgb(0xfc, 0x61, 0x8d), + green: rgb(0x7b, 0xd8, 0x8f), + yellow: rgb(0xfc, 0xe5, 0x66), + orange: rgb(0xfd, 0x93, 0x53), + blue: rgb(0x5a, 0xd4, 0xe6), + purple: rgb(0x94, 0x8a, 0xe3), + cyan: rgb(0x5a, 0xd4, 0xe6), + }), + }, + // Sonokai: sainnhe/sonokai autoload/sonokai.vim + Scheme { + family: "Sonokai", + name: "Default", + dark: true, + core: Some(Core { + bg: rgb(0x2c, 0x2e, 0x34), + dim: rgb(0x22, 0x23, 0x27), + raised: rgb(0x33, 0x35, 0x3f), + sel: rgb(0x41, 0x45, 0x50), + border: rgb(0x41, 0x45, 0x50), + fg: rgb(0xe2, 0xe2, 0xe3), + muted: rgb(0x7f, 0x84, 0x90), + faint: rgb(0x7f, 0x84, 0x90), + accent: rgb(0x76, 0xcc, 0xe0), + red: rgb(0xfc, 0x5d, 0x7c), + green: rgb(0x9e, 0xd0, 0x72), + yellow: rgb(0xe7, 0xc6, 0x64), + orange: rgb(0xf3, 0x96, 0x60), + blue: rgb(0x76, 0xcc, 0xe0), + purple: rgb(0xb3, 0x9d, 0xf3), + cyan: rgb(0x76, 0xcc, 0xe0), + }), + }, + Scheme { + family: "Sonokai", + name: "Atlantis", + dark: true, + core: Some(Core { + bg: rgb(0x2a, 0x2f, 0x38), + dim: rgb(0x24, 0x27, 0x2e), + raised: rgb(0x33, 0x38, 0x46), + sel: rgb(0x42, 0x4b, 0x5b), + border: rgb(0x42, 0x4b, 0x5b), + fg: rgb(0xe1, 0xe3, 0xe4), + muted: rgb(0x82, 0x8a, 0x9a), + faint: rgb(0x82, 0x8a, 0x9a), + accent: rgb(0x72, 0xcc, 0xe8), + red: rgb(0xff, 0x65, 0x78), + green: rgb(0x9d, 0xd2, 0x74), + yellow: rgb(0xea, 0xcb, 0x64), + orange: rgb(0xf6, 0x9c, 0x5e), + blue: rgb(0x72, 0xcc, 0xe8), + purple: rgb(0xba, 0x9c, 0xf3), + cyan: rgb(0x72, 0xcc, 0xe8), + }), + }, + Scheme { + family: "Sonokai", + name: "Andromeda", + dark: true, + core: Some(Core { + bg: rgb(0x2b, 0x2d, 0x3a), + dim: rgb(0x25, 0x26, 0x30), + raised: rgb(0x33, 0x36, 0x48), + sel: rgb(0x3f, 0x44, 0x5b), + border: rgb(0x3f, 0x44, 0x5b), + fg: rgb(0xe1, 0xe3, 0xe4), + muted: rgb(0x7e, 0x82, 0x94), + faint: rgb(0x7e, 0x82, 0x94), + accent: rgb(0x6d, 0xca, 0xe8), + red: rgb(0xfb, 0x61, 0x7e), + green: rgb(0x9e, 0xd0, 0x6c), + yellow: rgb(0xed, 0xc7, 0x63), + orange: rgb(0xf8, 0x98, 0x60), + blue: rgb(0x6d, 0xca, 0xe8), + purple: rgb(0xbb, 0x97, 0xee), + cyan: rgb(0x6d, 0xca, 0xe8), + }), + }, + Scheme { + family: "Sonokai", + name: "Shusia", + dark: true, + core: Some(Core { + bg: rgb(0x2d, 0x2a, 0x2e), + dim: rgb(0x21, 0x1f, 0x21), + raised: rgb(0x37, 0x34, 0x3a), + sel: rgb(0x49, 0x46, 0x4e), + border: rgb(0x49, 0x46, 0x4e), + fg: rgb(0xe3, 0xe1, 0xe4), + muted: rgb(0x84, 0x80, 0x89), + faint: rgb(0x84, 0x80, 0x89), + accent: rgb(0x7a, 0xcc, 0xd7), + red: rgb(0xf8, 0x5e, 0x84), + green: rgb(0x9e, 0xcd, 0x6f), + yellow: rgb(0xe5, 0xc4, 0x63), + orange: rgb(0xef, 0x90, 0x62), + blue: rgb(0x7a, 0xcc, 0xd7), + purple: rgb(0xab, 0x9d, 0xf2), + cyan: rgb(0x7a, 0xcc, 0xd7), + }), + }, + Scheme { + family: "Sonokai", + name: "Maia", + dark: true, + core: Some(Core { + bg: rgb(0x27, 0x31, 0x36), + dim: rgb(0x21, 0x28, 0x2c), + raised: rgb(0x31, 0x3b, 0x42), + sel: rgb(0x41, 0x4b, 0x53), + border: rgb(0x41, 0x4b, 0x53), + fg: rgb(0xe1, 0xe2, 0xe3), + muted: rgb(0x82, 0x87, 0x8b), + faint: rgb(0x82, 0x87, 0x8b), + accent: rgb(0x78, 0xce, 0xe9), + red: rgb(0xf7, 0x6c, 0x7c), + green: rgb(0x9c, 0xd5, 0x7b), + yellow: rgb(0xe3, 0xd3, 0x67), + orange: rgb(0xf3, 0xa9, 0x6a), + blue: rgb(0x78, 0xce, 0xe9), + purple: rgb(0xba, 0xa0, 0xf8), + cyan: rgb(0x78, 0xce, 0xe9), + }), + }, + Scheme { + family: "Sonokai", + name: "Espresso", + dark: true, + core: Some(Core { + bg: rgb(0x31, 0x2c, 0x2b), + dim: rgb(0x24, 0x21, 0x20), + raised: rgb(0x39, 0x32, 0x30), + sel: rgb(0x4e, 0x43, 0x3f), + border: rgb(0x4e, 0x43, 0x3f), + fg: rgb(0xe4, 0xe3, 0xe1), + muted: rgb(0x90, 0x81, 0x7b), + faint: rgb(0x90, 0x81, 0x7b), + accent: rgb(0x81, 0xd0, 0xc9), + red: rgb(0xf8, 0x68, 0x82), + green: rgb(0xa6, 0xcd, 0x77), + yellow: rgb(0xf0, 0xc6, 0x6f), + orange: rgb(0xf0, 0x8d, 0x71), + blue: rgb(0x81, 0xd0, 0xc9), + purple: rgb(0x9f, 0xa0, 0xe1), + cyan: rgb(0x81, 0xd0, 0xc9), + }), + }, + // Oxocarbon: nyoom-engineering/oxocarbon.nvim fnl/oxocarbon/init.fnl (yellow: IBM Carbon) + Scheme { + family: "Oxocarbon", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x16, 0x16, 0x16), + dim: rgb(0x13, 0x13, 0x13), + raised: rgb(0x26, 0x26, 0x26), + sel: rgb(0x39, 0x39, 0x39), + border: rgb(0x39, 0x39, 0x39), + fg: rgb(0xd0, 0xd0, 0xd0), + muted: rgb(0xd0, 0xd0, 0xd0), + faint: rgb(0x52, 0x52, 0x52), + accent: rgb(0x78, 0xa9, 0xff), + red: rgb(0xee, 0x53, 0x96), + green: rgb(0x42, 0xbe, 0x65), + yellow: rgb(0xf1, 0xc2, 0x1b), + orange: rgb(0xff, 0x7e, 0xb6), + blue: rgb(0x78, 0xa9, 0xff), + purple: rgb(0xbe, 0x95, 0xff), + cyan: rgb(0x3d, 0xdb, 0xd9), + }), + }, + Scheme { + family: "Oxocarbon", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xff, 0xff, 0xff), + dim: rgb(0xfa, 0xfa, 0xfa), + raised: rgb(0xf2, 0xf2, 0xf2), + sel: rgb(0xd0, 0xd0, 0xd0), + border: rgb(0xd0, 0xd0, 0xd0), + fg: rgb(0x37, 0x47, 0x4f), + muted: rgb(0x52, 0x52, 0x52), + faint: rgb(0x90, 0xa4, 0xae), + accent: rgb(0x0f, 0x62, 0xfe), + red: rgb(0xee, 0x53, 0x96), + green: rgb(0x42, 0xbe, 0x65), + yellow: rgb(0xb2, 0x86, 0x00), + orange: rgb(0xff, 0x6f, 0x00), + blue: rgb(0x0f, 0x62, 0xfe), + purple: rgb(0x67, 0x3a, 0xb7), + cyan: rgb(0x08, 0xbd, 0xba), + }), + }, + // Night Owl: sdras/night-owl-vscode-theme themes/*.json + Scheme { + family: "Night Owl", + name: "Night Owl", + dark: true, + core: Some(Core { + bg: rgb(0x01, 0x16, 0x27), + dim: rgb(0x01, 0x0e, 0x1a), + raised: rgb(0x0b, 0x29, 0x42), + sel: rgb(0x1d, 0x3b, 0x53), + border: rgb(0x12, 0x2d, 0x42), + fg: rgb(0xd6, 0xde, 0xeb), + muted: rgb(0x89, 0xa4, 0xbb), + faint: rgb(0x63, 0x77, 0x77), + accent: rgb(0x82, 0xaa, 0xff), + red: rgb(0xef, 0x53, 0x50), + green: rgb(0x22, 0xda, 0x6e), + yellow: rgb(0xec, 0xc4, 0x8d), + orange: rgb(0xf7, 0x8c, 0x6c), + blue: rgb(0x82, 0xaa, 0xff), + purple: rgb(0xc7, 0x92, 0xea), + cyan: rgb(0x7f, 0xdb, 0xca), + }), + }, + Scheme { + family: "Night Owl", + name: "Light Owl", + dark: false, + core: Some(Core { + bg: rgb(0xfb, 0xfb, 0xfb), + dim: rgb(0xf0, 0xf0, 0xf0), + raised: rgb(0xf0, 0xf0, 0xf0), + sel: rgb(0xe0, 0xe0, 0xe0), + border: rgb(0xd9, 0xd9, 0xd9), + fg: rgb(0x40, 0x3f, 0x53), + muted: rgb(0x90, 0xa7, 0xb2), + faint: rgb(0x98, 0x9f, 0xb1), + accent: rgb(0x28, 0x8e, 0xd7), + red: rgb(0xde, 0x3d, 0x3b), + green: rgb(0x08, 0x91, 0x6a), + yellow: rgb(0xe0, 0xaf, 0x02), + orange: rgb(0xaa, 0x09, 0x82), + blue: rgb(0x28, 0x8e, 0xd7), + purple: rgb(0x99, 0x4c, 0xc3), + cyan: rgb(0x2a, 0xa2, 0x98), + }), + }, + // Cyberdream: scottmckendry/cyberdream.nvim lua/cyberdream/colors.lua + Scheme { + family: "Cyberdream", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x16, 0x18, 0x1a), + dim: rgb(0x16, 0x18, 0x1a), + raised: rgb(0x1e, 0x21, 0x24), + sel: rgb(0x3c, 0x40, 0x48), + border: rgb(0x3c, 0x40, 0x48), + fg: rgb(0xff, 0xff, 0xff), + muted: rgb(0x7b, 0x84, 0x96), + faint: rgb(0x7b, 0x84, 0x96), + accent: rgb(0x5e, 0xa1, 0xff), + red: rgb(0xff, 0x6e, 0x5e), + green: rgb(0x5e, 0xff, 0x6c), + yellow: rgb(0xf1, 0xff, 0x5e), + orange: rgb(0xff, 0xbd, 0x5e), + blue: rgb(0x5e, 0xa1, 0xff), + purple: rgb(0xbd, 0x5e, 0xff), + cyan: rgb(0x5e, 0xf1, 0xff), + }), + }, + Scheme { + family: "Cyberdream", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xff, 0xff, 0xff), + dim: rgb(0xff, 0xff, 0xff), + raised: rgb(0xea, 0xea, 0xea), + sel: rgb(0xac, 0xac, 0xac), + border: rgb(0xac, 0xac, 0xac), + fg: rgb(0x16, 0x18, 0x1a), + muted: rgb(0x7b, 0x84, 0x96), + faint: rgb(0x7b, 0x84, 0x96), + accent: rgb(0x00, 0x57, 0xd1), + red: rgb(0xd1, 0x15, 0x00), + green: rgb(0x00, 0x8b, 0x0c), + yellow: rgb(0x99, 0x7b, 0x00), + orange: rgb(0xd1, 0x7c, 0x00), + blue: rgb(0x00, 0x57, 0xd1), + purple: rgb(0xa0, 0x18, 0xff), + cyan: rgb(0x00, 0x8c, 0x99), + }), + }, + // Melange: savq/melange-nvim lua/melange/palettes/*.lua + Scheme { + family: "Melange", + name: "Dark", + dark: true, + core: Some(Core { + bg: rgb(0x29, 0x25, 0x22), + dim: rgb(0x29, 0x25, 0x22), + raised: rgb(0x34, 0x30, 0x2c), + sel: rgb(0x40, 0x3a, 0x36), + border: rgb(0x40, 0x3a, 0x36), + fg: rgb(0xec, 0xe1, 0xd7), + muted: rgb(0xc1, 0xa7, 0x8e), + faint: rgb(0x86, 0x74, 0x62), + accent: rgb(0xa3, 0xa9, 0xce), + red: rgb(0xd4, 0x77, 0x66), + green: rgb(0x85, 0xb6, 0x95), + yellow: rgb(0xeb, 0xc0, 0x6d), + orange: rgb(0xe4, 0x9b, 0x5d), + blue: rgb(0xa3, 0xa9, 0xce), + purple: rgb(0xcf, 0x9b, 0xc2), + cyan: rgb(0x89, 0xb3, 0xb6), + }), + }, + Scheme { + family: "Melange", + name: "Light", + dark: false, + core: Some(Core { + bg: rgb(0xf1, 0xf1, 0xf1), + dim: rgb(0xe9, 0xe1, 0xdb), + raised: rgb(0xe9, 0xe1, 0xdb), + sel: rgb(0xd9, 0xd3, 0xce), + border: rgb(0xd9, 0xd3, 0xce), + fg: rgb(0x54, 0x43, 0x3a), + muted: rgb(0x7d, 0x66, 0x58), + faint: rgb(0xa9, 0x8a, 0x78), + accent: rgb(0x46, 0x5a, 0xa4), + red: rgb(0xbf, 0x00, 0x21), + green: rgb(0x3a, 0x68, 0x4a), + yellow: rgb(0xa0, 0x6d, 0x00), + orange: rgb(0xbc, 0x5c, 0x00), + blue: rgb(0x46, 0x5a, 0xa4), + purple: rgb(0x90, 0x41, 0x80), + cyan: rgb(0x3d, 0x65, 0x68), + }), + }, +]; diff --git a/crates/ledger-gui/src/test_support.rs b/crates/ledger-gui/src/test_support.rs index fadfb87..b5f6d1d 100644 --- a/crates/ledger-gui/src/test_support.rs +++ b/crates/ledger-gui/src/test_support.rs @@ -193,7 +193,7 @@ pub(crate) fn render( books: Books, route: Route, set: impl FnOnce(&mut PageState), -) -> (Vec, &'static crate::theme::Palette) { +) -> (Vec, crate::theme::Palette) { let (texts, _, p) = render_all(books, route, set); (texts, p) } @@ -206,7 +206,7 @@ pub(crate) fn render_all( ) -> ( Vec, Vec<(egui::Rect, Color32)>, - &'static crate::theme::Palette, + crate::theme::Palette, ) { let mut h = Harness::builder() .with_size(egui::vec2(1400.0, 2400.0)) diff --git a/crates/ledger-gui/src/theme.rs b/crates/ledger-gui/src/theme.rs index 8f07520..e762b62 100644 --- a/crates/ledger-gui/src/theme.rs +++ b/crates/ledger-gui/src/theme.rs @@ -1,5 +1,5 @@ -//! Look and feel: one palette per mode, Inter with tabular figures, and -//! the spacing the whole app shares. +//! Look and feel: the palette of the color scheme in force (schemes.rs), +//! Inter with tabular figures, and the spacing the whole app shares. use egui::{ Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, Stroke, @@ -90,13 +90,16 @@ pub const DARK: Palette = Palette { ], }; -/// The palette for the current mode. -pub fn pal(ctx: &Context) -> &'static Palette { - if ctx.global_style().visuals.dark_mode { - &DARK - } else { - &LIGHT - } +/// Where the palette in force is kept, in the context's memory. +fn palette_id() -> egui::Id { + egui::Id::new("wanderbooks-palette") +} + +/// The palette in force: the scheme's that `apply` set, else the +/// viewer's own for the current mode. +pub fn pal(ctx: &Context) -> Palette { + ctx.data(|d| d.get_temp::(palette_id())) + .unwrap_or_else(|| crate::schemes::own(ctx.global_style().visuals.dark_mode).palette()) } pub const SEMIBOLD: &str = "semibold"; @@ -166,9 +169,18 @@ pub fn install_fonts(ctx: &Context) { ctx.set_fonts(fonts); } -/// Apply the app's style for light or dark mode. +/// Apply the viewer's own style for light or dark mode (tests). +#[cfg(test)] pub fn apply(ctx: &Context, dark: bool) { - let p = if dark { &DARK } else { &LIGHT }; + apply_scheme(ctx, crate::schemes::own(dark)); +} + +/// Apply a color scheme: its palette, everywhere. +pub fn apply_scheme(ctx: &Context, scheme: &crate::schemes::Scheme) { + let dark = scheme.dark; + let palette = scheme.palette(); + ctx.data_mut(|d| d.insert_temp(palette_id(), palette)); + let p = &palette; let mut visuals = if dark { Visuals::dark() } else { @@ -179,6 +191,9 @@ pub fn apply(ctx: &Context, dark: bool) { visuals.extreme_bg_color = p.surface; visuals.faint_bg_color = p.hover; visuals.override_text_color = Some(p.text); + // egui's weak text (placeholders, hints) is the text at 60% alpha + // unless set: the palette's secondary text, which is kept readable. + visuals.weak_text_color = Some(p.muted); visuals.hyperlink_color = p.accent; visuals.selection.bg_fill = p.selected; visuals.selection.stroke = Stroke::new(1.0, p.accent); @@ -207,7 +222,21 @@ pub fn apply(ctx: &Context, dark: bool) { visuals.widgets.active.bg_fill = p.selected; visuals.widgets.active.weak_bg_fill = p.selected; visuals.widgets.active.fg_stroke = Stroke::new(1.0, p.text); - ctx.set_visuals(visuals); + visuals.widgets.open.bg_fill = p.hover; + visuals.widgets.open.weak_bg_fill = p.hover; + visuals.widgets.open.bg_stroke = Stroke::new(1.0, p.border); + visuals.widgets.open.fg_stroke = Stroke::new(1.0, p.text); + // egui keeps a light and a dark style and paints with the one its + // theme picks, which follows the system unless told: both get the + // scheme's visuals, and the theme is pinned to the scheme's mode, so + // a system that turns light or dark later changes nothing (review of + // the color schemes, 2026-10-19: figures went unreadable). + ctx.set_theme(if dark { + egui::Theme::Dark + } else { + egui::Theme::Light + }); + ctx.all_styles_mut(|style| style.visuals = visuals.clone()); ctx.all_styles_mut(|style| { style.text_styles = [ (TextStyle::Heading, semibold(20.0)), diff --git a/crates/ledger-gui/tests/schemes.rs b/crates/ledger-gui/tests/schemes.rs new file mode 100644 index 0000000..9d6755c --- /dev/null +++ b/crates/ledger-gui/tests/schemes.rs @@ -0,0 +1,259 @@ +//! Color schemes (schemes.rs): chosen from the menu in the sidebar's +//! corner, narrowed by the light/dark filter, kept with the viewer's +//! preferences. That every scheme paints the same words and figures in +//! the same places, each readable, is checked inside the crate +//! (schemes.rs, `painted`), where what is painted can be read. + +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use ledger_gui::nav::Route; +use ledger_gui::{App, data}; +use std::collections::HashMap; + +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn demo(tag: &str) -> (TestDir, data::Source) { + let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); + let dir = std::env::temp_dir().join(format!("ledger-gui-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + ledger_gui::demo::build(&dir, today).unwrap(); + let source = data::Source { + data_dir: dir.join("books-data"), + books_toml: Some(dir.join("wanderbooks.toml")), + today: Some(today), + }; + (TestDir(dir), source) +} + +/// eframe's preferences, in memory. +#[derive(Default)] +struct Prefs(HashMap); + +impl eframe::Storage for Prefs { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} +} + +#[test] +fn a_scheme_is_chosen_from_the_menu_and_kept() { + let (_dir, source) = demo("schemes-menu"); + let books = data::load_once(&source).unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 920.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); + h.run_steps(3); + assert_eq!(h.state().scheme_key(), "Wanderbooks/Light"); + // The button names the scheme in force; it opens the menu. + h.get_by_label_contains("Wanderbooks Light").click(); + h.run_steps(2); + // Narrowed to dark schemes: a light variant isn't offered. (The + // filter's "Dark" comes first; the viewer's own family, under the + // pointer, may have opened its flyout with its own "Dark".) + h.query_all_by_label("Dark").next().unwrap().click(); + h.run_steps(2); + h.get_by_label_contains("Catppuccin").hover(); + h.run_steps(3); + assert!( + h.query_by_label("Latte").is_none(), + "a light variant offered" + ); + h.get_by_label("Mocha").click(); + h.run_steps(3); + assert_eq!(h.state().scheme_key(), "Catppuccin/Mocha"); + // Worn: Mocha's own page color, and dark. + let visuals = h.ctx.global_style().visuals.clone(); + assert!(visuals.dark_mode); + assert_eq!( + visuals.panel_fill, + egui::Color32::from_rgb(0x18, 0x18, 0x25) + ); + // Kept with the viewer's preferences, the filter too. + let mut prefs = Prefs::default(); + eframe::App::save(h.state_mut(), &mut prefs); + assert_eq!(prefs.0["scheme"], "Catppuccin/Mocha"); + assert_eq!(prefs.0["scheme_filter"], "Dark"); + // Opened again: only the family in force carries the check, and the + // variant in force is selected under its name alone. + let check = egui_phosphor::regular::CHECK; + h.get_by_label_contains("Catppuccin Mocha").click(); + h.run_steps(2); + // A family's row (not the scheme button, which names the scheme). + let family = |h: &Harness<'_, App>, name: &str| -> String { + h.query_all_by_label_contains(name) + .map(|n| n.accesskit_node().label().unwrap_or_default().to_string()) + .find(|l| !l.contains(egui_phosphor::regular::PALETTE)) + .unwrap_or_else(|| panic!("no {name} row")) + }; + let label = family(&h, "Catppuccin"); + assert!(label.contains(check), "{label:?}"); + let label = family(&h, "Gruvbox"); + assert!(!label.contains(check), "{label:?}"); + h.query_all_by_label_contains("Catppuccin") + .find(|n| { + !n.accesskit_node() + .label() + .unwrap_or_default() + .contains(egui_phosphor::regular::PALETTE) + }) + .unwrap() + .hover(); + h.run_steps(3); + let on = |name: &str| format!("{:?}", h.get_by_label(name).accesskit_node().toggled()); + assert_eq!(on("Mocha"), "Some(True)"); + assert_eq!(on("Macchiato"), "Some(False)"); +} + +/// The families in the menu, alphabetically (owner, 2026-10-19). +const FAMILIES: [&str; 21] = [ + "Ayu", + "Catppuccin", + "Cyberdream", + "Dracula", + "Everforest", + "GitHub", + "Gruvbox", + "Kanagawa", + "Material", + "Melange", + "Monokai Pro", + "Night Owl", + "Nightfox", + "Nord", + "One Dark", + "Oxocarbon", + "Rosé Pine", + "Solarized", + "Sonokai", + "Tokyo Night", + "Wanderbooks", +]; + +/// A family's row in the open menu (not the scheme button, which names +/// the scheme in force). +fn family_row<'a>(h: &'a Harness<'_, App>, name: &'a str) -> egui_kittest::Node<'a> { + h.query_all_by_label_contains(name) + .find(|n| { + let label = n.accesskit_node().label().unwrap_or_default().to_string(); + !label.contains(egui_phosphor::regular::PALETTE) + // The check (in force) and the flyout's arrow aside. + && label.trim_matches(|c: char| !c.is_alphanumeric()) == name + }) + .unwrap_or_else(|| panic!("no {name} row")) +} + +/// Owner, 2026-10-19: Ayu was out of reach (the list scrolled, with no +/// sign of it), the families weren't alphabetical, and a click on a +/// variant's colors did nothing. Every family is on screen at once, in +/// order, in a short window or a tall one, and a click anywhere on a +/// variant's row chooses it. +#[test] +fn every_family_is_on_screen_in_order_and_a_whole_row_chooses() { + let (_dir, source) = demo("schemes-reach"); + for (w, h_) in [(1440.0, 920.0), (1000.0, 480.0)] { + let books = data::load_once(&source).unwrap(); + let mut h = Harness::builder() + .with_size(egui::vec2(w, h_)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); + h.run_steps(3); + h.get_by_label_contains("Wanderbooks Light").click(); + h.run_steps(3); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h_)); + let mut last: Option = None; + for family in FAMILIES { + let r = family_row(&h, family).rect(); + assert!( + screen.contains_rect(r), + "{w}x{h_}: {family} at {r:?} is off screen" + ); + // In order: down a column, then on to the next one. + if let Some(prev) = last { + assert!( + r.top() > prev.top() || r.left() > prev.left(), + "{w}x{h_}: {family} comes before the family above it" + ); + } + last = Some(r); + } + // Ayu's flyout; a click on Mirage's colors, not its name. + family_row(&h, "Ayu").hover(); + h.run_steps(3); + let row = h.get_by_label("Mirage").rect(); + let on_colors = egui::pos2(row.left() + 20.0, row.center().y); + h.hover_at(on_colors); + h.run_steps(1); + h.drag_at(on_colors); + h.run_steps(1); + h.drop_at(on_colors); + h.run_steps(3); + assert_eq!(h.state().scheme_key(), "Ayu/Mirage", "{w}x{h_}"); + assert!(row.width() >= 180.0, "the row is the button: {row:?}"); + } +} + +/// Pictures of the menu and of a few schemes, for a person to look at +/// (`SHOT_DIR=… cargo test -p ledger-gui --test schemes -- --ignored`). +#[test] +#[ignore] +fn pictures() { + let out = std::path::PathBuf::from(std::env::var("SHOT_DIR").expect("SHOT_DIR")); + std::fs::create_dir_all(&out).unwrap(); + let (_dir, source) = demo("schemes-shots"); + let shot_at = |size: egui::Vec2, key: &str, menu: Option<&str>, name: &str| { + let books = data::load_once(&source).unwrap(); + let mut h = Harness::builder() + .with_size(size) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); + h.run_steps(2); + let ctx = h.ctx.clone(); + h.state_mut().set_scheme(&ctx, key); + h.run_steps(4); + if let Some(family) = menu { + let label = h.state().scheme_key().replace('/', " "); + h.get_by_label_contains(label.split(' ').next().unwrap()) + .click(); + h.run_steps(3); + h.get_by_label_contains(family).hover(); + h.run_steps(4); + } + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + }; + let shot = |key: &str, menu: Option<&str>, name: &str| { + shot_at(egui::vec2(1440.0, 920.0), key, menu, name) + }; + shot("Wanderbooks/Light", Some("Ayu"), "menu"); + shot_at( + egui::vec2(1000.0, 480.0), + "Wanderbooks/Light", + Some("Melange"), + "menu-short", + ); + for key in [ + "Catppuccin/Mocha", + "Catppuccin/Latte", + "Tokyo Night/Night", + "Gruvbox/Dark", + "Ayu/Light", + "Kanagawa/Lotus", + "Nord/Nord", + "Solarized/Light", + ] { + shot(key, None, &key.replace(['/', ' '], "-")); + } +} diff --git a/docs/color-schemes.md b/docs/color-schemes.md new file mode 100644 index 0000000..f483c53 --- /dev/null +++ b/docs/color-schemes.md @@ -0,0 +1,84 @@ +# Color schemes in the viewer + +The viewer's color-scheme menu (the button in the sidebar's lower corner) +offers its own scheme, Wanderbooks Light and Dark, and twenty families of +popular editor schemes, each with all its variants: 80 schemes in all (78 +and the viewer's own two). The menu's filter narrows the list to light or +dark schemes. Families are listed alphabetically, each one's variants too, +in as many columns as the window's height needs, so every family is on +screen at once; a variant's whole row (its colors and its name) chooses +it. +The choice is a viewer preference, kept with eframe's other preferences; +nothing is written to the books, and no figure or word changes with the +scheme (`schemes.rs`, `painted`). + +Owner, 2026-10-19: "full blown colorscheme support … all the top neovim +style colorschemes"; "on the order of 20 families, each with a flyout of all +its variants"; a light/dark/all filter; Ayu included. + +## Where the colors come from + +Each scheme's sixteen colors (`crates/ledger-gui/src/schemes/table.rs`) were +taken from its official source and checked value by value (2026-10-19); the +table names the source above each family: + +| Column | What it is | +|---|---| +| bg | the editor background (Normal) | +| dim | a panel or sidebar background (mantle, bg_dark, bg0 …) | +| raised | a raised surface (surface0, bg_highlight, float …); recorded, not used | +| sel | the visual-selection background; recorded, not used | +| border | the split or border color; recorded, not used | +| fg | the text (Normal) | +| muted | secondary text (subtext, fg_dark, subtle …) | +| faint | comments | +| accent | the scheme's signature accent: in most its blue; Ayu's yellow-orange, Dracula's purple, Rosé Pine's iris, Monokai Pro's yellow, Material's teal, Nord's frost cyan (nord8), GitHub's own accent blue | +| red, green, yellow, orange, blue, purple, cyan | its named colors | + +Where a scheme has no color for a column, the closest official one stands +in (Dracula has no blue: its ANSI blue, its purple; Sonokai has no cyan: its +blue; Oxocarbon has no yellow: IBM Carbon's, and no orange: its pink). A few +columns repeat another for the same reason: seven schemes have one +background (Gruvbox Dark Hard, Nord, Solarized Dark and Light, Cyberdream +Dark and Light, Melange Dark), dim being their bg. Variant names are the plugins' own (GitHub +Dark Default, not Dark, which is another theme). + +## How the viewer paints with them + +- The page is the darker of a scheme's bg and dim, the cards the lighter, + so cards stand out in light and dark schemes alike. A scheme with one + background gets a second a shade toward its text: lighter cards in a + dark scheme, a darker page in a light one. +- Secondary text is the scheme's muted color, unless that is no dimmer than + its text (Nord's documented "subtle" tone is brighter): then a tone + between its text and its comments. +- Selection is the cards tinted with the accent; the new-entry highlight is + the cards lightly tinted with the yellow (at 10%: a stronger tint left + too little room for colored amounts on it, and bleached Nord's red to a + pink). +- The charts' series are the scheme's blue, cyan, yellow, purple, orange, + green, red and accent, in that order, each that looks like one already + taken (CIE76 ΔE under 20) passed over: Monokai Pro and Sonokai name their + cyan with their blue, and Kanagawa Dragon's blues, cyans and purples are + near one another. The three the charts show together (net, income, + spending) are always plainly different. +- **Readable, always.** Every color that carries text or a figure (text, + secondary text, the faintest text, which carries chart amounts and entry + numbers, gains, losses, warnings, links) reaches WCAG AA contrast (4.5:1) + on every background it is drawn on: page, cards, sidebar, hover, + selection, the new-entry highlight, and the soft fills of pills and + badges. A scheme's color that falls short keeps its hue and saturation + and only gets lighter (dark schemes) or darker (light ones), just far + enough. Gains, losses and plain text stay plainly different to the eye + (ΔE 20 or more apart). egui's own faint text (placeholders) is the + secondary text; the logo's initial is black or white, whichever reads on + the accent. The viewer's own palettes go through the same rule (it lifted + Wanderbooks Light's green on the sidebar from 4.35:1, its faint text and + warnings too, and Wanderbooks Dark's faint text). +- **Three levels of text, always apart.** Lifted one by one, secondary and + faint text came out alike (identical in some schemes, faint above + secondary in others). So the faintest is lifted first, then secondary + text until it reads at least 1.2 times better than the faint on the + ground it reads worst on, then the text the same over secondary. +- The scheme sets both of egui's styles and pins its light or dark theme, + so the system turning light or dark later changes nothing. diff --git a/docs/testing.md b/docs/testing.md index f9b3964..2d5069a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -406,6 +406,57 @@ reachable by a test: - the group's member check (`!` deleted), which agrees on every account whose own group lists no members, the usual case. +## Viewer color schemes (2026-10-19) + +`ledger-gui/src/schemes.rs` (docs/color-schemes.md). No mutation run (the +viewer's rule); its two musts are tested directly instead: +- `schemes::painted::every_scheme_paints_the_same_words_and_figures_readably` + draws thirteen pages (an entry, the company, assets, documents, overview, + cash, profit, transactions, the P&L, the balance sheet, accounts, + parties, the journal) in all 80 schemes, a thread a page, and reads what + was painted where it can be seen: every run of text, report rows + included, is the same and in the same place in every scheme, and each is + at 4.5:1 or better against what was painted under it by then (the fills + in paint order, cut to their clips, translucent ones laid over what was + below; every section of a text, its own color or the fallback). It found + egui's placeholder text (its weak text, 60% alpha of the text) at 2.8:1. + Not painted by it: hover, selection and new-entry rows, tooltips and + the menu, whose grounds the palette tests check by arithmetic. +- `the_system_turning_light_or_dark_changes_nothing`: starting as eframe + does (following the system), a scheme chosen and the system then + flipped leaves every painted color as it was. It fails on the first + version, which set only the current theme's style. +- The palette tests check every scheme: every text color at 4.5:1 on + every ground (soft fills included), text over secondary over faint each + at least 1.2 times the contrast of the next on its worst ground, chart + series at 3:1 on cards and the hover tint, the charts' three series shown + together and gains, losses and text each ΔE 20 or more apart, the + logo's initial readable on the accent, light or dark as its background + is, and the filter showing exactly its mode; the menu test chooses, + filters and keeps a scheme, and its accessible labels check only the + family in force and select the variant in force; `app::tests` restores + the scheme (and the old light/dark choice) from the preferences. + +The first version's review (2026-10-19) found figures turning unreadable +when the system's theme flipped, Ayu and the families after it out of +reach in a short window, Income and Net the same color in twelve schemes, +pills, faint figures and the logo initial below 4.5:1, a new-entry tint +that bleached reds and greens, and a "same figures" test that compared +only accessibility labels (no report rows); all fixed, with the tests +above. Round 2 found secondary and faint text made alike by lifting each +alone (identical in Wanderbooks Light), the painted test picking a text's +ground without paint order or clips, and every family's accessible label +carrying the hidden check; fixed, with the tests above. The owner, trying +it (2026-10-19): Ayu out of reach (the family list scrolled inside the +menu, its scroll bar hidden), the families not alphabetical, and a click on +a variant's colors doing nothing. Now the families are alphabetical, in +columns with no scrolling, and a variant's whole row is its button; +`every_family_is_on_screen_in_order_and_a_whole_row_chooses` checks all +three in a tall window and a short one. Earlier: the viewer's own light palette's green below 4.5:1 on the +sidebar (4.35:1), and a first fix (moving colors toward the text color) +that turned Everforest's red and green into the same grey; colors keep +their hue and change only lightness. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (Stephen, 2026-10-13: the books do little work diff --git a/plan/served.md b/plan/served.md index 6bcfdc3..198fd9b 100644 --- a/plan/served.md +++ b/plan/served.md @@ -247,6 +247,7 @@ The server, the CLI and the GUI find the data root with one shared function (`le - **Save a copy / Open with system app** on any document (`Snapshot::attachment_bytes`, snapshot.rs:328). Fixes the viewer half of defect 3. - **TIN click:** locally, the GUI prompts for the passphrase, reads the wrapped keyslot and the sealed record from the data root, unwraps in its own process, and returns the value to the clipboard-only path (`tests/tin_copy.rs` behaviour), then zeroizes. This is not access-logged, because the GUI never writes; the docs say so. Remotely, TINs appear only on `/h/` pages. - `[viewer] open_at_start` becomes a per-human eframe preference (`app.rs:833`), not journaled. +- **Color schemes** (owner, 2026-10-19: "full blown colorscheme support … all the top neovim style colorschemes", "on the order of 20 families, each with a flyout of all its variants", a light/dark/all filter; Ayu included). The button in the sidebar's lower corner opens a menu: the filter, then, alphabetically and all on screen at once, the viewer's own family (Wanderbooks Light and Dark) and twenty families of editor schemes (Ayu, Catppuccin, Cyberdream, Dracula, Everforest, GitHub, Gruvbox, Kanagawa, Material, Melange, Monokai Pro, Night Owl, Nightfox, Nord, One Dark, Oxocarbon, Rosé Pine, Solarized, Sonokai, Tokyo Night), each a flyout of all its variants, alphabetical, each a row with a strip of its colors that chooses it. The choice and the filter are per-human eframe preferences like `open_at_start`; nothing is journaled and no figure changes (`schemes.rs` paints nine pages in all 80 schemes and checks every painted word and figure is the same, in the same place, and readable on what is under it). Every palette keeps every text color (figures, gains and losses, warnings, links, the faintest text) at WCAG AA contrast (4.5:1) on every background it is drawn on: a scheme's own color that falls short keeps its hue and only changes lightness, and gains, losses and text stay plainly different to the eye. The scheme pins egui's light or dark theme, so the system flipping changes nothing. The palettes come from each scheme's official source (`docs/color-schemes.md`). ### 3.7 Backup and tamper evidence