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.
Dropdownis a control by every reading of the word and it holds aMenu, so it cannot live in.control— that tier may name onlycontrol/text/scroll, andMenuisoverlay. Same forColorSelectorandSearchField. 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) andCGUI_SERVER_AND_SERIALIZATION.md(how widget state travels to a client).Re-verify against the code before trusting a specific signature.
These rules are enforced in code and invisible from any single class. Every widget below obeys them.
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.
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.
::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.
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.
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.
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.
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.
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.
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 oppositeimportantPipeline 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.)
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 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 isSubtree-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'ssetRequestFocusEnabled(false)in shape though not in scope. - Handing focus back afterwards is
requestPointerFocus, neverrequestFocus. The latter is the DOM'selement.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.
-
attachListener(l, capture, bubble)always subscribes the target phase. The two booleans are additive, not a mode selector. -
Per-frame work is an
Animationhook 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 returnfalselived only on the interface. Used byScroller(press-and-hold repeat),ScrollerViewandTextField(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
NAMEconstant on the class and register it from aNodeKindsservice, whichUINodeRegistry.bootstrap()runs. (register(tag, Class, factory). Without it the widget has notagName(), 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
KindInfoat 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-*tokennothing, 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/Buttonslists insideMine, a group's menu offers New Group Inside…, and renaming or deleting a group carries its subgroups. The shippedFormssplits as Unity's UI Builder splits its fields — Numeric, Text, Choice, Composite and Value — plusStructure;Controlsinto Buttons and Toggles, Inputs and Pickers;Layoutinto Containers and Panes and Tabs;Startersinto Layout, Forms and Dialogs, and Game UI (a starter's category isStarters/<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 — starterplacing the kind — the Library, the Hierarchy's New ▸ the factory Preview.samplethe card only, never placed the starter A sample's
widthis 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 withpresentInline()(popovers, menus, dialogs, tooltips) orpresentFocused()(a text field). The shipped kinds' samples arewidget.WidgetSamples, styled byua/samples.css. -
Override
writeState/readStateif the widget carries state a server would want to send or a client would want to report back. SeeCGUI_SERVER_AND_SERIALIZATION.md.
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 UINodeActivates 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 buttonIntelliJ'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 thetooltip.__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
whenToggledface shows what a press switches to, and flips on the press itself. - Name the
contextwhenever 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 ⋮.
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 flowThe 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.
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.
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 boxonCycletakes 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 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 callA 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.
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)
Switch s = new Switch();
s.setChecked(true);
s.attachListener(checked -> …); // or: s.onCheckedChangedThe 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)
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..1Drag-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)
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)
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
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· partslines,heading,line,chord· tokens--empty-state-fg,--empty-state-chord-fg - Declared by
RunPanel,HierarchyToolWindow,LibraryToolWindow,Inspector, andDockGroupfor 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.cguiin front; close every editor tab for the watermark)
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 pagingStep 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 ·ScrollerandScrollerVieware bothUIFrameTickers - Scene:
cgui-gallery(Scroller page)
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)
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 underWhen 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:checkedworks via the overriddenisChecked() - Scene:
cgui-gallery(TabView page) - Known gap: tabs and panes do not round-trip through the codec — they live in internal containers.
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 showA 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 —
contexttoolbarhas 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
GtkStackkeep 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'
ToolStripoverflow 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'saddSeparator. 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
claimandsetBase. A child appended directly is not a page and is never hidden. -
A page's fields come from a
ToolbarFormbound 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
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 againTooltip.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-topindefault.css, and the wrap width istooltip .__label__ { max-width }. The max-width has to sit on the label, becauseUITextonly 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).
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-sizeddisplay: noneone — 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, notflex-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 onerendering aspanel 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: 0is what permits it at all — a flex item's automatic minimum is its min-content size, which for anowrapline 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.
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 handThe 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 atransitiononopacityhas nothing to interpolate from — the wall@starting-styleexists to solve.Popovertoggles__open__, anddefault.csskeeps a closed popover atopacity: 0and an open one at1, 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: 0at IMPORTANT origin, removed on the next tick — and silently defeated itself: that1 → 0write 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 passingnull: 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
menucomes back empty, and adropdownwith no options — items and options live in internal containers, the same gapTabViewhas with its tabs.
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/toponly 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 likeDialoggets all eight. The set is rebuilt whenpositionchanges, sincepositionandresizeare 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
INLINEorigin — the spec says the UA replaces declarations in the style attribute "without!important", so an author's!importantstill wins. Every other code-driven geometry write in this engine usesIMPORTANT; this is the deliberate exception. - No clamping in the resizer:
min-width/max-width/min-height/max-heightare 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
horizontalgets 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.
DialogoverridesapplyResizeOriginso 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 noblock/inlinevalues, 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.
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()oraddNode. - 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 growsscrollTopby 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, andTransform.applyTois 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-originis pinned to0 0at 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.panis 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.centerOnWorldis 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()andUIElement.toLocalspeak), physical (raw pointer pixels).screenToWorldcrosses all three;worldToViewportreturns logical, not physical, and says so.
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.
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.
PortTypeis 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.shaderformat — so CrystalGUI ships the interface and the registry, and the consumer shipsfloat/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()istype-<id>, carried by the port. The palette — dot and wire alike — is therefore a stylesheet's business.NodePort.typeColor()reads the dot's computedborder-colorback out of the cascade, which is what keeps the number's source in CSS even thoughCgVectorRendererneeds 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:blankmeans 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.
- 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
:checkedvia an overriddenisChecked(). The flag on the node is view state; 6.2.4 owns the model, andGraphView.selectNodeis the smallest click behaviour that works until then. - Dragging moves it in place —
left/topare 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.CLICKis not enough: the input handler focuses the exact element hit, which is the title bar. See the note inGraphNode.focusOnPress.
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×.
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
announcedByproperty, 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
editedInhistory, however many values it writes. setValueon a bound control writes the model — there is no second copy to set. It emits nothing; a user edit emitschanged.- 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;scrubRateoverrides both. - Prefer
proptorow.rowstarts at a value and follows nothing — a fact read once. - A toolbar cell shows the
shortLabeland the full label on hover, hung from the cell so it clears the bar.groupin 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
PanelFormgroup 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, atDEFAULTorigin, as aButtonis.
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
proprow 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
separatorby position. - A
customelement, or acontrolrow's control, is kept only when it isRefillable— 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
editedInare undone. A listener, watch or hook cannot be, so it goes inrow.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 indescriptorAdopted.
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 dragplaceholderdraws in an empty field — grey, and gone as soon as there is a value.descriptionis the row's hover text, on the label as well as the control.inlineListlays anARRAYout as rows in the field column rather than a folding group, with its add and remove buttons at the first row's height;emptyTextis 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.scrubRateoverrides the label-drag rate, and theDoubleSupplierform 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
editedInhistory with nothing named on the row.
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 withcommit, and bracket a drag withbeginInteraction/endInteractionso 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), orLiveEdits.follow(element, styleProperty, css)to show a declaration on an element. - Children redrawn from the value are kept with
ChildList, neverremoveAlland append: a rebuild destroys the row the pointer is pressing. Children with an identity — a row per declaration — useChildList.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 topanel.forget(row). - Parts of a composite value are
Property.maps of the whole, and a selection is aProperty.of(0)shared by the controls that edit through it. Write the setter asv -> whole.get().withX(v), neverwhole.get()::withX, which reads the whole once when the lab opens.
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__.
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
TreeViewCommandswithout the kit: Expand Selected (Mod+=), Expand All (Mod+Shift+=), Collapse All (Mod+-). The Edit menu reaches Cut/Copy/Paste throughClipboardActions, 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
DragsendsOveronly when it moves. canEditgates cut, delete, rename and dragging;canDropgates 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.copyModifieris Ctrl by default; a design tool's model answers Alt, where Ctrl is the row toggle.labelOfis 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.
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 rateEvery 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
healthOfagainst 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__andua/overlays.csssays 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.frameandcrystalgui.flow, but nevercrystalgui.blame, which walks a stack per invalidation and is asked for by name. FrameStatskeeps no storage of its own. It is a view overCgTrace'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 isCgTrace.add, and a chain that outlives a frame is a span onUiTrace.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=trueadds 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.ttfcarries — checked against the font's own character map, not assumed.
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 |
./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.