Skip to content

GuiProfileTheme: themes as the source of a Gui's look, and an editor for them - #84

Merged
greenfire27 merged 35 commits into
developmentfrom
Gui-Profile-Editor
Jul 27, 2026
Merged

GuiProfileTheme: themes as the source of a Gui's look, and an editor for them#84
greenfire27 merged 35 commits into
developmentfrom
Gui-Profile-Editor

Conversation

@greenfire27

@greenfire27 greenfire27 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Themes become the way a Gui gets its look, and the Gui Editor gets a full editor for them.

Before this, a project's appearance came from ~70 hand-written GuiControlProfile objects in AppCore/scripts/guiProfiles.cs. Changing the accent colour meant editing dozens of them by hand and hoping you found them all, and a profile could only be edited through the raw inspector — every field, no context, no idea which ones the control you were skinning actually reads.

What this adds

GuiProfileTheme (engine/source/gui/guiProfileTheme.{h,cc}) — a set of theme-wide values (three fonts, six colours, a base font size, a base border size) and an engine-defined category table. On registration a theme creates one member profile per category, named <Theme><Suffix>, and stamps every field from a recipe. Change a theme value and every member restamps, skipping any field you explicitly overrode. Members serialise as ordinary TAML children, writing only their name, category and overrides. AppCore's script profiles are gone; GuiDefaultProfile moved into C++ so it exists before any script runs.

The Profile Editor — a full-screen dialog in the Gui Editor with a tree of themes, categories, borders and stand-alone profiles, and a purpose-built pane for each:

  • a profile pane that shows only the fields the selected category actually renders, on a grid that reflows to the pane width, with per-field reset markers wherever you've diverged from the theme
  • a borders pane and a shared 16-value border grid (margin/border/colour/padding × four states)
  • a theme form for the six colours, three fonts and sizes
  • a live preview of the category you're editing
  • pickers rather than typing: installed fonts, image assets by thumbnail, bitmaps by file dialog

Set Theme re-profiles a whole Gui by control category in one action, and AppCore loads themes at runtime.

Highlights from the last few commits

  • GuiColorPopupCtrl gains two optional rows (1bd02bb7): a swatch grid the Profile Editor fills with the selected theme's six colours, so setting a fill colour to the accent is a click rather than a hunt around the wheel; and an R/G/B/A row in 0–255 or 0.0–1.0. Both off by default, so existing popups are untouched.
  • The colour pickers were quietly corrupting exact colours. Each one derives its colour by reading the pixel under its selector and pushing that back, which is right when you drag a selector and wrong when the popup placed it — a colour set from a swatch or typed into a box was overwritten by a framebuffer round-trip on the next frame, and simply opening a popup drifted the colour slightly. Fixed by suppressing one push-back when the popup is the one that moved the selector.
  • A latent GuiControl tooltip bug (1bd02bb7): renderTooltip acquired a default tooltip profile without taking a reference while onSleep handed one back regardless, so any control with a tooltip and no tooltip profile slept one reference short. It only ever surfaced once something tooltipped lived somewhere that sleeps on a user action. Tooltips now also default to the Tooltip member of the theme the control's own profile belongs to, re-resolved per draw so a re-themed control's tips follow it.

Testing

tests/ (9338451d) collects the TorqueScript integration suites that drive the real engine — real canvas, real editor, real posted mouse and keyboard input. These are the only coverage the editors have; GoogleTest never reaches a canvas.

tests\run.ps1                 16 suites, exits non-zero on a change
tests\run.ps1 -Shots          7 screenshot harnesses

Current state — 16 of 16 suites clean, 448 checks, no failures and nothing killed: profileForm 83, assetPicker 64, standalone 60, themeApply 38, border 37, profileEditor 32, font 32, colorPopup 31, borderPane 20, planetX 18, headerPane 9, bitmapPathWrite 7, tooltipProfile 6, textClick 3, toybox 3, bitmapPathRead 1. The 7 shot harnesses write 20 screenshots.

tests/README.md documents the two engine facts that shape the setup: the boot script's own folder becomes the working directory, and a relative path is expanded against the calling script rather than the working directory.

Known issue

One, and it is not introduced here.

The profile-lifetime problem is still open. Assigning a profile through the Profile field on a live control bypasses the reference counting setControlProfile does, so a profile deleted while still worn leaves controls holding a dangling pointer. This PR adds mitigations at editor teardown, a "still worn" warning on ~GuiControlProfile, and — for tooltip profiles specifically — setControlTooltipProfile, which does the book-keeping properly. Closing it in general means routing the Profile and tooltipprofile persist fields through protected setters, which touches every control and every TAML load, so it wants its own pass rather than riding along here.

Correction

An earlier revision of this description, and the commit message in 9338451d, listed two failing suites as pre-existing engine problems — border losing nine checks and hanging on a teardown assert, profileForm losing one. Both claims were wrong, and 38adc8d6 and 88d225c2 fix them. Neither was an engine bug; both were stale tests asserting a design that had moved on.

border looked for a stand-alone profile's bundle with %profile.getGroup() and asserted it was a ScriptGroup. Bundles became SimSets deliberately — a group takes the profile out of GuiDataGroup, the only place the engine looks when filling a Profile dropdown — and a set leaves membership alone, so the test got GuiDataGroup back and then deleted it, destroying every editor profile. The fatal assert two lines later was the test asking for what it had just destroyed; shutdown was never reached. profileForm asserted a fontDirectory row was visible, but that field has no row at all: the editor owns it and points every profile at the project's one font folder, so isVisible() was being called on nothing.

Both now assert what the design actually promises, and $Expected in the runner is empty.

Notes for review

  • Design doc: docs/superpowers/specs/2026-07-19-gui-profile-theme-design.md.
  • Engine sources are listed explicitly in cmake/EngineSources.cmake — the new files are registered there.
  • Script follows TORQUE_SCRIPT.md (one class per file, onAdd/onRemove ownership, class/superclass with init()).
  • 140 files, +18.8k/−2.7k. It reads far better commit by commit than as one diff; each message explains the why and the traps found along the way.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU

greenfire27 and others added 30 commits July 19, 2026 14:48
Design for the C++ GuiProfileTheme object: engine-defined category
table, stamp-on-change propagation with per-field overrides, TAML
persistence of overridden fields only. Foundation for the future
Profile Editor dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F93PJs6E2sB3KUYdaFufNT
C++ port of the script-side theme color helpers (BaseTheme::adjustValue
/ AppCore::AdjustColorValue): an HSV-value shift preserving hue and
alpha, and an alpha replacement. Fixes the script version's clamp bug
(value fraction now clamps to 0..1, so over-brightening no longer
washes out hue) and defines black to brighten along the neutral gray
rail. Seven unit tests, written first and watched fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F93PJs6E2sB3KUYdaFufNT
GuiControlProfile and GuiBorderProfile gain a GuiThemeMembership: a
non-owning back-pointer to their GuiProfileTheme plus per-field
override tracking. Any external field write (script, editor, TAML
read) marks the field overridden via onStaticModified; writeField
filters non-overridden fields from serialization on themed objects.
Standalone profiles are unaffected. GuiBorderProfile gains a category
persist field. GuiProfileTheme is now a registered SimObject.

Also fixes a latent dangling-pointer bug: border deleteNotify
registrations were never handled, so deleting a border profile left
GuiControlProfile pointing at freed memory. onDeleteNotify now nulls
the matching border pointers (and the theme back-pointer).

Seven new unit tests, written first and watched fail; 54/54 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F93PJs6E2sB3KUYdaFufNT
GuiProfileTheme is now a full SimObject with the theme-wide values
(3 semantic fonts + directory + size, 6 semantic colors, borderSize)
as persist fields. On registration it auto-creates one named member
profile per entry of the engine-defined 36-category table (plus
border members), stamps every member from the theme values, and
restamps on any theme field change, skipping member-overridden
fields. Members are owned (deleted on theme removal), delete-safe in
both directions, and deleted defaults are recreated on restamp so a
theme is always complete. Extra profiles can be created per category;
only extras are removable.

Category recipes are a shared placeholder for now; the real per-
category ports from AppCore guiProfiles.cs land next.

Six new unit tests, written first and watched fail; 60/60 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F93PJs6E2sB3KUYdaFufNT
All 36 profile categories and 27 border categories now have real
recipes, faithfully ported from library/AppCore/gui/guiProfiles.cs
with its color1..6 palette mapped onto the semantic theme values.
Per Peter, colorTextSubtle was replaced by colorHighlight (AppCore
has two accents - blue interaction + yellow flavor - and no subtle
text color), so the six roles map 1:1 with no dead fields.

Every recipe starts from the Default recipe so every themed field is
always derived; border slots are wired to theme border members with
delete notification. Six recipe contract tests written first and
watched fail; 66/66 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F93PJs6E2sB3KUYdaFufNT
A theme serializes to one TAML file: theme values as attributes plus
every member (borders first, defaults, then extras) as ordinary child
objects. Each themed member writes only its name, category, an
explicit themeOverrides field-name list (new protected field), and
the overridden field values - the writeField filter drops everything
derived. On read, onTamlPreRead suppresses auto-creation so file
members claim their names and category slots via addTamlChild
(preserving loaded override sets); onTamlPostRead creates any missing
defaults and restamps, re-deriving all non-overridden fields from the
loaded theme values. Standalone profile serialization is unchanged.

Two round-trip tests written first and watched fail; 68/68 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F93PJs6E2sB3KUYdaFufNT
The script surface the future Profile Editor drives: getProfile(s)/
getBorder by category, category-name enumeration for both tables,
createProfile/removeProfile for extras, resetField/resetProfile and
isFieldOverridden for override management (accepting profile or
border members), restamp, and the adjustValue/setAlpha color helpers.
End-to-end binding test via Con::executef/evaluate, watched fail
first; 69/69 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F93PJs6E2sB3KUYdaFufNT
…Editor

Script side (editor/GuiEditor):
- "Gui Tools" window docked above the Gui Inspector (frameset docks by
  add-order; the inspector column gains a vertical split) with a button
  bar that opens the new Profile Editor dialog.
- GuiProfileEditorDialog: near-full-screen EditorDialog with a theme tree
  (proxy SimGroup hierarchy - GuiTreeViewCtrl only walks SimGroups), the
  native GuiInspector on the selected member, a live preview pane
  (editorGrid sprite backdrop, samples centered on an invisible stage),
  and Cancel/Save. Toggles editorMode off while open: editor mode
  shadow-names new objects, which would break member naming and TAML.
- GuiProfileEditorLibrary: persistent theme owner living on GuiEditor so
  member profiles stay in GuiDataGroup for the profile dropdowns after
  the dialog closes. Loads <project>/themes/*.taml (idempotent rescans),
  tracks dirty roots, saves them to their files, reverts them by
  re-reading those files; theme file deletion is deferred to save.
  The themes path anchors on the loaded AppCore module rather than
  ProjectManager's cached derivation, which collapses to the repo root
  when module paths are relative or the module database shifts.
- Name-prompt and confirm helper dialogs; both defer their deletion
  through the parent (EditorCore::deleteDialogObject) because scheduling
  the native "delete" on an object fires inside its own script-callback
  guard and asserts.

Engine side:
- GuiProfileTheme::renameTheme: renames the theme and every member on
  the <ThemeName><Suffix> pattern, rewrites overridden border-name
  references, refuses atomically on any name collision. Unit tested.
- GuiInspector: OverrideLabelProfile plus a per-row reset button for
  themed profile/border targets; overridden fields restyle and reset to
  the theme value in place. GuiInspectorTypeEnum now routes through
  apply() so enum and profile dropdowns fire onPreApply/onPostApply.
- GuiTreeViewCtrl: null-guard mFocusControl, which is only assigned for
  editor-bound trees; any other tree crashed on first render.
- GuiInspectorField: initialize mEdit in both constructors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwWCqiB7KNhYfWUukrT27X
…fixes

Replace the generic inspector with a purpose-built ProfileThemeEditForm when a
theme is selected (the inspector stays for members), and make the dialog
genuinely usable.

Form (editor/GuiEditor/scripts/ProfileThemeEditForm.cs, EditorForm.cs):
- Border size, font directory (file picker), Title/Body/Code font dropdowns
  (enumerated from the directory, baked on apply), font size, and six color rows
  rebuilt to match the native inspector (swatch + numeric R/G/B/A). Text fields
  commit on blur; numeric-only input. Colors launder through the swatch so named
  colors like "White" no longer collapse to black.

Theme core (guiProfileTheme.cc/.h, tests):
- borderSize now scales every stamped border (it was previously dead).
- Fonts vary by role: Title on chrome, Code on inputs, Body on content, with a
  larger window title and smaller tooltips/overlays.
- Rename the palette for clarity: colorPanel->colorSurface, colorText->
  colorForeground (Background/Accent/Highlight/Warning kept).
- New themes default to borderSize 0, fontSize 16, and the engine font cache dir.

Preview (GuiProfileEditorPreview.cs):
- Load editor/GuiEditor/gui/theme_sample.gui.taml and re-skin every control with
  the theme's generated category profiles, instead of a hand-built mock.

Layout (GuiProfileEditorDialog.cs):
- Put the tree, member editor, and preview in a resizable GuiFrameSetCtrl; the
  member editor is a draggable "Properties" window. Force an initial layout pass
  since the frameset only lays out on a resize event.

Fixes:
- win32FileDialog.cc: the folder dialog crashed on x64 because a 64-bit WndProc
  pointer was truncated into a LONG; use LONG_PTR. Fixes every folder picker.
- Tear down the Profile Editor dialog in onExit before the editor and AppCore
  modules unload, so its live preview no longer dangles at shutdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwWCqiB7KNhYfWUukrT27X
Reduce a theme's generated borders from 27 categories to 6 named ones
(Empty/Rimmed/Thick/Bright/Dark/Padded), faithfully remapping the built-in
profile recipes onto them, and add a Borders pane to the Profile Editor so a
profile's default and per-side borders can be nudged without hand-authoring a
new profile to track.

Engine:
- guiProfileTheme: generate the 6 named borders; support single-use "custom"
  borders (mExtraBorders, createBorder/removeBorder) that persist via TAML and
  are routed on read by GuiBorderProfile's new isCustom flag.
- guiTypes (GuiControlProfile): re-resolve cached side-border pointers on any
  border-field write, and treat an empty side name as fall-back-to-default, so
  editing a side takes effect live.
- guiTextEditCtrl: optional onUpArrow/onDownArrow script hooks (opt-in via
  isMethod) for the up/down arrow keys.
- 6 new gtests (6-border generation/values, custom-border create/remove, TAML
  round-trip, side re-resolution) and updated the border-name assertions.

Editor (script):
- New GuiProfileEditorBorderSetter: five setters (default + top/bottom/left/
  right) with a dropdown of the 6 borders + "Custom...", a collapsible
  16-value editor, and numeric spinner inputs (click-selects, up/down nudge).
  A side is auto-cleared when it would duplicate the default.
- GuiProfileEditorDialog: a collapsing "Borders" frame shown only for
  profiles; the border fields are hidden from the inspector; scrollers use
  fill sizing so their scrollbars aren't clipped.
- GuiProfileEditorLibrary: standalone profiles are wrapped in a persisted
  ScriptGroup bundle so their custom borders round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwWCqiB7KNhYfWUukrT27X
Clicking a live preview sample while a border/inspector/theme-form text
field still held first-responder crashed with a use-after-free. The click
switched first-responder, firing the text box's onLoseFirstResponder ->
commit -> onBorderChanged/onProfileChanged/applyField -> preview.refresh()
-> clearSamples(), which deleted the very sample control whose onTouchDown
was still on the stack. The unwind then dereferenced freed memory in
GuiControl::setFirstResponder (this->setUpdate()), a wild `this`.

Defer the preview rebuild to schedule(0) (coalesced via previewRefreshEvent)
so samples are never freed inside an in-flight input/focus event. Route the
three field-commit refresh paths through the new
GuiProfileEditorDialog::schedulePreviewRefresh helper.

Verified with a headless focus-switch repro (sample now survives its own
click) and both smoke tests (borderSmoke 32/32, profileEditorSmoke 32/32).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwWCqiB7KNhYfWUukrT27X
Two independent tree-view bugs surfaced by the Gui Profile Editor, both
rooted in the tree overriding render/hit-test to work in visible-row
space while the parent list box kept sizing and scrolling in raw-index
space.

Scroll: clicking a row below a collapsed section jumped the scrollbar
down, and the scroll range never shrank when a section closed. Rows
render packed by visible position, but ScrollToIndex scrolled to
itemSize*rawIndex and updateSize sized the control to mItems.size()
(every item, including hidden ones).
- Override ScrollToIndex to scroll to the visible-row position.
- Make updateSize virtual; override it to size to the visible row count,
  and call it after collapse/expand (handleItemClick) and in refreshTree.

Drag: dragging a row crashed because onTouchUp cast every itemData to
GuiControl/SimGroup and called childrenReordered(); the Profile Editor
tree holds proxy SimGroup/ScriptObject items, so the virtual call hit a
garbage vtable.
- Add an independent AllowReorder field (default off) and gate mDragActive
  on it instead of overloading BindToGuiEditor. The Gui Editor explorer
  opts in; proxy trees stay off.
- Extract the reorder body into reorderFromDrag(), fully guarded: recover
  itemData via getItemObject() then dynamic_cast, and null-check dragItem,
  trunk (root drop), checkItem (walking off the top of mItems), target,
  group, and control. onTouchUp now always resets mDragActive and chains
  to Parent, even on early bail.
- Fix a mTouchPoint '==' typo in onTouchDown (drag threshold was measured
  from the origin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM6HgDAJzeHjdKboAs7pSM
Render and layout read the raw cached side-border pointers via
getLeftBorder()/getTopBorder()/etc., which have no fallback -- a NULL
side draws as border-width 0. The engine keeps those pointers resolved
on every border-mutation path (onAdd, onStaticModified) so a side with
an empty name reflects the current borderDefault. The theme recipe path
was the one exception: stampProfileBorders writes raw members (never
firing onStaticModified) and left fallback sides' pointers NULL.

A freshly stamped profile whose sides fall back to a non-empty default
border (e.g. the Empty profile's Rimmed border) therefore rendered
borderless until some later field edit fired onStaticModified and
re-resolved the sides -- the Profile Editor's "no border on first click,
appears after toggling the border away and back" bug.

Fix: resolve the four sides at the end of stampProfileBorders, exactly
as onStaticModified does, so fallback sides pick up the current default.

Adds GuiProfileThemeTests.FreshlyStampedProfileResolvesFallbackSidesToDefaultBorder
covering the pure-fallback profile (Empty) and the mixed/default-changing
case (ScrollThumb, whose recipe swaps the default mid-stamp). Full engine
suite (84 tests) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM6HgDAJzeHjdKboAs7pSM
…e render

Audit + polish of what a generated GuiProfileTheme produces, plus the engine
rendering it exercises.

Theme content (guiProfileTheme.cc):
- Differentiate Empty from Default: Empty now wears the never-drawing Empty
  border (it was byte-identical to Default). New themes default borderSize=1 so
  the recipes' bevels/rims show out of the box, and the base palette got a
  cooler/punchier refresh.
- Extend the named-border palette from 6 to 15, all theme-tracked and shown in
  the editor: rename the base Bright->Light and add Highlight, PaddedRim,
  Bevel Light/Dark, Padded Light/Dark, RimmedExpander, and Condenser Light/Dark.
  This replaces an earlier hidden-border experiment so a profile's border always
  shows as a named entry (no blank slots) and behaves consistently everywhere.
- Recipe tweaks: translucent Overlay/Scroll fills, Tab->Thick, Menu Empty+Padded
  sides, left content padding on list/drop-down/window, foreground-based TextEdit,
  window-button/tooltip/progress/scroll custom borders, and consistent raised
  (Button/CheckBox) / sunken (Slider) bevels using default + bottom/right. The
  radio uses a single clean rim, matching how its circle actually renders.

Sliders (guiSliderCtrl.{h,cc}): add a thumbProfile so the groove and thumb are
themed separately (mirrors GuiScrollCtrl); draw a themed groove + a stateful
thumb; clamp the vertical thumb so it isn't clipped at the travel extremes. New
Slider + SliderThumb theme categories back this.

Engine render robustness:
- dgl.cc: dglDrawCircle/dglDrawCircleFill bail on a non-positive radius instead of
  reading past the end of their vertex vector -- a padded border could squeeze a
  radio's circle to <= 0 and crash in std::vector. guiCheckBoxCtrl also clamps the
  box extent >= 0.
- guiDefaultControlRender.cc: the selected "on" dot (e.g. a radio) is now sized
  proportional to the radius, instead of a fixed radius-6 that shrank to a speck
  on a standard radio and vanished below radius 8.

Preview (GuiProfileEditorPreview.cs): per-category samples for sliders, a radio
group, tab pages, menu/menu-content (populated, opens on click), window content,
and a tooltip swatch; longer sample text so padding reads.

Tests (guiProfileThemeTests.cc): updated for the new border names and 15-border
palette; added slider, empty-border, and extended-border coverage. 87/87 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM6HgDAJzeHjdKboAs7pSM
Extract the sixteen-value border editor out of GuiProfileEditorBorderSetter
into a reusable GuiProfileEditorBorderGrid (margin/border/borderColor/padding
across normal/HL/SL/NA, plus a square underfill checkbox). Both the Borders
pane's "Custom..." editor and a new GuiProfileEditorBorderForm host it, so
underfill is now editable in both places from one source of truth.

Selecting a border tree node now shows the border form -- a display-only name
header over the shared grid -- in place of the native inspector; onTreeSelect
toggles three Properties panes (theme form / border form / inspector). Edits
commit the border in place via dialog.onBorderChanged (mark dirty + deferred
preview refresh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM6HgDAJzeHjdKboAs7pSM
Profile nodes fell through to the raw GuiInspector, which offered every field
a GuiControlProfile can carry regardless of what the profile is for. Seventeen
of the engine's thirty-eight categories never draw text, yet all of them showed
seven font fields and seven font colors, and the elementCount=10 expansion of
fontColors added ten rows duplicating the named aliases.

The new pane (GuiProfileEditorProfileForm) asks GuiProfileEditorFieldSpec what
the selected profile's category actually reads and hides the rest. The spec is
derived from the render paths rather than the theme recipes, since a recipe
stamps far more than its control ever reads: align/vAlign/textOffset appear
only inside GuiControl::renderText, scroll arrows and window buttons tint an
icon through getFontColor without rasterizing a glyph, and the text-selection
pair has one consumer in GuiTextEditCtrl. A Show All checkbox lifts the filter
over everything a profile can meaningfully carry; SimObject plumbing, theme
bookkeeping and the border references the Borders pane owns stay hidden either
way. Fields are grid cells that reflow into more columns as the Properties
frame widens, like the native inspector. Filtering is pure setVisible, never
a rebuild, so a selection change cannot free a control mid-event.

Engine changes this needed:

- GuiGridCtrl skips hidden children, so a filtered cell closes up instead of
  leaving a hole (and drops a dead ChainCount local).
- Both GuiGridCtrl and GuiChainCtrl gate that on the per-control isEditMode()
  instead of the global smDesignTime. GuiEditCtrl::onWake sets smDesignTime for
  the whole canvas the moment the editor opens, so GuiChainCtrl's existing
  guard never fired for tool windows and hidden children kept their space --
  filtering looked right by isVisible() while later siblings sat at stale
  positions off-screen.
- writeOneFontCache(face, size) writes one cache file instead of rescanning the
  project for *.uft and rewriting every match.

Font caches are no longer baked while editing: GFont::create rasterizes on
demand, so the preview never needed one. Save bakes each face/size a theme
actually uses, over the printable Latin-1 range. Changing a font size went from
~7s of frozen engine to nothing, and the Save-time bake is ~64ms.

Also here: a commit compares against the loaded value, so tabbing through a
field no longer records a theme override (a named color survives the swatch
round trip); the per-field and toolbar resets use the revert arrow, frame 22,
rather than a plus that read as another "new"; and the pane starts hidden until
something is selected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
The tree never overrode onKeyDown - the declaration sat commented out in
the header - so GuiListBoxCtrl's ran and stepped the selection by raw
mItems index. Collapsed branches stay in mItems with isVisible false and
never render, so a real Profile Editor tree of 58 rows had only 3 that
could be seen (raw indices 0, 1 and 57). One press dropped the selection
onto an invisible row and the highlight simply vanished.

Navigate in visible-row space instead, counting rows the way onRender
does. Up/Down step over collapsed branches; Left/Right collapse and
expand (they used to alias Up/Down, which is arbitrary for a tree);
Home/End go to the first and last visible row; Return and Delete still
defer to the list box, which hands script the raw index its bindings
expect. Arrows are also canvas accelerators for Nudge in the Gui Editor,
so a focused tree keeps swallowing them even when it cannot move.

Verified by posting real WM_KEYDOWN arrows at the canvas window: fails on
the previous binary (selection lands on a hidden row), passes here, and
the real Profile Editor tree now walks 0 -> 1 -> 57.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
onTreeSelect handled theme, border and the profile kinds, but the tree's
grouping rows are kind "root" ("Gui Themes") and "folder" ("Profiles",
"Borders", "Stand Alone"). Neither was matched, so they fell through to
the final else, which made the profile form visible - and unbind() only
drops the binding, it never clears what is drawn. Selecting a header
therefore left the previously selected profile's rows on screen.

Give them their own branch that hides and unbinds all three panes. The
existing branches now share the same hideMemberPanes() helper rather than
each repeating the other two panes' hide/unbind pairs.

The toolbar already handled this correctly (its predicates are all
kind-based) and updatePreview already cleared for unrecognised kinds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
Selecting a theme node in the Profile Editor put up a fatal assert dialog
and wedged the process:

  GuiCursor: unable to find specified cursor (editCursor) and
  DefaultCursor does not exist!

showTheme TamlReads theme_sample.gui.taml, which carried
editCursor="editCursor". Themes build their GuiCursors anonymously
(BaseTheme::makeCursors), so that name resolves to nothing, and the
DefaultCursor fallback only ever exists in game modules - never in the
editor - so TypeGuiCursor hit its AssertFatal.

Drop the dangling attribute (the only cursor reference in any .taml
here), and let the type setter warn and leave the field unset instead of
halting. A cursor is cosmetic and the read side already copes:
GuiTextEditCtrl::getCursor re-resolves lazily and otherwise leaves the
caller's cursor alone. This mirrors the call already made for a missing
font further down the same file. Without it, any .gui.taml naming an
unregistered cursor takes the editor down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
Quitting with a node selected in the Profile Editor exited 0xC0000005.
The preview wears theme member profiles owned by GuiEditor.themeLibrary,
and nothing tore the dialog down before those profiles went away, so the
controls kept a dangling mProfile until Sim::shutdown finally touched it.

The root cause was that the editor was never unloaded at all. It is
loaded module-by-module (EditorManager.LoadExplicit in editor/main.cs),
but onExit called unloadGroup("EditorGroup") and no editor module.taml
declares that group - so the call did nothing and not one editor
DestroyFunction ever ran. An explicit closeProfileEditor() in the root
main.cs had been papering over it.

Unload in editor/main.cs's onPreExit instead, reversing what it loaded.
ModuleManager resolves dependencies, calls the DestroyFunctions in
reverse order and refcounts each definition, so EditorCore - which the
four modules pull in as a shared dependency rather than loading
themselves - unloads last when its count reaches zero. Keeping this
beside the loads means the two lists stay in step and a project shipping
its own main.cs inherits the teardown. GuiEditor::destroy now closes the
Profile Editor before freeing the theme library it depends on, so no exit
hook special-cases the dialog any more.

Also warn when a GuiControlProfile is destroyed with a live refcount,
naming it and the wearer count, so this class of mistake reports itself
where it happens instead of as an unexplained access violation at exit.
It is gated on a new Sim::isShuttingDown(), because Sim::shutdown tears
everything down in an arbitrary order and the ungated version fired
constantly on perfectly healthy runs.

The underlying fragility is untouched: Profile is a raw field offset and
ConsoleSetType never sees the owning control, so there is nowhere to hang
a deleteNotify. Ordering remains the only defence - it is just no longer
silent when it is got wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
The Font Face drop-downs listed whatever font files happened to sit in a
directory the developer was made to pick, so a fresh theme offered
nothing at all and the two names actually visible - Arial and Courier New
- were the theme's own default field values that fillFontDropdown keeps,
not anything discovered. Meanwhile the developer was asked twice where to
keep font caches: a file picker on the theme pane and a fontDirectory row
on the profile pane, inviting one cache folder per theme for a cache that
is keyed by face and size alone and can therefore only ever be a
duplicate.

The engine could already answer the real question. PlatformFont::
enumeratePlatformFonts has been there since the GarageGames days and was
bound to nothing. getInstalledFonts() now exposes it, sorted and
de-duplicated in C++ because Windows enumerates a family once per
character set and sorting three hundred names in TorqueScript is not
free. Both panes offer that list, merged with any face the project
already holds a cache for - a theme that arrived with its caches names a
font nobody here has installed, and it must stay choosable - and with the
field's current value. It is cached on the library and invalidated by a
bake, since a pane repopulates on every tree selection.

Linux had no definition of enumeratePlatformFonts at all, only a
declaration nothing called; it gets one now via fontconfig, which Xft
already pulls in, plus an explicit -lfontconfig since the Fc* calls are
ours. Windows' vertical-writing '@' twins are dropped at the enumerator:
they are the same families rotated for vertical CJK text, and text picked
from one would render sideways.

The font folder is now predetermined - <project>/themes/fonts, beside the
themes it serves - and both directory pickers are gone. Themes and
standalone profiles are pointed at it when created, when loaded and when
reverted; a theme carrying an older folder is repaired on load without
being marked dirty, so its file is corrected on the next Save. AppCore's
$Gui::fontCacheDirectory derives the same folder from where the module
sits, so a profile that names no directory of its own lands there too.
The editor keeps its own arrangement: EditorCore still overrides that
variable with its own font folder, which is why the editor derives the
project path rather than reading the variable, and the script-based
editor themes that carry their own directories are untouched.

Save bakes what was actually rendered, not only what was declared. A
control's fontSizeAdjust multiplies its profile's fontSize, so a profile
set to 16 worn by a control adjusting 1.2 asks GFont for 19 - a size no
field names and no walk over fields could find. GFont::create now records
each face, size and directory it had to rasterize for want of a cache
(and only when a platform font existed, so nothing accumulates on a
backend that has none), getUncachedFonts reports them, and the bake takes
the ones belonging to this project's folder before clearing the record.

That bake had never written a file. Its "already cached?" guard asked
isFile, which answers out of the resource manager, and GFont::create
registers every font it synthesizes under the .uft path it looked for and
did not find - so the moment the editor rendered a face, isFile claimed
its cache existed and the bake skipped it. Every font, always. It asks
the filesystem now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
A GuiProfileTheme could be built but nothing consumed it. A developer could
spend an afternoon deriving a complete profile set from six colours and then
still hand-pick a profile for every control, and the game never loaded the
theme at all - a themed .gui fell back to GuiDefaultProfile the moment it ran
outside the editor. AppCore's own seventy hand-written profiles were the last
thing keeping that arrangement alive.

The Gui Editor gets a Set Theme button beside the Profile Editor, and it walks
the document filling every profile slot from the chosen theme. Slots are found
generically - a profile field is any persist field whose console type is
GuiProfile - so contentProfile, thumbProfile, closeButtonProfile and the rest
are covered without a hand-written slot map per class, and a control this
editor has never heard of contributes its slots too. Which category a slot
wants comes from a field-name table plus an ordered class table matched with
isMemberOfClass, so a subclass inherits its parent's answer.

What the developer already chose outranks all of that. A slot wearing a profile
from the target theme is left alone, whatever category it is in; a slot wearing
another theme's profile keeps its category and takes the equivalent from the
new one, which is what makes moving a Gui between two themes a single click.
Only when a slot holds a script profile, or nothing, do the class rules decide
- Empty for a layout control, Label for a bare GuiControl carrying one line of
text, Panel for one at the root of the Gui. Stand-alone profiles are the
supported alternative to theming and survive unless the dialog is told to
override them.

The theme is a property of the document: a new Gui starts on the session's
last, a dropped control is themed on arrival, and the name is written into the
saved file so reopening lands back where it was. That last part needed
canSaveDynamicFields turned on for the root being written - every GuiControl
clears the flag in its constructor, so both writers drop dynamic fields on
controls - with inference over the profiles actually worn as the fallback for a
Gui that predates the field.

Applying sets each field by object id rather than by name. The Gui Editor runs
the engine in editor mode, where SimObject::assignName stashes a name instead
of registering it so that naming a control being edited does not create a
global; a profile created during a session is therefore invisible to the name
dictionary, and setting a field by name would land on GuiDefaultProfile without
saying so. The theme library now brackets everything it names with a
save/restore of that flag through a new isEditorMode(), and it adopts themes
already in memory rather than reading their files a second time - AppCore loads
them at boot and the editor loads the project's AppCore, so a second read would
collide on every member name. It also detaches the Gui from a theme's profiles
before a revert frees them, since a control's profile field is a raw pointer
that nothing updates when the object goes away.

GuiDefaultProfile moves into C++. Three script modules raced to create the one
object the GUI cannot run without, some twenty control constructors name it,
GuiControl::onWake falls back to it and every new profile is seeded from it -
and in a release build its absence is a null dereference rather than an assert.
The engine builds it and its border beside the stock colours now. EditorCore no
longer creates it, only tunes it, which is how the editor still seeds its own
font through that constructor copy. The theme's Default category goes with it:
a control with nothing better to wear takes Empty, and stampDefaultProfile
stays on as stampProfileBase, the shared root every recipe starts from.

AppCore keeps its seven cursors and nothing else. It reads <project>/themes at
boot, repairs a theme that names some other project's font folder, and writes a
stock theme when it finds none, so a project always has one. That stock theme
ships in library/themes with its baked caches and a new project gets the folder
copied whole - it sits beside the modules rather than inside AppCore because a
theme belongs to the project while AppCore's directory is replaced wholesale
when it updates. The BuildID says so.

PlanetX creates no profiles at all any more. Its AppCore copy differed from the
library's in six palette colours, which is to say it was a theme; the two
.gui.taml screens were re-themed with the button and the script-built ones by
hand. The six profiles it cloned per font size became FontSizeAdjust on the
controls, so retuning the theme now moves the whole game, and the two that were
never about size - the heat bar's coral Progress and the key-capture control's
focusable Empty - are extra profiles inside the theme, editable like anything
else.

The toybox needed nothing: its AppCore is an older module with no gui folder
and Sandbox ships its own complete set. A window's default titleHeight goes
from 20 to 28, which is what a themed title bar with a real font and a chrome
border actually needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
A stand-alone profile is the supported alternative to theming, and three
things stopped one from being usable. Making a profile and then trying to
put it on a control was the first: it never appeared in the Profile
drop-down.

That drop-down is built by GuiInspectorTypeGuiProfile, which walks the Gui
data group's direct children, as do the border and cursor pickers beside
it. Every profile lands there on its own - GuiControlProfile::onAdd puts
it there - and that membership is what those three lists are. A
stand-alone profile was then wrapped in a ScriptGroup so the custom
borders it wears could be saved in one file with it, and
SimGroup::addObject reparents: the wrap quietly took the profile out of
the one group the editor was about to go looking in. Nothing else
noticed, because a Gui finds its profile by name and the name was still
registered. Only the editor's own list was wrong, and only for the
profiles a developer made by hand.

The bundle is a SimSet now. It implements TamlChildren exactly as a group
does, so the file is unchanged, but a set does not claim what it holds.
It does not own it either, so the library takes a bundle's contents down
explicitly rather than leaving them to the group. Bundles written before
this are rebuilt on load - children handed back to the Gui data group,
rewrapped, the group dropped - and the file is corrected on the next
save. This is how a theme has always behaved: GuiProfileTheme is a
SimObject with TamlChildren rather than a group, which is why theme
members were in the list all along and stand-alone profiles were not.

The preview was the second. Its category samples - a check box for
CheckBox, a window for Window - all sat behind a guard that sent anything
without a theme to the generic label-and-button sample, so changing the
For category on a stand-alone profile changed nothing on screen. The
guard was there for the samples that borrow sibling profiles from the
theme: a scroll bar needs something to scroll, a drop-down needs a
backdrop to pop over. Those slots are now filled only when there is a
theme to fill them from and left as the control was born with otherwise,
since handing them the profile under edit would put a scroll bar's skin
on its own contents. Every category builds its real sample.

Renaming and deleting were the third: neither existed. Both are toolbar
buttons that already worked for themes, and a theme and a stand-alone
profile are the same kind of thing here - the two nodes in the tree that
carry a name of their own and own a file of their own. So Rename Theme
and Delete Theme become Rename and Delete behind one predicate, each
doing the theme or the stand-alone version of the job. A rename replaces
the file on save the way a theme rename does; a delete takes the
profile's custom borders with it, moves anything in the open document off
it, and removes the file on save so that cancelling keeps it.

What neither can do is fix the Guis on disk that name the profile.
Nothing indexes that relationship and nothing can be made to, so the
delete confirmation says plainly that a control still asking for the
profile falls back to GuiDefaultProfile and leaves the rest to the
developer. Saying so took a taller dialog: the confirmation's text box
was a fixed height and clipped the last line of the longer messages, so
it sizes to its message now.

AppCore recognises the new bundle when it reads the themes folder at
runtime. Nothing else there changes - a stand-alone profile only has to
load to be found by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
Skinning a control with a bitmap meant typing an absolute path into a box
that would not let you put the caret where you clicked, and what got
written down named a folder on one developer's machine.

The text boxes came first. GuiProfileEditorRowInput::onTouchDown
re-selected the whole value on every click. That was deliberate - a click
selecting all is handy for typing over a number - but it ran after the
engine had already placed the caret where the click landed, so the caret
ended up at the end of the value and the selection anchor at the start,
and the next drag swept from character zero. Clicking into the middle of
a path was impossible. The override is gone from the field rows and the
border grid alike. Tabbing in still selects everything, since
GuiTextEditCtrl::setFirstResponder does that on focus, and a click now
does what a click does.

Then the path. TypeFilename expands whatever it is handed the moment it
is set, so a profile's mBitmapName is always absolute on the machine that
set it, and that was what was saved. The field gets a protected getter
returning the path relative to the game root when it points inside the
game - the form TypeFilename expands back correctly wherever the file is
next opened. A path leading somewhere else is left absolute rather than
dressed up in a "../.." chain that would be no more portable.

That alone changed nothing, because Taml::compileStaticFields ran
Con::collapsePath over every TypeFilename field afterwards. Collapsing
rewrites against a path expando when one matches ("^AppCore/x.png") and
otherwise joins the working directory back onto the path, handing back an
absolute path again. Run over a value that was already relative, it
turned a portable path into a machine-specific one - the opposite of what
the step exists for. Only an absolute path goes through it now, which
means the only values that skip it are the ones something deliberately
made relative.

And the box has a Find button. The row kind that draws one has been in
GuiProfileEditorFieldRow since the font work and lost its last user when
the font directory row went away; it now serves the bitmap field,
filtered to images, opening in the project folder and writing back what
it picked relative to the game root.

Nothing here changes how a bitmap array is read. Worth knowing for
whoever meets it next: the frames are separated by the colour of the
image's top-left pixel, and each row is found by probing column zero, so
an image drawn with a separator border down its left edge parses as no
frames at all and the control silently renders its fill colour instead.
The bitmap path is the old one - imageAsset is where this is going.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
The bitmap field got a Find button last time, and the closing line there
was that imageAsset is where this is going. It had no such thing. To skin
a control with an asset you typed Module:AssetName into a bare text box,
exactly, from memory, with no way to see what you were choosing and no
complaint if you got it wrong - the field only tells you it is wrong by
rendering nothing. The old way was easier to use than the new one.

So imageAsset gets a Find button too, and it opens a picker: a search box
over a grid of thumbnails, the asset id and its frame count and size on
the line underneath, Cancel and Choose. It is the Asset Manager's library
pane, narrowed to one job.

The database is asked once, when the dialog opens. Filtering after that
is setVisible over the buttons already built, so a keystroke costs a walk
over the grid and no database work, and the list cannot shift under a
search the way a re-query would. AssetDatabase.findAssetName would have
been the obvious thing to reach for and is the wrong one: it takes no
query-as-source argument, so it always searches the whole database and
would throw away the type filter the dialog was opened with. Matching is
a lowercased substring of the whole asset id, which means a module name
finds everything in it.

A grid does not re-lay out when a child is hidden. GuiGridCtrl::resize
skips invisible children, correctly, but only onChildAdded, onChildRemoved,
childResized, childMoved and childrenReordered ever call it, and setVisible
notifies nobody - so a filtered grid would keep its full height with the
hidden cells' holes still in it. AssetDictionary::fixSize works around
this by collapsing and re-expanding its panel; this one resizes the grid
to the size it already has, which walks the children again. It also needs
IsExtentDynamic, without which a grid inside a scroller stays one
screenful tall and everything past the first rows is unreachable.

The picker lives in EditorCore rather than in the Gui Editor, because the
Gui Editor is unloaded at exit and it is not the only caller.
GuiInspectorTypeAsset has been building a "..." button beside every asset
field in the native inspector and pointing it at a global script function
called getAsset. That function has never existed anywhere in the tree, so
every one of those buttons has been dead - on an animation's image, on a
particle emitter's, on a sprite's. It now calls the editor, which is
honest about what the inspector is: an editor-only control. Since the
line had to change anyway, the callback target and method go over as two
arguments rather than as one "<id>.apply" string, so the picker returns
through the same target-and-method pair every editor dialog already uses
and nothing has to take a string apart at the other end.

Choosing an asset writes imageAsset and leaves bitmap alone. The engine
prefers the asset when both are set, so the bitmap becomes inert - but a
row that reached over and cleared a field it does not own would destroy a
path on a mis-click, and no other row in the pane behaves that way. The
value stays where the developer put it.

The dialog is titled from the type name it was handed, which arrives from
the engine spelled as a class - ImageAsset - and needs the capital broken
out and an article chosen to suit. Worth knowing for whoever writes that
sort of thing next: $= in TorqueScript is case-insensitive, so a test for
"is this letter a capital" written as %c $= strupr(%c) is true for every
letter and fails silently. strcmp is the case-sensitive one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
Changing a profile's fill color from one of the theme's colors to another
meant hunting around the wheel until it looked close, and there was no way
to type an exact value at all. Both are odd gaps in a control whose whole
job is choosing a color, and the second is the reason the theme edit form
grew its own R/G/B/A boxes beside its swatches.

So the popup grows two rows under the wheel and the bars. Both are off by
default, so every popup that exists today looks and behaves exactly as it
did.

The first is a row of swatches in a GuiGridCtrl, which means a developer
can add as many as he likes and they wrap onto as many rows as they need -
the grid has a dynamic extent, so laying it out at the width it will get is
also how the popup learns how tall it has to be. Script owns the list:
addSwatchI, addSwatchF, clearSwatches, selectSwatch and the getters. The
row appears only while there is something in it, so there is no separate
flag to keep in sync. The cells are a pool that only ever grows and hides
its spares rather than deleting them, which keeps the grid's children
stable while the popup is live; a hidden child takes no cell, so the rest
close up on their own.

The second is a row of numeric boxes, one per channel, in 0-255 or 0.0-1.0
according to a new valueMode - integers by default, because the fields
these are used to edit are ColorI.

popupSize keeps meaning the wheel-and-bars area and each row is added below
it, so turning a row on never costs the wheel any of its height. onOpen
fires before any of that is measured now, which is what lets a script fill
the swatches in time for them to be laid out; nothing in the tree
implemented onOpen on a color popup, so moving it is safe.

Now the part that had to be fixed before any of it was usable. A picker
works out the color it is showing by reading the pixel under its selector
and pushing that back into the popup. That is right when the user drags a
selector and wrong every other time: a color arriving from a swatch, or
typed into a box, was overwritten by a framebuffer round trip on the very
next frame. It has been quietly drifting the color just from opening a
popup this whole time. Each picker now takes a suppressNextPush from the
popup when the popup is the one that moved its selector - the selector
still catches up visually, the color it derives is dropped. The two
directions have to stay distinct: setColor is a picker reporting what it
read and must never move the pickers, applyColor is an exact color arriving
and does both.

The Profile Editor fills the swatch row with the six colors of whichever
theme the tree has selected, from onOpen rather than when the row is built,
because the theme in play changes as the user moves around the tree while
the same widgets stay on screen. A stand-alone profile belongs to no theme,
so it gets no swatches and no row.

Two tooltip problems fell out of this, because the four value boxes are the
first controls with tooltips that live somewhere which sleeps on a user
action rather than at shutdown.

GuiControl::renderTooltip picked up a default tooltip profile the first
time a control drew a tip, which is necessarily while the control is awake,
so onWake had nothing to count and onSleep handed back a reference nobody
took. Closing the popup after hovering a box asserted on a zero ref count.
The setter says as much in a comment: reference counts are changed in
guiControl only if the guiControl is awake. Anything that assigns a profile
to a live control owes the book-keeping itself, and this one was not paying.
That is a latent bug for any control with a tooltip and no tooltip profile;
the editor is full of them, they just never sleep until the process is
already going down.

While in there: that default is now the Tooltip member of the theme the
control's own profile belongs to, falling back to the global profile for an
unthemed one. GuiProfileTheme has always had the category and the theme
applier has always used it for authored Guis - a control that was never
given one had no way to reach it. It is re-resolved on every draw rather
than cached once, because re-theming rewrites the Profile field directly
and there is no setter to hook, and only the pick a control made for itself
is ever revisited, so a profile someone set deliberately is left alone.

The popup does not lean on that path anyway: it hands its own tooltip
profile down to the boxes, the way it already hands down four others.
Worth knowing for whoever needs it next: %ctrl.setProfile() is the safe way
to re-profile a live control, because it goes through setControlProfile and
does the awake-aware dec/inc. Assigning %ctrl.Profile straight is the same
hole this commit fixes one instance of, and that one is still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
There were thirty-odd main.<something>.cs files sitting in the repo root,
none of them tracked, each one a boot script for a different corner of the
editor. They are the only coverage those editors have - the GoogleTest suite
never reaches a canvas - so losing them would have cost real ground, but
leaving a wall of main files in the root was not an option either.

They now live in tests/, and tests\run.ps1 runs them.

The obvious move, pointing the engine straight at tests/smoke/whatever.cs,
does not work, and it is worth writing down why. initGame takes its boot
script as argv[1] and then calls setMainDotCsDir and setCurrentDirectory on
that script's own folder, so a test launched from tests/smoke makes
tests/smoke the working directory and every relative path in the editor
resolves against the wrong place. Script cannot climb back out either: there
is no console binding for setCurrentDirectory, for the command line, or for
the environment. So the runner writes a two-line stub at the root and points
the engine at that. The stub is gitignored and deleted after each run.

The related trap, which cost most of the work here: a relative path is
expanded against the script that names it, not against the working
directory. That distinction does not exist while everything sits at the root
and bites hard the moment it does not. exec("./editor/main.cs") became
tests/smoke/editor/main.cs, createPath("./shots/") started making
tests/smoke/shots, and ModuleDatabase.scanModules("./toybox/ToyAssets")
scanned nothing at all - which is what took assetPicker from sixty-four
passing checks down to seven, and looked exactly like a pre-existing
failure until the folder it had quietly created gave it away. So
tests/lib/prelude.cs is exec'd ahead of every test and gives it testRoot()
and testExec(), and every path every test names goes through one of them.

The runner gives each test its own process, because they each boot the
editor and quit, and one of them still asserts on the way out. It kills on a
timeout, since a debug-build AssertFatal is a modal message box and a test
that trips one hangs rather than crashing. It wipes the throwaway project
folder first: a test that finds the last run's gets a cascade of "that name
is already taken" and fails checks that have nothing to do with what it is
testing, which is what made border look like it had ten failures instead of
nine. PlanetX and toybox are real content and are never touched.

Two suites fail today and neither failure is its own fault, so run.ps1
records what they do rather than leaving it as folklore: border loses nine
stand-alone-bundle checks and then hangs on a teardown assert, and
profileForm loses the fontDirectory row check. Both were confirmed by
stashing the tree and rebuilding. Writing the numbers down is what makes the
next real regression visible; the entry goes when the bug does.

Six scratch scripts are gone - repros for bugs that are now fixed, and a
path check whose own first line called it temporary. What was worth keeping
from them is the Win32 message posting that three had each copied, now
tests/lib/input.ps1: the tests that need a real click or a held hover ask
for one through a <name>.input.ps1 that the runner hands the window to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
The border suite lost nine checks and then died on a fatal assert, and the
commit that moved these tests into tests/ wrote that up as a pre-existing
teardown crash tied to the profile-lifetime problem. It was none of those
things, and this puts it right.

A stand-alone profile's bundle stopped being a ScriptGroup some time ago and
became a SimSet, on purpose: a group takes the profile out of GuiDataGroup,
and that group is the only place the engine looks when it fills a control's
Profile dropdown. wrapStandalone says so at length. The test never caught up.
It still asserted ScriptGroup, and - worse - still reached the bundle through
%profile.getGroup(), which a set leaves alone, so it got GuiDataGroup back.
Then it called delete() on that, which took every editor profile with it, and
the TAMLRead two lines later asked for the GuiDefaultBorderProfile the test
had just destroyed. Nine failures and a fatal, all out of one stale
assumption, and the fatal belonged to the test.

So the section now finds the bundle through the tree proxy's root while the
dialog is up, and through the library once onSave has closed it - saving
schedules the dialog's deletion, which is why the original reached for
GuiEditor.themeLibrary there and why a proxy lookup cannot work that late. It
checks isBundle rather than a class name, isMember rather than getGroup, and
asserts the two things the design actually promises: the profile and its
custom border stay in the gui data group, and the bundle only names them. The
saved file is checked for SimSet, and teardown goes through deleteRoot, which
is what deletes the contents a set does not own.

border is 37 passed, 0 failed, and exits on its own, so it comes off the
expected-failure list. profileForm's one check is still there.

Three things had made this read as an engine problem, and the runner now
takes two of them away: a killed process is reported as "killed after Ns"
rather than "hung", since a fatal assert is a modal box and nothing is
looping, and the last log line is printed with it, which names the assert.
The third was mine - the log tail was full of "deleted while still worn"
warnings that look exactly like shutdown, when shutdown was never reached.
tests/README.md now writes the whole thing up, including which lines to grep
for to tell the difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
The last entry on the known-failures list, and like the border one before it,
not an engine problem.

profileForm checked that a Slider's fontDirectory row was visible. There is no
fontDirectory row. The pane builds rows from enumerated lists and that field is
in none of them, because the editor owns it: applyFontsPath points every profile
at the project's one font folder, so there is nothing for a profile to decide
and the field spec says as much in a comment. %form.row["fontDirectory"] was
never an object, and isVisible() on nothing is false, so the check could only
ever fail.

It now asserts the two things the design does promise: the field is not offered
per profile, and something sets it anyway. The second half is the one worth
having - a profile left without a font directory falls back to
$GUI::fontCacheDirectory, which is the EDITOR's font folder while the editor is
loaded, and that is a real way to ship a project whose fonts only resolve on the
machine that built it.

profileForm is 83 passed, 0 failed, and $Expected is now empty. The mechanism
stays for a failure that genuinely is not a suite's own fault, but the standard
is to fix the test or delete it, and the README says so. Both entries this list
ever held were stale tests asserting a design that had moved on, which is a
warning about the list more than about the suites.

Full run: 16 smoke suites, 448 checks, no failures, nothing killed. 7 shot
harnesses, 20 screenshots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
The ignore rule and the README both called the stray ^EditorCore folder the
leavings of an active font-baking bug that the tests trip. Neither is true.

GuiDefaultProfile carries the unexpanded "^EditorCore/gui/fonts" as its font
directory, which guiProfiles.cs does deliberately - the lookup is meant to fail
and the desktop synthesizes the face from a system font instead. A folder of
that literal name only appears if something bakes a font-cache miss recorded
against that directory, and nothing in the suite does: deleting the folder and
running all sixteen smoke suites and all seven shot harnesses does not bring it
back. It appeared once, while the broken border test was deleting GuiDataGroup
and rebuilding profiles through TAMLRead, which is exactly the churn that would
register such a miss and bake it.

So the rule stays - a caret folder is never content - but it now describes a
latent trap rather than a bug the tests are hitting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU
greenfire27 and others added 5 commits July 27, 2026 17:14
The procedural slider groove passed a freshly-built RectI temporary into
renderUniversalRect, which takes RectI& by non-const reference. MSVC's
non-standard extension lets an rvalue bind there, so it built on Windows
but broke the Mac (and Linux) build with "no matching function". Hoist
the rect into a named local -- the function never mutates it, and every
other call site already passes an lvalue.

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

The Profile Editor's font picker is the first code to call enumeratePlatformFonts
from script and to bake font caches over a full character range, which exposed
three latent bugs in the macOS/iOS font backend:

- enumeratePlatformFonts released an autoreleased NSArray it did not own; the
  double-release later detonated in objc_release when the run loop drained the
  autorelease pool. Drop the release.

- enumeratePlatformFonts used availableFontNamesWithTraits:0, which returns an
  empty array on modern macOS, so getInstalledFonts() offered nothing. Use
  availableFontFamilies -- the installed families, matching what Windows'
  EnumFontFamilies and Linux' fontconfig report.

- getCharInfo tripped AssertFatal when a font lacked a glyph for a character.
  Baking hit DEL (0x7F), which fonts legitimately do not carry, and the
  debug-build assert popped a modal box that hung the process. Return the empty
  CharInfo instead, as the Windows backend already does. Applied to iOSFont too,
  which carried the identical assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run.ps1 is Windows-only -- it targets Torque2D_DEBUG.exe, needs PowerShell, and
posts input through Win32. run.sh mirrors its loop (stub generation, throwaway-
project cleanup, $Order/$KeepProject, timeout, PASS/FAIL grep) against the macOS
.app binary, falling back to the repo-root binary on Linux.

The two input-driven suites (tooltipProfile, textClick) depend on the Win32-only
*.input.ps1 harness and are skipped unless --with-input is passed.

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

getPrefixedDataField formatted the value into a fresh console return
buffer:

    char* buf = Con::getReturnBuffer(n);
    dSprintf(buf, n, "%s%s", fieldPrefix, pFieldValue);

But StringStack::getReturnBuffer hands back mBuffer + mStart -- the same
scratch address on every small call. For a numeric type (S32, Point2I,
an unnamed ColorI, ...) the field getter's value already lives in that
scratch buffer, so buf == pFieldValue and the dSprintf writes a buffer
onto itself. sprintf with source aliasing destination is undefined: on
glibc it comes back empty, while MSVC's CRT happens to copy intact.

An empty value makes SimObject::writeField skip the field, so it never
reaches the written TAML and reverts to its default on read. Only values
already interned in the string table survived -- stock color names and
bools -- which is why "Red"/"White" persisted but "9 8 7 6", a padding
of 7, or a control's extent/position silently vanished. On Linux this
quietly corrupted every saved scene, GUI, and profile; on Windows it was
invisible, so it sat here for years.

Fix: with no field prefix there is nothing to format, so return the
value directly (the common path, and the one every numeric field takes).
With a prefix, copy the value into a FrameTemp before reusing the return
buffer so the concatenating dSprintf can't alias.

Surfaced by the Gui Profile Editor smoke suites (profileEditor, border,
standalone) on Linux; all pass after this, with no regressions across
the full run.

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

The suite asserted getInstalledFonts() returns more than 20 families, which
is a Windows-shaped assumption: a minimal Linux fontconfig install (a bare
WSL or CI container) has only ~10 -- DejaVu, Liberation, Ubuntu -- and the
enumeration returning all of them is correct, matching fc-list. The check
exists to catch the backend collapsing to nothing or a single fallback, not
to demand a rich desktop, so lower the floor to 5. The drop-down checks that
follow compare against the live count, so they stay meaningful either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greenfire27
greenfire27 merged commit 063d08f into development Jul 27, 2026
18 checks passed
@greenfire27
greenfire27 deleted the Gui-Profile-Editor branch July 27, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant