git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commite0518b73e878cfe49418f7b64621db095b5a5705
parent734d6ba1c8
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 10:16
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