Skip to content

Data Views

Mike Strobel edited this page Aug 4, 2026 · 1 revision

Data Views

Cursorial.UI.DataViews is a DevExpress-style DataGrid for the terminal: multi-level sorting and grouping with summaries, criteria-tree filtering, conditional formatting, in-cell editing, row-id-keyed and cell-range selection, and thousands of virtualized, direct-drawn rows. It sits on top of a UI-free shaping engine (the Cursorial.UI.DataViews.Shaping namespace) that references nothing but the BCL — sorting, grouping, aggregation, and filter evaluation are compiled to typed delegates specialized to your row type, so the hot paths never box.

The grid is a plain Control; everything you'd reach for is on DataGrid and its DataGridColumns, and every gesture edits the same observable description collections that code and persistence read. If you've used a DevExpress grid, the shapes will feel familiar.

Namespace note. DataGrid, DataGridColumn, and the grid-facing types live in Cursorial.UI.DataViews. The shaping model — SortDescription, GroupDescription, SummaryDescription, FilterNode, FormatRule, AggregateKind — lives in Cursorial.UI.DataViews.Shaping. The criteria expression language and the CriteriaFunctions registry are in Cursorial.UI.DataViews.Shaping.Expressions; the auto-generation attributes are in Cursorial.UI.DataViews.Annotations. Colors on a format rule are the Core Cursorial.Output.Color value, not a UI brush — the rule model stays presentation-free.


A grid on screen

Set ItemsSource (any IEnumerable; INotifyCollectionChanged is observed, and per-row INotifyPropertyChanged edits stream in live unless you set LiveUpdates = false). With no columns added, AutoGenerateColumns (default true) builds one column per public property of the discovered row type.

using Cursorial.UI.DataViews;
using Cursorial.UI.DataViews.Shaping;

var grid = new DataGrid { ItemsSource = trades };

For explicit columns, add DataGridColumns to the get-only Columns collection and turn auto-generation off. A column points into the row type either by the XAML-friendly FieldName (a dotted property path) or the typed code-only KeySelector (a LambdaExpression, which wins when both are set).

var grid = new DataGrid { ItemsSource = trades, AutoGenerateColumns = false };

var symbol = new DataGridColumn { Header = "Ticker", FieldName = "Symbol" };
var amount = new DataGridColumn
{
    Header = "Amount",
    FieldName = "Amount",
    Format = "C2",
    TextAlignment = TextAlignment.Right,
    Width = DataGridLength.Cells(12),
};
grid.Columns.Add(symbol);
grid.Columns.Add(amount);

Columns

DataGridColumn is a property-system-backed description object (so bindings and dynamic resources reach it). The column instance is the shaping identity — every sort, group, filter, and format description references it.

  • Identity & display — Header (defaults to FieldName), FieldName/KeySelector, Format, TextAlignment, Visible (a hidden column keeps its shaping identity).
  • Width — Width (DataGridLength: Cells(n), Auto best-fit, or Star(weight)), MinWidth/MaxWidth.
  • Gestures — AllowSort, AllowGroup, AllowFilter gate the header/filter surfaces per column.
  • Sorting mode — SortMode (StringComparison; CurrentCulture default, Ordinal is the perf opt-in).
  • Freezing — Fixed (DataGridColumnFixed.Left pins the column while the grid scrolls horizontally).
  • Editing — EditorKind (below), Validator (a commit-time veto delegate).
  • Formatting — FormatRules (the get-only conditional-format collection, below).

Auto-generated columns and annotations

When columns auto-generate, the grid reads the standard System.ComponentModel.DataAnnotations metadata — [Display(Name/Order/AutoGenerateField)], [DisplayName], [DisplayFormat(DataFormatString)], and [Browsable(false)] — plus DataViews' own attributes for concepts the standard set has no word for: a default sort/grouping and conditional formats. The DataViews attributes are applied only when you haven't authored the matching state (they never clobber explicit sort/grouping); format attributes always add.

using System.ComponentModel.DataAnnotations;
using Cursorial.UI.DataViews.Annotations;
using Cursorial.UI.DataViews.Shaping;

public sealed class Trade
{
    [Display(Name = "Ticker", Order = 0)]
    [DefaultSort(SortDirection.Ascending)]
    public string Symbol { get; init; } = "";

    [DisplayFormat(DataFormatString = "{0:C2}")]
    [DataBar]
    public decimal Amount { get; init; }

    [DefaultGroup]
    [Highlight(FilterOperator.Equals, "Cancelled", foreground: "#586e75")]
    public string Status { get; init; } = "";
}

Two lifecycle events let you refine generation: AutoGeneratingColumn fires once per property (after the built-in metadata is applied) with a DataGridAutoGeneratingColumnEventArgs you can mutate, replace, or Cancel; AutoGeneratedColumns fires once when the set is complete.

grid.AutoGeneratingColumn += (_, e) =>
{
    if (e.PropertyName == "InternalId") e.Cancel = true;
    else if (e.PropertyType == typeof(decimal)) e.Column.TextAlignment = TextAlignment.Right;
};

Sorting and grouping

SortDescriptions and GroupDescriptions are observable collections and the one source of truth — gestures edit them, glyphs render them, layout persistence reads them. A grouping level is also a sort level. Add descriptions directly, or drive them through the gesture helpers.

grid.SortDescriptions.Add(SortDescription.Descending(amount));
grid.GroupDescriptions.Add(new GroupDescription(status));

// Gesture helpers (header clicks / keyboard call these):
grid.CycleSort(amount);    // none → asc → desc → none (single-level)
grid.AppendSort(status);   // add / cycle / remove an additional level
grid.GroupBy(status);
grid.Ungroup(status);

A GroupDescription can order its sibling groups by an aggregate instead of by key with OrderBySummary/SummaryDirection (design doc §9.5 — the key-ordered repair substrate is never permuted; only the display order changes).

Summaries

SummaryDescriptions pairs a column with an AggregateKind (Count, Sum, Min, Max, Average) and an optional format / DisplayTemplate ("{0}" is the value). They appear in the summary footer (ShowSummaryFooter, which auto-hides when empty) and on each group row. GroupSummaryDisplay chooses how group rows show them: one right-aligned Banner string, or InColumn — each aggregate aligned under its own column, like the footer.

grid.SummaryDescriptions.Add(new SummaryDescription(amount, AggregateKind.Sum, "C2"));
grid.SummaryDescriptions.Add(new SummaryDescription(amount, AggregateKind.Average, "C2"));
grid.GroupSummaryDisplay = GroupSummaryDisplay.InColumn;

grid.ToggleSummary(amount, AggregateKind.Max);   // the summary editor / context-menu gesture

Filtering

Filtering is one immutable criteria tree — FilterNode — that every surface targets. Compose And/Or/Not groups over condition, value-set, and custom-predicate leaves; the controller compiles the active tree against its typed key vectors into one Func<int,bool> per shape (literals convert to the key type at compile time — no boxing at evaluation).

grid.Filter = FilterNode.And(
    FilterNode.Condition(amount, FilterOperator.GreaterThan, 1000m),
    FilterNode.InSet(status, ["Open", "Pending"]));

The programmatic Filter is AND-composed with per-column fragments the filter surfaces produce — SetColumnFilter(column, fragment) / HasColumnFilter(column) back the auto-filter row (ShowAutoFilterRow; per-column FilterCellKind is a text condition editor or a distinct-value DistinctPicker) and the per-column filter popup (OpenFilterPopup(column)).

For a text lane, the criteria expression language parses and lowers a DevExpress-style string to the same FilterNode shape. FilterExpressionText/TryApplyFilterExpression round-trip it (the structural subset stays re-editable in the Filter Builder; anything else compiles to a FilterNode.Custom predicate), and invalid text applies nothing and hands back positioned CriteriaDiagnostics.

if (!grid.TryApplyFilterExpression("[Amount] > 1000 And [Status] In ('Open', 'Pending')", out var diagnostics))
{
    // diagnostics carry a message + line/column — surface them on the editor strip
}

await grid.OpenFilterBuilderAsync();   // the visual tree builder + expression editor dialog

The expression compiler recognizes built-in functions (Iif, Min, Max, Contains, Upper, Abs, Round, …; Floor/Ceiling/Sqrt/Pow/Mod ship pre-registered) and consults the process-global CriteriaFunctions registry for any other name. Register your own at startup — a static method or an expression-tree emitter: CriteriaFunctions.RegisterMethod("Tax", typeof(Money).GetMethod(nameof(Money.Tax))!).

Conditional formatting

A column's FormatRules collection holds immutable FormatRule description objects the controller compiles per shape and evaluates at band-fill time (never at paint). A rule's verdict is a CellFormat (foreground/background Color?, Bold, Inverse, and an optional left-edge Icon glyph). Four rule kinds:

  • DataBarRule — an in-cell █░ bar; the fill fraction is the value's position in the column's min/max range (numeric columns).
  • ThresholdRule — ordered (operator, value, format) entries, first match wins (the ▲●▼ badge / status tint idiom); the same compiled comparison lane the filter engine uses.
  • ColorScaleRule — a 2- or 3-stop heat scale over the column's range; interpolates the cell foreground.
  • PredicateRule — a compiled row predicate whose format underlays every cell in a matching row (the "dim Cancelled" idiom).
amount.FormatRules.Add(new DataBarRule { ColumnKey = amount });
amount.FormatRules.Add(new ThresholdRule
{
    ColumnKey = amount,
    Entries =
    [
        (FilterOperator.LessThan,          0m, new CellFormat(Foreground: Color.FromRgb(220, 50, 47), Icon: "▼")),
        (FilterOperator.GreaterThanOrEqual, 0m, new CellFormat(Foreground: Color.FromRgb(38, 139, 210), Icon: "▲")),
    ],
});
grid.RefreshFormatRules();   // recollect the columns' rules into the engine after a runtime edit

Toggle a single rule with its Enabled flag (the rules manager's "On" switch); OpenRulesManagerAsync() opens the full manager dialog.

In-cell editing

Editing hosts a real editor element over the active row (the sanctioned exception to direct-drawn rows). A column's EditorKind picks the editor — Auto resolves from the key type (bool/enum → Combo, date → Date, numeric → Spin, else Text); None disables editing. A Validator vetoes a bad commit before it reaches the compiled setter (return null to accept, or a message to reject and keep the editor open in its error look).

amount.EditorKind = DataGridEditorKind.Spin;
amount.Validator  = ctx => decimal.TryParse(ctx.Text, out var v) && v >= 0
    ? null : "Enter a non-negative amount.";

BeginEdit()/CommitEdit()/CancelEdit() drive editing programmatically. Set AllowAddNew for a trailing new-row template (needs a writable IList source and a constructible row — a parameterless ctor or an AddingNewRow handler) and AllowDelete for Delete/DeleteSelectedRows().

Selection

SelectionUnit switches between whole-Row selection and rectangular Cell ranges. Row selection is keyed on stable row ids, so membership survives every re-sort/filter/regroup with zero fixup — the RowSelection controller (DataGridSelectionController) exposes IsSelected(id), SelectAll(), Clear(), and a SelectionChanged event; large selections represent compactly (an inverted set above a threshold). CopySelectionToClipboard() emits the selected rows or the cell rectangle as TSV.

grid.SelectionUnit = DataGridSelectionUnit.Cell;
grid.RowSelection.SelectionChanged += (_, _) => UpdateStatusBar();
grid.CopySelectionToClipboard();

On the NoColor tier — where selection/focus background fills would resolve to Default (invisible) — the rows presenter falls back to reverse-video (Inverse) for the selected/range/focus cells, the direct-draw mirror of the framework's .caps-nocolor list-focus cue.

Scrolling, frozen columns, and virtualization

The grid owns an in-presenter horizontal axis: HorizontalOffset is the one scroll truth (every band presenter draws shifted by it; the vertical ScrollViewer handles rows only). Columns marked Fixed = DataGridColumnFixed.Left resolve first and paint unshifted over the scrolled band. Off-screen columns are virtualized. ScrollColumnIntoView and ScrollRowIntoView bring a target into view.

symbol.Fixed = DataGridColumnFixed.Left;
grid.ScrollRowIntoView(viewIndex);

Master-detail

Assign a DetailTemplate (a DataTemplate) to enable the expander gutter and per-row detail panes; the engine stays one row per entry (detail geometry is presenter-side). Each pane is built fresh per expansion with its DataContext set to the row object; drive expansion with ExpandDetail(rowId)/IsDetailExpanded(rowId).

Column UX and layout persistence

Columns drag to reorder, resize at the header seam, and hide/show through the column chooser (OpenColumnChooser()). The whole arrangement round-trips through a serializer-agnostic DataGridLayoutState record (column order/width/ visibility keyed by FieldName, plus the sort and group levels) via GetColumnLayout()/ApplyColumnLayout(state).

var json = JsonSerializer.Serialize(grid.GetColumnLayout());
// …next session…
grid.ApplyColumnLayout(JsonSerializer.Deserialize<DataGridLayoutState>(json)!);

Using the grid from XAML

The suite lives under the default Cursorial UI xmlns but isn't seeded into the loader by default (that would invert the assembly dependency). Make it XAML-addressable once at startup, and its code-first control themes register themselves through the assembly theme-contribution tier — no consumer merge needed.

XamlSchemaContext.Default.RegisterAssembly(typeof(Cursorial.UI.DataViews.DataGrid).Assembly);

See also

  • Controls — the base Control, presenters, and the wider catalog the grid is built from.
  • Data binding — wiring ItemsSource and cell editors to a view model.
  • Styling and themes — selectors, control templates, and the color-tier theming the grid honors.
  • XAML — the loader, RegisterAssembly, and markup extensions.
  • UI Overview — the element tree, the frame loop, and how a control becomes cells.

Clone this wiki locally