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.

Editor kinds
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) or DropDownStyle.Classic. WithDropDownButton decides when the button shows: on hover, always or never. F4 or Alt+Down opens it from the keyboard.
TypedColumn.Date · WithDateEditor
A drawn calendar. WithDateFormat alone 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. 0 required digit, 9 optional digit, L required letter, ? optional letter, A / a required / optional letter or digit, # digit, space or sign, & any character, C optional 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:

Display text and edit text
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 number

Gestures

Key or gestureWhat it does
F2, double-clickOpens the editor on the current cell.
TypingStarts an edit on the current cell and replaces its content.
Enter / Shift+EnterCommits and moves down / up. The editor does not reopen.
Tab / Shift+TabCommits and moves right / left.
EscCancels; the old value stays.
DeleteOutside an editor: clears the editable cells of the selection.
Ctrl+DCopies the top row of the selection down over the rest of it.
Ctrl+VPastes tab-separated text into the selection; see Selection and clipboard.
Ctrl+Z / Ctrl+YUndo / 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:

  1. The column: parsing, the mask, and .Validate(text => message or null).
  2. The edit policy: one object, typically from the view model, asked before an editor opens and before every write.
  3. The edit events: CellEditStarting and CellChanging can cancel.
An IGridEditPolicy
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;
}
Using it
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

Before and after a write
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

Api.Editing
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.

The history from code
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+Z

UndoCommand 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.

CommitPipeline
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.