Overview
Recoil’s GUI stack has three layers. VID (gui/vid) builds a face tree from a pure-data block. Draw (draw) lowers a block of graphics commands to path calls at compile time. A backend (recoil-gui) supplies the window, drawing and input. Application code imports gui/gui and never names the provider.
Backends
A bare r3 -s recoil.r3 install gui selects Cocoa on macOS, GTK on Linux and Win32/GDI+ on Windows. Pick another same-platform provider explicitly, e.g. install gui/raylib, gui/cocoa, gui/gtk or gui/windows.
| Backend | Host | Standalone | Embedded context | Notes |
|---|---|---|---|---|
| raylib | Linux, macOS, wasm | yes | no | OpenGL; portable provider |
| Cocoa | macOS | yes | no | native AppKit drawing and dialogs |
| GTK 3.24 | Linux | yes | no | GTK/GDK, Cairo and Pango |
| Pugl | macOS plug-in builds | no | yes | host-parented CLAP editor path |
| Win32/GDI+ | Windows | yes | yes | native windowing, dialogs and child HWND contexts |
Embedded contexts. The gui-context-* API creates independent host-parented surfaces. Each owns its renderer and input edges, is advanced by the host through gui-context-tick, and must attach, tick and detach on the native parent’s UI thread. Detaching from inside the frame callback is unsupported. Windows and the macOS Pugl provider implement contexts; the others keep ABI-compatible stubs.
Windows install detail. install gui/windows selects recoil-gui-backend-windows for native Windows or the windows-msvc target. The package builds a C11 static backend and exports the user32, gdi32, gdiplus and comdlg32 system libraries to the final link. Standalone windows and child HWND contexts share one persistent ARGB backbuffer, the same drawing implementation, Unicode input handling and native file chooser support. Multiple handle contexts may coexist; standalone and legacy singleton operation must not be mixed with them.
GTK. Needs libgtk-3-dev (Debian/Ubuntu) or gtk3-devel (Fedora) plus pkg-config. GDK picks the display protocol; constrain it with GDK_BACKEND=x11 or wayland. GTK is standalone-only in 0.12. Windows. The backend does not change process DPI awareness or create a message loop: the host supplies an HWND and calls gui-context-tick on its UI thread.
Smoke tests & CI recipes
Windows
Run the standard VID widgets, then the deterministic Recoil-authored embedding smoke (examples/vid-smoke and examples/gui-context-smoke):
cd examples/vid-smoke
r3 -s ../../recoil.r3 install gui/windows
r3 -s ../../recoil.r3 fetch
r3 -s ../../recoil.r3 compile --output vid-smoke.exe main.rcl
./vid-smoke.exe
cd examples/gui-context-smoke
r3 -s ../../recoil.r3 install gui/windows
r3 -s ../../recoil.r3 fetch
r3 -s ../../recoil.r3 compile --output gui-context-smoke.exe main.rcl
./gui-context-smoke.exe
Success ends with host-context-smoke=ok. Linux CI cross-compiles and links the backend with MinGW under strict warnings; release validation still requires the MSVC build plus both runtime smokes on Windows 10 or 11 x64.
GTK
Linux CI compiles the Recoil VID widget demo through recoil.r3, then runs it under Xvfb with GDK_BACKEND=x11 and injects mouse, character and Backspace input using xdotool, with a 30-second deadline. Native Wayland validation uses the same executable without Xvfb:
cd examples/vid-smoke
r3 -s ../../recoil.r3 install gui/gtk
r3 -s ../../recoil.r3 fetch
r3 -s ../../recoil.r3 compile --output vid-smoke main.rcl
GDK_BACKEND=wayland ./vid-smoke
Record a clean render, input and Close-button run on a native Wayland session as the manual release smoke; Xvfb cannot substitute for it, and no Wayland-specific code or build is involved.
VID: faces from data
view builds a runtime face tree from a pure-data block. A face may start with a set-word, which is its query name:
view [
across
text "volume" 80x20
vol: slider 200x20 on-change 'volume-changed
return
text "name" 80x24
name-field: field 200x24 on-change 'name-changed
]
print face-data 'vol
print face-text 'name-field
The face name is optional and must precede the face kind. (The old name 'vol facet is no longer accepted.)
Flow
Layout starts at 8x8 with an 8x8 gap and defaults to below. Every face advances the cursor.
across/belowpick the direction without moving the cursor.returnstarts the next row (across) or column (below). A row advances by its tallest face, a column by its widest.at 100x40sets a new cursor and return guide, keeping the direction.
Actors
Faces name handlers with word facets: on-click 'save, on-change 'volume-changed, on-draw 'meter. Register the handler before opening the view. The actor name is independent of the set-word face name.
The data facet
A face’s numeric facet is data, an f64! normalized to 0.0 – 1.0, as in Rebol/Red. Register a handler with actor-data and read or write it with face-data / set-face-data:
on-volume: func [v [f64!] "New normalized volume." return: [none!]] [
set-gain v * v ; the application owns its own curve
return none
]
actor-data 'volume-changed :on-volume
The facet is deliberately linear. Perceptual curves (log cutoff, exponential times) belong in the application, because a plug-in host writes parameters directly and never travels through VID. Dragging clamps data to 0–1; set-face-data does not, so a custom-drawn face may carry application-encoded state.
Changing a face at runtime
set-face-data 'vol 0.75
set-face-text 'preset "Bell Piano" ; overrides the declared label
set-face-draw 'meter 'other-actor
Parameters & the edit gesture
A face that drives a plug-in parameter says so with param (an i32!, default -1 = drives nothing) and names its handlers:
cutoff: knob "CUTOFF" 88x88
param 7
on-edit 'edited
on-default 'defaulted
on-format 'shown
A plug-in host records an automation edit only between a begin and an end, so a bare stream of changes is dropped. VID therefore reports the pointer as three phases: press fires ph-begin, every frame of the drag ph-change, release ph-end with the settled value.
on-edited: func [
id [u32!] "The face's param id."
normalized [f64!] "New value in 0.0 - 1.0."
phase [i32!] "ph-begin, ph-change or ph-end."
return: [none!]
] [
set-engine-parameter id normalized
host-edit id normalized phase
return none
]
actor-edit 'edited :on-edited
Knobs and faders share the gesture: vertical, relative to the value and pointer at contact, 200 px for full travel, up increases, Shift makes it ten times finer.
| Facet | Role |
|---|---|
on-edit | receives id normalized phase for each gesture phase |
on-default | a query for the parameter’s default (from the plug-in, so it is stated once). A double-click plays the whole begin/change/end around the answer, so the reset is one recordable edit |
on-value | reads the value from the plug-in once per frame, before drawing, so automation, preset loads and host writes are never masked by a cached copy |
on-format | renders the line drawn under the control (kHz, ms, %); the plug-in owns how a value reads. widget-format-number in gui/widget is the shared snprintf half |
All three receive the face’s param id, so one registration serves every control — and, with view-current-user, every plug-in instance.
Controls & layout
fader, radius and centre
fader is a custom-drawn rectangular face that takes the ordinary label, size, style, param and the actor facets. The entire rectangle is grabbable, and contact never jumps the handle to the pointer. Caption and readout sit inside the declared rectangle, so flow advances by exactly the size plus the gap. style loos selects the paper/oxblood treatment.
at 180x43 across
fader "LEVEL" 56x132 param 8 on-edit 'edited on-value 'reads
centre 110x190 knob "CUTOFF" radius 50 param 7 on-edit 'edited
radius <i32!> sizes a face to a square of twice it. centre <pair!> places a face on a point; it neither reads nor advances the cursor. Both work on any face kind.
panel
panel <pair!> <pair!> is a rectangle and a scope. With a body block it becomes a group: children are placed relative to its origin and it is their parent. With a colour it is also a background, a wash or a one-pixel rule.
panel 0x0 760x520 colour panel [
panel 12x12 736x496 gradient wash-top wash-bottom
panel 24x82 712x1 colour rule
at 32x43 text "TITLE" font 30 colour ink
]
hidden on a panel hides the whole group from rendering and hit-testing — unlike a leaf face, where it keeps an invisible-but-clickable region. The store is flat (a panel records a parent index); nesting resolves to a depth of 8.
grid
grid <columns> <pair!> lays faces out on a lattice: column count, then cell pitch. The origin is the centre of the first cell, since a grid exists to place round controls. A zero pitch component never advances that axis. centre still overrides it for one face.
at 110x190 grid 4 180x225
knob "CUTOFF" radius 50 param 7 on-edit 'edited
knob "LEVEL" radius 50 param 8 on-edit 'edited
colour, gradient and font
colour <word!> names a palette entry owned by widget-colour: panel, wash-top, wash-bottom, ink, muted, rule, accent, accent-bright. The loos-* names select a drafting palette. An unknown name resolves to ink. colour is an override: a face that names none keeps its kind’s look. gradient <word!> <word!> fills a panel vertically between two entries; font <i32!> sets a text face’s point size (default 16).
Tabs, pages & envelopes
tabs <pos> <size> on-change 'word draws a strip of tabs, one per page, and owns the selection. A page takes no geometry; its string is the tab caption. There is one strip per view.
at 360x26 tabs 116x28 on-change 'paged
page "Global" [ ... ]
page "Oscillator 1" [ ... ]
Only the selected page renders and hit-tests: a page is a panel with an ordinal, and a panel is closed if it is hidden or its page is not selected. One rule keeps the draw and hit-test passes in agreement.
env-preview <size> "Name" param <attack> depth <amount> plots one ADSR envelope and selects it; env-editor <size> is the large shared editor for whichever preview is selected. An envelope’s four stages are param and the three ids after it; depth is separate because it may live elsewhere or not exist. Stages are read back through on-value each frame, normalized.
env-preview 88x48 "Amplitude" param 20 on-value 'reads
env-preview 88x48 "Filter" param 30 depth 40 on-value 'reads
at 260x96 env-editor 380x320 on-edit 'edited on-value 'reads
Guarding a declared layout
view-parameter-tally <param> returns how many faces of the just-built view drive one parameter. A declared layout can silently miss a parameter (unreachable) or cover one twice (two controls fighting); the dialect answers because it owns what a face covers — a knob drives one, an envelope preview four.
repeat step total [
if (view-parameter-tally (step - 1)) <> 1 [ ... refuse the attach ... ]
]
Embedding a view
view-context-attach renders a spec into a host-owned parent as its own child surface. The embedder supplies native-parent and editor-state, drives redraws with view-context-tick, and tears down with view-context-detach.
This Recoil-only example uses fixed mock parameter values, so its layout and actors have no instrument or engine dependency:
demo-edit: func [id [u32!] value [f64!] phase [i32!] return: [none!]] [
print rejoin ["parameter " id " = " value " (phase " phase ")"]
return none
]
demo-value: func [id [u32!] return: [f64!]] [
return 0.5
]
demo-default: func [id [u32!] return: [f64!]] [
return 0.5
]
demo-format: func [id [u32!] value [f64!] return: [string!]] [
return widget-format-number (value * 100.0) "%.0f%%"
]
actor-edit 'edited :demo-edit
actor-value 'reads :demo-value
actor-default 'defaulted :demo-default
actor-format 'shown :demo-format
editor: view-context-attach [
panel 0x0 480x280 colour panel [
at 24x24 text "Control panel" font 24 colour ink
at 48x90 across
cutoff: knob "CUTOFF" 88x88 param 7
on-edit 'edited on-default 'defaulted
on-value 'reads on-format 'shown
level: fader "LEVEL" 56x132 param 8
on-edit 'edited on-default 'defaulted
on-value 'reads on-format 'shown
]
] :native-parent 480 280 :editor-state
view-current-user. And a named parameter id goes in parentheses: the spec is a quoted block, so a bare word arrives as a word! and will not match the i32! the grammar wants. Write param (as i32! Param!/cutoff) and keep the ids in an enum!.Draw: graphics as a dialect
draw renders a block of graphics commands. The block is vocabulary, not code, and it lowers at compile time into calls on the backend path contract, like parse, fsm! and fst!. There is no interpreter and no block walked per frame; every operand is type-checked at its call site.
draw :context [
fill-pen panel rounded-box (fxy 12.0 12.0) (fxy 220.0 180.0) 8
pen accent line-width 5
arc centre radius start stop
]
Operands are ordinary expressions, so geometry stays dynamic while structure stays static. A plain-word point is read directly; any other expression is bound to a temporary first, so circle (pair-add centre offset) r calls pair-add once. Because the block is lowered it must be literal at the call site: use control flow around draw, or several blocks.
Points and rectangles
Every coordinate is one point from std/geometry, so a width cannot land where a height belongs.
| Type | Components | For |
|---|---|---|
fpair! | x y as f64! | vector coordinates — everything Draw takes |
ipair! | x y as i32! | pixel grids, native widget positions |
rect! | origin size (both fpair!) | a laid-out area |
centre: fxy 122.0 96.0 ; fpair!
cell: xy 10 20 ; ipair!
plot: rect-of 24.0 74.0 400.0 100.0
pair-add, pair-sub, pair-scale, pair-equal? work componentwise (ipair-* for ipair!); rect-contains? counts edges as inside. fpair! is f64! because decimal literals are f64!; Draw casts to f32! once, at the backend boundary.
pen and fill-pen
Both take a packed 0xRRGGBBAA value, produced by a tuple! literal (packed at compile time, alpha defaults to 255) or gui-rgb. Reserve gui-rgb for computed channels: every Draw command has fixed arity, so fill-pen gui-rgb 26 30 35 255 is P1304 and must be hoisted to a local.
draw :context [
fill-pen 26.30.35.255 ; same as gui-rgb 26 30 35 255
pen 214.155.56 ; alpha 255
circle centre 24
]
A shape is stroked if a pen appears before it and filled if a fill-pen does; a shape with neither is a compile error.
off. Here the decision is lexical: it depends on which appear earlier in the block text, and there is no off. A pen set near the top strokes every later shape — scope it with push — and painting cannot be switched on a condition; choose between two blocks or two colours.Shapes
| Command | Operands | Painting |
|---|---|---|
line | from to | stroked |
box | origin size | fill and/or stroke |
rounded-box | origin size radius | fill and/or stroke |
circle | centre radius | fill and/or stroke |
ellipse | centre radii | fill and/or stroke; radii is a point |
arc | centre radius start end | stroked; radians |
triangle | a b c | fill and/or stroke |
curve | from control-1 control-2 to | stroked cubic bezier |
text | position size string | fill-pen; y is the top of the line |
circle is sugar over ellipse and box over a rectangle sub-path: shapes are expansions in the dialect, not backend primitives, so a new shape costs one expansion, not one per backend.
Traced paths: shape
Named shapes each own a path. A curve of several pieces — an envelope, a waveform — uses shape, which builds one path and paints it once. Segments: move point (must come first), line point, quad control point, curve c1 c2 point, close.
draw :context [
pen accent
line-width 2
shape [
move p/start
quad p/attack-ctrl p/peak
quad p/decay-ctrl p/sustain-in
line p/sustain-out
quad p/release-ctrl p/release-end
]
]
The block is dialect, so it cannot contain a call. A path traced by code belongs in a function issuing gui-path-* calls.
Stroke, transform and clip
| Command | Operands |
|---|---|
line-width | pixels |
line-cap | butt | round | square |
line-join | miter | round | bevel |
translate / rotate / scale | delta / radians / factors (a point) |
clip / reset-clip | origin size / none. clip intersects, never widens |
push | a block: saves paint, transform and clip, runs it, restores — including the lexical pen flags |
push [
translate centre
rotate angle
pen accent
line pivot tip ; a pointer, drawn along the x axis
]
Widgets, backend support and limits
gui/widget builds on Draw: a widget is two stateless functions, one that renders and one that hit-tests, so the caller keeps the value and decides what a click means. Examples: widget-knob, widget-knob-with-envelope, widget-fader, widget-envelope-editor. In an ADSR plot attack, decay and release each own a quarter of the width independently and sustain takes the rest, so turning one knob moves one part of the picture.
Draw needs the path contract, which today only the pugl backend implements. Cocoa, GTK, raylib and Windows define it as no-ops and report gui-path-supported? as false. Check first so an unsupported backend is a clear report rather than a blank window:
if not gui-path-supported? :context [return false]
Current limits: the block must be literal; no off for pens; the shape block cannot call functions; angles are radians. Not yet implemented from Red: hline, vline, sweep, spline, polygon, matrix/skew/transform, image, line-pattern, gradient paints and anti-alias.