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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 22:30:36 +00:00
1 parent 71a1903122
commit 956492c993
14 files changed
+290 -53

No files matched your search

Generated
+49
View File
@@ -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"
+1 -1
View File
@@ -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"
+9
View File
@@ -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);
}
+9
View File
@@ -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));
}
+1
View File
@@ -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!({}));
+3
View File
@@ -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.
+16
View File
@@ -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<Pos2> =
(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<Pos2> =
(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);
+19 -1
View File
@@ -51,6 +51,8 @@ pub struct UiState {
pub hidden_origin: Vec<String>,
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::<Value>(prefs)
&& let Some(d) = v.get("dark").and_then(Value::as_bool)
{
self.ui.dark = d;
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> 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;
}
+134 -5
View File
@@ -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);
+3 -3
View File
@@ -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");
}
+6 -8
View File
@@ -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);
+4 -3
View File
@@ -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 };
+34 -30
View File
@@ -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<Pos2> = 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());
+2 -2
View File
@@ -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 |