// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Component style descriptor for data table controls.
pub struct TableStyle {
header_h : Double
row_h : Double
radius : Double
} derive(Eq, Debug)
///|
pub fn TableStyle::default() -> TableStyle {
{ header_h: 32.0, row_h: 30.0, radius: 6.0, }
}
///|
/// Sort direction indicator for table columns.
pub(all) enum TableSortDirection {
Ascending
Descending
NoSort
} derive(Eq, Debug)
///|
/// Column definition for data table.
pub struct TableColumn {
id : String
title : String
mut width : Double
min_width : Double
sortable : Bool
} derive(Eq, Debug)
///|
/// Creates a new table column descriptor.
pub fn TableColumn::new(
id : String,
title : String,
width : Double,
min_width? : Double = 50.0,
sortable? : Bool = true,
) -> TableColumn {
{ id, title, width, min_width, sortable, }
}
///|
/// Interactive feedback response from table widget.
pub struct TableResponse {
sort_changed : Bool
sort_column : String
sort_direction : TableSortDirection
hovered_row : Int?
clicked_row : Int?
} derive(Eq, Debug)
///|
/// Standalone virtualized data table widget supporting fluent configuration.
pub struct Table {
id_salt : String
size : @math.Vec2
columns : Array[TableColumn]
row_count : Int
render_cell : (UIContext, Int, Int, @math.Rect) -> Unit
row_height : Double
header_height : Double
selected_row : Int
sort_column : String
sort_direction : TableSortDirection
}
///|
/// Create a new virtualized data table builder.
pub fn Table::new(
id_salt : String,
size : @math.Vec2,
columns : Array[TableColumn],
row_count : Int,
render_cell : (UIContext, Int, Int, @math.Rect) -> Unit,
) -> Table {
{
id_salt,
size,
columns,
row_count,
render_cell,
row_height: 0.0,
header_height: 0.0,
selected_row: -1,
sort_column: "",
sort_direction: NoSort,
}
}
///|
/// Set row height in pixels.
pub fn Table::row_height(self : Table, row_height : Double) -> Table {
{ ..self, row_height, }
}
///|
/// Set header height in pixels.
pub fn Table::header_height(self : Table, header_height : Double) -> Table {
{ ..self, header_height, }
}
///|
/// Set index of currently selected row.
pub fn Table::selected_row(self : Table, selected_row : Int) -> Table {
{ ..self, selected_row, }
}
///|
/// Set column identifier used for active sorting.
pub fn Table::sort_column(self : Table, sort_column : String) -> Table {
{ ..self, sort_column, }
}
///|
/// Set sort direction (Ascending, Descending, NoSort).
pub fn Table::sort_direction(
self : Table,
sort_direction : TableSortDirection,
) -> Table {
{ ..self, sort_direction, }
}
///|
/// Render the table into the provided UIContext.
pub fn Table::show(self : Table, ctx : UIContext) -> TableResponse {
table(
ctx,
self.id_salt,
self.size,
self.columns,
self.row_count,
self.render_cell,
row_height=self.row_height,
header_height=self.header_height,
selected_row=self.selected_row,
sort_column=self.sort_column,
sort_direction=self.sort_direction,
)
}
///|
/// Render a virtualized data table with resizable column headers, sorting,
/// alternating row stripes, row selection, and smooth vertical scrolling.
pub fn table(
ctx : UIContext,
id_salt : String,
size : @math.Vec2,
columns : Array[TableColumn],
row_count : Int,
render_cell : (UIContext, Int, Int, @math.Rect) -> Unit,
row_height? : Double = 0.0,
header_height? : Double = 0.0,
selected_row? : Int = -1,
sort_column? : String = "",
sort_direction? : TableSortDirection = NoSort,
) -> TableResponse {
let scale = ctx.style.scale
let t_style = TableStyle::default()
let eff_row_h = if row_height > 0.0 {
row_height
} else {
t_style.row_h * scale
}
let eff_header_h = if header_height > 0.0 {
header_height
} else {
t_style.header_h * scale
}
let table_radius = t_style.radius * scale
let container_id = ctx.id_stack.derive_id(id_salt)
ctx.push_id(id_salt)
let (table_id, table_rect, resp) = ctx.allocate_space(size)
ctx.pop_id()
ctx.register_focusable(table_id)
let is_focused = resp.has_focus() ||
ctx.has_focus(table_id) ||
ctx.has_focus(container_id)
let mut active_sort_col = sort_column
let mut active_sort_dir = sort_direction
let mut sort_changed = false
// 1. Render Table Background Card
ctx.painter().add_rect(table_rect, @color.Color::bg_surface(), table_radius)
ctx
.painter()
.add_rect_stroke(
table_rect,
@color.Color::border_default(),
1.0,
table_radius,
)
let header_rect = @math.Rect::new(
table_rect.x,
table_rect.y,
table_rect.w,
eff_header_h,
)
// Header background fill
ctx.painter().add_rect(header_rect, @color.Color::bg_subtle(), table_radius)
// Square off bottom corners of header
let header_bottom_fill = @math.Rect::new(
table_rect.x,
table_rect.y + eff_header_h - table_radius,
table_rect.w,
table_radius,
)
ctx.painter().add_rect(header_bottom_fill, @color.Color::bg_subtle(), 0.0)
// Bottom border line for header
ctx
.painter()
.add_line(
@math.Vec2::new(table_rect.x, table_rect.y + eff_header_h),
@math.Vec2::new(table_rect.x + table_rect.w, table_rect.y + eff_header_h),
@color.Color::border_default(),
1.0,
)
// 2. Interactive Column Headers (with horizontal scroll support)
let mut total_cols_w = 0.0
for col in columns {
total_cols_w = total_cols_w + col.width
}
let max_scroll_x = if total_cols_w > table_rect.w {
total_cols_w - table_rect.w
} else {
0.0
}
let mut scroll_x = ctx
.memory()
.get_double_or_default(container_id, 0.0, tag="scroll_x")
if ctx.is_hovered(table_rect) {
if ctx.input.modifiers.shift && ctx.input.scroll_delta.y != 0.0 {
scroll_x = scroll_x - ctx.input.scroll_delta.y
ctx.set_wants_capture_mouse(true)
} else if ctx.input.scroll_delta.x != 0.0 {
scroll_x = scroll_x - ctx.input.scroll_delta.x
ctx.set_wants_capture_mouse(true)
}
}
if scroll_x < 0.0 {
scroll_x = 0.0
}
if scroll_x > max_scroll_x {
scroll_x = max_scroll_x
}
ctx.memory().set_double(container_id, scroll_x, tag="scroll_x")
// Keyboard horizontal scrolling when table is focused
if is_focused && max_scroll_x > 0.0 {
if ctx.input.key_pressed(Key::ArrowLeft) {
scroll_x = scroll_x - 30.0 * scale
if scroll_x < 0.0 {
scroll_x = 0.0
}
ctx.memory().set_double(container_id, scroll_x, tag="scroll_x")
ctx.input.consume_key(Key::ArrowLeft)
}
if ctx.input.key_pressed(Key::ArrowRight) {
scroll_x = scroll_x + 30.0 * scale
if scroll_x > max_scroll_x {
scroll_x = max_scroll_x
}
ctx.memory().set_double(container_id, scroll_x, tag="scroll_x")
ctx.input.consume_key(Key::ArrowRight)
}
}
// Focus ring around table card
if is_focused {
ctx
.painter()
.add_rect_stroke(
table_rect.expand(2.0 * scale),
@color.Color::border_focus(),
1.5,
table_radius + 2.0 * scale,
)
}
ctx.push_clip(header_rect)
let mut current_x = table_rect.x - scroll_x
let total_cols = columns.length()
for c = 0; c < total_cols; c = c + 1 {
let col = columns[c]
let col_w = col.width
let col_header_rect = @math.Rect::new(
current_x,
table_rect.y,
col_w,
eff_header_h,
)
// Resize divider handle at right edge
let handle_w = 8.0 * scale
let handle_rect = @math.Rect::new(
current_x + col_w - handle_w * 0.5,
table_rect.y,
handle_w,
eff_header_h,
)
let handle_id = ctx.id_stack.derive_id(
"table_col_handle::" + id_salt + "::" + col.id,
)
let handle_hovered = ctx.is_hovered(handle_rect)
if handle_hovered {
ctx.set_cursor_icon("col-resize")
}
if handle_hovered && ctx.input.mouse_pressed {
ctx.set_active_id(handle_id)
}
if ctx.active_id() == handle_id {
ctx.set_cursor_icon("col-resize")
ctx.set_wants_capture_mouse(true)
if ctx.input.mouse_down {
let mouse_delta = ctx.input.mouse_pos.x - current_x
let clamped_w = if mouse_delta < col.min_width {
col.min_width
} else {
mouse_delta
}
col.width = clamped_w
}
if ctx.input.mouse_released {
ctx.set_active_id(Id::zero())
}
}
// Header click for sorting (when not interacting with resize handle)
let is_header_hovered = ctx.is_hovered(col_header_rect) &&
!handle_hovered &&
(ctx.active_id() == Id::zero() || ctx.active_id() == table_id)
if is_header_hovered && col.sortable {
ctx.set_cursor_icon("pointer")
if ctx.input.mouse_pressed {
if active_sort_col == col.id {
active_sort_dir = match active_sort_dir {
Ascending => Descending
Descending => Ascending
NoSort => Ascending
}
} else {
active_sort_col = col.id
active_sort_dir = Ascending
}
sort_changed = true
}
}
// Header title text
let font_sz = ctx.style.font_small * scale
let title_pos = @math.Vec2::new(
current_x + 10.0 * scale,
table_rect.y + (eff_header_h - font_sz) * 0.5,
)
let title_color = if active_sort_col == col.id {
@color.Color::accent_primary()
} else if is_header_hovered {
@color.Color::text_strong()
} else {
@color.Color::text_secondary()
}
ctx.painter().add_text(title_pos, col.title, font_sz, title_color)
// Sort indicator arrow
if active_sort_col == col.id {
let arrow_str = match active_sort_dir {
Ascending => "^"
Descending => "v"
NoSort => ""
}
if arrow_str != "" {
let arrow_x = current_x + col.width - 16.0 * scale
let arrow_pos = @math.Vec2::new(arrow_x, title_pos.y)
ctx
.painter()
.add_text(arrow_pos, arrow_str, font_sz, @color.Color::accent_primary())
}
}
// Vertical column divider line
if c < total_cols - 1 {
let divider_x = current_x + col.width
ctx
.painter()
.add_line(
@math.Vec2::new(divider_x, table_rect.y + 6.0 * scale),
@math.Vec2::new(divider_x, table_rect.y + eff_header_h - 6.0 * scale),
@color.Color::border_muted(),
1.0,
)
}
current_x = current_x + col.width
}
ctx.pop_clip()
// 3. Virtual Data Body
let body_y = table_rect.y + eff_header_h
let body_h = table_rect.h - eff_header_h
let body_rect = @math.Rect::new(table_rect.x, body_y, table_rect.w, body_h)
let body_container_id = ctx.id_stack.derive_id(id_salt + "::table_body")
let mut scroll_y = ctx.get_scroll_offset(body_container_id)
let total_content_h = row_count.to_double() * eff_row_h
let max_scroll = if total_content_h > body_h {
total_content_h - body_h
} else {
0.0
}
// Scroll wheel handling
if ctx.is_hovered(body_rect) && ctx.input.scroll_delta.y != 0.0 {
scroll_y = scroll_y - ctx.input.scroll_delta.y
ctx.set_wants_capture_mouse(true)
}
if scroll_y < 0.0 {
scroll_y = 0.0
}
if scroll_y > max_scroll {
scroll_y = max_scroll
}
ctx.set_scroll_offset(body_container_id, scroll_y)
ctx.set_scroll_content_height(body_container_id, total_content_h)
let mut hovered_row : Int? = None
let mut clicked_row : Int? = None
// Keyboard row navigation while the table holds focus: arrows move the
// highlighted row (kept inside the visible window), Enter activates it.
let mut kb_row = -1
if is_focused && row_count > 0 {
let mut cur = ctx.memory().get_int_or_default(container_id, 0, tag="kb_row")
if cur < 0 {
cur = 0
}
if cur >= row_count {
cur = row_count - 1
}
if ctx.input.key_pressed(Key::ArrowDown) {
cur = if cur + 1 < row_count { cur + 1 } else { cur }
ctx.input.consume_key(Key::ArrowDown)
}
if ctx.input.key_pressed(Key::ArrowUp) {
cur = if cur - 1 >= 0 { cur - 1 } else { cur }
ctx.input.consume_key(Key::ArrowUp)
}
let row_top = cur.to_double() * eff_row_h
if row_top < scroll_y {
scroll_y = row_top
} else if row_top + eff_row_h > scroll_y + body_h {
scroll_y = row_top + eff_row_h - body_h
}
if scroll_y < 0.0 {
scroll_y = 0.0
}
if scroll_y > max_scroll {
scroll_y = max_scroll
}
ctx.set_scroll_offset(body_container_id, scroll_y)
ctx.memory().set_int(container_id, cur, tag="kb_row")
kb_row = cur
if ctx.input.key_pressed(Key::Enter) {
ctx.input.consume_key(Key::Enter)
clicked_row = Some(cur)
}
}
// Calculate virtual row window
let start_row_raw = (scroll_y / eff_row_h).to_int()
let start_row = if start_row_raw < 0 {
0
} else if start_row_raw >= row_count {
row_count
} else {
start_row_raw
}
let visible_count = (body_h / eff_row_h).to_int() + 2
let end_row = if start_row + visible_count > row_count {
row_count
} else {
start_row + visible_count
}
ctx.push_clip(body_rect)
for r = start_row; r < end_row; r = r + 1 {
let row_y = body_y - scroll_y + r.to_double() * eff_row_h
let row_rect = @math.Rect::new(body_rect.x, row_y, body_rect.w, eff_row_h)
let is_row_hovered = ctx.is_hovered(body_rect) && ctx.is_hovered(row_rect)
if is_row_hovered {
hovered_row = Some(r)
if ctx.input.mouse_pressed &&
(ctx.active_id() == Id::zero() || ctx.active_id() == table_id) {
clicked_row = Some(r)
}
}
// Row background styling
let is_kb_row = is_focused && r == kb_row
if r == selected_row {
ctx.painter().add_rect(row_rect, @color.Color::table_row_selected(), 0.0)
} else if is_kb_row || is_row_hovered {
ctx.painter().add_rect(row_rect, @color.Color::bg_hover(), 0.0)
} else if r % 2 == 1 {
ctx.painter().add_rect(row_rect, @color.Color::bg_subtle(), 0.0)
}
if is_kb_row {
ctx
.painter()
.add_rect_stroke(row_rect, @color.Color::border_focus(), 1.0, 0.0)
}
// Row bottom separator line
ctx
.painter()
.add_line(
@math.Vec2::new(row_rect.x, row_rect.y + eff_row_h),
@math.Vec2::new(row_rect.x + row_rect.w, row_rect.y + eff_row_h),
@color.Color::border_muted(),
1.0,
)
// Render cells for this row
let mut cell_x = body_rect.x - scroll_x
for c = 0; c < total_cols; c = c + 1 {
let col = columns[c]
let cell_rect = @math.Rect::new(cell_x, row_y, col.width, eff_row_h)
let prev_cursor = ctx.cursor()
let prev_avail = ctx.available_width()
ctx.set_cursor(@math.Vec2::new(cell_rect.x, cell_rect.y))
ctx.set_available_width(cell_rect.w)
render_cell(ctx, r, c, cell_rect)
ctx.set_cursor(prev_cursor)
ctx.set_available_width(prev_avail)
cell_x = cell_x + col.width
}
}
ctx.pop_clip()
// 4. Scrollbar indicators & interactions
let v_thumb_id = ctx.id_stack.derive_id(id_salt + "::table_v_thumb")
let h_thumb_id = ctx.id_stack.derive_id(id_salt + "::table_h_thumb")
// Vertical Scrollbar
if max_scroll > 0.0 && total_content_h > 0.0 {
let scrollbar_w = 6.0 * scale
let bar_x = body_rect.x + body_rect.w - scrollbar_w - 3.0 * scale
let bar_track_y = body_rect.y + 2.0 * scale
let bar_track_h = body_rect.h -
4.0 * scale -
(if max_scroll_x > 0.0 { 8.0 * scale } else { 0.0 })
let ratio = body_rect.h / total_content_h
let raw_thumb_h = body_rect.h * ratio
let min_thumb_h = 24.0 * scale
let thumb_h = if raw_thumb_h < min_thumb_h {
min_thumb_h
} else if raw_thumb_h > bar_track_h {
bar_track_h
} else {
raw_thumb_h
}
let travel_h = bar_track_h - thumb_h
if ctx.input.mouse_pressed {
let scroll_pct = if max_scroll > 0.0001 {
scroll_y / max_scroll
} else {
0.0
}
let cur_thumb_y = bar_track_y + scroll_pct * travel_h
let thumb_rect = @math.Rect::new(bar_x, cur_thumb_y, scrollbar_w, thumb_h)
if ctx.is_hovered(thumb_rect) {
ctx.set_active_id(v_thumb_id)
let grab_offset = ctx.input.mouse_pos.y - cur_thumb_y
ctx.memory().set_double(container_id, grab_offset, tag="v_grab_offset")
}
}
if ctx.active_id() == v_thumb_id {
ctx.set_cursor_icon("pointer")
ctx.set_wants_capture_mouse(true)
let grab_offset = ctx
.memory()
.get_double_or_default(container_id, thumb_h * 0.5, tag="v_grab_offset")
let target_thumb_y = ctx.input.mouse_pos.y - grab_offset
let new_pct = if travel_h > 0.0 {
(target_thumb_y - bar_track_y) / travel_h
} else {
0.0
}
let clamped_pct = if new_pct < 0.0 {
0.0
} else if new_pct > 1.0 {
1.0
} else {
new_pct
}
scroll_y = clamped_pct * max_scroll
ctx.set_scroll_offset(body_container_id, scroll_y)
if ctx.input.mouse_released {
ctx.set_active_id(Id::zero())
}
}
let scroll_pct = if max_scroll > 0.0001 {
scroll_y / max_scroll
} else {
0.0
}
let thumb_y = bar_track_y + scroll_pct * travel_h
let thumb_rect = @math.Rect::new(bar_x, thumb_y, scrollbar_w, thumb_h)
let is_bar_hovered = ctx.is_hovered(thumb_rect) ||
ctx.active_id() == v_thumb_id
if is_bar_hovered {
ctx.set_cursor_icon("pointer")
}
let thumb_color = if is_bar_hovered {
@color.Color::accent_primary()
} else {
@color.Color::border_muted()
}
ctx.painter().add_rect(thumb_rect, thumb_color, 3.0 * scale)
}
// Horizontal Scrollbar
if max_scroll_x > 0.0 && total_cols_w > 0.0 {
let scrollbar_h = 6.0 * scale
let bar_y = body_rect.y + body_rect.h - scrollbar_h - 2.0 * scale
let bar_track_x = body_rect.x + 2.0 * scale
let bar_track_w = body_rect.w -
4.0 * scale -
(if max_scroll > 0.0 { 8.0 * scale } else { 0.0 })
let ratio = body_rect.w / total_cols_w
let raw_thumb_w = body_rect.w * ratio
let min_thumb_w = 24.0 * scale
let thumb_w = if raw_thumb_w < min_thumb_w {
min_thumb_w
} else if raw_thumb_w > bar_track_w {
bar_track_w
} else {
raw_thumb_w
}
let travel_w = bar_track_w - thumb_w
if ctx.input.mouse_pressed {
let scroll_pct_x = if max_scroll_x > 0.0001 {
scroll_x / max_scroll_x
} else {
0.0
}
let cur_thumb_x = bar_track_x + scroll_pct_x * travel_w
let thumb_rect_x = @math.Rect::new(
cur_thumb_x, bar_y, thumb_w, scrollbar_h,
)
if ctx.is_hovered(thumb_rect_x) {
ctx.set_active_id(h_thumb_id)
let grab_offset = ctx.input.mouse_pos.x - cur_thumb_x
ctx.memory().set_double(container_id, grab_offset, tag="h_grab_offset")
}
}
if ctx.active_id() == h_thumb_id {
ctx.set_cursor_icon("pointer")
ctx.set_wants_capture_mouse(true)
let grab_offset = ctx
.memory()
.get_double_or_default(container_id, thumb_w * 0.5, tag="h_grab_offset")
let target_thumb_x = ctx.input.mouse_pos.x - grab_offset
let new_pct = if travel_w > 0.0 {
(target_thumb_x - bar_track_x) / travel_w
} else {
0.0
}
let clamped_pct = if new_pct < 0.0 {
0.0
} else if new_pct > 1.0 {
1.0
} else {
new_pct
}
scroll_x = clamped_pct * max_scroll_x
ctx.memory().set_double(container_id, scroll_x, tag="scroll_x")
if ctx.input.mouse_released {
ctx.set_active_id(Id::zero())
}
}
let scroll_pct_x = if max_scroll_x > 0.0001 {
scroll_x / max_scroll_x
} else {
0.0
}
let thumb_x = bar_track_x + scroll_pct_x * travel_w
let thumb_rect_x = @math.Rect::new(thumb_x, bar_y, thumb_w, scrollbar_h)
let is_bar_hovered = ctx.is_hovered(thumb_rect_x) ||
ctx.active_id() == h_thumb_id
if is_bar_hovered {
ctx.set_cursor_icon("pointer")
}
let thumb_color = if is_bar_hovered {
@color.Color::accent_primary()
} else {
@color.Color::border_muted()
}
ctx.painter().add_rect(thumb_rect_x, thumb_color, 3.0 * scale)
}
{
sort_changed,
sort_column: active_sort_col,
sort_direction: active_sort_dir,
hovered_row,
clicked_row,
}
}