GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Implement ColumnarList widget and automatic scroll clipping bounds check
src/widget/container/columnar_list.rs | 519 ++++++++++++++++++++++++++++++++++
src/widget/container/mod.rs | 2 +
src/widget/mod.rs | 47 ++-
3 files changed, 565 insertions(+), 3 deletions(-)
diff --git a/src/widget/container/columnar_list.rs b/src/widget/container/columnar_list.rs
new file mode 100644
index 0000000..a0d4a45
--- /dev/null
+++ b/src/widget/container/columnar_list.rs
@@ -0,0 +1,519 @@
+use crate::widget::*;
+use super::scroll_box::ScrollBox;
+use crate::widget::display::TextLabel;
+use crate::context::UiContext;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum ColumnWidth {
+ Flex,
+ Absolute(f32),
+ RightOffset(f32),
+}
+
+#[derive(Debug, Clone)]
+pub struct ListColumn {
+ pub name: String,
+ pub width: ColumnWidth,
+ pub justification: Justification,
+}
+
+#[derive(Debug, Clone)]
+pub struct ListRow {
+ pub cells: Vec<String>,
+ pub icon: Option<String>,
+ pub selected: bool,
+}
+
+#[derive(Debug, Clone)]
+pub struct ColumnarList {
+ pub base: Widget,
+ pub scroll_box: ScrollBox,
+ pub columns: Vec<ListColumn>,
+ pub rows: Vec<ListRow>,
+ pub row_height: f32,
+ pub row_gap: f32,
+ pub hovered_row: Option<usize>,
+ pub pressed_row: Option<usize>,
+ pub clicked_row: Option<usize>,
+ pub double_clicked_row: Option<usize>,
+ pub last_click_time: Option<std::time::Instant>,
+}
+
+impl ColumnarList {
+ pub fn new(columns: Vec<ListColumn>, row_height: f32, row_gap: f32) -> Self {
+ Self {
+ base: Widget::new(),
+ scroll_box: ScrollBox::new(),
+ columns,
+ rows: Vec::new(),
+ row_height,
+ row_gap,
+ hovered_row: None,
+ pressed_row: None,
+ clicked_row: None,
+ double_clicked_row: None,
+ last_click_time: None,
+ }
+ }
+
+ pub fn get_column_bounds(&self, list_w: f32) -> Vec<(f32, f32)> {
+ let mut bounds = vec![(0.0, 0.0); self.columns.len()];
+ let mut flex_indices = Vec::new();
+ let mut reserved_width = 0.0;
+
+ // Pass 1: Resolve Absolute and RightOffset columns
+ for (i, col) in self.columns.iter().enumerate() {
+ match col.width {
+ ColumnWidth::Absolute(w) => {
+ bounds[i] = (0.0, w);
+ reserved_width += w;
+ }
+ ColumnWidth::RightOffset(offset) => {
+ let x = list_w - offset;
+ let mut next_x = list_w;
+ for j in (i + 1)..self.columns.len() {
+ if let ColumnWidth::RightOffset(o) = self.columns[j].width {
+ next_x = list_w - o;
+ break;
+ }
+ }
+ let w = (next_x - x).max(0.0);
+ bounds[i] = (x, w);
+ }
+ ColumnWidth::Flex => {
+ flex_indices.push(i);
+ }
+ }
+ }
+
+ // Pass 2: Layout left-aligned columns (Flex and Absolute)
+ let mut current_x = 0.0;
+ let mut right_boundary = list_w;
+ for (_, col) in self.columns.iter().enumerate() {
+ if let ColumnWidth::RightOffset(offset) = col.width {
+ if list_w - offset < right_boundary {
+ right_boundary = list_w - offset;
+ }
+ }
+ }
+
+ let left_space = (right_boundary - current_x).max(0.0);
+ let flex_share = if !flex_indices.is_empty() {
+ let flex_total = (left_space - reserved_width).max(0.0);
+ flex_total / flex_indices.len() as f32
+ } else {
+ 0.0
+ };
+
+ for (i, col) in self.columns.iter().enumerate() {
+ match col.width {
+ ColumnWidth::Absolute(w) => {
+ bounds[i] = (current_x, w);
+ current_x += w;
+ }
+ ColumnWidth::Flex => {
+ bounds[i] = (current_x, flex_share);
+ current_x += flex_share;
+ }
+ ColumnWidth::RightOffset(_) => {
+ // Already resolved
+ }
+ }
+ }
+
+ bounds
+ }
+
+ pub fn take_click(&mut self) -> Option<usize> {
+ self.clicked_row.take()
+ }
+
+ pub fn take_double_click(&mut self) -> Option<usize> {
+ self.double_clicked_row.take()
+ }
+
+ pub fn update_bounds(&mut self) {
+ let item_height_full = self.row_height + self.row_gap;
+ let content_h = self.rows.len() as f32 * item_height_full + 4.0;
+ let (_, _, _, h) = self.rect();
+ self.scroll_box.update_bounds(content_h, self.scroll_box.viewport_y, h);
+ }
+}
+
+impl Element for ColumnarList {
+ fn base(&self) -> Option<&Widget> { Some(&self.base) }
+ fn base_mut(&mut self) -> Option<&mut Widget> { Some(&mut self.base) }
+
+ fn rect(&self) -> (f32, f32, f32, f32) {
+ self.scroll_box.rect()
+ }
+
+ fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
+ self.base.x = x;
+ self.base.y = y;
+ self.base.w = w;
+ self.base.h = h;
+ self.scroll_box.set_rect(x, y, w, h);
+ self.update_bounds();
+ }
+
+ fn color(&self) -> [f32; 4] {
+ self.scroll_box.color()
+ }
+
+ fn rounded_corners(&self) -> (bool, bool, bool, bool) {
+ (true, true, true, true)
+ }
+
+ fn corner_radius(&self) -> f32 {
+ crate::layout::list_corner_radius()
+ }
+
+ fn solid_border(&self) -> Option<([f32; 4], f32)> {
+ let is_focused = focus::is_focused(self) || focus::is_focused(&self.scroll_box);
+ let is_hovered = self.hovered() || self.scroll_box.base.hovered;
+ if self.scroll_box.show_border {
+ let box_border_color = if is_focused {
+ [0.30, 0.50, 0.32, 1.0]
+ } else if is_hovered {
+ [0.25, 0.25, 0.35, 1.0]
+ } else {
+ [0.18, 0.18, 0.24, 1.0]
+ };
+ Some((box_border_color, 1.0))
+ } else {
+ None
+ }
+ }
+
+ 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 set_hovered(&mut self, v: bool) {
+ self.scroll_box.set_hovered(v);
+ if !v {
+ self.hovered_row = None;
+ }
+ }
+
+ fn hovered(&self) -> bool {
+ self.scroll_box.hovered()
+ }
+
+ fn highlight_color(&self, ctx: &UiContext) -> Option<[f32; 4]> {
+ self.scroll_box.highlight_color(ctx)
+ }
+
+ fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+ self.scroll_box.cursor_moved(px, py, ctx);
+ let (x, _, w, _) = self.rect();
+ let item_height_full = self.row_height + self.row_gap;
+
+ let mut new_hovered = None;
+ for idx in 0..self.rows.len() {
+ let virtual_y = idx as f32 * item_height_full + 2.0;
+ if let Some(draw_y) = self.scroll_box.get_item_draw_y(virtual_y, self.row_height) {
+ if px >= x + 2.0 && px <= x + w - 2.0 && py >= draw_y && py <= draw_y + self.row_height {
+ new_hovered = Some(idx);
+ break;
+ }
+ }
+ }
+
+ let changed = self.hovered_row != new_hovered;
+ self.hovered_row = new_hovered;
+ changed
+ }
+
+ fn mouse_wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+ self.scroll_box.mouse_wheel(delta, px, py, ctx)
+ }
+
+ fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
+ let (x, _, w, _) = self.rect();
+ let was_scroll = self.scroll_box.mouse_input(button, state, px, py, ctx);
+
+ if button != MouseButton::Left {
+ return was_scroll;
+ }
+
+ let item_height_full = self.row_height + self.row_gap;
+ let mut clicked_idx = None;
+ for idx in 0..self.rows.len() {
+ let virtual_y = idx as f32 * item_height_full + 2.0;
+ if let Some(draw_y) = self.scroll_box.get_item_draw_y(virtual_y, self.row_height) {
+ if px >= x + 2.0 && px <= x + w - 2.0 && py >= draw_y && py <= draw_y + self.row_height {
+ clicked_idx = Some(idx);
+ break;
+ }
+ }
+ }
+
+ match state {
+ ElementState::Pressed => {
+ if let Some(idx) = clicked_idx {
+ self.pressed_row = Some(idx);
+ self.scroll_box.focus();
+ ctx.set_focused_ptr(self.as_ptr_mut());
+ return true;
+ }
+ }
+ ElementState::Released => {
+ let mut clicked = false;
+ if let Some(idx) = clicked_idx {
+ if self.pressed_row == Some(idx) {
+ self.scroll_box.focus();
+ let now = std::time::Instant::now();
+ if let Some(last) = self.last_click_time {
+ if now.duration_since(last) < std::time::Duration::from_millis(400) {
+ self.double_clicked_row = Some(idx);
+ }
+ }
+ self.last_click_time = Some(now);
+ self.clicked_row = Some(idx);
+ clicked = true;
+ }
+ }
+ self.pressed_row = None;
+ return clicked || was_scroll;
+ }
+ }
+ was_scroll
+ }
+
+ fn focus(&mut self) {
+ self.scroll_box.focus();
+ }
+
+ fn unfocus(&mut self) {
+ self.scroll_box.unfocus();
+ }
+
+ fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
+ let mut quads = self.scroll_box.extra_quads();
+ let (x, _, w, _) = self.rect();
+ let item_height_full = self.row_height + self.row_gap;
+
+ let (r1, r2, r3, r4) = self.rounded_corners();
+ if r1 || r2 || r3 || r4 {
+ if !quads.is_empty() {
+ quads.remove(0);
+ }
+ }
+
+ for idx in 0..self.rows.len() {
+ let virtual_y = idx as f32 * item_height_full + 2.0;
+ if let Some(draw_y) = self.scroll_box.get_item_draw_y(virtual_y, self.row_height) {
+ let row = &self.rows[idx];
+ let is_hovered = self.hovered_row == Some(idx);
+ let is_pressed = self.pressed_row == Some(idx);
+
+ let bg_color = if row.selected {
+ if is_pressed { [0.30, 0.52, 0.78, 0.6] }
+ else if is_hovered { [0.30, 0.52, 0.78, 0.5] }
+ else { [0.20, 0.40, 0.65, 0.4] }
+ } else {
+ if is_pressed { [0.20, 0.20, 0.25, 0.25] }
+ else if is_hovered { [0.20, 0.20, 0.25, 0.15] }
+ else { [0.0, 0.0, 0.0, 0.0] }
+ };
+
+ if bg_color[3] > 0.001 {
+ quads.push((x + 2.0, draw_y, w - 4.0, self.row_height, bg_color));
+ }
+ }
+ }
+ quads
+ }
+
+ fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
+ let mut quads = Vec::new();
+ let (r1, r2, r3, r4) = self.rounded_corners();
+ let has_rounded = r1 || r2 || r3 || r4;
+ if !has_rounded {
+ for &child_ptr in &self.children(ctx) {
+ let widget = unsafe { &*child_ptr };
+ quads.extend(widget.all_rounded_quads(ctx));
+ }
+ return quads;
+ }
+
+ let radius = self.corner_radius();
+ let (x, y, w, h) = self.rect();
+
+ if let Some((border_color, thickness)) = self.solid_border() {
+ quads.push((x, y, w, h, radius, border_color, (r1, r2, r3, r4)));
+ quads.push((x + thickness, y + thickness, w - 2.0 * thickness, h - 2.0 * thickness, radius - thickness, crate::color::list_bg_color(), (r1, r2, r3, r4)));
+ } else {
+ quads.push((x, y, w, h, radius, crate::color::list_bg_color(), (r1, r2, r3, r4)));
+ }
+
+ for &child_ptr in &self.children(ctx) {
+ let widget = unsafe { &*child_ptr };
+ quads.extend(widget.all_rounded_quads(ctx));
+ }
+ quads
+ }
+
+ fn text_labels_with_font_and_bounds(&self, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+ if !self.visible() {
+ return Vec::new();
+ }
+ let (x, _, w, _) = self.rect();
+ let mut result = Vec::new();
+
+ let item_height_full = self.row_height + self.row_gap;
+ let col_bounds = self.get_column_bounds(w);
+
+ let fg = [230, 230, 242];
+ let text_dim = [140, 140, 153];
+
+ let view_min = self.scroll_box.viewport_y + 4.0;
+ let view_max = self.scroll_box.viewport_y + self.scroll_box.viewport_h - 4.0;
+ let clipping_bounds = Some([x, view_min, x + w, view_max]);
+
+ for idx in 0..self.rows.len() {
+ let virtual_y = idx as f32 * item_height_full + 2.0;
+ if let Some(draw_y) = self.scroll_box.get_item_draw_y(virtual_y, self.row_height) {
+ let row = &self.rows[idx];
+ let row_fg = if row.selected { fg } else { [178, 178, 191] };
+ let row_dim = if row.selected { fg } else { text_dim };
+
+ let y_text_12 = crate::layout::center_text_y(draw_y, self.row_height, 12.0);
+ let y_text_11 = crate::layout::center_text_y(draw_y, self.row_height, 11.0);
+
+ let mut start_text_offset = 8.0;
+ if let Some(ref icon) = row.icon {
+ if !col_bounds.is_empty() {
+ result.push((
+ TextLabel {
+ text: icon.clone(),
+ x: x + col_bounds[0].0 + 12.0,
+ y: y_text_12,
+ font_size: 12.0,
+ color: row_fg,
+ },
+ None,
+ clipping_bounds,
+ ));
+ start_text_offset = 32.0;
+ }
+ }
+
+ for (c_idx, cell_text) in row.cells.iter().enumerate() {
+ if c_idx >= col_bounds.len() {
+ break;
+ }
+ let (col_x, col_w) = col_bounds[c_idx];
+ if col_w <= 0.0 {
+ continue;
+ }
+
+ let cell_color = if c_idx == 0 { row_fg } else { row_dim };
+ let cell_y = if c_idx == 0 { y_text_12 } else { y_text_11 };
+ let cell_size = if c_idx == 0 { 12.0 } else { 11.0 };
+
+ let cell_draw_x = if c_idx == 0 {
+ x + col_x + start_text_offset
+ } else {
+ x + col_x
+ };
+
+ let max_w = if c_idx == 0 {
+ col_w - start_text_offset - 8.0
+ } else {
+ col_w - 8.0
+ };
+ let char_w = cell_size * 0.65;
+ let max_chars = (max_w / char_w).max(4.0) as usize;
+ let cell_text_truncated = if cell_text.chars().count() > max_chars {
+ let mut s: String = cell_text.chars().take(max_chars - 3).collect();
+ s.push_str("...");
+ s
+ } else {
+ cell_text.clone()
+ };
+
+ result.push((
+ TextLabel {
+ text: cell_text_truncated,
+ x: cell_draw_x,
+ y: cell_y,
+ font_size: cell_size,
+ color: cell_color,
+ },
+ None,
+ clipping_bounds,
+ ));
+ }
+ }
+ }
+ result
+ }
+
+ fn keyboard_input(&mut self, event: &KeyEvent, ctx: &mut UiContext) -> bool {
+ if self.scroll_box.keyboard_input(event, ctx) {
+ return true;
+ }
+ if event.state == ElementState::Pressed {
+ let mut current_selected = None;
+ for (idx, r) in self.rows.iter().enumerate() {
+ if r.selected {
+ current_selected = Some(idx);
+ break;
+ }
+ }
+
+ let mut next_selected = None;
+ if event.logical_key == Key::Named(NamedKey::ArrowDown) {
+ if let Some(curr) = current_selected {
+ if curr + 1 < self.rows.len() {
+ next_selected = Some(curr + 1);
+ }
+ } else if !self.rows.is_empty() {
+ next_selected = Some(0);
+ }
+ } else if event.logical_key == Key::Named(NamedKey::ArrowUp) {
+ if let Some(curr) = current_selected {
+ if curr > 0 {
+ next_selected = Some(curr - 1);
+ }
+ }
+ }
+
+ if let Some(next) = next_selected {
+ for (idx, r) in self.rows.iter_mut().enumerate() {
+ r.selected = idx == next;
+ }
+ self.clicked_row = Some(next);
+
+ let item_height_full = self.row_height + self.row_gap;
+ let item_y = next as f32 * item_height_full + 2.0;
+ let viewport_h = self.scroll_box.viewport_h;
+
+ if item_y < self.scroll_box.scroll_y {
+ self.scroll_box.scroll_y = item_y;
+ } else if item_y + self.row_height > self.scroll_box.scroll_y + viewport_h {
+ self.scroll_box.scroll_y = item_y + self.row_height - viewport_h;
+ }
+
+ self.mark_dirty(ctx);
+ return true;
+ }
+ }
+ false
+ }
+
+ fn parent(&self, _ctx: &UiContext) -> Option<*mut (dyn Element + 'static)> { self.scroll_box.parent(_ctx) }
+ fn set_parent(&mut self, parent: Option<*mut (dyn Element + 'static)>, ctx: &mut UiContext) { self.scroll_box.set_parent(parent, ctx); }
+ fn children(&self, ctx: &UiContext) -> Vec<*mut (dyn Element + 'static)> { self.scroll_box.children(ctx) }
+ fn add_child(&mut self, child: *mut (dyn Element + 'static), ctx: &mut UiContext) { self.scroll_box.add_child(child, ctx); }
+ fn clear_children(&mut self, ctx: &mut UiContext) { self.scroll_box.clear_children(ctx); }
+}
+
+unsafe impl Send for ColumnarList {}
+unsafe impl Sync for ColumnarList {}
diff --git a/src/widget/container/mod.rs b/src/widget/container/mod.rs
index 5fdf72e..1d8cb55 100644
--- a/src/widget/container/mod.rs
+++ b/src/widget/container/mod.rs
@@ -9,6 +9,7 @@ pub mod breadcrumb;
pub mod spreadsheet;
pub mod scroll_box;
pub mod scrolling_list;
+pub mod columnar_list;
pub mod plate;
pub mod switcher;
pub mod layer;
@@ -33,6 +34,7 @@ pub use breadcrumb::Breadcrumb;
pub use spreadsheet::Spreadsheet;
pub use scroll_box::ScrollBox;
pub use scrolling_list::{List, FontSelector};
+pub use columnar_list::{ColumnarList, ListColumn, ListRow, ColumnWidth};
pub use plate::Plate;
pub use switcher::Switcher;
pub use layer::Layer;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 2e8e43e..f102f18 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -523,9 +523,50 @@ pub trait Element {
self.text_labels().into_iter().map(|l| (l, None)).collect()
}
- fn text_labels_with_font_and_bounds(&self, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
+ fn text_labels_with_font_and_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
let font = self.widget_font();
- self.text_labels().into_iter().map(|l| (l, font.clone(), None)).collect()
+ let mut labels = self.text_labels().into_iter().map(|l| (l, font.clone(), None::<[f32; 4]>)).collect::<Vec<_>>();
+
+ let mut curr = self.parent(ctx);
+ let mut scroll_box_bounds = None;
+ while let Some(parent_ptr) = curr {
+ let parent = unsafe { &*parent_ptr };
+ if let Some(scroll_box) = parent.as_any().downcast_ref::<crate::widget::ScrollBox>() {
+ let (sb_x, _, sb_w, _) = scroll_box.rect();
+ let view_min = scroll_box.viewport_y + 4.0;
+ let view_max = scroll_box.viewport_y + scroll_box.viewport_h - 4.0;
+ scroll_box_bounds = Some([sb_x, view_min, sb_x + sb_w, view_max]);
+ break;
+ } else if let Some(list) = parent.as_any().downcast_ref::<crate::widget::List>() {
+ let (sb_x, _, sb_w, _) = list.rect();
+ let view_min = list.scroll_box.viewport_y + 4.0;
+ let view_max = list.scroll_box.viewport_y + list.scroll_box.viewport_h - 4.0;
+ scroll_box_bounds = Some([sb_x, view_min, sb_x + sb_w, view_max]);
+ break;
+ } else if let Some(col_list) = parent.as_any().downcast_ref::<crate::widget::ColumnarList>() {
+ let (sb_x, _, sb_w, _) = col_list.rect();
+ let view_min = col_list.scroll_box.viewport_y + 4.0;
+ let view_max = col_list.scroll_box.viewport_y + col_list.scroll_box.viewport_h - 4.0;
+ scroll_box_bounds = Some([sb_x, view_min, sb_x + sb_w, view_max]);
+ break;
+ }
+ curr = parent.parent(ctx);
+ }
+
+ if let Some(sb_bounds) = scroll_box_bounds {
+ for item in &mut labels {
+ if let Some(ref mut b) = item.2 {
+ b[0] = b[0].max(sb_bounds[0]);
+ b[1] = b[1].max(sb_bounds[1]);
+ b[2] = b[2].min(sb_bounds[2]);
+ b[3] = b[3].min(sb_bounds[3]);
+ } else {
+ item.2 = Some(sb_bounds);
+ }
+ }
+ }
+
+ labels
}
fn widget_font(&self) -> Option<String> { None }
@@ -729,7 +770,7 @@ pub use self::input::{
pub use self::container::{
Container, ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,
ColumnsLayout, MosaicLayout, ReverseMosaicLayout,
- SectionContainer, Header, ContentBg, ParametersBg, List,
+ SectionContainer, Header, ContentBg, ParametersBg, List, ColumnarList, ListColumn, ListRow, ColumnWidth,
ScrollBox, Menu, MenuBar, Spreadsheet, Breadcrumb, Plate,
Switcher, Layer, Page, Backplate, Paginator, ScrollBar, TreeList, TreeElement, ControlPanel
};