// ═══════════════════════════════════════════════════════════════════
// NeonVirtualTable — ListView-based virtualized data table
// ═══════════════════════════════════════════════════════════════════
//
// Renders only the rows currently visible in the viewport, so a
// 10k-row dataset is as cheap to scroll as 50 rows. Drop-in
// replacement for `NeonDataTable` for any dataset bigger than ~200
// rows.
//
// API mirrors NeonDataTable so migration is a rename:
//
// NeonDataTable → NeonVirtualTable
// rows: [RowEntry] → rows: [RowEntry] (same struct)
// columns: [string] → columns: [string] (same)
// selected-row: int → selected-row: int (same)
// sort-column / sort-asc → sort-column / sort-asc (same — host applies the sort, panel just renders the indicator)
// header-clicked / row-clicked → identical
//
// Extra capabilities vs NeonDataTable:
// • `row-height` — fixed; ListView uses it for fast vertical scroll math.
// • `row-double-clicked` — separate signal for inline edit / drill-down.
// • `column-widths` — optional; when given (and length matches),
// rows + header pin to those widths instead of equal stretch.
// • Empty state hint.
// • Sticky header (always on; ListView naturally clips).
//
// Why not just patch NeonDataTable in place:
// Switching VerticalLayout → ListView is a behavioural change
// (scrollbar, virtualization). Keeping the old non-virtualized
// one available avoids breaking layouts that depend on the old
// "renders everything stacked vertically" shape.
//
// ═══════════════════════════════════════════════════════════════════
import { ListView } from "std-widgets.slint";
import { Theme } from "../theme.slint";
import { RowEntry } from "list.slint";
export component NeonVirtualTable inherits Rectangle {
in property <[string]> columns;
in property <[RowEntry]> rows;
/// Optional explicit per-column widths. Empty array ⇒ each
/// column stretches equally (default). Length must equal
/// `columns.length` to take effect; otherwise we fall back to
/// equal stretch.
in property <[length]> column-widths;
in-out property <int> selected-row: -1;
in property <int> sort-column: -1;
in property <bool> sort-asc: true;
/// Fixed row height — ListView uses this to compute scroll
/// indicators. Tune to match the typography scale.
in property <length> row-height: 32px;
/// Header height. Slightly taller than rows for visual weight.
in property <length> header-height: 36px;
/// Empty-state title shown when `rows` is empty.
in property <string> empty-title: "Sin datos";
/// Empty-state hint — second line under the title.
in property <string> empty-hint: "Filtrá o cargá registros para ver la tabla.";
callback header-clicked(int);
callback row-clicked(int);
callback row-double-clicked(int);
property <bool> use-explicit-widths: root.column-widths.length == root.columns.length
&& root.columns.length > 0;
background: Theme.bg-base;
VerticalLayout {
spacing: 0;
padding: 0;
// ── Sticky header row ────────────────────────────────────
Rectangle {
height: root.header-height;
background: Theme.bg-elevated;
// Bottom border separator.
Rectangle {
height: 1px;
width: 100%;
background: Theme.border;
y: parent.height - 1px;
}
HorizontalLayout {
spacing: 0;
for col[idx] in root.columns: Rectangle {
width: root.use-explicit-widths
? root.column-widths[idx]
: (parent.width / root.columns.length);
HorizontalLayout {
padding-left: Theme.sp-2;
padding-right: Theme.sp-2;
spacing: Theme.sp-1;
alignment: start;
Text {
text: col;
font-size: Theme.font-sm;
font-weight: 600;
color: idx == root.sort-column
? Theme.neon-cyan
: Theme.text-primary;
overflow: elide;
horizontal-stretch: 1;
vertical-alignment: center;
}
if (idx == root.sort-column): Text {
text: root.sort-asc ? "▲" : "▼";
font-size: 10px;
color: Theme.neon-cyan;
vertical-alignment: center;
}
}
TouchArea {
clicked => { root.header-clicked(idx); }
mouse-cursor: MouseCursor.pointer;
accessible-role: button;
accessible-label: "Ordenar por " + col;
}
}
}
}
// ── Body ─────────────────────────────────────────────────
if (root.rows.length == 0): Rectangle {
vertical-stretch: 1;
background: Theme.bg-base;
VerticalLayout {
padding: Theme.sp-8;
spacing: Theme.sp-2;
alignment: center;
Text {
text: root.empty-title;
font-size: Theme.font-lg;
font-weight: 600;
color: Theme.text-muted;
horizontal-alignment: center;
}
Text {
text: root.empty-hint;
font-size: Theme.font-sm;
color: Theme.text-dim;
horizontal-alignment: center;
wrap: word-wrap;
}
}
}
if (root.rows.length > 0): ListView {
vertical-stretch: 1;
for data[row-idx] in root.rows: row-rect := Rectangle {
height: root.row-height;
background: row-idx == root.selected-row
? Theme.bg-selected
: (row-touch.has-hover
? Theme.bg-hover
: (mod(row-idx, 2) == 0 ? transparent : Theme.bg-row-alt));
// Bottom-edge divider — 1 px hairline.
Rectangle {
height: 1px;
width: 100%;
background: Theme.border;
opacity: 0.4;
y: parent.height - 1px;
}
HorizontalLayout {
spacing: 0;
for cell[c-idx] in data.cells: Rectangle {
width: root.use-explicit-widths
? root.column-widths[c-idx]
: (parent.width / root.columns.length);
HorizontalLayout {
padding-left: Theme.sp-2;
padding-right: Theme.sp-2;
Text {
text: cell;
font-size: Theme.font-sm;
color: row-idx == root.selected-row
? Theme.neon-cyan
: Theme.text-primary;
overflow: elide;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
}
}
row-touch := TouchArea {
clicked => {
root.selected-row = row-idx;
root.row-clicked(row-idx);
}
double-clicked => {
root.selected-row = row-idx;
root.row-double-clicked(row-idx);
}
mouse-cursor: MouseCursor.pointer;
}
}
}
}
}