Editing and validation
A column becomes editable when it knows how to write a value back. Everything else — the editor, parsing, validation, the veto of a view model, undo — sits on one write path, so a paste, a fill-down and an undo are checked by the same rules as typing.
Making a column editable
The typed factories take a setter; Column.Text takes .Editable(setter). A column without either is read-only. The editors are drawn by the grid itself, so they look and behave the same on every host.
var columns = new List<ColumnDefinition<Order>>
{
// Typed: a setter makes the column editable; text that does not parse is refused.
TypedColumn.Number<Order>("Amount", o => (double)o.Amount,
set: (o, v) => o.Amount = (decimal)(v ?? 0), format: "N2")
.WithKey("amount"),
// A choice from a list, drawn as a drop-down.
Column.Text<Order>("Status", o => Label(o.Status))
.WithKey("status")
.Editable((o, v) => o.Status = ParseStatus(v))
.WithEditOptions(_ => StatusLabels(), DropDownStyle.Classic),
// A mask: literals stay in place, only digits are accepted.
Column.Text<Order>("Phone", o => o.Phone)
.WithKey("phone")
.Editable((o, v) => o.Phone = v)
.WithInputMask("+00 000 0000000"),
// A validator: return a message to refuse, null to accept.
Column.Text<Order>("Note", o => o.Note)
.WithKey("note")
.Editable((o, v) => o.Note = v)
.Validate(v => v.Contains(';') ? "No semicolons, please." : null)
.WithSelectAllOnEdit(),
// A checkbox toggles directly; there is no editor session.
Column.Checkbox<Order>("Checked", o => o.Checked, (o, v) => o.Checked = v).WithKey("checked"),
};- Text
- The default editor: a single-line text box drawn into the cell.
WithEditOptions(options, style)- A pick list.
DropDownStyle.Menu(the default) orDropDownStyle.Classic.WithDropDownButtondecides when the button shows: on hover, always or never. F4 or Alt+Down opens it from the keyboard. TypedColumn.Date·WithDateEditor- A drawn calendar.
WithDateFormatalone gives a plain date text in which Up and Down change the day, month or year under the caret. TypedColumn.Number- Up and Down, and the mouse wheel, step the digit at the caret.
WithInputMask(mask, promptChar)- The editor shows the whole mask; typing fills the slots and skips literals.
0required digit,9optional digit,Lrequired letter,?optional letter,A/arequired / optional letter or digit,#digit, space or sign,&any character,Coptional any character,\escapes the next character. Everything else is a literal. Column.Checkbox- With a setter, a click or Space toggles directly, without an editor session.
Parsing and formatted values
The typed factories parse in the column's culture and format and refuse what does not parse: the old value stays and the edit reports Rejected. Their number and date getters are nullable, and an empty edit is valid there — clearing a cell is a legal state. On a Column.Text, .Nullable() allows the same.
When a column shows formatted text, the editor should not open on the formatting. Give it the raw value to edit, and a data type so the grid can refuse what is not a number:
Column.Text<Order>("Amount", o => o.Amount.ToString("C", CultureInfo.CurrentCulture))
.WithKey("amount")
.WithEditText(o => o.Amount.ToString(CultureInfo.CurrentCulture)) // edit "1234.5", not "€1,234.50"
.Editable((o, v) => o.Amount = decimal.Parse(v, CultureInfo.CurrentCulture))
.WithDataType(ColumnDataType.Number); // refuse what is not a numberGestures
| Key or gesture | What it does |
|---|---|
| F2, double-click | Opens the editor on the current cell. |
| Typing | Starts an edit on the current cell and replaces its content. |
| Enter / Shift+Enter | Commits and moves down / up. The editor does not reopen. |
| Tab / Shift+Tab | Commits and moves right / left. |
| Esc | Cancels; the old value stays. |
| Delete | Outside an editor: clears the editable cells of the selection. |
| Ctrl+D | Copies the top row of the selection down over the rest of it. |
| Ctrl+V | Pastes tab-separated text into the selection; see Selection and clipboard. |
| Ctrl+Z / Ctrl+Y | Undo / redo. Ctrl+Shift+Z also redoes. |
grid.Api.State.SelectAllOnEdit decides whether an opening editor selects its text or places the caret; .WithSelectAllOnEdit() overrides it per column. IsReadOnly on the control, or on Api.State, switches every edit off, undo and redo included.
Validation and the edit policy
Three places can refuse a value, from the narrowest to the widest:
- The column: parsing, the mask, and
.Validate(text => message or null). - The edit policy: one object, typically from the view model, asked before an editor opens and before every write.
- The edit events:
CellEditStartingandCellChangingcan cancel.
public sealed class OrderEditPolicy(GridView<Order> view) : IGridEditPolicy
{
private readonly int idColumn = view.ColumnIdOfKey("id");
private readonly int amountColumn = view.ColumnIdOfKey("amount");
public string? CanBeginEdit(GridCellRef cell)
=> cell.ElementId == idColumn ? "The order number cannot be changed." : null;
public string? Validate(CellEdit edit)
=> edit.ElementId == amountColumn
&& decimal.TryParse(edit.NewValue, CultureInfo.CurrentCulture, out var d) && d < 0
? "The amount must not be negative."
: null;
}grid.EditPolicy = new OrderEditPolicy(view); // or grid.Api.CommandInputs.EditPolicy
grid.Api.CommandInputs.EditRefused += reason => Log(reason);A non-null answer is the reason. The grid announces it to assistive technology and raises EditRefused with it, so a status line can say why nothing happened. CanBeginEdit has a default implementation that allows everything; implement only Validate if that is all you need.
Edit events
grid.Api.Events.CellEditStarting += e =>
{
if (e.ItemIndex == 0) e.Cancel = true; // no editor on the first row
};
grid.Api.Events.CellChanging += edit =>
{
if (edit.Origin == EditOrigin.Paste && edit.NewValue.Length > 200)
edit.Cancel = true; // refuse this one write
};
grid.Api.Events.CellChanged += edit => Log($"{edit.OldValue} -> {edit.NewValue} ({edit.Origin})");CellEdit carries the item, the element id, old and new value, the Origin — Text, Picker, Toggle, Paste, Clear, Fill, Undo, Redo, Api — and a BatchId that groups the writes of one gesture. CellEditCommitted is the event to persist on; its arguments carry item index, element id, old and new value.
Editing from code
var editing = grid.Api.Editing;
GridEditOutcome outcome = editing.SetValue(item: 3, column: 2, text: "1250");
// Committed: the setter ran. Rejected: parse, mask or policy refused it. NotEditable.
if (editing.Begin()) // opens the editor at the current cell
{
editing.Type("12"); // as if typed: mask and selection apply
outcome = editing.Commit();
}These go through the same path as the keyboard. The selection moves to the target cell whatever the outcome. Type and Commit without a running edit throw; Cancel does nothing when nothing is open. To address a row by its item and a column by its key, use Api.Actions.SetValue(item, "amount", "120").
Undo and redo
Every view keeps its own history of edits — the table, the card board and the timeline alike. One gesture is one step: a paste over four hundred cells undoes at once, and so does a fill-down. The history holds the last 500 steps; the oldest is dropped beyond that, so the history does not keep every edited item alive. An undo is a write like any other, which is why the edit policy and IsReadOnly apply to it too.
var history = grid.Api.Commands.History; // IUndoStack of the current view
history!.HistoryChanged += () => Log($"{history.UndoDepth} steps to undo");
grid.Api.Commands.Undo(); // same as Ctrl+Z
grid.Api.Commands.Redo(); // same as Ctrl+Y or Ctrl+Shift+ZUndoCommand and RedoCommand on Api.Commands are ICommands for a toolbar; their CanExecute follows the history.
Saving changes: the commit pipeline
The grid writes into your items; getting the change to a database is yours. CommitPipeline tracks each committed edit per cell on that way — Pending, then Success, Error or Conflict — grouped by gesture, and draws the state at the cell. It moves no bytes itself.
var pipeline = new CommitPipeline((TableDefinition)view.Template);
// Later, e.g. on a timer: send what is pending, report back per batch.
foreach (CommitBatch batch in pipeline.Pending)
{
try
{
// write batch.Changes (Item, ElementId, BaseValue, LocalValue) to your storage
pipeline.MarkSuccess(batch);
}
catch (Exception ex)
{
pipeline.MarkError(batch, ex.Message); // the local value stays in the cell
}
}A failed commit loses nothing: the value is already in the item, the error only marks it, and Retry or Cancel decide what happens next. For a change made elsewhere in the meantime, MarkConflict(change, serverValue) marks exactly that cell, and AcceptServer or KeepLocal resolves it. With a pipeline attached, Api.Commands.ShowChangeHistory() opens a pane listing every change and where it stands; without one it does nothing.
