From 648b261fd1b7e81cbc37de29dcade76977ae665f Mon Sep 17 00:00:00 2001 From: Matus Mastena Date: Sat, 12 Sep 2026 16:20:17 +0200 Subject: [PATCH] Add the Fusion Black and Fusion White themes Fusion Black pairs near-black chrome with a paper-white canvas, so the drawing is the only bright thing on screen. That breaks the assumption in theme_canvas_background that the canvas follows the chrome, hence the explicit lookup: falling through to the palette would paint model space black and undo the point of the theme. Both keep a blue accent rather than going fully greyscale. Selection highlight defaults to the theme primary, and a white primary would be invisible against a white canvas. These are Theme::Custom values and so are absent from iced's Theme::ALL. all_themes() is the list to enumerate from now; the picker in the options dialog uses it, and parse_theme_name resolves both by name. --- src/app/config.rs | 24 +++++- src/ui/style/fusion_theme.rs | 140 +++++++++++++++++++++++++++++++++++ src/ui/style/mod.rs | 1 + src/ui/window/options.rs | 2 +- 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 src/ui/style/fusion_theme.rs diff --git a/src/app/config.rs b/src/app/config.rs index 6d1c8f0eb..f8d2f26d3 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -176,11 +176,20 @@ impl UiThemePalette { } } -pub fn builtin_theme(name: &str) -> Option { +/// Every theme the user may pick: iced's built-ins followed by the Fusion +/// pair. The Fusion themes are `Theme::Custom`, so they are not in +/// `iced::Theme::ALL`; anything enumerating themes for display or for test +/// coverage must use this instead, or they silently vanish from the list. +pub fn all_themes() -> Vec { iced::Theme::ALL .iter() - .find(|theme| theme.to_string() == name) .cloned() + .chain(crate::ui::style::fusion_theme::fusion_themes()) + .collect() +} + +pub fn builtin_theme(name: &str) -> Option { + all_themes().into_iter().find(|theme| theme.to_string() == name) } fn color_to_rgb(color: iced::Color) -> [u8; 3] { @@ -376,7 +385,14 @@ pub fn theme_canvas_background(theme: &iced::Theme) -> [u8; 3] { iced::Theme::Nightfly => [1, 22, 39], iced::Theme::Oxocarbon => [22, 22, 22], iced::Theme::Ferra => [43, 41, 46], - _ => color_to_rgb(theme.palette().background.base.color), + // The Fusion pair deliberately breaks the "canvas follows chrome" + // rule: black chrome is paired with a white canvas, so falling + // through to the palette below would paint model space black and + // undo the whole point of the theme. + other => match crate::ui::style::fusion_theme::fusion_canvas(other) { + Some(rgb) => rgb, + None => color_to_rgb(other.palette().background.base.color), + }, } } @@ -407,6 +423,8 @@ pub fn parse_theme_name(s: &str) -> Option { "NIGHTFLY" => Some(iced::Theme::Nightfly), "OXOCARBON" => Some(iced::Theme::Oxocarbon), "FERRA" => Some(iced::Theme::Ferra), + "FUSIONBLACK" => Some(crate::ui::style::fusion_theme::fusion_black()), + "FUSIONWHITE" => Some(crate::ui::style::fusion_theme::fusion_white()), _ => None, } } diff --git a/src/ui/style/fusion_theme.rs b/src/ui/style/fusion_theme.rs new file mode 100644 index 000000000..19a0aed73 --- /dev/null +++ b/src/ui/style/fusion_theme.rs @@ -0,0 +1,140 @@ +//! The Fusion theme pair: black chrome around a white canvas, and its +//! inverse. +//! +//! "Mixed black/white" is about the split, not about draining every colour +//! out of the UI. Ribbon, docks, status bar and command line sit at +//! near-black while model space stays paper-white, so the drawing is the +//! only bright thing on screen. One accent survives that split: selection +//! highlight defaults to the theme primary +//! (`ModelSpaceThemeConfig::selection_highlight_color` = 0), and a white +//! primary would be invisible against a white canvas. The accent is +//! therefore a saturated blue that reads against both halves. +//! +//! These are `iced::Theme::Custom` values, so they are absent from +//! `iced::Theme::ALL`. Anything enumerating themes must go through +//! [`crate::app::config::all_themes`] instead. + +use iced::theme::palette::Seed; +use iced::{Color, Theme}; + +/// Display name of the black-chrome theme. Persisted verbatim in +/// `settings.json` as `theme.name`, so changing it orphans a user's choice. +pub const FUSION_BLACK: &str = "Fusion Black"; + +/// Display name of the light-chrome theme. +pub const FUSION_WHITE: &str = "Fusion White"; + +/// Model-space canvas for [`FUSION_BLACK`] — paper white against the +/// near-black chrome. Deliberately not pure #FFFFFF: a fraction below white +/// keeps the paper-space sheet distinguishable from the model canvas. +pub const FUSION_BLACK_CANVAS: [u8; 3] = [250, 250, 250]; + +/// Model-space canvas for [`FUSION_WHITE`]. +pub const FUSION_WHITE_CANVAS: [u8; 3] = [255, 255, 255]; + +fn rgb(hex: u32) -> Color { + Color::from_rgb8( + ((hex >> 16) & 0xFF) as u8, + ((hex >> 8) & 0xFF) as u8, + (hex & 0xFF) as u8, + ) +} + +/// Near-black chrome, white text, blue accent. +pub fn fusion_black() -> Theme { + Theme::custom( + FUSION_BLACK, + Seed { + background: rgb(0x1A1A1A), + text: rgb(0xF2F2F2), + primary: rgb(0x0696D7), + success: rgb(0x4CAF50), + warning: rgb(0xFFB300), + danger: rgb(0xE53935), + }, + ) +} + +/// The inverse: light chrome, near-black text. The accent darkens so it +/// keeps its contrast against a light surface. +pub fn fusion_white() -> Theme { + Theme::custom( + FUSION_WHITE, + Seed { + background: rgb(0xFAFAFA), + text: rgb(0x1A1A1A), + primary: rgb(0x0277BD), + success: rgb(0x2E7D32), + warning: rgb(0xE65100), + danger: rgb(0xC62828), + }, + ) +} + +/// Both Fusion themes, in the order the theme picker should show them. +pub fn fusion_themes() -> Vec { + vec![fusion_black(), fusion_white()] +} + +/// The canvas colour a Fusion theme asks for, or `None` for any other theme. +/// Keyed by name because `Theme::Custom` carries no discriminant to match on. +pub fn fusion_canvas(theme: &Theme) -> Option<[u8; 3]> { + match theme.to_string().as_str() { + FUSION_BLACK => Some(FUSION_BLACK_CANVAS), + FUSION_WHITE => Some(FUSION_WHITE_CANVAS), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::style::common::wcag_contrast; + + /// The same bar `theme_accessibility_tests` holds the built-in themes to. + /// These themes are hand-written rather than generated, so nothing else + /// would catch a palette typo that drops text below AA. + #[test] + fn fusion_themes_meet_wcag_aa_on_every_surface() { + for theme in fusion_themes() { + let p = theme.palette(); + for (name, pair) in [ + ("base", p.background.base), + ("weak", p.background.weak), + ("strong", p.background.strong), + ("weakest", p.background.weakest), + ] { + let contrast = wcag_contrast(pair.text, pair.color); + assert!( + contrast >= 4.5, + "{theme} {name} surface text is {contrast:.2}:1, below AA" + ); + } + } + } + + /// The accent exists so selection stays visible on the white canvas. If + /// it ever drifts toward white this catches it. + #[test] + fn fusion_accent_is_visible_on_the_canvas() { + for (theme, canvas) in [ + (fusion_black(), FUSION_BLACK_CANVAS), + (fusion_white(), FUSION_WHITE_CANVAS), + ] { + let canvas = Color::from_rgb8(canvas[0], canvas[1], canvas[2]); + let contrast = wcag_contrast(theme.palette().primary.base.color, canvas); + assert!( + contrast >= 3.0, + "{theme} accent is {contrast:.2}:1 on its canvas, too faint \ + to show a selection" + ); + } + } + + #[test] + fn fusion_canvas_is_keyed_by_name() { + assert_eq!(fusion_canvas(&fusion_black()), Some(FUSION_BLACK_CANVAS)); + assert_eq!(fusion_canvas(&fusion_white()), Some(FUSION_WHITE_CANVAS)); + assert_eq!(fusion_canvas(&Theme::Dark), None); + } +} diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index 2a86accac..f98b8d751 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -11,6 +11,7 @@ pub mod anno_object_scale; pub mod style_manager; pub mod common; pub mod form; +pub mod fusion_theme; #[cfg(test)] mod common_tests { diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index aa490a9f7..a6664e345 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -179,7 +179,7 @@ pub fn view_window<'a>( .copied() .find(|candidate| *candidate == default_save_format); - let theme_options = Theme::ALL + let theme_options = crate::app::config::all_themes() .iter() .map(ToString::to_string) .chain(std::iter::once("Custom".to_string()))