This document specifies a new high-performance tabular data control for XenoAtom.Terminal.UI.
The existing Table control is intentionally simple and geared toward static display. A separate control is needed for
large datasets and advanced interactions (scrolling, frozen rows/columns, selection, sorting, filtering, and bulk edit).
This control is named DataGridControl (and related types) to avoid collision/confusion with System.Data.DataTable.
The design is inspired by modern terminal UI patterns and spreadsheet/datagrid ecosystems (e.g. Textual’s DataTable).
System.Data.DataTable,ITextDocument:
DataTemplate<T> (see Data Template Specs).DataGridControl is split into three layers:
IDataGridDocument — owns data and mutations (similar to ITextDocument).IDataGridView — provides a sorted/filtered projection over a document (optional but recommended).DataGridControl — the visual control; handles input, rendering, selection, and editing.The control MUST be able to operate with:
Document != null, View == null), orView != null) that internally references a document.IDataGridDocument (mutation + versioning)The document API SHOULD follow the same design principles as ITextDocument:
BeginUpdate()),namespace XenoAtom.Terminal.UI.DataGrid;
/// <summary>
/// Represents a mutable tabular document that provides snapshots and change notifications.
/// </summary>
public interface IDataGridDocument
{
/// <summary>Gets the current snapshot of the document.</summary>
IDataGridSnapshot CurrentSnapshot { get; }
/// <summary>Gets the current version number for the document.</summary>
int Version { get; }
/// <summary>Begins a batch update scope.</summary>
IDisposable BeginUpdate();
/// <summary>
/// Inserts a row model at the specified index.
/// </summary>
/// <param name="rowIndex">The index at which to insert the row model.</param>
/// <param name="rowModel">The row model to insert.</param>
void InsertRow(int rowIndex, object rowModel);
/// <summary>
/// Replaces the row model at the specified index.
/// </summary>
/// <param name="rowIndex">The index of the row model to replace.</param>
/// <param name="rowModel">The new row model instance.</param>
void ReplaceRow(int rowIndex, object rowModel);
/// <summary>
/// Removes a range of rows starting at the specified index.
/// </summary>
/// <param name="rowIndex">The starting row index.</param>
/// <param name="count">The number of rows to remove.</param>
void RemoveRows(int rowIndex, int count);
/// <summary>
/// Occurs when the document structure or schema changes (rows/columns/schema/reset).
/// </summary>
event EventHandler<DataGridDocumentChangedEventArgs> Changed;
}
IDataGridSnapshot (stable reads for rendering)Snapshots MUST be:
CurrentSnapshot),namespace XenoAtom.Terminal.UI.DataGrid;
public interface IDataGridSnapshot
{
int Version { get; }
int RowCount { get; }
int ColumnCount { get; }
DataGridColumnInfo GetColumn(int columnIndex);
/// <summary>Gets the row model object for a row index.</summary>
object GetRowModel(int rowIndex);
}
public readonly record struct DataGridColumnInfo(
string Key,
string HeaderText,
Type? ValueType,
bool ReadOnly,
BindingAccessor Accessor);
public readonly record struct DataGridColumnInfo<T>(
string Key,
string HeaderText,
bool ReadOnly,
BindingAccessor<T> Accessor);
Notes:
Key MUST be stable across versions for a “logical column” (even if the column order changes).HeaderText is a convenience; DataGrid can also accept a header template/visual.ReadOnly indicates that the underlying data source does not support edits for this column (e.g. no setter, computed
value, read-only DataColumn).Accessor MUST be provided and is used by DataGrid to create cell bindings against the row model.Ergonomics:
[Bindable] source generator SHOULD generate a nested Accessor type that exposes
BindingAccessor<T> instances for each bindable property (e.g. MyRow.Accessor.Name), including inheritance for
derived row models.BindingAccessor<T> MAY convert to DataGridColumnInfo<T> (key/header default to BindingAccessor.Name and
read-only defaults to BindingAccessor.IsReadOnly) so list-backed documents can be configured concisely.Row model edits:
IDataGridDocument methods and MUST raise
Changed events with Rows (or Reset) so views can invalidate caches.Changes should be communicated as coarse ranges and kinds to keep invalidation fast.
namespace XenoAtom.Terminal.UI.DataGrid;
[Flags]
public enum DataGridChangeKind
{
None = 0,
Rows = 1 << 0, // rows inserted/removed/replaced
Columns = 1 << 1, // columns inserted/removed/moved
Schema = 1 << 2, // column metadata changed (header, read-only, accessor, formatting hints)
Projection = 1 << 3, // sorting/filtering/search changed
Reset = 1 << 4, // large change; drop caches and re-measure
}
public sealed class DataGridDocumentChangedEventArgs : EventArgs
{
public required int OldVersion { get; init; }
public required int NewVersion { get; init; }
public required DataGridChangeKind Kind { get; init; }
/// <summary>Row range affected, if applicable.</summary>
public int RowIndex { get; init; } = -1;
public int RowCount { get; init; }
/// <summary>Column range affected, if applicable.</summary>
public int ColumnIndex { get; init; } = -1;
public int ColumnCount { get; init; }
}
Normative guidance:
Reset when precise ranges are expensive to compute.BeginUpdate() SHOULD coalesce multiple edits into a single Changed event where possible.Changed events. DataGrid is expected to read cell values via
bindings so regular bindable invalidation covers the rendering path.To avoid forcing DataGrid to materialize or reorder large datasets itself, projection concerns (sorting, filtering,
searching) SHOULD be delegated to an IDataGridView abstraction.
namespace XenoAtom.Terminal.UI.DataGrid;
public interface IDataGridView
{
IDataGridDocument Document { get; }
/// <summary>Gets the current snapshot for the view projection.</summary>
IDataGridViewSnapshot CurrentSnapshot { get; }
event EventHandler<DataGridViewChangedEventArgs> Changed;
}
public interface IDataGridViewSnapshot
{
int Version { get; }
int RowCount { get; }
int ColumnCount { get; }
DataGridColumnInfo GetColumn(int columnIndex);
/// <summary>Maps a view row index to a document row index.</summary>
int MapRowToDocument(int viewRowIndex);
/// <summary>Gets the row model object for a view row index.</summary>
object GetRowModel(int viewRowIndex);
}
public sealed class DataGridViewChangedEventArgs : EventArgs
{
public required int OldVersion { get; init; }
public required int NewVersion { get; init; }
public required DataGridChangeKind Kind { get; init; }
}
Sorting, filtering, and search API shape (V1):
IDataGridView implementations MAY expose additional capabilities (interfaces) such as:
ISortableDataGridView (sort descriptions, multi-sort),IFilterableDataGridView (column filters / predicate),ISearchableDataGridView (find/next/previous, match counts),IPagedDataGridView (incremental loading).DataGrid MUST work even when the view does not implement these capabilities; in that case UI affordances are disabled
(or delegated to user-provided commands).
DataGridControl control public APIusing XenoAtom.Terminal.UI.Scrolling;
namespace XenoAtom.Terminal.UI.Controls;
public sealed partial class DataGridControl : Visual, IScrollable
{
public DataGridControl();
}
| Property | Type | Default | Description |
|---|---|---|---|
Document |
IDataGridDocument? |
null |
Direct document binding (optional if View is set) |
View |
IDataGridView? |
null |
Sorted/filtered projection binding |
Columns |
BindableList<DataGridColumn> |
empty | Column definitions (optional; see section 8) |
ShowHeader |
bool |
true |
Whether to display the header row |
FrozenRows |
int |
0 |
Number of data rows frozen at top (in addition to header) |
FrozenColumns |
int |
0 |
Number of columns frozen at left |
SelectionMode |
DataGridSelectionMode |
Cell |
Cell/row/column selection |
ReadOnly |
bool |
false |
Disables editing (still allows selection/sort) |
CurrentCell |
DataGridCell |
(-1,-1) |
Focused cell for navigation and editing |
EditMode |
DataGridEditMode |
OnEnter |
When editing starts |
The control MUST expose a ScrollModel for interoperability with ScrollViewer:
public ScrollModel Scroll { get; }
namespace XenoAtom.Terminal.UI.Controls;
public enum DataGridSelectionMode
{
Cell,
Row,
Column,
}
public readonly record struct DataGridCell(int Row, int Column)
{
public static DataGridCell None => new(-1, -1);
}
public enum DataGridEditMode
{
/// <summary>Editing starts when the user presses Enter/F2 on the current cell.</summary>
OnEnter,
/// <summary>Editing starts as soon as the current cell changes.</summary>
OnCellChange,
/// <summary>Editing starts when the user types (spreadsheet-style).</summary>
OnTyping,
}
Selection model rules:
CurrentCell MUST always be within bounds when RowCount/ColumnCount > 0; otherwise it MUST be None.Row mode, CurrentCell.Column represents the “current column” for horizontal navigation, but selection is row-based.Column mode, CurrentCell.Row represents the “current row” for vertical navigation, but selection is column-based.DataGrid SHOULD expose routed events for:
SelectionChangedCurrentCellChangedSortingChangedEditBeginning / EditCommitted / EditCanceledEvent args should include (at minimum):
DataGridColumnThe column object is responsible for:
using XenoAtom.Terminal.UI.Templating;
namespace XenoAtom.Terminal.UI.Controls;
public abstract partial class DataGridColumn : IVisualElement
{
[Bindable] public string Key { get; set; } = string.Empty;
[Bindable] public Visual? Header { get; set; }
[Bindable] public GridLength Width { get; set; } = GridLength.Star(1);
[Bindable] public int MinWidth { get; set; }
[Bindable] public int MaxWidth { get; set; } = int.MaxValue;
[Bindable] public bool Visible { get; set; } = true;
/// <summary>
/// Gets or sets the text alignment used when the header is rendered as text.
/// </summary>
[Bindable] public TextAlignment HeaderAlignment { get; set; } = TextAlignment.Left;
/// <summary>
/// Gets or sets the default text alignment for cells in this column.
/// </summary>
/// <remarks>
/// This is used by the fast rendering path and as a default when templates render plain text.
/// Typical defaults: strings = <see cref="TextAlignment.Left"/>, numbers = <see cref="TextAlignment.Right"/>.
/// </remarks>
[Bindable] public TextAlignment CellAlignment { get; set; } = TextAlignment.Left;
/// <summary>
/// Gets or sets a value indicating whether cells in this column are read-only (not editable).
/// </summary>
/// <remarks>
/// This is a UI-level affordance that SHOULD be combined with schema/data-source read-only information
/// (e.g. <see cref="DataGridColumnInfo.ReadOnly"/>) to compute an effective read-only state.
/// </remarks>
[Bindable] public bool ReadOnly { get; set; }
/// <summary>
/// Gets the CLR type of the cell values in this column.
/// </summary>
public abstract Type ValueType { get; }
/// <summary>
/// Gets the accessor used to create bindings against a row model for this column.
/// </summary>
public abstract BindingAccessor ValueAccessor { get; }
}
/// <summary>
/// A typed column that carries typed templates and a typed accessor to avoid boxing for value types.
/// </summary>
/// <typeparam name="T">The cell value type.</typeparam>
public sealed partial class DataGridColumn<T> : DataGridColumn
{
/// <summary>
/// Gets or sets the accessor used to create <see cref="Binding{T}"/> instances for this column.
/// </summary>
[Bindable] public BindingAccessor<T> TypedValueAccessor { get; set; } = null!;
/// <inheritdoc />
public override Type ValueType => typeof(T);
/// <inheritdoc />
public override BindingAccessor ValueAccessor => TypedValueAccessor;
/// <summary>
/// Gets or sets the display template used for cells in this column.
/// When empty (<see cref="DataTemplate{T}.IsEmpty"/>), templates are resolved from <see cref="DataTemplates"/>
/// in the environment; if none is found, a default text renderer is used.
/// </summary>
[Bindable] public DataTemplate<T> CellTemplate { get; set; }
/// <summary>
/// Gets or sets the display template used for read-only cells in this column.
/// </summary>
/// <remarks>
/// When empty, the column falls back to <see cref="CellTemplate"/>.
/// This template is intended to render values differently when the underlying data is not editable (e.g. dim text,
/// lock glyph, computed value styling) without requiring the control to enter edit mode.
/// </remarks>
[Bindable] public DataTemplate<T> ReadOnlyCellTemplate { get; set; }
/// <summary>
/// Gets or sets the editor template used when a cell enters edit mode.
/// When empty (<see cref="DataTemplate{T}.IsEmpty"/>), templates are resolved from <see cref="DataTemplates"/>
/// in the environment; if none is found, DataGrid uses built-in editors for common types.
/// </summary>
[Bindable] public DataTemplate<T> CellEditorTemplate { get; set; }
}
Notes:
Key MUST match a column key coming from the snapshot when binding to schema-driven sources (e.g. DataTable).Key MAY be arbitrary and the adapter maps it to getters/setters.DataGridColumn<T>) whenever possible to avoid boxing (especially for value types).
DataGridColumn<object?> is the escape hatch for unknown types.DataGrid SHOULD create cell bindings using the row model from the snapshot and the column accessor (typically
DataGridColumn<T>.TypedValueAccessor or DataTemplateValue<T> to display templates, andBinding<T> to editor templates.DataGrid.ReadOnly is trueDataGridColumn.ReadOnly is trueDataGridColumnInfo.ReadOnly == true)CellEditorTemplate MUST NOT be used for read-only cells.DataGrid MUST use ReadOnlyCellTemplate when it is not empty; otherwise it MUST use
CellTemplate (and then environment/default fallbacks as usual).To reuse the existing DataTemplate<T> contract (see Data Template Specs), DataGrid SHOULD use
DataTemplateContext as follows when invoking CellTemplate / CellEditorTemplate:
Owner = the DataGrid instanceRole = Display or EditorIndex = the row index (in view coordinates when View is set)State includes Selected/Focused/Hovered/Disabled as appropriateThe column identity is known by the DataGridColumn that owns the template, so templates can be column-specific without
needing a (row, column) pair in the context.
DataGrid MUST be vertically and horizontally scrollable and MUST express its scroll state through its ScrollModel:
Scroll.OffsetY = first visible data row index in the scrollable regionScroll.OffsetX = horizontal cell offset in columns in cell units (not column indices)Implementation guidance (binding-driven invalidation):
DataGrid MUST NOT rely on manual invalidation APIs.
Because ScrollModel can be changed externally (e.g. ScrollViewer dragging scroll bars), DataGrid SHOULD bind its
layout/rendering to ScrollModel.Version using the established ScrollVersion pattern used by other IScrollable
controls (e.g. TreeView):
ScrollVersion on the control,PrepareChildren() (ScrollVersion = Scroll.Version;),ArrangeCore and RenderOverride (_ = ScrollVersion;).This ensures arrange/render reruns when offsets/extents change, without reintroducing dependency loops.
Extent computation:
ExtentHeight MUST be HeaderHeight + FrozenRows + RowCount (in terminal rows), plus optional separators.ExtentWidth MUST be the sum of resolved column widths plus optional separators/borders.DataGrid MUST support:
ShowHeader = true,FrozenRows data rows frozen below the header,FrozenColumns columns frozen at the left.Frozen regions MUST remain visible while scrolling the remaining region.
Implementation guidance:
The control MUST NOT allocate or measure visuals for off-screen rows/cells by default.
Minimum requirement:
Stronger requirement (recommended):
To keep performance predictable for large datasets, the default display path SHOULD be “render text directly”:
string, numbers, dates, enums, bool), render via formatter into CellBuffer.Visual per cell when a column uses default formatting.DataGridColumn.CellAlignment) when writing formatted values.Cell templating (Visual-based) SHOULD still be supported, but it is not the universal fast path.
For templated cells/headers/editors, DataGrid SHOULD maintain recycle pools keyed by:
This mirrors the DataTemplate<T>.TryUpdate(...) pattern from Data Template Specs.
Suggested bindings (exact gestures may evolve):
CurrentCellPageUp/PageDown: move by viewport heightHome/End: move within row (left/right) or within column (top/bottom)Ctrl+Home/Ctrl+End: first/last cellShift+Arrow / Shift+Page*: extend selection (range)DataGrid MUST keep the current cell visible by calling Scroll.ScrollToMakeVisible(...) as navigation occurs.
Selection SHOULD be representable without materializing all selected indices (important for large ranges).
Recommended representation:
SelectionModeShift+Wheel scrolls horizontally (optional V1)DataGridControl SHOULD register UI commands for key gestures it supports so they are discoverable via CommandBar.
Suggested built-in commands:
DataGrid.Find (Ctrl+F)DataGrid.ToggleFilterRow (F4)DataGrid.NextMatch (F3) / DataGrid.PreviousMatch (Shift+F3)DataGrid.SelectAll (Ctrl+A)DataGrid.Copy (Ctrl+C)DataGrid.GoToStart (Ctrl+Home) / DataGrid.GoToEnd (Ctrl+End)DataGrid.EditCell (F2)Sorting, filtering, and searching are part of V1.
The projection state SHOULD live in the view layer so large datasets can be handled without DataGrid materializing or
reordering row models.
Suggested capability interfaces (exact API may evolve):
using XenoAtom.Terminal.UI.Controls;
namespace XenoAtom.Terminal.UI.DataGrid;
public enum DataGridSortDirection
{
Ascending,
Descending,
}
public readonly record struct DataGridSortDescription(string ColumnKey, DataGridSortDirection Direction);
public interface ISortableDataGridView : IDataGridView
{
IReadOnlyList<DataGridSortDescription> SortDescriptions { get; }
void SetSortDescriptions(IReadOnlyList<DataGridSortDescription> sortDescriptions);
}
public readonly record struct DataGridFilterDescription(string ColumnKey, string? Text);
public interface IFilterableDataGridView : IDataGridView
{
IReadOnlyList<DataGridFilterDescription> Filters { get; }
void SetFilters(IReadOnlyList<DataGridFilterDescription> filters);
}
public interface ISearchableDataGridView : IDataGridView
{
SearchQuery SearchQuery { get; }
void SetSearchQuery(in SearchQuery query);
}
Views MUST raise Changed with Kind = DataGridChangeKind.Projection when these settings change.
Sorting MUST be expressed through the view layer when possible:
View implements sortable capabilities, DataGrid toggles sort on header interaction.Suggested UX:
None → Asc → Desc → NoneShift+click adds/removes secondary sort (multi-sort)Filtering is similarly delegated to the view layer.
DataGrid MUST provide:
Suggested UX (V1):
F4: toggle filter row (one TextBox per visible column)Escape: clears the current filter box (or closes the filter row if empty)Filtering semantics (default view implementation):
Searching is distinct from filtering: it navigates to matching cells without changing the row set.
DataGrid SHOULD integrate with the existing SearchReplacePopup infrastructure:
Ctrl+F opens a SearchReplacePopup in Find mode (replace disabled)F3 / Shift+F3 navigate next/previous matchCurrentCell and call Scroll.ScrollToMakeVisible(...)For large datasets, DataGrid MAY delegate match discovery to a view implementing ISearchableDataGridView.
DataGrid MUST support a single active editor at a time (spreadsheet-style) for performance:
Enter / F2 / typing (based on EditMode)Enter (optionally Tab to next cell)EscapeRead-only rule:
DataGrid MUST NOT enter edit mode for a read-only cell (see section 8.1).Validation SHOULD be supported via:
On validation failure:
For common types, the control SHOULD provide default editors:
string → TextBoxNumberBoxbool → SwitchSelect<T> / a simple TextBox editor with Enum.TryParse fallbackFor custom types, CellEditorTemplate is used.
System.Data.DataTableProvide an adapter (name illustrative):
DataGridDataTableDocument : IDataGridDocumentRequirements:
DataColumn (key = ColumnName or stable identifier),DataRow,DataTable,DataTable MUST be translated to Changed events (coalesced when possible).
Value changes SHOULD be reflected through the row model binding surface (preferred) or by raising Reset when
fine-grained invalidation is not practical.Provide an adapter for IReadOnlyList<T> / BindableList<T> (name illustrative):
DataGridListDocument<T> : IDataGridDocumentColumns may be described by:
DataGridColumnInfo / DataGridColumnInfo<TValue> in the document/view snapshot, backed by BindingAccessor / BindingAccessor<TValue>.DataGridColumn<TValue> UI columns that map by Key to a DataGridColumnInfo from the snapshot (and provide templates/editors).This supports “bulk edit a list of view models with bindable properties”.
Example (bindable row model + generated accessors):
public sealed partial class SwimRow
{
[Bindable] public partial int Lane { get; set; }
[Bindable] public partial string Swimmer { get; set; } = string.Empty;
[Bindable] public partial string Country { get; set; } = string.Empty;
[Bindable] public partial double Time { get; set; }
}
var doc = new DataGridListDocument<SwimRow>();
using (doc.BeginUpdate())
{
doc
.AddColumn(SwimRow.Accessor.Lane)
.AddColumn(SwimRow.Accessor.Swimmer)
.AddColumn(SwimRow.Accessor.Country)
.AddColumn(SwimRow.Accessor.Time);
}
Introduce a style record similar to TableStyle, ListBoxStyle, etc.:
DataGridStyle : IStyle<DataGridStyle>Suggested fields:
LineGlyphs) when grid lines are enabled.Default appearance (V1):
This is closer to Textual’s DataTable than TableStyle.Grid.
TableTable remains the lightweight “static display” control.DataGrid is the “interactive, virtualized, data-bound” control.IDataGridSnapshot into a Table for scenarios that do not need
selection/editing (e.g. export/print).