From 956492c993651e5421049fe248f899eec652c30f Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 22:23:24 +0000 Subject: [PATCH] Dark theme (default): Fusion-like blue-grey viewport #2e3440, darker chrome, subtle grid, light text, blue accent; light theme kept as a second Tokens set; toggle on the application bar (sun/moon), ui.set {dark}, kept between runs (eframe storage; browser local storage on the web); viewport, sketch, glyph, view cube, timeline and overlay colours all come from the theme; preview spinner and state in ui.inspect Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- Cargo.lock | 49 +++++++++++ Cargo.toml | 2 +- apps/solvecraft-web/src/main.rs | 9 +++ apps/solvecraft/src/main.rs | 9 +++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs.rs | 3 + crates/ui-egui/src/icons.rs | 16 ++++ crates/ui-egui/src/lib.rs | 20 ++++- crates/ui-egui/src/theme.rs | 139 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/timeline.rs | 6 +- crates/ui-egui/src/toolbar.rs | 14 ++-- crates/ui-egui/src/tools.rs | 7 +- crates/ui-egui/src/viewport.rs | 64 ++++++++------- docs/control-protocol.md | 4 +- 14 files changed, 290 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1452795f..7273afc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,8 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ + "enumn", + "serde", "uuid", ] @@ -130,6 +132,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -451,6 +454,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "block2" @@ -863,6 +869,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -891,6 +898,8 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "ron", + "serde", "static_assertions", "wasm-bindgen", "web-sys", @@ -915,6 +924,8 @@ dependencies = [ "log", "nohash-hasher", "profiling", + "ron", + "serde", "smallvec", "unicode-segmentation", "web-sys", @@ -956,6 +967,7 @@ dependencies = [ "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", + "serde", "smithay-clipboard", "web-time", "winit", @@ -988,6 +1000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1017,6 +1030,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "epaint" version = "0.36.2" @@ -1035,6 +1059,7 @@ dependencies = [ "parking_lot", "profiling", "self_cell", + "serde", "skrifa", "smallvec", "unicode-general-category", @@ -1156,6 +1181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -2793,6 +2819,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3007,6 +3047,9 @@ name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] [[package]] name = "smithay-client-toolkit" @@ -3582,6 +3625,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index f43db44d..826e49fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ image = { version = "0.25", default-features = false, features = ["png"] } # ui egui = "0.36" -eframe = { version = "0.36", default-features = false } +eframe = { version = "0.36", default-features = false, features = ["persistence"] } # Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown). web-time = "1" egui-wgpu = "0.36" diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs index 57684061..b64f779d 100644 --- a/apps/solvecraft-web/src/main.rs +++ b/apps/solvecraft-web/src/main.rs @@ -28,8 +28,14 @@ mod web { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } + fn save(&mut self, storage: &mut dyn eframe::Storage) { + storage.set_string(PREFS_KEY, self.0.prefs()); + } } + /// Where the preferences (theme) are kept (the browser's local storage). + const PREFS_KEY: &str = "solvecraft.prefs"; + fn query() -> String { web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default() } @@ -54,6 +60,9 @@ mod web { options, Box::new(move |cc| { let mut app = SolveApp::new(Session::default(), Services::default()); + if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) { + app.load_prefs(&p); + } if let Some(rs) = &cc.wgpu_render_state { app.set_wgpu(rs, DEPTH_BITS, 1); } diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index ef15fe02..599ad07c 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -32,8 +32,14 @@ impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); } + fn save(&mut self, storage: &mut dyn eframe::Storage) { + storage.set_string(PREFS_KEY, self.0.prefs()); + } } +/// Where the preferences (theme) are kept in eframe's storage. +const PREFS_KEY: &str = "solvecraft.prefs"; + fn services() -> Services { Services { pick_open: Some(Box::new(|| { @@ -91,6 +97,9 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = SolveApp::new(Session::default(), services()); + if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) { + app.load_prefs(&p); + } if let Some(rs) = &cc.wgpu_render_state { app.set_wgpu(rs, DEPTH_BITS, u32::from(MSAA)); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cbaee31d..e69f9277 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -95,6 +95,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" }, "frame_ms": app.frame_ms, "build_ms": app.viewport.build_ms, + "preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced}, })), "ui.set" => { let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({})); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5a335484..45af1225 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -702,6 +702,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if ui.add(egui::Button::new(close).min_size(vec2(70.0, 24.0))).clicked() { cancel = true; } + if app.preview.busy { + ui.add(egui::Spinner::new().size(14.0)).on_hover_text("Updating the preview"); + } }); }); // Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 732d4d6b..5eef3dcc 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -183,6 +183,22 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink); pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a); } + "sun" => { + pen.circle(12.0, 12.0, 4.5, a, ink); + for i in 0..8 { + let t = i as f32 / 8.0 * std::f32::consts::TAU; + pen.line(&[(12.0 + 7.0 * t.cos(), 12.0 + 7.0 * t.sin()), (12.0 + 9.5 * t.cos(), 12.0 + 9.5 * t.sin())], ink); + } + } + "moon" => { + let outer: Vec = + (0..=24).map(|i| -0.6 + i as f32 / 24.0 * 4.4).map(|t| pen.at(12.0 + 8.0 * t.cos(), 12.0 + 8.0 * t.sin())).collect(); + let inner: Vec = + (0..=24).rev().map(|i| -0.9 + i as f32 / 24.0 * 3.9).map(|t| pen.at(15.5 + 6.5 * t.cos(), 9.5 + 6.5 * t.sin())).collect(); + let mut pts = outer; + pts.extend(inner); + p.add(Shape::closed_line(pts, Stroke::new(pen.w, ink))); + } "presspull" => { pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink); pen.arrow((12.0, 15.0), (12.0, 3.0), a); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50bd5c5d..7f4c5609 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,8 @@ pub struct UiState { pub hidden_origin: Vec, pub show_sketches: bool, pub palette_open: bool, + /// Dark theme (the default); a preference kept between runs. + pub dark: bool, } impl Default for UiState { @@ -66,6 +68,7 @@ impl Default for UiState { hidden_origin: Vec::new(), show_sketches: true, palette_open: false, + dark: true, } } } @@ -143,6 +146,20 @@ impl SolveApp { self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples)); } + /// Preferences kept between runs (JSON), for the host to store. + pub fn prefs(&self) -> String { + json!({ "dark": self.ui.dark }).to_string() + } + + /// Restore preferences saved with [`SolveApp::prefs`]. + pub fn load_prefs(&mut self, prefs: &str) { + if let Ok(v) = serde_json::from_str::(prefs) + && let Some(d) = v.get("dark").and_then(Value::as_bool) + { + self.ui.dark = d; + } + } + pub fn with_control(mut self, rx: Receiver) -> Self { self.control_rx = Some(rx); self @@ -402,7 +419,8 @@ impl SolveApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { - if !self.styled { + if !self.styled || theme::is_dark() != self.ui.dark { + theme::set_dark(self.ui.dark); theme::apply(ctx); self.styled = true; } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b1cec5f4..3075dfaf 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,8 +1,17 @@ -//! Colours and widget style. A light theme in the spirit of desktop CAD tools; every colour -//! the UI uses comes from [`Tokens`]. +//! 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, @@ -60,13 +69,51 @@ pub struct Tokens { 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, + /// Sketch points: fully constrained, free. + pub sketch_point: Color32, + pub sketch_point_free: 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, + /// View cube face (shaded by orientation) and edges. + pub cube_face: Color32, + pub cube_edge: 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 { - pub const fn get() -> 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), @@ -109,18 +156,99 @@ impl Tokens { 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_point_free: Color32::from_rgb(30, 90, 200), + glyph_bg: Color32::WHITE, + glyph_edge: Color32::from_rgb(90, 150, 90), + glyph_text: Color32::from_rgb(40, 110, 40), + overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235), + cube_face: Color32::from_rgb(236, 236, 236), + cube_edge: Color32::from_rgb(140, 148, 160), + 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.tab_active = Color32::from_rgb(46, 52, 64); + 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_point_free = Color32::from_rgb(92, 150, 255); + t.glyph_bg = Color32::from_rgb(44, 50, 60); + t.glyph_edge = Color32::from_rgb(96, 170, 110); + t.glyph_text = Color32::from_rgb(150, 220, 160); + t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240); + t.cube_face = Color32::from_rgb(196, 200, 207); + t.cube_edge = Color32::from_rgb(100, 108, 122); + 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 + } +} + +/// Switch themes (the visuals follow on the next [`apply`]). +pub fn set_dark(dark: bool) { + DARK.store(dark, Ordering::Relaxed); } pub fn apply(ctx: &egui::Context) { let t = Tokens::get(); - let mut v = egui::Visuals::light(); + let mut v = if is_dark() { egui::Visuals::dark() } else { egui::Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.panel; - v.extreme_bg_color = Color32::WHITE; + 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); @@ -129,6 +257,7 @@ pub fn apply(ctx: &egui::Context) { v.widgets.hovered.weak_bg_fill = t.hover; v.widgets.hovered.bg_fill = t.hover; v.window_corner_radius = egui::CornerRadius::same(6); + ctx.set_theme(if is_dark() { egui::Theme::Dark } else { egui::Theme::Light }); ctx.set_visuals(v); ctx.global_style_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index fa267618..f6a297f8 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -142,13 +142,13 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { hovered = Some(*id); } let bg = if err.is_some() { - Color32::from_rgb(250, 220, 220) + t.timeline_item_error } else if sel { t.accent_soft } else if resp.hovered() || from_view == Some(*id) { t.hover } else { - Color32::WHITE + t.timeline_item }; let edge = if err.is_some() { t.error @@ -236,7 +236,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.drag_started() { drag = Some(Drag::Marker); } - let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { Color32::from_rgb(70, 76, 88) }; + let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { t.timeline_marker }; p.rect_filled(mr, 1.0, col); resp.on_hover_text("History marker: drag to roll the model back; new features go here"); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d6b09929..9cc62346 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -41,13 +41,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { // Brand mark: a small solid block drawn in code. let logo = Rect::from_center_size(pos2(r.left() + 20.0, r.center().y), vec2(20.0, 20.0)); icons::paint(ui.painter(), logo, "box", Color32::from_rgb(220, 226, 236), Color32::from_rgb(90, 160, 240), Color32::WHITE); - ui.painter().text( - pos2(r.left() + 36.0, r.center().y), - Align2::LEFT_CENTER, - "SolveCraft", - FontId::proportional(14.0), - Color32::from_rgb(230, 234, 242), - ); + ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, "SolveCraft", FontId::proportional(14.0), t.app_bar_text); let mut x = r.left() + 128.0; let mut click = |ui: &mut egui::Ui, icon: &str, tip: &str| -> bool { let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0)); @@ -76,6 +70,10 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { if click(ui, "redo", "Redo (Ctrl+Y)") { let _ = app.run("RedoCommand", json!({})); } + let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" }; + if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) { + app.ui.dark = !app.ui.dark; + } if file { app.ui.palette_open = false; let ctx = ui.ctx().clone(); @@ -206,7 +204,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { // Workspace selector and tabs. let tabs_y = r.top() + 2.0; let ws = Rect::from_min_size(pos2(r.left() + 8.0, tabs_y + 26.0), vec2(96.0, 56.0)); - painter.rect(ws, 4.0, Color32::WHITE, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.rect(ws, 4.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); painter.text(pos2(ws.center().x - 5.0, ws.top() + 14.0), Align2::CENTER_CENTER, "DESIGN", FontId::proportional(12.0), t.text); caret(&painter, pos2(ws.center().x + 26.0, ws.top() + 14.0), t.text); painter.text(pos2(ws.center().x, ws.top() + 34.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 18e38bbd..64ac7698 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -1,7 +1,7 @@ //! Interactive sketch tools: click points on the sketch plane (or pick sketch entities) and the //! tool runs the matching command. Tools are UI state only; the work is done by commands. -use egui::{Color32, Pos2, Shape, Stroke}; +use egui::{Pos2, Shape, Stroke}; use serde_json::{Value, json}; use solvecraft_engine::geom::Vec2; @@ -265,7 +265,8 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { let st = app.session.model.state(); let Some(ss) = st.sketch(sid) else { return }; let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); - let stroke = Stroke::new(1.5, Color32::from_rgb(30, 120, 230)); + let tk = crate::theme::Tokens::get(); + let stroke = Stroke::new(1.5, tk.rubber_band); if let Some((hp, snap)) = &t.hover && let Some(sp) = to(*hp) { @@ -275,7 +276,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { egui::Align2::LEFT_TOP, format!("{:.2}, {:.2}", hp.x, hp.y), egui::FontId::proportional(11.0), - Color32::from_rgb(60, 70, 90), + tk.text_dim, ); } let Some((h, _)) = t.hover.clone() else { return }; diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index b1a8165f..b0743e70 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -144,6 +144,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.ui.show_sketches.hash(&mut h); app.ui.hidden_bodies.hash(&mut h); app.preview.replaced.hash(&mut h); + app.ui.dark.hash(&mut h); app.session.active_sketch.hash(&mut h); let (minor, _) = grid_step(app.cam.half_height()); minor.to_bits().hash(&mut h); @@ -152,7 +153,19 @@ fn scene_key(app: &SolveApp) -> u64 { h.finish() } +/// A sketch curve colour from the engine, in the current theme. +fn sketch_color(t: &Tokens, c: Rgb) -> [u8; 4] { + if c == colors::SKETCH { + c4(t.sketch) + } else if c == colors::SKETCH_DONE || c == colors::SKETCH_FIXED { + c4(t.sketch_fixed) + } else { + rgba(c) + } +} + fn build_scene(app: &SolveApp) -> GpuScene { + let tk = Tokens::get(); let mut sc = GpuScene::default(); let s = &app.session; let st = s.model.state(); @@ -164,7 +177,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { for i in -n..=n { let t = i as f64 * minor; let maj = (t / major).round() * major == t; - let col = if maj { rgba(colors::GRID_MAJOR) } else { rgba(colors::GRID) }; + let col = if maj { c4(tk.grid_major) } else { c4(tk.grid) }; let w = if maj { 1.0 } else { 0.7 }; sc.line((c + Vec3::new(t, -ext, 0.0)).to_f32(), (c + Vec3::new(t, ext, 0.0)).to_f32(), col, w, false); sc.line((c + Vec3::new(-ext, t, 0.0)).to_f32(), (c + Vec3::new(ext, t, 0.0)).to_f32(), col, w, false); @@ -174,7 +187,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { if app.ui.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { continue; } - let col = rgba(colors::BODY); + let col = c4(tk.body); let m = b.mesh(); for t in &m.triangles { for k in t { @@ -189,7 +202,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { continue; } for w in e.windows(2) { - sc.line(w[0].to_f32(), w[1].to_f32(), rgba(colors::EDGE), 1.3, false); + sc.line(w[0].to_f32(), w[1].to_f32(), c4(tk.body_edge), 1.3, false); } } } @@ -198,7 +211,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { let active = s.active_sketch == Some(sid); for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { for w in pts.windows(2) { - sc.line(w[0].to_f32(), w[1].to_f32(), rgba(col), if cons { 1.2 } else { 2.0 }, active); + sc.line(w[0].to_f32(), w[1].to_f32(), sketch_color(&tk, col), if cons { 1.2 } else { 2.0 }, active); } } } @@ -505,6 +518,7 @@ fn highlight_key(app: &SolveApp) -> u64 { serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h); format!("{:?}", app.viewport.hover).hash(&mut h); app.viewport.hover_feature.hash(&mut h); + app.ui.dark.hash(&mut h); app.preview.replaced.hash(&mut h); app.origin_visible().hash(&mut h); app.ui.hidden_origin.hash(&mut h); @@ -573,13 +587,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { { let m = b.mesh(); for f in 0..b.body.face_count() { - face_tris( - &mut sc, - &m, - f, - c4(Color32::from_rgb(colors::BODY_SELECTED.0, colors::BODY_SELECTED.1, colors::BODY_SELECTED.2)), - true, - ); + face_tris(&mut sc, &m, f, c4(t.body_selected), true); } } } @@ -609,7 +617,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { // Bodies made by the timeline item under the cursor. if let Some(fid) = app.viewport.hover_feature { let k = t.hover_face_lift; - let c = colors::BODY; + let c = (t.body.r(), t.body.g(), t.body.b()); for b in st.bodies.iter().filter(|b| b.feature == fid && !app.ui.hidden_bodies.contains(&b.name)) { let m = b.mesh(); for f in 0..b.body.face_count() { @@ -630,7 +638,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene { { if let Some(b) = st.body(body) { let k = t.hover_face_lift; - let c = colors::BODY; + let c = (t.body.r(), t.body.g(), t.body.b()); face_tris(&mut sc, &b.mesh(), *index, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); let m = b.mesh(); for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) { @@ -790,8 +798,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let proj = projection(app, rect); let painter = ui.painter_at(rect); // Background gradient. - let top = Color32::from_rgb(colors::BG_TOP.0, colors::BG_TOP.1, colors::BG_TOP.2); - let bot = Color32::from_rgb(colors::BG_BOTTOM.0, colors::BG_BOTTOM.1, colors::BG_BOTTOM.2); + let (top, bot) = (t.viewport_top, t.viewport_bottom); let mut mesh = egui::Mesh::default(); mesh.colored_vertex(rect.left_top(), top); mesh.colored_vertex(rect.right_top(), top); @@ -911,7 +918,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let pos = pos2(rect.left() + 12.0, rect.bottom() - 46.0); let galley = painter.layout_no_wrap(msg, FontId::proportional(12.5), if err { t.error } else { t.text }); let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0)); - painter.rect_filled(r, 4.0, Color32::from_white_alpha(230)); + painter.rect_filled(r, 4.0, t.overlay); painter.galley(pos + vec2(8.0, 4.0), galley, t.text); ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } else { @@ -1081,9 +1088,9 @@ fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj for (i, p) in sk.points.iter().enumerate() { let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) else { continue }; let det = ss.report.point_determined.get(i).copied().unwrap_or(false); - let c = if det { Color32::BLACK } else { Color32::from_rgb(30, 90, 200) }; + let c = if det { t.sketch_point } else { t.sketch_point_free }; if i == 0 { - painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, Color32::BLACK)); + painter.circle(sp, 3.5, Color32::from_rgb(240, 200, 60), Stroke::new(1.0, t.body_edge)); } else { painter.rect_filled(Rect::from_center_size(sp, vec2(5.0, 5.0)), 0.0, c); } @@ -1104,7 +1111,7 @@ fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj }; let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text); let r = Rect::from_center_size(a, galley.size() + vec2(8.0, 4.0)); - painter.rect(r, 2.0, Color32::from_white_alpha(235), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.rect(r, 2.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); painter.galley(r.min + vec2(4.0, 2.0), galley, t.text); let resp = ui.interact(r, ui.id().with(("dim", param.as_str())), Sense::click()); if resp.double_clicked() || resp.clicked() && app.tool.is_none() { @@ -1174,9 +1181,10 @@ fn glyph(painter: &egui::Painter, proj: &Proj, ss: &solvecraft_engine::doc::Solv }; let Some(a) = anchor.and_then(|a| proj.to_screen(ss.plane.to_world(a))) else { return }; let r = Rect::from_center_size(a + vec2(10.0, -10.0), vec2(13.0, 13.0)); - painter.rect_filled(r, 2.0, Color32::from_rgb(255, 255, 255)); - painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(90, 150, 90)), egui::StrokeKind::Inside); - painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), Color32::from_rgb(40, 110, 40)); + let t = Tokens::get(); + painter.rect_filled(r, 2.0, t.glyph_bg); + painter.rect_stroke(r, 2.0, Stroke::new(1.0, t.glyph_edge), egui::StrokeKind::Inside); + painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(10.0), t.glyph_text); } fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) { @@ -1238,11 +1246,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let pts: Vec = corners.iter().map(|p| to2(*p).0).collect(); let poly_hover = hover.is_some_and(|h| point_in_poly(h, &pts)); let shade = (0.75 + 0.25 * n.dot(b)) as f32; - painter.add(Shape::convex_polygon( - pts.clone(), - Color32::from_gray((236.0 * shade) as u8), - Stroke::new(1.0, Color32::from_rgb(140, 148, 160)), - )); + painter.add(Shape::convex_polygon(pts.clone(), t.cube_face.gamma_multiply(shade).to_opaque(), Stroke::new(1.0, t.cube_edge))); // Hover zone: the outer band of a face picks the edge or corner it borders. let zone = hover.filter(|_| poly_hover).and_then(|h| cube_zone(&pts, h)); if let Some((zi, zj)) = zone { @@ -1257,7 +1261,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } let center = to2(n).0; if n.dot(b) > 0.35 { - painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), t.text); + painter.text(center, Align2::CENTER_CENTER, label, FontId::proportional(9.5), Color32::from_rgb(40, 44, 52)); } if let Some((zi, zj)) = zone && ui.input(|i| i.pointer.primary_clicked()) @@ -1274,7 +1278,7 @@ fn view_cube(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } let home = Rect::from_center_size(pos2(c.x - 52.0, c.y - 48.0), vec2(18.0, 18.0)); let hr = ui.interact(home, ui.id().with("vc_home"), Sense::click()); - icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { Color32::WHITE }, t.accent); + icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent); if hr.on_hover_text("Home view").clicked() { app.animate_view("home"); } @@ -1345,7 +1349,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let w = items.len() as f32 * 30.0 + 10.0; let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)); let painter = ui.painter_at(rect); - painter.rect(bar, 6.0, Color32::from_white_alpha(235), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); for (i, (icon, tip)) in items.iter().enumerate() { let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), vec2(26.0, 26.0)); let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 96aa9245..41a093b5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -21,8 +21,8 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | UI state, viewport rect, camera, active tool/dialog, hover, renderer, timings | -| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, …) | | +| `ui.inspect` | — | UI state, viewport rect, camera, active tool/dialog, hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | +| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, `dark`, …) | `dark: false` switches to the light theme | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | | `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?` | real pointer input |