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

commitf8ec309b8af57c88fad5a4c303b01848b249573e
parent0e9449e6f6
authorLucas Galante <[email protected]>
date2026-06-25 12:31
Create ScrollBar widget and integrate automatically into Page objects

 src/context.rs                     |  28 ++++-
 src/widget/container/container.rs  |  19 +++
 src/widget/container/mod.rs        |   2 +
 src/widget/container/page.rs       | 174 +++++++++++++++++++++++---
 src/widget/container/scroll_bar.rs | 250 +++++++++++++++++++++++++++++++++++++
 src/widget/mod.rs                  |   2 +-
 6 files changed, 459 insertions(+), 16 deletions(-)

diff --git a/src/context.rs b/src/context.rs
index d8e6337..14314d4 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -34,6 +34,32 @@ impl UiContext {
             return false;
         }
         unsafe {
+            let mut out_of_bounds = false;
+            if (*root).is_page() {
+                let mut scrollbar_dragging = false;
+                if let Some(page) = (*root).as_any().downcast_ref::<crate::widget::Page>() {
+                    if page.scroll_bar.dragging {
+                        scrollbar_dragging = true;
+                    }
+                }
+                
+                if !scrollbar_dragging {
+                    if let Event::PointerMove { x, y }
+                    | Event::MouseButton { x, y, .. }
+                    | Event::MouseWheel { delta: _, x, y } = event
+                    {
+                        let (rx, ry, rw, rh) = (*root).rect();
+                        if *x < rx || *x > rx + rw || *y < ry || *y > ry + rh {
+                            out_of_bounds = true;
+                        }
+                    }
+                }
+            }
+
+            if out_of_bounds {
+                return false;
+            }
+
             // For KeyInput, send directly to focused widget if it exists
             if let Event::KeyInput(_) = event {
                 if let Some(focused) = self.focused_widget {
@@ -48,7 +74,7 @@ impl UiContext {
             let children = (*root).children(self);
             
             match event {
-                Event::PointerMove { .. } => {
+                Event::PointerMove { .. } | Event::Tick(_) => {
                     for child in children.into_iter().rev() {
                         if self.propagate_event(event, child) {
                             handled = true;
diff --git a/src/widget/container/container.rs b/src/widget/container/container.rs
index 9533484..132cc08 100644
--- a/src/widget/container/container.rs
+++ b/src/widget/container/container.rs
@@ -24,6 +24,25 @@ impl Element for Container {
         self as *mut Self as *mut (dyn Element + 'static)
     }
 
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+        let dx = x - self.base.x;
+        let dy = y - self.base.y;
+
+        self.base.x = x;
+        self.base.y = y;
+        self.base.w = w;
+        self.base.h = h;
+
+        if dx.abs() > 0.001 || dy.abs() > 0.001 {
+            for &child in &self.children {
+                unsafe {
+                    let (cx, cy, cw, ch) = (*child).rect();
+                    (*child).set_rect(cx + dx, cy + dy, cw, ch);
+                }
+            }
+        }
+    }
+
     fn measure(&self, constraints: LayoutConstraints, ctx: &UiContext) -> Size {
         let mut max_w = 0.0f32;
         let mut max_h = 0.0f32;
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index abbff2b..53fbb89 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -14,6 +14,7 @@ pub mod layer;
 pub mod page;
 pub mod backplate;
 pub mod paginator;
+pub mod scroll_bar;
 
 pub use container::Container;
 pub use header::Header;
@@ -31,3 +32,4 @@ pub use layer::Layer;
 pub use page::Page;
 pub use backplate::Backplate;
 pub use paginator::Paginator;
+pub use scroll_bar::ScrollBar;
diff --git a/src/widget/container/page.rs b/src/widget/container/page.rs
index 292a850..e5f16bf 100644
--- a/src/widget/container/page.rs
+++ b/src/widget/container/page.rs
@@ -4,7 +4,7 @@ use crate::widget::display::TextLabel;
 use super::layer::Layer;
 
 pub trait PageLayout {
-    fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn Element + 'static)]);
+    fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn Element + 'static)]) -> f32;
 }
 
 #[derive(Debug, Clone, Copy)]
@@ -25,7 +25,7 @@ impl Default for VerticalLayout {
 }
 
 impl PageLayout for VerticalLayout {
-    fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn Element + 'static)]) {
+    fn layout(&self, x: f32, y: f32, w: f32, _h: f32, children: &[*mut (dyn Element + 'static)]) -> f32 {
         let padding_x = self.padding_x;
         let padding_y = self.padding_y;
         let left_x = x + padding_x;
@@ -40,6 +40,7 @@ impl PageLayout for VerticalLayout {
             child.set_rect(left_x, current_y, available_w, use_h);
             current_y += use_h + spacing;
         }
+        current_y - y
     }
 }
 
@@ -61,10 +62,10 @@ impl Default for ColumnsLayout {
 }
 
 impl PageLayout for ColumnsLayout {
-    fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn Element + 'static)]) {
+    fn layout(&self, x: f32, y: f32, w: f32, h: f32, children: &[*mut (dyn Element + 'static)]) -> f32 {
         let count = children.len();
         if count == 0 {
-            return;
+            return 0.0;
         }
         let total_spacing = self.spacing * (count - 1) as f32;
         let total_padding = self.padding_x * 2.0;
@@ -79,6 +80,7 @@ impl PageLayout for ColumnsLayout {
             child.set_rect(current_x, start_y, col_w, use_h);
             current_x += col_w + self.spacing;
         }
+        use_h + 2.0 * self.padding_y
     }
 }
 
@@ -87,6 +89,9 @@ pub struct Page {
     pub visible: bool,
     pub owned_children: Vec<Box<dyn Element>>,
     pub layout: Box<dyn PageLayout>,
+    pub scroll_y: f32,
+    pub content_h: f32,
+    pub scroll_bar: ScrollBar,
 }
 
 impl std::fmt::Debug for Page {
@@ -94,6 +99,8 @@ impl std::fmt::Debug for Page {
         f.debug_struct("Page")
             .field("base", &self.base)
             .field("visible", &self.visible)
+            .field("scroll_y", &self.scroll_y)
+            .field("content_h", &self.content_h)
             .finish()
     }
 }
@@ -105,6 +112,9 @@ impl Page {
             visible: true,
             owned_children: Vec::new(),
             layout: Box::new(VerticalLayout::default()),
+            scroll_y: 0.0,
+            content_h: 0.0,
+            scroll_bar: ScrollBar::new(),
         }
     }
 
@@ -125,6 +135,28 @@ impl Page {
     }
 }
 
+fn clip_quad(
+    quad: (f32, f32, f32, f32, [f32; 4]),
+    bounds: (f32, f32, f32, f32),
+) -> Option<(f32, f32, f32, f32, [f32; 4])> {
+    let (qx, qy, qw, qh, qc) = quad;
+    let (bx, by, bw, bh) = bounds;
+
+    let x1 = qx.max(bx);
+    let y1 = qy.max(by);
+    let x2 = (qx + qw).min(bx + bw);
+    let y2 = (qy + qh).min(by + bh);
+
+    let w = x2 - x1;
+    let h = y2 - y1;
+
+    if w > 0.0 && h > 0.0 {
+        Some((x1, y1, w, h, qc))
+    } else {
+        None
+    }
+}
+
 impl Element for Page {
     fn base(&self) -> Option<&Widget> { Some(&self.base.base) }
     fn is_page(&self) -> bool { true }
@@ -148,7 +180,30 @@ impl Element for Page {
         if !self.visible {
             return;
         }
-        self.layout.layout(x, y, w, h, &self.base.children);
+        let content_h = self.layout.layout(x, y, w, h, &self.base.children);
+        self.content_h = content_h;
+
+        let max_scroll = (content_h - h).max(0.0);
+        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
+
+        // Position the scroll bar
+        let sb_w = 6.0;
+        let sb_padding = 2.0;
+        let sb_x = x + w - sb_w - sb_padding;
+        self.scroll_bar.set_rect(sb_x, y + 4.0, sb_w, h - 8.0);
+        self.scroll_bar.update(self.scroll_y, content_h, h);
+        
+        let self_ptr = self as *mut Page as *mut (dyn Element + 'static);
+        self.scroll_bar.parent = Some(self_ptr);
+
+        if self.scroll_y > 0.0 {
+            // Apply scroll offset to children
+            for &child_ptr in &self.base.children {
+                let child = unsafe { &mut *child_ptr };
+                let (cx, cy, cw, ch) = child.rect();
+                child.set_rect(cx, cy - self.scroll_y, cw, ch);
+            }
+        }
     }
 
     fn color(&self) -> [f32; 4] {
@@ -157,7 +212,6 @@ impl Element for Page {
         c
     }
 
-
     fn visible(&self) -> bool {
         self.visible
     }
@@ -184,7 +238,13 @@ impl Element for Page {
     }
 
     fn children(&self, ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
-        self.base.children(ctx)
+        let mut list = self.base.children(ctx);
+        let (_, _, _, h) = self.rect();
+        if self.content_h > h {
+            let sb_ptr = &self.scroll_bar as *const ScrollBar as *mut ScrollBar as *mut (dyn Element + 'static);
+            list.push(sb_ptr);
+        }
+        list
     }
 
     fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, ctx: &mut UiContext) {
@@ -201,34 +261,96 @@ impl Element for Page {
         }
         let mut quads = Vec::new();
         let c = self.color();
+        let bounds = self.rect();
         if c[3] > 0.0 {
-            let (x, y, w, h) = self.rect();
-            quads.push((x, y, w, h, c));
+            quads.push((bounds.0, bounds.1, bounds.2, bounds.3, c));
+        }
+        
+        for q in self.base.all_quads(ctx) {
+            if let Some(clipped) = clip_quad(q, bounds) {
+                quads.push(clipped);
+            }
+        }
+
+        if self.content_h > bounds.3 {
+            quads.extend(self.scroll_bar.all_quads(ctx));
         }
-        quads.extend(self.base.all_quads(ctx));
+
         quads
     }
 
-
     fn text_labels(&self) -> Vec<TextLabel> {
         if !self.visible {
             return Vec::new();
         }
-        self.base.text_labels()
+        let (_, py, _, ph) = self.rect();
+        let mut result = Vec::new();
+        for label in self.base.text_labels() {
+            if label.y >= py && label.y + label.font_size <= py + ph {
+                result.push(label);
+            }
+        }
+        result
     }
 
     fn text_labels_with_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
         if !self.visible {
             return Vec::new();
         }
-        self.base.text_labels_with_bounds(ctx)
+        let (px, py, pw, ph) = self.rect();
+        let page_bounds = [px, py, px + pw, py + ph];
+
+        let mut result = Vec::new();
+        for (label, bounds) in self.base.text_labels_with_bounds(ctx) {
+            let intersected_bounds = if let Some([l, t, r, b]) = bounds {
+                let il = l.max(page_bounds[0]);
+                let it = t.max(page_bounds[1]);
+                let ir = r.min(page_bounds[2]);
+                let ib = b.min(page_bounds[3]);
+                if il < ir && it < ib {
+                    Some([il, it, ir, ib])
+                } else {
+                    continue;
+                }
+            } else {
+                if label.y + label.font_size < py || label.y > py + ph {
+                    continue;
+                }
+                Some(page_bounds)
+            };
+            result.push((label, intersected_bounds));
+        }
+        result
     }
 
     fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
         if !self.visible {
             return Vec::new();
         }
-        self.base.text_labels_with_font_and_bounds(ctx)
+        let (px, py, pw, ph) = self.rect();
+        let page_bounds = [px, py, px + pw, py + ph];
+
+        let mut result = Vec::new();
+        for (label, font, bounds) in self.base.text_labels_with_font_and_bounds(ctx) {
+            let intersected_bounds = if let Some([l, t, r, b]) = bounds {
+                let il = l.max(page_bounds[0]);
+                let it = t.max(page_bounds[1]);
+                let ir = r.min(page_bounds[2]);
+                let ib = b.min(page_bounds[3]);
+                if il < ir && it < ib {
+                    Some([il, it, ir, ib])
+                } else {
+                    continue;
+                }
+            } else {
+                if label.y + label.font_size < py || label.y > py + ph {
+                    continue;
+                }
+                Some(page_bounds)
+            };
+            result.push((label, font, intersected_bounds));
+        }
+        result
     }
 
     fn get_text_items(&self) -> Vec<(&glyphon::Buffer, f32, f32, glyphon::Color)> {
@@ -244,4 +366,28 @@ impl Element for Page {
         }
         self.base.hit_test(px, py, ctx)
     }
+
+    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        if !self.visible {
+            return false;
+        }
+        if self.hit_test(px, py, ctx) {
+            let scroll_speed = 24.0;
+            let dy = match delta {
+                MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
+                MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+            };
+            let old_scroll = self.scroll_y;
+            let (x, y, w, h) = self.rect();
+            let max_scroll = (self.content_h - h).max(0.0);
+            self.scroll_y = (self.scroll_y + dy).clamp(0.0, max_scroll);
+            self.scroll_bar.scroll_y = self.scroll_y;
+            if (self.scroll_y - old_scroll).abs() > 0.01 {
+                self.set_rect(x, y, w, h);
+                self.mark_dirty(ctx);
+                return true;
+            }
+        }
+        false
+    }
 }
diff --git a/src/widget/container/scroll_bar.rs b/src/widget/container/scroll_bar.rs
new file mode 100644
index 0000000..8cf9262
--- /dev/null
+++ b/src/widget/container/scroll_bar.rs
@@ -0,0 +1,250 @@
+use crate::widget::*;
+use crate::context::UiContext;
+
+pub struct ScrollBar {
+    x: f32,
+    y: f32,
+    w: f32,
+    h: f32,
+    pub scroll_y: f32,
+    pub content_h: f32,
+    pub viewport_h: f32,
+    pub hovered: bool,
+    pub dragging: bool,
+    pub parent: Option<*mut (dyn Element + 'static)>,
+    pub children: Vec<*mut (dyn Element + 'static)>,
+}
+
+impl std::fmt::Debug for ScrollBar {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ScrollBar")
+            .field("x", &self.x)
+            .field("y", &self.y)
+            .field("w", &self.w)
+            .field("h", &self.h)
+            .field("scroll_y", &self.scroll_y)
+            .field("content_h", &self.content_h)
+            .field("viewport_h", &self.viewport_h)
+            .field("hovered", &self.hovered)
+            .field("dragging", &self.dragging)
+            .finish()
+    }
+}
+
+impl Clone for ScrollBar {
+    fn clone(&self) -> Self {
+        Self {
+            x: self.x,
+            y: self.y,
+            w: self.w,
+            h: self.h,
+            scroll_y: self.scroll_y,
+            content_h: self.content_h,
+            viewport_h: self.viewport_h,
+            hovered: self.hovered,
+            dragging: self.dragging,
+            parent: self.parent,
+            children: self.children.clone(),
+        }
+    }
+}
+
+impl ScrollBar {
+    pub fn new() -> Self {
+        Self {
+            x: 0.0,
+            y: 0.0,
+            w: 6.0,
+            h: 0.0,
+            scroll_y: 0.0,
+            content_h: 0.0,
+            viewport_h: 0.0,
+            hovered: false,
+            dragging: false,
+            parent: None,
+            children: Vec::new(),
+        }
+    }
+
+    pub fn update(&mut self, scroll_y: f32, content_h: f32, viewport_h: f32) {
+        self.scroll_y = scroll_y;
+        self.content_h = content_h;
+        self.viewport_h = viewport_h;
+    }
+
+    pub fn get_thumb_rect(&self) -> Option<(f32, f32, f32, f32)> {
+        if self.content_h <= self.viewport_h || self.viewport_h <= 0.0 || self.h <= 0.0 {
+            return None;
+        }
+        let sb_x = self.x;
+        let sb_w = self.w;
+        let sb_track_h = self.h;
+        let sb_track_y = self.y;
+
+        let visible_ratio = self.viewport_h / self.content_h;
+        let thumb_h = if sb_track_h <= 20.0 {
+            sb_track_h
+        } else {
+            (sb_track_h * visible_ratio).clamp(20.0, sb_track_h)
+        };
+        let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+        let scroll_ratio = if max_scroll > 0.0 { self.scroll_y / max_scroll } else { 0.0 };
+        let thumb_y = sb_track_y + scroll_ratio * (sb_track_h - thumb_h);
+
+        Some((sb_x, thumb_y, sb_w, thumb_h))
+    }
+}
+
+impl Element for ScrollBar {
+    fn base(&self) -> Option<&Widget> { None }
+    fn base_mut(&mut self) -> Option<&mut Widget> { None }
+
+    fn rect(&self) -> (f32, f32, f32, f32) {
+        (self.x, self.y, self.w, self.h)
+    }
+
+    fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+        self.x = x;
+        self.y = y;
+        self.w = w;
+        self.h = h;
+    }
+
+    fn as_any(&self) -> &dyn std::any::Any { self }
+    fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
+
+    fn as_ptr(&self) -> *mut (dyn Element + 'static) {
+        self as *const Self as *mut Self as *mut (dyn Element + 'static)
+    }
+
+    fn as_ptr_mut(&mut self) -> *mut (dyn Element + 'static) {
+        self as *mut Self as *mut (dyn Element + 'static)
+    }
+
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn set_hovered(&mut self, hovered: bool) {
+        self.hovered = hovered;
+    }
+
+    fn hovered(&self) -> bool {
+        self.hovered
+    }
+
+    fn hit_test(&self, px: f32, py: f32, ctx: &UiContext) -> bool {
+        if ctx.is_coordinate_covered(self as *const Self as *const () as usize, px, py) {
+            return false;
+        }
+        let hit_margin = 6.0;
+        px >= self.x - hit_margin && px <= self.x + self.w + hit_margin && py >= self.y && py <= self.y + self.h
+    }
+
+    fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        let mut changed = false;
+        let is_hit = self.hit_test(px, py, ctx);
+        if self.hovered != is_hit {
+            self.hovered = is_hit;
+            changed = true;
+        }
+
+        if self.dragging {
+            if let Some((_, _, _, thumb_h)) = self.get_thumb_rect() {
+                let sb_track_y = self.y;
+                let sb_track_h = self.h;
+                let track_scroll_range = sb_track_h - thumb_h;
+                if track_scroll_range > 0.0 {
+                    let mouse_y_in_track = (py - sb_track_y).clamp(0.0, sb_track_h);
+                    let scroll_ratio = (mouse_y_in_track - thumb_h / 2.0) / track_scroll_range;
+                    let max_scroll = (self.content_h - self.viewport_h).max(0.0);
+                    let new_scroll_y = (scroll_ratio.clamp(0.0, 1.0) * max_scroll).clamp(0.0, max_scroll);
+                    if (self.scroll_y - new_scroll_y).abs() > 0.01 {
+                        self.scroll_y = new_scroll_y;
+                        changed = true;
+
+                        if let Some(parent_ptr) = self.parent {
+                            unsafe {
+                                if let Some(page) = (*parent_ptr).as_any_mut().downcast_mut::<Page>() {
+                                    page.scroll_y = new_scroll_y;
+                                    let (px, py, pw, ph) = page.rect();
+                                    page.set_rect(px, py, pw, ph);
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        changed
+    }
+
+    fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+        if button == MouseButton::Left {
+            match state {
+                ElementState::Pressed => {
+                    if self.hit_test(px, py, ctx) {
+                        self.dragging = true;
+                        self.on_cursor_moved(px, py, ctx);
+                        return true;
+                    }
+                }
+                ElementState::Released => {
+                    if self.dragging {
+                        self.dragging = false;
+                        return true;
+                    }
+                }
+            }
+        }
+        false
+    }
+
+    fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+        let mut quads = Vec::new();
+        if self.content_h > self.viewport_h && self.h > 0.0 {
+            quads.push((self.x, self.y, self.w, self.h, [0.15, 0.15, 0.20, 0.3]));
+
+            if let Some((sb_x, thumb_y, sb_w, thumb_h)) = self.get_thumb_rect() {
+                let thumb_color = if self.dragging {
+                    [0.70, 0.70, 0.75, 0.6]
+                } else if self.hovered {
+                    [0.65, 0.65, 0.70, 0.5]
+                } else {
+                    [0.60, 0.60, 0.65, 0.4]
+                };
+                quads.push((sb_x, thumb_y, sb_w, thumb_h, thumb_color));
+            }
+        }
+        quads
+    }
+
+    fn parent(&self, _ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> {
+        self.parent
+    }
+
+    fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, _ctx: &mut UiContext) {
+        self.parent = parent;
+    }
+
+    fn children(&self, _ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> {
+        self.children.clone()
+    }
+
+    fn add_child(&mut self, child: *mut (dyn Element + 'static), _ctx: &mut UiContext) {
+        self.children.push(child);
+    }
+
+    fn clear_children(&mut self, _ctx: &mut UiContext) {
+        self.children.clear();
+    }
+}
+
+impl Drop for ScrollBar {
+    fn drop(&mut self) {
+        clear_widget_references(self);
+    }
+}
+
+unsafe impl Send for ScrollBar {}
+unsafe impl Sync for ScrollBar {}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 95d1d67..b243546 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -573,7 +573,7 @@ pub use self::input::{
 pub use self::container::{
     Container, Header, ContentBg, ViewportBg, ParametersBg, ScrollingList,
     ScrollBox, Menu, MenuBar, Spreadsheet, Breadcrumb, Plate,
-    Switcher, Layer, Page, Backplate, Paginator
+    Switcher, Layer, Page, Backplate, Paginator, ScrollBar
 };
 pub use self::display::{
     TextLabel, Label, SectionHeader, StyledLabel, TextItem, Svg, UsageBar,