YOUSEFUL / DELPHI

Atlas Virtual Grid: FAQ, examples and tutorial

Learning center · Component reference

Atlas Virtual Grid is a data grid for Delphi 13 VCL applications that draws itself and holds no cells. A TAtlasVirtualGrid asks a data source for the cells on screen, so the same control shows ten rows held in memory or a million computed on demand. It has column bands, frozen columns, sorting, a text filter, totals, multi-row selection, in-place editing with validation, conditional colours, and CSV, HTML, SVG and PNG output. Add Atlas.VGrid (the grid and its columns) and Atlas.VGridData (cells, TAtlasMemoryData and TAtlasVirtualData) to uses, plus Atlas.VGridPlot for TAtlasPlotData, the companion that shows and edits an Atlas Plot chart as a table. The product requires Atlas Plot: the grid draws through its drawing surface, and the companion reads TAtlasPlot. The components are on the Atlas Plot palette page. It is VCL only, for Win32 and Win64. See Getting started for building and installing the packages.

FAQ

How is it different from a grid that stores its cells? It keeps no copy of the data. The grid asks its source for the cells it is about to draw. When it is neither sorted nor filtered it allocates nothing per row and reads only the visible cells: fewer than 200 per paint in the product's million-row test. Sorting, filtering, totals, ToCSV and ToHTML read every row once, and take as long as the source takes to supply them. It is a different control from Atlas VCL's TAtlasGrid, which wraps the stock VCL draw grid around an in-memory table.

Which data source should I use? Use TAtlasMemoryData for rows you load into memory. Use TAtlasVirtualData when the data lives elsewhere or is computed: you answer OnGetCell, and OnSetCell if the data is editable. Use TAtlasPlotData to show a chart as a table. For anything else, derive from TAtlasGridData and override five methods. There is no TDataSet source; TAtlasGridData is the extension point for one. Several grids may share one source, and either side may be destroyed first.

What can a cell hold? One of four kinds (TAtlasCellKind): text, a number (a finite Double no larger than 1E100 in magnitude), a date/time (years 1 to 9999) or yes/no. Any cell may be null. Cells are TAtlasCell records, not Variants. Each data column has one kind, which decides how typed text is parsed, how the cell is formatted and aligned, and how it sorts and totals.

How does sorting work? Click a header to sort ascending, click again for descending, and a third time for source order. In code, call Sort(Column, Descending), where Column is an index into Columns and -1 removes the sort. The sort is stable, so equal keys keep source order. Nulls come first when ascending and last when descending. Text compares without regard to case, with case as the tie-break. The current record and the selected records stay with their records wherever they move. Only one column sorts at a time.

What does the filter match? FilterText keeps the rows where the formatted text of any visible column contains it, ignoring case. The filter searches what the user sees, so 2026-01 matches a date column formatted yyyy-mm-dd but not one formatted dd/mm/yyyy. Hidden columns are not searched. There is one filter text per grid; per-column filters and filter expressions are not implemented.

How do totals work? Set a column's Total to tkSum, tkAverage, tkMinimum, tkMaximum or tkCount (TAtlasTotalKind), and a footer appears. Totals cover the rows shown, so they follow the filter and every change in the data, and they use the column's format. Sum and average apply to numbers; minimum and maximum to numbers and dates; count to any kind, counting non-null cells. Totals that do not suit the column stay blank.

How do I validate what users type? In the data source. The grid parses the text for the column's kind, and text that does not parse is refused before your code runs. It then hands the cell to SetCell, or to OnSetCell for TAtlasVirtualData, where you raise EAtlasGridError to refuse it. A refused value is never stored. The editor stays open, LastError holds the message and OnEditError fires. Moving away, sorting and filtering wait until the value is corrected or the edit is cancelled with Esc. If focus leaves the grid while the value is refused, the edit is cancelled. The grid catches only EAtlasGridError.

Which locale is used where? TAtlasMemoryData.AddRow always reads invariant text: . decimals, ISO dates, yes/no. SetText uses the settings you pass. The in-place editor shows and parses values with the global FormatSettings. CellText, HTML and pictures are formatted with the global FormatSettings and each column's Format mask. ToCSV is always invariant (1250.5, 2026-03-31, true), so it reads back the same on every machine.

Can I export without showing a window? Yes. A grid with no parent can be bound, sorted, filtered and totalled, and it writes CSV, HTML, SVG and image files, as the quick start does in a console program. CSV and HTML carry every row shown. Pictures carry what fits in the requested size, starting at TopRow. In-place editing is the exception: it needs the grid to be on a form.

How does the chart companion lay out a chart? Column 0 is the argument: the point labels when the first visible series has any, dates for a date axis, and numbers otherwise. One number column follows per series. There is one row per distinct argument, in ascending order, and a missing point or a gap is a null cell. Editing a value moves the point, or adds one where the series had none. Arguments are read-only, blanks are refused, and the chart's own rules still apply. Changes made on the chart reach the table once per chart batch, and RowOfPoint finds the row of a clicked point.

Can users reorder, hide or group columns? Not with the mouse. Column reordering by drag, a column chooser, and grouping with expand and collapse are not implemented. In code, hide a column with Visible := False, and reorder by rebuilding Columns. Users can resize a column by dragging its header border, and fit it by double-clicking the border.

What is not implemented? An FMX control; editors other than text and yes/no (combo box, date picker, spin edit); row details and sub-grids; merged cells; multi-line rows; a TDataSet source; printing with pagination (Render draws one viewport); rescaling column widths when the DPI changes; VCL styles; and UI Automation. The clipboard, dragging the scroll bars and use in the IDE designer have not been tested.

Examples

A memory source with four typed columns, shown in a grid with a frozen first column, a format mask, a sum and a descending sort. Call FillOrders(Orders) and then ShowOrders(Self, Orders) from a form.

pascal
uses Vcl.Forms, Vcl.Controls, Atlas.VGridData, Atlas.VGrid;

procedure FillOrders(Orders: TAtlasMemoryData);
begin
  Orders.AddColumn('Customer');
  Orders.AddColumn('Amount', ckNumber);
  Orders.AddColumn('Due', ckDateTime);
  Orders.AddColumn('Paid', ckBoolean);
  Orders.BeginUpdate;                        // one notification for the whole batch
  try
    // One text per column, read with invariant settings: '.' decimals, ISO dates.
    Orders.AddRow(['Contoso', '1250.50', '2026-03-31', 'no']);
    Orders.AddRow(['Fabrikam', '980', '2026-02-28', 'yes']);
    Orders.AddRow(['Northwind', '', '', '']);   // empty text is a null cell
  finally
    Orders.EndUpdate;
  end;
end;

procedure ShowOrders(Form: TForm; Orders: TAtlasMemoryData);
var Grid: TAtlasVirtualGrid;
begin
  Grid := TAtlasVirtualGrid.Create(Form);
  Grid.Parent := Form;
  Grid.Align := alClient;
  Grid.Data := Orders;                       // no columns yet: one per data column
  Grid.FrozenColumns := 1;                   // Customer stays put when scrolling sideways
  Grid.Columns[1].Format := '#,##0.00';
  Grid.Columns[1].Total := tkSum;
  Grid.Columns[2].Format := 'yyyy-mm-dd';
  Grid.Sort(1, True);                        // largest amount first
end;

The grid lists Contoso (1,250.50), Fabrikam (980.00) and Northwind, whose null amount sorts last. The footer shows 2,230.50. With invariant settings, CellText(1, 0) returns 1,250.50.

An on-demand source that stores nothing. The grid asks GetCell only for the cells it draws.

pascal
uses System.Classes, Atlas.VGridData;

type
  TSquares = class(TComponent)
  private
    FData: TAtlasVirtualData;
    procedure GetCell(Sender: TObject; Column, Row: Integer; var Cell: TAtlasCell);
  public
    constructor Create(AOwner: TComponent); override;
    property Data: TAtlasVirtualData read FData;
  end;

constructor TSquares.Create(AOwner: TComponent);
begin
  inherited;
  FData := TAtlasVirtualData.Create(Self);
  FData.AddColumn('N', ckNumber);
  FData.AddColumn('N squared', ckNumber);
  FData.OnGetCell := GetCell;                // asked only for the cells being shown
  FData.RowCount := 1000000;
end;

procedure TSquares.GetCell(Sender: TObject; Column, Row: Integer; var Cell: TAtlasCell);
begin
  if Column = 0 then Cell := TAtlasCell.OfNumber(Row + 1)
  else Cell := TAtlasCell.OfNumber(Sqr(Int64(Row) + 1));
end;

With Grid.Data := Squares.Data, the grid has 1,000,000 rows, and the last one reads 1,000,000 and 1,000,000,000,000. The source is read-only because it has no OnSetCell.

Accepting edits and refusing bad values. Assigning OnSetCell makes the source writable, and raising EAtlasGridError refuses a value.

pascal
uses System.Classes, Atlas.VGridData;

type
  TPrices = class(TComponent)
  private
    FPrices: TArray<Double>;
    FData: TAtlasVirtualData;
    procedure GetPrice(Sender: TObject; Column, Row: Integer; var Cell: TAtlasCell);
    procedure SetPrice(Sender: TObject; Column, Row: Integer; const Cell: TAtlasCell);
  public
    constructor Create(AOwner: TComponent); override;
    property Data: TAtlasVirtualData read FData;
  end;

constructor TPrices.Create(AOwner: TComponent);
begin
  inherited;
  FPrices := [9.5, 12, 30];
  FData := TAtlasVirtualData.Create(Self);
  FData.AddColumn('Price', ckNumber);
  FData.OnGetCell := GetPrice;
  FData.OnSetCell := SetPrice;               // without a handler every column is read-only
  FData.RowCount := Length(FPrices);
end;

procedure TPrices.GetPrice(Sender: TObject; Column, Row: Integer; var Cell: TAtlasCell);
begin
  Cell := TAtlasCell.OfNumber(FPrices[Row]);
end;

procedure TPrices.SetPrice(Sender: TObject; Column, Row: Integer; const Cell: TAtlasCell);
begin
  // The text has already been parsed as a number. Raising EAtlasGridError
  // refuses the value; a grid keeps its editor open and reports the message.
  if Cell.IsNull or (Cell.Number <= 0) then
    raise EAtlasGridError.Create('A price must be above zero');
  FPrices[Row] := Cell.Number;
end;

Here is what happens when a user edits the first price in a grid bound to this source. Typing 0 keeps the editor open with A price must be above zero in LastError, and the price stays 9.5. Typing cheap is refused before SetPrice runs, with "cheap" is not a number. Typing 11.25 stores the value.

Conditional colours. OnCellStyle receives the grid column, the data row and the cell, and may change the background and text colour.

pascal
uses Vcl.Graphics, Atlas.VGridData, Atlas.VGrid;

type
  TOrderStyler = class
    procedure Style(Sender: TObject; Column: TAtlasGridColumn; DataRow: Integer;
      const Cell: TAtlasCell; var Background, TextColor: TColor);
  end;

procedure TOrderStyler.Style(Sender: TObject; Column: TAtlasGridColumn; DataRow: Integer;
  const Cell: TAtlasCell; var Background, TextColor: TColor);
begin
  // Data column 3 is Paid: unpaid orders get a pale red cell.
  if (Column.DataIndex = 3) and not Cell.IsNull and (Cell.Number = 0) then Background := $00D8D8FF;
  // Data column 1 is Amount: 1,000 or more in dark green.
  if (Column.DataIndex = 1) and not Cell.IsNull and (Cell.Number >= 1000) then TextColor := $00207A20;
end;

After Grid.OnCellStyle := Styler.Style on the orders grid, Contoso's Paid cell is pale red and its amount dark green, on screen and in exported pictures. Selected rows keep the selection colour.

The chart companion. The table below the chart edits the chart.

pascal
uses Vcl.Forms, Vcl.Controls, Atlas.Plot, Atlas.VGridPlot, Atlas.VGrid;

function LinkChart(Form: TForm; Chart: TAtlasPlot): TAtlasVirtualGrid;
var Link: TAtlasPlotData;
begin
  Link := TAtlasPlotData.Create(Form);
  Link.Plot := Chart;                        // column 0: the argument, then one column per series
  Result := TAtlasVirtualGrid.Create(Form);
  Result.Parent := Form;
  Result.Align := alBottom;
  Result.Data := Link;
  Result.Columns[1].Total := tkSum;
end;

Consider a chart with one bar series, Sales, holding Jan 10 and Feb 20. The grid shows two rows and a total of 30. Typing 25 over Feb raises the bar, and the total becomes 35. Adding a point on the chart adds a row.

Your own choice of columns. Columns that exist before the source is bound are kept.

pascal
uses Atlas.VGridData, Atlas.VGrid;

procedure ChooseColumns(Grid: TAtlasVirtualGrid; Orders: TAtlasMemoryData);
var Column: TAtlasGridColumn;
begin
  Grid.Columns.BeginUpdate;
  try
    Grid.Columns.Clear;
    Column := Grid.Columns.Add;
    Column.DataIndex := 0;                   // Customer
    Column.Width := 160;
    Column := Grid.Columns.Add;
    Column.DataIndex := 1;                   // Amount
    Column.Title := 'Amount (EUR)';
    Column.Format := '#,##0.00';
    Column.Alignment := caRight;
    Column.Total := tkSum;
  finally
    Grid.Columns.EndUpdate;
  end;
  Grid.Data := Orders;                       // existing columns are kept: Due and Paid stay hidden
end;

The grid shows two columns, and ToCSV starts with Customer,Amount (EUR).

Export: CSV and HTML carry every row shown, while a picture carries what fits.

pascal
uses System.IOUtils, Atlas.VGrid;

procedure ExportOrders(Grid: TAtlasVirtualGrid; const Folder: string);
begin
  Grid.SaveToFile(TPath.Combine(Folder, 'orders.csv'));             // every row, raw values
  Grid.SaveToFile(TPath.Combine(Folder, 'orders.html'));            // every row, formatted, with totals
  Grid.TopRow := 0;                                                 // pictures start at TopRow
  Grid.SaveToFile(TPath.Combine(Folder, 'orders.png'), 900, 400);   // what fits in 900 x 400
end;

For the orders above, orders.csv reads:

Example
Customer,Amount,Due,Paid
Contoso,1250.5,2026-03-31,false
Fabrikam,980,2026-02-28,true
Northwind,,,

Your own data source. Override GetRowCount, GetColumnCount, ColumnTitle, ColumnKind and GetCell, and call Changed when the data changes.

pascal
uses System.SysUtils, Atlas.VGridData;

type
  // A read-only multiplication table. A source must provide GetRowCount,
  // GetColumnCount, ColumnTitle, ColumnKind and GetCell, and call Changed.
  TTimesTable = class(TAtlasGridData)
  private
    FSize: Integer;
    procedure SetSize(Value: Integer);
  protected
    function GetRowCount: Integer; override;
    function GetColumnCount: Integer; override;
  public
    function ColumnTitle(Column: Integer): string; override;
    function ColumnKind(Column: Integer): TAtlasCellKind; override;
    function GetCell(Column, Row: Integer): TAtlasCell; override;
    property Size: Integer read FSize write SetSize;
  end;

procedure TTimesTable.SetSize(Value: Integer);
begin
  if Value < 0 then raise EAtlasGridError.Create('Size cannot be negative');
  FSize := Value;
  Changed;                                   // tells every watching grid
end;

function TTimesTable.GetRowCount: Integer;
begin
  Result := FSize;
end;

function TTimesTable.GetColumnCount: Integer;
begin
  Result := FSize;
end;

function TTimesTable.ColumnTitle(Column: Integer): string;
begin
  Result := 'x ' + IntToStr(Column + 1);
end;

function TTimesTable.ColumnKind(Column: Integer): TAtlasCellKind;
begin
  Result := ckNumber;
end;

function TTimesTable.GetCell(Column, Row: Integer): TAtlasCell;
begin
  CheckCell(Column, Row);                    // EAtlasGridError outside the table
  Result := TAtlasCell.OfNumber((Row + 1) * (Column + 1));
end;

With Size := 12, a grid shows twelve columns titled x 1 to x 12, and the bottom-right cell reads 144. Set Size before binding, or call the grid's ResetColumns afterwards (see Troubleshooting). Every column is read-only because the class keeps the default ColumnReadOnly and SetCell.

The VirtualAndPlotGrid sample puts these pieces in one window. It has a million on-demand rows with bands, two frozen columns, validated editing and conditional colours, and a chart edited through a table.

Tutorial

Sample project: Memory grid export.

Goal: build an in-memory table of orders, show it through a grid with bands and totals, sort, filter and edit it, and export it to CSV, HTML, SVG and PNG without opening a window.

  1. Create a console application named MemoryGridExport (in the IDE: File > New > Console Application). Add the Atlas Virtual Grid src folder and the Atlas Plot src folder to the project's search path, or install the runtime packages as described in Getting started. Replace the generated code with the program header and two helpers. Verify stops the program when a result is not what this tutorial promises, and Rejected tells whether an action was refused with EAtlasGridError.

    pascal
    program MemoryGridExport;
    
    // Atlas Virtual Grid quick start: an in-memory data source, a grid bound to it,
    // sorting, filtering, totals, programmatic edits and CSV/HTML/SVG/PNG output.
    // No window is shown; the grid works without a parent. Every result is checked.
    
    {$APPTYPE CONSOLE}
    
    uses
      Winapi.Windows, System.SysUtils, System.Classes, System.IOUtils, Atlas.VGridData, Atlas.VGrid;
    
    procedure Verify(Condition: Boolean; const What: string);
    begin
      if not Condition then raise Exception.Create('Check failed: ' + What);
      Writeln('  ok: ', What);
    end;
    
    function Rejected(const Action: TProc): Boolean;
    begin
      Result := False;
      try
        Action();
      except
        on EAtlasGridError do Result := True;
      end;
    end;

    Winapi.Windows and System.Classes are listed so the compiler can inline TDirectory.GetCurrentDirectory and Columns.Count without a hint.

  2. Add the Run procedure, which owns the source and the grid, and the main block. The grid is created without an owner or a parent: nothing is shown. The main block fixes the global FormatSettings, so the formatted text checked below is the same on every machine.

    pascal
    procedure Run;
    var
      Orders: TAtlasMemoryData;
      Grid: TAtlasVirtualGrid;
      Csv, Html, Svg: string;
      Line: string;
    begin
      Orders := TAtlasMemoryData.Create(nil);
      Grid := TAtlasVirtualGrid.Create(nil);
      try
        // steps 3 to 9 go here
      finally
        Grid.Free;
        Orders.Free;
      end;
    end;
    
    begin
      // Formatting follows the global FormatSettings; fix them so the checks hold on every machine.
      FormatSettings := TFormatSettings.Invariant;
      try
        Run;
        Writeln('MemoryGridExport: all checks passed');
      except
        on E: Exception do
        begin
          Writeln('MemoryGridExport failed: ', E.ClassName, ': ', E.Message);
          ExitCode := 1;
        end;
      end;
    end.
  3. Add the columns and rows. Each column has a kind, and the order number is read-only. AddRow takes one invariant text per column, and empty text is null. A row with a value that does not fit its column is refused whole, so the source still has six rows afterwards.

    pascal
        Writeln('1. Columns and rows');
        Orders.AddColumn('Order', ckNumber, True);       // read-only key
        Orders.AddColumn('Customer');                    // ckText by default
        Orders.AddColumn('Shipped', ckDateTime);
        Orders.AddColumn('Quantity', ckNumber);
        Orders.AddColumn('Unit price', ckNumber);
        Orders.AddColumn('Paid', ckBoolean);
        Orders.BeginUpdate;                              // one change notification for the batch
        try
          // One invariant text per column: '.' decimals, ISO dates, yes/no. Empty text is null.
          Orders.AddRow(['1001', 'Contoso', '2026-01-05', '4', '125.50', 'yes']);
          Orders.AddRow(['1002', 'Fabrikam', '2026-01-06', '12', '40', 'no']);
          Orders.AddRow(['1003', 'Northwind', '2026-01-09', '1', '980', 'yes']);
          Orders.AddRow(['1004', 'Contoso', '2026-01-12', '', '125.50', 'no']);
          Orders.AddRow(['1005', 'Tailspin, Ltd', '2026-01-15', '7', '64.25', 'yes']);
          Orders.AddRow(['1006', 'Fabrikam', '', '3', '40', 'yes']);
        finally
          Orders.EndUpdate;
        end;
        Verify((Orders.RowCount = 6) and (Orders.ColumnCount = 6), '6 rows, 6 columns');
        Verify(Rejected(procedure begin Orders.AddRow(['1007', 'Adatum', '2026-02-01', 'twelve', '10', 'no']); end)
          and (Orders.RowCount = 6), 'a row with a value that does not fit its column is rejected whole');
  4. Bind the grid. It has no columns yet, so it creates one per data column. Then give the columns format masks, two bands and three totals. CellText returns what a user would see, and TotalText returns the footer.

    pascal
        Writeln('2. Bind the grid and describe the columns');
        Grid.Data := Orders;                             // one grid column per data column
        Grid.Columns[0].Format := '0';
        Grid.Columns[0].Total := tkCount;
        Grid.Columns[1].Band := 'Shipment';
        Grid.Columns[2].Band := 'Shipment';
        Grid.Columns[2].Format := 'yyyy-mm-dd';
        Grid.Columns[3].Band := 'Amount';
        Grid.Columns[3].Total := tkSum;
        Grid.Columns[4].Band := 'Amount';
        Grid.Columns[4].Format := '#,##0.00';
        Grid.Columns[4].Total := tkAverage;
        Verify(Grid.Columns.Count = 6, 'columns were created from the source');
        Verify((Grid.CellText(1, 4) = 'Tailspin, Ltd') and (Grid.CellText(4, 0) = '125.50') and
          (Grid.CellText(5, 1) = 'No') and (Grid.CellText(3, 3) = ''), 'cells are formatted per column');
        Verify((Grid.TotalText(0) = '6') and (Grid.TotalText(3) = '27') and (Grid.TotalText(4) = '229.21'),
          'totals: count 6, quantity sum 27, average price 229.21');

    The count skips nothing (six orders). The quantity sum skips the null quantity (4 + 12 + 1 + 7 + 3 = 27). The average price is 1375.25 / 6 = 229.21.

  5. Sort. Row numbers in the grid are rows as shown; DataRowOf gives the data row. The two Contoso orders keep their source order (data rows 0 and 3), and the null quantity sorts last when descending.

    pascal
        Writeln('3. Sort');
        Grid.Sort(1);                                    // by customer, ascending, ignoring case
        Verify((Grid.CellText(1, 0) = 'Contoso') and (Grid.DataRowOf(0) = 0) and (Grid.DataRowOf(1) = 3) and
          (Grid.CellText(1, 5) = 'Tailspin, Ltd'), 'ascending by customer; equal keys keep source order');
        Grid.Sort(3, True);                              // by quantity, descending
        Verify((Grid.CellText(3, 0) = '12') and (Grid.DataRowOf(5) = 3), 'descending by quantity; the null quantity comes last');
  6. Filter. The filter keeps rows where any visible column's formatted text contains the text, and the totals follow it. Because it reads the formatted text, 2026-01-0 finds the three orders shipped between the 1st and the 9th of January.

    pascal
        Writeln('4. Filter');
        Grid.FilterText := 'fab';                        // any visible column, formatted text, ignoring case
        Verify((Grid.RowCount = 2) and (Grid.DataRowOf(0) = 1) and (Grid.DataRowOf(1) = 5), '2 Fabrikam rows, still sorted');
        Verify((Grid.TotalText(0) = '2') and (Grid.TotalText(3) = '15') and (Grid.TotalText(4) = '40.00'), 'totals follow the filter');
        Grid.FilterText := '2026-01-0';
        Verify(Grid.RowCount = 3, 'the filter reads the formatted date text: 3 rows shipped before 10 January');
        Grid.FilterText := '';
        Grid.Sort(-1);                                   // back to source order
        Verify((Grid.RowCount = 6) and (Grid.SortColumn = -1), 'filter and sort removed');
  7. Edit through the data source. The grid listens to the source, so the cell and the total change at once. Text that does not parse is refused and the value is kept. The read-only column refuses writes from code too.

    pascal
        Writeln('5. Edit through the data source');
        Orders.SetText(3, 3, '5', TFormatSettings.Invariant);
        Verify((Grid.CellText(3, 3) = '5') and (Grid.TotalText(3) = '32'), 'the grid follows the source: sum is now 32');
        Verify(Rejected(procedure begin Orders.SetText(3, 3, 'five', TFormatSettings.Invariant); end) and
          (Grid.CellText(3, 3) = '5'), 'text that is not a number is refused and the value kept');
        Verify(Rejected(procedure begin Orders.SetCell(0, 0, TAtlasCell.OfNumber(1)); end), 'the read-only key column refuses writes');
  8. Copy and export text. SelectionText is what Ctrl+C copies: formatted and tab-separated. ToCSV carries raw values, quotes the comma in Tailspin, Ltd and leaves hidden columns out. ToHTML carries the bands and totals.

    pascal
        Writeln('6. Selection text');
        Grid.SelectAll;
        Line := Grid.SelectionText;
        Verify((Grid.SelectedCount = 6) and Line.StartsWith('1001'#9'Contoso'#9'2026-01-05'#9'4'#9'125.50'#9'Yes'#13#10),
          'tab-separated formatted rows, ready for the clipboard');
    
        Writeln('7. Text output');
        Csv := Grid.ToCSV;
        Writeln(Csv);
        Verify(Csv.StartsWith('Order,Customer,Shipped,Quantity,Unit price,Paid'#13#10'1001,Contoso,2026-01-05,4,125.5,true'),
          'CSV carries raw values: invariant numbers, ISO dates, true/false');
        Verify((Pos('"Tailspin, Ltd"', Csv) > 0) and (Pos('1006,Fabrikam,,3,40,true', Csv) > 0), 'commas are quoted; nulls are empty');
        Grid.Columns[5].Visible := False;
        Verify(Grid.ToCSV.StartsWith('Order,Customer,Shipped,Quantity,Unit price'#13#10), 'hidden columns are left out');
        Grid.Columns[5].Visible := True;
        Html := Grid.ToHTML('Orders');
        Verify((Pos('<caption>Orders</caption>', Html) > 0) and (Pos('<th colspan="2">Amount</th>', Html) > 0) and
          (Pos('<tfoot>', Html) > 0) and (Pos('>229.21<', Html) > 0), 'HTML has the caption, bands and totals');
  9. Write the files. CSV and HTML carry every row. SVG and PNG carry what fits in 800 x 300 pixels, starting at TopRow, and they show the selection, so it is cleared first. An unknown extension is refused.

    pascal
        Writeln('8. Pictures and files');
        Grid.ClearSelection;                             // pictures show the selection too
        Svg := Grid.ToSVG(800, 300);
        Verify((Pos('<svg', Svg) > 0) and (Pos('Northwind', Svg) > 0), 'SVG of the visible part of the grid');
        Grid.SaveToFile('orders.csv');                   // everything
        Grid.SaveToFile('orders.html');                  // everything, formatted, with totals
        Grid.SaveToFile('orders.svg', 800, 300);         // what is on screen
        Grid.SaveToFile('orders.png', 800, 300);
        for Line in ['orders.csv', 'orders.html', 'orders.svg', 'orders.png'] do
          Verify(TFile.Exists(Line) and (TFile.GetSize(Line) > 100), Line + ' written to ' + TDirectory.GetCurrentDirectory);
        Verify(Rejected(procedure begin Grid.SaveToFile('orders.xlsx'); end), 'unknown extensions are refused');
  10. Build and run it: press F9 in the IDE. Every step prints its checks, the CSV appears in the middle, and the last line is MemoryGridExport: all checks passed, with exit code 0. The CSV reads:

    Example
    Order,Customer,Shipped,Quantity,Unit price,Paid
    1001,Contoso,2026-01-05,4,125.5,true
    1002,Fabrikam,2026-01-06,12,40,false
    1003,Northwind,2026-01-09,1,980,true
    1004,Contoso,2026-01-12,5,125.5,false
    1005,"Tailspin, Ltd",2026-01-15,7,64.25,true
    1006,Fabrikam,,3,40,true

    orders.png shows the Shipment and Amount bands, check boxes in the Paid column and a footer with 6, 32 and 229.21. To adapt it, replace the AddRow calls with a loop over your own records. Then put the grid on a form (Grid.Parent := Self; Grid.Align := alClient;), where users can click headers to sort and press F2 to edit.

Troubleshooting

The grid shows no columns, or not the source's new columns. A grid creates columns only when a source is bound while Columns is empty, and not while a form is loading from a .dfm. Binding before the source has columns gives a grid with none, and columns added to the source later are not added to the grid. This is the usual case when both components are placed at design time and the columns are added in FormCreate. Call Grid.ResetColumns after adding the source's columns.

EAtlasGridError: "1250,50" does not fit column "Amount" from AddRow. AddRow reads invariant text: . as the decimal separator and ISO dates such as 2026-03-31. Format values with FloatToStr(Value, TFormatSettings.Invariant), or store typed values with SetCell and TAtlasCell.OfNumber.

Sort, FilterText or SelectCell seem to do nothing. An in-place edit is open with a value the source refused. These calls commit first and do nothing while the commit fails. Check Editing and LastError, then correct the text with SetEditorText or call CancelEdit.

Edits are refused with "This data source is read-only", or columns you wanted locked are editable. A TAtlasVirtualData without OnSetCell is read-only in every column, and with OnSetCell it is writable in every column. Assign the handler, and set ReadOnly on the grid columns that must stay locked. A column created with AddColumn(..., True) on TAtlasMemoryData refuses writes from code as well.

EInvalidOperation: Control ... has no parent window from BeginEdit. The in-place editor needs a window. Put the grid on a form before editing, or change values through the source (SetText, SetCell) in code that has no window.

An exported picture starts in the middle of the data, or shows highlighted rows. Pictures draw from the grid's own TopRow and LeftOffset, include the selection, and hold only what fits in the requested size. A grid that was never shown is small, so selecting a row may have scrolled it. Set TopRow := 0 and call ClearSelection before SaveToFile, or export CSV or HTML for every row.

A total stays blank. Sum and average apply only to number columns; minimum and maximum to number and date columns. Count works for every kind, and shows 0 when every cell is null; the other totals stay blank then. Every total stays blank when the column's DataIndex is beyond the source's columns.

Filtering or sorting a large on-demand source is slow. Both read every row once through OnGetCell, and the filter formats every visible cell. In the VirtualAndPlotGrid sample, a filter over a million computed rows took 1.6 to 1.7 s and a sort 1.0 to 1.4 s on Win32. Apply the filter on a button or after a pause in typing, keep OnGetCell cheap, and remember that every change to the source recomputes an active sort or filter.

The program asks for AtlasPlotRuntime370.bpl at start-up. When built with runtime packages, AtlasVGridRuntime370.bpl needs AtlasPlotRuntime370.bpl beside it or on the PATH. Deploy both, or link the units statically.

Columns look too narrow on a high-DPI monitor. Column widths are pixels and are not rescaled when the DPI changes. Scale them yourself, for example with MulDiv(100, Screen.PixelsPerInch, 96), or call AutoFitColumn once the grid shows data.