graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the viewport menu's opacity is a wheel slider
The four opacity preset rows become one Opacity slider row, on cce-ui's new
context-menu slider rows: the wheel over it steps 5% and saves, a press on
its band jumps and drags with the value landing live, and the release
commits. The menu stays open throughout, and a wheel over the open menu no
longer reaches the scene beneath. Values land on geo_opacity directly,
since opacity is a draw-time uniform. cce-ui pinned to 5b88302.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 21 +++++++++----
Cargo.toml | 2 +-
src/app.rs | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++-----------
src/main.rs | 75 +++++++++++++++++++++++++++++++++++++---------
4 files changed, 158 insertions(+), 38 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8513593..120328e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1379,12 +1379,21 @@ 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 presets (`VIEWPORT_OPACITIES`, landing
-through `apply_setting("Geometry Opacity", …)` so the palette's row, the
-persist and the menu are one path — a menu cannot hold a slider, and an
-opacity set off the presets marks none of them). `viewport_menu_rows` and
-`run_viewport_menu_action` are split from the open and the click so a test
-reads and runs the rows.
+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
+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
+in `handle_viewport_menu_click`, `slider_dragging` in CursorMoved (after
+`cursor_moved`, which moves the held value), `slider_release` at the top of
+MouseInput, and `mouse_wheel` at the top of MouseWheel — where a wheel
+anywhere over the open menu is swallowed rather than orbiting the scene.
+`drain_viewport_menu_slider` lands a change by setting `geo_opacity`
+directly: opacity is a draw-time uniform, and `apply_setting`'s regenerate
+pass would re-evaluate the graph per pixel of drag. `viewport_menu_rows`
+and `run_viewport_menu_action` are split from the open and the click so a
+test reads and runs the rows.
**Smooth shading is baked, not shaded.** The raster pass flat-shades every
fill in `scene3d.wgsl` from screen-space derivative normals, and cce-ui's
diff --git a/Cargo.toml b/Cargo.toml
index 6b3e93a..f906eca 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
-cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "f50f6ef20a0b50dc31f1d9f55ec3ae2545c49207" }
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "5b883021d81abad8b30f96c0a22a6decb015223b" }
smithay-client-toolkit = "0.19.2"
calloop = "0.13.0"
calloop-wayland-source = "0.3.0"
diff --git a/src/app.rs b/src/app.rs
index 3ca0609..40734b6 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -446,16 +446,15 @@ pub enum ViewportMenuAction {
/// pick differs, so picking the mode already on is a no-op rather than
/// a flip.
Shading(bool),
- /// Set the polygon (geometry fill) opacity to one of `VIEWPORT_OPACITIES`
- /// — a menu cannot hold a slider, and the palette's Geometry Opacity row
- /// is the fine control.
- Opacity(f32),
+ /// The polygon (geometry fill) opacity, as a SLIDER row (cce-ui's
+ /// `context_menu::MenuSlider`): the wheel steps it by 5%, a press on its
+ /// band drags it, and the menu stays open. Picking the row runs nothing
+ /// — the slider's changes arrive through `drain_viewport_menu_slider`.
+ OpacitySlider,
/// A "-" row: engraved, inert.
Separator,
}
-/// The polygon opacities the viewport menu offers, highest first.
-pub const VIEWPORT_OPACITIES: [f32; 4] = [1.0, 0.75, 0.5, 0.25];
/// The network editor's right-click context menu (on empty space — a press on
/// a node still opens that node's menu). Every row but the separator names a
@@ -4307,14 +4306,53 @@ impl State {
}
/// Open the viewport right-click context menu at the cursor.
- fn open_viewport_context_menu(&mut self) {
+ pub(crate) fn open_viewport_context_menu(&mut self) {
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());
+ }
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: "%",
+ }
+ }
+
+ /// 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
+ /// 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.
+ pub(crate) fn drain_viewport_menu_slider(&mut self, persist: bool) -> bool {
+ let Some((idx, v)) = cce_ui::widget::context_menu::take_slider_change() else {
+ if persist {
+ self.save_settings();
+ }
+ 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();
+ }
+ }
+ 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 —
@@ -4340,11 +4378,8 @@ impl State {
options.push("-".to_string());
actions.push(ViewportMenuAction::Separator);
- for o in VIEWPORT_OPACITIES {
- let on = (self.geo_opacity - o).abs() < 0.005;
- options.push(format!("{} Opacity {}%", mark(on), (o * 100.0).round() as i32));
- actions.push(ViewportMenuAction::Opacity(o));
- }
+ options.push("Opacity".to_string());
+ actions.push(ViewportMenuAction::OpacitySlider);
let occluded_label = crate::command::by_id("toggle_show_occluded").map(|c| c.label).unwrap_or("Show Occluded");
options.push(format!("{} {occluded_label}", mark(self.show_occluded)));
actions.push(ViewportMenuAction::Command("toggle_show_occluded"));
@@ -4397,11 +4432,8 @@ impl State {
self.run_command("toggle_smooth_shading");
}
}
- ViewportMenuAction::Opacity(o) => {
- // The palette's Geometry Opacity row, so the write, the
- // persist and the dialog's re-read are the one path.
- self.apply_setting("Geometry Opacity", &format!("{o:.2}"));
- }
+ // The slider row is worked, not picked.
+ ViewportMenuAction::OpacitySlider => {}
ViewportMenuAction::Separator => {}
}
}
@@ -4422,6 +4454,12 @@ impl State {
if !self.viewport_menu_open() {
return false;
}
+ // A press on a slider row is the slider's: it jumps (on the band)
+ // and keeps the menu open, where every other row fires and closes.
+ if cce_ui::widget::context_menu::slider_press(self.cursor_x, self.cursor_y) {
+ self.drain_viewport_menu_slider(false);
+ return true;
+ }
if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
let picked = idx.and_then(|i| self.viewport_menu_actions.get(i).copied());
@@ -7362,6 +7400,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
if self.dialog_visible() {
return self.dialog_mouse_wheel(*delta);
}
+ // Over an open viewport menu the wheel is the menu's: a
+ // slider row steps, and nothing scrolls or orbits beneath.
+ if self.viewport_menu_open()
+ && cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y)
+ {
+ if cce_ui::widget::context_menu::mouse_wheel(delta, self.cursor_x, self.cursor_y) {
+ self.drain_viewport_menu_slider(true);
+ }
+ return true;
+ }
let in_network_pane = self.in_network_pane();
// eprintln!("DEBUG MOUSEWHEEL: delta={:?}, phase={:?}, cursor=({}, {}), in_network_pane={}", delta, phase, self.cursor_x, self.cursor_y, in_network_pane);
let node_area_y = self.positions[CONTENT_IDX].1;
@@ -7543,6 +7591,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
{
changed = true;
}
+ // A held menu slider follows the pointer (cursor_moved moved
+ // it); land the value, and let nothing else read this motion
+ // as a drag of its own.
+ if self.viewport_menu_open() && cce_ui::widget::context_menu::slider_dragging() {
+ self.drain_viewport_menu_slider(false);
+ return true;
+ }
// An in-flight curve-tool grab eats motion ahead of every
// other drag: the grabbed control point tracks the cursor.
@@ -7760,6 +7815,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// `drag_widget` only ever names a widget drag driven through the slot's Input
// drag hooks. Exactly one of the two is armed per press.
WindowEvent::MouseInput { state: btn_state, button, .. } => {
+ // The release that ends a menu slider drag — wherever the
+ // pointer is — commits it and is nobody else's.
+ if *btn_state == ElementState::Released
+ && *button == MouseButton::Left
+ && cce_ui::widget::context_menu::slider_release()
+ {
+ self.drain_viewport_menu_slider(true);
+ return true;
+ }
if *btn_state == ElementState::Pressed {
self.pan_velocity_x = 0.0;
self.pan_velocity_y = 0.0;
diff --git a/src/main.rs b/src/main.rs
index 4a47874..a7f6c0a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1540,16 +1540,16 @@ mod tests {
assert!(!state.see_through_active(), "nothing to see through at 100%");
assert!(state.last_status_text.contains("below 100%"), "{}", state.last_status_text);
- state.run_viewport_menu_action(A::Opacity(0.5));
+ state.apply_setting("Geometry Opacity", "0.50");
assert!(state.see_through_active());
state.run_command("toggle_show_occluded");
assert!(!state.see_through_active());
}
/// The viewport's right-click menu sets the display mode: the wireframe
- /// switch, flat or smooth shading as a radio pair, and the polygon
- /// opacity as presets — each mark reading the live state, each row
- /// landing on it.
+ /// switch and flat or smooth shading as a radio pair — each mark reading
+ /// the live state, each row landing on it — and the polygon opacity as a
+ /// slider row (below).
#[test]
fn the_viewport_menu_sets_the_display_mode() {
use crate::app::ViewportMenuAction as A;
@@ -1565,8 +1565,7 @@ mod tests {
assert!(row(&state, A::Command("toggle_wireframe")).starts_with('○'));
assert!(row(&state, A::Shading(false)).starts_with('●'));
assert!(row(&state, A::Shading(true)).starts_with('○'));
- assert!(row(&state, A::Opacity(1.0)).starts_with('●'));
- assert_eq!(row(&state, A::Opacity(0.5)), "○ Opacity 50%");
+ assert_eq!(row(&state, A::OpacitySlider), "Opacity");
state.run_viewport_menu_action(A::Command("toggle_wireframe"));
assert!(state.wireframe);
@@ -1587,14 +1586,62 @@ mod tests {
assert!(!state.smooth_shading);
assert!(state.scene_smooth_verts.is_empty(), "flat keeps no lit copy");
- state.run_viewport_menu_action(A::Opacity(0.5));
- assert!((state.geo_opacity - 0.5).abs() < 1e-6);
- assert!(row(&state, A::Opacity(0.5)).starts_with('●'));
- assert!(row(&state, A::Opacity(1.0)).starts_with('○'));
- // An opacity off the presets marks none of them.
- state.apply_setting("Geometry Opacity", "0.33");
- let (options, _) = state.viewport_menu_rows();
- assert!(options.iter().filter(|o| o.contains("Opacity")).all(|o| o.starts_with('○')));
+ }
+
+ /// The viewport menu's Opacity row is a cce-ui menu SLIDER: opened, it
+ /// reads the live opacity in percent; the wheel over it steps 5% and
+ /// saves, the menu staying open; a press on its band jumps and drags,
+ /// landing the value live, and the release commits it. The wheel over
+ /// an action row is still swallowed by the open menu rather than
+ /// orbiting the scene beneath it.
+ #[test]
+ fn the_viewport_menus_opacity_row_is_a_wheel_slider() {
+ 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.geo_opacity = 0.5;
+ state.cursor_x = 300.0;
+ state.cursor_y = 200.0;
+ state.open_viewport_context_menu();
+ assert!(state.viewport_menu_open());
+ let i = state.viewport_menu_actions.iter().position(|a| *a == A::OpacitySlider).expect("an Opacity row");
+ let s = context_menu::slider(i).expect("the row is a slider");
+ assert_eq!((s.value, s.min, s.max, s.step), (50.0, 0.0, 100.0, 5.0));
+
+ // Wheel over the row: one notch up is 5% more, saved, menu still up.
+ state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+ state.cursor_x = context_menu::x() + 20.0;
+ let wheel = |state: &mut State, notches: f32| {
+ state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, notches) })
+ };
+ assert!(wheel(&mut state, 1.0));
+ assert!((state.geo_opacity - 0.55).abs() < 1e-6, "{}", state.geo_opacity);
+ assert!(state.viewport_menu_open(), "the menu stays open");
+ let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+ assert!(kdl.contains("0.55"), "the wheel step persisted: {kdl}");
+ wheel(&mut state, -3.0);
+ assert!((state.geo_opacity - 0.40).abs() < 1e-6);
+
+ // Press on the band's right end: 100%, live; drag back; release.
+ let band = context_menu::CONTEXT_MENU.with(|m| m.borrow().slider_band(i));
+ state.cursor_x = band.x + band.width - 1.0;
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+ assert!((state.geo_opacity - 1.0).abs() < 1e-6, "{}", state.geo_opacity);
+ assert!(state.viewport_menu_open());
+ state.handle_event(&WindowEvent::CursorMoved { position: crate::window::LocalPosition { x: (band.x + band.width * 0.25) as f64, y: 900.0 } });
+ assert!((state.geo_opacity - 0.25).abs() < 1e-6, "the drag follows off the plate: {}", state.geo_opacity);
+ state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+ assert!(!context_menu::slider_dragging());
+ assert!(state.viewport_menu_open(), "the release does not close it");
+
+ // The wheel over an action row changes nothing and orbits nothing.
+ let before = (state.geo_opacity, state.viewport().rotation_y);
+ state.cursor_x = context_menu::x() + 20.0;
+ state.cursor_y = context_menu::row_y(0) + context_menu::ROW_H * 0.5;
+ assert!(wheel(&mut state, 1.0));
+ assert_eq!((state.geo_opacity, state.viewport().rotation_y), before);
+ context_menu::hide();
}
/// The dialog plate carries its own backdrop compression, above a