git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit132cdc2951da67c71df3b05b1c2393f58dab92e0
parentdcdb3a5aa5
authorLucas Galante <[email protected]>
date2026-06-26 20:20
feat: implement multi-control reordering, scrolled page hit-testing, and slider scroll locks

- Add drag-and-drop entry reordering support for MultiControl.
- Fix event propagation bounds calculations for scrolled Pages.
- Align column grid widget width with section padding.
- Add slider scroll sequence locks and clamp absorption to prevent page scroll leakage.

 src/context.rs                    |  28 ++-
 src/layout.rs                     | 109 +++++++++--
 src/widget/container/page.rs      |  75 +++++++-
 src/widget/input/multi_control.rs | 381 ++++++++++++++++++++++++++++++++++++--
 src/widget/input/slider.rs        |  63 ++++++-
 5 files changed, 615 insertions(+), 41 deletions(-)

diff --git a/src/context.rs b/src/context.rs
index 336bc6b..0a10f0c 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -64,6 +64,9 @@ pub struct UiContext {
     pub any_dirty: bool,
     pub tick_receivers: Vec<WidgetId>,
     pub spatial_grid: SpatialGrid,
+    pub last_scroll_time: Option<std::time::Instant>,
+    pub scroll_initiate_widget_id: Option<WidgetId>,
+    pub scroll_gesture_new: bool,
 }
 
 impl UiContext {
@@ -86,6 +89,9 @@ impl UiContext {
             any_dirty: false,
             tick_receivers: Vec::new(),
             spatial_grid: SpatialGrid::new(100.0),
+            last_scroll_time: None,
+            scroll_initiate_widget_id: None,
+            scroll_gesture_new: false,
         }
     }
 
@@ -98,6 +104,24 @@ impl UiContext {
     }
 
     pub fn propagate_event(&mut self, event: &Event, root: *mut (dyn Element + 'static)) -> bool {
+        if let Event::MouseWheel { .. } = event {
+            let now = std::time::Instant::now();
+            let is_new_gesture = match self.last_scroll_time {
+                None => true,
+                Some(last) => now.duration_since(last).as_millis() > 250,
+            };
+            if is_new_gesture {
+                self.scroll_initiate_widget_id = None;
+                self.scroll_gesture_new = true;
+            } else {
+                self.scroll_gesture_new = false;
+            }
+            self.last_scroll_time = Some(now);
+        }
+        self.propagate_event_impl(event, root)
+    }
+
+    fn propagate_event_impl(&mut self, event: &Event, root: *mut (dyn Element + 'static)) -> bool {
         if root.is_null() {
             return false;
         }
@@ -224,7 +248,7 @@ impl UiContext {
                             _ => {}
                         }
                         let adjusted_event = (*root).transform_event_for_child(child, local_adjusted, self);
-                        if self.propagate_event(&adjusted_event, child) {
+                        if self.propagate_event_impl(&adjusted_event, child) {
                             handled = true;
                         }
                     }
@@ -248,7 +272,7 @@ impl UiContext {
                             _ => {}
                         }
                         let adjusted_event = (*root).transform_event_for_child(child, local_adjusted, self);
-                        if self.propagate_event(&adjusted_event, child) {
+                        if self.propagate_event_impl(&adjusted_event, child) {
                             if check_drag_target {
                                 if let Some(b) = (*child).base() {
                                     self.drag_target = Some(b.id());
diff --git a/src/layout.rs b/src/layout.rs
index 3bf64d5..a41baf0 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -188,6 +188,15 @@ pub fn reload_config() {
                     }
                 }
             }
+            if let Some(rest) = trimmed.strip_prefix("grid_gap") {
+                let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+                let val_str = rest.trim_end_matches('"').trim();
+                if let Ok(val) = val_str.parse::<f32>() {
+                    if let Ok(mut lock) = GRID_GAP.write() {
+                        *lock = val;
+                    }
+                }
+            }
             if let Some(rest) = trimmed.strip_prefix("section_padding") {
                 let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
                 let val_str = rest.trim_end_matches('"').trim();
@@ -666,6 +675,36 @@ pub fn set_grid_min_col_width(width: f32) {
     }
 }
 
+static GRID_GAP: RwLock<f32> = RwLock::new(8.0);
+
+pub fn grid_gap() -> f32 {
+    use std::sync::Once;
+    static INIT: Once = Once::new();
+    INIT.call_once(|| {
+        if let Some(content) = read_config() {
+            for line in content.lines() {
+                let trimmed = line.trim();
+                if let Some(rest) = trimmed.strip_prefix("grid_gap") {
+                    let rest = rest.trim_start_matches(|c: char| c == ' ' || c == '=' || c == '"');
+                    let val_str = rest.trim_end_matches('"').trim();
+                    if let Ok(val) = val_str.parse::<f32>() {
+                        if let Ok(mut lock) = GRID_GAP.write() {
+                            *lock = val;
+                        }
+                    }
+                }
+            }
+        }
+    });
+    *GRID_GAP.read().unwrap()
+}
+
+pub fn set_grid_gap(gap: f32) {
+    if let Ok(mut lock) = GRID_GAP.write() {
+        *lock = gap;
+    }
+}
+
 pub fn section_padding() -> f32 {
     use std::sync::Once;
     static INIT: Once = Once::new();
@@ -2793,6 +2832,7 @@ pub struct AdaptiveGrid {
     grid: Option<Grid>,
     #[allow(dead_code)]
     min_col_width: f32,
+    #[allow(dead_code)]
     gap: f32,
     num_sections: Option<usize>,
 }
@@ -2811,13 +2851,14 @@ impl AdaptiveGrid {
 impl LayoutStrategy for AdaptiveGrid {
     fn init(&mut self, left: f32, top: f32, width: f32, _height: f32) {
         let min_col_width = crate::layout::grid_min_col_width();
-        let max_cols = ((width + self.gap) / (min_col_width + self.gap)).floor().max(1.0) as usize;
+        let gap = crate::layout::grid_gap();
+        let max_cols = ((width + gap) / (min_col_width + gap)).floor().max(1.0) as usize;
         let count = if let Some(n) = self.num_sections {
             n.min(max_cols).max(1)
         } else {
             max_cols
         };
-        self.grid = Some(Grid::new(left, top, width, min_col_width, self.gap, count));
+        self.grid = Some(Grid::new(left, top, width, min_col_width, gap, count));
     }
 
     fn allocate(&mut self, ww: f32, wh: f32) -> (f32, f32, f32, f32) {
@@ -2879,20 +2920,21 @@ impl LayoutStrategy for AdaptiveGrid {
     }
 
     fn get_gap(&self) -> f32 {
-        self.gap
+        crate::layout::grid_gap()
     }
 
     fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn crate::widget::Element + 'static)], _ctx: &mut crate::context::UiContext) -> f32 {
         let usable_w = w.max(1.0);
         let min_col_width = crate::layout::grid_min_col_width();
-        let cols = (((usable_w + self.gap) / (min_col_width + self.gap)).floor().max(1.0)) as usize;
+        let gap = crate::layout::grid_gap();
+        let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
         let count = if let Some(n) = self.num_sections {
             n.min(cols).max(1)
         } else {
             cols
         };
 
-        let total_gap = self.gap * (count - 1) as f32;
+        let total_gap = gap * (count - 1) as f32;
         let available_w = (w - total_gap).max(1.0);
         let col_w = available_w / count as f32;
         
@@ -2913,10 +2955,10 @@ impl LayoutStrategy for AdaptiveGrid {
                     }
                 }
                 
-                let cx = x + min_col as f32 * (col_w + self.gap);
+                let cx = x + min_col as f32 * (col_w + gap);
                 let cy = col_heights[min_col];
                 child.set_rect(cx, cy, col_w, use_h);
-                col_heights[min_col] += use_h + self.gap;
+                col_heights[min_col] += use_h + gap;
             }
         }
         
@@ -2927,7 +2969,8 @@ impl LayoutStrategy for AdaptiveGrid {
     fn measure(&self, constraints: crate::widget::LayoutConstraints, children: &[*mut (dyn crate::widget::Element + 'static)], ctx: &crate::context::UiContext) -> crate::widget::Size {
         let usable_w = constraints.max_width.max(1.0);
         let min_col_width = crate::layout::grid_min_col_width();
-        let cols = (((usable_w + self.gap) / (min_col_width + self.gap)).floor().max(1.0)) as usize;
+        let gap = crate::layout::grid_gap();
+        let cols = (((usable_w + gap) / (min_col_width + gap)).floor().max(1.0)) as usize;
         let count = if let Some(n) = self.num_sections {
             n.min(cols).max(1)
         } else {
@@ -2935,7 +2978,7 @@ impl LayoutStrategy for AdaptiveGrid {
         };
 
         let mut col_heights = vec![0.0f32; count];
-        let total_gap = self.gap * (count - 1) as f32;
+        let total_gap = gap * (count - 1) as f32;
         let available_w = (constraints.max_width - total_gap).max(1.0);
         let col_w = available_w / count as f32;
         
@@ -2952,7 +2995,7 @@ impl LayoutStrategy for AdaptiveGrid {
                         min_col = i;
                     }
                 }
-                col_heights[min_col] += size.height + self.gap;
+                col_heights[min_col] += size.height + gap;
             }
         }
         
@@ -3157,6 +3200,8 @@ pub struct SectionContext<'a, P> {
     pub is_child: bool,
     pub grid: Grid,
     pub last_col: usize,
+    pub content_start_y: f32,
+    pub row_gap: f32,
 }
 
 impl<'a, P: RenderTarget> SectionContext<'a, P> {
@@ -3218,9 +3263,20 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
             is_child,
             grid,
             last_col: usize::MAX,
+            content_start_y,
+            row_gap: Self::DEFAULT_ROW_GAP,
         }
     }
 
+    pub fn with_row_gap(mut self, gap: f32) -> Self {
+        self.row_gap = gap;
+        self
+    }
+
+    pub fn set_row_gap(&mut self, gap: f32) {
+        self.row_gap = gap;
+    }
+
     pub fn ax(&self, x_off: f32) -> f32 {
         let shift = if x_off >= 12.0 { self.padding() } else { 0.0 };
         self.left + x_off + shift
@@ -3249,7 +3305,16 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
     }
 
     pub fn text(&mut self, text: &str, x_off: f32, y_off: f32, font_size: f32, color: [f32; 4]) {
-        self.pc.text(text, self.ax(x_off), self.ay() + y_off, font_size, color);
+        let mut y = self.content_y + y_off;
+        if y > self.content_start_y {
+            y += self.row_gap;
+        }
+        self.pc.text(text, self.ax(x_off), y, font_size, color);
+        let new_bottom = y + font_size + 4.0;
+        self.content_y = new_bottom;
+        for h in &mut self.grid.col_heights {
+            *h = new_bottom;
+        }
     }
 
     pub fn widget<T: Element + 'static>(&mut self, w: &mut T, _x_off: f32, _ww: f32, mut wh: f32, ctx: &mut UiContext) {
@@ -3275,7 +3340,10 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
             let margin_x = pad + 12.0;
             let x = self.left + margin_x;
             let clamped_w = (self.cw - 2.0 * margin_x).max(0.0);
-            let max_h = self.grid.max_height().max(self.content_y);
+            let mut max_h = self.grid.max_height().max(self.content_y);
+            if max_h > self.content_start_y {
+                max_h += self.row_gap;
+            }
             let y = max_h;
 
             w.set_row_rect(self.left + pad, self.cw - 2.0 * pad);
@@ -3297,7 +3365,10 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
             let col = self.grid.next_column();
             self.last_col = col;
             let x = self.grid.col_lefts[col];
-            let y = self.grid.col_heights[col];
+            let mut y = self.grid.col_heights[col];
+            if y > self.content_start_y {
+                y += self.row_gap;
+            }
 
             let pad = self.padding();
             let aligned_x = x + pad;
@@ -3305,7 +3376,7 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
 
             w.set_row_rect(aligned_x, aligned_w);
             render_widget(self.pc, w, aligned_x, y, aligned_w, total_h, ctx);
-            self.grid.col_heights[col] += total_h;
+            self.grid.col_heights[col] = y + total_h;
             self.content_y = self.grid.max_height();
         }
     }
@@ -3391,11 +3462,17 @@ impl<'a, P: RenderTarget> SectionContext<'a, P> {
             let col = self.grid.next_column();
             self.last_col = col;
             let x = self.grid.col_lefts[col];
-            let y = self.grid.col_heights[col];
+            let mut y = self.grid.col_heights[col];
+            if y > self.content_start_y {
+                y += self.row_gap;
+            }
             (x, y, self.grid.col_width, true)
         } else {
             let left = self.ax(0.0) + pad;
-            let max_h = self.grid.max_height().max(self.content_y);
+            let mut max_h = self.grid.max_height().max(self.content_y);
+            if max_h > self.content_start_y {
+                max_h += self.row_gap;
+            }
             let top = max_h;
             let cw = self.cw - 2.0 * pad;
             (left, top, cw, false)
diff --git a/src/widget/container/page.rs b/src/widget/container/page.rs
index 84e3120..a430380 100644
--- a/src/widget/container/page.rs
+++ b/src/widget/container/page.rs
@@ -179,7 +179,8 @@ impl Element for Page {
         | Event::MouseWheel { x, y, .. } = event
         {
             let (rx, ry, rw, rh) = self.rect();
-            if *x < rx || *x > rx + rw || *y < ry || *y > ry + rh {
+            let screen_y = *y - self.scroll_y;
+            if *x < rx || *x > rx + rw || screen_y < ry || screen_y > ry + rh {
                 return true;
             }
         }
@@ -396,11 +397,13 @@ impl Element for Page {
         self.base.get_text_items()
     }
 
-    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
+    fn hit_test(&self, px: f32, py: f32, _ctx: &UiContext) -> bool {
         if !self.visible {
             return false;
         }
-        self.base.hit_test(px, py, ctx)
+        let (rx, ry, rw, rh) = self.rect();
+        let screen_y = py - self.scroll_y;
+        screen_y >= ry && screen_y <= ry + rh && px >= rx && px <= rx + rw
     }
 
     fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
@@ -427,3 +430,69 @@ impl Element for Page {
         false
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_page_bounds_scrolled() {
+        let ctx = UiContext::new();
+        let mut page = Page::new(0.0, 0.0, 800.0, 600.0);
+        
+        // 1. Unscrolled check
+        assert_eq!(page.scroll_y, 0.0);
+        
+        // Inside page boundaries (unscrolled)
+        let event_in = Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x: 100.0,
+            y: 200.0,
+            local_x: 100.0,
+            local_y: 200.0,
+        };
+        assert!(!page.check_out_of_bounds(&event_in, &ctx));
+        assert!(page.hit_test(100.0, 200.0, &ctx));
+
+        // Outside page boundaries (unscrolled)
+        let event_out = Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x: 100.0,
+            y: 700.0,
+            local_x: 100.0,
+            local_y: 700.0,
+        };
+        assert!(page.check_out_of_bounds(&event_out, &ctx));
+        assert!(!page.hit_test(100.0, 700.0, &ctx));
+
+        // 2. Scrolled check (scrolled down by 300px)
+        page.scroll_y = 300.0;
+
+        // Pointer virtual y = 500.0 (which translates to screen y = 200.0, inside page height of 600)
+        let event_scrolled_in = Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x: 100.0,
+            y: 500.0,
+            local_x: 100.0,
+            local_y: 500.0,
+        };
+        assert!(!page.check_out_of_bounds(&event_scrolled_in, &ctx));
+        assert!(page.hit_test(100.0, 500.0, &ctx));
+
+        // Pointer virtual y = 1000.0 (which translates to screen y = 700.0, outside page height of 600)
+        let event_scrolled_out = Event::MouseButton {
+            button: MouseButton::Left,
+            state: ElementState::Pressed,
+            x: 100.0,
+            y: 1000.0,
+            local_x: 100.0,
+            local_y: 1000.0,
+        };
+        assert!(page.check_out_of_bounds(&event_scrolled_out, &ctx));
+        assert!(!page.hit_test(100.0, 1000.0, &ctx));
+    }
+}
+
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
index 1b0d7c8..27787aa 100644
--- a/src/widget/input/multi_control.rs
+++ b/src/widget/input/multi_control.rs
@@ -134,6 +134,9 @@ pub struct MultiControlRow {
     pub type_dropdown: Dropdown,
     pub value_widget: InstancedWidget,
     pub remove_button: Button,
+    pub layout_y: f32,
+    pub layout_height: f32,
+    pub natural_y: f32,
 }
 
 impl MultiControlRow {
@@ -182,6 +185,9 @@ impl MultiControlRow {
             type_dropdown,
             value_widget,
             remove_button,
+            layout_y: 0.0,
+            layout_height: 0.0,
+            natural_y: 0.0,
         }
     }
 }
@@ -195,6 +201,10 @@ pub struct MultiControl {
     pub just_changed: bool,
     pub add_popover_open: bool,
     pub add_popover_hovered_idx: Option<usize>,
+    pub active_drag_index: Option<usize>,
+    pub drag_start_mouse_y: f32,
+    pub drag_y_offset: f32,
+    pub hovered_drag_index: Option<usize>,
 }
 
 impl MultiControl {
@@ -207,6 +217,10 @@ impl MultiControl {
             just_changed: false,
             add_popover_open: false,
             add_popover_hovered_idx: None,
+            active_drag_index: None,
+            drag_start_mouse_y: 0.0,
+            drag_y_offset: 0.0,
+            hovered_drag_index: None,
         };
         mc.load_from_config();
         mc
@@ -249,6 +263,58 @@ impl MultiControl {
         }).collect();
         save_config(name, &controls);
     }
+
+    pub fn get_drag_handle_quads(&self, idx: usize, theme: colors::Theme) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        let mut quads = Vec::new();
+        if idx >= self.rows.len() {
+            return quads;
+        }
+        let row = &self.rows[idx];
+        let (rx, _, _, _) = self.rect();
+        let pad_x = 8.0;
+        let drag_handle_w = 20.0;
+        
+        let key_h = crate::layout::textbox_height();
+        let type_h = crate::layout::dropdown_height();
+        let line1_h = key_h.max(type_h);
+        
+        let grip_center_y = row.layout_y + line1_h * 0.5;
+        let grip_center_x = rx + pad_x + drag_handle_w * 0.5;
+        
+        // Let's decide color based on hover / drag state
+        let color = if Some(idx) == self.active_drag_index {
+            theme.primary_accent
+        } else if Some(idx) == self.hovered_drag_index {
+            [
+                (theme.primary_accent[0] + 0.1).min(1.0),
+                (theme.primary_accent[1] + 0.1).min(1.0),
+                (theme.primary_accent[2] + 0.1).min(1.0),
+                0.8
+            ]
+        } else {
+            [
+                theme.surface_border[0],
+                theme.surface_border[1],
+                theme.surface_border[2],
+                0.5
+            ]
+        };
+
+        // Let's draw 3 horizontal bars for the grip
+        let bar_w = 10.0;
+        let bar_h = 2.0;
+        let bar_gap = 2.0;
+        let total_grip_h = 3.0 * bar_h + 2.0 * bar_gap;
+        let start_y = grip_center_y - total_grip_h * 0.5;
+        let start_x = grip_center_x - bar_w * 0.5;
+
+        for i in 0..3 {
+            let y = start_y + i as f32 * (bar_h + bar_gap);
+            quads.push((start_x, y, bar_w, bar_h, color));
+        }
+
+        quads
+    }
 }
 
 fn link_child(parent_ptr: *mut (dyn Element + 'static), parent_id: WidgetId, child: &mut dyn Element, ctx: &mut UiContext) {
@@ -308,7 +374,11 @@ impl Element for MultiControl {
         let type_h = crate::layout::dropdown_height();
         let line1_h = key_h.max(type_h);
 
-        let usable_w = (size.width - 2.0 * pad_x).max(1.0);
+        let drag_handle_w = 20.0;
+        let drag_gap_x = 6.0;
+        let left_shift = drag_handle_w + drag_gap_x;
+
+        let usable_w = (size.width - 2.0 * pad_x - left_shift).max(1.0);
         let gap_x = 6.0;
         let top_usable_w = usable_w - 2.0 * gap_x;
 
@@ -320,21 +390,44 @@ impl Element for MultiControl {
         let self_ptr = self.as_ptr();
         let self_id = self.base.id();
 
-        for row in &mut self.rows {
+        // Calculate rows area height for clamping
+        let mut total_rows_h = 0.0;
+        for (i, row) in self.rows.iter().enumerate() {
+            let line2_h = row.value_widget.preferred_height().unwrap_or(44.0);
+            let row_h = line1_h + gap_between_lines + line2_h;
+            total_rows_h += row_h;
+            if i < self.rows.len() - 1 {
+                total_rows_h += gap_between_rows;
+            }
+        }
+        let top_limit = origin.y + pad_y;
+        let bottom_limit = top_limit + total_rows_h;
+
+        for (i, row) in self.rows.iter_mut().enumerate() {
+            let line2_h = row.value_widget.preferred_height().unwrap_or(44.0);
+            let row_h = line1_h + gap_between_lines + line2_h;
+            row.layout_height = row_h;
+            row.natural_y = curr_y;
+
+            let mut row_y = curr_y;
+            if Some(i) == self.active_drag_index {
+                row_y = (curr_y + self.drag_y_offset).clamp(top_limit, (bottom_limit - row_h).max(top_limit));
+            }
+            row.layout_y = row_y;
+
             // Line 1: Label, Type, Remove
-            let key_x = origin.x + pad_x;
-            row.key_input.layout(Point { x: key_x, y: curr_y }, LayoutConstraints::new(key_w, key_w, line1_h, line1_h), ctx);
+            let key_x = origin.x + pad_x + left_shift;
+            row.key_input.layout(Point { x: key_x, y: row_y }, LayoutConstraints::new(key_w, key_w, line1_h, line1_h), ctx);
 
             let type_x = key_x + key_w + gap_x;
-            row.type_dropdown.layout(Point { x: type_x, y: curr_y }, LayoutConstraints::new(type_w, type_w, line1_h, line1_h), ctx);
+            row.type_dropdown.layout(Point { x: type_x, y: row_y }, LayoutConstraints::new(type_w, type_w, line1_h, line1_h), ctx);
 
             let remove_x = type_x + type_w + gap_x;
-            row.remove_button.layout(Point { x: remove_x, y: curr_y }, LayoutConstraints::new(remove_w, remove_w, line1_h, line1_h), ctx);
+            row.remove_button.layout(Point { x: remove_x, y: row_y }, LayoutConstraints::new(remove_w, remove_w, line1_h, line1_h), ctx);
 
             // Line 2: Value Control Widget
-            let line2_h = row.value_widget.preferred_height().unwrap_or(44.0);
-            let value_y = curr_y + line1_h + gap_between_lines;
-            let value_x = origin.x + pad_x;
+            let value_y = row_y + line1_h + gap_between_lines;
+            let value_x = origin.x + pad_x + left_shift;
             row.value_widget.layout(Point { x: value_x, y: value_y }, LayoutConstraints::new(usable_w, usable_w, line2_h, line2_h), ctx);
 
             link_child(self_ptr, self_id, &mut row.key_input, ctx);
@@ -347,13 +440,13 @@ impl Element for MultiControl {
             }
             link_child(self_ptr, self_id, &mut row.remove_button, ctx);
 
-            curr_y += line1_h + gap_between_lines + line2_h + gap_between_rows;
+            curr_y += row_h + gap_between_rows;
         }
 
         // Lay out add button
         let add_btn_w = 120.0f32.min(usable_w);
         let add_btn_h = 36.0;
-        let add_x = origin.x + pad_x;
+        let add_x = origin.x + pad_x + left_shift;
         self.add_button.layout(Point { x: add_x, y: curr_y }, LayoutConstraints::new(add_btn_w, add_btn_w, add_btn_h, add_btn_h), ctx);
         link_child(self_ptr, self_id, &mut self.add_button, ctx);
     }
@@ -366,13 +459,50 @@ impl Element for MultiControl {
             }
         }
 
-        for row in &self.rows {
+        let theme = colors::active_theme();
+
+        // 1. Draw all non-dragged rows first
+        for (idx, row) in self.rows.iter().enumerate() {
+            if Some(idx) == self.active_drag_index {
+                continue;
+            }
+            let handle_quads = self.get_drag_handle_quads(idx, theme);
+            quads.extend(handle_quads);
+
             quads.extend(row.key_input.all_quads(ctx));
             quads.extend(row.type_dropdown.all_quads(ctx));
             quads.extend(row.value_widget.all_quads(ctx));
             quads.extend(row.remove_button.all_quads(ctx));
         }
 
+        // 2. Draw active dragged row on top!
+        if let Some(idx) = self.active_drag_index {
+            if idx < self.rows.len() {
+                let row = &self.rows[idx];
+                
+                let (rx, _, rw, _) = self.rect();
+                let row_y = row.layout_y;
+                let row_h = row.layout_height;
+                let pad_x = 8.0;
+                let bg_color = [theme.surface_bg[0], theme.surface_bg[1], theme.surface_bg[2], 0.95];
+                let border_color = theme.primary_accent;
+                
+                // Add a backing plate/shadow for the dragged row
+                quads.push((rx + pad_x, row_y - 2.0, rw - 2.0 * pad_x, row_h + 4.0, [0.0, 0.0, 0.0, 0.25])); // shadow
+                quads.push((rx + pad_x, row_y - 1.0, rw - 2.0 * pad_x, row_h + 2.0, bg_color)); // background
+                quads.push((rx + pad_x, row_y - 1.0, rw - 2.0 * pad_x, 1.0, border_color)); // top border
+                quads.push((rx + pad_x, row_y + row_h + 1.0, rw - 2.0 * pad_x, 1.0, border_color)); // bottom border
+
+                let handle_quads = self.get_drag_handle_quads(idx, theme);
+                quads.extend(handle_quads);
+
+                quads.extend(row.key_input.all_quads(ctx));
+                quads.extend(row.type_dropdown.all_quads(ctx));
+                quads.extend(row.value_widget.all_quads(ctx));
+                quads.extend(row.remove_button.all_quads(ctx));
+            }
+        }
+
         quads.extend(self.add_button.all_quads(ctx));
         quads
     }
@@ -383,12 +513,28 @@ impl Element for MultiControl {
 
     fn text_labels_with_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
         let mut labels = Vec::new();
-        for row in &self.rows {
+        // 1. Draw non-dragged first
+        for (idx, row) in self.rows.iter().enumerate() {
+            if Some(idx) == self.active_drag_index {
+                continue;
+            }
             labels.extend(row.key_input.text_labels_with_bounds(ctx));
             labels.extend(row.type_dropdown.text_labels_with_bounds(ctx));
             labels.extend(row.value_widget.text_labels_with_bounds(ctx));
             labels.extend(row.remove_button.text_labels_with_bounds(ctx));
         }
+
+        // 2. Draw active dragged row last (on top)
+        if let Some(idx) = self.active_drag_index {
+            if idx < self.rows.len() {
+                let row = &self.rows[idx];
+                labels.extend(row.key_input.text_labels_with_bounds(ctx));
+                labels.extend(row.type_dropdown.text_labels_with_bounds(ctx));
+                labels.extend(row.value_widget.text_labels_with_bounds(ctx));
+                labels.extend(row.remove_button.text_labels_with_bounds(ctx));
+            }
+        }
+
         labels.extend(self.add_button.text_labels_with_bounds(ctx));
         labels
     }
@@ -400,13 +546,28 @@ impl Element for MultiControl {
             labels.push((l, font.clone(), None));
         }
 
-        for row in &self.rows {
+        // 1. Draw non-dragged first
+        for (idx, row) in self.rows.iter().enumerate() {
+            if Some(idx) == self.active_drag_index {
+                continue;
+            }
             labels.extend(row.key_input.text_labels_with_font_and_bounds(ctx));
             labels.extend(row.type_dropdown.text_labels_with_font_and_bounds(ctx));
             labels.extend(row.value_widget.text_labels_with_font_and_bounds(ctx));
             labels.extend(row.remove_button.text_labels_with_font_and_bounds(ctx));
         }
 
+        // 2. Draw active dragged row last (on top)
+        if let Some(idx) = self.active_drag_index {
+            if idx < self.rows.len() {
+                let row = &self.rows[idx];
+                labels.extend(row.key_input.text_labels_with_font_and_bounds(ctx));
+                labels.extend(row.type_dropdown.text_labels_with_font_and_bounds(ctx));
+                labels.extend(row.value_widget.text_labels_with_font_and_bounds(ctx));
+                labels.extend(row.remove_button.text_labels_with_font_and_bounds(ctx));
+            }
+        }
+
         labels.extend(self.add_button.text_labels_with_font_and_bounds(ctx));
         labels
     }
@@ -467,6 +628,128 @@ impl Element for MultiControl {
 
         let mut handled = false;
 
+        // 1.5 Handle drag events and drag handle interactions
+        let mut drag_handled = false;
+        match event {
+            Event::PointerMove { x, y, .. } => {
+                let (rx, _, _, _) = self.rect();
+                let pad_x = 8.0;
+                let drag_handle_w = 20.0;
+                let old_hovered = self.hovered_drag_index;
+                self.hovered_drag_index = None;
+                
+                if *x >= rx + pad_x && *x <= rx + pad_x + drag_handle_w {
+                    for (i, row) in self.rows.iter().enumerate() {
+                        if *y >= row.layout_y && *y <= row.layout_y + row.layout_height {
+                            self.hovered_drag_index = Some(i);
+                            break;
+                        }
+                    }
+                }
+                
+                if old_hovered != self.hovered_drag_index {
+                    drag_handled = true;
+                }
+            }
+            Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
+                let (rx, _, _, _) = self.rect();
+                let pad_x = 8.0;
+                let drag_handle_w = 20.0;
+                if *x >= rx + pad_x && *x <= rx + pad_x + drag_handle_w {
+                    for (i, row) in self.rows.iter().enumerate() {
+                        if *y >= row.layout_y && *y <= row.layout_y + row.layout_height {
+                            self.active_drag_index = Some(i);
+                            self.drag_start_mouse_y = *y;
+                            self.drag_y_offset = 0.0;
+                            drag_handled = true;
+                            break;
+                        }
+                    }
+                }
+            }
+            Event::DragStart { .. } => {
+                if self.active_drag_index.is_some() {
+                    drag_handled = true;
+                }
+            }
+            Event::DragUpdate { y, .. } => {
+                if let Some(mut i) = self.active_drag_index {
+                    self.drag_y_offset = *y - self.drag_start_mouse_y;
+                    
+                    let gap = 12.0; // gap_between_rows
+                    let mut swapped = true;
+                    while swapped {
+                        swapped = false;
+                        let row_h = self.rows[i].layout_height;
+                        let mid_y = self.rows[i].natural_y + self.drag_y_offset + row_h * 0.5;
+                        
+                        if i > 0 {
+                            let mid_above = self.rows[i-1].natural_y + self.rows[i-1].layout_height * 0.5;
+                            if mid_y < mid_above {
+                                self.drag_start_mouse_y -= self.rows[i-1].layout_height + gap;
+                                self.rows.swap(i, i-1);
+                                
+                                // Recalculate natural_y for all rows to prevent infinite swap loops
+                                let top_limit = self.rect().1 + 8.0;
+                                let mut curr_y = top_limit;
+                                for r in &mut self.rows {
+                                    r.natural_y = curr_y;
+                                    curr_y += r.layout_height + gap;
+                                }
+
+                                i -= 1;
+                                self.active_drag_index = Some(i);
+                                self.drag_y_offset = *y - self.drag_start_mouse_y;
+                                swapped = true;
+                                continue;
+                            }
+                        }
+                        if i < self.rows.len() - 1 {
+                            let mid_below = self.rows[i+1].natural_y + self.rows[i+1].layout_height * 0.5;
+                            if mid_y > mid_below {
+                                self.drag_start_mouse_y += self.rows[i+1].layout_height + gap;
+                                self.rows.swap(i, i+1);
+                                
+                                // Recalculate natural_y for all rows to prevent infinite swap loops
+                                let top_limit = self.rect().1 + 8.0;
+                                let mut curr_y = top_limit;
+                                for r in &mut self.rows {
+                                    r.natural_y = curr_y;
+                                    curr_y += r.layout_height + gap;
+                                }
+
+                                i += 1;
+                                self.active_drag_index = Some(i);
+                                self.drag_y_offset = *y - self.drag_start_mouse_y;
+                                swapped = true;
+                                continue;
+                            }
+                        }
+                    }
+                    drag_handled = true;
+                }
+            }
+            Event::DragEnd | Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
+                if self.active_drag_index.is_some() {
+                    self.active_drag_index = None;
+                    self.drag_y_offset = 0.0;
+                    self.save_to_config();
+                    self.just_changed = true;
+                    drag_handled = true;
+                }
+            }
+            _ => {}
+        }
+        
+        if drag_handled {
+            return true;
+        }
+
+        // If we are actively dragging, swallow all other events
+        if self.active_drag_index.is_some() {
+            return true;
+        }
+
         // 2. Intercept row dropdown open popovers first
         for row in &mut self.rows {
             if row.type_dropdown.open {
@@ -902,4 +1185,74 @@ mod tests {
             std::env::set_var("HOME", h);
         }
     }
+
+    #[test]
+    fn test_multicontrol_drag_reorder() {
+        let _guard = ENV_MUTEX.lock().unwrap();
+        let mut dummy = crate::context::UiContext::new();
+        let temp_dir = std::env::temp_dir().join("cce_test_home_drag");
+        let _ = std::fs::remove_dir_all(&temp_dir);
+        let _ = std::fs::create_dir_all(&temp_dir);
+        let old_home = std::env::var("HOME").ok();
+        std::env::set_var("HOME", temp_dir.to_str().unwrap());
+
+        let mut mc = MultiControl::new("test_mc_drag".to_string());
+        // Add two rows
+        mc.rows.push(MultiControlRow::new("param1".to_string(), "Spinbox".to_string(), "10".to_string()));
+        mc.rows.push(MultiControlRow::new("param2".to_string(), "TextBox".to_string(), "val".to_string()));
+        mc.save_to_config();
+
+        // Perform initial layout
+        mc.set_rect(10.0, 10.0, 300.0, 250.0);
+        mc.layout(Point { x: 10.0, y: 10.0 }, LayoutConstraints::new(300.0, 300.0, 250.0, 250.0), &mut dummy);
+
+        assert_eq!(mc.rows[0].key_input.get_value_string().unwrap(), "param1");
+        assert_eq!(mc.rows[1].key_input.get_value_string().unwrap(), "param2");
+
+        // Click on the drag handle of the first row (param1)
+        // Drag handle x is in: rx + pad_x (10 + 8 = 18) to rx + pad_x + drag_handle_w (18 + 20 = 38).
+        // Let's click at x = 25, y = mc.rows[0].layout_y + 5.
+        let click_x = 25.0;
+        let click_y = mc.rows[0].layout_y + 5.0;
+
+        let handled_press = mc.mouse_input(MouseButton::Left, ElementState::Pressed, click_x, click_y, &mut dummy);
+        assert!(handled_press);
+        assert_eq!(mc.active_drag_index, Some(0));
+
+        // Drag down to swap with the second row (param2)
+        // Row 1 starts around natural_y + height + gap. Let's move mouse to mid-point of row 1.
+        let target_y = mc.rows[1].natural_y + mc.rows[1].layout_height * 0.5 + 5.0;
+        
+        let drag_update_evt = Event::DragUpdate {
+            dx: 0.0,
+            dy: target_y - click_y,
+            x: click_x,
+            y: target_y,
+            local_x: click_x - 10.0,
+            local_y: target_y - 10.0,
+        };
+        let handled_drag = mc.handle_event(&drag_update_evt, &mut dummy);
+        assert!(handled_drag);
+
+        // Verify that the swap occurred!
+        assert_eq!(mc.active_drag_index, Some(1));
+        assert_eq!(mc.rows[0].key_input.get_value_string().unwrap(), "param2");
+        assert_eq!(mc.rows[1].key_input.get_value_string().unwrap(), "param1");
+
+        // End drag
+        let handled_end = mc.handle_event(&Event::DragEnd, &mut dummy);
+        assert!(handled_end);
+        assert_eq!(mc.active_drag_index, None);
+
+        // Load config from disk and verify the new order is persisted!
+        let loaded = load_config("test_mc_drag");
+        assert_eq!(loaded.len(), 2);
+        assert_eq!(loaded[0].key, "param2");
+        assert_eq!(loaded[1].key, "param1");
+
+        let _ = std::fs::remove_dir_all(&temp_dir);
+        if let Some(h) = old_home {
+            std::env::set_var("HOME", h);
+        }
+    }
 }
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 1dd504f..db40b8a 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -203,10 +203,16 @@ impl Element for Slider {
 
     fn drag_end(&mut self) { self.dragging = false; }
 
-    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, _ctx: &mut UiContext) -> bool {
+    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
         if !self.scroll_enabled {
             return false;
         }
+        let my_id = self.base.id();
+        if !ctx.scroll_gesture_new {
+            if ctx.scroll_initiate_widget_id != Some(my_id) {
+                return false;
+            }
+        }
         let top = self.base.label_offset();
         let visual_h = self.base.h - top;
         let (sx, sy, sw, _) = self.rect();
@@ -219,6 +225,9 @@ impl Element for Slider {
             (sx, sw)
         };
         if px >= track_x && px <= track_x + track_w && py >= sy + top && py <= sy + top + visual_h {
+            if ctx.scroll_gesture_new {
+                ctx.scroll_initiate_widget_id = Some(my_id);
+            }
             let scroll_amount = match delta {
                 MouseScrollDelta::LineDelta(_x, y) => *y,
                 MouseScrollDelta::PixelDelta(pos) => (pos.y as f32) / 120.0,
@@ -231,8 +240,8 @@ impl Element for Slider {
                     let scaled_val = self.min + self.value * (self.max - self.min);
                     self.edit_buffer = format!("{:.2}", scaled_val);
                 }
-                return true;
             }
+            return true;
         }
         false
     }
@@ -567,11 +576,20 @@ impl Element for RangeSlider {
         self.active_thumb = None;
     }
 
-    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, _ctx: &mut UiContext) -> bool {
+    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        let my_id = self.base.id();
+        if !ctx.scroll_gesture_new {
+            if ctx.scroll_initiate_widget_id != Some(my_id) {
+                return false;
+            }
+        }
         let top = self.base.label_offset();
         let visual_h = self.base.h - top;
         let (sx, sy, sw, _) = self.rect();
         if px >= sx && px <= sx + sw && py >= sy + top && py <= sy + top + visual_h {
+            if ctx.scroll_gesture_new {
+                ctx.scroll_initiate_widget_id = Some(my_id);
+            }
             let thumb_size = visual_h * 0.9;
             let range = sw - thumb_size;
             let thumb_low_x = sx + self.value_low * range;
@@ -603,15 +621,14 @@ impl Element for RangeSlider {
                 let new_val = (self.value_low - scroll_amount * step).clamp(0.0, self.value_high);
                 if (new_val - self.value_low).abs() > 0.0001 {
                     self.value_low = new_val;
-                    return true;
                 }
             } else {
                 let new_val = (self.value_high - scroll_amount * step).clamp(self.value_low, 1.0);
                 if (new_val - self.value_high).abs() > 0.0001 {
                     self.value_high = new_val;
-                    return true;
                 }
             }
+            return true;
         }
         false
     }
@@ -742,6 +759,7 @@ mod tests {
         let mut rs = RangeSlider::new().with_values(0.3, 0.7);
         rs.set_rect(10.0, 10.0, 200.0, 20.0);
         let mut dummy_ctx = crate::context::UiContext::new();
+        dummy_ctx.scroll_gesture_new = true;
 
         // Thumb size = h * 0.9 = 18.0
         // Range = w - thumb_size = 182.0
@@ -785,5 +803,38 @@ mod tests {
         assert_eq!(rs.values().0, 0.5); // low unchanged
         assert!((rs.values().1 - 0.52).abs() < 0.001);
     }
-}
 
+    #[test]
+    fn test_slider_scroll_initiation() {
+        let mut slider1 = Slider::new();
+        slider1.set_rect(10.0, 10.0, 200.0, 20.0);
+        let id1 = slider1.base.id();
+
+        let mut slider2 = Slider::new();
+        slider2.set_rect(10.0, 40.0, 200.0, 20.0);
+        let _id2 = slider2.base.id();
+
+        let mut ctx = crate::context::UiContext::new();
+
+        // 1. Initial scroll event on slider1
+        // This is a new gesture (last_scroll_time is None)
+        ctx.scroll_gesture_new = true;
+        ctx.scroll_initiate_widget_id = None;
+        let delta = MouseScrollDelta::LineDelta(0.0, 1.0);
+        
+        let handled = slider1.mouse_wheel(&delta, 50.0, 15.0, &mut ctx);
+        assert!(handled);
+        assert_eq!(ctx.scroll_initiate_widget_id, Some(id1));
+
+        // 2. Subsequent scroll event in the same gesture (elapsed < 250ms), but the mouse moved over slider2
+        ctx.scroll_gesture_new = false;
+        // The mouse wheel event is now routed to slider2
+        let handled2 = slider2.mouse_wheel(&delta, 50.0, 45.0, &mut ctx);
+        // slider2 must reject the event because it wasn't the initiator
+        assert!(!handled2);
+        
+        // 3. Subsequent scroll event routed to slider1 (the initiator)
+        let handled1 = slider1.mouse_wheel(&delta, 50.0, 15.0, &mut ctx);
+        assert!(handled1);
+    }
+}