Implement the reviewed UI icon system and durable review workflow (#853)
Thanks @Nathan-Dane.
This commit is contained in:
1 parent
c599238653
commit
64966bab88
374 files changed
+8187
-2221
No files matched your search
@@ -1363,7 +1363,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("p:{}", f.id),
|
||||
label: f.name.clone(),
|
||||
icon: "plane",
|
||||
icon: crate::timeline::icon_of(&f.kind),
|
||||
visible: !app.session.visibility.hidden_origin.contains(&f.name),
|
||||
selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)),
|
||||
color: None,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
//! axis tags, the surface analysis colours and every colour an icon draws. These tests hold that
|
||||
//! the tokens equal what was drawn before, and that a new colour cannot skip them.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, vec2};
|
||||
use egui::Color32;
|
||||
use solidcraft_engine::render::Rgb;
|
||||
use solidcraft_engine::view::colors;
|
||||
|
||||
use crate::theme::{IconColors, Tokens};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
fn rgb(c: Rgb) -> Color32 {
|
||||
Color32::from_rgb(c.0, c.1, c.2)
|
||||
@@ -56,103 +56,10 @@ fn analysis_colours_reach_the_shader_in_its_order() {
|
||||
assert_eq!(crate::gpu::analysis_colors(&striped)[32], 1.0);
|
||||
}
|
||||
|
||||
/// Every name a `match` arm in the icon source draws.
|
||||
fn icon_names() -> Vec<&'static str> {
|
||||
let src = include_str!("../../../assets/icons/ui/source/icons.rs");
|
||||
let body = src.split("#[cfg(test)]").next().unwrap_or(src);
|
||||
let mut names = Vec::new();
|
||||
for line in body.lines() {
|
||||
let line = line.trim_start();
|
||||
if !line.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
let Some((pattern, _)) = line.split_once("=>") else { continue };
|
||||
let pattern = pattern.split(" if ").next().unwrap_or(pattern);
|
||||
for part in pattern.split('|') {
|
||||
let part = part.trim();
|
||||
if part.len() > 2 && part.starts_with('"') && part.ends_with('"') {
|
||||
names.push(&part[1..part.len() - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// Marker icon colours: every field its own colour, from `seed`.
|
||||
fn marker_icons(seed: u8) -> IconColors {
|
||||
let c = |i: u8| Color32::from_rgb(seed, i, seed ^ i);
|
||||
IconColors {
|
||||
sheet: c(1),
|
||||
sheet_2: c(2),
|
||||
sheet_light: c(3),
|
||||
sheet_line: c(4),
|
||||
highlight: c(5),
|
||||
glint: c(6),
|
||||
folder: c(7),
|
||||
plane: c(8),
|
||||
fold: c(9),
|
||||
stripe: c(10),
|
||||
sky: c(11),
|
||||
floor: c(12),
|
||||
paint: [c(13), c(14), c(15)],
|
||||
axes: [c(16), c(17), c(18)],
|
||||
access: [c(19), c(20)],
|
||||
draft: [c(21), c(22)],
|
||||
curvature: [c(23), c(24), c(25)],
|
||||
interference: c(26),
|
||||
warning: c(27),
|
||||
agent: c(28),
|
||||
marker_halo: c(29),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours an icon draws (premultiplied `#RR_GG_BB_AA`, in drawing order).
|
||||
fn drawn_colours(name: &str, seed: u8) -> Vec<String> {
|
||||
let ctx = egui::Context::default();
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0))), ..Default::default() };
|
||||
let icons = marker_icons(seed);
|
||||
let out = ctx.run_ui(input, |ui| {
|
||||
let r = Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0));
|
||||
let ink = Color32::from_rgb(seed, 101, 1);
|
||||
let fill = Color32::from_rgb(seed, 102, 2);
|
||||
let accent = Color32::from_rgb(seed, 103, 3);
|
||||
crate::icons::paint_with(ui.painter(), r, name, ink, fill, accent, &icons);
|
||||
});
|
||||
let text = format!("{:?}", out.shapes);
|
||||
let b = text.as_bytes();
|
||||
let mut colours = Vec::new();
|
||||
for i in 0..b.len() {
|
||||
let hex = |k: usize| b.get(i + k).is_some_and(u8::is_ascii_hexdigit);
|
||||
if b[i] == b'#' && (1..=11).all(|k| if k % 3 == 0 { b.get(i + k) == Some(&b'_') } else { hex(k) }) {
|
||||
colours.push(text[i..i + 12].to_string());
|
||||
}
|
||||
}
|
||||
colours
|
||||
}
|
||||
|
||||
/// "All icons past, present and future" follow the colour vision mode: an icon may draw only the
|
||||
/// colours its caller passes (ink, fill, accent) and the `IconColors` tokens, or colours made
|
||||
/// from them. Each icon is drawn twice with different marker colours; a colour that comes out the
|
||||
/// same both times was written into the icon, and a mode could not reach it.
|
||||
#[test]
|
||||
fn every_icon_draws_only_colours_a_mode_can_reach() {
|
||||
let names = icon_names();
|
||||
assert!(names.len() > 150, "icon names were not found in the icon source: {}", names.len());
|
||||
let mut fixed = Vec::new();
|
||||
for name in &names {
|
||||
let (a, b) = (drawn_colours(name, 40), drawn_colours(name, 200));
|
||||
assert_eq!(a.len(), b.len(), "{name}: the two drawings differ in shape");
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
if x == y && !x.ends_with("_00") {
|
||||
fixed.push(format!("{name}: {x}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
fixed.dedup();
|
||||
assert!(fixed.is_empty(), "icons that draw a colour of their own: {fixed:?}");
|
||||
}
|
||||
// Icons no longer take their colours from the caller or from `IconColors`: the SVG masters name
|
||||
// palette roles (`assets/icons/ui/design/palette.toml`, light and dark, plus accents) that
|
||||
// `icon_svg` resolves. `icon_svg`'s own tests hold that every master parses and that only the
|
||||
// palette's colours exist; the colour vision modes do not reach them yet.
|
||||
|
||||
/// The most colour literals each UI file may hold, and why. None of them carries a meaning that a
|
||||
/// colour vision mode must change. A file with more fails here: make the new colour a token in
|
||||
@@ -171,6 +78,7 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
|
||||
("documents.rs", 4, "white hover overlays on document tabs"),
|
||||
("frame.rs", 1, "the window shadow"),
|
||||
("gizmo.rs", 2, "the gizmo's neutral grey ball"),
|
||||
("icons.rs", 1, "the white tint that carries an icon image's opacity"),
|
||||
("home.rs", 4, "white ink on accent buttons, and thumbnail tints"),
|
||||
("ref_images.rs", 1, "a reference image's opacity tint"),
|
||||
("sketch_palette.rs", 1, "white text on the accent button"),
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! The instruments are checked first: CIEDE2000 on the 34 pairs Sharma, Wu and Dalal published
|
||||
//! with their paper, Brettel on white and its own anchors.
|
||||
//!
|
||||
//! When a token edit breaks a mode here, give that token a value in the mode's table
|
||||
//! (`assets/styles/ui/palettes.rs`) that passes.
|
||||
//! When a token edit breaks a mode here, adjust its measured variant. Shared icon accents live
|
||||
//! in `assets/icons/ui/design/palette.toml`; `assets/styles/ui/palettes.rs` links those generated
|
||||
//! values to UI tokens and owns the remaining UI-only colour-vision adjustments.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -627,14 +628,14 @@ fn each_mode_changes_only_its_table() {
|
||||
"preview_sketch",
|
||||
];
|
||||
let mut red_green_light = red_green_both.to_vec();
|
||||
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error"]);
|
||||
red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error", "construction_geom"]);
|
||||
for (dark, mode, want) in [
|
||||
(false, ColorVision::Protan, set(&red_green_light)),
|
||||
(true, ColorVision::Protan, set(&red_green_both)),
|
||||
(false, ColorVision::Deutan, set(&red_green_light)),
|
||||
(true, ColorVision::Deutan, set(&red_green_both)),
|
||||
(false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "preview_sketch", "analysis.hatch"])),
|
||||
(true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "glyph_text", "preview_sketch", "analysis.hatch"])),
|
||||
(false, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "preview_sketch", "analysis.hatch"])),
|
||||
(true, ColorVision::Tritan, set(&["sketch_locked", "box_crossing", "construction_geom", "glyph_text", "preview_sketch", "analysis.hatch"])),
|
||||
] {
|
||||
let base = Tokens::themed(dark, ColorVision::Off);
|
||||
assert_eq!(changed_fields(&base, &Tokens::themed(dark, mode)), want, "{mode:?}, dark {dark}");
|
||||
|
||||
@@ -342,8 +342,8 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
Target::Workspace => {
|
||||
let rendering = crate::render_workspace::active(app);
|
||||
let mut v = vec![
|
||||
act("ui.workspace.design", "Design", "box").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
|
||||
act("ui.workspace.render", "Render", "camera")
|
||||
act("ui.workspace.design", "Design", "ws_design").with(json!({"workspace":"design"})).key(if rendering { "" } else { "current" }),
|
||||
act("ui.workspace.render", "Render", "ws_render")
|
||||
.with(json!({"workspace":"render"}))
|
||||
.key(if rendering { "current" } else { "" })
|
||||
.on(app.session.active_sketch.is_none() && app.tool.is_none() && app.dialog.is_none() && !app.file_load.busy()),
|
||||
|
||||
@@ -108,11 +108,12 @@ impl SolveApp {
|
||||
let _ = tx.send(());
|
||||
})
|
||||
.ok()?;
|
||||
match rx.recv_timeout(wait) {
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
|
||||
}
|
||||
_ => None,
|
||||
// Zero explicitly requests off-thread application, even if a fast worker
|
||||
// finishes before this thread is scheduled again. Apply on the next frame.
|
||||
if wait.is_zero() || matches!(rx.recv_timeout(wait), Err(std::sync::mpsc::RecvTimeoutError::Timeout)) {
|
||||
Some(Deferred { done: rx, started: crate::now_ms(), command, revision: self.session.revision })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,894 @@
|
||||
//! The UI icon renderer: the SVG masters in `assets/icons/ui/design/svg`, drawn at the pixel size
|
||||
//! they are shown.
|
||||
//!
|
||||
//! The masters are a small, linted SVG subset (`tools/lint_icons.py`): absolute `M L C Q A Z`
|
||||
//! paths, circles, groups with a clip or opacity, masks and clip paths. They are embedded at build
|
||||
//! time together with `palette.toml` (every colour role in light and dark, and the accents) and
|
||||
//! `aliases.toml` (see `build.rs`), parsed once, and rasterised with tiny-skia into a texture per
|
||||
//! (icon, accent, theme, pixel size). A master never carries a colour of its own: it names a
|
||||
//! palette role, which resolves to the theme's hex, or to the accent's hex for the `active-*`
|
||||
//! roles. GAP instead erases preceding artwork to transparency; it never paints a background.
|
||||
//! Changing a colour is an edit to `palette.toml` only.
|
||||
//!
|
||||
//! Nothing here panics on bad artwork: a master that does not parse is logged and drawn as the
|
||||
//! placeholder, and `every_master_parses` holds that none do.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use tiny_skia as sk;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/icon_data.rs"));
|
||||
|
||||
/// The id drawn for every icon that has no master yet.
|
||||
pub const PLACEHOLDER: &str = "placeholder";
|
||||
|
||||
/// How a shape is painted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Paint {
|
||||
None,
|
||||
/// Erase earlier artwork; never paint a theme background into the texture.
|
||||
Knockout,
|
||||
/// `currentColor`: the `line` role.
|
||||
Line,
|
||||
/// A palette role (index into `ROLE_NAMES`).
|
||||
Role(usize),
|
||||
/// A fixed colour (mask artwork).
|
||||
Fixed([u8; 3]),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Node {
|
||||
path: sk::Path,
|
||||
fill: Paint,
|
||||
rule: sk::FillRule,
|
||||
stroke: Paint,
|
||||
width: f32,
|
||||
dash: Vec<f32>,
|
||||
opacity: f32,
|
||||
mask: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Item {
|
||||
Node(Node),
|
||||
Group { clip: Option<usize>, opacity: f32, items: Vec<Item> },
|
||||
}
|
||||
|
||||
/// A parsed master.
|
||||
#[derive(Debug)]
|
||||
pub struct Doc {
|
||||
items: Vec<Item>,
|
||||
masks: Vec<Vec<Node>>,
|
||||
clips: Vec<Vec<Node>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Parsing
|
||||
|
||||
struct Tag<'a> {
|
||||
name: &'a str,
|
||||
attrs: Vec<(&'a str, &'a str)>,
|
||||
close: bool,
|
||||
selfclose: bool,
|
||||
}
|
||||
|
||||
impl Tag<'_> {
|
||||
fn get(&self, k: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(n, _)| *n == k).map(|(_, v)| *v)
|
||||
}
|
||||
fn num(&self, k: &str) -> Result<f32, String> {
|
||||
self.get(k).ok_or_else(|| format!("missing {k}"))?.trim().parse::<f32>().map_err(|e| format!("{k}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(src: &str) -> Result<Vec<Tag<'_>>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = src;
|
||||
while let Some(i) = rest.find('<') {
|
||||
let after = rest.get(i + 1..).ok_or("truncated")?;
|
||||
let j = after.find('>').ok_or("unterminated tag")?;
|
||||
let body = after.get(..j).ok_or("truncated")?;
|
||||
rest = after.get(j + 1..).ok_or("truncated")?;
|
||||
if body.starts_with('?') || body.starts_with('!') {
|
||||
continue;
|
||||
}
|
||||
let (close, body) = match body.strip_prefix('/') {
|
||||
Some(b) => (true, b),
|
||||
None => (false, body),
|
||||
};
|
||||
let (selfclose, body) = match body.strip_suffix('/') {
|
||||
Some(b) => (true, b),
|
||||
None => (false, body),
|
||||
};
|
||||
let body = body.trim();
|
||||
let name_end = body.find(char::is_whitespace).unwrap_or(body.len());
|
||||
let name = body.get(..name_end).ok_or("bad tag")?;
|
||||
let mut attrs = Vec::new();
|
||||
let mut a = body.get(name_end..).unwrap_or("");
|
||||
while let Some(eq) = a.find('=') {
|
||||
let key = a.get(..eq).ok_or("bad attribute")?.trim();
|
||||
let v = a.get(eq + 1..).ok_or("bad attribute")?.trim_start();
|
||||
let q = v.chars().next().filter(|c| *c == '"' || *c == '\'').ok_or("unquoted attribute")?;
|
||||
let v = v.get(1..).ok_or("bad attribute")?;
|
||||
let end = v.find(q).ok_or("unterminated attribute")?;
|
||||
attrs.push((key, v.get(..end).ok_or("bad attribute")?));
|
||||
a = v.get(end + 1..).unwrap_or("");
|
||||
}
|
||||
out.push(Tag { name, attrs, close, selfclose });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An SVG elliptical arc as cubic Béziers appended to `pb`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn arc(pb: &mut sk::PathBuilder, x1: f32, y1: f32, rx: f32, ry: f32, rot_deg: f32, large: bool, sweep: bool, x2: f32, y2: f32) {
|
||||
let (mut rx, mut ry) = (rx.abs(), ry.abs());
|
||||
if rx < 1e-6 || ry < 1e-6 || ((x1 - x2).abs() < 1e-6 && (y1 - y2).abs() < 1e-6) {
|
||||
pb.line_to(x2, y2);
|
||||
return;
|
||||
}
|
||||
let phi = rot_deg.to_radians();
|
||||
let (sp, cp) = phi.sin_cos();
|
||||
let (dx, dy) = ((x1 - x2) / 2.0, (y1 - y2) / 2.0);
|
||||
let (x1p, y1p) = (cp * dx + sp * dy, -sp * dx + cp * dy);
|
||||
let lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
|
||||
if lam > 1.0 {
|
||||
let s = lam.sqrt();
|
||||
rx *= s;
|
||||
ry *= s;
|
||||
}
|
||||
let num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
|
||||
let den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
|
||||
let sign = if large == sweep { -1.0 } else { 1.0 };
|
||||
let coef = sign * (num / den).max(0.0).sqrt();
|
||||
let (cxp, cyp) = (coef * rx * y1p / ry, -coef * ry * x1p / rx);
|
||||
let (cx, cy) = (cp * cxp - sp * cyp + (x1 + x2) / 2.0, sp * cxp + cp * cyp + (y1 + y2) / 2.0);
|
||||
let ang = |ux: f32, uy: f32, vx: f32, vy: f32| (ux * vy - uy * vx).atan2(ux * vx + uy * vy);
|
||||
let (ux, uy) = ((x1p - cxp) / rx, (y1p - cyp) / ry);
|
||||
let (vx, vy) = ((-x1p - cxp) / rx, (-y1p - cyp) / ry);
|
||||
let theta = ang(1.0, 0.0, ux, uy);
|
||||
let mut dtheta = ang(ux, uy, vx, vy);
|
||||
if !sweep && dtheta > 0.0 {
|
||||
dtheta -= std::f32::consts::TAU;
|
||||
} else if sweep && dtheta < 0.0 {
|
||||
dtheta += std::f32::consts::TAU;
|
||||
}
|
||||
let n = ((dtheta.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8);
|
||||
let delta = dtheta / n as f32;
|
||||
let t = 4.0 / 3.0 * (delta / 4.0).tan();
|
||||
let map = |x: f32, y: f32| (cx + rx * cp * x - ry * sp * y, cy + rx * sp * x + ry * cp * y);
|
||||
for i in 0..n {
|
||||
let a1 = theta + delta * i as f32;
|
||||
let a2 = a1 + delta;
|
||||
let (s1, c1) = a1.sin_cos();
|
||||
let (s2, c2) = a2.sin_cos();
|
||||
let (p1x, p1y) = map(c1 - t * s1, s1 + t * c1);
|
||||
let (p2x, p2y) = map(c2 + t * s2, s2 - t * c2);
|
||||
let (ex, ey) = map(c2, s2);
|
||||
pb.cubic_to(p1x, p1y, p2x, p2y, ex, ey);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_path(d: &str) -> Result<sk::Path, String> {
|
||||
let mut toks: Vec<&str> = Vec::new();
|
||||
let b = d.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let c = *b.get(i).ok_or("path")?;
|
||||
if c.is_ascii_alphabetic() {
|
||||
toks.push(d.get(i..=i).ok_or("path")?);
|
||||
i += 1;
|
||||
} else if c == b'-' || c == b'.' || c.is_ascii_digit() {
|
||||
let s = i;
|
||||
i += 1;
|
||||
while let Some(&n) = b.get(i) {
|
||||
if n.is_ascii_digit()
|
||||
|| n == b'.'
|
||||
|| ((n == b'-' || n == b'+') && matches!(b.get(i - 1), Some(b'e' | b'E')))
|
||||
|| n == b'e'
|
||||
|| n == b'E'
|
||||
{
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
toks.push(d.get(s..i).ok_or("path")?);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut pb = sk::PathBuilder::new();
|
||||
let (mut k, mut cmd) = (0usize, ' ');
|
||||
let (mut x, mut y) = (0.0f32, 0.0f32);
|
||||
let mut open = false;
|
||||
let num = |k: &mut usize| -> Result<f32, String> {
|
||||
let t = toks.get(*k).ok_or("path: missing number")?;
|
||||
*k += 1;
|
||||
t.parse::<f32>().map_err(|e| format!("path: {e}"))
|
||||
};
|
||||
while k < toks.len() {
|
||||
let t = *toks.get(k).ok_or("path")?;
|
||||
if t.len() == 1 && t.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) {
|
||||
cmd = t.chars().next().ok_or("path")?;
|
||||
k += 1;
|
||||
}
|
||||
match cmd {
|
||||
'M' => {
|
||||
(x, y) = (num(&mut k)?, num(&mut k)?);
|
||||
pb.move_to(x, y);
|
||||
open = true;
|
||||
cmd = 'L';
|
||||
}
|
||||
'L' => {
|
||||
(x, y) = (num(&mut k)?, num(&mut k)?);
|
||||
pb.line_to(x, y);
|
||||
}
|
||||
'C' => {
|
||||
let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?];
|
||||
pb.cubic_to(v[0], v[1], v[2], v[3], v[4], v[5]);
|
||||
(x, y) = (v[4], v[5]);
|
||||
}
|
||||
'Q' => {
|
||||
let v = [num(&mut k)?, num(&mut k)?, num(&mut k)?, num(&mut k)?];
|
||||
pb.quad_to(v[0], v[1], v[2], v[3]);
|
||||
(x, y) = (v[2], v[3]);
|
||||
}
|
||||
'A' => {
|
||||
let (rx, ry, rot) = (num(&mut k)?, num(&mut k)?, num(&mut k)?);
|
||||
let (fa, fs) = (num(&mut k)? != 0.0, num(&mut k)? != 0.0);
|
||||
let (ex, ey) = (num(&mut k)?, num(&mut k)?);
|
||||
arc(&mut pb, x, y, rx, ry, rot, fa, fs, ex, ey);
|
||||
(x, y) = (ex, ey);
|
||||
}
|
||||
'Z' => {
|
||||
if open {
|
||||
pb.close();
|
||||
}
|
||||
}
|
||||
other => return Err(format!("path command {other} is not supported")),
|
||||
}
|
||||
}
|
||||
pb.finish().ok_or_else(|| "empty path".to_string())
|
||||
}
|
||||
|
||||
fn parse_paint(v: Option<&str>, in_mask: bool) -> Result<Paint, String> {
|
||||
let Some(v) = v else { return Ok(Paint::None) };
|
||||
match v {
|
||||
"none" => Ok(Paint::None),
|
||||
"currentColor" => Ok(Paint::Line),
|
||||
"white" if in_mask => Ok(Paint::Fixed([255, 255, 255])),
|
||||
"black" if in_mask => Ok(Paint::Fixed([0, 0, 0])),
|
||||
_ => {
|
||||
let lower = v.to_ascii_lowercase();
|
||||
ROLE_SOURCE_HEX
|
||||
.iter()
|
||||
.position(|h| *h == lower)
|
||||
.map(|role| if ROLE_NAMES.get(role) == Some(&"gap") { Paint::Knockout } else { Paint::Role(role) })
|
||||
.ok_or_else(|| format!("colour {v} is not a palette colour"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dash(v: Option<&str>) -> Vec<f32> {
|
||||
let mut d: Vec<f32> = v.map(|s| s.split([' ', ',']).filter_map(|n| n.parse().ok()).collect()).unwrap_or_default();
|
||||
if d.len() % 2 == 1 {
|
||||
d.extend(d.clone());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn url_ref(v: &str) -> Option<&str> {
|
||||
v.strip_prefix("url(#")?.strip_suffix(')')
|
||||
}
|
||||
|
||||
enum Frame {
|
||||
Group { clip: Option<usize>, opacity: f32, items: Vec<Item> },
|
||||
Mask(usize),
|
||||
Clip(usize),
|
||||
Defs,
|
||||
}
|
||||
|
||||
impl Doc {
|
||||
pub fn parse(src: &str) -> Result<Doc, String> {
|
||||
let mut doc = Doc { items: Vec::new(), masks: Vec::new(), clips: Vec::new() };
|
||||
let (mut mask_ids, mut clip_ids): (HashMap<String, usize>, HashMap<String, usize>) = (HashMap::new(), HashMap::new());
|
||||
let mut stack: Vec<Frame> = Vec::new();
|
||||
for t in tags(src)? {
|
||||
match (t.name, t.close) {
|
||||
("svg" | "title", _) => {}
|
||||
("defs", false) => stack.push(Frame::Defs),
|
||||
("mask", false) => {
|
||||
doc.masks.push(Vec::new());
|
||||
mask_ids.insert(t.get("id").ok_or("mask without id")?.to_string(), doc.masks.len() - 1);
|
||||
stack.push(Frame::Mask(doc.masks.len() - 1));
|
||||
}
|
||||
("clipPath", false) => {
|
||||
doc.clips.push(Vec::new());
|
||||
clip_ids.insert(t.get("id").ok_or("clipPath without id")?.to_string(), doc.clips.len() - 1);
|
||||
stack.push(Frame::Clip(doc.clips.len() - 1));
|
||||
}
|
||||
("g", false) => {
|
||||
let clip = match t.get("clip-path").and_then(url_ref) {
|
||||
Some(id) => Some(*clip_ids.get(id).ok_or("unknown clip")?),
|
||||
None => None,
|
||||
};
|
||||
let opacity = t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0);
|
||||
stack.push(Frame::Group { clip, opacity, items: Vec::new() });
|
||||
}
|
||||
("defs" | "mask" | "clipPath" | "g", true) => match stack.pop() {
|
||||
Some(Frame::Group { clip, opacity, items }) => {
|
||||
let item = Item::Group { clip, opacity, items };
|
||||
match stack.last_mut() {
|
||||
Some(Frame::Group { items, .. }) => items.push(item),
|
||||
_ => doc.items.push(item),
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => return Err("unbalanced tags".into()),
|
||||
},
|
||||
("path" | "circle", _) => {
|
||||
let in_mask = matches!(stack.last(), Some(Frame::Mask(_)));
|
||||
let path = if t.name == "path" {
|
||||
parse_path(t.get("d").ok_or("path without d")?)?
|
||||
} else {
|
||||
sk::PathBuilder::from_circle(t.num("cx")?, t.num("cy")?, t.num("r")?).ok_or("bad circle")?
|
||||
};
|
||||
let mask = match t.get("mask").and_then(url_ref) {
|
||||
Some(id) => Some(*mask_ids.get(id).ok_or("unknown mask")?),
|
||||
None => None,
|
||||
};
|
||||
let dash = parse_dash(t.get("stroke-dasharray"));
|
||||
let mut stroke = parse_paint(t.get("stroke"), in_mask)?;
|
||||
let visible_stroke = match stroke {
|
||||
Paint::Line => true,
|
||||
Paint::Role(role) => !matches!(ROLE_NAMES.get(role).copied(), Some("gap" | "active-1" | "active-2" | "active-3")),
|
||||
_ => false,
|
||||
};
|
||||
// Non-blue dotted/dashed references share the guide colour.
|
||||
// Blue source paints retain their semantic accent; keep
|
||||
// background knockouts and literal mask paints unchanged.
|
||||
if !dash.is_empty() && visible_stroke && !in_mask {
|
||||
stroke = Paint::Role(ROLE_NAMES.iter().position(|name| *name == "guide").ok_or("missing guide colour")?);
|
||||
}
|
||||
let node = Node {
|
||||
path,
|
||||
fill: if t.get("fill").is_none() && in_mask { Paint::None } else { parse_paint(t.get("fill").or(Some("none")), in_mask)? },
|
||||
rule: if t.get("fill-rule") == Some("evenodd") { sk::FillRule::EvenOdd } else { sk::FillRule::Winding },
|
||||
stroke,
|
||||
width: t.get("stroke-width").and_then(|w| w.parse().ok()).unwrap_or(1.0),
|
||||
dash,
|
||||
opacity: t.get("opacity").and_then(|o| o.parse().ok()).unwrap_or(1.0),
|
||||
mask,
|
||||
};
|
||||
match stack.last_mut() {
|
||||
Some(Frame::Mask(i)) => doc.masks.get_mut(*i).ok_or("mask")?.push(node),
|
||||
Some(Frame::Clip(i)) => doc.clips.get_mut(*i).ok_or("clip")?.push(node),
|
||||
Some(Frame::Group { items, .. }) => items.push(Item::Node(node)),
|
||||
_ => doc.items.push(Item::Node(node)),
|
||||
}
|
||||
}
|
||||
(other, _) => return Err(format!("element <{other}> is not supported")),
|
||||
}
|
||||
let _ = t.selfclose;
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
return Err("unbalanced tags".into());
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Rendering
|
||||
|
||||
/// The colours of one drawing: the theme and an accent (index into `ACCENT_NAMES`, or `None` for
|
||||
/// the base palette).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Look {
|
||||
pub dark: bool,
|
||||
pub accent: Option<usize>,
|
||||
pub vision: crate::theme::ColorVision,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
fn rgb(self, role: usize) -> [u8; 3] {
|
||||
let theme = usize::from(self.dark);
|
||||
if ROLE_NAMES.get(role) == Some(&"guide") {
|
||||
return PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]);
|
||||
}
|
||||
let vision = match self.vision {
|
||||
crate::theme::ColorVision::Off => "",
|
||||
crate::theme::ColorVision::Protan | crate::theme::ColorVision::Deutan => "red-green",
|
||||
crate::theme::ColorVision::Tritan => "blue-yellow",
|
||||
};
|
||||
if let Some((_, _, _, l, d)) = ACCENT_VARIANTS.iter().find(|(v, a, r, _, _)| *v == vision && Some(*a) == self.accent && *r == role) {
|
||||
return if self.dark { *d } else { *l };
|
||||
}
|
||||
if let Some((_, l, d)) = self.accent.and_then(|a| ACCENTS.get(a)).and_then(|items| items.iter().find(|(r, _, _)| *r == role)) {
|
||||
return if self.dark { *d } else { *l };
|
||||
}
|
||||
PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255])
|
||||
}
|
||||
|
||||
fn resolve(self, p: Paint) -> Option<[u8; 3]> {
|
||||
match p {
|
||||
Paint::None | Paint::Knockout => None,
|
||||
Paint::Fixed(c) => Some(c),
|
||||
Paint::Role(r) => Some(self.rgb(r)),
|
||||
Paint::Line => ROLE_NAMES.iter().position(|n| *n == "line").map(|r| self.rgb(r)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_of(c: [u8; 3], alpha: f32) -> sk::Paint<'static> {
|
||||
let mut p = sk::Paint::default();
|
||||
p.set_color_rgba8(c[0], c[1], c[2], (alpha.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
p.anti_alias = true;
|
||||
p
|
||||
}
|
||||
|
||||
fn shape_paint(paint: Paint, look: Look, alpha: f32) -> Option<sk::Paint<'static>> {
|
||||
if paint == Paint::Knockout {
|
||||
let mut paint = paint_of([0, 0, 0], alpha);
|
||||
paint.blend_mode = sk::BlendMode::DestinationOut;
|
||||
Some(paint)
|
||||
} else {
|
||||
look.resolve(paint).map(|colour| paint_of(colour, alpha))
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_node(pm: &mut sk::Pixmap, n: &Node, look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) {
|
||||
let own;
|
||||
let mask = if let Some(m) = n.mask {
|
||||
own = doc.masks.get(m).and_then(|nodes| {
|
||||
let mut sub = sk::Pixmap::new(pm.width(), pm.height())?;
|
||||
for node in nodes {
|
||||
draw_node(&mut sub, node, look, tf, doc, None, 1.0);
|
||||
}
|
||||
let mut own = sk::Mask::from_pixmap(sub.as_ref(), sk::MaskType::Luminance);
|
||||
if let Some(parent) = clip {
|
||||
for (value, parent) in own.data_mut().iter_mut().zip(parent.data()) {
|
||||
*value = ((u16::from(*value) * u16::from(*parent) + 127) / 255) as u8;
|
||||
}
|
||||
}
|
||||
Some(own)
|
||||
});
|
||||
own.as_ref()
|
||||
} else {
|
||||
clip
|
||||
};
|
||||
let alpha = n.opacity * opacity;
|
||||
if let Some(paint) = shape_paint(n.fill, look, alpha) {
|
||||
pm.fill_path(&n.path, &paint, n.rule, tf, mask);
|
||||
}
|
||||
if let Some(paint) = shape_paint(n.stroke, look, alpha) {
|
||||
let stroke = sk::Stroke {
|
||||
width: n.width,
|
||||
line_cap: sk::LineCap::Round,
|
||||
line_join: sk::LineJoin::Round,
|
||||
dash: if n.dash.is_empty() { None } else { sk::StrokeDash::new(n.dash.clone(), 0.0) },
|
||||
..sk::Stroke::default()
|
||||
};
|
||||
pm.stroke_path(&n.path, &paint, &stroke, tf, mask);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_items(pm: &mut sk::Pixmap, items: &[Item], look: Look, tf: sk::Transform, doc: &Doc, clip: Option<&sk::Mask>, opacity: f32) {
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Node(n) => draw_node(pm, n, look, tf, doc, clip, opacity),
|
||||
Item::Group { clip: c, opacity: o, items } => {
|
||||
let mask = c.and_then(|c| doc.clips.get(c)).and_then(|nodes| {
|
||||
let mut m = sk::Mask::new(pm.width(), pm.height())?;
|
||||
for n in nodes {
|
||||
m.fill_path(&n.path, n.rule, true, tf);
|
||||
}
|
||||
Some(m)
|
||||
});
|
||||
draw_items(pm, items, look, tf, doc, mask.as_ref().or(clip), opacity * o);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterise `doc` into a square `px` × `px` premultiplied RGBA image.
|
||||
pub fn render(doc: &Doc, look: Look, px: u32) -> Option<sk::Pixmap> {
|
||||
let mut pm = sk::Pixmap::new(px, px)?;
|
||||
let s = px as f32 / 64.0;
|
||||
draw_items(&mut pm, &doc.items, look, sk::Transform::from_scale(s, s), doc, None, 1.0);
|
||||
Some(pm)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The icon table
|
||||
|
||||
struct Entry {
|
||||
doc: Doc,
|
||||
}
|
||||
|
||||
fn table() -> &'static (HashMap<&'static str, Entry>, HashMap<&'static str, Entry>) {
|
||||
static T: OnceLock<(HashMap<&'static str, Entry>, HashMap<&'static str, Entry>)> = OnceLock::new();
|
||||
T.get_or_init(|| {
|
||||
let (mut big, mut small) = (HashMap::new(), HashMap::new());
|
||||
for (id, fam, src) in SVGS {
|
||||
match Doc::parse(src) {
|
||||
Ok(doc) => {
|
||||
let map = if *fam == "small" { &mut small } else { &mut big };
|
||||
// Model masters precede flat glyphs: keep the reviewed command artwork.
|
||||
map.entry(*id).or_insert(Entry { doc });
|
||||
}
|
||||
Err(e) => log::warn!("icon {fam}/{id}: {e}"),
|
||||
}
|
||||
}
|
||||
(big, small)
|
||||
})
|
||||
}
|
||||
|
||||
/// A resolved icon: the master to draw and the accent it is drawn in.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Resolved {
|
||||
pub master: &'static str,
|
||||
pub accent: Option<usize>,
|
||||
pub placeholder: bool,
|
||||
}
|
||||
|
||||
fn accent_index(name: &str) -> Option<usize> {
|
||||
if name.is_empty() { None } else { ACCENT_NAMES.iter().position(|a| *a == name) }
|
||||
}
|
||||
|
||||
/// A semantic default belongs to the requested id, never to the caller's current workspace.
|
||||
fn default_accent(name: &str) -> Option<usize> {
|
||||
ICON_ACCENTS.iter().find(|(id, _)| *id == name).and_then(|(_, accent)| accent_index(accent))
|
||||
}
|
||||
|
||||
/// Primary semantic stroke, also used by toolbar affordances. The palette owns its value.
|
||||
pub(crate) fn primary(name: &str, dark: bool, vision: crate::theme::ColorVision) -> egui::Color32 {
|
||||
let look = Look { dark, accent: resolve(name).accent, vision };
|
||||
role_colour("active-3", look)
|
||||
}
|
||||
|
||||
pub(crate) fn role_colour(name: &str, look: Look) -> egui::Color32 {
|
||||
if name == "gap" {
|
||||
return egui::Color32::TRANSPARENT;
|
||||
}
|
||||
let role = ROLE_NAMES.iter().position(|n| *n == name);
|
||||
let [r, g, b] = role.map(|role| look.rgb(role)).unwrap_or([255, 0, 255]);
|
||||
crate::theme::icon_color([r, g, b])
|
||||
}
|
||||
|
||||
/// The master and accent for an app icon id: an alias (with its accent), a master of that name,
|
||||
/// or the placeholder.
|
||||
pub fn resolve(name: &str) -> Resolved {
|
||||
let (big, _) = table();
|
||||
let placeholder = Resolved { master: PLACEHOLDER, accent: None, placeholder: true };
|
||||
if let Some((_, master, accent)) = ALIASES.iter().find(|(a, _, _)| *a == name) {
|
||||
return match big.get_key_value(master) {
|
||||
Some((k, _)) => Resolved { master: k, accent: accent_index(accent).or_else(|| default_accent(name)), placeholder: false },
|
||||
None => placeholder,
|
||||
};
|
||||
}
|
||||
match big.get_key_value(name) {
|
||||
Some((k, _)) if name != PLACEHOLDER => Resolved { master: k, accent: default_accent(name), placeholder: false },
|
||||
_ => placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
/// The master to draw at `px` pixels: the small-slot variant where one exists.
|
||||
pub fn doc_for(master: &str, px: u32) -> Option<&'static Doc> {
|
||||
let (big, small) = table();
|
||||
if let Some(e) = small.get(master).filter(|_| px <= 24) {
|
||||
return Some(&e.doc);
|
||||
}
|
||||
big.get(master).or_else(|| big.get(PLACEHOLDER)).map(|e| &e.doc)
|
||||
}
|
||||
|
||||
/// Every master id (not the small variants), for tests and tooling.
|
||||
pub fn master_ids() -> Vec<&'static str> {
|
||||
let mut v: Vec<_> = table().0.keys().copied().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_master_parses() {
|
||||
for (id, fam, src) in SVGS {
|
||||
if let Err(e) = Doc::parse(src) {
|
||||
panic!("{fam}/{id}: {e}");
|
||||
}
|
||||
}
|
||||
assert!(table().0.contains_key(PLACEHOLDER), "the placeholder master exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliases_point_at_masters_and_known_accents() {
|
||||
for (alias, master, accent) in ALIASES {
|
||||
assert!(table().0.contains_key(master), "{alias} -> {master}");
|
||||
assert!(accent.is_empty() || ACCENT_NAMES.contains(accent), "{alias}: accent {accent}");
|
||||
assert!(!resolve(alias).placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_blue_dashed_strokes_use_guides_but_preserve_accents_and_knockouts() {
|
||||
let svg = "<svg><path d='M8 20L56 20' stroke='#8e9faf' stroke-width='4.25' stroke-dasharray='3 5'/><path d='M8 40L56 40' stroke='#ffffff' stroke-width='6' stroke-dasharray='3 5'/><path d='M8 52L56 52' stroke='#236ead' stroke-width='4.25' stroke-dasharray='3 5'/></svg>";
|
||||
let doc = Doc::parse(svg).unwrap();
|
||||
let guide = ROLE_NAMES.iter().position(|name| *name == "guide").unwrap();
|
||||
let active = ROLE_NAMES.iter().position(|name| *name == "active-3").unwrap();
|
||||
let Item::Node(line) = &doc.items[0] else { panic!("expected line") };
|
||||
let Item::Node(knockout) = &doc.items[1] else { panic!("expected knockout") };
|
||||
let Item::Node(blue) = &doc.items[2] else { panic!("expected blue line") };
|
||||
assert_eq!(line.stroke, Paint::Role(guide));
|
||||
assert_eq!(knockout.stroke, Paint::Knockout);
|
||||
assert_eq!(blue.stroke, Paint::Role(active));
|
||||
for dark in [false, true] {
|
||||
for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) {
|
||||
let look = Look { dark, accent, vision: crate::theme::ColorVision::Off };
|
||||
assert_eq!(look.rgb(guide), PALETTE[usize::from(dark)][guide]);
|
||||
let pm = render(&doc, look, 64).unwrap();
|
||||
assert_eq!([pm.pixel(9, 20).unwrap().red(), pm.pixel(9, 20).unwrap().green(), pm.pixel(9, 20).unwrap().blue()], look.rgb(guide));
|
||||
assert_eq!([pm.pixel(9, 52).unwrap().red(), pm.pixel(9, 52).unwrap().green(), pm.pixel(9, 52).unwrap().blue()], look.rgb(active));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_fills_and_strokes_erase_earlier_layers_and_allow_later_foreground() {
|
||||
let doc = Doc::parse("<svg><path d='M0 0L64 0L64 64L0 64Z' fill='#8e9faf'/><circle cx='20' cy='20' r='10' fill='#ffffff'/><circle cx='20' cy='20' r='3' fill='#236ead'/><path d='M8 44L56 44' stroke='#ffffff' stroke-width='6'/><circle cx='44' cy='20' r='6' fill='#ffffff' opacity='0.5'/></svg>").unwrap();
|
||||
for dark in [false, true] {
|
||||
for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) {
|
||||
let look = Look { dark, accent, vision: crate::theme::ColorVision::Off };
|
||||
let pm = render(&doc, look, 64).unwrap();
|
||||
assert_eq!(pm.pixel(26, 20).unwrap().alpha(), 0, "fill must reveal the real background");
|
||||
assert_eq!(pm.pixel(20, 44).unwrap().alpha(), 0, "stroke must reveal the real background");
|
||||
assert_eq!(pm.pixel(20, 20).unwrap().alpha(), 255, "foreground is drawn after erasure");
|
||||
assert_eq!(pm.pixel(2, 2).unwrap().alpha(), 255, "unaffected base stays opaque");
|
||||
assert!((126..=128).contains(&pm.pixel(44, 20).unwrap().alpha()), "partial opacity erases partially");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_respects_its_clip_and_combines_explicit_masks_with_clips() {
|
||||
let doc = Doc::parse("<svg><defs><clipPath id='left'><path d='M0 0L32 0L32 64L0 64Z'/></clipPath><mask id='all'><path d='M0 0L64 0L64 64L0 64Z' fill='white'/></mask></defs><path d='M0 0L64 0L64 64L0 64Z' fill='#8e9faf'/><g clip-path='url(#left)'><circle cx='32' cy='32' r='16' fill='#ffffff' mask='url(#all)'/></g></svg>").unwrap();
|
||||
let pm = render(&doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap();
|
||||
assert_eq!(pm.pixel(24, 32).unwrap().alpha(), 0);
|
||||
assert_eq!(pm.pixel(40, 32).unwrap().alpha(), 255, "cutout must not escape its parent clip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_badges_and_environment_sun_have_transparent_holes() {
|
||||
for dark in [false, true] {
|
||||
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
|
||||
assert_eq!(role_colour("gap", look), egui::Color32::TRANSPARENT);
|
||||
for (id, x, y) in [("contact_new", 48, 48), ("environment_map", 45, 19), ("line_mid", 35, 28)] {
|
||||
let pm = render(doc_for(id, 64).unwrap(), look, 64).unwrap();
|
||||
assert_eq!(pm.pixel(x, y).unwrap().alpha(), 0, "{id}: GAP must not be a painted theme colour");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_clip_paths_and_plus_mask_keep_the_artwork_inside_its_shape() {
|
||||
for dark in [false, true] {
|
||||
for px in [16, 24, 64] {
|
||||
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
|
||||
for id in ["environment_map", "curvature_map", "iso_analysis", "zebra"] {
|
||||
let doc = doc_for(id, px).unwrap();
|
||||
let pm = render(doc, look, px).unwrap();
|
||||
assert_eq!(pm.pixel(px - 2, px - 2).unwrap().alpha(), 0, "{id} escapes its native clip");
|
||||
assert!(pm.pixel(px / 2, px / 2).unwrap().alpha() > 0, "{id} lost its clipped contents");
|
||||
}
|
||||
}
|
||||
let pm = render(doc_for("new", 64).unwrap(), Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap();
|
||||
assert_eq!(pm.pixel(45, 45).unwrap().alpha(), 0, "the plus is genuinely punched out in the native renderer");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_meanings_choose_the_same_accent_on_every_surface() {
|
||||
let accent = |name| resolve(name).accent.map(|i| ACCENT_NAMES[i]);
|
||||
for c in solidcraft_engine::command_specs() {
|
||||
let want = if c.id.starts_with("surface.") {
|
||||
Some("surface")
|
||||
} else if c.panel == "CONSTRAINTS" {
|
||||
Some("constraint")
|
||||
} else if matches!(c.id, "sketch.construction" | "sketch.centerline") {
|
||||
Some("construction")
|
||||
} else if c.id.starts_with("sketch.") && matches!(c.panel, "CREATE" | "MODIFY" | "PATTERN" | "FINISH SKETCH") {
|
||||
Some("sketch")
|
||||
} else if c.id == "file.save_mesh" {
|
||||
Some("mesh")
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
assert_eq!(accent(c.icon), want, "{} ({})", c.id, c.icon);
|
||||
}
|
||||
for id in ["move", "scale", "copy", "delete", "export", "ground", "axis_two_planes"] {
|
||||
assert_eq!(accent(id), None, "shared utility / modelling icon {id}");
|
||||
}
|
||||
for (alias, master) in [("sketch_move", "move"), ("sketch_scale", "scale"), ("surface_fair", "spline_cv"), ("mesh_export", "export")] {
|
||||
assert_eq!(resolve(alias).master, resolve(master).master, "reuse accepted shapes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_ui_tokens_and_icon_strokes_share_the_palette_including_accessibility() {
|
||||
for dark in [false, true] {
|
||||
for vision in crate::theme::ColorVision::ALL {
|
||||
let tokens = crate::theme::Tokens::themed(dark, vision);
|
||||
assert_eq!(primary("line", dark, vision), tokens.sketch_accent);
|
||||
assert_eq!(primary("c_parallel", dark, vision), tokens.glyph_text);
|
||||
assert_eq!(primary("centerline", dark, vision), tokens.construction_geom);
|
||||
assert_eq!(primary("construction", dark, vision), tokens.construction_geom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_ramps_preserve_silhouettes_and_passive_faces_in_all_themes() {
|
||||
use crate::theme::ColorVision;
|
||||
for id in ["line", "c_parallel", "construction", "centerline", "sketch_rect_pattern", "surface_extrude", "mesh_export"] {
|
||||
let resolved = resolve(id);
|
||||
for dark in [false, true] {
|
||||
for vision in ColorVision::ALL {
|
||||
let look = Look { dark, accent: resolved.accent, vision };
|
||||
let base = Look { accent: None, ..look };
|
||||
for role in ["ink", "line", "passive-1", "passive-2", "passive-3", "gap"] {
|
||||
let role = ROLE_NAMES.iter().position(|name| *name == role).unwrap();
|
||||
assert_eq!(look.rgb(role), base.rgb(role), "{id} changes passive paint");
|
||||
}
|
||||
for px in [16, 24, 64] {
|
||||
let doc = doc_for(resolved.master, px).unwrap();
|
||||
let changed = render(doc, look, px).unwrap();
|
||||
let original = render(doc, base, px).unwrap();
|
||||
assert!(changed.pixels().iter().zip(original.pixels()).all(|(a, b)| a.alpha() == b.alpha()), "{id}: silhouette at {px}px");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_artwork_resolves_and_construct_methods_are_distinct() {
|
||||
let specs = solidcraft_engine::command_specs();
|
||||
for command in &specs {
|
||||
assert!(!resolve(command.icon).placeholder, "{}: {}", command.id, command.icon);
|
||||
}
|
||||
let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect();
|
||||
let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect();
|
||||
assert_eq!(construction.len(), 19);
|
||||
assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_controls_resolve_without_command_registry_entries() {
|
||||
for name in ["agent_chat", "normal", "infinite", "pattern_flip", "pattern_center"] {
|
||||
assert!(!resolve(name).placeholder, "UI helper: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_construction_modes_have_distinct_icons() {
|
||||
let families: &[&[&str]] = &[
|
||||
&["sketch.polygon.inscribed", "sketch.polygon.circumscribed", "sketch.polygon.edge"],
|
||||
&["sketch.slot.center_to_center", "sketch.slot.overall", "sketch.slot.center_point"],
|
||||
&["sketch.slot.arc_three_point", "sketch.slot.arc_center"],
|
||||
&["sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"],
|
||||
];
|
||||
for commands in families {
|
||||
let icons: std::collections::HashSet<_> = commands.iter().map(|id| solidcraft_engine::find_command(id).unwrap().icon).collect();
|
||||
assert_eq!(icons.len(), commands.len(), "Sketch modes share an icon: {commands:?}");
|
||||
for icon in icons {
|
||||
assert!(!resolve(icon).placeholder, "{icon}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_actions_have_distinct_icons_from_interference_analysis() {
|
||||
let mut icons = std::collections::HashSet::new();
|
||||
let interference = solidcraft_engine::find_command("inspect.interference").unwrap().icon;
|
||||
for id in ["contact.enable_sets", "contact.enable_all", "contact.disable_all", "contact.create"] {
|
||||
let icon = solidcraft_engine::find_command(id).unwrap().icon;
|
||||
assert_ne!(icon, interference, "{id} is a contact control, not an interference result");
|
||||
assert!(icons.insert(icon), "contact controls share an icon: {id}");
|
||||
assert!(!resolve(icon).placeholder, "{id}: {icon}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_model_folder_is_also_the_folder_drawn_in_the_app() {
|
||||
let source = SVGS.iter().find(|(id, fam, _)| *id == "folder" && *fam == "model").unwrap().2;
|
||||
let expected = Doc::parse(source).unwrap();
|
||||
for dark in [false, true] {
|
||||
let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off };
|
||||
let actual = render(doc_for("folder", 64).unwrap(), look, 64).unwrap();
|
||||
let reviewed = render(&expected, look, 64).unwrap();
|
||||
assert_eq!(actual.data(), reviewed.data());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_names_draw_the_placeholder() {
|
||||
assert!(resolve("no_such_icon").placeholder);
|
||||
assert!(resolve("").placeholder);
|
||||
assert!(!resolve("extrude").placeholder);
|
||||
}
|
||||
|
||||
fn coverage(pm: &sk::Pixmap) -> usize {
|
||||
pm.pixels().iter().filter(|p| p.alpha() > 0).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_master_draws_something_at_every_size_in_both_themes() {
|
||||
for id in master_ids() {
|
||||
for dark in [false, true] {
|
||||
for px in [16, 20, 26, 64] {
|
||||
let Some(doc) = doc_for(id, px) else { panic!("{id}") };
|
||||
let pm = render(doc, Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, px)
|
||||
.unwrap_or_else(|| panic!("{id} at {px}"));
|
||||
assert!(coverage(&pm) > 8, "{id} at {px}px (dark {dark}) is blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgba(pm: &sk::Pixmap) -> Vec<u8> {
|
||||
pm.data().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_theme_and_accents_change_colour_not_shape() {
|
||||
let doc = doc_for("extrude", 64).unwrap_or_else(|| panic!("extrude"));
|
||||
let light = render(doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
|
||||
let dark = render(doc, Look { dark: true, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
|
||||
assert_ne!(rgba(&light), rgba(&dark));
|
||||
let r = resolve("surface_extrude");
|
||||
assert_eq!(r.master, "extrude");
|
||||
let surface =
|
||||
render(doc, Look { dark: false, accent: r.accent, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render"));
|
||||
assert!(r.accent.is_some());
|
||||
assert_ne!(rgba(&light), rgba(&surface));
|
||||
// Same silhouette: the alpha channel is identical.
|
||||
let alpha = |pm: &sk::Pixmap| pm.pixels().iter().map(|p| p.alpha()).collect::<Vec<_>>();
|
||||
assert_eq!(alpha(&light), alpha(&surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_artwork_is_an_error_not_a_panic() {
|
||||
for bad in [
|
||||
"",
|
||||
"<",
|
||||
"<svg",
|
||||
"<svg><path d=\"M 1\"/></svg>",
|
||||
"<svg><path d=\"X\"/></svg>",
|
||||
"<svg><g></svg>",
|
||||
"<svg><rect/></svg>",
|
||||
"<svg><path d=\"M 0 0 L 1 1\" fill=\"#123456\"/></svg>",
|
||||
"<svg><path d='M0 0 L 1 1' mask='url(#x)'/></svg>",
|
||||
] {
|
||||
let _ = Doc::parse(bad);
|
||||
}
|
||||
assert!(Doc::parse("<svg><path d=\"M 1\"/></svg>").is_err());
|
||||
assert!(Doc::parse("<svg><g></svg>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_roles_are_distinct_and_complete() {
|
||||
assert_eq!(ROLE_NAMES.len(), ROLE_SOURCE_HEX.len());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(
|
||||
ROLE_NAMES.iter().zip(ROLE_SOURCE_HEX).filter(|(n, _)| **n != "line").all(|(_, h)| seen.insert(*h)),
|
||||
"two roles share a source identifier"
|
||||
);
|
||||
assert!(ROLE_NAMES.contains(&"line"));
|
||||
assert_eq!(PALETTE.len(), 2);
|
||||
assert!(PALETTE.iter().all(|p| p.len() == ROLE_NAMES.len()));
|
||||
}
|
||||
}
|
||||
+106
-6
@@ -1,9 +1,16 @@
|
||||
//! Stable UI rendering boundary for the collected artwork. Compiled only at L5.
|
||||
#[path = "../../../assets/icons/ui/source/icons.rs"]
|
||||
mod source;
|
||||
pub use source::paint;
|
||||
#[cfg(test)]
|
||||
pub(crate) use source::paint_with;
|
||||
//! Stable UI rendering boundary for the icon artwork. Compiled only at L5.
|
||||
//!
|
||||
//! Command, menu, browser and timeline icons are the SVG masters in `assets/icons/ui/design/svg`,
|
||||
//! rendered by [`crate::icon_svg`]: colours come from `palette.toml` (light and dark, and the
|
||||
//! accents), never from the caller. An icon with no master yet draws the placeholder.
|
||||
//! The glyph, marker, navigation and pointer sources below are not command icons.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Color32, Painter, Rect, TextureHandle, TextureOptions, pos2};
|
||||
|
||||
use crate::icon_svg::{self, Look};
|
||||
|
||||
#[path = "../../../assets/icons/ui/source/glyphs.rs"]
|
||||
pub(crate) mod glyphs;
|
||||
#[path = "../../../assets/icons/ui/source/markers.rs"]
|
||||
@@ -12,3 +19,96 @@ pub(crate) mod markers;
|
||||
pub(crate) mod navigation;
|
||||
#[path = "../../../assets/icons/ui/source/pointer.rs"]
|
||||
pub(crate) mod pointer;
|
||||
|
||||
/// Textures already rasterised: one per (master, look, pixel size).
|
||||
#[derive(Clone, Default)]
|
||||
struct Cache(Arc<Mutex<HashMap<(&'static str, Look, u32), TextureHandle>>>);
|
||||
|
||||
/// Draw the icon `name` in `r`. The ink, fill and accent colours the callers pass are no longer
|
||||
/// used: the palette decides. Only the ink's opacity is kept, so a dimmed (disabled) button dims
|
||||
/// its icon.
|
||||
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, _fill: Color32, _accent: Color32) {
|
||||
paint_icon(p, r, name, f32::from(ink.a()) / 255.0);
|
||||
}
|
||||
|
||||
/// [`paint`] with an explicit opacity (0 to 1).
|
||||
pub fn paint_icon(p: &Painter, r: Rect, name: &str, opacity: f32) {
|
||||
paint_on(p, r, name, None, opacity);
|
||||
}
|
||||
|
||||
/// Draw the icon `name` for a surface that is always dark (the application bar), whatever the
|
||||
/// theme: `dark: Some(true)`. `None` follows the theme.
|
||||
pub fn paint_on(p: &Painter, r: Rect, name: &str, dark: Option<bool>, opacity: f32) {
|
||||
let ctx = p.ctx();
|
||||
let ppp = ctx.pixels_per_point().max(0.5);
|
||||
let side = r.width().min(r.height());
|
||||
let px = (side * ppp).round().clamp(8.0, 512.0) as u32;
|
||||
let resolved = icon_svg::resolve(name);
|
||||
let look = Look { dark: dark.unwrap_or_else(|| ctx.global_style().visuals.dark_mode), accent: resolved.accent, vision: crate::theme::vision() };
|
||||
let cache = ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("sc_icon_cache"), Cache::default).clone());
|
||||
let Ok(mut map) = cache.0.lock() else { return };
|
||||
let key = (resolved.master, look, px);
|
||||
if let std::collections::hash_map::Entry::Vacant(slot) = map.entry(key) {
|
||||
let Some(doc) = icon_svg::doc_for(resolved.master, px) else { return };
|
||||
let Some(pm) = icon_svg::render(doc, look, px) else { return };
|
||||
let image = egui::ColorImage::from_rgba_premultiplied([px as usize, px as usize], pm.data());
|
||||
slot.insert(ctx.load_texture(format!("icon:{}:{px}", resolved.master), image, TextureOptions::LINEAR));
|
||||
}
|
||||
let Some(tex) = map.get(&key) else { return };
|
||||
// Snap the image to whole physical pixels so it stays crisp.
|
||||
let size = px as f32 / ppp;
|
||||
let min = pos2(((r.center().x - size / 2.0) * ppp).round() / ppp, ((r.center().y - size / 2.0) * ppp).round() / ppp);
|
||||
let tint = Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
p.image(tex.id(), Rect::from_min_size(min, egui::vec2(size, size)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::{self, ColorVision};
|
||||
|
||||
#[test]
|
||||
fn actual_uploaded_icon_pixels_follow_semantics_and_vision_cache_changes() {
|
||||
let ctx = egui::Context::default();
|
||||
let previous = theme::vision();
|
||||
for dark in [false, true] {
|
||||
for vision in [ColorVision::Off, ColorVision::Protan, ColorVision::Tritan] {
|
||||
theme::set_vision(vision);
|
||||
for icon in ["line", "c_parallel", "construction", "centerline", "surface_extrude", "mesh_export", "contact_new"] {
|
||||
let expected = if icon == "construction" {
|
||||
icon_svg::role_colour("guide", Look { dark, accent: None, vision })
|
||||
} else {
|
||||
icon_svg::primary(icon, dark, vision)
|
||||
};
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let r = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0));
|
||||
paint_on(ui.painter(), r, icon, Some(dark), 1.0);
|
||||
});
|
||||
assert!(
|
||||
output.textures_delta.set.iter().any(|(_, deltas)| deltas.iter().any(|delta| match &delta.image {
|
||||
egui::ImageData::Color(image) => image.pixels.contains(&expected),
|
||||
})),
|
||||
"{icon}, {dark}, {vision:?}: actual texture misses its semantic stroke or reused a stale cache entry"
|
||||
);
|
||||
if icon == "contact_new" {
|
||||
let image = output
|
||||
.textures_delta
|
||||
.set
|
||||
.values()
|
||||
.flatten()
|
||||
.find_map(|delta| match &delta.image {
|
||||
egui::ImageData::Color(image) if image.size == [64, 64] => Some(image),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(image.pixels.get(48 * 64 + 48), Some(&Color32::TRANSPARENT), "uploaded plus must stay transparent");
|
||||
}
|
||||
// This headless test inspects uploads instead of sending them
|
||||
// to a GPU; acknowledge the deltas before they are dropped.
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
theme::set_vision(previous);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ pub mod i18n;
|
||||
mod i18n_menu_tests;
|
||||
#[cfg(test)]
|
||||
mod i18n_ui_tests;
|
||||
pub mod icon_svg;
|
||||
pub mod icons;
|
||||
pub mod inference;
|
||||
pub mod install;
|
||||
|
||||
@@ -283,7 +283,8 @@ fn moved(x: &str, y: &str, angle: &str) -> Mod {
|
||||
fn the_command_now_has_a_toolbar_placement_and_opens_a_window() {
|
||||
let spec = solidcraft_engine::find_command("sketch.move").expect("sketch.move is registered");
|
||||
assert_eq!((spec.tab, spec.panel), ("SKETCH", "MODIFY"));
|
||||
assert_eq!(spec.icon, "move");
|
||||
assert_eq!(spec.icon, "sketch_move");
|
||||
assert_eq!(crate::icon_svg::resolve(spec.icon).master, "move", "reuse the finished Move drawing");
|
||||
|
||||
let mut h = rectangle();
|
||||
let before = curve_ids(&h).len();
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! uses comes from [`Tokens`].
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[path = "../../../assets/styles/ui/palettes.rs"]
|
||||
mod palettes;
|
||||
@@ -11,10 +10,11 @@ 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
|
||||
/// Theme state belongs to the UI thread drawing the frame. Concurrent app/test renderers
|
||||
/// must not change one another's palette while a frame or icon texture is being built.
|
||||
static DARK: Cell<bool> = const { Cell::new(true) };
|
||||
/// The colour vision mode, per thread. 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
|
||||
@@ -26,7 +26,12 @@ thread_local! {
|
||||
|
||||
/// Is the dark theme on?
|
||||
pub fn is_dark() -> bool {
|
||||
DARK.load(Ordering::Relaxed)
|
||||
DARK.get()
|
||||
}
|
||||
|
||||
/// The common conversion boundary for compiled icon palette data.
|
||||
pub(crate) const fn icon_color(rgb: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
/// Colour vision modes (#778): a second set of values for the colours that carry a meaning, for
|
||||
@@ -333,7 +338,7 @@ pub fn material_color(name: &str) -> Option<Color32> {
|
||||
|
||||
/// Switch themes (the visuals follow on the next [`apply`]).
|
||||
pub fn set_dark(dark: bool) {
|
||||
DARK.store(dark, Ordering::Relaxed);
|
||||
DARK.set(dark);
|
||||
}
|
||||
|
||||
fn visuals(dark: bool) -> egui::Visuals {
|
||||
@@ -373,6 +378,24 @@ pub fn apply(ctx: &egui::Context) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn concurrent_ui_renderers_keep_their_own_theme_and_vision() {
|
||||
let barrier = std::sync::Barrier::new(2);
|
||||
std::thread::scope(|scope| {
|
||||
for (dark, vision) in [(false, super::ColorVision::Off), (true, super::ColorVision::Protan)] {
|
||||
let barrier = &barrier;
|
||||
scope.spawn(move || {
|
||||
super::set_dark(dark);
|
||||
super::set_vision(vision);
|
||||
barrier.wait();
|
||||
assert_eq!(super::is_dark(), dark);
|
||||
assert_eq!(super::vision(), vision);
|
||||
assert_eq!(super::Tokens::get().sketch_accent, super::Tokens::themed(dark, vision).sketch_accent);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
pub(crate) use crate::icons::glyphs::transport_glyph;
|
||||
use egui::{Rect, Sense, Stroke, UiBuilder, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::doc::FeatureKind;
|
||||
use solidcraft_engine::doc::{
|
||||
FeatureKind, PlaneRef,
|
||||
construct::{AxisDef, PointDef},
|
||||
};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{SolveApp, icons};
|
||||
@@ -13,14 +16,18 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::Sketch { .. } => "sketch",
|
||||
FeatureKind::Extrude { .. } => "extrude",
|
||||
FeatureKind::SurfaceExtrude { .. } => "surface_extrude",
|
||||
FeatureKind::Unstitch { .. } => "unstitch",
|
||||
FeatureKind::ReverseNormal { .. } => "reverse_normal",
|
||||
FeatureKind::Unstitch { .. } => "surface_unstitch",
|
||||
FeatureKind::ReverseNormal { .. } => "surface_reverse_normal",
|
||||
FeatureKind::SurfaceRevolve { .. } => "surface_revolve",
|
||||
FeatureKind::SurfaceSweep { .. } => "surface_sweep",
|
||||
FeatureKind::SurfaceLoft { .. } => "surface_loft",
|
||||
FeatureKind::SurfaceOffset { .. } => "surface_offset",
|
||||
FeatureKind::SurfaceRuled { .. } => "ruled",
|
||||
FeatureKind::SurfaceUntrim { .. } => "untrim",
|
||||
FeatureKind::SurfaceRuled { .. } => "surface_ruled",
|
||||
FeatureKind::SurfaceUntrim { .. } => "surface_untrim",
|
||||
FeatureKind::SurfaceBlend { .. } => "surface_blend",
|
||||
FeatureKind::SurfaceBoundary { .. } => "surface_boundary",
|
||||
FeatureKind::SurfaceMatch { .. } => "continuity",
|
||||
FeatureKind::SurfaceFair { .. } => "surface_fair",
|
||||
FeatureKind::Revolve { .. } => "revolve",
|
||||
FeatureKind::Fillet { .. } => "fillet",
|
||||
FeatureKind::Chamfer { .. } => "chamfer",
|
||||
@@ -30,11 +37,25 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::Torus { .. } => "torus",
|
||||
FeatureKind::Combine { .. } => "combine",
|
||||
FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Rectangular { .. }, .. } => "pattern_rect",
|
||||
FeatureKind::Pattern { pattern: solidcraft_engine::doc::PatternKind::Path { .. }, .. } => "pattern_path",
|
||||
FeatureKind::Pattern { .. } => "pattern_circ",
|
||||
FeatureKind::Mirror { .. } => "mirror",
|
||||
FeatureKind::ConstructionPlane { .. } => "plane",
|
||||
FeatureKind::ConstructionAxis { .. } => "axis",
|
||||
FeatureKind::ConstructionPoint { .. } => "point",
|
||||
FeatureKind::ConstructionPlane { plane } => construction_plane_icon(plane),
|
||||
FeatureKind::ConstructionAxis { def } => match def {
|
||||
AxisDef::ThroughFace { .. } => "axis_through_cylinder",
|
||||
AxisDef::NormalToFace { .. } => "axis_normal_to_face",
|
||||
AxisDef::TwoPlanes { .. } => "axis_two_planes",
|
||||
AxisDef::TwoPoints { .. } => "axis_two_points",
|
||||
AxisDef::Edge { .. } => "axis_edge",
|
||||
},
|
||||
FeatureKind::ConstructionPoint { def } => match def {
|
||||
PointDef::At { .. } => "point_vertex",
|
||||
PointDef::TwoEdges { .. } => "point_two_edges",
|
||||
PointDef::ThreePlanes { .. } => "point_three_planes",
|
||||
PointDef::Center { .. } => "point_center",
|
||||
PointDef::EdgeAndPlane { .. } => "point_edge_plane",
|
||||
PointDef::AlongPath { .. } => "point_along_path",
|
||||
},
|
||||
FeatureKind::Hole { .. } => "hole",
|
||||
FeatureKind::Loft { .. } => "loft",
|
||||
FeatureKind::Sweep { .. } => "sweep",
|
||||
@@ -43,10 +64,43 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::Split { .. } => "split",
|
||||
FeatureKind::Move { .. } => "move",
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::MeshConvert { .. } => "import",
|
||||
FeatureKind::BodyTransfer { .. } => "move",
|
||||
FeatureKind::Thread { .. } => "thread",
|
||||
FeatureKind::SplitFace { .. } => "split_face",
|
||||
FeatureKind::Scale { .. } => "scale",
|
||||
FeatureKind::OffsetFace { .. } => "offset_face",
|
||||
FeatureKind::DeleteFace { .. } => "delete_face",
|
||||
FeatureKind::Remove { .. } => "delete",
|
||||
FeatureKind::BoundingSolid { .. } => "bounding_solid",
|
||||
FeatureKind::Pipe { .. } => "pipe",
|
||||
FeatureKind::Emboss { .. } => "emboss",
|
||||
FeatureKind::ReplaceFace { .. } => "replace_face",
|
||||
FeatureKind::Align { .. } => "align",
|
||||
FeatureKind::Coil { .. } => "coil",
|
||||
FeatureKind::SheetPerforate { .. } => "perforate",
|
||||
FeatureKind::BoundaryFill { .. } => "boundary_fill",
|
||||
FeatureKind::Patch { .. } => "surface_patch",
|
||||
FeatureKind::Stitch { .. } => "surface_stitch",
|
||||
FeatureKind::Thicken { .. } => "surface_thicken",
|
||||
FeatureKind::SurfaceExtend { .. } => "surface_extend",
|
||||
FeatureKind::SurfaceTrim { .. } => "surface_trim",
|
||||
other => crate::workspace::feature_icon(other).unwrap_or("feature"),
|
||||
}
|
||||
}
|
||||
|
||||
fn construction_plane_icon(plane: &PlaneRef) -> &'static str {
|
||||
match plane {
|
||||
PlaneRef::AtAngle { .. } => "plane_angle",
|
||||
PlaneRef::Midplane { .. } => "plane_midplane",
|
||||
PlaneRef::TwoEdges { .. } => "plane_two_edges",
|
||||
PlaneRef::ThreePoints { .. } => "plane_three_points",
|
||||
PlaneRef::Tangent { .. } => "plane_tangent",
|
||||
PlaneRef::AlongPath { .. } => "plane_along_path",
|
||||
PlaneRef::Perpendicular { .. } => "plane_perpendicular",
|
||||
_ => "plane",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Drag {
|
||||
Marker,
|
||||
@@ -439,6 +493,93 @@ mod tests {
|
||||
|
||||
use crate::scenario::Harness;
|
||||
|
||||
#[test]
|
||||
fn construction_history_matches_the_tool_that_created_it() {
|
||||
use solidcraft_engine::doc::{
|
||||
FeatureKind, PlaneRef,
|
||||
construct::{AxisDef, GeoRef, PointDef},
|
||||
};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
let point = || GeoRef::Point { p: Vec3::ZERO };
|
||||
let reference = || Box::new(point());
|
||||
let mut cases = Vec::new();
|
||||
for (id, def) in [
|
||||
("through_cylinder", AxisDef::ThroughFace { face: point() }),
|
||||
("normal_to_face", AxisDef::NormalToFace { face: point(), point: point() }),
|
||||
("two_planes", AxisDef::TwoPlanes { a: point(), b: point() }),
|
||||
("two_points", AxisDef::TwoPoints { a: point(), b: point() }),
|
||||
("edge", AxisDef::Edge { edge: point() }),
|
||||
] {
|
||||
cases.push((format!("construct.axis.{id}"), FeatureKind::ConstructionAxis { def }));
|
||||
}
|
||||
for (id, def) in [
|
||||
("vertex", PointDef::At { point: point() }),
|
||||
("two_edges", PointDef::TwoEdges { a: point(), b: point() }),
|
||||
("three_planes", PointDef::ThreePlanes { a: point(), b: point(), c: point() }),
|
||||
("center", PointDef::Center { of: point() }),
|
||||
("edge_plane", PointDef::EdgeAndPlane { edge: point(), plane: point() }),
|
||||
("along_path", PointDef::AlongPath { path: point(), t: "0.5".into() }),
|
||||
] {
|
||||
cases.push((format!("construct.point.{id}"), FeatureKind::ConstructionPoint { def }));
|
||||
}
|
||||
for (id, plane) in [
|
||||
("midplane", PlaneRef::Midplane { a: reference(), b: reference() }),
|
||||
("two_edges", PlaneRef::TwoEdges { a: reference(), b: reference() }),
|
||||
("three_points", PlaneRef::ThreePoints { a: reference(), b: reference(), c: reference() }),
|
||||
("tangent", PlaneRef::Tangent { face: reference(), at: Vec3::ZERO }),
|
||||
("along_path", PlaneRef::AlongPath { path: reference(), t: "0.5".into() }),
|
||||
("perpendicular", PlaneRef::Perpendicular { plane: reference(), line: reference() }),
|
||||
] {
|
||||
cases.push((format!("construct.plane.{id}"), FeatureKind::ConstructionPlane { plane }));
|
||||
}
|
||||
for (id, feature) in cases {
|
||||
let command = solidcraft_engine::find_command(&id).unwrap();
|
||||
let icon = super::icon_of(&feature);
|
||||
assert_eq!(icon, command.icon, "{id}: history and toolbar disagree");
|
||||
assert!(!crate::icon_svg::resolve(icon).placeholder, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_surface_and_web_history_icons_match_their_commands() {
|
||||
let cases = [
|
||||
("surface.ruled", json!({"type":"surface_ruled", "body":"A", "edges":[], "distance":"5"})),
|
||||
("surface.untrim", json!({"type":"surface_untrim", "face":{"body":"A", "point":[0,0,0]}})),
|
||||
("surface.unstitch", json!({"type":"unstitch", "body":"A"})),
|
||||
("surface.reverse_normal", json!({"type":"reverse_normal", "body":"A"})),
|
||||
("surface.blend", json!({"type":"surface_blend", "from":{"body":"A", "edges":[]}, "to":{"body":"B", "edges":[]}})),
|
||||
("surface.boundary", json!({"type":"surface_boundary", "sketch":0, "curves":[]})),
|
||||
("surface.match", json!({"type":"surface_match", "body":"A", "target":"B", "edges":[]})),
|
||||
("surface.fair", json!({"type":"surface_fair", "body":"A", "tolerance":"0.001"})),
|
||||
("solid.rib", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2"})),
|
||||
("solid.web", json!({"type":"rib", "sketch":0, "curves":[], "thickness":"2", "web":true})),
|
||||
];
|
||||
for (id, definition) in cases {
|
||||
let feature: solidcraft_engine::doc::FeatureKind = serde_json::from_value(definition).unwrap();
|
||||
let command = solidcraft_engine::find_command(id).unwrap();
|
||||
assert_eq!(super::icon_of(&feature), command.icon, "{id}: history and toolbar disagree");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_pattern_keeps_its_path_icon_in_history() {
|
||||
use solidcraft_engine::doc::{FeatureKind, PatternKind};
|
||||
let feature = FeatureKind::Pattern {
|
||||
features: Vec::new(),
|
||||
bodies: Vec::new(),
|
||||
pattern: PatternKind::Path {
|
||||
path_sketch: solidcraft_engine::doc::SketchId(0),
|
||||
path: Vec::new(),
|
||||
count: "2".into(),
|
||||
spacing: "10".into(),
|
||||
extent: false,
|
||||
flip: false,
|
||||
orient: false,
|
||||
},
|
||||
};
|
||||
assert_eq!(super::icon_of(&feature), solidcraft_engine::find_command("solid.pattern.path").unwrap().icon);
|
||||
}
|
||||
|
||||
fn two_boxes(left: bool) -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.timeline_left = left;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! A failed feature is marked in the bottom timeline and the Feature History by a shape, not only
|
||||
//! its colour (#972): the error mark (a disc with a cross) where a warning's mark goes.
|
||||
//! its colour (#972): the error mark where a warning's mark goes (now an icon image, so these
|
||||
//! tests look for the 10-point image at the mark's place).
|
||||
|
||||
use egui::{Pos2, Rect, Shape, vec2};
|
||||
use serde_json::json;
|
||||
@@ -27,20 +28,23 @@ fn features(app: &SolveApp) -> Vec<(String, bool)> {
|
||||
app.session.doc.features.iter().map(|f| (f.name.clone(), app.session.model.result(f.id).is_some_and(|r| r.error.is_some()))).collect()
|
||||
}
|
||||
|
||||
/// The discs filled in the error colour (either theme's) that one frame of `draw` paints.
|
||||
/// The centres of the 10 to 12-point icon images one frame of `draw` paints: the failed-feature mark
|
||||
/// (the sample has no warnings, so every such image is an error mark).
|
||||
fn error_discs(app: &mut SolveApp, draw: fn(&mut SolveApp, &mut egui::Ui)) -> Vec<Pos2> {
|
||||
crate::scenario::clear_handles();
|
||||
let ctx = egui::Context::default();
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0));
|
||||
let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui));
|
||||
let errors = [crate::theme::Tokens::light().error, crate::theme::Tokens::dark().error];
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| draw(app, ui));
|
||||
// Icons upload textures; dropping their deltas unapplied would panic.
|
||||
out.textures_delta.clear();
|
||||
let mut found = Vec::new();
|
||||
for clipped in &out.shapes {
|
||||
each(&clipped.shape, &mut |s| {
|
||||
if let Shape::Circle(c) = s
|
||||
&& errors.contains(&c.fill)
|
||||
{
|
||||
found.push(c.center);
|
||||
if let Shape::Mesh(m) = s {
|
||||
let b = m.calc_bounds();
|
||||
if matches!(m.texture_id, egui::TextureId::Managed(_)) && (b.width() - 11.0).abs() < 1.5 && (b.height() - 11.0).abs() < 1.5 {
|
||||
found.push(b.center());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -81,27 +85,3 @@ fn a_failed_feature_is_marked_in_the_feature_history() {
|
||||
let got = marked(&mut app, crate::timeline_left::left_timeline, |item, d| (d.y - item.y).abs() < 1.5 && d.x > item.x);
|
||||
assert_eq!(got, want, "an error mark on each failed feature and on no other");
|
||||
}
|
||||
|
||||
/// The mark is its own drawing: a disc with a cross in the caller's colours, not the fallback
|
||||
/// square, and a different shape from the warning's triangle.
|
||||
#[test]
|
||||
fn the_error_mark_is_a_disc_with_a_cross() {
|
||||
let ctx = egui::Context::default();
|
||||
let (ink, fill) = (egui::Color32::from_rgb(1, 2, 3), egui::Color32::from_rgb(4, 5, 6));
|
||||
let draw = |name: &'static str| {
|
||||
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::icons::paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, vec2(24.0, 24.0)), name, ink, fill, fill);
|
||||
});
|
||||
let mut shapes = Vec::new();
|
||||
for c in &out.shapes {
|
||||
each(&c.shape, &mut |s| shapes.push(s.clone()));
|
||||
}
|
||||
shapes
|
||||
};
|
||||
let error = draw("error");
|
||||
let discs = error.iter().filter(|s| matches!(s, Shape::Circle(c) if c.fill == fill && c.stroke.color == ink)).count();
|
||||
let strokes = error.iter().filter(|s| matches!(s, Shape::Path(p) if !p.closed && p.stroke.color == egui::epaint::ColorMode::Solid(ink))).count();
|
||||
assert_eq!((discs, strokes), (1, 2), "one disc and the two strokes of a cross: {error:?}");
|
||||
assert_ne!(format!("{error:?}"), format!("{:?}", draw("warning")));
|
||||
assert_ne!(format!("{error:?}"), format!("{:?}", draw("no such icon")), "drawn, not the fallback square");
|
||||
}
|
||||
+22
-113
@@ -10,39 +10,10 @@ use crate::{SolveApp, icons};
|
||||
|
||||
pub const TABS: &[&str] = &["SOLID", "SURFACE", "MESH", "SHEET METAL", "PLASTIC", "UTILITIES"];
|
||||
|
||||
/// The accent stroke colour for a panel's icons: constraint commands use the same red as the
|
||||
/// constraint glyphs they draw in the sketch (`glyph_text`), sketch tools otherwise keep
|
||||
/// `sketch_accent`, and every other tab keeps the plain `accent`.
|
||||
fn panel_accent(t: Tokens, tab: &str, panel: &str) -> Color32 {
|
||||
if panel == "CONSTRAINTS" {
|
||||
t.glyph_text
|
||||
} else if tab == "SKETCH" {
|
||||
t.sketch_accent
|
||||
} else {
|
||||
t.accent
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent stroke colour a command's icon keeps on every surface that draws it, where the
|
||||
/// command's own meaning fixes it. Construction geometry carries one fixed meaning across the
|
||||
/// whole app. `sketch.construction` and `sketch.centerline` therefore draw their icon in the
|
||||
/// theme's construction orange on the ribbon, in a panel's drop-down and under the pointer while
|
||||
/// being dragged. The Sketch Palette's own line type buttons pass that same orange. `icons::paint`
|
||||
/// draws both of these icon names in the accent its caller hands it, never in a colour of its own.
|
||||
/// The caller is the only place the rule can live. Every other command has no fixed colour. It
|
||||
/// keeps the accent of whichever surface draws it.
|
||||
fn fixed_accent(t: Tokens, id: &str) -> Option<Color32> {
|
||||
match id {
|
||||
"sketch.construction" | "sketch.centerline" => Some(t.construction_geom),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent stroke colour for one command's icon on the ribbon. It covers the command's own
|
||||
/// button and a collapsed panel's single button. A command with a `fixed_accent` keeps that.
|
||||
/// Every other command takes its `panel_accent`.
|
||||
fn command_accent(t: Tokens, tab: &str, panel: &str, id: &str) -> Color32 {
|
||||
fixed_accent(t, id).unwrap_or_else(|| panel_accent(t, tab, panel))
|
||||
/// Legacy paint arguments and non-SVG affordances follow the same semantic palette as the icon.
|
||||
/// The requested icon id determines its meaning on ribbons, menus and drag previews alike.
|
||||
fn command_accent(icon: &str) -> Color32 {
|
||||
crate::icon_svg::primary(icon, crate::theme::is_dark(), crate::theme::vision())
|
||||
}
|
||||
|
||||
/// Panels shown on each tab (in order). Commands come from the registry by (tab, panel).
|
||||
@@ -281,8 +252,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.35) };
|
||||
icons::paint(ui.painter(), br.shrink(5.0), icon, dim(t.app_bar_icon[0]), dim(t.app_bar_icon[1]), dim(t.app_bar_icon[2]));
|
||||
icons::paint_on(ui.painter(), br.shrink(5.0), icon, Some(true), if enabled { 1.0 } else { 0.35 });
|
||||
let clicked = resp.on_hover_text(tip).clicked();
|
||||
enabled && clicked
|
||||
};
|
||||
@@ -351,7 +321,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.painter().rect_filled(report, 4.0, if response.hovered() || response.has_focus() { t.hover } else { t.app_bar });
|
||||
ui.painter().rect_stroke(report, 4.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(ui.painter(), icon, "bug_report", t.app_bar_text, t.app_bar_text, t.app_bar);
|
||||
icons::paint_on(ui.painter(), icon, "bug_report", Some(true), 1.0);
|
||||
if report_label {
|
||||
ui.painter().text(pos2(report.left() + REPORT_TEXT_X, report.center().y), Align2::LEFT_CENTER, report_text, report_font, t.app_bar_text);
|
||||
}
|
||||
@@ -364,7 +334,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if gresp.hovered() {
|
||||
ui.painter().rect_filled(gear, 4.0, Color32::from_white_alpha(30));
|
||||
}
|
||||
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
|
||||
icons::paint_on(ui.painter(), gear.shrink(4.0), "settings", Some(true), 1.0);
|
||||
crate::scenario::publish_handle("titlebar:preferences", gear.center());
|
||||
let prefs_tip = if cfg!(target_os = "macos") {
|
||||
format!("{} (Cmd+,)", app.tr("ui.preferences"))
|
||||
@@ -597,7 +567,15 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let ws = Rect::from_min_max(pos2(r.left() + 6.0, r.top() + 4.0), pos2(r.left() + 104.0, r.bottom() - 5.0));
|
||||
let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click());
|
||||
painter.rect(ws, 4.0, if wresp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)), "box", t.icon, t.icon_fill, t.accent);
|
||||
let workspace_icon = if crate::render_workspace::active(app) { "ws_render" } else { "ws_design" };
|
||||
icons::paint(
|
||||
&painter,
|
||||
Rect::from_center_size(pos2(ws.center().x, ws.top() + 24.0), vec2(26.0, 26.0)),
|
||||
workspace_icon,
|
||||
t.icon,
|
||||
t.icon_fill,
|
||||
t.accent,
|
||||
);
|
||||
painter.text(
|
||||
pos2(ws.center().x - 5.0, ws.top() + 52.0),
|
||||
Align2::CENTER_CENTER,
|
||||
@@ -719,7 +697,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
if let Some(c) = shown.first().or(cmds.first()) {
|
||||
let acc = command_accent(t, &tab, panel, c.id);
|
||||
let acc = command_accent(c.icon);
|
||||
icons::paint(&painter, Rect::from_center_size(br.center() - vec2(0.0, 3.0), vec2(26.0, 26.0)), c.icon, t.icon, t.icon_fill, acc);
|
||||
}
|
||||
caret(&painter, pos2(br.center().x, br.bottom() - 3.0), t.text_dim);
|
||||
@@ -769,14 +747,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
} else if resp.hovered() && info.enabled {
|
||||
painter.rect_filled(br, 4.0, t.hover);
|
||||
}
|
||||
let (ink, fill, acc) = if info.enabled {
|
||||
(t.icon, t.icon_fill, command_accent(t, &tab, panel, selected.id))
|
||||
} else {
|
||||
(t.border, t.panel_header, t.border)
|
||||
};
|
||||
let (ink, fill, acc) =
|
||||
if info.enabled { (t.icon, t.icon_fill, command_accent(selected.icon)) } else { (t.border, t.panel_header, t.border) };
|
||||
let icon = match selected.id {
|
||||
"sketch.finish" => "finish",
|
||||
"config.table" => "configuration_table",
|
||||
_ => selected.icon,
|
||||
};
|
||||
let icon_rect =
|
||||
@@ -1105,11 +1079,9 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine
|
||||
p.rect_filled(rect, 3.0, t.hover);
|
||||
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
|
||||
}
|
||||
// Every other row keeps the plain accent it always has. A constraint command's row switches
|
||||
// to the constraint red, to match its button on the ribbon. A command with a `fixed_accent`
|
||||
// keeps that colour ahead of either.
|
||||
// The icon keeps its semantic accent in a drop-down as well as on the ribbon.
|
||||
let (ink, fill, acc, text) = if info.enabled {
|
||||
let acc = fixed_accent(t, c.id).unwrap_or(if c.panel == "CONSTRAINTS" { t.glyph_text } else { t.accent });
|
||||
let acc = command_accent(c.icon);
|
||||
(t.icon, t.icon_fill, acc, t.text)
|
||||
} else {
|
||||
(t.border, t.panel_header, t.border, t.text_dim)
|
||||
@@ -1243,7 +1215,7 @@ fn drag_ghost(ctx: &egui::Context, id: &str, at: egui::Pos2, removing: bool) {
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("sc_toolbar_drag")));
|
||||
let r = Rect::from_center_size(at + vec2(14.0, 16.0), vec2(30.0, 30.0));
|
||||
p.rect(r, 4.0, t.toolbar.gamma_multiply(0.92), Stroke::new(1.0, if removing { t.error } else { t.accent }), egui::StrokeKind::Inside);
|
||||
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, fixed_accent(t, c.id).unwrap_or(t.accent));
|
||||
icons::paint(&p, r.shrink(5.0), c.icon, t.icon, t.icon_fill, command_accent(c.icon));
|
||||
if removing {
|
||||
let s = Stroke::new(2.0, t.error);
|
||||
let x = Rect::from_center_size(r.right_top(), vec2(9.0, 9.0));
|
||||
@@ -1788,69 +1760,6 @@ mod panel_drop_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_accent_tests {
|
||||
use super::*;
|
||||
|
||||
/// `icons.rs` pins one half of the construction icon contract. `construction` and
|
||||
/// `centerline` take their colour from whatever accent their caller hands over. That half
|
||||
/// stays green when the caller hands over the wrong colour. That is exactly what the toolbar
|
||||
/// did. This pins the other half: the colour the toolbar actually passes for those two
|
||||
/// commands, on every surface the toolbar draws them on.
|
||||
#[test]
|
||||
fn the_toolbar_passes_construction_orange_for_both_line_type_commands() {
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
// The regression itself: the SKETCH tab's accent is green. These two icons drew in
|
||||
// it. Construction orange has to be a colour no panel accent can supply.
|
||||
assert_ne!(t.construction_geom, t.sketch_accent, "construction orange must differ from the sketch accent");
|
||||
assert_ne!(t.construction_geom, t.accent, "construction orange must differ from the plain accent");
|
||||
assert_ne!(t.construction_geom, t.glyph_text, "construction orange must differ from the constraint red");
|
||||
for id in ["sketch.construction", "sketch.centerline"] {
|
||||
assert_eq!(fixed_accent(t, id), Some(t.construction_geom), "{id} must have a fixed accent");
|
||||
// Its ribbon button, a collapsed panel's single button, and a drop-down row.
|
||||
assert_eq!(command_accent(t, "SKETCH", "CREATE", id), t.construction_geom, "{id} on the SKETCH ribbon");
|
||||
assert_eq!(command_accent(t, "SOLID", "CREATE", id), t.construction_geom, "{id} on the SOLID ribbon");
|
||||
assert_eq!(fixed_accent(t, id).unwrap_or(t.accent), t.construction_geom, "{id} in a drop-down row");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Those two and no others. Every other command's icon keeps the accent of the panel it sits
|
||||
/// on, so the constraint red and the sketch green both survive the exception above.
|
||||
#[test]
|
||||
fn no_other_command_overrides_the_accent_of_its_own_panel() {
|
||||
let t = Tokens::light();
|
||||
let specs = solidcraft_engine::command_specs();
|
||||
let mut fixed: Vec<&str> = specs.iter().map(|c| c.id).filter(|id| fixed_accent(t, id).is_some()).collect();
|
||||
fixed.sort_unstable();
|
||||
assert_eq!(fixed, ["sketch.centerline", "sketch.construction"], "only the two line type commands fix their own accent");
|
||||
for c in &specs {
|
||||
if fixed.contains(&c.id) {
|
||||
continue;
|
||||
}
|
||||
let want = panel_accent(t, c.tab, c.panel);
|
||||
assert_eq!(command_accent(t, c.tab, c.panel, c.id), want, "{} must keep the accent of its own panel", c.id);
|
||||
}
|
||||
// The fix did not flatten every icon to one colour. A sketch tool is still green, a
|
||||
// constraint still red, a solid tool still blue. A missing id fails this as well.
|
||||
let accent_of = |id: &str| solidcraft_engine::find_command(id).map(|c| command_accent(t, c.tab, c.panel, c.id));
|
||||
assert_eq!(accent_of("sketch.text"), Some(t.sketch_accent));
|
||||
assert_eq!(accent_of("sketch.constraint.polygon"), Some(t.glyph_text));
|
||||
assert_eq!(accent_of("solid.extrude"), Some(t.accent));
|
||||
}
|
||||
|
||||
/// Both line type commands sit on the SKETCH tab's CREATE panel. The accent above is
|
||||
/// therefore a colour a person can actually see. Their icons are the two `icons.rs` draws
|
||||
/// from the caller's accent.
|
||||
#[test]
|
||||
fn both_line_type_commands_are_create_panel_commands_with_their_own_icons() {
|
||||
for (id, icon) in [("sketch.construction", "construction"), ("sketch.centerline", "centerline")] {
|
||||
let seen = solidcraft_engine::find_command(id).map(|c| (c.tab, c.panel, c.icon));
|
||||
assert_eq!(seen, Some(("SKETCH", "CREATE", icon)), "{id} must be on the SKETCH CREATE panel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dropdown_toggle_tests {
|
||||
use crate::scenario::{Harness, rect_of};
|
||||
|
||||
@@ -152,8 +152,9 @@ pub fn feature_icon(k: &FeatureKind) -> Option<&'static str> {
|
||||
FeatureKind::SheetConvert { .. } => "convert_sheet",
|
||||
FeatureKind::Boss { .. } => "boss",
|
||||
FeatureKind::Lip { .. } => "lip",
|
||||
FeatureKind::SnapFit { .. } => "snap",
|
||||
FeatureKind::SnapFit { .. } => "snap_fit",
|
||||
FeatureKind::Rest { .. } => "rest",
|
||||
FeatureKind::Rib { web: true, .. } => "web",
|
||||
FeatureKind::Rib { .. } => "rib",
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user