Repository navigation
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 inCursorial.UI.DataViews. The shaping model —SortDescription,GroupDescription,SummaryDescription,FilterNode,FormatRule,AggregateKind— lives inCursorial.UI.DataViews.Shaping. The criteria expression language and theCriteriaFunctionsregistry are inCursorial.UI.DataViews.Shaping.Expressions; the auto-generation attributes are inCursorial.UI.DataViews.Annotations. Colors on a format rule are the CoreCursorial.Output.Colorvalue, not a UI brush — the rule model stays presentation-free.
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);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 toFieldName),FieldName/KeySelector,Format,TextAlignment,Visible(a hidden column keeps its shaping identity). -
Width —
Width(DataGridLength:Cells(n),Autobest-fit, orStar(weight)),MinWidth/MaxWidth. -
Gestures —
AllowSort,AllowGroup,AllowFiltergate the header/filter surfaces per column. -
Sorting mode —
SortMode(StringComparison;CurrentCulturedefault,Ordinalis the perf opt-in). -
Freezing —
Fixed(DataGridColumnFixed.Leftpins 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).
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;
};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).
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 gestureFiltering 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 dialogThe 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))!).
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 editToggle a single rule with its Enabled flag (the rules manager's "On" switch); OpenRulesManagerAsync() opens the
full manager dialog.
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().
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.
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);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).
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)!);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);-
Controls — the base
Control, presenters, and the wider catalog the grid is built from. -
Data binding — wiring
ItemsSourceand 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.
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Command & data surfaces
Declarative