YOUSEFUL / DELPHI

Atlas VCL: FAQ, examples and tutorial

Learning center · Component reference

Atlas VCL is a suite of original VCL components for Delphi 13 on Windows (Win32 and Win64) that registers 29 classes on the Atlas palette page and depends only on the Delphi RTL and VCL; Atlas Analytics, Atlas UI and Atlas Info are built on top of it. Its classes fall into three areas. This page covers the data classes: TAtlasTable (unit Atlas.Data), an in-memory table with typed columns, sorting, filtering and CSV; TAtlasGrid (Atlas.Grid) and TAtlasValueEdit (Atlas.Editors) to show and edit it; TAtlasTree (Atlas.Tree); TAtlasWorkbook (Atlas.Spreadsheet), a small formula engine, with its grid TAtlasSheetGrid (Atlas.SheetGrid); TAtlasPivot (Atlas.Pivot); TAtlasInspector (Atlas.Inspector); TAtlasRichEdit (Atlas.RichEdit) and TAtlasSpellChecker (Atlas.Spell). Charts, gauges, themes, the wizard, workspaces, command bars, tiles, navigation, maps and diagrams have their own page, the interface guide; printing, scheduling, Gantt plans and PDF viewing are in the planning guide. Every class lives in its own unit: add the units you use to uses and the Atlas VCL src folder to the search path, or build with the AtlasVclRuntime runtime package. The components are meant for the VCL main thread, report bad input with exceptions and leave their data unchanged when they do. There is no database connection, no undo for the data models (TAtlasRichEdit keeps the RichEdit control's own undo and redo), no compatibility layer for other component suites and no cross-platform support.

FAQ

Which units do I add to uses? One per class: Atlas.Data (TAtlasTable, TAtlasColumnKind, EAtlasDataError and the CSV functions), Atlas.Grid, Atlas.Editors, Atlas.Tree, Atlas.Spreadsheet (TAtlasWorkbook, TAtlasCellFormat, EAtlasFormulaError, AtlasCellAddress), Atlas.SheetGrid, Atlas.Pivot, Atlas.Inspector, Atlas.RichEdit and Atlas.Spell. A control unit does not re-export its model, so a form that creates a TAtlasTable for a TAtlasGrid needs both Atlas.Data and Atlas.Grid. For runtime packages, add AtlasVclRuntime and deploy AtlasVclRuntime370.bpl built for the same platform as the application.

Is TAtlasTable a dataset? Can I connect it to a database? No. It is an in-memory model of string values with typed validation. It is not a TDataSet, has no data source link, no transactions, and no persistence apart from CSV text. Load rows from your database in your own code (AddRow per record, or one ImportCSV for bulk loading) and write changes back from OnChange or a listener.

Why do row numbers change, and how do I identify a row? Cell, SetCell, DeleteRow, RowCount and SourceIndex use zero-based indices into the current view, which changes with every sort, filter, insert and delete. SourceIndex(ViewRow) returns the current position in the underlying list, but that position also shifts when earlier rows are deleted or the table is reloaded. Keep a business key (an invoice number, a product code) in a column of its own, usually read-only (AddColumn('Number', ackText, 100, False)), and search for it with a loop over Cell.

What values does each column kind accept? ackText accepts anything. ackNumber accepts finite numbers in invariant format (12.50, -3, 1e3) and stores the shortest form (12.5, 1000); 12,5, 1,000, NaN and INF raise EAtlasDataError. ackBoolean accepts true/false in any case and 1/0, stored as true/false. ackDate accepts real dates written exactly as YYYY-MM-DD. Every kind accepts an empty value. AtlasNormalizeValue(Kind, Value) applies the same rules to a single value.

Can I add columns after loading data, or define them at design time? Neither. AddColumn raises Define columns before adding rows once the table holds rows (after Clear you may add more), and columns can never be removed or renamed. TAtlasTable publishes only OnChange, so define the schema in code, for example in the form's constructor, before loading rows.

How do sorting and filtering work? Sort(Column, Ascending) orders the view by one column: numbers numerically, all other kinds as text ignoring ASCII case (ISO dates therefore sort by date). Empty values come first when ascending, rows with equal keys keep their source order, and Sort(-1) restores source order. SetFilter(Text, Column) keeps the rows whose stored value contains Text, ignoring case, in one column or in all columns (-1). Both settings are remembered and reapplied after every change. There is one sort column and one filter text; there are no filter expressions.

When does an edit in TAtlasGrid reach the table? When the cursor moves to another cell, a header is clicked, the grid loses focus, Enter is pressed with the editor closed, or your code calls CommitEdit; the value is validated then, and an invalid value raises EAtlasDataError and keeps the cursor where it is. In this baseline the first Enter only closes the editor, and Escape does not discard text typed in an open editor (a second Escape does). The tutorial's form-level key handler makes both keys act at once. Call CommitEdit before your own code sorts, filters or changes the table.

Which formulas does TAtlasWorkbook understand? Can it open spreadsheet files? Numbers, cell references (A1 to XFD1048576, any case), + - * / ^, parentheses and five functions, SUM, AVERAGE, MIN, MAX and COUNT, whose arguments may be ranges such as B2:C4. Errors show as #REF!, #CYCLE!, #DIV/0!, #NUM!, #NAME?, #VALUE! or #LIMIT!. There are no comparisons, strings, IF, absolute references or further sheets, and no file import or export: persist a workbook with ToJSON and FormatsJSON.

Can users type formulas into TAtlasSheetGrid? Not reliably in this baseline. The grid stores the editor text in the workbook on every keystroke, and the refresh that follows selects the whole editor text, so the next keystroke replaces it: typing =A1*2 leaves 2. Typing one character or pasting a whole value works. Remove goEditing from Options and edit through a separate edit box that calls SetCell; the TAtlasSheetGrid reference has a formula bar example.

What does TAtlasPivot return, and who frees it? A new TAtlasTable with no owner: one row per value of the row dimension plus Grand total, and the columns Rows: <name>, 1: <value>, 2: <value>, ... and Grand total. All its columns are read-only. Free it yourself, or pass it to an owner with InsertComponent before showing it in a grid. It summarizes the source's current view, so apply a filter first to summarize a subset.

Does TAtlasSpellChecker include a dictionary, and does it underline words? Neither. Load your own word list with LoadDictionary (one word per line, # for comments) or AddWord. Check returns the unknown words with one-based offsets and Suggest ranks corrections; showing them is up to the application. Case is ignored for ASCII letters only, so CAFÉ does not match café.

Can I use these classes without a window or from a background thread? The models TAtlasTable, TAtlasWorkbook, TAtlasPivot and TAtlasSpellChecker need no window, and the WorkbookReport sample runs them in a console program. The controls need a parent window before use: TAtlasTree nodes and TAtlasRichEdit RTF streaming raise EInvalidOperation without one. Nothing is thread-safe; use every instance from the thread that created it, normally the VCL main thread.

Examples

A table with a read-only key, a filter on the boolean column and a descending numeric sort:

pascal
uses System.SysUtils, Atlas.Data;

procedure ListUnpaidInvoices;
var
  Invoices: TAtlasTable;
  I: Integer;
begin
  Invoices := TAtlasTable.Create(nil);
  try
    Invoices.AddColumn('Number', ackText, 100, False); // read-only business key
    Invoices.AddColumn('Customer');
    Invoices.AddColumn('Amount', ackNumber);
    Invoices.AddColumn('Paid', ackBoolean);
    Invoices.AddColumn('Due', ackDate);
    Invoices.AddRow(['INV-001', 'Harbor Books', '125.50', 'false', '2026-10-01']);
    Invoices.AddRow(['INV-002', 'Blue Mill', '980', '1', '2026-09-15']);
    Invoices.AddRow(['INV-003', 'Harbor Books', '42', '0', '']);
    Invoices.SetFilter('false', 3); // unpaid rows only
    Invoices.Sort(2, False);        // largest amount first
    for I := 0 to Invoices.RowCount - 1 do
      Writeln(Invoices.Cell(0, I), ' ', Invoices.Cell(1, I), ' ', Invoices.Cell(2, I));
    Writeln(Invoices.RowCount, ' of ', Invoices.SourceRowCount, ' rows shown');
  finally
    Invoices.Free;
  end;
end;

Output: INV-001 Harbor Books 125.5, INV-003 Harbor Books 42 and 2 of 3 rows shown. The amount 125.50 is stored as 125.5 and 1 as true, so the filter on false hides INV-002.

Saving every row to a file and loading it back; ImportCSV checks the whole file before it replaces anything:

pascal
uses System.SysUtils, System.IOUtils, Atlas.Data;

procedure SaveToCsv(Table: TAtlasTable; const FileName: string);
begin
  // False: every row in source order, whatever the filter and sort.
  TFile.WriteAllText(FileName, Table.ExportCSV(False), TEncoding.UTF8);
end;

procedure ReloadFromCsv(Table: TAtlasTable; const FileName: string);
begin
  try
    Table.ImportCSV(TFile.ReadAllText(FileName, TEncoding.UTF8));
  except
    on E: EAtlasDataError do
      Writeln('Not loaded, previous rows kept: ', E.Message);
  end;
end;

With a table holding Ink, "archival" / 1.50, the reload gives the same row back (1.5). A file containing Paper,lots prints Not loaded, previous rows kept: Invalid finite number: lots and the table still holds the old row.

A summary of sales by region and quarter with TAtlasPivot:

pascal
uses System.SysUtils, Atlas.Data, Atlas.Pivot;

procedure PrintQuarterlySales;
var
  Sales, Summary: TAtlasTable;
  Pivot: TAtlasPivot;
  Row, Col: Integer;
  Line: string;
begin
  Sales := TAtlasTable.Create(nil);
  Pivot := TAtlasPivot.Create(nil);
  try
    Sales.AddColumn('Region');
    Sales.AddColumn('Quarter');
    Sales.AddColumn('Sales', ackNumber);
    Sales.AddRow(['East', 'Q1', '100']);
    Sales.AddRow(['East', 'Q2', '150']);
    Sales.AddRow(['West', 'Q1', '80']);
    Sales.AddRow(['East', 'Q1', '20']);
    Summary := Pivot.Summarize(Sales, 0, 1, 2, aaSum);
    try
      for Row := -1 to Summary.RowCount - 1 do
      begin
        Line := '';
        for Col := 0 to Summary.ColumnCount - 1 do
          if Row < 0 then
            Line := Line + Summary.Columns[Col].Name.PadRight(14)
          else
            Line := Line + Summary.Cell(Col, Row).PadRight(14);
        Writeln(Line.TrimRight);
      end;
    finally
      Summary.Free; // the caller owns the result
    end;
  finally
    Pivot.Free;
    Sales.Free;
  end;
end;

Output (columns padded to 14 characters):

Example
Rows: Region  1: Q1         2: Q2         Grand total
East          120           150           270
West          80            0             80
Grand total   200           150           350

An invoice computed by TAtlasWorkbook formulas and shown with currency formats:

pascal
uses System.SysUtils, Atlas.Spreadsheet;

procedure BuildInvoice(Book: TAtlasWorkbook);
var
  Money: TAtlasCellFormat;
begin
  Book.Clear;
  Book.SetCell('A1', 'Item');
  Book.SetCell('B1', 'Hours');
  Book.SetCell('C1', 'Rate');
  Book.SetCell('D1', 'Amount');
  Book.SetCell('A2', 'Design');
  Book.SetCell('B2', '12');
  Book.SetCell('C2', '125');
  Book.SetCell('D2', '=B2*C2');
  Book.SetCell('A3', 'Development');
  Book.SetCell('B3', '40');
  Book.SetCell('C3', '150');
  Book.SetCell('D3', '=B3*C3');
  Book.SetCell('C4', 'Total');
  Book.SetCell('D4', '=SUM(D2:D3)');
  Book.SetCell('C5', 'Tax');
  Book.SetCell('D5', '=D4*0.2');
  Money := TAtlasCellFormat.Default;
  Money.NumberFormat := anfCurrency;
  Book.SetCellFormat('D2', Money);
  Book.SetCellFormat('D3', Money);
  Money.Bold := True;
  Book.SetCellFormat('D4', Money);
  Book.SetCellFormat('D5', Money);
end;

procedure PrintInvoice(Book: TAtlasWorkbook);
var
  Row: Integer;
begin
  for Row := 2 to 5 do
    Writeln(Book.Display(AtlasCellAddress(1, Row)).PadRight(12),
      Book.Display(AtlasCellAddress(3, Row)).PadRight(6),
      Book.Display(AtlasCellAddress(4, Row)));
  Writeln('Total as a number: ', Book.Value('D4'):0:2);
end;

Output: Design 125 $1500.00, Development 150 $6000.00, Total $7500.00, Tax $1500.00 and Total as a number: 7500.00.

What the error codes look like, and how Value reports them:

pascal
uses System.SysUtils, Atlas.Spreadsheet;

procedure ShowFormulaErrors;
var
  Book: TAtlasWorkbook;
begin
  Book := TAtlasWorkbook.Create(nil);
  try
    Book.SetCell('A1', 'text');
    Book.SetCell('B1', '=A1+1');       // arithmetic on text
    Book.SetCell('B2', '=1/0');
    Book.SetCell('B3', '=B4');
    Book.SetCell('B4', '=B3');         // B3 and B4 refer to each other
    Book.SetCell('B5', '=ROUND(2.5)'); // not one of the five functions
    Book.SetCell('B6', '=SUM(A1:A3)'); // text and blank cells in a range are skipped
    Writeln(Book.Display('B1'), ' ', Book.Display('B2'), ' ', Book.Display('B3'), ' ',
      Book.Display('B5'), ' ', Book.Display('B6'));
    try
      Book.Value('B2');
    except
      on E: EAtlasFormulaError do
        Writeln(E.Code, ' | ', E.Message);
    end;
  finally
    Book.Free;
  end;
end;

Output: #VALUE! #DIV/0! #CYCLE! #NAME? 0 and #DIV/0! | #DIV/0! Division by zero at position 4. B6 is 0 because a range skips text and blank cells.

A folder browser whose nodes load their children when first expanded:

pascal
uses System.SysUtils, System.IOUtils, Vcl.Forms, Vcl.Controls, Vcl.ComCtrls, Atlas.Tree;

type
  TFolderBrowser = class
  private
    FTree: TAtlasTree;
    function PathOf(Node: TTreeNode): string;
    procedure LoadFolders(Sender: TObject; Node: TTreeNode);
  public
    constructor Create(Form: TForm; const RootFolder: string);
    property Tree: TAtlasTree read FTree;
  end;

constructor TFolderBrowser.Create(Form: TForm; const RootFolder: string);
begin
  inherited Create;
  FTree := TAtlasTree.Create(Form);
  FTree.Parent := Form;      // the tree needs its window before nodes are added
  FTree.Align := alLeft;
  FTree.ReadOnly := True;    // folder names are not edited in place
  FTree.OnLoadChildren := LoadFolders;
  FTree.AddNode(nil, RootFolder, True);
end;

function TFolderBrowser.PathOf(Node: TTreeNode): string;
begin
  Result := Node.Text;
  while Node.Parent <> nil do
  begin
    Node := Node.Parent;
    Result := TPath.Combine(Node.Text, Result);
  end;
end;

procedure TFolderBrowser.LoadFolders(Sender: TObject; Node: TTreeNode);
var
  Folder: string;
begin
  // Runs on the first expand only. If it raises (no access, for example), the
  // tree removes what was added and the node stays expandable.
  for Folder in TDirectory.GetDirectories(PathOf(Node)) do
    FTree.AddNode(Node, ExtractFileName(Folder), True);
end;

Create it with TFolderBrowser.Create(Form, 'C:\Projects'): the tree shows one expandable node, and expanding a folder lists its subfolders, each expandable again. A folder that cannot be read raises from the expand, and the node stays expandable.

Setting published properties by name with TAtlasInspector:

pascal
uses System.SysUtils, Vcl.Forms, Vcl.Controls, Atlas.Inspector;

function InspectControl(Form: TForm; Target: TControl): TAtlasInspector;
begin
  Result := TAtlasInspector.Create(Form);
  Result.Parent := Form;
  Result.Align := alRight;
  Result.Width := 320;
  Result.Instance := Target; // lists Target's published properties, sorted by name
end;

procedure SetFromText(Inspector: TAtlasInspector; const Name, Value: string);
begin
  try
    Inspector.SetProperty(Name, Value);
  except
    on E: EAtlasPropertyError do
      Writeln(Name, ' = ', Value, ': ', E.Message);
  end;
end;

SetFromText(Inspector, 'Caption', 'Inspected') changes the caption; 'Tag', 'seven' prints Tag = seven: Expected integer; 'Font', 'Arial' prints Font = Arial: Unsupported property: Font, because class properties are not listed.

Checking a sentence with TAtlasSpellChecker:

pascal
uses System.SysUtils, Atlas.Spell;

procedure ProofRead(const Text: string);
var
  Spell: TAtlasSpellChecker;
  Issue: TAtlasSpellingIssue;
begin
  Spell := TAtlasSpellChecker.Create(nil);
  try
    Spell.LoadDictionary('the'#13#10'quarterly'#13#10'report'#13#10'is'#13#10 +
      'ready'#13#10'isn''t'#13#10'it'#13#10'late');
    for Issue in Spell.Check(Text) do
      Writeln(Issue.Word, ' at ', Issue.StartIndex, ': ', string.Join(', ', Spell.Suggest(Issue.Word)));
  finally
    Spell.Free;
  end;
end;

ProofRead('The quartely reprot is ready, isn''t it?') prints quartely at 5: quarterly and reprot at 14: report. isn't is one word, and The matches the.

Tutorial

Sample project: GridQuickStart. The deeper samples are WorkbookReport (table, pivot, workbook and spelling in a console program) and TreeAndEditors (tree, sheet grid, inspector, rich edit and value edit, checked on a hidden form).

Goal: build a window that shows a typed product table in a grid, filters and sorts it, edits it in place, adds validated rows and exports the visible rows as CSV.

  1. Create a new VCL application in Delphi 13, save it as InventoryGrid, and remove the form unit the wizard created (the form here is built in code, so no .dfm is needed). Add the Atlas VCL src folder to the project's search path for both Win32 and Win64. Replace the whole .dpr with the code of the following steps, in order. It compiles once every method from steps 3 to 10 is in place.

  2. Start the program with its uses clause and the form declaration. Atlas.Data provides TAtlasTable and the column kinds, Atlas.Grid the grid, Atlas.Editors the value edits; Winapi.Windows is needed for the key codes in step 6.

    pascal
    program InventoryGrid;
    
    // Atlas VCL quick start: a typed TAtlasTable shown in a TAtlasGrid, with a
    // filter box, header sorting, in-place editing, validated entry through
    // TAtlasValueEdit, row deletion and CSV export. The form is built in code; no
    // .dfm is needed.
    
    uses
      Winapi.Windows, System.SysUtils, System.Classes, System.IOUtils, Vcl.Forms, Vcl.Controls,
      Vcl.StdCtrls, Vcl.ExtCtrls,
      Atlas.Data, Atlas.Grid, Atlas.Editors;
    
    type
      TMainForm = class(TForm)
      private
        FTable: TAtlasTable;
        FGrid: TAtlasGrid;
        FFilter: TEdit;
        FProduct: TEdit;
        FQuantity: TAtlasValueEdit;
        FPrice: TAtlasValueEdit;
        FRestock: TAtlasValueEdit;
        FAvailable: TCheckBox;
        FStatus: TLabel;
        procedure AddLabel(Parent: TWinControl; Left, Top: Integer; const Caption: string);
        procedure AddButton(Parent: TWinControl; Left, Top: Integer;
          const Caption: string; OnClick: TNotifyEvent);
        function AddValueEdit(Parent: TWinControl; Left, Width: Integer;
          Kind: TAtlasColumnKind; Required: Boolean): TAtlasValueEdit;
        function Invalid(Edit: TAtlasValueEdit; const Name: string): Boolean;
        procedure FormKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
        procedure FilterChanged(Sender: TObject);
        procedure AddClicked(Sender: TObject);
        procedure DeleteClicked(Sender: TObject);
        procedure ExportClicked(Sender: TObject);
        procedure ClearSortClicked(Sender: TObject);
        procedure TableChanged(Sender: TObject);
      public
        constructor Create(AOwner: TComponent); override;
      end;
  3. Write the first half of the constructor. CreateNew builds a form without a .dfm. KeyPreview lets the form see Enter and Escape before the grid's in-place editor does (step 6). The top panel holds the filter box and three buttons, then labelled entry boxes: a plain TEdit for the product name, three TAtlasValueEdit controls that validate a number, a number and a date, and an Available check box. The bottom panel holds the status line.

    pascal
    constructor TMainForm.Create(AOwner: TComponent);
    var
      Toolbar, Footer: TPanel;
    begin
      inherited CreateNew(AOwner);
      Caption := 'Atlas VCL: grid quick start';
      Width := 900;
      Height := 560;
      Position := poScreenCenter;
      KeyPreview := True; // FormKeyDown sees Enter and Escape before the grid editor
      OnKeyDown := FormKeyDown;
    
      Toolbar := TPanel.Create(Self);
      Toolbar.Parent := Self;
      Toolbar.Align := alTop;
      Toolbar.Height := 108;
      Toolbar.BevelOuter := bvNone;
      AddLabel(Toolbar, 16, 17, 'Filter');
      FFilter := TEdit.Create(Self);
      FFilter.Parent := Toolbar;
      FFilter.SetBounds(56, 14, 260, 24);
      FFilter.OnChange := FilterChanged;
      AddButton(Toolbar, 332, 11, 'Clear sort', ClearSortClicked);
      AddButton(Toolbar, 448, 11, 'Delete row', DeleteClicked);
      AddButton(Toolbar, 564, 11, 'Export CSV', ExportClicked);
    
      AddLabel(Toolbar, 16, 50, 'Product');
      AddLabel(Toolbar, 232, 50, 'Quantity');
      AddLabel(Toolbar, 338, 50, 'Unit price');
      AddLabel(Toolbar, 444, 50, 'Restock (YYYY-MM-DD)');
      FProduct := TEdit.Create(Self);
      FProduct.Parent := Toolbar;
      FProduct.SetBounds(16, 68, 200, 24);
      FQuantity := AddValueEdit(Toolbar, 232, 90, ackNumber, True);
      FPrice := AddValueEdit(Toolbar, 338, 90, ackNumber, True);
      FRestock := AddValueEdit(Toolbar, 444, 110, ackDate, False);
      FAvailable := TCheckBox.Create(Self);
      FAvailable.Parent := Toolbar;
      FAvailable.SetBounds(570, 70, 90, 20);
      FAvailable.Caption := 'Available';
      FAvailable.Checked := True;
      AddButton(Toolbar, 676, 65, 'Add row', AddClicked);
    
      Footer := TPanel.Create(Self);
      Footer.Parent := Self;
      Footer.Align := alBottom;
      Footer.Height := 32;
      Footer.BevelOuter := bvNone;
      FStatus := TLabel.Create(Self);
      FStatus.Parent := Footer;
      FStatus.SetBounds(16, 8, 860, 20);
  4. Finish the constructor with the model. All columns are defined before the first row, which TAtlasTable requires. Assigning FGrid.Table connects the grid, which follows every later change of the table by itself. ImportCSV loads the four starting rows in one step; its header must name the columns exactly. The quoted field "Ink, archival" contains a comma, and the empty last field of the pencil row is a blank date, which every column kind allows.

    pascal
      // The schema is fixed before the first row is added.
      FTable := TAtlasTable.Create(Self);
      FTable.AddColumn('Product', ackText, 260);
      FTable.AddColumn('Quantity', ackNumber, 110);
      FTable.AddColumn('Unit price', ackNumber, 110);
      FTable.AddColumn('Available', ackBoolean, 100);
      FTable.AddColumn('Restock', ackDate, 120);
      FTable.OnChange := TableChanged;
    
      FGrid := TAtlasGrid.Create(Self);
      FGrid.Parent := Self;
      FGrid.Align := alClient;
      FGrid.AlignWithMargins := True;
      FGrid.Margins.SetBounds(16, 8, 16, 8);
      FGrid.Table := FTable;
    
      FTable.ImportCSV(
        'Product,Quantity,Unit price,Available,Restock' + sLineBreak +
        'Field notebook,120,4.75,true,2026-10-03' + sLineBreak +
        'Drafting pencil,300,1.2,true,' + sLineBreak +
        'Steel ruler,45,6.5,false,2026-11-15' + sLineBreak +
        '"Ink, archival",80,12,true,2026-10-20' + sLineBreak);
    end;
  5. Add the three small helpers that the constructor uses to create labels, buttons and value edits.

    pascal
    procedure TMainForm.AddLabel(Parent: TWinControl; Left, Top: Integer; const Caption: string);
    var
      Text: TLabel;
    begin
      Text := TLabel.Create(Self);
      Text.Parent := Parent;
      Text.SetBounds(Left, Top, 180, 16);
      Text.Caption := Caption;
    end;
    
    procedure TMainForm.AddButton(Parent: TWinControl; Left, Top: Integer;
      const Caption: string; OnClick: TNotifyEvent);
    var
      Button: TButton;
    begin
      Button := TButton.Create(Self);
      Button.Parent := Parent;
      Button.SetBounds(Left, Top, 100, 30);
      Button.Caption := Caption;
      Button.OnClick := OnClick;
    end;
    
    function TMainForm.AddValueEdit(Parent: TWinControl; Left, Width: Integer;
      Kind: TAtlasColumnKind; Required: Boolean): TAtlasValueEdit;
    begin
      Result := TAtlasValueEdit.Create(Self);
      Result.Parent := Parent;
      Result.SetBounds(Left, 68, Width, 24);
      Result.ValueKind := Kind;
      Result.Required := Required;
    end;
  6. Add the key handler. In this baseline the grid's first Enter only closes the in-place editor, and Escape closes it without discarding the typed text. With the editor open, the handler hands the text to the grid (EditorMode := False) and stores it with CommitEdit on Enter; on Escape it calls CancelEdit twice, because closing the editor turns its text back into a pending edit. Moving to another cell with the arrow keys, Tab or the mouse stores the value without any help.

    pascal
    procedure TMainForm.FormKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
    begin
      // In this baseline the grid's first Enter only closes the in-place editor,
      // and Escape closes it without discarding the text. Make both keys act at once.
      if (ActiveControl <> FGrid) or not FGrid.EditorMode then
        Exit;
      if Key = VK_RETURN then
      begin
        FGrid.EditorMode := False; // hand the editor text to the grid
        FGrid.CommitEdit;          // validate and store it, or raise EAtlasDataError
      end
      else if Key = VK_ESCAPE then
      begin
        FGrid.CancelEdit;          // closing the editor keeps its text pending...
        FGrid.CancelEdit;          // ...so a second call discards it
        Key := 0;
      end;
    end;
  7. Add filtering and sorting. The filter box calls SetFilter on every change, across all columns and ignoring case. Sorting needs no code: clicking a header sorts by that column and a second click reverses it. Clear sort goes back to the original order with Sort(-1). Both handlers call CommitEdit first, so a pending edit is stored before the rows move.

    pascal
    procedure TMainForm.FilterChanged(Sender: TObject);
    begin
      FGrid.CommitEdit; // a pending edit must land before the view changes
      FTable.SetFilter(FFilter.Text);
    end;
    
    procedure TMainForm.ClearSortClicked(Sender: TObject);
    begin
      FGrid.CommitEdit;
      FTable.Sort(-1);
    end;
  8. Add new rows. A TAtlasValueEdit checks its value when focus leaves it: an invalid value raises EAtlasDataError from the focus change, and the application reports it. AddClicked covers what that check cannot see, such as a required box the user never entered: Validate returns False and puts the reason in ValidationMessage, which goes to the status line. NormalizedValue returns the stored form (12.50 becomes 12.5). AddRow takes one value per column, in column order, and validates them again.

    pascal
    function TMainForm.Invalid(Edit: TAtlasValueEdit; const Name: string): Boolean;
    begin
      Result := not Edit.Validate;
      if Result then
        FStatus.Caption := Name + ': ' + Edit.ValidationMessage;
    end;
    
    procedure TMainForm.AddClicked(Sender: TObject);
    const
      Flags: array[Boolean] of string = ('false', 'true');
    begin
      if Trim(FProduct.Text) = '' then
      begin
        FStatus.Caption := 'Product: a name is required';
        Exit;
      end;
      if Invalid(FQuantity, 'Quantity') or Invalid(FPrice, 'Unit price') or
         Invalid(FRestock, 'Restock') then
        Exit;
      FGrid.CommitEdit;
      FTable.AddRow([Trim(FProduct.Text), FQuantity.NormalizedValue, FPrice.NormalizedValue,
        Flags[FAvailable.Checked], FRestock.NormalizedValue]);
      FProduct.Clear;
      FQuantity.Clear;
      FPrice.Clear;
      FRestock.Clear;
    end;
  9. Add deletion and export. Grid row 0 is the header, so the table row under the cursor is FGrid.Row - 1, and it is a view index, which is what DeleteRow expects. ExportCSV without an argument writes the visible rows in their current order; TFile.WriteAllText with TEncoding.UTF8 adds a byte order mark.

    pascal
    procedure TMainForm.DeleteClicked(Sender: TObject);
    begin
      // Grid row 0 is the header; table rows are zero-based view indices.
      if (FGrid.Row >= 1) and (FGrid.Row <= FTable.RowCount) then
      begin
        FGrid.CancelEdit;
        FTable.DeleteRow(FGrid.Row - 1);
      end;
    end;
    
    procedure TMainForm.ExportClicked(Sender: TObject);
    var
      FileName: string;
    begin
      FGrid.CommitEdit;
      FileName := TPath.Combine(TPath.GetDirectoryName(ParamStr(0)), 'inventory.csv');
      TFile.WriteAllText(FileName, FTable.ExportCSV, TEncoding.UTF8);
      FStatus.Caption := 'Wrote the visible rows to ' + FileName;
    end;
  10. Add the status line. OnChange fires after every change of the table, including sorting and filtering, so RowCount (visible rows) and SourceRowCount (all rows) are always current.

    pascal
    procedure TMainForm.TableChanged(Sender: TObject);
    var
      SortText: string;
    begin
      if FTable.SortColumn < 0 then
        SortText := 'unsorted'
      else if FTable.Ascending then
        SortText := 'sorted by ' + FTable.Columns[FTable.SortColumn].Name + ' ascending'
      else
        SortText := 'sorted by ' + FTable.Columns[FTable.SortColumn].Name + ' descending';
      FStatus.Caption := Format('%d of %d rows shown, %s. Click a header to sort; ' +
        'Enter stores an edit, Escape discards it.',
        [FTable.RowCount, FTable.SourceRowCount, SortText]);
    end;
  11. End the program with the main block, then build and run it (F9).

    pascal
    var
      Form: TMainForm;
    begin
      Application.Initialize;
      Application.MainFormOnTaskbar := True;
      Application.CreateForm(TMainForm, Form);
      Application.Run;
    end.

The window shows four products in a grid with a dark header and alternating row tint; quantities and prices are right-aligned. The status line reads 4 of 4 rows shown, unsorted. Click a header to sort; Enter stores an edit, Escape discards it. Type ink into the filter box and one row remains (1 of 4 rows shown). Click Quantity twice to sort by quantity, descending; the header shows v and 45 comes after 120 because the column is numeric. Type lots into a quantity cell and press Enter: the application reports Invalid finite number: lots, and Escape brings the old value back. Type a product name and click Add row with the quantity box empty: the status line shows Quantity: A value is required and nothing is added. Type 2026-02-30 into the restock box and press Tab: the application reports Expected a valid date in YYYY-MM-DD format. Export CSV writes inventory.csv next to the executable.

To adapt it, replace the columns and the ImportCSV text with your own data, keep a unique code in a read-only column (AddColumn('Code', ackText, 100, False)), and write the rows back to your storage from TableChanged.

Troubleshooting

EAtlasDataError: Define columns before adding rows. AddColumn was called on a table that already holds rows. Define every column first, or call Clear before extending the schema. Columns cannot be removed or renamed.

CSV header does not match the table schema, CSV column does not match: <name> or CSV row <n> has an incorrect field count. ImportCSV needs a first record naming every column exactly, in order and with the same case, and exactly one field per column in every later record. A blank line counts as a record with one empty field, so remove blank lines, including a second line break at the end of the file. Nothing is imported until the whole text is valid.

Invalid finite number: 12,5, or dates such as 21.09.2026 are rejected. Table values use the invariant format, not the user's locale: . as the decimal point, no thousands separators, dates as YYYY-MM-DD. Convert user input before AddRow or SetCell, or validate it with a TAtlasValueEdit.

After Enter a grid cell shows its old value, or a value comes back after Escape. The first Enter only closes the in-place editor; the value is stored at the next move, header click or focus change. Escape in an open editor keeps the text pending, and the next move stores it. Press Escape twice, or add the form-level key handler from the tutorial (step 6).

A value typed into the grid ended up in a different row. Code sorted, filtered or changed the table while the in-place editor was still open, for example from a timer or a shortcut that does not move the focus. The typed text stays pending and is stored into the row that is under the cursor after the change. Call Grid.CommitEdit before such changes; the tutorial's handlers do.

Typing =A1*2 into a TAtlasSheetGrid cell leaves only 2. In this baseline every keystroke is stored in the workbook, and the refresh that follows selects the whole editor text, so the next keystroke replaces it. Remove goEditing from Options and edit through a formula bar (an edit box that calls Workbook.SetCell), as in the TAtlasSheetGrid reference.

EInvalidOperation: Control '...' has no parent window from TAtlasTree or TAtlasRichEdit. Tree nodes and RTF streaming need a window handle. Set Parent to a form or panel before calling AddNode, LoadJSON or LoadRTF; the form itself does not have to be visible.

Leaving a TAtlasValueEdit shows an error message and OnExit does not run. When focus leaves, the edit replaces its text with NormalizedValue, which raises for an invalid value or for a blank value while Required is True. The check cannot be switched off. To warn users before they leave the box, show ValidationMessage from the edit's OnChange whenever Validate returns False; to report a missing value only when the user saves, leave Required False and check for blanks in your own code.

Column widths snap back after an edit, sort or filter. TAtlasGrid resets every column to TAtlasColumn.Width (scaled to the monitor DPI) after each table change. Choose the widths in AddColumn; the grid does not keep widths the user dragged.

Pivot aggregation requires finite numeric values. Summarize with aaSum, aaMinimum, aaMaximum or aaAverage found a non-numeric value in the value column. Use an ackNumber column (empty values are skipped), or aaCount, which counts rows of any kind.

TAtlasInspector does not list a property. Only published properties of integer, Int64, floating-point, enumeration, Char and string types are listed. Public properties, sets, class properties such as Font and events are not. Read-only properties are listed but cannot be changed.

The spell checker reports a word that is in the dictionary. Lookups ignore case for ASCII letters only, so a dictionary entry café does not match CAFÉ; add the other spelling too. Digits and hyphens split words (e-mail is checked as e and mail), and an apostrophe only joins letters (isn't).