From 0a36868be51e007f8ae50d0084a262e375da6dff Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 13:35:09 +0200 Subject: [PATCH 1/8] feat: show Collector in English or French, following the system language Every string the user reads goes through src/i18n (en.rs, fr.rs), with French units and decimal commas. The quit hint moves to a full-width footer so it fits in French. --version stays exactly 'collector X.Y.Z'. Tests: identical key sets, placeholders and non-empty values in both locales, every key used in the code exists, and an 80x24 render in both locales with no label cut off. --- Cargo.lock | 10 ++ Cargo.toml | 3 + README.md | 19 +++- src/app.rs | 13 +++ src/cli.rs | 20 ++-- src/i18n/en.rs | 44 ++++++++ src/i18n/fr.rs | 44 ++++++++ src/i18n/mod.rs | 249 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 9 +- src/terminal.rs | 5 +- src/ui/cpu_memory.rs | 22 ++-- src/ui/disks.rs | 15 ++- src/ui/mod.rs | 66 +++++++++++- src/ui/network.rs | 8 +- src/ui/processes.rs | 15 ++- tests/cli.rs | 47 +++++++- tests/pty_smoke.rs | 5 +- 17 files changed, 544 insertions(+), 50 deletions(-) create mode 100644 src/i18n/en.rs create mode 100644 src/i18n/fr.rs create mode 100644 src/i18n/mod.rs diff --git a/Cargo.lock b/Cargo.lock index efbaf2c..7256656 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,6 +142,7 @@ dependencies = [ "portable-pty", "ratatui", "signal-hook 0.4.5", + "sys-locale", "sysinfo", "winresource", ] @@ -1411,6 +1412,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "sysinfo" version = "0.39.6" diff --git a/Cargo.toml b/Cargo.toml index a851e0b..9e7daa8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ crossterm = "0.29.0" # No default features: they add `component` and `user`, which Collector does # not use (on Windows, `component` brings in WMI and COM). sysinfo = { version = "0.39.6", default-features = false, features = ["system", "disk", "network"] } +# The system language, for English or French. No dependencies of its own on +# Linux, Windows or macOS. +sys-locale = "0.3.2" # Quit signals (SIGTERM, SIGHUP, SIGINT, SIGQUIT) set a flag the event loop # checks, so the terminal is restored before Collector dies of the signal. diff --git a/README.md b/README.md index f2f8094..f1681da 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,7 @@ locally, over SSH or inside tmux. - **Small and quiet**: a single binary of about 1 MB on Linux, with no network connection and no file written (see the [privacy policy](#privacy-policy)). -Today btop and bottom show far more. The [roadmap](#roadmap) closes that gap, -and adds English and French and the Colony palettes on the way. +Today btop and bottom show far more. The [roadmap](#roadmap) closes that gap. ## What it does @@ -54,7 +53,8 @@ One screen, refreshed every second: - **Processes**: the ten using the most CPU, with their share of the whole machine's CPU and their memory. -`q` or Ctrl+C quits, and the terminal is restored. +`q` or Ctrl+C quits, and the terminal is restored. Everything is shown in +English or French (see [Language](#language)). ## Installation @@ -124,13 +124,22 @@ and macOS it is also restored on a quit signal from outside (SIGTERM, SIGHUP, SIGINT, SIGQUIT): Collector notices it within a second, puts the terminal back, then ends the way that signal would have ended it. +### Language + +Collector speaks French when the system language is French, and English +otherwise, with French units and decimal commas (`1,5 Go`). On Linux the +language comes from the first of `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG` +that is set, so `LANGUAGE=en collector` runs it in English; on Windows and +macOS it is the language chosen in the system settings. `collector --version` +prints the same `collector X.Y.Z` line in both. + ## Roadmap Each step is one release. - **0.2.0**: release pipeline, signed builds, `--help` and `--version`, and a terminal restored on quit and on panic. -- **0.3.0**: core rewrite: Linux reads `/proc` and `/sys` directly; CPU, memory, disk, network and process boxes with correct rates; the terminal restored after crashes too; English and French. -- **0.4.0**: Preferences page, Colony palettes and accents, mouse support. +- **0.3.0**: English and French, a Preferences page, and the Colony themes and accents with high contrast. +- **0.4.0**: core rewrite: Linux reads `/proc` and `/sys` directly; CPU, memory, disk, network and process boxes with correct rates; the terminal restored after crashes too; mouse support. - **0.5.0**: process depth: tree view, detail panel, signals, renice, filtering. - **0.6.0**: sensors and hardware: temperatures, frequencies, disk I/O, battery. - **0.7.0**: GPU monitoring. diff --git a/src/app.rs b/src/app.rs index 7b25877..9953001 100644 --- a/src/app.rs +++ b/src/app.rs @@ -43,6 +43,19 @@ impl App { } } + /// No readings at all, for drawing tests: no system scan, nothing that + /// depends on the machine the tests run on. + #[cfg(test)] + pub fn empty() -> Self { + Self { + system: System::new(), + disks: Disks::new(), + networks: Networks::new(), + network_rate: (0.0, 0.0), + last_tick: Instant::now(), + } + } + fn refresh(&mut self) { self.system.refresh_cpu_all(); self.system.refresh_memory(); diff --git a/src/cli.rs b/src/cli.rs index 5546e7e..10b0cba 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,18 +5,9 @@ use std::ffi::OsString; use std::process::ExitCode; -const VERSION: &str = env!("CARGO_PKG_VERSION"); -const USAGE: &str = "\ -Usage: collector [OPTION] - -A terminal system monitor: CPU, memory, disks, network and processes. +use crate::i18n::{t, tf}; -Options: - -h, --help Print this help and exit - -V, --version Print the version and exit - -Keys: q or Ctrl+C quits. -"; +const VERSION: &str = env!("CARGO_PKG_VERSION"); pub enum Mode { Run, @@ -38,17 +29,20 @@ pub fn parse_args(args: &[OsString]) -> Result { } } +/// Exactly `collector X.Y.Z`, in every locale: the release workflow's smoke +/// test and the signed `.meta` of each asset are checked against it. pub fn print_version() -> ExitCode { println!("collector {VERSION}"); ExitCode::SUCCESS } pub fn print_help() -> ExitCode { - print!("collector {VERSION}\n\n{USAGE}"); + print!("collector {VERSION}\n\n{}", t("usage")); ExitCode::SUCCESS } pub fn print_unexpected(arg: &str) -> ExitCode { - eprint!("collector: unexpected argument '{arg}'\n\n{USAGE}"); + let message = tf("unexpected_argument", &[("arg", arg)]); + eprint!("{message}\n\n{}", t("usage")); ExitCode::from(2) } diff --git a/src/i18n/en.rs b/src/i18n/en.rs new file mode 100644 index 0000000..5b27bd7 --- /dev/null +++ b/src/i18n/en.rs @@ -0,0 +1,44 @@ +//! English, the canonical locale: each key is named after its English string, +//! and a new string is written here first. `fr.rs` must have the same keys. + +pub(super) const STRINGS: &[(&str, &str)] = &[ + // Command line. `collector --version` is not here: it stays exactly + // `collector X.Y.Z` in every locale (see `cli::print_version`). + ( + "usage", + "\ +Usage: collector [OPTION] + +A terminal system monitor: CPU, memory, disks, network and processes. + +Options: + -h, --help Print this help and exit + -V, --version Print the version and exit + +Keys: q or Ctrl+C quits. +", + ), + ( + "unexpected_argument", + "collector: unexpected argument '{arg}'", + ), + ( + "needs_terminal", + "Collector needs a terminal (with ssh, use ssh -t)", + ), + ("error", "collector: {error}"), + ("signal_error", "cannot handle signal {signal}: {error}"), + // The monitor. + ("cpu", "CPU"), + ("memory", "Memory"), + ("disks", "Disks"), + ("network", "Network"), + ("top_processes", "Top Processes"), + ("process", "Process"), + ("rx_rate", "RX: {rate} KB/s"), + ("tx_rate", "TX: {rate} KB/s"), + ("used_of_total_gb", "{used} / {total} GB"), + ("megabytes", "{value} MB"), + ("percent", "{value}%"), + ("quit_hint", "q or Ctrl+C to quit"), +]; diff --git a/src/i18n/fr.rs b/src/i18n/fr.rs new file mode 100644 index 0000000..5d6539d --- /dev/null +++ b/src/i18n/fr.rs @@ -0,0 +1,44 @@ +//! French. Same keys as `en.rs`, written as French rather than translated word +//! for word: French units (Go, Mo, Ko), a space before `:` and `%`. + +pub(super) const STRINGS: &[(&str, &str)] = &[ + ( + "usage", + "\ +Utilisation : collector [OPTION] + +Un moniteur système pour le terminal : processeur, mémoire, disques, réseau et processus. + +Options : + -h, --help Affiche cette aide et quitte + -V, --version Affiche la version et quitte + +Touches : q ou Ctrl+C pour quitter. +", + ), + ( + "unexpected_argument", + "collector : argument inattendu « {arg} »", + ), + ( + "needs_terminal", + "Collector a besoin d'un terminal (avec ssh, utilisez ssh -t)", + ), + ("error", "collector : {error}"), + ( + "signal_error", + "impossible d'intercepter le signal {signal} : {error}", + ), + ("cpu", "CPU"), + ("memory", "Mémoire"), + ("disks", "Disques"), + ("network", "Réseau"), + ("top_processes", "Processus les plus actifs"), + ("process", "Processus"), + ("rx_rate", "Reçu : {rate} Ko/s"), + ("tx_rate", "Envoyé : {rate} Ko/s"), + ("used_of_total_gb", "{used} / {total} Go"), + ("megabytes", "{value} Mo"), + ("percent", "{value} %"), + ("quit_hint", "q ou Ctrl+C pour quitter"), +]; diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..b5dc698 --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,249 @@ +//! The interface in English or French. Every string the user reads goes +//! through [`t`] or [`tf`], and every number with a decimal part through +//! [`number`], so French gets its own words, units and decimal comma. +//! +//! The locale is process-wide, as in colony-ui: only the event loop draws. + +mod en; +mod fr; + +use std::sync::atomic::{AtomicBool, Ordering}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Locale { + En, + Fr, +} + +impl Locale { + /// French when the language subtag is `fr` (`fr`, `fr-FR`, `fr_CA.UTF-8`), + /// English for anything else, `C` and `POSIX` included. + pub fn from_tag(tag: &str) -> Self { + let language = tag.split(['-', '_', '.', '@']).next().unwrap_or_default(); + if language.eq_ignore_ascii_case("fr") { + Self::Fr + } else { + Self::En + } + } + + /// The system's language: on Linux the first of `LANGUAGE`, `LC_ALL`, + /// `LC_MESSAGES` and `LANG` that is set, on Windows and macOS the user's + /// preferred language. + pub fn system() -> Self { + sys_locale::get_locale().map_or(Self::En, |tag| Self::from_tag(&tag)) + } + + fn strings(self) -> &'static [(&'static str, &'static str)] { + match self { + Self::En => en::STRINGS, + Self::Fr => fr::STRINGS, + } + } +} + +static FRENCH: AtomicBool = AtomicBool::new(false); + +pub fn set_locale(locale: Locale) { + FRENCH.store(locale == Locale::Fr, Ordering::Relaxed); +} + +pub fn locale() -> Locale { + if FRENCH.load(Ordering::Relaxed) { + Locale::Fr + } else { + Locale::En + } +} + +/// The string for `key` in the current locale. +pub fn t(key: &'static str) -> &'static str { + t_in(locale(), key) +} + +/// Falls back to English, then to the key itself. The tests below keep both +/// fallbacks unreachable. +pub fn t_in(locale: Locale, key: &'static str) -> &'static str { + lookup(locale, key) + .or_else(|| lookup(Locale::En, key)) + .unwrap_or(key) +} + +fn lookup(locale: Locale, key: &str) -> Option<&'static str> { + locale + .strings() + .iter() + .find(|(k, _)| *k == key) + .map(|&(_, value)| value) +} + +/// [`t`] with each `{name}` replaced by its value in `args`. One pass, so a +/// value that itself contains `{...}` (a process name, an argument) is left as +/// it is. +pub fn tf(key: &'static str, args: &[(&str, &str)]) -> String { + let mut rest = t(key); + let mut out = String::with_capacity(rest.len()); + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + let filled = after.find('}').and_then(|close| { + let name = &after[..close]; + let (_, value) = args.iter().find(|(arg, _)| *arg == name)?; + Some((close, *value)) + }); + match filled { + Some((close, value)) => { + out.push_str(value); + rest = &after[close + 1..]; + } + None => { + out.push('{'); + rest = after; + } + } + } + out.push_str(rest); + out +} + +/// `value` rounded to `decimals` places, with the locale's decimal separator: +/// `1.5` in English, `1,5` in French. +pub fn number(value: f64, decimals: usize) -> String { + let text = format!("{value:.decimals$}"); + match locale() { + Locale::En => text, + Locale::Fr => text.replace('.', ","), + } +} + +/// Runs `f` in `locale`. The locale is process-wide and tests run in +/// parallel, so every test that depends on it goes through here. +#[cfg(test)] +pub fn with_locale(locale: Locale, f: impl FnOnce() -> R) -> R { + use std::sync::{Mutex, PoisonError}; + static LOCK: Mutex<()> = Mutex::new(()); + let _guard = LOCK.lock().unwrap_or_else(PoisonError::into_inner); + let previous = self::locale(); + set_locale(locale); + let result = f(); + set_locale(previous); + result +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::fs; + use std::path::PathBuf; + + use super::*; + + fn keys(strings: &[(&'static str, &str)]) -> BTreeSet<&'static str> { + strings.iter().map(|&(key, _)| key).collect() + } + + fn placeholders(text: &str) -> BTreeSet<&str> { + text.split('{') + .skip(1) + .filter_map(|part| part.split_once('}').map(|(name, _)| name)) + .collect() + } + + #[test] + fn fr_and_en_have_identical_key_sets() { + assert_eq!(keys(en::STRINGS), keys(fr::STRINGS)); + } + + /// One key, one string: a key listed twice would carry two values. + #[test] + fn no_key_is_listed_twice() { + for strings in [en::STRINGS, fr::STRINGS] { + assert_eq!(keys(strings).len(), strings.len()); + } + } + + #[test] + fn no_string_is_empty() { + for (key, value) in en::STRINGS.iter().chain(fr::STRINGS) { + assert!(!value.trim().is_empty(), "{key} is empty"); + } + } + + #[test] + fn both_locales_fill_the_same_placeholders() { + for &(key, english) in en::STRINGS { + assert_eq!( + placeholders(english), + placeholders(t_in(Locale::Fr, key)), + "{key}" + ); + } + } + + /// A typo in a key would show the key itself on screen. + #[test] + fn every_key_used_in_the_code_exists() { + let known = keys(en::STRINGS); + let mut dirs = vec![PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))]; + let mut missing = Vec::new(); + while let Some(dir) = dirs.pop() { + for entry in fs::read_dir(&dir).expect("read src") { + let path = entry.expect("read src").path(); + if path.is_dir() { + dirs.push(path); + continue; + } + let source = fs::read_to_string(&path).expect("read a source file"); + for call in ["t(", "tf("] { + for (at, _) in source.match_indices(call) { + // `t(` and not `split(`, `print(` or the list above. + let before = source[..at].chars().next_back(); + if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '"') { + continue; + } + // rustfmt may put the key on the next line. + let Some(rest) = source[at + call.len()..].trim_start().strip_prefix('"') + else { + continue; + }; + let key = &rest[..rest.find('"').unwrap_or(0)]; + if !known.contains(key) { + missing.push(format!("{}: {key}", path.display())); + } + } + } + } + } + assert!(missing.is_empty(), "keys missing from en.rs: {missing:?}"); + } + + #[test] + fn french_follows_the_language_subtag() { + for tag in ["fr", "fr-FR", "fr_CA.UTF-8", "FR-be", "fr@euro"] { + assert_eq!(Locale::from_tag(tag), Locale::Fr, "{tag}"); + } + for tag in ["en-US", "C", "POSIX", "", "frr-DE", "de_DE.UTF-8"] { + assert_eq!(Locale::from_tag(tag), Locale::En, "{tag}"); + } + } + + #[test] + fn numbers_use_the_locale_decimal_separator() { + assert_eq!(with_locale(Locale::En, || number(1.26, 1)), "1.3"); + assert_eq!( + with_locale(Locale::Fr, || number(1536.0 / 1024.0, 1)), + "1,5" + ); + assert_eq!(with_locale(Locale::Fr, || number(42.0, 0)), "42"); + } + + #[test] + fn placeholders_are_filled_once() { + let line = with_locale(Locale::Fr, || { + tf("used_of_total_gb", &[("used", "{total}"), ("total", "2,0")]) + }); + assert_eq!(line, "{total} / 2,0 Go"); + let unknown = with_locale(Locale::En, || tf("percent", &[])); + assert_eq!(unknown, "{value}%"); + } +} diff --git a/src/main.rs b/src/main.rs index ac6535a..04cd435 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod cli; +mod i18n; mod terminal; mod ui; @@ -8,8 +9,10 @@ use std::ffi::OsString; use std::process::ExitCode; use cli::Mode; +use i18n::{t, tf}; fn main() -> ExitCode { + i18n::set_locale(i18n::Locale::system()); let args: Vec = env::args_os().skip(1).collect(); match cli::parse_args(&args) { Ok(Mode::Run) => {} @@ -19,14 +22,14 @@ fn main() -> ExitCode { } if !terminal::is_interactive() { - eprintln!("Collector needs a terminal (with ssh, use ssh -t)"); + eprintln!("{}", t("needs_terminal")); return ExitCode::from(1); } terminal::install_panic_hook(); let quit_signal = match terminal::quit_signal() { Ok(flag) => flag, Err(err) => { - eprintln!("collector: {err}"); + eprintln!("{}", tf("error", &[("error", &err.to_string())])); return ExitCode::FAILURE; } }; @@ -35,7 +38,7 @@ fn main() -> ExitCode { let restored = terminal::restore(); let result = result.and(restored.map_err(Into::into)); if let Err(err) = &result { - eprintln!("collector: {err}"); + eprintln!("{}", tf("error", &[("error", &err.to_string())])); } if let Some(code) = terminal::die_of_signal(&quit_signal) { diff --git a/src/terminal.rs b/src/terminal.rs index 2a14053..9963fdf 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -41,11 +41,14 @@ pub fn quit_signal() -> io::Result> { let quit_signal = Arc::new(AtomicUsize::new(0)); #[cfg(unix)] { + use crate::i18n::tf; use signal_hook::consts::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; for signal in [SIGTERM, SIGHUP, SIGINT, SIGQUIT] { let flag = Arc::clone(&quit_signal); signal_hook::flag::register_usize(signal, flag, signal as usize).map_err(|err| { - io::Error::new(err.kind(), format!("cannot handle signal {signal}: {err}")) + let (signal, error) = (signal.to_string(), err.to_string()); + let message = tf("signal_error", &[("signal", &signal), ("error", &error)]); + io::Error::new(err.kind(), message) })?; } } diff --git a/src/ui/cpu_memory.rs b/src/ui/cpu_memory.rs index a03973a..d2fa7c1 100644 --- a/src/ui/cpu_memory.rs +++ b/src/ui/cpu_memory.rs @@ -4,6 +4,9 @@ use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, Gauge}; use crate::app::App; +use crate::i18n::{number, t, tf}; + +const GIB: f64 = 1_073_741_824.0; pub fn render(frame: &mut Frame, app: &App, area: Rect) { let layout = Layout::default() @@ -11,11 +14,12 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(area); - let cpu_usage = app.system.global_cpu_usage(); + let cpu_percent = gauge_percent(f64::from(app.system.global_cpu_usage())); let cpu_gauge = Gauge::default() - .block(Block::default().title("CPU").borders(Borders::ALL)) + .block(Block::default().title(t("cpu")).borders(Borders::ALL)) .gauge_style(Style::default().fg(Color::Cyan)) - .percent(gauge_percent(f64::from(cpu_usage))); + .percent(cpu_percent) + .label(tf("percent", &[("value", &cpu_percent.to_string())])); frame.render_widget(cpu_gauge, layout[0]); let total_memory = app.system.total_memory() as f64; @@ -27,13 +31,15 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { }; let memory_gauge = Gauge::default() - .block(Block::default().title("Memory").borders(Borders::ALL)) + .block(Block::default().title(t("memory")).borders(Borders::ALL)) .gauge_style(Style::default().fg(Color::Magenta)) .percent(gauge_percent(memory_percent)) - .label(format!( - "{:.1} / {:.1} GB", - used_memory / 1_073_741_824.0, - total_memory / 1_073_741_824.0 + .label(tf( + "used_of_total_gb", + &[ + ("used", &number(used_memory / GIB, 1)), + ("total", &number(total_memory / GIB, 1)), + ], )); frame.render_widget(memory_gauge, layout[1]); } diff --git a/src/ui/disks.rs b/src/ui/disks.rs index 56ffa28..a049fff 100644 --- a/src/ui/disks.rs +++ b/src/ui/disks.rs @@ -5,6 +5,9 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use crate::app::App; +use crate::i18n::{number, t, tf}; + +const GIB: f64 = 1_073_741_824.0; pub fn render(frame: &mut Frame, app: &App, area: Rect) { // By mount point: btrfs subvolumes and bind mounts repeat one device name, @@ -21,16 +24,18 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { format!("{} ", mount_point.display()), Style::default().fg(Color::Yellow), ), - Span::raw(format!( - "{:.1}/{:.1} GB", - used / 1_073_741_824.0, - total / 1_073_741_824.0 + Span::raw(tf( + "used_of_total_gb", + &[ + ("used", &number(used / GIB, 1)), + ("total", &number(total / GIB, 1)), + ], )), ]) }) .collect(); let disk_block = - Paragraph::new(disk_lines).block(Block::default().title("Disks").borders(Borders::ALL)); + Paragraph::new(disk_lines).block(Block::default().title(t("disks")).borders(Borders::ALL)); frame.render_widget(disk_block, area); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e58e3d4..d5332f0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,17 +7,22 @@ mod processes; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::widgets::Paragraph; use crate::app::App; +use crate::i18n::t; pub fn render_ui(frame: &mut Frame, app: &App) { + // The processes take what is left, so a short terminal shrinks the list + // and never the boxes above it or the footer. let main_layout = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Length(7), Constraint::Length(7), - Constraint::Min(10), + Constraint::Fill(1), + Constraint::Length(1), ]) .split(frame.area()); @@ -31,4 +36,63 @@ pub fn render_ui(frame: &mut Frame, app: &App) { network::render(frame, app, disk_network[1]); processes::render(frame, app, main_layout[2]); + + // Full width, where the French hint fits too. + frame.render_widget(Paragraph::new(t("quit_hint")), main_layout[3]); +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + + use super::*; + use crate::i18n::{self, Locale, number, tf}; + + /// The buffer as text, one line per row. + fn screen_text(buffer: &Buffer) -> String { + let width = usize::from(buffer.area.width); + buffer + .content() + .chunks(width) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n") + } + + /// French runs 15 to 20% longer than English: at the smallest usual + /// terminal size, every title, header and hint is drawn whole in both. + #[test] + fn every_label_fits_at_80x24_in_both_locales() { + let app = App::empty(); + for locale in [Locale::En, Locale::Fr] { + let (screen, expected) = i18n::with_locale(locale, || { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + terminal.draw(|frame| render_ui(frame, &app)).expect("draw"); + let zero = number(0.0, 1); + let mut expected: Vec = [ + "cpu", + "memory", + "disks", + "network", + "top_processes", + "process", + "quit_hint", + ] + .map(|key| t(key).to_owned()) + .into(); + expected.push(tf("rx_rate", &[("rate", &zero)])); + expected.push(tf("tx_rate", &[("rate", &zero)])); + expected.push(tf("used_of_total_gb", &[("used", &zero), ("total", &zero)])); + (screen_text(terminal.backend().buffer()), expected) + }); + for text in expected { + assert!( + screen.contains(&text), + "{locale:?}: {text:?} is cut off or missing:\n{screen}" + ); + } + } + } } diff --git a/src/ui/network.rs b/src/ui/network.rs index cf222ff..0ddb304 100644 --- a/src/ui/network.rs +++ b/src/ui/network.rs @@ -4,15 +4,15 @@ use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Paragraph}; use crate::app::App; +use crate::i18n::{number, t, tf}; pub fn render(frame: &mut Frame, app: &App, area: Rect) { let (rx, tx) = app.network_rate; let net_lines = vec![ - Line::from(format!("RX: {:.1} KB/s", rx / 1024.0)), - Line::from(format!("TX: {:.1} KB/s", tx / 1024.0)), - Line::from("q or Ctrl+C to quit"), + Line::from(tf("rx_rate", &[("rate", &number(rx / 1024.0, 1))])), + Line::from(tf("tx_rate", &[("rate", &number(tx / 1024.0, 1))])), ]; let net_block = - Paragraph::new(net_lines).block(Block::default().title("Network").borders(Borders::ALL)); + Paragraph::new(net_lines).block(Block::default().title(t("network")).borders(Borders::ALL)); frame.render_widget(net_block, area); } diff --git a/src/ui/processes.rs b/src/ui/processes.rs index 32f033a..c157f9f 100644 --- a/src/ui/processes.rs +++ b/src/ui/processes.rs @@ -4,6 +4,7 @@ use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, Row, Table}; use crate::app::App; +use crate::i18n::{number, t, tf}; const PROCESS_LIMIT: usize = 10; @@ -20,8 +21,11 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let cpu_usage = process.cpu_usage() / cpu_count; Row::new(vec![ process.name().to_string_lossy().into_owned(), - format!("{:.1}%", cpu_usage), - format!("{:.1} MB", process.memory() as f64 / 1_048_576.0), + tf("percent", &[("value", &number(f64::from(cpu_usage), 1))]), + tf( + "megabytes", + &[("value", &number(process.memory() as f64 / 1_048_576.0, 1))], + ), ]) }); @@ -33,10 +37,13 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { Constraint::Percentage(25), ], ) - .header(Row::new(vec!["Process", "CPU", "Memory"]).style(Style::default().fg(Color::Green))) + .header( + Row::new(vec![t("process"), t("cpu"), t("memory")]) + .style(Style::default().fg(Color::Green)), + ) .block( Block::default() - .title("Top Processes") + .title(t("top_processes")) .borders(Borders::ALL), ); diff --git a/tests/cli.rs b/tests/cli.rs index 2dd4ae9..e11fcc8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,12 +1,20 @@ //! The command line works with no terminal at all: the release workflow's smoke //! test runs `--version` headless, and anything else without a terminal must //! say so instead of drawing into a pipe. +//! +//! The messages follow the system language, so these checks look for what +//! reads the same in English and French: option names, the argument, `ssh -t`. use std::process::{Command, Output, Stdio}; fn collector(args: &[&str]) -> Output { + collector_with_env(args, &[]) +} + +fn collector_with_env(args: &[&str], env: &[(&str, &str)]) -> Output { Command::new(env!("CARGO_BIN_EXE_collector")) .args(args) + .envs(env.iter().copied()) .stdin(Stdio::null()) .output() .expect("run collector") @@ -21,7 +29,7 @@ fn version_and_help_need_no_terminal() { let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.starts_with(&version), "{flag}: {stdout}"); } - assert!(String::from_utf8_lossy(&collector(&["--help"]).stdout).contains("Usage:")); + assert!(String::from_utf8_lossy(&collector(&["--help"]).stdout).contains("--version")); } #[test] @@ -31,7 +39,7 @@ fn unknown_argument_prints_usage_and_exits_2() { assert!(out.stdout.is_empty()); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("'--nope'") && stderr.contains("Usage:"), + stderr.contains("--nope") && stderr.contains("--version"), "{stderr}" ); } @@ -42,8 +50,39 @@ fn without_a_terminal_it_says_so_and_exits_1() { assert_eq!(out.status.code(), Some(1)); assert!(out.stdout.is_empty()); let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("ssh -t"), "{stderr}"); +} + +/// `--version` is the one string that never changes with the language. +#[test] +fn version_is_the_same_in_french() { + let out = collector_with_env( + &["--version"], + &[("LANGUAGE", "fr"), ("LC_ALL", "fr_FR.UTF-8")], + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert_eq!(stdout, format!("collector {}\n", env!("CARGO_PKG_VERSION"))); +} + +/// Linux takes the language from the environment (Windows and macOS from the +/// system settings), so this is where both locales can be checked end to end. +#[cfg(target_os = "linux")] +#[test] +fn messages_follow_the_language_on_linux() { + let english = [("LANGUAGE", "en"), ("LC_ALL", "C")]; + let french = [("LANGUAGE", "fr"), ("LC_ALL", "fr_FR.UTF-8")]; + let stderr = |env| String::from_utf8_lossy(&collector_with_env(&[], env).stderr).into_owned(); assert!( - stderr.contains("Collector needs a terminal (with ssh, use ssh -t)"), - "{stderr}" + stderr(&english).contains("Collector needs a terminal (with ssh, use ssh -t)"), + "{}", + stderr(&english) + ); + assert!( + stderr(&french).contains("Collector a besoin d'un terminal (avec ssh, utilisez ssh -t)"), + "{}", + stderr(&french) ); + let help = + String::from_utf8_lossy(&collector_with_env(&["--help"], &french).stdout).into_owned(); + assert!(help.contains("Utilisation : collector"), "{help}"); } diff --git a/tests/pty_smoke.rs b/tests/pty_smoke.rs index 738cce6..be3a161 100644 --- a/tests/pty_smoke.rs +++ b/tests/pty_smoke.rs @@ -20,8 +20,9 @@ const EXIT_TIMEOUT: Duration = Duration::from_secs(15); /// A box title from the first frame. ConPTY renders the screen itself and /// need not pass escape sequences such as the alternate-screen switch through, /// so the test waits for text rather than for a sequence. One word: ratatui -/// skips a blank cell with a cursor move, so a space splits text in two. -const FRAME_MARKER: &[u8] = b"Network"; +/// skips a blank cell with a cursor move, so a space splits text in two. And +/// the same in English and French, as the test runs in the system language. +const FRAME_MARKER: &[u8] = b"CPU"; /// Cursor position request. ConPTY sends one when it starts and waits for the /// answer, as a real terminal would give. const CURSOR_QUERY: &[u8] = b"\x1b[6n"; From 5d28454ac695571bc582b49a74680bc19609dc00 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 14:03:28 +0200 Subject: [PATCH 2/8] feat: add a Preferences page, opened from Collector's name, saved under the Colony folder --- Cargo.lock | 67 +++++++ Cargo.toml | 6 + README.md | 73 ++++++-- src/app.rs | 297 ++++++++++++++++++++++++++++--- src/i18n/en.rs | 66 ++++++- src/i18n/fr.rs | 62 ++++++- src/main.rs | 17 +- src/paths.rs | 151 ++++++++++++++++ src/preferences.rs | 388 +++++++++++++++++++++++++++++++++++++++++ src/terminal.rs | 25 ++- src/ui/mod.rs | 184 +++++++++++++++++--- src/ui/preferences.rs | 396 ++++++++++++++++++++++++++++++++++++++++++ src/ui/style.rs | 26 +++ tests/cli.rs | 55 ++++++ tests/pty_smoke.rs | 57 +++++- 15 files changed, 1797 insertions(+), 73 deletions(-) create mode 100644 src/paths.rs create mode 100644 src/preferences.rs create mode 100644 src/ui/preferences.rs create mode 100644 src/ui/style.rs diff --git a/Cargo.lock b/Cargo.lock index 7256656..38b9f3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,8 +139,10 @@ name = "collector" version = "0.2.1" dependencies = [ "crossterm", + "dirs", "portable-pty", "ratatui", + "serde_json", "signal-hook 0.4.5", "sys-locale", "sysinfo", @@ -310,6 +312,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "document-features" version = "0.2.12" @@ -581,6 +604,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + [[package]] name = "line-clipping" version = "0.3.8" @@ -791,6 +823,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1144,6 +1182,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "regex" version = "1.13.1" @@ -1249,6 +1297,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serial2" version = "0.2.38" @@ -1942,3 +2003,9 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 9e7daa8..7fc5bba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,12 @@ sysinfo = { version = "0.39.6", default-features = false, features = ["system", # The system language, for English or French. No dependencies of its own on # Linux, Windows or macOS. sys-locale = "0.3.2" +# The preferences file. Read as a JSON value and taken field by field, so no +# serde derive: it would put the Linux tree over its crate budget. +serde_json = "1.0.154" +# Where that file lives: the platform's local config directory, never the +# roaming one on Windows (see src/paths.rs). +dirs = "7.0.0" # Quit signals (SIGTERM, SIGHUP, SIGINT, SIGQUIT) set a flag the event loop # checks, so the terminal is restored before Collector dies of the signal. diff --git a/README.md b/README.md index f1681da..d1a49e7 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,15 @@ locally, over SSH or inside tmux. - **In the terminal you already have open**: locally, over SSH (`ssh -t`) or inside tmux, where a desktop task manager cannot follow. - **Small and quiet**: a single binary of about 1 MB on Linux, with no network - connection and no file written (see the [privacy policy](#privacy-policy)). + connection and no file written except its own preferences (see + [Privacy](#privacy)). Today btop and bottom show far more. The [roadmap](#roadmap) closes that gap. ## What it does -One screen, refreshed every second: +One screen, refreshed every second, or at the interval chosen in +[Preferences](#preferences) (half a second to five seconds): - **CPU**: overall usage, as a gauge. - **Memory**: used and total, as a gauge. @@ -53,8 +55,9 @@ One screen, refreshed every second: - **Processes**: the ten using the most CPU, with their share of the whole machine's CPU and their memory. -`q` or Ctrl+C quits, and the terminal is restored. Everything is shown in -English or French (see [Language](#language)). +`q` or Ctrl+C quits, and the terminal is restored. Clicking **Collector** in +the top-left corner, or pressing `p`, opens the [Preferences](#preferences). +Everything is shown in English or French (see [Language](#language)). ## Installation @@ -117,21 +120,62 @@ collector --version print the version and exit (-V) | Key | Action | |---|---| +| `p` | Open or close Preferences (or click **Collector**, top left) | | `q`, Ctrl+C | Quit | +On the Preferences page: + +| Key | Action | +|---|---| +| Up, Down | Move between categories, or between sections and settings | +| Tab, Shift+Tab | Switch between the categories and their content | +| Enter | Open or close a section, or move a setting to its next value | +| Left, Right | Change the focused setting | +| Esc, `p` | Close Preferences (or click **Close**) | + +Collector takes the mouse for its clicks, so selecting text with the mouse +needs the terminal's own modifier, Shift in most terminals. + The terminal is restored on `q`, on Ctrl+C and when Collector panics. On Linux and macOS it is also restored on a quit signal from outside (SIGTERM, SIGHUP, SIGINT, SIGQUIT): Collector notices it within a second, puts the terminal back, then ends the way that signal would have ended it. +### Preferences + +Click **Collector** in the top-left corner, or press `p`: the Preferences page +takes the place of the boxes until you close it. Its categories are General, +Appearance, Accessibility and About, and each section opens with Enter or a +click. A change applies at once and is saved at once; there is no Save button. + +- **General**: the interface language, and how often the readings are taken + (every 0.5, 1, 2 or 5 seconds; more often costs more CPU). +- **About**: the version, the license, the source code and where the + preferences file is. + +The preferences are kept in `preferences/preferences.json` under Collector's +config directory: + +| Platform | Preferences file | +|---|---| +| Linux | `~/.config/Colony/Collector/preferences/preferences.json` (or under `$XDG_CONFIG_HOME`) | +| Windows | `%LOCALAPPDATA%\Colony\Collector\preferences\preferences.json` | +| macOS | `~/Library/Application Support/Colony/Collector/preferences/preferences.json` | + +A value Collector does not understand falls back to its default and the rest +of the file is kept. A file that is not valid JSON is renamed to +`preferences.json.invalid`, never overwritten, and Collector says so at the +bottom of the screen and starts from the defaults. + ### Language Collector speaks French when the system language is French, and English otherwise, with French units and decimal commas (`1,5 Go`). On Linux the language comes from the first of `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG` that is set, so `LANGUAGE=en collector` runs it in English; on Windows and -macOS it is the language chosen in the system settings. `collector --version` -prints the same `collector X.Y.Z` line in both. +macOS it is the language chosen in the system settings. The language chosen in +[Preferences](#preferences) overrides it, for `--help` and the error messages +too. `collector --version` prints the same `collector X.Y.Z` line in both. ## Roadmap @@ -165,13 +209,18 @@ Team roles and members: - Committers and reviewers: [MotherSphere](https://github.com/MotherSphere) - Approvers: [MotherSphere](https://github.com/MotherSphere) -### Privacy policy +## Privacy + +Collector sends no telemetry, no analytics and no crash reports. + +| Data | Stored or sent | Where, and why | +|---|---|---| +| Settings | stored | `Colony/Collector/preferences/preferences.json` in this machine's config directory: `~/.config` on Linux, `%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS (see [Preferences](#preferences)) | +| System statistics | neither | read from the operating system to draw the screen, kept in memory only | -Collector makes no network connection of any kind. It reads the statistics it -shows from the operating system of the machine it runs on and draws them in -your terminal; nothing leaves that machine. There is no telemetry, no -analytics, no account and no update check (Colony handles updates). Collector -writes no files. +Collector connects to no server at all: it has no account, no update check +(Colony handles updates) and no network code. Nothing it reads leaves the +machine it runs on. ## License diff --git a/src/app.rs b/src/app.rs index 9953001..0ad8e21 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,35 @@ -//! The readings and the loop that refreshes and draws them. +//! The readings, the Preferences page's state, and the loop that refreshes, +//! draws and reacts to keys and clicks. use std::collections::HashSet; use std::error::Error; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; -use crossterm::event::{self, Event}; +use crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::layout::Position; use sysinfo::{Disk, Disks, Networks, ProcessesToUpdate, System}; -use crate::{terminal, ui}; +use crate::i18n::{self, tf}; +use crate::preferences::{self, Preferences, Setting}; +use crate::terminal; +use crate::ui::{self, Focus, Hit, Page, preferences::Row}; -const TICK_RATE: Duration = Duration::from_millis(1000); +/// What a key and a click can both do. Each control sends the message its +/// key sends, so the two can never disagree. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Message { + /// The identity button, the page's Close button, `p`, and Esc on the page. + TogglePreferences, + /// A category of the open page. + Category(usize), + /// A row of the open category that takes focus: focus it, then open or + /// close it (a section) or move it to its next value (a setting). + Activate(usize), +} pub struct App { pub system: System, @@ -21,10 +39,22 @@ pub struct App { /// loopback together. pub network_rate: (f64, f64), last_tick: Instant, + pub prefs: Preferences, + /// Where changes are saved; `None` when they cannot be. + prefs_path: Option, + /// A message about the preferences file, shown until the next key or + /// click. + pub status: Option, + /// The Preferences page, while it is open. + pub page: Option, + /// The mouse's cell, for hover. + pub mouse: Option, + /// What the last frame drew that a click can reach. + hits: Vec, } impl App { - fn new() -> Self { + fn new(prefs: Preferences, prefs_path: Option, status: Option) -> Self { let mut system = System::new_all(); system.refresh_all(); @@ -40,6 +70,12 @@ impl App { networks, network_rate: (0.0, 0.0), last_tick: Instant::now(), + prefs, + prefs_path, + status, + page: None, + mouse: None, + hits: Vec::new(), } } @@ -53,9 +89,19 @@ impl App { networks: Networks::new(), network_rate: (0.0, 0.0), last_tick: Instant::now(), + prefs: Preferences::default(), + prefs_path: None, + status: None, + page: None, + mouse: None, + hits: Vec::new(), } } + fn refresh_interval(&self) -> Duration { + Duration::from_secs_f64(self.prefs.refresh_interval) + } + fn refresh(&mut self) { self.system.refresh_cpu_all(); self.system.refresh_memory(); @@ -79,31 +125,172 @@ impl App { pub fn mounted_disks(&self) -> Vec<(&Path, &Disk)> { unique_mount_points(self.disks.iter().map(|disk| (disk.mount_point(), disk))) } + + pub fn update(&mut self, message: Message) { + match message { + Message::TogglePreferences => { + self.page = match self.page { + Some(_) => None, + None => Some(Page::default()), + }; + } + Message::Category(category) => { + if let Some(page) = &mut self.page { + page.category = category; + page.focus = Focus::Categories; + page.row = 0; + } + } + Message::Activate(index) => { + let Some(page) = &mut self.page else { return }; + page.focus = Focus::Content; + page.row = index; + match focusable_rows(&self.prefs, page).into_iter().nth(index) { + Some(Row::Section { key, .. }) => { + if !page.expanded.remove(key) { + page.expanded.insert(key); + } + } + Some(Row::Setting { setting, .. }) => self.change(setting, 1), + _ => {} + } + } + } + } + + /// Applies a change at once and saves it: there is no Save button. + fn change(&mut self, setting: Setting, by: isize) { + self.prefs.step(setting, by); + if setting == Setting::Language { + i18n::set_locale(self.prefs.language.locale()); + } + let Some(path) = &self.prefs_path else { return }; + if let Err(err) = preferences::save(path, &self.prefs) { + let error = err.to_string(); + self.status = Some(tf("preferences_save_failed", &[("error", &error)])); + } + } + + /// Returns true when the key quits. + fn on_key(&mut self, key: KeyEvent) -> bool { + // Windows also reports key releases: without this, every key would + // act twice. + if key.kind != KeyEventKind::Press { + return false; + } + if terminal::is_quit(&key) { + return true; + } + self.status = None; + let Some(page) = &mut self.page else { + if key.code == KeyCode::Char('p') { + self.update(Message::TogglePreferences); + } + return false; + }; + let rows = focusable_rows(&self.prefs, page).len(); + match (key.code, page.focus) { + (KeyCode::Char('p') | KeyCode::Esc, _) => self.update(Message::TogglePreferences), + (KeyCode::Tab | KeyCode::BackTab, Focus::Content) => page.focus = Focus::Categories, + ( + KeyCode::Tab | KeyCode::BackTab | KeyCode::Right | KeyCode::Enter, + Focus::Categories, + ) if rows > 0 => { + page.focus = Focus::Content; + page.row = page.row.min(rows - 1); + } + (KeyCode::Up, Focus::Categories) => { + let category = page.category.saturating_sub(1); + self.update(Message::Category(category)); + } + (KeyCode::Down, Focus::Categories) => { + let category = (page.category + 1).min(ui::preferences::CATEGORIES.len() - 1); + self.update(Message::Category(category)); + } + (KeyCode::Up, Focus::Content) => page.row = page.row.saturating_sub(1), + (KeyCode::Down, Focus::Content) => { + page.row = (page.row + 1).min(rows.saturating_sub(1)) + } + (KeyCode::Enter | KeyCode::Char(' '), Focus::Content) => { + let row = page.row; + self.update(Message::Activate(row)); + } + (KeyCode::Left | KeyCode::Right, Focus::Content) => { + let by = if key.code == KeyCode::Left { -1 } else { 1 }; + match focusable_rows(&self.prefs, page).into_iter().nth(page.row) { + Some(Row::Setting { setting, .. }) => self.change(setting, by), + // Right opens a section, Left closes it. + Some(Row::Section { key, .. }) if by > 0 => { + page.expanded.insert(key); + } + Some(Row::Section { key, .. }) => { + page.expanded.remove(key); + } + _ => {} + } + } + _ => {} + } + false + } + + fn on_mouse(&mut self, mouse: MouseEvent) { + let position = Position::new(mouse.column, mouse.row); + self.mouse = Some(position); + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + self.status = None; + let clicked = self.hits.iter().find(|hit| hit.area.contains(position)); + if let Some(&Hit { message, .. }) = clicked { + self.update(message); + } + } +} + +/// The rows of the open category that take focus, in order: what +/// [`Message::Activate`] and the focused row count. +fn focusable_rows(prefs: &Preferences, page: &Page) -> Vec { + ui::preferences::rows(prefs, page) + .into_iter() + .filter(Row::takes_focus) + .collect() } /// Takes over the terminal and runs until a quit key or a quit signal. The -/// caller restores the terminal, whatever this returns. -pub fn run_app(quit_signal: &AtomicUsize) -> Result<(), Box> { +/// caller restores the terminal, whatever this returns. `prefs_path` is where +/// changes are saved, and `status` what to tell the user about the file. +pub fn run_app( + quit_signal: &AtomicUsize, + prefs: Preferences, + prefs_path: Option, + status: Option, +) -> Result<(), Box> { let mut terminal = terminal::enable()?; - let mut app = App::new(); + let mut app = App::new(prefs, prefs_path, status); - // Checked after every poll, which waits one tick at most. + // Checked after every poll, which waits one refresh interval at most. while quit_signal.load(Ordering::SeqCst) == 0 { // Only on the tick: any other event (a held key, a key release on - // Windows, a resize) just redraws. Refreshing on each one rescanned - // every process many times a second and sampled CPU usage over - // intervals too short to mean anything. - if app.last_tick.elapsed() >= TICK_RATE { + // Windows, a resize, the mouse moving) just redraws. Refreshing on + // each one rescanned every process many times a second and sampled + // CPU usage over intervals too short to mean anything. + if app.last_tick.elapsed() >= app.refresh_interval() { app.refresh(); } - terminal.draw(|frame| ui::render_ui(frame, &app))?; - - let timeout = TICK_RATE.saturating_sub(app.last_tick.elapsed()); - if event::poll(timeout)? - && let Event::Key(key) = event::read()? - && terminal::is_quit(&key) - { - break; + let mut hits = Vec::new(); + terminal.draw(|frame| hits = ui::render_ui(frame, &app))?; + app.hits = hits; + + let timeout = app + .refresh_interval() + .saturating_sub(app.last_tick.elapsed()); + if event::poll(timeout)? { + match event::read()? { + Event::Key(key) if app.on_key(key) => break, + Event::Mouse(mouse) => app.on_mouse(mouse), + _ => {} + } } } @@ -142,6 +329,7 @@ fn unique_mount_points<'a, T>( #[cfg(test)] mod tests { use super::*; + use crate::preferences::Language; #[test] fn loopback_is_recognised_on_every_system() { @@ -168,6 +356,71 @@ mod tests { ); } + fn press(app: &mut App, code: KeyCode) -> bool { + app.on_key(KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)) + } + + #[test] + fn the_page_is_driven_by_the_keyboard() { + let mut app = App::empty(); + assert!(!press(&mut app, KeyCode::Char('p'))); + let page = app.page.as_ref().expect("p opens Preferences"); + assert_eq!((page.category, page.focus), (0, Focus::Categories)); + + press(&mut app, KeyCode::Down); + press(&mut app, KeyCode::Down); + assert_eq!(app.page.as_ref().unwrap().category, 2); + press(&mut app, KeyCode::Up); + press(&mut app, KeyCode::Up); + press(&mut app, KeyCode::Up); + assert_eq!(app.page.as_ref().unwrap().category, 0, "stops at General"); + + // Into General's sections: open Language, move to its setting and + // change it, in both directions. + i18n::with_locale(i18n::Locale::En, || { + press(&mut app, KeyCode::Tab); + press(&mut app, KeyCode::Enter); + assert!(app.page.as_ref().unwrap().expanded.contains("language")); + press(&mut app, KeyCode::Down); + press(&mut app, KeyCode::Right); + assert_eq!(app.prefs.language, Language::En); + press(&mut app, KeyCode::Right); + assert_eq!(app.prefs.language, Language::Fr); + assert_eq!(i18n::locale(), i18n::Locale::Fr, "applies at once"); + press(&mut app, KeyCode::Left); + press(&mut app, KeyCode::Left); + assert_eq!(app.prefs.language, Language::System); + }); + + assert!(!press(&mut app, KeyCode::Esc)); + assert!(app.page.is_none(), "Esc closes"); + press(&mut app, KeyCode::Char('p')); + let page = app.page.as_ref().unwrap(); + assert!(page.expanded.is_empty(), "sections arrive closed again"); + assert!(press(&mut app, KeyCode::Char('q')), "q still quits"); + } + + #[test] + fn a_click_does_what_its_hit_says() { + let mut app = App::empty(); + app.hits = vec![Hit { + area: ratatui::layout::Rect::new(1, 0, 11, 1), + message: Message::TogglePreferences, + }]; + let click = |column, row| MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: crossterm::event::KeyModifiers::NONE, + }; + app.on_mouse(click(30, 0)); + assert!(app.page.is_none(), "outside the button"); + app.on_mouse(click(5, 0)); + assert!(app.page.is_some()); + app.on_mouse(click(5, 0)); + assert!(app.page.is_none()); + } + #[test] fn network_rate_is_per_second() { assert_eq!(per_second(2048, Duration::from_millis(500)), 4096.0); diff --git a/src/i18n/en.rs b/src/i18n/en.rs index 5b27bd7..c295d6e 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -15,7 +15,7 @@ Options: -h, --help Print this help and exit -V, --version Print the version and exit -Keys: q or Ctrl+C quits. +Keys: p opens and closes Preferences, q or Ctrl+C quits. ", ), ( @@ -40,5 +40,67 @@ Keys: q or Ctrl+C quits. ("used_of_total_gb", "{used} / {total} GB"), ("megabytes", "{value} MB"), ("percent", "{value}%"), - ("quit_hint", "q or Ctrl+C to quit"), + ("monitor_hint", "p: Preferences q or Ctrl+C: quit"), + // The Preferences page. The settings_* keys and their strings are + // colony-ui's (colony_ui::i18n), copied exactly: every Colony program + // says the same thing in the same words. + ("settings_title", "Preferences"), + ("settings_close", "Close"), + ("settings_cat_general", "General"), + ( + "settings_general_desc", + "Preferences are saved automatically.", + ), + ("settings_cat_appearance", "Appearance"), + ( + "settings_appearance_desc", + "Adjust the theme, accents, text and visual effects.", + ), + ("settings_cat_accessibility", "Accessibility"), + ( + "settings_accessibility_desc", + "Make the interface easier to see, read and use.", + ), + ("settings_cat_about", "About"), + // Collector's own. + ( + "preferences_hint", + "↑↓ Move Tab Column ←→ Change Enter Open Esc Close", + ), + ("preferences_section_language", "Language"), + ( + "preferences_language_desc", + "Applies at once, to every screen and message.", + ), + ("preferences_language", "Interface language"), + ("preferences_language_system", "Same as the system"), + ("language_en", "English"), + ("language_fr", "Français"), + ("preferences_section_refresh", "Refresh"), + ( + "preferences_refresh_desc", + "How often the readings are taken. More often costs more CPU.", + ), + ("preferences_refresh_interval", "Interval"), + ("seconds", "{value} s"), + ( + "preferences_about_desc", + "Collector, the Colony terminal system monitor.", + ), + ("preferences_version", "Version {version}"), + ("preferences_license", "License: {license}"), + ("preferences_source", "Source code: {url}"), + ("preferences_file", "Preferences file:"), + ( + "preferences_set_aside", + "Invalid preferences set aside as preferences.json.invalid", + ), + ( + "preferences_not_saved", + "Preferences will not be saved: {error}", + ), + ( + "preferences_save_failed", + "Could not save the preferences: {error}", + ), ]; diff --git a/src/i18n/fr.rs b/src/i18n/fr.rs index 5d6539d..399dde3 100644 --- a/src/i18n/fr.rs +++ b/src/i18n/fr.rs @@ -13,7 +13,7 @@ Options : -h, --help Affiche cette aide et quitte -V, --version Affiche la version et quitte -Touches : q ou Ctrl+C pour quitter. +Touches : p ouvre et ferme les Préférences, q ou Ctrl+C quitte. ", ), ( @@ -40,5 +40,63 @@ Touches : q ou Ctrl+C pour quitter. ("used_of_total_gb", "{used} / {total} Go"), ("megabytes", "{value} Mo"), ("percent", "{value} %"), - ("quit_hint", "q ou Ctrl+C pour quitter"), + ("monitor_hint", "p : Préférences q ou Ctrl+C : quitter"), + ("settings_title", "Préférences"), + ("settings_close", "Fermer"), + ("settings_cat_general", "Général"), + ( + "settings_general_desc", + "Les préférences sont enregistrées automatiquement.", + ), + ("settings_cat_appearance", "Apparence"), + ( + "settings_appearance_desc", + "Ajustez le thème, les accents, le texte et les effets visuels.", + ), + ("settings_cat_accessibility", "Accessibilité"), + ( + "settings_accessibility_desc", + "Rendez l'interface plus facile à voir, à lire et à utiliser.", + ), + ("settings_cat_about", "À propos"), + ( + "preferences_hint", + "↑↓ Déplacer Tab Colonne ←→ Modifier Entrée Ouvrir Échap Fermer", + ), + ("preferences_section_language", "Langue"), + ( + "preferences_language_desc", + "S'applique tout de suite, à tous les écrans et messages.", + ), + ("preferences_language", "Langue de l'interface"), + ("preferences_language_system", "Comme le système"), + ("language_en", "English"), + ("language_fr", "Français"), + ("preferences_section_refresh", "Actualisation"), + ( + "preferences_refresh_desc", + "La fréquence des relevés. Plus souvent coûte plus de processeur.", + ), + ("preferences_refresh_interval", "Intervalle"), + ("seconds", "{value} s"), + ( + "preferences_about_desc", + "Collector, le moniteur système de Colony pour le terminal.", + ), + ("preferences_version", "Version {version}"), + ("preferences_license", "Licence : {license}"), + ("preferences_source", "Code source : {url}"), + ("preferences_file", "Fichier des préférences :"), + ( + "preferences_set_aside", + "Préférences invalides mises de côté : preferences.json.invalid", + ), + ( + "preferences_not_saved", + "Les préférences ne seront pas enregistrées : {error}", + ), + ( + "preferences_save_failed", + "Impossible d'enregistrer les préférences : {error}", + ), ]; diff --git a/src/main.rs b/src/main.rs index 04cd435..5fda1d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod app; mod cli; mod i18n; +mod paths; +mod preferences; mod terminal; mod ui; @@ -12,7 +14,17 @@ use cli::Mode; use i18n::{t, tf}; fn main() -> ExitCode { - i18n::set_locale(i18n::Locale::system()); + // Read first, so --help speaks the chosen language too, but only read: + // a file that fails to load is set aside further down, by the monitor, + // which can tell the user. + let prefs_path = preferences::path(); + let loaded = prefs_path.as_deref().map_or_else( + |_| Ok(preferences::Preferences::default()), + preferences::load, + ); + let prefs = loaded.as_ref().cloned().unwrap_or_default(); + i18n::set_locale(prefs.language.locale()); + let args: Vec = env::args_os().skip(1).collect(); match cli::parse_args(&args) { Ok(Mode::Run) => {} @@ -34,7 +46,8 @@ fn main() -> ExitCode { } }; - let result = app::run_app(&quit_signal); + let (save_to, status) = preferences::save_target(prefs_path, &loaded); + let result = app::run_app(&quit_signal, prefs, save_to, status); let restored = terminal::restore(); let result = result.and(restored.map_err(Into::into)); if let Err(err) = &result { diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..d834951 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,151 @@ +//! Where Collector keeps its files: the config row of the Colony table +//! (design/filesystem.md in Project-Colony-Resources). +//! +//! | Linux | Windows | macOS | +//! |---|---|---| +//! | `~/.config/Colony/Collector/` | `%LOCALAPPDATA%\Colony\Collector\` | `~/Library/Application Support/Colony/Collector/` | +//! +//! A port of `colony_ui::paths`, not a call to it: colony-ui is built on iced, +//! and depending on it would pull a GUI toolkit into a terminal program, far +//! past Collector's crate and size budgets. The rules are the same: the +//! platform's local config directory (never the roaming one on Windows), the +//! `Colony` vendor directory, then the program, and a program name that is a +//! single safe path component other than the reserved `apps`. +//! +//! Collector keeps preferences and nothing else, so only the config row is +//! ported. It has no data to keep and nothing to cache, and creates neither +//! directory. + +use std::ffi::OsStr; +use std::io; +use std::path::{Path, PathBuf}; + +/// The organisation directory every Colony program nests under. +const VENDOR: &str = "Colony"; + +/// `/Colony/apps/` is the launcher's shared install root, so no program +/// directory may take that name. +const APPS: &str = "apps"; + +/// The program's directory name, capitalised as the program spells it. +pub const PROGRAM: &str = "Collector"; + +/// Collector's config directory, `/Colony/Collector/`, without +/// creating it: saving the preferences creates what it needs. +pub fn config_dir() -> io::Result { + config_dir_of(PROGRAM) +} + +fn config_dir_of(program: &str) -> io::Result { + let root = dirs::config_local_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "cannot determine the platform config directory", + ) + })?; + Ok(root.join(VENDOR).join(program_component(program)?)) +} + +/// Rejects anything that is not a single, safe path component, as +/// `colony_ui::paths` does: `..` or a separator would leave the Colony tree, +/// and Windows strips a trailing dot or space, so `Grape.` would be `Grape`. +fn component(name: &str) -> io::Result<&str> { + let invalid = name.is_empty() + || name == "." + || name == ".." + || name.ends_with(['.', ' ']) + || name.contains(['/', '\\', '\0']) + || Path::new(name).components().count() != 1 + || Path::new(name).file_name() != Some(OsStr::new(name)); + if invalid { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name:?} is not a safe single path component"), + )); + } + Ok(name) +} + +/// [`component`], plus `apps` in any case: Windows and default macOS +/// filesystems ignore case, and upper-casing first also catches a letter that +/// folds to an ASCII one, such as the long s in `appſ`. +fn program_component(name: &str) -> io::Result<&str> { + let name = component(name)?; + if name.to_uppercase().eq_ignore_ascii_case(APPS) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name:?} is reserved for the shared install root and cannot name a program"), + )); + } + Ok(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_config_dir_nests_under_the_vendor_then_the_program() { + let path = config_dir().unwrap(); + let tail: Vec<_> = path + .components() + .rev() + .take(2) + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + assert_eq!(tail, ["Collector", "Colony"], "{path:?}"); + } + + #[test] + fn a_program_name_cannot_escape_the_colony_tree() { + for bad in [ + "..", + ".", + "", + "../../etc", + "a/b", + "a\\b", + "with\0nul", + "Grape.", + "Grape ", + ] { + let err = config_dir_of(bad).expect_err(bad); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{bad:?}"); + } + } + + #[test] + fn the_install_root_name_is_reserved_in_any_case() { + for reserved in ["apps", "Apps", "APPS", "apps.", "apps ", "app\u{17F}"] { + let err = config_dir_of(reserved).expect_err(reserved); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{reserved:?}"); + } + for good in ["Colony", "Collector", "SAM - Colony Edition"] { + assert!(config_dir_of(good).is_ok(), "{good:?}"); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_uses_the_xdg_config_directory() { + // XDG_CONFIG_HOME may be set; only assert the default shape when not. + if std::env::var_os("XDG_CONFIG_HOME").is_none() { + let home = std::env::var("HOME").expect("HOME"); + assert_eq!( + config_dir().unwrap(), + PathBuf::from(home).join(".config/Colony/Collector") + ); + } + } + + /// Never AppData\Roaming: `dirs::config_dir()` would be. + #[cfg(windows)] + #[test] + fn windows_uses_local_app_data() { + let local = std::env::var("LOCALAPPDATA").expect("LOCALAPPDATA"); + assert_eq!( + config_dir().unwrap(), + PathBuf::from(local).join("Colony").join("Collector") + ); + } +} diff --git a/src/preferences.rs b/src/preferences.rs new file mode 100644 index 0000000..e65faf5 --- /dev/null +++ b/src/preferences.rs @@ -0,0 +1,388 @@ +//! What the Preferences page sets, saved to +//! `/preferences/preferences.json` the moment it changes: there is no +//! Save button. +//! +//! The file is read as a JSON value and taken field by field. A field that is +//! missing or of the wrong type falls back to its default and the rest of the +//! file is kept, so an old file, a hand-edited one or one written by a newer +//! build still loads. Only a file that cannot be read or is not JSON at all +//! fails to load, and the monitor then sets it aside rather than saving over it. + +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::i18n::{Locale, t, tf}; +use crate::paths; + +/// The theme a fresh install starts on, and the one an unknown theme falls +/// back to, as in colony-ui. +pub const DEFAULT_THEME: (&str, &str) = ("gruvbox", "dark"); + +/// Seconds between two readings, as the Preferences page offers them. +pub const REFRESH_INTERVALS: [f64; 4] = [0.5, 1.0, 2.0, 5.0]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Language { + /// Whatever the system language is, read again at each start. + #[default] + System, + En, + Fr, +} + +impl Language { + pub const ALL: [Self; 3] = [Self::System, Self::En, Self::Fr]; + + /// The value written to the file. + fn key(self) -> &'static str { + match self { + Self::System => "system", + Self::En => "en", + Self::Fr => "fr", + } + } + + pub fn locale(self) -> Locale { + match self { + Self::System => Locale::system(), + Self::En => Locale::En, + Self::Fr => Locale::Fr, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Preferences { + // The fields of colony_ui::preferences::StandardPreferences that mean + // something in a terminal, under its names and in its JSON forms, so the + // file reads like every other Colony program's. The font, text size and + // animation fields are left out: the terminal owns the font, and + // Collector has no animation to switch off. + pub selected_theme: String, + pub selected_variant: String, + /// An accent key such as `"violet"`, or `None` for the theme's own. + /// Left out of the file when `None`, as colony-ui does. + pub selected_accent: Option, + pub auto_accent: bool, + pub high_contrast: bool, + pub reduce_motion: bool, + // Collector's own. + pub language: Language, + /// Seconds, one of [`REFRESH_INTERVALS`]. + pub refresh_interval: f64, +} + +impl Default for Preferences { + fn default() -> Self { + Self { + selected_theme: DEFAULT_THEME.0.to_owned(), + selected_variant: DEFAULT_THEME.1.to_owned(), + selected_accent: None, + auto_accent: false, + high_contrast: false, + reduce_motion: false, + language: Language::System, + refresh_interval: 1.0, + } + } +} + +impl Preferences { + /// Fails only when `text` is not JSON. Anything else that is wrong falls + /// back to its default, one field at a time, and unknown fields are + /// ignored; JSON that is not an object is all defaults. + pub fn from_json(text: &str) -> serde_json::Result { + let value: Value = serde_json::from_str(text)?; + let string = |key| value.get(key).and_then(Value::as_str).map(str::to_owned); + let flag = |key, default| value.get(key).and_then(Value::as_bool).unwrap_or(default); + let defaults = Self::default(); + Ok(Self { + selected_theme: string("selected_theme").unwrap_or(defaults.selected_theme), + selected_variant: string("selected_variant").unwrap_or(defaults.selected_variant), + selected_accent: string("selected_accent"), + auto_accent: flag("auto_accent", defaults.auto_accent), + high_contrast: flag("high_contrast", defaults.high_contrast), + reduce_motion: flag("reduce_motion", defaults.reduce_motion), + language: string("language") + .and_then(|key| Language::ALL.into_iter().find(|l| l.key() == key)) + .unwrap_or(defaults.language), + refresh_interval: value + .get("refresh_interval") + .and_then(Value::as_f64) + .filter(|seconds| REFRESH_INTERVALS.contains(seconds)) + .unwrap_or(defaults.refresh_interval), + }) + } + + /// Moves `setting` `by` places through its choices, wrapping around at + /// either end. + pub fn step(&mut self, setting: Setting, by: isize) { + match setting { + Setting::Language => self.language = cycle(&Language::ALL, self.language, by), + Setting::RefreshInterval => { + self.refresh_interval = cycle(&REFRESH_INTERVALS, self.refresh_interval, by); + } + } + } + + /// Pretty-printed, for whoever opens the file by hand. + pub fn to_json(&self) -> String { + let mut value = json!({ + "selected_theme": self.selected_theme, + "selected_variant": self.selected_variant, + "auto_accent": self.auto_accent, + "high_contrast": self.high_contrast, + "reduce_motion": self.reduce_motion, + "language": self.language.key(), + "refresh_interval": self.refresh_interval, + }); + if let Some(accent) = &self.selected_accent { + value["selected_accent"] = accent.as_str().into(); + } + format!("{value:#}\n") + } +} + +/// A setting the Preferences page changes with Left and Right. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Setting { + Language, + RefreshInterval, +} + +/// The choice `by` places from `current`, wrapping around. A current value +/// that is not one of the choices counts as the first. +fn cycle(choices: &[T], current: T, by: isize) -> T { + let at = choices.iter().position(|&c| c == current).unwrap_or(0); + let len = choices.len() as isize; + choices[(at as isize + by).rem_euclid(len) as usize] +} + +/// `/preferences/preferences.json`, the design/filesystem.md tree. +pub fn path() -> io::Result { + Ok(paths::config_dir()? + .join("preferences") + .join("preferences.json")) +} + +/// The preferences at `path`, or the defaults when there is no file yet. An +/// error means the file exists but cannot be read or is not JSON; it is left +/// untouched. +pub fn load(path: &Path) -> io::Result { + match fs::read_to_string(path) { + Ok(text) => Preferences::from_json(&text).map_err(io::Error::other), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Preferences::default()), + Err(err) => Err(err), + } +} + +/// Writes through a temporary file in the same directory, then renames it +/// over the old one: a crash or a full disk mid-write leaves the previous file +/// whole rather than half of a new one. +pub fn save(path: &Path, preferences: &Preferences) -> io::Result<()> { + let dir = path.parent().unwrap_or(Path::new(".")); + fs::create_dir_all(dir)?; + let temporary = with_suffix(path, ".tmp"); + let written = File::create(&temporary).and_then(|mut file| { + file.write_all(preferences.to_json().as_bytes())?; + file.sync_all() + }); + let result = written.and_then(|()| fs::rename(&temporary, path)); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +/// Where the monitor saves, and what the status line tells the user about the +/// file, given what [`path`] and [`load`] returned. A file that failed to load +/// is renamed to `preferences.json.invalid` first, so that saving never +/// overwrites something the user may want back. If even that fails, nothing +/// is saved this session. +pub fn save_target( + path: io::Result, + loaded: &io::Result, +) -> (Option, Option) { + let not_saved = |err: io::Error| { + let message = tf("preferences_not_saved", &[("error", &err.to_string())]); + (None, Some(message)) + }; + match (path, loaded) { + (Err(err), _) => not_saved(err), + (Ok(path), Ok(_)) => (Some(path), None), + (Ok(path), Err(_)) => match fs::rename(&path, with_suffix(&path, ".invalid")) { + Ok(()) => (Some(path), Some(t("preferences_set_aside").to_owned())), + Err(err) => not_saved(err), + }, + } +} + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(suffix); + name.into() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh directory under the system temp dir, removed on drop. + struct TempDir(PathBuf); + + impl TempDir { + fn new(name: &str) -> Self { + let dir = + std::env::temp_dir().join(format!("collector-test-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + Self(dir) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn an_empty_object_is_the_defaults() { + assert_eq!( + Preferences::from_json("{}").unwrap(), + Preferences::default() + ); + assert_eq!( + Preferences::from_json("[1, 2]").unwrap(), + Preferences::default() + ); + } + + #[test] + fn a_bad_value_falls_back_and_keeps_the_rest() { + let p = Preferences::from_json( + r#"{ + "selected_theme": 42, + "selected_variant": "latte", + "selected_accent": ["violet"], + "high_contrast": "yes", + "reduce_motion": true, + "language": "de", + "refresh_interval": 0.25, + "font_size": "large" + }"#, + ) + .unwrap(); + let defaults = Preferences::default(); + assert_eq!(p.selected_theme, defaults.selected_theme); + assert_eq!(p.selected_accent, None); + assert!(!p.high_contrast); + assert_eq!(p.language, Language::System); + assert_eq!(p.refresh_interval, 1.0); + // The valid neighbours survive. + assert_eq!(p.selected_variant, "latte"); + assert!(p.reduce_motion); + } + + #[test] + fn settings_cycle_through_their_choices_both_ways() { + let mut p = Preferences::default(); + p.step(Setting::Language, 1); + assert_eq!(p.language, Language::En); + p.step(Setting::Language, -2); + assert_eq!(p.language, Language::Fr, "wraps around backwards"); + p.step(Setting::Language, 1); + assert_eq!(p.language, Language::System, "wraps around forwards"); + + p.step(Setting::RefreshInterval, 1); + assert_eq!(p.refresh_interval, 2.0); + p.step(Setting::RefreshInterval, -3); + assert_eq!(p.refresh_interval, 5.0); + } + + #[test] + fn it_round_trips_through_the_file() { + let dir = TempDir::new("round-trip"); + let path = dir.0.join("preferences").join("preferences.json"); + let preferences = Preferences { + selected_theme: "catppuccin".into(), + selected_variant: "mocha".into(), + selected_accent: Some("violet".into()), + auto_accent: true, + high_contrast: true, + reduce_motion: true, + language: Language::Fr, + refresh_interval: 0.5, + }; + save(&path, &preferences).unwrap(); + assert_eq!(load(&path).unwrap(), preferences); + assert!(!with_suffix(&path, ".tmp").exists(), "temporary file left"); + + // No accent: left out, as colony-ui writes it, and read back as none. + let preferences = Preferences::default(); + save(&path, &preferences).unwrap(); + assert!( + !fs::read_to_string(&path) + .unwrap() + .contains("selected_accent") + ); + assert_eq!(load(&path).unwrap(), preferences); + } + + #[test] + fn a_missing_file_is_the_defaults() { + let dir = TempDir::new("missing"); + assert_eq!( + load(&dir.0.join("preferences.json")).unwrap(), + Preferences::default() + ); + } + + #[test] + fn an_invalid_file_is_set_aside_and_never_overwritten() { + let dir = TempDir::new("invalid"); + fs::create_dir_all(&dir.0).unwrap(); + let path = dir.0.join("preferences.json"); + fs::write(&path, "{ not json").unwrap(); + + let loaded = load(&path); + assert!(loaded.is_err()); + let (target, status) = save_target(Ok(path.clone()), &loaded); + assert_eq!(target.as_deref(), Some(path.as_path())); + assert!(status.is_some(), "the user is told"); + assert_eq!( + fs::read_to_string(with_suffix(&path, ".invalid")).unwrap(), + "{ not json" + ); + + // The next save starts a new file next to the one set aside. + save(&path, &Preferences::default()).unwrap(); + assert_eq!(load(&path).unwrap(), Preferences::default()); + assert!(with_suffix(&path, ".invalid").exists()); + } + + #[test] + fn no_config_directory_means_nothing_is_saved() { + let (target, status) = save_target( + Err(io::Error::new(io::ErrorKind::NotFound, "no home")), + &Ok(Preferences::default()), + ); + assert_eq!(target, None); + assert!(status.is_some()); + } + + #[test] + fn the_file_sits_under_preferences_in_the_config_dir() { + let path = path().unwrap(); + assert!( + path.ends_with( + Path::new("Colony") + .join("Collector") + .join("preferences") + .join("preferences.json") + ) + ); + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 9963fdf..78300cb 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -8,11 +8,13 @@ use std::sync::Arc; use std::sync::atomic::AtomicUsize; use crossterm::cursor::Show; -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, +}; +use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, }; -use crossterm::{ExecutableCommand, execute}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; @@ -70,18 +72,27 @@ pub fn die_of_signal(quit_signal: &AtomicUsize) -> Option { None } -/// Raw mode on, alternate screen entered. [`restore`] undoes both. +/// Raw mode on, alternate screen entered, mouse reported (clicks and +/// movement, for the identity button, the Preferences page and hover). +/// [`restore`] undoes all three. pub fn enable() -> io::Result>> { enable_raw_mode()?; - io::stdout().execute(EnterAlternateScreen)?; + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; Terminal::new(CrosstermBackend::new(io::stdout())) } -/// Leaves the alternate screen, shows the cursor and turns raw mode off. Safe -/// to run more than once, and from the panic hook. +/// Stops the mouse reports, leaves the alternate screen, shows the cursor and +/// turns raw mode off. Safe to run more than once, and from the panic hook. +/// Mouse reporting left on would fill the shell with escape codes at every +/// click. pub fn restore() -> io::Result<()> { let raw = disable_raw_mode(); - execute!(io::stdout(), LeaveAlternateScreen, Show)?; + execute!( + io::stdout(), + DisableMouseCapture, + LeaveAlternateScreen, + Show + )?; raw } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d5332f0..e452560 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,30 +1,90 @@ -//! Drawing: one file per box. +//! Drawing: a header bar, the monitor's boxes (one file each) or the +//! Preferences page in their place, and a footer. mod cpu_memory; mod disks; mod network; +pub mod preferences; mod processes; +mod style; use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::Span; use ratatui::widgets::Paragraph; -use crate::app::App; +use crate::app::{App, Message}; use crate::i18n::t; +use crate::paths; -pub fn render_ui(frame: &mut Frame, app: &App) { +pub use preferences::{Focus, Page}; + +/// Where a click lands, and what it does: the same message as the key that +/// does the same thing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Hit { + pub area: Rect, + pub message: Message, +} + +/// Draws the screen and returns what can be clicked on it. +pub fn render_ui(frame: &mut Frame, app: &App) -> Vec { + let mut hits = Vec::new(); + let [header, body, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .horizontal_margin(1) + .areas(frame.area()); + + // The program's name is the way into Preferences, and out again + // (design/navigation.md): one button, marked while the page is open. + let name = format!(" {} ", paths::PROGRAM); + let button = Rect { + width: (Span::raw(name.as_str()).width() as u16).min(header.width), + ..header + }; + let open = app.page.is_some(); + let style = style::item(open, hovered(app, button)).patch(style::title()); + frame.render_widget(Paragraph::new(name).style(style), button); + hits.push(Hit { + area: button, + message: Message::TogglePreferences, + }); + + match &app.page { + Some(page) => preferences::render(frame, app, page, body, &mut hits), + None => render_monitor(frame, app, body), + } + + // Full width, where the French hints fit too. A status message about the + // preferences file takes the line until the next key or click. + let footer_text = match (&app.status, open) { + (Some(status), _) => Paragraph::new(status.as_str()), + (None, false) => Paragraph::new(t("monitor_hint")).style(style::muted()), + (None, true) => Paragraph::new(t("preferences_hint")).style(style::muted()), + }; + frame.render_widget(footer_text, footer); + hits +} + +/// Whether the mouse is over `area`. +fn hovered(app: &App, area: Rect) -> bool { + app.mouse.is_some_and(|position| area.contains(position)) +} + +fn render_monitor(frame: &mut Frame, app: &App, area: Rect) { // The processes take what is left, so a short terminal shrinks the list - // and never the boxes above it or the footer. + // and never the boxes above it. let main_layout = Layout::default() .direction(Direction::Vertical) - .margin(1) .constraints([ Constraint::Length(7), Constraint::Length(7), Constraint::Fill(1), - Constraint::Length(1), ]) - .split(frame.area()); + .split(area); cpu_memory::render(frame, app, main_layout[0]); @@ -36,9 +96,6 @@ pub fn render_ui(frame: &mut Frame, app: &App) { network::render(frame, app, disk_network[1]); processes::render(frame, app, main_layout[2]); - - // Full width, where the French hint fits too. - frame.render_widget(Paragraph::new(t("quit_hint")), main_layout[3]); } #[cfg(test)] @@ -61,15 +118,36 @@ mod tests { .join("\n") } - /// French runs 15 to 20% longer than English: at the smallest usual - /// terminal size, every title, header and hint is drawn whole in both. + /// Draws `app` at 80x24, the smallest usual terminal size. + fn draw(app: &App) -> (String, Vec) { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + let mut hits = Vec::new(); + terminal + .draw(|frame| hits = render_ui(frame, app)) + .expect("draw"); + (screen_text(terminal.backend().buffer()), hits) + } + + fn assert_shown(screen: &str, locale: Locale, expected: &[String]) { + for text in expected { + assert!( + screen.contains(text.as_str()), + "{locale:?}: {text:?} is cut off or missing:\n{screen}" + ); + } + // A key with no string would show the key itself. + for prefix in ["settings_", "preferences_"] { + assert!(!screen.contains(prefix), "{locale:?}: a raw key:\n{screen}"); + } + } + + /// French runs 15 to 20% longer than English: every title, header and + /// hint is drawn whole in both. #[test] fn every_label_fits_at_80x24_in_both_locales() { let app = App::empty(); for locale in [Locale::En, Locale::Fr] { let (screen, expected) = i18n::with_locale(locale, || { - let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); - terminal.draw(|frame| render_ui(frame, &app)).expect("draw"); let zero = number(0.0, 1); let mut expected: Vec = [ "cpu", @@ -78,21 +156,83 @@ mod tests { "network", "top_processes", "process", - "quit_hint", + "monitor_hint", ] .map(|key| t(key).to_owned()) .into(); expected.push(tf("rx_rate", &[("rate", &zero)])); expected.push(tf("tx_rate", &[("rate", &zero)])); expected.push(tf("used_of_total_gb", &[("used", &zero), ("total", &zero)])); - (screen_text(terminal.backend().buffer()), expected) + expected.push(paths::PROGRAM.to_owned()); + (draw(&app).0, expected) }); - for text in expected { - assert!( - screen.contains(&text), - "{locale:?}: {text:?} is cut off or missing:\n{screen}" - ); + assert_shown(&screen, locale, &expected); + } + } + + /// Each category with every section open: the page header, every + /// category name, every heading, section and setting are drawn whole in + /// both locales. + #[test] + fn the_preferences_page_fits_at_80x24_in_both_locales() { + for locale in [Locale::En, Locale::Fr] { + for category in 0..preferences::CATEGORIES.len() { + let mut app = App::empty(); + app.update(Message::TogglePreferences); + app.update(Message::Category(category)); + let page = app.page.as_mut().expect("open"); + page.expanded.extend(["language", "refresh"]); + + let (screen, expected) = i18n::with_locale(locale, || { + let page = app.page.as_ref().expect("open"); + let mut expected: Vec = + ["settings_title", "settings_close", "preferences_hint"] + .into_iter() + .chain(preferences::CATEGORIES) + .map(|key| t(key).to_owned()) + .collect(); + for row in preferences::rows(&app.prefs, page) { + match row { + preferences::Row::Section { title, .. } => expected.push(title.into()), + preferences::Row::Setting { title, value, .. } => { + expected.push(title.into()); + expected.push(value); + } + // Wrapped: each word is there. + preferences::Row::Note(text) => { + expected.extend(text.split(' ').map(str::to_owned)); + } + _ => {} + } + } + (draw(&app).0, expected) + }); + assert_shown(&screen, locale, &expected); } } } + + /// The identity button and the page's Close button send the same + /// message, and that message closes what it opened. + #[test] + fn the_identity_button_and_close_send_the_same_toggle() { + let mut app = App::empty(); + let (_, hits) = draw(&app); + let identity = hits[0]; + assert_eq!(identity.message, Message::TogglePreferences); + assert_eq!(identity.area.y, 0, "top-left"); + + app.update(identity.message); + assert!(app.page.is_some()); + let (_, hits) = draw(&app); + let toggles: Vec<_> = hits + .iter() + .filter(|hit| hit.message == Message::TogglePreferences) + .collect(); + assert_eq!(toggles.len(), 2, "identity and Close: {hits:?}"); + assert!(toggles.iter().any(|hit| hit.area == identity.area)); + + app.update(toggles[1].message); + assert!(app.page.is_none()); + } } diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs new file mode 100644 index 0000000..f1cdabf --- /dev/null +++ b/src/ui/preferences.rs @@ -0,0 +1,396 @@ +//! The Preferences page. It replaces the monitor's boxes, not the screen: the +//! header bar and the footer stay (design/settings-page.md). Its header, its +//! category list on the left and its collapsible sections follow the +//! colony-ui kit, drawn in cells instead of iced widgets. + +use std::collections::HashSet; + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use super::{Hit, hovered, style}; +use crate::app::{App, Message}; +use crate::i18n::{number, t, tf}; +use crate::preferences::{self, Language, Preferences, Setting}; + +/// colony-ui's `STANDARD_CATEGORIES` in their fixed order, then its +/// `ABOUT_CATEGORY`, last. +pub const CATEGORIES: [&str; 4] = [ + "settings_cat_general", + "settings_cat_appearance", + "settings_cat_accessibility", + "settings_cat_about", +]; + +/// The line under each category's heading. +const DESCRIPTIONS: [&str; 4] = [ + "settings_general_desc", + "settings_appearance_desc", + "settings_accessibility_desc", + "preferences_about_desc", +]; + +/// Section chevrons, from the WGL4 set every console font has: no Nerd Font +/// needed. +const COLLAPSED: &str = "►"; +const EXPANDED: &str = "▼"; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Focus { + /// Up and Down change the category. + #[default] + Categories, + /// Up and Down move between the category's sections and settings. + Content, +} + +/// What the page remembers while it is open. Closing drops it, so the page +/// always opens on General with every section closed. +#[derive(Debug, Default)] +pub struct Page { + pub category: usize, + pub focus: Focus, + /// The focused row, counted among the rows that take focus. + pub row: usize, + /// The open sections, by key. + pub expanded: HashSet<&'static str>, +} + +/// One entry of a category, below its heading and description. +pub enum Row { + /// A collapsible section header. Enter or a click opens or closes it. + Section { + key: &'static str, + title: &'static str, + expanded: bool, + }, + /// A setting and its current value. Left and Right change it; Enter or a + /// click moves it to the next value. + Setting { + setting: Setting, + title: &'static str, + value: String, + }, + /// A muted explanation. + Note(String), + Text(String), + /// A blank line after an open section. + Gap, +} + +impl Row { + pub fn takes_focus(&self) -> bool { + matches!(self, Self::Section { .. } | Self::Setting { .. }) + } +} + +/// The open category's rows, top to bottom. +pub fn rows(prefs: &Preferences, page: &Page) -> Vec { + let mut rows = Vec::new(); + match page.category { + 0 => { + section( + &mut rows, + page, + "language", + "preferences_section_language", + || { + vec![ + Row::Note(t("preferences_language_desc").to_owned()), + Row::Setting { + setting: Setting::Language, + title: t("preferences_language"), + value: language_label(prefs.language).to_owned(), + }, + ] + }, + ); + section( + &mut rows, + page, + "refresh", + "preferences_section_refresh", + || { + vec![ + Row::Note(t("preferences_refresh_desc").to_owned()), + Row::Setting { + setting: Setting::RefreshInterval, + title: t("preferences_refresh_interval"), + value: seconds(prefs.refresh_interval), + }, + ] + }, + ); + } + 3 => { + let version = env!("CARGO_PKG_VERSION"); + rows.push(Row::Text(tf( + "preferences_version", + &[("version", version)], + ))); + let license = env!("CARGO_PKG_LICENSE"); + rows.push(Row::Text(tf( + "preferences_license", + &[("license", license)], + ))); + let url = env!("CARGO_PKG_REPOSITORY"); + rows.push(Row::Text(tf("preferences_source", &[("url", url)]))); + rows.push(Row::Gap); + rows.push(Row::Note(t("preferences_file").to_owned())); + rows.push(Row::Text(match preferences::path() { + Ok(path) => path.display().to_string(), + Err(err) => err.to_string(), + })); + } + // Appearance and Accessibility: their heading and description only. + _ => {} + } + rows +} + +/// A section header, and its content when it is open. +fn section( + rows: &mut Vec, + page: &Page, + key: &'static str, + title: &'static str, + content: impl FnOnce() -> Vec, +) { + let expanded = page.expanded.contains(key); + rows.push(Row::Section { + key, + title: t(title), + expanded, + }); + if expanded { + rows.extend(content()); + rows.push(Row::Gap); + } +} + +fn language_label(language: Language) -> &'static str { + match language { + Language::System => t("preferences_language_system"), + Language::En => t("language_en"), + Language::Fr => t("language_fr"), + } +} + +/// `0.5 s`, `1 s`: no decimal part when there is none. +fn seconds(value: f64) -> String { + let decimals = if value.fract() == 0.0 { 0 } else { 1 }; + tf("seconds", &[("value", &number(value, decimals))]) +} + +pub fn render(frame: &mut Frame, app: &App, page: &Page, area: Rect, hits: &mut Vec) { + let [header, body] = Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area); + + // The title, then Close at the far right. Close sends the identity + // button's message: two ways out, one message. + let close = format!(" {} ", t("settings_close")); + let close_width = Span::raw(close.as_str()).width() as u16; + let [title, close_area] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(close_width)]).areas(header); + frame.render_widget( + Paragraph::new(t("settings_title")).style(style::title()), + title, + ); + frame.render_widget( + Paragraph::new(close).style(style::item(false, hovered(app, close_area))), + close_area, + ); + hits.push(Hit { + area: close_area, + message: Message::TogglePreferences, + }); + + let labels = CATEGORIES.map(t); + // As wide as the longest name in this locale, plus a space each side and + // the border: the list grows with the language rather than cutting it. + let list_width = labels + .iter() + .map(|label| Span::raw(*label).width()) + .max() + .unwrap_or(0) + + 3; + let [list, content] = + Layout::horizontal([Constraint::Length(list_width as u16), Constraint::Fill(1)]) + .spacing(1) + .areas(body); + + let block = Block::default().borders(Borders::RIGHT); + let inner = block.inner(list); + frame.render_widget(block, list); + for (index, (label, y)) in labels.iter().zip(inner.y..inner.bottom()).enumerate() { + let item = Rect { + y, + height: 1, + ..inner + }; + let style = style::item(index == page.category, hovered(app, item)); + frame.render_widget(Paragraph::new(format!(" {label}")).style(style), item); + hits.push(Hit { + area: item, + message: Message::Category(index), + }); + } + + render_content(frame, app, page, content, hits); +} + +fn render_content(frame: &mut Frame, app: &App, page: &Page, area: Rect, hits: &mut Vec) { + let width = usize::from(area.width); + let mut lines = vec![Line::styled(t(CATEGORIES[page.category]), style::title())]; + lines.extend(muted_lines(t(DESCRIPTIONS[page.category]), width)); + lines.push(Line::default()); + + // (line, index among the rows that take focus) + let mut targets = Vec::new(); + for row in rows(&app.prefs, page) { + if row.takes_focus() { + targets.push((lines.len(), targets.len())); + } + match row { + Row::Section { + title, expanded, .. + } => { + let chevron = if expanded { EXPANDED } else { COLLAPSED }; + lines.push(spread( + Span::styled(title, style::title()), + Span::raw(chevron), + width, + )); + } + Row::Setting { title, value, .. } => lines.push(spread( + Span::raw(format!(" {title}")), + Span::raw(format!("‹ {value} ›")), + width, + )), + Row::Note(text) => lines.extend(muted_lines(&text, width)), + Row::Text(text) => lines.extend(wrap(&text, width).into_iter().map(Line::from)), + Row::Gap => lines.push(Line::default()), + } + } + + // Scrolled just enough to keep the focused row on screen. + let focused = |index: usize| page.focus == Focus::Content && index == page.row; + let focused_line = targets + .iter() + .find(|&&(_, index)| focused(index)) + .map_or(0, |&(line, _)| line); + let offset = (focused_line + 1).saturating_sub(usize::from(area.height)); + + for (line, index) in targets { + let Some(y) = line + .checked_sub(offset) + .and_then(|row| u16::try_from(row).ok()) + .filter(|&row| row < area.height) + else { + continue; + }; + let target = Rect { + y: area.y + y, + height: 1, + ..area + }; + lines[line].style = style::item(focused(index), hovered(app, target)); + hits.push(Hit { + area: target, + message: Message::Activate(index), + }); + } + + frame.render_widget(Paragraph::new(lines.split_off(offset)), area); +} + +/// `left` at the start of the line and `right` at its end. +fn spread<'a>(left: Span<'a>, right: Span<'a>, width: usize) -> Line<'a> { + let gap = width.saturating_sub(left.width() + right.width()).max(1); + Line::from(vec![left, Span::raw(" ".repeat(gap)), right]) +} + +fn muted_lines(text: &str, width: usize) -> Vec> { + wrap(text, width) + .into_iter() + .map(|line| Line::styled(line, style::muted())) + .collect() +} + +/// Breaks `text` between words to fit `width` columns, and a word longer than +/// a line (a path) wherever it has to. +// ponytail: counts characters, not display columns, so a wide (CJK) character +// in a path can push a line past the edge, where it is cut. Measure with +// unicode-width if that ever shows up. +fn wrap(text: &str, width: usize) -> Vec { + let width = width.max(1); + let mut lines = Vec::new(); + let mut line = String::new(); + for mut word in text.split(' ') { + let used = line.chars().count(); + let needed = word.chars().count() + usize::from(used > 0); + if used + needed <= width { + if used > 0 { + line.push(' '); + } + line.push_str(word); + continue; + } + if used > 0 { + lines.push(std::mem::take(&mut line)); + } + while word.chars().count() > width { + let cut = word + .char_indices() + .nth(width) + .map_or(word.len(), |(at, _)| at); + lines.push(word[..cut].to_owned()); + word = &word[cut..]; + } + line.push_str(word); + } + lines.push(line); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_standard_categories_come_first_and_about_last() { + assert_eq!( + CATEGORIES, + [ + "settings_cat_general", + "settings_cat_appearance", + "settings_cat_accessibility", + "settings_cat_about", + ] + ); + } + + #[test] + fn wrapping_keeps_words_whole_and_cuts_only_what_cannot_fit() { + assert_eq!(wrap("one two three", 7), ["one two", "three"]); + assert_eq!(wrap("short", 20), ["short"]); + assert_eq!(wrap("/a/very/long/path", 6), ["/a/ver", "y/long", "/path"]); + assert_eq!(wrap("Réseau é", 6), ["Réseau", "é"]); + } + + #[test] + fn sections_arrive_closed_and_show_their_content_open() { + let prefs = Preferences::default(); + let mut page = Page::default(); + let focusable = |page: &Page| { + rows(&prefs, page) + .iter() + .filter(|r| r.takes_focus()) + .count() + }; + assert_eq!(focusable(&page), 2, "two closed sections"); + page.expanded.insert("language"); + assert_eq!(focusable(&page), 3, "the language setting shows"); + } +} diff --git a/src/ui/style.rs b/src/ui/style.rs new file mode 100644 index 0000000..6b986f8 --- /dev/null +++ b/src/ui/style.rs @@ -0,0 +1,26 @@ +//! The styles every drawing function picks from, by role rather than by +//! colour. + +use ratatui::style::{Modifier, Style}; + +/// Page and category headings, section titles. +pub fn title() -> Style { + Style::default().add_modifier(Modifier::BOLD) +} + +/// Descriptions and hints: read once, then out of the way. +pub fn muted() -> Style { + Style::default().add_modifier(Modifier::DIM) +} + +/// Something that can be clicked or focused: selected stands out by its +/// background, hovered by a mark of its own, distinct from selected. +pub fn item(selected: bool, hovered: bool) -> Style { + if selected { + Style::default().add_modifier(Modifier::REVERSED) + } else if hovered { + Style::default().add_modifier(Modifier::UNDERLINED) + } else { + Style::default() + } +} diff --git a/tests/cli.rs b/tests/cli.rs index e11fcc8..95d127c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -5,6 +5,9 @@ //! The messages follow the system language, so these checks look for what //! reads the same in English and French: option names, the argument, `ssh -t`. +#[cfg(target_os = "linux")] +use std::fs; +use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; fn collector(args: &[&str]) -> Output { @@ -12,14 +15,29 @@ fn collector(args: &[&str]) -> Output { } fn collector_with_env(args: &[&str], env: &[(&str, &str)]) -> Output { + collector_in(&empty_config_home(), args, env) +} + +/// Runs Collector with `config_home` as its config root on Linux and its home +/// on macOS, so the preferences of whoever runs the tests stay out of them. +/// Windows reads its folders from the system, not the environment; its CI +/// runners have no preferences file. +fn collector_in(config_home: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { Command::new(env!("CARGO_BIN_EXE_collector")) .args(args) + .env("XDG_CONFIG_HOME", config_home) + .env("HOME", config_home) .envs(env.iter().copied()) .stdin(Stdio::null()) .output() .expect("run collector") } +/// A config root with nothing in it. It need not exist: Collector only reads. +fn empty_config_home() -> PathBuf { + std::env::temp_dir().join(format!("collector-cli-empty-{}", std::process::id())) +} + #[test] fn version_and_help_need_no_terminal() { let version = format!("collector {}", env!("CARGO_PKG_VERSION")); @@ -86,3 +104,40 @@ fn messages_follow_the_language_on_linux() { String::from_utf8_lossy(&collector_with_env(&["--help"], &french).stdout).into_owned(); assert!(help.contains("Utilisation : collector"), "{help}"); } + +/// A config root holding `preferences` as Collector's preferences file. +#[cfg(target_os = "linux")] +fn config_home_with(name: &str, preferences: &str) -> (PathBuf, PathBuf) { + let home = std::env::temp_dir().join(format!("collector-cli-{name}-{}", std::process::id())); + let file = home.join("Colony/Collector/preferences/preferences.json"); + let _ = fs::remove_dir_all(&home); + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(&file, preferences).unwrap(); + (home, file) +} + +/// The language chosen in Preferences wins over the system's, from the very +/// first message. +#[cfg(target_os = "linux")] +#[test] +fn the_language_preference_overrides_the_system() { + let (home, _) = config_home_with("language", r#"{ "language": "fr" }"#); + let out = collector_in(&home, &["--help"], &[("LANGUAGE", "en"), ("LC_ALL", "C")]); + let help = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!(help.contains("Utilisation : collector"), "{help}"); + let _ = fs::remove_dir_all(&home); +} + +/// Only the monitor sets a broken file aside, where it can say so: the command +/// line reads it, falls back to the defaults and leaves it as it is. +#[cfg(target_os = "linux")] +#[test] +fn the_command_line_never_touches_the_preferences_file() { + let (home, file) = config_home_with("untouched", "{ not json"); + let out = collector_in(&home, &["--help"], &[("LANGUAGE", "en"), ("LC_ALL", "C")]); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).contains("Usage: collector")); + assert_eq!(fs::read_to_string(&file).unwrap(), "{ not json"); + assert!(!file.with_extension("json.invalid").exists()); + let _ = fs::remove_dir_all(&home); +} diff --git a/tests/pty_smoke.rs b/tests/pty_smoke.rs index be3a161..20f62c9 100644 --- a/tests/pty_smoke.rs +++ b/tests/pty_smoke.rs @@ -1,7 +1,8 @@ //! Runs the real binary in a pseudo-terminal (ConPTY on Windows), waits for //! the first frame, quits it (q, or SIGTERM on Unix) and checks that Collector //! exits and, on Unix, hands the terminal back the way it found it: same -//! escape sequences last, same line discipline (raw mode off). +//! escape sequences last, same line discipline (raw mode off). One run opens +//! and closes Preferences on the way. use std::io::{Read, Write}; #[cfg(unix)] @@ -23,6 +24,9 @@ const EXIT_TIMEOUT: Duration = Duration::from_secs(15); /// skips a blank cell with a cursor move, so a space splits text in two. And /// the same in English and French, as the test runs in the system language. const FRAME_MARKER: &[u8] = b"CPU"; +/// A category name from the Preferences page, cut before the one letter that +/// differs between English (Accessibility) and French (Accessibilité). +const PAGE_MARKER: &[u8] = b"Accessibilit"; /// Cursor position request. ConPTY sends one when it starts and waits for the /// answer, as a real terminal would give. const CURSOR_QUERY: &[u8] = b"\x1b[6n"; @@ -57,6 +61,25 @@ impl Output { alive } + /// Pumps until `needle` has been written more than `seen` times. A panic + /// here drops the pseudo-terminal, which hangs Collector up. + fn wait_for(&mut self, needle: &[u8], seen: usize, what: &str) { + let deadline = Instant::now() + FIRST_FRAME_TIMEOUT; + while count(&self.bytes, needle) <= seen { + if Instant::now() > deadline || !self.pump(Duration::from_millis(100)) { + panic!( + "no {what} within {FIRST_FRAME_TIMEOUT:?}; output ends with:\n{}", + self.tail() + ); + } + } + } + + fn press(&mut self, keys: &[u8]) { + self.writer.write_all(keys).expect("press a key"); + self.writer.flush().expect("press a key"); + } + fn tail(&self) -> String { let start = self.bytes.len().saturating_sub(1500); String::from_utf8_lossy(&self.bytes[start..]) @@ -95,6 +118,16 @@ fn run_and_quit(how: &str, quit: impl FnOnce(&mut Output, Option)) -> (Exit .local_flags; let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_collector")); cmd.env("TERM", "xterm-256color"); + // Keeps the preferences of whoever runs the tests out of them (Linux and + // macOS; Windows reads its folders from the system, and its CI runners + // have no preferences file). Nothing here changes a preference, so + // nothing is written. + let config_home = std::env::temp_dir().join(format!("collector-pty-{}", std::process::id())); + cmd.env("XDG_CONFIG_HOME", &config_home); + cmd.env("HOME", &config_home); + // Without a directory of its own, the child would start in HOME, which + // does not exist. + cmd.cwd(std::env::temp_dir()); let mut child = pair.slave.spawn_command(cmd).expect("start collector"); // Only the child may hold the terminal side open, or on Unix the reader // would never see the end of the output. @@ -174,9 +207,25 @@ fn run_and_quit(how: &str, quit: impl FnOnce(&mut Output, Option)) -> (Exit #[test] fn quits_on_q_and_restores_the_terminal() { - let (status, out) = run_and_quit("q", |out, _| { - out.writer.write_all(b"q").expect("press q"); - out.writer.flush().expect("press q"); + let (status, out) = run_and_quit("q", |out, _| out.press(b"q")); + assert!( + status.success(), + "exited with {status}; output ends with:\n{}", + out.tail() + ); +} + +/// p opens Preferences in place of the boxes, Esc brings the boxes back, and +/// q still quits. +#[test] +fn opens_and_closes_preferences_then_quits() { + let (status, out) = run_and_quit("p, Esc, then q", |out, _| { + out.press(b"p"); + out.wait_for(PAGE_MARKER, 0, "Preferences page after p"); + let boxes = count(&out.bytes, FRAME_MARKER); + out.press(b"\x1b"); + out.wait_for(FRAME_MARKER, boxes, "monitor after Esc"); + out.press(b"q"); }); assert!( status.success(), From 102d73be3932232f588506dc88523a0e6b06b889 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 14:22:13 +0200 Subject: [PATCH 3/8] feat: draw Collector in the Colony themes and accents, with high contrast and reduced motion config/themes.json is a byte copy of Resources generated/themes.json, embedded and read once; a CI job fails with the refresh command when it falls behind. src/theme.rs resolves a family and variant with the Gruvbox dark fallback, ports colony-ui's high-contrast derivation and contrast_on, and maps colours to the nearest xterm 256 when the terminal does not advertise truecolor. Every widget now draws from the palette; no colour literal is left outside theme.rs. Preferences gains Appearance (theme cards per family, the eight accent swatches, auto accent, a live preview) and Accessibility (high contrast, reduce motion), with colony-ui's shared strings in English and French. A theme change shows the theme_applied toast in the footer. The mouse wheel moves through the open category, and a click on a card or swatch picks it. --- .github/workflows/ci.yml | 19 + README.md | 26 +- config/themes.json | 4161 ++++++++++++++++++++++++++++++++++++++ src/app.rs | 89 +- src/i18n/en.rs | 68 +- src/i18n/fr.rs | 64 + src/main.rs | 1 + src/preferences.rs | 119 +- src/theme.rs | 530 +++++ src/ui/cpu_memory.rs | 12 +- src/ui/disks.rs | 12 +- src/ui/mod.rs | 109 +- src/ui/network.rs | 6 +- src/ui/preferences.rs | 327 ++- src/ui/processes.rs | 15 +- src/ui/style.rs | 94 +- 16 files changed, 5552 insertions(+), 100 deletions(-) create mode 100644 config/themes.json create mode 100644 src/theme.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca6b7c..ba6242b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,25 @@ jobs: print("colony.json is valid") PY + # config/themes.json is a copy of what Resources generates, never edited + # here. A theme added or changed there fails this job until the copy is + # refreshed, so Collector cannot fall behind the other Colony programs. + themes: + name: themes.json + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Compare with Resources main + run: | + url=https://raw.githubusercontent.com/Project-Colony/Project-Colony-Resources/main/generated/themes.json + curl -fsSL "$url" -o "$RUNNER_TEMP/themes.json" + cmp -s "$RUNNER_TEMP/themes.json" config/themes.json || { + echo "::error::config/themes.json differs from Resources main. Refresh it: curl -fsSL $url -o config/themes.json" + exit 1 + } + echo "config/themes.json matches Resources main" + # Budgets from the design: the stripped Linux x86_64 release binary stays at # or under 3 MB, built exactly as the release workflow builds it. size: diff --git a/README.md b/README.md index d1a49e7..3594c1b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ locally, over SSH or inside tmux. > is the first release, and Colony installs it only with a valid signature. > A btop-class rewrite is in progress (see the [roadmap](#roadmap) below). > Known gap until then: the process list cannot be scrolled, sorted or -> filtered. +> filtered. Colours are 24-bit when the terminal says it shows them +> (`COLORTERM=truecolor` or `24bit`, or Windows Terminal), and the nearest of +> the xterm 256 colours otherwise. ## Why Collector @@ -57,7 +59,8 @@ One screen, refreshed every second, or at the interval chosen in `q` or Ctrl+C quits, and the terminal is restored. Clicking **Collector** in the top-left corner, or pressing `p`, opens the [Preferences](#preferences). -Everything is shown in English or French (see [Language](#language)). +Everything is shown in English or French (see [Language](#language)), in the +Colony theme and accent chosen there, with high contrast if asked for. ## Installation @@ -130,11 +133,13 @@ On the Preferences page: | Up, Down | Move between categories, or between sections and settings | | Tab, Shift+Tab | Switch between the categories and their content | | Enter | Open or close a section, or move a setting to its next value | -| Left, Right | Change the focused setting | +| Left, Right | Change the focused setting; on a theme, move through its variants | | Esc, `p` | Close Preferences (or click **Close**) | -Collector takes the mouse for its clicks, so selecting text with the mouse -needs the terminal's own modifier, Shift in most terminals. +The mouse wheel moves through the open category, and a click on a theme card +or an accent swatch picks it. Collector takes the mouse for this, so selecting +text with the mouse needs the terminal's own modifier, Shift in most +terminals. The terminal is restored on `q`, on Ctrl+C and when Collector panics. On Linux and macOS it is also restored on a quit signal from outside (SIGTERM, SIGHUP, @@ -150,6 +155,17 @@ click. A change applies at once and is saved at once; there is no Save button. - **General**: the interface language, and how often the readings are taken (every 0.5, 1, 2 or 5 seconds; more often costs more CPU). +- **Appearance**: the theme, from every Colony theme family and its light and + dark variants; the accent, one of the eight shared Colony accents or the + theme's own; and a live preview. The themes come from + [Project-Colony-Resources](https://github.com/Project-Colony/Project-Colony-Resources), + the same ones every Colony program offers. Typography and Effects are left + out: the terminal sets the font and its size, and Collector has no visual + effects. +- **Accessibility**: high contrast, which moves text and borders away from the + background of whichever theme is on, and reduce motion (Collector animates + nothing today). A dyslexia-friendly font or larger text is set in the + terminal. - **About**: the version, the license, the source code and where the preferences file is. diff --git a/config/themes.json b/config/themes.json new file mode 100644 index 0000000..88f868a --- /dev/null +++ b/config/themes.json @@ -0,0 +1,4161 @@ +{ + "schemaVersion": 1, + "source": "https://github.com/Project-Colony/Project-Colony-Resources", + "note": "@generated by colony-tokens from tokens/families/*.toml — DO NOT EDIT.", + "familyCount": 26, + "variantCount": 59, + "fields": [ + { + "name": "bg_primary", + "group": "backgrounds", + "cssVar": "--colony-bg-primary" + }, + { + "name": "bg_sidebar", + "group": "backgrounds", + "cssVar": "--colony-bg-sidebar" + }, + { + "name": "bg_card", + "group": "backgrounds", + "cssVar": "--colony-bg-card" + }, + { + "name": "bg_card_hover", + "group": "backgrounds", + "cssVar": "--colony-bg-card-hover" + }, + { + "name": "bg_card_pressed", + "group": "backgrounds", + "cssVar": "--colony-bg-card-pressed" + }, + { + "name": "bg_selected", + "group": "backgrounds", + "cssVar": "--colony-bg-selected" + }, + { + "name": "bg_input", + "group": "backgrounds", + "cssVar": "--colony-bg-input" + }, + { + "name": "bg_progress", + "group": "backgrounds", + "cssVar": "--colony-bg-progress" + }, + { + "name": "text_primary", + "group": "text", + "cssVar": "--colony-text-primary" + }, + { + "name": "text_secondary", + "group": "text", + "cssVar": "--colony-text-secondary" + }, + { + "name": "text_muted", + "group": "text", + "cssVar": "--colony-text-muted" + }, + { + "name": "text_dim", + "group": "text", + "cssVar": "--colony-text-dim" + }, + { + "name": "text_dimmer", + "group": "text", + "cssVar": "--colony-text-dimmer" + }, + { + "name": "text_dimmest", + "group": "text", + "cssVar": "--colony-text-dimmest" + }, + { + "name": "text_placeholder", + "group": "text", + "cssVar": "--colony-text-placeholder" + }, + { + "name": "accent_blue", + "group": "accent", + "cssVar": "--colony-accent-blue" + }, + { + "name": "accent_icon", + "group": "accent", + "cssVar": "--colony-accent-icon" + }, + { + "name": "accent_progress", + "group": "accent", + "cssVar": "--colony-accent-progress" + }, + { + "name": "btn_default", + "group": "buttons", + "cssVar": "--colony-btn-default" + }, + { + "name": "btn_hover", + "group": "buttons", + "cssVar": "--colony-btn-hover" + }, + { + "name": "btn_pressed", + "group": "buttons", + "cssVar": "--colony-btn-pressed" + }, + { + "name": "success", + "group": "success", + "cssVar": "--colony-success" + }, + { + "name": "success_bg", + "group": "success", + "cssVar": "--colony-success-bg" + }, + { + "name": "btn_success", + "group": "success", + "cssVar": "--colony-btn-success" + }, + { + "name": "btn_success_hover", + "group": "success", + "cssVar": "--colony-btn-success-hover" + }, + { + "name": "btn_success_pressed", + "group": "success", + "cssVar": "--colony-btn-success-pressed" + }, + { + "name": "warning", + "group": "warning", + "cssVar": "--colony-warning" + }, + { + "name": "warning_bg", + "group": "warning", + "cssVar": "--colony-warning-bg" + }, + { + "name": "error", + "group": "error", + "cssVar": "--colony-error" + }, + { + "name": "error_light", + "group": "error", + "cssVar": "--colony-error-light" + }, + { + "name": "error_bg", + "group": "error", + "cssVar": "--colony-error-bg" + }, + { + "name": "btn_danger_bg", + "group": "error", + "cssVar": "--colony-btn-danger-bg" + }, + { + "name": "btn_danger_hover", + "group": "error", + "cssVar": "--colony-btn-danger-hover" + }, + { + "name": "btn_trash_hover", + "group": "error", + "cssVar": "--colony-btn-trash-hover" + }, + { + "name": "btn_trash_pressed", + "group": "error", + "cssVar": "--colony-btn-trash-pressed" + }, + { + "name": "bg_modal_section", + "group": "modal", + "cssVar": "--colony-bg-modal-section" + }, + { + "name": "border_subtle", + "group": "modal", + "cssVar": "--colony-border-subtle" + }, + { + "name": "divider", + "group": "modal", + "cssVar": "--colony-divider" + } + ], + "accents": [ + { + "key": "red", + "labelKey": "settings_accent_red", + "label": { + "fr": "Rouge", + "en": "Red" + }, + "color": "#e05555", + "cssVar": "--colony-accent-red" + }, + { + "key": "orange", + "labelKey": "settings_accent_orange", + "label": { + "fr": "Orange", + "en": "Orange" + }, + "color": "#e0855a", + "cssVar": "--colony-accent-orange" + }, + { + "key": "yellow", + "labelKey": "settings_accent_yellow", + "label": { + "fr": "Jaune", + "en": "Yellow" + }, + "color": "#c8a832", + "cssVar": "--colony-accent-yellow" + }, + { + "key": "green", + "labelKey": "settings_accent_green", + "label": { + "fr": "Vert", + "en": "Green" + }, + "color": "#55b87a", + "cssVar": "--colony-accent-green" + }, + { + "key": "blue", + "labelKey": "settings_accent_blue", + "label": { + "fr": "Bleu", + "en": "Blue" + }, + "color": "#6b8bd6", + "cssVar": "--colony-accent-blue" + }, + { + "key": "indigo", + "labelKey": "settings_accent_indigo", + "label": { + "fr": "Indigo", + "en": "Indigo" + }, + "color": "#7b6bd6", + "cssVar": "--colony-accent-indigo" + }, + { + "key": "violet", + "labelKey": "settings_accent_violet", + "label": { + "fr": "Violet", + "en": "Violet" + }, + "color": "#b06bd6", + "cssVar": "--colony-accent-violet" + }, + { + "key": "amber", + "labelKey": "settings_accent_amber", + "label": { + "fr": "Ambre", + "en": "Amber" + }, + "color": "#d4a030", + "cssVar": "--colony-accent-amber" + } + ], + "families": [ + { + "key": "catppuccin", + "order": 1, + "labelKey": "settings_theme_catppuccin", + "label": { + "fr": "Catppuccin", + "en": "Catppuccin" + }, + "icon": "f0f4", + "notes": null, + "variants": [ + { + "key": "latte", + "slug": "catppuccin-latte", + "const": "CATPPUCCIN_LATTE", + "labelKey": "settings_theme_catppuccin_latte", + "label": { + "fr": "Latte", + "en": "Latte" + }, + "mode": "light", + "swatch": { + "bg": "#eff1f5", + "accent": "#1e66f5" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 7.06, + "textMutedOnBgPrimary": 4.37 + }, + "palette": { + "bg_primary": "#eff1f5", + "bg_sidebar": "#e6e9ef", + "bg_card": "#e6e9ef", + "bg_card_hover": "#dce0e8", + "bg_card_pressed": "#ccd0da", + "bg_selected": "#ccd0da", + "bg_input": "#dce0e8", + "bg_progress": "#dce0e8", + "text_primary": "#4c4f69", + "text_secondary": "#5c5f77", + "text_muted": "#6c6f85", + "text_dim": "#7c7f93", + "text_dimmer": "#8c8fa1", + "text_dimmest": "#9ca0b0", + "text_placeholder": "#8c8fa1", + "accent_blue": "#1e66f5", + "accent_icon": "#7287fd", + "accent_progress": "#7287fd", + "btn_default": "#dce0e8", + "btn_hover": "#ccd0da", + "btn_pressed": "#bcc0cc", + "success": "#40a02b", + "success_bg": "#d5f0cd", + "btn_success": "#40a02b", + "btn_success_hover": "#369222", + "btn_success_pressed": "#2c8219", + "warning": "#df8e1d", + "warning_bg": "#f5e6c8", + "error": "#d20f39", + "error_light": "#e64553", + "error_bg": "#f5d0d6", + "btn_danger_bg": "#f5d0d6", + "btn_danger_hover": "#eab0ba", + "btn_trash_hover": "#d20f39", + "btn_trash_pressed": "#b00c30", + "bg_modal_section": "#e6e9ef", + "border_subtle": "#ccd0da", + "divider": "#ccd0da" + } + }, + { + "key": "frappe", + "slug": "catppuccin-frappe", + "const": "CATPPUCCIN_FRAPPE", + "labelKey": "settings_theme_catppuccin_frappe", + "label": { + "fr": "Frappé", + "en": "Frappé" + }, + "mode": "dark", + "swatch": { + "bg": "#303446", + "accent": "#8caaee" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 8.06, + "textMutedOnBgPrimary": 5.55 + }, + "palette": { + "bg_primary": "#303446", + "bg_sidebar": "#292c3c", + "bg_card": "#292c3c", + "bg_card_hover": "#414559", + "bg_card_pressed": "#51576d", + "bg_selected": "#51576d", + "bg_input": "#414559", + "bg_progress": "#414559", + "text_primary": "#c6d0f5", + "text_secondary": "#b5bfe2", + "text_muted": "#a5adce", + "text_dim": "#949cbb", + "text_dimmer": "#838ba7", + "text_dimmest": "#737994", + "text_placeholder": "#838ba7", + "accent_blue": "#8caaee", + "accent_icon": "#babbf1", + "accent_progress": "#babbf1", + "btn_default": "#414559", + "btn_hover": "#51576d", + "btn_pressed": "#626880", + "success": "#a6d189", + "success_bg": "#2a3a28", + "btn_success": "#549444", + "btn_success_hover": "#468838", + "btn_success_pressed": "#387a2c", + "warning": "#e5c890", + "warning_bg": "#3a3828", + "error": "#e78284", + "error_light": "#ea999c", + "error_bg": "#3a2628", + "btn_danger_bg": "#3a2628", + "btn_danger_hover": "#5a3638", + "btn_trash_hover": "#9a3234", + "btn_trash_pressed": "#7a2224", + "bg_modal_section": "#292c3c", + "border_subtle": "#414559", + "divider": "#414559" + } + }, + { + "key": "macchiato", + "slug": "catppuccin-macchiato", + "const": "CATPPUCCIN_MACCHIATO", + "labelKey": "settings_theme_catppuccin_macchiato", + "label": { + "fr": "Macchiato", + "en": "Macchiato" + }, + "mode": "dark", + "swatch": { + "bg": "#24273a", + "accent": "#8aadf4" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 9.92, + "textMutedOnBgPrimary": 6.62 + }, + "palette": { + "bg_primary": "#24273a", + "bg_sidebar": "#1e2030", + "bg_card": "#1e2030", + "bg_card_hover": "#363a4f", + "bg_card_pressed": "#494d64", + "bg_selected": "#494d64", + "bg_input": "#363a4f", + "bg_progress": "#363a4f", + "text_primary": "#cad3f5", + "text_secondary": "#b8c0e0", + "text_muted": "#a5adcb", + "text_dim": "#939ab7", + "text_dimmer": "#8087a2", + "text_dimmest": "#6e738d", + "text_placeholder": "#8087a2", + "accent_blue": "#8aadf4", + "accent_icon": "#b7bdf8", + "accent_progress": "#b7bdf8", + "btn_default": "#363a4f", + "btn_hover": "#494d64", + "btn_pressed": "#5b6078", + "success": "#a6da95", + "success_bg": "#243826", + "btn_success": "#4e9240", + "btn_success_hover": "#408634", + "btn_success_pressed": "#327a28", + "warning": "#eed49f", + "warning_bg": "#383428", + "error": "#ed8796", + "error_light": "#ee99a0", + "error_bg": "#382428", + "btn_danger_bg": "#382428", + "btn_danger_hover": "#583438", + "btn_trash_hover": "#983038", + "btn_trash_pressed": "#782028", + "bg_modal_section": "#1e2030", + "border_subtle": "#363a4f", + "divider": "#363a4f" + } + }, + { + "key": "mocha", + "slug": "catppuccin-mocha", + "const": "CATPPUCCIN_MOCHA", + "labelKey": "settings_theme_catppuccin_mocha", + "label": { + "fr": "Mocha", + "en": "Mocha" + }, + "mode": "dark", + "swatch": { + "bg": "#1e1e2e", + "accent": "#89b4fa" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 11.34, + "textMutedOnBgPrimary": 7.37 + }, + "palette": { + "bg_primary": "#1e1e2e", + "bg_sidebar": "#181825", + "bg_card": "#181825", + "bg_card_hover": "#313244", + "bg_card_pressed": "#45475a", + "bg_selected": "#45475a", + "bg_input": "#313244", + "bg_progress": "#313244", + "text_primary": "#cdd6f4", + "text_secondary": "#bac2de", + "text_muted": "#a6adc8", + "text_dim": "#9399b2", + "text_dimmer": "#7f849c", + "text_dimmest": "#6c7086", + "text_placeholder": "#7f849c", + "accent_blue": "#89b4fa", + "accent_icon": "#b4befe", + "accent_progress": "#b4befe", + "btn_default": "#313244", + "btn_hover": "#45475a", + "btn_pressed": "#585b70", + "success": "#a6e3a1", + "success_bg": "#1e3a1e", + "btn_success": "#48904a", + "btn_success_hover": "#3a843c", + "btn_success_pressed": "#2c782e", + "warning": "#f9e2af", + "warning_bg": "#3a3420", + "error": "#f38ba8", + "error_light": "#f5a0b8", + "error_bg": "#3a1e28", + "btn_danger_bg": "#3a1e28", + "btn_danger_hover": "#5a2e38", + "btn_trash_hover": "#952e3a", + "btn_trash_pressed": "#751e2a", + "bg_modal_section": "#181825", + "border_subtle": "#313244", + "divider": "#313244" + } + } + ] + }, + { + "key": "gruvbox", + "order": 2, + "labelKey": "settings_theme_gruvbox", + "label": { + "fr": "Gruvbox", + "en": "Gruvbox" + }, + "icon": "", + "notes": null, + "variants": [ + { + "key": "light", + "slug": "gruvbox-light", + "const": "GRUVBOX_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fbf1c7", + "accent": "#458588" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.22, + "textMutedOnBgPrimary": 5.74 + }, + "palette": { + "bg_primary": "#fbf1c7", + "bg_sidebar": "#f2e5bc", + "bg_card": "#ebdbb2", + "bg_card_hover": "#d5c4a1", + "bg_card_pressed": "#bdae93", + "bg_selected": "#d5c4a1", + "bg_input": "#ebdbb2", + "bg_progress": "#f2e5bc", + "text_primary": "#3c3836", + "text_secondary": "#504945", + "text_muted": "#665c54", + "text_dim": "#7c6f64", + "text_dimmer": "#928374", + "text_dimmest": "#a89984", + "text_placeholder": "#928374", + "accent_blue": "#458588", + "accent_icon": "#458588", + "accent_progress": "#458588", + "btn_default": "#ebdbb2", + "btn_hover": "#d5c4a1", + "btn_pressed": "#bdae93", + "success": "#98971a", + "success_bg": "#dde6b0", + "btn_success": "#689d6a", + "btn_success_hover": "#5a8f5c", + "btn_success_pressed": "#4c814e", + "warning": "#d79921", + "warning_bg": "#f0e0b0", + "error": "#cc241d", + "error_light": "#d44040", + "error_bg": "#f0c8c8", + "btn_danger_bg": "#f0c8c8", + "btn_danger_hover": "#e0a8a8", + "btn_trash_hover": "#cc241d", + "btn_trash_pressed": "#aa1818", + "bg_modal_section": "#fbf1c7", + "border_subtle": "#d5c4a1", + "divider": "#d5c4a1" + } + }, + { + "key": "dark", + "slug": "gruvbox-dark", + "const": "GRUVBOX_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#282828", + "accent": "#83a598" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.75, + "textMutedOnBgPrimary": 6.77 + }, + "palette": { + "bg_primary": "#282828", + "bg_sidebar": "#1d2021", + "bg_card": "#3c3836", + "bg_card_hover": "#504945", + "bg_card_pressed": "#665c54", + "bg_selected": "#504945", + "bg_input": "#3c3836", + "bg_progress": "#32302f", + "text_primary": "#ebdbb2", + "text_secondary": "#d5c4a1", + "text_muted": "#bdae93", + "text_dim": "#a89984", + "text_dimmer": "#928374", + "text_dimmest": "#7c6f64", + "text_placeholder": "#928374", + "accent_blue": "#83a598", + "accent_icon": "#83a598", + "accent_progress": "#83a598", + "btn_default": "#3c3836", + "btn_hover": "#504945", + "btn_pressed": "#665c54", + "success": "#b8bb26", + "success_bg": "#2a3020", + "btn_success": "#689d6a", + "btn_success_hover": "#5a8f5c", + "btn_success_pressed": "#4c814e", + "warning": "#fabd2f", + "warning_bg": "#3a3420", + "error": "#fb4934", + "error_light": "#fe6050", + "error_bg": "#3c1f1f", + "btn_danger_bg": "#3c1f1f", + "btn_danger_hover": "#5c2f2f", + "btn_trash_hover": "#9d3030", + "btn_trash_pressed": "#7d2020", + "bg_modal_section": "#282828", + "border_subtle": "#3c3836", + "divider": "#3c3836" + } + } + ] + }, + { + "key": "everblush", + "order": 3, + "labelKey": "settings_theme_everblush", + "label": { + "fr": "Everblush", + "en": "Everblush" + }, + "icon": "f06c", + "notes": null, + "variants": [ + { + "key": "light", + "slug": "everblush-light", + "const": "EVERBLUSH_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#e8eded", + "accent": "#3a88c0" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.72, + "textMutedOnBgPrimary": 6.5 + }, + "palette": { + "bg_primary": "#e8eded", + "bg_sidebar": "#dce3e3", + "bg_card": "#d0d8d8", + "bg_card_hover": "#c4cece", + "bg_card_pressed": "#b8c4c4", + "bg_selected": "#c4cece", + "bg_input": "#d0d8d8", + "bg_progress": "#dce3e3", + "text_primary": "#2d3437", + "text_secondary": "#3a4448", + "text_muted": "#4a5558", + "text_dim": "#5a6668", + "text_dimmer": "#6a7878", + "text_dimmest": "#7a8a8a", + "text_placeholder": "#6a7878", + "accent_blue": "#3a88c0", + "accent_icon": "#3a88c0", + "accent_progress": "#3a88c0", + "btn_default": "#d0d8d8", + "btn_hover": "#c4cece", + "btn_pressed": "#b8c4c4", + "success": "#5aaa4e", + "success_bg": "#c8e8c4", + "btn_success": "#5aaa4e", + "btn_success_hover": "#4c9e42", + "btn_success_pressed": "#3e9236", + "warning": "#c0a030", + "warning_bg": "#e8e0c0", + "error": "#c85050", + "error_light": "#d86868", + "error_bg": "#f0d0d0", + "btn_danger_bg": "#f0d0d0", + "btn_danger_hover": "#e0b0b0", + "btn_trash_hover": "#c85050", + "btn_trash_pressed": "#a84040", + "bg_modal_section": "#e8eded", + "border_subtle": "#c4cece", + "divider": "#c4cece" + } + }, + { + "key": "dark", + "slug": "everblush-dark", + "const": "EVERBLUSH_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#141b1e", + "accent": "#67b0e8" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 12.46, + "textMutedOnBgPrimary": 8.75 + }, + "palette": { + "bg_primary": "#141b1e", + "bg_sidebar": "#1a2124", + "bg_card": "#232a2d", + "bg_card_hover": "#2c3538", + "bg_card_pressed": "#354043", + "bg_selected": "#2c3538", + "bg_input": "#232a2d", + "bg_progress": "#1a2124", + "text_primary": "#dadada", + "text_secondary": "#c4c4c4", + "text_muted": "#b3b9b8", + "text_dim": "#9aa0a0", + "text_dimmer": "#808888", + "text_dimmest": "#667070", + "text_placeholder": "#808888", + "accent_blue": "#67b0e8", + "accent_icon": "#67b0e8", + "accent_progress": "#67b0e8", + "btn_default": "#232a2d", + "btn_hover": "#2c3538", + "btn_pressed": "#354043", + "success": "#8ccf7e", + "success_bg": "#1a2e1e", + "btn_success": "#5aaa50", + "btn_success_hover": "#4c9e44", + "btn_success_pressed": "#3e9238", + "warning": "#e5c76b", + "warning_bg": "#2e2a1a", + "error": "#e57474", + "error_light": "#f08888", + "error_bg": "#2e1a1a", + "btn_danger_bg": "#2e1a1a", + "btn_danger_hover": "#4e2a2a", + "btn_trash_hover": "#9a3030", + "btn_trash_pressed": "#7a2020", + "bg_modal_section": "#232a2d", + "border_subtle": "#2c3538", + "divider": "#2c3538" + } + } + ] + }, + { + "key": "kanagawa", + "order": 4, + "labelKey": "settings_theme_kanagawa", + "label": { + "fr": "Kanagawa", + "en": "Kanagawa" + }, + "icon": "f073e", + "notes": null, + "variants": [ + { + "key": "light", + "slug": "kanagawa-light", + "const": "KANAGAWA_LOTUS", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#f2ecbc", + "accent": "#4d699b" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.19, + "textMutedOnBgPrimary": 4.26 + }, + "palette": { + "bg_primary": "#f2ecbc", + "bg_sidebar": "#e5ddb0", + "bg_card": "#e7dba0", + "bg_card_hover": "#d5cea3", + "bg_card_pressed": "#c8c093", + "bg_selected": "#d5cea3", + "bg_input": "#e4d794", + "bg_progress": "#e5ddb0", + "text_primary": "#545464", + "text_secondary": "#43436c", + "text_muted": "#716e61", + "text_dim": "#8a8980", + "text_dimmer": "#a09cac", + "text_dimmest": "#b0acb8", + "text_placeholder": "#8a8980", + "accent_blue": "#4d699b", + "accent_icon": "#5d57a3", + "accent_progress": "#5d57a3", + "btn_default": "#e7dba0", + "btn_hover": "#d5cea3", + "btn_pressed": "#c8c093", + "success": "#6f894e", + "success_bg": "#b7d0ae", + "btn_success": "#6e915f", + "btn_success_hover": "#608554", + "btn_success_pressed": "#527949", + "warning": "#de9800", + "warning_bg": "#f9d791", + "error": "#c84053", + "error_light": "#d7474b", + "error_bg": "#d9a594", + "btn_danger_bg": "#d9a594", + "btn_danger_hover": "#c89484", + "btn_trash_hover": "#c84053", + "btn_trash_pressed": "#a83043", + "bg_modal_section": "#f2ecbc", + "border_subtle": "#d5cea3", + "divider": "#d5cea3" + } + }, + { + "key": "dark", + "slug": "kanagawa-dark", + "const": "KANAGAWA_WAVE", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#1f1f28", + "accent": "#7e9cd8" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 11.26, + "textMutedOnBgPrimary": 7.08 + }, + "palette": { + "bg_primary": "#1f1f28", + "bg_sidebar": "#181820", + "bg_card": "#2a2a37", + "bg_card_hover": "#363646", + "bg_card_pressed": "#54546d", + "bg_selected": "#363646", + "bg_input": "#2a2a37", + "bg_progress": "#223249", + "text_primary": "#dcd7ba", + "text_secondary": "#c8c093", + "text_muted": "#9cabca", + "text_dim": "#938aa9", + "text_dimmer": "#727169", + "text_dimmest": "#54546d", + "text_placeholder": "#727169", + "accent_blue": "#7e9cd8", + "accent_icon": "#7fb4ca", + "accent_progress": "#7fb4ca", + "btn_default": "#2a2a37", + "btn_hover": "#363646", + "btn_pressed": "#54546d", + "success": "#98bb6c", + "success_bg": "#2b3328", + "btn_success": "#76946a", + "btn_success_hover": "#68885c", + "btn_success_pressed": "#5a7c4e", + "warning": "#e6c384", + "warning_bg": "#49443c", + "error": "#e46876", + "error_light": "#ff5d62", + "error_bg": "#43242b", + "btn_danger_bg": "#43242b", + "btn_danger_hover": "#63343b", + "btn_trash_hover": "#c34043", + "btn_trash_pressed": "#a33033", + "bg_modal_section": "#1f1f28", + "border_subtle": "#2a2a37", + "divider": "#2a2a37" + } + }, + { + "key": "journal", + "slug": "kanagawa-journal", + "const": "KANAGAWA_DRAGON", + "labelKey": "settings_theme_kanagawa_journal", + "label": { + "fr": "Mode journal", + "en": "Journal mode" + }, + "mode": "light", + "swatch": { + "bg": "#d5cea3", + "accent": "#7a6840" + }, + "notes": "Colony's own light take on Kanagawa Dragon: warm parchment surfaces, a\nslightly darker sidebar, dark brown text and a warm brown accent matched to\nthe reference screenshot.\n", + "contrast": { + "textPrimaryOnBgPrimary": 6.48, + "textMutedOnBgPrimary": 3.22 + }, + "palette": { + "bg_primary": "#d5cea3", + "bg_sidebar": "#c8b98e", + "bg_card": "#c8b98e", + "bg_card_hover": "#bdae80", + "bg_card_pressed": "#b0a070", + "bg_selected": "#bdae80", + "bg_input": "#c8b98e", + "bg_progress": "#bdae80", + "text_primary": "#43412e", + "text_secondary": "#5c5840", + "text_muted": "#736e55", + "text_dim": "#8a856c", + "text_dimmer": "#a09a80", + "text_dimmest": "#b5ae94", + "text_placeholder": "#8a856c", + "accent_blue": "#7a6840", + "accent_icon": "#7a6840", + "accent_progress": "#7a6840", + "btn_default": "#c8b98e", + "btn_hover": "#bdae80", + "btn_pressed": "#b0a070", + "success": "#6f894e", + "success_bg": "#cbd8b8", + "btn_success": "#6e915f", + "btn_success_hover": "#608554", + "btn_success_pressed": "#527949", + "warning": "#c49a20", + "warning_bg": "#e0d4a0", + "error": "#c84053", + "error_light": "#d7474b", + "error_bg": "#e0bfb0", + "btn_danger_bg": "#e0bfb0", + "btn_danger_hover": "#d0a898", + "btn_trash_hover": "#c84053", + "btn_trash_pressed": "#a83043", + "bg_modal_section": "#d5cea3", + "border_subtle": "#b8a878", + "divider": "#b8a878" + } + }, + { + "key": "dragon", + "slug": "kanagawa-dragon", + "const": "KANAGAWA_DRAGON_DARK", + "labelKey": "settings_theme_kanagawa_dragon", + "label": { + "fr": "Mode dragon", + "en": "Dragon mode" + }, + "mode": "dark", + "swatch": { + "bg": "#181616", + "accent": "#8ba4b0" + }, + "notes": "Upstream Kanagawa Dragon, the low-contrast charcoal counterpart to Wave.\nValues come from the theme's own dragonBlack/dragonWhite ramp rather than\nfrom a darkening of Wave, so the greys stay warm instead of turning blue.\n", + "contrast": { + "textPrimaryOnBgPrimary": 10.76, + "textMutedOnBgPrimary": 6.49 + }, + "palette": { + "bg_primary": "#181616", + "bg_sidebar": "#0d0c0c", + "bg_card": "#282727", + "bg_card_hover": "#393836", + "bg_card_pressed": "#625e5a", + "bg_selected": "#393836", + "bg_input": "#282727", + "bg_progress": "#2d2a2a", + "text_primary": "#c5c9c5", + "text_secondary": "#a6a69c", + "text_muted": "#9e9b93", + "text_dim": "#7a8382", + "text_dimmer": "#737c73", + "text_dimmest": "#625e5a", + "text_placeholder": "#7a8382", + "accent_blue": "#8ba4b0", + "accent_icon": "#8ea4a2", + "accent_progress": "#8ea4a2", + "btn_default": "#282727", + "btn_hover": "#393836", + "btn_pressed": "#625e5a", + "success": "#87a987", + "success_bg": "#2b332b", + "btn_success": "#8a9a7b", + "btn_success_hover": "#7c8c6d", + "btn_success_pressed": "#6e7e5f", + "warning": "#c4b28a", + "warning_bg": "#46413c", + "error": "#c4746e", + "error_light": "#d4827c", + "error_bg": "#3d2a28", + "btn_danger_bg": "#3d2a28", + "btn_danger_hover": "#5a3a37", + "btn_trash_hover": "#c4746e", + "btn_trash_pressed": "#a35c57", + "bg_modal_section": "#181616", + "border_subtle": "#282727", + "divider": "#282727" + } + } + ] + }, + { + "key": "nord", + "order": 5, + "labelKey": "settings_theme_nord", + "label": { + "fr": "Nord", + "en": "Nord" + }, + "icon": "f2dc", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "nord-dark", + "const": "NORD_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#2e3440", + "accent": "#88c0d0" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 9.25, + "textMutedOnBgPrimary": 5.54 + }, + "palette": { + "bg_primary": "#2e3440", + "bg_sidebar": "#252b37", + "bg_card": "#3b4252", + "bg_card_hover": "#434c5e", + "bg_card_pressed": "#4c566a", + "bg_selected": "#434c5e", + "bg_input": "#3b4252", + "bg_progress": "#252b37", + "text_primary": "#d8dee9", + "text_secondary": "#c0c8d4", + "text_muted": "#a0aec0", + "text_dim": "#7e8fa4", + "text_dimmer": "#616e88", + "text_dimmest": "#4c566a", + "text_placeholder": "#616e88", + "accent_blue": "#88c0d0", + "accent_icon": "#81a1c1", + "accent_progress": "#81a1c1", + "btn_default": "#3b4252", + "btn_hover": "#434c5e", + "btn_pressed": "#4c566a", + "success": "#a3be8c", + "success_bg": "#2a3425", + "btn_success": "#809b6c", + "btn_success_hover": "#728f60", + "btn_success_pressed": "#648354", + "warning": "#ebcb8b", + "warning_bg": "#3a3828", + "error": "#bf616a", + "error_light": "#d08080", + "error_bg": "#3c2830", + "btn_danger_bg": "#3c2830", + "btn_danger_hover": "#5c3840", + "btn_trash_hover": "#bf616a", + "btn_trash_pressed": "#9f5158", + "bg_modal_section": "#2e3440", + "border_subtle": "#3b4252", + "divider": "#3b4252" + } + }, + { + "key": "light", + "slug": "nord-light", + "const": "NORD_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#eceff4", + "accent": "#5e81ac" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.84, + "textMutedOnBgPrimary": 6.4 + }, + "palette": { + "bg_primary": "#eceff4", + "bg_sidebar": "#e5e9f0", + "bg_card": "#d8dee9", + "bg_card_hover": "#ccd4df", + "bg_card_pressed": "#b8c2d0", + "bg_selected": "#ccd4df", + "bg_input": "#d8dee9", + "bg_progress": "#e5e9f0", + "text_primary": "#2e3440", + "text_secondary": "#3b4252", + "text_muted": "#4c566a", + "text_dim": "#636e80", + "text_dimmer": "#7e8fa4", + "text_dimmest": "#a0aab8", + "text_placeholder": "#7e8fa4", + "accent_blue": "#5e81ac", + "accent_icon": "#81a1c1", + "accent_progress": "#81a1c1", + "btn_default": "#d8dee9", + "btn_hover": "#ccd4df", + "btn_pressed": "#b8c2d0", + "success": "#6b8c56", + "success_bg": "#d0e0c8", + "btn_success": "#6b8c56", + "btn_success_hover": "#5e8048", + "btn_success_pressed": "#50743a", + "warning": "#b5a050", + "warning_bg": "#e8dfc0", + "error": "#a04048", + "error_light": "#bf616a", + "error_bg": "#e8c8c8", + "btn_danger_bg": "#e8c8c8", + "btn_danger_hover": "#d8b0b0", + "btn_trash_hover": "#a04048", + "btn_trash_pressed": "#883438", + "bg_modal_section": "#eceff4", + "border_subtle": "#ccd4df", + "divider": "#ccd4df" + } + } + ] + }, + { + "key": "dracula", + "order": 6, + "labelKey": "settings_theme_dracula", + "label": { + "fr": "Dracula", + "en": "Dracula" + }, + "icon": "eefe", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "dracula-dark", + "const": "DRACULA_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#282a36", + "accent": "#bd93f9" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 13.36, + "textMutedOnBgPrimary": 8.46 + }, + "palette": { + "bg_primary": "#282a36", + "bg_sidebar": "#21222c", + "bg_card": "#44475a", + "bg_card_hover": "#515470", + "bg_card_pressed": "#606382", + "bg_selected": "#44475a", + "bg_input": "#44475a", + "bg_progress": "#21222c", + "text_primary": "#f8f8f2", + "text_secondary": "#e8e8e0", + "text_muted": "#c8c8c0", + "text_dim": "#9898a0", + "text_dimmer": "#6272a4", + "text_dimmest": "#4c5478", + "text_placeholder": "#6272a4", + "accent_blue": "#bd93f9", + "accent_icon": "#ff79c6", + "accent_progress": "#bd93f9", + "btn_default": "#44475a", + "btn_hover": "#515470", + "btn_pressed": "#606382", + "success": "#50fa7b", + "success_bg": "#1e3228", + "btn_success": "#40c862", + "btn_success_hover": "#38b858", + "btn_success_pressed": "#30a84e", + "warning": "#f1fa8c", + "warning_bg": "#3a382a", + "error": "#ff5555", + "error_light": "#ff7777", + "error_bg": "#3c222a", + "btn_danger_bg": "#3c222a", + "btn_danger_hover": "#5c323a", + "btn_trash_hover": "#ff5555", + "btn_trash_pressed": "#dd4444", + "bg_modal_section": "#282a36", + "border_subtle": "#44475a", + "divider": "#44475a" + } + }, + { + "key": "light", + "slug": "dracula-light", + "const": "DRACULA_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fffbeb", + "accent": "#7c5fc2" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 15.89, + "textMutedOnBgPrimary": 7.19 + }, + "palette": { + "bg_primary": "#fffbeb", + "bg_sidebar": "#f4efd8", + "bg_card": "#e8e4cf", + "bg_card_hover": "#dcd8c0", + "bg_card_pressed": "#d0ccb8", + "bg_selected": "#dcd8c0", + "bg_input": "#e8e4cf", + "bg_progress": "#f4efd8", + "text_primary": "#1f1f1f", + "text_secondary": "#333333", + "text_muted": "#555555", + "text_dim": "#777777", + "text_dimmer": "#7970a9", + "text_dimmest": "#a09ca0", + "text_placeholder": "#7970a9", + "accent_blue": "#7c5fc2", + "accent_icon": "#e04886", + "accent_progress": "#7c5fc2", + "btn_default": "#e8e4cf", + "btn_hover": "#dcd8c0", + "btn_pressed": "#d0ccb8", + "success": "#2ba479", + "success_bg": "#d0e8d8", + "btn_success": "#2ba479", + "btn_success_hover": "#24966c", + "btn_success_pressed": "#1d8860", + "warning": "#d5a212", + "warning_bg": "#ede0c0", + "error": "#de3535", + "error_light": "#e85858", + "error_bg": "#eedcdc", + "btn_danger_bg": "#eedcdc", + "btn_danger_hover": "#dec8c8", + "btn_trash_hover": "#de3535", + "btn_trash_pressed": "#c02828", + "bg_modal_section": "#fffbeb", + "border_subtle": "#dcd8c0", + "divider": "#dcd8c0" + } + } + ] + }, + { + "key": "solarized", + "order": 7, + "labelKey": "settings_theme_solarized", + "label": { + "fr": "Solarized", + "en": "Solarized" + }, + "icon": "f185", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "solarized-dark", + "const": "SOLARIZED_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#002b36", + "accent": "#268bd2" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 4.75, + "textMutedOnBgPrimary": 4.21 + }, + "palette": { + "bg_primary": "#002b36", + "bg_sidebar": "#00222c", + "bg_card": "#073642", + "bg_card_hover": "#0a4858", + "bg_card_pressed": "#1a5a6c", + "bg_selected": "#073642", + "bg_input": "#073642", + "bg_progress": "#00222c", + "text_primary": "#839496", + "text_secondary": "#93a1a1", + "text_muted": "#738c90", + "text_dim": "#657b83", + "text_dimmer": "#586e75", + "text_dimmest": "#475860", + "text_placeholder": "#586e75", + "accent_blue": "#268bd2", + "accent_icon": "#2aa198", + "accent_progress": "#268bd2", + "btn_default": "#073642", + "btn_hover": "#0a4858", + "btn_pressed": "#1a5a6c", + "success": "#859900", + "success_bg": "#0a2a18", + "btn_success": "#6a7a00", + "btn_success_hover": "#5c6c00", + "btn_success_pressed": "#4e5e00", + "warning": "#b58900", + "warning_bg": "#1a2818", + "error": "#dc322f", + "error_light": "#e8504e", + "error_bg": "#2a1a1a", + "btn_danger_bg": "#2a1a1a", + "btn_danger_hover": "#3a2a2a", + "btn_trash_hover": "#dc322f", + "btn_trash_pressed": "#bc2220", + "bg_modal_section": "#002b36", + "border_subtle": "#073642", + "divider": "#073642" + } + }, + { + "key": "light", + "slug": "solarized-light", + "const": "SOLARIZED_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fdf6e3", + "accent": "#268bd2" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 4.13, + "textMutedOnBgPrimary": 3.16 + }, + "palette": { + "bg_primary": "#fdf6e3", + "bg_sidebar": "#eee8d5", + "bg_card": "#e6e0ca", + "bg_card_hover": "#dcd6c0", + "bg_card_pressed": "#d0cab4", + "bg_selected": "#dcd6c0", + "bg_input": "#e6e0ca", + "bg_progress": "#eee8d5", + "text_primary": "#657b83", + "text_secondary": "#586e75", + "text_muted": "#7c8e94", + "text_dim": "#93a1a1", + "text_dimmer": "#a8b4b8", + "text_dimmest": "#c0c8ca", + "text_placeholder": "#a8b4b8", + "accent_blue": "#268bd2", + "accent_icon": "#2aa198", + "accent_progress": "#268bd2", + "btn_default": "#e6e0ca", + "btn_hover": "#dcd6c0", + "btn_pressed": "#d0cab4", + "success": "#859900", + "success_bg": "#dae8c8", + "btn_success": "#6a7a00", + "btn_success_hover": "#5c6c00", + "btn_success_pressed": "#4e5e00", + "warning": "#b58900", + "warning_bg": "#ece0c0", + "error": "#dc322f", + "error_light": "#e8504e", + "error_bg": "#eccccc", + "btn_danger_bg": "#eccccc", + "btn_danger_hover": "#dcb8b8", + "btn_trash_hover": "#dc322f", + "btn_trash_pressed": "#bc2220", + "bg_modal_section": "#fdf6e3", + "border_subtle": "#dcd6c0", + "divider": "#dcd6c0" + } + } + ] + }, + { + "key": "tokyonight", + "order": 8, + "labelKey": "settings_theme_tokyonight", + "label": { + "fr": "Tokyo Night", + "en": "Tokyo Night" + }, + "icon": "f0219", + "notes": null, + "variants": [ + { + "key": "night", + "slug": "tokyonight-night", + "const": "TOKYONIGHT_NIGHT", + "labelKey": "settings_theme_tokyonight_night", + "label": { + "fr": "Nuit", + "en": "Night" + }, + "mode": "dark", + "swatch": { + "bg": "#1a1b26", + "accent": "#7aa2f7" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.59, + "textMutedOnBgPrimary": 5.42 + }, + "palette": { + "bg_primary": "#1a1b26", + "bg_sidebar": "#16161e", + "bg_card": "#292e42", + "bg_card_hover": "#3b4261", + "bg_card_pressed": "#414868", + "bg_selected": "#2d3f76", + "bg_input": "#292e42", + "bg_progress": "#16161e", + "text_primary": "#c0caf5", + "text_secondary": "#a9b1d6", + "text_muted": "#8890b0", + "text_dim": "#737aa2", + "text_dimmer": "#565f89", + "text_dimmest": "#414868", + "text_placeholder": "#565f89", + "accent_blue": "#7aa2f7", + "accent_icon": "#7dcfff", + "accent_progress": "#7aa2f7", + "btn_default": "#292e42", + "btn_hover": "#3b4261", + "btn_pressed": "#414868", + "success": "#9ece6a", + "success_bg": "#202830", + "btn_success": "#7eaa50", + "btn_success_hover": "#709e44", + "btn_success_pressed": "#629238", + "warning": "#e0af68", + "warning_bg": "#302820", + "error": "#f7768e", + "error_light": "#db4b4b", + "error_bg": "#302025", + "btn_danger_bg": "#302025", + "btn_danger_hover": "#503035", + "btn_trash_hover": "#f7768e", + "btn_trash_pressed": "#d75e76", + "bg_modal_section": "#1a1b26", + "border_subtle": "#292e42", + "divider": "#292e42" + } + }, + { + "key": "day", + "slug": "tokyonight-day", + "const": "TOKYONIGHT_DAY", + "labelKey": "settings_theme_tokyonight_day", + "label": { + "fr": "Jour", + "en": "Day" + }, + "mode": "light", + "swatch": { + "bg": "#e1e2e7", + "accent": "#2e7de9" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 4.52, + "textMutedOnBgPrimary": 3.57 + }, + "palette": { + "bg_primary": "#e1e2e7", + "bg_sidebar": "#d0d5e3", + "bg_card": "#c4c8da", + "bg_card_hover": "#b7c1e3", + "bg_card_pressed": "#a8b4d4", + "bg_selected": "#b7c1e3", + "bg_input": "#c4c8da", + "bg_progress": "#d0d5e3", + "text_primary": "#3760bf", + "text_secondary": "#4a6ca8", + "text_muted": "#6172b0", + "text_dim": "#848cb5", + "text_dimmer": "#a1a6c5", + "text_dimmest": "#b4b5b9", + "text_placeholder": "#a1a6c5", + "accent_blue": "#2e7de9", + "accent_icon": "#007197", + "accent_progress": "#2e7de9", + "btn_default": "#c4c8da", + "btn_hover": "#b7c1e3", + "btn_pressed": "#a8b4d4", + "success": "#587539", + "success_bg": "#c8dcc0", + "btn_success": "#587539", + "btn_success_hover": "#4c692e", + "btn_success_pressed": "#405d24", + "warning": "#8c6c3e", + "warning_bg": "#dcd8c0", + "error": "#f52a65", + "error_light": "#c64343", + "error_bg": "#e8c0c8", + "btn_danger_bg": "#e8c0c8", + "btn_danger_hover": "#d8a8b4", + "btn_trash_hover": "#f52a65", + "btn_trash_pressed": "#d52050", + "bg_modal_section": "#e1e2e7", + "border_subtle": "#c4c8da", + "divider": "#c4c8da" + } + } + ] + }, + { + "key": "rosepine", + "order": 9, + "labelKey": "settings_theme_rosepine", + "label": { + "fr": "Rosé Pine", + "en": "Rosé Pine" + }, + "icon": "f46d", + "notes": null, + "variants": [ + { + "key": "main", + "slug": "rosepine-main", + "const": "ROSEPINE_MAIN", + "labelKey": "settings_theme_rosepine_main", + "label": { + "fr": "Principal", + "en": "Main" + }, + "mode": "dark", + "swatch": { + "bg": "#191724", + "accent": "#9ccfd8" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 13.39, + "textMutedOnBgPrimary": 5.48 + }, + "palette": { + "bg_primary": "#191724", + "bg_sidebar": "#1f1d2e", + "bg_card": "#26233a", + "bg_card_hover": "#403d52", + "bg_card_pressed": "#524f67", + "bg_selected": "#403d52", + "bg_input": "#26233a", + "bg_progress": "#1f1d2e", + "text_primary": "#e0def4", + "text_secondary": "#cccadd", + "text_muted": "#908caa", + "text_dim": "#7e7a96", + "text_dimmer": "#6e6a86", + "text_dimmest": "#555168", + "text_placeholder": "#6e6a86", + "accent_blue": "#9ccfd8", + "accent_icon": "#c4a7e7", + "accent_progress": "#9ccfd8", + "btn_default": "#26233a", + "btn_hover": "#403d52", + "btn_pressed": "#524f67", + "success": "#31748f", + "success_bg": "#1e2830", + "btn_success": "#2a6478", + "btn_success_hover": "#24586c", + "btn_success_pressed": "#1e4c60", + "warning": "#f6c177", + "warning_bg": "#302820", + "error": "#eb6f92", + "error_light": "#f08098", + "error_bg": "#301828", + "btn_danger_bg": "#301828", + "btn_danger_hover": "#502838", + "btn_trash_hover": "#eb6f92", + "btn_trash_pressed": "#cb5878", + "bg_modal_section": "#191724", + "border_subtle": "#26233a", + "divider": "#26233a" + } + }, + { + "key": "moon", + "slug": "rosepine-moon", + "const": "ROSEPINE_MOON", + "labelKey": "settings_theme_rosepine_moon", + "label": { + "fr": "Lune", + "en": "Moon" + }, + "mode": "dark", + "swatch": { + "bg": "#232136", + "accent": "#9ccfd8" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 11.86, + "textMutedOnBgPrimary": 4.86 + }, + "palette": { + "bg_primary": "#232136", + "bg_sidebar": "#2a273f", + "bg_card": "#393552", + "bg_card_hover": "#44415a", + "bg_card_pressed": "#56526e", + "bg_selected": "#44415a", + "bg_input": "#393552", + "bg_progress": "#2a273f", + "text_primary": "#e0def4", + "text_secondary": "#cccadd", + "text_muted": "#908caa", + "text_dim": "#7e7a96", + "text_dimmer": "#6e6a86", + "text_dimmest": "#555168", + "text_placeholder": "#6e6a86", + "accent_blue": "#9ccfd8", + "accent_icon": "#c4a7e7", + "accent_progress": "#9ccfd8", + "btn_default": "#393552", + "btn_hover": "#44415a", + "btn_pressed": "#56526e", + "success": "#3e8fb0", + "success_bg": "#202830", + "btn_success": "#347a98", + "btn_success_hover": "#2c6e88", + "btn_success_pressed": "#246278", + "warning": "#f6c177", + "warning_bg": "#382820", + "error": "#eb6f92", + "error_light": "#f08098", + "error_bg": "#381828", + "btn_danger_bg": "#381828", + "btn_danger_hover": "#582838", + "btn_trash_hover": "#eb6f92", + "btn_trash_pressed": "#cb5878", + "bg_modal_section": "#232136", + "border_subtle": "#393552", + "divider": "#393552" + } + }, + { + "key": "dawn", + "slug": "rosepine-dawn", + "const": "ROSEPINE_DAWN", + "labelKey": "settings_theme_rosepine_dawn", + "label": { + "fr": "Aurore", + "en": "Dawn" + }, + "mode": "light", + "swatch": { + "bg": "#faf4ed", + "accent": "#56949f" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.66, + "textMutedOnBgPrimary": 4.02 + }, + "palette": { + "bg_primary": "#faf4ed", + "bg_sidebar": "#f2e9e1", + "bg_card": "#f4ede8", + "bg_card_hover": "#dfdad9", + "bg_card_pressed": "#cecacd", + "bg_selected": "#dfdad9", + "bg_input": "#f4ede8", + "bg_progress": "#f2e9e1", + "text_primary": "#575279", + "text_secondary": "#6e6a86", + "text_muted": "#797593", + "text_dim": "#9893a5", + "text_dimmer": "#b0acc0", + "text_dimmest": "#c0bcc8", + "text_placeholder": "#b0acc0", + "accent_blue": "#56949f", + "accent_icon": "#907aa9", + "accent_progress": "#56949f", + "btn_default": "#f4ede8", + "btn_hover": "#dfdad9", + "btn_pressed": "#cecacd", + "success": "#286983", + "success_bg": "#d0e0d8", + "btn_success": "#286983", + "btn_success_hover": "#205c74", + "btn_success_pressed": "#184f65", + "warning": "#ea9d34", + "warning_bg": "#ece0c8", + "error": "#b4637a", + "error_light": "#c87890", + "error_bg": "#e8d0d4", + "btn_danger_bg": "#e8d0d4", + "btn_danger_hover": "#d8bcc4", + "btn_trash_hover": "#b4637a", + "btn_trash_pressed": "#965268", + "bg_modal_section": "#faf4ed", + "border_subtle": "#dfdad9", + "divider": "#dfdad9" + } + } + ] + }, + { + "key": "onedark", + "order": 10, + "labelKey": "settings_theme_onedark", + "label": { + "fr": "One Dark", + "en": "One Dark" + }, + "icon": "", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "onedark-dark", + "const": "ONEDARK_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#282c34", + "accent": "#61afef" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.57, + "textMutedOnBgPrimary": 4.08 + }, + "palette": { + "bg_primary": "#282c34", + "bg_sidebar": "#21252b", + "bg_card": "#3e4452", + "bg_card_hover": "#4b5162", + "bg_card_pressed": "#5c6370", + "bg_selected": "#3e4452", + "bg_input": "#3e4452", + "bg_progress": "#21252b", + "text_primary": "#abb2bf", + "text_secondary": "#9da4b0", + "text_muted": "#848b98", + "text_dim": "#6b7280", + "text_dimmer": "#5c6370", + "text_dimmest": "#4b5162", + "text_placeholder": "#5c6370", + "accent_blue": "#61afef", + "accent_icon": "#56b6c2", + "accent_progress": "#61afef", + "btn_default": "#3e4452", + "btn_hover": "#4b5162", + "btn_pressed": "#5c6370", + "success": "#98c379", + "success_bg": "#1e3428", + "btn_success": "#78a060", + "btn_success_hover": "#6c9454", + "btn_success_pressed": "#608848", + "warning": "#e5c07b", + "warning_bg": "#343020", + "error": "#e06c75", + "error_light": "#e88888", + "error_bg": "#342028", + "btn_danger_bg": "#342028", + "btn_danger_hover": "#543038", + "btn_trash_hover": "#e06c75", + "btn_trash_pressed": "#c05860", + "bg_modal_section": "#282c34", + "border_subtle": "#3e4452", + "divider": "#3e4452" + } + }, + { + "key": "light", + "slug": "onedark-light", + "const": "ONEDARK_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fafafa", + "accent": "#4078f2" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.86, + "textMutedOnBgPrimary": 5.14 + }, + "palette": { + "bg_primary": "#fafafa", + "bg_sidebar": "#f0f0f0", + "bg_card": "#e2e2e2", + "bg_card_hover": "#d4d4d4", + "bg_card_pressed": "#c8c8c8", + "bg_selected": "#d4d4d4", + "bg_input": "#e2e2e2", + "bg_progress": "#f0f0f0", + "text_primary": "#383a42", + "text_secondary": "#4a4c56", + "text_muted": "#686a76", + "text_dim": "#818387", + "text_dimmer": "#a0a1a7", + "text_dimmest": "#b8b9bc", + "text_placeholder": "#a0a1a7", + "accent_blue": "#4078f2", + "accent_icon": "#0184bc", + "accent_progress": "#4078f2", + "btn_default": "#e2e2e2", + "btn_hover": "#d4d4d4", + "btn_pressed": "#c8c8c8", + "success": "#50a14f", + "success_bg": "#d0e8d0", + "btn_success": "#50a14f", + "btn_success_hover": "#449444", + "btn_success_pressed": "#388838", + "warning": "#c18401", + "warning_bg": "#ece0c0", + "error": "#e45649", + "error_light": "#f06860", + "error_bg": "#eacccc", + "btn_danger_bg": "#eacccc", + "btn_danger_hover": "#dab8b8", + "btn_trash_hover": "#e45649", + "btn_trash_pressed": "#c44038", + "bg_modal_section": "#fafafa", + "border_subtle": "#d4d4d4", + "divider": "#d4d4d4" + } + } + ] + }, + { + "key": "monokai", + "order": 11, + "labelKey": "settings_theme_monokai", + "label": { + "fr": "Monokai Pro", + "en": "Monokai Pro" + }, + "icon": "f121", + "notes": null, + "variants": [ + { + "key": "pro", + "slug": "monokai-pro", + "const": "MONOKAI_PRO", + "labelKey": "settings_theme_monokai_pro", + "label": { + "fr": "Pro", + "en": "Pro" + }, + "mode": "dark", + "swatch": { + "bg": "#2d2a2e", + "accent": "#78dce8" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 13.79, + "textMutedOnBgPrimary": 7.8 + }, + "palette": { + "bg_primary": "#2d2a2e", + "bg_sidebar": "#221f22", + "bg_card": "#403e41", + "bg_card_hover": "#525052", + "bg_card_pressed": "#5b595c", + "bg_selected": "#403e41", + "bg_input": "#403e41", + "bg_progress": "#221f22", + "text_primary": "#fcfcfa", + "text_secondary": "#e0e0de", + "text_muted": "#c1c0c0", + "text_dim": "#939293", + "text_dimmer": "#727072", + "text_dimmest": "#5b595c", + "text_placeholder": "#727072", + "accent_blue": "#78dce8", + "accent_icon": "#ab9df2", + "accent_progress": "#78dce8", + "btn_default": "#403e41", + "btn_hover": "#525052", + "btn_pressed": "#5b595c", + "success": "#a9dc76", + "success_bg": "#222820", + "btn_success": "#88b860", + "btn_success_hover": "#7cac54", + "btn_success_pressed": "#70a048", + "warning": "#ffd866", + "warning_bg": "#302a20", + "error": "#ff6188", + "error_light": "#ff8098", + "error_bg": "#341e28", + "btn_danger_bg": "#341e28", + "btn_danger_hover": "#542e38", + "btn_trash_hover": "#ff6188", + "btn_trash_pressed": "#df5070", + "bg_modal_section": "#2d2a2e", + "border_subtle": "#403e41", + "divider": "#403e41" + } + }, + { + "key": "classic", + "slug": "monokai-classic", + "const": "MONOKAI_CLASSIC", + "labelKey": "settings_theme_monokai_classic", + "label": { + "fr": "Classic", + "en": "Classic" + }, + "mode": "dark", + "swatch": { + "bg": "#272822", + "accent": "#66d9ef" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 13.94, + "textMutedOnBgPrimary": 8.19 + }, + "palette": { + "bg_primary": "#272822", + "bg_sidebar": "#1a1a18", + "bg_card": "#49483e", + "bg_card_hover": "#5a5950", + "bg_card_pressed": "#6b6a62", + "bg_selected": "#49483e", + "bg_input": "#49483e", + "bg_progress": "#1a1a18", + "text_primary": "#f8f8f2", + "text_secondary": "#e0e0d8", + "text_muted": "#c1c0c0", + "text_dim": "#939293", + "text_dimmer": "#75715e", + "text_dimmest": "#5b595c", + "text_placeholder": "#75715e", + "accent_blue": "#66d9ef", + "accent_icon": "#ae81ff", + "accent_progress": "#66d9ef", + "btn_default": "#49483e", + "btn_hover": "#5a5950", + "btn_pressed": "#6b6a62", + "success": "#a6e22e", + "success_bg": "#1e2e18", + "btn_success": "#86c020", + "btn_success_hover": "#7ab418", + "btn_success_pressed": "#6ea810", + "warning": "#e6db74", + "warning_bg": "#302a18", + "error": "#f92672", + "error_light": "#ff5090", + "error_bg": "#341828", + "btn_danger_bg": "#341828", + "btn_danger_hover": "#542838", + "btn_trash_hover": "#f92672", + "btn_trash_pressed": "#d91860", + "bg_modal_section": "#272822", + "border_subtle": "#49483e", + "divider": "#49483e" + } + }, + { + "key": "spectrum", + "slug": "monokai-spectrum", + "const": "MONOKAI_SPECTRUM", + "labelKey": "settings_theme_monokai_spectrum", + "label": { + "fr": "Spectrum", + "en": "Spectrum" + }, + "mode": "dark", + "swatch": { + "bg": "#222222", + "accent": "#5ad4e6" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 14.38, + "textMutedOnBgPrimary": 8.41 + }, + "palette": { + "bg_primary": "#222222", + "bg_sidebar": "#191919", + "bg_card": "#363537", + "bg_card_hover": "#484749", + "bg_card_pressed": "#5a595b", + "bg_selected": "#363537", + "bg_input": "#363537", + "bg_progress": "#191919", + "text_primary": "#f7f1ff", + "text_secondary": "#e0dae8", + "text_muted": "#c0bac8", + "text_dim": "#938ea0", + "text_dimmer": "#69676c", + "text_dimmest": "#4a484c", + "text_placeholder": "#69676c", + "accent_blue": "#5ad4e6", + "accent_icon": "#948ae3", + "accent_progress": "#5ad4e6", + "btn_default": "#363537", + "btn_hover": "#484749", + "btn_pressed": "#5a595b", + "success": "#7bd88f", + "success_bg": "#1a2820", + "btn_success": "#62b074", + "btn_success_hover": "#58a468", + "btn_success_pressed": "#4e985c", + "warning": "#fce566", + "warning_bg": "#2e2a1a", + "error": "#fc618d", + "error_light": "#ff80a0", + "error_bg": "#2e1a22", + "btn_danger_bg": "#2e1a22", + "btn_danger_hover": "#4e2a32", + "btn_trash_hover": "#fc618d", + "btn_trash_pressed": "#dc5078", + "bg_modal_section": "#222222", + "border_subtle": "#363537", + "divider": "#363537" + } + } + ] + }, + { + "key": "ayu", + "order": 12, + "labelKey": "settings_theme_ayu", + "label": { + "fr": "Ayu", + "en": "Ayu" + }, + "icon": "f06c0", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "ayu-dark", + "const": "AYU_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#0b0e14", + "accent": "#e6b450" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.27, + "textMutedOnBgPrimary": 5.44 + }, + "palette": { + "bg_primary": "#0b0e14", + "bg_sidebar": "#070a10", + "bg_card": "#1b2028", + "bg_card_hover": "#252b35", + "bg_card_pressed": "#303842", + "bg_selected": "#1b3a4b", + "bg_input": "#1b2028", + "bg_progress": "#070a10", + "text_primary": "#bfbdb6", + "text_secondary": "#a8a6a0", + "text_muted": "#8a8880", + "text_dim": "#7a786e", + "text_dimmer": "#626a73", + "text_dimmest": "#4a505a", + "text_placeholder": "#626a73", + "accent_blue": "#e6b450", + "accent_icon": "#59c2ff", + "accent_progress": "#e6b450", + "btn_default": "#1b2028", + "btn_hover": "#252b35", + "btn_pressed": "#303842", + "success": "#aad94c", + "success_bg": "#0c1a10", + "btn_success": "#88b040", + "btn_success_hover": "#7ca436", + "btn_success_pressed": "#70982c", + "warning": "#ffb454", + "warning_bg": "#1a1808", + "error": "#d95757", + "error_light": "#e87070", + "error_bg": "#1a0c0c", + "btn_danger_bg": "#1a0c0c", + "btn_danger_hover": "#2a1c1c", + "btn_trash_hover": "#d95757", + "btn_trash_pressed": "#b94848", + "bg_modal_section": "#0b0e14", + "border_subtle": "#1b2028", + "divider": "#1b2028" + } + }, + { + "key": "mirage", + "slug": "ayu-mirage", + "const": "AYU_MIRAGE", + "labelKey": "settings_theme_ayu_mirage", + "label": { + "fr": "Mirage", + "en": "Mirage" + }, + "mode": "dark", + "swatch": { + "bg": "#1f2430", + "accent": "#ffcc66" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 9.45, + "textMutedOnBgPrimary": 5.36 + }, + "palette": { + "bg_primary": "#1f2430", + "bg_sidebar": "#1a1f2b", + "bg_card": "#2a303e", + "bg_card_hover": "#33415e", + "bg_card_pressed": "#3d4a68", + "bg_selected": "#33415e", + "bg_input": "#2a303e", + "bg_progress": "#1a1f2b", + "text_primary": "#cccac2", + "text_secondary": "#b4b2aa", + "text_muted": "#9a988e", + "text_dim": "#858380", + "text_dimmer": "#707a8c", + "text_dimmest": "#555d6e", + "text_placeholder": "#707a8c", + "accent_blue": "#ffcc66", + "accent_icon": "#73d0ff", + "accent_progress": "#ffcc66", + "btn_default": "#2a303e", + "btn_hover": "#33415e", + "btn_pressed": "#3d4a68", + "success": "#bae67e", + "success_bg": "#1a2a1e", + "btn_success": "#98c066", + "btn_success_hover": "#8cb45a", + "btn_success_pressed": "#80a84e", + "warning": "#ffd580", + "warning_bg": "#2a2818", + "error": "#f28779", + "error_light": "#ff9e90", + "error_bg": "#2a1e1e", + "btn_danger_bg": "#2a1e1e", + "btn_danger_hover": "#4a2e2e", + "btn_trash_hover": "#f28779", + "btn_trash_pressed": "#d27060", + "bg_modal_section": "#1f2430", + "border_subtle": "#2a303e", + "divider": "#2a303e" + } + }, + { + "key": "light", + "slug": "ayu-light", + "const": "AYU_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fafafa", + "accent": "#ff9940" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.22, + "textMutedOnBgPrimary": 3.27 + }, + "palette": { + "bg_primary": "#fafafa", + "bg_sidebar": "#f0eee4", + "bg_card": "#e8e6dc", + "bg_card_hover": "#dcd8cc", + "bg_card_pressed": "#d0ccc0", + "bg_selected": "#d1e4f4", + "bg_input": "#e8e6dc", + "bg_progress": "#f0eee4", + "text_primary": "#575f66", + "text_secondary": "#6b737a", + "text_muted": "#848c94", + "text_dim": "#9ca4ac", + "text_dimmer": "#abb0b6", + "text_dimmest": "#c0c4c8", + "text_placeholder": "#abb0b6", + "accent_blue": "#ff9940", + "accent_icon": "#36a3d9", + "accent_progress": "#ff9940", + "btn_default": "#e8e6dc", + "btn_hover": "#dcd8cc", + "btn_pressed": "#d0ccc0", + "success": "#86b300", + "success_bg": "#d8e8c8", + "btn_success": "#86b300", + "btn_success_hover": "#78a400", + "btn_success_pressed": "#6a9600", + "warning": "#f29718", + "warning_bg": "#ede0c0", + "error": "#f51818", + "error_light": "#f04040", + "error_bg": "#eec8c8", + "btn_danger_bg": "#eec8c8", + "btn_danger_hover": "#deb0b0", + "btn_trash_hover": "#f51818", + "btn_trash_pressed": "#d50e0e", + "bg_modal_section": "#fafafa", + "border_subtle": "#dcd8cc", + "divider": "#dcd8cc" + } + } + ] + }, + { + "key": "everforest", + "order": 13, + "labelKey": "settings_theme_everforest", + "label": { + "fr": "Everforest", + "en": "Everforest" + }, + "icon": "f1bb", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "everforest-dark", + "const": "EVERFOREST_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#2d353b", + "accent": "#7fbbb3" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 7.38, + "textMutedOnBgPrimary": 4.33 + }, + "palette": { + "bg_primary": "#2d353b", + "bg_sidebar": "#232a2e", + "bg_card": "#343f44", + "bg_card_hover": "#3d484d", + "bg_card_pressed": "#475258", + "bg_selected": "#543a48", + "bg_input": "#343f44", + "bg_progress": "#232a2e", + "text_primary": "#d3c6aa", + "text_secondary": "#c0b498", + "text_muted": "#a09880", + "text_dim": "#859289", + "text_dimmer": "#7a8478", + "text_dimmest": "#5c6a62", + "text_placeholder": "#7a8478", + "accent_blue": "#7fbbb3", + "accent_icon": "#a7c080", + "accent_progress": "#7fbbb3", + "btn_default": "#343f44", + "btn_hover": "#3d484d", + "btn_pressed": "#475258", + "success": "#a7c080", + "success_bg": "#1e2e24", + "btn_success": "#86a066", + "btn_success_hover": "#7a945a", + "btn_success_pressed": "#6e884e", + "warning": "#dbbc7f", + "warning_bg": "#2e2a1e", + "error": "#e67e80", + "error_light": "#f09090", + "error_bg": "#2e2020", + "btn_danger_bg": "#2e2020", + "btn_danger_hover": "#4e3030", + "btn_trash_hover": "#e67e80", + "btn_trash_pressed": "#c66868", + "bg_modal_section": "#2d353b", + "border_subtle": "#343f44", + "divider": "#343f44" + } + }, + { + "key": "light", + "slug": "everforest-light", + "const": "EVERFOREST_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fdf6e3", + "accent": "#3a94c5" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 5.18, + "textMutedOnBgPrimary": 3.08 + }, + "palette": { + "bg_primary": "#fdf6e3", + "bg_sidebar": "#efecd4", + "bg_card": "#f4f0d9", + "bg_card_hover": "#e6e2cc", + "bg_card_pressed": "#e0dcc7", + "bg_selected": "#eaddc0", + "bg_input": "#f4f0d9", + "bg_progress": "#efecd4", + "text_primary": "#5c6a72", + "text_secondary": "#6e7a80", + "text_muted": "#829181", + "text_dim": "#939f91", + "text_dimmer": "#a6b0a0", + "text_dimmest": "#bcc5b8", + "text_placeholder": "#a6b0a0", + "accent_blue": "#3a94c5", + "accent_icon": "#8da101", + "accent_progress": "#3a94c5", + "btn_default": "#f4f0d9", + "btn_hover": "#e6e2cc", + "btn_pressed": "#e0dcc7", + "success": "#8da101", + "success_bg": "#d0e0c4", + "btn_success": "#8da101", + "btn_success_hover": "#7e9200", + "btn_success_pressed": "#6f8300", + "warning": "#dfa000", + "warning_bg": "#ece0b8", + "error": "#f85552", + "error_light": "#e86868", + "error_bg": "#ecc8c4", + "btn_danger_bg": "#ecc8c4", + "btn_danger_hover": "#dcb0ac", + "btn_trash_hover": "#f85552", + "btn_trash_pressed": "#d84040", + "bg_modal_section": "#fdf6e3", + "border_subtle": "#e6e2cc", + "divider": "#e6e2cc" + } + } + ] + }, + { + "key": "material", + "order": 14, + "labelKey": "settings_theme_material", + "label": { + "fr": "Material", + "en": "Material" + }, + "icon": "f0509", + "notes": null, + "variants": [ + { + "key": "oceanic", + "slug": "material-oceanic", + "const": "MATERIAL_OCEANIC", + "labelKey": "settings_theme_material_oceanic", + "label": { + "fr": "Oceanic", + "en": "Oceanic" + }, + "mode": "dark", + "swatch": { + "bg": "#263238", + "accent": "#89ddff" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.9, + "textMutedOnBgPrimary": 4.47 + }, + "palette": { + "bg_primary": "#263238", + "bg_sidebar": "#1e272c", + "bg_card": "#2e3c42", + "bg_card_hover": "#3a4a52", + "bg_card_pressed": "#465862", + "bg_selected": "#546e7a", + "bg_input": "#2e3c42", + "bg_progress": "#1e272c", + "text_primary": "#b0bec5", + "text_secondary": "#9aaab2", + "text_muted": "#849aa4", + "text_dim": "#6e8490", + "text_dimmer": "#546e7a", + "text_dimmest": "#405a64", + "text_placeholder": "#546e7a", + "accent_blue": "#89ddff", + "accent_icon": "#80cbc4", + "accent_progress": "#89ddff", + "btn_default": "#2e3c42", + "btn_hover": "#3a4a52", + "btn_pressed": "#465862", + "success": "#c3e88d", + "success_bg": "#1a3028", + "btn_success": "#a0c070", + "btn_success_hover": "#94b464", + "btn_success_pressed": "#88a858", + "warning": "#ffcb6b", + "warning_bg": "#2e2e1e", + "error": "#ff5370", + "error_light": "#ff7088", + "error_bg": "#2e1e22", + "btn_danger_bg": "#2e1e22", + "btn_danger_hover": "#4e2e32", + "btn_trash_hover": "#ff5370", + "btn_trash_pressed": "#df4058", + "bg_modal_section": "#263238", + "border_subtle": "#2e3c42", + "divider": "#2e3c42" + } + }, + { + "key": "palenight", + "slug": "material-palenight", + "const": "MATERIAL_PALENIGHT", + "labelKey": "settings_theme_material_palenight", + "label": { + "fr": "Palenight", + "en": "Palenight" + }, + "mode": "dark", + "swatch": { + "bg": "#292d3e", + "accent": "#c792ea" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.11, + "textMutedOnBgPrimary": 3.84 + }, + "palette": { + "bg_primary": "#292d3e", + "bg_sidebar": "#232838", + "bg_card": "#343a4e", + "bg_card_hover": "#414662", + "bg_card_pressed": "#515772", + "bg_selected": "#717cb4", + "bg_input": "#343a4e", + "bg_progress": "#232838", + "text_primary": "#a6accd", + "text_secondary": "#929abe", + "text_muted": "#7e86ae", + "text_dim": "#717cb4", + "text_dimmer": "#676e95", + "text_dimmest": "#515772", + "text_placeholder": "#676e95", + "accent_blue": "#c792ea", + "accent_icon": "#82aaff", + "accent_progress": "#c792ea", + "btn_default": "#343a4e", + "btn_hover": "#414662", + "btn_pressed": "#515772", + "success": "#c3e88d", + "success_bg": "#202e28", + "btn_success": "#a0c070", + "btn_success_hover": "#94b464", + "btn_success_pressed": "#88a858", + "warning": "#ffcb6b", + "warning_bg": "#2e2c1e", + "error": "#ff5370", + "error_light": "#ff7088", + "error_bg": "#2e1e22", + "btn_danger_bg": "#2e1e22", + "btn_danger_hover": "#4e2e32", + "btn_trash_hover": "#ff5370", + "btn_trash_pressed": "#df4058", + "bg_modal_section": "#292d3e", + "border_subtle": "#343a4e", + "divider": "#343a4e" + } + }, + { + "key": "deepocean", + "slug": "material-deepocean", + "const": "MATERIAL_DEEPOCEAN", + "labelKey": "settings_theme_material_deepocean", + "label": { + "fr": "Deep Ocean", + "en": "Deep Ocean" + }, + "mode": "dark", + "swatch": { + "bg": "#0f111a", + "accent": "#84ffff" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.15, + "textMutedOnBgPrimary": 4.84 + }, + "palette": { + "bg_primary": "#0f111a", + "bg_sidebar": "#090b10", + "bg_card": "#1a1c28", + "bg_card_hover": "#252836", + "bg_card_pressed": "#3b3f51", + "bg_selected": "#44475a", + "bg_input": "#1a1c28", + "bg_progress": "#090b10", + "text_primary": "#8f93a2", + "text_secondary": "#a0a4b4", + "text_muted": "#7880a0", + "text_dim": "#606888", + "text_dimmer": "#464b5d", + "text_dimmest": "#3b3f51", + "text_placeholder": "#464b5d", + "accent_blue": "#84ffff", + "accent_icon": "#82aaff", + "accent_progress": "#84ffff", + "btn_default": "#1a1c28", + "btn_hover": "#252836", + "btn_pressed": "#3b3f51", + "success": "#c3e88d", + "success_bg": "#0c1a14", + "btn_success": "#a0c070", + "btn_success_hover": "#94b464", + "btn_success_pressed": "#88a858", + "warning": "#ffcb6b", + "warning_bg": "#1a180c", + "error": "#ff5370", + "error_light": "#ff7088", + "error_bg": "#1a0c10", + "btn_danger_bg": "#1a0c10", + "btn_danger_hover": "#3a1c20", + "btn_trash_hover": "#ff5370", + "btn_trash_pressed": "#df4058", + "bg_modal_section": "#0f111a", + "border_subtle": "#1a1c28", + "divider": "#1a1c28" + } + } + ] + }, + { + "key": "flexoki", + "order": 15, + "labelKey": "settings_theme_flexoki", + "label": { + "fr": "Flexoki", + "en": "Flexoki" + }, + "icon": "f02d", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "flexoki-dark", + "const": "FLEXOKI_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#100f0f", + "accent": "#4385be" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 11.98, + "textMutedOnBgPrimary": 7.05 + }, + "palette": { + "bg_primary": "#100f0f", + "bg_sidebar": "#1c1b1a", + "bg_card": "#282726", + "bg_card_hover": "#343331", + "bg_card_pressed": "#403e3c", + "bg_selected": "#403e3c", + "bg_input": "#282726", + "bg_progress": "#1c1b1a", + "text_primary": "#cecdc3", + "text_secondary": "#b7b5ac", + "text_muted": "#9f9d96", + "text_dim": "#878580", + "text_dimmer": "#6f6e69", + "text_dimmest": "#575653", + "text_placeholder": "#6f6e69", + "accent_blue": "#4385be", + "accent_icon": "#da702c", + "accent_progress": "#4385be", + "btn_default": "#282726", + "btn_hover": "#343331", + "btn_pressed": "#403e3c", + "success": "#879a39", + "success_bg": "#141a10", + "btn_success": "#6e8030", + "btn_success_hover": "#627428", + "btn_success_pressed": "#566820", + "warning": "#d0a215", + "warning_bg": "#1e1a0c", + "error": "#d14d41", + "error_light": "#e06858", + "error_bg": "#1e100c", + "btn_danger_bg": "#1e100c", + "btn_danger_hover": "#3e201c", + "btn_trash_hover": "#d14d41", + "btn_trash_pressed": "#b13830", + "bg_modal_section": "#100f0f", + "border_subtle": "#282726", + "divider": "#282726" + } + }, + { + "key": "light", + "slug": "flexoki-light", + "const": "FLEXOKI_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fffcf0", + "accent": "#205ea6" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.36, + "textMutedOnBgPrimary": 4.97 + }, + "palette": { + "bg_primary": "#fffcf0", + "bg_sidebar": "#f2f0e5", + "bg_card": "#e6e4d9", + "bg_card_hover": "#dad8ce", + "bg_card_pressed": "#cecdc3", + "bg_selected": "#e6e4d9", + "bg_input": "#e6e4d9", + "bg_progress": "#f2f0e5", + "text_primary": "#403e3c", + "text_secondary": "#575653", + "text_muted": "#6f6e69", + "text_dim": "#878580", + "text_dimmer": "#9f9d96", + "text_dimmest": "#b7b5ac", + "text_placeholder": "#9f9d96", + "accent_blue": "#205ea6", + "accent_icon": "#bc5215", + "accent_progress": "#205ea6", + "btn_default": "#e6e4d9", + "btn_hover": "#dad8ce", + "btn_pressed": "#cecdc3", + "success": "#66800b", + "success_bg": "#d0e0c0", + "btn_success": "#66800b", + "btn_success_hover": "#587208", + "btn_success_pressed": "#4a6405", + "warning": "#ad8301", + "warning_bg": "#e0dcc0", + "error": "#af3029", + "error_light": "#d14d41", + "error_bg": "#e0c8c0", + "btn_danger_bg": "#e0c8c0", + "btn_danger_hover": "#d0b4ac", + "btn_trash_hover": "#af3029", + "btn_trash_pressed": "#902420", + "bg_modal_section": "#fffcf0", + "border_subtle": "#dad8ce", + "divider": "#dad8ce" + } + } + ] + }, + { + "key": "nightfox", + "order": 16, + "labelKey": "settings_theme_nightfox", + "label": { + "fr": "Nightfox", + "en": "Nightfox" + }, + "icon": "f0139", + "notes": null, + "variants": [ + { + "key": "nightfox", + "slug": "nightfox-nightfox", + "const": "NIGHTFOX", + "labelKey": "settings_theme_nightfox_nightfox", + "label": { + "fr": "Nightfox", + "en": "Nightfox" + }, + "mode": "dark", + "swatch": { + "bg": "#192330", + "accent": "#719cd6" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.06, + "textMutedOnBgPrimary": 4.98 + }, + "palette": { + "bg_primary": "#192330", + "bg_sidebar": "#131a24", + "bg_card": "#2b3b51", + "bg_card_hover": "#3c5372", + "bg_card_pressed": "#39506d", + "bg_selected": "#2b3b51", + "bg_input": "#2b3b51", + "bg_progress": "#131a24", + "text_primary": "#cdcecf", + "text_secondary": "#aeafb0", + "text_muted": "#8e9098", + "text_dim": "#738091", + "text_dimmer": "#5c6a7c", + "text_dimmest": "#3e4e62", + "text_placeholder": "#5c6a7c", + "accent_blue": "#719cd6", + "accent_icon": "#63cdcf", + "accent_progress": "#719cd6", + "btn_default": "#2b3b51", + "btn_hover": "#3c5372", + "btn_pressed": "#39506d", + "success": "#81b29a", + "success_bg": "#142820", + "btn_success": "#68907e", + "btn_success_hover": "#5c8472", + "btn_success_pressed": "#507866", + "warning": "#dbc074", + "warning_bg": "#282418", + "error": "#c94f6d", + "error_light": "#d66880", + "error_bg": "#281820", + "btn_danger_bg": "#281820", + "btn_danger_hover": "#482830", + "btn_trash_hover": "#c94f6d", + "btn_trash_pressed": "#a9405c", + "bg_modal_section": "#192330", + "border_subtle": "#2b3b51", + "divider": "#2b3b51" + } + }, + { + "key": "dawnfox", + "slug": "nightfox-dawnfox", + "const": "DAWNFOX", + "labelKey": "settings_theme_nightfox_dawnfox", + "label": { + "fr": "Dawnfox", + "en": "Dawnfox" + }, + "mode": "light", + "swatch": { + "bg": "#faf4ed", + "accent": "#286983" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 6.66, + "textMutedOnBgPrimary": 4.73 + }, + "palette": { + "bg_primary": "#faf4ed", + "bg_sidebar": "#ebe5df", + "bg_card": "#ebd8ce", + "bg_card_hover": "#dacdc3", + "bg_card_pressed": "#c8beb4", + "bg_selected": "#ebd8ce", + "bg_input": "#ebd8ce", + "bg_progress": "#ebe5df", + "text_primary": "#575279", + "text_secondary": "#625c87", + "text_muted": "#6e6a86", + "text_dim": "#9893a5", + "text_dimmer": "#aea8b8", + "text_dimmest": "#c0bac8", + "text_placeholder": "#aea8b8", + "accent_blue": "#286983", + "accent_icon": "#907aa9", + "accent_progress": "#286983", + "btn_default": "#ebd8ce", + "btn_hover": "#dacdc3", + "btn_pressed": "#c8beb4", + "success": "#618774", + "success_bg": "#d0e0d4", + "btn_success": "#618774", + "btn_success_hover": "#547a68", + "btn_success_pressed": "#476d5c", + "warning": "#ea9d34", + "warning_bg": "#ece0c8", + "error": "#b4637a", + "error_light": "#c87890", + "error_bg": "#e8d0d4", + "btn_danger_bg": "#e8d0d4", + "btn_danger_hover": "#d8bcc4", + "btn_trash_hover": "#b4637a", + "btn_trash_pressed": "#965268", + "bg_modal_section": "#faf4ed", + "border_subtle": "#dacdc3", + "divider": "#dacdc3" + } + } + ] + }, + { + "key": "sonokai", + "order": 17, + "labelKey": "settings_theme_sonokai", + "label": { + "fr": "Sonokai", + "en": "Sonokai" + }, + "icon": "", + "notes": null, + "variants": [ + { + "key": "default", + "slug": "sonokai-default", + "const": "SONOKAI_DEFAULT", + "labelKey": "settings_theme_sonokai_default", + "label": { + "fr": "Défaut", + "en": "Default" + }, + "mode": "dark", + "swatch": { + "bg": "#2c2e34", + "accent": "#76cce0" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.48, + "textMutedOnBgPrimary": 5.75 + }, + "palette": { + "bg_primary": "#2c2e34", + "bg_sidebar": "#242529", + "bg_card": "#33353f", + "bg_card_hover": "#3b3e48", + "bg_card_pressed": "#414550", + "bg_selected": "#3b3e48", + "bg_input": "#33353f", + "bg_progress": "#242529", + "text_primary": "#e2e2e3", + "text_secondary": "#ccccd0", + "text_muted": "#a8a8b0", + "text_dim": "#8c8c96", + "text_dimmer": "#7f8490", + "text_dimmest": "#585c68", + "text_placeholder": "#7f8490", + "accent_blue": "#76cce0", + "accent_icon": "#b39df3", + "accent_progress": "#76cce0", + "btn_default": "#33353f", + "btn_hover": "#3b3e48", + "btn_pressed": "#414550", + "success": "#9ed072", + "success_bg": "#1e2e22", + "btn_success": "#7eaa5a", + "btn_success_hover": "#729e4e", + "btn_success_pressed": "#669242", + "warning": "#e7c664", + "warning_bg": "#2e2a1a", + "error": "#fc5d7c", + "error_light": "#ff7890", + "error_bg": "#2e1a20", + "btn_danger_bg": "#2e1a20", + "btn_danger_hover": "#4e2a30", + "btn_trash_hover": "#fc5d7c", + "btn_trash_pressed": "#dc4c66", + "bg_modal_section": "#2c2e34", + "border_subtle": "#33353f", + "divider": "#33353f" + } + } + ] + }, + { + "key": "oxocarbon", + "order": 18, + "labelKey": "settings_theme_oxocarbon", + "label": { + "fr": "Oxocarbon", + "en": "Oxocarbon" + }, + "icon": "f0620", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "oxocarbon-dark", + "const": "OXOCARBON_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#161616", + "accent": "#78a9ff" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 16.43, + "textMutedOnBgPrimary": 8.7 + }, + "palette": { + "bg_primary": "#161616", + "bg_sidebar": "#0e0e0e", + "bg_card": "#262626", + "bg_card_hover": "#393939", + "bg_card_pressed": "#525252", + "bg_selected": "#393939", + "bg_input": "#262626", + "bg_progress": "#0e0e0e", + "text_primary": "#f2f4f8", + "text_secondary": "#dde1e6", + "text_muted": "#b0b4bc", + "text_dim": "#8a8e96", + "text_dimmer": "#6e7278", + "text_dimmest": "#525252", + "text_placeholder": "#6e7278", + "accent_blue": "#78a9ff", + "accent_icon": "#be95ff", + "accent_progress": "#78a9ff", + "btn_default": "#262626", + "btn_hover": "#393939", + "btn_pressed": "#525252", + "success": "#42be65", + "success_bg": "#0c1c12", + "btn_success": "#359e52", + "btn_success_hover": "#2c9048", + "btn_success_pressed": "#24823e", + "warning": "#08bdba", + "warning_bg": "#0c1a1a", + "error": "#ee5396", + "error_light": "#ff7eb6", + "error_bg": "#1c0c14", + "btn_danger_bg": "#1c0c14", + "btn_danger_hover": "#3c1c24", + "btn_trash_hover": "#ee5396", + "btn_trash_pressed": "#ce4480", + "bg_modal_section": "#161616", + "border_subtle": "#262626", + "divider": "#262626" + } + }, + { + "key": "light", + "slug": "oxocarbon-light", + "const": "OXOCARBON_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#ffffff", + "accent": "#0f62fe" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 15.13, + "textMutedOnBgPrimary": 7.81 + }, + "palette": { + "bg_primary": "#ffffff", + "bg_sidebar": "#f2f4f8", + "bg_card": "#dde1e6", + "bg_card_hover": "#c8ccd2", + "bg_card_pressed": "#b4b8c0", + "bg_selected": "#dde1e6", + "bg_input": "#dde1e6", + "bg_progress": "#f2f4f8", + "text_primary": "#262626", + "text_secondary": "#393939", + "text_muted": "#525252", + "text_dim": "#6e7278", + "text_dimmer": "#8a8e96", + "text_dimmest": "#b0b4bc", + "text_placeholder": "#8a8e96", + "accent_blue": "#0f62fe", + "accent_icon": "#8a3ffc", + "accent_progress": "#0f62fe", + "btn_default": "#dde1e6", + "btn_hover": "#c8ccd2", + "btn_pressed": "#b4b8c0", + "success": "#198038", + "success_bg": "#d0f0d8", + "btn_success": "#198038", + "btn_success_hover": "#14702e", + "btn_success_pressed": "#106024", + "warning": "#005d5d", + "warning_bg": "#d0e8e8", + "error": "#da1e28", + "error_light": "#ee5396", + "error_bg": "#f0d0d8", + "btn_danger_bg": "#f0d0d8", + "btn_danger_hover": "#e0b8c4", + "btn_trash_hover": "#da1e28", + "btn_trash_pressed": "#ba1420", + "bg_modal_section": "#ffffff", + "border_subtle": "#c8ccd2", + "divider": "#c8ccd2" + } + } + ] + }, + { + "key": "nightowl", + "order": 19, + "labelKey": "settings_theme_nightowl", + "label": { + "fr": "Night Owl", + "en": "Night Owl" + }, + "icon": "f19e", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "nightowl-dark", + "const": "NIGHTOWL_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#011627", + "accent": "#82aaff" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 13.54, + "textMutedOnBgPrimary": 6.81 + }, + "palette": { + "bg_primary": "#011627", + "bg_sidebar": "#011221", + "bg_card": "#0b2942", + "bg_card_hover": "#1d3b53", + "bg_card_pressed": "#2a4f6c", + "bg_selected": "#1d3b53", + "bg_input": "#0b2942", + "bg_progress": "#011221", + "text_primary": "#d6deeb", + "text_secondary": "#b4c0d0", + "text_muted": "#8ca0b4", + "text_dim": "#7e94a8", + "text_dimmer": "#637777", + "text_dimmest": "#4a6060", + "text_placeholder": "#637777", + "accent_blue": "#82aaff", + "accent_icon": "#7fdbca", + "accent_progress": "#82aaff", + "btn_default": "#0b2942", + "btn_hover": "#1d3b53", + "btn_pressed": "#2a4f6c", + "success": "#addb67", + "success_bg": "#011e14", + "btn_success": "#8cb852", + "btn_success_hover": "#80ac48", + "btn_success_pressed": "#74a03e", + "warning": "#ecc48d", + "warning_bg": "#1a1808", + "error": "#ef5350", + "error_light": "#ff6e6a", + "error_bg": "#1a0808", + "btn_danger_bg": "#1a0808", + "btn_danger_hover": "#3a1818", + "btn_trash_hover": "#ef5350", + "btn_trash_pressed": "#cf4040", + "bg_modal_section": "#011627", + "border_subtle": "#0b2942", + "divider": "#0b2942" + } + }, + { + "key": "light", + "slug": "nightowl-light", + "const": "NIGHTOWL_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#fbfbfb", + "accent": "#4876d6" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 9.88, + "textMutedOnBgPrimary": 4.82 + }, + "palette": { + "bg_primary": "#fbfbfb", + "bg_sidebar": "#f0f0f0", + "bg_card": "#e8e8e8", + "bg_card_hover": "#e0e0e0", + "bg_card_pressed": "#d4d4d4", + "bg_selected": "#e0e0e0", + "bg_input": "#e8e8e8", + "bg_progress": "#f0f0f0", + "text_primary": "#403f53", + "text_secondary": "#565570", + "text_muted": "#6e6d88", + "text_dim": "#8888a0", + "text_dimmer": "#989fb1", + "text_dimmest": "#b0b4c0", + "text_placeholder": "#989fb1", + "accent_blue": "#4876d6", + "accent_icon": "#0c969b", + "accent_progress": "#4876d6", + "btn_default": "#e8e8e8", + "btn_hover": "#e0e0e0", + "btn_pressed": "#d4d4d4", + "success": "#2aa298", + "success_bg": "#d0e8e0", + "btn_success": "#2aa298", + "btn_success_hover": "#22948c", + "btn_success_pressed": "#1a8680", + "warning": "#d98e24", + "warning_bg": "#ece0c8", + "error": "#de3d3b", + "error_light": "#e85858", + "error_bg": "#ecc8c8", + "btn_danger_bg": "#ecc8c8", + "btn_danger_hover": "#dcb0b0", + "btn_trash_hover": "#de3d3b", + "btn_trash_pressed": "#be2e2c", + "bg_modal_section": "#fbfbfb", + "border_subtle": "#e0e0e0", + "divider": "#e0e0e0" + } + } + ] + }, + { + "key": "iceberg", + "order": 20, + "labelKey": "settings_theme_iceberg", + "label": { + "fr": "Iceberg", + "en": "Iceberg" + }, + "icon": "f2dc", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "iceberg-dark", + "const": "ICEBERG_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#161821", + "accent": "#84a0c6" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 10.6, + "textMutedOnBgPrimary": 6.16 + }, + "palette": { + "bg_primary": "#161821", + "bg_sidebar": "#0f1117", + "bg_card": "#1e2132", + "bg_card_hover": "#2e313f", + "bg_card_pressed": "#3d425b", + "bg_selected": "#2e313f", + "bg_input": "#1e2132", + "bg_progress": "#0f1117", + "text_primary": "#c6c8d1", + "text_secondary": "#b0b4c0", + "text_muted": "#9498a8", + "text_dim": "#818596", + "text_dimmer": "#6b7089", + "text_dimmest": "#4e5268", + "text_placeholder": "#6b7089", + "accent_blue": "#84a0c6", + "accent_icon": "#89b8c2", + "accent_progress": "#84a0c6", + "btn_default": "#1e2132", + "btn_hover": "#2e313f", + "btn_pressed": "#3d425b", + "success": "#b4be82", + "success_bg": "#141e18", + "btn_success": "#949e68", + "btn_success_hover": "#88925c", + "btn_success_pressed": "#7c8650", + "warning": "#e2a478", + "warning_bg": "#201c14", + "error": "#e27878", + "error_light": "#f09090", + "error_bg": "#201418", + "btn_danger_bg": "#201418", + "btn_danger_hover": "#402428", + "btn_trash_hover": "#e27878", + "btn_trash_pressed": "#c26060", + "bg_modal_section": "#161821", + "border_subtle": "#1e2132", + "divider": "#1e2132" + } + }, + { + "key": "light", + "slug": "iceberg-light", + "const": "ICEBERG_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#e8e9ec", + "accent": "#2d539e" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 9.66, + "textMutedOnBgPrimary": 5.03 + }, + "palette": { + "bg_primary": "#e8e9ec", + "bg_sidebar": "#dcdfe7", + "bg_card": "#d0d4de", + "bg_card_hover": "#cad0de", + "bg_card_pressed": "#bec4d2", + "bg_selected": "#cad0de", + "bg_input": "#d0d4de", + "bg_progress": "#dcdfe7", + "text_primary": "#33374c", + "text_secondary": "#444862", + "text_muted": "#5c6080", + "text_dim": "#747896", + "text_dimmer": "#8b98b6", + "text_dimmest": "#a8b0c8", + "text_placeholder": "#8b98b6", + "accent_blue": "#2d539e", + "accent_icon": "#33635c", + "accent_progress": "#2d539e", + "btn_default": "#d0d4de", + "btn_hover": "#cad0de", + "btn_pressed": "#bec4d2", + "success": "#668e3d", + "success_bg": "#d0e0c8", + "btn_success": "#668e3d", + "btn_success_hover": "#588032", + "btn_success_pressed": "#4a7228", + "warning": "#c57339", + "warning_bg": "#e4d8c8", + "error": "#cc517a", + "error_light": "#dd6890", + "error_bg": "#e4c8d4", + "btn_danger_bg": "#e4c8d4", + "btn_danger_hover": "#d4b4c0", + "btn_trash_hover": "#cc517a", + "btn_trash_pressed": "#ac4068", + "bg_modal_section": "#e8e9ec", + "border_subtle": "#cad0de", + "divider": "#cad0de" + } + } + ] + }, + { + "key": "horizon", + "order": 21, + "labelKey": "settings_theme_horizon", + "label": { + "fr": "Horizon", + "en": "Horizon" + }, + "icon": "f06c0", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "horizon-dark", + "const": "HORIZON_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#1c1e26", + "accent": "#26bbd9" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 11.61, + "textMutedOnBgPrimary": 6.51 + }, + "palette": { + "bg_primary": "#1c1e26", + "bg_sidebar": "#16161c", + "bg_card": "#2e303e", + "bg_card_hover": "#3a3c4e", + "bg_card_pressed": "#484a60", + "bg_selected": "#2e303e", + "bg_input": "#2e303e", + "bg_progress": "#16161c", + "text_primary": "#d5d8da", + "text_secondary": "#bec0c4", + "text_muted": "#a0a2a8", + "text_dim": "#888a92", + "text_dimmer": "#6c6f93", + "text_dimmest": "#50526e", + "text_placeholder": "#6c6f93", + "accent_blue": "#26bbd9", + "accent_icon": "#b877db", + "accent_progress": "#26bbd9", + "btn_default": "#2e303e", + "btn_hover": "#3a3c4e", + "btn_pressed": "#484a60", + "success": "#29d398", + "success_bg": "#141e1c", + "btn_success": "#22b480", + "btn_success_hover": "#1ca874", + "btn_success_pressed": "#169c68", + "warning": "#fac29a", + "warning_bg": "#2a2218", + "error": "#e95678", + "error_light": "#f07090", + "error_bg": "#2a1620", + "btn_danger_bg": "#2a1620", + "btn_danger_hover": "#4a2630", + "btn_trash_hover": "#e95678", + "btn_trash_pressed": "#c94460", + "bg_modal_section": "#1c1e26", + "border_subtle": "#2e303e", + "divider": "#2e303e" + } + } + ] + }, + { + "key": "melange", + "order": 22, + "labelKey": "settings_theme_melange", + "label": { + "fr": "Mélange", + "en": "Melange" + }, + "icon": "f0f4", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "melange-dark", + "const": "MELANGE_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#292522", + "accent": "#a3a9ce" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 11.81, + "textMutedOnBgPrimary": 7.12 + }, + "palette": { + "bg_primary": "#292522", + "bg_sidebar": "#221e1b", + "bg_card": "#34302c", + "bg_card_hover": "#403a36", + "bg_card_pressed": "#4c4640", + "bg_selected": "#403a36", + "bg_input": "#34302c", + "bg_progress": "#221e1b", + "text_primary": "#ece1d7", + "text_secondary": "#d4c8bc", + "text_muted": "#bab0a4", + "text_dim": "#9e9488", + "text_dimmer": "#867462", + "text_dimmest": "#6a5e50", + "text_placeholder": "#867462", + "accent_blue": "#a3a9ce", + "accent_icon": "#89b3b6", + "accent_progress": "#a3a9ce", + "btn_default": "#34302c", + "btn_hover": "#403a36", + "btn_pressed": "#4c4640", + "success": "#85b695", + "success_bg": "#1e2820", + "btn_success": "#6c9a7c", + "btn_success_hover": "#608e70", + "btn_success_pressed": "#548264", + "warning": "#ebc06d", + "warning_bg": "#2a261a", + "error": "#d47766", + "error_light": "#e89080", + "error_bg": "#2a1c18", + "btn_danger_bg": "#2a1c18", + "btn_danger_hover": "#4a2c28", + "btn_trash_hover": "#d47766", + "btn_trash_pressed": "#b46050", + "bg_modal_section": "#292522", + "border_subtle": "#34302c", + "divider": "#34302c" + } + }, + { + "key": "light", + "slug": "melange-light", + "const": "MELANGE_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#f4f0ed", + "accent": "#5e6dab" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 8.27, + "textMutedOnBgPrimary": 4.33 + }, + "palette": { + "bg_primary": "#f4f0ed", + "bg_sidebar": "#e9e1db", + "bg_card": "#ddd2c8", + "bg_card_hover": "#d0c6ba", + "bg_card_pressed": "#c4baae", + "bg_selected": "#ddd2c8", + "bg_input": "#ddd2c8", + "bg_progress": "#e9e1db", + "text_primary": "#54433a", + "text_secondary": "#6b5c4d", + "text_muted": "#7e6e5e", + "text_dim": "#948472", + "text_dimmer": "#a89888", + "text_dimmest": "#beb0a0", + "text_placeholder": "#a89888", + "accent_blue": "#5e6dab", + "accent_icon": "#3f7c82", + "accent_progress": "#5e6dab", + "btn_default": "#ddd2c8", + "btn_hover": "#d0c6ba", + "btn_pressed": "#c4baae", + "success": "#4e7548", + "success_bg": "#d0e0d0", + "btn_success": "#4e7548", + "btn_success_hover": "#42683c", + "btn_success_pressed": "#365c30", + "warning": "#9a7c24", + "warning_bg": "#e0dcc0", + "error": "#a44c36", + "error_light": "#c06048", + "error_bg": "#e0ccc4", + "btn_danger_bg": "#e0ccc4", + "btn_danger_hover": "#d0b8b0", + "btn_trash_hover": "#a44c36", + "btn_trash_pressed": "#843c28", + "bg_modal_section": "#f4f0ed", + "border_subtle": "#d0c6ba", + "divider": "#d0c6ba" + } + } + ] + }, + { + "key": "synthwave", + "order": 23, + "labelKey": "settings_theme_synthwave", + "label": { + "fr": "Synthwave '84", + "en": "Synthwave '84" + }, + "icon": "f001", + "notes": null, + "variants": [ + { + "key": "dark", + "slug": "synthwave-dark", + "const": "SYNTHWAVE_DARK", + "labelKey": "settings_theme_dark_mode", + "label": { + "fr": "Mode sombre", + "en": "Dark mode" + }, + "mode": "dark", + "swatch": { + "bg": "#262335", + "accent": "#ff7edb" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 15.28, + "textMutedOnBgPrimary": 8.26 + }, + "palette": { + "bg_primary": "#262335", + "bg_sidebar": "#241b2f", + "bg_card": "#34294f", + "bg_card_hover": "#3e3460", + "bg_card_pressed": "#463465", + "bg_selected": "#463465", + "bg_input": "#2a2139", + "bg_progress": "#241b2f", + "text_primary": "#ffffff", + "text_secondary": "#e2e2e2", + "text_muted": "#c0bcd0", + "text_dim": "#a09cb4", + "text_dimmer": "#848bbd", + "text_dimmest": "#614d85", + "text_placeholder": "#848bbd", + "accent_blue": "#ff7edb", + "accent_icon": "#36f9f6", + "accent_progress": "#ff7edb", + "btn_default": "#34294f", + "btn_hover": "#3e3460", + "btn_pressed": "#463465", + "success": "#72f1b8", + "success_bg": "#1a2828", + "btn_success": "#5cc898", + "btn_success_hover": "#50bc8c", + "btn_success_pressed": "#44b080", + "warning": "#fede5d", + "warning_bg": "#2a281a", + "error": "#fe4450", + "error_light": "#ff6670", + "error_bg": "#2a1a1e", + "btn_danger_bg": "#2a1a1e", + "btn_danger_hover": "#4a2a2e", + "btn_trash_hover": "#fe4450", + "btn_trash_pressed": "#de3440", + "bg_modal_section": "#262335", + "border_subtle": "#34294f", + "divider": "#34294f" + } + } + ] + }, + { + "key": "modus", + "order": 24, + "labelKey": "settings_theme_modus", + "label": { + "fr": "Modus", + "en": "Modus" + }, + "icon": "f06e", + "notes": null, + "variants": [ + { + "key": "operandi", + "slug": "modus-operandi", + "const": "MODUS_OPERANDI", + "labelKey": "settings_theme_modus_operandi", + "label": { + "fr": "Operandi", + "en": "Operandi" + }, + "mode": "light", + "swatch": { + "bg": "#ffffff", + "accent": "#0031a9" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 21.0, + "textMutedOnBgPrimary": 12.63 + }, + "palette": { + "bg_primary": "#ffffff", + "bg_sidebar": "#f0f0f0", + "bg_card": "#e0e0e0", + "bg_card_hover": "#d0d0d0", + "bg_card_pressed": "#c4c4c4", + "bg_selected": "#d0d0d0", + "bg_input": "#e0e0e0", + "bg_progress": "#f0f0f0", + "text_primary": "#000000", + "text_secondary": "#1a1a1a", + "text_muted": "#333333", + "text_dim": "#595959", + "text_dimmer": "#7f7f7f", + "text_dimmest": "#9f9f9f", + "text_placeholder": "#7f7f7f", + "accent_blue": "#0031a9", + "accent_icon": "#005e8b", + "accent_progress": "#0031a9", + "btn_default": "#e0e0e0", + "btn_hover": "#d0d0d0", + "btn_pressed": "#c4c4c4", + "success": "#006800", + "success_bg": "#d0f0d0", + "btn_success": "#006800", + "btn_success_hover": "#005800", + "btn_success_pressed": "#004800", + "warning": "#6f5500", + "warning_bg": "#f0e8c8", + "error": "#a60000", + "error_light": "#d00000", + "error_bg": "#f0c8c8", + "btn_danger_bg": "#f0c8c8", + "btn_danger_hover": "#e0b0b0", + "btn_trash_hover": "#a60000", + "btn_trash_pressed": "#860000", + "bg_modal_section": "#ffffff", + "border_subtle": "#d0d0d0", + "divider": "#d0d0d0" + } + }, + { + "key": "vivendi", + "slug": "modus-vivendi", + "const": "MODUS_VIVENDI", + "labelKey": "settings_theme_modus_vivendi", + "label": { + "fr": "Vivendi", + "en": "Vivendi" + }, + "mode": "dark", + "swatch": { + "bg": "#000000", + "accent": "#2fafff" + }, + "notes": null, + "contrast": { + "textPrimaryOnBgPrimary": 21.0, + "textMutedOnBgPrimary": 11.54 + }, + "palette": { + "bg_primary": "#000000", + "bg_sidebar": "#1e1e1e", + "bg_card": "#303030", + "bg_card_hover": "#404040", + "bg_card_pressed": "#535353", + "bg_selected": "#404040", + "bg_input": "#303030", + "bg_progress": "#1e1e1e", + "text_primary": "#ffffff", + "text_secondary": "#e0e0e0", + "text_muted": "#c0c0c0", + "text_dim": "#989898", + "text_dimmer": "#707070", + "text_dimmest": "#535353", + "text_placeholder": "#707070", + "accent_blue": "#2fafff", + "accent_icon": "#00d3d0", + "accent_progress": "#2fafff", + "btn_default": "#303030", + "btn_hover": "#404040", + "btn_pressed": "#535353", + "success": "#44bc44", + "success_bg": "#0a1a0a", + "btn_success": "#38a038", + "btn_success_hover": "#30942e", + "btn_success_pressed": "#288824", + "warning": "#d0bc00", + "warning_bg": "#1a1a00", + "error": "#ff5f59", + "error_light": "#ff7f86", + "error_bg": "#1a0808", + "btn_danger_bg": "#1a0808", + "btn_danger_hover": "#3a1818", + "btn_trash_hover": "#ff5f59", + "btn_trash_pressed": "#df4f48", + "bg_modal_section": "#000000", + "border_subtle": "#303030", + "divider": "#303030" + } + } + ] + }, + { + "key": "stellar_blade", + "order": 25, + "labelKey": "settings_theme_stellar_blade", + "label": { + "fr": "Stellar Blade", + "en": "Stellar Blade" + }, + "icon": "f04e5", + "notes": "Fan-made and unofficial. No official colour codes exist for these\ncharacters; every hex is derived from reference screenshots (hair, suit,\nglow, material). Palettes are not copyrightable and no game art ships\nwith this set. Adam and Roxanne are still pending reference images.\n", + "variants": [ + { + "key": "eve", + "slug": "stellar-blade-eve", + "const": "STELLAR_EVE", + "labelKey": "settings_theme_stellar_blade_eve", + "label": { + "fr": "EVE", + "en": "EVE" + }, + "mode": "light", + "swatch": { + "bg": "#eceeec", + "accent": "#21a04d" + }, + "notes": "Green ink on white-grey ceramic — her Planet Diving Suit. A light theme on\npurpose: a dark theme can never say \"white\".\n", + "contrast": { + "textPrimaryOnBgPrimary": 5.61, + "textMutedOnBgPrimary": 3.5 + }, + "palette": { + "bg_primary": "#eceeec", + "bg_sidebar": "#d3d7d3", + "bg_card": "#ffffff", + "bg_card_hover": "#e2e6e2", + "bg_card_pressed": "#d7dcd7", + "bg_selected": "#d9e4da", + "bg_input": "#e0e4e0", + "bg_progress": "#dee1de", + "text_primary": "#1a6b3b", + "text_secondary": "#2b7d4a", + "text_muted": "#4e8a63", + "text_dim": "#6fa082", + "text_dimmer": "#84ad94", + "text_dimmest": "#9abda7", + "text_placeholder": "#84ad94", + "accent_blue": "#21a04d", + "accent_icon": "#21a04d", + "accent_progress": "#21a04d", + "btn_default": "#dfe3df", + "btn_hover": "#d2d8d3", + "btn_pressed": "#c5cdc7", + "success": "#2f9e5c", + "success_bg": "#d3ecd9", + "btn_success": "#2f9e5c", + "btn_success_hover": "#288b50", + "btn_success_pressed": "#227a46", + "warning": "#c8861e", + "warning_bg": "#f0e6c8", + "error": "#d5493c", + "error_light": "#e05a4e", + "error_bg": "#f0cdc8", + "btn_danger_bg": "#f0cdc8", + "btn_danger_hover": "#e3b6b0", + "btn_trash_hover": "#c23a2e", + "btn_trash_pressed": "#a93227", + "bg_modal_section": "#e7eae7", + "border_subtle": "#c0c8c1", + "divider": "#c0c8c1" + } + }, + { + "key": "tachy", + "slug": "stellar-blade-tachy", + "const": "STELLAR_TACHY", + "labelKey": "settings_theme_stellar_blade_tachy", + "label": { + "fr": "Tachy", + "en": "Tachy" + }, + "mode": "dark", + "swatch": { + "bg": "#10121a", + "accent": "#ff7c1e" + }, + "notes": "Amber text and an orange accent on carbon-navy, her three suit tones. The\nteal glints of her suit take the success role.\n", + "contrast": { + "textPrimaryOnBgPrimary": 10.75, + "textMutedOnBgPrimary": 5.16 + }, + "palette": { + "bg_primary": "#10121a", + "bg_sidebar": "#0a0b10", + "bg_card": "#171a24", + "bg_card_hover": "#1e2230", + "bg_card_pressed": "#252a3a", + "bg_selected": "#202536", + "bg_input": "#191c28", + "bg_progress": "#0d0e15", + "text_primary": "#f2bc4e", + "text_secondary": "#d9a133", + "text_muted": "#a58136", + "text_dim": "#75612f", + "text_dimmer": "#5c4d28", + "text_dimmest": "#453a20", + "text_placeholder": "#5c4d28", + "accent_blue": "#ff7c1e", + "accent_icon": "#eaa72c", + "accent_progress": "#ff7c1e", + "btn_default": "#171a24", + "btn_hover": "#1e2230", + "btn_pressed": "#252a3a", + "success": "#3fbfa8", + "success_bg": "#0e2420", + "btn_success": "#2f9d8a", + "btn_success_hover": "#298c7b", + "btn_success_pressed": "#237b6c", + "warning": "#eaa72c", + "warning_bg": "#2a2210", + "error": "#f0503c", + "error_light": "#ff6a55", + "error_bg": "#2a120e", + "btn_danger_bg": "#2a120e", + "btn_danger_hover": "#41201a", + "btn_trash_hover": "#b8402f", + "btn_trash_pressed": "#96331f", + "bg_modal_section": "#141720", + "border_subtle": "#2b3040", + "divider": "#2b3040" + } + }, + { + "key": "lily", + "slug": "stellar-blade-lily", + "const": "STELLAR_LILY", + "labelKey": "settings_theme_stellar_blade_lily", + "label": { + "fr": "Lily", + "en": "Lily" + }, + "mode": "dark", + "swatch": { + "bg": "#111010", + "accent": "#ffd23e" + }, + "notes": "Strict gold-on-black duotone. No brown tint — the gold must stay gold.", + "contrast": { + "textPrimaryOnBgPrimary": 11.96, + "textMutedOnBgPrimary": 5.37 + }, + "palette": { + "bg_primary": "#111010", + "bg_sidebar": "#0a0909", + "bg_card": "#191817", + "bg_card_hover": "#201e1c", + "bg_card_pressed": "#262421", + "bg_selected": "#232120", + "bg_input": "#1a1918", + "bg_progress": "#0d0c0c", + "text_primary": "#f6c832", + "text_secondary": "#d4a92c", + "text_muted": "#a5842a", + "text_dim": "#7a6526", + "text_dimmer": "#5f4f21", + "text_dimmest": "#483c1b", + "text_placeholder": "#5f4f21", + "accent_blue": "#ffd23e", + "accent_icon": "#ffd23e", + "accent_progress": "#ffd23e", + "btn_default": "#191817", + "btn_hover": "#201e1c", + "btn_pressed": "#262421", + "success": "#5fb55a", + "success_bg": "#15200f", + "btn_success": "#4c9d48", + "btn_success_hover": "#428c3f", + "btn_success_pressed": "#397b36", + "warning": "#e08a2a", + "warning_bg": "#261c0c", + "error": "#e0503c", + "error_light": "#f0654f", + "error_bg": "#26100c", + "btn_danger_bg": "#26100c", + "btn_danger_hover": "#3c1d16", + "btn_trash_hover": "#b8402f", + "btn_trash_pressed": "#96331f", + "bg_modal_section": "#151413", + "border_subtle": "#2e2b26", + "divider": "#2e2b26" + } + }, + { + "key": "enya", + "slug": "stellar-blade-enya", + "const": "STELLAR_ENYA", + "labelKey": "settings_theme_stellar_blade_enya", + "label": { + "fr": "Enya", + "en": "Enya" + }, + "mode": "light", + "swatch": { + "bg": "#eef0f3", + "accent": "#3e90c9" + }, + "notes": "Light white ceramic with an ice-blue sheen for the accent.", + "contrast": { + "textPrimaryOnBgPrimary": 14.99, + "textMutedOnBgPrimary": 4.3 + }, + "palette": { + "bg_primary": "#eef0f3", + "bg_sidebar": "#d5dae1", + "bg_card": "#ffffff", + "bg_card_hover": "#e4e8ee", + "bg_card_pressed": "#d9dfe7", + "bg_selected": "#dce3ec", + "bg_input": "#e2e6ec", + "bg_progress": "#dfe3ea", + "text_primary": "#171c23", + "text_secondary": "#3f4a58", + "text_muted": "#66727f", + "text_dim": "#8d97a3", + "text_dimmer": "#a3adb8", + "text_dimmest": "#bac2cc", + "text_placeholder": "#a3adb8", + "accent_blue": "#3e90c9", + "accent_icon": "#3e90c9", + "accent_progress": "#3e90c9", + "btn_default": "#e1e5eb", + "btn_hover": "#d4dae2", + "btn_pressed": "#c8cfd9", + "success": "#2f9e5c", + "success_bg": "#d3ecd9", + "btn_success": "#2f9e5c", + "btn_success_hover": "#288b50", + "btn_success_pressed": "#227a46", + "warning": "#c8861e", + "warning_bg": "#f0e6c8", + "error": "#d5493c", + "error_light": "#e05a4e", + "error_bg": "#f0cdc8", + "btn_danger_bg": "#f0cdc8", + "btn_danger_hover": "#e3b6b0", + "btn_trash_hover": "#c23a2e", + "btn_trash_pressed": "#a93227", + "bg_modal_section": "#e9ecf0", + "border_subtle": "#c2cad4", + "divider": "#c2cad4" + } + }, + { + "key": "kaya", + "slug": "stellar-blade-kaya", + "const": "STELLAR_KAYA", + "labelKey": "settings_theme_stellar_blade_kaya", + "label": { + "fr": "Kaya", + "en": "Kaya" + }, + "mode": "dark", + "swatch": { + "bg": "#1a1622", + "accent": "#cfa8ff" + }, + "notes": "Lavender ink on deep violet. Her khaki coat is demoted to the success role\nrather than becoming a second surface — two saturated materials side by side\nclashed and were rejected.\n", + "contrast": { + "textPrimaryOnBgPrimary": 11.4, + "textMutedOnBgPrimary": 4.72 + }, + "palette": { + "bg_primary": "#1a1622", + "bg_sidebar": "#14111b", + "bg_card": "#221d2e", + "bg_card_hover": "#292338", + "bg_card_pressed": "#302a42", + "bg_selected": "#2c2540", + "bg_input": "#241f31", + "bg_progress": "#171320", + "text_primary": "#d9c8f0", + "text_secondary": "#b49ae0", + "text_muted": "#8d7bae", + "text_dim": "#6b5c88", + "text_dimmer": "#55486e", + "text_dimmest": "#423857", + "text_placeholder": "#55486e", + "accent_blue": "#cfa8ff", + "accent_icon": "#cfa8ff", + "accent_progress": "#cfa8ff", + "btn_default": "#221d2e", + "btn_hover": "#292338", + "btn_pressed": "#302a42", + "success": "#8a9455", + "success_bg": "#1d2012", + "btn_success": "#767f48", + "btn_success_hover": "#68703f", + "btn_success_pressed": "#5a6136", + "warning": "#d9a441", + "warning_bg": "#271f0e", + "error": "#e06a6a", + "error_light": "#ef8080", + "error_bg": "#291214", + "btn_danger_bg": "#291214", + "btn_danger_hover": "#3e1e21", + "btn_trash_hover": "#b84848", + "btn_trash_pressed": "#963a3a", + "bg_modal_section": "#1e1928", + "border_subtle": "#382f4c", + "divider": "#382f4c" + } + } + ] + }, + { + "key": "parchment", + "order": 26, + "labelKey": "settings_theme_parchment", + "label": { + "fr": "Parchemin", + "en": "Parchment" + }, + "icon": "f0f6", + "notes": "The parchment-and-burgundy look Colony Firewall Control shipped, and the one\nDNS-345 Colony Edition, Grape and SAM were written to match. It lived as eleven\nhand-written colour constants in cfc-ui; the eleven are the identity, the other\ntwenty-seven fields are derived from them by the rules in design/theming.md.\n", + "variants": [ + { + "key": "light", + "slug": "parchment-light", + "const": "PARCHMENT_LIGHT", + "labelKey": "settings_theme_light", + "label": { + "fr": "Mode clair", + "en": "Light mode" + }, + "mode": "light", + "swatch": { + "bg": "#f2e9d0", + "accent": "#801f2c" + }, + "notes": "Aged paper as the single surface material, dark sepia ink for every text step,\nand burgundy as the accent. The ink green becomes success and the amber becomes\nwarning, so both signature colours keep a role instead of being decoration.\n", + "contrast": { + "textPrimaryOnBgPrimary": 13.5, + "textMutedOnBgPrimary": 5.43 + }, + "palette": { + "bg_primary": "#f2e9d0", + "bg_sidebar": "#e6d9b6", + "bg_card": "#e6d9b6", + "bg_card_hover": "#ded0a8", + "bg_card_pressed": "#d9c69a", + "bg_selected": "#d9c69a", + "bg_input": "#e6d9b6", + "bg_progress": "#ded0a8", + "text_primary": "#2b1d0e", + "text_secondary": "#4a3a24", + "text_muted": "#6e5a3e", + "text_dim": "#8a7454", + "text_dimmer": "#b8996a", + "text_dimmest": "#c2aa7a", + "text_placeholder": "#b8996a", + "accent_blue": "#801f2c", + "accent_icon": "#801f2c", + "accent_progress": "#801f2c", + "btn_default": "#e6d9b6", + "btn_hover": "#ded0a8", + "btn_pressed": "#d9c69a", + "success": "#3c5a3b", + "success_bg": "#d9e3d4", + "btn_success": "#3c5a3b", + "btn_success_hover": "#33502f", + "btn_success_pressed": "#2a4526", + "warning": "#b87333", + "warning_bg": "#f0e0c8", + "error": "#801f2c", + "error_light": "#a83442", + "error_bg": "#f0d4d4", + "btn_danger_bg": "#f0d4d4", + "btn_danger_hover": "#e0b4b4", + "btn_trash_hover": "#801f2c", + "btn_trash_pressed": "#5a121c", + "bg_modal_section": "#ede2c4", + "border_subtle": "#c2aa7a", + "divider": "#c2aa7a" + } + } + ] + } + ] +} diff --git a/src/app.rs b/src/app.rs index 0ad8e21..fc92360 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,8 +15,8 @@ use sysinfo::{Disk, Disks, Networks, ProcessesToUpdate, System}; use crate::i18n::{self, tf}; use crate::preferences::{self, Preferences, Setting}; -use crate::terminal; use crate::ui::{self, Focus, Hit, Page, preferences::Row}; +use crate::{terminal, theme}; /// What a key and a click can both do. Each control sends the message its /// key sends, so the two can never disagree. @@ -29,6 +29,20 @@ pub enum Message { /// A row of the open category that takes focus: focus it, then open or /// close it (a section) or move it to its next value (a setting). Activate(usize), + /// A card of the theme picker, by position in themes.json. + Theme { family: usize, variant: usize }, + /// An accent swatch, by position in themes.json. + Accent(usize), +} + +/// A line the footer shows until the next key or click. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + /// colony-ui's `theme_applied` toast: the change was immediate, this + /// makes it noticeable. + ThemeApplied, + /// Something went wrong with the preferences file. + Problem(String), } pub struct App { @@ -42,9 +56,7 @@ pub struct App { pub prefs: Preferences, /// Where changes are saved; `None` when they cannot be. prefs_path: Option, - /// A message about the preferences file, shown until the next key or - /// click. - pub status: Option, + pub status: Option, /// The Preferences page, while it is open. pub page: Option, /// The mouse's cell, for hover. @@ -54,7 +66,7 @@ pub struct App { } impl App { - fn new(prefs: Preferences, prefs_path: Option, status: Option) -> Self { + fn new(prefs: Preferences, prefs_path: Option, status: Option) -> Self { let mut system = System::new_all(); system.refresh_all(); @@ -141,6 +153,14 @@ impl App { page.row = 0; } } + Message::Theme { family, variant } => { + self.prefs.set_theme(family, variant); + self.changed(Setting::Theme(family)); + } + Message::Accent(accent) => { + self.prefs.set_accent(accent); + self.changed(Setting::Accent); + } Message::Activate(index) => { let Some(page) = &mut self.page else { return }; page.focus = Focus::Content; @@ -158,16 +178,24 @@ impl App { } } - /// Applies a change at once and saves it: there is no Save button. fn change(&mut self, setting: Setting, by: isize) { self.prefs.step(setting, by); - if setting == Setting::Language { - i18n::set_locale(self.prefs.language.locale()); + self.changed(setting); + } + + /// Applies a change at once and saves it: there is no Save button. + fn changed(&mut self, setting: Setting) { + match setting { + Setting::Language => i18n::set_locale(self.prefs.language.locale()), + Setting::Theme(_) => self.status = Some(Status::ThemeApplied), + _ => {} } + theme::apply(&self.prefs); let Some(path) = &self.prefs_path else { return }; if let Err(err) = preferences::save(path, &self.prefs) { let error = err.to_string(); - self.status = Some(tf("preferences_save_failed", &[("error", &error)])); + let message = tf("preferences_save_failed", &[("error", &error)]); + self.status = Some(Status::Problem(message)); } } @@ -237,7 +265,20 @@ impl App { fn on_mouse(&mut self, mouse: MouseEvent) { let position = Position::new(mouse.column, mouse.row); self.mouse = Some(position); - if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + // The wheel moves through the open category's rows, as Up and Down + // do, and the page scrolls with the focus. + let by = match mouse.kind { + MouseEventKind::ScrollDown => 1, + MouseEventKind::ScrollUp => -1, + MouseEventKind::Down(MouseButton::Left) => 0, + _ => return, + }; + if by != 0 { + if let Some(page) = &mut self.page { + let last = focusable_rows(&self.prefs, page).len().saturating_sub(1); + page.focus = Focus::Content; + page.row = page.row.saturating_add_signed(by).min(last); + } return; } self.status = None; @@ -266,8 +307,9 @@ pub fn run_app( prefs_path: Option, status: Option, ) -> Result<(), Box> { + theme::apply(&prefs); let mut terminal = terminal::enable()?; - let mut app = App::new(prefs, prefs_path, status); + let mut app = App::new(prefs, prefs_path, status.map(Status::Problem)); // Checked after every poll, which waits one refresh interval at most. while quit_signal.load(Ordering::SeqCst) == 0 { @@ -421,6 +463,31 @@ mod tests { assert!(app.page.is_none()); } + #[test] + fn the_wheel_moves_through_the_rows() { + let mut app = App::empty(); + let wheel = |kind| MouseEvent { + kind, + column: 0, + row: 0, + modifiers: crossterm::event::KeyModifiers::NONE, + }; + app.on_mouse(wheel(MouseEventKind::ScrollDown)); + assert!(app.page.is_none(), "nothing to scroll on the monitor"); + app.update(Message::TogglePreferences); + // General: two closed sections. + app.on_mouse(wheel(MouseEventKind::ScrollDown)); + app.on_mouse(wheel(MouseEventKind::ScrollDown)); + let page = app.page.as_ref().unwrap(); + assert_eq!( + (page.focus, page.row), + (Focus::Content, 1), + "stops at the last" + ); + app.on_mouse(wheel(MouseEventKind::ScrollUp)); + assert_eq!(app.page.as_ref().unwrap().row, 0); + } + #[test] fn network_rate_is_per_second() { assert_eq!(per_second(2048, Duration::from_millis(500)), 4096.0); diff --git a/src/i18n/en.rs b/src/i18n/en.rs index c295d6e..fb2f64f 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -41,9 +41,10 @@ Keys: p opens and closes Preferences, q or Ctrl+C quits. ("megabytes", "{value} MB"), ("percent", "{value}%"), ("monitor_hint", "p: Preferences q or Ctrl+C: quit"), - // The Preferences page. The settings_* keys and their strings are - // colony-ui's (colony_ui::i18n), copied exactly: every Colony program - // says the same thing in the same words. + // The Preferences page. The settings_* keys, theme_applied and their + // strings are colony-ui's (colony_ui::i18n), copied exactly: every Colony + // program says the same thing in the same words. Theme and accent names + // are not here: they come from config/themes.json. ("settings_title", "Preferences"), ("settings_close", "Close"), ("settings_cat_general", "General"), @@ -62,6 +63,48 @@ Keys: p opens and closes Preferences, q or Ctrl+C quits. "Make the interface easier to see, read and use.", ), ("settings_cat_about", "About"), + ("settings_section_theme", "Theme"), + ( + "settings_theme_desc", + "Applies immediately to the whole interface.", + ), + ("settings_section_colors", "Colors & accents"), + ( + "settings_colors_desc", + "Pick an accent, or keep the one the theme was designed with.", + ), + ("settings_auto_accent", "Auto accent from background"), + ( + "settings_auto_accent_desc", + "Uses the theme's own accent instead of the one picked above.", + ), + ("settings_default_accent", "Theme accent"), + ("settings_section_preview", "Preview"), + ( + "settings_preview_desc", + "A live sample of your current choices.", + ), + ("settings_preview_card", "Preview card"), + ("settings_preview_body", "Body text looks like this."), + ("settings_preview_button", "Button"), + ("settings_section_vision", "Vision"), + ( + "settings_vision_desc", + "Options that make the interface easier to see.", + ), + ("settings_high_contrast", "High contrast"), + ( + "settings_high_contrast_desc", + "Increases the contrast of text and borders.", + ), + ("settings_section_motion", "Motion"), + ("settings_motion_desc", "Calm the interface down."), + ("settings_reduce_motion", "Reduce motion"), + ( + "settings_reduce_motion_desc", + "Turns off every animation and transition.", + ), + ("theme_applied", "Theme applied."), // Collector's own. ( "preferences_hint", @@ -103,4 +146,23 @@ Keys: p opens and closes Preferences, q or Ctrl+C quits. "preferences_save_failed", "Could not save the preferences: {error}", ), + ("preferences_accent", "Accent"), + ("preferences_on", "On"), + ("preferences_off", "Off"), + ( + "preferences_appearance_note", + "Typography and Effects are not here: the terminal sets the font and its size, and Collector has no visual effects.", + ), + ( + "preferences_font_note", + "The font is the terminal's: for a dyslexia-friendly one, such as OpenDyslexic Mono, pick it in the terminal's settings.", + ), + ( + "preferences_motion_note", + "Nothing in Collector is animated: only the readings change, at the refresh interval. Anything animated added later will follow this setting.", + ), + ( + "preferences_reading_note", + "Reading is not here: text size is the terminal's. Zoom with its settings or its keyboard shortcut.", + ), ]; diff --git a/src/i18n/fr.rs b/src/i18n/fr.rs index 399dde3..bd217e8 100644 --- a/src/i18n/fr.rs +++ b/src/i18n/fr.rs @@ -59,6 +59,51 @@ Touches : p ouvre et ferme les Préférences, q ou Ctrl+C quitte. "Rendez l'interface plus facile à voir, à lire et à utiliser.", ), ("settings_cat_about", "À propos"), + ("settings_section_theme", "Thème"), + ( + "settings_theme_desc", + "S'applique immédiatement à toute l'interface.", + ), + ("settings_section_colors", "Couleurs & accents"), + ( + "settings_colors_desc", + "Choisissez un accent, ou gardez celui du thème.", + ), + ("settings_auto_accent", "Accent automatique selon le fond"), + ( + "settings_auto_accent_desc", + "Utilise l'accent du thème plutôt que celui choisi ci-dessus.", + ), + ("settings_default_accent", "Accent du thème"), + ("settings_section_preview", "Aperçu"), + ( + "settings_preview_desc", + "Un aperçu en direct de vos choix actuels.", + ), + ("settings_preview_card", "Carte d'aperçu"), + ( + "settings_preview_body", + "Le texte courant ressemble à ceci.", + ), + ("settings_preview_button", "Bouton"), + ("settings_section_vision", "Vision"), + ( + "settings_vision_desc", + "Options pour rendre l'interface plus lisible.", + ), + ("settings_high_contrast", "Contraste élevé"), + ( + "settings_high_contrast_desc", + "Augmente le contraste du texte et des bordures.", + ), + ("settings_section_motion", "Mouvement"), + ("settings_motion_desc", "Apaisez l'interface."), + ("settings_reduce_motion", "Réduire les animations"), + ( + "settings_reduce_motion_desc", + "Désactive toutes les animations et transitions.", + ), + ("theme_applied", "Thème appliqué."), ( "preferences_hint", "↑↓ Déplacer Tab Colonne ←→ Modifier Entrée Ouvrir Échap Fermer", @@ -99,4 +144,23 @@ Touches : p ouvre et ferme les Préférences, q ou Ctrl+C quitte. "preferences_save_failed", "Impossible d'enregistrer les préférences : {error}", ), + ("preferences_accent", "Accent"), + ("preferences_on", "Activé"), + ("preferences_off", "Désactivé"), + ( + "preferences_appearance_note", + "La typographie et les effets ne sont pas ici : le terminal choisit la police et sa taille, et Collector n'a pas d'effets visuels.", + ), + ( + "preferences_font_note", + "La police est celle du terminal : pour une police adaptée à la dyslexie, comme OpenDyslexic Mono, choisissez-la dans ses réglages.", + ), + ( + "preferences_motion_note", + "Rien n'est animé dans Collector : seuls les relevés changent, à l'intervalle d'actualisation. Toute animation ajoutée plus tard suivra ce réglage.", + ), + ( + "preferences_reading_note", + "La lecture n'est pas ici : la taille du texte est celle du terminal. Zoomez dans ses réglages ou avec son raccourci clavier.", + ), ]; diff --git a/src/main.rs b/src/main.rs index 5fda1d2..c7d459e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod i18n; mod paths; mod preferences; mod terminal; +mod theme; mod ui; use std::env; diff --git a/src/preferences.rs b/src/preferences.rs index e65faf5..f2a9880 100644 --- a/src/preferences.rs +++ b/src/preferences.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; use crate::i18n::{Locale, t, tf}; -use crate::paths; +use crate::{paths, theme}; /// The theme a fresh install starts on, and the one an unknown theme falls /// back to, as in colony-ui. @@ -59,8 +59,9 @@ pub struct Preferences { // The fields of colony_ui::preferences::StandardPreferences that mean // something in a terminal, under its names and in its JSON forms, so the // file reads like every other Colony program's. The font, text size and - // animation fields are left out: the terminal owns the font, and - // Collector has no animation to switch off. + // animations fields are left out: the terminal owns the font, and + // Collector has no effects for Appearance to switch off. Reduce motion + // stays, as Accessibility's Motion setting. pub selected_theme: String, pub selected_variant: String, /// An accent key such as `"violet"`, or `None` for the theme's own. @@ -118,13 +119,64 @@ impl Preferences { } /// Moves `setting` `by` places through its choices, wrapping around at - /// either end. + /// either end. A toggle flips whichever way it is moved. pub fn step(&mut self, setting: Setting, by: isize) { match setting { Setting::Language => self.language = cycle(&Language::ALL, self.language, by), Setting::RefreshInterval => { self.refresh_interval = cycle(&REFRESH_INTERVALS, self.refresh_interval, by); } + // Through the family's variants. Another family's row starts on + // its first variant going forwards, its last going backwards. + Setting::Theme(family) => { + let Some(family_meta) = theme::catalog().families.get(family) else { + return; + }; + let count = family_meta.variants.len() as isize; + let current = family_meta.variants.iter().position(|variant| { + self.selected_theme == family_meta.key && self.selected_variant == variant.key + }); + let variant = match current { + Some(at) => (at as isize + by).rem_euclid(count), + None if by < 0 => count - 1, + None => 0, + }; + self.set_theme(family, variant as usize); + } + // The theme's own accent, then each shared one. + Setting::Accent => { + let accents = &theme::catalog().accents; + let choices: Vec> = std::iter::once(None) + .chain(accents.iter().map(|accent| Some(accent.key.as_str()))) + .collect(); + let current = theme::picked_accent(self).map(|accent| accent.key.as_str()); + self.selected_accent = cycle(&choices, current, by).map(str::to_owned); + self.auto_accent = false; + } + Setting::AutoAccent => self.auto_accent = !self.auto_accent, + Setting::HighContrast => self.high_contrast = !self.high_contrast, + Setting::ReduceMotion => self.reduce_motion = !self.reduce_motion, + } + } + + /// The theme picker's cards: `variant` of `family`, by position in + /// themes.json. Out of range does nothing. + pub fn set_theme(&mut self, family: usize, variant: usize) { + let families = &theme::catalog().families; + if let Some(family) = families.get(family) + && let Some(variant) = family.variants.get(variant) + { + self.selected_theme.clone_from(&family.key); + self.selected_variant.clone_from(&variant.key); + } + } + + /// An accent swatch, by position in themes.json. Picking one turns auto + /// accent off, as in colony-ui. + pub fn set_accent(&mut self, accent: usize) { + if let Some(accent) = theme::catalog().accents.get(accent) { + self.selected_accent = Some(accent.key.clone()); + self.auto_accent = false; } } @@ -151,6 +203,12 @@ impl Preferences { pub enum Setting { Language, RefreshInterval, + /// A family of the theme picker, by position in themes.json. + Theme(usize), + Accent, + AutoAccent, + HighContrast, + ReduceMotion, } /// The choice `by` places from `current`, wrapping around. A current value @@ -302,6 +360,59 @@ mod tests { assert_eq!(p.refresh_interval, 5.0); } + #[test] + fn theme_and_accent_rows_pick_from_themes_json() { + let position = |key| { + let families = &theme::catalog().families; + families.iter().position(|f| f.key == key).unwrap() + }; + let theme = |p: &Preferences| (p.selected_theme.clone(), p.selected_variant.clone()); + let mut p = Preferences::default(); + p.step(Setting::Theme(position("catppuccin")), 1); + assert_eq!(theme(&p), ("catppuccin".into(), "latte".into())); + p.step(Setting::Theme(position("catppuccin")), -1); + assert_eq!(p.selected_variant, "mocha", "wraps around backwards"); + p.step(Setting::Theme(position("gruvbox")), -1); + assert_eq!( + theme(&p), + ("gruvbox".into(), "dark".into()), + "starts on the last" + ); + p.set_theme(usize::MAX, 0); + assert_eq!(theme(&p), ("gruvbox".into(), "dark".into()), "out of range"); + + p.step(Setting::Accent, 1); + assert_eq!(p.selected_accent.as_deref(), Some("red")); + p.step(Setting::Accent, -2); + assert_eq!(p.selected_accent.as_deref(), Some("amber"), "wraps around"); + p.auto_accent = true; + p.step(Setting::Accent, 1); + assert_eq!( + p.selected_accent.as_deref(), + Some("red"), + "from the theme's own" + ); + assert!(!p.auto_accent, "picking an accent turns auto accent off"); + p.step(Setting::Accent, -1); + assert_eq!(p.selected_accent, None, "the theme's own again"); + p.set_accent(6); + assert_eq!(p.selected_accent.as_deref(), Some("violet")); + + for (setting, flag) in [ + ( + Setting::AutoAccent, + (|p: &Preferences| p.auto_accent) as fn(&Preferences) -> bool, + ), + (Setting::HighContrast, |p| p.high_contrast), + (Setting::ReduceMotion, |p| p.reduce_motion), + ] { + p.step(setting, -1); + assert!(flag(&p), "{setting:?} turns on"); + p.step(setting, 1); + assert!(!flag(&p), "{setting:?} turns off"); + } + } + #[test] fn it_round_trips_through_the_file() { let dir = TempDir::new("round-trip"); diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..0511246 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,530 @@ +//! The Colony themes and accents, read from `config/themes.json`: a +//! byte-for-byte copy of Project-Colony-Resources' `generated/themes.json`, +//! never edited here (CI fails when it falls behind Resources). +//! +//! colony-ui reads the same tokens, but it is built on iced, which a terminal +//! program cannot carry (see src/paths.rs). So the parts a terminal needs are +//! ported from its theme.rs: the (family, variant) resolver with its Gruvbox +//! dark fallback, the high-contrast derivation and `contrast_on`. +//! +//! Every colour Collector draws comes from here: no other file names one. + +use std::sync::{OnceLock, PoisonError, RwLock}; + +use ratatui::style::Color; +use serde_json::Value; + +use crate::i18n::{self, Locale}; +use crate::preferences::{DEFAULT_THEME, Preferences}; + +const THEMES_JSON: &str = include_str!("../config/themes.json"); + +/// A name in English and French, as themes.json carries it. +#[derive(Debug)] +pub struct Label { + en: String, + fr: String, +} + +impl Label { + /// The name in the current locale. + pub fn text(&self) -> &str { + match i18n::locale() { + Locale::En => &self.en, + Locale::Fr => &self.fr, + } + } + + fn from_json(label: &Value) -> Option { + let text = |locale| label.get(locale)?.as_str().map(str::to_owned); + Some(Self { + en: text("en")?, + fr: text("fr")?, + }) + } +} + +macro_rules! palette { + ($($field:ident),* $(,)?) => { + /// The palette fields Collector draws with, under their themes.json + /// names. The rest of a theme has nothing to colour in a terminal. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct Palette { + $(pub $field: Color,)* + /// The accent in effect: the one picked in Preferences, or the + /// theme's own (`accent_blue`). + pub accent: Color, + /// Legible text on `accent`. + pub on_accent: Color, + } + + impl Palette { + fn from_json(palette: &Value) -> Option { + let color = |field| hex(palette.get(field)?); + let accent = color("accent_blue")?; + Some(Self { + $($field: color(stringify!($field))?,)* + accent, + on_accent: contrast_on(accent), + }) + } + + fn map(self, f: impl Fn(Color) -> Color) -> Self { + Self { + $($field: f(self.$field),)* + accent: f(self.accent), + on_accent: f(self.on_accent), + } + } + } + }; +} + +palette!( + bg_primary, + bg_card_hover, + bg_selected, + bg_progress, + text_primary, + text_secondary, + text_muted, + text_dim, + success, + success_bg, + warning, + border_subtle, + divider, +); + +impl Palette { + /// colony-ui's `ThemePalette::with_high_contrast`, same amounts: text and + /// borders move away from the background, which itself stays. + fn with_high_contrast(mut self) -> Self { + let light = is_light(self.bg_primary); + // Darkened on a light background, brightened on a dark one. + let push = |color, on_light: f32, on_dark: f32| { + if light { + shift(color, -on_light) + } else { + shift(color, on_dark) + } + }; + self.text_primary = push(self.text_primary, 0.15, 0.12); + self.text_secondary = push(self.text_secondary, 0.12, 0.10); + self.text_muted = push(self.text_muted, 0.10, 0.10); + self.text_dim = push(self.text_dim, 0.10, 0.08); + self.border_subtle = push(self.border_subtle, 0.15, 0.12); + self.divider = push(self.divider, 0.15, 0.12); + self + } + + fn with_accent(self, accent: Color) -> Self { + Self { + accent, + on_accent: contrast_on(accent), + ..self + } + } +} + +pub struct Variant { + pub key: String, + pub label: Label, + /// The picker card: the variant's background, crossed by its accent. + pub swatch_bg: Color, + pub swatch_accent: Color, + palette: Palette, +} + +pub struct Family { + pub key: String, + pub label: Label, + pub variants: Vec, +} + +/// One of the shared accents a user can pick over the theme's own. +pub struct Accent { + pub key: String, + pub label: Label, + pub color: Color, +} + +pub struct Catalog { + /// In the order themes.json lists them, which is the pickers' order. + pub families: Vec, + pub accents: Vec, +} + +/// Every family, variant and accent in themes.json, read once. +pub fn catalog() -> &'static Catalog { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| parse(THEMES_JSON)) +} + +/// An entry with a missing or malformed field is left out rather than +/// failing: the tests hold the shipped file to its own counts. +fn parse(text: &str) -> Catalog { + let json: Value = serde_json::from_str(text).unwrap_or_default(); + let list = |key| { + json.get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + }; + let key = |value: &Value| value.get("key")?.as_str().map(str::to_owned); + let label = |value: &Value| Label::from_json(value.get("label")?); + + let variant = |value: &Value| { + let swatch = value.get("swatch")?; + Some(Variant { + key: key(value)?, + label: label(value)?, + swatch_bg: hex(swatch.get("bg")?)?, + swatch_accent: hex(swatch.get("accent")?)?, + palette: Palette::from_json(value.get("palette")?)?, + }) + }; + let families = list("families") + .filter_map(|value| { + Some(Family { + key: key(value)?, + label: label(value)?, + variants: value + .get("variants")? + .as_array()? + .iter() + .filter_map(variant) + .collect(), + }) + }) + .collect(); + let accents = list("accents") + .filter_map(|value| { + Some(Accent { + key: key(value)?, + label: label(value)?, + color: hex(value.get("color")?)?, + }) + }) + .collect(); + Catalog { families, accents } +} + +/// `"#rrggbb"`, the only form themes.json writes. +fn hex(value: &Value) -> Option { + let digits = value.as_str()?.strip_prefix('#')?; + if digits.len() != 6 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let rgb = u32::from_str_radix(digits, 16).ok()?; + let [_, r, g, b] = rgb.to_be_bytes(); + Some(Color::Rgb(r, g, b)) +} + +/// The palette of `family`/`variant`, or Gruvbox dark when either is unknown: +/// an old file, a hand-edited one or a removed family degrades instead of +/// failing. +pub fn resolve(family: &str, variant: &str) -> Palette { + let find = |family: &str, variant: &str| { + catalog() + .families + .iter() + .find(|f| f.key == family)? + .variants + .iter() + .find(|v| v.key == variant) + .map(|v| v.palette) + }; + find(family, variant) + .or_else(|| find(DEFAULT_THEME.0, DEFAULT_THEME.1)) + .expect("themes.json has Gruvbox dark") +} + +/// The accent the user picked, or `None` for the theme's own: while auto +/// accent is on, when none was picked, or when the key is not a shipped +/// accent. +pub fn picked_accent(prefs: &Preferences) -> Option<&'static Accent> { + if prefs.auto_accent { + return None; + } + let key = prefs.selected_accent.as_deref()?; + catalog().accents.iter().find(|accent| accent.key == key) +} + +/// What every widget draws with, as `prefs` asks for it. +fn palette_for(prefs: &Preferences, truecolor: bool) -> Palette { + let mut palette = resolve(&prefs.selected_theme, &prefs.selected_variant); + if prefs.high_contrast { + palette = palette.with_high_contrast(); + } + if let Some(accent) = picked_accent(prefs) { + palette = palette.with_accent(accent.color); + } + if truecolor { + palette + } else { + palette.map(nearest_256) + } +} + +static ACTIVE: RwLock> = RwLock::new(None); + +/// Applies the theme, accent and contrast in `prefs`, as colony-ui's +/// `StandardPreferences::apply` does: every frame drawn after this uses them. +pub fn apply(prefs: &Preferences) { + let palette = palette_for(prefs, truecolor()); + *ACTIVE.write().unwrap_or_else(PoisonError::into_inner) = Some(palette); +} + +/// The palette in effect: the defaults until [`apply`] is called. +pub fn active() -> Palette { + let active = *ACTIVE.read().unwrap_or_else(PoisonError::into_inner); + active.unwrap_or_else(|| palette_for(&Preferences::default(), truecolor())) +} + +/// `color` as this terminal can show it. +pub fn paint(color: Color) -> Color { + if truecolor() { + color + } else { + nearest_256(color) + } +} + +/// Whether the terminal says it shows 24-bit colour. Windows Terminal does +/// but does not say so in COLORTERM, so its own variable counts too. +fn truecolor() -> bool { + static TRUECOLOR: OnceLock = OnceLock::new(); + *TRUECOLOR.get_or_init(|| { + std::env::var_os("WT_SESSION").is_some() + || std::env::var("COLORTERM") + .is_ok_and(|value| value == "truecolor" || value == "24bit") + }) +} + +/// The closest of the xterm 256 colours that is not one of the first 16, +/// which every terminal redefines: the 6x6x6 cube or the 24-step grey ramp. +fn nearest_256(color: Color) -> Color { + let Color::Rgb(r, g, b) = color else { + return color; + }; + const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255]; + let level = |c: u8| (0..6).min_by_key(|&i| LEVELS[i].abs_diff(c)).unwrap_or(0); + let (ri, gi, bi) = (level(r), level(g), level(b)); + let average = (u16::from(r) + u16::from(g) + u16::from(b)) / 3; + let grey = (average.saturating_sub(3) / 10).min(23) as u8; + let distance = |(cr, cg, cb): (u8, u8, u8)| { + [(r, cr), (g, cg), (b, cb)] + .map(|(a, b)| u32::from(a.abs_diff(b)).pow(2)) + .iter() + .sum::() + }; + let grey_value = 8 + 10 * grey; + let cube = (LEVELS[ri], LEVELS[gi], LEVELS[bi]); + if distance((grey_value, grey_value, grey_value)) < distance(cube) { + Color::Indexed(232 + grey) + } else { + Color::Indexed(16 + 36 * ri as u8 + 6 * gi as u8 + bi as u8) + } +} + +/// `color` moved `amount` (-1 to 1) on each channel, as colony-ui moves its +/// float colours, then rounded back to a byte. +fn shift(color: Color, amount: f32) -> Color { + let Color::Rgb(r, g, b) = color else { + return color; + }; + let channel = |c: u8| { + let c = f32::from(c) / 255.0; + let moved = if amount < 0.0 { + (c + amount).max(0.0) + } else { + (c + amount).min(1.0) + }; + (moved * 255.0).round() as u8 + }; + Color::Rgb(channel(r), channel(g), channel(b)) +} + +fn channels(color: Color) -> [f32; 3] { + match color { + Color::Rgb(r, g, b) => [r, g, b].map(|c| f32::from(c) / 255.0), + _ => [0.0; 3], + } +} + +/// colony-ui's `ColorExt::is_light`: YIQ luma over one half. +fn is_light(color: Color) -> bool { + let [r, g, b] = channels(color); + 0.299 * r + 0.587 * g + 0.114 * b > 0.5 +} + +/// colony-ui's `contrast_on`: near-black or near-white, whichever reads +/// better on `background` by WCAG contrast. +pub fn contrast_on(background: Color) -> Color { + // colony-ui's INK (0.08, 0.08, 0.10) and PAPER (0.97, 0.98, 1.0). + const INK: Color = Color::Rgb(20, 20, 26); + const PAPER: Color = Color::Rgb(247, 250, 255); + if contrast_ratio(INK, background) >= contrast_ratio(PAPER, background) { + INK + } else { + PAPER + } +} + +/// WCAG contrast ratio, 1 to 21. +fn contrast_ratio(a: Color, b: Color) -> f32 { + let luminance = |color| { + let [r, g, b] = channels(color).map(|v| { + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }); + 0.2126 * r + 0.7152 * g + 0.0722 * b + }; + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use super::*; + + #[test] + fn every_family_variant_and_accent_loads_with_both_labels() { + let json: Value = serde_json::from_str(THEMES_JSON).unwrap(); + let catalog = catalog(); + let count = |key| json[key].as_u64().unwrap() as usize; + assert_eq!(catalog.families.len(), count("familyCount")); + let variants: usize = catalog.families.iter().map(|f| f.variants.len()).sum(); + assert_eq!(variants, count("variantCount"), "a variant was skipped"); + assert_eq!(catalog.accents.len(), 8); + + let labels = catalog.families.iter().flat_map(|family| { + std::iter::once(&family.label).chain(family.variants.iter().map(|v| &v.label)) + }); + for label in labels.chain(catalog.accents.iter().map(|a| &a.label)) { + assert!(!label.en.trim().is_empty(), "{label:?}"); + assert!(!label.fr.trim().is_empty(), "{label:?}"); + } + } + + #[test] + fn an_unknown_theme_falls_back_to_gruvbox_dark() { + let gruvbox_dark = resolve("gruvbox", "dark"); + assert_eq!(gruvbox_dark.bg_primary, Color::Rgb(0x28, 0x28, 0x28)); + assert_eq!(resolve("no_such_family", "dark"), gruvbox_dark); + assert_eq!(resolve("gruvbox", "no_such_variant"), gruvbox_dark); + assert_ne!(resolve("catppuccin", "latte"), gruvbox_dark); + + let prefs = Preferences { + selected_theme: "removed".into(), + ..Preferences::default() + }; + assert_eq!(palette_for(&prefs, true), gruvbox_dark); + } + + /// colony-ui's float arithmetic, rounded to the nearest byte. Worked out + /// by hand from the gruvbox tokens, not by running this code. + #[test] + fn high_contrast_moves_text_and_borders_as_colony_ui_does() { + let light = resolve("gruvbox", "light").with_high_contrast(); + assert_eq!(light.text_primary, Color::Rgb(22, 18, 16)); + assert_eq!(light.text_secondary, Color::Rgb(49, 42, 38)); + assert_eq!(light.text_muted, Color::Rgb(77, 67, 59)); + assert_eq!(light.text_dim, Color::Rgb(99, 86, 75)); + assert_eq!(light.border_subtle, Color::Rgb(175, 158, 123)); + assert_eq!(light.divider, Color::Rgb(175, 158, 123)); + + let dark = resolve("gruvbox", "dark").with_high_contrast(); + assert_eq!(dark.text_primary, Color::Rgb(255, 250, 209)); + assert_eq!(dark.text_secondary, Color::Rgb(239, 222, 187)); + assert_eq!(dark.text_muted, Color::Rgb(215, 200, 173)); + assert_eq!(dark.text_dim, Color::Rgb(188, 173, 152)); + assert_eq!(dark.border_subtle, Color::Rgb(91, 87, 85)); + + // The surface itself never moves. + assert_eq!(dark.bg_primary, resolve("gruvbox", "dark").bg_primary); + } + + #[test] + fn a_picked_accent_wins_unless_auto_accent_is_on() { + let theme_accent = resolve("gruvbox", "dark").accent; + let mut prefs = Preferences { + selected_accent: Some("violet".into()), + ..Preferences::default() + }; + let violet = palette_for(&prefs, true); + assert_eq!(violet.accent, Color::Rgb(0xb0, 0x6b, 0xd6)); + assert_eq!(violet.on_accent, contrast_on(violet.accent)); + + prefs.auto_accent = true; + assert_eq!(palette_for(&prefs, true).accent, theme_accent); + prefs.auto_accent = false; + prefs.selected_accent = Some("chartreuse".into()); + assert_eq!(palette_for(&prefs, true).accent, theme_accent); + } + + #[test] + fn text_on_a_fill_is_whichever_end_reads_better() { + assert_eq!( + contrast_on(Color::Rgb(255, 255, 255)), + Color::Rgb(20, 20, 26) + ); + assert_eq!(contrast_on(Color::Rgb(0, 0, 0)), Color::Rgb(247, 250, 255)); + // colony-ui's own example: green takes ink, not paper. + assert_eq!( + contrast_on(Color::Rgb(0x55, 0xb8, 0x7a)), + Color::Rgb(20, 20, 26) + ); + } + + #[test] + fn colours_map_to_the_nearest_of_the_256() { + assert_eq!(nearest_256(Color::Rgb(0, 0, 0)), Color::Indexed(16)); + assert_eq!(nearest_256(Color::Rgb(255, 255, 255)), Color::Indexed(231)); + assert_eq!(nearest_256(Color::Rgb(255, 0, 0)), Color::Indexed(196)); + // Greys land on the ramp, which is finer than the cube's diagonal. + assert_eq!(nearest_256(Color::Rgb(128, 128, 128)), Color::Indexed(244)); + assert_eq!( + nearest_256(Color::Rgb(0x28, 0x28, 0x28)), + Color::Indexed(235) + ); + // Gruvbox's accent, blue-grey: the cube. + assert_eq!( + nearest_256(Color::Rgb(0x83, 0xa5, 0x98)), + Color::Indexed(108) + ); + assert_eq!(nearest_256(Color::Indexed(3)), Color::Indexed(3)); + + let mapped = palette_for(&Preferences::default(), false); + assert!(matches!(mapped.bg_primary, Color::Indexed(_))); + assert!(matches!(mapped.on_accent, Color::Indexed(_))); + } + + /// Every colour comes from the palette (design/settings-page.md): no + /// other source file spells one out. + #[test] + fn no_colour_is_written_outside_this_file() { + let mut dirs = vec![PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))]; + let mut found = Vec::new(); + while let Some(dir) = dirs.pop() { + for entry in fs::read_dir(&dir).expect("read src") { + let path = entry.expect("read src").path(); + if path.is_dir() { + dirs.push(path); + } else if !path.ends_with("theme.rs") + && fs::read_to_string(&path).expect("read").contains("Color::") + { + found.push(path.display().to_string()); + } + } + } + assert!(found.is_empty(), "colours outside src/theme.rs: {found:?}"); + } +} diff --git a/src/ui/cpu_memory.rs b/src/ui/cpu_memory.rs index d2fa7c1..1eeecdd 100644 --- a/src/ui/cpu_memory.rs +++ b/src/ui/cpu_memory.rs @@ -1,8 +1,8 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, Gauge}; +use ratatui::widgets::Gauge; +use super::style; use crate::app::App; use crate::i18n::{number, t, tf}; @@ -16,8 +16,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let cpu_percent = gauge_percent(f64::from(app.system.global_cpu_usage())); let cpu_gauge = Gauge::default() - .block(Block::default().title(t("cpu")).borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Cyan)) + .block(style::block(t("cpu"))) + .gauge_style(style::gauge()) .percent(cpu_percent) .label(tf("percent", &[("value", &cpu_percent.to_string())])); frame.render_widget(cpu_gauge, layout[0]); @@ -31,8 +31,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { }; let memory_gauge = Gauge::default() - .block(Block::default().title(t("memory")).borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Magenta)) + .block(style::block(t("memory"))) + .gauge_style(style::gauge()) .percent(gauge_percent(memory_percent)) .label(tf( "used_of_total_gb", diff --git a/src/ui/disks.rs b/src/ui/disks.rs index a049fff..3079cce 100644 --- a/src/ui/disks.rs +++ b/src/ui/disks.rs @@ -1,9 +1,9 @@ use ratatui::Frame; use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; +use super::style; use crate::app::App; use crate::i18n::{number, t, tf}; @@ -20,10 +20,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { let available = disk.available_space() as f64; let used = total - available; Line::from(vec![ - Span::styled( - format!("{} ", mount_point.display()), - Style::default().fg(Color::Yellow), - ), + Span::styled(format!("{} ", mount_point.display()), style::accent()), Span::raw(tf( "used_of_total_gb", &[ @@ -35,7 +32,6 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { }) .collect(); - let disk_block = - Paragraph::new(disk_lines).block(Block::default().title(t("disks")).borders(Borders::ALL)); + let disk_block = Paragraph::new(disk_lines).block(style::block(t("disks"))); frame.render_widget(disk_block, area); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e452560..7c356a1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,9 +11,9 @@ mod style; use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::text::Span; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Block, Paragraph}; -use crate::app::{App, Message}; +use crate::app::{App, Message, Status}; use crate::i18n::t; use crate::paths; @@ -30,6 +30,7 @@ pub struct Hit { /// Draws the screen and returns what can be clicked on it. pub fn render_ui(frame: &mut Frame, app: &App) -> Vec { let mut hits = Vec::new(); + frame.render_widget(Block::new().style(style::base()), frame.area()); let [header, body, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), @@ -46,7 +47,7 @@ pub fn render_ui(frame: &mut Frame, app: &App) -> Vec { ..header }; let open = app.page.is_some(); - let style = style::item(open, hovered(app, button)).patch(style::title()); + let style = style::identity(open, hovered(app, button)); frame.render_widget(Paragraph::new(name).style(style), button); hits.push(Hit { area: button, @@ -58,12 +59,19 @@ pub fn render_ui(frame: &mut Frame, app: &App) -> Vec { None => render_monitor(frame, app, body), } - // Full width, where the French hints fit too. A status message about the - // preferences file takes the line until the next key or click. + // Full width, where the French hints fit too. A status takes the line + // until the next key or click. let footer_text = match (&app.status, open) { - (Some(status), _) => Paragraph::new(status.as_str()), - (None, false) => Paragraph::new(t("monitor_hint")).style(style::muted()), - (None, true) => Paragraph::new(t("preferences_hint")).style(style::muted()), + (Some(Status::ThemeApplied), _) => { + // As wide as the words, like a toast, not the whole line. + let toast = Span::styled(format!(" {} ", t("theme_applied")), style::toast()); + Paragraph::new(toast) + } + (Some(Status::Problem(problem)), _) => { + Paragraph::new(problem.as_str()).style(style::warning()) + } + (None, false) => Paragraph::new(t("monitor_hint")).style(style::hint()), + (None, true) => Paragraph::new(t("preferences_hint")).style(style::hint()), }; frame.render_widget(footer_text, footer); hits @@ -106,6 +114,7 @@ mod tests { use super::*; use crate::i18n::{self, Locale, number, tf}; + use crate::theme; /// The buffer as text, one line per row. fn screen_text(buffer: &Buffer) -> String { @@ -120,7 +129,11 @@ mod tests { /// Draws `app` at 80x24, the smallest usual terminal size. fn draw(app: &App) -> (String, Vec) { - let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + draw_at(app, 24) + } + + fn draw_at(app: &App, height: u16) -> (String, Vec) { + let mut terminal = Terminal::new(TestBackend::new(80, height)).expect("test terminal"); let mut hits = Vec::new(); terminal .draw(|frame| hits = render_ui(frame, app)) @@ -170,18 +183,21 @@ mod tests { } } - /// Each category with every section open: the page header, every - /// category name, every heading, section and setting are drawn whole in - /// both locales. + /// Each category with every section open, on a screen tall enough not to + /// scroll: the page header, every category name, every heading, section, + /// setting, theme card and note are drawn whole in 80 columns in both + /// locales. #[test] - fn the_preferences_page_fits_at_80x24_in_both_locales() { + fn the_preferences_page_fits_80_columns_in_both_locales() { for locale in [Locale::En, Locale::Fr] { for category in 0..preferences::CATEGORIES.len() { let mut app = App::empty(); app.update(Message::TogglePreferences); app.update(Message::Category(category)); let page = app.page.as_mut().expect("open"); - page.expanded.extend(["language", "refresh"]); + page.expanded.extend([ + "language", "refresh", "theme", "colors", "preview", "vision", "motion", + ]); let (screen, expected) = i18n::with_locale(locale, || { let page = app.page.as_ref().expect("open"); @@ -202,16 +218,79 @@ mod tests { preferences::Row::Note(text) => { expected.extend(text.split(' ').map(str::to_owned)); } + preferences::Row::Cards(family) => { + let family = &theme::catalog().families[family]; + let names = family.variants.iter().map(|v| v.label.text()); + expected.extend(names.map(str::to_owned)); + } + preferences::Row::Preview => expected.extend( + ["settings_preview_card", "settings_preview_button"] + .map(|key| t(key).to_owned()), + ), _ => {} } } - (draw(&app).0, expected) + (draw_at(&app, 120).0, expected) }); assert_shown(&screen, locale, &expected); } } } + /// The theme list is longer than the screen: the focused family scrolls + /// into view with its cards, and a click on a card applies it. + #[test] + fn the_last_theme_scrolls_into_view_and_its_cards_are_clickable() { + let families = &theme::catalog().families; + let last = families.last().expect("a family"); + let mut app = App::empty(); + app.update(Message::TogglePreferences); + app.update(Message::Category(1)); + let page = app.page.as_mut().expect("open"); + page.expanded.insert("theme"); + page.focus = Focus::Content; + // The section header, then one row per family. + page.row = families.len(); + + let (screen, hits, names) = i18n::with_locale(Locale::En, || { + let names: Vec<&str> = std::iter::once(&last.label) + .chain(last.variants.iter().map(|variant| &variant.label)) + .map(|label| label.text()) + .collect(); + let (screen, hits) = draw(&app); + (screen, hits, names) + }); + for name in names { + assert!(screen.contains(name), "{name:?} is off screen:\n{screen}"); + } + let card = Message::Theme { + family: families.len() - 1, + variant: 0, + }; + let hit = hits.iter().find(|hit| hit.message == card).expect("a card"); + app.update(hit.message); + assert_eq!(app.prefs.selected_theme, last.key); + assert_eq!(app.prefs.selected_variant, last.variants[0].key); + assert_eq!(app.status, Some(Status::ThemeApplied), "the toast"); + } + + #[test] + fn a_swatch_click_picks_its_accent() { + let mut app = App::empty(); + app.update(Message::TogglePreferences); + app.update(Message::Category(1)); + app.page.as_mut().expect("open").expanded.insert("colors"); + app.prefs.auto_accent = true; + let (_, hits) = i18n::with_locale(Locale::En, || draw(&app)); + let hit = hits + .iter() + .find(|hit| hit.message == Message::Accent(6)) + .expect("the violet swatch"); + app.update(hit.message); + assert_eq!(app.prefs.selected_accent.as_deref(), Some("violet")); + assert!(!app.prefs.auto_accent); + } + /// The identity button and the page's Close button send the same /// message, and that message closes what it opened. #[test] diff --git a/src/ui/network.rs b/src/ui/network.rs index 0ddb304..6d2a6ed 100644 --- a/src/ui/network.rs +++ b/src/ui/network.rs @@ -1,8 +1,9 @@ use ratatui::Frame; use ratatui::layout::Rect; use ratatui::text::Line; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::Paragraph; +use super::style; use crate::app::App; use crate::i18n::{number, t, tf}; @@ -12,7 +13,6 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { Line::from(tf("rx_rate", &[("rate", &number(rx / 1024.0, 1))])), Line::from(tf("tx_rate", &[("rate", &number(tx / 1024.0, 1))])), ]; - let net_block = - Paragraph::new(net_lines).block(Block::default().title(t("network")).borders(Borders::ALL)); + let net_block = Paragraph::new(net_lines).block(style::block(t("network"))); frame.render_widget(net_block, area); } diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs index f1cdabf..91acede 100644 --- a/src/ui/preferences.rs +++ b/src/ui/preferences.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; @@ -14,6 +15,7 @@ use super::{Hit, hovered, style}; use crate::app::{App, Message}; use crate::i18n::{number, t, tf}; use crate::preferences::{self, Language, Preferences, Setting}; +use crate::theme; /// colony-ui's `STANDARD_CATEGORIES` in their fixed order, then its /// `ABOUT_CATEGORY`, last. @@ -36,6 +38,11 @@ const DESCRIPTIONS: [&str; 4] = [ /// needed. const COLLAPSED: &str = "►"; const EXPANDED: &str = "▼"; +/// The mark on the current theme card and accent swatch. Not WGL4, but in +/// every common monospace font, and a terminal falls back for the others. +const CHECK: &str = "✓"; +/// Where the cards and swatches start: under their row's title. +const INDENT: &str = " "; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Focus { @@ -78,6 +85,13 @@ pub enum Row { Text(String), /// A blank line after an open section. Gap, + /// A theme family's variant cards, under its row. Each card is a click + /// target of its own. + Cards(usize), + /// The accent swatches, under the accent row, clicked one by one too. + Swatches, + /// A live sample of the current theme and accent. + Preview, } impl Row { @@ -144,12 +158,106 @@ pub fn rows(prefs: &Preferences, page: &Page) -> Vec { Err(err) => err.to_string(), })); } - // Appearance and Accessibility: their heading and description only. + 1 => { + section(&mut rows, page, "theme", "settings_section_theme", || { + let mut rows = vec![Row::Note(t("settings_theme_desc").to_owned())]; + // Without the family's glyph: it is a Nerd Font one, which + // the terminal's font may not have. + for (index, family) in theme::catalog().families.iter().enumerate() { + let current = family.variants.iter().find(|variant| { + family.key == prefs.selected_theme && variant.key == prefs.selected_variant + }); + rows.push(Row::Setting { + setting: Setting::Theme(index), + title: family.label.text(), + value: current.map_or_else(String::new, |v| v.label.text().to_owned()), + }); + rows.push(Row::Cards(index)); + } + rows + }); + section(&mut rows, page, "colors", "settings_section_colors", || { + let accent = theme::picked_accent(prefs) + .map_or(t("settings_default_accent"), |accent| accent.label.text()); + vec![ + Row::Note(t("settings_colors_desc").to_owned()), + Row::Setting { + setting: Setting::Accent, + title: t("preferences_accent"), + value: accent.to_owned(), + }, + Row::Swatches, + Row::Setting { + setting: Setting::AutoAccent, + title: t("settings_auto_accent"), + value: on_off(prefs.auto_accent), + }, + Row::Note(t("settings_auto_accent_desc").to_owned()), + ] + }); + section( + &mut rows, + page, + "preview", + "settings_section_preview", + || { + vec![ + Row::Note(t("settings_preview_desc").to_owned()), + Row::Preview, + ] + }, + ); + closing_note(&mut rows, "preferences_appearance_note"); + } + 2 => { + section(&mut rows, page, "vision", "settings_section_vision", || { + vec![ + Row::Note(t("settings_vision_desc").to_owned()), + Row::Setting { + setting: Setting::HighContrast, + title: t("settings_high_contrast"), + value: on_off(prefs.high_contrast), + }, + Row::Note(t("settings_high_contrast_desc").to_owned()), + Row::Note(t("preferences_font_note").to_owned()), + ] + }); + section(&mut rows, page, "motion", "settings_section_motion", || { + vec![ + Row::Note(t("settings_motion_desc").to_owned()), + Row::Setting { + setting: Setting::ReduceMotion, + title: t("settings_reduce_motion"), + value: on_off(prefs.reduce_motion), + }, + Row::Note(t("settings_reduce_motion_desc").to_owned()), + Row::Note(t("preferences_motion_note").to_owned()), + ] + }); + closing_note(&mut rows, "preferences_reading_note"); + } _ => {} } rows } +/// What a category leaves out and why, a blank line below its sections. +fn closing_note(rows: &mut Vec, key: &'static str) { + if !matches!(rows.last(), Some(Row::Gap)) { + rows.push(Row::Gap); + } + rows.push(Row::Note(t(key).to_owned())); +} + +fn on_off(on: bool) -> String { + t(if on { + "preferences_on" + } else { + "preferences_off" + }) + .to_owned() +} + /// A section header, and its content when it is open. fn section( rows: &mut Vec, @@ -197,10 +305,8 @@ pub fn render(frame: &mut Frame, app: &App, page: &Page, area: Rect, hits: &mut Paragraph::new(t("settings_title")).style(style::title()), title, ); - frame.render_widget( - Paragraph::new(close).style(style::item(false, hovered(app, close_area))), - close_area, - ); + let close_style = style::muted().patch(style::item(false, hovered(app, close_area))); + frame.render_widget(Paragraph::new(close).style(close_style), close_area); hits.push(Hit { area: close_area, message: Message::TogglePreferences, @@ -220,7 +326,9 @@ pub fn render(frame: &mut Frame, app: &App, page: &Page, area: Rect, hits: &mut .spacing(1) .areas(body); - let block = Block::default().borders(Borders::RIGHT); + let block = Block::default() + .borders(Borders::RIGHT) + .border_style(style::divider()); let inner = block.inner(list); frame.render_widget(block, list); for (index, (label, y)) in labels.iter().zip(inner.y..inner.bottom()).enumerate() { @@ -242,13 +350,15 @@ pub fn render(frame: &mut Frame, app: &App, page: &Page, area: Rect, hits: &mut fn render_content(frame: &mut Frame, app: &App, page: &Page, area: Rect, hits: &mut Vec) { let width = usize::from(area.width); + let prefs = &app.prefs; let mut lines = vec![Line::styled(t(CATEGORIES[page.category]), style::title())]; lines.extend(muted_lines(t(DESCRIPTIONS[page.category]), width)); lines.push(Line::default()); // (line, index among the rows that take focus) let mut targets = Vec::new(); - for row in rows(&app.prefs, page) { + let mut picks = Vec::new(); + for row in rows(prefs, page) { if row.takes_focus() { targets.push((lines.len(), targets.len())); } @@ -263,48 +373,217 @@ fn render_content(frame: &mut Frame, app: &App, page: &Page, area: Rect, hits: & width, )); } - Row::Setting { title, value, .. } => lines.push(spread( - Span::raw(format!(" {title}")), - Span::raw(format!("‹ {value} ›")), - width, - )), + Row::Setting { title, value, .. } => { + let title = Span::raw(format!(" {title}")); + lines.push(if value.is_empty() { + Line::from(title) + } else { + let value = Span::styled(format!("‹ {value} ›"), style::secondary()); + spread(title, value, width) + }); + } Row::Note(text) => lines.extend(muted_lines(&text, width)), Row::Text(text) => lines.extend(wrap(&text, width).into_iter().map(Line::from)), Row::Gap => lines.push(Line::default()), + Row::Cards(family) => cards(prefs, family, width, &mut lines, &mut picks), + Row::Swatches => swatches(prefs, &mut lines, &mut picks), + Row::Preview => lines.extend(preview(prefs)), } } - // Scrolled just enough to keep the focused row on screen. + // Scrolled just enough to keep the focused row on screen, with what + // belongs to it up to the next row (its cards, its description) when + // that fits too. let focused = |index: usize| page.focus == Focus::Content && index == page.row; - let focused_line = targets + let (focused_line, end) = targets .iter() - .find(|&&(_, index)| focused(index)) - .map_or(0, |&(line, _)| line); - let offset = (focused_line + 1).saturating_sub(usize::from(area.height)); - - for (line, index) in targets { - let Some(y) = line - .checked_sub(offset) + .position(|&(_, index)| focused(index)) + .map_or((0, 0), |at| { + let next = targets.get(at + 1).map_or(lines.len(), |&(line, _)| line); + (targets[at].0, next) + }); + let offset = end + .saturating_sub(usize::from(area.height)) + .min(focused_line); + let screen_row = |line: usize| { + line.checked_sub(offset) .and_then(|row| u16::try_from(row).ok()) .filter(|&row| row < area.height) - else { + .map(|row| area.y + row) + }; + + for (line, index) in targets { + let Some(y) = screen_row(line) else { continue; }; let target = Rect { - y: area.y + y, + y, height: 1, ..area }; - lines[line].style = style::item(focused(index), hovered(app, target)); + // Over the spans' own colours too, so selected text always sits on + // the accent in the accent's own legible colour. + let style = style::item(focused(index), hovered(app, target)); + let line = &mut lines[line]; + line.style = line.style.patch(style); + for span in &mut line.spans { + span.style = span.style.patch(style); + } hits.push(Hit { area: target, message: Message::Activate(index), }); } + for pick in picks { + let Some(y) = screen_row(pick.line) else { + continue; + }; + let x = area + .x + .saturating_add(u16::try_from(pick.x).unwrap_or(u16::MAX)); + let width = u16::try_from(pick.width).unwrap_or(u16::MAX); + let target = Rect::new(x, y, width, 1).intersection(area); + if !target.is_empty() { + hits.push(Hit { + area: target, + message: pick.message, + }); + } + } frame.render_widget(Paragraph::new(lines.split_off(offset)), area); } +/// A click target inside a line: a theme card or an accent swatch. +struct Pick { + line: usize, + /// Columns from the left of the content. + x: usize, + width: usize, + message: Message, +} + +/// A family's variant cards, as many to a line as fit. A card is the +/// variant's background with a bar of its accent, then its name, and a check +/// on the current one, as colony-ui's theme picker draws them. +fn cards( + prefs: &Preferences, + family: usize, + width: usize, + lines: &mut Vec>, + picks: &mut Vec, +) { + let Some(meta) = theme::catalog().families.get(family) else { + return; + }; + let mut line = Line::from(INDENT); + for (index, variant) in meta.variants.iter().enumerate() { + let current = meta.key == prefs.selected_theme && variant.key == prefs.selected_variant; + let mark = if current { CHECK } else { " " }; + let name = format!("{} {mark} ", variant.label.text()); + let card = 1 + Span::raw(name.as_str()).width(); + if line.width() > INDENT.len() { + if line.width() + 1 + card > width { + lines.push(std::mem::replace(&mut line, Line::from(INDENT))); + } else { + line.push_span(" "); + } + } + picks.push(Pick { + line: lines.len(), + x: line.width(), + width: card, + message: Message::Theme { + family, + variant: index, + }, + }); + // Painted from the variant's own colours, not the active theme's: + // each card shows what it would look like. + let background = theme::paint(variant.swatch_bg); + let on = |color| Style::new().fg(theme::paint(color)).bg(background); + line.push_span(Span::styled("▌", on(variant.swatch_accent))); + line.push_span(Span::styled( + name, + on(theme::contrast_on(variant.swatch_bg)), + )); + } + lines.push(line); +} + +/// The shared accents as filled circles, a check after the picked one. +fn swatches(prefs: &Preferences, lines: &mut Vec>, picks: &mut Vec) { + let picked = theme::picked_accent(prefs).map(|accent| accent.key.as_str()); + let mut line = Line::from(INDENT); + for (index, accent) in theme::catalog().accents.iter().enumerate() { + picks.push(Pick { + line: lines.len(), + x: line.width(), + width: 2, + message: Message::Accent(index), + }); + let dot = Style::new().fg(theme::paint(accent.color)); + line.push_span(Span::styled("●", dot)); + line.push_span(if picked == Some(accent.key.as_str()) { + CHECK + } else { + " " + }); + line.push_span(" "); + } + lines.push(line); +} + +/// colony-ui's Preview card in cells: a title, body text and the theme and +/// accent names down the text ramp, then a gauge and a button in the accent. +fn preview(prefs: &Preferences) -> Vec> { + let family = theme::catalog() + .families + .iter() + .find(|family| family.key == prefs.selected_theme); + let theme_name = family.map_or_else( + || prefs.selected_theme.clone(), + |family| { + let variant = family + .variants + .iter() + .find(|variant| variant.key == prefs.selected_variant) + .map_or("", |variant| variant.label.text()); + format!("{} · {variant}", family.label.text()) + }, + ); + let accent = theme::picked_accent(prefs) + .map_or(t("settings_default_accent"), |accent| accent.label.text()); + let card = |content: Vec>| { + let mut line = Line::from(Span::styled("│ ", style::divider())); + line.spans.extend(content); + line + }; + vec![ + card(vec![Span::styled( + t("settings_preview_card"), + style::title(), + )]), + card(vec![Span::styled( + t("settings_preview_body"), + style::secondary(), + )]), + card(vec![Span::styled( + format!("{theme_name} · {accent}"), + style::muted(), + )]), + card(vec![ + Span::styled("█".repeat(12), style::gauge()), + Span::styled(" ".repeat(8), style::gauge()), + Span::raw(" "), + Span::styled( + format!(" {} ", t("settings_preview_button")), + style::item(true, false), + ), + ]), + ] +} + /// `left` at the start of the line and `right` at its end. fn spread<'a>(left: Span<'a>, right: Span<'a>, width: usize) -> Line<'a> { let gap = width.saturating_sub(left.width() + right.width()).max(1); diff --git a/src/ui/processes.rs b/src/ui/processes.rs index c157f9f..12c27a2 100644 --- a/src/ui/processes.rs +++ b/src/ui/processes.rs @@ -1,8 +1,8 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Rect}; -use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, Row, Table}; +use ratatui::widgets::{Row, Table}; +use super::style; use crate::app::App; use crate::i18n::{number, t, tf}; @@ -37,15 +37,8 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) { Constraint::Percentage(25), ], ) - .header( - Row::new(vec![t("process"), t("cpu"), t("memory")]) - .style(Style::default().fg(Color::Green)), - ) - .block( - Block::default() - .title(t("top_processes")) - .borders(Borders::ALL), - ); + .header(Row::new(vec![t("process"), t("cpu"), t("memory")]).style(style::secondary())) + .block(style::block(t("top_processes"))); frame.render_widget(table, area); } diff --git a/src/ui/style.rs b/src/ui/style.rs index 6b986f8..269d188 100644 --- a/src/ui/style.rs +++ b/src/ui/style.rs @@ -1,26 +1,100 @@ //! The styles every drawing function picks from, by role rather than by -//! colour. +//! colour. The colours are the active theme's (src/theme.rs). use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::Block; -/// Page and category headings, section titles. +use crate::theme; + +/// The whole screen: the theme's background and main text colour. Every +/// other style is drawn over it. +pub fn base() -> Style { + let palette = theme::active(); + Style::new().fg(palette.text_primary).bg(palette.bg_primary) +} + +/// Page and category headings, section and box titles. pub fn title() -> Style { - Style::default().add_modifier(Modifier::BOLD) + Style::new() + .fg(theme::active().text_primary) + .add_modifier(Modifier::BOLD) } -/// Descriptions and hints: read once, then out of the way. +/// A setting's value, and anything one step below the main text. +pub fn secondary() -> Style { + Style::new().fg(theme::active().text_secondary) +} + +/// Descriptions: read once, then out of the way. pub fn muted() -> Style { - Style::default().add_modifier(Modifier::DIM) + Style::new().fg(theme::active().text_muted) +} + +/// The footer's key hints, a step further down. +pub fn hint() -> Style { + Style::new().fg(theme::active().text_dim) +} + +/// Mount points: the accent, without a background. +pub fn accent() -> Style { + Style::new().fg(theme::active().accent) } -/// Something that can be clicked or focused: selected stands out by its -/// background, hovered by a mark of its own, distinct from selected. +/// A gauge: filled in the accent over the progress track. +pub fn gauge() -> Style { + let palette = theme::active(); + Style::new().fg(palette.accent).bg(palette.bg_progress) +} + +/// Something that can be clicked or focused. Selected sits on the accent, as +/// colony-ui's `selection_style` draws it; hovered gets the hover background, +/// distinct from selected. pub fn item(selected: bool, hovered: bool) -> Style { + let palette = theme::active(); if selected { - Style::default().add_modifier(Modifier::REVERSED) + Style::new().fg(palette.on_accent).bg(palette.accent) + } else if hovered { + Style::new().bg(palette.bg_card_hover) + } else { + Style::new() + } +} + +/// The identity button: the program's name in the accent, on the selected +/// background while Preferences is open, as colony-ui's `identity_button`. +pub fn identity(open: bool, hovered: bool) -> Style { + let palette = theme::active(); + let style = Style::new().fg(palette.accent).add_modifier(Modifier::BOLD); + if open { + style.bg(palette.bg_selected) } else if hovered { - Style::default().add_modifier(Modifier::UNDERLINED) + style.bg(palette.bg_card_hover) } else { - Style::default() + style } } + +/// The line between the category list and the page's content. +pub fn divider() -> Style { + Style::new().fg(theme::active().divider) +} + +/// The toast that confirms a change, as colony-ui draws it: `success` on +/// `success_bg`. +pub fn toast() -> Style { + let palette = theme::active(); + Style::new().fg(palette.success).bg(palette.success_bg) +} + +/// A problem with the preferences file. +pub fn warning() -> Style { + Style::new().fg(theme::active().warning) +} + +/// A monitor box: a subtle border around the content, its title on top. +pub fn block(title: &str) -> Block<'_> { + Block::bordered() + .border_style(Style::new().fg(theme::active().border_subtle)) + .title(Span::styled(title, self::title())) +} From 6e28068fe9fae4fbdc887067696d12f77e4fc785 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 18:22:05 +0200 Subject: [PATCH 4/8] docs: give the binary size the theme catalog and locale reading brought --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3594c1b..8452b2f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ locally, over SSH or inside tmux. installs, updates and checks. - **In the terminal you already have open**: locally, over SSH (`ssh -t`) or inside tmux, where a desktop task manager cannot follow. -- **Small and quiet**: a single binary of about 1 MB on Linux, with no network +- **Small and quiet**: a single binary of about 1.3 MB on Linux, with no network connection and no file written except its own preferences (see [Privacy](#privacy)). From fad3c4e99231818708eb549d1f4e3d29f8d4e49d Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 18:23:07 +0200 Subject: [PATCH 5/8] test: hold the shared Preferences strings to the labels themes.json now carries --- config/themes.json | 238 ++++++++++++++++++++++++++++++++++++++++++++- src/i18n/en.rs | 5 +- src/i18n/mod.rs | 18 ++++ 3 files changed, 257 insertions(+), 4 deletions(-) diff --git a/config/themes.json b/config/themes.json index 88f868a..94113e4 100644 --- a/config/themes.json +++ b/config/themes.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "source": "https://github.com/Project-Colony/Project-Colony-Resources", - "note": "@generated by colony-tokens from tokens/families/*.toml — DO NOT EDIT.", + "note": "@generated by colony-tokens from tokens/. DO NOT EDIT.", "familyCount": 26, "variantCount": 59, "fields": [ @@ -4157,5 +4157,239 @@ } ] } - ] + ], + "labels": { + "en": { + "settings_theme_catppuccin": "Catppuccin", + "settings_theme_catppuccin_latte": "Latte", + "settings_theme_catppuccin_frappe": "Frappé", + "settings_theme_catppuccin_macchiato": "Macchiato", + "settings_theme_catppuccin_mocha": "Mocha", + "settings_theme_gruvbox": "Gruvbox", + "settings_theme_light": "Light mode", + "settings_theme_dark_mode": "Dark mode", + "settings_theme_everblush": "Everblush", + "settings_theme_kanagawa": "Kanagawa", + "settings_theme_kanagawa_journal": "Journal mode", + "settings_theme_kanagawa_dragon": "Dragon mode", + "settings_theme_nord": "Nord", + "settings_theme_dracula": "Dracula", + "settings_theme_solarized": "Solarized", + "settings_theme_tokyonight": "Tokyo Night", + "settings_theme_tokyonight_night": "Night", + "settings_theme_tokyonight_day": "Day", + "settings_theme_rosepine": "Rosé Pine", + "settings_theme_rosepine_main": "Main", + "settings_theme_rosepine_moon": "Moon", + "settings_theme_rosepine_dawn": "Dawn", + "settings_theme_onedark": "One Dark", + "settings_theme_monokai": "Monokai Pro", + "settings_theme_monokai_pro": "Pro", + "settings_theme_monokai_classic": "Classic", + "settings_theme_monokai_spectrum": "Spectrum", + "settings_theme_ayu": "Ayu", + "settings_theme_ayu_mirage": "Mirage", + "settings_theme_everforest": "Everforest", + "settings_theme_material": "Material", + "settings_theme_material_oceanic": "Oceanic", + "settings_theme_material_palenight": "Palenight", + "settings_theme_material_deepocean": "Deep Ocean", + "settings_theme_flexoki": "Flexoki", + "settings_theme_nightfox": "Nightfox", + "settings_theme_nightfox_nightfox": "Nightfox", + "settings_theme_nightfox_dawnfox": "Dawnfox", + "settings_theme_sonokai": "Sonokai", + "settings_theme_sonokai_default": "Default", + "settings_theme_oxocarbon": "Oxocarbon", + "settings_theme_nightowl": "Night Owl", + "settings_theme_iceberg": "Iceberg", + "settings_theme_horizon": "Horizon", + "settings_theme_melange": "Melange", + "settings_theme_synthwave": "Synthwave '84", + "settings_theme_modus": "Modus", + "settings_theme_modus_operandi": "Operandi", + "settings_theme_modus_vivendi": "Vivendi", + "settings_theme_stellar_blade": "Stellar Blade", + "settings_theme_stellar_blade_eve": "EVE", + "settings_theme_stellar_blade_tachy": "Tachy", + "settings_theme_stellar_blade_lily": "Lily", + "settings_theme_stellar_blade_enya": "Enya", + "settings_theme_stellar_blade_kaya": "Kaya", + "settings_theme_parchment": "Parchment", + "settings_accent_red": "Red", + "settings_accent_orange": "Orange", + "settings_accent_yellow": "Yellow", + "settings_accent_green": "Green", + "settings_accent_blue": "Blue", + "settings_accent_indigo": "Indigo", + "settings_accent_violet": "Violet", + "settings_accent_amber": "Amber", + "settings_title": "Preferences", + "settings_close": "Close", + "theme_applied": "Theme applied.", + "settings_cat_general": "General", + "settings_general_desc": "Preferences are saved automatically.", + "settings_cat_appearance": "Appearance", + "settings_appearance_desc": "Adjust the theme, accents, text and visual effects.", + "settings_cat_accessibility": "Accessibility", + "settings_accessibility_desc": "Make the interface easier to see, read and use.", + "settings_cat_about": "About", + "settings_section_theme": "Theme", + "settings_theme_desc": "Applies immediately to the whole interface.", + "settings_section_colors": "Colors & accents", + "settings_colors_desc": "Pick an accent, or keep the one the theme was designed with.", + "settings_auto_accent": "Auto accent from background", + "settings_auto_accent_desc": "Uses the theme's own accent instead of the one picked above.", + "settings_default_accent": "Theme accent", + "settings_section_typography": "Typography", + "settings_typography_desc": "The interface font and its base size.", + "settings_font_family": "Font", + "settings_font_family_desc": "Switches to OpenDyslexic when the dyslexia-friendly font is on.", + "settings_font_size": "Text size", + "settings_font_size_desc": "Base size of every text in the interface.", + "settings_font_size_small": "Small", + "settings_font_size_default": "Default", + "settings_font_size_large": "Large", + "settings_font_size_xlarge": "Extra large", + "settings_section_effects": "Effects", + "settings_effects_desc": "Visual extras. Always off while Reduce motion is on.", + "settings_animations": "Animations", + "settings_animations_desc": "Animated transitions and fades.", + "settings_section_preview": "Preview", + "settings_preview_desc": "A live sample of your current choices.", + "settings_preview_card": "Preview card", + "settings_preview_body": "Body text looks like this.", + "settings_preview_button": "Button", + "settings_section_vision": "Vision", + "settings_vision_desc": "Options that make the interface easier to see.", + "settings_high_contrast": "High contrast", + "settings_high_contrast_desc": "Increases the contrast of text and borders.", + "settings_dyslexia_font": "Dyslexia-friendly font", + "settings_dyslexia_font_desc": "Replaces the interface font with OpenDyslexic.", + "settings_section_motion": "Motion", + "settings_motion_desc": "Calm the interface down.", + "settings_reduce_motion": "Reduce motion", + "settings_reduce_motion_desc": "Turns off every animation and transition.", + "settings_section_reading": "Reading", + "settings_reading_desc": "Reading comfort options.", + "settings_text_size_a11y": "Text size", + "settings_text_size_a11y_desc": "Scales all text. Multiplies with the size chosen in Appearance." + }, + "fr": { + "settings_theme_catppuccin": "Catppuccin", + "settings_theme_catppuccin_latte": "Latte", + "settings_theme_catppuccin_frappe": "Frappé", + "settings_theme_catppuccin_macchiato": "Macchiato", + "settings_theme_catppuccin_mocha": "Mocha", + "settings_theme_gruvbox": "Gruvbox", + "settings_theme_light": "Mode clair", + "settings_theme_dark_mode": "Mode sombre", + "settings_theme_everblush": "Everblush", + "settings_theme_kanagawa": "Kanagawa", + "settings_theme_kanagawa_journal": "Mode journal", + "settings_theme_kanagawa_dragon": "Mode dragon", + "settings_theme_nord": "Nord", + "settings_theme_dracula": "Dracula", + "settings_theme_solarized": "Solarized", + "settings_theme_tokyonight": "Tokyo Night", + "settings_theme_tokyonight_night": "Nuit", + "settings_theme_tokyonight_day": "Jour", + "settings_theme_rosepine": "Rosé Pine", + "settings_theme_rosepine_main": "Principal", + "settings_theme_rosepine_moon": "Lune", + "settings_theme_rosepine_dawn": "Aurore", + "settings_theme_onedark": "One Dark", + "settings_theme_monokai": "Monokai Pro", + "settings_theme_monokai_pro": "Pro", + "settings_theme_monokai_classic": "Classic", + "settings_theme_monokai_spectrum": "Spectrum", + "settings_theme_ayu": "Ayu", + "settings_theme_ayu_mirage": "Mirage", + "settings_theme_everforest": "Everforest", + "settings_theme_material": "Material", + "settings_theme_material_oceanic": "Oceanic", + "settings_theme_material_palenight": "Palenight", + "settings_theme_material_deepocean": "Deep Ocean", + "settings_theme_flexoki": "Flexoki", + "settings_theme_nightfox": "Nightfox", + "settings_theme_nightfox_nightfox": "Nightfox", + "settings_theme_nightfox_dawnfox": "Dawnfox", + "settings_theme_sonokai": "Sonokai", + "settings_theme_sonokai_default": "Défaut", + "settings_theme_oxocarbon": "Oxocarbon", + "settings_theme_nightowl": "Night Owl", + "settings_theme_iceberg": "Iceberg", + "settings_theme_horizon": "Horizon", + "settings_theme_melange": "Mélange", + "settings_theme_synthwave": "Synthwave '84", + "settings_theme_modus": "Modus", + "settings_theme_modus_operandi": "Operandi", + "settings_theme_modus_vivendi": "Vivendi", + "settings_theme_stellar_blade": "Stellar Blade", + "settings_theme_stellar_blade_eve": "EVE", + "settings_theme_stellar_blade_tachy": "Tachy", + "settings_theme_stellar_blade_lily": "Lily", + "settings_theme_stellar_blade_enya": "Enya", + "settings_theme_stellar_blade_kaya": "Kaya", + "settings_theme_parchment": "Parchemin", + "settings_accent_red": "Rouge", + "settings_accent_orange": "Orange", + "settings_accent_yellow": "Jaune", + "settings_accent_green": "Vert", + "settings_accent_blue": "Bleu", + "settings_accent_indigo": "Indigo", + "settings_accent_violet": "Violet", + "settings_accent_amber": "Ambre", + "settings_title": "Préférences", + "settings_close": "Fermer", + "theme_applied": "Thème appliqué.", + "settings_cat_general": "Général", + "settings_general_desc": "Les préférences sont enregistrées automatiquement.", + "settings_cat_appearance": "Apparence", + "settings_appearance_desc": "Ajustez le thème, les accents, le texte et les effets visuels.", + "settings_cat_accessibility": "Accessibilité", + "settings_accessibility_desc": "Rendez l'interface plus facile à voir, à lire et à utiliser.", + "settings_cat_about": "À propos", + "settings_section_theme": "Thème", + "settings_theme_desc": "S'applique immédiatement à toute l'interface.", + "settings_section_colors": "Couleurs & accents", + "settings_colors_desc": "Choisissez un accent, ou gardez celui du thème.", + "settings_auto_accent": "Accent automatique selon le fond", + "settings_auto_accent_desc": "Utilise l'accent du thème plutôt que celui choisi ci-dessus.", + "settings_default_accent": "Accent du thème", + "settings_section_typography": "Typographie", + "settings_typography_desc": "La police de l'interface et sa taille de base.", + "settings_font_family": "Police", + "settings_font_family_desc": "Passe à OpenDyslexic quand la police adaptée à la dyslexie est activée.", + "settings_font_size": "Taille du texte", + "settings_font_size_desc": "Taille de base de tous les textes de l'interface.", + "settings_font_size_small": "Petit", + "settings_font_size_default": "Par défaut", + "settings_font_size_large": "Grand", + "settings_font_size_xlarge": "Très grand", + "settings_section_effects": "Effets", + "settings_effects_desc": "Extras visuels. Toujours désactivés quand Réduire les animations est activé.", + "settings_animations": "Animations", + "settings_animations_desc": "Transitions et fondus animés.", + "settings_section_preview": "Aperçu", + "settings_preview_desc": "Un aperçu en direct de vos choix actuels.", + "settings_preview_card": "Carte d'aperçu", + "settings_preview_body": "Le texte courant ressemble à ceci.", + "settings_preview_button": "Bouton", + "settings_section_vision": "Vision", + "settings_vision_desc": "Options pour rendre l'interface plus lisible.", + "settings_high_contrast": "Contraste élevé", + "settings_high_contrast_desc": "Augmente le contraste du texte et des bordures.", + "settings_dyslexia_font": "Police adaptée à la dyslexie", + "settings_dyslexia_font_desc": "Remplace la police de l'interface par OpenDyslexic.", + "settings_section_motion": "Mouvement", + "settings_motion_desc": "Apaisez l'interface.", + "settings_reduce_motion": "Réduire les animations", + "settings_reduce_motion_desc": "Désactive toutes les animations et transitions.", + "settings_section_reading": "Lecture", + "settings_reading_desc": "Options de confort de lecture.", + "settings_text_size_a11y": "Taille du texte", + "settings_text_size_a11y_desc": "Agrandit tout le texte. Se multiplie avec la taille choisie dans Apparence." + } + } } diff --git a/src/i18n/en.rs b/src/i18n/en.rs index fb2f64f..3ea915d 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -43,8 +43,9 @@ Keys: p opens and closes Preferences, q or Ctrl+C quits. ("monitor_hint", "p: Preferences q or Ctrl+C: quit"), // The Preferences page. The settings_* keys, theme_applied and their // strings are colony-ui's (colony_ui::i18n), copied exactly: every Colony - // program says the same thing in the same words. Theme and accent names - // are not here: they come from config/themes.json. + // program says the same thing in the same words. A test holds both + // locales to the `labels` of config/themes.json, which carries them. + // Theme and accent names are not here: they come from that file too. ("settings_title", "Preferences"), ("settings_close", "Close"), ("settings_cat_general", "General"), diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index b5dc698..929dd68 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -217,6 +217,24 @@ mod tests { assert!(missing.is_empty(), "keys missing from en.rs: {missing:?}"); } + /// The settings_* strings and theme_applied are colony-ui's. Resources + /// ships them in config/themes.json under `labels`, and the themes.json + /// CI job keeps that copy current, so a reworded label fails here. + #[test] + fn shared_strings_match_the_colony_labels() { + let themes: serde_json::Value = + serde_json::from_str(include_str!("../../config/themes.json")).expect("themes.json"); + for (locale, strings) in [("en", en::STRINGS), ("fr", fr::STRINGS)] { + let shared = strings + .iter() + .filter(|(key, _)| key.starts_with("settings_") || *key == "theme_applied"); + for &(key, value) in shared { + let label = themes["labels"][locale][key].as_str(); + assert_eq!(label, Some(value), "{locale}: {key}"); + } + } + } + #[test] fn french_follows_the_language_subtag() { for tag in ["fr", "fr-FR", "fr_CA.UTF-8", "FR-be", "fr@euro"] { From a49d7c2ce435034c6ffe76a8ee64ef98301423ab Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Mon, 12 Oct 2026 19:27:06 +0200 Subject: [PATCH 6/8] chore: refresh themes.json from Resources main --- config/themes.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/themes.json b/config/themes.json index 94113e4..c326d11 100644 --- a/config/themes.json +++ b/config/themes.json @@ -3796,7 +3796,7 @@ "bg": "#eceeec", "accent": "#21a04d" }, - "notes": "Green ink on white-grey ceramic — her Planet Diving Suit. A light theme on\npurpose: a dark theme can never say \"white\".\n", + "notes": "Green ink on white-grey ceramic: her Planet Diving Suit. A light theme on\npurpose: a dark theme can never say \"white\".\n", "contrast": { "textPrimaryOnBgPrimary": 5.61, "textMutedOnBgPrimary": 3.5 @@ -3916,7 +3916,7 @@ "bg": "#111010", "accent": "#ffd23e" }, - "notes": "Strict gold-on-black duotone. No brown tint — the gold must stay gold.", + "notes": "Strict gold-on-black duotone. No brown tint: the gold must stay gold.", "contrast": { "textPrimaryOnBgPrimary": 11.96, "textMutedOnBgPrimary": 5.37 @@ -4036,7 +4036,7 @@ "bg": "#1a1622", "accent": "#cfa8ff" }, - "notes": "Lavender ink on deep violet. Her khaki coat is demoted to the success role\nrather than becoming a second surface — two saturated materials side by side\nclashed and were rejected.\n", + "notes": "Lavender ink on deep violet. Her khaki coat is demoted to the success role\nrather than becoming a second surface: two saturated materials side by side\nclashed and were rejected.\n", "contrast": { "textPrimaryOnBgPrimary": 11.4, "textMutedOnBgPrimary": 4.72 From 51f85169aba5b7066f59102da46e7270092c615b Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 14 Oct 2026 12:08:44 +0200 Subject: [PATCH 7/8] chore: refresh themes.json with the contrastEnds Resources now writes --- config/themes.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/themes.json b/config/themes.json index c326d11..b3548c8 100644 --- a/config/themes.json +++ b/config/themes.json @@ -278,6 +278,10 @@ "cssVar": "--colony-accent-amber" } ], + "contrastEnds": { + "ink": "#14141a", + "paper": "#f7faff" + }, "families": [ { "key": "catppuccin", From 224c21cfebb43bc1f94d43d44a580cd981d461d2 Mon Sep 17 00:00:00 2001 From: MotherSphere Date: Wed, 14 Oct 2026 12:08:44 +0200 Subject: [PATCH 8/8] refactor: take the text-on-fill ink and paper from themes.json contrastEnds --- src/theme.rs | 61 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/src/theme.rs b/src/theme.rs index 0511246..46b6858 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -5,7 +5,8 @@ //! colony-ui reads the same tokens, but it is built on iced, which a terminal //! program cannot carry (see src/paths.rs). So the parts a terminal needs are //! ported from its theme.rs: the (family, variant) resolver with its Gruvbox -//! dark fallback, the high-contrast derivation and `contrast_on`. +//! dark fallback, the high-contrast derivation and `contrast_on` with the +//! ink and paper of `contrastEnds`. //! //! Every colour Collector draws comes from here: no other file names one. @@ -59,13 +60,13 @@ macro_rules! palette { } impl Palette { - fn from_json(palette: &Value) -> Option { + fn from_json(palette: &Value, ends: ContrastEnds) -> Option { let color = |field| hex(palette.get(field)?); let accent = color("accent_blue")?; Some(Self { $($field: color(stringify!($field))?,)* accent, - on_accent: contrast_on(accent), + on_accent: readable_end(accent, ends), }) } @@ -153,8 +154,12 @@ pub struct Catalog { /// In the order themes.json lists them, which is the pickers' order. pub families: Vec, pub accents: Vec, + /// Text on a fill: `contrastEnds`' ink, then paper. + contrast_ends: ContrastEnds, } +type ContrastEnds = [Color; 2]; + /// Every family, variant and accent in themes.json, read once. pub fn catalog() -> &'static Catalog { static CATALOG: OnceLock = OnceLock::new(); @@ -174,6 +179,14 @@ fn parse(text: &str) -> Catalog { let key = |value: &Value| value.get("key")?.as_str().map(str::to_owned); let label = |value: &Value| Label::from_json(value.get("label")?); + // Pure black and white stand in for an end the file lacks. + let ends = json.get("contrastEnds"); + let end = |name: &str, fallback| ends.and_then(|e| hex(e.get(name)?)).unwrap_or(fallback); + let contrast_ends = [ + end("ink", Color::Rgb(0, 0, 0)), + end("paper", Color::Rgb(255, 255, 255)), + ]; + let variant = |value: &Value| { let swatch = value.get("swatch")?; Some(Variant { @@ -181,7 +194,7 @@ fn parse(text: &str) -> Catalog { label: label(value)?, swatch_bg: hex(swatch.get("bg")?)?, swatch_accent: hex(swatch.get("accent")?)?, - palette: Palette::from_json(value.get("palette")?)?, + palette: Palette::from_json(value.get("palette")?, contrast_ends)?, }) }; let families = list("families") @@ -207,7 +220,11 @@ fn parse(text: &str) -> Catalog { }) }) .collect(); - Catalog { families, accents } + Catalog { + families, + accents, + contrast_ends, + } } /// `"#rrggbb"`, the only form themes.json writes. @@ -359,16 +376,18 @@ fn is_light(color: Color) -> bool { 0.299 * r + 0.587 * g + 0.114 * b > 0.5 } -/// colony-ui's `contrast_on`: near-black or near-white, whichever reads -/// better on `background` by WCAG contrast. +/// colony-ui's `contrast_on`: the ink or the paper of `contrastEnds`, +/// whichever reads better on `background` by WCAG contrast. pub fn contrast_on(background: Color) -> Color { - // colony-ui's INK (0.08, 0.08, 0.10) and PAPER (0.97, 0.98, 1.0). - const INK: Color = Color::Rgb(20, 20, 26); - const PAPER: Color = Color::Rgb(247, 250, 255); - if contrast_ratio(INK, background) >= contrast_ratio(PAPER, background) { - INK + readable_end(background, catalog().contrast_ends) +} + +/// [`contrast_on`] with the ends given, for the catalog still being read. +fn readable_end(background: Color, [ink, paper]: ContrastEnds) -> Color { + if contrast_ratio(ink, background) >= contrast_ratio(paper, background) { + ink } else { - PAPER + paper } } @@ -472,16 +491,18 @@ mod tests { #[test] fn text_on_a_fill_is_whichever_end_reads_better() { + // colony-ui's INK (0.08, 0.08, 0.10) and PAPER (0.97, 0.98, 1.0). + let (ink, paper) = (Color::Rgb(20, 20, 26), Color::Rgb(247, 250, 255)); assert_eq!( - contrast_on(Color::Rgb(255, 255, 255)), - Color::Rgb(20, 20, 26) + catalog().contrast_ends, + [ink, paper], + "contrastEnds left colony-ui's ends" ); - assert_eq!(contrast_on(Color::Rgb(0, 0, 0)), Color::Rgb(247, 250, 255)); + + assert_eq!(contrast_on(Color::Rgb(255, 255, 255)), ink); + assert_eq!(contrast_on(Color::Rgb(0, 0, 0)), paper); // colony-ui's own example: green takes ink, not paper. - assert_eq!( - contrast_on(Color::Rgb(0x55, 0xb8, 0x7a)), - Color::Rgb(20, 20, 26) - ); + assert_eq!(contrast_on(Color::Rgb(0x55, 0xb8, 0x7a)), ink); } #[test]