GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);
+ }
+}