Skip to content

Architecture and Advanced Usage

Marco Breveglieri edited this page Jul 21, 2026 · 2 revisions

Architecture & Advanced Usage

This page is for developers who want to understand how Blinki works internally, optimise performance, extend the framework, or write custom widgets.


Layered Architecture

Blinki is organised into six stacked layers. Each layer depends only on the layers below it; application code touches only the top two.

┌─────────────────────────────────────────────────────────┐
│                    TTuiApp  (App layer)                  │  Blinki.Core.App
│  event loop · focus ring · modal stack · theme dispatch  │
├─────────────────────────────────────────────────────────┤
│               TTuiWidget  (Widget layer)                 │  Blinki.Core.Widget
│  widget tree · ownership · DoRender / DoHandleEvent      │  Blinki.Widgets.*
│  layout containers                                       │  Blinki.Layout.*
│  layout solver                                            │  Blinki.Layout.Solver
│  FX helpers (gradients, colour interpolation)             │  Blinki.FX.Gradient
├─────────────────────────────────────────────────────────┤
│              TTuiCanvas  (Canvas layer)                  │  Blinki.Core.Canvas
│  double-buffer · diff renderer · clip stack              │
├─────────────────────────────────────────────────────────┤
│   ITuiConsoleBackend  (Console abstraction layer)        │  Blinki.Core.Console
│  TTuiWindowsConsoleBackend  (Windows implementation)     │  Blinki.Core.Console.Windows
│  TTuiPosixConsoleBackend    (Linux64 implementation)     │  Blinki.Core.Console.Posix
│  escape-sequence decoder (unit-tested)                    │  Blinki.Core.Console.Sequences
├─────────────────────────────────────────────────────────┤
│          TTuiAnsi  (ANSI sequence builder)               │  Blinki.Core.Ansi
│  SGR · cursor · screen · alternate buffer · box chars    │
├─────────────────────────────────────────────────────────┤
│      TTuiStyle · TTuiColor · TTuiTheme  (Data layer)     │  Blinki.Core.Style
│  colours · text attributes · semantic theme palette      │  Blinki.Core.Theme
│  TTuiFrameBuffer · TTuiCell  (render model)              │  Blinki.Core.Render
│  grapheme/codepoint segmentation, emoji width             │  Blinki.Core.Unicode
│  `:shortcode:` emoji catalog                               │  Blinki.Core.Emoji
└─────────────────────────────────────────────────────────┘

Application code deals exclusively with TTuiApp, TTuiWidget subclasses, and the layout / widget units. The layers below are internal implementation details.


The Event Loop & TUI Lifecycle

The entry point for any Blinki application is TTuiApp in Blinki.Core.App.

Minimal usage

LApp := TTuiApp.Create;
LApp.SetRoot(LMyRootWidget);
LApp.Run;   // blocks until Quit is called
LApp.Free;

What TTuiApp.Run does

Run executes the following sequence. Teardown in the finally block is guaranteed even if a widget raises an exception during rendering or event handling.

Run
 ├─ SetupTerminal
 │   ├─ Backend.Open               -- enable VT processing, raw input, UTF-8 code page
 │   ├─ Write AlternateBufferOn    -- CSI ?1049h  (saves/restores the normal shell screen)
 │   ├─ Write CursorHide           -- CSI ?25l
 │   └─ Write ClearScreen          -- CSI 2J + CSI 1;1H
 │
 ├─ Create TTuiCanvas(Backend)     -- allocate front/back frame buffers
 │
 ├─ InitializeRunState
 │   ├─ FRoot.Init                 -- calls DoInit depth-first on the entire widget tree
 │   ├─ BuildFocusRing             -- pre-order traversal; collects focusable widgets
 │   └─ SelectFirstFocusable       -- gives focus to the first widget in the ring
 │
 ├─ LOOP while not FQuitRequested
 │   ├─ Backend.TryReadEvent(TickMs, LEvent)   -- blocks at most TickMs ms (default 50 ms)
 │   ├─ case LEvent.Kind of
 │   │   ekKey:   DispatchKey(LEvent.Key)
 │   │   ekMouse: DispatchMouse(LEvent.Mouse)
 │   ├─ OnTimer(ElapsedMs)          -- global timer handler
 │   ├─ FRoot.Tick(ElapsedMs)       -- DoTick on every widget (animations)
 │   ├─ Tick each active modal
 │   ├─ PollResize                  -- detect terminal resize; calls Canvas.HandleResize
 │   └─ RenderFrame
 │       ├─ Canvas.Clear
 │       ├─ FRoot.Render(Canvas, FullRect)
 │       ├─ ApplyDimOverlay if modal active (Canvas.DimRect)
 │       ├─ render each modal widget
 │       └─ Canvas.Flush            -- diff + single Write to backend
 │
 └─ finally TeardownTerminal
     ├─ FreeAndNil(FCanvas)
     ├─ Write AlternateBufferOff   -- CSI ?1049l  (restores the shell screen)
     ├─ Write CursorShow           -- CSI ?25h
     └─ Write Reset                -- SGR 0

Key TTuiApp properties and methods

// Timing
property TickMs: Integer;          // default 50 (= 20 fps); lower = more responsive but more CPU

// Root and modals
procedure SetRoot(AWidget: TTuiWidget; AOwnsRoot: Boolean = True);
procedure PushModal(AModal: TTuiWidget; AOwnsModal: Boolean = True);
procedure PopModal;
property ModalActive: Boolean;
property ModalDim: Boolean;        // default True — dims the background behind modals

// Theme
property Theme: TTuiTheme;         // set to TTuiTheme.Dark or TTuiTheme.Light at runtime

// Control
procedure Quit;
procedure Invalidate;              // mark the whole tree dirty; triggers a full re-render

// Handlers (anonymous methods)
property OnKeyPress: TTuiKeyHandler;   // reference to procedure(const AKey: TTuiKeyEvent)
property OnMouse: TTuiMouseHandler;    // reference to procedure(const AMouse: TTuiMouseEvent)
property OnTimer: TTuiTimerHandler;    // reference to procedure(AElapsedMs: Integer)
property OnResize: TTuiResizeHandler;  // reference to procedure(const ASize: TSize)

Keyboard & Mouse Input

Input types are defined in Blinki.Core.Input.

Keyboard

TTuiKeyCode = (
  kcNone, kcChar,           // printable character → read AKey.Character
  kcEnter, kcEscape, kcBackspace, kcTab, kcSpace,
  kcUp, kcDown, kcLeft, kcRight,
  kcHome, kcEnd, kcPageUp, kcPageDown,
  kcInsert, kcDelete,
  kcF1 .. kcF12
);

TTuiKeyModifier  = (kmShift, kmCtrl, kmAlt);
TTuiKeyModifiers = set of TTuiKeyModifier;

TTuiKeyEvent = record
  Code:      TTuiKeyCode;
  Character: Char;           // valid only when Code = kcChar; first UTF-16 unit for non-BMP chars
  CodePoint: TTuiCodePoint;  // full Unicode code point (emoji-safe); valid only when Code = kcChar
  Modifiers: TTuiKeyModifiers;
  function CharText: string; // the character as a ready-to-insert UTF-16 string (1-2 code units)
  function IsPrintable: Boolean;
  function ToString: string;
end;

CodePoint and CharText exist because a single Char cannot hold an emoji outside the Basic Multilingual Plane: backends reassemble surrogate pairs (Windows) or UTF-8 sequences (POSIX) into one event, so CharText returns the whole character — use it instead of Character whenever the input may contain emoji.

Detecting key combinations — from Demos\Tetris\Tetris.dpr:

LApp.OnKeyPress := procedure(const AKey: TTuiKeyEvent)
  begin
    case AKey.Code of
      kcEscape:
        if LGame.State <> gsPaused then
          LApp.Quit;
      kcChar:
        if UpCase(AKey.Character) = 'Q' then
          LApp.Quit;
    end;
  end;

Detecting Ctrl combinationsCtrl+Q generates kcChar with Character = #17 (ASCII 17 = Q − 64):

if (AKey.Code = kcChar) and (kmCtrl in AKey.Modifiers) and (AKey.Character = #17) then
  LApp.Quit;

Mouse

TTuiMouseButton    = (mbNone, mbLeft, mbRight, mbMiddle);
TTuiMouseEventKind = (mekDown, mekUp, mekMove, mekWheel);

TTuiMouseEvent = record
  X, Y:        Integer;           // 0-based column / row
  Button:      TTuiMouseButton;
  Kind:        TTuiMouseEventKind;
  WheelDelta:  Integer;           // +1 = scroll up, -1 = scroll down
  Modifiers:   TTuiKeyModifiers;
end;

Mouse coordinates are 0-based (column 0 is the leftmost character cell; row 0 is the top row).

Focus management

  • Tab / Shift-Tab are intercepted by TTuiApp before reaching any widget and advance the focus ring — a pre-order depth-first list of all focusable widgets built at startup.
  • A widget becomes focusable by calling SetFocusable(True) inside DoInit. All interactive built-in widgets do this automatically.
  • Mouse click on a focusable widget moves focus to it before dispatching the click event.
  • TTuiApp.PushModal pushes a new focus root; the background widgets no longer receive keyboard or mouse events until PopModal is called.

Widget Tree & Layout

The widget base class

All widgets descend from the abstract class TTuiWidget (Blinki.Core.Widget). The tree is an ownership hierarchy: every widget owns its children via TObjectList<TTuiWidget>(OwnsObjects := True).

Creating a child:

LButton := TTuiButton.Create(LParentWidget);  // LParentWidget takes ownership of LButton

Destroying the root (or calling LApp.Free) cascades through the entire tree — you never call Free on individual widgets that have a parent.

Layout containers

Containers are in Blinki.Layout.*. They compute the bounding TRect of each child and call Render with it.

Class Unit Behaviour
TTuiVStack Blinki.Layout.Stack Stacks children top-to-bottom
TTuiHStack Blinki.Layout.Stack Stacks children left-to-right
TTuiGrid Blinki.Layout.Grid Row/column grid
TTuiScrollable Blinki.Layout.Scrollable Scrolls a single child that is taller/wider than the viewport

Layout constraints

Widgets do not have Left, Top, Width, or Height properties. Instead each widget declares a LayoutConstraint that tells its parent container how to size it.

property LayoutConstraint: TTuiLayoutConstraint;  // default = Fill(1)
// Fixed size — exactly 1 row
LHeader.LayoutConstraint := TTuiLayoutConstraint.Fixed(1);

// Fill remaining space with weight 1 (equal share among Fill siblings)
LBody.LayoutConstraint := TTuiLayoutConstraint.Fill(1);

// Fill with weight 2 — twice as large as a Fill(1) sibling
LMainPanel.LayoutConstraint := TTuiLayoutConstraint.Fill(2);

// At least 3 rows
LDropdown.LayoutConstraint := TTuiLayoutConstraint.Min(3);

// Exactly 40% of the container
LSidebar.LayoutConstraint := TTuiLayoutConstraint.Percentage(40);

TTuiLayoutSolver (Blinki.Layout.Solver, internal to the layout containers) resolves these constraints using a two-pass algorithm: first pass allocates Fixed/Min/Max/Percentage sizes; second pass distributes the remaining space proportionally among Fill children.

The widget's actual rectangle after the last render is available read-only as LastRect: TRect (coordinates are 0-based relative to the terminal screen).

Built-in widget reference

Widget Unit Key properties Events
TTuiLabel Blinki.Widgets.Labels Text, Style
TTuiButton Blinki.Widgets.Button Caption, NormalStyle, FocusedStyle OnClick: TProc
TTuiBox Blinki.Widgets.Box Title, BoxStyle, BorderStyle
TTuiTextInput Blinki.Widgets.TextInput Text, Placeholder, PasswordChar, MaxLength OnTextChanged, OnSubmit
TTuiTextArea Blinki.Widgets.TextArea Text, ReadOnly OnTextChanged
TTuiCheckbox Blinki.Widgets.Checkbox Caption, Checked OnToggle: TProc<Boolean>
TTuiRadioButton Blinki.Widgets.RadioButton Caption, Checked, Group OnSelect: TProc
TTuiSelect Blinki.Widgets.Select Items, ItemIndex OnChange: TProc<Integer>
TTuiTabs Blinki.Widgets.Tabs AddTab(caption, child), ActiveIndex OnChange: TProc<Integer>
TTuiProgressBar Blinki.Widgets.ProgressBar Value: Single (0..1), ShowPercentage
TTuiTable Blinki.Widgets.Table Columns, Rows, SelectedRow
TTuiToast Blinki.Widgets.Toast DurationMs
TTuiDialog Blinki.Widgets.Dialog Title, Buttons, Shadow OnClose: TTuiDialogCloseEvent

TTuiBox as a panel/window: TTuiBox draws a titled border around exactly one child. It supports four border styles — bsSingle, bsDouble, bsRounded, bsHeavy — using Unicode box-drawing characters (e.g. ┌─┐│└─┘ for bsSingle).

Modal dialogs

TTuiApp.PushModal renders a widget on top of the root and restricts keyboard/mouse focus to it:

var LDialog: TTuiDialog;
begin
  LDialog := TTuiDialog.Create;
  LDialog.Title := ' Confirm ';
  LDialog.OnClose := procedure(AResult: TTuiDialogResult)
    begin
      LApp.PopModal;
      if AResult = drOK then
        DoDelete;
    end;
  LApp.PushModal(LDialog);  // App owns LDialog; PopModal will free it
end;

Colors, Styles & Themes

TTuiColor — four colour modes

TTuiColorKind = (ckDefault, ck16, ck256, ckRGB);

// Terminal default colour (inherits the user's terminal theme)
TTuiColor.Default

// One of the 16 standard ANSI colours (index 0–15)
TTuiColor.Standard(0)    // Black
TTuiColor.Standard(9)    // Bright Red

// xterm 256-colour palette (index 0–255)
TTuiColor.Palette(196)   // Red

// 24-bit True Color (Windows 10 v1903+)
TTuiColor.RGB($56, $9C, $D6)   // Blinki primary blue

TTuiColors — named ANSI constants

uses Blinki.Core.Style;

LStyle.Foreground := TTuiColors.BrightWhite;
LStyle.Background := TTuiColors.Black;

Available constants: Black, Red, Green, Yellow, Blue, Magenta, Cyan, White, BrightBlack (dark grey), BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite.

TTuiStyle — a complete cell style

TTuiTextAttr = (taBold, taDim, taItalic, taUnderline, taBlink, taInverse, taStrikethrough);
TTuiTextAttrs = set of TTuiTextAttr;

TTuiStyle = record
  Foreground: TTuiColor;
  Background: TTuiColor;
  Attributes: TTuiTextAttrs;

  class function Default: TTuiStyle; static;
  class function Create(AFg, ABg: TTuiColor;
                        AAttrs: TTuiTextAttrs = []): TTuiStyle; static;
end;

// Example — bold white on a dark blue background
var LStyle := TTuiStyle.Create(
  TTuiColor.RGB($FF, $FF, $FF),
  TTuiColor.RGB($1E, $3A, $5F),
  [taBold]
);

TTuiTheme — semantic colour palette

Instead of hardcoding colours in widgets, always derive them from the active theme:

TTuiTheme = record
  Primary, Secondary: TTuiColor;   // brand / accent colours
  Success, Warning, Error: TTuiColor;
  Background, Surface: TTuiColor;  // page background vs. card/panel surface
  Text, TextDim: TTuiColor;        // primary text vs. muted/placeholder text
  Border: TTuiColor;

  class function Dark: TTuiTheme; static;    // default — VS Code Dark+ inspired
  class function Light: TTuiTheme; static;
  class function Default: TTuiTheme; static; // alias for Dark
end;

Switching theme at runtime propagates to every widget in the tree:

LApp.Theme := TTuiTheme.Light;
// Internally: LApp.Root.ApplyTheme(LApp.Theme)  → recursive DoApplyTheme on every widget

In a custom widget's DoApplyTheme, rebuild your cached TTuiStyle values from the new theme instead of relying on hardcoded colours:

procedure TMyWidget.DoApplyTheme(const ATheme: TTuiTheme);
begin
  inherited;
  FNormalStyle  := TTuiStyle.Create(ATheme.Text, ATheme.Surface);
  FFocusedStyle := TTuiStyle.Create(ATheme.Background, ATheme.Primary);
end;

Performance & Screen Refresh

Blinki's rendering pipeline is designed to eliminate flicker and minimise the number of bytes written to the terminal — the slowest part of any TUI.

Double-buffer

TTuiCanvas (Blinki.Core.Canvas) maintains two TTuiFrameBuffer instances:

  • FBack — the frame currently being drawn during RenderFrame.
  • FFront — the last frame that was actually emitted to the terminal.

After each widget calls DoRender, Canvas.Flush compares FBack to FFront cell by cell (TTuiCell = Character + TTuiStyle). It emits ANSI sequences only for cells that differ.

Diff renderer — what Flush emits

For each cell (X, Y) where FBack[X,Y] ≠ FFront[X,Y]:
  if not adjacent to the previous written cell:
    emit CursorTo(Y+1, X+1)   -- ANSI positions are 1-based
  if style changed:
    emit ApplyStyleDelta(prev_style, new_style)   -- minimal SGR sequence
  emit Character
  advance column (+2 for wide CJK characters)

Emit the entire sequence in ONE Backend.Write call, then Backend.Flush.
Copy FBack → FFront.

The single-write approach prevents the terminal emulator from partially rendering a frame.

Dirty flag

Every widget has a Dirty flag. Invalidate propagates upward to the root. RenderFrame calls Canvas.Clear unconditionally (filling the back-buffer with the default cell), then re-renders the entire tree. Canvas.Flush exits early with no I/O if Dirty is False.

To trigger a repaint from inside a widget, call Invalidate. The framework calls it automatically when you assign to a property via a guard-and-invalidate setter.

Wide characters

TTuiAnsi.IsWideChar is deprecated — it only sees a single UTF-16 code unit, so it misses surrogate pairs and grapheme clusters (every non-BMP emoji). Use TTuiUnicode.CodePointWidth / TTuiUnicode.ClusterWidthAt instead: they are grapheme-aware, computing the visible width of a whole cluster (base character + combining marks, ZWJ sequences, flags) rather than one code unit at a time. Canvas.WriteAt and Canvas.Flush handle wide clusters correctly — a wide glyph is written once and the following column(s) are set to a blank placeholder so the diff renderer does not attempt to write to a half-cell.

Tuning frame rate

LApp.TickMs := 16;   // ~60 fps — smooth animations, higher CPU
LApp.TickMs := 50;   // 20 fps  — default; fine for most forms
LApp.TickMs := 100;  // 10 fps  — adequate for static UIs

TickMs is also the maximum latency for keyboard and mouse events, because TryReadEvent blocks for up to TickMs milliseconds waiting for input. Lower values improve input responsiveness but increase CPU usage.

Alternate screen buffer

On startup TTuiApp writes CSI ?1049h (AlternateBufferOn), which saves the current terminal contents and switches to a fresh blank screen. On exit CSI ?1049l (AlternateBufferOff) restores the original contents. This means Blinki applications leave no trace in the terminal scrollback — exactly like vim, htop, or less.


Emoji & Unicode

Blinki renders emoji and other complex Unicode text grapheme-aware, not code-unit-aware: a family emoji joined by ZWJ, a flag made of two regional indicators, or a skin-tone modifier applied to a base emoji must all be measured and drawn as a single cluster, or the diff renderer would split them across cells.

TTuiUnicode (Blinki.Core.Unicode)

TTuiUnicode is a record of class functions for code point and grapheme-cluster handling:

  • Code pointsNextCodePoint, CombineSurrogates, CodePointToString, IsHighSurrogate / IsLowSurrogate reconstruct full Unicode code points from UTF-16.
  • Grapheme segmentationGraphemeLengthAt, NextGraphemeBoundary, PrevGraphemeBoundary, SnapToClusterStart find cluster boundaries so cursor movement and text editing never land inside a ZWJ sequence or a combining mark.
  • WidthCodePointWidth, ClusterWidthAt, StringWidth return the visible terminal-column width of a code point, a cluster, or a whole string — the grapheme-aware replacement for the deprecated TTuiAnsi.IsWideChar.
  • Emoji detectionIsEmojiPresentation, IsExtendedPictographic, IsRegionalIndicator, IsZWJ, IsVariationSelector15 / 16 classify individual code points during segmentation.
  • Terminal capability — the EmojiLevel class property (TTuiEmojiLevel) controls whether ZWJ sequences and flags are measured/rendered as single wide glyphs; DetectEmojiLevel probes the terminal and ApplyDetectedEmojiLevel applies the result unless the application already set EmojiLevel explicitly.

TTuiEmoji (Blinki.Core.Emoji)

TTuiEmoji provides a :shortcode: catalog on top of TTuiUnicode:

uses Blinki.Core.Emoji;

var LText := TTuiEmoji.Expand('deploy :rocket: complete :white_check_mark:');
// 'deploy 🚀 complete ✅'
  • Expand(AText) replaces every :shortcode: occurrence in a string with its emoji glyph.
  • Find(AName) resolves a single shortcode to its glyph (empty string if unknown).
  • Count / Entry(AIndex) expose the underlying catalog for enumeration or tooling.

FX / Gradients

Blinki.FX.Gradient adds true-color gradient rendering on top of the canvas and colour layers. Both functions require ckRGB colours — passing a ck16/ck256/ckDefault colour raises ETuiFX, since a gradient needs continuous RGB components to interpolate.

uses Blinki.FX.Gradient;

// Linearly interpolate between two RGB colours (AT in [0.0 .. 1.0])
var LMid := LerpColor(TTuiColor.RGB($56, $9C, $D6), TTuiColor.RGB($E6, $2E, $2E), 0.5);

// Draw text with the foreground colour interpolated character by character
DrawGradient(ACanvas, 0, 0, 'Blinki', TTuiColor.RGB($56, $9C, $D6),
  TTuiColor.RGB($E6, $2E, $2E), TTuiColor.Default);

DrawGradient walks AText one grapheme cluster at a time (via TTuiUnicode.GraphemeLengthAt / ClusterWidthAt) rather than one Char at a time, so wide glyphs and emoji keep their head-plus-continuation cells intact and the colour ramp stays linear across terminal columns instead of resetting mid-cluster.


Custom Widgets

To create a widget that is not covered by the built-in set, subclass TTuiWidget and override the hooks you need.

Minimal example — a coloured status bar

unit MyStatusBar;

interface

uses
  System.Types,
  Blinki.Core.Widget,
  Blinki.Core.Canvas,
  Blinki.Core.Event,
  Blinki.Core.Style,
  Blinki.Core.Theme;

type
  TMyStatusBar = class(TTuiWidget)
  strict private
    FText: string;
    FStyle: TTuiStyle;
    procedure SetText(const AValue: string);
  protected
    procedure DoInit; override;
    procedure DoRender(const ACanvas: TTuiCanvas; const ARect: TRect); override;
    procedure DoApplyTheme(const ATheme: TTuiTheme); override;
  public
    property Text: string read FText write SetText;
  end;

implementation

procedure TMyStatusBar.DoInit;
begin
  inherited;
  // This widget does not accept keyboard focus
end;

procedure TMyStatusBar.DoApplyTheme(const ATheme: TTuiTheme);
begin
  inherited;
  FStyle := TTuiStyle.Create(ATheme.Background, ATheme.Primary);
end;

procedure TMyStatusBar.DoRender(const ACanvas: TTuiCanvas; const ARect: TRect);
begin
  ACanvas.FillRect(ARect, ' ', FStyle);        // paint the background
  if FText <> '' then
    ACanvas.WriteAt(ARect.Left + 1, ARect.Top, FText, FStyle);
end;

procedure TMyStatusBar.SetText(const AValue: string);
begin
  if FText = AValue then
    Exit;
  FText := AValue;
  Invalidate;  // request a repaint
end;

end.

Usage:

LBar := TMyStatusBar.Create(LRoot);
LBar.Text := ' Ready';
LBar.LayoutConstraint := TTuiLayoutConstraint.Fixed(1);

All overridable hooks

Method signature When called Common use
procedure DoInit; virtual; Once, after SetRoot, before the first render SetFocusable(True), allocate child widgets
procedure DoRender(const ACanvas: TTuiCanvas; const ARect: TRect); virtual; abstract; Every dirty frame Draw the widget
function DoHandleEvent(const AEvent: TTuiEvent): Boolean; virtual; On keyboard or mouse event Return True if the event was consumed
procedure DoApplyTheme(const ATheme: TTuiTheme); virtual; When TTuiApp.Theme changes Rebuild TTuiStyle from theme colours
procedure DoTick(AElapsedMs: Integer); virtual; Every TickMs ms Drive animations, poll model state
function IsChildFocusTraversable(AIndex: Integer): Boolean; virtual; During focus ring build Return False to exclude a child tab from focus (used by TTuiTabs)

TTuiCanvas drawing API (inside DoRender)

// Fill a rectangle with a character and style
ACanvas.FillRect(ARect, ' ', AStyle);

// Write text at a 0-based (X, Y) position
ACanvas.WriteAt(ARect.Left, ARect.Top, LText, AStyle);

// Draw a border box with an optional title
ACanvas.DrawBox(ARect, bsRounded, ' My Title ', ABorderStyle);

// Push / pop a clipping region (subsequent writes outside ARect are clipped)
ACanvas.PushClip(ARect);
try
  // draw clipped content
finally
  ACanvas.PopClip;
end;

All coordinates are 0-based (column 0 = leftmost, row 0 = topmost). The canvas converts them to ANSI 1-based sequences internally during Flush.

Timer-driven animation (DoTick)

procedure TMySpinner.DoTick(AElapsedMs: Integer);
const
  FRAMES: array[0..3] of Char = ('|', '/', '-', '\');
begin
  Inc(FElapsed, AElapsedMs);
  if FElapsed >= 100 then  // advance every 100 ms
  begin
    FElapsed := 0;
    FFrameIndex := (FFrameIndex + 1) mod Length(FRAMES);
    FCurrentChar := FRAMES[FFrameIndex];
    Invalidate;
  end;
end;

For a real-world example see Demos\TeamChat\TeamChat.View.pas (TChatView.DoTick animates the "is typing…" indicator) or Demos\Tetris\Tetris.View.pas (TTetrisBoardView.DoTick drives the game loop).