GG-UI — Core
The GGUI library: storage, the tree, authoring, bindings, layout, published snapshots, and the data-driven widgets built on them. It has no engine dependency (enforced by CMake) and runs headless. Roadmap and status are in Design; pixels are in Rendering, input in Interaction, looks in Styling.
Authoring
Requirements
| # | Requirement | What it rules out |
|---|---|---|
| 1 | Code is shaped like the tree it builds | Structure hidden in method order; child lists built far from their parent |
| 2 | No macros in authoring code | Declaration, argument-block, and slot macros |
| 3 | One obvious way to set a property, add a child, handle an event | Parallel styles for the same thing |
| 4 | A widget is a function. A new widget needs no class and no registration | A class + argument struct + construct method per widget |
| 5 | Plain C++ control flow builds dynamic UI | Special "conditional" and "repeat" nodes as the only way to branch |
| 6 | Events are lambdas | Delegate types, binding helpers, manual unbinding |
| 7 | Looks are not in code. Code names a style class | Colours and paddings scattered through panel code |
| 8 | Errors point at the mistake, on the line that wrote it | Template and macro expansions in diagnostics |
| 9 | Cheap to compile: one small header, a non-template core in .cpp files | Header-heavy widget templates |
| 10 | Debuggable: a breakpoint on a widget line works; the inspector maps a node back to its line | Deferred builders with meaningless stacks |
| 11 | One system under every authoring surface (designer tools, reflection-generated UI) | A second widget set for generated UI |
| 12 | Scales down and up: ten widgets or a million rows use the same calls | Separate "simple" and "virtualised" APIs |
Syntax
A scoped builder with two forms, each with one job.
// The function is the row: it opens the row's scope. void HealthRow(UI& ui, EntityHandle entity) { if (auto row = ui.Row({ .Gap = 8, .Align = GGAlign::Center })) { ui.Label("Health"); ui.Slider({ .Min = 0, .Max = 100, .Grow = 1 }, Field(entity, &CHealth::Value)); ui.Button("Reset", [=] { ResetHealth(entity); }); } } // Lambdas describe content, and how each piece of data is laid out. void Inspector(UI& ui, Selection& selection) { if (auto panel = ui.Panel("Details")) { ui.List(selection.Entities(), { .RowHeight = 22 }, [&](EntityHandle entity) { ui.Key(entity); // identity survives reordering ui.Row({ .Gap = 8 }, [&] { ui.Label(NameOf(entity)); ui.Badge(TypeOf(entity)); }); }); } }
| Scope object | Lambda | |
|---|---|---|
| Job | The element this function builds | Content passed to an element: children, slots, per-item layout |
| Control flow | The enclosing function's | The lambda's own |
| When it runs | Now | Now, or later (a tooltip, a menu, a tab page, a list row: built only when shown) |
| Re-run | With its function | On its own: a stored lambda is a unit the core can re-run |
Rules:
- Every container offers both forms from one implementation, producing the same nodes with the same identity.
- A scope object must be bound to a name. Containers are
[[nodiscard]], so a bareui.Row({…});warns. - Content that runs now is taken by a non-owning reference and may capture locals by reference (
[&] { ... }). Content that runs later is stored and must capture by value ([=](UI& ui) { ... }). - Small trivially-copyable captures are stored inline in the node (32 bytes for a handler, 48 for a component function). Anything else (a string, a vector, a larger capture) goes in a pooled block the node holds. The author writes the same lambda either way.
- A component function with plain captures is compared with the previous run's and skipped when equal. One capturing something that owns memory can't be compared and re-runs with its parent. To make it skippable, pass its data as properties:
ui.Component(&Function, properties).
Declaration order, for every element: subject (a label, an image, options, a source), then props, then what it shows, then what a change or click does. Props can always be omitted. Text is always a GGText. State the caller owns is what the element shows and does (a ui.State writes itself); where it is a place the element writes and the caller saves (a split's ratio, a table's widths), it is a pointer.
Shorthand
using namespace GGUI::StyleKeywords; if (auto bar = ui.Row({ .Width = Grow, .Height = 32, .Gap = 6, .Padding = 8, .Align = Center })) { ui.Label("Layers", "muted"); // a class where the props would be ui.Spacer(); ui.Button("Add", "primary", [] { AddLayer(); }); }
| Written | Means |
|---|---|
.Width = 84 | GGSize::Fixed(84) |
.Width = Grow, Grow(2), Fit, Percent(50) | the GGSize of that name |
.Padding = 8 | GGEdges::All(8) |
.Align = Center, .Overflow = Scroll, ... | the matching enum value |
ui.Row("toolbar"), ui.Label("x", "muted") | the same with { .Class = … } |
ui.Spacer(), ui.Spacer(2) | a box that grows |
ui.Key(name) | a key from the name's hash |
The keywords live in GGUI::StyleKeywords so everyday words (Start, End, Fit) don't collide with user code. A sheet can also set size and alignment (Styling); an element takes them only where the builder set none.
Deferred Content
A box can build its content after the frame it appears in.
ui.Stack({ .Class = "slot", .Priority = 1 }, [item](UI& ui) { ui.Image(IconOf(item)); ui.Label(CountOf(item)); });
Priority = 0 (default) | Built with the box |
Priority = 1 and up | Built afterwards, lowest number first; among equals, visible before hidden, then document order |
Wait = n | Built no sooner than n updates after the box, regardless of budget |
| Budget | UIContext::SetBuildBudget (ui.build_budget_ms, 0.5 ms by default under the engine). An update with nothing else to run builds at least one |
| No budget set | Everything is built in the same update, in priority order. Results never depend on machine speed |
- Priority orders work; it doesn't force a delay. With time to spare the whole screen is in its first frame.
- Deferred content is a component: it re-runs on its own and is skipped when unchanged.
- Give the box a size. A box sized by its content has none until the content is built.
- A priority on content built immediately does nothing and is warned about.
- Measured (
GGUI_Scale_AScreenInOrderOfPriority, 2,000 slots, 8,052 nodes): all at once, the first frame is 5.4 ms. With a 0.5 ms budget the first frame is 1.3 ms and the rest arrives over four frames of at most 1.8 ms.
Not yet: priorities on list and tree rows; a placeholder while content waits; popups build all at once.
Alternatives Rejected
- Value tree (
Row{ .Children = { ... } }): no inline branching or loops, every node is a copied temporary, and a breakpoint lands on one giant expression. - Fluent chain (
Row().Gap(8).Add(...)): structure lives in parenthesis depth, conditionals break the chain, every property becomes a method on every widget. - Markup as the primary format: not the foundation. A simpler layer for non-engineers sits on top instead.
The Layer Above
Designers make widget assets: data built at runtime through these same calls, edited in the designer (Widgets). This requires that every widget and property be reflected.
How UI Updates
Two mechanisms, each with one job. Using the wrong one works and is merely slow.
| Component re-run | Binding | |
|---|---|---|
| Answers | What elements exist? | What does this element show? |
| Use when | Data decides which elements there are | An element shows a value that changes |
| On change | The function runs again; the result is matched to existing nodes by identity | The one property is re-read and its node marked dirty. No function runs |
| Cost | A call and a reconcile of that subtree | One read and one compare |
void Inspector(UI& ui, Selection& selection) { ui.Watch(selection); // structure: re-run when the selection changes if (selection.Empty()) { ui.Label("Nothing selected", "muted"); return; } for (const FieldInfo& field : FieldsOf(selection.Type())) { ui.Key(field.ID); PropertyRow(ui, selection, field); // values inside are bound, not rebuilt } }
- Identity is the source line plus
ui.Key(x)or, without one, the count of elements that line has built under the parent. It keeps focus, scroll position, and local state on the right node across a re-run. - Local state (
ui.State<T>(initial)) lives with the node, not the function. - A watch covers what is inside. Stored content (tabs, split panes, kept boxes, popups) follows what the components around it watch, so one
ui.Watchat the top of a panel keeps all of it current. A dock panel is a page of its own and doesn't follow the dock's watches. A component re-run by its own state still skips stored content whose captures are unchanged. A part that changes more often than its panel watches its own revision. - Idle is free. A component re-runs only when something it, or something around it, watches changed; a binding is re-read only when its source did.
- Deterministic mode: re-runs and binding reads happen in node order at a defined point of the fixed step.
Bindings
A binding is data by default: an expression over fields with a closed operation set (arithmetic, compare, min / max / clamp, lerp, normalise, select, number formatting). Being data, it can run on the CPU or compile for the GPU, and dirty tracking knows exactly which fields it reads.
| Kind | Example | Runs on |
|---|---|---|
| A reflected field | Field(&CHealth::Value) | CPU or GPU |
| An expression | Field(&CHealth::Value) / Field(&CHealth::Max) | CPU or GPU |
| UI or tool state | GGBind(settings.Volume) | CPU |
| A lambda (escape hatch) | GGBind(entity, [](const Frame& f, EntityHandle e) { … }) | CPU only |
ui.Label(binding, format)formats a number (decimals, prefix, suffix, notation). The node is laid out again when the text changes, so a template refuses it.- Big numbers. A
BigNumberfield is bound as aBigField: as text, as a fraction of another (a bar), or compared with another. The arithmetic is done inBigNumber; only the result is a float. CPU only. - Exact values. A value widget bound to a single field reads the field's own bits (
BindingReader::ReadBits), not a float.
Rules that follow from threading and rollback:
- A binding reads the published frame, never a live Construct. A value the UI needs belongs in a component.
- The target is a handle, resolved on each read. A dead target reads as "no value".
- Bound UI is correct across rollback and time travel: the frame on screen is the frame that was read.
- Writes never go through a binding. A widget issues a command (game) or a
FieldEdit(editor).
Not yet: colour ramps; fixed-point expressions.
Fields and Editors
Details views are generated from reflection. The core doesn't see the engine's reflection: the integration layer hands it a FieldSource (whose data, which type, and a function that enumerates fields as FieldInfo).
ui.Fields(source); // a keyed row per field: name and editor ui.Fields(source, [&](const FieldInfo& field) { ... }); // rows laid out by hand ui.Editor(source, field); // the widget registered for field.Type
EditorRegistrymaps a field type to its editor, with per-field overrides.UIHost::holds the built-in ones (Editors() AddDefaults) and whatever a game or plugin registers.- A type with no editor shows its nested fields. One with neither shows a placeholder and is counted in
BuildStats::MissingEditors. - Editors never write data. They tell the source (
FieldSource::SetValuewith the edit phase,SetText), and show what the source reports afterwards. - Built-in editors: number (or slider), toggle, text, vector, colour, item picker.
The engine's entities (EntityFields.h):
EntityFields fields(world, entity); // one category per component, a field per reflected field ui.Properties(fields);
- Values are read through the host's binding reader at the world's published read frame. A field edits in its own type (
Fixed32staysFixed32). - An edit is a
FieldEditthe Brain applies. In the editor it goes through the edit sink: Begin to Commit is one undo step, and Cancel puts the starting value back. With no sink it is submitted to the world. - A binding target is the entity's handle with the world's slot in its spare top byte, so a copy of the source stays valid; a world's slot is freed at shutdown and its targets then read as gone.
- The engine's flags component isn't listed.
Not yet: hints on engine fields (TNX_FIELD_HINTS, below); asset references as item pickers; Constructs' own fields.
Hints
How a field is edited is declared with the field, not by writing another editor (UIHints.h).
Hints::Min(0), Hints::Max(100), Hints::Decimals(1), Hints::Units("m/s") // on a field const int places = field.Get(Hints::Decimals, 2); // in an editor namespace WeaponHints { inline constexpr GGHint<float> Recoil{ "Recoil", "How far a shot kicks, in degrees" }; }
- A hint is a declared constant with a value type and a description. A misspelt hint or a wrongly typed value doesn't compile. Games declare their own in their own namespace.
- An editor lists the hints it reads when it registers. A hint its editor doesn't read, or two that contradict (min above max), is reported with the field's name and counted (
BuildStats::HintProblems). - Built in:
Min,Max,SoftMin,SoftMax(what a drag reaches; typing can go further),Step,Decimals,Units,Slider,Multiline,Password,Wrap,Opaque,List,Class. UIHost::and the console'sDescribeHints() ui.hintslist every hint some editor reads.
Planned for the engine side: TNX_FIELD_HINTS(CWeapon, Recoil, Range(0, 20), Decimals(1), Units("deg")), written after the field's declaration, which fails to compile unless the field is reflected and each hint fits its type.
Values in Their Own Type
ValueService<T> (UIValueService.h) is the arithmetic of an edit (step, clamp, snap, format, parse), written once and instantiated for bool, int32_t, int64_t, float, double, and Fixed32. Integers and Fixed32 use integer arithmetic throughout. The core stores every value widget's value as bits plus the service for its type, and never interprets it.
ui.Numbertakes its type from what it shows; for a binding, useui.NumberOf<T>.- A handler receives the value in its type:
OnCommit([](Fixed32 length) { ... }). - A source is told a
GGValue:value.As<Fixed32>(). - Expressions are still float, which is what a bar or fraction needs and what runs in a GPU template.
Value Widgets
| Widget | Notes |
|---|---|
ui.Number | Drag sideways to change by its step (within soft limits, 10x with shift). Click or Enter to type (within hard limits). bButtons adds step buttons |
ui.Slider, ui.Toggle, ui.Dropdown, ui.Choice | In the type of what they show. DropdownProps::bSearch adds a filter field |
ui.Vector | Components as numbers, one handler told which changed. bWrap with both limits makes a rotation. ui.VectorOf<Fixed32> for another type |
ui.SearchField | A line of text and a clear button; Escape clears |
ui.Picker | One item from a source: a button that opens a searchable, virtualised list. Accepts a row dropped on it. A search scans 50,000 items and lists 2,000 |
ui.Swatch | A box whose colour is data, optionally a gradient |
ui.Color | A swatch that opens a picker: saturation / lightness square, hue bar, alpha bar, hex |
Data Sources
Presentation widgets take a GGDataSource and neither own nor copy the data. The source is a struct of functions the integration layer fills in (children, parent, columns, cells) plus a Structure revision bumped when items come, go, or move.
Two independent choices:
| Choice | Made by | Example |
|---|---|---|
| Shape on screen | Which widget gets the source | Tree, table, or tiles of the same entities |
| Relation shown | Which source the widget gets | Entities by attachment, by owner, or by prefab |
Selection is an ItemSet keyed by ItemID, so every view of the same items shares it.
List and Tree
ItemSet selection; ui.List(source, { .RowHeight = 22, .Selected = &selection, .Select = GGSelect::Many }, [=](UI& ui, ItemID item) { EntityRow(ui, item); }); ui.Tree(source, { .Toggled = &open, .RowHeight = 22, .Selected = &selection, .Sort = { .Column = 0 } }, [=](UI& ui, ItemID item) { EntityRow(ui, item); });
- One widget is both (
ItemWidget): a list is a tree with one level. Rows are keyed by item. A tree's rows are siblings at every depth: depth is an indent, not nesting. - Virtualised with a row height. Only visible rows are built. Without one, every row is built at its content height.
- Scroll position is a row index plus an offset within that row. The offset is the box's own scroll, so moving within a row writes one value and builds nothing. Nothing is ever positioned at a coordinate that grows with the item count, so the millionth row is as exact as the first in either numeric type.
- Row content, stored or not:
| Written as | When other rows are built |
|---|---|
[&](ItemID item) { ... } | The declaring function re-runs. Fine when that is the list's own component |
[=](UI& ui, ItemID item) { ... } | The rows rebuild on their own |
- Selection: press selects; with
Many, ctrl toggles and shift selects a range. Arrows, page keys, home and end move; shift extends; ctrl+A selects all. A selected row is:checked. - Tree keys: right expands, then goes to the first child; left collapses, then goes to the parent.
- Sort by a column: numbers before text, ties in source order, per parent in a tree.
- Type-to-find: typed text jumps to the next row whose first column starts with it. Characters within a second of each other form one prefix; repeating a letter cycles. It uses text input, so it works in any keyboard layout and script.
- Scroll bars drag, on lists and on any scrolling box.
- It follows its data: structure, expanded set, and selection are watched by the widget.
- Measured (
GGUI_Scale_AMillionRows, 49 visible): built in 0.28 ms; a frame scrolled within a row 0.003 ms, past a row 0.05 ms.
Not yet: variable row heights when virtualised; the log view on this core; the graph shape.
Tiles
ui.Tiles(assets, { .TileWidth = 96, .TileHeight = 112, .Gap = 8, .Selected = &selection }, [=](UI& ui, ItemID item) { ui.Image(ThumbOf(item)); ui.Label(NameOf(item)); });
The list widget with a line of tiles where a list has a row. Only visible lines are built. The column count is worked out when it gets its size; on resize the top item stays in view. Not yet: variable tile sizes.
Table
GGSort sort; ui.Table(source, { .RowHeight = 22, .Columns = { { .Column = 0 }, { .Column = 2, .Width = 90, .bResizable = false } }, .Selected = &selection, .SortBy = &sort }); ui.Table(source, props, [=](UI& ui, ItemID item, uint32_t column) { ... }); // cells laid out by hand
- A header over a list (
TableWidget). Rows, selection, keys, and scrolling are the list's. - Columns are
GGSizes that layout resolves, the same in the header and every row, so the table measures nothing and a resize runs no function. - Click a heading to sort (ascending, descending, source order). Drag its edge to resize (minimum 24). Order and widths go to the caller's
GGSort/Widths, or are kept by the table. - The header and rows are each a component: sorting or resizing rebuilds those two only.
bTreeputs a tree's toggles and indents in the first column.- Editable cells, where the source allows (
ColumnInfo::bEditable):
| The source has | The cell |
|---|---|
CellField | Holds the editor registered for that field, always present |
SetCell | Shows its value. Double click, or Enter, swaps in a text field. Enter or losing focus commits; Escape cancels |
- Cell cursor: left and right move between cells; Enter or F2 enters the cell; Escape returns to the rows;
UI.Table.Sort(Ctrl+Space) sorts by the cursor column. - Scrolls horizontally when its columns are wider than it is.
- Classes:
table,table-header,table-heading,table-sort,table-grip,table-body,table-row,table-cell,table-cell-cursor. - Measured (
GGUI_Scale_ATableOfAMillionRows, six columns, 50 rows built): built in 0.83 ms; scrolled within a row 0.011 ms, past a row 0.21 ms; a column drag frame 0.22 ms. Sorting a million numeric rows takes 113 ms, once. A source that large should supply its items in order.
Not yet: Fit columns (treated as Grow); reordering or hiding columns; tab between edited cells; headings reachable by keyboard.
Property Grid
ui.Properties(selection, { .RowHeight = 22, .Filter = filterText, .Toggled = &closed }); ui.Properties({ first, second }, props); // several sources
- A
FieldSource's fields as a table: name and editor per row, grouped under categories and parent fields. - Fields are enumerated when the declaring function runs. What a source reports is only valid during the visit, so the grid copies it and exposes that as a
GGDataSource. Filterkeeps fields whose name matches, with their ancestors and descendants.- Several sources: shows the fields all of them have. Editors read the first; a field they disagree on shows
Mixed. An edit goes to every source as one edit group (FieldSource::Group), so undo reverts them together. - Reset: a field that differs from its default (
FieldSource::Default) has a reset button. - Lists (
Hints::List): elements are rows, with add, remove, and drag to reorder (InsertElement,RemoveElement,MoveElement). Lists of different lengths across sources showMixedand no elements. - Keys: up and down move between fields, Enter enters the editor, Escape leaves it, typing finds a field.
- A press on a control inside a draggable row drags the control, not the row. This holds for every drag source.
Panels
UIPanels.h: what a tool window is assembled from.
ui.MenuBar([&] { ui.SubMenu("File", [=](UI& ui) { ui.MenuItem(OpenScene); }); }); ui.Toolbar([&] { ui.ToolButton(moveIcon, MoveTool, { .bChecked = bMoving }); ui.Separator(); }); ui.Split({ .Direction = GGSplit::Columns, .Ratio = &ratio, .Min = 120 }, [=](UI& ui) { Outliner(ui); }, [=](UI& ui) { ui.Tabs({ .bClosable = true }, tab, [&] { ui.Tab("Scene", [=](UI& ui) { ... }); }); });
- Split: each pane grows by a share of the space the bar leaves, so layout does the arithmetic and a resize keeps proportions without running anything. Dragging the bar writes the shares.
- Tabs: only the chosen tab's content is built. Ctrl+Tab cycles from anywhere inside.
OnCloseadds a close button; the owner removes the tab. - Menu bar: a row of
ui.SubMenus that open below their title. While one is open, the pointer on another title opens that one instead; Left and Right move between them (Right from an item without a submenu). - Toolbar: a row of tool buttons.
- What doesn't fit: a toolbar or tab strip too narrow for its items shows those that fit whole and a
>>button with a menu: a toolbar's lists the hidden buttons, a tab strip's lists every tab. A tab strip keeps the chosen tab shown by starting further along; tabs before the first shown aren't built. Hidden items aren't reached by Tab, only through the menu. Which items fit is worked out after layout from the widths they were laid out at, and the component around the strip is built again when that changes (so on a resize, not every frame). The dock's tab groups work the same way. - A menu item or tool button given an action takes its title, tooltip, and chord from the action and runs the nearest command for it (input actions).
The workbench (--ui-workbench) exercises all of it over a made-up scene.
Not yet: a hidden item's place in the menu for anything but a tool button, separator, or tab (other content in a toolbar is clipped and not listed).
Graph
UIGraph.h. Nodes joined by wires, panned and zoomed: node scripts, and later material and animation graphs.
ui.Graph(view, { .OnMove = moved, .OnConnect = connect, .OnDisconnect = cut, .OnDelete = remove, .Menu = addNodeMenu }, [&] { for (const Node& node : nodes) { ui.Key(node.ID); ui.GraphNode(node.ID, { .X = node.X, .Y = node.Y, .Title = node.Title }, [&] { if (auto row = ui.Row({ .Gap = 6, .Align = GGAlign::Center })) { ui.Pin(node.In, { .Side = GGPinSide::In, .Color = floatColour }); ui.Label("Value"); } }); } for (const Link& link : links) ui.GraphLink(link.From, link.To); });
- Nodes are UI. Anything goes inside a node; a pin is an element in it. Wires are drawn from where layout put the pins.
- The owner keeps the data. The graph reports a gesture once, at its end (a move at release, a wire when it is dropped), so each is one undo step in the owner's history.
CanConnectcan refuse a wire while it hovers. - The view is the owner's (
GGGraphView): pan, zoom, selection, and where the menu was opened. The graph writes it as the user acts, so each open graph keeps its own. - Gestures: a box on the background selects (Shift adds, Ctrl removes); a press on a node selects it and a drag moves the selection, the pressed node landing on the grid; a drag from a pin draws a wire; Alt+click on a pin cuts its wires; Ctrl+drag from a pin picks its wires up, to drop on another pin or cut on empty space. Right or middle drag pans, the wheel zooms about the pointer, a right click opens
Menu. - Keys are actions (
GGGraphKeys): delete, duplicate, copy, cut, paste at the pointer, select all, frame (Home or F), deselect. - Cost: the nodes are in an
Overflow::Panbox, so pan and zoom are paint-only; the GPU moves and scales them. Grid and wires are redrawn into canvases after layout, in the document's own units, so lines stay a pixel wide at any zoom.
Not yet: comments and reroute nodes, a minimap, dropping a wire on empty space to open the menu, text sharp when zoomed in past 1 (the default MaxZoom: glyphs are rasterised once).
Docking
UIDock.h. Panels the user arranges into splits and tab groups by dragging tabs. One GGDockSpace covers every window; the host owns it (UIHost::).
inline constexpr GGPanel Outliner{ "Editor.Outliner", "Outliner" }; dock.Panel(Outliner, [](UI& ui) { OutlinerPanel(ui); }); dock.SetDefault(GGDock::Columns({ { 0.25f, GGDock::Tabs({ Outliner }) }, { 0.75f, GGDock::Tabs({ Viewport, Log }) } })); ui.Dock(dock); // in the window's document
Dragging a tab
| Dropped on | Result |
|---|---|
| A tab strip | Joins that group |
| Another tab | Inserted before it |
| Edge of a panel | Splits the group on that side |
| Middle of a panel, or outside every window | Torn off into its own OS window at the pointer |
The same targets work in another dock window. Floating doesn't depend on there being free desktop to drop on, so it works with a maximised window.
A group that loses its last panel collapses. A floating window that loses its last panel closes.
Maximize(panel) shows that panel's tab group alone in its window, until Maximize(0) or a layout is applied. It isn't saved. Pointed() is the front panel of the group the pointer was last over, for a command about "the
panel under the pointer".
Layouts
- Named:
SaveAs(name),Use(name),Layouts(),Reset(). Defaultis the layout set in code. It is always listed and can't be overwritten.Save()/Load()write every layout as hand-editable text. The host keeps it inLayout.ini(UIHost::) and writes once a change has settled.SetLayoutFile - Switching layouts moves and resizes open floating windows to the layout's saved values.
- Panels are referenced by name. An unknown name is kept and shown once that panel is registered.
Floating windows
- Always real OS windows, each with its own document. The host opens and closes them to match
FloatingWindows()(UIHost::).SetDockWindowSink - Closing one from the OS closes every panel in it.
- Each has an
Abovetoggle: stay above the main window or not.PanelProps::bAbovesets the initial value. Implemented with OS window ownership, so it never sits above other applications. - Moving a panel between windows rebuilds its content.
ui.Stateinside the panel does not survive the move; keep durable state outside it.
Threading
GGDockSpace is not thread-safe. Dock documents are updated one at a time (every floating document follows the main one), and the host reconciles windows inside the main document's update. From other threads use UIHost:: / ClosePanel.
Limits
- Wayland: no window positions, so a tab can't be dragged between windows there. Tear-off works; the compositor places the window.
- Mixed-DPI monitors: window positions assume one display scale.
- The drop highlight shows the edge strip, not the half the panel will take. Nothing shows that a drop on the middle will float.
- A reopened panel goes to the main window's first tab group, not where it was.
- More tabs than fit are clipped.
- No keyboard control of docking.
Dialogs, Notices, and Files
ui.Dialog(open, { .Title = "Rename" }, [=](UI& ui) { ... }); ui.Ask(open, { .Title = "Quit", .Text = "Save first?", .Choices = { "Save", "Discard", "Cancel" } }, [](int choice) { ... }); auto notices = ui.Notices(); // handles: take them while building, use them in handlers auto files = ui.Files(); ui.Button("Open", [=] { files.Open({ .Title = "Open scene" }, [](std::span<const std::string> paths) { ... }); });
- Dialog: a box centred over a backdrop that blocks the pointer. Open while its state is true. Focus goes to its first control and stays inside. Escape or its close button closes it; a press outside doesn't. Losing application focus closes menus but not dialogs.
- Ask: a dialog with a question and a button per choice. The chosen index goes to the handler; closing without choosing reports nothing. The question can say what it is about, as any text can:
.Text = GGText("Editor.Delete", { { "name", name } }). It keeps its own copy, so the arguments needn't outlive the call (GGKeptText). - Handles, not calls. A handler has no
ui, soui.Notices()andui.Files()return handles to capture, the way aui.Stateis captured. - Notice:
notices.Show(text, { .Seconds, .Kind }). A line in a corner box for a while. Takes no input. Classes:notices,notice,notice-success,notice-warning,notice-error. - File dialog: the platform's. The result arrives later, at an update of the document that asked: the paths, or none if cancelled. A document destroyed first is told nothing.
- One service of each per host, shared by every document and by non-UI code from any thread (
UIHost::,Notices() UIHost::). They belong to the host, not a world: a window isn't a world's.Files() UIHost::picks the window that shows notices.SetNoticeWindow
Not yet: a dialog in its own OS window; dismissible notices or notices with buttons; notices in the active window rather than a preset one.
Documents Inside Documents
GGDocument Preview; // kept by whoever shows it ui.Document(Preview, { .Width = 2532, .Height = 1170, .Dpi = 3.0f, .Widgets = &previews, .Strings = french }, [asset](UI& ui) { ui.Use(asset); }); const NodeHandle under = Preview.Hit(event.LocalX, event.LocalY); // in its box's units const GGRect drawn = Preview.Bounds(under);
- A screen of its own. The content is laid out on a screen of
WidthbyHeightpixels atDpipixels to a unit: 2532 by 1170 at 3 is 844 by 390 units. Swapping the two is turning the screen round. With no size it fills its box at one unit to one, for an area whose size the parent sets. - Drawn in its box, scaled to fit and centred, clipped to the box, in the parent's draw order: it is the box's primitives, under the parent's clips and transforms, so a pan space zooms it and a scroll moves it. Its own clips are kept inside the box. Its text is shaped for the pixels it is drawn at.
- Its own assets and text, or the parent's (
Widgets,Stringsunset). Styles, images, text measurement, actions, and editors are the parent's.SafeAreais what itsui.SafeAreakeeps clear. - Updated with the parent: built, bound, laid out, timed, and published in the parent's update, on its thread. A change inside repaints only the box.
- The handle keeps the document and what it built, and answers for the parent:
Hit(what is under a point),Bounds(where a node is drawn), both in units from the box's corner. Not shown, it keeps what it built; destroyed, it leaves the parent. Either may go first.
Inside a zoomed box (a pan space) it is laid out and asked in its box's own units, as anywhere else, and its text is shaped for the size it is drawn at.
Not yet: input to the document inside (hover and press states shown in it); a box sized by its content.
From API to Pixels
Screen UI
- Widget functions run and produce nodes.
- Layout produces a rect per node.
- Paint turns each node into a few primitives (rounded rects, glyph quads, images) in tree order.
- Only dirty ranges are uploaded; one instanced draw per surface renders them.
- When a bound value changes, that primitive's parameter is patched. No function runs and nothing is laid out.
World UI (templates, drawn in batches by the GPU)
- The widget function runs once, against a placeholder entity.
- Layout and paint produce a fixed primitive list in local space, with a parameter slot per binding.
- Each frame the GPU evaluates the bindings from slab fields for every visible entity carrying the template and writes the primitives into the buffer screen UI uses. CPU cost doesn't grow with entity count.
void Nameplate(UI& ui, TemplateEntity e) { const auto health = Field(&CHealth::Value) / Field(&CHealth::Max); if (auto plate = ui.Column({ .Class = "nameplate" })) { ui.Visible(e.Bind(health < 1)); // an `if` on entity data becomes a visibility binding ui.Text(e.Text(&CName::Run)); ui.Progress({ .Class = "health" }, e.Bind(health)); } }
The Four Contracts
| # | Contract | Why |
|---|---|---|
| 1 | Bindings are data; lambdas are a CPU-only escape hatch | A lambda can't run on the GPU |
| 2 | Every property is paint-only or layout-affecting | Decides whether a change needs layout, and what may vary per entity in a template |
| 3 | A template's structure is fixed | The function runs once; an if over entity data has nothing to run on |
| 4 | A document's root is a surface, not a window | One core serves a window, a billboard, an in-world panel, and an offscreen target |
| Property class | Examples | In a template | On change |
|---|---|---|---|
| Paint-only | colour, opacity, fill fraction, offset, visibility, image region | May be bound per entity | Repaint that node |
| Layout-affecting | size rules, padding, gap, alignment, text content, font | Fixed | Lay out the affected subtree |
UIContext::BuildTemplate rejects, naming file and line: a lambda binding; a binding on a layout-affecting property; ui.Watch, ui.Component, ui.State. A rejected call is recorded and the build continues, so one build reports every problem.
| Surface | Root size from | Pointer reaches it as |
|---|---|---|
| Window | The platform window and its UI scale | Window-local position |
| Projected (nameplates) | The template | The picking buffer's widget ID |
| World panel | The panel's size in UI units | A ray hit in panel-local space |
| Offscreen | The target | Whatever the owner forwards |
Only the window surface is implemented.
Storage
Chunked SoA storage per UIContext, rows grouped by how often they change. See Memory Model.
- Chunk: 64 slots, so each bitplane is one word per chunk.
- Handles: a stable index plus a generation.
- Rows: structure (parent, children, siblings, depth, type, key); layout inputs; paint inputs; computed values; per-widget-kind state pools.
- Sparse data (bindings, state, watches, reactions) lives in pools linked into a short chain per node, so lookups cost the node's chain length, not the tree's size.
Mutation
Structural change from other threads goes through one lock-free multi-producer queue, applied at the start of the UI update (UIContext::Apply): destroy, append, insert-before, remove, and invoke (a function run in the update, with an optional pooled buffer).
One listener hears every change to the tree, whoever makes it: the builder, the queue, or a queued function. The context is that listener. It resets a new node's rows, releases everything stored for a destroyed node, and queues a changed child list for layout. No path can forget.
Layout
A flex-style box model, evaluated per depth over what changed.
- Driven by a queue. Everything that can affect layout queues its node. With an empty queue
UpdateLayouttouches nothing. - Four passes: measure widths (deepest first), arrange widths (shallowest first), measure heights, arrange heights. A height can depend on a width (wrapping text), so widths are final first.
- How far a change travels: a queued node is measured and its parent re-arranges its children. The parent is itself re-measured only if the node's wanted size changed and the parent fits its content on that axis.
- Rects are relative to the parent, so a container that moves or scrolls rewrites one rect.
- Snapping happens on read, edge by edge, for the surface's scale: boxes that share an edge still share it. A scale change needs no box layout; text is re-measured.
- Text is measured through
TextMeasurerand shaped once: the node keeps the run for paint. The built-in measurer (8 × 16 per character) needs no font and is what headless tests use. - Free space: grow items share by weight; one clamped by min or max is settled and the rest redistributed. What nobody takes is what
Justifydistributes. A grow item wants no space of its own: in a box that fits its content it is as big as its siblings make it. - Components are boxes round what they build. On an axis where what a component builds grows, the component grows too, and still wants its content's size, so a growing bar in a component (or a widget asset) fills the space the component is given. A button given content is a box round it, sized like a row.
- Overflow: visible, clip, scroll, or pan. The scroll offset is paint-only, so scrolling lays nothing out. A pan box has no bars or limits and also zooms (
UIContext::SetPan): its content is drawn moved and scaled about its corner, hit-testing follows, and a handler is told where it was hit in its node's own units. - Baselines: text's own, or a container's first text.
GGAlign::Baselinealigns a row on them. - Numeric type: written once and instantiated for
floatandFixed32.
ui.Row({ .Gap = 4, .bWrap = true }); // wraps onto further lines ui.Grid({ .Columns = 8, .Gap = 2 }); // eight equal columns ui.Grid({ .Columns = { GGSize::Fixed(120), GGSize::Fit(), GGSize::Grow() } }); ui.Place({ .Anchor = GGAnchor::TopRight, .X = -4, .Y = 4 }, [&] { Badge(ui); }); // overlay ui.Stack({ .Class = "dragged", .ZOrder = 1 }, [&] { ... }); // in front of its siblings
| Wrapping row | Fills a line until the next child wouldn't fit. Each line distributes its width like a row and is as tall as its tallest child. Gap applies between lines |
| Grid | Children fill it row by row. Columns is a count, or up to twelve sizes (fixed, Fit, percent, Grow) |
ui.Place | Takes no space among its siblings. Sits at one of nine anchors of the box, offset by X / Y. Align = Stretch makes its children as wide as the widest |
Layer | Among siblings, higher draws in front and is hit first. Layout and focus order don't change |
Not yet: wrapping columns; grid cells that span; a shared baseline on a wrapped line; layers across parents (which is what popups are for).
Publish
After layout the computed rows and paint output are published. Sentinel hit-tests the latest snapshot; Encoder draws it. A snapshot never points into rows the UI update is writing.
- Shape: a row per node at the node's slot, linked to parent, first child, and next sibling.
Find(handle)is one lookup;Walk()visits in draw order. - Rows hold unscrolled positions. Each scrolling box's offset is stored beside them (
GGSnapshot::Spaces), so a scroll writes that and no row.GGSnapshot::Placedgives a node's on-screen rect. - Snapshots share unchanged rows. Rows are in chunks of 64 aligned with the document's; a chunk is copied when first written. Four snapshots cost about what one does.
- A publish writes what changed. Anything that changes what a snapshot says about a node calls
MarkPublish. Children are visited only when what the node passes down changed (origin, clip, visibility, opacity, disabled, text colour). Reachis the bounds of everything a node and its subtree draw. Hit-testing and painting skip subtrees by it, so both cost what is on the surface, not what is in the document.- Publishing never waits. Four snapshots serve up to two readers. A reader acquires the latest and holds it; the writer fills one nobody holds. No locks.
- Hit-testing is a query on a snapshot in document space, respecting clip, scroll, layers, and visibility.
- Verified in tests.
UIContext::VerifySnapshotrecomputes every row from scratch and compares. Testbed turns it on for every publish, so a change that forgetsMarkPublishfails whatever test exercises it.
Accessibility
Role, name, description, and state are rows written by the same widget functions that build the visuals: a Button is a button to a screen reader with no extra code. Authors add what a widget can't know with ui.Accessible. Text and buttons default to their text as a name. Published with the snapshot, so a bridge can read from another thread. The platform bridge is not built.
Determinism
The same inputs give bit-identical layout. UIContext::LayoutChecksum hashes every node's computed values in tree order. The test lays one document out three ways per numeric type (fresh, fresh again, and after a history of other content and sizes) and requires equal checksums. The Fixed32 checksum is recorded in the test, so another machine or compiler that differs fails it.
Foundation Layer
GGUI can't include engine headers, so what it shares with the engine lives in a foundation library both depend on: chunk allocation, handles, bitplanes, the lock-free ring, string IDs, math, and Fixed32 with its trig. SimFloat (the engine's float / Fixed32 choice) stays in the engine; GGUI instantiates layout for the fixed-point type directly.
Open Questions
- Component functions run on whichever thread updates their document, and two documents' may run at once. Touching engine state from inside one needs a clear rule and a debug check (reading the published frame is fine; anything else is a queued command).
- Designated-initialiser props can't express "unset" for every field without a wrapper.
- How a custom-drawn widget (node graph canvas, curve editor) emits primitives.