slint-ui-system 0.5.0

Neon Design System — Slint UI components for Rust desktop apps. 35+ components, dark/light theme, neon accents.
// ═══════════════════════════════════════════════════════════════════
//  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;
                }
            }
        }
    }
}