ckVision

Provider-backed data views

ListView, TreeView, and Table can display compact materialized values, but their scalable interface is a caller-owned provider. The widget never copies a large result set and never treats a display offset as persistent identity.

List providers

Implement ListModel for the application's ordered view. item_at() is called only for visible rows; index_of() is the required reverse lookup that lets the view keep its cursor and selections through sorting, filtering, paging, or a refresh. ListItemId is opaque to ckVision and must be non-zero and stable for as long as the underlying item exists.

class SearchResults final : public widgets::ListModel {
public:
    std::size_t item_count() const override;
    widgets::ListItem item_at(std::size_t display_index) const override;
    std::optional<std::size_t> index_of(widgets::ListItemId id) const override;
    std::optional<std::size_t> find_prefix(std::string_view folded, std::size_t after) const override;
};

SearchResults results;
widgets::ListView list;
list.set_model(results);                 // results outlives list
list.on_activate_id = [](widgets::ListItemId id) { /* open result */ };

When the application changes its result ordering or cache, it does so on the UI thread and then calls list.model_changed(). A surviving id remains the cursor/selection. A removed id is cleared. A background worker updates the model only through Application::post(); providers never call a view directly from a worker.

set_items() remains a compact convenience for small static menus and lists. It uses deterministic internal ids, but applications that need stable identity across a refresh should use ListModel.

Tree providers

TreeModel represents a stable hierarchy without materializing a second tree inside the widget. It supplies root, parent, child, and reverse-index lookups; TreeView owns expansion, cursor, and selection by TreeItemId. This lets a model preserve application identity across refreshes while the view resolves only the expanded path needed for a visible row.

class DirectoryTree final : public widgets::TreeModel {
public:
    std::size_t root_count() const override;
    widgets::TreeItemId root_id_at(std::size_t root_index) const override;
    std::optional<std::size_t> root_index_of(widgets::TreeItemId id) const override;
    std::optional<widgets::TreeItemId> parent_id_of(widgets::TreeItemId id) const override;
    std::size_t child_count(widgets::TreeItemId parent) const override;
    widgets::TreeItemId child_id_at(widgets::TreeItemId parent, std::size_t child_index) const override;
    std::optional<std::size_t> child_index_of(widgets::TreeItemId parent,
                                               widgets::TreeItemId child) const override;
    std::optional<widgets::TreeItem> item(widgets::TreeItemId id) const override;
};

DirectoryTree directories;
widgets::TreeView tree;
tree.set_model(directories);            // directories outlives tree
tree.on_selection_changed_id = [](widgets::TreeItemId id) { /* show detail */ };

Every id is non-zero, unique, and stable while its item exists. Calling tree.reveal_and_select(id) opens the required ancestors without synthesizing input. After a model changes, call tree.model_changed() on the UI thread: a surviving selected or expanded id remains in place; a removed id is cleared. set_item_expanded() sets view-owned expansion for a known item. An item with children_known == false exposes an expander and emits on_expand_request_id once; the application performs any work, publishes its new hierarchy, and calls model_changed().

The model is queried only for visible item content plus the small chain of root/parent/child-index lookups needed to resolve state. A provider can page or cache its own backing data, but it must not start work, access services, or call a view from another thread. set_roots() remains the simple API for small, static TreeNode value trees.

Table providers

TableModel separates application data from presentation. Its rows have stable TableRowIds; a TableCellRef combines that id with a column index. Cells carry a portable typed CellValue, an optional display override, an optional style, and an editability flag. A column declares the expected edit type. The framework performs canonical parsing for text, Boolean, integer, and real values, while the provider owns business validation and persistence.

class Orders final : public widgets::TableModel {
public:
    std::size_t row_count() const override;
    widgets::TableRowId row_id_at(std::size_t display_index) const override;
    std::optional<std::size_t> index_of(widgets::TableRowId id) const override;
    widgets::TableCell cell(widgets::TableCellRef ref) const override;
    void request_sort(std::optional<std::size_t> column, bool ascending) override;
    widgets::TableEditResult commit(widgets::TableCellRef ref,
                                    const widgets::CellValue& value) override;
};

Orders orders;
widgets::Table table;
table.set_columns({{"Quantity", 10, 4, widgets::TableCellType::Integer, true}});
table.set_model(orders);

F2, Enter, or typing starts an edit for an editable selected cell. Enter commits it and Esc cancels it. A rejected provider commit leaves the editor open and exposes its diagnostic through edit_diagnostic(). Header selection requests sorting from the provider; the table does not sort a large model by formatted strings. Once the provider publishes the changed order, call table.model_changed().

The compact set_rows() API remains for static string tables. It is not the right choice for paged or refreshable data.

Cell grid providers

CellGrid shows a two-dimensional surface of cells — a worksheet, a query result, a matrix — under a column header and beside a row gutter, with a cursor, a rectangular selection, frozen leading bands and merged spans. Its CellGridModel differs from the three providers above in one respect, and deliberately (D-067): it owns the cursor, the selection and the scroll origin, and the widget asks it to move them. In a grid of cells those are what the application's commands act on, and the rules for moving them need the application's knowledge — where a block of data ends, which rows are hidden, how a frozen band scrolls — so a second copy in the widget would have to be reconciled with the first on every key.

class Worksheet final : public widgets::CellGridModel {
public:
    void set_viewport(int rows, int width) override;   // the room the grid has
    widgets::GridFrame frame() const override;         // what it shows, frozen bands first
    int column_width(widgets::GridIndex column) const override;
    int row_header_width() const override;
    std::string row_label(widgets::GridIndex row) const override;
    std::string column_label(widgets::GridIndex column) const override;
    widgets::GridCell cell(widgets::GridIndex row, widgets::GridIndex column) const override;
    widgets::GridPosition cursor() const override;
    std::optional<widgets::GridRange> selection() const override;
    void navigate(widgets::GridMove move, bool extend) override;
    void place_cursor(widgets::GridPosition at, bool extend) override;
    void scroll_by(int rows, int columns) override;
};

Worksheet sheet;
widgets::CellGrid grid;
grid.set_model(sheet);                                  // sheet outlives grid
grid.on_type_ahead = [&](const std::string& text) { /* start editing with it */ };

Indices are the provider's own ordered integers. The frame lists the visible rows and columns in display order, each frozen band first; a hidden row is simply not listed, and a span is resolved over the listed indices, so a region whose anchor has scrolled away still covers the cells the reader can see. A cell states only what it changes about the theme's cell style (GridCellStyle: a foreground, a background, the attributes, the underline shape), and the grid composes the cursor and the selection over it — a cell with no colour of its own wears the role, a coloured cell keeps both colours and swaps them, and an unreadable swap falls back to the role.

The grid tells the provider its room through set_viewport() before every frame. After the provider changes anything itself — an edit, a command, a scroll it performed — the application calls grid.model_changed(); after a change the grid requested, the grid repaints on its own and reports through on_changed. Enter, F2, a printable key and Delete reach the owner through on_activate, on_type_ahead and on_clear_request, and are consumed only where a handler is installed.

MaterializedCellGridModel holds a table of values with the generic rules — the cursor clamps to the table, a page is the body's height, a jump reaches the table's edge — for a grid that shows a result rather than edits a document, and for tests.

Ownership and threading

All three widgets borrow their provider. The provider must outlive the view or be replaced with clear_model() first. Calls occur on the owning UI thread. This leaves cache size, cancellation, query scheduling, transaction policy, and domain types to applications while retaining deterministic interaction and testability in ckVision.