-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture and Advanced Usage
This page is for developers who want to understand how Blinki works internally, optimise performance, extend the framework, or write custom widgets.
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 entry point for any Blinki application is TTuiApp in Blinki.Core.App.
LApp := TTuiApp.Create;
LApp.SetRoot(LMyRootWidget);
LApp.Run; // blocks until Quit is called
LApp.Free;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
// 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)Input types are defined in Blinki.Core.Input.
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 combinations — Ctrl+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;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).
-
Tab / Shift-Tab are intercepted by
TTuiAppbefore 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)insideDoInit. All interactive built-in widgets do this automatically. - Mouse click on a focusable widget moves focus to it before dispatching the click event.
-
TTuiApp.PushModalpushes a new focus root; the background widgets no longer receive keyboard or mouse events untilPopModalis called.
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 LButtonDestroying the root (or calling LApp.Free) cascades through the entire tree — you never call
Free on individual widgets that have a parent.
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 |
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).
| 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 |
TTuiBoxas a panel/window:TTuiBoxdraws a titled border around exactly one child. It supports four border styles —bsSingle,bsDouble,bsRounded,bsHeavy— using Unicode box-drawing characters (e.g.┌─┐│└─┘forbsSingle).
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;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 blueuses 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.
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]
);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 widgetIn 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;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.
TTuiCanvas (Blinki.Core.Canvas) maintains two TTuiFrameBuffer instances:
-
FBack— the frame currently being drawn duringRenderFrame. -
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.
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.
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.
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.
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 UIsTickMs 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.
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.
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 is a record of class functions for code point and grapheme-cluster handling:
-
Code points —
NextCodePoint,CombineSurrogates,CodePointToString,IsHighSurrogate/IsLowSurrogatereconstruct full Unicode code points from UTF-16. -
Grapheme segmentation —
GraphemeLengthAt,NextGraphemeBoundary,PrevGraphemeBoundary,SnapToClusterStartfind cluster boundaries so cursor movement and text editing never land inside a ZWJ sequence or a combining mark. -
Width —
CodePointWidth,ClusterWidthAt,StringWidthreturn the visible terminal-column width of a code point, a cluster, or a whole string — the grapheme-aware replacement for the deprecatedTTuiAnsi.IsWideChar. -
Emoji detection —
IsEmojiPresentation,IsExtendedPictographic,IsRegionalIndicator,IsZWJ,IsVariationSelector15/16classify individual code points during segmentation. -
Terminal capability — the
EmojiLevelclass property (TTuiEmojiLevel) controls whether ZWJ sequences and flags are measured/rendered as single wide glyphs;DetectEmojiLevelprobes the terminal andApplyDetectedEmojiLevelapplies the result unless the application already setEmojiLevelexplicitly.
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.
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.
To create a widget that is not covered by the built-in set, subclass TTuiWidget and override the
hooks you need.
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);| 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) |
// 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.
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).
Blinki v0.1.0 · MIT License · Copyright © 2026 Marco Breveglieri · GitHub · Home · Getting Started · Architecture · Contributing