GuiProfileTheme: themes as the source of a Gui's look, and an editor for them - #84
Merged
Conversation
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
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>
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.
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
GuiControlProfileobjects inAppCore/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;GuiDefaultProfilemoved 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:
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
GuiColorPopupCtrlgains 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.GuiControltooltip bug (1bd02bb7):renderTooltipacquired a default tooltip profile without taking a reference whileonSleephanded 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.Current state — 16 of 16 suites clean, 448 checks, no failures and nothing killed:
profileForm83,assetPicker64,standalone60,themeApply38,border37,profileEditor32,font32,colorPopup31,borderPane20,planetX18,headerPane9,bitmapPathWrite7,tooltipProfile6,textClick3,toybox3,bitmapPathRead1. The 7 shot harnesses write 20 screenshots.tests/README.mddocuments 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
Profilefield on a live control bypasses the reference countingsetControlProfiledoes, 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 theProfileandtooltipprofilepersist 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 —borderlosing nine checks and hanging on a teardown assert,profileFormlosing one. Both claims were wrong, and38adc8d6and88d225c2fix them. Neither was an engine bug; both were stale tests asserting a design that had moved on.borderlooked for a stand-alone profile's bundle with%profile.getGroup()and asserted it was aScriptGroup. Bundles becameSimSets deliberately — a group takes the profile out ofGuiDataGroup, the only place the engine looks when filling a Profile dropdown — and a set leaves membership alone, so the test gotGuiDataGroupback 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.profileFormasserted afontDirectoryrow 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, soisVisible()was being called on nothing.Both now assert what the design actually promises, and
$Expectedin the runner is empty.Notes for review
docs/superpowers/specs/2026-07-19-gui-profile-theme-design.md.cmake/EngineSources.cmake— the new files are registered there.TORQUE_SCRIPT.md(one class per file,onAdd/onRemoveownership,class/superclasswithinit()).🤖 Generated with Claude Code
https://claude.ai/code/session_01C5SkVxmwBq2TV52GbeBRLU