Skip to content

Latest commit

 

History

History
1559 lines (1249 loc) · 90.9 KB

CGUI_WIDGETS.md

File metadata and controls

1559 lines (1249 loc) · 90.9 KB

CrystalGUI Widgets

Current-state reference for core/src/main/java/com/crystalgui/widget/, layered so a build fails when a layer reaches upward (LayeringTest): .control < .text/.scroll < .overlay < .form/.layout < .collection < .graph < .config < .canvas < .dnd < .texteditor.

A widget's TIER is decided by what it COMPOSES, not by what it is. Dropdown is a control by every reading of the word and it holds a Menu, so it cannot live in .control — that tier may name only control/text/scroll, and Menu is overlay. Same for ColorSelector and SearchField. The tier is a fact about the dependency graph, which is why it is checkable rather than intended.

Companions: CGUI_STYLE_RENDER_PIPELINE.md (how the cascade and painting actually work) and CGUI_SERVER_AND_SERIALIZATION.md (how widget state travels to a client).

Re-verify against the code before trusting a specific signature.


0. Conventions — read this before writing a widget

These rules are enforced in code and invisible from any single class. Every widget below obeys them.

A widget DECLARES that its structure is fixed

refusePublicChildren(), called in the constructor every other one chains through — on the no-arg one a new Dialog("title") never makes the declaration at all, which is most dialogs.

It is a promise a widget makes about itself and not something derived from whether it has a default slot. That was tried: an unslotted light child is the WEB's ordinary state — it sits in the light tree, out of the composed tree, drawn by nobody, and it is legal — so deriving the refusal turned a documented state into a throw and took three tests with it.

Accepts children How you put content in it
UINode append
ScrollerView append — it is the viewport, through its slot
SplitView first() / second(), or first(content) / second(content)
TabView never directly — addTab(label).content()

When adding a widget, give it a named accessor for its content rather than opening the tree.

Structure is a SHADOW TREE; a caller's content needs a SLOT

attachShadow() plus appendStructural, with each part carrying a part name a theme reaches through ::part(name). The names are the old __double-underscore__ classes with the wrapper removed — __mark__ became mark, __thumb__ became thumb.

A widget that takes content must give its slot a home. A light child of a shadow-hosting node with no default slot is in no composed tree at all: no box, no paint, no promotion, and nothing anywhere reporting a problem. Three widgets got this wrong in one batch and each looked like a different bug — ScrollerView put a caller's rows beside its scrollbars, Menu appended its items INTO its own items part so every menuitem rule in every sheet reached nothing, and Dropdown put its whole Menu in its shadow tree, which put the items inside one twice over.

And the slot must be told to fill. A slot is an ordinary box between the host and its content, so align-items: stretch on the host reaches the SLOT and stops there — a menu's rows sized to their text and the focus bar stopped short of the menu's edge.

A widget may host a shadow tree only if nothing reaches THROUGH its structure

::part() has no spelling for a part under a part, a tag under a part, or a nested widget's part. Measured over the shipped sheets: 23 widgets can, 21 cannot, and 220 rules have no ::part() spelling at all — colorselector alone accounts for 51. A subclass cannot un-shadow its parent (Dropdown extends Button), so decide the base class first.

What a shadow tree buys is the SLOT, not the part naming: a caller's content cannot land among a widget's parts, which is what .__content__ cost three separate times.

A pseudo-class after ::part() describes the PART, not the host

dropdown::part(menu):open asks whether the MENU is open. Parts before the pseudo-element describe the originator; parts after it describe the pseudo-element. That is CSS's rule and collapsing it made a dropdown's menu match the closed-popup opacity: 0 and match nothing that lifted it again — it opened, promoted, placed itself correctly, and drew at zero alpha.

A subclass inherits its parent's Name unless given its own

Dropdown extends Button, MenuItem extends Button, Menu extends Popover — each chained to the public parent constructor, so all three reported the wrong tag and every rule they have in every sheet matched nothing. A widget meant to be extended takes a protected (Name, ...) constructor. Which answer is right is a judgement: a dropdown deliberately does not answer button, while a widget wanting everything the supertype has plus a modifier passes the supertype's NAME and adds a class.

A widget that paints its own content must be Measurable

TextField draws its glyphs itself and has no child nodes, so nothing in the box tree had anything to size it from: it laid out 215x0 — present, styled, bordered, drawing its icon and placeholder, with no height at all, so a click aimed at it landed on whatever was behind. The old engine never needed this because a Taffy leaf with no measure function fell back to whatever the sheet said, and the shipped sheets give every field a height. It is the widget a THEME sizes that breaks.

A listener on a shadow host can never see its own parts

event.getTarget() is retargeted before the listener runs, and a listener attached to the host is OUTSIDE its own shadow root. The old idiom — one listener on the widget, an if-chain comparing the target against its shadow parts — compiles, runs, and takes the wrong branch forever. Attach inside the shadow tree, and stopPropagation() there.

A widget's CSS identity is its tag, not its Java supertype

Dropdown extends Button, MenuItem extends Button, Tab extends Button — and none of them are matched by button { … }, because a type selector matches the registered tag, exactly as in CSS. Java inheritance is invisible to the cascade.

This is not theoretical: Dropdown shipped laying out at zero height, because min-height is where a Button gets its box and no rule reached it. default.css now names dropdown alongside button throughout (and so does ore.css, or a themed dropdown renders with no chrome at all).

When you subclass a widget, decide explicitly: extend the existing selector list if it should look the same, or write it its own block if it should not. Doing neither gives you an invisible control.

No sizes, no timings, no colours in Java

Widgets write structure and state. default.css gives every widget functional geometry so it works with no theme loaded; ore.css gives it appearance. Switch's knob animation is a transition on flex-grow in CSS, not a Java tween. If you find yourself typing a pixel value into a widget, it belongs in default.css.

Widget-authored baseline styling goes in at DEFAULT origin, the lowest priority, so any stylesheet rule beats it without needing !important. The idiom every widget uses:

StyleGroup.defaultPipeline(getStyle().getLayoutGroup(), l -> l.flexDirection(FlexDirection.ROW));
StyleGroup.importantPipeline(getStyle().getGeneralGroup(), g -> g.display(NONE));  // the rare opposite

importantPipeline is for state a stylesheet must not be able to override — Tab hides a deselected pane that way. (UINode.moveInlineAsDefault() also exists and does the same job retroactively, but no widget uses it; prefer defaultPipeline.)

Pseudo-classes come from getters

PseudoClasses binds each selector to a real UINode method:

Selector Method
:enabled / :disabled isEnabled()
:checked isChecked()
:blank isBlank()
:invalid isInvalid()
:hover isHovered()
:active isPressed()
:focus isFocused()
:focus-visible isFocusVisible()

A widget gets a pseudo-class for free by overriding the getter — Tab.isChecked() returning its selected flag is the whole of how tab:checked works.

:focus-visible is the web's rule and the one a focus ring should hang off: true for keyboard and programmatic focus, false after a mouse click — except on elements that take text input (consumesTextInput()), which always ring. Input decides it from FocusSource; see CGUI_STYLE_RENDER_PIPELINE.md §2. Note hyphenated names resolve via PseudoClasses.lookup, not valueOf.

A surface that owns a caret says so — setRetainsFocus

A press that finds no click-focusable ancestor clears the focus owner. That is the web's rule — Blink's HandleMouseFocus, and why clicking a bare <div> blurs the input beside it — and for ordinary chrome it is right. For a widget whose whole point is one field it is not: pressing a prompt's caption, its divider or its padding blurred the field and focused nothing, so the window had no focus owner and the next keystroke went nowhere.

popup.setRetainsFocus(true);   // a press on scenery in here leaves the caret where it is

Subtree-wide, and it still defers to anything click-focusable inside it: a ListView row still takes its own press and still selects, because that list drives selection from focus. What it catches is the scenery, whose only effect on focus otherwise is to destroy it.

Two things to know when you reach for it:

  • The web's own escape hatch is preventDefault() on the mousedown, and it does not work here: focus is assigned before dispatch, so no handler can object in time. The opt-out is on the element instead, which is Swing's setRequestFocusEnabled(false) in shape though not in scope.
  • Handing focus back afterwards is requestPointerFocus, never requestFocus. The latter is the DOM's element.focus(): it rings and it scrolls its target into view, and that reveal scrolls the popup itself — the divider and every row shift together, which reads as the popup jumping on click.

Listeners, tickers, tags

  • attachListener(l, capture, bubble) always subscribes the target phase. The two booleans are additive, not a mode selector.

  • Per-frame work is an Animation hook OWNED by the node — document.animation(), not a ticker the widget registers on the window and can never unregister. It stops when the node leaves the tree, which the old one-way registration could not guarantee: a hidden window's ticker carried on invisibly, and the contract that it must return false lived only on the interface. Used by Scroller (press-and-hold repeat), ScrollerView and TextField (caret blink).

  • Anything that reads GEOMETRY uses afterLayout. An ordinary hook runs BEFORE this frame's layout — the frame is animation → style → layout — so a hook that measures reads the previous frame's box, or none at all on the first.

  • Declare the tag as a NAME constant on the class and register it from a NodeKinds service, which UINodeRegistry.bootstrap() runs. (register(tag, Class, factory). Without it the widget has no tagName(), cannot be a CSS type selector, and cannot be serialized. Current tags: element, button, checkbox, scroller, scrollerview, slider, splitview, switch, tab, tabview, textfield, text.

  • Say what a tree calls it and draws for it with a KindInfo at registration. The builder's Hierarchy shows every node with a 12 px glyph and a tooltip, and a kind that says nothing still gets one:

    UIElementRegistry.register(MachineGauge.NAME, MachineGauge::new, CONTRACT,
            KindInfo.named("Machine Gauge").glyph(GlyphRole.CONTROL));   // ships mymod:nodes/ui/machinegauge.svg
    
    KindInfo.named("Heat Field").glyphOf(Slider.NAME);                   // draws the slider's glyph and role
    KindInfo.named("Reactor").glyph("mymod:machines/reactor", GlyphRole.COLLECTION);   // a file named otherwise
    Your kind declares The row draws
    a glyph it — a 16-unit SVG in currentColor, tinted by the role's --kind-glyph-* token
    nothing, and extends a kind that has one that kind's glyph, under your class name split at capitals
    nothing, and lays out children its layout from computed style — column, row, wrap, grid — or a frame while empty
    nothing else a diamond tinted as an addon, saying Your Name · yourmod

    Declaring a glyph without shipping its file draws nothing.

  • Say where the builder's Library files it, what a card shows and what placing inserts — the same KindInfo. A kind that declares nothing is still listed, under a folder named for its namespace, and its card draws what its factory builds; machinery a document never holds hides itself:

    KindInfo.named("Machine Gauge").inCategory("Machines/Gauges")      // a path nests; mymod's folder otherwise
            .synonyms("dial", "meter").describedAs("A needle over a scale.")
            .starter(() -> new MachineGauge().setValue(40))             // what a drop or double-click inserts
            .preview(Preview.sample(() -> new MachineGauge().setValue(70)).width(80));   // what its card draws
    
    KindInfo.named("Reactor").preview(Preview.picture("icon(\"mymod:reactor\")"));   // cannot be shown live
    KindInfo.hidden();                                                  // a part, never listed

    A category is a /-separated path, and so is a user's group in the Library: Mine/Buttons lists inside Mine, a group's menu offers New Group Inside…, and renaming or deleting a group carries its subgroups. The shipped Forms splits as Unity's UI Builder splits its fields — Numeric, Text, Choice, Composite and Value — plus Structure; Controls into Buttons and Toggles, Inputs and Pickers; Layout into Containers and Panes and Tabs; Starters into Layout, Forms and Dialogs, and Game UI (a starter's category is Starters/<folder>). A category of a handful stays flat.

    Supplier Used by Defaults to
    the factory a decode, and everything below when nothing else is declared —
    starter placing the kind — the Library, the Hierarchy's New ▸ the factory
    Preview.sample the card only, never placed the starter

    A sample's width is the logical width it is laid out at before the card scales it down; a control that fills its row needs one or it lays out to nothing. whole() shrinks it however small that makes it, rather than cropping it once its text would stop reading; atCardWidth() lays it out as wide as the card shows and fits it whole, for a stretching row whose far end would otherwise be cropped. A widget whose look needs a state nothing is in draws it with presentInline() (popovers, menus, dialogs, tooltips) or presentFocused() (a text field). The shipped kinds' samples are widget.WidgetSamples, styled by ua/samples.css.

  • Override writeState/readState if the widget carries state a server would want to send or a client would want to report back. See CGUI_SERVER_AND_SERIALIZATION.md.


1. Button

Button b = new Button("Click me");
b.attachListener(() -> count++);        // or: b.onPressed
b.setText("Now this");
b.setPreIcon(icon);  b.setPostIcon(null);   // null clears
b.setEnabled(false);                        // inherited from UINode

Activates on press-then-release over the same element (dragging off cancels), and on Space/Enter when focused. Fires CgPlatform.sound() with button_click; silent unless the registered platform implements it.

  • Tag button · internal __pre-icon__, __post-icon__ · pseudo :hover :active :focus :focus-visible :disabled
  • Scene: cgui-gallery (Button page)

A button that opens a menu wears IntelliJ's dropdown gutter — one call, drawn by the UA sheet over the button's box in its own colour, unhittable and out of flow. It says a menu opens; the press is still yours.

eye.setDropdownMark(true);    // button::part(dropdown-gutter), and __dropdown-mark__ on the button

ActionButton — a command or a menu, as an icon (widget.composite)

IntelliJ's ActionButton. A Button by tag carrying __action-button__, and not a focus stop.

ActionButton.command(TreeViewCommands.COLLAPSE_ALL)          // runs a command
        .icon("crystalgui:general/action/collapseAll")
        .context(tree);                                       // what it resolves against

ActionButton.menu("New File or Directory…", MenuId.EXPLORER_NEW)   // drops a menu down, gutter and all
        .icon("crystalgui:general/action/add")
        .context(tree);

expand.hint(TreeViewCommands.EXPAND_ALL, "Press {} to expand all nodes");   // second tooltip line

ActionButton.command(TOGGLE_ROWS).icon("crystalgui:general/action/viewRows")   // "Show as Rows"
        .whenToggled("crystalgui:general/action/viewCards", "Show as Cards");  // while the command is toggled
  • The tooltip is IntelliJ's help tooltip: the command's label, its live chord beside it (Tooltip.setShortcut) and the description or hint under them (Tooltip.setDescription), after the tooltip.__wait__ delay Hide shares. tooltip() is the observable.
  • A command button greys while the command is disabled, re-asked four times a second while in a tree.
  • A menu button's second press closes its menu; it is the menu's invoker.
  • A toggle's whenToggled face shows what a press switches to, and flips on the press itself.
  • Name the context whenever the button sits away from what it acts on — a header is outside its view. context(Supplier) for content that is replaced under the button.
  • Consumers: every tool window's title line (TitleActionsContributor) and its ⋮.

Drawn as a picture — for a Library card or a style guide

A widget whose real appearance needs a state nothing is in — open, focused — can be told to draw it without entering it. Only the appearance: nothing is promoted, placed, focused or made modal, so never show one presented this way. Samples are declared per kind in widget.WidgetSamples, styled by ua/samples.css.

new TextField().setPlaceholder("Type here").presentFocused();   // caret, placeholder and :focus, no focus
menu.presentInline();                                           // Popover, Menu, Dialog, Tooltip: open, in flow

A search box over a tree — SearchTree (widget.composite)

The Insert Node menu's search-and-tree half as a dockable element: a box that filters while the caret stays in it, a categorised tree for a blank query and a ranked flat list for anything else, arrows and Enter driving the list. CreateMenu wraps one in its popover; the builder's Library embeds one.

SearchTree<Row, Kind> search = new SearchTree<>();
search.setRows(rows);                       // roots(query), children, label, isCategory, payload
search.onChosen.connect(kind -> place(kind));

A row draws an icon when Rows.icon(node) names one, wearing Rows.iconClass(node) so a theme can tint it (.__entry-icon__); a category row never does.

A searchable popover — CreateMenu (widget.composite)

SearchTree under a draggable title, over a footer. Every one looks alike — the surface's Insert menu, the graph's Insert Node, the Styles tab's Add property — because the look is keyed on the __quick-input__ class the constructor adds, not on a subclass's tag: a new menu gets it by extending, with nothing to opt into.

final class ThingMenu extends CreateMenu<Row, Thing> {
    ThingMenu() {
        super(NAME, "Add Thing");
        setRows(rows);
        setHints("↑↓ to choose · Enter to add · Esc to close");   // the footer's resting line
        treeView().onSelectionChanged.connect(i -> setFooterText(describeHighlighted()));
    }
}

setFooterText(null) puts the hints back. Rows are 20px, set from Java: a virtualised row's height is the view's, and a stylesheet height on .__entry__ breaks the tree rather than resizing it.

A surface's Add menu — InsertMenu (widget.surface.insert)

What every registered InsertSource offers, searchable, opened at a world point: Blender's Shift+A. Browsing groups offers by path() in the order sources gave them; a query ranks label, synonyms and path, and skips an offer marked browsingOnly() (a Recent row, which the search finds under its own path). The footer describes the highlighted offer.

surface.registerInsertSource(() -> offers);          // Insertable: label, path, synonyms, description, icon, insert
InsertMenu menu = surface.openInsertMenu(worldX, worldY);
menu.header().append(placementLine);                 // a consumer's band above the search
menu.showHeader(true);
menu.onCycle.connect(step -> placement.cycle(step)); // Tab / Shift+Tab in the search box
  • onCycle takes Tab only while something is connected to it; otherwise Tab keeps its meaning.
  • The header is hidden until showHeader(true).
  • The UI builder's consumer is BuilderInsert: Shift+Space under the selection, right-click on blank page at the pointer, Insert… first on a node's menu.

A kind drawn as itself — PreviewCard (app.uibuilder.library)

A Library card: the kind's sample in a shadow root wearing the window's sheets and no panel rule, fitted into a tile and cropped by a square scissor, with its name under it. Samples build a few per frame through the window's PreviewBuilds; until its turn a card shows the kind's glyph.

A card may also show a starter — a snippet of several nodes, listed first under Starters in the Library and the Insert menu. LibraryStarters declares each as a LibraryCatalog.Entry over a shipped .cgui:

LibraryCatalog.Entry card = LibraryStarters.CARD;   // id "starter:crystalgui:uibuilder/starters/card"
card.kind();                                        // null: a starter is no one kind, so it joins no user group
card.build();                                       // a fresh tree of the snippet each call

Keeping rows realised — ListView.setOverscan

A list realises the viewport plus two rows each side and rebinds rows as they scroll. A list whose rows are few and costly to bind keeps them instead: tree.setOverscan(64). Rows off screen are culled from paint, so what they cost is their settled style and layout.

2. Checkbox + CheckboxGroup

Checkbox c = new Checkbox("Enable thing");
c.setChecked(true);
c.attachListener(checked -> …);         // or: c.onCheckedChanged

CheckboxGroup group = new CheckboxGroup().allowEmpty(false);
c.setGroup(group);                      // normal usage — not group.register(c)
Checkbox current = group.getCurrent();

CheckboxGroup is a plain object, not a UINode — it has no place in the tree and no styling. With allowEmpty(false) it refuses to let you un-check the last checked box, giving radio-button semantics.

The mark is drawn entirely by CSS — checkbox:checked .__mark__ { … }. There is no Java-side "draw a tick".

  • Tag checkbox · internal __mark__ · pseudo :checked :hover :active :focus :focus-visible :disabled
  • Scene: cgui-gallery (Checkbox page)

3. Switch

Switch s = new Switch();
s.setChecked(true);
s.attachListener(checked -> …);         // or: s.onCheckedChanged

The knob slides by animating an invisible __spacer__'s flex-grow between 0 and 1. All timing is CSS — switch .__spacer__ { transition: flex-grow 150ms ease-out; }. There is no duration, easing or size anywhere in Switch.java.

  • Tag switch · internal __spacer__, __knob__ · pseudo :checked :hover :active :focus :focus-visible :disabled
  • Scene: cgui-gallery (Switch page)

4. Slider

Slider sl = new Slider();
sl.setRange(0, 100).setStep(5).setValue(40);   // step 0 = continuous
sl.attachListener(v -> …);                     // or: sl.onValueChanged
float f = sl.getFraction();                    // 0..1

Drag-by-delta, click-to-jump, Left/Right arrows, Home/End, and the wheel. Focus policy is CLICK.

  • Tag slider · internal __fill__, __thumb__, __spacer__ · pseudo :hover :active :focus :focus-visible :disabled
  • Scene: cgui-gallery (Slider page)

5. TextField

The largest widget. Three layers worth keeping straight:

TextField t = new TextField();

// 1. raw box contents
t.setText("abc");  t.getText();

// 2. published value — Property<String>, the thing you bind
t.value;  t.getValue();
t.bindValueTo(model);  t.bindValueBidirectional(model);
t.setUpdateMode(UpdateMode.IMMEDIATE);   // default is ON_COMMIT (Enter or blur)
t.onSubmit;                              // fired on Enter
boolean ok = t.commit();                 // clamps + reverts if invalid

// 3. validation, in two tiers
t.setMode(Mode.INTEGER).setRange(-50, 50).setStep(5);   // auto-derives a char filter
t.setCharPattern("[0-9]");               // per-keystroke: rejects outright
t.setCharFilter(ch -> …);
t.setPattern("\\w+");                    // whole-text: marks :invalid, still editable
t.setTextValidator(s -> …);

The keystroke/whole-text split is deliberate. A char filter refuses input that could never be valid; a text validator flags input that is not yet valid but must be typable on the way to something that is — you cannot reach -5 without passing through a bare -.

Mode is STRING | INTEGER | LONG | FLOAT | DOUBLE, and setting a range narrows the auto-derived filter: a 0..100 integer field silently drops -, a -50..50 one admits it.

Also: caret blink (setCaretBlinkSeconds, isCaretVisible, via UIFrameTicker), selection (selectAll, clearSelection, getSelectedText, getSelectionStart/End), clipboard through CgPlatform.input().getClipboard(), wheel-steps-the-value (stepBy(notches)), and Escape to revert.

Six style properties drive its text: color, font-size, font-family, line-height (vertical centring only), caret-width, selection-color.

line-height defaults to CSS's normal — the font's own line box. Neither the caret nor the selection band is taken from it: both are ascender + descender, excluding the lineGap a line box also carries, because that gap is leading between lines and this field is single-line. The selection also paints only while focused — blurring hides the band but keeps the range, as browsers do. See CGUI_STYLE_RENDER_PIPELINE.md §8c.

Input-method composition (Japanese, Chinese, Korean): a host that reports a run in progress sends it through Input.consumeComposition(text, caret). The field shows the run in its text, underlined, replacing it on every update; the committed characters then arrive as ordinary typing, filtered as usual, and drop the run first. textInputArea() answers the caret's box in surface pixels, where the host opens the IME's candidate list. TextEditor does the same, underlining through ::highlight(ime-composition). Only Minecraft 26.3 (SDL3) reports a composition; everywhere else the OS draws the run itself.

// a host with a composing input method -- Minecraft 26.3's CgUiScreen.preeditUpdated
document.input().consumeComposition(preedit.fullText(), preedit.caretPosition());
float[] caret = document.input().textInputArea();   // {x, y, w, h}, or null
  • Tag textfield · pseudo :blank :invalid :hover :active :focus :focus-visible :disabled
  • Scene: cgui-gallery (TextField page)

6. UIText

UIText t = new UIText("hello");
t.setText("world");
t.bindTextTo(someProperty);             // or: t.text  (Property<String>)

It sizes itself by writing width/height at IMPORTANT origin. So a bare UIText cannot be given a width by CSS — wrap it in a fixed-width slot element when you need aligned columns:

UINode slot = new UINode().layout(l -> l.width(58));
slot.append(new UIText("label"));

Consumes color, font-size, font-family, plus text-offset-x/text-offset-y (paint-time glyph nudge, applied after the wrap width is read so it can never affect geometry; percentages resolve per-axis against this element's own box). Wraps at the font's own metrics and does not honour line-height yet.

It also draws text outlines — text-stroke, stroke-align and paint-order. text-stroke is the only spelling for the width and colour; the two longhands behind it are not writable by name (see CGUI_STYLE_RENDER_PIPELINE.md §8d for why they still exist):

text { text-stroke: 2px #0B5D8F; }                        /* outline outside the letterform */
text { text-stroke: 1px #0B5D8F; stroke-align: inset; }   /* an inline, inside it */
text { text-stroke: 2px #FFF; color: transparent; }   /* hollow: the stroke names its own colour */

Three limits worth knowing before reaching for it: the stored distance field caps the width at 0.131em for most faces — 8.4px at text size 64 — and at 0.056em (3.6px) for a face carrying a dense script, which keeps a narrower range because that is all eight bits hold without merging a kanji's strokes; wider clamps rather than erroring, and UIText.maxStrokeWidthEm() answers which applies. A stroke pulls its label onto the distance-field tier and keeps it there down to text the field can still antialias (7px on the wide band, 15px on the narrow), below which the outline is dropped rather than drawn on a fill the field cannot resolve, and an inset or center stroke closes a letterform's joints once it exceeds their thickness — which is geometry, not a defect. Full account in docs/CGUI_STYLE_RENDER_PIPELINE.md §8d.

The trap: declaring font-size on a WRAPPER does not reach a widget's label, because default.css opens with * { font-size: 10 } and that is a candidate on every element — put the size on the element that draws, or on the ::part().

  • Tag text · no shadow parts
  • Scenes: cgui-text (wrapping, font fallback, live binding), cgui-gallery

6b. EmptyState — what a vacant panel says (widget.display)

A heading and a few short lines over the middle of the panel — IntelliJ's StatusText. It never wraps: a panel narrower than the note clips each line evenly at both ends while centred, so a resize never reflows it. setCentred(false) lines a list of dashes up on one edge, as every workbench panel does. The copy is a direction, not a status: "To :" and the steps there, naming the kind of document the panel needs with one example, since another extension may serve the same panel.

A workbench panel declares one and says when it is vacant; the ViewContainer it is docked in places the note and shows it in the panel's place:

private final EmptyState empty = EmptyState.of(this, "To see a document's structure:",
        "— Open one that has a structure, such as a .cgui file");

private void show(@Nullable Thing thing) {
    empty.setVacant(thing == null);
}

of records the note on the panel (EmptyState.NOTE) and lines it up as a list; it starts vacant. A line that changes is rewritten on the same note. Outside a container, new EmptyState(...), append it and setDisplayed it yourself — it fills its parent absolutely and takes no pointer, so the parent needs a size.

A line may end in a key chord, which the sheet dims against the words it belongs to — the words are the instruction and the chord is how to perform it, so painting them alike makes the chord read as part of the sentence. Pass the chord separately rather than concatenating it, and pass null when a command has no binding rather than inventing one:

KeyChord chord = Keymap.acceleratorFor(this, ExplorerCommands.GO_TO_FILE);
empty.setLine(0, "— Go to File", chord == null ? null : chord.toString());
  • Tag emptystate · parts lines, heading, line, chord · tokens --empty-state-fg, --empty-state-chord-fg
  • Declared by RunPanel, HierarchyToolWindow, LibraryToolWindow, Inspector, and DockGroup for the empty editor's watermark — whose copy is the DOCK's (DockArea.setWatermark), because naming an application's ways in is the application's business and a dock is a widget other products build with
  • The watermark reads larger and brighter than a panel's note (dockgroup emptystate::part(line), --editor-watermark-fg): it owns the whole work area and is read rather than glanced at
  • Scenes: cgui-desktop (open the Run, Hierarchy or Library panel with no .cgui in front; close every editor tab for the watermark)

7. Scroller and ScrollerView

Two different things, and the distinction is the point.

Scrolling is an ordinary UINode capability. Any element with overflow: hidden/auto scrolls — setScrollTop, setScrollLeft, setScroll, setScrollImmediate, scrollIntoView, getMaxScrollTop/Left, getScrollWidth/Height, getClientWidth/Height, setScrollExempt. A bare element scrolls programmatically only; it never listens to the wheel.

ScrollerView is that viewport plus two bars. It opts into the wheel explicitly.

ScrollerView view = new ScrollerView();
view.append(content);                 // accepts public children — it IS the viewport
view.setScrollbarsVisible(false);       // scrollable, just no visible bars
view.refreshScrollers();                // cheap + idempotent; auto-called from onLayoutChanged
view.verticalScroller(); view.horizontalScroller(); view.corner();

Wheel is 40px per notch; Shift+wheel scrolls horizontally; a horizontal-only view takes the plain wheel; at either end the event bubbles, giving scroll chaining.

Scroller is the bar widget by itself — usable standalone, e.g. as TabView's strip bar.

Scroller bar = new Scroller();
bar.setOrientation(Scroller.Orientation.VERTICAL);
bar.setValue(0.5f);                     // 0..1
bar.setVisibleRatio(0.3f);              // thumb length
bar.setStepFraction(0.1f);
bar.attachListener(v -> …);             // or: onValueChanged
bar.onScrollIntent;                     // relative nudges from the step buttons / track paging

Step buttons exist but are display: none by default — a theme enables them with .__head__, .__tail__ { display: flex; }.

  • Tags scroller, scrollerview · internal as listed in §0 · Scroller and ScrollerView are both UIFrameTickers
  • Scene: cgui-gallery (Scroller page)

8. SplitView

SplitView sv = new SplitView();
sv.setOrientation(SplitView.Orientation.VERTICAL);   // adds __vertical__ to the root
sv.first(leftContent).second(rightContent);          // or sv.first().append(…)
sv.setPercentage(35f).setLimits(10f, 90f);           // 0..100, NOT 0..1
sv.attachListener(pct -> …);                         // or: onPercentageChanged
sv.divider();

Percentages are 0..100, defaulting to LDLib2's 5..95 limits. Passing 0.35f is silently clamped to the minimum and collapses the first pane, which looks like a layout bug and is not one.

The panes accept children; the SplitView itself does not. Splits nest.

  • Tag splitview · internal __first__, __divider__, __second__; root gets __vertical__
  • Scene: cgui-gallery (SplitView page)

9. TabView + Tab

TabView tv = new TabView();
tv.setTabSide(TabView.TabSide.LEFT);        // TOP | BOTTOM | LEFT | RIGHT
Tab first = tv.addTab("General");           // the FIRST tab added is auto-selected
first.content().append(page);             // content() is the pane — an ordinary element
tv.addTabAt("Inserted", 1);
tv.selectIndex(2);  tv.getSelectedTab();  tv.getTabs();
tv.removeTab(first);  tv.clearTabs();
tv.attachListener(tab -> …);                // or: onTabSelected — emits null when the last tab goes
tv.strip(); tv.rail(); tv.bar(); tv.panes();   // for styling
tv.setTabOverflow(TabView.TabOverflow.WRAP);    // SCROLL (default) | SQUEEZE | WRAP
tv.stripActions().append(menuButton);          // controls at the strip's end, never scrolled under

When the tabs do not fit — IntelliJ's "Show tabs in". SCROLL pans the rail and fades whichever end has tabs past it; SQUEEZE gives every tab an equal share, capped at its natural width, and cuts its label; WRAP breaks the rail into rows. The mode is a class on the view (__tabs-squeeze__, __tabs-wrap__) and the sheet does the layout. stripActions() sits over the strip's end: a strip that fills it reserves the width with its own padding-right, as an editor group's does.

Structure: TabView → __strip__ { __rail__ (a ScrollerView), __strip-bar__ (a Scroller) } + __panes__ { __pane__ … }. The rail is a real ScrollerView with its bars hidden, so an overflowing tab strip pans on the wheel and the separate __strip-bar__ shows the position.

setTabSide writes flex-direction at DEFAULT origin and swaps a state class on the root, so CSS can do tabview.__left__ .__strip__ { … }.

Panes are not lazy. addTab eagerly builds the Tab and its pane and inserts it immediately; hiding is display toggling at IMPORTANT origin, not tree removal. That is what makes element identity, listeners and scroll position survive a tab switch, and hit-testing plus Tab-order traversal both skip hidden panes. If you want lazy population, do it yourself in an onTabSelected listener.

Tab extends Button, so it has setText/setPreIcon/setPostIcon and Space/Enter activation. Tab.setSelected is package-private on purpose — go through TabView.selectTab.

Keyboard: Left/Right (Up/Down when vertical) step the selection, Home/End jump to the ends; focus moves with the selection. Bubble-phase, so a focused widget inside a pane sees arrows first.

A whole strip is one Tab stop — the ARIA APG's roving tabindex. The selected tab is FocusPolicy.CLICK; every other tab is CLICK_NOT_TABBABLE (the web's tabindex="-1"), so Tab enters the strip once, arrows move within it, and Tab again leaves for whatever follows. Ten tabs cost one press to skip, not ten.

TabView.updateTabStops() owns that invariant — not Tab — for the same reason selectTab owns selection: "exactly one" is a strip-wide property, and a per-tab setter could leave zero stops, which makes the entire tablist unreachable by keyboard. It runs on every selection and membership change, falls back to the first tab when nothing is selected (selectTab(null) is public), and restores a removed tab to ordinarily-tabbable on the way out.

  • Tags tabview, tab · internal as listed in §0 · tab:checked works via the overridden isChecked()
  • Scene: cgui-gallery (TabView page)
  • Known gap: tabs and panes do not round-trip through the codec — they live in internal containers.

9b. ContextToolbar

One toolbar row whose page follows what is going on — Photoshop's options bar, Inkscape's tool controls, Blender's tool header.

ContextToolbar bar = new ContextToolbar(documentToolbar);   // the base: shown while nothing claims the row
column.append(bar, canvas);

Disposable claim = bar.claim(transformOptions);             // shown in the toolbar's place
claim.dispose();                                            // and back; disposing twice is harmless
bar.shown();                                                // the page on show

A surface wires every tool in one call, and each tool's options() page then comes and goes with it:

surface.modes().showOptionsIn(bar);

// on the Tool: build once, return the same element every time
public UIElement options() {
    if (options == null) options = new BrushOptions(ctx);
    return options;
}
  • The row's height is the sheet's, never a page's — contexttoolbar has a definite height and clips, so a page swapping in cannot move what is under the bar. Blender fixes its tool header the same way.

  • Claims stack. The newest live claim shows; releasing it reveals the one under it — a modal gesture over a tool's options, as Blender's modal header text sits over the tool header.

  • A page is added on its first claim and kept, hidden between claims as Inkscape and GtkStack keep theirs, so its fields keep what they hold.

  • What does not fit folds from the end behind a » at the row's end, which opens it in a popover — WinForms' ToolStrip overflow and Qt's toolbar extension. Put the least-used items last. A folded item is hidden rather than clipped, so Tab cannot land on it.

  • ContextToolbar.separator() is a hairline between a page's groups — Photoshop's, and Qt's addSeparator. As in a menu, the fold never leaves one at the end of the row and the popover never opens on one.

  • A field on the row is a stop, not a home. Enter lands it and Escape drops what was typed, and either hands the keyboard back — Photoshop's options bar, where the next Enter is the tool's. A tool that also wants Enter leaves a focused field alone.

  • A page hidden while it holds focus gives focus back to whatever had it before focus entered the bar.

  • Pages arrive through claim and setBase. A child appended directly is not a page and is never hidden.

  • A page's fields come from a ToolbarForm bound to properties (§12d), so a live number follows its source and is left alone while something is typed into it.

  • Tag contexttoolbar · pages carry __context-page__

  • Scenes: none yet — the UI builder's toolbar row is the consumer


10. Tooltip

The first consumer of the top layer (CSS Position 4 §top-layer — the machinery behind <dialog> and the Popover API), and the reason it was built.

Tooltip tip = Tooltip.attach(anchor, "explain this");   // shown on hover, hidden on leave
tip.detach();                                            // remove it again

Tooltip.attach is a static factory on the widget, deliberately not UINode.setTooltip. UINode is the core DOM node every widget builds on; a tooltip is a widget, so putting the wiring there inverted the dependency (core importing widget) and made every element in the tree carry a field for a feature most never use. It was also where a real bug lived: a set/clear/set cycle attached a second pair of hover listeners each time. Creating the tooltip and its listeners in one call makes that unrepresentable.

Why it needs the top layer. A tooltip's whole job is to draw outside the thing it describes. the paint walk paints depth-first under every ancestor's scissor, so before promotion a tooltip on a row inside an overflow: hidden scroller was clipped to the scroller. See UIDocument.promote.

Structure. Internal child of the anchor — which keeps the cascade behaving like the web's, since it inherits color/font-family from where it sits in the tree rather than from wherever it paints. Its own internal __label__ is a UIText.

Closed ⇒ display: none, exactly as a closed popover is on the web. Not cosmetic: an ordinary child participates in its parent's flex flow, so a hidden tooltip would silently pad every element that had one. One property covers layout, paint and input at once.

Never eats the pointer (setHitTest(false)). Otherwise the tooltip appearing under the cursor counts as leaving the anchor → hide → un-hover → show: a flicker loop. The web's pointer-events: none on tooltips exists for the same reason.

Placement is recomputed every frame from the anchor's localToWorld — not its layout box, which knows nothing about scrolling — so it tracks a scrolling or animating anchor. Below the anchor by default, flips above when there is no room below, then clamps horizontally. That is the useful subset of CSS Anchor Positioning's position-try-fallbacks.

  • Tag tooltip · internal __label__ · Scene: cgui-gallery (Tooltip page)
  • No pixel values in Java: the gap under the anchor is the tooltip's own margin-top in default.css, and the wrap width is tooltip .__label__ { max-width }. The max-width has to sit on the label, because UIText only wraps against a width it can see on itself.
  • Known gaps: no show delay (a delay is a timing value and belongs in the cascade — that needs a real CSS property); no platform-delegated tooltips yet (an item's real MC tooltip must be drawn by the loader, which needs a new SPI).

11. Dialog

A floating, movable panel — the web's <dialog>, modeless form.

Dialog panel = new Dialog("Inspector");
stage.append(panel);          // stage is the containing block: dialogs are position: absolute
panel.moveTo(20, 20).show();
panel.getContent().append(...);

Named Dialog, not UIDocument — that name already means the runtime/Document analogue, and reusing it would be actively misleading.

Modeless on purpose. Only showModal() adds a dialog to the top layer; show() leaves it in ordinary flow and ordinary stacking. That is what lets several editor panels coexist and order among each other — and against page content — by z-index.

showModal() is implemented, and it is where inert earns its keep. It does three things show() does not: joins the top layer, makes everything outside it inert, and closes on Escape via a close watcher (a cancelable onCancel, then close()). Focus trapping is not a fourth feature — it falls out of inertness, which is why there is no trap code anywhere. Nesting works and unwinds in order.

A modal also gets a __backdrop__ scrim: an shadow part promoted to the top layer just before the dialog, so it paints behind it and covers the whole window (100% against the initial containing block, same as the dialog's own offsets). Not a ::backdrop pseudo-element — the style engine has none — but the same idea via the substitute the widgets already use. It is setHitTest(false) and inert, because it is decoration rather than a control.

Escape on a modeless dialog still does nothing, and that is not an omission — only showModal() establishes a close watcher, so browsers behave the same way. A live drag also eats Escape ahead of the modal, because a drag is the innermost live interaction.

Escape does not close it, and that is correct. Only showModal() "establishes a close watcher", the machinery that turns a close request into a cancel event and then a close — so browsers do not close a modeless dialog on Escape either. The affordance is the __close__ button instead, which browsers leave to the author because their dialogs ship no chrome. (Escape during a drag cancels the drag — Input consumes it before focus routing, so the innermost live interaction wins.)

Focus follows the spec: the focus delegate (first focusable descendant) else the dialog itself on show(), and on close() focus returns to whatever held it beforehand. Clicking the title bar also focuses the dialog — without that the ring is only ever produced programmatically and looks like a glitch, since FocusPolicy.FOCUSABLE excludes click.

Moving is ours — nothing in CSS or HTML moves an element by pointer. It runs on the P2 positional drag from __title-bar__, and writes left/top at INLINE origin, matching what CSS resize mandates for the size it writes. One rule covers both: user-driven geometry is inline, so an author's !important can pin a dialog.

The position is a field, never re-read from the resolved box. The re-clamp ticker runs during advanceFrame, before layout, so just after a reopen the box is still the zero-sized display: none one — deriving position from it snapped every reopened dialog to (0,0).

The title ellipsizes, and it is the one place this engine truncates text by default. CSS does not: text-overflow's initial value is clip, and no browser user-agent sheet ellipsizes generic text — spilling content is the correct default, since the content is the author's. A title bar is chrome though, with a close button pinned to its right edge that a long title paints straight over, and every native window manager ellipsizes window titles for that reason. default.css therefore gives __title-bar__ .__label__ the web's canonical truncation recipe verbatim: flex: 1 1 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis. Override on getTitleLabel() if the whole title matters more than the button.

flex-grow/flex-basis: 0, not flex-shrink: 1 — and the difference is not cosmetic. Shrinking from the label's intrinsic width makes its box depend on its own measured glyphs, so a title that fits by a fraction of a pixel truncates anyway and loses a whole real character to an ellipsis it never needed. (Observed: panel one rendering as panel on.) Growing from zero sizes the label to what is left after the close button, which is the actual intent and has no such edge. min-width: 0 is what permits it at all — a flex item's automatic minimum is its min-content size, which for a nowrap line is the entire line.

  • Tag dialog · internal __title-bar__, __content__, __close__, __label__ on the title text, and __backdrop__ when shown modally · Scenes: cgui-gallery (Dialog page, modal page)
  • Combines with resize (§12): the gallery's second panel is both movable and resizable.

11b. Popover, Menu, MenuItem, Dropdown

The Popover API port, plus the two widgets on top of it. A dropdown and a context menu are the same class — Menu — anchored to an element or to a point, which is how the web does it too.

Menu menu = new Menu();
parent.append(menu);                    // must be in the tree to be promoted
menu.addItem("Cut").attachListener(...);
menu.showFor(button, button);             // dropdown-style: under an element
menu.showAt(x, y, null);                  // context menu: at the pointer

Dropdown quality = new Dropdown("Quality");
quality.addOptions("Low", "Medium", "High");
quality.attachSelectionListener(index -> ...);

A popover built per opening removes itself with removeWhenHidden() — hide() keeps a popover parented so it can be shown again, so one built on every press and never removed stays in the tree, hidden, one more per press. It waits for the close fade, as Dialog.removeWhenClosed() does.

Popover palette = new Popover().removeWhenHidden();
window.topLayerNode().append(palette);   // not beside the button, or presses inside bubble back to it
palette.showFor(button, button);

Popover.Mode.AUTO gets light dismiss + Escape; MANUAL gets neither. Placement is setPreferredSide + setOffset, resolved by AnchoredPlacement — never set left/top yourself on one, it fights placement every frame.

Menu refuses public children (items only), focuses its first item on open, and handles Up/Down (wrapping, per the ARIA pattern), Home/End; Enter/Space comes from Button. The whole menu is one Tab stop — its items are CLICK_NOT_TABBABLE, and unlike Tab the stop does not rove, because an open menu holds focus outright and Tab has nothing to do inside it.

A menu is never taller than the window. Past that its rows scroll inside ::part(scroller), a ScrollerView, and the keyboard scrolls the focused row into view — so a submenu listing fifty groups is a scrolling list, as a font picker's is. A menu that fits shows no bar.

An icon before a row's label comes from its command, so every menu showing the command draws it; a menu holding any such row reserves the icon column on every row (__has-icons__), so labels stay aligned:

Command.of("new.button", "Button").icon("crystalgui:nodes/ui/button", GlyphRole.CONTROL.cssClass());
item.setIcon("crystalgui:general/action/add", null);   // on a MenuItem built by hand

The class lands on the row and tints the icon through menuitem.<class>::part(pre-icon). A checkable row draws its checkmark in that slot instead and takes no icon.

Submenus open on hover, after Menu.DEFAULT_SUBMENU_DELAY (0.4s — Windows' own MenuShowDelay default; setSubmenuDelay to change it). Instant opening makes submenus flash as the pointer sweeps past. addSubmenu also adds a > indicator in the __post-icon__ slot (__submenu-arrow__, replaceable via setPostIcon) and marks the row __has-submenu__. Right arrow opens immediately — a keypress is never an accidental sweep; Left closes back into the parent.

The fade-in hangs off a state class, not a starting style. A popover opens out of display: none, so a transition on opacity has nothing to interpolate from — the wall @starting-style exists to solve. Popover toggles __open__, and default.css keeps a closed popover at opacity: 0 and an open one at 1, which gives the transition a real from-value. The duration lives in the sheet; drop that line and popovers snap in.

An earlier attempt hand-rolled the starting style — one frame of opacity: 0 at IMPORTANT origin, removed on the next tick — and silently defeated itself: that 1 → 0 write is a transitionable change too, so the engine eased toward zero and the removal retargeted it back before it arrived. Nothing visibly faded, and no test noticed. Don't reintroduce it.

Choosing a leaf closes the whole chain of menus; Escape peels one level. Popover.hideChain() walks the invoker chain (parentPopover()) and closes it — what the ARIA pattern means by "activates the item and closes the menu" — and stops at a popover that only CONTAINS the menu, since continuesChain() is true for a Menu alone: picking from a dropdown inside a panel popup closes the dropdown, not the popup. hide() alone closes a popover and its descendants, which leaves a submenu's parent standing.

Pass an invoker only for a toggle. showFor/showAt's invoker is excluded from light dismiss, which is what a dropdown button needs (its own press must not close the menu it just opened) and wrong for a context menu — naming its trigger surface makes that whole surface unable to dismiss the menu. Nothing is lost by passing null: a popover opened during a press is already protected from that press.

Menus do focus-follows-hover. Hovering a row focuses it via Input.requestPointerFocus, so exactly one row is ever highlighted and the keyboard continues from wherever the pointer left off. requestPointerFocus is the no-ring, no-scroll variant — a focus ring trailing the mouse is what :focus-visible exists to prevent, and menuitem's row highlight is its focus affordance instead.

Submenus go through Menu.addSubmenu(label, child), never addItem plus a listener. It wires the three things that are easy to forget: the item does not close its parent (an ordinary item does), the child anchors to the row rather than to the menu, and it prefers Side.RIGHT so it sits beside rather than on top. MenuItem.getSubmenu()/hasSubmenu() expose the relationship. Closing a parent closes its submenus.

Dropdown is a Button that owns a Menu as an shadow part and keeps its label in step with the selection. Pressing it toggles. Its writeState records the index, not the text — the label is derived, so restoring the text would put the right words on a control that still thinks nothing is selected.

  • Tags popover, menu, menuitem, dropdown · internal __items__ (Menu), __menu__ (Dropdown)
  • Scene: cgui-gallery (menus page — dropdown, context menu, submenu)
  • Known gap: a decoded menu comes back empty, and a dropdown with no options — items and options live in internal containers, the same gap TabView has with its tabs.

12. resize — an element capability, not a widget

Leading-edge handles exist only for out-of-flow elements. A top or left handle must move the origin so the opposite edge stays put, and left/top only place an absolutely positioned box — on an in-flow one they are a relative offset that slides it over the sibling above while everything below carries on as if nothing moved. So an in-flow element gets right, bottom and the bottom-right corner, which is exactly the set CSS offers (and CSS never moves the box at all); a positioned one like Dialog gets all eight. The set is rebuilt when position changes, since position and resize are independent.

CSS resize (CSS UI 4) is ambient on any element, exactly as overflow makes any element a scroll container. Setting it adds an internal __resizer__ grab handle; clearing it removes one.

panel.generalStyle(g -> g.resize(Resize.BOTH));   // or in CSS: resize: both;
  • The resulting size is written at INLINE origin — the spec says the UA replaces declarations in the style attribute "without !important", so an author's !important still wins. Every other code-driven geometry write in this engine uses IMPORTANT; this is the deliberate exception.
  • No clamping in the resizer: min-width/max-width/min-height/max-height are the spec's only constraints and Taffy already applies them.
  • Eight handles — four edges, four corners. Not a divergence: the spec says only that the UA "presents a bidirectional resizing mechanism" and never prescribes one corner grabber. Which handles exist follows the resizable axes, so horizontal gets the two side edges and no corners.
  • A leading edge moves the box too — growing leftwards keeps the right edge still, which CSS's single bottom-right grabber exists to avoid ever needing. Dialog overrides applyResizeOrigin so its own clamped position stays the source of truth rather than being overwritten each frame.
  • Divergences: applies regardless of overflow (browsers restrict it to scroll containers, half a rendering artifact of the grabber living in the scrollbar gutter); and no block/inline values, which are writing-mode-relative and would be silent aliases here.
  • Anything resizable should normally also set overflow — the other half of that restriction did real work, guaranteeing a resizable box contains its content.

The rule this establishes, worth reusing: if the web expresses it as a CSS property, make it ambient on UINode; if the web expresses it as an element, make it a widget.


12b. CanvasView — the pan/zoom plane

com.crystalgui.widget.canvas · tag canvasview · cgui-gallery → canvas page · P6.2.2

An unbounded plane viewed through a fixed window: wheel-zoom about the cursor, middle- or Space+left-drag to pan, fitToContent(), and off-screen culling. The substrate the node graph sits on, and deliberately not graph-specific — nodes, ports and wires are 6.2.3's.

CanvasView canvas = new CanvasView();
canvas.addNode(box, 120f, 80f);              // world coordinates
canvas.zoomAt(2f, event.getPosition().x(), event.getPosition().y());
canvas.fitToContent(30f);
Vector2f world = canvas.screenToWorld(rawX, rawY);
CanvasView            overflow: hidden — the window, and the element gestures are read on
  └── __content__     absolutely positioned, carries translate(pan) scale(zoom)
        └── nodes     the caller's elements, positioned in world coordinates
  • It is a viewport, so it refuses public children. A child of the viewport would sit outside the transform and stay nailed to the screen while everything else panned. Use content() or addNode.
  • A positive wheel notch means the wheel rolled down, so it zooms out. The sign is not guessable and the only thing in the repo that states it is ScrollerView, which grows scrollTop by a positive delta. Taking it at face value ships a canvas that zooms in when you scroll down — wrong in a way every user notices immediately and no test catches, because a test written from the implementation agrees with the implementation. Pinned by asserting both consumers in one test.
  • Zoom is CSS transform, which is layout-free by construction — scaling the plane cannot reflow anything, and Transform.applyTo is shared by the render pose and the hit-test chain, so clicks follow the picture with no code in the widget. That is why this is a few hundred lines.
  • transform-origin is pinned to 0 0 at IMPORTANT. It defaults to 50% and every conversion here assumes the plane scales about its top-left; a theme setting it would offset the whole canvas by half a viewport, scaled. Pinned rather than compensated for, so there is one answer rather than two.
  • pan is measured after zoom — a screen-space offset, not a world one. That is what makes a pan drag a plain addition of the pointer delta at any zoom. centerOnWorld is there for when you genuinely want world units.
  • A bare left-drag does not pan. It is reserved for 6.2.4's marquee. Middle-drag always pans; Space+left is the escape hatch for a mouse with no usable middle button (Figma/Blender/Photoshop's answer). The gesture is read in the capture phase so it beats whatever is under the cursor.
  • Three coordinate spaces: world (what you author), logical (what Box.x() and UIElement.toLocal speak), physical (raw pointer pixels). screenToWorld crosses all three; worldToViewport returns logical, not physical, and says so.

Culling skips paint, not layout

Off-screen nodes get opacity: 0 at IMPORTANT origin, which the paint walk early-returns on. display: none is the obvious choice and is wrong here: a culled node's layout collapses, and its layout rect is precisely the input the cull decision is computed from — so it could never be un-culled without a cache of where it used to be, which goes stale the moment anything moves it. Keeping layout live makes the decision self-correcting on every tick, and costs no relayout as nodes cross the viewport edge, which panning does constantly. What is given up is layout cost for off-screen nodes, which is the smaller half: layout recomputes only when dirty, paint happens every frame.

The pass runs from a UIFrameTicker rather than only on view changes, because a node can move without the view moving and the canvas gets no notification of a child's layout change. It is one AABB test per node.

A node is culled by its box, so anything that paints outside its box must declare the region it draws in. The gallery's wire layer is the case: it paints Béziers across the whole graph from an element whose natural size is auto, i.e. 0×0 at world origin — so it would be culled the instant you panned off that one point, taking every wire with it while every node stayed visible. Giving it the grid's extent fixes it and makes the cull correct: the wires now disappear exactly when no wire is on screen. 6.2.3's real wire layer inherits this.


12c. The node graph — GraphView, GraphNode, NodePort

com.crystalgui.widget.graph · tags graphview / graphnode / nodeport · cgui-gallery → graph page · P6.2.3

Unity Shader Graph's construction, literally: a title bar, two port columns, controls, a preview slot, and wires that take their colour from the ports they leave.

GraphView graph = new GraphView();
GraphNode position = new GraphNode("Position");
NodePort out = position.addOutput(VEC3, "Out");
position.addControl("Space", spaceDropdown());
position.preview();                       // the slot is created on first ask
graph.addNode(position, 20f, 30f);
graph.connect(out, add.addInput(VEC3, "A"));

GraphView extends CanvasView, so it inherits pan, zoom, fit and culling — and reports the tag graphview, which matches no canvasview rule. The viewport's structural styling is written from Java at DEFAULT origin precisely so the subclass gets it for real.

Ports

  • PortType is an interface, not an enum. The types a shader graph carries are GLSL's, and the manifesto is explicit that the graph is a visual editor for the .shader format — so CrystalGUI ships the interface and the registry, and the consumer ships float/vec3/sampler2D. Compatibility is asked of the source type and is deliberately asymmetric: GLSL promotes a float to a vec3 and does not demote.
  • type.cssClass() is type-<id>, carried by the port. The palette — dot and wire alike — is therefore a stylesheet's business. NodePort.typeColor() reads the dot's computed border-color back out of the cascade, which is what keeps the number's source in CSS even though CgVectorRenderer needs an int.
  • One edge per input, many per output. Dropping onto an occupied input replaces; the displaced edge leaves through the same disconnect() as a manual one, so 6.2.4 can make the pair one undoable command.
  • nodeport:blank means unconnected — isBlank() is overridden. It drives the hollow-vs-filled dot and whether the type's inline editor shows, so both are CSS rather than Java.
  • The dot is decorative; the port is the target. The dot is 8px and a pointer is not, so the dot and label are setHitTest(false) and the port's padding is the hit area.

Nodes

  • The node paints no background. Each region paints its own and the port band paints none, so a wire — drawn under every node — shows through and reads as plugged into its dot rather than cut off at the border. The title bar and preview carry the corner radii the root no longer paints.
  • Collapse hides unconnected ports, not just the body — a stylesheet rule over nodeport:blank.
  • Selection is :checked via an overridden isChecked(). The flag on the node is view state; 6.2.4 owns the model, and GraphView.selectNode is the smallest click behaviour that works until then.
  • Dragging moves it in place — left/top are rewritten, nothing is rebuilt. The node is a safe drag source (unlike the plane in a pan) because layout is not in the transform matrix, so its own coordinate space stands still while it moves through it.
  • Clicking focuses it, by asking. FocusPolicy.CLICK is not enough: the input handler focuses the exact element hit, which is the title bar. See the note in GraphNode.focusOnPress.

Wires

One NodeWireLayer paints all of them in a single ctx.curve() batch — a trade: no Taffy node or draw-call boundary per edge, at the cost of per-wire CSS state. It sits at world (0,0), which makes its own origin the plane origin, and is cull-exempt (CanvasView.setCullExempt) because culling asks an element's box where it is and a painter's box says nothing about where it draws. It culls per wire instead. Stroke width is clamped against the canvas's zoom so a pose-scaled 2px wire does not vanish at 0.2×.


12d. The config kit — ConfigForm over Property

com.crystalgui.widget.config · tags configuratorpanel / configurator / configuratorgroup and one per control (numbercontrol, slidercontrol, booleancontrol, selectcontrol, textcontrol, colorcontrol, vectorcontrol, matrixcontrol, maskcontrol, assetcontrol, arraycontrol, anchorcontrol, infocontrol, notecontrol, headercontrol) · cgui-new-gallery → config kit

Blender's layout.prop, drawn as Unity's inspector. A ConfigDescriptor says what a field is, a Property says where its value lives, and a form builds the control and binds it. The same calls fill an inspector tab, a preferences page, a tuning window and a tool's toolbar page.

// An inspector section
public void build(ConfigForm form, DataContext ctx) {
    Node node = ctx.get(NODE);
    form.header("Transform");
    form.prop(ConfigDescriptor.number("rotation", "Rotation").unit("°"),
            Property.derived(node::rotation, node::setRotation).editedIn(document.undoStack()));

    ConfigForm about = form.group("About", true);
    about.prop(ConfigDescriptor.info("id", "Id"), Property.derived(node::id));   // read-only
}
// A tool's page in a ContextToolbar: a letter per number, the full name on hover
ToolbarForm form = ToolbarForm.into(this);
form.prop(ConfigDescriptor.anchor("pivot", "Pivot"), pivot);
form.separator();
form.prop(ConfigDescriptor.number("w", "Width").shortLabel("W").unit("%"), scaleX)
        .describeWith(Property.derived(() -> "Width: " + Math.round(box.width()) + " px"));
form.prop(ConfigDescriptor.bool("link", "Maintain aspect ratio").toggle(true).shortLabel(""), linked);
// A panel of its own
ConfiguratorPanel panel = new ConfiguratorPanel();
PanelForm form = panel.form();
form.note("Rim is the hairline at the boundary; glow is the broad falloff.");
form.prop(ConfigDescriptor.number("blur", "Blur").range(0f, 40f),
        Property.derived(() -> (double) glass.getBlurRadius(), v -> glass.setBlurRadius(v.floatValue())));

A value stored in another shape is mapped, never converted in the control — a shader graph keeps GLSL text, and its colour field binds stored.map(ShaderColorFieldWidget::parseVec4, ShaderColorFieldWidget::formatVec4).

Kind Property type
NUMBER — a slider when it has a range Double
TEXT, SELECT, ASSET, INFO, NOTE String
BOOLEAN Boolean
COLOR Integer ARGB
VECTOR, MATRIX, ANCHOR double[] — an anchor is [x, y] as fractions of the box
MASK Set<String>
ARRAY List<Object>
  • A bound field needs nothing wired. An edit writes the property; a change made anywhere else shows up — at once for a stored or announcedBy property, after the next layout for a polled one — and only while the field is in a tree, re-reading when it comes back. A host reacts to an edit in the property's writer.
  • A field holding a typed edit is never overwritten, as in Dear ImGui and Unity. What moved meanwhile shows once the edit lands or is dropped.
  • A scrub or a slider drag is one step of the property's editedIn history, however many values it writes.
  • setValue on a bound control writes the model — there is no second copy to set. It emits nothing; a user edit emits changed.
  • The label is the scrub handle, in a row and in a toolbar cell alike. A pixel is worth a hundredth of the field's range, or one unit when it has none; Shift ×10, Ctrl ÷10; scrubRate overrides both.
  • Prefer prop to row. row starts at a value and follows nothing — a fact read once.
  • A toolbar cell shows the shortLabel and the full label on hover, hung from the cell so it clears the bar. group in a toolbar starts a new cluster behind a separator; there is nowhere to fold.
  • A kind with no registered control throws — a blank field is what the registry exists to prevent.
  • A PanelForm group remembers being closed across a refill of its panel.
  • Geometry is ua/inspector.css. The one layout in Java is that a toolbar page, a cell and an anchor's lines are rows, at DEFAULT origin, as a Button is.

Refilling — a panel that follows a selection

A panel describing whatever is selected fills itself again on every change. refill keeps what the last fill placed wherever this one places the same thing, so the next node of the same kind costs its values rather than a new subtree — no teardown, no new elements, no restyle, no relayout. The Inspector fills its tabs this way.

panel.refill(form -> {
    form.header("Transform");
    form.prop(ConfigDescriptor.number("x", "X"), node.x());   // last fill's row, bound to this node
    Configurator left = form.prop(ConfigDescriptor.text("left", "Left"), node.left());
    left.addClass(SET_CLASS);                                  // reset for you before the next fill
    left.decorations().add(scrub.attach());                    // taken off by the next fill
});
  • A prop row is kept when its descriptor has the same shape (ConfigDescriptor.sameShape): every stated value equal, each supplier present on both or neither. Its control adopts the new descriptor and binds the new property, so a validator or range over the new subject is the one it asks. State the whole descriptor before placing it — one changed afterwards no longer matches.
  • A group is kept by its title and refilled in turn; a separator by position.
  • A custom element, or a control row's control, is kept only when it is Refillable — handed the one this fill built, it takes over what that one shows. Otherwise it is replaced, which is always correct.
  • Use what a placement returns — it is the element on screen.
  • A kept row forgets what the last fill did to it: the classes it added, a hidden or inert state and an editedIn are undone. A listener, watch or hook cannot be, so it goes in row.decorations() — or it stays on a row the next fill reuses and acts on whatever was selected before.
  • A control reads its descriptor's questions when it asks them, through descriptor(), never from a copy taken at construction; one that derived state from them re-derives it in descriptorAdopted.

A descriptor also says how a field READS, and these four are what a dense inspector row needs:

ConfigDescriptor.text("id", "id").placeholder("none").description("Unique in the document.")
ConfigDescriptor.array("sizes", "Sizes").inlineList(true).emptyText("No sizes")
ConfigDescriptor.number("left", "Left").scrubRate(() -> 1d / zoom)   // units per pixel, asked per drag
  • placeholder draws in an empty field — grey, and gone as soon as there is a value. description is the row's hover text, on the label as well as the control.
  • inlineList lays an ARRAY out as rows in the field column rather than a folding group, with its add and remove buttons at the first row's height; emptyText is what it says with nothing in it. A new row starts blank and is committed only once typed into, so a list never gains an empty entry by being opened.
  • scrubRate overrides the label-drag rate, and the DoubleSupplier form is asked at each step — what a canvas that zooms needs, where a pixel is worth a different number of units every frame.

A number's range, unit and step can follow another value — the control keeps asking while it is shown, so nothing holds the control to push a bound at it:

form.prop(ConfigDescriptor.number("stroke", "Stroke")
        .range(() -> new ConfigDescriptor.Range(0f, em() ? 13f : cap * fontSize()))
        .unit(() -> em() ? "%" : "px")
        .step(0.1f), stroke);
  • A control built of numbers copies its descriptor with part(id, label) — a slider's field, a vector's X. Every attribute and supplier comes along; a hand copy drops whichever one it forgets.
  • A row's Ctrl+Z reaches the property's editedIn history with nothing named on the row.

A control of your own

Anything that edits a value is a ValueControl<T>, so it follows its property exactly as the kit's do:

public final class OffsetPad extends ValueControl<double[]> {
    public OffsetPad(String id) {
        super(Name.of("offsetpad"), ConfigDescriptor.vector(id, "", 2), new double[2]);
        StyleGizmos.drag(this, this::beginInteraction, (dx, dy) -> commit(offsetBy(dx, dy)), this::endInteraction);
    }
    @Override protected void writeToWidgets(double[] at) { /* draw the value; never emit */ }
}

panel.append(new OffsetPad("offset").bind(shadow.map(Shadow::offset, at -> shadow.get().withOffset(at))));
  • Draw in writeToWidgets, report with commit, and bracket a drag with beginInteraction / endInteraction so it is one undo step. Never keep a copy of the value in a field.
  • A view that is not a control — a caption, a preview — follows with PropertyWatch.follow(element, property, show), or LiveEdits.follow(element, styleProperty, css) to show a declaration on an element.
  • Children redrawn from the value are kept with ChildList, never removeAll and append: a rebuild destroys the row the pointer is pressing. Children with an identity — a row per declaration — use ChildList.Keyed, which adds and removes only the keys that came and went.
  • A list of rows in a panel builds each with panel.row(descriptor, value), which registers without placing, and hands a removed one to panel.forget(row).
  • Parts of a composite value are Property.maps of the whole, and a selection is a Property.of(0) shared by the controls that edit through it. Write the setter as v -> whole.get().withX(v), never whole.get()::withX, which reads the whole once when the lab opens.

Chips for a list of names — ClassChips

com.crystalgui.widget.config.control · tag classchips

ClassChips chips = new ClassChips(ConfigDescriptor.of("classes", "classes", Kind.ARRAY), List.of("title"));
chips.setSuggestions(() -> sheetClassNames());                    // what the prompt completes from
chips.setFlagged(name -> !sheetClassNames().contains(name));      // drawn as a warning
chips.setAccepts(name -> !ClassNames.isEngine(name));             // never added, typed or suggested
chips.bind(fields.classes(node));

Chromium DevTools' .cls pane, with Unity UI Builder's removal: the prompt completes by prefix, several names separated by spaces go in at once, Enter or Tab takes the highlighted suggestion or else what was typed, a chip's cross removes it, and Backspace in an empty prompt takes the last chip. The value is the whole list in order, and a name already present is not added twice.

::part-less by design — it is a config control, so its pieces are classes a theme reaches directly: __chips__, __chip__, __chip-label__, __chip-remove__, __flagged__, __chip-prompt__, __chip-suggestions__, __chip-suggestion__, __active__, __chip-hint__.


12e. Editing a tree — TreeEditing over TreeEditModel

com.crystalgui.widget.collection.tree · consumers: the Project panel (ExplorerEditModel) and the Hierarchy panel (HierarchyEditModel)

What a file manager does to files, for any TreeView: drag rows to move them (a modifier copies), cut, copy, paste, duplicate, F2 rename and delete, over the whole selection, from keys, the Edit menu and a right-click. The kit decides what a gesture means — which items, which parent and index, move or copy, which name. The model performs it.

private static final TreeClipboard<Node> CLIPBOARD = new TreeClipboard<>();   // one per KIND of tree

editing = new TreeEditing<>(tree, this, this::itemForRow, tree::refresh, CLIPBOARD);
editing.setModel(new NodeModel());
editing.attachContextMenu(CommandRegistry.global(), () -> ContextMenu.of(NODE_MENU));
TreeEditing.contributeMenu(CommandRegistry.global(), NODE_MENU);   // once, by the feature; dispose it with the feature

// the renderer
public UIElement createTemplate() { ... editing.installRow(row, field); ... }
public void bind(Node node, TreeRow<Node> row, int i, UIElement template) {
    editing.bindRow(template, label, field, node);
}
final class NodeModel implements TreeEditModel<Node> {
    public boolean isOrdered()                   { return true; }   // a document's order: before / into / after
    public Node parentOf(Node node)              { return node.parent(); }
    public int indexOf(Node node)                { return node.parent().children().indexOf(node); }
    public boolean isContainer(Node node)        { return node.takesChildren(); }
    public boolean canEdit(Node node)            { return node.parent() != null; }
    public String nameOf(Node node)              { return node.name(); }
    public void move(List<Node> nodes, Target<Node> to) { document.applyAll("move", moves(nodes, to)); }
    public void copy(List<Node> nodes, Target<Node> to) { document.applyAll("copy", copies(nodes, to)); }
    public void delete(List<Node> nodes)         { document.applyAll("delete", removals(nodes)); }
    public void rename(Node node, String name)   { document.apply(new Rename(node, name)); }
}
Ordered (isOrdered() == true) Unordered — a sorted listing
A drop on a row top quarter before it, bottom quarter after, middle into it when it is a container; a leaf has only halves; after an open container is its first child — VS Code's listView.ts into it when it is a container, else into its parent
Paste after the last selected item, in its parent into a selected container, else beside the selected leaf
Duplicate offered, just after the last selected item not offered
Row mark __drop-before__, __drop-target__, __drop-after__ __drop-target__
  • Every verb acts on the selection, outermost items only, in tree order. A row inside another selected row travels with it.
  • Cut is performed at paste. Until then the held rows carry __cut__ (dimmed by the UA sheet) and nothing moves. A copy stays for the next paste; a cut is spent by one.
  • Keys are on the tree's own keymap — Mod+X/C/V, Mod+D, F2, Delete — so they never fire while typing elsewhere. Every tree also answers TreeViewCommands without the kit: Expand Selected (Mod+=), Expand All (Mod+Shift+=), Collapse All (Mod+-). The Edit menu reaches Cut/Copy/Paste through ClipboardActions, which the kit installs on the list.
  • A right-click inside the selection keeps it; outside, the clicked row becomes the selection.
  • A drag rests to open and pushes to scroll, as VS Code's lists do: 500 ms on a closed branch it would land in opens it, and a pointer held within 24 px of the top or bottom scrolls the tree, faster the deeper it goes. Both run while the pointer is still, since a Drag sends Over only when it moves.
  • canEdit gates cut, delete, rename and dragging; canDrop gates where a paste or drop may land (by default a container, and not the item itself or anything under it). Both are asked before a verb.
  • copyModifier is Ctrl by default; a design tool's model answers Alt, where Ctrl is the row toggle.
  • labelOf is what a ghost and the clipboard text say, when a name can be empty — a node with no id.
  • The commands resolve TreeEditing.KEY, which the list answers for rows inside it (ListView.putData). A host that is also a document-level data provider must not answer it, or a palette Delete elsewhere reaches this tree.

12f. The frame readout — FrameStatsOverlay over FrameStats

com.crystalgui.widget.display / com.crystalgui.core.trace · tag framestats · cgui-desktop (F7 shows, F8 expands)

How fast the last few seconds ran, drawn over whatever is being measured. The collector is headless and the overlay is one call, so the same readout serves a harness scene, the editor and a Minecraft screen.

FrameStatsOverlay.toggleOn(document);                         // F7 — find-or-attach, then show/hide
FrameStatsOverlay.expandOn(document);                         // F8 — show, and expand the phases
FrameStatsOverlay hud = FrameStatsOverlay.attach(document);   // top-right, promoted, hit-tests nothing
FrameStatsOverlay.of(document);                               // the one already there, or null
hud.stats().setBudgetMs(1000f / 144f).setWindowSeconds(5f);   // this host's refresh rate

Every surface with a desktop already has it on a key. DesktopCommands registers desktop.frameStats (F7) and desktop.frameStatsDetail (F8) — the harness scene's own keys, so a gesture learned while profiling a scene is the gesture in the editor and in game. They carry no menu entry on purpose: a frame readout is an instrument turned on the application rather than a feature of it, and it lives where every other one does — a documented key and the command palette.

116 fps   8.3ms wall   8.1ms cpu                 <- the rate, and what it cost to produce
3s: best 5.7  p50 8.1  p95 9.6  worst 35.1       <- the spread behind that average
1% low 29 fps   over 16.7ms: 6/348   GC 4ms      <- the stutter, the misses, the collector
▁▁▂▁▁█▁▂▁▁▁▃▁▁▁▂▁▁▁▁▁▁▂▁▁▁▁▁▁▂▁▁▁▁▁▁▁▂▁▁▁▁▁▁    <- the shape: oldest left, one bar per column
paint:tree 6.31ms  frame:layout 1.01ms           <- where it went; expands to a row each
jobs-busy=0 drawcalls=31 svg-raster-draws=153

Expanded (toggleDetail), the phase line becomes a column — the question after the summary has said there is one:

slowest 13.1ms cpu: phases 12.9ms
  style                 6.20ms  48%
  layout                3.11ms  24%
  paint                 2.40ms  19%
  gl:draw               1.18ms   9%
  • Wall and CPU are different questions. Wall is the interval between frame starts, vsync included — what the rate is made of. CPU is what the frame spent working. Under vsync the first sits at the refresh rate while the second says how much headroom is left, so a readout with only the first reports 60fps right up until it collapses.
  • A frame's sample is committed at the NEXT frame's start, because its wall time is the interval to it. That also means a document framed without being painted still reports a rate: the end of a frame is the painter's last line, and a HUD that waited for one said "warming up" forever in a test.
  • Green/amber/red is a verdict, and every verdict is about the typical case. The rate row judges the window's average frame, the spread row its p95 and the miss row the share that missed. Judging the worst frame instead makes the row permanently red — measured at a steady 117fps, every three-second window holds a frame over twice the budget. The worst frame is shown; it is not the judgement.
  • A row that states rather than judges gets no colour (Health.NONE) — the phase breakdown has no budget of its own to be measured against, and green on it reads as "this part is fine".
  • The bars carry a verdict per character, and that is not the same verdict as their height. A bar's height is its share of the window's own worst frame, so the tallest bar in a flawless run is as tall as the tallest bar in a terrible one; the colour is healthOf against the budget, the same judgement the three rows above are making. Between them one glance answers both questions — which frames were the slow ones, and whether slow here means anything. A column is the worst frame in its bucket and never the mean, since averaging a bucket is exactly what erases a spike. FrameStats.spark(columns) hands back both; Row.barHealth() carries them to a renderer that can colour a text range, and a renderer that cannot ignores it and loses only the colour. The overlay colours them through ::highlight(framestats-warn|bad) over runs — a node per bar would be forty-six Taffy nodes rebuilt ten times a second, inside the thing that measures that sort of cost.
  • Expanded, the breakdown is the SLOWEST frame's in the window, not the last one's. The readout refreshes ten times a second against a scene running at a hundred, so the frame whose phases are on screen is one arbitrary frame in ten — and the one worth reading is the spike, which is never the one still showing by the time an eye reaches it. The peak is held by CPU rather than wall time (wall includes waiting, and there is nothing in a vsync wait to fix) and it decays with the window, or the breakdown would describe startup forever: the first frames of any scene carry every lazy allocation and every shader's first compile, and nothing afterwards would ever beat them. The collapsed one-line hint stays the live frame's.
  • The thresholds are the engine's and the colours are the sheet's: a row wears __good__ / __warn__ / __bad__ and ua/overlays.css says what those look like.
  • Collecting is held, not switched. hold()/release() are counted, the overlay takes one while it is showing and in a tree, and with no holder the per-frame cost is one mask test. A hold enables CrystalGUI's trace channels — crystalgui.frame and crystalgui.flow, but never crystalgui.blame, which walks a stack per invalidation and is asked for by name.
  • FrameStats keeps no storage of its own. It is a view over CgTrace's frame ring (plan/platform-trace-engine.md), which is why the expanded breakdown can be the slowest frame's: every frame's zones are still there, so there is no peak to hold and no decay to get right.
  • The phase rows are zones on UiTrace.FRAME, written straight to that engine — a per-frame count is CgTrace.add, and a chain that outlives a frame is a span on UiTrace.FLOW, because it is not a phase. Recording follows the channel mask rather than a property, so a readout can ask for the phases without a restart, which is exactly when a stall is least reproducible; -Dcrystalgui.frameprofile=true adds the per-slow-frame LOGGING on top and nothing else.
  • A share is of the phases' own total, not of the frame. Phases nest and the gaps between them are nobody's, so a column measured against the frame would sum to 140% one frame and 60% the next, which reads as a broken profiler rather than an honest total.
  • Monospaced deliberately: every number is rewritten ten times a second, and in a proportional face each rewrite shifts the rest of the line. The bars are block glyphs U+2581–U+2588, which JetBrainsMono-Regular.ttf carries — checked against the font's own character map, not assumed.

Reading the counts

The count rows are CgTrace.add(UiTrace.FRAME, ...) calls — summed per frame and written once, since add(..., "drawcalls", 1) fires once per draw. Phase timing follows the channel mask, so they all reach the HUD with no property set. The layer ones answer the question a paint-bound frame actually raises — why are there seventeen layers? — and they account for every one:

Count Means
layers-opacity · layers-mask What the layer is for. A mask layer is two targets and a composite; an opacity layer is one, and the two are removed by different things. layers-mask counts only the masks that could not be a rounded clip
layers-elided · masks-elided A layer that was not opened — an opacity folded into the draw, a rounded box with no children to clip
clips-rounded A rounded overflow: hidden clipped with no layer at all: a CgClipTable entry its children's draws carry
segments-replayed · segments-changed A box's own paint recorded again from what it kept, against painted anew
segments-dynamic Never kept, because the node repaints itself (backdrop-filter, or a node overriding a paint hook without paintsDynamically() false). A readout with many of these and few replayed usually means this, not a broken cache
surfaces-kept · surfaces-painted A window composited from its picture, against walked again
surface-miss-dynamic · surface-miss-revision Why a window was walked: something in it paints dynamically, or something in it changed
surface-damage-kpx What a walked window executed again, in kilopixels: a caret's blink is a few, a scrolled list most of it
svg-raster-draws · svg-direct An icon drawn as one tinted quad from the atlas, against one drawn cell-by-cell. A frame full of the second is a finding
layer-clear-kpx · layer-blit-kpx Kilopixels cleared and composited. Megapixels here beside a two-digit drawcalls is the shape of a frame that is layer-bound, not draw-bound

13. Harness scenes

./gradlew :gl-debug-harness:runHarness --args="--mode=cgui-gallery"

cgui-gallery is the front door: every widget, one page each, with a live Ore ⇄ default theme toggle. The rest are focused regression scenes for what a gallery page cannot hold.

Mode Covers
cgui-gallery every widget, a page each, + theme toggle
cgui-text UIText — auto-size, wrap, font fallback, live binding
cgui-text-stress many text nodes at once — shaping/layout cost, retained-shape reuse
cgui-styling selectors, cascade, !important, transitions, background cross-fades
cgui-visual-layers overflow masking, opacity isolation, scissor vs mask

All are INTERACTIVE and stay open until you close the window — they do not exit on their own. Kill lingering java.exe processes matching harness after a run.