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

commit99321f4d16c9859922896bb94d06dd356fe5c7a9
parent3bc64aa676
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 15:14
fix: dialog sliders land as draw-time values, not through the full apply

A dialog slider drag ran `apply_setting` on every pointer motion: a whole
graph evaluation, two more keyed on the geometry version it bumped (the
group markers, the params pane's pickers), a path-tracer restart and a
synchronous state.kdl write — per event, for six values the graph never
reads. The viewport menu's sliders over the same fields already landed
lightly (`land_viewport_menu_slider`), so the dialog dragged behind the
pointer while the menu did not.

`State::land_draw_time_setting(key, v)` is now the one landing behind
both, by `DesignSettings` field key, so a clamp or which marker mesh a
size re-sizes cannot differ between them. `land_dialog_slider` routes a
slider row through it and re-reads the controls in place; state.kdl is
written once on the drag's release, or at once for a wheel notch or an
arrow key. A row the landing does not know (a spin row) still takes the
full apply, whose regenerate it needs.

`a_dialog_slider_drag_lands_without_re_evaluating_the_graph` drives a
press, a motion and a release through the event loop and asserts the
geometry version never moves, the file is untouched mid-drag and written
on the release, and a spin row still regenerates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md     | 16 +++++++++++-
 src/app.rs    | 46 ++++++++++++++++++++++++++-------
 src/dialog.rs | 26 ++++++++++++++++++-
 src/main.rs   | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 159 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2a5213f..51dc5c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1922,7 +1922,21 @@ selection stays where it was:
   (`Dialog::draggable` / `drag_*`), so the value follows the pointer off the
   plate; the wheel over the control turns it (2% of the range a notch) where
   over the rest of the list it scrolls; Left/Right nudge it by the row's
-  `step` while it is selected; Enter on it runs nothing. The **zoom row**
+  `step` while it is selected; Enter on it runs nothing. **A slider row
+  lands through `State::land_draw_time_setting`** — the one landing the
+  viewport menu's sliders use, by `DesignSettings` field key: the field,
+  the one marker mesh it feeds, a redraw, then `refresh_dialog_controls`
+  — NOT `apply_setting`, and state.kdl is written once on the drag's
+  release (`dialog_mouse_input`), or at once for a wheel notch or arrow
+  key. Until 2026-09-28 every motion of a drag ran the full apply: a graph
+  evaluation, two more keyed on the version it bumped (group markers, the
+  params pane's pickers), a path-tracer restart and a synchronous file
+  write, per pointer event, for six values the graph never reads — which
+  is what made the dialog's sliders drag behind the pointer while the
+  menu's did not. A row the landing does not know (a spin row) still
+  takes the full apply, whose regenerate it needs;
+  `a_dialog_slider_drag_lands_without_re_evaluating_the_graph` pins all
+  of it. The **zoom row**
   (`ZOOM_ROW_ID`, only while the network pane is focused, since zoom is that
   pane's) is one of these over `State::zoom_percent` (100 = Reset Zoom,
   range the pitch limits), landing through `set_zoom_percent`, which zooms
diff --git a/src/app.rs b/src/app.rs
index 1076b81..9c2b59c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4775,29 +4775,57 @@ impl State {
     /// the scene positions its rebuild kept. None of it re-evaluates the
     /// graph.
     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),
-            ViewportMenuAction::WireOpacitySlider => self.wire_opacity = (v / 100.0).clamp(0.0, 1.0),
-            ViewportMenuAction::PointSizeSlider => {
+        // The menu's opacity rows are in percent; the field is a fraction.
+        let (key, v) = match action {
+            ViewportMenuAction::OpacitySlider => ("geo_opacity", v / 100.0),
+            ViewportMenuAction::WireThicknessSlider => ("wire_width", v),
+            ViewportMenuAction::WireOpacitySlider => ("wire_opacity", v / 100.0),
+            ViewportMenuAction::PointSizeSlider => ("point_size", v),
+            ViewportMenuAction::PointMarkerSizeSlider => ("point_marker_size", v),
+            ViewportMenuAction::GroupMarkerScaleSlider => ("group_marker_scale", v),
+            ViewportMenuAction::PullArrowScaleSlider => ("pull_arrow_scale", v),
+            _ => return,
+        };
+        self.land_draw_time_setting(key, v);
+    }
+
+    /// Land a DRAW-TIME display setting by its `DesignSettings` field key —
+    /// the one landing behind the viewport menu's sliders AND the dialog's
+    /// (`land_dialog_slider`), so the two cannot disagree about a clamp or
+    /// about which marker mesh a size feeds. Returns false for a key that is
+    /// not one of these, which the dialog takes as "run the full apply".
+    ///
+    /// Until 2026-09-28 the dialog's sliders went through `apply_setting` on
+    /// every motion of a drag: a whole graph evaluation, a second one for
+    /// the group markers and a third for the params pane's pickers (both
+    /// keyed on the geometry version the first had just bumped), a restart
+    /// of the path tracer's refine, and a synchronous state.kdl write — per
+    /// pointer event, for six values none of which the graph reads.
+    pub(crate) fn land_draw_time_setting(&mut self, key: &str, v: f32) -> bool {
+        match key {
+            "geo_opacity" => self.geo_opacity = v.clamp(0.0, 1.0),
+            "wire_width" => self.wire_width = v.clamp(1.0, 8.0),
+            "wire_opacity" => self.wire_opacity = v.clamp(0.0, 1.0),
+            "point_size" => {
                 self.point_size = v.clamp(0.0, 0.1);
                 self.rebuild_group_marker_verts();
             }
-            ViewportMenuAction::PointMarkerSizeSlider => {
+            "point_marker_size" => {
                 self.point_marker_size = v.clamp(0.005, 0.1);
                 self.rebuild_overlay_marker_verts();
             }
-            ViewportMenuAction::GroupMarkerScaleSlider => {
+            "group_marker_scale" => {
                 self.group_marker_scale = v.clamp(0.5, 4.0);
                 self.rebuild_group_marker_verts();
             }
-            ViewportMenuAction::PullArrowScaleSlider => {
+            "pull_arrow_scale" => {
                 self.pull_arrow_scale = v.clamp(0.25, 10.0);
                 self.rebuild_pull_arrow_verts();
             }
-            _ => return,
+            _ => return false,
         }
         self.viewport_dirty = true;
+        true
     }
 
     /// Land what a viewport menu slider did. During a drag this is called
diff --git a/src/dialog.rs b/src/dialog.rs
index a973908..9ae7c0d 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -2081,10 +2081,32 @@ impl State {
 
     /// A slider row's value arriving from a drag, a wheel or an arrow: the
     /// zoom row zooms, a setting row writes its setting.
-    fn land_dialog_slider(&mut self, id: &str, v: f32) {
+    ///
+    /// A slider row is a draw-time value, so it lands through the viewport
+    /// menu's own `land_draw_time_setting` — the field, the one mesh it
+    /// feeds, a redraw — and the row re-reads in place. During a drag this
+    /// runs on every motion, so state.kdl is written on the RELEASE
+    /// (`dialog_mouse_input`) rather than here; a wheel notch or an arrow
+    /// key is a single landing and saves at once, as the menu's wheel does.
+    /// A row the landing does not know falls through to `apply_setting`,
+    /// whose full regenerate pass is what a spin row like Grid Thickness
+    /// needs.
+    pub(crate) fn land_dialog_slider(&mut self, id: &str, v: f32) {
         if id == ZOOM_ROW_ID {
             self.set_zoom_percent(v);
         } else if let Some(s) = setting_of_row(id) {
+            if let (Owner::Field(key), Ctl::Slider { dec, .. }) = (s.owner, s.ctl) {
+                // Rounded as the row shows it, so the field holds the value
+                // the readout names rather than the pointer's raw fraction.
+                let shown: f32 = format!("{:.*}", dec, v).parse().unwrap_or(v);
+                if self.land_draw_time_setting(key, shown) {
+                    self.refresh_dialog_controls();
+                    if !self.slots.dialog.slider_dragging() {
+                        self.save_settings();
+                    }
+                    return;
+                }
+            }
             let value = match s.ctl {
                 Ctl::Slider { dec, .. } => format!("{:.*}", dec, v),
                 _ => (v.round() as i64).to_string(),
@@ -2247,6 +2269,8 @@ impl State {
             self.drag_widget = None;
             self.drag_press_cursor = None;
             self.drain_dialog_clicks();
+            // The drag's motions landed without saving; the release does.
+            self.save_settings();
             return Some(true);
         }
 
diff --git a/src/main.rs b/src/main.rs
index a5b9ed7..78814cc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10892,6 +10892,88 @@ mod tests {
         assert_eq!(d.take_slider_change(), None);
     }
 
+    /// A dialog slider is worked by the pointer, and every motion of a drag
+    /// lands its value. Until 2026-09-28 each landing ran `apply_setting`'s
+    /// whole regenerate pass — a graph evaluation (and two more keyed on the
+    /// version it bumped), a path-tracer restart and a synchronous state.kdl
+    /// write, per pointer event, for a value the graph never reads. A slider
+    /// row lands as the viewport menu's sliders do: the field, a redraw, the
+    /// row re-read in place, and the file written once on the release. A
+    /// single landing (a wheel notch, an arrow key) saves at once, and a
+    /// spin row still takes the full pass, whose regenerate it needs.
+    #[test]
+    fn a_dialog_slider_drag_lands_without_re_evaluating_the_graph() {
+        use crate::dialog::{setting_row_id, Control, ROW_H, SLIDER_W, TOGGLE_W};
+        use crate::slots::DIALOG_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton};
+        let mut state = State::new(false);
+        state.geo_opacity = 1.0;
+        state.save_settings();
+        let path = crate::app::DesignSettings::file_path();
+        let saved = |path: &std::path::Path| {
+            let kdl = fs::read_to_string(path).expect("a settings file");
+            crate::app::DesignSettings::from_kdl_str(&kdl).render.geo_opacity
+        };
+        assert!((saved(&path) - 1.0).abs() < 1e-3);
+
+        state.open_dialog();
+        state.slots.dialog.query = "geometry opacity".into();
+        state.refresh_dialog_rows();
+        let row = setting_row_id("Geometry Opacity");
+        assert_eq!(state.slots.dialog.rows.first().map(|r| r.id.as_str()), Some(row.as_str()), "the setting row ranks first");
+        let has_toggle = state.slots.dialog.rows.iter().any(|r| matches!(r.control, Some(Control::Toggle(_))));
+        let (x, y, w, _) = state.positions[DIALOG_IDX];
+        let row_y = y + 12.0 + 30.0 + 8.0 + ROW_H * 0.5;
+        let band_right = x + w - 12.0 - 8.0 - if has_toggle { TOGGLE_W + 12.0 } else { 0.0 };
+        let band_x = band_right - SLIDER_W;
+        let at = |state: &mut State, t: f32| {
+            let px = band_x + SLIDER_W * t;
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: px as f64, y: row_y as f64 } });
+        };
+        let version = state.rt_geometry_version;
+
+        // The press takes the band and jumps the value; nothing is evaluated
+        // and nothing is written.
+        at(&mut state, 0.5);
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        assert!(state.slots.dialog.slider_dragging(), "the press took the band");
+        assert!((state.geo_opacity - 0.5).abs() < 0.02, "{}", state.geo_opacity);
+        assert_eq!(state.rt_geometry_version, version, "a draw-time value re-evaluated the graph");
+        assert!((saved(&path) - 1.0).abs() < 1e-3, "written mid-drag");
+
+        // A motion lands the value live and re-reads the row in place.
+        at(&mut state, 0.25);
+        assert!((state.geo_opacity - 0.25).abs() < 0.02, "{}", state.geo_opacity);
+        let shown = state.slots.dialog.rows[0].slider_value().expect("a slider row");
+        assert!((shown - state.geo_opacity).abs() < 1e-3, "the row shows {shown}, the field holds {}", state.geo_opacity);
+        assert_eq!(state.rt_geometry_version, version, "a drag motion re-evaluated the graph");
+        assert!((saved(&path) - 1.0).abs() < 1e-3, "written mid-drag");
+
+        // The release writes the file once.
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        assert!(!state.slots.dialog.slider_dragging());
+        assert!((saved(&path) - state.geo_opacity).abs() < 1e-3, "the release did not save");
+        assert_eq!(state.rt_geometry_version, version);
+
+        // A single landing saves at once, and clamps as the menu clamps.
+        state.land_dialog_slider(&row, 0.7);
+        assert!((state.geo_opacity - 0.7).abs() < 1e-6);
+        assert!((saved(&path) - 0.7).abs() < 1e-3, "a wheel or arrow landing did not save");
+        state.land_dialog_slider(&row, 7.0);
+        assert!((state.geo_opacity - 1.0).abs() < 1e-6, "clamped");
+        assert_eq!(state.rt_geometry_version, version);
+
+        // Point Size re-sizes the group markers from their kept members,
+        // and a spin row still takes the full regenerate pass.
+        state.land_dialog_slider(&setting_row_id("Point Size"), 0.05);
+        assert!((state.point_size - 0.05).abs() < 1e-6);
+        assert!((state.last_group_marker_size - state.group_marker_size()).abs() < 1e-6);
+        assert_eq!(state.rt_geometry_version, version);
+        state.land_dialog_slider(&setting_row_id("Grid Thickness"), 40.0);
+        assert!(state.rt_geometry_version > version, "a spin row regenerates");
+    }
+
     /// A right press is the dialog's while it is open: inside the plate it is
     /// swallowed — no context menu opens for the pane beneath, which used to
     /// come up over the modal with its labels clipped — and outside it