Files
OneCAD/crates/ui-egui/src/theme.rs
T
CamWight f6a47a49d7 Colour vision modes: Protan, Deutan and Tritan in Preferences › Graphics (#778) (#1007)
Colour vision modes (Protan, Deutan, Tritan) in Preferences > Graphics, with palettes, shader and icon colours adapted per mode, translations and tests.
2026-10-14 17:35:56 -04:00

442 lines
17 KiB
Rust

//! Colours and widget style: a dark theme (the default, a blue-grey in the spirit of desktop CAD
//! tools, viewport #2e3440) and a light one, each with colour vision modes. Every colour the UI
//! uses comes from [`Tokens`].
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
#[path = "../../../assets/styles/ui/palettes.rs"]
mod palettes;
use egui::{Color32, Stroke};
use serde::{Deserialize, Serialize};
static DARK: AtomicBool = AtomicBool::new(true);
thread_local! {
/// The colour vision mode, per thread, where the dark theme is one global (`DARK`). Each app
/// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the
/// thread that draws it. Tests run many apps side by side on their own threads in one process:
/// with one global, a test in a mode changed the colours under another one
/// (`hover_stays_off_during_spin_and_animated_views` failed that way). No worker thread reads
/// it: the preview thread is handed its colours (`preview::Colors`) and thumbnails are drawn by
/// the engine in the model's colours, so a new thread starts Off and stays Off.
static VISION: Cell<ColorVision> = const { Cell::new(ColorVision::Off) };
}
/// Is the dark theme on?
pub fn is_dark() -> bool {
DARK.load(Ordering::Relaxed)
}
/// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for
/// a weak or missing red (protan), green (deutan) or blue (tritan) sense. Not a filter: Off gives
/// back the theme's colours unchanged. Protan and Deutan share one red-green table, measured
/// clear for both (`colour_vision_tests`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ColorVision {
#[default]
Off,
Protan,
Deutan,
Tritan,
}
impl ColorVision {
pub const ALL: [ColorVision; 4] = [ColorVision::Off, ColorVision::Protan, ColorVision::Deutan, ColorVision::Tritan];
/// The saved value (`ui.prefs` `color_vision`).
pub fn id(self) -> &'static str {
match self {
ColorVision::Off => "off",
ColorVision::Protan => "protan",
ColorVision::Deutan => "deutan",
ColorVision::Tritan => "tritan",
}
}
pub fn from_id(id: &str) -> Option<ColorVision> {
ColorVision::ALL.into_iter().find(|m| m.id() == id)
}
/// Its name in the Preferences (a message id).
pub fn label_id(self) -> &'static str {
match self {
ColorVision::Off => "ui.colour_vision_off",
ColorVision::Protan => "ui.colour_vision_protan",
ColorVision::Deutan => "ui.colour_vision_deutan",
ColorVision::Tritan => "ui.colour_vision_tritan",
}
}
/// Who it is for, in one line (its tooltip; a message id).
pub fn help_id(self) -> &'static str {
match self {
ColorVision::Off => "ui.colour_vision_off_tip",
ColorVision::Protan => "ui.colour_vision_protan_tip",
ColorVision::Deutan => "ui.colour_vision_deutan_tip",
ColorVision::Tritan => "ui.colour_vision_tritan_tip",
}
}
}
/// From saved preferences: a value this version doesn't know (a later version's mode, a hand
/// edit) is Off rather than an error, which would lose every other saved preference with it.
/// `ui.prefs` refuses such a value before it gets here (`prefs::set`).
impl<'de> Deserialize<'de> for ColorVision {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_str().and_then(ColorVision::from_id).unwrap_or_default())
}
}
/// The colour vision mode on now (on this thread).
pub fn vision() -> ColorVision {
VISION.with(Cell::get)
}
/// Switch the colour vision mode (the visuals follow on the next [`apply`]).
pub fn set_vision(mode: ColorVision) {
VISION.with(|v| v.set(mode));
}
/// An sRGB colour given as 0..1 (the analysis colours), for drawing in the UI.
pub fn srgb(c: [f32; 3]) -> Color32 {
let byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(byte(c[0]), byte(c[1]), byte(c[2]))
}
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them (exact, so they read
/// the same as when they were literals in the WGSL).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius.
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
/// The colours icons draw of their own (`icons::paint` takes ink, fill and accent from its
/// caller). Every icon colour is a token so a colour vision mode can reach it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct IconColors {
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet, iso lines and teeth.
pub sheet: Color32,
pub sheet_2: Color32,
pub sheet_light: Color32,
pub sheet_line: Color32,
/// Highlights (and the white ink of markers and the agent pointer), and the sphere's glint.
pub highlight: Color32,
pub glint: Color32,
pub folder: Color32,
pub plane: Color32,
pub fold: Color32,
/// Zebra stripes, and the environment map's sky and floor.
pub stripe: Color32,
pub sky: Color32,
pub floor: Color32,
/// Appearance: red, blue and yellow paint dabs.
pub paint: [Color32; 3],
/// Origin: X, Y and Z arrows.
pub axes: [Color32; 3],
/// Accessibility: reachable, blocked. Draft: positive, between. Curvature: flat, middle,
/// tight.
pub access: [Color32; 2],
pub draft: [Color32; 2],
pub curvature: [Color32; 3],
pub interference: Color32,
pub warning: Color32,
/// The pointer an AI agent moves.
pub agent: Color32,
/// The dark halo under viewport markers (snap points).
pub marker_halo: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens {
pub app_bar: Color32,
pub toolbar: Color32,
pub panel: Color32,
pub panel_header: Color32,
pub border: Color32,
pub text: Color32,
pub text_dim: Color32,
pub accent: Color32,
pub accent_soft: Color32,
pub sketch_accent: Color32,
pub error: Color32,
pub warning: Color32,
pub icon: Color32,
pub icon_fill: Color32,
pub timeline: Color32,
pub hover: Color32,
// ---- viewport selection feedback ----
/// Selected face (flat fill).
pub sel_face: Color32,
/// Selected edge core and its lighter rim.
pub sel_edge: Color32,
pub sel_edge_rim: Color32,
/// Hovered edge: dark core with a light halo.
pub hover_edge: Color32,
pub hover_edge_halo: Color32,
/// How much lighter a hovered face is (grey levels).
pub hover_face_lift: u8,
/// Selected profile fill and outline.
pub sel_profile: Color32,
pub sel_profile_edge: Color32,
/// Hovered profile: faint tint and a bright outline.
pub hover_profile: Color32,
pub hover_profile_edge: Color32,
/// Selected vertex dot.
pub sel_vertex: Color32,
// ---- origin ----
pub origin_plane: Color32,
pub origin_plane_edge: Color32,
pub origin_plane_hover: Color32,
pub origin_point: Color32,
pub axis_x: Color32,
pub axis_y: Color32,
pub axis_z: Color32,
/// Construction planes.
pub construction_plane: Color32,
/// Construction axes and points.
pub construction_geom: Color32,
// ---- bodies and live previews ----
pub body: Color32,
pub body_edge: Color32,
/// Command manipulators (direction arrows, handles).
pub manipulator: Color32,
/// New surface in a feature preview.
pub preview_add: Color32,
/// Proposed sketch geometry, distinct from blue selections.
pub preview_sketch: Color32,
/// Removed material in a feature preview (translucent).
pub preview_cut: Color32,
/// Navigation cube faces, outline and labels.
pub cube_face: Color32,
pub cube_border: Color32,
pub cube_text: Color32,
/// Light blue hover fill, readable on the pale cube in either theme.
pub cube_hover: Color32,
// ---- viewport ----
/// Viewport background (top and bottom of the gradient; equal for a flat fill).
pub viewport_top: Color32,
pub viewport_bottom: Color32,
pub grid: Color32,
pub grid_major: Color32,
/// A whole selected body.
pub body_selected: Color32,
/// Sketch curves: under-constrained, fully constrained (and finished sketches).
pub sketch: Color32,
pub sketch_fixed: Color32,
/// Fixed (locked) sketch geometry.
pub sketch_locked: Color32,
/// Sketch dimensions: driving, driven (reference).
pub dimension: Color32,
pub dimension_driven: Color32,
/// Sketch points: fully constrained, free.
pub sketch_point: Color32,
pub sketch_point_free: Color32,
/// Command dialogs: panel, border, and the filled selection chip.
pub dialog_bg: Color32,
pub dialog_border: Color32,
pub chip: Color32,
/// Band behind a panel's heading and, lighter, behind its section headings (both set off
/// from `dialog_bg`).
pub heading_bg: Color32,
pub subheading_bg: Color32,
/// Inside of bodies cut by a section.
pub section_cap: Color32,
/// Closed sketch profiles (translucent fill).
pub profile_fill: Color32,
/// Constraint glyph boxes.
pub glyph_bg: Color32,
pub glyph_edge: Color32,
pub glyph_text: Color32,
/// Chips drawn over the viewport (status, navigation bar, dimension labels).
pub overlay: Color32,
/// Rubber band of the shape being drawn.
pub rubber_band: Color32,
/// Light text on the application bar.
pub app_bar_text: Color32,
/// A hovered tab on the application bar (keeps `app_bar_text` readable).
pub app_bar_hover: Color32,
/// Outline of the inactive tabs on the application bar, so neighbouring tabs stay apart.
pub app_bar_tab_edge: Color32,
/// Timeline items: normal and failed.
pub timeline_item: Color32,
pub timeline_item_error: Color32,
pub timeline_marker: Color32,
/// Button and field backgrounds on panels.
pub field: Color32,
// ---- box selection ----
pub box_window: Color32,
pub box_crossing: Color32,
// ---- colours that came from the engine or were drawn in place ----
/// Sketch curves the engine tags (`view::colors`): projected, lost link, construction,
/// centerline.
pub sketch_projected: Color32,
pub sketch_lost: Color32,
pub sketch_construction: Color32,
pub sketch_centerline: Color32,
/// The view triad's X, Y and Z (and the CPU scene's origin axes).
pub triad: [Color32; 3],
/// The move gizmo's X, Y and Z.
pub gizmo: [Color32; 3],
/// Sheet metal flat pattern: bend up, bend down.
pub bend_up: Color32,
pub bend_down: Color32,
/// Interference: the clash fill, before its shading.
pub interference: Color32,
pub analysis: AnalysisColors,
pub icons: IconColors,
/// The application bar's buttons: ink, fill and accent of their icons.
pub app_bar_icon: [Color32; 3],
/// The SolidCraft mark on the application bar: light, mid and dark faces, and its edge.
pub logo: [Color32; 4],
}
impl Tokens {
/// The tokens of the current theme and colour vision mode.
pub fn get() -> Tokens {
Tokens::themed(is_dark(), vision())
}
/// The tokens of a theme and colour vision mode.
pub fn themed(dark: bool, mode: ColorVision) -> Tokens {
(if dark { Tokens::dark() } else { Tokens::light() }).with_vision(dark, mode)
}
/// These tokens with a colour vision mode's values on top (the tables in `palettes.rs`); Off
/// gives them back unchanged.
pub fn with_vision(self, dark: bool, mode: ColorVision) -> Tokens {
match mode {
ColorVision::Off => self,
ColorVision::Protan | ColorVision::Deutan => self.red_green(dark),
ColorVision::Tritan => self.blue_yellow(dark),
}
}
/// Fade idle cube colours into the viewport while retaining opaque facets.
pub fn navigation_cube(self, active: bool) -> Tokens {
if active {
return self;
}
let mut t = self;
t.cube_face = t.cube_face.lerp_to_gamma(t.viewport_top, 0.55);
t.cube_border = t.cube_border.lerp_to_gamma(t.viewport_top, 0.55);
t.cube_text = t.cube_text.lerp_to_gamma(t.viewport_top, 0.55);
t.axis_x = t.axis_x.lerp_to_gamma(t.viewport_top, 0.55);
t.axis_y = t.axis_y.lerp_to_gamma(t.viewport_top, 0.55);
t.axis_z = t.axis_z.lerp_to_gamma(t.viewport_top, 0.55);
t
}
}
/// How a physical material looks (the default appearance follows the material).
pub fn material_color(name: &str) -> Option<Color32> {
solidcraft_engine::doc::material_color(name).map(|[r, g, b]| Color32::from_rgb(r, g, b))
}
/// Switch themes (the visuals follow on the next [`apply`]).
pub fn set_dark(dark: bool) {
DARK.store(dark, Ordering::Relaxed);
}
fn visuals(dark: bool) -> egui::Visuals {
let t = Tokens::themed(dark, vision());
let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() };
v.panel_fill = t.panel;
v.window_fill = t.panel;
v.window_stroke = Stroke::new(1.0, t.border);
v.extreme_bg_color = t.field;
v.faint_bg_color = t.panel_header;
v.override_text_color = Some(t.text);
v.widgets.inactive.weak_bg_fill = t.panel_header;
v.widgets.inactive.bg_fill = t.panel_header;
v.widgets.active.weak_bg_fill = t.accent_soft;
v.selection.bg_fill = t.accent_soft;
v.selection.stroke = Stroke::new(1.0, t.accent);
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text);
v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text);
v.widgets.hovered.weak_bg_fill = t.hover;
v.widgets.hovered.bg_fill = t.hover;
// egui uses the open-state fill for a movable window's active title bar.
v.widgets.open.weak_bg_fill = t.panel_header;
v.window_corner_radius = egui::CornerRadius::same(6);
v
}
pub fn apply(ctx: &egui::Context) {
let dark = is_dark();
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
ctx.set_visuals(visuals(dark));
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 3.0);
});
}
#[cfg(test)]
mod tests {
/// Title and section bands stand out from a dialog, the title more than its sections.
#[test]
fn heading_bands_are_darker_than_the_dialog_in_order() {
let luma = |c: egui::Color32| u32::from(c.r()) * 3 + u32::from(c.g()) * 6 + u32::from(c.b());
for t in [super::Tokens::light(), super::Tokens::dark()] {
assert!(luma(t.heading_bg) < luma(t.subheading_bg), "{t:?}");
assert!(luma(t.subheading_bg) < luma(t.dialog_bg), "{t:?}");
}
}
#[test]
fn active_movable_window_header_uses_theme_panel_header() {
for dark in [false, true] {
let ctx = egui::Context::default();
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
ctx.set_visuals(super::visuals(dark));
let mut output = egui::FullOutput::default();
for _ in 0..3 {
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
..Default::default()
};
output = ctx.run_ui(raw, |ui| {
crate::frame::window(ui.ctx(), crate::i18n::tr_label("Parameters"), crate::frame::Width::Normal).fade_in(false).show(
ui.ctx(),
|ui| {
ui.label(crate::i18n::tr_label("Parameter values"));
},
);
});
output.textures_delta.clear();
}
let title = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.text() == "Parameters" => Some(text.pos + text.galley.rect.center().to_vec2()),
_ => None,
})
.expect("window title must be drawn");
let header = output
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) if rect.fill.a() > 0 && rect.rect.contains(title) => Some(rect),
_ => None,
})
.min_by(|a, b| a.rect.area().total_cmp(&b.rect.area()))
.expect("window header must be drawn behind its title");
let expected = if dark { super::Tokens::dark() } else { super::Tokens::light() };
assert_eq!(header.fill, expected.panel_header, "active title bar should follow the {dark} palette");
}
}
}