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.
This commit is contained in:
Matus Mastena committed 2026-09-17 16:51:41 +02:00
1 parent a570f4ac16
commit 648b261fd1
4 files changed
+163 -4

No files matched your search

+21 -3
View File
@@ -176,11 +176,20 @@ impl UiThemePalette {
}
}
pub fn builtin_theme(name: &str) -> Option<iced::Theme> {
/// 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> {
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<iced::Theme> {
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<iced::Theme> {
"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,
}
}
+140
View File
@@ -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<Theme> {
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);
}
}
+1
View File
@@ -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 {
+1 -1
View File
@@ -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()))