graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: a Wire Thickness slider in the viewport menu
A slider row under Show Wireframe over the palette row's 1-8 px, half a
pixel a wheel notch, dragged on its band; saved like the opacity slider.
The menu's sliders now come from one table (viewport_menu_slider) and land
through one writer (land_viewport_menu_slider).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 10 +++++--
src/app.rs | 92 ++++++++++++++++++++++++++++++++++++++++++-------------------
src/main.rs | 40 +++++++++++++++++++++++++++
3 files changed, 111 insertions(+), 31 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 120328e..7b79938 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1379,9 +1379,13 @@ multi-selection, so taking `Shift+L` now would have to be given back later.
The viewport's right-click menu carries the DISPLAY MODE under Frame All
and View 1:1: the Show Wireframe switch (its registry command), **Flat
Shading / Smooth Shading** as a radio pair over `toggle_smooth_shading`,
-and the polygon **Opacity** as a SLIDER row — cce-ui's
-`context_menu::MenuSlider` (2026-09-25), set on the shown menu by
-`open_viewport_context_menu`. The wheel over it steps 5% and saves; a press
+a **Wire Thickness** slider under the wireframe switch (1–8 px by
+half a pixel, the palette row's range), and the polygon **Opacity** as a
+SLIDER row — cce-ui's `context_menu::MenuSlider` (2026-09-25), set on the
+shown menu by `open_viewport_context_menu`. `viewport_menu_slider` is the
+one table of the menu's sliders (read from the live value) and
+`land_viewport_menu_slider` writes each back, so another slider row is a
+row in `viewport_menu_rows` plus an arm in each. The wheel over Opacity steps 5% and saves; a press
on its band jumps and drags (a slider row never closes the menu), landing
the value live and committing on the release. The designer dispatches the
menu itself, so four hooks carry it: `slider_press` ahead of the row action
diff --git a/src/app.rs b/src/app.rs
index 40734b6..51503a9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -451,6 +451,9 @@ pub enum ViewportMenuAction {
/// band drags it, and the menu stays open. Picking the row runs nothing
/// — the slider's changes arrive through `drain_viewport_menu_slider`.
OpacitySlider,
+ /// The wire pass's thickness in px, a slider row under Show Wireframe:
+ /// 1–8 like the palette's Wire Thickness row, half a pixel a notch.
+ WireThicknessSlider,
/// A "-" row: engraved, inert.
Separator,
}
@@ -4310,29 +4313,62 @@ impl State {
let (options, actions) = self.viewport_menu_rows();
let target = self.slots.viewport.id();
cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
- if let Some(i) = actions.iter().position(|a| *a == ViewportMenuAction::OpacitySlider) {
- cce_ui::widget::context_menu::set_row_slider(i, self.opacity_menu_slider());
+ for (i, a) in actions.iter().enumerate() {
+ if let Some(slider) = self.viewport_menu_slider(*a) {
+ cce_ui::widget::context_menu::set_row_slider(i, slider);
+ }
}
self.viewport_menu_active = true;
self.viewport_menu_actions = actions;
}
- /// The viewport menu's opacity slider: the live fill opacity in percent,
- /// stepped by 5 — the palette's Geometry Opacity row is the fine control.
- fn opacity_menu_slider(&self) -> cce_ui::widget::context_menu::MenuSlider {
- cce_ui::widget::context_menu::MenuSlider {
- value: (self.geo_opacity.clamp(0.0, 1.0) * 100.0).round(),
- min: 0.0,
- max: 100.0,
- step: 5.0,
- decimals: 0,
- suffix: "%",
+ /// The slider a viewport menu row carries, read from the live value —
+ /// `None` for an action row. The one table of the menu's sliders:
+ /// `open_viewport_context_menu` sets each from here and
+ /// `land_viewport_menu_slider` writes each back, so a slider row is
+ /// added in those two matches and the row list.
+ ///
+ /// Opacity reads in percent, stepped by 5; Wire Thickness in px over the
+ /// palette row's own 1–8, by half a pixel. The palette rows are the fine
+ /// controls.
+ pub(crate) fn viewport_menu_slider(&self, action: ViewportMenuAction) -> Option<cce_ui::widget::context_menu::MenuSlider> {
+ use cce_ui::widget::context_menu::MenuSlider;
+ Some(match action {
+ ViewportMenuAction::OpacitySlider => MenuSlider {
+ value: (self.geo_opacity.clamp(0.0, 1.0) * 100.0).round(),
+ min: 0.0,
+ max: 100.0,
+ step: 5.0,
+ decimals: 0,
+ suffix: "%",
+ },
+ ViewportMenuAction::WireThicknessSlider => MenuSlider {
+ value: self.wire_width.clamp(1.0, 8.0),
+ min: 1.0,
+ max: 8.0,
+ step: 0.5,
+ decimals: 1,
+ suffix: " px",
+ },
+ _ => return None,
+ })
+ }
+
+ /// Write a slider row's value onto the live field. Both are draw-time
+ /// values — a uniform, a line width and the fill's matching depth bias —
+ /// so a redraw is all either needs.
+ fn land_viewport_menu_slider(&mut self, action: ViewportMenuAction, v: f32) {
+ match action {
+ ViewportMenuAction::OpacitySlider => self.geo_opacity = (v / 100.0).clamp(0.0, 1.0),
+ ViewportMenuAction::WireThicknessSlider => self.wire_width = v.clamp(1.0, 8.0),
+ _ => return,
}
+ self.viewport_dirty = true;
}
- /// Land what the viewport menu's slider did. During a drag this is
- /// called on every motion, so it only sets the value and asks for a
- /// redraw — opacity is a draw-time uniform, and `apply_setting`'s full
+ /// Land what a viewport menu slider did. During a drag this is called
+ /// on every motion, so it only sets the value and asks for a redraw —
+ /// every menu slider is a draw-time value, and `apply_setting`'s full
/// regenerate-and-rebuild pass would re-evaluate the graph per pixel of
/// drag. `persist` saves state.kdl, which the wheel does per step and a
/// drag does once, on the release.
@@ -4343,24 +4379,22 @@ impl State {
}
return false;
};
- if self.viewport_menu_actions.get(idx) == Some(&ViewportMenuAction::OpacitySlider) {
- self.geo_opacity = (v / 100.0).clamp(0.0, 1.0);
- self.viewport_dirty = true;
- if persist {
- self.save_settings();
+ if let Some(action) = self.viewport_menu_actions.get(idx).copied() {
+ if self.viewport_menu_slider(action).is_some() {
+ self.land_viewport_menu_slider(action, v);
+ if persist {
+ self.save_settings();
+ }
}
}
true
}
/// The viewport menu's rows and what each does: framing, then the
- /// DISPLAY MODE — the wireframe switch, flat or smooth shading as a
- /// radio pair, and the polygon opacity as a radio group of presets —
- /// then the editor pin. Split from the open so a test can read it.
- ///
- /// Marks are the ●/○ the pin rows and the network menu use. An opacity
- /// set to anything off the presets (the palette's slider) marks none of
- /// them, which says so honestly rather than rounding to the nearest.
+ /// DISPLAY MODE — the wireframe switch and its thickness slider, flat or
+ /// smooth shading as a radio pair, the polygon opacity slider and Show
+ /// Occluded — then the editor pin. Split from the open so a test can
+ /// read it. Marks are the ●/○ the pin rows and the network menu use.
pub(crate) fn viewport_menu_rows(&self) -> (Vec<String>, Vec<ViewportMenuAction>) {
let mut options = vec!["Frame All".to_string(), "View 1:1".to_string()];
let mut actions = vec![ViewportMenuAction::FrameAll, ViewportMenuAction::OneToOne];
@@ -4371,6 +4405,8 @@ impl State {
let wire_label = crate::command::by_id("toggle_wireframe").map(|c| c.label).unwrap_or("Show Wireframe");
options.push(format!("{} {wire_label}", mark(self.wireframe)));
actions.push(ViewportMenuAction::Command("toggle_wireframe"));
+ options.push("Wire Thickness".to_string());
+ actions.push(ViewportMenuAction::WireThicknessSlider);
options.push(format!("{} Flat Shading", mark(!self.smooth_shading)));
actions.push(ViewportMenuAction::Shading(false));
options.push(format!("{} Smooth Shading", mark(self.smooth_shading)));
@@ -4433,7 +4469,7 @@ impl State {
}
}
// The slider row is worked, not picked.
- ViewportMenuAction::OpacitySlider => {}
+ ViewportMenuAction::OpacitySlider | ViewportMenuAction::WireThicknessSlider => {}
ViewportMenuAction::Separator => {}
}
}
diff --git a/src/main.rs b/src/main.rs
index a7f6c0a..a4ca94d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1644,6 +1644,46 @@ mod tests {
context_menu::hide();
}
+ /// Wire Thickness is a slider row right under Show Wireframe, over the
+ /// palette row's 1–8 px: the wheel steps half a pixel and saves, a press
+ /// on the band jumps, and the value is the live `wire_width` the wire
+ /// pass draws with.
+ #[test]
+ fn the_viewport_menu_sets_the_wire_thickness_by_wheel() {
+ use crate::app::ViewportMenuAction as A;
+ use crate::window::WindowEvent;
+ use cce_ui::widget::{context_menu, ElementState, MouseButton, MouseScrollDelta};
+ let mut state = State::new(false);
+ state.wire_width = 2.0;
+ state.cursor_x = 300.0;
+ state.cursor_y = 200.0;
+ state.open_viewport_context_menu();
+ let acts = state.viewport_menu_actions.clone();
+ let i = acts.iter().position(|a| *a == A::WireThicknessSlider).expect("a Wire Thickness row");
+ assert_eq!(acts[i - 1], A::Command("toggle_wireframe"), "it sits under Show Wireframe");
+ let sl = context_menu::slider(i).expect("the row is a slider");
+ assert_eq!((sl.value, sl.min, sl.max, sl.step), (2.0, 1.0, 8.0, 0.5));
+ // Every other slider the menu carries answers the same table.
+ for (k, a) in acts.iter().enumerate() {
+ assert_eq!(context_menu::slider(k).is_some(), state.viewport_menu_slider(*a).is_some(), "row {k} {a:?}");
+ }
+
+ state.cursor_x = context_menu::x() + 20.0;
+ state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+ state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 2.0) });
+ assert!((state.wire_width - 3.0).abs() < 1e-6, "{}", state.wire_width);
+ let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+ assert!(kdl.contains("wire_width (f64)3") || kdl.contains("wire_width 3"), "persisted: {kdl}");
+ assert!(state.viewport_menu_open());
+
+ let band = context_menu::CONTEXT_MENU.with(|m| m.borrow().slider_band(i));
+ state.cursor_x = band.x + 1.0;
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+ assert!((state.wire_width - 1.0).abs() < 1e-6, "the band's left end is 1 px: {}", state.wire_width);
+ context_menu::hide();
+ }
+
/// The dialog plate carries its own backdrop compression, above a
/// menu's: whatever the plates' own is (0 in a config that keeps the
/// panes clear), the modal pulls its backdrop toward the tint, and a