Files
OneCAD/crates/ui-egui/src/theme.rs
T
William Dunn 72c5d974b8 fix(ui): draw sketch constraint glyphs in red (#414)
* fix(ui): draw sketch constraint glyphs in red

Traditional CAD convention marks constraint glyphs in red. glyph_edge
and glyph_text in both themes now use red values. glyph_bg is
unchanged. The same glyph box switches to the error colour when a
constraint fails. The new reds are chosen to stay distinct from that
error colour. They are also distinct from the blue used for selection
and hover. See the pull request body for the before and after RGB
values and the contrast reasoning.

* fix(ui): match constraint toolbar icons to the red glyph colour

The CONSTRAINTS panel buttons and their drop-down rows drew their accent
strokes in the normal sketch tool green. They now use glyph_text, the same
red the matching constraint glyph box uses in the sketch view. A new
panel_accent helper in toolbar.rs picks the colour: the constraint red for
the CONSTRAINTS panel, the existing sketch green for every other sketch
tool, and the plain accent everywhere else. No other toolbar styling
changed.
2026-10-12 21:37:42 -04:00

362 lines
16 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. Every colour the UI uses comes from [`Tokens`].
use std::sync::atomic::{AtomicBool, Ordering};
use egui::{Color32, Stroke};
static DARK: AtomicBool = AtomicBool::new(true);
/// Is the dark theme on?
pub fn is_dark() -> bool {
DARK.load(Ordering::Relaxed)
}
#[derive(Clone, Copy, Debug)]
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,
/// Removed material in a feature preview (translucent).
pub preview_cut: 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,
/// 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,
}
impl Tokens {
/// The tokens of the current theme.
pub fn get() -> Tokens {
if is_dark() { Tokens::dark() } else { Tokens::light() }
}
pub const fn light() -> Tokens {
Tokens {
app_bar: Color32::from_rgb(52, 56, 64),
toolbar: Color32::from_rgb(246, 247, 249),
panel: Color32::from_rgb(250, 251, 252),
panel_header: Color32::from_rgb(236, 238, 242),
border: Color32::from_rgb(208, 212, 220),
text: Color32::from_rgb(38, 42, 50),
text_dim: Color32::from_rgb(110, 116, 128),
accent: Color32::from_rgb(38, 120, 218),
accent_soft: Color32::from_rgb(214, 230, 250),
sketch_accent: Color32::from_rgb(46, 150, 90),
error: Color32::from_rgb(214, 54, 54),
warning: Color32::from_rgb(222, 150, 30),
icon: Color32::from_rgb(70, 78, 92),
icon_fill: Color32::from_rgb(178, 196, 222),
timeline: Color32::from_rgb(232, 235, 240),
hover: Color32::from_rgb(226, 234, 246),
sel_face: Color32::from_rgb(76, 127, 203),
sel_edge: Color32::from_rgb(0, 127, 255),
sel_edge_rim: Color32::from_rgb(127, 191, 255),
hover_edge: Color32::from_rgb(0, 0, 0),
hover_edge_halo: Color32::from_rgb(203, 203, 203),
hover_face_lift: 40,
sel_profile: Color32::from_rgb(26, 84, 167),
sel_profile_edge: Color32::from_rgb(47, 109, 198),
hover_profile: Color32::from_rgba_unmultiplied_const(75, 129, 253, 48),
hover_profile_edge: Color32::from_rgb(75, 129, 253),
sel_vertex: Color32::from_rgb(70, 110, 170),
origin_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 94),
origin_plane_edge: Color32::from_rgba_unmultiplied_const(214, 140, 84, 200),
origin_plane_hover: Color32::from_rgba_unmultiplied_const(120, 130, 230, 150),
origin_point: Color32::from_rgb(214, 215, 218),
axis_x: Color32::from_rgb(255, 0, 0),
axis_y: Color32::from_rgb(0, 255, 0),
axis_z: Color32::from_rgb(2, 2, 248),
construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
construction_geom: Color32::from_rgb(232, 140, 48),
body: Color32::from_rgb(176, 186, 198),
body_edge: Color32::from_rgb(40, 44, 52),
manipulator: Color32::from_rgb(40, 110, 220),
preview_add: Color32::from_rgb(112, 160, 226),
preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110),
viewport_top: Color32::from_rgb(240, 243, 247),
viewport_bottom: Color32::from_rgb(203, 210, 221),
grid: Color32::from_rgb(196, 202, 212),
grid_major: Color32::from_rgb(170, 178, 190),
body_selected: Color32::from_rgb(110, 170, 235),
sketch: Color32::from_rgb(30, 90, 200),
sketch_fixed: Color32::from_rgb(20, 20, 20),
sketch_point: Color32::BLACK,
sketch_locked: Color32::from_rgb(0, 140, 60),
dimension: Color32::from_rgb(36, 40, 48),
dimension_driven: Color32::from_rgb(120, 126, 138),
sketch_point_free: Color32::from_rgb(30, 90, 200),
section_cap: Color32::from_rgb(214, 128, 72),
dialog_bg: Color32::from_rgb(250, 251, 252),
dialog_border: Color32::from_rgb(208, 212, 220),
chip: Color32::from_rgb(38, 120, 218),
heading_bg: Color32::from_rgb(218, 223, 231),
subheading_bg: Color32::from_rgb(234, 237, 242),
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
glyph_bg: Color32::WHITE,
glyph_edge: Color32::from_rgb(190, 110, 100),
glyph_text: Color32::from_rgb(150, 40, 35),
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
rubber_band: Color32::from_rgb(30, 120, 230),
app_bar_text: Color32::from_rgb(230, 234, 242),
timeline_item: Color32::WHITE,
timeline_item_error: Color32::from_rgb(250, 220, 220),
timeline_marker: Color32::from_rgb(70, 76, 88),
field: Color32::WHITE,
box_window: Color32::from_rgb(38, 120, 218),
box_crossing: Color32::from_rgb(40, 160, 80),
}
}
/// Dark blue-grey: viewport #2e3440 as measured in the reference tool, chrome a little
/// darker, light text, a blue accent.
pub const fn dark() -> Tokens {
let mut t = Tokens::light();
t.app_bar = Color32::from_rgb(28, 31, 38);
t.toolbar = Color32::from_rgb(37, 41, 49);
t.panel = Color32::from_rgb(39, 43, 52);
t.panel_header = Color32::from_rgb(47, 52, 62);
t.border = Color32::from_rgb(63, 69, 82);
t.text = Color32::from_rgb(222, 226, 232);
t.text_dim = Color32::from_rgb(148, 155, 168);
t.accent = Color32::from_rgb(58, 136, 238);
t.accent_soft = Color32::from_rgb(42, 70, 110);
t.sketch_accent = Color32::from_rgb(110, 200, 140);
t.error = Color32::from_rgb(240, 100, 96);
t.warning = Color32::from_rgb(236, 170, 70);
t.icon = Color32::from_rgb(208, 214, 226);
t.icon_fill = Color32::from_rgb(86, 112, 150);
t.timeline = Color32::from_rgb(33, 37, 45);
t.hover = Color32::from_rgb(54, 61, 74);
t.hover_edge_halo = Color32::from_rgb(203, 203, 203);
t.origin_point = Color32::from_rgb(214, 215, 218);
t.viewport_top = Color32::from_rgb(46, 52, 64);
t.viewport_bottom = Color32::from_rgb(46, 52, 64);
t.grid = Color32::from_rgb(59, 65, 76);
t.grid_major = Color32::from_rgb(73, 79, 90);
t.body = Color32::from_rgb(152, 156, 162);
t.body_edge = Color32::from_rgb(16, 16, 18);
t.sketch = Color32::from_rgb(92, 150, 255);
t.sketch_fixed = Color32::from_rgb(232, 234, 238);
t.sketch_point = Color32::from_rgb(232, 234, 238);
t.sketch_locked = Color32::from_rgb(96, 204, 116);
t.dimension = Color32::from_rgb(224, 228, 234);
t.dimension_driven = Color32::from_rgb(142, 150, 164);
t.sketch_point_free = Color32::from_rgb(92, 150, 255);
t.profile_fill = Color32::from_rgba_unmultiplied_const(120, 150, 200, 40);
t.dialog_bg = Color32::from_rgb(59, 68, 83);
t.dialog_border = Color32::from_rgb(72, 83, 99);
t.chip = Color32::from_rgb(59, 101, 168);
t.heading_bg = Color32::from_rgb(38, 44, 55);
t.subheading_bg = Color32::from_rgb(48, 55, 68);
t.glyph_bg = Color32::from_rgb(44, 50, 60);
t.glyph_edge = Color32::from_rgb(200, 130, 120);
t.glyph_text = Color32::from_rgb(240, 165, 155);
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
t.rubber_band = Color32::from_rgb(92, 160, 255);
t.timeline_item = Color32::from_rgb(52, 58, 70);
t.timeline_item_error = Color32::from_rgb(110, 50, 54);
t.timeline_marker = Color32::from_rgb(170, 178, 192);
t.field = Color32::from_rgb(30, 34, 41);
t.box_window = Color32::from_rgb(70, 150, 255);
t.box_crossing = Color32::from_rgb(80, 200, 120);
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 = if dark { Tokens::dark() } else { Tokens::light() };
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(), "Parameters", crate::frame::Width::Normal).fade_in(false).show(ui.ctx(), |ui| {
ui.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");
}
}
}