feat(model-space): theme-aware model space canvas and CAD selection/grip sysvars

Model space appearance now follows the active UI theme instead of a
hardcoded dark background. Canvas mode is user-selectable (Match Theme /
Classic CAD Dark / Custom RGB), with matching controls for the paper
sheet and desk surround backgrounds and grid opacity.
Implements industry-standard CAD system variables, all settable via
command name, SETVAR, and the Options dialog:
- COLORTHEME (0/1 dark/light UI scheme, preserves custom palettes)
- COLORSCHEME (interactive named-theme selection)
- BACKGROUND (THEME/CLASSIC/DEFAULT/DESK presets, RGB triplets)
- SELECTIONAREA, SELECTIONAREAOPACITY (translucent marquee fill)
- WINDOWSAREACOLOR/WINDOWAREACOLOR, CROSSINGAREACOLOR (ACI or theme)
- SELECTIONEFFECT, SELECTIONEFFECTCOLOR (GPU highlight pass, driven by
  selection_generation invalidation — no retessellation)
- SELECTIONPREVIEW (bit 0 = idle rollover, bit 1 = in-command hover)
- GRIPSIZE, GRIPCOLOR, GRIPHOT, GRIPHOVER (grip overlay styling)
This commit is contained in:
Karim Jerbi committed 2026-09-03 17:44:44 +01:00
1 parent 89b1a22850
commit 688785a6cf
18 files changed
+1949 -316

No files matched your search

+244 -79
View File
@@ -71,6 +71,52 @@ pub struct CrosshairOptions {
pub snap_angle_deg: f32,
}
/// Rendering style for the viewport grid.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GridStyle {
pub opacity: u8,
pub bg_luminance: f32,
}
impl Default for GridStyle {
fn default() -> Self {
Self {
opacity: 18,
bg_luminance: 0.15,
}
}
}
/// Visual effect options for selection windows, crossings, entity highlights, and grips.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SelectionVisualOptions {
pub area: bool,
pub opacity: u8,
pub window_color: u8,
pub crossing_color: u8,
pub highlight_color: u8,
pub grip_size: f32,
pub grip_color: u8,
pub grip_hot: u8,
pub grip_hover: u8,
}
impl Default for SelectionVisualOptions {
fn default() -> Self {
Self {
area: true,
opacity: 12,
window_color: 0,
crossing_color: 0,
highlight_color: 0,
grip_size: 5.0,
grip_color: 0,
grip_hot: 0,
grip_hover: 0,
}
}
}
// ── Grip marker data ──────────────────────────────────────────────────────
/// Describes one grip to be drawn in the viewport overlay.
@@ -147,13 +193,13 @@ impl GridGeometry {
pub(crate) struct GridKey {
pub grids: Vec<GridParams>,
pub bounds: iced::Rectangle,
pub style: GridStyle,
}
impl GridKey {
/// Build a key from the per-pane `GridParams` and the overlay bounds. The
/// input slice is copied; callers can drop the original.
pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle) -> Self {
Self { grids: grids.to_vec(), bounds }
/// Build a key from the per-pane `GridParams`, overlay bounds, and grid style.
pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle, style: GridStyle) -> Self {
Self { grids: grids.to_vec(), bounds, style }
}
}
@@ -281,8 +327,9 @@ pub struct OstTrackPoint {
pub fn grid_overlay<'a>(
grid: Vec<GridParams>,
style: GridStyle,
) -> Element<'a, Message> {
canvas(GridCanvas { grid })
canvas(GridCanvas { grid, style })
.width(Length::Fill)
.height(Length::Fill)
.into()
@@ -290,6 +337,7 @@ pub fn grid_overlay<'a>(
struct GridCanvas {
grid: Vec<GridParams>,
style: GridStyle,
}
impl canvas::Program<Message> for GridCanvas {
@@ -303,10 +351,10 @@ impl canvas::Program<Message> for GridCanvas {
bounds: iced::Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let key = GridKey::from_grids(&self.grid, bounds);
let key = GridKey::from_grids(&self.grid, bounds, self.style);
// Hit check: same params, same bounds ⇒ the cached geometry is still
// valid. The key includes bounds, so a `should_reuse` match implies
// Hit check: same params, same bounds, same style ⇒ the cached geometry is still
// valid. The key includes bounds and style, so a `should_reuse` match implies
// both are equal, and the fork's `draw_with_bounds` will return the
// cached `Arc` clone (essentially free).
let hit = should_reuse(state.key.borrow().as_ref(), &key);
@@ -316,11 +364,8 @@ impl canvas::Program<Message> for GridCanvas {
// returns the cached geometry without invoking the closure.
state.cache.draw_with_bounds(renderer, bounds, |_frame| {})
} else {
// Params or bounds changed. Clear so the closure runs even when
// bounds happen to match the previously-cached frame (e.g. a pan
// within the same canvas size would otherwise leave stale
// geometry served — the fork's bounds-equality test would return
// the cached clone without calling our rebuild closure).
// Params, style, or bounds changed. Clear so the closure runs even when
// bounds happen to match the previously-cached frame.
state.cache.clear();
state.cache.draw_with_bounds(renderer, bounds, |frame| {
for g in &self.grid {
@@ -348,14 +393,14 @@ impl canvas::Program<Message> for GridCanvas {
g.origin,
g.axes,
g.limits,
self.style,
)
});
}
})
};
// Update the stored key. Safe: the `borrow()` for `should_reuse` is
// dropped at the end of the `if` expression above; no live Ref here.
// Update the stored key.
*state.key.borrow_mut() = Some(key);
vec![geometry]
@@ -383,6 +428,7 @@ pub fn selection_overlay<'a>(
hover_locked: bool,
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions,
) -> Element<'a, Message> {
canvas(SelectionCanvas {
selection,
@@ -405,6 +451,7 @@ pub fn selection_overlay<'a>(
hover_locked,
crosshair_bg,
crosshair,
selection_visual,
})
.width(Length::Fill)
.height(Length::Fill)
@@ -462,11 +509,17 @@ struct SelectionCanvas {
/// rather than the UI theme, which may be light over a dark model viewport.
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
selection_visual: SelectionVisualOptions,
}
fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) {
fn draw_grip_marker(
frame: &mut canvas::Frame,
grip: &GripMarker,
theme: &Theme,
visual: &SelectionVisualOptions,
) {
let sp = grip.pos;
let h = crate::scene::pick::grip::GRIP_HALF_PX;
let h = visual.grip_size.clamp(1.0, 25.0);
let path = match grip.shape {
GripShape::Square => canvas::Path::rectangle(
Point::new(sp.x - h, sp.y - h),
@@ -521,10 +574,28 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme)
};
if grip.is_hot {
frame.fill(&path, theme.palette().danger.base.color);
let hot_color = if visual.grip_hot > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_hot) {
Color::from_rgb8(r, g, b)
} else {
theme.palette().danger.base.color
}
} else {
theme.palette().danger.base.color
};
frame.fill(&path, hot_color);
} else if grip.is_hovered {
let pair = theme.palette().primary.strong;
frame.fill(&path, pair.color);
let hover_color = if visual.grip_hover > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_hover) {
Color::from_rgb8(r, g, b)
} else {
pair.color
}
} else {
pair.color
};
frame.fill(&path, hover_color);
frame.stroke(
&path,
canvas::Stroke {
@@ -535,7 +606,15 @@ fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme)
);
} else {
let palette = theme.palette();
let color = palette.primary.base.color;
let color = if visual.grip_color > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.grip_color) {
Color::from_rgb8(r, g, b)
} else {
palette.primary.base.color
}
} else {
palette.primary.base.color
};
let fill = if grip.shape == GripShape::Dropdown {
color
} else {
@@ -715,20 +794,40 @@ impl canvas::Program<Message> for SelectionCanvas {
b: Point,
crossing: bool,
theme: &Theme,
visual: &SelectionVisualOptions,
) {
let base = if crossing {
theme.palette().success.base.color
if visual.crossing_color > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.crossing_color) {
Color::from_rgb8(r, g, b)
} else {
theme.palette().success.base.color
}
} else {
theme.palette().success.base.color
}
} else {
theme.palette().primary.base.color
if visual.window_color > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(visual.window_color) {
Color::from_rgb8(r, g, b)
} else {
theme.palette().primary.base.color
}
} else {
theme.palette().primary.base.color
}
};
let fill = base.scale_alpha(0.12);
let stroke = base.scale_alpha(0.9);
let x0 = a.x.min(b.x);
let y0 = a.y.min(b.y);
let w = (a.x - b.x).abs();
let h = (a.y - b.y).abs();
let rect = canvas::Path::rectangle(Point::new(x0, y0), Size::new(w, h));
frame.fill(&rect, fill);
if visual.area && visual.opacity > 0 {
let alpha = (visual.opacity as f32 / 100.0).clamp(0.0, 1.0);
let fill = base.scale_alpha(alpha);
frame.fill(&rect, fill);
}
let stroke = base.scale_alpha(0.9);
frame.stroke(
&rect,
canvas::Stroke {
@@ -740,33 +839,52 @@ impl canvas::Program<Message> for SelectionCanvas {
}
if let (Some(a), Some(b)) = (self.selection.borrow().box_anchor, self.selection.borrow().box_current) {
draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme);
draw_marquee(&mut frame, a, b, self.selection.borrow().box_crossing, theme, &self.selection_visual);
}
// Preview marquee for point-picked windows (STRETCH) — same look, no pick.
if let Some((a, b, crossing)) = self.selection.borrow().preview_box {
draw_marquee(&mut frame, a, b, crossing, theme);
draw_marquee(&mut frame, a, b, crossing, theme, &self.selection_visual);
}
if self.selection.borrow().poly_active && self.selection.borrow().poly_points.len() > 1 {
let base = if self.selection.borrow().poly_crossing {
theme.palette().success.base.color
} else {
theme.palette().primary.base.color
};
let fill = base.scale_alpha(0.12);
let stroke = base.scale_alpha(0.9);
if let Some(cur) = self.selection.borrow().last_move_pos {
let start = self.selection.borrow().poly_points[0];
let fill_path = canvas::Path::new(|p| {
p.move_to(start);
for pt in &self.selection.borrow().poly_points[1..] {
p.line_to(*pt);
if self.selection_visual.crossing_color > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(self.selection_visual.crossing_color) {
Color::from_rgb8(r, g, b)
} else {
theme.palette().success.base.color
}
p.line_to(cur);
p.line_to(start);
});
frame.fill(&fill_path, fill);
} else {
theme.palette().success.base.color
}
} else {
if self.selection_visual.window_color > 0 {
if let Some((r, g, b)) = acadrust::types::aci_table::aci_to_rgb(self.selection_visual.window_color) {
Color::from_rgb8(r, g, b)
} else {
theme.palette().primary.base.color
}
} else {
theme.palette().primary.base.color
}
};
if self.selection_visual.area && self.selection_visual.opacity > 0 {
let alpha = (self.selection_visual.opacity as f32 / 100.0).clamp(0.0, 1.0);
let fill = base.scale_alpha(alpha);
if let Some(cur) = self.selection.borrow().last_move_pos {
let start = self.selection.borrow().poly_points[0];
let fill_path = canvas::Path::new(|p| {
p.move_to(start);
for pt in &self.selection.borrow().poly_points[1..] {
p.line_to(*pt);
}
p.line_to(cur);
p.line_to(start);
});
frame.fill(&fill_path, fill);
}
}
let stroke = base.scale_alpha(0.9);
let path = canvas::Path::new(|p| {
p.move_to(self.selection.borrow().poly_points[0]);
for pt in &self.selection.borrow().poly_points[1..] {
@@ -840,7 +958,7 @@ impl canvas::Program<Message> for SelectionCanvas {
}
}
for grip in &self.grips {
draw_grip_marker(frame, grip, theme);
draw_grip_marker(frame, grip, theme, &self.selection_visual);
}
});
}
@@ -1242,6 +1360,7 @@ impl canvas::Program<Message> for SelectionCanvas {
ucs.origin_screen,
ucs.hover,
ucs.selected,
self.crosshair_bg,
);
}
@@ -1341,6 +1460,7 @@ const MIN_GRID_PX: f32 = 20.0;
/// Stop an infinite perspective grid before adjacent lines merge at the horizon.
const MIN_HORIZON_GRID_PX: f32 = 5.0;
#[allow(clippy::too_many_arguments)]
fn draw_grid(
frame: &mut canvas::Frame,
view_rot: Mat4,
@@ -1350,12 +1470,25 @@ fn draw_grid(
grid_origin: glam::DVec3,
grid_axes: (Vec3, Vec3, Vec3),
limits: Option<(glam::DVec2, glam::DVec2)>,
style: GridStyle,
) {
let gc = Color {
r: 0.28,
g: 0.28,
b: 0.28,
a: 0.7,
let alpha = (style.opacity as f32 / 100.0).clamp(0.02, 1.0);
let gc = if style.bg_luminance > 0.5 {
// Light background: subtle dark grid lines
Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: alpha,
}
} else {
// Dark background: subtle light grid lines
Color {
r: 0.80,
g: 0.80,
b: 0.80,
a: alpha,
}
};
let st = canvas::Stroke {
width: 0.5,
@@ -1375,7 +1508,7 @@ fn draw_grid(
if geometry.axis_extent > 0.0 {
let (gx, gy, gz) = grid_axes;
let extent = (geometry.axis_extent + step) * 1.5;
draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz));
draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz), style.bg_luminance);
}
}
@@ -1922,6 +2055,7 @@ pub(crate) fn grid_segments(
// ── Coloured UCS axes ──────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
fn draw_axes(
frame: &mut canvas::Frame,
view_rot: Mat4,
@@ -1930,6 +2064,7 @@ fn draw_axes(
extent: f32,
origin: glam::DVec3,
axes: (Vec3, Vec3, Vec3),
bg_luminance: f32,
) {
let w2s = |world: glam::DVec3| -> Point {
let ndc = view_rot.project_point3((world - eye).as_vec3());
@@ -1955,8 +2090,9 @@ fn draw_axes(
axis_stroke(r, g, b),
);
};
let y_green = if bg_luminance > 0.5 { 0.60 } else { 0.85 };
line(ax, 0.90, 0.20, 0.20); // X — red
line(ay, 0.20, 0.85, 0.20); // Y — green
line(ay, 0.20, y_green, 0.20); // Y — green
line(az, 0.20, 0.40, 0.90); // Z — blue
}
@@ -2090,6 +2226,7 @@ fn draw_ucs_icon(
origin_screen: Option<Point>,
hover: bool,
selected: bool,
crosshair_bg: [f32; 4],
) {
let Some((icon_origin, at_origin, geom)) =
ucs_icon_geometry(vp, bounds, axes, origin_screen)
@@ -2101,6 +2238,8 @@ fn draw_ucs_icon(
// corner), so hover/selection highlight applies there too.
let _ = at_origin;
let highlight = hover || selected;
let is_light_bg = 0.299 * crosshair_bg[0] + 0.587 * crosshair_bg[1] + 0.114 * crosshair_bg[2] > 0.5;
let y_green = if is_light_bg { 0.60 } else { 0.85 };
struct AxisInfo {
dx: f32,
@@ -2129,7 +2268,7 @@ fn draw_ucs_icon(
sc_len: geom[1].sc_len,
depth: geom[1].depth,
r: 0.22,
g: 0.85,
g: y_green,
b: 0.22,
label: "Y",
},
@@ -2228,15 +2367,23 @@ fn draw_ucs_icon(
// Origin dot.
let circle = canvas::Path::circle(icon_origin, 3.5);
frame.fill(
&circle,
let dot_lum = 0.299 * crosshair_bg[0] + 0.587 * crosshair_bg[1] + 0.114 * crosshair_bg[2];
let dot_color = if dot_lum > 0.5 {
Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 0.95,
}
} else {
Color {
r: 0.9,
g: 0.9,
b: 0.9,
a: 0.95,
},
);
}
};
frame.fill(&circle, dot_color);
// Draggable grips when selected: a square at the origin and at the X / Y
// tips. Warm grip colour with a light border, like an entity grip.
@@ -3043,11 +3190,11 @@ mod bench_grid_geometry_tests {
// Pre-seed a `GridCanvasState` with the same key the bench will
// build each iteration — guaranteed hit path.
let state = GridCanvasState::default();
let stored_key = GridKey::from_grids(&grids, canvas_bounds);
let stored_key = GridKey::from_grids(&grids, canvas_bounds, GridStyle::default());
*state.key.borrow_mut() = Some(stored_key);
for _ in 0..20 {
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds));
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default());
let hit = should_reuse(state.key.borrow().as_ref(), &key);
black_box(hit);
}
@@ -3056,7 +3203,7 @@ mod bench_grid_geometry_tests {
let start = Instant::now();
let mut hit_count = 0u32;
for _ in 0..n {
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds));
let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default());
if should_reuse(state.key.borrow().as_ref(), &key) {
hit_count += 1;
}
@@ -3098,17 +3245,17 @@ mod grid_key_tests {
}
}
/// Same `Vec<GridParams>` + same bounds ⇒ keys compare equal.
/// Same `Vec<GridParams>` + same bounds + same style ⇒ keys compare equal.
#[test]
fn grid_key_matches_identical_params() {
let grids = vec![baseline_params(), baseline_params()];
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
let a = GridKey::from_grids(&grids, bounds);
let b = GridKey::from_grids(&grids, bounds);
let a = GridKey::from_grids(&grids, bounds, GridStyle::default());
let b = GridKey::from_grids(&grids, bounds, GridStyle::default());
assert_eq!(a, b);
}
/// One test, one baseline. Every change of any of the 7 inputs must produce
/// One test, one baseline. Every change of any of the inputs must produce
/// a key that differs from the baseline. This is the entire correctness
/// contract for cache hit/miss — if any input is ignored, the cache serves
/// a stale grid.
@@ -3121,13 +3268,13 @@ mod grid_key_tests {
height: 720.0,
};
let baseline_grids = vec![baseline_params()];
let baseline_key = GridKey::from_grids(&baseline_grids, baseline_bounds);
let baseline_key = GridKey::from_grids(&baseline_grids, baseline_bounds, GridStyle::default());
// view_rot: small extra rotation
let mut p = baseline_params();
p.view_rot = Mat4::from_rotation_x(0.15 + 0.01) * Mat4::from_rotation_y(0.05);
assert_ne!(
GridKey::from_grids(&[p], baseline_bounds),
GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()),
baseline_key,
"view_rot change must invalidate"
);
@@ -3136,7 +3283,7 @@ mod grid_key_tests {
let mut p = baseline_params();
p.eye = glam::DVec3::new(4.0, 3.5, 9.5);
assert_ne!(
GridKey::from_grids(&[p], baseline_bounds),
GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()),
baseline_key,
"eye change must invalidate"
);
@@ -3145,7 +3292,7 @@ mod grid_key_tests {
let mut p = baseline_params();
p.step = 40.0;
assert_ne!(
GridKey::from_grids(&[p], baseline_bounds),
GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()),
baseline_key,
"step change must invalidate"
);
@@ -3154,7 +3301,7 @@ mod grid_key_tests {
let mut p = baseline_params();
p.origin = glam::DVec3::new(100.0, 0.0, 0.0);
assert_ne!(
GridKey::from_grids(&[p], baseline_bounds),
GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()),
baseline_key,
"origin change must invalidate"
);
@@ -3163,7 +3310,7 @@ mod grid_key_tests {
let mut p = baseline_params();
p.axes = (Vec3::Y, Vec3::X, Vec3::Z);
assert_ne!(
GridKey::from_grids(&[p], baseline_bounds),
GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()),
baseline_key,
"axes change must invalidate"
);
@@ -3172,7 +3319,7 @@ mod grid_key_tests {
let mut p = baseline_params();
p.limits = Some((glam::DVec2::new(0.0, 0.0), glam::DVec2::new(100.0, 100.0)));
assert_ne!(
GridKey::from_grids(&[p], baseline_bounds),
GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()),
baseline_key,
"limits change must invalidate"
);
@@ -3185,10 +3332,28 @@ mod grid_key_tests {
height: 720.0,
};
assert_ne!(
GridKey::from_grids(&baseline_grids, other_bounds),
GridKey::from_grids(&baseline_grids, other_bounds, GridStyle::default()),
baseline_key,
"bounds change must invalidate"
);
// style opacity: change opacity
let mut style = GridStyle::default();
style.opacity = 50;
assert_ne!(
GridKey::from_grids(&baseline_grids, baseline_bounds, style),
baseline_key,
"style opacity change must invalidate"
);
// style bg_luminance: change luminance (dark to light)
let mut style = GridStyle::default();
style.bg_luminance = 0.9;
assert_ne!(
GridKey::from_grids(&baseline_grids, baseline_bounds, style),
baseline_key,
"style bg_luminance change must invalidate"
);
}
/// In a 2-pane tiled layout, changing the second pane's params (with pane 1
@@ -3200,13 +3365,13 @@ mod grid_key_tests {
let pane1 = baseline_params();
let pane2 = baseline_params();
let both = vec![pane1.clone(), pane2.clone()];
let baseline = GridKey::from_grids(&both, bounds);
let baseline = GridKey::from_grids(&both, bounds, GridStyle::default());
let mut pane2_changed = pane2;
pane2_changed.step = 160.0;
let dirty = vec![pane1, pane2_changed];
assert_ne!(
GridKey::from_grids(&dirty, bounds),
GridKey::from_grids(&dirty, bounds, GridStyle::default()),
baseline,
"second pane change must invalidate"
);
@@ -3217,7 +3382,7 @@ mod grid_key_tests {
fn should_reuse_empty() {
let grids = vec![baseline_params()];
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
let key = GridKey::from_grids(&grids, bounds);
let key = GridKey::from_grids(&grids, bounds, GridStyle::default());
assert!(!should_reuse(None, &key));
}
@@ -3226,7 +3391,7 @@ mod grid_key_tests {
fn should_reuse_equal() {
let grids = vec![baseline_params()];
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
let key = GridKey::from_grids(&grids, bounds);
let key = GridKey::from_grids(&grids, bounds, GridStyle::default());
assert!(should_reuse(Some(&key), &key));
}
@@ -3239,8 +3404,8 @@ mod grid_key_tests {
pane2.step = 160.0;
let grids_b = vec![pane2];
let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 };
let old = GridKey::from_grids(&grids_a, bounds);
let new = GridKey::from_grids(&grids_b, bounds);
let old = GridKey::from_grids(&grids_a, bounds, GridStyle::default());
let new = GridKey::from_grids(&grids_b, bounds, GridStyle::default());
assert!(!should_reuse(Some(&old), &new));
}
}
@@ -3281,7 +3446,7 @@ mod grid_canvas_state_tests {
axes: grid_axes,
limits,
};
let key = GridKey::from_grids(&[params], bounds);
let key = GridKey::from_grids(&[params], bounds, GridStyle::default());
let state = GridCanvasState::default();
*state.key.borrow_mut() = Some(key.clone());
@@ -3291,7 +3456,7 @@ mod grid_canvas_state_tests {
// Different bounds on the same params ⇒ different key, do not reuse.
let other_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 640.0, height: 480.0 };
let other_key = GridKey::from_grids(&[params], other_bounds);
let other_key = GridKey::from_grids(&[params], other_bounds, GridStyle::default());
assert!(!should_reuse(state.key.borrow().as_ref(), &other_key));
}
}
+429 -44
View File
@@ -27,6 +27,7 @@ impl<T> fmt::Display for Labelled<T> {
}
}
#[allow(clippy::too_many_arguments)]
pub fn view_window<'a>(
default_save_format: &'a str,
file_assoc_enabled: bool,
@@ -40,6 +41,10 @@ pub fn view_window<'a>(
crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str,
lineweight_display_scale: i32,
model_space: &'a crate::app::config::ModelSpaceThemeConfig,
model_bg_input: &'a str,
paper_bg_input: &'a str,
desk_bg_input: &'a str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let selected_format = crate::io::SAVE_FORMAT_OPTIONS
@@ -204,7 +209,144 @@ pub fn view_window<'a>(
..Default::default()
});
let display = column![
let model_bg_rgb = model_space.custom_bg.unwrap_or(crate::app::config::CLASSIC_CAD_DARK_BG);
let model_bg_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
model_bg_rgb[0],
model_bg_rgb[1],
model_bg_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let paper_bg_rgb = model_space.custom_paper_bg.unwrap_or(crate::app::config::DEFAULT_PAPER_BG);
let paper_bg_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
paper_bg_rgb[0],
paper_bg_rgb[1],
paper_bg_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let desk_bg_rgb = model_space.custom_desk_bg.unwrap_or(crate::app::config::DEFAULT_DESK_BG);
let desk_bg_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
desk_bg_rgb[0],
desk_bg_rgb[1],
desk_bg_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let mode_options = crate::app::config::ModelSpaceMode::ALL
.into_iter()
.map(|value| Labelled {
label: crate::t!(value.label()).into_owned(),
value,
})
.collect::<Vec<_>>();
let selected_mode = mode_options
.iter()
.find(|choice| choice.value == model_space.mode)
.cloned();
let color_choice_options = [
(0u8, "0: Theme Default"),
(1, "1: Red"),
(2, "2: Yellow"),
(3, "3: Green"),
(4, "4: Cyan"),
(5, "5: Blue"),
(6, "6: Magenta"),
(7, "7: White/Black"),
]
.into_iter()
.map(|(value, label)| Labelled {
value,
label: label.to_string(),
})
.collect::<Vec<_>>();
let selected_window_color = color_choice_options
.iter()
.find(|c| c.value == model_space.selection_window_color)
.cloned()
.unwrap_or_else(|| Labelled {
value: model_space.selection_window_color,
label: format!("ACI {}", model_space.selection_window_color),
});
let selected_crossing_color = color_choice_options
.iter()
.find(|c| c.value == model_space.selection_crossing_color)
.cloned()
.unwrap_or_else(|| Labelled {
value: model_space.selection_crossing_color,
label: format!("ACI {}", model_space.selection_crossing_color),
});
let selected_highlight_color = color_choice_options
.iter()
.find(|c| c.value == model_space.selection_highlight_color)
.cloned()
.unwrap_or_else(|| Labelled {
value: model_space.selection_highlight_color,
label: format!("ACI {}", model_space.selection_highlight_color),
});
let selected_grip_color = color_choice_options
.iter()
.find(|c| c.value == model_space.grip_color)
.cloned()
.unwrap_or_else(|| Labelled {
value: model_space.grip_color,
label: format!("ACI {}", model_space.grip_color),
});
let selected_grip_hot = color_choice_options
.iter()
.find(|c| c.value == model_space.grip_hot)
.cloned()
.unwrap_or_else(|| Labelled {
value: model_space.grip_hot,
label: format!("ACI {}", model_space.grip_hot),
});
let selected_grip_hover = color_choice_options
.iter()
.find(|c| c.value == model_space.grip_hover)
.cloned()
.unwrap_or_else(|| Labelled {
value: model_space.grip_hover,
label: format!("ACI {}", model_space.grip_hover),
});
let mut display = column![
text(crate::tr!("options", "theme-section")).size(15),
Space::new().height(10),
row![
@@ -226,71 +368,176 @@ pub fn view_window<'a>(
Space::new().height(12),
color_controls,
Space::new().height(24),
text(crate::t!("Crosshair")).size(15),
Space::new().height(10),
row![
text(crate::t!("Crosshair size")).size(12).width(140),
slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged)
.step(1)
.width(Fill),
text(format!("{}%", cursor_size.clamp(1, 100)))
.size(11)
.width(44),
text(crate::t!("Model Space Appearance")).size(15),
Space::new().width(Fill),
button(text(crate::t!("Restore Defaults")).size(11))
.on_press(Message::RestoreModelSpaceDisplayDefaults)
.padding([4, 10])
.style(button::secondary),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Cursor type")).size(12).width(140),
text(crate::t!("Canvas mode")).size(12).width(140),
iced::widget::pick_list(
selected_cursor,
cursor_options,
selected_mode,
mode_options,
|choice| choice.label.clone(),
)
.on_select(|choice| Message::CursorTypeChanged(choice.value))
.on_select(|choice| Message::ModelSpaceModeChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
];
if model_space.mode == crate::app::config::ModelSpaceMode::Custom {
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Model background")).size(12).width(140),
model_bg_swatch,
text_input("#RRGGBB", model_bg_input)
.on_input(Message::ModelSpaceBgChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
);
}
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Crosshair color")).size(12).width(140),
crosshair_swatch,
text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input)
.on_input(Message::CrosshairColorChanged)
text(crate::t!("Paper background")).size(12).width(140),
paper_bg_swatch,
text_input("#RRGGBB", paper_bg_input)
.on_input(Message::PaperSpaceBgChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!("Leave the color blank to keep automatic viewport contrast."))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Lineweight")).size(15),
Space::new().height(10),
);
display = display.push(Space::new().height(10)).push(
row![
text(crate::t!("Model display scale")).size(12).width(140),
slider(
25..=200,
lineweight_display_scale.clamp(25, 200),
Message::LineweightDisplayScaleChanged,
)
.step(1)
.width(Fill),
text(format!("{}%", lineweight_display_scale.clamp(25, 200)))
.size(11)
.width(44),
text(crate::t!("Desk surround")).size(12).width(140),
desk_bg_swatch,
text_input("#RRGGBB", desk_bg_input)
.on_input(Message::DeskSpaceBgChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!("Changes the on-screen width in Model without affecting plotted output."))
);
display = display
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Grid opacity")).size(12).width(140),
slider(5..=100, model_space.grid_opacity.clamp(5, 100), Message::GridOpacityChanged)
.step(1)
.width(Fill),
text(format!("{}%", model_space.grid_opacity.clamp(5, 100)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(match model_space.mode {
crate::app::config::ModelSpaceMode::MatchTheme => {
crate::t!("Canvas background, grid, and default line colors automatically adapt to the active theme.")
}
crate::app::config::ModelSpaceMode::ClassicDark => {
crate::t!("Model space canvas remains locked to classic CAD dark charcoal (#212830).")
}
crate::app::config::ModelSpaceMode::Custom => {
crate::t!("Custom background colors set above are applied to Model and Paper space.")
}
})
.size(11)
.width(sizing.width),
]
.spacing(0)
.width(sizing.width);
)
.push(Space::new().height(24))
.push(text(crate::t!("Crosshair")).size(15))
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Crosshair size")).size(12).width(140),
slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged)
.step(1)
.width(Fill),
text(format!("{}%", cursor_size.clamp(1, 100)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Cursor type")).size(12).width(140),
iced::widget::pick_list(
selected_cursor,
cursor_options,
|choice| choice.label.clone(),
)
.on_select(|choice| Message::CursorTypeChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Crosshair color")).size(12).width(140),
crosshair_swatch,
text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input)
.on_input(Message::CrosshairColorChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(crate::t!("Leave the color blank to keep automatic viewport contrast."))
.size(11)
.width(sizing.width),
)
.push(Space::new().height(24))
.push(text(crate::t!("Lineweight")).size(15))
.push(Space::new().height(10))
.push(
row![
text(crate::t!("Model display scale")).size(12).width(140),
slider(
25..=200,
lineweight_display_scale.clamp(25, 200),
Message::LineweightDisplayScaleChanged,
)
.step(1)
.width(Fill),
text(format!("{}%", lineweight_display_scale.clamp(25, 200)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
)
.push(Space::new().height(6))
.push(
text(crate::t!("Changes the on-screen width in Model without affecting plotted output."))
.size(11)
.width(sizing.width),
);
let display_element = display.spacing(0).width(sizing.width);
let selection = column![
text(crate::t!("Selection")).size(15),
@@ -310,13 +557,151 @@ pub fn view_window<'a>(
))
.size(11)
.width(sizing.width),
Space::new().height(24),
row![
text(crate::t!("Visual Effect Settings")).size(15),
Space::new().width(Fill),
button(text(crate::t!("Restore Defaults")).size(11))
.on_press(Message::RestoreSelectionVisualDefaults)
.padding([4, 10])
.style(button::secondary),
]
.align_y(iced::Center),
Space::new().height(10),
row![
iced::widget::checkbox(model_space.selection_area)
.on_toggle(Message::SelectionAreaToggled)
.size(15),
text(crate::t!("Indicate selection area with transparent fill (SELECTIONAREA)"))
.size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Selection area opacity")).size(12).width(140),
slider(
0..=100,
model_space.selection_opacity.clamp(0, 100),
Message::SelectionOpacityChanged,
)
.step(1)
.width(Fill),
text(format!("{}%", model_space.selection_opacity.clamp(0, 100)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Transparency of the window and crossing selection areas (SELECTIONAREAOPACITY)."
))
.size(11)
.width(sizing.width),
Space::new().height(10),
row![
text(crate::t!("Window selection color")).size(12).width(140),
iced::widget::pick_list(
Some(selected_window_color),
color_choice_options.clone(),
|choice| choice.label.clone(),
)
.on_select(|choice| Message::SelectionWindowColorChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Crossing selection color")).size(12).width(140),
iced::widget::pick_list(
Some(selected_crossing_color),
color_choice_options.clone(),
|choice| choice.label.clone(),
)
.on_select(|choice| Message::SelectionCrossingColorChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Selection highlight color")).size(12).width(140),
iced::widget::pick_list(
Some(selected_highlight_color),
color_choice_options.clone(),
|choice| choice.label.clone(),
)
.on_select(|choice| Message::SelectionHighlightColorChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(24),
text(crate::t!("Grip Settings")).size(15),
Space::new().height(10),
row![
text(crate::t!("Grip size")).size(12).width(140),
slider(
1..=25,
model_space.grip_size.clamp(1, 25),
Message::GripSizeChanged,
)
.step(1)
.width(Fill),
text(format!("{} px", model_space.grip_size.clamp(1, 25)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Unselected grip color")).size(12).width(140),
iced::widget::pick_list(
Some(selected_grip_color),
color_choice_options.clone(),
|choice| choice.label.clone(),
)
.on_select(|choice| Message::GripColorChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Selected/hot grip color")).size(12).width(140),
iced::widget::pick_list(
Some(selected_grip_hot),
color_choice_options.clone(),
|choice| choice.label.clone(),
)
.on_select(|choice| Message::GripHotChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Hover grip color")).size(12).width(140),
iced::widget::pick_list(
Some(selected_grip_hover),
color_choice_options,
|choice| choice.label.clone(),
)
.on_select(|choice| Message::GripHoverChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
]
.spacing(0)
.width(sizing.width);
let content: Element<'a, Message> = match active_tab {
OptionsTab::General => general.into(),
OptionsTab::Display => display.into(),
OptionsTab::Display => display_element.into(),
OptionsTab::Selection => selection.into(),
};