Gui Editor: an editor you can build a screen in - #85
Merged
Conversation
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
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
GuiTextEditSliderCtrlaboveGuiTreeViewCtrlabove
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 reachesaddNewControl, firesonAddNewCtrl, and is undoable for free rather than by a second implementationthat 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::onRenderItemdrew a row in one unbroken run, so this neededthe row split at the two places new content goes:
renderItemGutterbefore thefocus line and the indent (so it stays pinned to the left edge instead of
travelling with the tree),
renderItemIconbetween 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.
hiddenandlockedmoved here out of the properties pane, where they had beenstanding 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++
GuiInspectorreflected every registered persist field into flatalphabetical groups. That offers a
GuiChainCtrlnine text fields it neverdraws, and a
GuiTabPageCtrlfour geometry fields its book overwrites on the nextlayout pass — editing either looks like it works and silently reverts.
GuiEditorInspectorPanereplaces it: a per-class spec of what that class reallyreads, 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:
arrive with no pages and render as nothing whatsoever.
GuiMenuItemCtrlis rightly refused by thepalette, and nothing else made one, so a dropped bar was a permanently empty
strip.
TAML custom nodes. Previously its rows lived only in
mItemsand had to bebuilt in script in an
onWakesomewhere away from the Gui that shows them.Undo, the clipboard, and the document
Ctrl+Zdid nothing: the Edit menu was switched off with a note saying so, andtwelve mutation sites in
guiEditCtrl.cccarried a bare// undocomment wherethe recording used to be. Almost none of what was missing was transport —
UndoManagerhas been incollection/undo.hall along, bound to script, ownedby
GuiEditCtrl. What was missing was a recorder, and a faithful way toduplicate a control.
delete, so undo never has to resurrect anything.
SimGroupstash, placed throughthe same
acceptControlevery dropped control goes through.Ctrl+D), which puts a copy one grid step off in the sameparent and leaves the clipboard alone — that last part being the whole reason
it is a command rather than a habit.
the key worked and no menu said so. It is now the last item in Edit.
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
isModifiedcompares 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 pointersthat look nothing alike.
GuiEditorCursorCtrlis a magnifying hot-spot editor, because whether a hot spotis 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,hotSpotandrenderOffsetare setonce 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 itcolors 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
GuiControlsizing flags were named for the wrong edge.horizResizeRighthas no branch in
parentResizedat all, so "right" pinned the left edge.Reading a Gui file meant inverting every flag in your head. The tables gain
anchorLeft/anchorRight/anchorTop/anchorBottomandscale, listedfirst; the old names still parse.
GuiScrollCtrlnow leaves room for its own bar. It subtracted the bar onlywhere 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:".
cached the proportion of its parent it occupied;
onChildAddedapplied theold 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.
renderChildControlsskipped a hidden child;
findHitControldid not — so you could see through itbut not reach through it, which is the one thing hiding is for. Both now ask
GuiControl::isHiddenInEditor, scoped byisEditModeso a running gameneither 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;assignFieldsFromcopiednameand
parentGroup, and the second of those is a trap, becausesetParentGroupcalls
parent->addObject()and the "copy" silently moves itself into theoriginal's group.
GuiButtonCtrlno longer seeds a "Button" caption.writeFielddropsevery 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::classLinkTotreats a self-link as the no-op it is, and warnsonce in terms someone can act on.
above), and
GuiTextEditCtrlno longer draws two carets after a return.Removals
GuiImageButtonCtrl— it existed to draw four named state images on abutton, and a profile's
imageAssetis already indexed by control state, so aplain
GuiButtonCtrlwearing a four-frame strip does the same with a classless. VirtualKeyboard was its only user and has been converted.
GuiEditCtrl::saveSelection/loadSelection— a prefab mechanism from theoriginal 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, thewindow it builds commented out at the call site, still
exec'd on every editorstart.
docs/superpowers/specs) — a working artefact of how achange got planned. What the code does and why is written where it is done.
Content
KeyboardToy opened to an empty screen:
create()calledreset()on a bare"KeyboardToy", which is not the name of anything, so the dialog was neverpushed. Behind that sat three years of quiet rot — a
GuiTextCtrlthat ControlArmageddon 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
ScriptObjectnamed after its own class loses the redundant
class; and a sound that wassilently a particle (
playerDeathcollided across asset families, so the deathsound has never played) is renamed.
Testing
tests\run-unit.ps1is new — the GoogleTest side had no runner at all, and ittakes a filter rather than passing one on, because
runAllUnitTestshandsInitGoogleTestan empty argv and GoogleTest readsGTEST_FILTERfrom theenvironment.
smoke suites pass on Windows against a Debug build of this branch.
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.mdandtests/README.mdnow 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 andGuiDefaultProfile, but a font registers a texture andTextureManager::refreshasserts — 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
$Expectedknown-failure table intests/run.ps1is empty: every suite isexpected to pass.
tooltipProfileis 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.
Two claims are deliberately not made: "every edit is undoable" (a frame set
handles its own divider drags through
onMouseDownEditor, and nothing recordsthose), and that the Explorer sits opposite the properties pane (it shares the
right-hand column with the palette).
prompt.
quit()posts the quit message the moment it is called and the X postsit 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