GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Implement vertical scrolling and scrolling velocity/inertia in Spreadsheet widget
src/widget.rs | 805 +++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 776 insertions(+), 29 deletions(-)
diff --git a/src/widget.rs b/src/widget.rs
index 7f3390a..6eb4507 100644
--- a/src/widget.rs
+++ b/src/widget.rs
@@ -53,6 +53,8 @@ pub trait Widget {
fn menu_click(&mut self) -> Option<(usize, usize)> { None }
fn set_item_checked(&mut self, _menu_idx: usize, _item_idx: usize, _checked: bool) {}
fn set_menu_items(&mut self, _menu_idx: usize, _items: &[String]) {}
+ fn is_menu_bar(&self) -> bool { false }
+ fn is_menu_open(&self) -> bool { false }
fn set_grid_snap(&mut self, _gx: f32, _gy: f32) {}
fn set_node_name(&mut self, _name: &str) {}
fn set_visible(&mut self, _visible: bool) {}
@@ -76,6 +78,8 @@ pub trait Widget {
fn set_geom_visible(&mut self, _visible: bool) {}
fn geom_visible(&self) -> bool { true }
fn take_geom_toggle(&mut self) -> bool { false }
+ fn set_spreadsheet_data(&mut self, _headers: Vec<String>, _rows: Vec<Vec<String>>) {}
+ fn tick(&mut self, _dt: f32) -> bool { false }
}
pub struct Header {
@@ -116,7 +120,13 @@ impl ContentBg {
impl Widget for ContentBg {
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 color(&self) -> [f32; 4] { colors::CONTENT_BG }
+ fn color(&self) -> [f32; 4] {
+ if self.show_network_grid {
+ [0.0, 0.0, 0.0, 0.0]
+ } else {
+ colors::CONTENT_BG
+ }
+ }
fn set_hovered(&mut self, v: bool) { self.hovered = v; }
fn hovered(&self) -> bool { self.hovered }
fn hit_test(&self, _px: f32, _py: f32) -> bool { false }
@@ -131,42 +141,193 @@ impl Widget for ContentBg {
return vec![];
}
let mut quads = Vec::new();
- let grid_color = [0.25, 0.25, 0.32, 1.0];
+ let grid_color = [0.0, 0.0, 0.0, 0.0];
+ let max_alpha = colors::CONTENT_BG[3]; // Peak opacity in the middle of gradient cells matches non-gradient cells
+ let steps = 20; // Silky-smooth gradient transition
let step_y = self.grid_size_y + self.skipped_row_h;
- let diff_y = self.y - self.grid_origin_y;
- let mut k = (diff_y / step_y).floor() - 1.0;
- loop {
- let y1 = self.grid_origin_y + k * step_y;
- let y2 = y1 + self.grid_size_y;
- if y1 >= self.y + self.h {
- break;
- }
- if y1 >= self.y {
- quads.push((self.x, y1, self.w, 1.0, grid_color));
+ let step_x = self.grid_size_x + self.skipped_col_w;
+
+ if step_y >= 4.0 && step_x >= 4.0 {
+ let ry_start = ((self.y - self.grid_origin_y) / step_y).floor() as i32 - 1;
+ let ry_end = ((self.y + self.h - self.grid_origin_y) / step_y).ceil() as i32 + 1;
+ let ry_start = ry_start.max(-100_000);
+ let ry_end = ry_end.min(100_000);
+
+ let cx_start = ((self.x - self.grid_origin_x) / step_x).floor() as i32 - 1;
+ let cx_end = ((self.x + self.w - self.grid_origin_x) / step_x).ceil() as i32 + 1;
+ let cx_start = cx_start.max(-100_000);
+ let cx_end = cx_end.min(100_000);
+
+ // Draw individual cell backgrounds to avoid stacking with gradients
+ for ry in ry_start..=ry_end {
+ let y1 = self.grid_origin_y + (ry as f32) * step_y;
+ let draw_start_y = y1.max(self.y);
+ let draw_end_y = (y1 + self.grid_size_y).min(self.y + self.h);
+ if draw_start_y < draw_end_y {
+ for cx in cx_start..=cx_end {
+ let x1 = self.grid_origin_x + (cx as f32) * step_x;
+ let draw_start_x = x1.max(self.x);
+ let draw_end_x = (x1 + self.grid_size_x).min(self.x + self.w);
+ if draw_start_x < draw_end_x {
+ quads.push((draw_start_x, draw_start_y, draw_end_x - draw_start_x, draw_end_y - draw_start_y, colors::CONTENT_BG));
+ }
+ }
+ }
}
- if y2 >= self.y && y2 < self.y + self.h {
- quads.push((self.x, y2, self.w, 1.0, grid_color));
+ }
+
+ // Draw interstitial row gradients (horizontal bands fading to 0 alpha at left and right sides)
+ if self.skipped_row_h > 0.0 {
+ let step_y = self.grid_size_y + self.skipped_row_h;
+ let step_x = self.grid_size_x + self.skipped_col_w;
+ if step_y >= 4.0 && step_x >= 4.0 {
+ let k_start = ((self.y - self.grid_origin_y) / step_y).floor() as i32 - 1;
+ let k_end = ((self.y + self.h - self.grid_origin_y) / step_y).ceil() as i32 + 1;
+ let k_start = k_start.max(-100_000);
+ let k_end = k_end.min(100_000);
+
+ let cx_start = ((self.x - self.grid_origin_x) / step_x).floor() as i32 - 1;
+ let cx_end = ((self.x + self.w - self.grid_origin_x) / step_x).ceil() as i32 + 1;
+ let cx_start = cx_start.max(-100_000);
+ let cx_end = cx_end.min(100_000);
+
+ for k in k_start..=k_end {
+ let y1 = self.grid_origin_y + (k as f32) * step_y;
+ let y2 = y1 + self.grid_size_y;
+ if y1 >= self.y + self.h {
+ continue;
+ }
+ let draw_start_y = y2.max(self.y);
+ let draw_end_y = (y2 + self.skipped_row_h).min(self.y + self.h);
+ if draw_start_y >= draw_end_y {
+ continue;
+ }
+
+ for cx in cx_start..=cx_end {
+ let x1 = self.grid_origin_x + (cx as f32) * step_x;
+ let x_mid = x1 + self.grid_size_x / 2.0;
+ let w_total = self.grid_size_x;
+ let sub_w = w_total / steps as f32;
+
+ for i in 0..steps {
+ let sx_start = x1 + i as f32 * sub_w;
+ let sx_end = sx_start + sub_w;
+ let draw_start_x = sx_start.max(self.x);
+ let draw_end_x = sx_end.min(self.x + self.w);
+ if draw_start_x < draw_end_x {
+ let sx_mid = (sx_start + sx_end) / 2.0;
+ let dist = (sx_mid - x_mid).abs();
+ let d = (dist / (w_total / 2.0)).min(1.0);
+
+ // Fade the cell background color from max_alpha in the middle to transparent at the edges
+ let alpha = max_alpha * (1.0 - d);
+ if alpha > 0.001 {
+ quads.push((draw_start_x, draw_start_y, draw_end_x - draw_start_x, draw_end_y - draw_start_y, [colors::CONTENT_BG[0], colors::CONTENT_BG[1], colors::CONTENT_BG[2], alpha]));
+ }
+ }
+ }
+ }
+ }
}
- k += 1.0;
}
- let step_x = self.grid_size_x + self.skipped_col_w;
- let diff_x = self.x - self.grid_origin_x;
- let mut k_x = (diff_x / step_x).floor() - 1.0;
- loop {
- let x1 = self.grid_origin_x + k_x * step_x;
- let x2 = x1 + self.grid_size_x;
- if x1 >= self.x + self.w {
- break;
+ // Draw interstitial column gradients (vertical bands fading to 0 alpha at top and bottom)
+ if self.skipped_col_w > 0.0 {
+ let step_y = self.grid_size_y + self.skipped_row_h;
+ let step_x = self.grid_size_x + self.skipped_col_w;
+ if step_y >= 4.0 && step_x >= 4.0 {
+ let k_start = ((self.x - self.grid_origin_x) / step_x).floor() as i32 - 1;
+ let k_end = ((self.x + self.w - self.grid_origin_x) / step_x).ceil() as i32 + 1;
+ let k_start = k_start.max(-100_000);
+ let k_end = k_end.min(100_000);
+
+ let ry_start = ((self.y - self.grid_origin_y) / step_y).floor() as i32 - 1;
+ let ry_end = ((self.y + self.h - self.grid_origin_y) / step_y).ceil() as i32 + 1;
+ let ry_start = ry_start.max(-100_000);
+ let ry_end = ry_end.min(100_000);
+
+ for k in k_start..=k_end {
+ let x1 = self.grid_origin_x + (k as f32) * step_x;
+ let x2 = x1 + self.grid_size_x;
+ if x1 >= self.x + self.w {
+ continue;
+ }
+ let draw_start_x = x2.max(self.x);
+ let draw_end_x = (x2 + self.skipped_col_w).min(self.x + self.w);
+ if draw_start_x >= draw_end_x {
+ continue;
+ }
+
+ for ry in ry_start..=ry_end {
+ let y1 = self.grid_origin_y + (ry as f32) * step_y;
+ let y_mid = y1 + self.grid_size_y / 2.0;
+ let h_total = self.grid_size_y;
+ let sub_h = h_total / steps as f32;
+
+ for i in 0..steps {
+ let sy_start = y1 + i as f32 * sub_h;
+ let sy_end = sy_start + sub_h;
+ let draw_start_y = sy_start.max(self.y);
+ let draw_end_y = sy_end.min(self.y + self.h);
+ if draw_start_y < draw_end_y {
+ let sy_mid = (sy_start + sy_end) / 2.0;
+ let dist = (sy_mid - y_mid).abs();
+ let d = (dist / (h_total / 2.0)).min(1.0);
+
+ // Fade the cell background color from max_alpha in the middle to transparent at the edges
+ let alpha = max_alpha * (1.0 - d);
+ if alpha > 0.001 {
+ quads.push((draw_start_x, draw_start_y, draw_end_x - draw_start_x, draw_end_y - draw_start_y, [colors::CONTENT_BG[0], colors::CONTENT_BG[1], colors::CONTENT_BG[2], alpha]));
+ }
+ }
+ }
+ }
+ }
}
- if x1 >= self.x {
- quads.push((x1, self.y, 1.0, self.h, grid_color));
+ }
+
+ // Draw the grid borders
+ let step_y = self.grid_size_y + self.skipped_row_h;
+ if step_y >= 4.0 {
+ let k_start = ((self.y - self.grid_origin_y) / step_y).floor() as i32 - 1;
+ let k_end = ((self.y + self.h - self.grid_origin_y) / step_y).ceil() as i32 + 1;
+ let k_start = k_start.max(-100_000);
+ let k_end = k_end.min(100_000);
+ for k in k_start..=k_end {
+ let y1 = self.grid_origin_y + (k as f32) * step_y;
+ let y2 = y1 + self.grid_size_y;
+ if y1 >= self.y + self.h {
+ continue;
+ }
+ if y1 >= self.y {
+ quads.push((self.x, y1, self.w, 1.0, grid_color));
+ }
+ if y2 >= self.y && y2 < self.y + self.h {
+ quads.push((self.x, y2, self.w, 1.0, grid_color));
+ }
}
- if x2 >= self.x && x2 < self.x + self.w {
- quads.push((x2, self.y, 1.0, self.h, grid_color));
+ }
+
+ let step_x = self.grid_size_x + self.skipped_col_w;
+ if step_x >= 4.0 {
+ let k_start = ((self.x - self.grid_origin_x) / step_x).floor() as i32 - 1;
+ let k_end = ((self.x + self.w - self.grid_origin_x) / step_x).ceil() as i32 + 1;
+ let k_start = k_start.max(-100_000);
+ let k_end = k_end.min(100_000);
+ for k in k_start..=k_end {
+ let x1 = self.grid_origin_x + (k as f32) * step_x;
+ let x2 = x1 + self.grid_size_x;
+ if x1 >= self.x + self.w {
+ continue;
+ }
+ if x1 >= self.x {
+ quads.push((x1, self.y, 1.0, self.h, grid_color));
+ }
+ if x2 >= self.x && x2 < self.x + self.w {
+ quads.push((x2, self.y, 1.0, self.h, grid_color));
+ }
}
- k_x += 1.0;
}
quads
}
@@ -422,6 +583,7 @@ pub struct MenuBar {
clicked_dropdown: Option<(usize, usize)>,
was_open: Option<usize>,
pub vertical: bool,
+ pub visible: bool,
}
impl MenuBar {
@@ -439,9 +601,11 @@ impl MenuBar {
clicked_dropdown: None,
was_open: None,
vertical: false,
+ visible: true,
}
}
+
pub fn with_title(mut self, title: &str) -> Self {
self.title = title.to_string();
self
@@ -525,6 +689,9 @@ impl MenuBar {
}
impl Widget for MenuBar {
fn rect(&self) -> (f32, f32, f32, f32) {
+ if !self.visible {
+ return (0.0, 0.0, 0.0, 0.0);
+ }
if self.vertical {
let total_h = if self.menu_items.is_empty() {
self.h
@@ -538,11 +705,20 @@ impl Widget for MenuBar {
}
}
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 color(&self) -> [f32; 4] { colors::PANEL_MENU_BG }
+ fn color(&self) -> [f32; 4] {
+ if !self.visible {
+ [0.0, 0.0, 0.0, 0.0]
+ } else {
+ colors::PANEL_MENU_BG
+ }
+ }
fn set_hovered(&mut self, v: bool) { self.hovering = v; }
fn hovered(&self) -> bool { self.hovering }
fn hit_test(&self, px: f32, py: f32) -> bool {
+ if !self.visible {
+ return false;
+ }
let (rx, ry, rw, rh) = self.rect();
if px >= rx && px <= rx + rw && py >= ry && py <= ry + rh {
return true;
@@ -557,6 +733,9 @@ impl Widget for MenuBar {
}
fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ if !self.visible {
+ return false;
+ }
self.was_open = None;
let was = self.hovering;
self.hovering = self.hit_test(px, py);
@@ -607,6 +786,9 @@ impl Widget for MenuBar {
}
fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32) -> bool {
+ if !self.visible {
+ return false;
+ }
if button != MouseButton::Left || state != ElementState::Pressed { return false; }
if !self.hit_test(px, py) { return false; }
@@ -693,7 +875,13 @@ impl Widget for MenuBar {
}
}
+ fn is_menu_bar(&self) -> bool { self.visible }
+ fn is_menu_open(&self) -> bool { self.visible && self.open_menu.is_some() }
+
fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ if !self.visible {
+ return Vec::new();
+ }
let mut quads = Vec::new();
if let Some(idx) = self.hovered_menu {
if self.vertical {
@@ -718,6 +906,9 @@ impl Widget for MenuBar {
}
fn text_labels(&self) -> Vec<TextLabel> {
+ if !self.visible {
+ return Vec::new();
+ }
let mut labels = Vec::new();
if !self.title.is_empty() {
labels.push(TextLabel {
@@ -774,6 +965,13 @@ impl Widget for MenuBar {
}
labels
}
+
+ fn set_visible(&mut self, visible: bool) {
+ self.visible = visible;
+ }
+ fn visible(&self) -> bool {
+ self.visible
+ }
}
pub enum ButtonKind {
@@ -1929,6 +2127,397 @@ fn parse_hex(s: &str) -> Option<[u8; 3]> {
Some([r, g, b])
}
+pub struct Spreadsheet {
+ x: f32,
+ y: f32,
+ w: f32,
+ h: f32,
+ hovered: bool,
+ visible: bool,
+ headers: Vec<String>,
+ rows: Vec<Vec<String>>,
+ scroll_y: f32,
+ scroll_velocity: f32,
+ dragging_scrollbar: bool,
+ drag_offset_y: f32,
+ scrollbar_hovered: bool,
+ scrollbar_thumb_hovered: bool,
+}
+
+impl Spreadsheet {
+ pub fn new() -> Self {
+ Self {
+ x: 0.0,
+ y: 0.0,
+ w: 0.0,
+ h: 0.0,
+ hovered: false,
+ visible: false,
+ headers: Vec::new(),
+ rows: Vec::new(),
+ scroll_y: 0.0,
+ scroll_velocity: 0.0,
+ dragging_scrollbar: false,
+ drag_offset_y: 0.0,
+ scrollbar_hovered: false,
+ scrollbar_thumb_hovered: false,
+ }
+ }
+}
+
+impl Widget for Spreadsheet {
+ fn rect(&self) -> (f32, f32, f32, f32) {
+ if !self.visible {
+ (0.0, 0.0, 0.0, 0.0)
+ } else {
+ (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 color(&self) -> [f32; 4] {
+ if !self.visible {
+ [0.0, 0.0, 0.0, 0.0]
+ } else {
+ [0.08, 0.08, 0.12, 1.0]
+ }
+ }
+
+ fn set_hovered(&mut self, v: bool) {
+ self.hovered = v;
+ }
+
+ fn hovered(&self) -> bool {
+ self.hovered
+ }
+
+ fn hit_test(&self, px: f32, py: f32) -> bool {
+ if !self.visible {
+ return false;
+ }
+ let (rx, ry, rw, rh) = self.rect();
+ px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
+ }
+
+ fn set_visible(&mut self, visible: bool) {
+ self.visible = visible;
+ }
+
+ fn visible(&self) -> bool {
+ self.visible
+ }
+
+ fn set_spreadsheet_data(&mut self, headers: Vec<String>, rows: Vec<Vec<String>>) {
+ self.headers = headers;
+ self.rows = rows;
+
+ // Clamp scroll_y to new bounds
+ let content_h = self.rows.len() as f32 * 24.0;
+ let visible_h = (self.h - 24.0).max(0.0);
+ let max_scroll_y = (content_h - visible_h).max(0.0);
+ self.scroll_y = self.scroll_y.clamp(0.0, max_scroll_y);
+ }
+
+ fn cursor_moved(&mut self, px: f32, py: f32) -> bool {
+ let was_hovered = self.hovered;
+ self.hovered = self.hit_test(px, py);
+
+ let was_sb_hovered = self.scrollbar_hovered;
+ let was_thumb_hovered = self.scrollbar_thumb_hovered;
+
+ let content_h = self.rows.len() as f32 * 24.0;
+ let visible_h = (self.h - 24.0).max(0.0);
+ if visible_h > 0.0 && content_h > visible_h {
+ let scrollbar_w = 6.0;
+ let scrollbar_padding = 2.0;
+ let scrollbar_x = self.x + self.w - scrollbar_w - scrollbar_padding;
+ let track_y = self.y + 24.0;
+
+ self.scrollbar_hovered = px >= scrollbar_x - 2.0 && px <= self.x + self.w
+ && py >= track_y && py <= self.y + self.h;
+
+ let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
+ let max_scroll_y = content_h - visible_h;
+ let scroll_ratio = self.scroll_y / max_scroll_y;
+ let track_scroll_range = visible_h - thumb_h;
+ let thumb_y = track_y + scroll_ratio * track_scroll_range;
+
+ self.scrollbar_thumb_hovered = px >= scrollbar_x - 2.0 && px <= self.x + self.w
+ && py >= thumb_y && py <= thumb_y + thumb_h;
+ } else {
+ self.scrollbar_hovered = false;
+ self.scrollbar_thumb_hovered = false;
+ }
+
+ was_hovered != self.hovered
+ || was_sb_hovered != self.scrollbar_hovered
+ || was_thumb_hovered != self.scrollbar_thumb_hovered
+ }
+
+ fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32) -> bool {
+ if !self.visible {
+ return false;
+ }
+ if self.hit_test(px, py) {
+ let content_h = self.rows.len() as f32 * 24.0;
+ let visible_h = (self.h - 24.0).max(0.0);
+ if visible_h > 0.0 && content_h > visible_h {
+ let scroll_amount = match delta {
+ MouseScrollDelta::LineDelta(_x, y) => *y * 24.0,
+ MouseScrollDelta::PixelDelta(pos) => pos.y as f32,
+ };
+ self.scroll_velocity += scroll_amount * 12.0;
+ return true;
+ }
+ }
+ false
+ }
+
+ fn draggable(&self) -> bool {
+ if !self.visible {
+ return false;
+ }
+ let content_h = self.rows.len() as f32 * 24.0;
+ let visible_h = (self.h - 24.0).max(0.0);
+ visible_h > 0.0 && content_h > visible_h
+ }
+
+ fn is_dragging(&self) -> bool {
+ self.dragging_scrollbar
+ }
+
+ fn drag_begin(&mut self, px: f32, py: f32) {
+ self.scroll_velocity = 0.0;
+ let content_h = self.rows.len() as f32 * 24.0;
+ let visible_h = (self.h - 24.0).max(0.0);
+ if visible_h > 0.0 && content_h > visible_h {
+ let scrollbar_w = 6.0;
+ let scrollbar_padding = 2.0;
+ let scrollbar_x = self.x + self.w - scrollbar_w - scrollbar_padding;
+ let track_y = self.y + 24.0;
+
+ if px >= scrollbar_x - 4.0 && px <= self.x + self.w
+ && py >= track_y && py <= self.y + self.h
+ {
+ self.dragging_scrollbar = true;
+
+ let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
+ let max_scroll_y = content_h - visible_h;
+ let scroll_ratio = self.scroll_y / max_scroll_y;
+ let track_scroll_range = visible_h - thumb_h;
+ let thumb_y = track_y + scroll_ratio * track_scroll_range;
+
+ if py >= thumb_y && py <= thumb_y + thumb_h {
+ self.drag_offset_y = py - thumb_y;
+ } else {
+ self.drag_offset_y = thumb_h / 2.0;
+ let new_thumb_y = py - self.drag_offset_y;
+ let scroll_ratio = if track_scroll_range > 0.0 {
+ ((new_thumb_y - track_y) / track_scroll_range).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ self.scroll_y = scroll_ratio * max_scroll_y;
+ }
+ }
+ }
+ }
+
+ fn drag_update(&mut self, _px: f32, py: f32) -> bool {
+ if self.dragging_scrollbar {
+ self.scroll_velocity = 0.0;
+ let content_h = self.rows.len() as f32 * 24.0;
+ let visible_h = (self.h - 24.0).max(0.0);
+ if visible_h > 0.0 && content_h > visible_h {
+ let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
+ let max_scroll_y = content_h - visible_h;
+ let track_y = self.y + 24.0;
+ let track_scroll_range = visible_h - thumb_h;
+
+ let new_thumb_y = py - self.drag_offset_y;
+ let scroll_ratio = if track_scroll_range > 0.0 {
+ ((new_thumb_y - track_y) / track_scroll_range).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let old_scroll_y = self.scroll_y;
+ self.scroll_y = scroll_ratio * max_scroll_y;
+
+ return (self.scroll_y - old_scroll_y).abs() > 0.01;
+ }
+ }
+ false
+ }
+
+ fn drag_end(&mut self) {
+ self.dragging_scrollbar = false;
+ self.scroll_velocity = 0.0;
+ }
+
+ fn tick(&mut self, dt: f32) -> bool {
+ if self.scroll_velocity.abs() > 0.01 {
+ let content_h = self.rows.len() as f32 * 24.0;
+ let visible_h = (self.h - 24.0).max(0.0);
+ let max_scroll_y = (content_h - visible_h).max(0.0);
+ let old_scroll_y = self.scroll_y;
+
+ self.scroll_y = (self.scroll_y + self.scroll_velocity * dt).clamp(0.0, max_scroll_y);
+
+ // Decelerate with friction (exponential decay)
+ let friction = 8.0;
+ self.scroll_velocity *= (-friction * dt).exp();
+
+ if self.scroll_y == 0.0 || self.scroll_y == max_scroll_y {
+ self.scroll_velocity = 0.0;
+ }
+
+ if self.scroll_velocity.abs() < 5.0 {
+ self.scroll_velocity = 0.0;
+ }
+
+ (self.scroll_y - old_scroll_y).abs() > 0.01
+ } else {
+ false
+ }
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ if !self.visible {
+ return Vec::new();
+ }
+ let mut quads = Vec::new();
+
+ // Header bg
+ quads.push((self.x, self.y, self.w, 24.0, [0.12, 0.12, 0.16, 1.0]));
+
+ // Zebra rows
+ let row_h = 24.0;
+ let body_top = self.y + 24.0;
+ let body_bottom = self.y + self.h;
+ for i in 0..self.rows.len() {
+ let ry = self.y + 24.0 + i as f32 * row_h - self.scroll_y;
+ if ry + row_h <= body_top || ry >= body_bottom {
+ continue;
+ }
+ let draw_y = ry.max(body_top);
+ let draw_h = (ry + row_h).min(body_bottom) - draw_y;
+ if draw_h > 0.0 {
+ let row_color = if i % 2 == 0 {
+ [0.10, 0.10, 0.13, 0.9]
+ } else {
+ [0.08, 0.08, 0.11, 0.9]
+ };
+ quads.push((self.x, draw_y, self.w, draw_h, row_color));
+
+ // Horizontal row separator
+ let sep_y = ry + row_h;
+ if sep_y >= body_top && sep_y < body_bottom {
+ quads.push((self.x, sep_y, self.w, 1.0, [0.20, 0.20, 0.25, 0.3]));
+ }
+ }
+ }
+
+ // Header separator
+ quads.push((self.x, self.y + 24.0, self.w, 1.0, [0.20, 0.20, 0.25, 0.5]));
+
+ // Vertical separators
+ let divider_h = self.h;
+ if divider_h > 0.0 && !self.headers.is_empty() {
+ let n_cols = self.headers.len();
+ for i in 1..n_cols {
+ let r = i as f32 / n_cols as f32;
+ quads.push((self.x + self.w * r, self.y, 1.0, divider_h, [0.20, 0.20, 0.25, 0.3]));
+ }
+ }
+
+ // Scrollbar track & thumb
+ let content_h = self.rows.len() as f32 * row_h;
+ let visible_h = (self.h - 24.0).max(0.0);
+ if visible_h > 0.0 && content_h > visible_h {
+ let scrollbar_w = 6.0;
+ let scrollbar_padding = 2.0;
+ let scrollbar_x = self.x + self.w - scrollbar_w - scrollbar_padding;
+ let track_y = self.y + 24.0;
+ let track_h = visible_h;
+
+ // Track BG
+ quads.push((scrollbar_x, track_y, scrollbar_w, track_h, [0.05, 0.05, 0.08, 0.4]));
+
+ // Thumb
+ let thumb_h = ((visible_h / content_h) * visible_h).clamp(15.0_f32.min(visible_h), visible_h);
+ let max_scroll_y = content_h - visible_h;
+ let scroll_ratio = self.scroll_y / max_scroll_y;
+ let track_scroll_range = visible_h - thumb_h;
+ let thumb_y = track_y + scroll_ratio * track_scroll_range;
+
+ let thumb_color = if self.dragging_scrollbar {
+ [0.40, 0.40, 0.48, 1.0]
+ } else if self.scrollbar_thumb_hovered {
+ [0.32, 0.32, 0.38, 1.0]
+ } else if self.scrollbar_hovered {
+ [0.24, 0.24, 0.30, 0.9]
+ } else {
+ [0.18, 0.18, 0.24, 0.7]
+ };
+
+ quads.push((scrollbar_x, thumb_y, scrollbar_w, thumb_h, thumb_color));
+ }
+
+ quads
+ }
+
+ fn text_labels(&self) -> Vec<TextLabel> {
+ if !self.visible {
+ return Vec::new();
+ }
+ let mut labels = Vec::new();
+ if self.headers.is_empty() {
+ return labels;
+ }
+
+ let n_cols = self.headers.len();
+ for (i, header) in self.headers.iter().enumerate() {
+ let cx = self.x + self.w * (i as f32 / n_cols as f32) + 8.0;
+ labels.push(TextLabel {
+ text: header.clone(),
+ x: cx,
+ y: self.y + 6.0,
+ font_size: 12.0,
+ color: [0xdd, 0xdd, 0xee],
+ });
+ }
+
+ let row_h = 24.0;
+ let body_top = self.y + 24.0;
+ let body_bottom = self.y + self.h;
+ for (i, row) in self.rows.iter().enumerate() {
+ let ry = self.y + 24.0 + i as f32 * row_h - self.scroll_y;
+ // Only show text if the row is fully inside the spreadsheet body
+ if ry < body_top || ry + row_h > body_bottom {
+ continue;
+ }
+
+ for (col_idx, val) in row.iter().enumerate().take(n_cols) {
+ let cx = self.x + self.w * (col_idx as f32 / n_cols as f32) + 8.0;
+ labels.push(TextLabel {
+ text: val.clone(),
+ x: cx,
+ y: ry + 6.0,
+ font_size: 12.0,
+ color: [0xbb, 0xbb, 0xcc],
+ });
+ }
+ }
+ labels
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -2017,4 +2606,162 @@ mod tests {
menubar_v.cursor_moved(20.0, iy + ih / 2.0);
assert_eq!(menubar_v.hovered_menu, Some(0));
}
+
+ #[test]
+ fn test_spreadsheet_dynamic() {
+ let mut spreadsheet = Spreadsheet::new();
+ spreadsheet.set_rect(10.0, 10.0, 100.0, 200.0);
+ spreadsheet.set_visible(true);
+
+ // Initially empty
+ assert!(spreadsheet.headers.is_empty());
+ assert!(spreadsheet.rows.is_empty());
+ assert!(spreadsheet.text_labels().is_empty());
+
+ // Set dynamic headers and rows
+ let headers = vec!["ColA".to_string(), "ColB".to_string()];
+ let rows = vec![
+ vec!["Val1".to_string(), "Val2".to_string()],
+ vec!["Val3".to_string(), "Val4".to_string()],
+ ];
+ spreadsheet.set_spreadsheet_data(headers, rows);
+
+ assert_eq!(spreadsheet.headers.len(), 2);
+ assert_eq!(spreadsheet.rows.len(), 2);
+
+ // Verify labels generated
+ let labels = spreadsheet.text_labels();
+ // 2 headers + 4 cell values = 6 labels total
+ assert_eq!(labels.len(), 6);
+ assert_eq!(labels[0].text, "ColA");
+ assert_eq!(labels[1].text, "ColB");
+ assert_eq!(labels[2].text, "Val1");
+ assert_eq!(labels[3].text, "Val2");
+ assert_eq!(labels[4].text, "Val3");
+ assert_eq!(labels[5].text, "Val4");
+
+ // Verify positions are correct (col 0 starts at x = 10.0 + 8.0 = 18.0)
+ assert_eq!(labels[0].x, 18.0);
+ // Col 1 starts at x = 10.0 + 100.0 * 0.5 + 8.0 = 68.0
+ assert_eq!(labels[1].x, 68.0);
+ assert_eq!(labels[2].x, 18.0);
+ assert_eq!(labels[3].x, 68.0);
+ }
+
+ #[test]
+ fn test_spreadsheet_scrolling() {
+ let mut spreadsheet = Spreadsheet::new();
+ // Visible height is 100px. Header is 24px, so body is 76px.
+ spreadsheet.set_rect(0.0, 0.0, 100.0, 100.0);
+ spreadsheet.set_visible(true);
+
+ let headers = vec!["ColA".to_string()];
+ // Each row is 24px. With 10 rows, content_h = 240px.
+ let mut rows = Vec::new();
+ for i in 0..10 {
+ rows.push(vec![format!("Row{}", i)]);
+ }
+ spreadsheet.set_spreadsheet_data(headers, rows);
+
+ // Content height is 240px, visible height is 100px (body is 76px).
+ // Since content height > visible body height, it should be draggable.
+ assert!(spreadsheet.draggable());
+
+ // Max scroll height = 240.0 - 76.0 = 164.0
+
+ // Initial scroll position should be 0.0
+ assert_eq!(spreadsheet.scroll_y, 0.0);
+
+ // Scroll down via mouse wheel (positive delta scrolls content down, scroll_y increases via tick)
+ let delta = MouseScrollDelta::LineDelta(0.0, 2.0);
+ // Mouse over spreadsheet (50, 50)
+ let changed = spreadsheet.mouse_wheel(&delta, 50.0, 50.0);
+ assert!(changed);
+ assert_eq!(spreadsheet.scroll_y, 0.0);
+ assert!(spreadsheet.scroll_velocity > 0.0);
+
+ // Tick to apply velocity
+ let mut ticked_change = false;
+ for _ in 0..100 {
+ if spreadsheet.tick(0.016) {
+ ticked_change = true;
+ }
+ }
+ assert!(ticked_change);
+ assert!(spreadsheet.scroll_y > 0.0);
+ assert_eq!(spreadsheet.scroll_velocity, 0.0);
+
+ // Scroll back to top
+ let delta_up = MouseScrollDelta::LineDelta(0.0, -10.0);
+ spreadsheet.mouse_wheel(&delta_up, 50.0, 50.0);
+ assert!(spreadsheet.scroll_velocity < 0.0);
+
+ // Tick back to top
+ for _ in 0..100 {
+ spreadsheet.tick(0.016);
+ }
+ assert_eq!(spreadsheet.scroll_y, 0.0);
+ assert_eq!(spreadsheet.scroll_velocity, 0.0);
+
+ // Drag test
+ // Scrollbar width is 6px. Padding is 2px. Width is 100px.
+ // Scrollbar track x is from 92px to 98px.
+ // Let's drag. Click at (94, 50).
+ spreadsheet.drag_begin(94.0, 50.0);
+ assert!(spreadsheet.is_dragging());
+
+ // Update drag to y = 80
+ let changed_drag = spreadsheet.drag_update(94.0, 80.0);
+ assert!(changed_drag);
+ assert!(spreadsheet.scroll_y > 0.0);
+
+ // End drag
+ spreadsheet.drag_end();
+ assert!(!spreadsheet.is_dragging());
+ }
+
+ #[test]
+ fn test_spreadsheet_zero_height_no_panic() {
+ let mut spreadsheet = Spreadsheet::new();
+ // Visible height is set to 0.0
+ spreadsheet.set_rect(0.0, 0.0, 100.0, 0.0);
+ spreadsheet.set_visible(true);
+
+ let headers = vec!["ColA".to_string()];
+ let mut rows = Vec::new();
+ for i in 0..10 {
+ rows.push(vec![format!("Row{}", i)]);
+ }
+ // This should not panic
+ spreadsheet.set_spreadsheet_data(headers, rows);
+
+ // This should not panic
+ spreadsheet.cursor_moved(50.0, 50.0);
+
+ let delta = MouseScrollDelta::LineDelta(0.0, -2.0);
+ // This should not panic
+ spreadsheet.mouse_wheel(&delta, 50.0, 50.0);
+
+ // This should not panic
+ assert!(!spreadsheet.draggable());
+
+ // This should not panic
+ spreadsheet.drag_begin(94.0, 50.0);
+ spreadsheet.drag_update(94.0, 80.0);
+ spreadsheet.drag_end();
+
+ // This should not panic and return empty quads for scrollbar
+ let _quads = spreadsheet.extra_quads();
+ // The header quad and divider (if any) are drawn, but scrollbar is not
+ // Let's verify that the scrollbar was not drawn
+ // (the last quad would be the scrollbar thumb with thumb_color if drawn,
+ // but here scrollbar track & thumb shouldn't be added)
+ assert_eq!(spreadsheet.scroll_y, 0.0);
+
+ // Let's check text labels (should be empty because self.h is 0)
+ let labels = spreadsheet.text_labels();
+ // Headers labels are still generated since they don't depend on scroll/height,
+ // but rows shouldn't be
+ assert_eq!(labels.len(), 1); // Only header ColA
+ }
}