Halcyon UI
Halcyon is the engine’s player-facing UI system: a settings screen, a scoreboard, a HUD panel. You describe what the screen should look like in ordinary C#, and Halcyon works out what changed, lays it out, and emits a flat list of draw commands.
It is also what the developer tooling paints with. The debug HUD, the world gizmos and the DigitalHeaven overlay’s windows do not go through widgets — they are pinned to screen coordinates or world-projected, which Halcyon’s layout deliberately does not express — but they go through Halcyon’s draw list, which is the part of Halcyon that has no layout in it at all. See the developer surfaces.
Executable build label
Section titled “Executable build label”The shared main and pause menu display passive Build N text at the safe-area top-right, outside the scrolling links but inside the existing menu transition. It is not a button or a text-entry target. The host supplies an immutable BuildIdentity; the UI never allocates numbers or reads files. Desktop uses cached executable metadata through EngineVersion.Build. iOS reads the actual bundle’s CFBundleVersion and string DhDevelopmentBuild once when composing its menu. Only an explicit lowercase false development marker plus a positive integer yields a numbered label; missing or malformed metadata displays Development build instead of mistaking Apple’s required fallback version for a release.
The two inspirations
Section titled “The two inspirations”Halcyon borrows deliberately from two places.
Flutter supplies the structure: immutable widget descriptions, a long-lived element tree they reconcile into, and the constraints-down/sizes-up layout protocol. The value of that protocol is that layout is a pure function of the tree and its constraints — a child never reaches up for its parent’s size, so the same tree under the same constraints always produces the same result, which is what makes the whole thing testable without a window.
S&box supplies the reconciliation and the styling posture: a plain keyed diff rather than a virtual DOM, and the understanding that a UI system’s styling layer eventually wants to look like CSS whether or not it is authored as CSS.
CSS conventions are followed on purpose. Every style and layout property mirrors a standard CSS name and semantic: padding, gap, background-color, border-radius, flex-grow/flex-shrink/flex-basis, justify-content, align-items, white-space, text-overflow. Two things follow from that. Anyone who knows CSS already knows what these do, and a stylesheet-and-selector authoring layer can be added later that resolves into the same structs without any of this changing.
Circular icon controls and touch capture
Section titled “Circular icon controls and touch capture”CircleButton is a reusable, reference-free Halcyon widget, not an engine or iOS special case. It composes the existing Button interaction rather than maintaining a second click implementation. Diameter sizes the painted circle; HitDiameter independently sizes its square hit target, never smaller than the circle. Supply a required human-readable Label and either an atlas Glyph or a passive IconBuilder receiving state-resolved ink. Hosts supply existing theme styles for resting, hover, pressed and focus treatments; disabled controls fade and reject pointer interaction. Opt into focus with Focusable. Retaining an action label does not itself provide an OS accessibility or VoiceOver bridge.
The shared UiTree supports indexed pointer capture: a movement contact, camera contact and ordinary UI contact can retain separate owners simultaneously. Desktop pointer ID 0 keeps the existing capture path. Mobile hosts forward ordered contact samples through TouchPointerUpdate and cancellation; the engine’s shared controller assigns gameplay roles without a separate native gesture recognizer. The same primitives support the touchscreen pause/close control and its larger hit target.
A finger on a list drags it one to one, past the slop that keeps a tap a tap, and a fast release glides on: Halcyon’s drag-to-scroll, shared with the XR overlay’s laser. The glide is exponential decay over a constant friction, so a gentle release stops within a fraction of a second while a flick slows smoothly for two to three; a press on a gliding list stops it; and a list pulled or flung past an end rubber-bands back as iOS does (ScrollPhysics.Overscroll also offers Android’s stretch and the Vita’s growing gaps). The mouse is untouched: it still selects by dragging and scrolls with its wheel, and a pen counts as a mouse. PointerKind (Touch, Pen, and the overlay’s Laser and Poke) is Halcyon’s, the one the touch samples carry. Every number is a field of ScrollPhysics; the engine runs the defaults, which Halcyon/Halcyon/README.md lists with their sources.
A notched mouse wheel glides: each notch adds its distance to what the glide still has to go and starts a new 300 ms ease-out from where the list is, never slower than it already moves, so a slow notch eases gently and a fast spin gathers speed (notches under 100 ms apart grow by a quarter of a notch each after the third, up to four times). It eases into an end and stops there. A touchpad, or anything else SDL reports in fractions of a notch, moves at once. A middle click on a list autoscrolls it: a puck appears where you clicked, the list moves toward the pointer faster the further it is from the puck (still inside 14 units), the chevron it moves toward lights in the theme accent, and the cursor turns to the arrow it moves along. Any click, Escape, the wheel, or the window losing focus ends it; holding the button past the puck and letting go ends it too. A middle drag on the editor’s viewport keeps its pan. client.ui.smoothWheel, client.ui.wheelGlide, client.ui.wheelStep and client.ui.middleAutoscroll tune it from the console; the offscreen capture turns the glide off so a notch lands where it points in the frame it was turned.
Density: one mechanism for touch and VR
Section titled “Density: one mechanism for touch and VR”UiDensity (Halcyon, Style/UiDensity.cs) says how roomy a surface’s controls are, in logical units. It is not a DPI scale and not an input-device detector: a host picks a profile for each surface. It carries a minimum target, a minimum gap and a minimum content padding, and four helpers that never shrink what was authored: Target(x) is max(x, MinimumTarget), Gap and Padding do the same for spacing and insets, and TargetPadding(inset, fontSize) grows a control’s own inset until its frame reaches the target. It also names a label size, an icon size, the bezel’s rim and outer widths and a button radius, which default to the authored values.
| Preset | Target | Gap | Padding | Who uses it |
|---|---|---|---|---|
Desktop | 0 | 0 | 0 | Every authored metric, unchanged. |
Touch | 44 | 8 | 16 | The engine’s touchscreen profile (iPad, Android). |
Vr(metersPerUnit, targetMeters = 0.021, gapMeters = 0.003) | 21 mm | 3 mm | 6 mm | A VR surface, sized physically. At the hand widget’s 0.375 mm per unit that is about 56 and 8 units. |
VrAngular(metersPerUnit, viewingDistanceMeters, targetDegrees = 2.5, gapDegrees = 0.35) | 2.5° | 0.35° | 0.7° | A far VR panel, sized by the angle a control subtends. The angles become meters at the viewing distance (Subtended) and the rest is Vr. |
The presets are records, so a host overrides one with with { ... }. A purely physical target is a no-op on a far panel: the overlay’s settings pane is 2 m across 640 units, 3.1 mm per unit, so 21 mm is about 7 units, below every control on it. The angular form is what a panel read from across the room needs: at 1.5 m the same pane asks for about 21-unit targets and 3-unit gaps, at 2 m about 28 and 4.
StudioBlackControls.Switch and .Slider take their heights from a density (a taller switch grows wider by the same amount, so its thumb keeps its travel). The engine’s UiInteractionStyle composes a Density and keeps only what is the engine’s own: the viewport margin and the widths at which the settings, launch and connect windows stack. Every engine reader goes through Style.Density.
One material, square corners and line-drawn marks
Section titled “One material, square corners and line-drawn marks”Bezel.Toned(lightnessDelta, chromaScale) moves a bezel’s outer ring, rim and fill by one OKLCh step relative to each color (TonalRamp.Toned does it for one color; Lightened is the step with the chroma kept). A plate one step darker is then the same material rather than a different recipe, which is how the overlay’s button card is made from the clock card (Toned(-0.04, ×1.25)). Bezel.InnerRadius is the radius inside both rings, the outline offset inward, so a cell drawn on the fill at that radius is concentric with the plate.
BoxStyle.SquareCorners keeps chosen corners of a rounded box square. It rides RoundedRectCommand.SquareCorners to the rect stage, which picks each quadrant’s radius from the Corners bits in a uniform branch, so a box with every corner rounded (the default, Corners.None) takes exactly the path it always did and the engine’s frames do not change. Hover and press styles derive from the resting style with with, so they keep the same corners. CornerMath.SquareInBottomRow answers which corners a cell in a row across a plate’s bottom keeps square, and CornerMath.SquareInRow the same for a row on any of the outline’s edges: a top row rounds its top outside corners, a row that is the whole outline all four, a middle row none.
VectorIcon draws a VectorGlyph: a few round-capped lines, rounded rects and circles on a 24-unit view box, stroked centered on their outlines as an SVG stroke is, with the draw list’s own Line and RoundedRect. It needs no atlas, so a host with no image pipeline still has marks. VectorGlyphs carries a desktop, a keyboard and a gear. The engine’s PNG icon atlas and Studio’s Material Symbols both need an image or font pipeline the overlay does not have, so neither moved.
A host that has an atlas can hand a glyph a mask instead: VectorGlyph.FromMask(IconGlyph) makes an icon draw one tinted quad of the slice that suits its device size, centered in a square at its short side, in place of strokes (IconElement.Paint is the one place that picks the slice). Studio’s Material Symbols are drawn that way, packed by UiIconFiles.LoadSet. A few widgets take the marks and radii a host like that wants and keep the editor’s look when none is passed: Chevron.Mark and ToolbarPalette.FoldOpen / FoldShut replace the ASCII chevron, Segmented.Marks puts a mark before each option, StudioBlackControls.ModeSwitch takes a track radius, TabStrip.TabRadius rounds a tab’s top corners, and DockChrome.Pane takes dressTabs (restyle the strip) and cornerRadius (round the plate).
Widgets
Section titled “Widgets”A widget is an immutable record describing what should be on screen. Building one is cheap; they are created and discarded freely.
public sealed record SettingRow : StatelessWidget{ public required string Label { get; init; } public required string Value { get; init; }
public override Widget Build(BuildContext context) => Ui.Row( [ Ui.Text(Label), Ui.Space(), Ui.Text(Value, color: UiColor.FromBytes(150, 150, 160)), ], alignItems: AlignItems.Center, padding: EdgeInsets.Symmetric(horizontal: 12f, vertical: 6f));}StatelessWidget builds from its own properties. StatefulWidget pairs with a State<TWidget> that survives rebuilds and calls SetState when something it owns changes:
public sealed record Toggle : StatefulWidget{ public required string Label { get; init; } public Action<bool>? OnChanged { get; init; }
public override WidgetState CreateState() => new ToggleState();}
public sealed class ToggleState : State<Toggle>{ private bool _on;
public override Widget Build(BuildContext context) => Ui.Box( onClick: () => SetState(() => { _on = !_on; Widget.OnChanged?.Invoke(_on); }), style: new BoxStyle { BackgroundColor = _on ? Accent : Idle, BorderRadius = 4f }, padding: EdgeInsets.All(6f), child: Ui.Text(Widget.Label));}SetState never rebuilds on the spot. It marks the element dirty and returns; the rebuild happens on the next UiTree.Update(). That is what keeps a build a function of state at a single instant rather than a function of the order events happened to arrive in.
The render primitives
Section titled “The render primitives”| Widget | CSS analogue | What it does |
|---|---|---|
Flex | display: flex | Row or column, with Gap, Padding, JustifyContent, AlignItems. |
FlexChild | flex: g s b | A marker declaring a child’s Grow, Shrink and Basis. Unwrapped by the parent; never becomes an element. |
Spacer | — | Empty space that claims leftover main-axis room. |
Box | div | Sizing, padding, background, border radius, border, and an optional click handler. |
TextWidget | text node | A run of text, wrapped to its constraints. |
Stack | position: relative overlay | Overlapping children anchored by a nine-point Alignment. |
Positioned | position: absolute with top/left | Fills the space its parent offers and places ONE child at an absolute offset inside it, measured against unbounded constraints. The offset moves the child and never resizes it, so a child larger than the box — or partly outside it — keeps its full size and is simply clipped. This is how a floating window states where it is. |
ScrollView | overflow-y: auto | A vertical viewport that clips and owns a live scroll offset. A MaxHeight above zero makes it shrink-wrap to content up to that ceiling and scroll past it. RevealKey names a descendant to scroll into view. Scrollbars and ScrollbarStyle decide the gutter. |
VirtualList | overflow-y: auto over a windowed row set | A bottom-anchored list that builds only the rows the viewport can see. Index 0 is the oldest; the offset is measured from the end, so growth at the newest end never shifts what you are reading. Carries the same Scrollbars and ScrollbarStyle. Horizontal lets rows run wider than the list: each row takes the width it measures, a horizontal bar rides the bottom edge of the viewport (a child of the list, so it never scrolls away) and hides while every row fits, and a tilt wheel, a trackpad or a drag on the bar moves the rows. |
Scrollbar | the bar of a overflow: scroll box | Never authored. A scroller mounts one as its own trailing child, and it reads every number it draws off the parent through IScrollAxisSource. See Scrollbars. |
Popup | position: absolute anchored to a trigger | An anchor plus a floating surface. Only the anchor takes part in layout, so opening a popup cannot move anything; the surface is sized to its own content up to the viewport, painted after the whole tree and clipped by nothing. |
EditableText | the editable part of input | A single-line editable run: caret, selection, click-to-caret, horizontal scroll. Focusable by construction. Its Padding is the FIELD’s inset — see focus & text input. |
MultilineText | the editable part of input | A multi-line editor over the same TextEditController and editing functions: lines, caret, selection painted per line, wheel and caret scroll in both directions (it draws only the lines in view), the clipboard, undo and redo through an optional TextEditHistory that joins a typing run into one step, the I-beam, and an OnKeyPressed hook a host uses to claim Ctrl+S. MultilineEditing holds what only lines need (vertical moves that keep their column, line starts and ends, a newline that keeps the indent). No wrapping; a line scrolls sideways. Enter inserts a line break and Tab inserts two spaces; Escape and Shift+Tab fall through. |
TextField | input | EditableText inside a Box that swaps style while focus is within it; the inset goes to the leaf, not the box (TextField.SplitPadding). An optional Leading widget rides inside the frame, ahead of the text — how the settings search field carries its magnifier. |
SelectableText | ::selection on a text node | A text run that belongs to a numbered line and paints the slice of it a SelectionRegion says is selected. Not focusable and not editable. |
SelectionRegion | a selection root | Owns the drag gesture over the selectable runs beneath it, and paints the whole-line bands once a selection spans more than one line. Lays out as its child. |
KeyListener | onkeydown on a container | Binds keys for a whole subtree, seeing whatever the focused element declined. Paints nothing and lays out as its child. |
The controls
Section titled “The controls”These are the input controls a settings screen is made of. All five are controlled: none holds the value it renders, so the single source of truth stays where the caller keeps it (for the settings screen, a Preference<T>). A null handler leaves the control drawn but inert rather than removing it — and inert means inert: a control with no handler does not respond to the pointer either, so the same widget doubles as a readout. Everything that does carry a handler picks up hover and pressed paint automatically, derived from its own style.
| Widget | CSS/HTML analogue | What it does |
|---|---|---|
Button | button | A labeled, clickable chip. A thin wrapper over Box — it exists so a call site says “button” — plus the one thing a button owns that a box does not: an Enabled flag. See below. |
Switch | input type=checkbox | A pill track with a round thumb that slides between the ends, track color crossfading with it. Stateful, because the travel is an AnimationController the widget owns; composed from boxes, because a click is something the tree already routes. |
Slider | input type=range | Track, fill and a draggable thumb, with arrow-key stepping and Home/End. ThumbInset insets the thumb’s travel from both ends, for the case of a thumb narrower than its track that has to ride inside it; a thumb larger than its track leaves it at zero. Has an element of its own, because a drag needs a local coordinate and reconstructing one from absolute rectangles would be the second coordinate path the hit-test contract exists to prevent. A press captures the pointer-to-value mapping in viewport space and the whole drag reads through it, so a slider whose own value relayouts the screen — the interface scale is exactly one — cannot slide out from under the hand holding it. |
Segmented | a radio group | A row of mutually exclusive chips, keyed by label so filtering an option set moves the element with the option. |
Dropdown | select | One value plus an option list that hangs off the chip as a floating menu. Built on Popup, so opening it moves nothing around it; with the chip focused, Enter opens the list, Up and Down walk a highlight, Enter picks, and shut, Up and Down change the value directly; Enabled = false dims the chip and never opens; the menu is as wide as its widest entry, never narrower than the chip, and scrolls once it reaches MaxMenuHeight. Its rows line up on one left edge — icons in a column, labels in a column after them — and its chevron is a ChromeMark, cut from the same lattice a window’s close mark is, rather than a letter borrowed from the font. |
AutocompleteField | input with a datalist, an address bar | A TextField whose suggestions for what it holds float under it in a Popup, like a browser’s address bar. The list opens when the TEXT changes, not on focus, and stays shut once a suggestion is taken or dismissed until the text changes again. Up and Down walk the rows (Up from nothing wraps to the last), Enter takes the highlighted one into the field and calls OnAccept, Enter with nothing highlighted calls OnSubmit with the text, Escape or a press elsewhere closes it, and a click takes a row. The rows are a VirtualList that scrolls, so every suggestion is reachable however many there are; Row dresses a row (Studio puts the map’s state badge on it). Rows are keyed AutocompleteField.RowKey(fieldKey, value). |
EnumField | select / radio group | One value out of a closed set of tokens. Up to EnumField.SegmentedMax (4) options lay out as a Segmented row; more fold into a Dropdown. The caller never decides which. Every chip or row is keyed by its token, and Labels may dress them for display without changing the keys. |
ScalarCell | input type=number with a drag handle | One number with a handle: a label glyph in front of a text box. The box takes a typed value — commits on Enter and on blur, Escape puts the live value back, an untouched blur commits nothing, nonsense reverts, Min/Max clamp. The label, when the host lends a ScrubSeam, is a drag handle: ResizeHorizontal cursor on hover, a dead zone, Shift coarse and Alt fine, sensitivity frozen at the press, every frame committed through the same OnCommit, and a bump of the seam’s CancelEpoch puts the press value back. A cell with no OnCommit is read-only and has no handle; Mixed empties the box behind ScalarCell.MixedLabel. The box carries ScalarCell.MinBoxWidth as its flex basis, so a squeezed row takes its width out of whatever else is on the row rather than out of the number — a box narrower than its own digits scrolls the leading one out of sight, which is a wrong number rather than a cramped one. ScalarCell.Format is NumberText.Trimmed: the decimals are a ceiling, not a quota. |
VectorField | — | Two to four ScalarCells labeled X, Y, Z, W. The Cell is a prototype: every component wears its styling, seam, identity and limits and differs only in value, label, key and mapping (OnCommit(index, value)). |
ColorField | input type=color, as numbers | A bezeled swatch and three ScalarCells labeled R, G, B, clamped to 0..1. Three typed numbers rather than a picker: Halcyon has no floating layer to hang one in, and a stored color is a number a person may want to state exactly. Alpha is neither shown nor edited and comes back untouched. |
FloatField | — | One ScalarCell whose label is the row’s caption at a fixed width, so a lone number lines up with the vectors above it and its caption drags exactly as an axis letter does. |
Menu | a context menu | The floating panel of a context menu: MenuCommand rows, MenuDivider rules, and MenuGroup rows whose submenu unfolds beside the row on hover as a nested Menu in its own Popup (PopupPlacement.Beside). The anchor is the caller’s — a three-dot button, a right-clicked row — wrapped in a Popup whose overlay this is, so viewport clamping, outside-press dismissal and Escape all come from the popup layer. A null OnInvoke draws a row dead rather than dropping it; picking a live one runs it and asks the owner to close the whole menu through OnClose. |
BarMenu | a menu bar | A row of BarItem headings, at most one pulled down as a Popup panel. BarMenuEntry is a command, BarMenuEntry.Separator a rule, and an entry carrying Entries of its own is a parent row: a > marker at its right edge, and a nested panel unfolding beside it on hover — PopupPlacement.Beside again, so it flips left when the right edge has no room. The open path is one index per depth, so sliding from one parent onto another folds what the pointer left rather than stacking panels. Keyboard: with a heading open, Up and Down walk the deepest panel and step over rules and dead rows, Home and End go to the ends, Right unfolds the row the cursor is on (or moves to the next heading when it has nothing beside it), Left folds a level back onto the row that named it (or moves to the previous heading), and Enter runs what is lit. A hover anywhere takes the menu back off the keyboard, and a shut bar claims neither the arrows nor focus. |
Canvas | canvas | A fixed-size box painting an explicit list of rectangles at explicit local positions, clipped to its bounds. The escape hatch from layout for geometry computed by something other than a layout algorithm. The pixel grid rounds what it emits like everything else; a canvas whose shapes are a lattice declares Unit so the cell itself is rounded to whole device pixels first. |
Ui supplies short factories — Ui.Row, Ui.Column, Ui.Box, Ui.Text, Ui.Button, Ui.Chip, Ui.Flexible, Ui.Space, Ui.Layers, Ui.Scroll, Ui.Popup, Ui.VirtualList, Ui.DragSurface, Ui.SelectableRun, Ui.Selection — so a Build method reads as a shape rather than a wall of object initializers. The records remain the full-fidelity form; reach for new Box { ... } whenever a factory would take more arguments than it saves.
Buttons, and what “disabled” means
Section titled “Buttons, and what “disabled” means”Ui.Button is the reusable chip: a rounded box, one run of text, and the paint states a click implies.
Ui.Button( "Cancel", onClick: OnCancel, enabled: !busy, fontSize: FontAtlas.SmallFontSize, color: theme.Muted, hoverColor: theme.Text, style: new BoxStyle { BackgroundColor = theme.Menu, BorderColor = theme.Border, BorderWidth = 1f, BorderRadius = Button.DefaultRadius, }, hoverStyle: ..., pressedStyle: ...);| Member | Meaning |
|---|---|
Label | The text drawn inside the chip. Required. |
OnClick | Fired on a press and release inside the chip. Null draws it inert. |
Enabled | False turns it off — see below. Defaults true. |
DisabledOpacity | The alpha multiplier a disabled chip paints at. Defaults to Button.DefaultDisabledOpacity (0.5). |
Style / HoverStyle / PressedStyle | The three paints. A null hover or pressed style is derived from Style. |
IsOn / OnPaint | A toggle’s value, owned by the call site the way a Switch’s is, and the ButtonPaint (three surfaces and three inks) it paints with while on. Hover and press keep answering the pointer over the on paint; a button with no OnPaint looks the same on or off. Segmented and Dropdown mark their selected chip this way, with an on paint that holds still under the pointer. |
Padding | Inset from edge to label. Defaults to Button.DefaultPadding. |
Width | A fixed outer width, or null to shrink-wrap the label. |
FontSize / Color / HoverColor | The label’s size, its ink, and its ink under the pointer. HoverColor is the only reason this widget is stateful. |
Disabled is four behaviors, not one. A chip with Enabled: false does not fire, does not take its hover paint, does not ask for the pointer cursor, and fades whole — fill, border and label all multiplied to DisabledOpacity. Each is a separate way to get “disabled” wrong, so each is pinned by its own test against an enabled control that differs in nothing else; a widget that only dropped the click would still glow under the pointer and still promise, with its cursor, that it was going to do something. Losing the cursor needs no rule of its own: the cursor follows the click handler, so dropping the handler reverts the chip to the ordinary arrow. HoverStyle and PressedStyle are pinned to the resting paint rather than merely left underived, because a call site that handed in an explicit hover style would otherwise still light an unusable control up.
Fading the whole object, rather than flattening it, is the convention for a chip that is looked at on its own — a floating control with no row around it for a flattened surface to sink into, which is the case the loading box’s Cancel is. Only alpha moves; hue, radius and border width are untouched, so it stays the same object seen dimly rather than a second palette nobody declared. The settings screen keeps its own convention for controls that sit inside a row (ink to Disabled, surface flattened to the card’s value), where taking the chip away is what removes the affordance. Both say the same thing in the place it reads.
Ui.Chip: the one way a button is dressed
Section titled “Ui.Chip: the one way a button is dressed”Ui.Button is the widget. Ui.Chip is the dress, and it is the only one — every button in the engine and the editor goes through it, so there is exactly one place a button’s look is decided.
Ui.Chip("Save", palette.Bezels.Button(BezelVariant.ButtonPrimary), onClick: OnSave);It takes a BezelButton — the theme’s four-part answer for a pressable surface: a Rest, a Hover and a Pressed bezel, plus one Ink — and spends it. BezelSet.Button(variant) hands one out for each of the two pressable roles:
| Role | Where it is spent |
|---|---|
ButtonDefault | Everything ordinary: a dialog’s second answer, a card’s foot button at rest, an inspector row’s Restore. |
ButtonPrimary | The one answer a dialog or a footer is steering toward — Reload and discard, Save, Load Map. Brand accent at fill strength, and never more than one on a surface. Also worn by a toggle that is currently on: a lit toggle is a primary-colored chip, not a role of its own, and it keeps the full three-state ladder so it can still be hovered and pressed. |
A role that is not pressable at all — Panel, Dialog — falls back to the ordinary chip rather than handing back a default-constructed one that would paint nothing.
A chip answers the pointer with its FILL and never with its ink. Ui.Chip passes the bezel’s Ink as the resting color and no hover color at all, so the label holds still through rest, hover and press. Two things follow. A word that changes color exactly when a person is about to press it reads as a second control, and accent ink on an accent fill disappears outright — so a lit toggle stayed legible only by accident before. And HoverColor is the only reason Button is stateful, so a chip that never states one stays out of the hover latch: answering the pointer costs a repaint rather than a rebuild.
The label is centered against the button’s FINAL rectangle, via Button.Align and the Box.ContentAlignment under it, snapped to the pixel grid. A box places its child against its content by default, which is right until the box is stretched — a stated Width, or a full-row foot button in a card — and then every label sat against the left edge with the slack piled up after it. AlignmentMath.Anchor is the same nine-point rule Stack places children with; the two share it rather than each carrying a copy.
Two numbers ride on preferences, because they are the two a person actually reaches for:
| Preference | Default | Meaning |
|---|---|---|
client.ui.buttonRadius | 5 | The corner of every chip, in logical pixels. PixelGrid.SnapRadius lands it on whole device pixels at draw time. |
client.ui.buttonHoverLift | 0.04 | How far an ordinary or selected chip’s fill brightens under the pointer. The primary keeps its own larger step: it already sits at the bright end of the ramp, where this one would not be seen. |
Both reach the theme through UiTheme.SetChip, pushed once a frame from HalcyonLayer and EngineOverlay — the same way the theme seed travels — so a console line moves every button in the program on the next frame rather than at the next relaunch. Their defaults are the theme’s own authored constants, HalcyonSettingsTheme.BezelButtonRadius and TonalRamp.ButtonHoverLift, and a test asserts the two have not drifted apart; a drift would ship a look nobody chose.
The editor reaches the same chips across the reload boundary: DigitalHeaven.Editor may not declare preferences or read Engine.Client theme internals, so the host snapshots the whole BezelSet onto ToolbarPalette and Chip.Build calls Ui.Chip with it. Chip.RowPadding is the one named exception to the default padding — a chip sitting inline in an inspector row takes the field rows’ vertical inset so it matches a text box’s height beside it.
ProgressReveal: a button that shows how far a job is
Section titled “ProgressReveal: a button that shows how far a job is”A button for a thing that is under way, such as Cancel on a running import, draws itself twice: the idle face over the whole button, and over the left Fraction of its width a second face of the very same button in a busy style. Both faces have the same size, shape, padding, mark and label, and each has its own bezel (outer ring, rim, fill), so the busy part is shaped by its own rounded corners and rim and never pokes past the button’s outline, and its label is its own dark ink instead of the idle ink fading into the fill. The boundary is a straight vertical edge: the busy face sits in a Box with Clip set to Width * Fraction, which is one PushClip on the draw list’s clip stack.
new ProgressReveal{ Label = "Cancel", Mark = glyph, Width = 120f, Height = 32f, Idle = quietDangerChip, Progress = RolePalette.Violet.Busy, Fraction = job.Fraction, OnClick = cancel,}- One target. The widget owns the pointer: a press, a release and hover work the same at 0% and at 100%, and both faces are painted from the one hover and press state (
IdleandProgressareBezelButtons, so each face lifts and drops the way a chip does). The faces areButtons withPassiveset, which takes no pointer and never hovers itself, so nothing in them competes for the press. - Disabled (
OnClicknull) fades the composite as one picture, not each face separately, so the idle face does not show through the busy one. - Fraction at zero or below draws the idle face alone, so a job that has said nothing countable looks like an ordinary button.
- The colors are roles.
RolePalette.Busyis the busy-blue chip: a mid blue fill, a bright rim, a dark outer ring and a near-black ink that clears 4.5:1 on the fill (pinned with the other role chips). The widget takes anyBezelButtonas its progress face.
Studio’s Kit.Cancel is this widget with the quiet danger chip as the idle face; every Cancel that shows while a job runs uses it.
ToastStack: notification cards in a corner
Section titled “ToastStack: notification cards in a corner”ToastStack is the model and ToastStackView the widget, for any Halcyon app that wants notifications.
- Placement and stacking. Cards sit in the bottom-right corner of the box the view fills,
MarginRightandMarginBottomfrom its edges (a host that has a status bar adds its height), at mostMaxWidthwide (a longer message wraps), the newest at the bottom and older ones pushed up. The box takes no pointer, so what is behind it stays reachable. The look is aFunc<ToastSeverity, ToastStyle>the host supplies (a surface and an ink), so a severity can be tinted without Halcyon knowing a theme; the corner radius is whatever the host’s surface carries. - Rules, matched to the developer overlay’s
NotificationQueue: a toast has a duration (zero or less never expires), the stack holds at mostMaxVisibleand a push past that removes the oldest, an identical message already up folds into it with a repeat count and a fresh life, and the fade is the arrival ramp times the departure ramp (FadeIn,FadeOut), so a toast shorter than both never reaches full opacity. - Time is fed in.
Advance(seconds)ages the toasts by the time the host gives it, never a wall clock, so a capture ages a toast exactly as a live frame does. It returns whether anything is up, so the host knows to draw another frame for the fades. - Click dismisses; hover holds. A click calls
Dismiss. The pointer resting on a card callsHold, which stops that toast aging and keeps it out of its fade-out until the pointer leaves. - The overlay’s cards slide to a new slot when one above them goes; this stack fades and does not slide.
Studio puts its notifications here (StudioShell.Toasts; ShowToast pushes an info toast, or an error toast that keeps the danger tint and stays ProblemToastFactor times as long). Its width, margin, count and fade times are the settings toastMaxWidth, toastMargin, toastMaxVisible, toastFadeInSeconds and toastFadeOutSeconds.
Popups: anchored, dismissed by the tree
Section titled “Popups: anchored, dismissed by the tree”A Popup is positioned relative to its anchor, never to the viewport. That is the whole design: every floating surface a UI actually needs hangs off something, and a coordinate-positioned overlay would have to be told where its anchor ended up, which is the one number only layout knows. Placement picks the side (Below/Above, or Beside for a submenu unfolding out of a menu row’s right edge and flipping left when the screen ends), Alignment picks the edge that lines up (Start, End, or Stretch for “exactly the anchor’s width” — under Beside the same two align the top and bottom edges instead), and MinWidthFromAnchor floors the surface at its anchor’s width without pinning it there.
The surface is sized to its own content. It is measured against an unbounded width and then laid out at exactly the width that measurement reported. The unbounded pass is the whole trick: a surface is normally a column of rows stretched to a common width, and a stretching column offered a loose ceiling fills it — so measuring a menu against the viewport reports the viewport every time, whatever is in it, which is “every menu runs to the right edge of the screen” in one sentence. Offered no ceiling, stretch degrades to the widest row. The second, tight pass then puts that width back so the rows still stretch to a common edge. The screen is the only ceiling, and a surface that reaches it scrolls inside itself.
The anchor may be the pointer. Popup.AnchorToPointer swaps the rectangle the placement is resolved against from the anchor widget’s own to a zero-size box at the cursor, and UiTree relayouts those popups as the pointer moves — which is how a plate follows a cursor along a slider track and sits above it. It is a parameter on the one placement rule rather than a second floating surface: side, alignment, flipping and the slide back inside the viewport are all still the rule above.
Placement is a request. PopupElement.Place resolves it once the anchor’s absolute position is known — layout cannot answer “would this run off the bottom”, because it never learns where it ended up. A surface with no room on the side it asked for takes the other one when that fits, and is then slid back inside the viewport on both axes. Slid rather than shrunk: a menu that resized itself near a screen edge would re-wrap its own labels as it moved.
Dismissal lives in UiTree, not in each caller. A press outside an open popup dismisses it; Escape dismisses the innermost one and consumes the key, so a menu closing over a paused game does not also unpause it. Both arrive as OnDismiss, and a popup with no OnDismiss opts out of both — which is what a completion list wants, since it is dismissed by its own editing rules rather than by clicking away from it.
Two consequences worth knowing before using one. A dismissing press is not swallowed: it still reaches whatever is under it, so these menus are not modal, and clicking a pill while a menu is open both closes the menu and toggles the pill. And the surface paints above every layer, so it does not inherit a Transition’s opacity, translate or blur — a screen that owns popups closes them when it hides.
Spring-loaded menus: press, hold, travel, release
Section titled “Spring-loaded menus: press, hold, travel, release”A popup whose anchor is a clickable box opens on the press, not on the release, and the press that opened it starts a gesture the person may finish without ever letting the button up: hold, walk the rows, unfold a submenu on the way, and release over the command you wanted. One press, one command. Popup.SpringLoaded carries it and defaults to on, so the menu bar, the inspector’s three-dot menus, Dropdown and anything else built on Popup grow the gesture with no code of their own — an anchor whose click does something other than open its popup clears the flag and keeps waiting for the release.
The gesture is one mechanism in UiTree, and it reuses the machinery that was already there rather than growing a parallel one. Travel while held is ordinary hover — the same callback that lights a row and unfolds a MenuGroup for a pointer with no button down — so the two modes cannot drift into two menus. Letting go has exactly three landings:
| Release lands on | What happens |
|---|---|
| A command inside an open surface | That is the pick: invoked once, and the menu closes itself through OnClose. |
| Anything else still part of the menu — a submenu’s parent row, a divider, a dead row, the panel’s padding, or the anchor the gesture started on | Converts to regular mode: the menu stays down and the next click works normally, which is precisely the state a click-to-open would have left. |
| Anywhere else | Closes, through the same DismissPopupsOutside a press outside uses. Dragging away and letting go is how a hand says never mind, and it is what Windows and macOS both do with a menu drag that ends off the menu. |
A “command” is simply a clickable box — a MenuGroup row opens on hover and carries no click, a divider and a panel’s padding carry none, and a disabled row’s handler is null — so the surface’s own grammar answers is this a leaf with no second flag to keep in step with it.
Two edges are worth stating. The opener’s click is spent on the press, and the release is suppressed, or the toggle behind a menu heading would shut the panel the same gesture just opened; a second press on an anchor whose popup is already down is an ordinary click, so a heading still closes its own menu. And Escape during a hold closes everything rather than peeling one level, and ends the gesture, so the release still to come commits nothing — a hand still on the button has no way to ask for a peel and no reason to want one.
Virtualized lists
Section titled “Virtualized lists”VirtualList builds its children during layout rather than build: only the rows intersecting the viewport, plus Overscan pixels either side, become elements at all. Each is wrapped in a Box keyed by index and clipped to its own rect, and its measured extent is cached against that index — so a row that scrolls away keeps contributing its real height to the scroll range instead of reverting to EstimatedItemExtent. The estimate is only ever used for rows that have never been on screen, which is why scrolling far up stays cheap without the list lying about how long it is.
It is bottom-anchored by construction, because the thing it exists for is a scrollback. The offset is measured from the end, PinToEnd glues the newest row to the bottom, and OnScrolled reports every offset change so an owner can drop follow-mode the moment the user scrolls away.
AnchorToStart says which edge a change holds on to, and it is the flag every ordinary pane sets — an asset listing, a hierarchy, anything read from the top down. A log holds its newest row: give it a shorter viewport and history is revealed above the line you were reading; give it more content and the line you were reading slides up. A start-anchored list holds its first visible row instead, so the same two changes read the opposite way, and both go through the same coordinate. The offset is stored as a distance back from the end of the content, so keeping a position measured from the start means moving that number by exactly as much as the thing it is measured from moved: += before - after when the viewport changes height, += after - before when the content extent does. Neither is a correction applied after the fact — both run inside the layout that changed the quantity, before the offset is clamped, because a clamp that has already spent the position cannot give it back.
That is what makes a dragged pane divider behave. A pane shortened by its own top edge is a viewport that got shorter with its leading edge moving down, so its rows come down with it and the first one stays under the header; a pane shortened by its bottom edge holds the same first row and hides rows off the bottom. Nothing asks which edge moved — one rule states both, and the same rule covers a listing that changes shape under a fixed pane, such as rows becoming tiles a quarter the count and four times the height. The exception is a reveal, which names an absolute row to be at rather than a position to keep, and is therefore left alone by the content correction it would otherwise fight.
Scrollbars
Section titled “Scrollbars”A scroller mounts its bar as its own trailing child, not as a piece of its own painting. That single decision buys three things from machinery that already exists: hit testing walks children last to first, so the bar takes a press before the content under it; drawing walks them first to last, so the bar paints on top of that content; and hover, press, pointer capture and the cursor request all arrive the way they do for any other element. ScrollbarElement owns the drag gesture and nothing else — every position it draws is read from its parent through IScrollAxisSource, and every position it commits is written back through the same interface.
IScrollAxisSource exists because the two scrollers store their position in opposite directions. A ScrollElement counts from the start of the content; a VirtualListElement counts back from the end, which is what makes follow mode an offset of zero rather than a correction. Every number on the interface is measured from the START, so the inversion lives in one method on the virtual list and a bar never learns which kind of list it is attached to. A test pins the two producing an identical thumb for identical content, at an offset deliberately off the midpoint of the travel — at the midpoint the two coordinates are the same number and a list that had forgotten to invert would agree by accident.
The gutter is the default, and it is reserved unconditionally
Section titled “The gutter is the default, and it is reserved unconditionally”| Mode | Gutter | Painted |
|---|---|---|
Auto — the default | Reserved | Whenever the content overflows |
Always | Reserved | Always, as a full-length thumb when nothing scrolls |
AutoGutter | Reserved only when the content overflows at full width | Whenever the content overflows |
Overlay | None; the bar floats over the trailing edge | Whenever the content overflows, then fades after FadeHoldSeconds; the pointer moving over the scroller brings it back thin, and the pointer in its band brings it back at full width and holds it |
Never | None | Never; the wheel still scrolls |
The reservation is a function of the policy alone and never of the measured overflow, which is the part worth understanding. A gutter that appeared when the content grew would narrow the content, which rewraps its text, which changes its height, which can remove the overflow that summoned the gutter — a layout oscillation with no fixed point and no way to reproduce it reliably. Reserving unconditionally costs fourteen pixels and has no failure mode at all. AutoGutter is the one mode that asks about the overflow, and it escapes the oscillation by asking at full width: the question is put to the content as it would be laid out with no bar at all, so the answer can never be changed by the lane the answer reserves. It is for a surface where the empty lane is not furniture but a visible margin — a dropdown’s menu, whose rows and search box are its whole frame. It also means the content is laid out inside the remaining width, so a bar never covers a word, and the scroll position is legible without moving the pointer to summon it.
Tone follows the same budget as everything else: neutral at rest, accent on touch. Any panel-heavy screen has three or four bars on it, so a resting thumb is white at 0.18 alpha — furniture — over a lane at 0.05, and the brand accent arrives only under the pointer. The thumb also widens from 6 px to 8 px inside its 14 px hit band, because near-white has nowhere left to lift to. The client’s one definition of all of this is HalcyonScrollbarTheme; Halcyon itself ships the neutral half, since it has no theme and cannot see the preference store.
A press on the lane pages one viewport toward the press. It does not jump to the pressed position — a click that teleports a reader to an arbitrary point in a document loses their place with no way back. A press on the thumb starts a drag measured against the thumb the user can actually see, so the thumb does not slide out from under the hand that grabbed it.
The bar declares CursorKind.Arrow explicitly rather than declaring nothing. Declaring nothing is indistinguishable from an arrow on an empty screen and wrong everywhere a bar is actually useful, which is beside text: an I-beam region under the bar would otherwise win the cursor and invite the player to type into a scrollbar.
Two prerequisites landed with it. VirtualListElement now tracks a row count of its own, because every one of its window routines indexes Children directly and an appended bar would otherwise be measured as a row and displace every real one. And PointerState carries ScrollDeltaX beside the vertical delta, with UiTree routing once per non-zero axis — two scalars rather than a vector, because the axes are consumed independently: a horizontal gesture inside a vertical-only list has to reach whatever outside it can use one, on the same frame its vertical component was swallowed. Wave 1 draws vertical bars only; the plumbing under them is per-axis throughout, so horizontal is a second case in existing arithmetic rather than a rewrite.
Drag surfaces
Section titled “Drag surfaces”Ui.DragSurface is a Box with drag hooks whose HoverStyle and PressedStyle are pinned explicitly to its own Style. That is the entire point: leaving them null is what asks a draggable Box to derive a lifted paint, and a window body that lit up under the pointer would be claiming to be a button. A drag surface is background — it must react to the gesture and to nothing else. Press targeting still prefers a deeper interactive child, so a slider sitting on one keeps its own press.
Tab strips
Section titled “Tab strips”TabStrip is a row of tabs with exactly one in front — the header of a tabbed pane. It renders and reports, deciding nothing: a press is reported as the tab’s index (pressing a tab is also how a drag begins, so the caller selects on it), every drag sample is converted into strip-local coordinates before it crosses, and what a drag means — reorder, tear off into a dock gesture, nothing — is entirely the caller’s question. That is what keeps the widget reference-free: reordering mutates a model this assembly has no name for.
It owns no palette — every color is a parameter, including the dimmed paint of a tab whose window is riding elsewhere as a drag ghost (GhostIndex). The width arithmetic is public (TabWidth, RightEdges) so a caller turning a strip-local x back into an insertion index computes with the same numbers the strip laid out with; two measurements would drift.
Tooltips and asterisks
Section titled “Tooltips and asterisks”HoverTooltip is the one tooltip every Halcyon app uses, the editor and Studio alike: a plate that appears under its anchor once the pointer has rested for DwellSeconds and leaves when the pointer does. The dwell is the point: a plate that appeared on contact would flash every time the pointer crossed something on its way elsewhere, so a touch spends the dwell on contact instead. The anchor box is built by the tooltip, because hover has to be reported by the element the popup hangs off; the placement is Popup’s, which flips and slides to stay on screen.
What the plate looks like is a TooltipStyle the host states once: fill, border, ink, size, opacity, wrap width, padding, radius. HoverTooltip.Of(child, style, text, key) is the usual one-liner. The editor states its palette as a TooltipStyle through TooltipPlate.Build, so its tooltips come out exactly as they did; TooltipStyle.StudioBlackPlate is the plate in Studio Black.
Asterisks is a run of Asterisk(ink, tip) marks set after a name, each with its own HoverTooltip. A host owns what the states are, what colors they take and what the tips say; Halcyon owns how they sit. StudioBlack.Unsaved (yellow) and StudioBlack.NeedsBuild (blue) are the theme roles Studio uses. TabItem.Marks puts the same run after a tab’s title, and the tab’s width includes it (Asterisks.Width).
Docking
Section titled “Docking”Halcyon.Docking is the dock brain: the model and rules behind the level editor’s dock layout, kept apart from anything that draws it so a second host can dock the same way with its own controls. Nothing in it takes a DrawList, a UiTree or a widget; it is geometry in and geometry out, and it is the one part of this assembly that another UI toolkit can use as it stands.
| Type | What it answers |
|---|---|
DockTree, DockSplit, DockPane | The tree of rows and columns whose leaves are tab groups, and every edit to it: tab, reorder, split a pane, split the whole area, remove and collapse, the divider resize and the share a docked window arrives at. DockPane.InsertTab/RemoveTab are the tab edits a floating pane uses with no tree around it, and DockPane.TabAt names the window a gesture on a strip index means. |
DockLayoutSolver | A tree and a rectangle into pane and divider rectangles, with optional per-pane minimum widths. Nothing stores a rectangle, so a resize is just a different answer next frame. |
DockDragging | Which drop a pointer is over and what letting go would do (float, tab, split a pane, span an edge), where the indicator goes, the insertion index in a strip, and when a press tears off (Tears, TearsFromStrip). |
DockDragOptions | The numbers a drag is resolved with: strip height, edge band, center zone, the two preview shares and the tear distance. The host fills it every frame from its own settings; the engine reads its client.editor.dock* preferences. |
DockLayoutText | The one-line layout spelling and its all-or-nothing reader, with IsWindowId for a host that mints ids. |
DockTabs | Keyed tabs (a prefix plus what the tab is looking at) opened beside a docked neighbor, raised instead of duplicated. |
DividerGrab | The anchored divider gesture every draggable seam shares. |
One standard for closing. A docked pane has no close X in any host. A docked tab closes by a middle-click on it or the Close row of its right-click menu (DockChrome.TabMenu, the window’s own commands above a rule above Close); only a floating window wears an X (DockChrome.CloseChip), with a red hover fill and glyph that fall back to the shared danger red when a palette supplies none. Reopening is the host’s Window or View menu, one checked row per window, and Reset layout puts the default back. Studio’s DockArea and the editor’s EditorToolbarShell both call DockChrome.Pane, which enforces it: the X is only drawn when the pane is floating.
The painting stays with the host. In the engine the tab strip is TabStrip, and the dividers, the drop indicator, the floating windows and the drag ghost are drawn by EditorToolbarShell and DockChrome, which call the brain for every decision. Floating panes are the host’s too: each is a DockPane beside the tree with geometry of its own, which is what lets a host with real OS windows float them there instead.
Reconciliation
Section titled “Reconciliation”UiTree.SetRoot(widget) declares what should be on screen; UiTree.Update() applies it. The declared tree is diffed into the retained element tree, which is what actually owns state, scroll offsets and laid-out geometry.
The matching rule is short:
- A new child widget matches an existing child element by its explicit
Keyif it has one, searched across the whole sibling list so a reordered child finds its element wherever it moved to. - Otherwise it matches by child slot — but a keyed element never answers to its slot, so keyed and keyless siblings can be mixed without the keyed ones being stolen.
- In both cases the runtime widget type must match. A mismatch unmounts the old element and mounts a new one.
There is no virtual DOM and no patch list. The widget tree is the diff input, and it is discarded immediately afterward.
The practical consequence: a list of things that can reorder, be inserted into, or be removed from wants keys. Without them, slot is identity, and reordering two rows swaps their state rather than moving it.
The root gets the same test, so a host may re-declare its entire UI every frame — the natural shape for a game loop — without losing a scroll position to it. Only a different root type tears the tree down.
Layout
Section titled “Layout”Constraints flow down, sizes flow up, parents position children.
A BoxConstraints is a min/max envelope on each axis. Tight means min equals max and the child has no say; loose means the minimum is zero. A parent loosens before measuring a child it intends to size itself around — passing its own tight minimum down is how a label inside a min-width panel ends up stretched.
The flex algorithm is a CSS subset:
- Each child’s basis is its declared
Basisor, failing that, its measured content size (CSSflex-basis: auto). - Gaps are reserved before anything is distributed, so an overflowing child can never squeeze the spacing away.
- Surplus space is handed to the grow factors; a deficit is taken from the shrink factors weighted by each item’s own size, exactly as CSS does, so a wide item gives up more than a narrow one with the same factor.
- Whatever survives is what
JustifyContentdistributes. This is why a row containing a growing child ignores justification entirely — the grow already consumed the slack. AlignItemsplaces children on the cross axis;Stretchdegrades toStarton an unbounded cross axis rather than forcing an infinite size.Baselineis row-direction only — it degrades toStartin a column, the same fallbackStretchtakes on an unbounded axis — and lines up each child’sElement.Baseline(the true typographic baseline on a text run; the bottom of the box on anything else), which is how a big run of digits and a smaller trailing run sit on the same line instead of centering by their boxes.- Overflow is reported, not resized. The container clips; CSS does the same.
The main-axis half of this is a pure static function, FlexSolver.Solve, which takes a span of FlexItem and writes a span of FlexPlacement. It touches no widgets and no elements, which is why it can be tested with a handful of numbers.
Not yet implemented
Section titled “Not yet implemented”These are gaps to be filled on demand, not non-goals: flex wrapping, percentage sizes, aspect-ratio constraints, transforms, margins (padding covers today’s cases), reversed flex directions, space-around/space-evenly, and align-self.
Absolute positioning exists in exactly one shape — Positioned, an offset applied to a single child inside the box the parent offered. There is no right/bottom anchoring and no positioned stacking context; a surface that wants to sit against the far edge still measures its own offset.
Text measures through an IFontMetrics abstraction — Advance, LineHeight, Ascent — so layout has no idea what a font file is. The core assembly ships only that interface; FontAtlas in Engine.Client implements it with stb_truetype (see the font pipeline).
Wrapping is greedy first-fit: UI labels are short, ragged edges do not matter, and greedy is the only variant that stays linear and is therefore safe inside a layout pass that may run more than once per frame. Newlines always break. A word too long for any line is broken between characters rather than left to paint outside its panel.
Numbers are printed by NumberText, in invariant culture, and there are two spellings on purpose. Bare (and Multiplier over it) keeps a fixed decimal count and drops the leading zero — .50x, 1.00x — which is what a readout changing under the pointer in a narrow chip wants. Trimmed is what a field wants: the same fixed format with every trailing zero and then the point itself taken back off, and the zero in front kept — 2400, 1.4, 0.5, 0. A value that rounds away prints 0 rather than the sign and the point the trimming would otherwise leave behind.
TextStyle.Overflow is text-overflow, and it is settled during layout on the same measurement the wrap used: a line still wider than the box it was given is cut to whole characters and ends in TextLayout.Ellipsis. Unlike a clip at paint time, the elided run reports the width it actually occupies, so a caller can still ask how wide a label ended up. TextLayout.Elide is the one place the fit is found — ProgressCard trims its last line through the same call.
Styling
Section titled “Styling”BoxStyle and TextStyle are the resolved style layer: plain value structs holding the properties an element actually paints with.
new BoxStyle{ BackgroundColor = UiColor.FromBytes(24, 24, 28), BorderRadius = 6f, BorderColor = UiColor.FromBytes(60, 60, 70), BorderWidth = 1f,}Every property is a plain value — a color, a float, an edge inset — with no delegates, lookups or anything else that could not be tweened. Nothing interpolates between two BoxStyle values today: the transition engine that ships composites whole subtrees, and per-property animation arrives with the stylesheet layer. The shape is the precondition, not a claim about current behavior.
Gradient fills
Section titled “Gradient fills”BoxStyle.Gradient is a two-stop linear ramp, the analogue of CSS’s background-image: linear-gradient(...). It supersedes BackgroundColor exactly as a CSS background image covers the color underneath it, is cut to the same BorderRadius, and sits under the same border.
new BoxStyle{ Gradient = GradientFill.Linear( from: UiColor.FromBytes(6, 6, 8).WithAlpha(0f), to: UiColor.FromBytes(6, 6, 8).WithAlpha(0.94f), direction: GradientDirection.ToLeft, exponent: 1.9f),}| Member | Meaning |
|---|---|
From / To | The two stops, authored in sRGB like every other UiColor. |
Direction | ToRight, ToLeft, ToBottom or ToTop. Arbitrary angles are not expressible. |
Exponent | A gamma on the ramp’s position: the mix factor is position^Exponent. Above one the fill holds near From; below one it does the reverse. Zero — what a default struct carries — reads as 1, so a gradient naming only its stops behaves like CSS’s linear-gradient. |
Two stops, not N. Every ramp the engine has wanted is a fade between one color and another, most often a scrim fading to nothing, and that is the shape that stays a fixed-size, value-comparable struct with no allocation. The exponent is what keeps two stops sufficient: it curves the fill in the shader rather than approximating a curve with extra stops. Something that genuinely needs three stops can be two boxes.
It mixes in sRGB, then linearizes. The attachment re-encodes on store, so a flat fill is linearized in the vertex stage and blended in linear light. The gradient deliberately runs the other way round: it interpolates the two stops in the space they were authored in and linearizes the result. Interpolating in linear light instead would put the ramp’s perceptual midpoint nowhere near its geometric middle — linear is proportional to radiance and the eye to roughly its cube root, so a linear-space fade dumps almost the whole visible transition into a narrow strip at the dark end. Mixing in sRGB is also what CSS does by default, so a ramp authored against a browser lands the same here.
It is dithered. The mix factor carries the same noise field the flat fill does, scaled by the ramp’s own span so the jitter stays proportionate whatever the stops are. Perturbing the factor rather than the output color is what lets one noise term move the color and the alpha in step. Without it, a wide low-contrast ramp — the menu scrim is the worst case, eighty display levels spread across nine hundred pixels — bands visibly.
Noise, and why it is one number
Section titled “Noise, and why it is one number”The interface has always carried a fine noise over its surfaces. It is the same noise term, now with an amplitude: client.ui.noise is a peak-to-peak figure in 8-bit steps, and it rides a seventh push-constant lane into halcyon_rect.frag — so every rectangle Halcyon draws is noised by the same number, panels, cards, scrims and switch tracks alike, with no per-call-site opt-in to forget.
Three things it is deliberately not:
- Not a post-process. A full-screen noise pass would noise the world too, and would have to be re-applied over UI drawn after it. The noise belongs to the surfaces, so it is applied where the surfaces are shaded.
- Not applied to glyphs. Text is already carrying antialiasing coverage at a fraction of a pixel; jittering it a step either way is legible as a shimmer, not as texture. Only the rect path is noised.
- Not in linear space. The offset is added in sRGB, before
srgbToLinear, which is what makes “steps” mean the same amount of visible noise over a dark panel and a light one. Adding it after would make the same number nearly invisible on the dark surfaces the interface is mostly made of.
The default is 8, which is visible without being a texture; 0 turns it off entirely, and 32 is the ceiling.
One number drives the gradient dither too. A ramp is where banding actually happens, so it is tempting to give gradients their own hidden dither floor that the preference cannot reach — and that is exactly how the two would drift apart until a scrim visibly did not match the panel on top of it. Instead the gradient path perturbs its mix factor by noiseOffset() / reach, dividing out the ramp’s own span so the output jitter is the same number of steps whatever the two stops are, and a ramp with no reach falls back to the flat path’s noise rather than coming out conspicuously smooth. Turning the noise off therefore also turns off the anti-banding, which is the honest trade: it is one effect with one control, not two.
The field is a photograph, not a function
Section titled “The field is a photograph, not a function”Interleaved gradient noise, evaluated per pixel, would be cheap and stable, but it lays out on a lattice of diagonal lines. At the low end that lattice is invisible; at the amplitudes this preference actually reaches it is the first thing the eye finds, and the effect reads as a repeating pattern rather than as texture.
The field is the tile PUI uses — assets/noise/noise_256_monochrome.webp from the Flutter UI package, copied to Assets/Noise/ so the engine and a PUI app carry one noise field rather than two that almost match. Engine/scripts/make-ui-noise.ps1 bakes it to Assets/Noise/noise-256-field.r8: PUI’s alpha times its gray, folded into one signed byte per texel (127 means “leave this pixel alone”), 64 KiB embedded straight into DigitalHeaven.Engine.Client and uploaded once as R8Unorm. There is no decoder, no mip chain and no file to ship. UiNoiseField owns the constants; halcyon_rect.frag reads it with texelFetch — no filtering, no scaling, one texel to one device pixel, exactly as PUI paints it, which is why the specks do not grow with the interface scale.
Two places this parts company with PUI, both on purpose:
- Added, not composited. PUI modulates the tile to an opacity and composites source-over, which pulls a surface toward the tile’s own gray. Its slider stops at three hundredths; this one goes to 32 steps, where that shift would visibly lift a dark card. The baked field is mean-zero and is added, so it is the same specks with no tint.
- Scrambled per block. A Flutter surface is a few hundred pixels of noise seen once. A full screen is a grid of 256-pixel tiles seen side by side, which is close enough to wallpaper to notice. Each block hashes its own coordinates into one of the eight dihedral symmetries of the square plus an origin inside the tile, so no two blocks on a screen present the same arrangement. Nothing is blended across the seams and nothing needs to be — the field is white noise, so a flipped or shifted sample of it is just as valid a sample and joins its neighbor invisibly.
The one honest cost: a photographic distribution spends fewer of its samples at full swing than the triangular one it replaced, so at the same step count it reads a little softer.
Animation. GradientFill is a value struct whose stops and exponent a component-wise lerp would walk with no special case. Direction is the one member that could not be blended: it is a discrete axis choice, and half way between “to right” and “to bottom” is a diagonal the type cannot express, so a future lerp must switch on it.
Interaction states are derived, not authored
Section titled “Interaction states are derived, not authored”Nothing in the theme names a hover color. A control’s hover and pressed paint is derived from its own base style by lifting every color it carries toward white — UiColor.Lift(amount), applied to the background, both gradient stops and the border, with alpha left alone:
| Constant | Value | Used for |
|---|---|---|
BoxStyle.HoverEmphasis | 0.08 | Style.Hovered() — the pointer is resting on it |
BoxStyle.PressEmphasis | 0.16 | Style.Held() — it is being held down |
BoxStyle.BareScrimAlpha | 0.6 | the scrim an unpainted control gains instead, since it has no fill to brighten |
Deriving rather than authoring is what keeps this theme-agnostic: a chip restyled to any color keeps a correct hover state, and the brand palette gains no new entries. It also means every clickable Box in the engine — settings chips, the console’s pills, log rows, the sidebar rail — lit up the moment this landed, with no call site changes.
Only what acts lights up. ResolveStyle returns the base style untouched when OnClick is null, so a box used as a panel and a switch used as a readout stay perfectly still under the pointer. A control that wants something other than the derived paint sets HoverStyle/PressedStyle explicitly, and passing default(BoxStyle) for both is the opt-out — the menu links take it, because a link there is a word on the backdrop and a scrim behind it would put a surface where the design has none.
Keyboard focus stays a ring, not a brightness step, so a focused control and a hovered one never look alike: Slider draws FocusRingColor at FocusRingWidth around its thumb while focused and leaves the thumb’s own fill exactly as authored.
The accent is a ramp, and the ramp is arithmetic
Section titled “The accent is a ramp, and the ramp is arithmetic”The same instinct applies one level up. ThemePalette grows from one seed — #C478B8 by default, the brand accent — and every tinted surface in the interface is derived from it through TonalRamp, which works in OKLCh: convert sRGB to Björn Ottosson’s OKLab, read it as lightness, chroma and hue, then hold the hue exactly, replace the lightness with a fixed target, and scale the chroma by a fixed fraction. Four tones fall out:
| Tone | L | Chroma × | Derived | Where it lands |
|---|---|---|---|---|
TonalWash | 0.22 | 0.40 | #281125 | A hovered rail row — present, but quieter than a selection |
TonalContainer | 0.30 | 0.52 | #41203C | The selected rail row’s rounded container |
TonalTrack | 0.56 | 0.85 | #995B8F | A switch that is on, a slider’s fill — the loudest tone, and still under the accent |
TonalOn | 0.86 | 0.35 | #E3C7DE | Ink and thumbs written on those surfaces |
OKLCh rather than HSL, because HSL’s “lightness” is a channel average and means something different at every hue: a 30% yellow and a 30% blue are nowhere near each other on screen, so an HSL ramp needs hand-correction per hue and stops being a recipe. OKLab’s L is perceptual, so one number produces the same apparent step whatever the accent is. Chroma is scaled rather than set, so a muted brand color yields a muted ramp instead of being pushed to a saturation it never asked for, and it is pulled down at both ends — a very dark tone at full chroma reads as mud, a very light one as a pastel cast.
Since it is arithmetic, changing the accent re-derives the whole look. A test pins the four bytes above (so a silent recolor of the UI is caught), and separate tests pin the structure — the tones climb in lightness in the order they are used, every tone keeps the accent’s hue, and swapping in a blue, a green or a yellow lands the same lightness targets. Pinning alone would be a change detector; structure alone would pass on a ramp that had drifted several shades.
The seed is a preference
Section titled “The seed is a preference”client.ui.themeColor picks the seed, and the whole ramp follows. ThemePalette.From(seed) derives the accent, the focus ring, the text selection, the four tones above and the console’s own accent fill and selection tints in one shot; UiTheme holds the live palette, and HalcyonSettingsTheme, HalcyonConsoleTheme and HalcyonChatTheme read through it rather than owning static readonly colors. That is why the settings screen’s card tint, a switch that is on, the console’s completion highlight and the chat caret all move together the moment the preference changes — none of them is authored.
| Seed | Value | |
|---|---|---|
orchid | #C478B8 | The brand pink, and the default — the interface’s color is unchanged |
rose | #E05A78 | A warm red-pink, one step around from the brand accent |
ember | #E0784C | Burnt orange, the warmest seed |
gold | #D8B24A | Muted gold — the lightest seed, so its container tone reads warmest |
fern | #6FBF73 | A mid green, saturated enough to stay green in the darkest tone |
teal | #4FBFB0 | Blue-green, the coolest seed that still carries visible chroma when dark |
azure | #5A9FE0 | A clear mid blue |
iris | #8C7CE0 | Blue-violet, one step around the cool way |
The list is deliberately short and pre-picked rather than a free color wheel: a seed has to survive being pushed to L 0.22 and to L 0.86 and still read as itself at both ends, and most arbitrary colors do not. Interface → Theme draws the choice beside five labeled swatches — the seed and the four tones it produces — so the ramp is visible before it is applied rather than after.
One tone is not seeded. Danger (#E05A5A) and the three tones derived from it stay fixed, because a destructive action must not turn out green because somebody picked a green interface.
The wire carries the resolved triple, not the ordinal. The client sends its seed as a packed 0xRRGGBB in clientInfo, and again in clientAppearance whenever it changes mid-session — one message carrying everything a player wears, color and avatar together — so a server that has never heard of a preset added later still stores a usable color. Protocol 27 is exactly this pair of additions; the server stores the value per player and replicates nothing, pending a use in chat.
The raw accent is still the accent. It is spent only where something must read as the highlight rather than as a surface: the focused text field’s border, and the focus ring. Everything that is a filled surface — a selected row, a switch track, a slider fill, a category chip on a search hit — takes a ramp tone. A chip that was previously the accent at fill strength is now TonalOn, because it is a label saying which section a hit came from, not a highlight competing with the control beside it.
Widgets and elements also carry a Classes set:
Ui.Box(classes: ["panel", "settings"], child: /* ... */)Nothing consumes it yet. It is the binding point for a future stylesheet-and-selector layer, which would match on classes together with the live pseudo-state the elements already track (Hovered and Pressed — the :hover and :active equivalents) and resolve down into the same BoxStyle/TextStyle structs described here.
UiTree.PointerUpdate(pointerState) routes a pointer sample. Hover is set on the whole ancestor chain, matching :hover. A press targets the nearest clickable ancestor of the deepest hit, so clicking the label inside a button presses the button. A release outside the pressed element cancels the click. The wheel goes to the innermost ScrollView under the cursor, and walks outward only when that one has nowhere left to go — per axis, so a scroller that refuses the horizontal component still consumes the vertical one and passes the rest outward on the same frame. Because a bar is a child of its scroller rather than a sibling of it, a notch delivered over the gutter reaches the scroller through the same walk.
Open popup surfaces are hit-tested before the root and drawn after it, which is the same ordering stated twice: a floating surface is on top, so it takes the pointer first and paints last. A press that misses every open surface dismisses them, and then carries on to whatever it actually landed on.
Only elements that paint or act absorb a hit
Section titled “Only elements that paint or act absorb a hit”Element.ConsumesPointer decides what happens when a point lands inside an element’s box but hits none of its children. A Box answers true — it is a surface, and a surface stops a pointer even where it is empty. The pure-layout elements — Flex, Stack, Layer, Spacer and the component wrapper — answer false, and the hit path unwinds back past them so the sibling underneath gets its turn. It is the same idea as Flutter’s HitTestBehavior.deferToChild.
This is not a nicety. Screens anchor themselves by filling the viewport with an invisible column, and a screen’s root is never unmounted (an exit has to animate, so the mount latch keeps it). With every element consuming its own box, the first screen ever opened kept a full-screen invisible column in the hit path forever and swallowed every click aimed at anything below it — which is precisely how the pause menu came to be drawn, animated, hovered by nothing and completely unclickable once the console had been opened once.
The same trap has a smaller shape, and it bit a second time: a Box used only to fix a width. Ui.Frame is that box with ConsumesPointer = false, and it exists so a sizing wrapper cannot become a surface by accident. The chat feed’s carrier was a plain Box, so once a session had shown chat, its 560×62 rectangle took the pointer over the menu underneath it for the rest of the process — and the links inside that band never lit under the pointer and never answered a click. A link that is dead and a link that is covered look identical, so it was reported as “Disconnect is grayed out”, which is worth remembering: when a control reads as disabled and its enablement rule says it is not, suspect the hit path before the predicate. Any wrapper that paints nothing is a Frame.
Hover, press, and what they cost
Section titled “Hover, press, and what they cost”| State | Set on | Rebuilds? |
|---|---|---|
Hovered | the whole ancestor chain under the pointer | no |
Pressed | only the press target — the nearest clickable Box ancestor | no |
Focused | the focused element and its ancestors (:focus-within) | yes |
Hover and press deliberately do not dirty a build: they change every frame the mouse moves, and a rebuild per sample would make pointer motion the most expensive thing the UI does. They are therefore resolved in Draw — Box.ResolveStyle(hovered, pressed) picks the paint at the moment of painting. A widget that needs structure to change on hover, rather than paint, can still ask for it explicitly with Box.OnHoverChanged plus SetState; the menu links do exactly that to recolor their text.
Focus is the exception because it moves at human speed, so it can afford to rebuild — which is what lets a build read context.IsFocused.
The double is a second handler, never a second gesture
Section titled “The double is a second handler, never a second gesture”Box.OnDoubleClick fires when the same element takes two clicks inside UiTree.DoubleClickSeconds of each other. OnClick still fires for both of them — a double is a shortcut laid over the single, not a replacement for it, so a row that selects on click and applies on double does the selecting twice and the applying once, and a person who was only aiming for the first click has still had it.
Three conditions each hold the answer away on their own:
- Identity is the
Element, not the widget. Widgets are rebuilt records and two of them compare equal whenever their fields do, so a list of identical rows would otherwise read a click on one and a click on its neighbor as a double. - The pair is consumed when it fires. Three clicks are one double, not two.
- An interval of
0refuses every double. That is not a degenerate case; it is what an offscreen capture asks for, so a timeline that clicks one control twice cannot fire a shortcut nobody authored.
A box with only OnDoubleClick is still clickable — Box.IsClickable is the predicate the press router uses, and a double-only box that could not win a press would never see the second click.
The interval is a preference, client.ui.doubleClickSeconds (default 0.4), stated once in Halcyon as UiTree.DefaultDoubleClickSeconds and restated by UiPreferences and HalcyonSettings because neither may reference the other. All three are pinned against each other by a test, exactly as the caret blink is.
The coordinate contract
Section titled “The coordinate contract”Hit-test positions descend in each element’s own local space. A parent converts before recursing, in exactly one place, and a scroll offset is not special — layout has already baked it into the child’s offset, so there is only ever one conversion per level.
This is pinned deliberately rather than by accident. A system that re-bases into local space on the way down but un-bases in global space on the way back is correct whenever the error cancels — at unit scale, or at zero scroll offset — and only becomes visible once a transform sits above a scrolled container, one bug per scrollable axis. UI scaling will eventually sit above ScrollView, which is exactly that arrangement, so the tests already compose nested scrolled containers at differing nonzero offsets where a mismatch cannot cancel out. Click-to-caret rides on the same contract: a text field converts the local point it is handed, plus its own horizontal scroll, into a character offset — it never looks at an absolute rectangle either.
Clipping falls out of this for free: a scroll view rejects a point outside its own box before any child sees it, so content scrolled out of view is unreachable even though its absolute rectangle still says it is up there.
HalcyonLayer.Update takes a pointerAvailable flag, and a caller withholding the pointer — in practice a headless capture aiming at nothing — hands Halcyon a position far offscreen rather than simply skipping the sample, so a widget that was hovered or pressed sees the pointer leave instead of freezing mid-interaction. The windowed client never withholds it: nothing else is competing for the pointer, and the tree’s own hit test is what decides whether the world sees the click.
The cursor is a product of the hit test
Section titled “The cursor is a product of the hit test”The OS cursor changes shape to say what is under it: a hand over anything clickable, an I-beam over text you can edit or select, a diagonal over a window’s resize grabber. Halcyon resolves which one out of the walk it already does — there is no second system of cursor regions, and therefore no way for a cursor to be declared and silently not apply.
Every Widget carries a CursorKind?. Null means “no opinion”, and the deepest hit element that has one wins, so a popup entry beats the popup, and a wrapper can never override what it wraps. Nothing with an opinion under the pointer resolves to Arrow.
Most call sites never set it, because the two common shapes are derived:
| Element | Asks for | When |
|---|---|---|
Box | Pointer | OnClick or OnDoubleClick is non-null |
EditableText | Text | always, read-only included |
SelectableText | Text | always — the I-beam promises “draggable text”, not “typable text” |
Slider | Pointer | OnChanged is non-null |
That one rule about Box covers every button, toggle, segmented chip, dropdown and menu entry in the engine, since all of them are a clickable box underneath. It also gets disabled right for free: “disabled” throughout this codebase is a null handler — the graying is only a color — so a control stops asking for the hand at exactly the moment it stops responding.
Dragging is deliberately not enough to infer a shape. A whole-window drag surface would otherwise put a hand over an entire panel, so a grabber declares its own explicitly — the scrollbar states Arrow, and a window’s resize frame states the diagonal or axis of the handle under the pointer.
While a press is captured, the captured element’s declaration wins over whatever the pointer is now above. This is the cursor half of the rule OnPointerDrag already encodes for coordinates: a drag keeps working off its own bounds, so it has to keep looking like itself too — a resize leaves the corner that started it on its first moved pixel, and a diagonal that snapped back to an arrow there would be worse than none at all.
UiTree.Cursor is the frame’s answer, surfaced through HalcyonLayer and UiLayer to the host, which hands it to ClientRuntime.SetCursorShape. WindowHost maps CursorKind onto SDL’s system cursors and only touches the mouse when the shape changes; each shape’s cursor is made once and kept. SDL has every shape Halcyon names on every desktop (it draws its own where the platform has none), so there is no fallback ladder. Grab is the engine’s own fly cursor from an embedded image, falling back to the pointing hand only where a platform refuses custom cursors. A frame that withholds the pointer parks Halcyon’s sample far offscreen, so the fallback to Arrow happens on its own with no extra gate.
The developer overlay’s windows are hit-tested by arithmetic rather than by elements — they predate the widget tree — so OverlayWindowInput resolves their edge and corner cursors against the same zones its drag model uses, and ClientHost prefers the widget tree’s answer whenever it has one, matching the order the two are painted in.
Focus & text input
Section titled “Focus & text input”A widget opts in with Focusable = true, and the tree holds exactly one focused element — focus is a property of the tree, not a flag several elements set independently. UiTree.FocusedElement is an element reference rather than a key or a path, because the element already is the identity that survives reconciliation: a rebuild that updates a field in place keeps focus, its caret and its selection for free, while a rebuild that genuinely unmounts the field drops focus through the same unmount path that clears hover and press.
| Call | Does |
|---|---|
Focus(element) | Focuses it. Throws if it is unmounted or not Focusable — both are silent-failure shapes otherwise, where focus appears to move and no key ever arrives. |
ClearFocus() | Focuses nothing, handing the keyboard back to the host. |
FocusNext(backward) | Tab / shift+Tab. Declaration order — a pre-order walk, which is the order the widgets were written and drawn in — wrapping at either end. |
KeyDown(keyEvent) | Routes to the focused element, then up its ancestors. Returns whether the UI consumed it. |
TextInput(char) / TextInput(string) | The same route for typed characters. |
WantsKeyboard | True while anything is focused. The one flag a host gates its own bindings on. |
Focus lights the whole ancestor chain (Element.Focused, BuildContext.IsFocused) exactly as hover does, so a card can draw a ring around a field it merely contains — the :focus-within equivalent. Unlike hover it also marks that chain for rebuild, which is what lets a build read context.IsFocused and pick a style; it is affordable precisely because focus changes at human speed, never per frame.
The tree itself binds two keys, and only as a fallback after every element has declined, so a widget can always override them: Tab moves focus and Escape drops it. Escape belongs to focus rather than to any field — “get me out of this control” is a property of being focused — and with nothing focused the tree does not consume it, so the host’s pause menu still opens on the next press.
Editing is Flutter-shaped
Section titled “Editing is Flutter-shaped”Text state lives in a TextEditController, not in the widget and not in the element:
private readonly TextEditController _name = new("halcyon");
Ui.Field(_name, placeholder: "Type something", width: 288f, onSubmit: Rename)The controller owns an immutable TextEditValue of (Text, Selection, Composing). Every edit produces a new value — the operations in TextEditing are pure static functions, and one that changes nothing returns the same instance — and assigning it raises Changed. Application code reads and writes controller.Text; the widget is free to be rebuilt from scratch every frame, which is exactly what an animating panel does.
TextSelection is directional, like Flutter’s: a BaseOffset anchor and an ExtentOffset that moves. The caret is a collapsed selection where the two are equal. Shift+arrows, shift+home/end and a mouse drag move only the extent, so a selection dragged backward past its anchor and forward again lands where a person expects; Start/End are derived minimum and maximum, computed where rendering needs an ordered range. Composing is a TextRange, empty by default and reserved: IME is not implemented, but the slot is in the model so CJK input can be additive rather than a change to the value’s shape.
Caret geometry goes through CaretMetrics, which measures with the same IFontMetrics the glyphs are drawn from — OffsetToX for the caret and selection rectangle, XToOffset for click-to-caret using the half-advance rule, and Reveal for the horizontal scroll that keeps the caret inside a field narrower than its text. Surrogate pairs are one caret stop in every direction, and word-wise movement follows the desktop rule: skip whitespace, then consume a run of one kind (word characters or punctuation).
EditableText is the bare editable run and TextField is the styled wrapper around it — a Box that swaps to FocusedStyle while focus is within, which is the whole reason it is a StatefulWidget. The field’s inset lives on the leaf, as EditableText.Padding, and never on the wrapping box. Only the leaf is focusable and a press focuses the deepest focusable element it hits, so padding held by the box is padding a press cannot focus through — it clears focus instead, which on a touchscreen (where UiInteractionStyle inflates that inset to reach a 44pt target) makes most of a field dead to the finger. TextField.SplitPadding decides the split for every text entry in the codebase, including the console prompt and the chat composer: the box keeps only the left edge, and only when a Leading ornament sits in it. Cut, copy and paste go through UiTree.Clipboard, an IUiClipboard; it defaults to an in-memory implementation, so a headless tree is fully functional and the Engine.Client binding to the window’s clipboard is one small class. Pasted text is flattened to a single line before it is inserted.
Selecting text you cannot edit
Section titled “Selecting text you cannot edit”A log is read, not edited, but it still has to be selectable — and a second selection model beside TextEditController’s would have been two answers to “which characters are highlighted?”. Two widgets extend the editing machinery instead of duplicating it:
SelectableText(Ui.SelectableRun) is a text run that also knows which line it belongs to and which slice of that line is selected. It lays out exactly asTextdoes, through the sameTextLayoutand the sameIFontMetrics, and maps a point to a character with the sameCaretMetrics.XToOffseta click into a field uses.SelectionRegion(Ui.Selection) wraps a subtree and owns the gesture. It isClaimsPress, so a drag that starts over selectable text selects rather than dragging the window the text sits in, and it reports every change throughonSelectionChanged— the selection itself is caller state, exactly like aTextEditController.
The model is LineSelection: an anchor (line, offset) and a focus (line, offset), both indices, never pixels. Its one interesting rule is that the two regimes are derived rather than latched:
- While the focus is on the anchor’s line, the selection is character-precise —
RangeOnreturns the crossed characters, and the run paints its own highlight. - The moment the focus lands on any other line, the selection is whole lines —
RangeOnreturns the entire line for everything between the two, and the region paints one full-width band per line so column gaps leave no holes.
Because the regime is a function of the current focus and not a mode that was entered, dragging back onto the starting line restores character precision for free.
SelectionRegion locates a point by walking its own children in local space, and a point that lands in a line’s vertical band but not on a run — the unselectable chrome beside a message, say — still resolves to that line. Runs outside the region’s box, which a virtualized list overscans into existence, are ignored. Nothing here is a pixel threshold: escalation is topological, so a tall line and a short one behave the same.
CopyText assembles the result by asking the caller for each covered line’s text and joining with '\n', skipping lines the caller no longer has. Only what is inside a selectable run can ever be copied, which is the structural reason a row’s metadata columns stay out of the clipboard.
The client seam
Section titled “The client seam”WindowHost buffers typed characters, so Halcyon reads the same buffer; the physical keys it needs — the editing keys, both sides of every modifier, and Apple’s Command keys as Control — are a short table in HalcyonKeyMap, since printable characters arrive as text rather than as keys. That table is the only one: the touch shells look their key edges up in it too (HalcyonKeyMap.For). The platform’s text entry — the IME’s composition and candidate windows on a desktop, the on-screen keyboard on a phone — opens exactly while UiTree.WantsTextInput holds, so a gameplay key never composes into an IME, and characters only arrive while a field wants them. Keys, text and mouse buttons count only while the window holds focus; the pointer’s position keeps tracking unfocused, so hover is right the moment focus returns. KeyboardSampler turns polled key states into press and auto-repeat events, capping the repeats one long frame may produce so a hitch cannot delete a paragraph.
Delivery happens after Tick and before SetRoot, so a focus move is reflected by the build that immediately follows it. It is gated on the caller’s keyboardAvailable exactly as the pointer is gated on pointerAvailable, and a frame where Halcyon has nothing mounted drops focus and clears the sampler rather than leaving the keyboard captured by a field that no longer exists. In the other direction the host suppresses its own backtick, Enter and F bindings whenever a field holds the keyboard — the console’s prompt, an inspector’s coordinate box, any of them — so no key spent on a field can also toggle a screen behind it. Escape is suppressed while Halcyon has something that owns it — a dismissable popup or the completion panel (UiLayer.PopupOwnsEscape), or a focused field outside the console (UiLayer.FieldOwnsEscape), since the tree spends Escape on whatever holds the caret either by the field’s own hook or by blurring it. Both are reported before the key is routed, because the shell and Halcyon are fed by two independent key paths and neither can consume a key on the shell’s behalf after the fact. The console is excluded from the field claim on purpose: its command line holds the keyboard for as long as the console is open, so a focus-only rule would leave Escape unable to close a console.
That whole gate is one function, ClientKeyRouting.Route(ownership, key), which answers a ClientKeyTarget (None, Withheld, Menu, Console, Overlay, Chat, ChatClose) rather than a bare bool — so every shell binding is decided in one place and can be tested from both sides of the seam, and the console’s open/close contract is a case in it rather than a condition scattered through the host. Its first rule is the chat prompt: while chat is composing, every key except Escape is withheld from the shell, including the movement keys, because a prompt that owns the keyboard must own all of it — typing “was” while a chat line is open cannot also walk you forward. Escape is the exception, and it is deliberate: it routes to ChatClose, so the shell can always get the player out of a prompt rather than depending on the prompt still holding the keyboard to hear its own cancel key. A rewrite of the pause/console contract relocates that one rule rather than hunting conditions.
A subtree can claim keys of its own with KeyListener (Ui.Keys(child, onKey)): it sees any key the focused element declined, before the tree’s own Tab and Escape fallbacks, which is how a screen binds a key regardless of which of its fields happens to hold focus.
Drawing
Section titled “Drawing”UiTree.Draw(drawList) walks the laid-out tree and appends to a DrawList — an append-only buffer of RoundedRect, TextRun, Line, ConvexPoly, TexturedRect, PushClip/PopClip and PushLayer/PopLayer. There are no GPU types anywhere; a texture is an opaque int.
ConvexPoly is the one primitive that covers area at an arbitrary angle — a gizmo’s plane panel, a rotate readout’s wedge. Convexity is a contract, not a hint: a backend is free to fan-triangulate from the first point, and a concave outline drawn through it comes out wrong rather than slow. It exists because the alternative, a fan of parallel strokes standing in for a fill, cannot hold one flat alpha against the pixel grid, and stripes through a translucent panel are what that costs.
Clips are intersected as they are pushed, so the rectangle on every PushClipCommand is final and a backend can hand it straight to a scissor without tracking a stack. Layers compose the same way: PushLayer returns the values already combined with every enclosing layer, so a backend never has to multiply opacities itself. Offsets add and opacities multiply; blur sigma deliberately does not accumulate, because two nested blurs of sigma 2 are not a blur of sigma 4.
Everything lands on the pixel grid, exactly once
Section titled “Everything lands on the pixel grid, exactly once”Layout is fractional; the draw list is not. A flex solver that rounded as it went would accumulate its rounding into the gaps between siblings, and a centered child would jump a whole pixel every time its parent grew by one. So layout keeps its fractions, and the conversion to whole pixels happens in one place: DrawList, as each command is recorded, with the enclosing layer’s translate already folded in. The Vulkan backend rounds nothing at all — by the time geometry reaches it, it is already on the grid.
The rule lives in PixelGrid, and DrawList.Grid is the copy in force. Its Scale is the device pixel ratio, so geometry is snapped on the device grid rather than the logical one; it is 1 on every display DigitalHeaven currently drives, and the arithmetic costs nothing when it is.
Snapping is half-up, not MathF.Round. Banker’s rounding is right for sums of measurements and exactly wrong for geometry: it is not monotonic across the half-pixel boundary, so a panel sliding continuously to the right sends 0.5 to 0 and 1.5 to 2 and the gap between two edges flickers between one pixel and two as the animation crosses each boundary. Half-up moves every coordinate the same direction, so a block of content stays rigid while it travels. There is no relax-during-animation: content moves in whole-pixel steps, which is what keeps subpixel text antialiasing filtering against the same phase from the first frame of a transition to the last.
Four rules, by what is being snapped:
| Shape | Rule | Why |
|---|---|---|
| A box | All four edges round | Two boxes sharing an edge keep sharing it. Rounding the size instead would let each of them round the shared edge its own way and leave a seam. |
A hairline — a rule, a divider, an icon stroke at or below ThinShapeMaxExtent | Width first: the thickness rounds once, then the far edge is placed that far from the snapped near edge | A 1.5-pixel bar whose edges round independently is 1 pixel thick at one subpixel phase and 2 at the next, so a row of nominally identical strokes comes out ragged and an animated one pulses. A hairline has no neighbors to need exact adjacency, so it trades that for constant thickness. Strokes are centered by parity, so a gizmo’s dark halo and the fill it backs stay concentric. |
| A clip or a layer’s bounds | Outward: floor the top-left, ceiling the bottom-right | A scissor that rounded inward could land half a pixel inside a fill that rounded outward and shave a column off the content it was only meant to bound. |
| A text origin | Rounds in X and Y; the line pitch is rounded too | Consecutive baselines then differ by exactly one integer, so a paragraph cannot drift off the grid down its own height. Glyph advances and ascents are rounded in the metric rather than at paint time, so measurement, hit testing and painting agree. |
A diagonal line is left exactly where it was asked for, position and width both — a rotating gizmo arm has to sweep, not step. A convex polygon is given the same treatment for the same reason, and more bluntly: every polygon that reaches the draw list is a projected one, so it has no edge that would land on the grid even in principle. A shape that could be snapped is a rectangle, and rectangles have their own command.
Persistent origins — a dragged window’s position, a scroll offset — snap where they are written, not only where they are drawn, so hit testing and painting agree about where a thing is. Momentum may stay fractional inside the scroller; the offset it publishes does not.
Why any of this matters: a rounded rect is rasterized as a quad that is exactly its own rectangle, while the signed-distance edge is antialiased symmetrically about that boundary — half a pixel inside, half outside — and the outer half falls outside the quad and is never shaded. On a whole-pixel edge that costs nothing. On a fractional edge the left and top edges spread one border pixel over two columns that between them still carry its full weight, while the right and bottom edges simply lose the column that fell outside. The result is a button that looks like it was drawn with a thicker pen along its top and left, which is what flex layout at fractional offsets produced all day.
The render backend
Section titled “The render backend”HalcyonRenderBackend (Halcyon.Vulkan) consumes a DrawList through dynamic rendering: one alpha-blended depth-free pipeline family built for the swapchain format, with the caller owning the rendering block.
Textures are per-draw. Every registered texture gets its own combined-image-sampler descriptor set and an opaque one-based id, so TexturedRect works with any of them.
There is no vertex buffer. Each command is a four-vertex triangle strip generated in the vertex shader from gl_VertexIndex, with everything that varies carried in a 96-byte push-constant block (viewport, rect, uv, color, border color, shape). That trades a draw call per command for no buffer management whatsoever, which is the right trade at Halcyon’s volume: a screen is tens of quads, and the rounded-rect SDF needs the rectangle’s extents per fragment anyway, so the data would be duplicated into every vertex regardless. Text is the one place the cost shows — a quad per glyph — and batching glyph runs is the obvious optimization if a paragraph-heavy screen ever needs it.
Two commands take a different vertex stage rather than a different buffer. A line is an oriented quad built in the segment’s own frame by halcyon_line.vert, from two endpoints rather than an origin and a size, grown by the half thickness at each end so the round caps are not clipped square; teaching the shared stage about rotation would have cost every rect, glyph and blur quad a sin/cos it never needs. A convex polygon is fan-triangulated on the CPU and submitted as one three-vertex draw per triangle, the corners riding in the push constants exactly as a rectangle’s do. Still no buffer, still nothing to manage — a polygon at Halcyon’s volume is a handful of triangles.
Eight fragment shaders share one pipeline layout:
| Shader | Draws |
|---|---|
halcyon_rect.frag | Rounded rect fill and border, from a shared SDF in halcyon_sdf.glsl. The border is the ring between the shape and the same field offset inward, which keeps the stroke an even width around the corners. It also evaluates the gradient fill, reusing the uv push-constant lane (dead in this pipeline) for the ramp’s second stop rather than growing the six-vec4 block every Halcyon stage shares. A box with no gradient takes a uniform branch and records the same commands it always did. |
halcyon_texture.frag | A textured rect masked by the same rounded coverage. Repeat wrap, so a UV rectangle wider than one unit tiles. |
halcyon_line.frag | A round-capped stroke, as a capsule SDF evaluated in the segment’s own frame — the same frame halcyon_line.vert built the quad in, so the two never have to agree on a matrix. Antialiased over exactly one pixel. |
halcyon_poly.frag | A flat fill, and the one Halcyon stage with no distance field in it. That is the point rather than an omission: the polygon is fan-triangulated, so an antialiased edge on every triangle would fade the interior seams too and a translucent panel would blend twice along the fan’s spokes — the stripes this primitive exists to be rid of. Coverage is left to the rasterizer, which tiles adjacent triangles exactly; a caller who wants a soft outer edge strokes one over the fill. |
halcyon_text.frag | A glyph quad on the grayscale path: one coverage sample from the single-channel atlas, stem-darkened and put through the mask correction in halcyon_text.glsl. No sRGB decode of the atlas — coverage is a geometric fraction and gamma has no meaning for it. |
halcyon_text_subpixel.frag | The same glyph quad on the subpixel path: five atlas taps become three per-channel coverages, each corrected independently, emitted as two outputs for dual-source blending. Bound only where the device supports it. |
halcyon_blur.frag | One axis of a separable gaussian over an offscreen layer target. Sigma and the per-tap stride ride in the shape lane; the source is premultiplied, which blurs correctly channel-for-channel. |
halcyon_composite.frag | Draws a finished layer target back over the frame, premultiplied, scaled by the layer’s composed opacity. Deliberately no sRGB decode: the target carries the pass’s own sRGB format, so the sampler has already linearized. |
Translate and opacity from a layer are applied CPU-side while walking the list: both are affine over a subtree, so the backend folds the translate into each rectangle’s position and the opacity into each color’s alpha. No offscreen target, no GPU work — for an unblurred layer, which is every layer of a settled UI.
The pass renders into the post-tonemap LDR window, after the HDR resolve, and records the frame’s three lists in one go — see the developer surfaces for what goes in which and why the order is what it is.
Blur is a second entry point, not a second pass
Section titled “Blur is a second entry point, not a second pass”A blurred subtree has to be composited offscreen before anything can sample it, and Renderer.RecordUiPass records inside an already-open vkCmdBeginRendering block, which Vulkan forbids nesting. So the backend has two entry points and the renderer calls both:
Prepareruns before the swapchain block opens (immediately before the scene-to-UI barrier, where the tonemap pass has provably closed its own block). It walks the frame’s draw list for layers whose sigma clearsclient.ui.blurThreshold, and for each one replays that layer’s commands into an offscreen target sized to the layer’s bounds grown by 3σ and clamped to the viewport, then runs the two-pass separable gaussian across a ping-pong pair. Each of those is a rendering block of its own.Recordthen draws one composite quad per prepared layer in place of replaying its commands, positioned by the layer’s composed translate and modulated by its composed opacity.
The subtree is replayed offscreen with its own translate and opacity divided back out, so the ordering is exactly the design note’s: blur wraps the content, translate wraps that, opacity outermost. A blurred layer nested inside another is replayed sharp inside the outer composite — two nested gaussians are not a third gaussian, and Halcyon’s own UI does not produce the case.
Targets are pooled per frame-in-flight slot and grow-only, so a viewport change resizes at most once and a shrink simply uses less of an existing image. Renderer.DrawFrame already waits on that slot’s fence before recording, which is what makes recreating and rewriting a slot’s images mid-recording safe without a retirement queue.
The backdrop is the other thing that can be blurred
Section titled “The backdrop is the other thing that can be blurred”A layer blur blurs a subtree’s own pixels. A plate floating over live play — the speedometer, the flight indicator, the unsaved-edits badge — wants the opposite: its own pixels are a fill, and what should be soft is the world under it. That is a second verb, DrawList.BackdropBlur(rect, radius, sigma), recording a BackdropBlurCommand, and HudPlate is the one caller: every floating HUD surface draws through it, so the plate is one object rather than three cards.
The backend serves it in the same two entry points. Prepare is handed the image the UI pass is about to draw over (the present image, after tonemap and before the scene-to-UI barrier); for each backdrop command that clears the threshold it transitions that image to a transfer source, clears a pooled ping target and blits the region — the plate grown by 3σ — into it, hands the image back in color-attachment layout, and runs the same separable gaussian a layer’s composite runs. Record then draws the composite quad in place of the command, sampling only the plate’s own window inside the region (the margin exists to feed the taps, not to be seen) and clipped to the plate’s corner radius by halcyon_composite.frag, so the blur and the bezel painted over it share one silhouette.
Three things can make the backend decline. With the world docked into an editor pane the present image holds nothing yet (the UI clears it rather than loading it, and the world is a picture in the tree), so no backdrop is offered. An output whose images carry no transfer-source usage — a swapchain on a surface that does not support it, reported by IRenderOutput.CopyableSource — offers none either. And a sigma below client.ui.blurThreshold is left alone. In every one of those cases the command prepares nothing and replays as nothing: the sharp world is already under the plate, and the plate’s ground (client.hud.plateOpacity, an honest linear-light number) has to read on its own. The plate’s rings are the dialog bezel’s rings at client.hud.plateRingOpacity — the lighter version of the doubled border, lighter by fading rather than by thinning, since a ring is already one device pixel.
The font pipeline
Section titled “The font pipeline”FontAtlas (Halcyon.Fonts) implements IFontMetrics against StbTrueTypeSharp (MIT, pure managed), referenced by Halcyon.Fonts only — the Halcyon core assembly stays dependency-free. It bakes an alpha-only 2048×2048 atlas of printable ASCII at four sizes (small 11, body 14, heading 22, title 44) from the JetBrains Mono the engine already ships, uploaded as R8_UNORM — a quarter of the memory an RGBA bake of the same glyphs would take. Every one of those sizes is baked exactly, so no size the UI actually uses is ever a scaled bitmap.
The atlas is rasterized at 3× horizontal oversampling and box-filtered back down by stb, which is what makes subpixel rendering possible without a second atlas: the stored texel already carries a third of a pixel’s worth of coverage. All four sizes together occupy about 563 000 of the atlas’s 4.19 million texels, so 2048 has room for several more sizes before it needs to grow.
Metrics and rasterization are split on purpose:
- Advances, ascent and line height are analytic, scaled from the font’s own tables, so they are exact at any size, including sizes that were never baked. Layout therefore always agrees with painting.
- Glyph images come from the nearest baked size and are scaled to fit. Off-size text is slightly soft; it is never mispositioned.
Shaping is advance-width only — no kerning, no ligatures, no bidi, no complex-script shaping. A character outside the baked range substitutes ? in both the advance and the glyph lookup; measuring one and painting the other would silently drop characters while keeping the space they occupied.
Text rendering
Section titled “Text rendering”Halcyon has two text paths and picks between them per draw. The subpixel path is ClearType-class: it places edges at thirds of a pixel by driving a display’s red, green and blue stripes independently. The grayscale path is the classical one, a single coverage value per pixel. Neither is a mode a player switches into for the session — client.ui.textAa sets a ceiling, and the backend decides, for every run, whether that ceiling can be honored this frame.
Coverage is not alpha: the mask correction
Section titled “Coverage is not alpha: the mask correction”The UI composites in linear light against an sRGB attachment: the hardware decodes on load, blends linearly, and re-encodes on store. Coverage from a rasterizer is a geometric quantity — the fraction of the pixel the outline covers — and handing it straight to that blend puts a half-covered texel roughly a quarter of the way to the text color instead of half. Light text on dark thins out; dark text on light thickens. This is the same problem Skia solves with SkMaskGamma, and Halcyon solves it the same way.
TextAntialiasing.MaskAlpha solves for the alpha that makes the linear blend land where an encoded-space blend of the same coverage would:
- The destination is not known at the draw call, so it is guessed the way Skia guesses it: the perceptual inverse of the text color,
E_b = 1 − E_f. Light text is assumed to sit on dark and vice versa, which is true of every surface the UI actually draws. - Both endpoints go through the real sRGB transfer function, toe included, not a
pow(2.2)approximation. - The correction is fixed at both ends — zero coverage stays zero, full coverage stays full — and monotonic in between, so it can never invert a gradient.
- When the text color and its guessed destination are within
LuminanceEpsilonin linear luminance, the correction is the identity. There is no contrast to correct and the solve would divide by nothing.
The subpixel path applies the same correction independently to each of R, G and B. That is not a detail: correcting the three channels by different amounts, or correcting a luminance and scaling the channels by it, is exactly how a subpixel renderer acquires a color cast. The luminance that selects the correction is computed once per run from the encoded text color and reused for all three channels.
The earlier empirical pow(1.45) blend curve is gone. It was a fit to this effect, and the derived correction replaces it.
Stem darkening, and the size it is aimed at
Section titled “Stem darkening, and the size it is aimed at”One perceptual constant survives, and it is measured rather than chosen. StemDarkeningPeak emboldens coverage before it becomes an alpha, with the shape c + (1 − c)·c·amount — zero at both ends, largest at half coverage, so only the partial texels a thin stroke is made of move at all. FreeType calls this stem darkening, DirectWrite calls it enhanced contrast, Apple emboldens the outline outright.
The amount is aimed by rendering the same string in the same typeface at the same physical size through Halcyon and through Windows’ own ClearType, and summing the ink in each:
| Size | Engine ink ÷ Windows ink, undarkened | Shipped |
|---|---|---|
| 11 | 0.708 | 0.871 |
| 14 | 0.830 | 0.830 |
| 22 | 0.831 | 0.831 |
| 44 | 0.901 | 0.901 |
22 px and 44 px define the band: a fixed systematic difference the constant is not trying to close. 11 px sits 12 to 19 points below it — a real deficit. 14 px does not: undarkened it measures 0.830 against 22 px’s 0.831, the same weight to within a thousandth. So the ramp runs full at 11 px, zero at 14 px and linearly between, and the peak is 0.45 because that is what measures 11 px back into the band (0.15 leaves it at 0.763, 0.30 at 0.816). An earlier shape that darkened everything below 22 px pushed 14 px to 0.937 — heavier than every other size on the sheet, including the 44 px display line.
Dual-source blending
Section titled “Dual-source blending”Per-channel alpha over an arbitrary background cannot be expressed with one output. The subpixel pipeline declares two:
layout(location = 0, index = 0) out vec4 outColor; // text color, premultipliedlayout(location = 0, index = 1) out vec4 outBlend; // per-channel coverageand blends with SrcColor = One, DstColor = OneMinusSrc1Color, so each channel is attenuated by its own coverage. This needs the Vulkan dualSrcBlend device feature. GraphicsDevice requests it and exposes SupportsDualSourceBlend; when a device does not have it the subpixel pipeline is never created, and the resolve below returns grayscale for every run — one code path, no branch at the call site.
The filter is ClearType’s own: two box-3 passes compose to [1, 2, 3, 2, 1] / 9. The atlas is already box-3 prefiltered by stb’s 3× oversampling, so the shader gathers three adjacent stored texels and gets the five-tap kernel exactly. That reach is why FontAtlas.AtlasPadding is 3 rather than 1, and why AtlasRightMargin exists at all: stb’s own padding clears the left and top edges only, and a tap two texels past the last column wraps into the next row, which is a different glyph.
Degradation is per draw, and it is a question about animation
Section titled “Degradation is per draw, and it is a question about animation”TextAntialiasing.Resolve is a pure function, and every one of its conditions alone forces grayscale:
| Condition | Why |
|---|---|
The ceiling is grayscale | The player asked. |
The device has no dualSrcBlend | There is no subpixel pipeline to bind. |
| The size is not a baked one | A scaled bitmap has already lost the stripe alignment the filter depends on. |
| Layer opacity < 1 | A fringe blended at partial alpha is a colored halo, not a sharper edge. |
| Text color alpha < 1 | Same, one level down. |
| The layer translate is not a whole pixel | A transition is animating this subtree right now. |
| The run is inside an offscreen blur composite | The composite is resolved and re-blended, so the destination the correction assumed is not the destination it lands on. |
The interesting one is the translate. LayerValues carries only translate, opacity and blur sigma — no scale, no rotation — which makes a fractional translate a complete signal that a transition is mid-flight. The run’s own layout position is deliberately not tested: the filter is horizontal, and the backend rounds both the pen and the baseline before emitting a glyph, so a run laid out at y = 391.5 paints exactly the rows a run at y = 391 does. Testing the layout position would send vertically-centered rows in the settings screen to grayscale for no benefit, against a number that is thrown away regardless.
Vertical scrolling keeps subpixel, and that is correct: ScrollElement moves content through layout Offset, and a vertical translation moves every fringe rigidly without changing any of them. Every desktop text stack does the same.
The evidence that the predicate holds is a capture pair rather than an argument. Shooting the whole UI timeline twice — once with the ceiling at subpixel, once at grayscale — every settled frame that draws text differs between the two runs, and every mid-transition and mid-fade frame is byte-identical. On a fading specimen the maximum per-pixel channel spread is 3 of 255, against 135 on the settled one.
Preferences
Section titled “Preferences”| Key | Default | What it does |
|---|---|---|
client.ui.textAa | subpixel | Ceiling on text antialiasing. subpixel uses the display’s stripes; grayscale is the fallback for panels whose stripes run the other way, or for anyone who sees the fringing rather than the sharpness. Exposed in the options window under Interface → Text |
client.ui.textSpecimen | false | Mounts a full-viewport specimen sheet: the same string at every baked size over dark, light and mid-gray grounds. For A/B-ing the two paths live |
Interface scale
Section titled “Interface scale”client.ui.scale magnifies the whole interface — every menu, panel, control and label — between 75% and 200% in 5% steps. It applies on the next frame from anywhere: the Interface scale slider at the top of the options window’s Interface section, a console line, or Ctrl and the mouse wheel wherever a menu is open.
It is a layout input, not a transform. UiTree.UiScale is multiplied into every metric an element reads off its widget — Element.Scaled(float), Scaled(float?) and Scaled(EdgeInsets) — so a box’s padding, declared width and height, corner radius and border weight, and a layer’s translate, all come out of layout already magnified. Nothing is applied at draw time and nothing is applied at pointer time: hit testing runs against the same rectangles layout produced, so a magnified button is hit exactly where it is drawn, by the same code that hits an unmagnified one.
The consequence worth knowing about is that an authored width is magnified while the viewport is not — the display does not grow when the interface does. A screen that means “the whole viewport” is therefore handed displaySize / uiScale, and UiTree.ViewportSize stays in real pixels. Without that division a viewport-sized frame at 125% would be a quarter larger than the screen and drag everything anchored to its edges off the bottom and right.
The slider that sets the scale is dragged through its own relayout, and that is why a slider’s drag is a captured gesture rather than a live measurement. SliderElement records a SliderTrackMapping — the absolute viewport x of fraction zero, and the pixels of travel — at the press, and every sample until the release reads through it. Read live instead, the loop closes on itself: a sample raises the scale, the raised scale magnifies the control’s authored width and pushes its origin along, and the next sample of a barely-moved pointer lands on a different fraction of a track that has moved under the finger — which slams the value between the extremes and flickers the whole interface once a tick. Viewport space is the space that survives, because it is the space the pointer is reported in. The knob is still painted from the value on the live track, so it stays inside the control it belongs to; the capture governs the value alone, and it lives on the element, so the per-frame rebuild that relayouts the slider cannot take it away.
Text is re-rasterized, not stretched. FontAtlas.Rebake(scale) re-bakes the whole ladder (11/14/22/44 × scale) into the same pixel buffer and the backend re-uploads into the already-registered image, so a wheel notch costs one repack rather than a new four-megabyte atlas. FontAtlas.MaxBakeScale and UiPreferences.MaxScale are pinned equal by a test, because a ceiling raised at one end only would produce blurry off-size text at the top of the range instead of a clamp.
client.ui.scale is not the same knob as PixelGrid.Scale. That one is the device pixel ratio, and it decides which grid commands are snapped onto; snapping keeps happening in device pixels no matter what the interface scale is.
Ctrl and the wheel
Section titled “Ctrl and the wheel”The gesture is gated on a menu being up — the pause menu, the main menu, the options window, the console, the demo panel or the specimen sheet. During play the wheel belongs to the game, so the gesture does not exist there. Chat is deliberately excluded too: its feed scrolls, and a modifier that quietly stole those notches would be a worse trade than a player having to open a menu.
A claimed notch is consumed — it never also scrolls whatever is under the pointer — and it is claimed at the top of the frame, before the tree is built, so the readout is up on the same frame as the gesture. The layer only reports the notch; the host writes the preference, so the wheel, the slider and a console line cannot drift apart.
The readout
Section titled “The readout”Changing the scale raises a small pill near the bottom of the screen carrying the current percentage and a Reset button back to the default. It uses the standard panel transition, so it arrives and leaves the way every other surface does.
It lingers for client.ui.scaleBarLinger seconds after the last change, and hovering it holds it open — the timer is re-armed on both edges of the hover, so the pill cannot fade out from under a cursor on its way to the one button it carries. A notch that the clamp turns into no change at all still counts as a gesture and still raises the readout; otherwise pushing against the end of the range would look like the wheel had stopped working. At the default the Reset button is drawn but inert, so the pill does not change width the instant the scale comes home.
| Key | Default | What it does |
|---|---|---|
client.ui.scale | 1 | Interface magnification, 0.75–2.0, snapped to 5% steps |
client.ui.scaleBarLinger | 1.4 | How long the scale readout stays up after the last change, seconds. Hovering it holds it open regardless |
Transitions
Section titled “Transitions”UiTree.Tick(dt) advances every registered AnimationController and marks the owning states dirty; call it before Update() or the frame paints one tick stale. A controller holds nothing but raw linear 0..1 progress and a direction. That is the design note’s central instruction: an exit is the entrance played in reverse, from wherever the entrance got to — not a second timeline authored to look like the first one backwards. Reversing at 0.4 resumes from 0.4, which a separate exit starting at 1.0 cannot do without a jump.
Curves are applied where the value is consumed, never in the controller, because one timeline feeds several properties that ease differently. TransitionRecipe.Evaluate(progress) turns progress into a LayerValues (translate, opacity, blur sigma — and deliberately no scale).
The standard panel transition, frozen from Engine/design-notes/halcyon-menu-transition.md:
| Property | Value | Curve |
|---|---|---|
| Duration | 150 ms, one timeline both ways | — |
| Translate | 7 px vertical → 0 | ease-out-cubic, complete by 50% of the timeline |
| Opacity | 0 → 1 | ease-out-cubic, complete by 50% of the timeline |
| Blur sigma | 6 → 0 | ease-out, across the full duration |
| Scale | none | — |
The front-loading is the whole effect: at 75 ms the panel is placed and opaque while the blur is still resolving, which reads as focus pulling in rather than as a crossfade. An optional reverse flag flips the travel direction and nothing else.
The content-arrival sibling is the same machinery with different numbers — 320 ms, 16 px, opacity 0.08 → 1, everything eased over the full duration, no stagger and no per-child delay.
new Transition{ Visible = _open, // false plays the same timeline backwards Recipe = TransitionRecipe.Menu, Child = /* ... */,}Defaults live as named constants on TransitionRecipe so tests assert against one source of truth, and Engine.Client seeds a recipe from client.ui.* preferences each frame so they are tunable live from the console.
A Transition only composites, so it cannot move layout or change a style. Animated is the same controller for those: it heads for the end Active names from wherever it is and hands the eased progress to a builder. Paired with Reveal, a clipped drawer that shows a fraction of its child’s height from the bottom edge up and takes only that much space, it slides a card out from under another with its height, offset and opacity on one timeline. From = RevealFrom.Below is the mirror, a bar coming up from behind the card below it:
new Animated{ Active = _open, Builder = t => new Reveal { Factor = t, Child = new Layer { Values = LayerValues.Identity with { Opacity = t }, Child = drawer } },}The demo surface
Section titled “The demo surface”Nothing player-facing changes by default. client.ui.demo true mounts a Halcyon card — a flat surface, text at two sizes, a focusable text field, a toggle, a proportional bar and a scrolling list — wrapped in the standard transition and bound to the preference, so flipping the key demonstrates both the enter and the exit.
The field autofocuses, which does mean the demo panel holds the keyboard while it is up: Escape unfocuses it and a second Escape reaches the pause menu. That is acceptable for a developer-only panel that is off by default, and the alternative — a capture that cannot show a focused field or a caret at all — is worse.
| Preference | Default | Meaning |
|---|---|---|
client.ui.demo | false | Show the demo panel |
client.ui.transitionDuration | 0.150 | Panel transition duration, seconds |
client.ui.transitionTranslate | 7 | Vertical travel, pixels |
client.ui.transitionBlur | 6 | Peak blur sigma, pixels. Zero turns the offscreen composite off entirely |
client.ui.blurThreshold | 0.05 | Sigma below which a layer skips the composite and draws direct |
client.ui.blurMaxSigma | 16 | Ceiling on any layer’s sigma, bounding target size and tap count |
client.ui.transitionArriveFraction | 0.5 | Fraction of the timeline over which translate and opacity finish |
client.ui.panelOpacity | 0.98 | Opacity of panel and card fills. High because Halcyon blends in linear light, where the same alpha transmits far more of a bright surface behind the panel than a gamma-space toolkit would; the developer overlay’s chrome sits at the same value for the same reason |
client.ui.caretBlink | 1.06 | Caret blink period in seconds, one full off-and-on cycle. Zero holds the caret solid |
client.ui.textAa | subpixel | Ceiling on text antialiasing; the backend still decides per draw |
client.ui.textSpecimen | false | Show the text specimen sheet for A/B-ing the two paths |
client.ui.showOcclusion | false | Paint the screen-occlusion debug wash: green over the screen the UI may use, red over whatever is covering it |
client.ui.noise | 8 | Noise over every Halcyon surface, peak to peak in 8-bit steps. Clamped 0–32; zero is off |
client.ui.themeColor | orchid | The seed the accent and every tinted surface are derived from. One of orchid, rose, ember, gold, fern, teal, azure, iris |
client.ui.menuTheme | auto | The main menu’s backdrop and the treatment its text takes. One of auto, immersiveDark, black, charcoal, accentTinted, accentMuted, white, legacy |
client.ui.menuTintSaturation | 0.06 | Saturation of the accentTinted backdrop, 0-1 |
client.ui.menuTintLightness | 0.11 | Lightness of the accentTinted backdrop, 0-1 |
client.ui.menuMutedSaturation | 0.10 | Saturation of the accentMuted backdrop, 0-1 |
client.ui.menuMutedLightness | 0.08 | Lightness of the accentMuted backdrop, 0-1 |
The menu
Section titled “The menu”The pause menu and the main menu are one screen with two link sets: a left-aligned column over the live scene, in the shape Garry’s Mod uses — the brand wordmark, then bold text links in groups, over a flat scrim that dims the whole viewport. There is no centered dialog and no full-screen fader. Nothing is a card and nothing is centered; the world stays the thing you are looking at.
The title is the real mark, drawn as geometry
Section titled “The title is the real mark, drawn as geometry”The menu’s title is the shipped DigitalHeaven wordmark, not its name set in the UI font. BrandWordmark holds the mark’s 98×5 block grid — the same art the CLI prints on the terminal and the same art Docs/DigitalHeaven.Docs/src/assets/logo.svg ships as 91 rectangles — merges each row into runs, and hands them to a Canvas. The whole grid is painted twice: once in the shadow color one grid unit below and right, then once on top, split into white for DIGITAL and the brand accent for HEAVEN by which side of the grid a run sits on.
Geometry rather than a texture, for three reasons that are all the same reason:
- No sampling, so no filtering to get wrong. Halcyon antialiases a rectangle across one pixel of its distance field, so on a whole-number scale at a whole-number origin every edge lands on a pixel boundary and coverage is exactly zero or one. The mark is as sharp as nearest-neighbor at every size, with no texture upload, no image decoder and no mipmap decision.
HalcyonMenuTheme.LogoScaleis3— 588×57, the same optical weight the old two-line text lockup had — and it is a whole number on purpose: the grid’s rows are one unit apart, and a fractional scale would land those gaps on a half pixel where the antialias closes them up. - The mark is a lattice, and says so. The shapes are authored in grid units and the canvas is given
Unit = LogoScale, so the cell is rounded to whole device pixels before any run is placed. A wholeLogoScaleis not enough on its own:client.ui.scalemultiplies it, and 3 × 1.1 is 3.3, which rounds the mark’s stems to different widths and reshuffles them on every resize. Quantizing the cell instead means the mark grows in whole steps — unchanged at 110%, one unit bigger at 125% — which is what pixel art wants. Its drop shadow is measured in units for the same reason, so it stays exactly one cell behind the artwork and grows with it. - The canvas snaps, because everything does. The menu centers its column vertically, so its origin is half of whatever slack is left over — a fraction, most of the time. Every run shares that one origin, so they all round the same way; the canvas’s clip is rounded outward so the snap cannot shave the far edge.
- The font atlas stays as it is. It bakes printable ASCII only, so the block character the art is written in has no glyph in it; rendering the mark as text would have meant widening the atlas for one string.
Tests pin the grid against the shipped SVG’s numbers — 91 runs, three units tall on a four-unit pitch, filling exactly 196×19 — and that no run straddles the boundary between the two words, which would silently repaint half a letter. A second set pins the mark and the links in device pixels across a sweep of magnifications and viewport heights, because a menu measured only at 100% on an even viewport is measured at the one setting where every pairing bug is invisible.
The links carry the same shadow the mark does, and carry it the same way: the shadow is declared on the run through TextStyle, not built as a second Text behind the first. The menu has no panel under it, so the shadow is the only thing keeping a white word legible over a white wall — and it is the surface where a shadow that drifts by a pixel against its own label is most obvious, because there is nothing else on that side of the screen to look at.
Which links appear is a function of one thing — whether there is a session:
| Group | With a session (pause) | Without one (main menu) |
|---|---|---|
| 1 | Resume | Start Game, Quick Launch |
| 2 | Server Settings, Options, Console | Options, Console |
| 3 | Disconnect, Quit | Quit |
The link set is not the only thing that answer decides. The scrim and the lens vignette belong to a session, not to the menu. A pause screen dims the world because there is a world behind it that you are being taken out of; a main menu has no world at all — ending a session dissolves the map and unloads it — so a full-viewport dim over an empty frame is dimming nothing, and a vignette is a lens over a scene that is no longer there. Both are multiplied by a session level that is 1 throughout a session and 0 at the main menu. Only the flat full-viewport box goes: the wordmark, the links and everything else the column paints are untouched, because they are the menu’s own art rather than the pause treatment.
That level is a ramp, not a switch, because the frame a session ends on is the frame the world starts dithering away on, and a dim that popped off while the map was still fading would announce the transition twice at two different speeds. client.ui.sessionFade is how long the ramp takes, and 0 there is a real off switch: both are gone on the same frame, with no animation at all.
The default is declared as RenderPreferences.DefaultWorldDissolve rather than as its own copy of 1, and a test pins the two together, so the treatment lifting on the same clock the world dithers away on cannot silently drift apart. Going the other way needs no ramp at all: a session beginning snaps the level straight back to 1, because the menu that was up disappears on the same frame and there is nothing left to ease.
MenuLinks holds that table and nothing else — no UI types at all — so “Resume and Disconnect exist exactly while a session does” is asserted directly rather than inferred from a rendered tree. A link that cannot act is absent, not disabled: the column is short enough that its shape reads as the state it is in. The exception is a link whose authority is missing rather than its meaning: Server Settings on a session this client neither serves nor operates, and Start Game and Quick Launch on a client launched with --connect that has no embedded server to start — both open a world of this client’s own, and there is none to open. Those stay in the column, dimmed by the same Button.DefaultDisabledOpacity a dead button takes, taking no hover and no click — the door is real, the key is not. MenuLinks.Enabled is the single predicate, read by the renderer and by ClientMenuController.Activate alike, so a dimmed link cannot be clicked through a stale frame.
The launch window
Section titled “The launch window”Start Game and Server Settings open the same window: a map browser in the shape of Garry’s Mod’s, a Halcyon window like the options screen but wider. It opens fitting the screen (client.ui.launchFit): display, the default, takes 90% of the viewport capped at 1800 logical pixels wide; full fills the viewport inside a margin, as Garry’s Mod’s does; off keeps the size client.ui.launchWidth / launchHeight name (HalcyonSettings.DefaultLaunchWidth 1180 × 680). The numbers are client.ui.launchFitShare, launchFitMaxWidth and launchFitMargin; the rule is LaunchLook.FitSize. It is a dialog rather than a workspace, so unlike the options window and the console card it remembers no corner: every open centers it in the current viewport (WindowGeometry.Reopen), and only its size persists. Drag it wherever you like while it is up — the drag owns the window until it closes. Three columns:
It opens on the map you last played. Before anyone picks a row, the selection is the map standing in the session; at the main menu it is the head of client.launch.recent while the catalog still has it, then the core default map (DevMapContent.DefaultMapBarcode), and only with neither is there nothing selected and a Select a map to start. note on the button. A map joins the recent list when it lands in play, whoever asked for it — the window, the console’s map, the editor’s Open…, a server’s own switch — because the loading gate dropping with a world standing is the one moment both hosts share (ClientUiState.LoadEnded, which ClientMenuController listens to). A switch that was asked for and never landed is not a map played. The rule is ClientMenuController.Selection, so the desktop and the iPad open the window on the same row; launchWindowLastPlayed and launchWindowDefaultMap photograph both answers.
- Categories on the left — All, Favorites, Recent (last played, most recent first, capped at
LaunchPreferences.RecentCap), Built-in (maps from the pallets the engine bundles undercontent/), then one row per game the maps come from, DigitalHeaven’s own first, each wearing that game’s own icon (see Where a map comes from). A game with maps beyond the ones it ships unfolds into Stock, Workshop and, when any map was read from a loose file, Local (Garry’s Mod and Counter-Strike 2 today); a game of stock maps alone stays flat. A section’s row is filed under its game (LaunchCatalog.SectionId), since every game’s Workshop shares a label: selecting a game lists all of its maps and never opens it, and the chevron slot folds it. The slot is a segment of the row (FoldRowwithFoldChevronPlacement.TrailingOnHover): a square as tall as the row at the row’s far end, after the count badge, with its own press and click apart from the row body, and its chevron shows only while the row is pointed at or open. Pointing anywhere on the row lights the whole row, and pointing at the slot adds a stronger step on the slot alone (FoldRow.SlotHoverFill). A row that cannot fold has no slot at all: its body runs the full width of the rail, so pointing at its far end lights the row like anywhere else and clicking there picks it, with no empty chevron zone to tint or hit; every label and icon sitsclient.ui.picker.railInsetinside the fill;client.ui.picker.railRadiusis the fill’s corner radius. A count badge always stands the same gap (ItemPickerPane.StarGap) from its label. The otherFoldRowcallers (Studio’s Sources tree and Home cards, the editor’s hierarchy and asset browser) keep the defaultLeadingplacement: an always-shown slot flush against the row’s left edge. Selecting a section lists that section’s. Each row wears a count badge, and the search box under the rail narrows the middle list by barcode substring. - Maps in the middle — every
dh.maptheMapRegistryknows (the same setmapautocompletes), each row the dimmed-barcode style with the map’s name bright (AssetNaming.Split), a star ahead of it — lit with the accent while the map is in Favorites, faint while it is not, and clicking it is the only thing it does: the star is a favorite mark and never a selection, which is why it is a glyph (HalcyonLaunchScreen.StarGlyph) rather than a ring. A ring that fills with the accent reads as a radio button, and a radio on every row beside a highlight on one row would tell two stories about what was chosen. Choosing a map is the row itself, and it is one at a time. Between the star and the name is the map’s own thumbnail on a square plate (HalcyonLaunchScreen.ThumbnailEdge) — square because an unsized thumbnail camera photographs square, so a shipped picture fills the plate rather than letterboxing into it. A map that shipped none wears theMAPbadge on the plate, the same stand-in the asset browser’s map tiles wear: both callUiThumbnail.MapPlate, because an empty plate reads as a picture that failed to load while the badge says there was never one to load. Either way the plate keeps its size, so the names stay on one column whatever the list is made of. Clicking a row selects it. Double-clicking one selects it and takes the bottom button’s action in one gesture. - Configuration on the right — Open server (
server.open) — an ordinary settings switch, held at its natural width and leading in the row rather than stretched across the column, and wearing the theme’s own on-tint from the one factory the options screen also calls, Player limit (server.maxPlayers, disabled while the server is closed), the Preset dropdown, and the preset’s settings as editable rows (see launch presets). Everything above the bottom button scrolls; the button’s bar keeps its height (HalcyonLaunchScreen.ActionBarHeight) whether the rows overflow or not.
Where a map comes from
Section titled “Where a map comes from”The rail groups by the game that ships a map, not by the pallet it happens to sit in: a Workshop addon is one pallet per map, and a rail of pallet names is a rail of map names. The answer is ImportOrigins.Of, the one rule Studio’s Import page also reads: the importer’s originGame stamp wins, then the game the pallet id sits under (com.valvesoftware.hl2.maps.d1_canals_01 is Half-Life 2’s), then the Steam Workshop namespace (Garry’s Mod’s), then the game stamp; a pallet that names none is DigitalHeaven’s own. LocalMapCatalogSource reads the pallet manifest’s metadata and puts the result on the catalog row (AssetCatalogEntry.OriginGame, OriginVia); LaunchCatalog.OriginOf falls back to the pallet id for a row from a catalog that carries none. A map imported through Garry’s Mod’s mount but shipped by Half-Life 2 lists under Half-Life 2. The section is the importer’s metadata.source stamp (stock, workshop, local), read by ImportOrigins.SourceOf and carried on the row (AssetCatalogEntry.Source, a trailing section of the catalog blob after the origins); LaunchCatalog.SectionOf asks SourceOf for a row from a catalog that carries none, which takes a com.steamcommunity.workshop.* pallet as Workshop and any other as Stock.
On the wire the origin is a trailing section of the catalog blob, after every entry, so a client built before it reads exactly the bytes it always did and sees maps with no stated origin; the format version does not move. Built and Bytes (the pallet’s compile time and file size) are local to the process that scanned the pallets and are not sent.
A game’s icon is GameIcons.Find (Steam’s library cache, then the game’s own registered finder; MinecraftIcons.Register() runs once at startup), read through the map-thumbnail pool under the key gameicon:<platform id>, so it loads off the frame and is cached with its misses like a map’s picture. The one seam is LaunchModel.GameIcon. Only picture-kind icons are drawn; a game whose icon lives inside an executable shows no mark rather than a letter.
The size slider
Section titled “The size slider”A slider under the list, in the band the Start Game button’s bar reserves, steps over four rungs (MapPickerView.Ladder, a BrowserLadder — the asset browser’s slider is another instance of the same type):
| Rung | Draws |
|---|---|
| 32 | A details table |
| 48 | A list: the picture, the name large with its maps/ prefix dimmed, the pallet small beneath |
| 96, 160 | Tiles: the picture nearly filling the tile, the star on its top-left corner, one line of name beneath |
The rung is client.ui.mapPickerTileSize, written when the slider moves. A tile shows the game’s icon on its top-right corner only in a view that mixes games — All, Favorites, Recent — and none in a single game’s view, where it would say the same thing on every tile (MapPickerView.ShowsBadge). The star is a Material Symbols Rounded glyph (UiSymbols, baked from content/ui/symbols), outlined when off and filled in client.ui.favoriteColor when on.
The table is Halcyon’s reusable Table: a header pinned over square rows, a click on a header sorting by that column and a second click reversing it with an arrow on the active column, and a divider at each header cell’s right edge that resizes the column and every cell under it. It owns neither the order nor the widths; the host passes the rows sorted and is told of each sort and resize. The columns are the star, Name, Game, Pallet, Last played (client.launch.played, a barcode|unix seconds history stamped when a map lands in play), Built and Size; a map with no value for a column sorts after the ones that have one in either direction. The sort is client.ui.mapPickerSort (a column id, - for descending) and the widths client.ui.mapPickerColumnWidths (id:width;id:width).
Narrow windows
Section titled “Narrow windows”Below client.ui.launchNarrowWidth (1100 logical pixels, which is where 2x on a 1920 wide screen lands) three columns do not fit, so the rail narrows to client.ui.launchNarrowRailWidth and the settings column moves under the list, client.ui.launchNarrowAsideHeight tall, with the Start Game button stretched across it. client.ui.launchCommitHeight is the height of the bar the button sits in.
The middle two columns are not the window’s own. The rail, the search box and the tile list are MapPickerPane, one widget builder the launch window composes with its server settings on the right, and the editor’s File ▸ Open… composes with a footer instead. There is exactly one spelling of a map list in the program. The category rail is UiNavRail — the same control the settings screen hangs its categories off — so pressing one segment and sliding onto another switches the list while the button is held, and the pressed background follows the pointer rather than staying on the row the press began on. The rounded row paint comes from that control too, not from a radius copied beside it. That control also draws the rule between two groups of rows — the menus’ divider shape at the rail’s own hairline tone — which is what fences the editor composition’s leading Workspace row off from the catalog’s categories. The launch window passes no browse, so it shows neither. MapPickerPane is itself the map reading of a more general pane, ItemPickerPane, which owns the rail, the search, the star column, the empty states and the stacking; the Connect window is the same pane read as servers. An improvement to the picker reaches both windows because there is only one picker to improve. The pane’s middle, the list, the details table, the tiles, the star and the size slider, is the shared ItemBrowser in Authoring.Ui, painted with this client’s theme through an ItemBrowserLook; Studio’s Import page browses a game’s maps with the same browser in its own paint.
The bottom button is the Bezel primary chip and its word is the session’s: Start Game from the main menu, Apply from a pause. Start Game opens a session on the chosen map: the barcode is handed to the embedded server before it is started, so the map the player picked is the first and only one that world ever loads. The frame it lands, the preset’s lines and the server shape are forwarded to it. A map picked while a session is live still switches the way map <barcode> does, editor guard included. Apply forwards the preset live and switches the map the same way. Quick Launch is the old Start Game: connect and load in, on whatever the last launch left behind.
server.open is read when the embedded server binds its socket, so the switch describes the next launch; the window hands the value to the server as soon as one is running so it persists, while server.maxPlayers applies live. Escape over the window puts it away and leaves the menu standing (EscapeLayer.Launch); resuming, starting, and disconnecting close it with the menu, with no restore latch — a chooser does not come back on its own the way the options window does. The window is ClientWindow.Launch in the stack, over the options window and under a console that has the front.
The scrim behind the column is a single flat box over the whole viewport, never a ramp: a ramp of any width puts its own soft vertical edge somewhere on screen, and that edge is a shape the design never asked for.
The connect window
Section titled “The connect window”Connect on the main menu opens the smallest window in the program: an address to join, and the servers you have joined before. It is the launch window’s shape — the shared picker on the left, one column pinned on the right, a primary chip under it — and deliberately far simpler, because joining a server is one field and one decision. Its size persists as client.ui.connectWidth / connectHeight (HalcyonSettings.DefaultConnectWidth 900 × 560) and, like the launch window, it is a dialog rather than a workspace: every open centers it in the current viewport.
The left is ServerPickerPane, which is ItemPickerPane — the map picker’s own pane — read as servers rather than maps. The rail holds All, Favorites and Recent, each with its count; All leads with the starred servers and follows with the ones you merely visited, so a server you meant to keep cannot sink under them. A row is one host:port: the host bright, the :port dim behind it, because the port is a fact about the address rather than something you chose. Ahead of it is the same star the map rows wear, from the same builder, lit with the accent while the server is starred. Clicking a row fills the address field; double-clicking it connects. The search box under the rail narrows the list by address substring.
The right column is the window’s own: an Address field (host or host:port), the same star beside it so favoriting what you typed and favoriting what you picked are visibly one gesture, the reason underneath when the text is not an address — and only once something has been typed, since an empty field is a window that was just opened rather than a mistake — and the Connect chip pinned at the foot, live only on text that parses. Enter in the field is the same command as the chip. On a touchscreen nothing takes focus by itself (UiInteractionStyle.AutoFocusInput): a keyboard rising over the list would hide the servers the window was opened to show.
ServerEndpoint is the one reading of that text, shared with the connect console command — a bare host is that host on Conventions.DefaultPort, an IPv6 literal is bracketed, and an unbracketed one is refused rather than split at a guessed colon. Both lists store the canonical host:port (ConnectPreferences, client.connect.favorites and client.connect.recent, capped at 8, newest first), so example.net and example.net:27015 are one remembered server rather than two rows to star twice. Committing remembers the server before the attempt, closes the window and hands the host the parsed pair: an address you could not reach is exactly the one you want back in the field on the next try.
Escape over the window puts it away and leaves the menu standing (EscapeLayer.Connect); it is ClientWindow.Connect in the stack, and it exists on the main menu only. The window’s own frame carries the key connectWindow, which is what --renderWindow connect crops its picture to.
The main menu wears a theme, and the dissolve resolves into it
Section titled “The main menu wears a theme, and the dissolve resolves into it”The pause menu has a world behind it. The main menu has nothing behind it: the sessionless menu paints its own full-viewport backdrop, in Halcyon, at the bottom of its own layer stack. It does not rely on the renderer’s clear color — the flat gray a frame is cleared to when no sky paints over it — which is a fallback for a world with no sky, not a menu design. Renderer.ClearColor is untouched and stays the 3D fallback.
client.ui.menuTheme picks which backdrop, and a backdrop is more than a color — MenuTheme is a record of the ground and the treatment its words get over it:
| Choice | Backdrop | Text |
|---|---|---|
auto | #202020 | Resolves to Immersive dark on every platform. A label for a decision already made, not a per-platform probe |
immersiveDark | #202020 | The Windows dark titlebar gray |
black | #000000 | |
charcoal | #0C0C0C | |
accentTinted | the live accent’s hue at menuTintSaturation / menuTintLightness | #221620 while the seed is Orchid |
accentMuted | the same hue at menuMutedSaturation / menuMutedLightness | #161216 while the seed is Orchid |
white | #F3F3F3 | Black ink, no drop shadow, the bold cut |
legacy | #51555C | The renderer’s own clear-color gray, authored as the sRGB the eye saw |
The two accent entries are resolved at draw time from the live palette, not baked at startup, so picking a different client.ui.themeColor moves the menu’s ground with the rest of the interface on the next frame. Only the accent’s hue is read; the saturation and lightness are the four client.ui.menu* preferences, so the tints are tunable from the console without a relaunch.
The paper entry is the only one that flips the text, and each half of that flip has a reason. A drop shadow is what holds a pale word over an unknown backdrop; on a light ground there is nothing to hold, and the shadow reads as a smudge. The bold cut is Halcyon’s own FontFace.Bold — the ladder bakes a real bold at the menu’s heading size, so this is a second cut and not the same run stamped twice a pixel apart, which reads as a blur rather than as weight. The wordmark follows the same flip: DIGITAL takes the items’ own black and HEAVEN takes the live accent’s dark-ink cut from ThemePalette.PrimaryLabel, with no shadow pass, so the brand’s art never disagrees with the ink around it.
The backdrop rides the session fade’s complement, which is what makes the world dissolve land somewhere deliberate. The dither is a per-pixel discard in the world pass, so what shows through it is whatever is painted behind the world — and behind the world is now this box, at 1 - sessionFade. On the frame a session ends it is fully transparent and the world is intact; by the time the last world pixel has dithered away it is fully opaque. The dissolve therefore resolves into the menu’s ground whatever the sky behind it was, and the clear color can never leak through the transition. In a session it is transparent and costs nothing: BoxElement skips an invisible fill outright, so the pause menu emits no backdrop command at all, and the box is still in the tree either way so a session ending does not change the shape of the draw list.
The picker is a dropdown in the options window’s Theme group, beside the accent seed, applying live on the next frame.
Leaving a session is not leaving the process
Section titled “Leaving a session is not leaving the process”Disconnect ends the session: the net client disconnects (which clears replicas, the local pawn, the map identity and the server’s commands and convars), the prediction state resets, and the two continuous motion-driven audio sources are silenced. The window, the renderer, the preference store, the audio device and Halcyon itself are all untouched — and so are the loaded map and the render scene, deliberately, because they are the menu’s backdrop rather than session state. Quit is that same teardown plus taking the window down: Dispose runs the identical seam first, so there is exactly one path and quit-to-desktop cannot drift from quit-to-menu. Start Game reconnects to the endpoint the client was launched with, so the round trip world → menu → world needs no relaunch.
There is no transport axis in any of this. ClientHost.ReconcileSession keys on state != ClientState.Disconnected and nothing else, so an embedded server is a session exactly as a remote one is: Disconnect is drawn and live while singleplayer is running (it shuts the local server down and returns to the menu, which is what the disconnect console command has always done), and a connect still in flight counts as a session from the first frame. A theory pins the link set against every ClientState, and another pins that every link the menu draws answers a click anywhere in its own hit box — the stronger guarantee that “absent, not disabled” buys, and the one both dead-link reports were of failing.
The scene the client builds at window load is still there behind all of this, independent of any connection — the menu’s themed backdrop is simply painted over it, which is what makes leaving a session land on a deliberate ground rather than on whatever the last frame cleared to. Source-style background maps, showing that scene again, are a later idea and not this.
Escape is a stack, and the main menu is its floor
Section titled “Escape is a stack, and the main menu is its floor”| Where | Escape does |
|---|---|
| A popup is open | Dismisses the innermost popup, and nothing else |
| Overlay up | Closes the overlay, leaving whatever is under it |
| Console up with no pause layer | Closes the console and hands the game straight back — it does not pause |
| Main menu | Nothing. There is no session behind it to resume; leaving is Start Game’s job |
| Otherwise | Toggles pause, taking the options window and the console down with the layer |
MenuEscapeCoordinator reports the layer it handled, so “handled by doing nothing” is a distinct outcome from “toggled pause”, “closed a console” and “closed the overlay”, and a test can tell them apart.
One press clears one thing, and never leaves a window behind. The console is a tool you use while playing, so the backtick opens it straight from gameplay without pausing anything, and while it is up Escape means that console rather than the pause menu. But it does not outlive the menu either: pressing Escape (or clicking Resume) over a paused game puts the layer and the console away together, so one press always ends with a clean screen. Bringing it back is one backtick.
That is the whole of the rule, and both halves matter. Without the first, a console over live gameplay would be a trap — Escape would pause the game behind the console instead of getting you out of it. Without the second, leaving the menu would strand a window over a running game and make you find a second key for it.
The options window is different in exactly one way: its open flag survives. It is furniture on the pause layer, so Escape never peels it off separately — it toggles the whole layer, the window goes down with the links, and Escape back in puts it right where it was left, at the size and position it was left at. The console’s visibility is spent by that same press, because a window the player just dismissed should not reappear uninvited. The two things that close the options window outright are its close box and the Options link, which toggles.
ClientKeyRouting.Route enforces the seam: a focused, front console takes only its own toggle and chat’s open key off the shell, and an open popup — or a focused field anywhere but the console — takes Escape for the frame it is showing, because the shell and Halcyon receive keys through two independent paths, so a popup’s consumption has to be asked about in advance rather than reported back after the fact. Everything else about what one Escape means is decided in MenuEscapeCoordinator, with the whole client in view.
Both windows stack over the link column rather than replacing it, and over each other. Keeping the links up is what makes Options and Console toggles a player can reach twice.
The two windows are a stack, not an order
Section titled “The two windows are a stack, not an order”The console and the options window are siblings with a front, held in ClientUiState.FrontWindow:
- A pointer press anywhere inside a window raises it above the other. That needs
Box.OnPressInside, which fires on every element in the hit path, outermost first, on press —OnClickcannot do it, because it fires on release and only on the nearest clickable ancestor, so a press that landed on a slider inside the window would never reach the window. - Opening a window raises it too. Opening something you can neither see nor type into would be a strange thing to ask for.
- The front window occludes the other and owns the keyboard.
HalcyonLayerappends the two roots in stack order, so occlusion is ordinary z-order hit testing rather than a second mechanism. - Focus follows the front. A console that loses the front releases the keyboard on the same frame, and a console that regains it re-arms its autofocus with no click — the prompt is focused again by the next build.
Window chrome: dragging and resizing
Section titled “Window chrome: dragging and resizing”The whole window is the move handle. There is no title bar to grab and no strip that lights up as the pointer crosses it: the heading, the padding and every gap between the controls all drag the window, and none of them react to being pointed at. A window is furniture rather than a control, so it deliberately has no narrow drag bar — a specific, hover-lit target you would have to aim for on a surface that should simply move.
Ui.DragSurface is what makes it true, and it is one piece shared by both windows rather than two copies: the options window and the console’s card placement are the same call. A plain draggable Box derives a lifted paint on hover, and a window-sized box that lifts on hover lifts whenever the pointer is anywhere on the window, so the surface pins HoverStyle and PressedStyle to its own style. The controls inside keep their own presses — press targeting honors Element.ClaimsPress and the nearest clickable ancestor before it falls back to the drag surface, so grabbing a slider drags the slider, not the window.
Eight handles, and hit zones larger than what you can see
Section titled “Eight handles, and hit zones larger than what you can see”Resizing is available from all four edges and all four corners. There is no bottom-right grip singled out as the one way to resize a window; every edge and corner works the same way, with no separate square kept beside them.
Ui.ResizeHandles is one element laid over the window, and one element rather than eight boxes on purpose: the precedence between a corner and the two edges it overlaps is then arithmetic in ResizeZones.Classify rather than an accident of declaration order, and the cursor comes out of the same call that decides what a press would do. One hit test, one answer.
It paints nothing. The whole affordance is the hit zone and the cursor — the same discoverability a desktop window has, where you learn the edge is grabbable because the pointer changes shape as you cross it.
Every band is centered on the boundary it grabs. The preference names the band’s total width, and half of it falls inside the window and half outside — the way a stroke is centered on a path rather than drawn inside it:
| Total band, across the boundary | Inside | Outside | Preference | |
|---|---|---|---|---|
| Edge band | 8 px | 4 px | 4 px | client.ui.windowResizeEdge |
| Corner square | 20 px on each axis | 10 px | 10 px | client.ui.windowResizeCorner |
A cursor aimed at the edge of a window naturally lands on both sides of the hairline, so a zone that reached inward only would turn the outer half of every aim into a miss. Straddling gives back on the outside exactly what it costs on the inside: the target is the same size, it is simply where the eye puts it. The totals are deliberately much larger than the visual they sit on, which is a one-pixel border — a hairline is not a target, and a miss silently starts a window move instead, which is the most annoying possible wrong answer. Both scale with UiScale, so a handle stays the same apparent size at any magnification.
ResizeZones.Overhang is the outward half of the widest band, and it is the one number the placement needs: the frame lays out larger than the window it covers by that much on every side, and is placed one overhang up and to the left of it. It has to be, because the hit walk rejects a point outside an element before that element’s own zones are ever consulted — a frame exactly the size of its window could not answer a point beside it. That is also why the frame is placed with its own Positioned rather than stacked with the window: a Stack anchors both children to the same corner, which would put every band an overhang out of place.
The outward half is input only; nothing about the painted window changes. A point in the overhang that is on no band is a miss like any other, so it falls through to whatever is behind — the window’s own body, a window underneath, the pause menu, the world. The layering rule is simply paint order: the frame is declared after its window and before nothing else, so a window’s outward band wins over anything behind that window and loses to anything in front of it. A band that would extend past the viewport is lost, and nothing compensates for it.
A corner is a square, not the point where two thin bands cross, and being larger than the edge band is exactly what gives it precedence in the region they share — over the whole straddled zone, outward half included, so a point diagonally off the window’s corner is the corner rather than either edge. A corner resizes both axes at once, which is the gesture people actually reach for, so it should be the easier of the two to hit.
The cursor map, resolved by ResizeZones.CursorFor and delivered through the ordinary hit-test cursor walk:
| Handle | Cursor |
|---|---|
| North, South | ResizeVertical |
| West, East | ResizeHorizontal |
| North-west, south-east | ResizeNwse |
| North-east, south-west | ResizeNesw |
The two diagonals are not interchangeable — north-west and south-east are the same line seen from its two ends, so they share a cursor and the other pair is its mirror. Getting them the wrong way round is invisible in a screenshot and obvious under the hand, so a test pins each pairing and pins that the two diagonals differ from each other.
ResizeEdge is a [Flags] enum where a corner is the pair of edges meeting at it (NorthWest == North | West), so the resize arithmetic asks one question per axis instead of carrying eight cases. Eight cases is eight places for a sign to be wrong.
The frame takes a hit only on a handle; a point in the body is a miss, which is what lets the window underneath keep every press that is not on an edge. It also declares ClaimsPress, which matters wherever the frame ends up with a draggable ancestor.
A window’s size is its own; position never changes it
Section titled “A window’s size is its own; position never changes it”A window’s laid-out and painted size is a function of its own size alone. Where it sits never changes it. A window positioned partly or wholly outside the viewport keeps its full size and is simply clipped by the display’s edge: the off-screen part is not drawn, and nothing about the on-screen part moves, reflows or resizes. Text does not re-wrap, rows do not reflow, and a scrollbar does not appear or disappear as a window is dragged toward an edge.
The viewport clips a window. It never constrains one.
This has to be stated as a rule, because correct geometry alone cannot guarantee it. Positioning a window by padding a viewport-sized frame does not only offset the child: padding deflates the envelope handed down, so the child’s maximum width becomes “whatever is left between the offset and the far edge” — pushing the window toward an edge would squeeze it, one pixel of width per pixel of travel, independent of its stored size. An 860-px card on a 1237-px display would paint at 860 → 739 → 430 → 121 → 10 as it crossed, while the stored size stays at 860 throughout. Clamps inside WindowGeometry cannot fix this, because the constraint is not in the geometry at all — it is in how the window is positioned.
Ui.Positioned is the fix and the whole of it. It takes the box its parent offers, measures its child against BoxConstraints.Unbounded, and moves it:
// Unbounded: the child's size is a function of its own declared size and nothing else.child = Children[0].Layout(BoxConstraints.Unbounded);Children[0].Offset = offset;Taking the parent’s whole box is what makes the offset absolute within the viewport, and what keeps the part of the window that is on screen reachable by the pointer. It consumes no pointer of its own, for the reason any full-viewport wrapper must not: a root that has appeared once is never unmounted, and a viewport-sized surface would swallow the pause menu’s clicks forever.
Two consequences worth stating outright. A window may be larger than the display and is painted at every pixel of its size, for the same reason it may hang off one. And a negative corner is ordinary data throughout — clip rectangles, which are intersected as they are pushed, still bound drawing correctly for a window whose rectangle runs past any edge in either direction.
The pointer is what the screen restricts, never the window
Section titled “The pointer is what the screen restricts, never the window”A window may hang off any edge of the viewport, by any amount. The single restriction is that the grab point — the place on the window the cursor took hold of — may not leave the viewport. WindowGeometry.Apply states exactly that by clamping the pointer on the way in and applying nothing at all on the way out.
That one rule is enough, and it needs no companion rule about not losing a window. The grab point is by construction a point on the window, at a fixed offset inside it for the whole gesture, so a window’s rectangle this frame puts that point precisely at the clamped pointer. Keeping it inside the viewport therefore keeps some of the window on screen — and since the whole window is the move handle, whatever is on screen is grabbable. Reachability falls out of the geometry instead of being a second clamp that has to be kept in agreement with the first.
Clamping the rectangle instead of the pointer would be wrong in a way only using it reveals: a window could not be pushed off the side of the screen to get it out of the way, which is one of the main things people move windows for. It would also break resizing, twice over — a window already hanging off the left could not be widened by its right edge, because the far side would be hauled back into view to make the rectangle fit, and a size ceiling of “no larger than the viewport” would trim the same edge from the other direction. There is no size ceiling: a window may be larger than the display for the same reason it may hang off it.
Resizing follows the identical principle. The dragged edge or corner follows the pointer, and the pointer is what is held inside the viewport; ResizeAxis knows nothing about the viewport at all, because a second clamp there is the whole-rectangle rule sneaking back in through the resize path. Edges the handle does not name are still copied from the anchor and still cannot move, and the minimum still pushes the edge that is moving.
Recovery is a response to the viewport changing, and to nothing else. A window legitimately parked half off screen can end up wholly outside it when the display shrinks — the OS window is resized, the resolution changes, client.ui.scale changes — and the grab-point rule cannot speak for a viewport that moved after the gesture. So WindowGeometry.Recover nudges a window back by the least amount that leaves client.ui.windowGrabMargin pixels of it in view on each axis, and it runs only on the frame the viewport actually changed, plus on load.
| Preference | Default | Meaning |
|---|---|---|
client.ui.windowGrabMargin | 64 | How much of a window is kept in view when the viewport changes under it, in pixels. Clamped 16–512. Comfortably larger than the 20 px corner square, so the sliver left on screen is real drag surface rather than nothing but resize band |
Running it every frame instead would be the old bug back in a slower and much harder to see form: the window would creep toward the display behind the player’s hand. WindowGeometry.Reseat is where that decision lives, as a pure function, so it can be stated without a device to run it on — and it also declines to recover a pushed position that agrees with the live one, because that push is the window’s own gesture arriving back from the preference it was just written to, one frame after the button came up.
Both gestures need the pointer in a space that does not move, so HalcyonLayer keeps this frame’s sample and hands that to the geometry rather than the window-local point a widget callback carries. It is held in logical pixels — the pointer divided by UiScale — because a window’s position becomes a Positioned offset and its size becomes a declared width, and Halcyon magnifies both where the element reads them. Mixing the two spaces is not a rounding error: at a magnification of 2 the window travels twice as far as the pointer dragging it.
WindowGeometry.Apply works in edges, not in an origin and a length. An edge the gesture does not name is copied from the anchor and is therefore incapable of moving — the direct answer to a resize that walked the far side of the window. An edge it does name is the anchor plus the pointer’s travel, held apart from its opposite by the minimum. The minimum pushes the edge that is moving, so the one standing still keeps standing still; enforced the other way, dragging a left edge rightward shoves the whole window off the screen.
A grab ends when the window is taken away. A release is the ordinary path, and losing the pointer (focus goes elsewhere) already produced one. What did not was an element being unmounted or a whole root swapped while the button was still held — the tree dropped the press silently, leaving whoever owned the geometry believing the button was down. UiTree now ends the press properly in both, which is the same signal a release gives, so every drag handler already knows what to do with it.
Out-of-bounds delivery is what makes any of this reachable. A pointer-captured element keeps receiving OnPointerDrag after the pointer has left it — it has to, since the moment a window moves the pointer is outside what it grabbed, and a hit-test-only feed would stall the drag on its first frame. The delivered point stays in the element’s own local space in both cases: the tree uses the hit path’s accumulated conversion while the pointer is inside and reconstructs absolute − Rect.Position when it is not, which is provably the same number because Halcyon has no transform stack. ResizeHandles adds Rect.Position back on to report an absolute position, since that is what the anchor rule needs.
WindowGeometry is a handful of pure static functions — Apply, ClampPointer, ClampSize, Recover, Reseat, Center, Resolve — so the arithmetic is unit-tested without a tree, a pointer or a window. The interaction on top of it is tested through a real tree at fractional UI scales (1.05, 1.1, 1.25, 1.75) and odd window sizes, because a hit band or a drag delta that is correct at scale 1 on a round-numbered window has not been tested at all. The off-viewport cases are driven the same way — dragged past each of the four edges, with the grab point asserted to be exactly under the held pointer — and two capture frames photograph a window hanging off the display, one clipped at the top-left and one at the bottom-right.
The size-independence rule is asserted against the draw list rather than against the stored geometry, and it has to be: the stored size was already correct while the painted one was collapsing, so a test that read CardSize would have proved nothing. WindowSizeIndependenceTests compares the painted window rectangle before and after a drag past each of the four edges and the four corners, at five magnifications on an odd viewport, and compares the frame’s whole picture with the window’s own origin subtracted out — every rectangle, clip and text run — so a re-wrap or a reflowed row fails on the words before it fails on any number. One of them takes both pictures mid-gesture, since releasing re-seeds the position and hid the squeeze behind a restore.
Geometry persists as preferences, written once per gesture rather than once per frame, and lives in the layer between writes so a console edit to the same keys still applies on the next frame. A stored corner may be negative, since a window may be parked off the left or the top of the screen, so the lower clamp on those preferences is negative too and a saved off-viewport position round-trips unchanged.
Resolve is what makes a saved geometry safe to trust: the -32768 sentinel means “never moved” and centers at whatever the viewport is now, and a real position is recovered only as far as the grab margin requires. That sentinel test is an exact comparison, not “at or below” — reading every negative coordinate as “never placed” makes the sentinel collide with real data, and the symptom is spectacular: a window dragged past the left edge does not stay there, it teleports to the middle of the display and stays there for as long as it is held. Exactness alone was not enough either, which is why the sentinel sits at the very floor of the range rather than at -1: at a magnification of 1 a drag moves a corner in whole pixels, so -1 was a coordinate the gesture itself could land on. A sentinel has to be a value no real position can take. The minimum size is 480×360, restated as the lower clamp on the width and height preferences (a test pins the two together, since the preference assembly does not reference the client).
| Preference | Default | Meaning |
|---|---|---|
client.ui.menuDim | 0.82 | Opacity of the flat scrim the menu lays over the whole viewport, clamped 0–1 |
client.ui.menuInset | 72 | Inset from the left edge to the link column, pixels |
client.ui.sessionFade | client.render.worldDissolve’s default (1) | Seconds the scrim and the vignette take to lift when a session ends, clamped 0–10. 0 is instant |
The scrim is one flat fill over the whole viewport, not a left-edge ramp. A gradient scrim leaves the right half of the screen at full brightness, which reads as the world competing with the menu rather than receding behind it, and it makes every panel that floats over it — settings, the console — sit on a background that changes under its own width. One opacity over everything is both simpler and calmer, and it is the only knob a direction needs to retune.
The loading box
Section titled “The loading box”The card that reports a join — one step line, one bar, one Cancel — is a Halcyon surface like any other, built from a Ui.Frame sized to the viewport with growing Ui.Space spacers centering it, or — for a map switch inside a session (ProgressCardModel.Docked) — pushing it to the bottom-right corner, where the trailing and falling spacers become fixed margins (ProgressCard.DockMargin, fed from client.overlay.toastMargin). Spacers rather than Ui.Positioned, because the docked card is anchored to the FAR corner and an absolute offset would have to be measured from the box’s own size every frame. It is the widget the Button disabled convention was written for. What it says, and the frame-sliced load behind it, are documented with the session lifecycle.
What leaves has to be what was there. A root stays mounted after the host stops asking for it, so it can play its exit — but the host pushes the hidden model on the very frame a person dismisses the card. Handed straight through, the card spent its whole fade animating an empty version of itself: no title, no message, and buttons with no words on them. HalcyonModelLatch<T> holds the last model a root was actually asked to SHOW and keeps handing it back while the root is leaving; the confirm dialogue, the loading card and the build card all sit behind one. The hidden models are correspondingly empty — a hidden model dressed up with a button label is a second, worse answer to the same question.
The settings screen
Section titled “The settings screen”The first real consumer, and the only settings surface there is: there is no immediate-mode options window kept behind a toggle.
It is titled OPTIONS, matching the link that opens it — one name for one thing, wherever the player meets it. The code still says settings throughout (HalcyonSettingsScreen, ClientSettingsBuilder, the client.ui.settings* preferences) because those are the model’s names and renaming stored preference keys would be a migration for no gain. Its heading row carries a close box on the right, which takes the same route out as the Options link, so the two ways to close it cannot drift.
ClientSettingsBuilder remains the single source of truth. It builds a ClientSettings of SettingsSections, each section carrying an icon slug and search keywords and holding SettingsGroups, each group holding the typed controls (ClientUiSlider, ClientUiToggle, ClientUiChoice, …) that read and write preferences live. Halcyon renders that model; it does not own any of it.
There are six sections, and the small number is deliberate. Splitting by subsystem would force a person to choose between “Display”, “Graphics” and “Camera” before they could go looking for a field of view — where the honest answer to which of the three owns it is “any of them”, because all three are about what the picture looks like. A rail entry that is one group long — Appearance, Crosshair, HUD, Console — is a page whose entire content is its own sub-header. Grouping by what a setting is for rather than by which subsystem implements it collapses the first three into Video and the four interface-facing ones into Interface. The player’s frame-rate display also lives under Interface; the asset cache remains under System.
| Section | Icon slug | Keywords | Groups, in order |
|---|---|---|---|
| Player | player | name, profile, identity, nickname | Identity |
| Controls | controls | input, mouse, keyboard, aim | Mouse |
| Video | display | video, display, graphics, monitor, screen, window, quality, rendering, visuals | Screen, Frame pacing, Field of view, Panini projection, Speed field of view, Quality, Parallax, Sharpening, Lens, Bloom, Ambient occlusion, Motion blur, Loading stand-ins |
| Audio | audio | sound, volume, mixer | Volume |
| Interface | appearance | ui, interface, appearance, theme, color, accent, look, style | Scale, Theme, Surface, Text, Gameplay, Performance, Crosshair, Speedometer, Console |
| System | storage | system, program, engine, installation | Asset cache |
Video’s order walks outward from the glass: the screen the frame is presented on, then the lens it is seen through, then the work the renderer does to fill it. Interface opens with its four Appearance groups — scale first, because it is the one control that changes how legible every other control is — and the three one-group pages follow behind it.
Video’s Quality group leads with the Preset dropdown (Low, Medium, High, Ultra, Custom) and a Detect button beside it. The dropdown reads Custom whenever a covered setting was changed by hand, from the window or the console, and a client with no detection on record is asked once at the main menu whether to detect. The table, the detection and the first-launch rules are on Quality Presets.
Compact navigation and frame-rate display
Section titled “Compact navigation and frame-rate display”Options keeps its category rail beside the selected page at the authored 720-unit default width, including on iPad. Touch windows narrower than that stack a bounded scrolling rail above the page; the decision uses the clamped logical window width, not drawable density. Server Settings keeps its existing three-column layout at wide sizes and a scrolling vertical layout below its 1000-unit touch breakpoint.
Interface → Performance → Performance display offers None, Map and frame rate, Frame times
and Netcode, one per level of client.showFps (0 to 3), so the index is the level. The default is
Map and frame rate: the compact frame-stats card,
one line in the shared HUD’s safe-area top-left naming the frame rate at the left edge and the map after it,
so every screenshot says which map it was taken on. Desktop and mobile paint it through the same
shared layer and the same frame-time smoother; turning the display off hides its ink without stopping its
samples. Menus cover the HUD normally. The two deeper levels add the frame time with a graph of the recent
frames and the round trip, then the build and the netcode rows.
Readout background sits beside it: client.hud.readoutBackground is None, Transparent (the
default: a soft neutral gray plate, translucent and blurred, with no border) or Card (the same plate
opaque). Both corner readouts, the frame-stats card and the position readout, read it and draw through one
painter, so the two corners always match.
Net health in the frame-stats card
Section titled “Net health in the frame-stats card”client.showFps 2 judges the round trip on the card’s summary line, and client.showFps 3 adds the netcode rows: the starved-tick rate beside the
clock buffer, the last correction the predictor took (in centimeters, with the rate of corrections past the
desync threshold), and the round trip with its jitter — rtt - where the transport never timed the link,
since an untimed link is not a fast one. While the link is healthy they are plain text in the card’s
existing style. Each quantity is graded by one shared rule (value, warn, alarm), so a reading at or past
client.net.desyncWarn tints that reading alone in client.hud.warnColor and one at or past
client.hud.desyncAlarm takes client.hud.alarmColor; round trip and the starved-tick rate use the same
two steps against their own thresholds. A correction lasts one frame and a warning nobody can read is not a
warning, so a tint holds for client.hud.warnHold seconds after the last offending reading and then decays
back on its own. The rates and the jitter are smoothed over client.hud.healthWindow. Nothing here changes
the wire: the round trip is read from the transport’s existing peer description, and a starved tick is
counted once per server tick rather than once per frame, so a fast client does not multiply one starve.
The hudDesync capture frame photographs the alarm state from fixed authored readings.
| Preference | Default | Meaning |
|---|---|---|
client.net.desyncWarn | 0.2 | Correction magnitude, meters, at which the correction line takes the warning tint. Also the threshold the predictor’s log warning and its counted corrections use |
client.hud.desyncAlarm | 1 | Correction magnitude, meters, at which that line takes the alarm tint |
client.hud.rttWarn | 120 | Round trip, milliseconds, at which the rtt line takes the warning tint |
client.hud.rttAlarm | 250 | Round trip, milliseconds, at which the rtt line takes the alarm tint |
client.hud.starveWarn | 1 | Starved ticks per second at which the buffer line takes the warning tint |
client.hud.starveAlarm | 5 | Starved ticks per second at which the buffer line takes the alarm tint |
client.hud.warnHold | 2 | Seconds a tint is held after the last offending reading, so a one-frame spike is readable. Clamped at or above zero |
client.hud.healthWindow | 2 | Smoothing window, seconds, for the correction and starve rates and the round-trip jitter. Clamped 0.1–60 |
client.hud.warnColor | 255, 176, 64, 255 | RGBA 0–255 ink for a warning line |
client.hud.alarmColor | 255, 84, 72, 255 | RGBA 0–255 ink for an alarm line |
Frame pacing
Section titled “Frame pacing”Video’s Frame pacing group holds two frame-rate caps: one for the ordinary case and a second,
tighter one for while the window has no OS focus (alt-tabbed, clicked away). Both are enforced by
FrameLimiter at the end of every rendered frame, independently of the swapchain present mode, so
either cap holds even with VSync off.
| Preference | Default | Range | What it does |
|---|---|---|---|
client.fpsMax | 240 | 0–1000 | Frame-rate cap in FPS. 0 is uncapped. |
client.display.unfocusedFrameCap | 30 | 0–1000 | Frame-rate cap in FPS while the window is unfocused. 0 applies no separate cap, so the window keeps client.fpsMax. |
client.display.menuFrameCap | 30 | 0–1000 | Frame-rate cap in FPS while the main menu is up with no world behind it. 0 applies no menu cap. The lowest non-zero cap wins. |
client.display.menuIdleHeartbeat | 0.5 | 0–5 | Seconds an idle main menu sleeps waiting for input before it runs a frame anyway. 0 never rests. |
client.display.menuIdleGrace | 1 | 0–30 | Seconds after the last input before an idle main menu may start resting. |
client.display.menuIdleAxisWake | 0.3 | 0–1 | How far a controller stick or trigger must be pushed, as a fraction of its travel, to count as input to a resting menu. |
client.display.frameSpinWindow | 0.001 | 0–0.05 | Seconds before each frame deadline the limiter spins instead of sleeping. |
client.display.frameMaxSpin | 0.004 | 0–0.05 | Longest the limiter spins in one wait before it sleeps anyway and takes the lateness. |
client.display.raiseFramePriority | true | on/off | Runs the frame thread above normal priority, so a busy machine does not make an unfocused window wake late. |
When both caps could apply, the lower non-zero one wins — an unfocused cap left higher than a
tuned-down client.fpsMax never loosens it. All of these apply live on the next frame, exactly like every
other display preference; no relaunch. The unfocused cap only ever matters to the windowed client:
offscreen dh render and the headless server open no window, so nothing there ever evaluates as
unfocused.
A cap is a rendering budget and nothing else. It decides how many frames are drawn, never how
fast the world is simulated. The fixed 60 Hz tick pump and the one input per tick the server is
owed both live on the frame loop, so the loop is paced to the cap or to the tick rate, whichever is
higher, and an unfocused client at client.display.unfocusedFrameCap draws one frame in two while stepping every
tick and sending every input on time. That is why an alt-tabbed client produces no client-side pacing
warnings and never shows up on the server as a starved input queue. What the cap does throttle is the
draw: presentation, and the GPU work behind it. What it does not throttle is the simulation, the input
send cadence, the clock regulator, or anything the server can observe. A frame that genuinely blocked
— a synchronous load, a GC pause, a debugger break — is the separate case: the pump steps at most
client.tick.maxCatchUp (15) whole ticks in one frame and drops the rest rather than bursting a
backlog onto the server’s queue, counting the drops and warning once per run.
The main menu rests. With no world behind it (the main menu, nothing loading, the session fade and any dissolve run out) there is nothing to simulate, so the loop is paced to client.display.menuFrameCap alone, with no tick floor. Once the composed picture stops changing and no input has arrived for client.display.menuIdleGrace, the window stops polling and sleeps in the OS for up to client.display.menuIdleHeartbeat, waking at once for any input or window event. A frame whose picture is command for command the one already on screen, with no event behind it, is not drawn. A controller’s idle axis jitter and update markers do not count as input (client.display.menuIdleAxisWake), because a connected pad streams them every frame. A focused text field and an open stand-in preview keep the menu awake, since a caret blinks and a preview renders into a texture the picture only names. The loop tells the stall watch about the sleep first, so a long heartbeat is not reported as a stall, and the frame after a sleep advances the UI by one frame period when an input ended it. Joining a map, loading or opening the editor leaves the state at once and restores full pacing on that same frame. The pause menu over a live world is not at rest: the world is still simulating and drawing behind it. The policy is MenuRestPacing in Engine.Client, read from ClientUiState.AtRestMenu and the preferences, so any shell with a frame loop gets the same answer.
The wait wakes on time whatever the window’s state. The limiter shares the server’s
DeadlineWait: a sleep for the bulk of the wait, then a spin for the
last client.display.frameSpinWindow. On Windows the sleep is a high-resolution waitable timer,
which keeps sub-millisecond accuracy even after Windows 11 withdraws a 1 ms timer period from a window
it judges to be in the background; there a plain one-millisecond sleep returns after 15.6 ms, which is
what filled an unfocused window’s graph with late frames. An unfocused window also loses the
foreground’s scheduling boost, so on a machine busy with a build its frame thread woke late from every
wait; client.display.raiseFramePriority keeps it above ordinary-priority work.
Two of the folded-in groups are both named Appearance — the crosshair’s and the console’s. Under one section a sub-header has to say what it is on its own, so each is titled for the thing it configures (Crosshair, Console), and the section name it would otherwise share is carried down as a keyword, so a search for it still lands on the group rather than on the whole page.
The icons are files, tinted at draw time
Section titled “The icons are files, tinted at draw time”FontAtlas bakes printable ASCII, so there is no character an icon slug could resolve to — and picking a letter that looks a bit like a speaker reads as a typo rather than as an icon. An icon slug resolves through UiIcons to an IconGlyph instead: a PNG loaded from content/ui/icons/<slug>.png at startup, rasterized into an atlas at every size the interface draws, and painted by Halcyon’s Icon widget as one TexturedRectCommand with the widget’s color as the tint. Two contracts share the directory. A standalone mark is a white-on-transparent coverage mask the theme tints at draw time — the active-state color swap costs nothing, because the mask carries no color to fight the tint. A folder mark (UiIcons.CarriesColor) is baked in its own brand color and drawn with a white tint, which is the identity: the materials folder’s sphere has a shade no single tint could give it.
The masks are Material Symbols (Sharp, weight 500 — Apache 2.0, see Engine/content/ui/icons/PROVENANCE.md), and the editable .svg sources sit beside the baked PNGs; Engine/scripts/bake-ui-icons (a bun script) re-bakes them, and replacing a mask file and relaunching is the whole workflow — no code changes. UiIconFiles downscales each 512 px bake by alpha-weighted area-averaging to a ladder of sizes (12–256 px, the top rung being the asset browser’s largest grid tile), shelf-packs them into a 2048-wide atlas, and IconGlyph.Pick samples the smallest rasterization at least as large as the drawn square, so an icon is only ever shrunk, never blurred by enlargement. A missing file, or a tinted mark that is not white, fails the boot loudly with the file’s name.
Three marks in that directory are not Material Symbols: consoleInfo, consoleWarn and consoleError are authored in-repo, redrawing the console’s info/warning/error severity marks — a dot-and-stem, a staircase triangle, a chamfered octagon — as <rect>s on the same 960-unit grid, since they carry no equivalent in the Material Symbols set. ConsoleMarkIcons is the one place that maps a ConsoleMark to its icon name, shared by the console’s own filter row and the editor bar’s console badge.
The level editor draws from the same registry across its reload boundary — the module calls into the non-reloadable client and gets plain data back (a texture id and UV rectangles), which no unload can strand.
Browse and search are two modes of one screen
Section titled “Browse and search are two modes of one screen”Browsing puts a category rail on the left and one section’s content on the right, its controls grouped under sub-headers, each control a card row: icon, title and a dim one-line description on the left, the control itself on the right.
The content column names itself. The selected section’s title is drawn above the scroller at PageTitleSize — the heading size, and the one piece of type on this screen allowed to be large — because “where am I” should be answerable without hunting for the highlighted row in the rail. Three sizes carry the whole screen and they are distinct rather than merely differently weighted: the page title at 22, a control’s label and a group’s sub-header at 14, a description or a chip at 11. All three are sizes the atlas bakes exactly; anything else would render as a scaled bitmap. The sub-header is set in the case it was authored in rather than shouted in capitals, and stays in Muted: an 11 px uppercase sub-header is denser than the 14 px labels under it, and density reads as weight, so the thing meant to organize the rows ended up competing with them. It separates itself by size and by air now — GroupGap is more than twice RowGap, so a sub-header belongs to the rows under it rather than floating between two sets of them.
A segmented control’s chosen option takes the tonal container with a TonalTrack border and TonalOn ink, the same tones the selected rail row wears — a choice that is made should look like the other thing on screen that is made, not like a neutral chip that happens to be a shade lighter. Because every one of those tones is derived from the seed, picking a different client.ui.themeColor moves them with it.
A rail row is an icon and a label, in one of three states: the selected row sits on a TonalContainer rounded to NavRadius with TonalOn ink, a hovered row gets a TonalWash at the same radius, and every other row is bare. The hover has to be stated rather than derived for a mechanical reason: a bare row has no fill, and lifting a transparent color toward white leaves it transparent.
The controls got the same treatment. A switch is a 40×22 pill with a 16 px round thumb inset 3 px on every side, sliding 18 px across an ease-out curve while the track crossfades between SwitchOffTrack and TonalTrack. A slider is a thick 12 px track in a 20 px lane with a prominent TonalTrack fill from the minimum edge, and a 16 px thumb that straddles it, standing SliderThumbOverhang (2 px) proud on each side. That relationship is the whole readability of the control, and it was got wrong first: the track was 16 px in the same 20 px lane, which left the knob nowhere to grow into, so it was made smaller than the track at 12 px. A knob that fits inside its own groove reads as a bead somebody dropped on the fill rather than as something a hand can grab — reported as “the slider handles are still smaller than the tracks”. Thinning the track by 4 px, rather than growing the lane and disturbing every row’s rhythm, gave the whole overhang budget to the knob; 12 px is still unmistakably a quantity rather than a hairline. SliderThumbInset is consequently zero — the inset exists only to hold a knob smaller than its track inside the capsule’s end caps, and a knob that straddles the track has no capsule to stay in.
The thumb reacts to the pointer by growing rather than by brightening — a ladder of 16 / 18 / 20 px, one SliderThumbSizeStep apart, with SliderThumbPressSize landing exactly on the 20 px lane height because SliderElement caps the painted diameter there and anything larger would be silently clipped. Size rather than value because the thumb is already near-white, and the lift-toward-white every other control reacts with is a change nobody can see on it. The two extra sizes are paint-only: travel and hit-testing still use ThumbSize, so a knob that grew under the pointer does not move the value under it. Tests pin both: the switch’s per-state colors and that its thumb is strictly mid-travel one half-duration in, and — for the slider — that the knob straddles its track, that the painted diameter is one number across the full travel, down a column of rows at different subpixel phases, and at fractional magnifications, plus the round trip from a press at the painted thumb position back to the value it was painted for.
Searching hides the rail entirely while the query is non-empty and replaces the content column with the hits. The semantics are deliberately dull: case-insensitive substring over group title, section title and both sets of keywords, returned in declaration order. No fuzzy matching, no ranking — a settings search that reorders itself as you type makes the thing you were reaching for move.
Each hit renders the real group: its own sub-header under a small category chip naming the section it came from, then the actual live controls. Nothing is flattened or copied, so a slider dragged from a search result writes the same preference the browsed one does. That is pinned by a test that drags a searched control and asserts the preference moved.
A control you cannot use loses its affordance, not just its brightness
Section titled “A control you cannot use loses its affordance, not just its brightness”“Disabled” is a null handler everywhere in this codebase, and that half was always right: Box.ResolveStyle returns the base style for both hover and pressed when nothing is interactive, and BoxElement derives no cursor without an OnClick, so a dead control neither lifts under the pointer nor asks for the hand. What was missing was the part you can see. A locked button kept full Text ink on a full raised chip; a locked field kept its well and changed nothing but readOnly. The report was exact: “all I see is maybe this shadow looks slightly different, or maybe I’m tweaking.”
The rule now is that the affordance goes away, not that it dims a little. Ink drops to Disabled, and the surface flattens to DisabledSurface for fill and border — which is defined as Card, the row’s own value, so the chip or the well stops existing and what is left is a dim word lying flat on the row. Brightness is a thing you can only judge against a memory of the same control alive; a missing container is legible on its own.
DisabledSurface is a neutral, following the split the rest of the theme already keeps — Text, Muted, Faint, Disabled, Card and Track are fixed values and only accent tones come off ThemePalette. Deriving it from the seed would put a dead control in the same color family as a switch that is on, which is the one reading it must never have.
ClientUiSlider and ClientUiToggle already dimmed their fills and went inert, and row labels already dropped to Disabled; buttons and text fields were brought onto that existing convention rather than given a second one. Segmented rows, dropdowns and color pickers are deliberately left alone: flattening them would erase the selection they are carrying, which is information the player still needs while the control is locked.
Tests assert the difference rather than the appearance — a locked label’s color, a locked chip’s fill being the card’s value, a locked field losing its well, and that a live button asks for CursorKind.Pointer while a locked one asks for nothing and paints identically under the pointer. The disabledControl run in UiCaptureTimeline photographs the same control free and locked, so dh render --renderUi shows the two side by side; the locked frame is produced by a launch-time name override, which is where a genuinely non-editable control actually occurs.
The crosshair has one painter
Section titled “The crosshair has one painter”Interface → Crosshair draws a live preview that updates as its controls change — the one place a preview earns its keep, because those controls cannot be evaluated without seeing the result.
It is not a second drawing of the crosshair, and not a second emitter either. CrosshairPainter.Build takes a CrosshairReadout — the eight preferences the reticle is made of, read from the store in one place — and returns a list of CrosshairQuads: a rect, a corner radius and a color. The live band replays them as RoundedRect commands into HalcyonLayer.Crosshair; the settings preview replays the same list as CanvasShapes. Neither knows what an arm or a dot is.
The shape layer under it is unchanged: CrosshairOverlay.ResolveGeometry takes style, size and thickness and returns segments and dots. Everything after that goes through the one painter, rather than being turned into pixels twice: two independent consumers translating the same geometry into pixels their own way would drift apart — round caps in one, square in the other — and a color helper would exist twice with the same three-channels-are-opaque rule in both, with roundness and a drop shadow each written twice more. A test pins the two outputs quad-for-quad.
Dots go through Painter.SquareAt, the same helper Circle and Ring use, so a dot’s diameter is snapped once rather than at each edge — see the pixel grid.
| Preference | Type | Default | What it does |
|---|---|---|---|
client.crosshair.roundness | float | 1 | How round each element is, 0 (square) to 1. The radius is half the shorter side, so a dot becomes a circle and an arm gets capped ends without stretching. |
client.crosshair.shadow | bool | true | A soft dark halo under every element, centered rather than offset. |
client.crosshair.shadowIntensity | float | 0.7 | How dark the halo is right against an element. |
client.crosshair.shadowSpread | float | 2 | How far it reaches past an element, in pixels, up to CrosshairPreferences.MaxShadowSpread. |
Roundness defaults to fully round because that is the look the crosshair has — the dotted presets are filled circles and the line preset is a round-capped stroke. A smaller default would be a silent change to every existing profile’s reticle rather than a new knob.
The halo is stacked stamps, not a blur. Halcyon’s blur is a layer — an offscreen gaussian over a whole subtree — which would soften the ink instead of spreading darkness under it, and would cost a render target every frame for a mark a few pixels across. So the painter draws each element again once per pixel of spread, grown outward and rounded to match, each at intensity / layers alpha — concentric coats that accumulate into a falloff. Every halo quad is emitted before every ink quad, or on a dot cross one dot’s halo would land on its neighbor’s ink and dim it. The layer count comes from the spread rather than being a fifth tuning number, capped at CrosshairPainter.MaxShadowLayers.
The shadow is on by default because the reticle ships in semi-transparent white, which is legible on a dark ground and vanishes on a bright wall or sky; the halo costs nothing where the mark was already readable. The alpha is the authored number — Halcyon blends in linear light, so it reads lighter than the same figure would in a gamma-space toolkit, and the correction belongs in the preference rather than folded into the painter.
The preview shows two grounds at once: the theme’s dark track on the left, a light neutral plate on the right. Both are the same painter on the same readout, so they update together as the sliders move. Two plates rather than one because the question the halo answers is only about value — a pale reticle is legible on the dark plate whatever the shadow does, and only the bright plate beside it shows whether the halo is carrying the mark. The bright ground is deliberately not white, which no wall in a lit scene is and which would flatter the treatment.
UiCaptureTimeline.Crosshair and UiCaptureTimeline.CrosshairSettings photograph both ends of the roundness knob — in play and on the settings page — by pinning client.crosshair.roundness on the step, the way the antialiasing ceiling and the theme seed are pinned. Every step of those runs states the value, since a pin is a preference write and a run that stated it only on the frame that deviates would hand the square reticle to whatever the timeline concatenates after it.
The speedometer is the first player HUD element after the crosshair
Section titled “The speedometer is the first player HUD element after the crosshair”Interface → Speedometer owns client.hud.*, the player-facing head-up display’s own preference branch — deliberately not a corner of client.debug.*. Everything under the debug branch reports on the program and is read next to a stack trace; a speedometer is part of the game, read while moving, so it sits beside the crosshair, which is the other surface of that kind and already owns a branch.
| Preference | Type | Default | What it does |
|---|---|---|---|
client.hud.speedometer | bool | false | Draws a card at the bottom center reading the player’s speed in m/s. Off by default: a new readout appearing unbidden on everyone’s screen is a change to what the game looks like. |
client.hud.speedMeasure | enum | Horizontal | Which motion the number reports — Horizontal, Rush or Full. |
The three measures are genuinely different claims rather than three roundings of one number:
Horizontal— horizontal speed only, in the air as well as on the ground, so a dead-vertical fall reads0.0. What a surf or bunny-hop readout reports, and the one measure that does not change when the terrain tilts under a run.Rush— whatever the engine’s own rush signal is reading: horizontal while grounded, the full magnitude once airborne. The number the speed field of view and the wind loop are driven by, so the readout and the effects agree.Full— the full three-dimensional magnitude at all times.
All three go through the same SpeedRush.Speed entry point (HudSpeed.Speed selects the arguments), so there is no second magnitude implementation for the readout and the effects to drift apart in.
The number is fixed to one decimal. Zero places is too coarse on a metric scale where a walk and a sprint are eight units apart — the readout would sit on one integer through most of an acceleration. Two places puts a digit under the number’s own frame-to-frame noise, and at a few hundred frames a second that digit is a blur. One place resolves a tenth of a meter per second and stays legible in motion. It is padded to a fixed character field as well, so the card cannot breathe in and out as the speed crosses ten, and it is formatted invariant, so a screenshot says the same number to everyone reading it.
Type is the atlas’s baked title size (44 px) for the number and its body size (14 px) for the m/s suffix. Both are baked, so neither is a scaled bitmap — and that is what picks them rather than any size in between: the atlas rasterizes exactly four sizes and anything else is the nearest bitmap stretched, so the number takes the top rung of that ladder rather than a number that reads as damage to the glyphs. The card is the theme’s Card fill at reduced opacity with the strong border the loading box takes, since it floats over the world with no scrim under it, padded 20 px horizontally and 10 px vertically so it stays a plate holding a number rather than a number that has outgrown its plate. It sits bottom center, floating 8% of the viewport height off the bottom edge with the shared floating margin as a floor — a fraction rather than a pixel count because the readout belongs in the lower band of the image rather than a fixed distance off the glass.
The unit is held back by alpha, not by gray. The theme’s faint tone is what a label is on a panel, where it sits on a flat surface the whole screen is made of; this card floats over live gameplay, and a gray glyph over a bright world does not read as quiet, it reads as smudged. So the suffix is the same white the number is at 75% alpha — legible as a label, unmistakably subordinate to the number, which the size gap already says.
It records in Hud, a band distinct from Tooling but spliced right alongside it — under the player’s screens, exactly like the position readout. A pause menu’s scrim dims the speed along with the world it reports on, the same treatment the position readout gets, rather than letting the number burn through the menu. The console, the options window and the developer overlay’s chrome still cover it, same as Tooling. Hud stays a separate band from Tooling even so — a speedometer is player-facing and a position readout is a developer tool, and merging them would let a debug HUD level gate the speedometer. Crosshair was never a candidate — that band is the reticle’s alone, spliced directly under the screens and above both readouts. The band is declared by the surface (SpeedometerOverlay.Band) and resolved through UiLayer.List, so the live client and the offscreen capture cannot photograph different stacks.
On top of the layering it is gated, but only by the one term that matters: ClientUiState.HudVisible is exactly InSession — there is a world. The main menu and a join that has not landed draw no readout at all; pause, focus loss, a console and the options window leave it alone and let the stack decide what covers it.
SpeedometerLayout is pure — a font’s metrics in, a rectangle and two text origins out — so where the card sits, that its width does not move, and that the unit rides the number’s baseline rather than its line box are all asserted headlessly. UiCaptureTimeline.Speedometer photographs the readout at a stand, at the engine’s declared walk and sprint speeds and at a fall, then raises the pause menu (speedometerOverMenu, dimming the readout under its scrim) and the console over that (speedometerUnderConsole) — both halves of the layering claim, because a boundary with only its true side photographed is not a picture of a boundary.
Interface is where the interface’s own look lives
Section titled “Interface is where the interface’s own look lives”Interface opens with the four groups that are about the interface rather than about the game: Scale, Theme (the seed color), Surface (the noise slider) and Text (the antialiasing ceiling). Gameplay follows them with the one switch that is about what the interface says about the world: Highlight interactive objects (client.highlightInteractive, default on), which rings the thing your Use key would act on — see the same ring outside the editor. The crosshair, the speedometer and the console’s layout sit behind them, since every one of those is also a judgment about the surface the player reads rather than about the world behind it.
Text antialiasing lives here rather than among the screen controls: it is not a display setting, nothing about it depends on the monitor’s mode, and everything about it is a judgment on how the interface’s type looks.
The Theme group carries a preview beside the choice: five labeled swatches — Seed, Hover, Selected, Switch on, Label — painted from the live palette, so a seed is chosen against the shades it produces rather than against its own name. It is the second group in the screen to earn one, and it earns it more emphatically than the crosshair does: the seed itself is the one color a person could have guessed, and the four derived tones are the ones they will actually be looking at.
| Preference | Default | Meaning |
|---|---|---|
client.ui.settingsWidth | 720 | Outer width of the options window, pixels (minimum 480) |
client.ui.settingsRailWidth | 150 | Width of the category rail, pixels |
client.ui.settingsHeight | 640 | Outer height of the options window, pixels (minimum 360) |
client.ui.settingsX | -1 | Left edge of the options window, pixels; -1 centers it |
client.ui.settingsY | -1 | Top edge of the options window, pixels; -1 centers it |
client.ui.windowResizeEdge | 8 | How far into a window’s edge the resize band reaches, pixels |
client.ui.windowResizeCorner | 20 | How far into a window’s corner the corner square reaches, pixels |
settingsX, settingsY, settingsWidth and settingsHeight are what the drag surface and resize handles write when a gesture ends, so dragging the window and editing these from the console are the same edit through two front doors. The last two are the hit geometry rather than a record of anything, and they apply to both windows — Halcyon has no preference system of its own, so they are declared here and passed into Ui.ResizeHandles as constructor arguments.
Tunable numbers stay Preference<T> and are read at the Engine.Client call site, where the preference store lives — Halcyon itself has no engine reference and no notion of preferences, which is what keeps the layout solver pure and its tests deterministic.
The console
Section titled “The console”The console is Halcyon, and there is no immediate-mode console kept alongside it — ClientUi.cs is gone, not toggled off. ConsoleViewModel, ConsoleLineRing, ConsoleHistory and ConsoleCompletionRanker are pure model code with no renderer in them, so the view can change without changing the behavior. Ranking, the ten-candidate cap, subtree descent, the audience gate and the annotation strings are the same code paths a player was already using.
One body, three placements
Section titled “One body, three placements”client.console.layout chooses where the console sits, and that is all it chooses:
| Value | Shape |
|---|---|
sheet | Full-width drop-sheet from the top edge, the default posture |
card | A floating window: draggable from anywhere on its background, resizable from any edge or corner |
dock (default) | A full-height panel against the right edge |
There is one BuildBody — header, log well, completion panel, input row — and a Placement/Anchor pair that decides size, corner radius and which edge it is pinned to. A fourth shape would be a case in two switches, not a fourth console.
The card is a real window, on the same footing as the options window and through the same Ui.DragSurface: any background area moves it — the header’s dead space, the padding, the gaps between rows — unless a button, a field, a completion row, a scrollbar or a popup takes the press first. Its geometry persists under client.console.card*, written once per gesture, clamped and centered by the same WindowGeometry helpers. The sheet and the dock have no resize handles, because neither has a size to change: one is as wide as the viewport, the other as tall.
The keyboard is part of the screen, not part of the console
Section titled “The keyboard is part of the screen, not part of the console”A software keyboard covers the bottom of the screen, and on an iPad it covers nearly half of it. Everything the console keeps down there — the tail of the log, the prompt itself — was under it, which is a particularly bad joke for the one panel a keyboard is opened for.
The fix is a convention rather than a console feature. ScreenOcclusion is a shared value in Engine.Client/Ui describing what something outside the UI is currently covering — today one band along the bottom edge, in the same units the viewport is measured in. It flows beside the safe-area insets and by exactly the same route: HeadlessRenderer.UpdateClientUi takes it, HalcyonLayer divides it by the interface scale into authored units, and screens read it from there.
It is deliberately not a safe-area inset, and keeping the two apart is the point. A safe area is a fact about the device — the notch and the home indicator are there for the whole session, so the permanent furniture is anchored inside it once and never thinks about it again. An occlusion is transient: it arrives when a field takes focus and is gone a moment later. So it is a per-frame derived value that is never stored. A consumer runs its ordinary placement arithmetic against the reduced screen and writes nothing back — no preference is touched, no layout fraction is rewritten — which is what makes the keyboard dismissing land the console exactly where it was, to the float, rather than somewhere near it.
The console is the only consumer, and it needs no per-layout opt-in:
- dock and sheet end at the top of the band instead of at the bottom of the screen.
- card is reseated upward so its bottom edge is at or above the band, in the spirit of
WindowGeometry.Reseatand just as purely. A card taller than what is left is pinned to the top rather than resized: a keyboard is a moment, and a window that resized itself for one would come back a different size than it was left. - A layout the band never reaches is untouched. A sheet at 62% of a 720-unit screen ends at 446, a 240-unit keyboard starts at 480, and nothing moves — which is why the sheet is clamped against the available height rather than having its fraction rescaled.
client.ui.showOcclusion true paints the answer: translucent green over the screen the UI may use, translucent red over the band, over everything and passive throughout, so the interaction it was switched on to watch still works underneath it. The seam between the two washes is where an occlusion-aware edge should land.
Producing the band is the only platform-specific part, and it is a genuinely thin adapter: IosKeyboardOcclusion observes UIKit’s keyboard frame notifications, converts the frame into the key window’s coordinate space so Split View and Stage Manager report only what is over this app, and hands the height across in points. It holds no layout policy and no console knowledge.
The header is one row
Section titled “The header is one row”Left to right: a loggers menu button, a layout menu button, slack, the filter field, the wrap/time/follow pills, and a close box hard against the right edge. No title — the prompt already says what the panel is, and a two-row header would leave a column of dead air over the top-left corner of the log for no gain. The engine’s name and version are not shown here either; the pause menu is where a build number belongs.
The per-category chips are gone, replaced by the loggers menu, and the reason is a bound: there is no limit on how many categories a session produces, so a chip per category is a row that grows until it has squeezed the filter to nothing. A button that opens a list is the same information in a fixed width, and the list scrolls.
Both menu buttons carry a small chevron, because a bare word in a header row full of other bare words gives no sign that clicking it opens anything. Both are Popups, so opening one cannot move the header by a pixel, and both go through OnDismiss — a press outside or Escape closes the menu, and the Escape is consumed there rather than continuing on to the pause layer.
The two menus differ in one deliberate way. Clicking a logger row does not close the loggers menu: muting categories is done in threes and fours while watching the log react, and a menu that shut after each one would have to be reopened from a button the eye has to find again. A row also stays in the list while its category is hidden, since one that vanished on its own click could never be clicked back. Clicking a layout row does close its menu, because the console visibly becomes a different shape and the button the menu hangs off has moved.
Opening either menu closes the other — they drop from the same row, and two overlapping surfaces there would be unreadable.
Anchoring is done with flex spacers, not a stack alignment. The layer that hosts every root loosens its children, so a stack here would shrink-wrap the console and have no slack of its own left to anchor within — a right-docked panel would land dead center. A flex line with one growing child takes its whole bounded axis, so the edge is decided by the console rather than inherited from the layer. The log column is Flexible with a zero basis for the same class of reason: a list measures to whatever it is offered, so a log that stated its natural height would claim the entire column and leave the solver a deficit to spread — CSS-correctly — across the header and the prompt too, and a prompt shrunk by a few pixels clips its own glyphs.
The log is virtualized, and every line says where it came from
Section titled “The log is virtualized, and every line says where it came from”The scrollback is a VirtualList, so a ring holding thousands of lines builds only the rows the well can show. Rows scrolled out of the viewport stop being elements and leave their last measured height behind for the scroll range, which is what keeps scrolling far up cheap without the list mis-stating its own length. PinToEnd follows the newest line, and every scroll is reported so following breaks the moment the user pulls away from the bottom.
Each row is three fixed columns and one flexible one: an optional timestamp, the severity tag (DBG/INF/WRN/ERR), the category, then the text. The tag and its color both come from LogFormat — the same source the terminal sink and the log file use — so a severity means the same thing, in the same color, wherever you read it. Two spellings of one severity language would be two places to change it. Fixed column widths rather than a formatted prefix string, so the message text of every row starts on the same x whatever its level and category are.
“Fixed” has to be stated to the solver, not merely declared as a width. A plain flex child shrinks (Shrink = 1), and a log row overflows all the time — a NoWrap message measures its whole text, and even a wrapping one is first measured against the full row — so FlexSolver shared the deficit across every child weighted by basis and pulled the prefix cells narrower by an amount that depended on the message and on the console’s width. The tags drifted left as a line got longer or the card got narrower. Each prefix column is therefore Flexible(grow: 0, shrink: 0): the entire deficit lands on the message column, which is the one meant to absorb it and which the well already clips. Wrapped continuation lines then indent to the message column for free, because the message is a column and every line it wraps to is drawn from that column’s left edge. The timestamp column is sized for the small text size it is actually drawn at, not the body size — sizing it for the body size left a visible hole between the clock and the tag.
Tab belongs to the completion panel
Section titled “Tab belongs to the completion panel”Halcyon routes Tab to focus traversal because the focused element declined it. The command line therefore consumes Tab at the element, through the field’s key hook, before traversal can ever see it: Tab cycles candidates forward, Shift+Tab backward, and focus does not move.
The completion list has no cap
Section titled “The completion list has no cap”There is no limit on how many candidates a prefix may match, and no ”… and N more” row — that hint existed only because the rest was unreachable. The panel shows CompletionMaxRows (8) rows’ worth and scrolls to everything else; Tab walks the whole set, and the view follows the highlight down.
Nothing materializes a full match list to do it:
ConsoleCompletionRanker.Rankreturns a deferredIEnumerable<ConsoleCompletionEntry>. A query whose panel is never drawn costs nothing.ConsoleCompletionCursorholds the enumerator plus a cache grown on demand. AReset(any keystroke) realizes one window plus one window of lookahead — 16 entries — whatever the match count is. Cycling past the cache pulls another lookahead; wheeling near the bottom of the realized rows pulls another.- Being honest about where laziness ends: the ranker deduplicates and orders globally, so the first
MoveNextscans every source. What the cursor saves is everything after that — entries never constructed, rows never built, panels never drawn.
Forward wrapping therefore only happens once the stream has genuinely run out, which is the guarantee the user walked the whole set rather than the head of it. Shift+Tab from the top is the one deliberately eager move: naming the last element means reaching it.
The view follows the highlight through ScrollView.RevealKey — the panel names the selected row’s key and the scroll element scrolls that row’s measured box into view, applied once per distinct key so the wheel is free to scroll away from it. Typing gives the panel a new key of its own (candidates<generation>), which remounts the scroll element and puts the offset back at the top, since scroll offset is element state and would otherwise outlive the match set it belonged to.
Completion continues past the space
Section titled “Completion continues past the space”Naming a variable is the easy half. client.debug.colliderGizmo — a known path, a space typed, the caret exactly where a value goes — is where the questions a player actually has at that moment (“what is it set to, and what else does it take?”) get answered. ConsoleValueCompletion answers them, for both spellings of a write: the bare client.fovAxis horizontal and the set client.fovAxis horizontal sugar. get and reset take no value and keep completing a path.
The order is fixed, and it is about what is in force rather than what was declared:
- The value in force, always first. A list whose top row is the current setting answers “what is it?” without running
get. - A boolean’s opposite, second — so accepting the row below the current one with a click or a Tab is the toggle.
- Every enum member, in declaration order, each carrying its own description.
- The declared default, so a number has one row worth naming besides itself: the cheapest possible undo.
Everything is deduplicated case-insensitively, which is what makes “current first” compose with the type’s own list — an enum’s current member is lifted out of its declaration slot rather than repeated at the top. Rows are tagged current, default, or current, default when they are the same string.
Ordering survives the ranker because each option is emitted at its own SortTier: one option per tier, so the declared order is the drawn order. Alphabetical ordering would be actively wrong here — Lightmap sorts above LightingOnly and neither is what the enum says.
Per-member prose comes from [ValueHelp] on the enum member itself, resolved through DigitalHeaven.Core.ValueHelp. It lives on the member because a preference’s own help line has to describe the whole setting and therefore can never say what one option does. ValueHelp sits in Core rather than the engine so LightFalloff — a Core type — can carry it too. Every enum reachable from a registered preference describes every one of its members, and a test fails the build if one does not.
Two deliberate silences:
- A value this console cannot know is not claimed. A client console binds no world, so
world.render.tonemapresolves to a declaration but not to a live value, and per-player overrides live on the pawn. Both still list every legal option and tag the default; neither pretends to know which one is in force. - The inline ghost waits for a first character. While the line ends in a space, nothing has been typed of the value yet, so there is no prefix to complete from — ghosting the leading row would draw the value already in force and then fight every digit typed over it. The panel still lists everything; only the preview holds off.
The lookup itself is one delegate, ConsoleVariableLookup, satisfied by GameConsole.TryResolveVariable. It resolves through the same machinery name completion already uses — the PreferenceRegistry for client.*, world.* and server.*, and PlayerVarCommands.TryResolveVar for the players.<target>.<var> move and look catalogs — rather than a second registry that could drift from the first.
An ordinary command’s argument completes too
Section titled “An ordinary command’s argument completes too”A value belongs to a preference; a barcode belongs to an ordinary command, and it gets the same treatment through a different door. [ConCommand] carries FirstArgument, one ConsoleArgumentKind naming what the command’s first argument is rather than a per-command completion function: prefab.open and spawn declare SpawnableBarcode, map declares MapBarcode, avatar declares AvatarBarcode, prefab.close declares StagedEntityId, and world.open declares nothing at all — it names a brand-new world, and there is no catalog of those to offer. One switch in ClientHost.ArgumentSuggestions reads the declared kind and dispatches to that kind’s one source (SpawnableRegistry/MapRegistry for a barcode, the client’s own staged entities for an id), so a future command completes by declaring the kind it already has rather than by anyone adding a case here. A server-forwarded command has no FirstArgument on the wire — adding one would be a protocol bump — so CommandRegistry.FirstArgumentOf reads the attribute straight off the declaring type instead, the same value a locally-registered command’s own entry already carries.
Barcode candidates are ranked exactly like a command name: prefix match first, then substring, case-insensitive, capped by client.console.completionRows (default 8) before the ranker ever sees them, and rewritten by ConsoleCompletionRanker.RankArgument into the full command argument line a Tab or a click inserts whole. prefab.open’s and spawn’s source is filtered to objects and avatars; avatar’s is the same registry narrowed to the .dh-avatar extension alone, so a model never shows up where only a face could go.
The list floats, and the input row never moves
Section titled “The list floats, and the input row never moves”The completion list is a Popup anchored to the command line, which is the only shape that satisfies the one rule that matters: the prompt does not move when candidates appear. A panel in the console’s own column would resize the footer on every keystroke that changes the match count, jumping the line being typed on. As a popup it takes part in no layout at all — it floats over the log, and the anchor’s size is the popup’s size whether it is open or shut.
Which way it unfolds follows the placement. The dock puts the prompt at the bottom of a full-height panel, so there is nothing below to unfold into and the list grows upward. The sheet and the card hang from the top, so the list drops downward — unless the console has been dragged low enough that there is not room for it, in which case it flips up. That rule is one pure function of the layout, the surface’s bottom edge and the viewport height, tested directly rather than through a screenshot.
A send button sits to the right of the input, square at the row’s height and accent-colored once there is a line to send; it calls the same Submit Enter does, so clicking it and pressing Enter are the same event.
Alignment is Stretch: the list is exactly as wide as the command line. Width is not decoration here — a value-position candidate carries a description that is the content, and a list sized to its names would truncate the only part worth reading.
Rows are clickable, with hover and pressed paint like any other control, and a click applies that candidate through the same cursor Tab walks. The list opts out of OnDismiss deliberately: it is dismissed by its own editing rules (Escape at the field, or a keystroke that empties the match set), not by clicking away from it.
How it opens and how it closes
Section titled “How it opens and how it closes”The backtick opens it from anywhere, and opening it does not pause the game. Press it while playing and the console arrives over a world that keeps running: the pointer is freed so the panel can be clicked into, gameplay input is gated so a w typed at the prompt does not also walk you forward, and nothing else about the frame changes — no dim, no links, no stopped clock. That is the console’s whole reason to exist in this shape; a tool you can only reach by stopping the thing you are debugging is a worse tool.
Closing it is the toggle key again, the menu’s Console link, or the header’s close box — all three raise literally the same event through the model, so they cannot drift into meaning different things. Escape closes it too, in the two ways the Escape stack sets out: over live gameplay it closes the console alone and hands the game back, and over a paused game it takes the console down with the pause layer. Either way one press ends with a clean screen.
The toggle is bound at screen level, on a KeyListener wrapping the whole console, so whatever inside it holds focus — the command line, the filter field, nothing at all — one press closes it. It is routed as a key (UiKey.Grave, mapped from the window’s GraveAccent), never as a typed character, and Halcyon delivers keys before characters. That ordering is the whole reason a backtick cannot land in the command line on the frame it closes the console.
The scrollback is a ring of client.console.historyLines lines (20,000 by default), and the console draws all of it: the log column is a VirtualList, so only the rows in view are built however deep the history runs, and the ring hands back one cached snapshot until a line arrives so a rebuild with nothing new does not copy 20,000 lines again.
help is a way to find things, not a dump. Plain help prints a short overview: how many commands and variables there are, the undotted command names, and one line per top-level group with its counts. help <prefix> lists everything whose name starts with the prefix (help client.render.vram finds the GPU memory pool settings), and when that is more than client.console.helpLines lines it summarizes by sub-group instead, so each step names where to narrow next. help <name> shows one command or variable in full, with its default. A word that starts no name falls back to the substring search find does. The groups are cut with ConsoleNameTree, the same branch rule the completion ranker uses, over the same command and preference registries.
What always survives a close is the scrollback and the half-typed line: both live in the model rather than in the elements, so a console reopened after any of those routes is the console you left. The one thing that empties it is clear, which drops every held line — and with them the severity counts the editor bar’s console badge reads off the same ring.
The completion panel’s Escape sits on the field itself, so while the panel is up Escape dismisses it and the console stays open with its input focused. An open header menu takes Escape the same way, through Popup’s dismissal in UiTree rather than through anything the console wrote. Neither reaches the shell, because UiLayer.PopupOwnsEscape reports the surface before the key is routed and the routing withholds it for that frame — the shell and Halcyon are fed by two independent key paths, so a popup cannot consume a key on the shell’s behalf after the fact.
ClientKeyRouting.Route withholds from the shell exactly the bindings a person can produce while typing, whenever any field holds the keyboard: the backtick, Enter and keypad Enter, and F. That is the tree’s own WantsKeyboard, the same flag the frame already gates movement on, asked of the routed keys too — so a coordinate committed with Enter in the editor’s inspector does not also open the chat prompt, and a backtick typed into a name box does not raise the console. The console’s own prompt is the narrow case of that rule, named separately because it is the one surface whose claim depends on being the front window: withholding its toggle while the console sits behind the options window would send the key to a surface nobody can see. Function keys are deliberately not on the list — F1 still raises the overlay and F2 still leaves the editor from inside a field, because nothing types a function key and a mode you cannot get out of while a box has the caret is a mode that has trapped you. Escape is withheld too, but for the other reason: the field is the first layer a press has to pop, so Escape reverts the edit and drops the caret and goes no further — the Escape ladder is not walked, and the editor selection a person was editing survives the press that abandoned the number. The next Escape, with nothing focused, walks the ladder exactly as before. Chat is the one surface that withholds everything but Escape, because it is the one surface with a prompt the player types free text into while the world is still running — and Escape is kept out of that blanket precisely so a prompt can never become unclosable.
Keyboard capture and platform text entry are separate contracts: UiTree.WantsKeyboard retains focused key routing, while WantsTextInput is true only for an editable, non-read-only focused target. iOS starts its software keyboard from the latter. Touch focuses buttons without keyboard-navigation decoration; BuildContext.IsFocusVisible restores button focus paint on keyboard navigation, while text fields retain caret/focus styling. Mouse focus behavior is unchanged.
On desktop, opening the console focuses the command line with no click and no Tab. The mount latch never unmounts a root, and autofocus fires on mount, so the console stamps an open count into the input’s key: each open remounts the field and re-arms the autofocus. Nothing is lost by that, because the text lives in the controller rather than in the element. With the host’s UiInteractionStyle.Touchscreen, the shared layer disables HalcyonConsoleScreen.AutoFocusInput: opening or raising the console, or reading its log surface, does not summon the software keyboard. Tapping the command field still focuses it normally. The same interaction style sets UiTree.AutofocusSearchFilters: opening a long dropdown on touch leaves its search field unfocused until tapped, while desktop keeps immediate search focus. Short lists still omit search altogether. This chooser policy does not suppress explicit composition actions such as opening touch chat.
Failed loading/session cards offer Open Console beside their existing dismissal when the host has bound a console and its menu action. It opens through that shared action, or raises a console already open behind the report; it does not dismiss the failure, retry, or launch a session. An existing secondary recovery action such as Rebuild keeps its button and route. Hosts without a bound console/action do not get an inert button.
Narrowing what you are reading
Section titled “Narrowing what you are reading”Three controls, composable, all in the header:
- A filter field — case-insensitive substring over the visible lines, the same dull semantics the settings search uses.
- The severity filter, three joined segments — info, warning, error — each wearing the editor bar badge’s mark and the number of lines the scrollback holds at that severity. Each segment is an independent switch over whether those lines are drawn, so “everything except the chatter” and “errors only” are both one click away; a switched-off segment keeps counting its own lines, because otherwise nothing would say what turning it back on would bring. Debug lines follow the info switch, the same fold the counts make. The three states persist.
- The loggers menu, one row per category the scrollback has actually produced (and a “No output yet.” row when it has produced none). Clicking a row hides that category, clicking it again brings it back, and the menu stays open through both. Hiding a category re-arms follow, since the visible set just changed under the viewport.
Alongside them: wrap and time pills over the two preferences, a follow toggle that pauses automatic scrolling when on and jumps to the latest line when switched back on (newly appended logs keep the paused reading position; manual scrolling still works), and a dim ghost preview of what accepting the highlighted candidate would insert (drawn after the caret, visual only).
Selecting out of the scrollback
Section titled “Selecting out of the scrollback”The log is a SelectionRegion over the virtual list, and every message run inside it is selectable:
- Drag within one line and you get exactly the characters you crossed.
- Leave that line, up or down, and the selection escalates to whole lines — the ends stop being character-precise, because a range that kept them would copy a half-line at each end, which is never what a stack trace or a wrapped URL wanted. Dragging back onto the starting line restores the character range.
- Click without dragging clears it. So does pressing anywhere the log has no text.
- Hovering a message shows the I-beam, the same shape the text fields show, so the log says it is selectable before anyone tries.
- Ctrl+C copies, through the same
UiTree.Clipboardthe text fields use. It is gated on the prompt having no selection of its own, so Ctrl+C never stops meaning “copy what I highlighted here”.
Copy is the message text and nothing else. The timestamp, the severity tag and the category are plain runs outside the selection, so they are not selectable and cannot be copied — a pasted line is the thing you wanted to paste into a search box or an issue, not a column layout that has to be stripped by hand. Selection anchors are the visible-line index and a character offset, never a pixel, so scrolling — or a row leaving the virtual list entirely — cannot move it. Editing the filter, muting a category or hiding a severity renumbers the visible lines, so each clears the selection rather than leave it pointing at whatever now occupies those indices.
This replaced a click-to-highlight-a-row model outright. A row highlight has no way to express half a line and no way at all to express two, and its stale-selection behavior was the bug that motivated the rewrite.
Both text fields — the prompt and the filter — carry a hover paint as well as a focused one. Focus outranks hover, so the hover state only shows on an unfocused field, which is exactly when it is useful: it is the answer to “is this thing a text box?” before anyone has clicked it.
| Preference | Default | Meaning |
|---|---|---|
client.console.layout | dock | Placement: sheet, card or dock |
client.console.wrap | true | Word-wrap long lines to the panel width |
client.console.timestamps | false | Prefix each line with the time it was logged |
client.console.historyLines | 20000 | Lines of scrollback kept; the ring follows it live, lowering it trims the oldest at once |
client.console.helpLines | 40 | The most lines one help prints before it summarizes by group |
client.console.showInfo | true | Show informational lines (debug lines with them) |
client.console.showWarn | true | Show warning lines |
client.console.showError | true | Show error lines |
client.console.cardX | -1 | Left edge of the card, pixels; -1 centers it |
client.console.cardY | -1 | Top edge of the card, pixels; -1 centers it |
client.console.cardWidth | 860 | Outer width of the card, pixels (minimum 360) |
client.console.cardHeight | 460 | Outer height of the card, pixels (minimum 220) |
client.ui.consoleSheetHeight | 0.62 | Fraction of the viewport height the sheet occupies |
client.ui.consoleDockWidth | 0.44 | Fraction of the viewport width the dock occupies |
The split between the two prefixes is not an accident. A fraction of the viewport is a taste setting a person tunes, and it lives with the rest of the UI’s tuning under client.ui.*. A dragged rectangle is a record of what a person did, and it lives with the console’s own settings under client.console.*. Only the second kind is written by the window itself, once per gesture. The card is stored in pixels rather than fractions for the same reason the options window is: a resize handle drags a rectangle, and a size held as a proportion would change whenever the viewport did.
All three placements are reachable from the settings screen’s Interface → Console group and from the header’s own layout menu, so choosing one is never a console line about the console.
The developer surfaces
Section titled “The developer surfaces”The debug HUD, the world gizmos and the DigitalHeaven overlay all paint through Halcyon — but not through widgets. A corner-pinned readout, a marker projected from a world position and an overlay window the user dragged to an arbitrary rectangle all name their own coordinates, and Halcyon’s layout has no absolute placement to name them with. That is a deliberate hole in the layout engine, not a gap to be plugged: flex layout stops being predictable the moment a child can opt out of it.
So they take the layer underneath. DrawList is the seam — a flat list of primitives with no layout in it at all — and everything above it is optional.
The frame is one recording of five lists, and the order is the layering
Section titled “The frame is one recording of five lists, and the order is the layering”HalcyonLayer owns five lists, and UiBandStack.Compose splices four of them into the fifth, which makes it the one place UI layering is decided:
| List | Holds | Why there |
|---|---|---|
Tooling | The frame-stats card’s deeper levels, the position readout, the effects readout, the axis / entity / scene-pivot gizmos and the sound cues | Spliced at the start of the tree, under every screen — except with a docked game view, where it rises to just past the picture that screen paints. These are world-anchored or read-only: each describes the frame underneath it, and a pause menu is a scrim over that frame, so the scrim dims the description along with the thing described. A gizmo painting at full strength over the pause menu reads as the tooling having escaped the pause. See the note below for the one case that inverts |
Hud | The player’s speedometer and the compact frame-stats card | Spliced directly over Tooling, sharing its lift under a docked game view too — under every screen, under the console, under the options window, under the overlay’s chrome. A speed is information about a world that is still there behind a pause screen, so it earns the same scrim the world gets rather than burning through the menu. It stays a separate band from Tooling because it is player-facing rather than a developer tool — merging them would let a debug HUD level gate the speedometer |
Crosshair | The aiming reticle, and only the reticle | Spliced directly above Tooling and Hud, and directly below the screens, which is two claims and both are wanted. Above the readouts because during play a reticle says where a shot would go and no debug HUD may obscure it. Below the screens because a reticle on top of a pause menu is absurd — obviously. Declared by CrosshairOverlay.Band and resolved through UiLayer.List, like every other pinned surface |
| The widget tree, first band | The pause menu, the main menu and their scrim | The screens the player opened the shell with |
Overlay | The DigitalHeaven overlay’s own backdrop, menu bar, windows and toasts | Spliced at the screen seam, over the menus. The chrome is only there because somebody deliberately opened it, so it wins against everything nobody opened — including the readouts, which would paint straight through its menu bar if Hud shared this seam instead of sitting earlier in the stack. It still loses to the console and the options window, because those are the two surfaces a person raises to do something |
| The widget tree, second band | The console, the options window, the demo panel — everything the player stacked on top | Where layout lives, and where the front-window stack is |
The raw lists are cleared and repainted every frame, exactly like the tree’s emission — nothing retained, nothing to invalidate.
The frame-stats card’s deeper levels are in Tooling, not beside the speedometer, and they are covered while paused. The compact card (the map and the frame rate) is the player’s and sits in Hud. Splitting the band is the only arrangement where both hold: keeping the netcode rows in Hud would paint it through the overlay’s own menu bar, and putting the whole developer band over the screens would fix that but lift the gizmos over the pause menu with it. Nothing here changes when a readout exists — only what covers it, which is the one thing the band model exists to keep separate.
Tooling and Hud are the two bands that move, and a docked game view is what moves them. Everywhere else the world is the frame itself, painted by the renderer before a single widget runs, so a band spliced first still lands on top of it. Inside the editor the world is a picture a screen paints — an opaque image in the editor’s own tree — and a band spliced before that screen is a band the picture then covers. Left unlifted, the position readout and the frame-stats card would vanish the moment the editor opened, even with their preferences on. So when Tooling carries a WorldRect (which is the same fact, and is why it is read from the list rather than passed in — a stack that disagreed with the anchoring about whether the world is a panel would put every readout somewhere unseeable) both it and Hud lift to a point inside that screen: the index the screen marked when it finished painting the picture. Hud is usually empty there in practice — the speedometer’s own gate excludes EditorActive — but the splice rule makes no exception for that, so a HUD surface that ever does draw during editing lands correctly without a second lift rule to maintain.
The mark is a widget, because only the screen’s own builder knows where its order breaks. The tree’s seam can only ever fall between roots, and the editor’s picture and the chrome over it are siblings inside one root — so lifting to the screen seam would raise the band over the editor’s toolbar, its docked panes and its floating windows too. UiBandStack.Compose splices at DrawList.WorldSeam, offset by the bands already inserted ahead of it.
DrawProbe is how a surface learns it can be seen. It wraps a child and calls back on every draw with its box, the part of that box inside every clip in force (DrawList.CurrentClip, already intersected through nested scroll views), and the interface scale. A scroll view lays out content it has scrolled away, so layout cannot say whether something is on screen; the clip a draw is made under can. Not being drawn at all (an unmounted page, a closed window) is a silence rather than a report, so an owner treats “no report this frame” as “not visible”. The options window’s stand-in preview uses it to hold a render target only while its box is on screen.
WorldSeam wraps the picture rather than standing beside it, and that is the whole of the placement. Its element pushes a clip of the picture’s rect, draws the picture, marks, and pops — so the band lands immediately past the picture and inside the picture’s own scissor. Ordering alone can never state this: a pane’s header is built before its body, and a sibling pane the dock solver placed first is painted before the whole pane, so both are earlier in the stream than any mark taken after the picture. Marking beside the picture instead of inside its scissor would put gizmos over the tab strip and over whatever is docked beside the view. The clip does not care what order the layout solved in. A childless seam marks nothing at all, because a boundary between a picture and what covers it does not exist where no picture was painted.
A mark taken inside an open layer is still refused rather than recorded — splicing there would hand the band a fade from a subtree it knows nothing about — so while the editor animates in under its transition layer no mark is taken and the band falls back to the screen seam for those frames: visible over the chrome briefly, which is the better side to fail on than invisible under the picture. An open clip is the opposite case and is permitted, because inheriting one is the point. Landing inside an enclosing clip stays sound for the reason Splice already requires: both lists must be closed, so the run inserted is itself balanced and every push in the stream keeps the pop it had.
The four raw bands are not separate recordings, and they could not be: the blur composite plans index ranges into one list before the frame is recorded, so another list handed to the backend would have no plan covering it. Instead UiTree.Draw(list, bandRoots) reports the seam index after the first bandRoots roots have painted, and DrawList.Splice(index, other) drops each band’s commands in at it — one list, one plan, one order. The whole order is four calls in UiBandStack.Compose, kept out of HalcyonLayer so it can be asserted without a Vulkan device: tooling at index zero, the HUD right after it, the crosshair after both, then the chrome at the screen seam plus all three of their command counts. Each offset is the preceding bands’ own counts rather than the tree’s growth, because a splice of an empty list is a no-op that still has to leave the next one landing where it belongs — an empty band must never push a later one past the windows, and that is the ordinary case rather than an edge one, since three of the four are empty in a plain frame of play. Every band must have its clips and layers closed for a splice to be legal (commands are absolute and pre-snapped, so moving them is sound, but a push whose pop sat on the other side of the seam would nest the frame wrong), and the layer’s ComposeBands is idempotent so Prepare and Record can each ask for it.
The crosshair’s placement is defense in depth, and both halves are load-bearing. A second recorded pass after the tree would make “no developer surface covers the reticle” a fact about the stack, but leave “no reticle lands on a pause menu” a fact about a flag alone — ClientUiState.CrosshairVisible, true only while the state is Playing with no console and no options window over the scene — one regression in that predicate away from a crosshair painted on a menu. Splicing the band under the screens instead makes both facts structural, and it costs nothing during play: no screen is mounted then, so the reticle is still the last thing in the frame.
The gate stays exactly as it was, because the two answer different questions. The gate decides whether a reticle is drawn: with the pointer freed nobody is aiming, so the claim it makes is false and the mark leaves the screen entirely — no band placement can express that, since a reticle under a translucent scrim is still a reticle. The band decides what covers it if it is drawn. Composing a chat line is the deliberate exception to the gate, because the cursor stays locked and the world keeps running underneath. The speedometer sharing this neighborhood does not share the strict rule: it is gated only on there being a world, and lets the stack decide what covers it.
The developer overlay is a second tree, not a second painter
Section titled “The developer overlay is a second tree, not a second painter”DigitalHeaven.Engine.Client.Overlay is the DigitalHeaven overlay, and it is native Halcyon widgets — the engine is the reference implementation of that design, and a future Unity host and a UGUI compile target are modeled against what is here.
The developer overlay does not go through a draw-list port: reimplementing the overlay’s immediate-mode DrawContext on top of DrawList (as HalcyonDrawContext would) bypasses the widget tree, the constraint solver and the element diff entirely — no scrolling anywhere (overflow clipped, rows past the edge unreachable), no gradient, and a control’s state living in a static dictionary keyed by a string the caller invented. DrawContext and its Unity backend stay alive for the game mods, which still register ConfigWindow through Unity’s IMGUI; nothing engine-side speaks it.
The overlay owns a UiTree of its own. It has to: the overlay records into UiBand.Overlay, a list spliced into the player interface’s tree over the menus and under the console and the options window, and a tree reports exactly one seam. Two trees, one band each, and the layering stays a property of UiBandStack.Compose rather than a rule two surfaces have to keep agreeing on.
EngineOverlay.Render takes one OverlayFrame — the draw list, the font metrics, the display size, the pointer, the wheel and the interface scale — and that parameter is the whole reason the overlay is testable. There is no device in it, no window, no context: a test constructs a bare DrawList, a deterministic IFontMetrics, and drives real frames, then reads the commands back.
| Piece | What it is now |
|---|---|
| Menu bar | A Positioned row of item boxes with a Space between the left and right groups. Right-aligned items are laid out from the right edge inward, so the first one declared is the one nearest the close box and the list is walked backward. An item’s box is round(measure(label) + 2 × client.overlay.barItemPadding), and its label is centered in that box by measurement — see below. The row sits inside the bar’s own border, so an active item’s fill cannot paint over it. |
| The shadow band | One Box with a GradientFill, black at client.overlay.shadowOpacity fading to nothing over client.overlay.shadowHeight, flipped to ToTop for a bottom bar. Unity generates a 1×32 texture for this; the engine never had it at all. It spans the whole display whatever the bar’s own alignment is — it is the shadow the bar casts, and a shadow does not stop where the object above it is inset to. |
| Windows | A Positioned chrome box, and inside its border a DragSurface title bar, a 1 px separator, a padded body, plus Ui.ResizeHandles in a second Positioned one overhang up and to the left. Every gesture goes through WindowGeometry.Apply against an anchor captured once — the same arithmetic, and the same caution, as the console and the options window. |
| Toasts | Positioned boxes under their own Layer opacity, sliding to a new slot over client.overlay.toastSettle instead of jumping a whole row when one below them is dismissed. |
| Controls | Button, Dropdown, Switch, Slider, ScrollView and a track-and-fill progress bar, from OverlayControls. |
One theme, and what is allowed to follow the accent
Section titled “One theme, and what is allowed to follow the accent”OverlayTheme is resolved once per frame out of the preference store — a value, not a bag of mutable properties five window implementations each hold a reference to. That is what killed the duplicated hex literals.
Selection follows the accent; meaning does not. An active menu toggle, a chosen row and a control’s fill take a tone off ThemePalette, so client.ui.themeColor moves the overlay with the rest of the interface. Success, error, warning, attention and progress are fixed, for the same reason Danger is not seeded: information that changed color because somebody picked a green interface would be a lie told by the theme. A test pins each half against the other.
Every metric is a client.overlay.* preference — the bar’s height, font and item padding, the band’s height and opacity, the window defaults and minimums, the cascade, the fades, the toasts — so the whole design is tunable from a console line rather than from a rebuild. The colors are not: they are either semantic or derived, and both of those are answers rather than settings.
Three STRENGTHS are the exception, and they are not colors. activeItemStrength says how far an active menu item’s fill travels from the bar’s surface toward the accent’s container tone; hoverStrength and pressStrength say how much of the white pointer tint a surface takes. Which color is used stays an answer — the accent’s own ramp, and white — while how loud it is is exactly the kind of number that has to be judged by looking at it, which means it has to apply on the next frame. A fixed number is wrong for all three: at full strength three open windows would tint the whole bar instead of marking three items, and a hover tint that loud would drown out the active fill it needs to sit quietly beside.
The chrome wears the player windows’ grays
Section titled “The chrome wears the player windows’ grays”The overlay’s neutrals are HalcyonSettingsTheme’s, tone for tone: the body is the options window’s surface, a title bar is its card, the outline is its border, the line under a title is its hairline, a zebra stripe is its hover gray, and the ink ramp is its text, muted and disabled. The overlay is a different product from the player’s interface and may read as more utilitarian than it — a dense bar of tooling over an arbitrary scene — but not as an unrelated one, which is the same argument HalcyonConsoleTheme already makes for borrowing the same grays. A test asserts the identity, so retuning one side without the other fails the build rather than drifting quietly.
Every one of them is opaque. A translucent white at a low alpha reads as much lighter than it looks on paper, because linear-light blending makes a small alpha much larger in practice: an alpha of 0.30 measures sRGB 149 on a rendered frame — a mid gray outlining a window whose body is 13 — and a “hairline” under a title bar, at 0.12, measures 99. An alpha also means the outline is a different color over every scene the overlay is opened on, which is exactly what a border must not be.
A label in a fixed box is centered by measurement
Section titled “A label in a fixed box is centered by measurement”Box lays its child out against loosened constraints, so a Row with JustifyContent.Center inside a fixed-size box shrink-wraps to its content and is then placed at the box’s top-left corner: there is no slack for the justification to distribute and both alignments silently do nothing. That is what put every menu item’s label hard against the corner of its own box. OverlayChrome.CenteringInset splits the slack on both axes into a padding instead — the same trick WindowCloseButton.GlyphPadding uses — and takes the vertical extent from the font’s line height, never from half the font size, which coincides with the line box at exactly one ratio and is wrong at every other.
They are photographed, not just asserted
Section titled “They are photographed, not just asserted”Because the developer surfaces are now plain draw commands, the offscreen renderer can composite them, which was structurally impossible while they went through a context that only exists next to a live window. UiCaptureTimeline.Developer walks the HUD one level at a time — hudStats, hudGizmos, hudSignal — so consecutive images differ by exactly one level, then brings the overlay up and shoots six frames of it: overlayBar (the bar, its gradient band and the wordmark over the backdrop, with nothing open), then one image per window archetype — overlayList for the scrolling list with its zebra rows, two-line items, inline actions and per-row bars, overlayForm for the labeled dropdowns, switch and sliders, overlayInfo for the key-and-value panel with its pinned footer action — then four frames with a pointer on them — overlayBarHover and overlayBarPress over a menu item, overlayControlHover and overlayControlPress over a button inside a window — then overlayWindows with all three cascaded, which is the only frame that photographs the placement and the z-order together, and finally overlayToasts after the fade out, which is the picture of the notification queue being outside the fade.
The pointer frames are aimed by KEY into the overlay’s own tree (EngineOverlay.Aim, over the shared UiCaptureAim), because the host’s other aiming resolves against the player’s tree and the overlay is a second one. They exist because hover and press were the only part of the chrome no still could show, so they were the only part nobody could judge — and the first pictures of them promptly showed a hovered item painting brighter than an item that was actually on, which is what turned client.overlay.hoverStrength and pressStrength into preferences at a tenth of their old values. UiCaptureTimeline.HudLayering does the opposite: it holds the readout at one level and moves the screens around it — hudOverPauseMenu, hudUnderConsole, hudOverMainMenu — so the band’s position is a picture rather than only an assertion. The labels predate Hud landing under the screens and are kept because the frames are the same frames; what hudOverPauseMenu photographs now is the frame-stats card dimmed under the menu’s scrim. UiCaptureTimeline.Speedometer shoots the player-facing readout on the same terms — speedometer0…speedometer3 across a stand, a walk, a sprint and a fall, then speedometerOverMenu and speedometerUnderConsole — and now makes the same claim rather than the opposite one, since Tooling and Hud land at the same seam: both the frame-stats card and the speedometer are dimmed under the pause menu’s scrim. Photographing both is what makes that shared placement visible rather than only asserted. See offscreen rendering.
Project layout
Section titled “Project layout”| Project | Contents |
|---|---|
Halcyon/Halcyon | The whole system: Primitives, Style, Layout, Text, Widgets, Elements, Input, Drawing, Animation, and Docking (the dock brain). No dependencies at all. |
Halcyon/Halcyon.Fonts | FontAtlas (stb_truetype), FontLadder and the embedded JetBrains Mono faces. CPU only. |
Halcyon/Halcyon.Graphics.Vulkan | The Vulkan device core: GraphicsDevice, the allocator and buffers, Swapchain and OffscreenTarget, readback, and ShaderLibrary. |
Halcyon/Halcyon.Vulkan | HalcyonRenderBackend (Vulkan), HalcyonBlurTargets, HalcyonBlurPlan and HalcyonBlurGeometry (the offscreen layer composite), TextAntialiasing (the degradation predicate, the mask correction and its CPU mirror of the shader), UiNoiseField, PanelTargeting and PanelRenderTarget. |
Halcyon/Halcyon.Vulkan/Shaders | halcyon.vert, halcyon_sdf.glsl, halcyon_rect.frag, halcyon_texture.frag, halcyon_text.glsl (the shared stem-darkening and mask correction), halcyon_text.frag, halcyon_text_subpixel.frag, halcyon_blur.frag, halcyon_composite.frag, halcyon_line.vert/.frag, halcyon_poly.vert/.frag. |
Halcyon/Halcyon.Tests | xunit coverage of the solver, constraints, wrapping, reconciliation, layout, hit testing, draw emission, pixel snapping, curves, transitions, gradients, focus traversal, the text editing model, dragging, popups, list virtualization, the input controls and the dock brain (tree, solver, drop resolution, layout spelling, keyed tabs, divider grab). |
Engine/DigitalHeaven.Engine.Client/Ui | HalcyonLayer (per-frame driving, texture registration), HalcyonKeyMap and WindowClipboard (the keyboard and clipboard bindings), WindowMinimums (the engine windows’ floors for WindowGeometry), HalcyonSettings, HalcyonDemoPanel, HalcyonSettingsScreen, HalcyonMenuScreen, HalcyonTextSpecimen and HalcyonConsoleScreen with their themes, ConsoleViewModel and the console’s model types, MenuLinks, SettingsSearch, SliderValueFormat, TonalRamp, UiIcons and UiIconFiles (the icon registry and its mask loader), BrandWordmark and CrosshairPreviewShapes — the player HUD’s own painters, SpeedometerOverlay with its pure SpeedometerLayout and CrosshairOverlay with its pure CrosshairGeometry, each declaring the UiBand it records into — and the developer painters that go straight to the draw list: PerformanceOverlay, PositionReadoutOverlay, AxisGizmoOverlay, EntityGizmoOverlay, ScenePivotGizmoOverlay and SoundCueOverlay. |
Engine/DigitalHeaven.Engine/Preferences/UiPreferences.cs | The client.ui.* keys. They live in the engine assembly because that is one of the three the preference registry scans. |
Engine/DigitalHeaven.Engine/Preferences/ConsolePreferences.cs | The client.console.* keys: the placement, the two line-rendering toggles and the card’s geometry. |
Engine/DigitalHeaven.Engine/Preferences/HudPreferences.cs | The client.hud.* keys: whether the speedometer is drawn and which motion it measures. |
Engine/DigitalHeaven.Engine/Preferences/OverlayPreferences.cs | The client.overlay.* keys: the developer overlay’s metrics, durations and thresholds. |
Engine/DigitalHeaven.Engine.Client.Overlay | The DigitalHeaven overlay, as widgets: EngineOverlay (the tree, the fade and the gestures), OverlayFrame, OverlayChrome and OverlayChromeModel (the bar, the band, the wordmark, the windows and the toasts), OverlayTheme, OverlayControls, OverlayWindow/OverlayWindowState/OverlayWindowSet, OverlayToastStack, OverlayContent, the three archetypes (OverlayAssetsWindow, OverlayTuningWindow, OverlaySessionWindow) and OverlayLogBridge. It references Engine.Client for the pieces it shares with the player interface rather than copying them: ThemePalette/TonalRamp, BrandWordmark and WindowCloseButton’s glyph, and uses Halcyon’s own WindowGeometry. |
The libraries under Halcyon/ carry no DigitalHeaven name and reference nothing in Engine/, Toolchain/ or Content/, so an app outside the engine (the XR overlay) takes them as they are. The library family draws the graph.
The library family
Section titled “The library family”Halcyon itself is reference-free and renderer-agnostic: its output is the device-independent DrawList. Everything that touches a GPU, a font file or a headset is a library of its own beside it, and the engine consumes them rather than the reverse.
Halcyon (reference-free: widgets, layout, DrawList, TextAa, WindowGeometry) ^ ^ ^ | | | Halcyon.Fonts | Halcyon.Xr (pure placement math) (StbTrueTypeSharp) | ^ ^ | | | | | Halcyon.Vulkan ----+ Halcyon.Xr.OpenXr -+ (backend, halcyon* shaders) (Silk.NET.OpenXR, vendored loader) | | v v Halcyon.Graphics.Vulkan <-----+ Halcyon.Xr.OpenVr (reference-free) (Silk.NET.Vulkan device core)
Halcyon.Graphics.ShaderCompiler: build-time only, through Shaders.targets (imported by Halcyon.Vulkan and DigitalHeaven.Engine.Client)| Library | References | What it holds |
|---|---|---|
Halcyon | nothing | The UI system, plus TextAa (the text antialiasing ceiling) and WindowGeometry (the drag arithmetic every movable window uses). |
Halcyon.Fonts | Halcyon, StbTrueTypeSharp | The glyph atlas, the size ladder and the fonts. |
Halcyon.Graphics.Vulkan | Silk.NET.Vulkan | The device core: one device-description path for every host. |
Halcyon.Graphics.ShaderCompiler | Silk.NET.Shaderc | The build tool and Shaders.targets. |
Halcyon.Vulkan | Halcyon, Halcyon.Fonts, Halcyon.Graphics.Vulkan | The draw-list backend and its shaders. |
Halcyon.Xr | nothing | View angles, frustum tangents, panel placement. |
Halcyon.Xr.OpenXr | Halcyon.Xr, Halcyon.Graphics.Vulkan, Silk.NET.OpenXR | The OpenXR runtime, discovery, frame loop and session ladder. |
Halcyon.Xr.OpenVr | nothing | The OpenVR binding. |
What stayed in the engine is engine composition: HalcyonLayer, UiLayer and the bands, the themes that read UiPreferences, UiPanelPass (the quad in the eyes), HalcyonKeyMap, ClientGraphics (the client’s options, log and renderer-specific device answers over the device core), ClientShaders (the client’s shader library with its development reload path), and the F8 toggle, eye renderer and room body of the VR path. The halcyon* stages are no longer in the engine’s shader reload tree: they belong to Halcyon.Vulkan and take effect after a rebuild.
What is left
Section titled “What is left”- IME composition, using the range the value model already reserves.
- UI scaling and transforms above the element tree.
- A stylesheet-and-selector authoring layer feeding the same resolved structs.