Skip to content

Gui Editor: an editor you can build a screen in - #85

Merged
greenfire27 merged 56 commits into
developmentfrom
gui-editor-improvements
Aug 8, 2026
Merged

Gui Editor: an editor you can build a screen in#85
greenfire27 merged 56 commits into
developmentfrom
gui-editor-improvements

Conversation

@greenfire27

Copy link
Copy Markdown
Collaborator

The Gui Editor arrived in Early Access 3 as a canvas, a generic inspector and a
list of class names. This is the work that turns it into something you can build
a screen in: an illustrated palette to drag from, an Explorer that shows the tree
the way a layers panel does, a properties pane that offers only the fields the
selected class actually reads, undo, a clipboard, and a document that is not
thrown away without being asked about.

Along the way it fixed a good deal of what was underneath it. Roughly a third of
the diff is engine work — sizing flags that were named for the wrong edge, a
scroll control that never left room for its own bar, a deep clone that could not
be done at all — and each of those is a fix for everyone, not just for the editor
that found it.

52 commits on top of development. 228 files, +34,042 / −2,444.


The palette

It was a list box of class names: GuiTextEditSliderCtrl above GuiTreeViewCtrl
above GuiTextEditCtrl, which tells a person nothing about what any of them is.

It is now 30 drawn miniatures of the controls themselves, in four collapsible
groups, in either an 80px grid or a 32px row with a human-readable name beside
it. The art is generated — a 64-unit square with primary structure on multiples
of four, rendered at 16, 64 and 128 so one index names the same picture at every
size. Icons are modulated by the theme they are worn in, so they stop being a
white smear the moment anyone picks the light theme.

Clicking a tile places the control for you. That is built as a synthetic drop
through the brain's own onControlDropped, so it reaches addNewControl, fires
onAddNewCtrl, and is undoable for free rather than by a second implementation
that could drift.

The Explorer

Each row carries a 16px picture of its class, and two fixed gutter columns down
the left in the manner of a layers panel — the eye shown when a control is not
hidden, the padlock when it is locked — so a tidy Gui reads as a column of eyes
and a column of nothing. Clicking a box toggles the flag and deliberately does
not move the selection.

GuiTreeViewCtrl::onRenderItem drew a row in one unbroken run, so this needed
the row split at the two places new content goes: renderItemGutter before the
focus line and the indent (so it stays pinned to the left edge instead of
travelling with the tree), renderItemIcon between the triangle and the text.
The indent step drops from a row height to 12px, because two columns, a triangle
and a picture is already ~80px of a 228px window before the first letter.

hidden and locked moved here out of the properties pane, where they had been
standing next to Visible / Active / Accepts Input / Accepts Children as though
they were the same kind of thing. They are not — neither is ever written to a
file — and they are no longer persisted.

The properties pane

The generic C++ GuiInspector reflected every registered persist field into flat
alphabetical groups. That offers a GuiChainCtrl nine text fields it never
draws, and a GuiTabPageCtrl four geometry fields its book overwrites on the next
layout pass — editing either looks like it works and silently reverts.

GuiEditorInspectorPane replaces it: a per-class spec of what that class really
reads, purpose-built editors for the common fields (an anchor picker for sizing,
color swatches, an image picker you choose by looking at it), and a Category
dropdown that decides which profiles a control may wear.

Three kinds of content that were unreachable from an editor at all now author
themselves:

  • A tab book makes its own pages. A book dropped from the palette used to
    arrive with no pages and render as nothing whatsoever.
  • A menu bar makes its own items. GuiMenuItemCtrl is rightly refused by the
    palette, and nothing else made one, so a dropped bar was a permanently empty
    strip.
  • A list box or drop down carries the rows it was authored with, saved as
    TAML custom nodes. Previously its rows lived only in mItems and had to be
    built in script in an onWake somewhere away from the Gui that shows them.

Undo, the clipboard, and the document

Ctrl+Z did nothing: the Edit menu was switched off with a note saying so, and
twelve mutation sites in guiEditCtrl.cc carried a bare // undo comment where
the recording used to be. Almost none of what was missing was transport —
UndoManager has been in collection/undo.h all along, bound to script, owned
by GuiEditCtrl. What was missing was a recorder, and a faithful way to
duplicate a control.

  • Undo and redo, on that existing machinery. The trash is limbo rather than a
    delete, so undo never has to resurrect anything.
  • Cut, copy and paste, as deep clones in a SimGroup stash, placed through
    the same acceptControl every dropped control goes through.
  • Duplicate (Ctrl+D), which puts a copy one grid step off in the same
    parent and leaves the clipboard alone — that last part being the whole reason
    it is a command rather than a habit.
  • Delete was already implemented and already recorded, but was invisible:
    the key worked and no menu said so. It is now the last item in Edit.
  • A Gui you have changed is not thrown away without being asked. New, Open,
    Close Project and Exit all prompt; the Gui Tools title carries the document
    name and a *; File gains a Revert.

The dirty flag is not an undo depth. Save at five, undo once, make a different
edit, and the depth is five again with a different document underneath — a flag
built that way reads clean while dirty, which is the one direction it must never
be wrong in. So every action carries the serial it leaves the document at, and
isModified compares serials rather than counts.

Themes: cursors

Cursors were the last part of the GUI a theme did not own — AppCore built seven
of them out of literals, so a themed Gui got themed everything except its
pointer. They are now the third member family, with per-theme art under
<project>/themes/cursors/<Theme> so two themes in one game can carry pointers
that look nothing alike.

GuiEditorCursorCtrl is a magnifying hot-spot editor, because whether a hot spot
is the right pixel is a question about what the art looks like, not a number you
can type. It draws the art a pixel at a time rather than as a stretched bitmap
(magnifying through the texture filter would blur exactly the boundary being
aimed at) and owns its own decoded copy, since a texture handle's CPU-side bitmap
is freed after upload.

The rule the rest hangs on: bitmapName, hotSpot and renderOffset are set
once at creation and are never stamped, because art cannot be derived from a
palette and a restamp must not fight what the user chose. Only the tint is
stamped, from colorForeground — the stock art is grayscale, so multiplying it
colors the body and leaves the outline, and a new theme's cursors match its
palette with nobody drawing anything. White is a byte-for-byte no-op, so every
cursor that existed before this renders identically.

Five of the seven default placements moved once they were aimed in the editor
rather than guessed, which is the editor paying for itself on its first outing.

Engine changes that stand on their own

  • GuiControl sizing flags were named for the wrong edge. horizResizeRight
    has no branch in parentResized at all, so "right" pinned the left edge.
    Reading a Gui file meant inverting every flag in your head. The tables gain
    anchorLeft / anchorRight / anchorTop / anchorBottom and scale, listed
    first; the old names still parse.
  • GuiScrollCtrl now leaves room for its own bar. It subtracted the bar only
    where it clipped children, so content meant to fit across sized itself from a
    width that included the bar — the palette laid a grid column under it and the
    names read "Number Bo:".
  • A control that changes parent keeps the size it had. A scaled control
    cached the proportion of its parent it occupied; onChildAdded applied the
    old parent's proportion to the new parent's extent, so a 200-wide button
    moving from an 800-wide container to a 200-wide one arrived 50 wide.
  • A hidden control is out of the way, not just out of sight. renderChildControls
    skipped a hidden child; findHitControl did not — so you could see through it
    but not reach through it, which is the one thing hiding is for. Both now ask
    GuiControl::isHiddenInEditor, scoped by isEditMode so a running game
    neither pays for it nor obeys it.
  • SimObject::deepClone — a clone that copies the data and runs no lifecycle.
    clone() made a shell and copied no fields; assignFieldsFrom copied name
    and parentGroup, and the second of those is a trap, because setParentGroup
    calls parent->addObject() and the "copy" silently moves itself into the
    original's group.
  • GuiButtonCtrl no longer seeds a "Button" caption. writeField drops
    every empty value, so a blank caption is written as absent and an absent
    attribute reads back as the constructor default — meaning an author's blank
    could not survive the round trip. The placeholder moved to the moment a control
    is placed from the palette, so a dropped check box now reads "Check Box".
  • Namespace::classLinkTo treats a self-link as the no-op it is, and warns
    once in terms someone can act on.
  • A tree row can carry more than a triangle and a name (the two render hooks
    above), and GuiTextEditCtrl no longer draws two carets after a return.

Removals

  • GuiImageButtonCtrl — it existed to draw four named state images on a
    button, and a profile's imageAsset is already indexed by control state, so a
    plain GuiButtonCtrl wearing a four-frame strip does the same with a class
    less. VirtualKeyboard was its only user and has been converted.
  • GuiEditCtrl::saveSelection / loadSelection — a prefab mechanism from the
    original editor that nothing has ever called, writing through the old
    console-object format into a file nothing reads. If prefabs are wanted later,
    the clipboard is the foundation, not this.
  • GuiEditorColorWindow — a scratch pad, most of its body commented out, the
    window it builds commented out at the call site, still exec'd on every editor
    start.
  • The design docs (docs/superpowers/specs) — a working artefact of how a
    change got planned. What the code does and why is written where it is done.

Content

KeyboardToy opened to an empty screen: create() called reset() on a bare
"KeyboardToy", which is not the name of anything, so the dialog was never
pushed. Behind that sat three years of quiet rot — a GuiTextCtrl that Control
Armageddon deleted in 2021, two profiles that went with AppCore's. Both dialogs
have been rebuilt in the Gui Editor, which is also the first end-to-end use of
this branch's own editor on real content.

PlanetX gains its theme's seven cursors, each aimed by hand; a ScriptObject
named after its own class loses the redundant class; and a sound that was
silently a particle (playerDeath collided across asset families, so the death
sound has never played) is renamed.

Testing

tests\run-unit.ps1 is new — the GoogleTest side had no runner at all, and it
takes a filter rather than passing one on, because runAllUnitTests hands
InitGoogleTest an empty argv and GoogleTest reads GTEST_FILTER from the
environment.

  • 8 new unit test files, 102 new TEST blocks. All 199 unit tests pass.
  • 29 new smoke suites and 10 new shot harnesses (45 and 17 in total). All 45
    smoke suites pass on Windows against a Debug build of this branch.
  • Several suites post real WM_ input, because the promise being tested is a
    negative — that clicking a gutter box does not select the row, that a hidden
    control is not a drag target — and calling the toggle from script would skip
    the very code that could break it.

CLAUDE.md and tests/README.md now record what a unit test can actually reach,
because the limit is sharper than "no canvas" and it decides where coverage goes:
the engine boots far enough for Con, Sim, the string table and
GuiDefaultProfile, but a font registers a texture and TextureManager::refresh
asserts — which in a debug build is a modal box, so the failure arrives as a
hang. That rules out adding a row to a list box or a tree, since that calls
updateSize()getFont().

Notes for reviewers

  • The $Expected known-failure table in tests/run.ps1 is empty: every suite is
    expected to pass.
  • tooltipProfile is intermittently flaky in a full run and passes in isolation —
    it rides on a real posted mouse hover. Re-run before treating it as a
    regression.
  • The README's editor section was rewritten to describe what is actually there.
    Two claims are deliberately not made: "every edit is undoable" (a frame set
    handles its own divider drags through onMouseDownEditor, and nothing records
    those), and that the Explorer sits opposite the properties pane (it shares the
    right-hand column with the palette).
  • The window's own close button is deliberately not guarded by the unsaved
    prompt. quit() posts the quit message the moment it is called and the X posts
    it straight from the window procedure, with no script in between to veto with;
    guarding it means a hook in each of six platform back-ends.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF

greenfire27 and others added 30 commits July 28, 2026 18:40
editorIcons16 was 60 icons in a 32x2 grid indexed by raw number. Only 29
of those 60 came from the Mono set the art is drawn from -- the rest were
foreign -- and none had a variant above 16px, so a button could never be
given a bigger icon than the one slot it started in.

Rebuilt as four sheets, editorIcons16/24/32/48, each a 32x10 grid of the
same 304 icons in the same order. A frame index names the same picture at
every size, so a control can change size without changing its Frame. 16
cells spare.

A 24px or 48px cell grid can never be power-of-two -- both carry a factor
of 3 -- and TextureManager pads non-POT bitmaps to POT on upload. That is
safe here: ImageAsset takes cell positions from the original bitmap and
divides by the padded getTextureWidth(), and createPowerOfTwoBitmap puts
the content at the top-left, so the UVs come out right.

Frame numbers are now $EditorIcon:: constants from the generated
EditorIcons.cs, exec'd first thing in EditorCore::create. Indices are
alphabetical, so adding an icon reflows them -- which costs nothing
through a constant and silently draws the wrong picture through a raw
number.

Six concepts in the old sheet had no Mono equivalent. Five were dead. The
one in use was the particle graph's zoom pair, which now wears a squared
plus and minus: the set has a magnifier but no +/- variants of it, and
the squared pair stays distinct from the round plus and minus that mean
add and remove everywhere else.

tests/shots/iconSheet.cs now walks all 304 icons across all four sheets.
All 19 smoke suites pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sheet had align left, center and right but no vertical counterpart,
and the engine has had one all along -- guiTypes.h spells it TopVAlign,
MiddleVAlign, BottomVAlign, which is where the names come from (middle,
not center, for the vertical one).

Derived from the horizontal three rather than drawn. The align bars are a
square shape, so a quarter turn is a pure pixel permutation with nothing
resampled and nothing lost, and clockwise sends the left edge to the top
and the right edge to the bottom -- exactly the mapping wanted:

    align_left   -> align_top
    align_center -> align_middle
    align_right  -> align_bottom

Only the alpha is turned. The set shades its ink on a top-to-bottom ramp
that is flat within a row and independent of the shape, so rotating the
RGB would have left these three shaded left-to-right and obviously
foreign. Instead the ramp is measured off the source icon by least
squares and re-laid vertically, which puts the derived icon on the same
slope and offset as the one it came from.

The art lives in the icon set's derived/ folder, kept apart from the png/
the set shipped with, and the sheet builder now reads both.

307 icons, 13 cells spare. Indices after align_left shift by three, which
is what the constants are for. All 19 smoke suites pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two script accessors the Gui Editor's properties pane needs.

rendersChildren() exposes what already decides whether isContainer means
anything: setIsContainerFn forces the field false for a control that cannot
draw children, so offering the switch there is offering a switch wired to
nothing. Reading the engine's answer beats keeping a list of the nine classes
in script, which would drift.

applySizing() re-runs the layout against the parent's current size. HorizSizing
and VertSizing are only ever consulted from parentResized, so setting one left
the control where it was until something else happened to resize the parent.
Running with a zero delta is exactly right: the modes that describe a reaction
to a size change correctly do nothing, while center and fill -- which describe a
position the control should always be in -- take effect at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYHdKkn3xVRLjtr226EACJ
…ery control a profile

Three fixes in one file.

The sizing names were the inverse of what they did. From parentResized,
horizResizeRight has no branch at all -- nothing moves -- so "right" pinned the
LEFT edge, and "left" pinned the right one. Reading a Gui file meant inverting
every flag in your head. The tables gain anchorLeft / anchorRight / anchorTop /
anchorBottom, plus "scale" for relative, and those are listed FIRST:
ConsoleGetType returns the first label whose value matches, so the anchor names
are what a field reads back as and what TAML writes. The originals stay in the
table and still load, because ConsoleSetType accepts any label in it -- every
.gui.taml on disk and every script that spells a flag the old way keeps working.
Note that a Gui saved by this build cannot be read by an older one, which has no
"anchorLeft" and falls back to index 0 on a miss.

The alignment tables were declared with count 3 while holding 4 entries, so
"default" was unreachable. That was a bug rather than a choice: a control starts
on DefaultAlign, which getAlignmentType resolves to the PROFILE's alignment, so
it is the setting that means "inherit" and every control has it until someone
picks otherwise. Hiding it meant ConsoleGetType could not name the value it
found and answered with an empty string, and there was no way back to inheriting
once a side had been chosen.

onAdd now falls back to GuiDefaultProfile for anything that reaches registration
without a profile. GuiChainCtrl, GuiTabPageCtrl, GuiSliderCtrl, GuiTextEditCtrl,
GuiInputCtrl, GuiSpriteCtrl and SceneWindow never set one, and a null mProfile
is a crash waiting for the first thing that reads it -- a chain does not even
need to be rendered, since adding a child runs calculateExtent, which asks the
profile for its borders. Doing it here rather than in each constructor means a
class added later cannot forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYHdKkn3xVRLjtr226EACJ
Both are editor scaffolding rather than properties of the document. Hidden is
read from exactly one render path and only inside an edit root -- guiControl.cc
renderChildControls guards it with isEditMode(), so a running game never
consults it at all -- and Locked only stops the Gui Editor selecting something.

Saving them wrote a working state into the file: hide a control to reach what
was behind it, save, and the next person to open it gets an invisible control
whose only clue is a dashed outline. They still read and set normally; they
simply do not serialize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYHdKkn3xVRLjtr226EACJ
The engine delivers touch events to inactive controls -- GuiControl::
findHitControl tests mVisible and mUseInput and never mActive -- so hovering a
disabled button ran onTouchEnter and repainted its icon in the enabled hover
colour. The disabled state was gone until something else forced it back, and the
button read as clickable when it was not.

Every hover and press handler is now guarded on isActive().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYHdKkn3xVRLjtr226EACJ
The Gui Editor's Properties panel was the generic C++ GuiInspector, which
reflects every registered persist field into flat alphabetical groups. That
offers a GuiChainCtrl nine text fields it never draws, and a GuiTabPageCtrl four
geometry fields its book overwrites on every layout pass -- editing either looks
like it works and silently reverts. It also offered each of the 34
TypeGuiProfile slots as a flat list of every named profile in the sim, applied
by name, which fails silently for anything created during the session.

GuiEditorInspectorPane replaces it, following the pattern the Gui Profile Editor
established: GuiEditorControlSpec is pure data describing what each class
actually reads, and the pane shows that and nothing else. There is deliberately
no Show All -- unlike a profile field, which some other category might use, a
field hidden here is one the engine provably never looks at.

Shared rows are built once and filtered with setVisible; only the class-specific
sections are rebuilt, so a selection change cannot free a control the engine is
mid-dispatch on. The always-visible header is one shell with swappable geometry,
text and value blocks rather than twenty-nine headers, and the geometry block
keys off the PARENT: a chain, grid, frame set or tab book writes its children's
bounds itself, so the fields it owns are shown inert instead of pretending.

Supporting widgets: GuiEditorToggleIcon (a checkbox given the whole control and
no caption, which is what a toggle button is here -- and unlike a button it
refuses to act while disabled), GuiEditorAnchorPicker (edge pins, because the
sizing enum names read backwards), GuiEditorChoiceRow (a segmented control for
the two alignments) and GuiEditorDynamicFields, built fresh because the
inspector's version hardcodes 3.x profile names and registers a fixed global
name that collides on the second object inspected.

Profile slots follow a two-set rule. A slot shows a picker only when there is
something to choose -- the theme's members for that slot's category, standalones
stamped for it, or a value the theme does not offer -- and the picker then also
lists uncategorised standalones. So "Any" means usable as any control's main
profile, not usable in any slot; otherwise one uncategorised profile would put a
row on every slot of every control.

Three staleness bugs found along the way, all of which made a freshly dropped
window show pickers full of the wrong profiles:

  - the brain adds a control (which announces it, and so inspects it) and only
    then applies the theme, so the pane read all five of a GuiWindowCtrl's
    constructor profiles as the control's own choice. It now posts Rethemed
    after theming. The theming cannot simply move earlier: the control's parent
    is what decides its category.
  - setTheme refreshed the explorer tree and said nothing to the pane.
  - fillItems re-inserts a selection the new list does not contain, which is
    right for the Profile Editor's font list and wrong here, where candidates
    are recomputed every bind. It left a ghost of the previous theme.

Also drops GuiDragAndDropCtrl and GuiMenuItemCtrl from the palette (both are
runtime or child-only constructs; a menu item does not even register
GuiControl's fields), and gives the editor windows' sole children "fill" sizing
-- their extents were measured against a 20-pixel title bar and the default has
been 28 for a while, so each was clipping its last eight pixels.

Covered by three new smoke suites: inspectorSpec drives the spec with no UI and
guards against a control class being added without an entry, inspectorPane
drives the pane itself, and inspectorVariants drives the two-set rule and the
real drop order. tests/shots/inspectorPane.cs renders the four header shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AYHdKkn3xVRLjtr226EACJ
…home

Building a high score dialog hit two defects at once. A GuiControl dropped
into a panel had its category guessed once, at drop time, from what it held
-- no text yet, so Empty -- and typing the caption afterwards re-ran nothing,
so the Profile drop-down had one entry in it and no way to reach a Label.
Reaching for the font size instead failed too: applyFilter hid all nine text
fields whenever the header carried the text block, and the header carried
three of them, so fontSizeAdjust, fontColor, overrideFontColor, textWrap and
textExtend had no home at all on any render, caption or placeholder class.

A Category row now sits between Name and Profile, on the one class in the
palette whose category cannot be read off its type, offering the four things
a bare GuiControl is: Empty, Panel, Label, Overlay. Picking one writes that
category's profile at once, so the picker and the profile can never disagree.
It needs no new state -- a profile carries the category it was stamped for,
so what the control wears is the record of what it was told to be, and it
survives a reselect, a save and a Set Theme.

GuiEditorTextBlock replaces the nine scattered rows with one component: a
caption row carrying the wrap and extend flags, a three-line wrapped text
box, one H:/V: alignment row, and the font size and colour pair. Two
instances exist -- the header's and the Text section's -- and the pane points
the three shared row names at whichever is in use, which is what stops them
fighting over one entry in the registry.

Three things found on the way, each of which made the field it touched not
work:

- The field row rounded every float. getValue ran mFloor, so a font size of
  1.5 committed as 1 and a slider's value and range lost everything after the
  point. Latent for as long as those rows were invisible. New decimal and
  pointf kinds: decimal input mode, no flooring, arrow keys stepping a tenth.

- A profile's category reads back in whatever case StringTable interned the
  word in first -- "empty", not "Empty" -- so matching it needs $=, not the
  strstr-based listHas. That read as "this profile is not one of the four"
  and quietly fell back to the guess.

- textExtend is live with wrap off, whatever the pane assumed: guiControl.cc
  grows the width when wrap is off and the height when it is on. Both icons
  stay clickable and the tooltip says which axis.

The text box is now a real multi-line editor, which took four engine fixes:

- GFont::isValidChar answered true for a line break, because every caller
  reads that as "draw this and count its width" and WinFont::isValidChar
  returns true for everything but NUL, its range check commented out. A
  newline in any control's text drew the font's missing-glyph box and took up
  space. Answered in the cross-platform layer so every back-end agrees.

- GuiControl::getLineList built its paragraphs with getline, which cannot
  tell an empty string from no string -- so empty text produced no lines at
  all, and a line block is what draws the caret. An empty multi-line box had
  no cursor in it. It also dropped the empty paragraph a trailing newline
  makes, and called back() on it, which is undefined behaviour.

- handleEnterKey inserts a line break in a wrapped box instead of ending the
  edit. Nothing in the repo sets textWrap on a GuiTextEditCtrl, so no
  existing content changes behaviour.

- onUndo now reports the buffer change like every other path that makes one.

The up and down arrows work in a text box again. GuiTextEditCtrl hands a
script onUpArrow the key before its own caret movement and isMethod answers
for the class, not the instance, so the spinner class that every row input
wore swallowed both arrows in every box that was not a number. It goes on
numeric rows only now.

The control also fills in as you type: GuiTextEditCtrl already ran its
Command on every buffer change, so the box writes the control directly per
keystroke and the pane writes it once, properly, when the box loses focus --
one edit rather than one per character.

tests/smoke/inspectorText.cs covers the pane half and tests/smoke/textEdit.cs
the engine half, driving real clicks and real Return and Up keys. All 21
suites pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
… does

Seven things, all of them about fitting more of an answer into the pane and
fewer questions into the user's head.

A toggle's tooltip is two lines now: what the switch is and which way it is
set, then what that means.

    Visible - On
    Draws when the game runs. Its children draw with it...

The descriptions are longer than they were on purpose. This is an editor;
answering the question here is cheaper than making someone go and look it up.
The heading is opt-in, from a toggleLabel on the icon -- a segmented row's
buttons are choices rather than switches, and "Centre text - On" would be a
worse caption than "Centre text".

That needed the tooltip renderer taught about line breaks. It does its own
word wrapping, splitting on spaces alone, so a newline was part of a word --
and once GFont stopped drawing a glyph for one, the two lines would have run
together. The wrapping is unchanged; it just runs once per paragraph now.

The rest of the compaction:

- The tooltip itself is a three-line box at the width of the pane, the same
  one the text block uses, with its width and its hover delay sharing the
  line beneath it. It is a paragraph, not a caption.

- A window's six switches are an icon row beside Title Height rather than a
  section of six captioned checkboxes. The section is gone.

- Each easing sits beside the time it takes: hover on one line, press on the
  next. Grids take a minimum cell width now, so a section asks for half a
  pane and still reflows to one column when the frame is dragged narrow.

Three fixes for things that were showing what they should not:

- A drop-down's placeholder offered Wrap Text and Extend To Fit Text. A
  placeholder is drawn only while nothing is chosen, so a control that wraps
  or resizes to fit one changes shape when it is used.

- A GuiInputCtrl drew its text block's caption and both icons on top of the
  Text section's own title, which read as "Textt". resizeToFit called
  resize(0, 0, ...), and resize sets the position as well as the extent, so
  every bind dragged the block up out of the panel it was sitting in. The
  header's copy never showed it because a chain repositions its children.

- A menu bar offered a Position, an Extent and an Anchor picker, none of
  which it honours: GuiMenuBarCtrl::resize throws away the position it is
  given and onRender resizes the bar to its parent's width every frame. It
  does keep mBounds.extent.y, so Position and Anchor go and Extent stays,
  with its width greyed and its height live -- the bar's thickness is the one
  thing there is to set on it, and nothing else can set it.

Two pieces of dead code went with them. onToggleChanged now takes the value
the widget flipped itself to, which retires a six-case switch and is what
made the window row cheap; and makeToggleBox with its onToggleClicked had had
no callers since the icon row replaced captioned checkboxes -- after the
signature change it would have written an empty value had anything reached it.

Also: colour -> color throughout, and the tooltip and class rows had been
captioning themselves with their field names next to a properly cased
"Super Class".

tests/smoke/toggleTip.cs covers the two-line format and screenshots a real
hovered tip, since the rendering is the half that script cannot see. All 22
suites pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
The control palette is a list of class names, which tells a person nothing
about what any of them looks like -- GuiTextEditSliderCtrl sits above
GuiTreeViewCtrl and below GuiTextEditCtrl, and none of the three is any the
wiser for it. This is the art and the pipeline for a visual palette; the
palette itself is the next piece.

Thirty-one icons, each a miniature of the control rather than a metaphor,
so a slider looks like a slider and nothing has to be learned. The one
exception is GuiInputCtrl, which has no appearance to miniaturise.

A bare GuiControl gets four of them. In 4.0 it is the wrapper, the backdrop,
the line of text and the modal scrim, and the properties pane already asks
which; giving each a tile lets it be answered by picking rather than by
correcting afterwards. That is also why the table is keyed by palette entry
instead of by class -- four entries share GuiControl and differ by category.

Frame 0 is the fallback, and deliberately not the last cell: frameFor
answers 0 for a key it has never heard of, and an unset Frame field reads as
0 too, so both failures land on a legible question mark rather than on an
arbitrary wrong picture. A control class added to the engine after this was
generated is meant to reach the palette anyway and simply wear it -- which
is why the build script reports undrawn classes instead of asserting on
them.

The drawing rules are measured off the Mono set rather than guessed at: the
ink ramp runs 255 at the top of a tile to 218 at the bottom, corners are one
shared radius, negative space is knocked out of the fill instead of stroked,
and the art bleeds to the tile edge. White only, because EditorIconButton
tints the sprite from the theme's font colour -- the art is a mask, and the
ramp survives underneath it as shading. Everything is placed in a 64-unit
square with primary geometry on multiples of four, which is a whole pixel at
16, 32, 64 and 128 alike; that is what would make a 16px sheet for the
Explorer tree a rerun rather than a redraw.

Two silent failures had to be cleared. GuiEditor's module.taml carried no
DeclaredAssets block at all -- it was the one editor module shipping no
assets -- so without it the sheets would have sat on disk unregistered, with
no error anywhere. And GuiEditor::create execs every script by name, so the
generated file needed a line there or it would never have run.

The shot harness exists mostly for the first of those. A missing image
renders as nothing rather than throwing, so the way that failure shows up is
a page of labels over blank space, and nothing else would have caught it.

Sources and build script live beside the Mono set, outside the repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVXsRigjXMEjMrbyHYawJD
screenShot does not create its destination, and it reports failure by
logging rather than by throwing. A tree that has never run a shot before --
a fresh clone, or a git worktree, where shots/ is gitignored and so absent
-- therefore runs the harness green all the way to SHOTS DONE and writes
nothing at all. The runner counts files on disk, so it reads as zero shots
with no indication why.

Every other harness in shots/ has the same latent hole and is only saved by
inheriting a folder that already exists. Fixing this one where it was found;
the rest want the same line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVXsRigjXMEjMrbyHYawJD
It was a list box of class names -- GuiTextEditSliderCtrl above
GuiTreeViewCtrl above GuiTextEditCtrl -- which told a person nothing about
what any of them was. The icons went in last time; this is the thing that
shows them.

Two views, switched by a pair of buttons at the top. Grid gives 80-pixel
pictures three to a row with the class name in the tooltip; rows gives a
32-pixel picture with a human-readable name beside it. Both are the same
GuiGridCtrl with different cell metrics rather than two layouts, which is
possible because a grid treats CellSizeX as the narrowest a column may be
and shares the remainder -- so grid mode reflows to two columns or four as
the frame is dragged, and rows mode asks for a cell wider than the pane and
can only ever be one. Vertically the mode is absolute; variable sizes a row
to its tallest child, which put a 100-pixel tile in a 40-pixel row.

The controls are in four collapsible groups, ordered so the kinds people
reach for most sit at the top. Grouping is a column on the generated table
rather than a list here, and deliberately independent of frame order:
regrouping the palette must not repack the sheets, or a frame index would
quietly start naming a different picture.

The buttons are a GuiEditorChoiceRow, which already describes itself as a
radio group that looks like a segmented control. Nothing new was needed.

A click now places a control as well as a drag. It routes through the
brain's own onControlDropped rather than adding anything itself -- that path
is where theming, selection, the AddControl event and undo recording live,
and a second way into the document would have had to reproduce all of it and
would have gone stale the first time one changed. A click is a drop that
never moved. Two things had to be handled: a button keeps mDepressed through
a drag, so releasing after one fires onAction too and would have placed a
second control; and a press has to travel five pixels before it counts as a
drag, or a shaky click starts one.

The four faces of a bare GuiControl can finally be dropped as what they are.
The theme applier guessed the category from what a control held, and two of
the four were unreachable that way -- a Panel that is not the root, and
Overlay ever. The palette now says outright which it dropped, and the
applier consumes that on the first pass: what the control ends up wearing is
the lasting record, so a request left lying about would overrule a later
change made in the properties pane.

Three things this cost:

- A panel starts collapsed and measures its open height from the children it
  has at the moment it opens, so the groups had to be expanded after their
  tiles rather than with them, and remeasured on every mode change.
- The tiles sit in an inner grid, never on the panel, because GuiExpandCtrl
  force-writes mVisible on every direct child whenever it opens or closes.
- A fixed bar above a stretching pane has no sizing flag: fill discards the
  position and height needs the border sizes, which script cannot ask for.
  The scroller is built filling so the engine measures the content rect,
  then moved down under the bar and switched to height.

isKnown answers about a key; coversClass answers about a class. Only one
class needs the difference, and it is the one that matters: nothing is keyed
"GuiControl", so the sweep for undrawn classes offered a fifth, iconless
copy of a control the palette already showed four ways.

Verified by tests/smoke/palette.cs (111 checks) and a three-shot harness.
The drag half is not covered -- startDragging wants real mouse state, and
the input-driven suites are the flaky ones -- but it shares makePayload with
the click, which is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVXsRigjXMEjMrbyHYawJD
Ctrl+Z did nothing. The Edit menu was switched off in GuiEditor::open with a
note saying these features still needed development, and twelve mutation
sites in guiEditCtrl.cc carried a bare "// undo" comment marking where the
recording used to be.

Almost none of what was missing was transport. UndoManager, UndoAction and
UndoScriptAction have been in collection/undo.h all along, fully bound to
script; GuiEditCtrl owns a manager, registers it in onAdd and exposes it;
the menu items and their accelerators were already wired to
GuiEditor::Undo. The C++ even announces every edit it makes at the moment it
makes it -- onPreEdit and onPostEdit around a drag or a handle-resize, a
separate nudge pair so a run of arrow keys can be folded into one action,
onTrashSelection fired deliberately before the controls reach the trash.
Nothing had ever implemented a single one of those callbacks. What was
absent was the layer that records; this is that layer.

GuiEditorUndoRecorder owns it and does the writing itself, so a write that
is not recorded is a write that did not happen. Everything the editor
changes already funnelled through four places -- the properties pane's
writeField, the theme applier, the dynamic fields section, GuiEditor's own
menu commands -- and those now go through the recorder instead of straight
to setEditFieldValue. A transaction groups whatever happens between begin
and end into one action, so Set Theme is one Ctrl+Z however many hundred
profile slots it fills, and a control drop is one however many things
arriving involves.

One action class holds an ordered list of ops, because a gesture mixes
kinds: a drop is a move into the add set plus the theming plus a position.
Undo replays the list backwards writing the before values, redo forwards
writing the after.

Deleting does not delete. GuiEditCtrl has always moved trashed controls into
a SimGroup it owns and never empties, and that group is now the limbo for
both directions: undoing an add puts the control there rather than freeing
it, which is what leaves redo something to put back, wearing everything it
had. It is also why a drop needs no geometry recorded -- the control keeps
its own while it waits.

Sibling rearrangement is recorded as one parent's whole child list rather
than as per-control indices. Every index restored shifts the siblings around
it, so a rearrangement touching several at once would depend on replay
order; restoring a list cannot be got subtly wrong. Single-control
add, delete and restack still use cheap index ops, and deletes are recorded
descending, because undo replays backwards and only ascending re-insertion
lands them all where they were.

Records name objects by id, so the stack is dropped on New Gui, on opening a
Gui, and on detaching a theme -- a Profile Editor revert frees the profiles
the records name.

Three things fought back, and each one turned out to be a thing the engine
does that the recording had to answer.

A selection is announced by select() and not by addSelection(). The second
is the receiving half of the editor's event bus -- what the brain calls,
under radio silence, once the tree or the pane has already announced -- so
restoring a selection with clearSelection plus addSelection emptied the
properties pane and never refilled it: the canvas drew handles round a
control nothing else had heard of. GuiEditorBrain::restoreSelection makes
the announcement itself, and short-circuits to a Replayed event -- the pane
re-reads its rows, no rebuild -- when the wanted selection is already the
current one, which is the change-a-setting-then-Ctrl+Z case.

Setting an axis to center or fill hands that axis's geometry to the engine,
which lays the control out then and there. Restoring the enum alone left the
control sitting where centring had put it, and the two enum writes were
landing as two separate steps besides. It is one action of four ops now,
recorded geometry first and enums last, because ops replay in reverse for an
undo and a control still on center overrides any position written under it.

And a container that places its own children takes something from every
child that arrives. A GuiChainCtrl zeroes the position outright while the
editor is open, a GuiGridCtrl forces the sizing off center and fill, a
GuiFrameSetCtrl forces it off center and then resizes the control to its
frame. All of that is right for a control being dropped in and wrong for one
being put back, which knows what it was -- so a structural op remembers all
four fields a container can take, at each end, and writes them again after
the ops replay. After, because the write has to follow the move in both
directions, which an ordinary field op cannot express.

The engine additions are all of that same shape: announcements and access
that were not there.

GuiTreeViewCtrl::reorderFromDrag rearranged the real control hierarchy and
told nobody, so it gets a pre and post pair -- the only document mutation
that was neither menu-initiated nor already announced. GuiControl gains a
childrenReordered binding, because reorderChild and the SimSet ordering
methods change the list silently: a control put back into a chain kept the
slot it briefly held at the end of the list, and the layout was stale rather
than the order wrong. GuiTabBookCtrl gains a childrenReordered of its own,
since its tab strip is drawn from a page vector independent of the children
and it had no way to be told at all -- which also fixes reordering tabs by
dragging in the tree, which never updated the strip either.

GuiFrameSetCtrl gains getFrameLayout and setFrameLayout. It keeps its layout
in a tree beside the child list and destroys a frame outright when the
control in it is removed, so deleting a control collapsed the split and the
sibling swallowed the space; the tree was built once in onAdd from dynamic
fields, then cleared, and could afterwards be neither read nor written. It
serialises one record per frame, parent before children, and names each
frame's control by object id rather than by child index -- so a tree
recorded before a delete restores with that frame standing empty, waiting
for the control the undo is about to put back, instead of swallowing
whichever control now occupies that slot. The rebuild goes through the same
splitFrame call loadFrame makes, so a tree built from text is built the way
one read from a file is.

The Edit menu is switched on, greying Undo and Redo from the stacks. Cut,
Copy and Paste are still empty functions and are greyed deliberately, so
they read as unavailable rather than broken. Menu items cannot be relabelled
from script, so getNextUndoName has no reader yet.

tests/smoke/undo.cs covers it in 135 checks, and asserts stack depth as
often as values: a gesture recording eleven steps is as broken as one
recording none, and only the count says so. Typing a caption is one step, a
run of nudges is one, a nudge with an edit between two of them is three.

Its last step opens the real editor UI, which is how the container work got
verified at all. smEditorHandle is only set in GuiEditCtrl::onWake, and
calling GuiEditor.open() registers the editor without putting it on the
canvas -- so the brain never wakes, isEditMode() is false throughout, and
the first version of the chain test passed the very check it existed to
make. Every suite here runs outside edit mode unless it says otherwise,
which is worth knowing about the coverage that was already in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
The palette's click-to-place was built as a synthetic drop -- it calls the
brain's own onControlDropped rather than adding a control itself -- on the
reasoning that this reaches GuiEditCtrl::addNewControl, which fires
onAddNewCtrl, which is where an undo recorder would hook in. At the time
there was no recorder to test that against.

There is now, so assert it: a click is one undo step, undoing takes the
control back out, and redo restores the same class. If a click ever grows its
own way into the document, this is what notices.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVXsRigjXMEjMrbyHYawJD
screenShot does not create its destination, and it reports failure by
logging rather than by throwing. shots/ is gitignored, so a tree that has
never run one does not have the folder -- a fresh clone, or a git worktree.
The harness then runs green all the way to SHOTS DONE and writes nothing,
and the runner reports "0 shots" with no indication why.

Eight files already guarded against it and nine did not, including six smoke
tests: the screenshots there are diagnostics rather than the point, so they
were easy to overlook. Placed as a bare top-level line before the kickoff
schedule, matching shots/planetX.cs, which is where the pattern already was.

The reason is now written down once in the README's traps rather than nine
times in comments.

Not re-run: the line is the same one shots/planetX.cs has carried at top
level all along, and the full suite passed on this tree immediately before
this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVXsRigjXMEjMrbyHYawJD
Copy and paste in the Gui Editor has to duplicate a control tree, and
nothing here could. clone() makes a shell of the right class and copies no
fields at all -- SimObject::copyTo carries the class names and nothing else
-- and assignFieldsFrom copies name and parentGroup like any other field.
The second of those is the trap: setParentGroup calls parent->addObject(),
so a "copy" made that way silently moves itself into the original's group,
building a frame in a frame set or getting its position zeroed by a chain on
the way past.

deepClone() copies the fields, the dynamic fields and the whole child tree,
and promises the two things a clipboard needs. Both are orderings rather
than code, so both are commented where they happen and tested:

copyTo runs LAST, because copyTo is what links the script class's
namespaces. Until it has run there is no script class on the clone for
anything to find a callback on, so registerObject's onAdd and the parent's
onChildAdded stay silent -- and a class whose onAdd builds children of its
own cannot build a second set on top of the ones being copied. class and
superclass are ordinary persist fields whose setters link the namespaces
too, so the field copy skips them as well; without that half the first child
added defeats the whole rule, which is how this was found.

A child is added to its new parent BEFORE its fields are written, so a
container that takes something from an arriving child -- a chain zeroing a
position, a grid forcing sizing off center -- does it before the values the
copy is carrying land, rather than after.

assignFieldsFrom becomes copyFieldsFrom(source, 0) and is unchanged for its
two callers, save for one bug it always had: it read every field through the
destination while passing the source's raw data. A protected field whose get
function ignores that pointer and answers from the object instead --
GuiControl's "text" does exactly that -- therefore read back what the
destination already held and wrote it straight back. Those fields never
copied at all, a control's caption among them, and new Foo(:other) had the
same hole. It now reads through the source.

GuiFrameSetCtrl overrides deepCloneChildren to rebuild its frame tree, which
is the one piece of layout in the gui tree that is not a persist field: it
lives in TAML custom nodes and nowhere else, so a copy built from fields
alone comes back holding children with no frames to put them in. Copied
structurally rather than through getFrameLayout, whose text names each
frame's control by an id the copy's children do not have, and after the
children exist, because assignChildToFrame only fills frames that are
already empty -- it never makes one.

The recursion is on SimGroup, not SimSet: a group owns what it holds, while
a set only references objects some group owns, and duplicating those would
be inventing objects nobody asked for.

Verified: nine new tests in simObjectCloneTests.cc, including a ScriptObject
class with a counting onAdd that has to count zero and a frame set whose
copy must hold its own children in the same tree. Whole unit-test run green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
The three stubs left on the Edit menu. Everything around them was already
built: the menu items and their Ctrl+X/C/V accelerators have been there all
along, the canvas offers a key to the first responder before the accelerator
map (so a text box in the properties pane keeps Ctrl+C for its own text and
the canvas only gets it when nothing else wanted it), and undo's recorder
turns an add or a trash into one step. What was missing was a faithful way
to duplicate a control, which the previous commit adds.

GuiEditorClipboard owns a SimGroup outside the document and deep-clones the
selection into it, so the clipboard is a snapshot: editing or deleting the
original afterwards does not change what will be pasted. Paste clones a
second time out of that stash, which is what makes pasting repeatedly free.
Cut is a copy plus the delete the Delete key already does -- the trash, and
onTrashSelection -- so it was undoable the moment it was written.

The legacy file clipboard in guiEditCtrl.cc (saveSelection/loadSelection) is
left exactly as it is, and still unused. It cannot do this job: the .cs
writer drops dynamic fields, never writes a frame set's frame tree, and
duplicates names.

Choices worth writing down.

The selection is reduced to the controls no other selected control already
contains, in document order, by walking the document rather than reading the
selection: selecting a panel and a button inside it is one copy, not two, and
walking settles the order so the copies keep the originals' z-order.

Placement steps per container. A paste into the container the copy came from
lands one grid step off so it is not hidden exactly behind the original; a
paste anywhere else keeps the position it had; each further paste into a
given container steps again. That last part is per container on purpose --
one running count looks right until you paste into A, then B, then A again,
and the third lands exactly on the first.

Names are uniquified: okButton becomes okButton2 and then okButton3, and a
name already ending in digits counts on from them rather than growing
another. deepClone deliberately drops names, so each copy carries its
original's in a dynamic field the paste consumes and clears. Uniqueness is
tested by walking the document, because the editor runs in editor mode where
assignName stashes a name instead of registering it, so isObject() cannot
answer. A cut and paste therefore keeps the name it had -- the original is in
the trash, which is not the document, so nothing there holds it.

onControlDropped is split. acceptControl is now everything about a control
arriving except where it lands -- the undo record, theming on arrival, the
selection, the AddControl event the Explorer tree listens for -- and a drop
is that plus its two global-coordinate placements. Paste sets a local
position before calling it instead, for a reason worth knowing: a control's
mRenderInsetLT is its PARENT's content inset, written onto the child when the
parent renders it, so a control that has not been drawn in its new container
has none. Handing the drop path a global position therefore lands the paste
correctly and then shifts it by the container's border thickness 40ms later,
when the re-assert fires against an inset that now exists.

The clipboard is cleared in detachTheme beside the undo stack, and for the
same reason: the copies hold GuiControlProfile pointers by raw field, and a
paste after the library freed one would read freed memory. It deliberately
survives New Gui and Open Gui, so a copy taken from one Gui can be pasted
into the next.

Verified: tests/smoke/clipboard.cs, 99 checks -- placement, one step per
paste however many controls it puts back, undo into the trash and redo back
out, a panel copied with its children renamed, dynamic fields, a frame set's
tree, a control whose class builds a child in onAdd holding exactly one
child afterwards, the menu items following the selection and the clipboard,
and the theme-deletion clear. Full suite green, all 25 as expected; undo.cs
loses the assertion that Cut is still a stub.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
Three complaints, two causes.

A drop is accepted anywhere on the screen. GuiDragAndDropCtrl hit-tests from
the drag control's PARENT, which is the brain, and findHitControl answers
"me" when none of its children was hit -- it never tests its own bounds. So
the walk up to something carrying onControlDropped resolves to the brain for
every point there is, and the brain then placed the control at the cursor.
Dragging one back onto the palette to change your mind, which is what a hand
does, added it behind the palette; so did letting go over the explorer, the
inspector or the menu bar. Measured before the fix: a drop at 724,146, which
the brain's own rect does not contain, was accepted and landed over the
palette.

So the brain polices its own boundary. A drop whose pointer is not over the
canvas is not a drop -- nothing is added, and the payload dies with the drag
control that carried it, deleteOnMouseUp freeing the control and a SimGroup
freeing what it holds. onControlDragged asks the same question first, because
hit-testing a point outside the Gui answers the Gui itself: a drag that
strayed over a tool window quietly reset the add set to the root, and the
click gesture places into the add set, so the next click moved with it.

Neither callback can be asked where the pointer is. The position they are
handed is built from the drag control's own bounds (sendDragEvent) and so is
local to the brain, which is why the code has always ignored it and read the
payload's global position instead. cursorFrom does the same: a drag grabs a
control by the middle, so the middle of the payload is the pointer.

Clicking a tile was already centred in the container being worked in, and
that was the second cause. A Gui is authored at its own size -- 1024x768 --
and the canvas frame is a few hundred pixels wide, so a container runs off
the side of it more often than not, and the middle of one that does is
off-canvas. With PlanetX's titleGui loaded the middle of its inner container
measures 808,643, behind the explorer, and the middle of the loaded root
measures 808,395, directly behind the control list. Which is the report.

centredPlacement therefore clips the container to the canvas and centres in
what is left. A container that fits -- every container in a Gui the size of
the canvas -- still gets its own exact middle, so the rule is unchanged
where it was already right. It lives on the brain rather than on the tile
because the brain owns both the add set and the canvas the container has to
be visible on.

The Explorer tree not refreshing on a delete is its own thing, and it
depended on which of the three routes asked. The Delete key on the canvas and
Cut both delete and then announce it, and the Explorer window is listening.
The Delete key while the TREE holds first responder is the other way round:
the tree announces and the brain does the deleting -- and postEvent walks the
listener list only, so it never delivers to whoever posted. The window that
owns the tree was never told, and its rows kept naming a control already in
the trash.

The receiving half now re-announces, so every route into a delete leaves the
panes agreeing with the document. It has to come after endRadioSilence, which
is what re-adds the listeners a post needs, and it is safe from the
circular-event guard because bIsEventRaised is per object -- the brain is not
the tree. Refreshing from inside the tree's own post is safe too:
refreshTree's clearItems frees the item vectors directly and never calls
clearSelection, so it fires no selection callback to collide with.

Verified: two new suites. canvasDrop.cs, 20 checks -- a drop over the palette
adds nothing and takes its payload with it and is not an undo step, the same
gesture over the canvas lands under the cursor, a drag off the canvas keeps
the container, a click centres in a panel exactly and on the canvas, and the
1024x768 case places into the part of the container that can be seen.
explorerDelete.cs, 13 checks -- all three routes, plus undo putting the row
back. Both were watched failing first, on those measured numbers. Full suite
green, 27 as expected; undo.cs loses one stale expectation, having synthesised
a drop with the payload left at 0 0, which is off the canvas and now
correctly refuses.

The drags in canvasDrop are built rather than posted. A real drag needs
startDragging, which mouse-locks the canvas, and posted WM_MOUSEMOVE cannot
drive the gesture at all -- a harness that presses a tile and posts moves
adds nothing on unmodified code either, so it observes nothing about this
change. The object graph a drag makes is assembled instead, and the drag
control deleted exactly as onTouchUp deletes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
The palette offered a Tab Page tile, and a tab page is the one control that
means nothing anywhere but inside a tab book. Dropped on a panel it is a
panel that draws nothing and that no tab will ever select. Dropped on a book
it works, but only because the book happened to be the add set.

And a book dropped from the palette arrived with no pages at all, which
rendered as nothing whatsoever -- not an empty strip, nothing. Both halves of
that are one short circuit: calculatePageTabs returns early on an empty
mPages, leaving mTabRect the zero it was constructed with, and onRender bails
on the invalid rect before it draws the strip, the background or the
children.

So the book makes its own pages. Tab Page leaves the palette, a dropped book
arrives holding Page 1, and while the Gui is being authored the book draws a
ghosted square "+" after the last real tab whose click adds another. Pages
are removed with the Delete that already worked. It is GuiChainCtrl's
edit-mode "+" band, except that the chain's is a drop-target hint nothing
hit-tests and this one is the primary way a page is made -- so it is laid
out, hit-tested, and reserved space in the strip.

Choices worth writing down.

The palette REFUSES the class rather than losing the row. frame IS the row
index in GuiEditorControlIcons, so deleting Tab Page's row would silently
repoint Window and Input onto the wrong art; and dropping it from covered[]
would have addUndrawnClasses sweep the class registry and offer it straight
back in an "Undrawn" group with a question mark for an icon. The row keeps
its frame, its label and its place in covered[]; only groupKeys refuses it.
So keysInGroup is the palette's view and keys() is the table's, and the icon
sheet still carries the art.

The page is made in script, not in C++. A page created while authoring has to
be themed, recorded for undo and announced to the Explorer tree, and a
control knows how to do none of that. GuiTabBookCtrl draws the affordance and
hit-tests it; GuiEditorBrain::onAddTabPage does the rest. acceptControl is
split for it -- adoptControl is now the half of arriving that is the same
however a control got here, and the undo record stays with each caller
because what counts as one step differs: a dropped control is a step, and the
page seeded inside a book that is itself arriving is part of that book
arriving. That seeded page is added with a plain add(), which announces
nothing and so records nothing, and undo of the drop takes the book to the
trash with its page inside it. One step, which is what the user did.

The "+" is drawn whenever the book is in edit mode, selected or not, and the
strip reserves its space the whole time. Drawing it only on selection -- what
the chain does -- would reflow the tabs under the cursor at the moment you
select the book, and it is the only way to make a page, so it has to be
findable without knowing to select the book first.

It wraps like a real tab when it will not fit. That grows mTabRect and
shrinks mPageRect and so re-sizes every page, but only in edit mode and not
durably: the book sizes each page from mPageRect whenever one is added, so a
Gui saved with the "+" on a row of its own loads back unchanged.

A book with pages always has an active one, and exactly one page is visible
-- but NOT via selectPage. selectPage ends in an onTabSelected script
callback and EditorCore's handler opens the editor a page belongs to, while
EditorCore::FinishRegistration adds one page per editor as each editor's
module loads. Calling it from onChildAdded would open EditorConsole during
LoadExplicit, before the other three editors exist, and every smoke suite
boots through that path. syncPageVisibility is the quiet half: no callback,
safe to call while a child is arriving or leaving. It also fixes undo of
deleting the ACTIVE page, which restored a visible page on top of the one
promoted in its place -- the recorder puts back position, extent and sizing,
and visibility is not among them.

canBeChildOf is a new GuiControl virtual, and it is advice the editor takes
rather than an invariant the object model enforces: there is no veto hook
anywhere in the engine, SimGroup::addObject cannot refuse, and add() will
still put a control wherever it is told. GuiTabPageCtrl answers true only for
a tab book, so book to book is still a move. Four doors ask -- the Explorer
drag, the canvas drag, paste, and the tree's drop indicator. The indicator
matters: a drag that is quietly dropped on the floor reads as a broken tree,
where a line that does not appear reads as a rule.

Three bugs fell out, all pre-existing, all newly reachable because an empty
book is now an ordinary state.

onMouseDownEditor hit-tested tabs with raw control-local coordinates where
onTouchDown has always converted with getTabLocalCoord first. Invisible for a
top-aligned book, out by the whole page area for a bottom or right one. The
"+" needs the same conversion, so it is fixed here rather than around it.

The bottom and right cases of calculatePageTabs computed the strip's origin
by measuring back from the far edge using mTabRect.extent BEFORE this pass
had written it -- last pass's size, and zero on the first pass. A book with
pages hid it behind a second pass; a book with none gets only the first. All
four cases now assign extent before point.

reorderFromDrag called bringObjectToFront unconditionally after addObject.
That is reOrder(obj, front()), and front() is evaluated to build the argument
before reOrder can notice the object is not a member -- so dropping a button
on a page-less book, which onChildAdded re-homes away, dereferenced an empty
list. Guarded with isMember. onChildAdded's re-home also resolved its
destination before removing the child, rather than after, so a book with no
page and no parent no longer leaves it registered with no group at all.

Verified: two new suites and a shot harness. tabBook.cs, 63 checks -- the
palette refusing the class while keeping its frame, a drop arriving with one
themed page as one undo step, the "+" adding and numbering pages, the
geometry the click depends on, a book emptied to nothing and refilled from
its own "+", exactly one page visible through a whole undo walk, and the
reparent rule through canBeChildOf, moveSelectionToCtrl and paste.
tabBookClick.cs posts a REAL click onto the "+" and asserts a themed, active,
undoable page appears -- that is the only cover the C++ hit test has, and it
is why the coordinate fix above is testable at all. shots/tabBook.cs draws a
populated and an empty book in all four tab positions, which is what the two
stale-field cases needed a picture of.

The click point is not hard-coded in the driver. A fixed coordinate that
drifted beside the "+" would report a missing page, which is exactly what a
broken hit test reports, and the test would be lying either way. The engine
works out where the "+" actually landed -- which depends on the theme's font
and the profile's borders -- and hands it over in a file the .input.ps1 polls
for. getAddPageTabRect exists for that, and answers empty outside edit mode
so closing the editor cannot leave a stale rectangle behind.

Full suite green, all 29 as expected, and 12 shot harnesses. No existing
suite lost an expectation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
GuiMenuItemCtrl is refused by the palette, and rightly -- a menu item means
nothing outside a menu bar. But nothing else made one either, so a bar dropped
into a Gui was a permanently empty strip: there was no way, anywhere in the
editor, to put a single item in it.

So the bar makes its own. A dropped bar arrives holding "Menu 1", draws a
ghosted "+" after the last menu whose click adds another, and while a menu is
selected drops a box under it with a row per command and a "+" at its foot.
Both halves are here because of the nesting: a "+" only on the bar would let
you make menus you could never fill.

The dropdown the editor draws is NOT the one the game opens.

The runtime dropdown is a full-canvas GuiMenuBGCtrl pushed at layer 99, and a
dialog at that layer takes 100% of the mouse -- handleTouchDown breaks on the
first non-pass-through consumer and mAllowEventPassThru defaults false, so
GuiEditCtrl::onTouchDown is never reached while one is up. Reusing it would
mean reimplementing selection, the "+" and close-on-outside-click on the
dialog side, and fixing GuiMenuBGCtrl::positionMenu, which reads the bar's
parent-relative mBounds.point as canvas-global and so already opens an
authored bar's menu in the wrong place.

The bar draws the edit-mode box itself instead, and the runtime path is not
touched at all. That works because of renderChild: the clip in force during a
control's onRender is its PARENT's content rect, not its own bounds, so a bar
can already draw below itself and be clipped to the container it sits in --
which is exactly as far as an authored dropdown should reach. No dialog, no
clip escape, and the editor keeps the mouse.

Hit testing is the other half and does not come free. findHitControl reaches a
control through pointInControl, which walks mBounds, so every click on the box
fell through to whatever was behind the bar. The bar answers for the box's
rectangle as well as its own.

The box is drawn from scratch rather than delegated to GuiMenuListCtrl. That
list is built lazily when the first child arrives, so a menu with nothing in
it has none -- and a menu the "+" just made, needing its first command, is the
case that matters most.

Which menu is open is derived, never stored. onPreRender walks the editor's
selection and up each control's parents; a selection that reaches a direct
child of this bar opens that menu. So it follows the Explorer tree as readily
as the canvas, a command stays visible while it is being edited, and the menu
the "+" just made is open the moment the brain selects it.

The item itself is made in script. One created while authoring has to be
themed, recorded for undo and announced to the Explorer tree, and a control
knows how to do none of that -- so C++ draws the affordance and hit-tests it
and GuiEditorBrain::onAddMenuItem does the rest, as onAddTabPage does.
Numbering is per parent, so each menu's commands count from 1.

canBeChildOf answers true for a bar OR another item, unlike a tab page's one
legal parent: moving a command between menus, or a menu between bars, are both
ordinary things to want.

The properties pane.

A menu item calls SimObject::initPersistFields rather than GuiControl's, so it
has no profile, no geometry, no tooltip and no sizing -- a header with a
caption in it and nothing underneath. Its own fields sat in a "Menu Item"
section below that emptiness, which never opened, so there was no way to set
any of them. They are in the header now, in a GuiEditorMenuItemBlock, because
there is nothing to scroll past to reach them.

The block owns its caption box rather than borrowing the shared text block.
That one is multi-line with wrap, extend, alignment and font rows attached,
all of which a menu item hides, and a box three lines tall for a word like
"File" says the wrong thing about what belongs there. It is also where the
per-keystroke write lives: a top-level menu is exactly as wide as its caption
-- one of the few controls whose content area is dictated by its text -- so
setUpdate recalculates the strip and the bar reflows under the cursor as the
caption is typed. The undo step is still written once, on commit, from the
value stashed on the first keystroke.

Toggle and Radio are two bool fields but one decision, so they are one chooser,
and a separator is its fourth choice. A separator has no field of its own: a
single dash in the caption is the whole of it, in a .gui file and here. So
picking Spacer writes the dash and typing a dash picks Spacer. The kinds are
drawn now, too -- the square bullet, the round one, the submenu arrow, the
same marks GuiMenuListCtrl::onRenderItem draws -- so what a kind looks like in
the editor is what it will look like in the game.

Nuts.

A menu item's position and extent are ignored by everything below the top
level, and a top-level item's position is ignored as well. Selecting one still
put eight sizing knobs on screen, around the 64x64 default rectangle, with no
visible relation to the row that was selected -- and dragging them resized
something nobody would ever see. isGeometryEditable is a new GuiControl
virtual, false for menu items and tab pages, and the editor gives them the
plain outline it already draws for a locked control: no nuts, no resize, no
move. The outline is the point. It circles the row you actually picked.

The theme.

A generated theme's MenuItem wore Padded on all four sides, which left a
separator nothing to be. A separator is drawn as the menu item profile in its
SELECTED state, and nothing else in a menu ever uses that state -- hover is
Highlight, greyed is Disabled -- so the SL fields of a menu item's borders
belong to separators alone, and Padded's were the same 10px inset as every
other state.

SelectedInset is a new named border: a 10px inset in three states, and in the
selected state a 4px margin, a 1px rim in the theme's surface colour and no
padding at all. A separator's height is nothing but that border's margin, rim
and padding -- GuiMenuListCtrl::updateSize measures one with a zero-height
interior -- so those numbers are a 2px rule with 4px of the menu's own fill
above and below it. MenuItem wears it top and bottom with Padded on the sides,
which keeps the label's inset and lets the rule run the width of the menu
rather than being capped at each end by the same margin and rim.

Alone among the recipes it ignores the theme's borderSize. This rim is a rule
between two groups of commands, not the edge of a control: at borderSize 0 it
would vanish and the menu would silently lose its grouping, and at 3 it would
thicken into a band.

Bugs that fell out. All pre-existing; all newly reachable because an empty or
edited menu bar is now an ordinary thing rather than a curiosity.

findHitControl was declared with two parameters where the base takes four, so
it hid rather than overrode and its return this was dead code. The base
recursed into the items and their sub-items, whose mBounds are either the
GuiControl 64x64 default or a stale canvas-global rect stamped by
GuiMenuListCtrl::onRenderItem -- so clicking a menu on an authored bar handed
the editor the last sub-item of that menu.

Radio was declared TypeS32 over a bool mRadio. The default getter read four
bytes off a one-byte member and answered with whatever was beside it, so a
plain command could read back as a radio item. That is what made the new Kind
chooser show the wrong kind, and it has been wrong since the field was added.

onChildAdded dropped a rejected child on the floor -- removeObject and return,
leaving it registered with no group at all. Both overrides now resolve the
destination before removing, and leave the child where it is if there is
nowhere to send it.

onChildRemoved repaired nothing: mPrevItem/mNextItem still pointed at the
departed item and mHoverTarget/mOpenMenu still named it, and all four are
dereferenced later.

GuiMenuItemCtrl::mMenuBar was never initialised and onChildAdded dereferences
it -- one add() before the item was in a bar away.

childrenReordered was missing, so reordering items in the Explorer tree left
the strip with stale rectangles.

onKeyDown fell off the end returning junk, on the delegate-to-submenu branch
and on any unmatched key. A warning on every build.

Emptying a menu of its last child tried to deleteObject the scroller and the
list, which are built with a bare new and never registered -- an assert, and a
modal one, which in a headless run reads as a 90-second hang. Registering them
instead moves them into the canvas's ownership and hangs the engine on the way
down if a menu is open when it quits; that was measured against a stashed tree
rather than reasoned about. They are kept and reused, which changes no
lifetimes at all.

And click-to-place did nothing for a menu bar. onControlDropped asks whether
the cursor is over the canvas, which is a drag's question; a bar pins itself to
its parent's origin, so a clicked payload sat at 0,0 and the test measured a
point in the editor's chrome. placeControl is that path without the cursor
test, and the tile's click uses it.

Verified: two new suites, a shot harness and two unit tests.

menuBar.cs, 78 checks -- the palette still refusing the class, a drop arriving
with one themed "Menu 1" as a single undo step, onAddMenuItem at both levels
with numbering per parent, the geometry the clicks depend on (including a menu
with no children at all, which still offers its "+"), a bar emptied to nothing
and refilled from its own "+", the four kinds and the dash in both directions,
and the reparent rule through canBeChildOf, moveSelectionToCtrl and paste.

menuBarClick.cs posts four REAL clicks: the bar's "+", the dropdown's "+", and
then the editor's own File menu opened and closed again -- the last two
because they are what prove the runtime path is untouched. The engine works
out where each "+" actually landed, which depends on the theme's font and the
profile's borders, and hands the coordinates to the driver through a file. A
hard-coded point that drifted beside the "+" would report a missing item,
which is exactly what a broken hit test reports.

shots/menuBar.cs draws the bar in five states, two of which the runtime
machinery cannot draw at all: a bar with no menus, and an open menu with
nothing in it.

inspectorPane.cs, 150 checks, gains the new block -- single-line caption,
Visible and Active kept in the header's own toggle row -- and loses the
expectation that the dead section exists. guiProfileThemeTests gains
SelectedInset's values and MenuItem's wiring.

Full suite green: 31 smoke suites, 13 shot harnesses, and the C++ unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
A list box or a drop down dropped into a Gui was an empty rectangle. Its rows
lived only in mItems, filled by addItem, and initPersistFields registered two
fields -- AllowMultipleSelections and FitParentWidth -- neither of which is a
row. So a difficulty picker, a resolution list or a language menu had to be
built in script, in an onWake somewhere away from the Gui that shows it, and
the canvas showed nothing at all while the screen was being laid out.

Now the rows are authored in an Items section of the properties pane, drawn on
the canvas as they are typed, and saved with the Gui. Script keeps every method
it had; a list filled at runtime is untouched.

On disk.

An item is neither a field nor a child object -- GuiListBoxCtrl::addObject
refuses children outright -- so neither of the two things a writer walks can
see one. They go out as TAML custom nodes, which is what GuiFrameSetCtrl does
with its frame tree and for the same reason:

    <GuiListBoxCtrl.Items>
        <Item Text="Easy" Id="1" />
        <Item Text="Normal" Id="2" Selected="1" />
        <Item Text="Hard" Id="3" Color="Red" Active="0" />
    </GuiListBoxCtrl.Items>

Only what differs from an LBItem's defaults is written, so an ordinary list of
captions is one attribute a row. TamlXmlWriter::compileCustomElements names the
section after the element it is writing, so a subclass gets its own heading
with nothing extra to do.

getItemList/setItemList is the whole list as one opaque string, and it is not
the file format: it is what the pane reads and writes in a single call and what
the undo stack records, exactly as getFrameLayout/setFrameLayout serve a frame
set. deepCloneChildren copies the rows, which is what makes copy, cut and paste
carry them without the clipboard knowing anything about them.

A drop down keeps its rows in a GuiDropDownListBoxCtrl built in its constructor
and added to no set anything walks, so all four of those are forwarded.

A tree is not a list here. GuiTreeViewCtrl derives from GuiListBoxCtrl but
generates its rows from a root object, so a written-out set would be stale the
moment it next built itself: writesItems() returns false, and the pane's
hasItemList names the two classes rather than testing ancestry.

The Items section.

Nine controls on one line: caption, ID, show color, the color, active, starts
selected, up, down, remove. Nine rather than eight because the color is two
values, hasColor and color, and nothing can be read off a swatch alone -- a
swatch always holds SOME color. Show color, not "own color": hasColor does not
tint anything, it draws a bullet in front of the caption and indents the text
past it (renderColorBullet).

The block edits the list as a WHOLE, reading it in one getItemList and writing
it back in one setItemList. Every gesture on offer shifts what is around the
row it touched, so a per-row write would have to know which of them it was, and
an undo of one would have to know what the others became. A list is short.

It is built once and shown per class, which is the arrangement the dynamic
fields section uses and for the reason the pane's header comment gives: a block
rebuilt on a selection change can delete a control the engine is mid-dispatch
on. Its rows are rebuilt, always from a schedule(0), because the click that
removes or moves one arrives from a button inside the row about to be freed.

Four things that had to be got right, each commented where it lives:

  ColorF's default constructor is empty (gColor.h), so a row that has no color
  carries uninitialized memory in item->color. Serializing it made two
  identical lists compare unequal.

  Adding, removing and moving read the CONTROL, not the row widgets. The
  widgets lag the deferred rebuild, so two clicks on Add inside one frame read
  the same stale chain and the first row added was lost.

  Only the deferred path re-measures the section. forceLayout nudges the pane's
  width by a pixel and back, and an expanded GuiPanelCtrl takes the nudge up
  without giving it back -- so a second one in the same bind left the section
  wider than the pane every time a control was selected, and the row's
  right-hand icons were what fell off the edge.

  The rows sit in a GuiGridCtrl at one column, not a chain. A chain sizes
  itself from its children, so a chain of full-width rows inside a chain inside
  a panel had each level widening the one above it, a bit per layout pass.

TAML custom-node names must be interned case-INSENSITIVELY.

They are matched by StringTable pointer, and TamlCustomNodes::findNode and the
XML parser both intern with the default. A name interned the case-sensitive way
is a different pointer and matches nothing.

This is not theoretical: StringTable hands back the first spelling of a name it
was ever given, so a case-sensitive "ID" wrote itself out as Id="1" -- whichever
spelling reached the table first -- and then failed to recognise its own output,
dropping every ID in the file with nothing but a warnf. Which spelling wins
depends on static initialisation order across translation units, so it passed
one build and failed the next.

GuiFrameSetCtrl's ten frame-node constants had the same form and are fixed with
it. That one has been lucky so far, and reverting it and rebuilding still
passed, which is the hazard rather than a refutation of it -- so the frame-set
test that comes with this is not a demonstration of that fix. It is here
because frame-set persistence had no coverage at all, and a flat tree with both
children piled into it is the shape the failure takes.

Saving as .gui says what it cannot keep.

FileObject::writeObject walks fields and children and that is the whole of it,
so the script format drops custom nodes. GuiEditor::tamlOnlyStateSummary names
what would go -- rows on any list, and any frame set that has been split -- in
the save dialog's feedback line and through warn() from SaveCore, since a
re-save never opens a dialog. Frame layouts have been lost this way silently
since they were written; they are covered too.

tests/smoke/listItems.cs is 74 checks over the encoding, the TAML round trip
for both classes, deep clone, the tree exclusion, the pane, undo and redo, the
single-selection rule and the warning. tests/smoke/frameSet.cs is 12 over a
split frame set through a file and a clone. tests/shots/listItems.cs draws the
section for looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
Pressing return in a multi-line text box left two carets blinking: one at the
end of the line just left, one at the start of the new line. The condition
deciding which line block draws the caret suppressed it at a line's end unless
the caret sat at the end of the whole text -- a sound proxy for "there is no
next line to hand it to" only while getLineList built its paragraphs with
getline, which drops the empty paragraph a trailing return makes. 7b35118
fixed that drop, so an empty last line now starts at the end of the text too,
and both blocks answered yes.

Nothing local to a line can tell it that it is the last one: a line can end at
the end of the text without being last, which is exactly what a trailing return
makes. So renderLineList says which one is, and the decision moved out of
renderIbeam into isIbeamOnLine, which draws nothing and can therefore be tested.

Three more defects turned up on the way, each found by a test:

- insertNewLine never cleared the end-of-line flag, so a click at the end of a
  wrapped line followed by return drew the caret on the line just left.

- GuiTextEditSelection's constructor set ten of its twelve members. mTextLength
  is the bound every caret move clamps against, so the caret landed wherever the
  leftover memory allowed -- 99 in a three-character box in one test, 1 in
  another. The copy constructor had always covered all twelve, which is what
  made the omission visible.

- Initializing it alone would have made things worse rather than better. setText
  is the one path that changes the text with no keystroke behind it -- it is how
  a control loaded from TAML gets its text -- and it never passed the new length
  on, so a fixed zero would have clamped every setCursorPos to 0. It says so now.

guiTextEditTests.cc holds twenty tests: caret ownership at every seam, an empty
box, wrap boundaries either side of the end-of-line flag, both return cases,
blink and focus, the length clamp, and the paragraph split.

Only half of getLineList needs a font, and measuring text is the one thing a C++
unit test here cannot do -- a font registers a texture, and that suite runs with
no canvas to make one in. splitParagraphs is that half separated out: pure, and
the half that has broken. Its five tests were checked against the pre-7b351186
implementation and each failed for its own reason. The wrapping half stays with
tests/smoke/textEdit.cs, which has a real canvas.

119 unit tests and all 33 script suites pass. tooltipProfile failed once along
the way and is the known posted-hover flake: it passes alone, and in a full
re-run on the same binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
The control palette's pictures are greyscale art, drawn to be modulated. The
tiles never modulated them: nothing called setImageColor, so every icon kept
GuiSpriteCtrl's opaque white. That looked right on the theme the editor starts
in -- which draws its text white too -- and became a white smear on a pale panel
the moment anyone chose the light one.

The two view buttons above them had the other half of the same problem.
GuiEditorToggleIcon::refresh copies a color off iconButtonProfile onto its
sprite, and a copy does not follow the profile it came from. ThemeManager swaps
the profile object on everything that registered one, so the button's background
changed theme and the picture on it did not -- until something happened to call
refresh() again, which is why the icons corrected themselves the moment you
clicked one.

Both now listen to ThemeManager and re-read the color when it posts, the way
EditorIconButton already did. Fixing it inside the toggle repairs the anchor
picker, the header block, the text block and the item rows as well: they all
wear iconButtonProfile and all went stale together.

The palette suite grows a section that checks both tints against the profiles
they claim to come from, under the default theme and under the light one. Only
the second catches anything -- under the default, white-because-themed and
white-because-untouched are the same pixel, which is what hid this. Comparing
colors needs care: TypeColorI reads back as a stock NAME when the components
match one, so a white profile color answers "White" while the sprite it was set
from answers "255 255 255 255".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
Grid mode drew an 80px icon and hid the caption, so the only way to learn
what a tile was, was to hover it for the tooltip. The icon comes down to 56
and the name sits under it -- centered, on the floor of the tile, wrapping
to a second line -- so "Check Box" and "Radio Button" read level across a
row instead of stepping up and down.

The caption fills the tile and bottom-aligns its text rather than being a
band placed by arithmetic. A tile's profile insets 3 pixels a side on the
base theme and 4 on Torque Suit, a number script cannot ask for, and a band
positioned against the outer extent hangs below the inner rect, where
renderChild clips the last line's descenders off. Filling also measures
that inset, so the picture is centered in whatever room is actually left
above the name whatever a theme's border costs.

Both view modes share the one caption, so each branch now sets every
property the other touches; a switch back stops being a switch back
otherwise.

palette.cs covers the caption, its alignment and its wrap. The shots
harness gains a fourth shot, scrolled to the two-line names -- every name
in Basics fits on one line, so the first three could not show what the
caption band is sized for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
GuiScrollCtrl subtracted its bar only where it CLIPPED children --
applyScrollBarSpacing, from onRender -- and nowhere else, so a content
child kept its full extent and was simply drawn cut off. That is right for
a scroller, whose content is meant to be free to exceed it, and wrong for
content that is supposed to fit ACROSS: it sized itself from a width that
included the bar. The Gui Editor control palette laid out one grid column
underneath the bar, and the names in that column read "Number Bo:".

The rule this now follows: an axis whose bar is alwaysOff does not scroll,
so the space is bounded and the control has a real size to hand a child. An
axis that is alwaysOn or dynamic is a window onto content of any length --
there is no size to give, and filling there would clamp the content and
leave nothing to scroll.

  - getInnerRect subtracts whatever bars are showing, and onRender uses it,
    so the rect children are sized against and the rect they are clipped
    into are one definition rather than two that could drift apart.
  - addObject strips fill and center only in an axis that can scroll, via
    preventUnsizedModes. GuiControl gained per-axis variants of the two
    prevent calls; the existing both-axis ones now delegate to them.
  - computeSizes announces a bar appearing or going away, with old and new
    content extents measured from the same bar-free rect so the difference
    is the bars and nothing else. Nothing announced it before: the bar
    usually arrives from childResized, nowhere near a resize, and the code
    in resize() passed identical old and new, so every sizing mode that
    works from a delta computed zero and did nothing. The mRenderInsetRB it
    poked was overwritten by the next renderChild anyway.
  - calcBarPresence judges each axis against the room that REMAINS, so a
    vertical bar can call a horizontal one into being. The old code compared
    both against the un-narrowed extent, so its second look at the
    horizontal bar asked a question it had already answered.

The bar arithmetic is two static helpers, subtractScrollBars and
calcBarPresence, so it can be tested away from a canvas, a Sim and a GL
context -- guiScrollLayoutTests.cc, 13 tests including the circular case.

The palette's script workarounds go with it: the chain asks to fill and the
engine answers, including as the frame is dragged and as the bar comes and
goes. Its smoke test now sweeps the window across 31 widths instead of
asserting once, because a single static check is exactly what the script
workarounds passed while still breaking on the next drag.

Note for anyone reaching for fill next: a GuiPanelCtrl cannot be filled.
GuiExpandCtrl::parentResized ends by writing mExpandedExtent straight into
mBounds.extent, and a direct write goes around resize, which is the only
thing that honours fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
GuiTreeViewCtrl::onRenderItem drew a row in one unbroken run, and the only
way to add anything to it was to override the whole thing -- which cannot
be done faithfully anyway, because the editor focus line is driven by
private state. It is now split at the two places new content goes.

  - renderItemGutter runs BEFORE the focus line and the depth indent, so
    what it draws stays pinned to the row's left edge instead of travelling
    with the tree. renderItemIcon runs between the triangle and the text.
    Both take contentRect by reference and may carve space off its left;
    every step after them uses what is left. The base draws nothing in
    either, so every existing tree renders exactly as it did.
  - A row can wear one frame of a sheet, set by IconImage/IconSize. Which
    frame is script's answer to onGetItemIcon, asked ONCE as the tree
    builds and cached on the TreeItem -- onRenderItem runs for every visible
    row of every frame, so a callback there would be a console call per row
    per frame. Modelled on getObjectText, which already works that way.
    refreshItem re-asks both text and icon for one row, because a control's
    picture can change without the row moving: re-profiling a bare
    GuiControl from a panel to a label is the same object in the same place
    wearing a different face.
  - The icons are drawn with dglDrawBitmapStretchSR rather than
    renderStretchedImageAsset. That one reads the asset off the PROFILE, and
    its first statement is dglClearBitmapModulation -- which here would
    throw away the row's font colour. Drawn directly, a white sheet inherits
    the row's normal/highlight/selected/disabled ink for nothing.
  - mIndentSize is wired up at last. It was declared, initialised to 10 and
    read nowhere; wiring it without resetting the constructor to 0 would
    have silently re-indented every tree in the engine. 0 now means "one row
    height", which is the step the tree has always used.

GuiEditorExplorerTree is the first thing to use the gutter seam: two
columns of editor state, an eye and a padlock, in the manner of a layers
panel. It is editor-only and the palette refuses it for free, by the
GuiEdit prefix, in both copies of that rule.

hidden and locked are not properties of the Gui. Neither is ever written to
a file -- _writeHidden and _writeLocked both refuse -- because saving them
would put a working state into the document. So a click in a column writes
the flag straight, with no undo record: undoing one would restore something
that can never reach a file, and would leave Ctrl+Z after "hide three
things, then move a button" un-hiding something instead of putting the
button back. It acts on the row clicked and not on the selection, which is
what a layers panel does and what someone reaching for one row's eye means.

Two things the columns must not do, and how:

  - Not change the selection. onTouchDown handles the gutter above
    everything, including the base's row-0 case -- which never calls
    Parent::onTouchDown at all, so the root row would otherwise be the one
    row whose columns did nothing. Returning before Parent::onTouchDown
    buys the rest: no setFirstResponder, no handleItemClick, therefore no
    selection change and no onClick or onDoubleClick, and mLastClickItem is
    left alone so a press in a column followed by a press on the row is not
    read as a double click.
  - Not arm a reorder. An mGutterPress latch makes onTouchDragged return
    early, which also skips getHitIndex -- whose side effects are the drag
    state the drop indicator and reorderFromDrag read. onTouchUp swallows
    rather than chains, because GuiControl::onTouchUp bubbles an event the
    script did not consume.

The columns are drawn in ONE ink whatever state the row is in. Inheriting
the row's font colour is what the icons did at first and it read correctly
nowhere: the box behind them does not change with selection, so a selected
row put selected-TEXT ink on an unselected background and the eye all but
vanished, by varying amounts in all four themes. An icon on a
state-independent background needs a state-independent colour.

The geometry is all statics taking everything they use -- resolveIndent,
iconSlot, focusLineOffset, getGutterCells, columnAt, getBoxRect -- because
a unit test cannot reach it any other way. Adding a row to a tree calls
updateSize, which asks the profile for a font, which LOADS one, which
registers a texture, which asserts with no GL context; in a debug build
that is a modal box, so it arrives as a hang. guiTreeRowLayoutTests.cc, 18
tests, and treeIcons.cs for the plumbing that does need a canvas.

focusLineOffset is there because making the indent settable broke the focus
line four pixels, silently. The rule that hangs from a container's triangle
was centred in the INDENT slot, and the triangle is drawn in a square of
the ROW HEIGHT. Those were the same number, so nobody had to know which one
they were centring on. Now the line takes the row height and nothing else,
and the test sweeps every height from 4 to 64 asserting it still covers the
triangle's point.

Two things fixed while in here. getObjectText declared char buffer[1024],
filled it only inside if(obj), and returned StringTable->insert(buffer)
unconditionally -- a null object read uninitialised stack.
calculateHeaderExtent was dead: it computed two locals, used neither, and
nothing called it. It was the only trace of an earlier attempt at columns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
controlIcons16.png, 128x64, the same 31 drawings in the same 8x4 grid as
the 64 and 128 sheets, so one index still names the same icon in all three.
sheetFor gains a tier on the same rule as before -- each threshold is that
sheet's own resolution, because past it the sheet would have to be enlarged
and enlarging is what looks soft.

The art was designed for this. icons.py places everything in a 64-unit
square with primary structure on multiples of four, which is a whole pixel
at 16, and nothing is ever stroked -- a frame is a filled rect with a
smaller rect knocked out. All 31 pass the build script's own bbox and
ink-ramp assertions at 16.

What did NOT survive the trip down was the resample. render_alpha finished
with Image.LANCZOS, which is a sharpening kernel: it has negative lobes, so
it rings. At 64 and 128 the thinnest primary feature is 4 units, which is
4px or 8px, broad enough that its middle still reaches full alpha. At 16 a
4-unit bar IS one pixel, and Lanczos resolved it at alpha 221 with 22-alpha
ghosts either side. Measured on the sheet: GuiTextEditCtrl at 16px had ZERO
fully-opaque pixels and 202 mid-alpha ones, against 64 and 4 under
Image.BOX, which over an 8x supersample is exact area averaging. The
generator now picks the filter by size, so the shipped 64 and 128 PNGs are
byte-identical.

Worth writing down because the two are easy to conflate: landing on a pixel
boundary and surviving the resample are separate problems, and the 4-unit
grid rule only ever promised the first.

The generator had also drifted from what shipped -- commit d2845d7's
GuiTabPageCtrl refusal and the refused-class guard around groupKeys existed
only in the generated file on disk, so the next regeneration would have
silently reverted them. Fixed there first and proved a no-op before the new
size was added. The refusal needed a set of its own rather than joining
NOT_PLACED_BY_HAND: the build reports stale = drawn - placeable, so a class
that is refused but still DRAWN put in that set reads as a dead tile.

palette.cs checks both new thresholds from either side, and that every
sheet sheetFor can name actually resolves with all 32 cells -- a missing
image renders as nothing rather than throwing, so an unregistered asset
would show up as blank space in the tree and nowhere else at all.
controlIcons.cs gains a third page for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
They were the first two of six icon toggles in the pane's header, beside
Visible, Active, Accepts Input and Accepts Children. Those four change the
Gui a player will run. hidden and locked change your view of it while you
work -- neither is ever written to a file -- and standing them next to the
four that ARE the document read as a promise that they were the same kind
of thing. A 36px gap was the only thing saying otherwise.

They are now two columns down the left of the Explorer tree, in the manner
of a layers panel: the eye is shown when a control is NOT hidden, the
padlock when it IS locked, so a tidy Gui is a column of eyes and a column
of nothing, and a whole branch reads at a glance. The columns do not indent
with the tree -- they are a fixed rail -- and the boxes and dividers are
drawn in the profile's hover fill, which is near enough the row fill to
stay quiet. The cell under the pointer steps up to the selected fill, which
is the only thing saying the rail can be clicked at all: on an unlocked,
visible control both boxes are empty.

The root row is the simulated canvas, stage furniture rather than a control
in the document, so it draws no boxes. It still draws its dividers, or the
rail would start one row down and read as a column that had failed to paint.

Each row also gains a 16px picture of its own class, between the triangle
and the text. Which one is onGetItemIcon's answer, and answering it needed
something that did not exist: a way back from a live control to a palette
entry. The two are not the same. Everything but a bare GuiControl is keyed
by its class, but a GuiControl is the wrapper, the backdrop, the line of
text and the modal scrim -- four entries sharing one class and told apart by
the profile category they wear. keyFor asks the pane's currentCategory,
which is what keeps the Category dropdown and the row icon from disagreeing
about the same control. onPostApply moves to refreshItem so a re-profiled
control's picture follows it.

The pane's toggle row keeps the four that remain and closes up to the front.
The gap between them is gone with the boundary it marked.

editorToggles() must keep naming both fields, and now says why. Removing
the two buttons does not remove the two fields: buildOtherSection sweeps up
every persist field no section claimed, so dropping them there would file
them under "Other" as generic checkboxes -- same working state, now among
the leftovers, which is worse than where they started. inspectorPane.cs
asserts it directly rather than leaving it to be rediscovered.

Its two lockedButton checks move to buttons that still exist: the box
geometry to visibleButton, and the round-trip and disabled-toggle guard to
activeButton, which has a true on/off icon pair and so can also prove the
icon follows the value.

explorerGutter.cs covers the rest. The central promise is a negative --
clicking a box toggles the flag and does NOT select the row -- and calling
the toggle from script would skip the very code that could break it, so
those three clicks are posted for real. The engine works out where each box
landed and writes the point out; a hard-coded coordinate that drifted off
the box would report a control that never toggled, which is exactly what a
broken hit test reports, and the test would be lying either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
greenfire27 and others added 24 commits August 2, 2026 21:34
Its own commit because it is the one change here that alters how the tree
looks rather than what it holds.

The default step is a row height, which is what every tree has always used
and what IndentSize=0 still means. This tree can no longer afford it. Two
columns, a triangle and a picture stand in front of the name, which is
about eighty pixels of a 228px window before a single letter, and every
level of depth was adding twenty more. At three deep there was almost
nothing left for the text.

Twelve still reads as a clear step and buys back ten pixels a level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
…claim

tests\run.ps1 has always been the way to run the script suites; the
GoogleTest side had nothing. You launched the exe with an alternate boot
script by hand and read console.log yourself, and there is no CI job for it
either -- PR-builds.yml configures and builds and stops there.

    tests\run-unit.ps1
    tests\run-unit.ps1 GuiTreeRowLayoutTests.*

Note it takes a FILTER rather than passing one on: runAllUnitTests is
declared 1,1 and hands InitGoogleTest an empty argv, so there is no
argument to forward. GoogleTest reads GTEST_FILTER from the environment
instead, which is what the parameter sets. A filter matching nothing exits
non-zero rather than reporting success, because GoogleTest is perfectly
happy to run no tests at all.

Two things CLAUDE.md said that were not true. There are no TEST() blocks
"throughout the engine" -- all 150 are in engine/source/testing/tests/, each
listed explicitly in EngineSources.cmake, so a new file needs a CMake edit
and a re-configure to be compiled at all. And runAllUnitTests takes no
filter argument, as above.

Both files now also record what a unit test can actually reach, because the
limit is sharper than "no canvas" and it decides where coverage goes. The
engine boots far enough to give a test Con, Sim, the string table, the
resource manager and GuiDefaultProfile, so it can new and registerObject a
control, read and write fields, run script and round-trip TAML. What it
cannot do is wake a control or measure text -- a font registers a texture
and TextureManager::refresh asserts, which in a debug build is a modal box
and so arrives as a hang rather than a failure.

That rules out more than it looks. Adding a row to a list box or a tree
calls updateSize, which asks the profile for a font, which LOADS one. So
rows are script-suite territory and only the arithmetic can be unit tested,
which is what the established move already assumes: pull it into a static
that takes everything it uses and test the static.

Worth saying plainly since the script suites are one process each with a 90
second timeout and the whole unit run takes seconds: prefer a unit test
wherever the thing under test can be reached without a canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
Together in one commit because they are four small, unrelated repairs found
in one pass rather than four pieces of work. Three had a test written that
failed first.

A drag that crosses a container boundary is now one undo step that puts the
control back. This was the sharp one: Ctrl+Z after such a drag left the
control in its new parent and wrote the old parent's coordinates into it, so
it landed somewhere it had never been.

A drag is not one event. guiEditCtrl.cc calls moveSelection once per
mouse-move, and each of those brackets ITSELF with onPreSelectionNudged /
onPostSelectionNudged, so the record was being built a frame at a time and
coalesced back afterwards. Then somewhere in the middle of all that,
onTouchDragged looks at what is under the cursor and calls
moveSelectionToCtrl -- which reparents the selection and rewrites each
control's position to keep it under the pointer, announcing neither. The
frame-by-frame record has no way to see either one.

So onPreEdit and onPostEdit now bracket the whole gesture rather than
standing in for its first and last frame: beginGesture takes one snapshot of
where every selected control stood, endGesture reads them again and writes
one action, and the nudge callbacks are ignored while it runs. That is not
only for the reparent -- it is also what a drag always meant, and it makes
the hundred small moves and their coalescing unnecessary rather than merely
harmless.

A control that changed parent is recorded as a move op plus a layout fix.
The fix rather than a Position field op, because it carries the sizing modes
as well as the bounds and is applied after every op has run, so it is the
one that wins where the container places its own children. One writer per
control either way.

Arrow-key nudges are untouched. They arrive with no onPreEdit, so they still
take the snapshot + commitGeometry route and still coalesce.

Snap to Grid no longer throws away the grid size. Set Grid Size offers 5 to
50; the Layout menu's toggle then called setSnapToGrid(10) and put it back
to ten. The C++ was already built for the two to be separate -- passing 0
clears the flag and deliberately leaves mGridSnap alone, which is what lets
getGridSize answer "the grid size even if the grid is off" -- and the script
above it is now separate too.

The Save Gui As button greys out again. Validate has been setting .active on
%this.createButton, which is a name from the Asset Admin dialogs this form
was copied from; this one's button is saveButton. So the write went nowhere
and Save looked available whatever the form said. Harmless in effect, since
onSave revalidates, but the form was telling the user the opposite of what
it had just worked out.

And GuiEditorColorWindow is gone. It was a scratch pad for looking at color
picker modes side by side, most of its own body commented out, the window it
builds commented out at the GuiEditor.cs end since before this branch -- and
still exec'd on every editor start.

Two new suites, both for behaviour that had none. gridSize drives the real
Set Grid Size dialog and then proves snapping by what it does rather than by
a getter, because there isn't one: hasSnapToGrid is C++-side only, so the
tell is a one-pixel nudge either landing on the next grid line or moving one
pixel. saveDialog checks the button against the form and deliberately never
calls onSave -- the folder it points at is real PlanetX content, and writing
a file into that to prove a button is grey would be a poor trade.

undo.cs gains the canvas drag in two halves: one that stays inside its
parent, which passed before this change and is here to hold the ordinary
drag still while the recording underneath it was rewritten, and one that
crosses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
New Gui cleared the canvas, Open Gui deleted what was on it, Close Project
restarted the instance and Exit quit, and not one of them said a word. The
file name was not on screen either, so there was nothing to say what was
being edited or that it had changed. The Profile Editor has asked about
unsaved themes since it was written; the document the editor exists to
author had never asked about anything.

All four ask now. The Gui Tools window's title carries the document -
"titleScreen.gui", or "untitled.gui *" before its first save with changes in
it - and File gains a Revert.

Not the window's own close button, which cannot be done from here. quit()
posts the quit message the moment it is called and the X posts it straight
from the window procedure, with no script in between and nothing to veto
with; onPreExit runs inside shutdown, long past the point where a question
could be asked. Guarding it means a hook in each of six platform back-ends.

The flag is not a count. UndoManager offers getUndoCount, getRedoCount and
two name lookups and no way to ask what is on top, and a depth is not an
identity anyway: save at five, undo once, make a DIFFERENT edit, and it is
five again with a different document underneath. A flag built that way reads
clean while dirty, which is the one direction it must never be wrong in.

So every action carries the serial it leaves the document at and the serial
it was applied to, and the recorder tracks where the document currently
stands. Undo of A moves to A.priorSerial, redo to A.serial; markClean
remembers the value, isModified compares against it. Undo back to the state
that was saved and the marker goes away; branch off differently and it
cannot, because that action's serial has never existed. Nothing mirrors the
C++ stack, so nothing can drift out of step with it - an action trimmed off
the bottom never replays and never restores its serial, which is the safe
direction. clear() takes a fresh serial for the same reason: detachTheme
empties the stack because every record names a profile about to be freed,
and the controls are exactly as edited afterwards as they were before.

The awkward part is that Save, from the prompt, may not finish. On a Gui
with no file it opens Save As, which has its own Cancel. So the interrupted
command is released in exactly two places - the end of SaveCore, and Discard
- and dropped when either dialog closes. There is no path where the file was
not written and the thing the user was leaving for happens anyway.

Close Project and Exit go through EditorCore.guardedCommand, which hands off
to the Gui Editor when there is one. EditorCore naming GuiEditor is the
coupling its File, Edit, Layout and Select menus already have.

Revert re-reads through loadGuiFile, which is Open's own second half pulled
out so the two cannot drift; it is guarded like the rest, because putting
the file back discards everything since. Its greying is deliberately NOT on
refreshDocumentTitle, which runs on every edit: setMenuActive walks the whole
menu tree and re-applies every item's profile, and whether the document has a
file changes only on a save, a new one and an open.

tests/smoke/unsaved.cs covers all of it, including the branch case above and
the cancelled Save As. undo.cs answers the new prompt where it uses New Gui
to empty the stacks, and the prompt-answering helper is in the prelude
because two other harnesses reach for New Gui the same way - one of them a
shot harness, which would otherwise have photographed the dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
A prefab mechanism from the original editor that nothing has ever called.
No script in this repository names either one, and the pair was already
orphaned when the 4.0 editor was written around this control.

It is not worth reviving. saveSelection writes through SimSet::write, the
old console-object format, into a file nothing else in the editor reads, and
loadSelection execs that file back and hunts for a set called guiClipboard
by name. The editor grew its own answer to this on the way past: the
clipboard holds deep clones, uniquifies the names they arrive with, and
places them through the same acceptControl every dropped control uses. If
prefabs are wanted later, that is the foundation, not this.

Neither was dangerous - I checked, because at a glance saveSelection looks
like it reparents the selection into a set and then deletes it. SimSet does
not own its members (SimSet::onRemove only clears notifications, where
SimGroup::onRemove unregisters), so the controls would have survived. Dead
rather than sharp.

GuiEditorBrain::onAddNewCtrlSet goes with them. loadSelection was the only
caller of that callback and the brain was its only implementation, so it has
been recording undo for an event that could not be raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
It still sold the editor as "an inspector, tree view, menus, save/load
dialogs, frame set layouts, color picker, and control reordering" - written
before most of the work and describing an inspector that has since been
replaced. Nothing about the control palette, the theme system, the Profile
Editor, undo, or the clipboard, all of which are the reasons to use it.

This is the first thing anyone evaluating 4.0 reads, so it now says what you
would notice in the first ten minutes: how a screen gets built, what the two
side panels are for, that the properties pane offers only the fields the
selected class actually reads, that a Gui you have changed is not thrown
away without being asked, and that appearance comes from a theme rather than
from profiles wired up by hand.

Two claims deliberately not made. "Every edit is undoable" is not quite
true - a frame set inside the Gui being authored can handle its own divider
drags through onMouseDownEditor, and nothing records those - so it says
there is undo and redo and leaves it there. And the Explorer is not opposite
the properties pane; it shares the right-hand column with the palette.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
The Edit menu offered Undo, Redo, Cut, Copy and Paste and stopped there.

Delete existed but was invisible. The key works on the canvas and in the
Explorer tree, and both routes have been recorded for undo all along, but
nothing in a menu said so and there was no other way to reach it. It is now
the last item in Edit, with the key beside it.

Duplicate did not exist. It was Ctrl+C, Ctrl+V, then drag the copy back to
roughly where you wanted it -- and the Ctrl+C took whatever was on the
clipboard with it. Now it is one command that puts the copy in the parent the
original is in, one grid step off, and leaves the clipboard alone. That last
part is the whole reason it is a command rather than a habit, and the suite
checks it directly: copy one control, duplicate another, paste, and what
arrives is what was copied.

It lives on GuiEditorClipboard because four of the five things it needs are
already there and are exactly right -- the reduction that stops a control
being copied twice when it and its parent are both selected, the name
carrying that deepClone deliberately does not do, the uniquifying, and the
counting-on from a trailing number so a copy of okButton2 is okButton3. Two
things a paste has to do it does not: no canBeChildOf test, because the
original is already a legal child of that parent, and no per-container step
count, because a duplicate is always one step from its own original.

Cut is now copy-then-DeleteSelection rather than carrying its own copy of
what deleting means.

DeleteSelection, and not Delete, for a reason worth writing down: delete is a
console method on every SimObject, so GuiEditor.Delete() destroys the editor.
Quietly, too -- the object goes and the next line to touch GuiEditor is the
one that reports an error, which in a test reads as four unrelated failures
several steps later. The smoke suite found it on the first run.

Ctrl+D goes to Duplicate, so Deselect moves to Ctrl-Shift A, which is what it
is bound to nearly everywhere else and pairs with Ctrl+A above it. The Delete
accelerator cannot double-fire with the key the canvas and tree handle
themselves: the canvas consults accelerators only once the first responder
has passed on the key, the same mechanism that lets a text box in the
properties pane keep Ctrl+C. What it adds is Delete working while focus is in
a tool window.

Both items follow the selection in toggleMenuItems, beside Cut and Copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
Cursors were the last part of the GUI a theme did not own. AppCore built
seven of them out of literals, and the file said so itself: "Cursors have
not moved into the theme yet, so they stay here." So a themed Gui got
themed everything except its pointer.

They are now the third member family, and they follow the profile shape
rather than the border one: seven categories, each guaranteed a default
member, each able to hold extras. A category with two cursors in it is the
thing that makes the Gui Editor offer a choice on a control's cursor slot.

Art is per theme, in <project>/themes/cursors/<Theme>. Two themes in one
game may want cursors that look nothing alike -- a menu pointer and a
combat reticle -- and a shared folder would mean one overwriting the
other's files.

Which forces the rule the rest of this hangs on: bitmapName, hotSpot and
renderOffset are set once when the member is created and are never
stamped. Art cannot be derived from a palette, so a restamp must not fight
what the user chose. They are also exempt from override tracking, because
there is no theme value behind them to override or to reset to -- and they
persist regardless, since nothing could rebuild them on load. Only the
tint is stamped, from colorForeground: a pointer is the mouse's equivalent
of text and wants the same contrast against the background.

The tint is worth having because the stock art is grayscale -- black
outline, white body -- so multiplying it colors the body and leaves the
outline. A new theme's cursors match its palette with nobody drawing
anything. dglClearBitmapModulation is literally white, so a colour field
defaulting to white is a byte-for-byte no-op for every cursor that existed
before this.

hotSpot and renderOffset both stay, and the split is not redundant.
renderOffset is a FRACTION of the art's own size, so one value anchors art
of any size; that is what stops a 13x17 pointer and a 32x32 sizer from
appearing to leap when one replaces the other. hotSpot is the pixel nudge
on top of it. The pointer ends up at hotSpot + trunc(extent *
renderOffset), and the editor drags only the second.

Two defects in GuiCursor had to go first. It cached its TextureHandle and
never dropped it, so pointing a live cursor at new art went on drawing the
old bitmap forever -- fatal for an editor. And bitmapName is TypeFilename,
which expands to an absolute path the moment it is set, so a saved theme
would have named a folder on one machine; it now has the same relative
read-back GuiControlProfile's bitmap already had.

GuiEditorCursorCtrl is the hot-spot editor. A hot spot cannot be set by
typing numbers -- it is one pixel in a 13x17 image, and whether it is the
right pixel is a question about what the art looks like. It draws the art
a pixel at a time rather than as a stretched bitmap, because magnifying
through the texture filter would blur exactly the boundaries the user is
aiming at, and it owns its own decoded copy of the art: a BitmapTexture
handle's CPU-side bitmap is freed once the texture manager has uploaded
it, so asking the cursor for its pixels gets NULL. The zoom clamps to what
fits and reports what it clamped to, because a readout naming a
magnification nothing is drawn at is worse than no readout.

The name matters: an editor-only Gui control must begin "GuiEdit" or the
control palette treats it as placeable. Two rules enforce that by prefix,
and the one in GuiEditorControlIcons.cs cannot be hand-edited because that
file is generated.

The placement values in the table are not the ones this started with. Five
of the seven moved once they were aimed in the editor rather than guessed:
the resize cursors want their crosshair centred on the pointer rather than
a pixel down and right of it, and the two bars want a pixel of lift so the
gap between their arrowheads straddles the edge being dragged. That is the
editor paying for itself on its first outing.

The arithmetic both the dot and the hit test depend on lives in statics
that take everything they use, so it can be checked without a canvas --
rendering a cursor needs a GL context and a texture, and a unit test has
neither. 14 new tests: the category table, member naming, extras, rename,
the tint override surviving a restamp, art NOT surviving one, a Taml round
trip, and the placement arithmetic including the truncation the engine
does and the editor has to reproduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
… when there is a choice

The engine half of this landed cursors in GuiProfileTheme. This is the
part you can use.

The Profile Editor grows a Cursors folder beside Profiles and Borders,
with a pane built around the magnifier rather than around field rows. The
name, art, tint, Nudge and Anchor rows are the ordinary field-row
machinery; what is not is the hot spot, and the readout under the
magnifier names the pixel the pointer really lands on -- which is neither
field on its own, so without it the two numbers below would look like they
disagreed with the dot. The Anchor is set from a 3x3 of presets, because
nobody reads "0.5 0.5" as "the middle" at a glance.

The preview frame gets a range to move the pointer through with a target
to click, because a cursor is judged by using it and nothing else in that
pane can tell you whether a hot spot is a pixel out. It swaps the canvas
cursor on entry and puts it back on exit; the target shows the cursor too,
because a button does not override getCursor, so what the canvas holds is
what it displays.

That range is dressed in the theme being edited rather than the editor's
own chrome, which is also what gives the target a hover state -- the
editor's button profile defines no highlight fill, and a target you cannot
see yourself hit is no test of anything. It is laid out entirely by sizing
flags: a theme is free to give its panels whatever padding it likes,
PlanetX does, and hard-coded positions slid straight out from under it.
GuiControl::onChildAdded parent-resizes each child against the parent's
INNER rect, so "fill" and "center" resolve against the padded area the
moment the child is added.

In the properties pane a cursor slot joins Variants on exactly the terms a
profile slot does: the row appears only where the active theme holds more
than one cursor for that job. Set Theme fills all six slots silently
either way, so a Gui wears ITS theme's cursors rather than whichever set a
project happened to install globally, and an unremarkable window shows no
cursor rows at all. TypeGuiCursor stays "hidden" to the generic field
walk, or the catch-all Other section would sprout an empty dropdown on
every window.

Detach writes the canonical name for the slot rather than an empty string.
Writing "" through TypeGuiCursor does not clear the field -- it falls back
to DefaultCursor -- which would put an arrow on a window's resize edge.

At runtime AppCore installs a chosen theme's cursors onto the names the
engine hard-codes, by copying fields onto them: a name belongs to one
object, and a theme's members must keep their own for the Guis that
reference them. guiCursors.cs therefore did not die so much as change
where it reads from, and appCore.cs now runs it after loadThemes instead
of before. Which theme is $pref::AppCore::cursorTheme, else the only one
loaded, else Base, else the first with a warning naming the ambiguity.
installThemeCursors is callable at any time, which is how a game swaps
between themes that look nothing alike -- swap the set, not the object.

Seeding gives each theme its own copy of the stock art and is safe to
re-run: pathCopy is asked not to overwrite, so art that has been replaced
or edited is never clobbered, and a theme that arrived from another
project heals itself on load. A theme rename takes the folder with it.

Two shared widgets grew one option each rather than being changed for
everybody. GuiProfileEditorFieldRow takes a swatchWidth, so a colour row
can be a button instead of a bar while a profile's state-colour rows keep
the full width they need to be told apart. EditorButtonBar takes a
TooltipFunction beside its existing EnabledFunction, so the two buttons
that serve both profiles and cursors can say which one they are about to
act on instead of always saying "Profile".

PlanetX's AppCore is updated to match the library's, which is the intended
path for a project's boilerplate and the only way anything in the repo
exercises the new runtime. The toybox's is deliberately untouched -- its
module says "historically different from the library AppCore. Do not
update it."

Two suites: cursorPane drives the pane, the seeded art, the placement
arithmetic, extras and the rename; cursorSlots drives the Variants rule,
the silent fill, and the detach. Plus a shots harness, because the
magnifier is the one part of this whose correctness is a thing you look
at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
… ours

Removing an extra cursor deleted its image file as a side effect. Right
answer nine times in ten -- the art was made for that cursor and nothing
else will use it -- but silently, and wrong in two cases that matter.

An extra can be pointed at art the user chose with the Find button, which
this editor did not put there and has no business removing. And it can be
pointed at the category's own stock file, which the default member is
still using, so deleting it would take the working cursor's picture with
it. Nothing checked either.

So the library no longer deletes anything on its own. removeExtraCursor
hands back the path it orphaned, or "" when there is nothing worth asking
about, and the dialog asks. A path qualifies only if it is inside the
theme's own cursor folder, no remaining cursor names it, and there is
really a file there. Confirming dooms it at the next save, like every
other file this editor removes -- so Cancel keeps it, exactly as Cancel
keeps a deleted theme.

Writing the test for that turned up a worse bug behind it. Renaming a
theme moved the cursor folder by copying the files the CATEGORY TABLE
names, then repointing every member -- but an extra's art is named after
the member, not the category. So a rename repointed each extra into the
new folder and left its file in the old one, and the cursor came back
pointing at nothing. The move is now driven by what the members actually
name, plus the stock file for each category so a member with no art still
finds one to be filled from at the next restamp. Art from outside the
folder is left where it is; it is not the theme's to move.

Also dropped a check in the PlanetX suite that asserted the project's own
extra cursor exists. That was the suite testing its author's content
rather than the engine's behaviour, and it failed the moment he deleted
the cursor, which was his to delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
Saved from the editor. Seven cursors, each aimed by hand in the hot-spot
pane rather than guessed, and all seven tinted to the theme's accent
green -- which is an override, so they stay that colour if the palette
moves under them.

The art travels with the theme instead of being referenced out of AppCore.
That is the whole point of a per-theme folder: a second theme in this
project can carry cursors that look nothing like these without one
overwriting the other's files. The seven files are copies of the stock
grayscale set, which is what lets the tint colour them.

The placement values here are also the ones now baked into the engine's
category table as the defaults for every new theme, so a theme created
from scratch starts where this one ended up rather than where it started.

Two things in the diff that are not cursors. cursorDirectory is new on the
theme, naming the folder above. PlanetXSelectedInsetBorder appears because
the border category existed but had never been written to this file; a
save materialises every member. And two category values case-fold on
write -- WindowButton to windowButton, FrameSet to frameSet -- which is
the long-standing StringTable case-insensitivity quirk, cosmetic and
unrelated to any of this.

The baked font caches sitting beside the theme are left out: those are a
test run's byproduct, not part of the project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
… particle

Opening the demo wrote two lines to the console before the title screen
came up, and both were real.

The first was Namespace::classLinkTo refusing to change the parent
linkage of PlanetXUpgrades from ScriptObject to PlanetXUpgrades. The
upgrade catalog was built as new ScriptObject(PlanetXUpgrades) { class =
"PlanetXUpgrades"; } -- named after its own class. linkNamespaces links
the class to the C++ class and then links the object name to the class,
and those are one namespace, so it asked that namespace to become its own
parent. Nothing broke: the failed link is ignored and the object ends up
wearing exactly the namespace it wanted either way. But it fired on every
boot, and its mirror image out of unlinkNamespaces on every exit.

The fix is to delete the class. A name IS a namespace -- linkNamespaces
links the object name whether or not a class is there -- so the second
word bought nothing. ThemeManager in EditorCore has always been written
this way. Only an unnamed object needs a class, which is why the settings
and screen singletons two lines away keep theirs.

The engine still has something to say about it, just not that. A
namespace cannot be its own parent and has no need to be, so classLinkTo
now treats the self-link as the no-op it is -- uncounted, because a count
here would want an unlink that never comes -- and warns in terms someone
can act on rather than reporting a failure that did not happen. Once,
where the repeat is written, not again at teardown. The error that guard
actually exists for, one namespace handed two different parents, is
untouched.

The second line was an asset id collision. sound/playerDeath.audio.taml
and particles/playerDeath.particle.taml both declared AssetName
"playerDeath", and a module's ids are one namespace across every asset
family. The particle is scanned first, so the sound was refused -- which
means the death sound has never played, because Audio.PlaySound was
being handed a ParticleAsset. The sound is playerDeathBurst now, named
for what it plays over. This module had already solved the same
collision once: sound/steam.audio.taml has held AssetName "steamHiss"
beside the steam particle all along, so the sound is what gets qualified
and the particle keeps the plain name. File names stay as they are,
paired with the .wav. playerDeath was the only duplicate AssetName in
the module.

Two sets of tests. namespaceLinkTests covers the engine half: the warning
fires once, names the namespace, and stays out of teardown; the parent
reference count survives two full rounds of link and unlink, which is
what a mismatched pair would quietly break; and two parents for one class
is still an error. The PlanetX suite gains five checks -- that the
catalog's onAdd ran and its methods resolve through the name alone, and
that each of the two ids answers with the right kind of asset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qi2pZ9UzF5rsLCNVErpzwt
The control existed to draw four named images - Normal, Hover, Down and
Inactive - on a button. A GuiControlProfile's imageAsset is already indexed
by control state, so a plain GuiButtonCtrl wearing a four frame strip does
the same thing with a class less.

VirtualKeyboard was its only user. Its thirty seven keys now wear four
profiles over four strips: keyStates, spaceBarStates, closeStates, and
keyLatched, which carries the pressed cap in every frame so caps lock reads
as held down for as long as the lock is on. The six single state images the
old control needed are gone.

The palette loses an entry with it: thirty drawings now, and the spec, the
three icon sheets and the control table all say so. tabBook asserted two
frame literals either side of the Tab Page seam; it now asserts that Window
sits immediately after Tab Page, which is what that test is actually for and
survives the next removal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
It opened to an empty screen. create() finished by calling reset() on a bare
"KeyboardToy", which is not the name of anything - a module's create is handed
its definition as %this, the way TruckToy and the rest call it - so reset never
ran, the dialog was never pushed, and nothing was drawn. That is the whole of
the black screen.

Behind it sat three years of quiet rot that only shows once something opens the
toy. UserNameTxt was a GuiTextCtrl, which Control Armageddon deleted in 2021; a
plain GuiControl carries text now. Two of the profiles the dialogs named went
with AppCore's: the image-backed roots asked for GuiToolboxProfile and now wear
GuiSpriteProfile, and the entry asked for GuiConsoleTextEditProfile and now
wears GuiTextEditProfile. BlueButtonProfile, GuiTextProfile and
GuiToolTipProfile are all still there, and are left alone.

The dialogs are held on %this and deleted through those handles rather than by
global name, guarded, because the Sandbox owns them once they are added and may
have taken them down first - which is exactly what the two "Unable to find
object" errors at shutdown were.

The smoke suite loads the toy and raises the keyboard the way its button does.
It is also the first time the state strip keycaps have been rendered by
anything, the toy being the only front end VirtualKeyboard has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
Peter's pass through the Gui Editor: ChangeUsernameDlg rebuilt with the entry
and a container holding Cancel and OK, both dialogs filling the canvas rather
than anchoring two edges, and the sizing named for the edge it holds.

MainGameDlg had only been resized, so it still carried the two dead references
that keep the toy from drawing: the root asked for GuiToolboxProfile, which
went with AppCore's profiles, and the label was still a GuiTextCtrl. The root
wears GuiDefaultProfile now, to match the other dialog, and the label is a
GuiControl carrying its own text. Everything else in the file is as it was
laid out.

Note for later: the entry renders invisible while it has focus, and push()
always gives it focus. GuiTextEditProfile names fillColor, fillColorHL and
fillColorNA but no fillColorSL, so the selected state falls back to
GuiDefaultProfile's "0 0 0 0". That is the Sandbox's shared profile, not this
toy's, so it is left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
The Gui Editor saved the keyboard's keys without a Text attribute, which was
right - their captions were blank, and SimObject::writeField drops every empty
value. Reading them back put "Button" on all thirty seven of them, because
that is what the constructor seeded and an absent attribute is exactly what a
constructor default fills.

So a default caption cannot survive the round trip it is part of: blank is
written as absent, absent reads as the default, and the author's blank is
gone. GuiButtonCtrl carries no caption of its own now. GuiCheckBoxCtrl and
GuiRadioCtrl take their text from it, so a check box that read "Button" is
fixed along with it.

The placeholder is still worth having, so it moved to the moment a control is
actually placed. GuiEditorControlTile::makePayload captions what it makes from
the palette's own label, and it is the one point both the drag and the click
go through, so the two gestures cannot drift apart. A dropped check box reads
"Check Box" now instead of the "Button" it used to inherit.

GuiDropDownCtrl keeps its "none". It draws mText only while nothing is
selected, so that is an empty state rather than a caption, and nothing about
it is a caption to be replaced.

The checks are a smoke suite rather than a C++ unit test because building a
button assigns it a profile, a profile loads its font, and loading a font
registers a texture - which asserts with no GL context. Two traps are worth
knowing about and are written down in the suite: getText() on a control that
was never made answers the empty string, so every check tests for the object
first, and GuiDropDownCtrl binds its own getText that answers the selected
item and never reads mText.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
A text edit takes focus the moment it is shown - VirtualKeyboard::push makes
its target the first responder - and a focused control draws in the selected
state. The profile named fillColor, fillColorHL and fillColorNA but no
fillColorSL, so the selected state fell through to GuiDefaultProfile's
"0 0 0 0" and a focused field rendered as nothing at all: a caret hanging in
mid air over whatever was behind it.

It takes the same color as the highlight it sits beside, so the field looks
the same whether the pointer is over it or the caret is in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
…m out

MainGameDlg rebuilt the way ChangeUsernameDlg already was: the name and both
buttons gathered into one centred container, so the group holds the middle of
the canvas rather than each control anchoring separately, and sized to be
read - "Change User Name" and Exit at 160 by 50, the name above them at twice
the profile's font size.

keyboardGui.taml is now keyboardGui.gui.taml. The Gui Editor lists the files
it can open by that suffix, so the keyboard could not be opened in the editor
that made the rest of this; main.cs loads the new name and nothing else
referred to the old one.

The keys come back from the editor with no Text attribute at all, which is
exactly what they mean - their faces are in the profile's imageAsset and the
caption is deliberately blank. That is only safe to save now: a blank caption
is written as an absent one, and until GuiButtonCtrl stopped seeding "Button"
an absent caption read back as a captioned key. All thirty seven of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
They are a working artefact of how a change got planned, not something
the repository needs to carry. What the code does and why is already
written where it is done, which is the only copy that cannot go stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
A scaled control caches the proportion of its parent it occupies, so that
a run of layout passes cannot round its edges away a pixel at a time. The
Position and Extent field setters clear that cache, because writing a
position is the moment it stops describing the control -- and changing
parent is that moment too, which nothing had noticed.

So onChildAdded applied the OLD parent's proportion to the NEW parent's
extent. A button 200 wide at x=100 in an 800-wide container arrived 50
wide at x=25 in a 200-wide one, and the drop then put the position back
under the pointer, which hid half of it: the button landed where it was
dropped, at a quarter of its size. Every other sizing mode already kept
its extent across a move, and now scale does too. The cache is recharged
against the new parent rather than switched off, so a scaled control
still scales when that parent is resized.

mStoredExtent goes the same way. It records extent given up to minExtent
and owed back when there is room again, and a debt run up under one
parent is not the next one's to pay.

Two more things fall out of testing it.

moveSelectionToCtrl now resizes rather than writing mBounds directly.
Keeping the control under the pointer is right for the modes that own
their position and wrong for the two that do not: addObject has just
centred or filled the control against its new parent, and the direct
write threw that away. It looked correct only because the next mouse move
called resize for some other reason and put it back.

And rescuedPosition/pullIntoView, which the Explorer tree needs next: a
tree drag has no pointer in it, so nothing supplies a position and a small
enough new parent can leave the control entirely outside itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
Dragging a row in the Explorer tree from one branch to another is the one
reparenting gesture with no pointer in it. Nothing supplies a position, so
the control keeps the local one it held in its old parent -- and a button
at x=400 moved into a container 100 wide is not clipped or half hidden, it
is gone. Gone from the canvas is nearly gone for good, because the canvas
is where you would reach for it.

So onPostReorder now offers each control that moved the chance to come
back. Per axis, because a placement that is still valid should be kept: a
control that was 20 pixels down and 400 across is still 20 pixels down.
Only for a control that is ENTIRELY outside, because one the user can see
is one the user can drag, and moving one that merely overhangs an edge
would be undoing a placement rather than rescuing it.

Before commitHierarchy, which is what makes undo right at no cost. The
undo step is built from what layoutOf reads at commit time, so rescuing
first folds the correction into the same "Reparent Control" action: one
Ctrl+Z puts the control back in its old parent AT ITS OLD POSITION, rather
than leaving it rescued in a parent that no longer wants it there.

The canvas drag is deliberately left out. A dragged control is under the
pointer and so inside its new container by construction, and a sibling of
a multiple selection snapping to the origin half way through a gesture the
user is still making is worse than the thing it fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
Clicking the eye in the Explorer stopped a control being drawn and left it
taking every click aimed at what was behind it -- so you could see through
it but not reach through it, which is the one thing hiding is for. The
intent was already written down in two places that promised it and nowhere
that did it: SimObject::_writeHidden's comment ("hide a control to reach
what was behind it") and the eye's own tooltip.

The asymmetry was one line. renderChildControls skipped a hidden child;
findHitControl did not. Both now ask GuiControl::isHiddenInEditor, so the
paint and the hit test cannot drift apart again, and skipping the child
before the recursion takes its whole subtree with it exactly as the render
does. The test is scoped by isEditMode, which walks up to the edit root and
gives up at once when the editor is shut, so a running game neither pays
for it nor obeys it.

Three paths reach a control without hit testing it, and all three would
have defeated that:

- The sizing knobs are tested at the top of onTouchDown, ahead of the hit
  test, and the eye click deliberately does not move the selection - so a
  control hidden while selected kept eight invisible grab zones straddling
  its edges, which is where someone reaching past it would click.
  editGeometryFrozen gained isHidden, which settles the resize cursor, the
  resize gesture, the drag snap-back and the arrow-key nudge at once, and
  makes the decoration honest: it already drew a hidden control as a dashed
  outline with no handles.
- A rubber band walks the add set's children rather than hit testing them.
- The add set is not chosen by hit testing at all, so hiding the container
  being worked in left the next control placed inside it, appearing
  nowhere. GuiEditCtrl::controlHidden walks back out to what still draws.
  A drag heals itself, because it picks its target with findHitControl.

Locked is deliberately untouched: a padlock means "do not change this by
accident", not "get out of my way", and you still want to click it to read
its properties.

Verified both ways round. guiHitTestTests.cc covers the rule and its
scoping without a canvas; hiddenClickThrough.cs asks the same questions of
the real editor; hiddenNotATarget.cs posts real input for the three paths
script cannot reach - a click on a hidden control's knob, a click on the
eye of the add set, and a band across a hidden control and a shown one.
With the engine change reverted, each of those fails exactly the checks it
exists for and no others.

Send-EngineDrag is new in tests/lib/input.ps1: a posted press, run of moves
and release does drive a rubber band, unlike the drag-and-drop gesture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDKWjuEF5Nuw791mpU7aPF
The hot-spot pane held a raw GuiCursor* and nothing told it when that
cursor went away. Removing a theme's extra cursor is exactly that
sequence, so the next frame drew from freed memory: resolveBitmap asked
the dead object for its bitmap name and dereferenced whatever came back.
On Linux that is a segfault partway through cursorPane.cs, with mCursor's
vptr already NULL and every field garbage. On Windows the freed heap
still read plausibly, which is the only reason this looked fine.

The registration is tracked in mNotifiedCursor rather than inferred from
mCursor, because the two are not always the same. setCursorObject has no
callers at all: the pane is bound by writing the "cursor" field, which
TypeGuiCursor resolves and stores straight into mCursor with no chance
for us to see what was there before. So the notify is reconciled after
the write instead -- onStaticModified is the one hook every path goes
through, script assignment and .gui.taml load alike -- and setCursorObject
reconciles too, so binding either way is safe.

onDeleteNotify drops the pointer and releases the decoded art, leaving
the pane drawing empty until it is given another cursor, which is what it
already does for a cursor whose file is missing. SimObject unwinds both
halves of the registration itself in unregisterObject, so there is
nothing to undo in the destructor.

GuiTextEditCtrl::mEditCursor is exposed the same way through the same
console type, and is left alone here: nothing deletes a cursor out from
under a text box yet, and that one predates this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…them

The bash runner skipped a hardcoded pair, tooltipProfile and textClick,
as the suites whose input comes from a Win32-only *.input.ps1 companion.
Six more have gained one since, and every one of them was being run
without a driver: explorerGutter, hiddenNotATarget, menuBarClick,
tabBookClick, textEdit and toggleTip. Five then failed, and failed
convincingly -- "clicking the eye hid the control", "the box took the
click and the caret is at the end" -- so a Unix run reported nineteen
engine failures that were nothing but the missing mouse.

The list is now read off the companions actually on disk, which is the
same fact the pair was standing in for and cannot fall behind them again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
greenfire27 and others added 2 commits August 7, 2026 22:54
Platform::createPath took the last '/' in the path and terminated the
string after it, to drop the file name from the end. A bare file name in
the working directory has no '/' at all, so dStrrchr returned NULL and
the write went to address 0x1.

Windows and Linux both walk the path with dStrchr and so simply do
nothing when there is no separator to find; say the same thing here and
return, since a path with no directory component has none to create.

This crashed the unit suite at test 65 of 199: the profile theme tests
round-trip a TAML file named in the working directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… out

These two are the only platforms whose File holds buffered stdio rather
than an unbuffered OS handle: Windows keeps a HANDLE and asks
GetFileSize, Linux keeps a descriptor and seeks to the end, and neither
can see a size the file does not yet have. Here getSize fstat'd the
inode, which knows nothing of what is still sitting in the FILE* buffer,
so a file just written to reported a size of zero.

Flush before asking. Only when the file can be written to -- a read-only
file has nothing outstanding, which leaves the engine's ordinary reading
of scripts and assets untouched -- and the one caller that asks often,
setPosition, has already flushed by way of its own fseek, so it finds an
empty buffer and pays nothing.

FileStream::getStreamSize already compensates this way for its own
buffer, taking the greater of the file's size and its dirty tail; the
stdio buffer beneath it had no such cover. The one place the stale
answer reached persisted data is ZipArchive::updateFile, which records
getStreamSize as a zip entry's compressed size while the temp stream is
still open (that path is now closed, though a short size was never
observed in the wild).

Fixes PlatformFileIOTests.FileWriteRead, which wrote 21 bytes, agreed
the position was 21, and was told the size was 0.

The iOS copy is the same edit to identical code; it is unbuilt, as this
machine has only the Command Line Tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greenfire27
greenfire27 merged commit 8e85afb into development Aug 8, 2026
18 checks passed
@greenfire27
greenfire27 deleted the gui-editor-improvements branch August 8, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant