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

commit57f2a9da9772f464977b8bf83b0a698cc1c64d3f
parenta87d177646
authorLucas Galante <[email protected]>
date2026-07-01 09:58
Add vertical scrolling and mouse wheel support to TextBox

 src/widget/input/text_box.rs | 108 +++++++++++++++++++++++++++++++++++++------
 1 file changed, 93 insertions(+), 15 deletions(-)

diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index a77d5a3..4b45333 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -38,6 +38,7 @@ pub struct TextBox {
     pub font_family: String,
     pub placeholder: Option<String>,
     pub editor_state: TextEditorState,
+    pub scroll_y: f32,
 }
 
 impl TextBox {
@@ -68,6 +69,7 @@ impl TextBox {
             font_family: "monospace".to_string(),
             placeholder: None,
             editor_state,
+            scroll_y: 0.0,
         }
     }
 
@@ -318,6 +320,44 @@ impl TextBox {
         self.all_selected = state.all_selected;
         self.sync_editor_state();
     }
+
+    pub fn clamp_scroll(&mut self) {
+        if !self.multiline {
+            self.scroll_y = 0.0;
+            return;
+        }
+        let char_width = self.font_size * 0.6;
+        let line_height = self.font_size * 1.333;
+        let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
+        let (lines, _) = self.wrap_text(max_chars);
+        let content_h = lines.len() as f32 * line_height;
+        let max_scroll = (content_h - (self.base.h - 16.0)).max(0.0);
+        self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
+    }
+
+    pub fn scroll_to_cursor(&mut self) {
+        if !self.multiline { return; }
+        let char_width = self.font_size * 0.6;
+        let line_height = self.font_size * 1.333;
+        let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
+        let (_, index_map) = self.wrap_text(max_chars);
+        if index_map.is_empty() { return; }
+        
+        let cursor_idx = self.cursor_idx.min(index_map.len() - 1);
+        let (line_idx, _) = index_map[cursor_idx];
+        
+        let top = self.base.label_offset();
+        let line_y = top + 8.0 + (line_idx as f32 * line_height);
+        
+        let viewport_h = self.base.h - top - 16.0;
+        
+        if line_y < self.scroll_y + 10.0 {
+            self.scroll_y = (line_y - 20.0).max(0.0);
+        } else if line_y + line_height > self.scroll_y + viewport_h - 10.0 {
+            self.scroll_y = (line_y + line_height - viewport_h + 20.0).max(0.0);
+        }
+        self.clamp_scroll();
+    }
 }
 
 impl Default for TextBox {
@@ -340,6 +380,7 @@ impl Element for TextBox {
             self.edit_buffer = val_str;
             self.just_changed = true;
             self.sync_editor_state();
+            self.clamp_scroll();
             true
         } else {
             false
@@ -406,6 +447,7 @@ impl Element for TextBox {
             b.w = final_w;
             b.h = h;
         }
+        self.clamp_scroll();
     }
     fn set_row_rect(&mut self, x: f32, w: f32) {
         let final_w = if let Some(explicit_w) = self.width {
@@ -439,7 +481,7 @@ impl Element for TextBox {
                 let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
                 let (lines, index_map) = self.wrap_text(max_chars);
                 let top = self.base.label_offset();
-                let click_line = (((py - (self.base.y + top + 8.0)) / line_height).floor() as isize).max(0) as usize;
+                let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
                 let click_col = (((px - (self.base.x + 8.0)) / char_width).round() as isize).max(0) as usize;
                 self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
             } else {
@@ -482,7 +524,7 @@ impl Element for TextBox {
             let line_height = self.font_size * 1.333;
             let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
             let (lines, index_map) = self.wrap_text(max_chars);
-            let click_line = (((py - (self.base.y + top + 8.0)) / line_height).floor() as isize).max(0) as usize;
+            let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
             let click_col = (((px - (self.base.x + 8.0)) / char_width).round() as isize).max(0) as usize;
             self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
         } else {
@@ -532,7 +574,7 @@ impl Element for TextBox {
                         let line_height = self.font_size * 1.333;
                         let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
                         let (lines, index_map) = self.wrap_text(max_chars);
-                        let click_line = (((py - (self.base.y + top + 8.0)) / line_height).floor() as isize).max(0) as usize;
+                        let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
                         let click_col = (((px - (self.base.x + 8.0)) / char_width).round() as isize).max(0) as usize;
                         self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
                     } else {
@@ -732,6 +774,7 @@ impl Element for TextBox {
             self.select_anchor = state.select_anchor;
             self.all_selected = state.all_selected;
             self.sync_editor_state();
+            self.scroll_to_cursor();
         }
         
         handled
@@ -784,6 +827,9 @@ impl Element for TextBox {
                 let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
                 let (_lines, index_map) = self.wrap_text(max_chars);
 
+                let view_top = self.base.y + top;
+                let view_bottom = self.base.y + self.base.h;
+
                 if start != end {
                     let start_pos = index_map[start.min(index_map.len() - 1)];
                     let end_pos = index_map[end.min(index_map.len() - 1)];
@@ -807,14 +853,18 @@ impl Element for TextBox {
                         if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
                             let highlight_x = self.base.x + 8.0 + (sc as f32 * char_width);
                             let highlight_w = (ec - sc + 1) as f32 * char_width;
-                            let highlight_y = self.base.y + top + 8.0 + (line_idx as f32 * line_height);
-                            quads.push((
-                                highlight_x,
-                                highlight_y,
-                                highlight_w,
-                                line_height,
-                                highlight_color,
-                            ));
+                            let highlight_y = self.base.y + top + 8.0 + (line_idx as f32 * line_height) - self.scroll_y;
+                            let clipped_y = highlight_y.max(view_top);
+                            let clipped_bottom = (highlight_y + line_height).min(view_bottom);
+                            if clipped_y < clipped_bottom {
+                                quads.push((
+                                    highlight_x,
+                                    clipped_y,
+                                    highlight_w,
+                                    clipped_bottom - clipped_y,
+                                    highlight_color,
+                                ));
+                            }
                         }
                     }
                 }
@@ -823,10 +873,15 @@ impl Element for TextBox {
                     let caret_h = self.font_size * 1.15;
                     let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
                     let cursor_x = self.base.x + 8.0 + (cursor_c as f32 * char_width);
-                    let cursor_y = self.base.y + top + 8.0 + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0;
-                    quads.push((cursor_x, cursor_y, 1.5, caret_h, cursor_color));
+                    let cursor_y = self.base.y + top + 8.0 + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
+                    let clipped_y = cursor_y.max(view_top);
+                    let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
+                    if clipped_y < clipped_bottom {
+                        quads.push((cursor_x, clipped_y, 1.5, clipped_bottom - clipped_y, cursor_color));
+                    }
                 }
-            } else {
+            }
+ else {
                 let caret_h = self.font_size * 1.15;
                 if start != end {
                     let highlight_x = self.base.x + 8.0 + (start as f32 * char_width);
@@ -923,7 +978,7 @@ impl Element for TextBox {
                 labels.push(TextLabel {
                     text: line_text.clone(),
                     x: self.base.x + 8.0,
-                    y: self.base.y + top + 8.0 + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0,
+                    y: self.base.y + top + 8.0 + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0 - self.scroll_y,
                     font_size: self.font_size,
                     color: label_color,
                 });
@@ -950,6 +1005,29 @@ impl Element for TextBox {
         let bounds = Some([self.base.x, self.base.y, self.base.x + self.base.w, self.base.y + self.base.h]);
         self.text_labels().into_iter().map(|l| (l, font.clone(), bounds)).collect()
     }
+
+    fn mouse_wheel(&mut self, delta: &MouseScrollDelta, _px: f32, _py: f32, ctx: &mut UiContext) -> bool {
+        if self.disabled || !self.multiline { return false; }
+        let char_width = self.font_size * 0.6;
+        let line_height = self.font_size * 1.333;
+        let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
+        let (lines, _) = self.wrap_text(max_chars);
+        let content_h = lines.len() as f32 * line_height;
+        let max_scroll = (content_h - (self.base.h - 16.0)).max(0.0);
+        
+        let scroll_amt = match *delta {
+            MouseScrollDelta::LineDelta(_, dy) => -dy * line_height * 2.0,
+            MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+        };
+        let old_scroll = self.scroll_y;
+        self.scroll_y = (self.scroll_y + scroll_amt).clamp(0.0, max_scroll);
+        if old_scroll != self.scroll_y {
+            self.mark_dirty(ctx);
+            true
+        } else {
+            false
+        }
+    }
 }
 
 impl Drop for TextBox {