From 194ef3b3c50effe97e832c2c85e1affef7302791 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Sat, 8 Aug 2026 17:18:35 -0400 Subject: [PATCH 01/26] Asset Library: tiles or rows, a search box, and sort by name or category The library was a wall of picture-only thumbnails in whatever order the asset database's hash table happened to return, which is not the same order twice. The name reached a person only as a tooltip, and AssetCategory and AssetDescription -- both editable in the inspector -- were read by nothing at all. AssetLibraryWindow is now the class on libWindow and owns the pinned toolbar, the scroller, the five groups and the three pieces of state they share. The toolbar stays put while the groups scroll under it, measured into place rather than positioned against a guessed title-bar height. Filtering matches name, description and category as you type, across every group at once; a group whose matches are all gone keeps its header and reads "Images (0)", so the shape of the library does not move under the person typing. Sorting reorders the tiles that are already there rather than rebuilding them, which keeps the selection, the running animations and the asset acquisitions intact. Three decisions worth recording. Rows mode is MaxColCount = 1, not the control palette's trick of asking for a cell wider than the pane. With CellModeX variable the single column takes the whole width, and unlike a width-derived CellSizeX it survives a resize with nothing recomputing it. The search could not use the engine's queries. findAssetName's partial mode is a case-insensitive prefix match rather than a substring one, findAssetCategory is exact and case-sensitive, and there is no findAssetDescription at all -- so each tile lowercases its own key once and the filter walks those. GuiEditorChoiceRow and GuiEditorToggleIcon become EditorChoiceRow and EditorToggleIcon in EditorCore. editor/main.cs loads AssetAdmin before GuiEditor, so nothing the Asset Manager builds at create time can come out of a module loaded after it; neither file was ever Gui Editor specific. EditorPreferences is the editor's first memory between runs -- dynamic fields on a ScriptObject written as TAML to getPrefsPath, holding the view mode and the sort field. Deliberately not $pref:: globals: script has no setVariable(), so writing one by name would need eval(). The PlanetX assets carry categories now, so the sort has something to sort by. Covered by tests/smoke/assetLibrary.cs, 81 checks, and a shot harness alongside it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../gui/images/upgradePlaceholder.image.taml | 3 +- .../gui/images/upgrade_autorepair.image.taml | 3 +- .../gui/images/upgrade_damage.image.taml | 3 +- .../gui/images/upgrade_firerate.image.taml | 3 +- .../gui/images/upgrade_lessheat.image.taml | 3 +- .../gui/images/upgrade_maxheat.image.taml | 3 +- .../images/upgrade_selfdestruct.image.taml | 3 +- .../gui/images/upgrade_split.image.taml | 3 +- .../gui/images/upgrade_tighten.image.taml | 3 +- .../gui/images/upgrade_ventfast.image.taml | 3 +- .../particles/bugDeath.particle.taml | 1 + .../PlanetXGame/sound/enemyChase.audio.taml | 4 +- .../PlanetXGame/sound/enemyDeath.audio.taml | 4 +- .../sprites/alien_brute.animation.taml | 5 +- .../sprites/alien_brute.image.taml | 9 +- .../sprites/alien_walk.animation.taml | 3 +- .../PlanetXGame/sprites/alien_walk.image.taml | 9 +- .../sprites/spaceman_idle.image.taml | 3 +- .../sprites/spaceman_idle2.image.taml | 3 +- .../sprites/spaceman_walk.animation.taml | 5 +- .../sprites/spaceman_walk.image.taml | 9 +- .../sprites/spaceman_walk2.animation.taml | 5 +- .../sprites/spaceman_walk2.image.taml | 9 +- editor/AssetAdmin/AssetAdmin.cs | 89 +-- editor/AssetAdmin/AssetBase.cs | 26 + editor/AssetAdmin/AssetDictionary.cs | 325 ++++++++++- editor/AssetAdmin/AssetDictionaryButton.cs | 166 +++++- editor/AssetAdmin/AssetDictionarySprite.cs | 37 ++ editor/AssetAdmin/AssetLibraryWindow.cs | 504 ++++++++++++++++ .../EditorChoiceRow.cs} | 22 +- editor/EditorCore/EditorCore.cs | 16 + editor/EditorCore/EditorPreferences.cs | 120 ++++ .../EditorToggleIcon.cs} | 20 +- editor/GuiEditor/GuiEditor.cs | 2 - .../scripts/GuiEditorAnchorPicker.cs | 2 +- .../scripts/GuiEditorControlGroup.cs | 2 +- .../scripts/GuiEditorControlListWindow.cs | 4 +- .../GuiEditor/scripts/GuiEditorHeaderBlock.cs | 2 +- editor/GuiEditor/scripts/GuiEditorItemRow.cs | 4 +- .../scripts/GuiEditorMenuItemBlock.cs | 2 +- .../GuiEditor/scripts/GuiEditorTextBlock.cs | 4 +- tests/shots/assetLibrary.cs | 113 ++++ tests/smoke/assetLibrary.cs | 547 ++++++++++++++++++ 43 files changed, 1928 insertions(+), 178 deletions(-) create mode 100644 editor/AssetAdmin/AssetBase.cs create mode 100644 editor/AssetAdmin/AssetDictionarySprite.cs create mode 100644 editor/AssetAdmin/AssetLibraryWindow.cs rename editor/{GuiEditor/scripts/GuiEditorChoiceRow.cs => EditorCore/EditorChoiceRow.cs} (87%) create mode 100644 editor/EditorCore/EditorPreferences.cs rename editor/{GuiEditor/scripts/GuiEditorToggleIcon.cs => EditorCore/EditorToggleIcon.cs} (91%) create mode 100644 tests/shots/assetLibrary.cs create mode 100644 tests/smoke/assetLibrary.cs diff --git a/PlanetX/PlanetXGame/gui/images/upgradePlaceholder.image.taml b/PlanetX/PlanetXGame/gui/images/upgradePlaceholder.image.taml index 01ca947b2..b76514d18 100644 --- a/PlanetX/PlanetXGame/gui/images/upgradePlaceholder.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgradePlaceholder.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgradePlaceholder.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_autorepair.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_autorepair.image.taml index 4910eac0a..cf21d0b57 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_autorepair.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_autorepair.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_autorepair.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_damage.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_damage.image.taml index 53571a49c..d4ff81f10 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_damage.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_damage.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_damage.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_firerate.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_firerate.image.taml index f3f828c4b..ab61692ed 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_firerate.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_firerate.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_firerate.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_lessheat.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_lessheat.image.taml index 7c959415a..29ce9e499 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_lessheat.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_lessheat.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_lessheat.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_maxheat.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_maxheat.image.taml index 0999cc5c7..1a0fe30a3 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_maxheat.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_maxheat.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_maxheat.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_selfdestruct.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_selfdestruct.image.taml index 715514ea9..0c01573b5 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_selfdestruct.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_selfdestruct.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_selfdestruct.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_split.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_split.image.taml index f4d80888a..c344399d9 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_split.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_split.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_split.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_tighten.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_tighten.image.taml index dd65498f4..8e5ef0cea 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_tighten.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_tighten.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_tighten.png" /> diff --git a/PlanetX/PlanetXGame/gui/images/upgrade_ventfast.image.taml b/PlanetX/PlanetXGame/gui/images/upgrade_ventfast.image.taml index 1d1cacbab..66ae080b2 100644 --- a/PlanetX/PlanetXGame/gui/images/upgrade_ventfast.image.taml +++ b/PlanetX/PlanetXGame/gui/images/upgrade_ventfast.image.taml @@ -1,3 +1,4 @@ + AssetCategory="card" + ImageFile="@assetFile=upgrade_ventfast.png" /> diff --git a/PlanetX/PlanetXGame/particles/bugDeath.particle.taml b/PlanetX/PlanetXGame/particles/bugDeath.particle.taml index 3d078ad94..8720008b9 100644 --- a/PlanetX/PlanetXGame/particles/bugDeath.particle.taml +++ b/PlanetX/PlanetXGame/particles/bugDeath.particle.taml @@ -1,5 +1,6 @@ diff --git a/PlanetX/PlanetXGame/sound/enemyDeath.audio.taml b/PlanetX/PlanetXGame/sound/enemyDeath.audio.taml index 1864a5ce5..e1c5f9da8 100644 --- a/PlanetX/PlanetXGame/sound/enemyDeath.audio.taml +++ b/PlanetX/PlanetXGame/sound/enemyDeath.audio.taml @@ -1,4 +1,6 @@ diff --git a/PlanetX/PlanetXGame/sprites/alien_brute.animation.taml b/PlanetX/PlanetXGame/sprites/alien_brute.animation.taml index 6affc6893..976e4fa15 100644 --- a/PlanetX/PlanetXGame/sprites/alien_brute.animation.taml +++ b/PlanetX/PlanetXGame/sprites/alien_brute.animation.taml @@ -1,5 +1,6 @@ + AnimationFrames="0 1 2 3" + animationTime="0.600000024" /> diff --git a/PlanetX/PlanetXGame/sprites/alien_brute.image.taml b/PlanetX/PlanetXGame/sprites/alien_brute.image.taml index ddd4960e7..9d44b5961 100644 --- a/PlanetX/PlanetXGame/sprites/alien_brute.image.taml +++ b/PlanetX/PlanetXGame/sprites/alien_brute.image.taml @@ -1,8 +1,9 @@ + CellCountX="2" + CellHeight="40" + CellCountY="2" /> diff --git a/PlanetX/PlanetXGame/sprites/alien_walk.animation.taml b/PlanetX/PlanetXGame/sprites/alien_walk.animation.taml index 9c45c941e..eb11dbb4e 100644 --- a/PlanetX/PlanetXGame/sprites/alien_walk.animation.taml +++ b/PlanetX/PlanetXGame/sprites/alien_walk.animation.taml @@ -1,5 +1,6 @@ diff --git a/PlanetX/PlanetXGame/sprites/alien_walk.image.taml b/PlanetX/PlanetXGame/sprites/alien_walk.image.taml index 17f3aa142..ec911ac4b 100644 --- a/PlanetX/PlanetXGame/sprites/alien_walk.image.taml +++ b/PlanetX/PlanetXGame/sprites/alien_walk.image.taml @@ -1,8 +1,9 @@ + CellCountX="4" + CellHeight="32" + CellCountY="1" /> diff --git a/PlanetX/PlanetXGame/sprites/spaceman_idle.image.taml b/PlanetX/PlanetXGame/sprites/spaceman_idle.image.taml index e938f947d..319836e17 100644 --- a/PlanetX/PlanetXGame/sprites/spaceman_idle.image.taml +++ b/PlanetX/PlanetXGame/sprites/spaceman_idle.image.taml @@ -1,4 +1,5 @@ diff --git a/PlanetX/PlanetXGame/sprites/spaceman_idle2.image.taml b/PlanetX/PlanetXGame/sprites/spaceman_idle2.image.taml index 1cd823a37..172e636e1 100644 --- a/PlanetX/PlanetXGame/sprites/spaceman_idle2.image.taml +++ b/PlanetX/PlanetXGame/sprites/spaceman_idle2.image.taml @@ -1,4 +1,5 @@ diff --git a/PlanetX/PlanetXGame/sprites/spaceman_walk.animation.taml b/PlanetX/PlanetXGame/sprites/spaceman_walk.animation.taml index 7cd6ce16c..1679334dd 100644 --- a/PlanetX/PlanetXGame/sprites/spaceman_walk.animation.taml +++ b/PlanetX/PlanetXGame/sprites/spaceman_walk.animation.taml @@ -1,5 +1,6 @@ + AnimationFrames="0 1 2 3" + animationTime="0.400000006" /> diff --git a/PlanetX/PlanetXGame/sprites/spaceman_walk.image.taml b/PlanetX/PlanetXGame/sprites/spaceman_walk.image.taml index 9dee6f549..8c7050866 100644 --- a/PlanetX/PlanetXGame/sprites/spaceman_walk.image.taml +++ b/PlanetX/PlanetXGame/sprites/spaceman_walk.image.taml @@ -1,8 +1,9 @@ + CellCountX="4" + CellHeight="32" + CellCountY="1" /> diff --git a/PlanetX/PlanetXGame/sprites/spaceman_walk2.animation.taml b/PlanetX/PlanetXGame/sprites/spaceman_walk2.animation.taml index cadfd8e9c..695fef789 100644 --- a/PlanetX/PlanetXGame/sprites/spaceman_walk2.animation.taml +++ b/PlanetX/PlanetXGame/sprites/spaceman_walk2.animation.taml @@ -1,5 +1,6 @@ + AnimationFrames="0 1 2 3" + animationTime="0.400000006" /> diff --git a/PlanetX/PlanetXGame/sprites/spaceman_walk2.image.taml b/PlanetX/PlanetXGame/sprites/spaceman_walk2.image.taml index 4fc0f578f..896a34660 100644 --- a/PlanetX/PlanetXGame/sprites/spaceman_walk2.image.taml +++ b/PlanetX/PlanetXGame/sprites/spaceman_walk2.image.taml @@ -1,8 +1,9 @@ + CellCountX="4" + CellHeight="32" + CellCountY="1" /> diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index 125315ae8..a2665411b 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -22,9 +22,12 @@ function AssetAdmin::create(%this) { + exec("./AssetLibraryWindow.cs"); exec("./AssetDictionary.cs"); exec("./AssetWindow.cs"); exec("./AssetDictionaryButton.cs"); + exec("./AssetDictionarySprite.cs"); + exec("./AssetBase.cs"); exec("./AssetInspector.cs"); exec("./AssetAudioPlayButton.cs"); exec("./NewAssetButton.cs"); @@ -80,10 +83,14 @@ return %content; } +// Everything inside the library -- the toolbar, the scroller, the chain and the +// groups -- belongs to AssetLibraryWindow, which builds it in its own onAdd. All +// this has to decide is where the window goes. function AssetAdmin::buildLibrary(%this) { %this.libWindow = new GuiWindowCtrl() { + Class = "AssetLibraryWindow"; HorizSizing = "right"; VertSizing = "bottom"; Position = "0 0"; @@ -104,62 +111,10 @@ ThemeManager.setProfile(%this.libWindow, "windowButtonProfile", "MaxButtonProfile"); %this.content.add(%this.libWindow); - %this.libScroller = new GuiScrollCtrl() - { - HorizSizing = "width"; - VertSizing = "height"; - Position="0 0"; - Extent="324 356"; - MinExtent="0 0"; - hScrollBar="dynamic"; - vScrollBar="alwaysOn"; - constantThumbHeight="0"; - showArrowButtons="1"; - scrollBarThickness="14"; - }; - ThemeManager.setProfile(%this.libScroller, "scrollingPanelProfile"); - ThemeManager.setProfile(%this.libScroller, "scrollingPanelThumbProfile", ThumbProfile); - ThemeManager.setProfile(%this.libScroller, "scrollingPanelTrackProfile", TrackProfile); - ThemeManager.setProfile(%this.libScroller, "scrollingPanelArrowProfile", ArrowProfile); - %this.libWindow.add(%this.libScroller); - - %this.dictionaryList = new GuiChainCtrl() - { - HorizSizing="width"; - VertSizing="height"; - Position="0 0"; - Extent="310 768"; - MinExtent="220 200"; - }; - ThemeManager.setProfile(%this.dictionaryList, "emptyProfile"); - %this.libScroller.add(%this.dictionaryList); - - %this.dictionaryList.add(%this.buildDictionary("Images", "ImageAsset")); - %this.dictionaryList.add(%this.buildDictionary("Animations", "AnimationAsset")); - %this.dictionaryList.add(%this.buildDictionary("Particle Effects", "ParticleAsset")); - %this.dictionaryList.add(%this.buildDictionary("Fonts", "FontAsset")); - %this.dictionaryList.add(%this.buildDictionary("Audio", "AudioAsset")); - //%this.dictionaryList.add(%this.buildDictionary("Spines", "SpineAsset")); -} - -function AssetAdmin::buildDictionary(%this, %title, %type) -{ - %this.Dictionary[%type] = new GuiPanelCtrl() - { - Class = AssetDictionary; - Text=%title; - command=""; - HorizSizing="width"; - VertSizing="bottom"; - Position="0 0"; - Extent="306 22"; - MinExtent="80 22"; - Type = %type; - }; - %this.Dictionary[%type].setExpandEase("EaseInOut", 1000); - ThemeManager.setProfile(%this.Dictionary[%type], "panelProfile"); - - return %this.Dictionary[%type]; + // Measure again. The window sized its own contents in onAdd, which is before + // any of the five profiles above were on it and before the frame set gave it + // its real extent -- so that pass was against GuiDefaultProfile's title bar. + %this.libWindow.fitScroller(); } function AssetAdmin::buildInspector(%this) @@ -281,12 +236,7 @@ class = AssetWindow; function AssetAdmin::open(%this) { - %this.Dictionary["ImageAsset"].load(); - %this.Dictionary["AnimationAsset"].load(); - %this.Dictionary["ParticleAsset"].load(); - %this.Dictionary["FontAsset"].load(); - %this.Dictionary["AudioAsset"].load(); - //%this.Dictionary["SpineAsset"].load(); + %this.libWindow.loadAssets(); %this.assetScene.setScenePause(false); %this.isOpen = true; @@ -294,21 +244,8 @@ class = AssetWindow; function AssetAdmin::close(%this) { - %this.Dictionary["ImageAsset"].unload(); - %this.Dictionary["AnimationAsset"].unload(); - %this.Dictionary["ParticleAsset"].unload(); - %this.Dictionary["FontAsset"].unload(); - %this.Dictionary["AudioAsset"].unload(); - //%this.Dictionary["SpineAsset"].unload(); + %this.libWindow.unloadAssets(); %this.assetScene.setScenePause(true); %this.isOpen = false; } - -function AssetBase::onRefresh(%this) -{ - if(AssetAdmin.isOpen && isObject(AssetAdmin.chosenButton)) - { - AssetAdmin.chosenButton.onClick(); - } -} diff --git a/editor/AssetAdmin/AssetBase.cs b/editor/AssetAdmin/AssetBase.cs new file mode 100644 index 000000000..d83231ee6 --- /dev/null +++ b/editor/AssetAdmin/AssetBase.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------------- +// What the Asset Manager does when an asset changes underneath it. +// +// AssetBase::setAssetName, setAssetDescription and setAssetCategory all end in +// refreshAsset(), which fires this. The inspector edits all three, and the +// library now searches and sorts by all three -- so a tile that cached them has +// to be told, or the search box goes on answering about the old values. +//----------------------------------------------------------------------------- + +function AssetBase::onRefresh(%this) +{ + // The library has nothing loaded while the Asset Manager is shut, and this + // also fires as assets are acquired during the load itself, before there is + // anything to refresh. + if(!AssetAdmin.isOpen) + { + return; + } + + AssetAdmin.libWindow.onAssetRefreshed(%this.getAssetId()); + + if(isObject(AssetAdmin.chosenButton)) + { + AssetAdmin.chosenButton.onClick(); + } +} diff --git a/editor/AssetAdmin/AssetDictionary.cs b/editor/AssetAdmin/AssetDictionary.cs index 6eb48db91..d05309f5f 100644 --- a/editor/AssetAdmin/AssetDictionary.cs +++ b/editor/AssetAdmin/AssetDictionary.cs @@ -20,8 +20,42 @@ // IN THE SOFTWARE. //----------------------------------------------------------------------------- +// One collapsible group of the Asset Library -- all the assets of one type. +// A GuiPanelCtrl whose header is the toggle, with a "New" button and a grid of +// AssetDictionaryButtons inside it. +// +// The tiles go in that inner grid and never directly on the panel: +// GuiExpandCtrl::toggleHiddenChildren force-writes mVisible on every DIRECT +// child whenever the panel expands, collapses or resizes, which would fight the +// search filter for control of exactly the same flag. Grandchildren are left +// alone. +// +// The group owns the arrangement; AssetLibraryWindow owns the decisions. It is +// told which view mode to draw, which field to sort on and what to filter by, +// because all three apply to the whole library at once. + +$AssetDictionary::gridCell = 72; +$AssetDictionary::gridCellHeight = 96; +$AssetDictionary::rowHeight = 40; + +// Where the grid starts: under the header and the New button. +$AssetDictionary::gridTop = 62; + function AssetDictionary::onAdd(%this) { + if(%this.viewMode $= "") + { + %this.viewMode = "grid"; + } + if(%this.sortField $= "") + { + %this.sortField = "name"; + } + if(%this.title $= "") + { + %this.title = %this.getText(); + } + %this.newButton = new GuiButtonCtrl() { class = "NewAssetButton"; @@ -34,23 +68,42 @@ class = "NewAssetButton"; ThemeManager.setProfile(%this.newButton, "buttonProfile"); %this.add(%this.newButton); + // CellModeX variable makes CellSizeX a MINIMUM column width -- as many columns + // as fit, with the remainder shared out -- which is the whole reflow, and it is + // also what lets a single row stretch across the group in rows mode. CellModeY + // has to stay absolute; variable would size a row to its tallest child, which + // puts a tile-sized cell in a 40 pixel row. + // + // IsExtentDynamic is what lets the grid grow and shrink with its contents, so + // the panel has something to measure and a fully filtered group collapses to + // its header instead of leaving a hole. %this.grid = new GuiGridCtrl() { - Position="0 62"; + Position = "0" SPC $AssetDictionary::gridTop; Extent = "310 50"; HorizSizing = "width"; VertSizing = "height"; - CellSizeX = 60; - CellSizeY = 60; - CellModeX = variable; + CellSizeX = $AssetDictionary::gridCell; + CellSizeY = $AssetDictionary::gridCellHeight; + CellModeX = "variable"; + CellModeY = "absolute"; CellSpacingX = 4; CellSpacingY = 4; + MaxColCount = 0; + MaxRowCount = 0; OrderMode = "LRTB"; + IsExtentDynamic = true; }; ThemeManager.setProfile(%this.grid, "emptyProfile"); %this.add(%this.grid); + + %this.setViewMode(%this.viewMode); } +//----------------------------------------------------------------------------- +// Contents. +//----------------------------------------------------------------------------- + function AssetDictionary::load(%this) { %query = new AssetQuery(); @@ -63,33 +116,70 @@ class = "NewAssetButton"; if(!AssetDatabase.isAssetInternal(%assetID)) { - %this.addButton(%assetID); + %this.addButton(%assetID, true); } } %query.delete(); + // findAllAssets walks a hash table, so what it returns is in no particular + // order and not even the same order twice. Sorting once here is what makes the + // library's default order mean something. + %this.applySort(%this.sortField); + %this.newButton.text = "New" SPC %this.type; %this.newButton.type = %this.type; } -function AssetDictionary::addButton(%this, %assetID) +// %deferPlacement is for load(), which sorts once at the end rather than paying +// for a sort per asset. Everything else -- the New Asset dialogs -- adds one +// asset to a library that is already open, and wants it to land where it belongs. +function AssetDictionary::addButton(%this, %assetID, %deferPlacement) { + // Authored at the cell size for the current mode so the tile's first pass at + // arranging itself is close; the grid resizes it for real on add, and the + // second setViewMode below is what actually settles it. Spelled from the + // constants rather than read back off the grid, because CellSizeX and + // CellSizeY are floats and "72.000000 96.000000" is not a Point2I. + %cellHeight = (%this.viewMode $= "rows") + ? $AssetDictionary::rowHeight + : $AssetDictionary::gridCellHeight; + %button = new GuiButtonCtrl() { - Class = AssetDictionaryButton; - HorizSizing="center"; - VertSizing="center"; - Extent = "100 100"; + Class = "AssetDictionaryButton"; + HorizSizing = "center"; + VertSizing = "center"; + Extent = $AssetDictionary::gridCell SPC %cellHeight; Tooltip = AssetDatabase.getAssetName(%assetID); Text = ""; AssetID = %assetID; Type = %this.Type; + viewMode = %this.viewMode; }; ThemeManager.setProfile(%button, "itemSelectProfile"); ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); %this.grid.add(%button); - %this.fixSize(); + // Again, now that the grid has resized it to a real cell: the pass inside + // onAdd could only measure the extent the tile was authored with. + %button.setViewMode(%this.viewMode); + + if(!%deferPlacement) + { + %this.applySort(%this.sortField); + + // The library owns the needle, and a new asset has to face it like every + // other: adding one while a filter is up must not sneak an unmatched tile + // onto the screen. + if(isObject(%this.owner)) + { + %this.owner.applyFilter(); + } + else + { + %this.fixSize(); + } + } return %button; } @@ -100,21 +190,20 @@ class = "NewAssetButton"; if(isObject(%button)) { %button.delete(); - %this.fixSize(); + + if(isObject(%this.owner)) + { + %this.owner.applyFilter(); + } + else + { + %this.fixSize(); + } return true; } return false; } -function AssetDictionary::fixSize(%this) -{ - if(%this.getExpanded()) - { - %this.setExpanded(false); - %this.setExpanded(true); - } -} - function AssetDictionary::getButton(%this, %assetID) { for(%i = 0; %i < %this.grid.getCount(); %i++) @@ -128,6 +217,11 @@ class = "NewAssetButton"; return 0; } +function AssetDictionary::getButtonCount(%this) +{ + return %this.grid.getCount(); +} + function AssetDictionary::unload(%this) { //Remove all the child gui controls @@ -142,14 +236,195 @@ class = "NewAssetButton"; { %this.unload(); %this.load(); + + if(isObject(%this.owner)) + { + %this.owner.applyFilter(); + } } -function AssetDictionarySprite::onAnimationEnd(%this, %animationAssetID) +//----------------------------------------------------------------------------- +// View mode. +//----------------------------------------------------------------------------- + +// Both modes are one grid with different cell metrics, not two layouts. Nothing +// is rebuilt, hidden or swapped -- every level rewrites its own numbers and the +// same controls stay put, so the selection and the running animations survive a +// mode change. +function AssetDictionary::setViewMode(%this, %mode) { - %this.schedule(2000, "restartAnimation", %animationAssetID); + %this.viewMode = %mode; + %width = getWord(%this.getExtent(), 0); + + if(%mode $= "rows") + { + // One column, said outright. MaxColCount clamps the chain length, and + // CellModeX variable then hands that single column the whole width, so a + // row fills the group however wide the frame is dragged. Asking for a cell + // wider than the pane would do it too, but only until the next resize. + %this.grid.MaxColCount = 1; + %this.grid.CellSizeY = $AssetDictionary::rowHeight; + } + else + { + %this.grid.MaxColCount = 0; + %this.grid.CellSizeY = $AssetDictionary::gridCellHeight; + } + + // The grid lays out on resize, so nudge it before the tiles read their own + // extents -- otherwise each one measures the cell it had in the other mode. + %this.grid.resize(0, $AssetDictionary::gridTop, %width, 4); + + for(%i = 0; %i < %this.grid.getCount(); %i++) + { + %this.grid.getObject(%i).setViewMode(%mode); + } } -function AssetDictionarySprite::restartAnimation(%this, %animationAssetID) +//----------------------------------------------------------------------------- +// Search. +//----------------------------------------------------------------------------- + +// Hide what does not match and report what is left. The needle arrives already +// lowercased and trimmed, and every tile's searchKey was lowercased once when it +// was built, because this walks every tile on every keystroke. +function AssetDictionary::applyFilter(%this, %needle) +{ + %shown = 0; + + for(%i = 0; %i < %this.grid.getCount(); %i++) + { + %button = %this.grid.getObject(%i); + %match = (%needle $= "") || (strstr(%button.searchKey, %needle) != -1); + %button.setVisible(%match); + + if(%match) + { + %shown++; + } + } + + // A group whose matches are all gone keeps its header and its New button, so + // the shape of the library does not change under the person typing. + %this.setText(%this.title SPC "(" @ %shown @ ")"); + + %this.reflowGrid(); + + return %shown; +} + +// A grid re-lays out when a child is added, removed, moved or resized, and +// setVisible is none of those -- so without this the hidden cells leave their +// holes behind and the grid keeps its old height. Resizing it to the size it +// already has walks the children again, and the walk skips the invisible ones. +function AssetDictionary::reflowGrid(%this) { - %this.setAnimation(%animationAssetID); + %position = %this.grid.getPosition(); + %extent = %this.grid.getExtent(); + %this.grid.resize(getWord(%position, 0), getWord(%position, 1), + getWord(%extent, 0), getWord(%extent, 1)); +} + +//----------------------------------------------------------------------------- +// Sort. +//----------------------------------------------------------------------------- + +// Reorder the tiles that are already there rather than rebuilding them. A +// rebuild would release and re-acquire every asset, throw away and remake every +// sprite, restart every animation and drop the current selection -- all to +// change the order of a list. +function AssetDictionary::applySort(%this, %field) +{ + %this.sortField = %field; + + %count = %this.grid.getCount(); + if(%count < 2) + { + return; + } + + for(%i = 0; %i < %count; %i++) + { + %button = %this.grid.getObject(%i); + %item[%i] = %button; + %major[%i] = (%field $= "category") ? %button.sortCategory : %button.sortName; + %minor[%i] = %button.sortName; + } + + // Insertion sort, the same shape as GuiProfileEditorLibrary::sortTabList. A + // group holds tens of assets, sometimes low hundreds, and this runs when the + // sort field changes or a group loads -- never per frame and never per + // keystroke -- so the quadratic cost never shows. + for(%i = 1; %i < %count; %i++) + { + %heldItem = %item[%i]; + %heldMajor = %major[%i]; + %heldMinor = %minor[%i]; + + %j = %i - 1; + while(%j >= 0 && %this.sortsAfter(%major[%j], %minor[%j], %heldMajor, %heldMinor)) + { + %item[%j + 1] = %item[%j]; + %major[%j + 1] = %major[%j]; + %minor[%j + 1] = %minor[%j]; + %j--; + } + + %item[%j + 1] = %heldItem; + %major[%j + 1] = %heldMajor; + %minor[%j + 1] = %heldMinor; + } + + // SimSet::reOrder inserts its first argument IN FRONT OF its second, so + // walking backwards and putting each tile ahead of the one that follows it + // lands the whole list in order. The grid does not hear about it on its own. + for(%i = %count - 2; %i >= 0; %i--) + { + %this.grid.reorderChild(%item[%i], %item[%i + 1]); + } + %this.grid.childrenReordered(); +} + +// True when A belongs after B. A category sort falls back to the name, so the +// contents of one category are still alphabetical -- and so the many assets that +// carry no category at all keep a stable order among themselves rather than +// whatever the hash table last handed over. +function AssetDictionary::sortsAfter(%this, %aMajor, %aMinor, %bMajor, %bMinor) +{ + %order = stricmp(%aMajor, %bMajor); + if(%order != 0) + { + return %order > 0; + } + + return stricmp(%aMinor, %bMinor) > 0; +} + +//----------------------------------------------------------------------------- +// Layout. +//----------------------------------------------------------------------------- + +// A GuiPanelCtrl caches the height it opens to, measured from the children it +// had at the moment it opened. Anything that changes the size of what is inside +// has to throw that cache away. +function AssetDictionary::fixSize(%this) +{ + if(%this.getExpanded()) + { + %this.setExpanded(false); + %this.setExpanded(true); + } +} + +// fixSize plus a width nudge: one parentResized through every child with the +// widths unchanged, which is what makes the grid re-measure its cells before the +// panel measures the grid. +function AssetDictionary::forceLayout(%this) +{ + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + %this.resize(0, 0, %w + 1, %h); + %this.resize(0, 0, %w, %h); + + %this.fixSize(); } diff --git a/editor/AssetAdmin/AssetDictionaryButton.cs b/editor/AssetAdmin/AssetDictionaryButton.cs index 92e9b24b5..e8a54fbdd 100644 --- a/editor/AssetAdmin/AssetDictionaryButton.cs +++ b/editor/AssetAdmin/AssetDictionaryButton.cs @@ -20,11 +20,95 @@ // IN THE SOFTWARE. //----------------------------------------------------------------------------- +// One asset in the library: a picture and the asset's name, arranged as a tile +// or as a row depending on the library's view mode. +// +// The picture is whatever the asset can show of itself -- the image, the running +// animation -- falling back to a flat icon for the kinds that have no likeness. +// The name is drawn as well as being the tooltip, because the library can now be +// searched and sorted by it and an order you cannot read is not an order. + +$AssetDictionaryButton::gridArt = 50; + +// The band at the bottom of a tile the caption is allowed to fill. +$AssetDictionaryButton::gridCaption = 34; + +$AssetDictionaryButton::rowArt = 28; +$AssetDictionaryButton::rowTextLeft = 36; + function AssetDictionaryButton::onAdd(%this) { + %this.buildSearchKey(); + %this.buildCaption(); + %this.call("load" @ %this.type, %this.assetID); + + if(%this.viewMode $= "") + { + %this.viewMode = "grid"; + } + %this.setViewMode(%this.viewMode); +} + +// Everything the library asks about this asset, worked out once. +// +// The name, description and category all come off the AssetDefinition without +// loading anything, and any of the three may be empty -- most assets carry no +// description and no category at all. They are lowercased here rather than in +// the filter because the filter walks every tile on every keystroke, and strstr +// is case sensitive (unlike $=, which is not). +function AssetDictionaryButton::buildSearchKey(%this) +{ + %name = AssetDatabase.getAssetName(%this.assetID); + %description = AssetDatabase.getAssetDescription(%this.assetID); + %category = AssetDatabase.getAssetCategory(%this.assetID); + + %this.assetName = %name; + %this.assetCategory = %category; + + %this.sortName = strlwr(%name); + %this.sortCategory = strlwr(%category); + + // Trimmed, because two of the three are usually empty: without it an asset + // with neither a description nor a category ends up keyed "name ", and one + // with nothing at all ends up keyed " " -- which a needle of a single space + // would match. (The needle is trimmed too, so that case cannot arise today; + // the key should not depend on that staying true.) + %this.searchKey = trim(strlwr(%name SPC %description SPC %category)); +} + +// The three fields are editable in the inspector, so what was worked out once +// has to be worked out again when they change. Everything the tile shows or is +// found by comes from here. +function AssetDictionaryButton::refreshKeys(%this) +{ + %this.buildSearchKey(); + + %this.caption.setText(%this.assetName); + %this.Tooltip = %this.assetName; +} + +function AssetDictionaryButton::buildCaption(%this) +{ + %this.caption = new GuiControl() + { + Position = "0 0"; + Extent = "60 20"; + MinExtent = "0 0"; + Text = %this.assetName; + align = "center"; + vAlign = "bottom"; + textWrap = true; + UseInput = false; + }; + ThemeManager.setProfile(%this.caption, "labelProfile"); + %this.add(%this.caption); } +//----------------------------------------------------------------------------- +// The picture, one loader per asset kind. +//----------------------------------------------------------------------------- + function AssetDictionaryButton::loadImageAsset(%this, %assetID) { %imageAsset = AssetDatabase.acquireAsset(%assetID); @@ -111,24 +195,92 @@ %this.add(%texture); } +// MinExtent is deliberately tiny: setViewMode drives this down to the row art +// size, and a minimum of the tile size would silently refuse. function AssetDictionaryButton::buildIcon(%this) { - %texture = new GuiSpriteCtrl() + %this.icon = new GuiSpriteCtrl() { class = "AssetDictionarySprite"; - HorizSizing="center"; - VertSizing="center"; - Extent = "50 50"; - minExtent = "50 50"; + HorizSizing = "center"; + VertSizing = "center"; + Extent = $AssetDictionaryButton::gridArt SPC $AssetDictionaryButton::gridArt; + minExtent = "8 8"; Position = "0 0"; constrainProportions = "1"; fullSize = "1"; UseInput = false; }; - ThemeManager.setProfile(%texture, "spriteProfile"); - return %texture; + ThemeManager.setProfile(%this.icon, "spriteProfile"); + return %this.icon; +} + +//----------------------------------------------------------------------------- +// View mode. +//----------------------------------------------------------------------------- + +// The picture and the caption swap places rather than the tile being rebuilt. +// The sprite scales itself (constrainProportions and fullSize), so only its +// extent and position change -- the image it holds, and an animation part way +// through, are untouched. +function AssetDictionaryButton::setViewMode(%this, %mode) +{ + %this.viewMode = %mode; + + if(!isObject(%this.icon) || !isObject(%this.caption)) + { + return; + } + + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + + if(%mode $= "rows") + { + %art = $AssetDictionaryButton::rowArt; + %left = $AssetDictionaryButton::rowTextLeft; + + %this.icon.HorizSizing = "anchorLeft"; + %this.icon.VertSizing = "center"; + %this.icon.setExtent(%art, %art); + %this.icon.setPosition(4, (%h - %art) / 2); + + %this.caption.HorizSizing = "width"; + %this.caption.VertSizing = "center"; + %this.caption.setExtent(%w - %left - 4, %art); + %this.caption.setPosition(%left, (%h - %art) / 2); + %this.caption.align = "left"; + %this.caption.vAlign = "middle"; + %this.caption.textWrap = false; + } + else + { + %art = $AssetDictionaryButton::gridArt; + %band = $AssetDictionaryButton::gridCaption; + + %this.caption.HorizSizing = "fill"; + %this.caption.VertSizing = "fill"; + %this.caption.align = "center"; + %this.caption.vAlign = "bottom"; + %this.caption.textWrap = true; + %this.caption.applySizing(); + + // And that fill is how the button's own border inset gets measured: what + // the caption reports back after filling IS the content rect. + %innerH = getWord(%this.caption.getExtent(), 1); + + %this.icon.HorizSizing = "center"; + %this.icon.VertSizing = "anchorTop"; + %this.icon.setExtent(%art, %art); + %this.icon.setPosition(0, (%innerH - %band - %art) / 2); + %this.icon.applySizing(); + } } +//----------------------------------------------------------------------------- +// Selection. +//----------------------------------------------------------------------------- + function AssetDictionaryButton::onClick(%this) { %firstLoad = false; diff --git a/editor/AssetAdmin/AssetDictionarySprite.cs b/editor/AssetAdmin/AssetDictionarySprite.cs new file mode 100644 index 000000000..005d32e61 --- /dev/null +++ b/editor/AssetAdmin/AssetDictionarySprite.cs @@ -0,0 +1,37 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// The picture on an AssetDictionaryButton. +// +// An animation asset plays in the library so a person can tell two similar ones +// apart, but it plays on a loop with a breath between passes rather than +// running flat out: a wall of thumbnails all animating at once is unreadable. + +function AssetDictionarySprite::onAnimationEnd(%this, %animationAssetID) +{ + %this.schedule(2000, "restartAnimation", %animationAssetID); +} + +function AssetDictionarySprite::restartAnimation(%this, %animationAssetID) +{ + %this.setAnimation(%animationAssetID); +} diff --git a/editor/AssetAdmin/AssetLibraryWindow.cs b/editor/AssetAdmin/AssetLibraryWindow.cs new file mode 100644 index 000000000..7ce81a178 --- /dev/null +++ b/editor/AssetAdmin/AssetLibraryWindow.cs @@ -0,0 +1,504 @@ +//AssetLibraryWindow.cs +// +// The Asset Library: a fixed toolbar over a scroller of collapsible asset +// groups. +// +// toolbar view mode, sort field, and the search box +// scroller everything else, so the toolbar never scrolls away +// dictionaryList a chain of AssetDictionary panels, one per asset type +// +// The window owns all three. It also owns the three pieces of state the groups +// share -- view mode, sort field and the search needle -- because all of them +// apply to every group at once: a person looking for "rock" wants the rock +// image AND the rock sound, and switching to rows is a statement about the +// library, not about one type of asset. +// +// Groups are reached from elsewhere through AssetAdmin.Dictionary[%type], which +// is what the New/Delete dialogs have always used; addDictionary keeps writing +// it. + +$AssetLibraryWindow::toolbarHeight = 58; +$AssetLibraryWindow::pad = 4; +$AssetLibraryWindow::rowHeight = 24; +$AssetLibraryWindow::searchY = 30; +$AssetLibraryWindow::iconSize = 16; +$AssetLibraryWindow::countWidth = 88; + +function AssetLibraryWindow::onAdd(%this) +{ + // The view and the sort are remembered between runs; the search box is not. + // A filter is about the thing you are doing right now, and reopening the + // editor to a library that is mysteriously missing most of its assets is a + // bug report waiting to happen. + %this.viewMode = EditorPreferences.get("assetLibraryViewMode", "grid"); + %this.sortField = EditorPreferences.get("assetLibrarySortField", "name"); + %this.dictionaryCount = 0; + + %this.buildToolbar(); + + // Built filling the whole content rect, then moved down under the toolbar by + // fitScroller. Fill is the only way to find out how big that rect is: it is + // the window's extent less the title bar and whatever borders the profile + // asks for, and script cannot ask for any of those numbers. + %this.scroller = new GuiScrollCtrl() + { + HorizSizing = "fill"; + VertSizing = "fill"; + Position = "0 0"; + Extent = "324 356"; + MinExtent = "0 0"; + hScrollBar = "alwaysOff"; + vScrollBar = "alwaysOn"; + constantThumbHeight = "0"; + showArrowButtons = "1"; + scrollBarThickness = "14"; + }; + ThemeManager.setProfile(%this.scroller, "scrollingPanelProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelThumbProfile", "ThumbProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelTrackProfile", "TrackProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelArrowProfile", "ArrowProfile"); + %this.add(%this.scroller); + %this.fitScroller(); + + // Fill across, and nothing about widths below. The horizontal bar is off, so + // across is an axis that does not scroll and the engine knows exactly how much + // room there is. Down is left alone; that axis scrolls, so the chain is as tall + // as its groups and GuiScrollCtrl refuses fill there. + %this.dictionaryList = new GuiChainCtrl() + { + HorizSizing = "fill"; + Position = "0 0"; + Extent = "310 4"; + MinExtent = "0 0"; + IsVertical = true; + ChildSpacing = 2; + }; + ThemeManager.setProfile(%this.dictionaryList, "emptyProfile"); + %this.scroller.add(%this.dictionaryList); + + %this.populate(); +} + +function AssetLibraryWindow::onRemove(%this) +{ + %this.stopListening(ThemeManager); + + // The toolbar and the scroller are this window's two children; the groups and + // their tiles hang off the scroller and go with it, and the toolbar's own + // controls go with the toolbar. + if(isObject(%this.toolbar)) + { + %this.toolbar.delete(); + } + if(isObject(%this.scroller)) + { + %this.scroller.delete(); + } +} + +//----------------------------------------------------------------------------- +// The toolbar. +//----------------------------------------------------------------------------- + +function AssetLibraryWindow::buildToolbar(%this) +{ + %this.toolbar = new GuiControl() + { + HorizSizing = "width"; + VertSizing = "bottom"; + Position = "0 0"; + Extent = "310" SPC $AssetLibraryWindow::toolbarHeight; + }; + ThemeManager.setProfile(%this.toolbar, "emptyProfile"); + %this.add(%this.toolbar); + + // Two segmented rows rather than one: they answer different questions, and a + // four-button strip would read as one choice of four. + // + // labelWidth is 4, not 0 -- EditorChoiceRow::build sizes its caption at + // labelWidth - 4, so a zero asks for a control four pixels wide in the wrong + // direction and silently eats the whole row. + %this.viewRow = new GuiControl() + { + class = "EditorChoiceRow"; + HorizSizing = "right"; + Position = $AssetLibraryWindow::pad SPC 2; + labelText = ""; + labelWidth = 4; + owner = %this; + fieldName = "viewMode"; + }; + %this.toolbar.add(%this.viewRow); + %this.viewRow.addChoice("grid", $EditorIcon::grid_2x2, "Show assets as a grid of thumbnails"); + %this.viewRow.addChoice("rows", $EditorIcon::list_bullets, "Show assets as a compact list"); + %this.viewRow.build(); + %this.viewRow.setValue(%this.viewMode); + + %this.sortRow = new GuiControl() + { + class = "EditorChoiceRow"; + HorizSizing = "left"; + Position = "0 2"; + labelText = ""; + labelWidth = 4; + owner = %this; + fieldName = "sortField"; + }; + %this.toolbar.add(%this.sortRow); + // font_size is the sheet's "Aa" pair, which is what every other tool uses for + // "alphabetical" -- there is no A-Z glyph here. text_letter_t was tried first + // and is a serif T whose crossbar and stem read as two bars at 16 pixels once + // the theme tints it. + %this.sortRow.addChoice("name", $EditorIcon::font_size, "Sort by asset name"); + %this.sortRow.addChoice("category", $EditorIcon::tag, "Sort by asset category, then by name"); + %this.sortRow.build(); + %this.sortRow.setValue(%this.sortField); + + // A funnel rather than the word "Search": the caption was clipped in every + // theme, and widening it would have come straight out of the box it labels -- + // this pane is 324 wide and the sort row already owns the other end. The + // funnel also says the truer thing, since the box narrows the library in + // place rather than jumping to a hit. + // Sizing left at the default, which pins the top-left corner. NOT "center": + // that recentres the control in its PARENT on every resize, and the parent + // here is the whole 58-pixel toolbar -- so the icon jumped up to the middle of + // the bar, level with the view toggle, instead of staying level with the box. + %this.searchIcon = new GuiSpriteCtrl() + { + Extent = $AssetLibraryWindow::iconSize SPC $AssetLibraryWindow::iconSize; + MinExtent = $AssetLibraryWindow::iconSize SPC $AssetLibraryWindow::iconSize; + Position = $AssetLibraryWindow::pad SPC ($AssetLibraryWindow::searchY + + (($AssetLibraryWindow::rowHeight - $AssetLibraryWindow::iconSize) / 2)); + Image = "EditorCore:EditorIcons16"; + ImageSize = "16 16"; + constrainProportions = "1"; + fullSize = "0"; + Frame = $EditorIcon::filter; + UseInput = false; + }; + ThemeManager.setProfile(%this.searchIcon, "spriteProfile"); + %this.toolbar.add(%this.searchIcon); + + // The sheets are greyscale, drawn to be modulated -- an untinted icon blends + // with opaque white, which happens to look right on the theme the editor opens + // in and is a white smear on the light ones. And the tint has to be re-read on + // a theme change: ThemeManager swaps the profile object, which carries + // backgrounds and text for free, but a color COPIED onto a sprite stays behind. + %this.startListening(ThemeManager); + %this.refreshSearchIcon(); + + // Command fires on every keystroke, which is what makes the library narrow as + // you type; AltCommand would only fire when the box lost focus. + %this.searchBox = new GuiTextEditCtrl() + { + HorizSizing = "width"; + Position = "0" SPC $AssetLibraryWindow::searchY; + Extent = "180" SPC $AssetLibraryWindow::rowHeight; + align = "left"; + Tooltip = "Filter every group by asset name, description or category"; + }; + ThemeManager.setProfile(%this.searchBox, "textEditProfile"); + ThemeManager.setProfile(%this.searchBox, "tipProfile", "TooltipProfile"); + %this.searchBox.Command = %this.getID() @ ".onSearchChanged();"; + %this.searchBox.EscapeCommand = %this.getID() @ ".clearSearch();"; + %this.toolbar.add(%this.searchBox); + + // labelProfile rather than infoProfile: this is a status line, and infoProfile + // draws a border and a fill, which reads as a second empty text box. + %this.countLabel = new GuiControl() + { + HorizSizing = "left"; + Position = "0" SPC $AssetLibraryWindow::searchY; + Extent = $AssetLibraryWindow::countWidth SPC $AssetLibraryWindow::rowHeight; + Text = ""; + align = "right"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%this.countLabel, "labelProfile"); + %this.toolbar.add(%this.countLabel); +} + +// The colour the caption this replaced would have been drawn in, so the icon +// reads as part of the same row rather than as a picture sitting next to it. +function AssetLibraryWindow::refreshSearchIcon(%this) +{ + %this.searchIcon.setImageColor(ThemeManager.activeTheme.labelProfile.fontColor); +} + +function AssetLibraryWindow::onThemeChange(%this, %theme) +{ + %this.refreshSearchIcon(); +} + +// The three controls that hang off the right edge cannot be placed until the +// content rect has been measured, so this is called from fitScroller with the +// width it found. Their sizing flags hold them there through every later resize. +function AssetLibraryWindow::layoutToolbar(%this, %width) +{ + %pad = $AssetLibraryWindow::pad; + %count = $AssetLibraryWindow::countWidth; + %y = $AssetLibraryWindow::searchY; + + %this.toolbar.resize(0, 0, %width, $AssetLibraryWindow::toolbarHeight); + + %sortWidth = getWord(%this.sortRow.getExtent(), 0); + %this.sortRow.setPosition(%width - %pad - %sortWidth, 2); + + %boxLeft = %pad + $AssetLibraryWindow::iconSize + 6; + %boxWidth = %width - %boxLeft - %pad - %count - 6; + %this.searchBox.resize(%boxLeft, %y, %boxWidth, $AssetLibraryWindow::rowHeight); + + %this.countLabel.setPosition(%width - %pad - %count, %y); +} + +// Put the scroller under the toolbar without ever naming a border thickness. +// +// A fixed bar above a stretching pane has no sizing flag of its own: "fill" +// takes the whole inner rect and throws the position away, so the bar ends up +// underneath it, and "height" keeps whatever gaps the authored extent happened +// to start with -- which means guessing the title height and the border sizes. +// +// So let the engine measure instead: the scroller is built filling, one resize +// pass makes that real, and what it reports back IS the content rect. Take the +// bar off the top of it and switch to "height", which from then on holds the top +// edge where it was put and lets the bottom follow the window. +// +// Callable more than once, which the palette's version is not. The measurement +// this makes is only as good as the profile the window is wearing at the time, +// and AssetAdmin::buildLibrary applies the window profiles AFTER the new{} block +// returns -- so the pass inside onAdd measures GuiDefaultProfile's title bar and +// borders. Going back to fill before measuring makes a second pass, once the +// real profiles are on and the frame set has sized the window, give the right +// answer instead of subtracting the bar height twice. +function AssetLibraryWindow::fitScroller(%this) +{ + %x = getWord(%this.getPosition(), 0); + %y = getWord(%this.getPosition(), 1); + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + + %this.scroller.HorizSizing = "fill"; + %this.scroller.VertSizing = "fill"; + + // Nudge the width by a pixel and back: one parentResized through every child, + // widths unchanged. The position is carried through rather than zeroed, so + // this does not move the window it is measuring. + %this.resize(%x, %y, %w + 1, %h); + %this.resize(%x, %y, %w, %h); + + %inner = %this.scroller.getExtent(); + %bar = $AssetLibraryWindow::toolbarHeight; + + %this.layoutToolbar(getWord(%inner, 0)); + + %this.scroller.HorizSizing = "width"; + %this.scroller.VertSizing = "height"; + %this.scroller.resize(0, %bar, getWord(%inner, 0), getWord(%inner, 1) - %bar); +} + +//----------------------------------------------------------------------------- +// The groups. +//----------------------------------------------------------------------------- + +function AssetLibraryWindow::populate(%this) +{ + %this.addDictionary("Images", "ImageAsset"); + %this.addDictionary("Animations", "AnimationAsset"); + %this.addDictionary("Particle Effects", "ParticleAsset"); + %this.addDictionary("Fonts", "FontAsset"); + %this.addDictionary("Audio", "AudioAsset"); + //%this.addDictionary("Spines", "SpineAsset"); +} + +// Groups size with "width" rather than "fill": GuiExpandCtrl::parentResized +// writes mExpandedExtent straight into mBounds.extent, bypassing resize(), which +// is the only thing that honours fill. +function AssetLibraryWindow::addDictionary(%this, %title, %type) +{ + %dictionary = new GuiPanelCtrl() + { + Class = "AssetDictionary"; + Text = %title; + command = ""; + HorizSizing = "width"; + VertSizing = "bottom"; + Position = "0 0"; + Extent = "306 22"; + MinExtent = "80 22"; + Type = %type; + title = %title; + owner = %this; + viewMode = %this.viewMode; + sortField = %this.sortField; + }; + %dictionary.setExpandEase("EaseInOut", 1000); + ThemeManager.setProfile(%dictionary, "panelProfile"); + %this.dictionaryList.add(%dictionary); + + %this.dictionary[%this.dictionaryCount] = %dictionary; + %this.dictionaryCount++; + + // How the New and Delete dialogs have always found a group. + AssetAdmin.Dictionary[%type] = %dictionary; + + return %dictionary; +} + +function AssetLibraryWindow::loadAssets(%this) +{ + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %this.dictionary[%i].load(); + } + + %this.applyFilter(); +} + +// An asset's name, description or category was edited. The tile that shows it +// cached all three when it was built, so it has to re-read them -- and then the +// order and the filter it fed into are both potentially wrong. +// +// Which group holds it is not known here, so ask them all; that is the same move +// DeleteAssetDialog makes, and there are five. +function AssetLibraryWindow::onAssetRefreshed(%this, %assetID) +{ + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %button = %this.dictionary[%i].getButton(%assetID); + if(isObject(%button)) + { + %button.refreshKeys(); + %this.dictionary[%i].applySort(%this.sortField); + %this.applyFilter(); + return; + } + } +} + +function AssetLibraryWindow::unloadAssets(%this) +{ + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %this.dictionary[%i].unload(); + } +} + +//----------------------------------------------------------------------------- +// View mode and sort field. +//----------------------------------------------------------------------------- + +function AssetLibraryWindow::onChoiceRowChanged(%this, %row) +{ + if(%row.fieldName $= "viewMode") + { + %this.setViewMode(%row.getValue()); + } + else if(%row.fieldName $= "sortField") + { + %this.setSortField(%row.getValue()); + } +} + +function AssetLibraryWindow::setViewMode(%this, %mode) +{ + %this.viewMode = %mode; + + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %this.dictionary[%i].setViewMode(%mode); + } + + %this.relayout(); + + EditorPreferences.set("assetLibraryViewMode", %mode); +} + +function AssetLibraryWindow::setSortField(%this, %field) +{ + %this.sortField = %field; + + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %this.dictionary[%i].applySort(%field); + } + + EditorPreferences.set("assetLibrarySortField", %field); +} + +//----------------------------------------------------------------------------- +// Search. +//----------------------------------------------------------------------------- + +function AssetLibraryWindow::onSearchChanged(%this) +{ + %this.applyFilter(); +} + +function AssetLibraryWindow::clearSearch(%this) +{ + %this.searchBox.setText(""); + %this.applyFilter(); +} + +// One needle, lowercased and trimmed once, then handed to every group. Each +// group hides what does not match and reports how much survived, so the count +// line can say "12 of 40" for the library as a whole. +function AssetLibraryWindow::applyFilter(%this) +{ + %needle = strlwr(trim(%this.searchBox.getText())); + %shown = 0; + %total = 0; + + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %dictionary = %this.dictionary[%i]; + %shown += %dictionary.applyFilter(%needle); + %total += %dictionary.getButtonCount(); + } + + // "152/152" rather than "152 of 152": this shares a row with the search box in + // a 324 pane, and the words were clipped on a library of three figures. The + // compact form still fits at four, which no real project reaches. + %this.countLabel.setText(%shown @ "/" @ %total); + + // settle(), not relayout(): no group changed width, so none of them needs the + // width nudge -- and this runs on every keystroke. + %this.settle(); +} + +//----------------------------------------------------------------------------- +// Layout. +//----------------------------------------------------------------------------- + +// A GuiPanelCtrl caches the height it opens to, so anything that changes the +// size of what is inside one has to make it measure again -- and a chain +// positions its children without resizing them, so it has to be told too. +// +// The cheap one: the groups are the width they already were, and only the number +// of visible cells changed. This is the keystroke path. +function AssetLibraryWindow::settle(%this) +{ + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %this.dictionary[%i].fixSize(); + } + + %w = getWord(%this.dictionaryList.getExtent(), 0); + %h = getWord(%this.dictionaryList.getExtent(), 1); + %this.dictionaryList.resize(0, 0, %w, %h); +} + +// The full one, for a change of view mode: every cell is a different size now, +// so each group needs the width nudge that makes its grid re-measure before the +// panel measures the grid. +function AssetLibraryWindow::relayout(%this) +{ + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %this.dictionary[%i].forceLayout(); + } + + %w = getWord(%this.dictionaryList.getExtent(), 0); + %h = getWord(%this.dictionaryList.getExtent(), 1); + %this.dictionaryList.resize(0, 0, %w, %h); +} diff --git a/editor/GuiEditor/scripts/GuiEditorChoiceRow.cs b/editor/EditorCore/EditorChoiceRow.cs similarity index 87% rename from editor/GuiEditor/scripts/GuiEditorChoiceRow.cs rename to editor/EditorCore/EditorChoiceRow.cs index 2f2c45d80..4a9331752 100644 --- a/editor/GuiEditor/scripts/GuiEditorChoiceRow.cs +++ b/editor/EditorCore/EditorChoiceRow.cs @@ -6,7 +6,7 @@ // each have a picture, and seeing which one is set matters more than reading // its name. // -// The buttons are GuiEditorToggleIcon, the same checkbox-as-button the header +// The buttons are EditorToggleIcon, the same checkbox-as-button the header // and the anchor picker use, so a choice looks pressed and a disabled row // refuses to act. Exclusivity is this row's job rather than the button's: a // click turns the others off, and clicking the chosen one again does nothing -- @@ -22,16 +22,16 @@ // owner.onChoiceRowChanged(%row). //----------------------------------------------------------------------------- -function GuiEditorChoiceRow::onAdd(%this) +function EditorChoiceRow::onAdd(%this) { ThemeManager.setProfile(%this, "emptyProfile"); %this.choiceCount = 0; %this.value = ""; } -// %icon may be "" for the entry that means "unset" -- GuiEditorToggleIcon draws +// %icon may be "" for the entry that means "unset" -- EditorToggleIcon draws // nothing when its frame is empty, leaving a plain button. -function GuiEditorChoiceRow::addChoice(%this, %value, %icon, %tip) +function EditorChoiceRow::addChoice(%this, %value, %icon, %tip) { %i = %this.choiceCount; %this.choiceValue[%i] = %value; @@ -40,7 +40,7 @@ %this.choiceCount = %i + 1; } -function GuiEditorChoiceRow::build(%this) +function EditorChoiceRow::build(%this) { // Wide enough for a caption by default. The text block asks for a narrow one: // its two rows sit side by side under the text box and are labelled "H:" and @@ -66,7 +66,7 @@ { %button = new GuiCheckBoxCtrl() { - class = "GuiEditorToggleIcon"; + class = "EditorToggleIcon"; Position = (%labelW + (%i * (%size + %gap))) SPC 2; Extent = %size SPC %size; frameOn = %this.choiceIcon[%i]; @@ -87,7 +87,7 @@ class = "GuiEditorToggleIcon"; // absolutely, so this leaves a gap where the choice was rather than reflowing the // row -- which is the right trade for a choice that comes and goes with the // selection: the ones that stay do not move under the cursor. -function GuiEditorChoiceRow::setChoiceVisible(%this, %value, %visible) +function EditorChoiceRow::setChoiceVisible(%this, %value, %visible) { for(%i = 0; %i < %this.choiceCount; %i++) { @@ -105,7 +105,7 @@ class = "GuiEditorToggleIcon"; // Load a value without telling the owner. A value the row does not offer leaves // every button up rather than guessing, so nothing is silently rewritten. -function GuiEditorChoiceRow::setValue(%this, %value) +function EditorChoiceRow::setValue(%this, %value) { %this.value = %value; %this.populating = true; @@ -116,7 +116,7 @@ class = "GuiEditorToggleIcon"; %this.populating = false; } -function GuiEditorChoiceRow::getValue(%this) +function EditorChoiceRow::getValue(%this) { return %this.value; } @@ -124,7 +124,7 @@ class = "GuiEditorToggleIcon"; // A button toggled itself. Whatever it did to its own state, the row's rule is // that exactly one is on -- so re-assert that from the value rather than from // what the button happens to hold. -function GuiEditorChoiceRow::onToggleIconChanged(%this, %button) +function EditorChoiceRow::onToggleIconChanged(%this, %button) { if(%this.populating) { @@ -147,7 +147,7 @@ class = "GuiEditorToggleIcon"; } } -function GuiEditorChoiceRow::setEnabled(%this, %enabled) +function EditorChoiceRow::setEnabled(%this, %enabled) { %this.label.setActive(%enabled); for(%i = 0; %i < %this.choiceCount; %i++) diff --git a/editor/EditorCore/EditorCore.cs b/editor/EditorCore/EditorCore.cs index e5ca147fa..4b68b7e30 100644 --- a/editor/EditorCore/EditorCore.cs +++ b/editor/EditorCore/EditorCore.cs @@ -51,11 +51,23 @@ exec("./EditorForm.cs"); exec("./EditorIconButton.cs"); exec("./EditorButtonBar.cs"); + + // The segmented toggle row. It lives here rather than in the Gui Editor that + // first grew it because editor/main.cs loads AssetAdmin FIRST, so anything the + // Asset Manager builds at create time cannot come out of a module loaded after + // it. Nothing in either file was ever Gui-Editor specific. + exec("./EditorToggleIcon.cs"); + exec("./EditorChoiceRow.cs"); + exec("./EditorAssetPickerDialog.cs"); exec("./EditorAssetPickerItem.cs"); + exec("./EditorPreferences.cs"); new ScriptObject(ThemeManager); + // Before any editor builds a control that wants to remember how it was left. + new ScriptObject(EditorPreferences); + %this.initGui(); %this.editorKeyMap.push(); } @@ -64,6 +76,10 @@ function EditorCore::destroy( %this ) { + if(isObject(EditorPreferences)) + { + EditorPreferences.delete(); + } } function EditorCore::initGui(%this) diff --git a/editor/EditorCore/EditorPreferences.cs b/editor/EditorCore/EditorPreferences.cs new file mode 100644 index 000000000..754b641ab --- /dev/null +++ b/editor/EditorCore/EditorPreferences.cs @@ -0,0 +1,120 @@ +//----------------------------------------------------------------------------- +// The editor's memory between runs. +// +// Until now the editor had none: every choice a person made -- which theme, how +// a list was arranged -- lasted exactly as long as the process. The things worth +// remembering are the ones a person sets once and expects to stay set, and the +// Asset Library's view mode and sort order are the first two. +// +// Deliberately NOT $pref:: globals. Those are the engine's own settings, they +// are re-declared on every boot by defaultPreferences.cs, and script has no +// setVariable() to write one by name -- only eval(). Dynamic fields on a +// SimObject give the same key/value store with getFieldValue/setFieldValue and +// no string-built code. +// +// The file is written to the platform's per-user application data folder +// (getPrefsPath), never into the repository or a project, because it describes +// the person rather than the work. +//----------------------------------------------------------------------------- + +function EditorPreferences::onAdd(%this) +{ + %this.path = getPrefsPath("editorPreferences.taml"); + %this.load(); +} + +// A value the editor has never been told is not an error -- it is the first run. +function EditorPreferences::get(%this, %key, %fallback) +{ + %value = %this.getFieldValue(%key); + + return (%value $= "") ? %fallback : %value; +} + +// Written through immediately. There is no "apply" step anywhere in the editor, +// and a preferences file that only survives a clean exit is one that never +// survives the interesting exits. +function EditorPreferences::set(%this, %key, %value) +{ + if(%this.getFieldValue(%key) $= %value) + { + return; + } + + %this.setFieldValue(%key, %value); + %this.save(); +} + +//----------------------------------------------------------------------------- +// The file. +//----------------------------------------------------------------------------- + +function EditorPreferences::load(%this) +{ + if(!%this.fileExists(%this.path)) + { + return; + } + + %stored = TamlRead(%this.path); + if(!isObject(%stored)) + { + warn("EditorPreferences: could not read " @ %this.path); + return; + } + + // getDynamicField answers with the field's NAME and nothing else, despite the + // "myField myValue" its own doc comment implies -- see simObject_ScriptBinding.h, + // which sprintfs entry->slotName alone. The value has to be asked for separately. + %count = %stored.getDynamicFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %name = %stored.getDynamicField(%i); + %this.setFieldValue(%name, %stored.getFieldValue(%name)); + } + + %stored.delete(); +} + +// Saved as a plain ScriptObject rather than as this object, because "class" is a +// persistent field: writing this one out would record class="EditorPreferences", +// and reading it back would construct a second one, whose onAdd would load the +// file again. +function EditorPreferences::save(%this) +{ + %store = new ScriptObject(); + + %count = %this.getDynamicFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %name = %this.getDynamicField(%i); + + // path is this object's own bookkeeping, not something to remember. + if(%name $= "path") + { + continue; + } + + %store.setFieldValue(%name, %this.getFieldValue(%name)); + } + + // The per-user folder does not exist until something makes it, and TamlWrite + // fails by logging rather than by telling the caller. + createPath(%this.path); + + TamlWrite(%store, %this.path); + %store.delete(); +} + +// isFile() answers from the ResourceManager's dictionary rather than from the +// disk, so it can say yes to a path nothing ever wrote and no to one written +// this session. Opening the file is the only answer that is about the file. +function EditorPreferences::fileExists(%this, %path) +{ + %file = new FileObject(); + %found = %file.openForRead(%path); + %file.close(); + %file.delete(); + + return %found; +} diff --git a/editor/GuiEditor/scripts/GuiEditorToggleIcon.cs b/editor/EditorCore/EditorToggleIcon.cs similarity index 91% rename from editor/GuiEditor/scripts/GuiEditorToggleIcon.cs rename to editor/EditorCore/EditorToggleIcon.cs index 47b83c7f0..ed25f0ea6 100644 --- a/editor/GuiEditor/scripts/GuiEditorToggleIcon.cs +++ b/editor/EditorCore/EditorToggleIcon.cs @@ -34,7 +34,7 @@ // inline. Clicks arrive at owner.onToggleIconChanged(%this). //----------------------------------------------------------------------------- -function GuiEditorToggleIcon::onAdd(%this) +function EditorToggleIcon::onAdd(%this) { // Field assignment, not setBoxOffset/setBoxExtent: those bindings document // one argument and read two (argv[2] and argv[3]), so a single "0 0" string @@ -77,7 +77,7 @@ %this.refresh(); } -function GuiEditorToggleIcon::onThemeChange(%this, %theme) +function EditorToggleIcon::onThemeChange(%this, %theme) { %this.refresh(); } @@ -85,7 +85,7 @@ // GuiCheckBoxCtrl has already flipped mStateOn by the time the Command runs, so // the owner is told what the value became rather than being asked to work it // out. -function GuiEditorToggleIcon::onToggled(%this) +function EditorToggleIcon::onToggled(%this) { %this.refresh(); @@ -96,20 +96,20 @@ } // Set the state without telling the owner, for loading a value in. -function GuiEditorToggleIcon::setValue(%this, %on) +function EditorToggleIcon::setValue(%this, %on) { %this.setStateOn(%on); %this.refresh(); } -function GuiEditorToggleIcon::getValue(%this) +function EditorToggleIcon::getValue(%this) { return %this.getStateOn(); } // The single place the icon's look is decided: which frame, which tooltip, and // which of the profile's font colors tints it. -function GuiEditorToggleIcon::refresh(%this) +function EditorToggleIcon::refresh(%this) { %on = %this.getStateOn(); %profile = ThemeManager.activeTheme.iconButtonProfile; @@ -119,7 +119,7 @@ %frame = (%on && %this.frameOn !$= "") ? %this.frameOn : %this.frameOff; // So is frameOff. A button with no icon at all is a deliberate shape: it is - // how GuiEditorChoiceRow spells the "unset" end of a segmented control, + // how EditorChoiceRow spells the "unset" end of a segmented control, // where the absence of a picture is the meaning. %this.icon.setVisible(%frame !$= ""); if(%frame !$= "") @@ -149,7 +149,7 @@ // buttons are choices rather than switches -- "Centre text - On" would be a // worse caption than "Centre text" -- so those pass no label and keep the one // line they had. -function GuiEditorToggleIcon::buildTip(%this, %on) +function EditorToggleIcon::buildTip(%this, %on) { %tip = %on ? %this.tipOn : %this.tipOff; @@ -163,12 +163,12 @@ } // setActive does not repaint on its own, and the disabled tint is ours to draw. -function GuiEditorToggleIcon::onActive(%this) +function EditorToggleIcon::onActive(%this) { %this.refresh(); } -function GuiEditorToggleIcon::onInactive(%this) +function EditorToggleIcon::onInactive(%this) { %this.refresh(); } diff --git a/editor/GuiEditor/GuiEditor.cs b/editor/GuiEditor/GuiEditor.cs index 4ee717b15..b25eeb902 100644 --- a/editor/GuiEditor/GuiEditor.cs +++ b/editor/GuiEditor/GuiEditor.cs @@ -55,8 +55,6 @@ // The properties pane that replaced the native GuiInspector. exec("./scripts/GuiEditorControlSpec.cs"); - exec("./scripts/GuiEditorToggleIcon.cs"); - exec("./scripts/GuiEditorChoiceRow.cs"); exec("./scripts/GuiEditorAnchorPicker.cs"); exec("./scripts/GuiEditorTextBlock.cs"); exec("./scripts/GuiEditorMenuItemBlock.cs"); diff --git a/editor/GuiEditor/scripts/GuiEditorAnchorPicker.cs b/editor/GuiEditor/scripts/GuiEditorAnchorPicker.cs index f51b590a7..d5f4dba30 100644 --- a/editor/GuiEditor/scripts/GuiEditorAnchorPicker.cs +++ b/editor/GuiEditor/scripts/GuiEditorAnchorPicker.cs @@ -104,7 +104,7 @@ { %pin = new GuiCheckBoxCtrl() { - class = "GuiEditorToggleIcon"; + class = "EditorToggleIcon"; Position = %x SPC %y; Extent = "24 24"; frameOff = %frame; diff --git a/editor/GuiEditor/scripts/GuiEditorControlGroup.cs b/editor/GuiEditor/scripts/GuiEditorControlGroup.cs index 6bbcfa08a..ee123f508 100644 --- a/editor/GuiEditor/scripts/GuiEditorControlGroup.cs +++ b/editor/GuiEditor/scripts/GuiEditorControlGroup.cs @@ -3,7 +3,7 @@ // on -- holding a grid of GuiEditorControlTiles. // // The same shape the Asset Manager uses for its asset dictionaries -// (AssetAdmin::buildDictionary, AssetDictionary.cs): a GuiPanelCtrl whose header +// (AssetLibraryWindow::addDictionary, AssetDictionary.cs): a GuiPanelCtrl whose header // is the toggle, with the real content in a grid inside it. // // The tiles go in that inner grid and never directly on the panel. diff --git a/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs b/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs index a9a3bab38..f5fe22c32 100644 --- a/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs +++ b/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs @@ -111,7 +111,7 @@ } //----------------------------------------------------------------------------- -// The view switch. GuiEditorChoiceRow is already "a radio group that looks like +// The view switch. EditorChoiceRow is already "a radio group that looks like // a segmented control" -- a row of toggle buttons of which exactly one is down, // which is exactly this. It carries a caption by default; two icons say enough // on their own, so the label width goes to nothing. @@ -121,7 +121,7 @@ { %this.modeRow = new GuiControl() { - class = "GuiEditorChoiceRow"; + class = "EditorChoiceRow"; HorizSizing = "width"; Position = "4 2"; labelText = ""; diff --git a/editor/GuiEditor/scripts/GuiEditorHeaderBlock.cs b/editor/GuiEditor/scripts/GuiEditorHeaderBlock.cs index 61d43621e..244ebee15 100644 --- a/editor/GuiEditor/scripts/GuiEditorHeaderBlock.cs +++ b/editor/GuiEditor/scripts/GuiEditorHeaderBlock.cs @@ -136,7 +136,7 @@ { %button = new GuiCheckBoxCtrl() { - class = "GuiEditorToggleIcon"; + class = "EditorToggleIcon"; Position = %x SPC 2; Extent = "24 24"; frameOn = %frameOn; diff --git a/editor/GuiEditor/scripts/GuiEditorItemRow.cs b/editor/GuiEditor/scripts/GuiEditorItemRow.cs index 5dc24f08c..d9c66cd05 100644 --- a/editor/GuiEditor/scripts/GuiEditorItemRow.cs +++ b/editor/GuiEditor/scripts/GuiEditorItemRow.cs @@ -153,14 +153,14 @@ class = "GuiProfileEditorColorPopup"; "Remove this row.", ".onRemoveClicked();"); } -// A checkbox wearing an icon, the same GuiEditorToggleIcon the header's flags +// A checkbox wearing an icon, the same EditorToggleIcon the header's flags // use. frameOn is optional: where there is one picture for the idea, the tint // alone carries the state. function GuiEditorItemRow::makeToggle(%this, %x, %name, %label, %frameOn, %frameOff, %tipOn, %tipOff) { %toggle = new GuiCheckBoxCtrl() { - class = "GuiEditorToggleIcon"; + class = "EditorToggleIcon"; HorizSizing = "left"; Position = %x SPC 1; Extent = "24 24"; diff --git a/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs b/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs index 6ed54aae6..a134d24f3 100644 --- a/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs +++ b/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs @@ -106,7 +106,7 @@ { %row = new GuiControl() { - class = "GuiEditorChoiceRow"; + class = "EditorChoiceRow"; labelText = "Kind"; labelWidth = 76; fieldName = "kind"; diff --git a/editor/GuiEditor/scripts/GuiEditorTextBlock.cs b/editor/GuiEditor/scripts/GuiEditorTextBlock.cs index 799e3a973..da087a536 100644 --- a/editor/GuiEditor/scripts/GuiEditorTextBlock.cs +++ b/editor/GuiEditor/scripts/GuiEditorTextBlock.cs @@ -104,7 +104,7 @@ { %button = new GuiCheckBoxCtrl() { - class = "GuiEditorToggleIcon"; + class = "EditorToggleIcon"; HorizSizing = "left"; Position = %x SPC 1; Extent = %size SPC %size; @@ -229,7 +229,7 @@ class = "GuiEditorToggleIcon"; { %row = new GuiControl() { - class = "GuiEditorChoiceRow"; + class = "EditorChoiceRow"; Position = "0 0"; labelText = %label; labelWidth = 24; diff --git a/tests/shots/assetLibrary.cs b/tests/shots/assetLibrary.cs new file mode 100644 index 000000000..09b4467b5 --- /dev/null +++ b/tests/shots/assetLibrary.cs @@ -0,0 +1,113 @@ +// Visual harness for the Asset Library. Three shots: +// +// 0 tile mode -- a thumbnail over its name, the default view +// 1 row mode -- a small thumbnail with the name beside it +// 2 tile mode, filtered so one group empties and another does not +// +// The toolbar is the part that only a picture can settle: the filter icon, the +// search box and the count line share a row, and the two segmented rows share +// the one above it. None of that is checkable by assertion beyond "the numbers +// are what I wrote". +// +// Run: tests/run.ps1 -Shots assetLibrary ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "aOpenProject"); + +function aOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + // Switching the view below writes a preference, and unredirected that lands + // in the real per-user folder -- so running the harness would change how the + // editor opens for the person who ran it. + createPath(testRoot("shots/")); + EditorPreferences.path = testRoot("shots/assetLibraryShotPrefs.taml"); + + // A project's own assets belong to modules the editor has only scanned, so the + // library opens empty and every shot below would show five headers and nothing + // else. ToyAssets is nothing but assets -- no ScriptFile, no CreateFunction -- + // so registering what it declares fills the library without running toy code. + ModuleDatabase.scanModules(testRoot("toybox/ToyAssets")); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + + schedule(2500, 0, "aOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function aOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "aOpenGroups"); +} + +// Every group starts collapsed -- GuiExpandCtrl's constructor leaves mExpanded +// false and the library has always opened that way -- so a shot taken as-is is +// five headers and nothing else. Open the two that carry real art. +function aOpenGroups() +{ + AssetAdmin.Dictionary["ImageAsset"].setExpanded(true); + AssetAdmin.Dictionary["AnimationAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + schedule(1200, 0, "aTileShot"); +} + +function aGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging, so a + // tree that has never run a shot writes nothing and says nothing. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/assetLibrary" @ %name @ ".png"), "PNG"); +} + +function aTileShot() +{ + aGrab(0); + + AssetAdmin.libWindow.setViewMode("rows"); + AssetAdmin.libWindow.viewRow.setValue("rows"); + schedule(800, 0, "aRowShot"); +} + +function aRowShot() +{ + aGrab(1); + + AssetAdmin.libWindow.setViewMode("grid"); + AssetAdmin.libWindow.viewRow.setValue("grid"); + schedule(800, 0, "aFilterShot"); +} + +// A needle that leaves images behind and empties the sound and font groups, so +// the shot shows both halves of the rule: a group that matched keeps its tiles, +// a group that did not keeps its header and reads (0). +function aFilterShot() +{ + AssetAdmin.libWindow.searchBox.setText("a"); + AssetAdmin.libWindow.applyFilter(); + schedule(800, 0, "aFinish"); +} + +function aFinish() +{ + aGrab(2); + + AssetAdmin.libWindow.searchBox.setText(""); + AssetAdmin.libWindow.applyFilter(); + + echo("SHOTS DONE"); + schedule(500, 0, "quit"); +} diff --git a/tests/smoke/assetLibrary.cs b/tests/smoke/assetLibrary.cs new file mode 100644 index 000000000..fba546900 --- /dev/null +++ b/tests/smoke/assetLibrary.cs @@ -0,0 +1,547 @@ +// Asset Library smoke test. Drives the Asset Manager's right-hand library +// through script: the pinned toolbar, the tiles/rows view switch, the live +// search across all five groups (name, description and category), the per-group +// header counts, sorting by name and by category, and the preference round trip. +// Run: tests/run.ps1 assetLibrary ; grep ALIB in tests/logs/. +// +// Driven by calling the library rather than by posting input. A tile's position +// on screen depends on the scroll offset, which groups are open and how many +// columns the grid chose, none of which script can read -- so a click at a +// computed point would be testing the arithmetic in this file. +// +// NOTE: EditorPreferences writes to the tester's real per-user application data +// folder, which nothing in tests/run.ps1 cleans up. Step 1 redirects it into +// shots/ for the duration so a test run cannot change how the editor opens +// afterwards. It cannot redirect the READ -- the library is built during +// AssetAdmin::create, long before this file gets a turn -- which is why the +// order checks pin the sort field themselves rather than assume one. +// +// NOTE: the fixture copy this makes is about 30 MB and is left behind; there is +// no deleteDirectory binding in script to tidy it. tests/run.ps1 deletes every +// *SmokeProject folder before each run, so it never accumulates. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function alCheck(%label, %cond) +{ + if(%cond) echo("ALIB PASS: " @ %label); + else echo("ALIB FAIL: " @ %label); +} + +// A bare project has no assets but the editor's own, which are all marked +// AssetInternal and rightly skipped. ToyAssets is nothing but assets -- no +// ScriptFile, no CreateFunction -- so registering what it declares gives the +// library real images and animations without running a line of toy code. +// +// A COPY of it, though, never the module itself. Step 5 edits an asset's +// description and category, and AssetManager::refreshAsset writes the asset +// straight back to its own file the moment a field changes (assetManager.cc) -- +// so aimed at toybox/ToyAssets this test rewrites tracked repository content, +// and rewrites it in Taml's own idiom rather than the way it was authored. The +// copy goes inside the throwaway project folder, which tests/run.ps1 deletes +// before every run. +function alLoadFixtureAssets() +{ + %copy = testRoot("assetLibrarySmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +function alVisibleCount(%dictionary) +{ + %shown = 0; + for(%i = 0; %i < %dictionary.grid.getCount(); %i++) + { + if(%dictionary.grid.getObject(%i).isVisible()) + { + %shown++; + } + } + return %shown; +} + +// Deliberately does not call AssetDictionary::sortsAfter -- comparing the order +// with the function that produced it would agree with any bug in it. +// +// Names the pair it tripped on. "the list is not sorted" sends you back to the +// engine with 90 tiles to read; "brick_05 before brick_04 at 7" does not. +// Run the check, then build the label from what it found -- the label cannot +// read $alSortFailure in the same expression that sets it. +function alSortedCheck(%label, %dictionary, %field) +{ + %ok = alSortedOk(%dictionary, %field); + alCheck(%label SPC $alSortFailure, %ok); + + if(!%ok) + { + %count = %dictionary.grid.getCount(); + %start = %count - 6; + if(%start < 0) + { + %start = 0; + } + for(%i = %start; %i < %count; %i++) + { + %tile = %dictionary.grid.getObject(%i); + echo("ALIB TAIL " @ %i @ ": name='" @ %tile.assetName @ + "' sortName='" @ %tile.sortName @ "' key='" @ %tile.searchKey @ "'"); + } + } +} + +function alSortedOk(%dictionary, %field) +{ + $alSortFailure = ""; + + for(%i = 1; %i < %dictionary.grid.getCount(); %i++) + { + %prev = %dictionary.grid.getObject(%i - 1); + %next = %dictionary.grid.getObject(%i); + %wrong = false; + + if(%field $= "category") + { + %order = stricmp(%prev.sortCategory, %next.sortCategory); + %wrong = (%order > 0) || + (%order == 0 && stricmp(%prev.sortName, %next.sortName) > 0); + } + else + { + %wrong = stricmp(%prev.sortName, %next.sortName) > 0; + } + + if(%wrong) + { + $alSortFailure = "at" SPC %i @ ":" SPC %prev.assetName SPC "[" @ + %prev.sortCategory @ "] before" SPC %next.assetName SPC "[" @ + %next.sortCategory @ "]"; + return false; + } + } + return true; +} + +// Every asset id in the group, so a reorder that loses or duplicates one is +// caught. A monotonic list can still be the wrong list. +function alAssetIdSet(%dictionary) +{ + %ids = ""; + for(%i = 0; %i < %dictionary.grid.getCount(); %i++) + { + %ids = %ids TAB %dictionary.grid.getObject(%i).assetID; + } + return %ids; +} + +function alHoldsEvery(%dictionary, %ids) +{ + for(%i = 0; %i < getFieldCount(%ids); %i++) + { + %id = getField(%ids, %i); + if(%id $= "") + { + continue; + } + if(!isObject(%dictionary.getButton(%id))) + { + return false; + } + } + return true; +} + +function alSearch(%needle) +{ + $alWindow.searchBox.setText(%needle); + $alWindow.applyFilter(); +} + +testExec("editor/main.cs"); +schedule(2000, 0, "alStep1"); + +//----------------------------------------------------------------------------- +// Opening the library. +//----------------------------------------------------------------------------- + +function alStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable, here and in the copy path + // above: tests/run.ps1 finds the folder to delete by reading this file for + // setProjectFolder("..."), so a name it cannot see is a folder it cannot + // sweep. + ProjectManager.setProjectFolder("assetLibrarySmokeProject"); + + // Before anything can toggle a view and write one. + EditorPreferences.path = testRoot("shots/assetLibrarySmokePrefs.taml"); + + alCheck("fixture asset module registered", alLoadFixtureAssets()); + + // Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, + // GuiEditor. Selecting the tab is what calls AssetAdmin::open. + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "alStep2"); +} + +//----------------------------------------------------------------------------- +// Structure. +//----------------------------------------------------------------------------- + +function alStep2() +{ + $alWindow = AssetAdmin.libWindow; + alCheck("library window exists", isObject($alWindow)); + alCheck("library window is an AssetLibraryWindow", $alWindow.class $= "AssetLibraryWindow"); + + alCheck("toolbar built", isObject($alWindow.toolbar)); + alCheck("search box built", isObject($alWindow.searchBox)); + alCheck("count label built", isObject($alWindow.countLabel)); + alCheck("view row offers two choices", $alWindow.viewRow.choiceCount == 2); + alCheck("sort row offers two choices", $alWindow.sortRow.choiceCount == 2); + alCheck("search box fires per keystroke", + strstr($alWindow.searchBox.Command, "onSearchChanged") != -1); + + // A glyph rather than the word "Search", which was clipped in every theme. + alCheck("search icon built", isObject($alWindow.searchIcon)); + alCheck("search icon is the filter glyph", + $alWindow.searchIcon.Frame == $EditorIcon::filter); + alCheck("search box starts clear of the icon", + getWord($alWindow.searchBox.getPosition(), 0) + >= getWord($alWindow.searchIcon.getPosition(), 0) + $AssetLibraryWindow::iconSize); + alCheck("search box has room left", getWord($alWindow.searchBox.getExtent(), 0) > 0); + + // The tint is a colour COPIED onto the sprite, so swapping the theme's profile + // underneath would leave it behind. Prove the re-read happens by wrecking it. + $alWindow.searchIcon.setImageColor("255 0 255 255"); + %wrongTint = $alWindow.searchIcon.imageColor; + $alWindow.onThemeChange(ThemeManager.activeTheme); + alCheck("a theme change re-tints the search icon", + $alWindow.searchIcon.imageColor !$= %wrongTint); + + alCheck("five asset groups", $alWindow.dictionaryCount == 5); + alCheck("the dialogs can still find a group by type", + AssetAdmin.Dictionary["ImageAsset"] == $alWindow.dictionary[0]); + + // The whole point of the pinned bar: the scroller starts where the toolbar + // ends, and nothing was clipped off the bottom doing it. + %barBottom = getWord($alWindow.toolbar.getPosition(), 1) + + getWord($alWindow.toolbar.getExtent(), 1); + %scrollTop = getWord($alWindow.scroller.getPosition(), 1); + alCheck("scroller starts below the toolbar (" @ %scrollTop SPC %barBottom @ ")", + %scrollTop == %barBottom); + alCheck("scroller has height left", getWord($alWindow.scroller.getExtent(), 1) > 0); + + $alImages = AssetAdmin.Dictionary["ImageAsset"]; + $alAudio = AssetAdmin.Dictionary["AudioAsset"]; + $alImageCount = $alImages.getButtonCount(); + + alCheck("the fixture gave the library image assets (" @ $alImageCount @ ")", + $alImageCount > 1); + + // With an empty database every count below compares nought to nought and + // passes without testing anything, which reads green while proving nothing. + if($alImageCount < 2) + { + echo("ALIB ABORT: too few image assets, the rest of the run would prove nothing"); + schedule(300, 0, "quit"); + return; + } + + schedule(300, 0, "alStep3"); +} + +//----------------------------------------------------------------------------- +// Header counts and the default order. +//----------------------------------------------------------------------------- + +function alStep3() +{ + alCheck("group header carries its count", + $alImages.getText() $= ("Images (" @ $alImageCount @ ")")); + alCheck("group header keeps its title", strstr($alImages.getText(), "Images") == 0); + + // findAllAssets returns hash order. Sorting on load is what makes the library + // open the same way twice. + // + // Pin the field and reload rather than assume the library opened on "name": + // the sort field is a saved preference, and EditorPreferences has already + // read the real per-user file by the time this test can redirect it -- the + // window is built during AssetAdmin::create, long before alStep1 runs. So + // what the library opened on is whatever the machine last chose, and a test + // that assumed "name" passed or failed on ambient state rather than on the + // code. reload() is unload() + load(), the same path AssetAdmin::open takes. + $alWindow.setSortField("name"); + $alImages.reload(); + alSortedCheck("images are in name order on load", $alImages, "name"); + + $alWindow.setSortField("category"); + $alImages.reload(); + alSortedCheck("images are in category order on load", $alImages, "category"); + + $alWindow.setSortField("name"); + $alImages.reload(); + alCheck("every tile visible before filtering", + alVisibleCount($alImages) == $alImageCount); + + %total = 0; + for(%i = 0; %i < $alWindow.dictionaryCount; %i++) + { + %total += $alWindow.dictionary[%i].getButtonCount(); + } + alCheck("count line totals the whole library", + $alWindow.countLabel.getText() $= (%total @ "/" @ %total)); + $alTotal = %total; + + // Every tile has a caption and a picture in both modes. + %tile = $alImages.grid.getObject(0); + alCheck("a tile has a caption", isObject(%tile.caption)); + alCheck("the caption names the asset", %tile.caption.getText() $= %tile.assetName); + alCheck("a tile has a picture", isObject(%tile.icon)); + alCheck("the search key is lowercased", %tile.searchKey $= strlwr(%tile.searchKey)); + alCheck("the search key has no stray padding", %tile.searchKey $= trim(%tile.searchKey)); + + schedule(300, 0, "alStep4"); +} + +//----------------------------------------------------------------------------- +// Searching by name. +//----------------------------------------------------------------------------- + +function alStep4() +{ + // Taken from the data rather than hardcoded, so this does not go stale when + // the toy assets change. + $alName = $alImages.grid.getObject(0).assetName; + + alSearch($alName); + + %shown = alVisibleCount($alImages); + alCheck("searching a name shows at least that asset (" @ $alName @ ")", %shown >= 1); + alCheck("searching a name hides the rest", %shown < $alImageCount); + alCheck("header follows the filter", + $alImages.getText() $= ("Images (" @ %shown @ ")")); + + %needle = strlwr($alName); + %everyMatch = true; + for(%i = 0; %i < $alImages.grid.getCount(); %i++) + { + %tile = $alImages.grid.getObject(%i); + %hit = (strstr(%tile.searchKey, %needle) != -1); + if(%tile.isVisible() != %hit) + { + %everyMatch = false; + } + } + alCheck("exactly the matching tiles are visible", %everyMatch); + + // $= is case-insensitive but strstr is not, which is why both sides are + // lowercased. An upper-case needle proves it. + alSearch(strupr($alName)); + alCheck("matching ignores case", alVisibleCount($alImages) == %shown); + + // A needle nothing matches: every group keeps its header rather than + // disappearing, so the shape of the library does not change under the person + // typing. + alSearch("zzqqxx"); + alCheck("no matches anywhere", alVisibleCount($alImages) == 0); + alCheck("an emptied group stays visible", $alImages.isVisible()); + alCheck("an emptied group says so", $alImages.getText() $= "Images (0)"); + alCheck("an untouched group is emptied too", $alAudio.getText() $= "Audio (0)"); + alCheck("count line reports nothing found", + $alWindow.countLabel.getText() $= ("0/" @ $alTotal)); + + alSearch(""); + alCheck("clearing the box restores every tile", + alVisibleCount($alImages) == $alImageCount); + alCheck("count line restored", + $alWindow.countLabel.getText() $= ($alTotal @ "/" @ $alTotal)); + + schedule(300, 0, "alStep5"); +} + +//----------------------------------------------------------------------------- +// Searching by description and by category. +// +// Both are set through the asset itself, which is the production path: the +// inspector edits the same fields, AssetBase::setAssetDescription calls +// refreshAsset, and that fires onRefresh -- which is what re-keys the tile. +// refreshAsset is in-memory only, so nothing on disk is touched. +//----------------------------------------------------------------------------- + +function alStep5() +{ + %tile = $alImages.grid.getObject(0); + $alSubjectID = %tile.assetID; + + %asset = AssetDatabase.acquireAsset($alSubjectID); + %asset.AssetDescription = "zzdescription"; + %asset.AssetCategory = "zzcategory"; + + alCheck("the tile re-read its description", + strstr(%tile.searchKey, "zzdescription") != -1); + alCheck("the tile re-read its category", %tile.sortCategory $= "zzcategory"); + + alSearch("zzdescription"); + alCheck("a description-only match is found", alVisibleCount($alImages) == 1); + alCheck("and it is the right one", $alImages.grid.getObject(0).isVisible() + || $alImages.getButton($alSubjectID).isVisible()); + + alSearch("zzcateg"); + alCheck("a partial category match is found", alVisibleCount($alImages) == 1); + + alSearch(""); + AssetDatabase.releaseAsset($alSubjectID); + + schedule(300, 0, "alStep6"); +} + +//----------------------------------------------------------------------------- +// Sorting. +//----------------------------------------------------------------------------- + +function alStep6() +{ + $alIds = alAssetIdSet($alImages); + + $alWindow.setSortField("category"); + alCheck("sort field taken", $alWindow.sortField $= "category"); + alSortedCheck("images are in category order", $alImages, "category"); + alCheck("category sort kept every tile", + $alImages.grid.getCount() == $alImageCount); + alCheck("category sort lost none of them", alHoldsEvery($alImages, $alIds)); + // The fixture's own categories are all "sprites", and step 5 gave one asset + // "zzcategory" -- so category order has to end with that one, and a sort that + // quietly ignored its field would leave it wherever the name order put it. + alCheck("the highest category sorts to the end", + $alImages.grid.getObject($alImageCount - 1).assetID $= $alSubjectID); + + $alWindow.setSortField("name"); + alSortedCheck("images are back in name order", $alImages, "name"); + alCheck("name sort kept every tile", $alImages.grid.getCount() == $alImageCount); + alCheck("name sort lost none of them", alHoldsEvery($alImages, $alIds)); + + // Order has to survive a filter: the hidden tiles are reordered too, so + // clearing the box must not reveal an unsorted list. + alSearch($alName); + $alWindow.setSortField("category"); + alSearch(""); + alSortedCheck("sorting while filtered still sorted the hidden tiles", + $alImages, "category"); + $alWindow.setSortField("name"); + + schedule(300, 0, "alStep7"); +} + +//----------------------------------------------------------------------------- +// View mode. +//----------------------------------------------------------------------------- + +function alStep7() +{ + %tile = $alImages.grid.getObject(0); + + alCheck("starts in grid mode", $alWindow.viewMode $= "grid"); + alCheck("grid tile draws the art at tile size", + getWord(%tile.icon.getExtent(), 0) == $AssetDictionaryButton::gridArt); + alCheck("grid caption is centred", %tile.caption.align $= "center"); + alCheck("grid caption sits under the art", %tile.caption.vAlign $= "bottom"); + alCheck("grid caption wraps", %tile.caption.textWrap); + + $alWindow.setViewMode("rows"); + + alCheck("mode taken", $alWindow.viewMode $= "rows"); + alCheck("groups were told", $alImages.viewMode $= "rows"); + alCheck("row tile draws the art small", + getWord(%tile.icon.getExtent(), 0) == $AssetDictionaryButton::rowArt); + alCheck("row caption is left aligned", %tile.caption.align $= "left"); + alCheck("row caption is centred vertically", %tile.caption.vAlign $= "middle"); + alCheck("row caption does not wrap", !%tile.caption.textWrap); + alCheck("row art is left of the caption", + getWord(%tile.icon.getPosition(), 0) < getWord(%tile.caption.getPosition(), 0)); + + // One column: every tile shares an x, and the rows are the row height. + %x = getWord($alImages.grid.getObject(0).getPosition(), 0); + %oneColumn = true; + for(%i = 0; %i < $alImages.grid.getCount(); %i++) + { + if(getWord($alImages.grid.getObject(%i).getPosition(), 0) != %x) + { + %oneColumn = false; + } + } + alCheck("rows mode is one column", %oneColumn); + alCheck("rows mode uses the row height", + getWord(%tile.getExtent(), 1) == $AssetDictionary::rowHeight); + alCheck("a row is wider than a tile", + getWord(%tile.getExtent(), 0) > $AssetDictionary::gridCell); + + // The picture is the same picture -- nothing was rebuilt. + alCheck("the same tile object survived the switch", + $alImages.grid.getObject(0).assetID $= %tile.assetID); + + // Filtering still works in rows mode. + alSearch("zzqqxx"); + alCheck("rows mode filters too", alVisibleCount($alImages) == 0); + alSearch(""); + + $alWindow.setViewMode("grid"); + alCheck("back to grid art size", + getWord(%tile.icon.getExtent(), 0) == $AssetDictionaryButton::gridArt); + + schedule(300, 0, "alStep8"); +} + +//----------------------------------------------------------------------------- +// Preferences. +//----------------------------------------------------------------------------- + +function alStep8() +{ + $alWindow.setViewMode("rows"); + $alWindow.setSortField("category"); + + alCheck("view mode remembered", + EditorPreferences.get("assetLibraryViewMode", "grid") $= "rows"); + alCheck("sort field remembered", + EditorPreferences.get("assetLibrarySortField", "name") $= "category"); + + // The file is the point: an in-memory field would survive nothing. + alCheck("a preferences file was written", + EditorPreferences.fileExists(EditorPreferences.path)); + + // And what is in it is what the next session would read. + %stored = TamlRead(EditorPreferences.path); + alCheck("the file parses back", isObject(%stored)); + alCheck("view mode is in the file", %stored.assetLibraryViewMode $= "rows"); + alCheck("sort field is in the file", %stored.assetLibrarySortField $= "category"); + + // Saved as a plain ScriptObject on purpose: writing the preferences object + // itself would record class="EditorPreferences", and reading it back would + // build a second one that loads the file again. + alCheck("the file does not rebuild a preferences object", + %stored.class $= ""); + %stored.delete(); + + $alWindow.setViewMode("grid"); + $alWindow.setSortField("name"); + + echo("ALIB DONE"); + schedule(300, 0, "quit"); +} From 27c748aa8b79b309a812fcd02a614656b18e160d Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Sat, 8 Aug 2026 17:18:59 -0400 Subject: [PATCH 02/26] A word too long to wrap left an empty line under it GuiControl::getLineList pushes the line it has been building at the end of every paragraph. When the last word was too wide to fit, that word had already been emitted as a line of its own and the buffer left empty -- so the push added a second, empty line, and a caption of one unbreakable word came out two lines tall. It reads as a bottom-alignment bug, because that is where it shows: the empty line takes the bottom slot and the word climbs out of it. It is not. The line count becomes blockHeight, which is what getTextVerticalOffset positions from, what mTextExtend sizes a control from, and what renderText compares against the room available to decide the text does not fit at all. A whole line of movement under BottomVAlign, half a line under MiddleVAlign, none under TopVAlign, and the wrong answer about fitting under all three. The push is now guarded on the buffer having something in it -- or on the paragraph having produced no lines at all, which is what keeps an empty paragraph yielding the one empty line that blank lines and an empty text box's caret both depend on. The word-fitting half comes out as GuiControl::wrapParagraph, taking a width-measuring callback instead of a GFont so that it can be tested with no GL context: asking a profile for a font registers a texture, and TextureManager asserts without one -- which in a debug build is a modal box, so the failure would arrive as a hang. getTextVerticalOffset is public and static for the same reason. Eight tests in guiTextWrapTests.cc cover the ordinary wrapping, the unbreakable word first, last and doubled, the empty paragraph, and which alignments the stray line moved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- cmake/EngineSources.cmake | 1 + engine/source/gui/guiControl.cc | 115 +++++++---- engine/source/gui/guiControl.h | 20 +- .../source/testing/tests/guiTextWrapTests.cc | 185 ++++++++++++++++++ 4 files changed, 278 insertions(+), 43 deletions(-) create mode 100644 engine/source/testing/tests/guiTextWrapTests.cc diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index 8b7181c48..df89124f0 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -345,6 +345,7 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/testing/tests/guiProfileThemeTests.cc ${TORQUE_SRC}/testing/tests/guiScrollLayoutTests.cc ${TORQUE_SRC}/testing/tests/guiTextEditTests.cc + ${TORQUE_SRC}/testing/tests/guiTextWrapTests.cc ${TORQUE_SRC}/testing/tests/guiTreeRowLayoutTests.cc ${TORQUE_SRC}/testing/tests/namespaceLinkTests.cc ${TORQUE_SRC}/testing/tests/platformFileIoTests.cc diff --git a/engine/source/gui/guiControl.cc b/engine/source/gui/guiControl.cc index e521e13b0..79b8991c7 100755 --- a/engine/source/gui/guiControl.cc +++ b/engine/source/gui/guiControl.cc @@ -2575,6 +2575,77 @@ vector GuiControl::splitParagraphs(const char* text) return paragraphList; } +// The measurer getLineList hands to wrapParagraph: the real font. +static U32 fontStrWidth(void* context, const char* text) +{ + return static_cast(context)->getStrWidth(text); +} + +vector GuiControl::wrapParagraph(const string& paragraph, S32 totalWidth, TextWidthFn measure, void* context) +{ + vector lineList = vector(); + + vector wordList = vector(); + istringstream f2(paragraph); + string s2; + while (getline(f2, s2, ' ')) { + wordList.push_back(s2); + } + + //now process the word list + string line; + bool newLine = true; + line.clear(); + for (string& word : wordList) + { + if (measure(context, word.c_str()) >= totalWidth) + { + if (line.size() > 0) + { + lineList.push_back(string(line + " ")); + line.clear(); + } + lineList.push_back(word + " "); + newLine = true; + continue; + } + + string prevLine = string(line); + line += (!newLine) ? " " + word : word; + newLine = false; + if (measure(context, line.c_str()) >= totalWidth && word.length() != 0) + { + lineList.push_back(prevLine + " "); + line = word; + } + } + // back() on an empty string is undefined behaviour, and an empty + // paragraph is ordinary now: it is what a blank line between two + // others is made of. + if (!paragraph.empty() && paragraph.back() == ' ') + { + line += " "; + } + + // A word too long to fit is pushed as a line of its own and leaves nothing + // behind it, so pushing again here would put an empty line after it -- and + // that line is not free. It counts into blockHeight, which is what both + // vertical alignment and mTextExtend are measured from, so a one-word + // caption came out a whole line high under BottomVAlign, half a line high + // under MiddleVAlign, and pushed a control that fitted into the "too tall to + // fit" branch of renderText. + // + // An empty paragraph must still produce its one empty line: that is what a + // blank line between two others is made of, and what gives an empty + // multi-line box somewhere to put its caret. + if (!line.empty() || lineList.empty()) + { + lineList.push_back(string(line)); + } + + return lineList; +} + vector GuiControl::getLineList(const char* text, GuiControlProfile* profile, S32 totalWidth) { GFont* font = profile->getFont(mFontSizeAdjust); @@ -2590,48 +2661,8 @@ vector GuiControl::getLineList(const char* text, GuiControlProfile* prof for (string& paragraph : paragraphList) { - vector wordList = vector(); - istringstream f2(paragraph); - string s2; - while (getline(f2, s2, ' ')) { - wordList.push_back(s2); - } - - //now process the word list - string line; - bool newLine = true; - line.clear(); - for (string& word : wordList) - { - if (font->getStrWidth(word.c_str()) >= totalWidth) - { - if (line.size() > 0) - { - lineList.push_back(string(line + " ")); - line.clear(); - } - lineList.push_back(word + " "); - newLine = true; - continue; - } - - string prevLine = string(line); - line += (!newLine) ? " " + word : word; - newLine = false; - if (font->getStrWidth(line.c_str()) >= totalWidth && word.length() != 0) - { - lineList.push_back(prevLine + " "); - line = word; - } - } - // back() on an empty string is undefined behaviour, and an empty - // paragraph is ordinary now: it is what a blank line between two - // others is made of. - if (!paragraph.empty() && paragraph.back() == ' ') - { - line += " "; - } - lineList.push_back(string(line)); + vector paragraphLines = wrapParagraph(paragraph, totalWidth, &fontStrWidth, font); + lineList.insert(lineList.end(), paragraphLines.begin(), paragraphLines.end()); } } diff --git a/engine/source/gui/guiControl.h b/engine/source/gui/guiControl.h index c6f26e99d..c78e0bab7 100755 --- a/engine/source/gui/guiControl.h +++ b/engine/source/gui/guiControl.h @@ -895,6 +895,18 @@ class GuiControl : public SimGroup, public virtual Tickable /// getLineList needs a font; this half does not, which is what lets it be /// tested on its own. static vector splitParagraphs(const char* text); + + /// How wide a string is, asked of whatever knows: a GFont at render time, a + /// stand-in in a unit test. wrapParagraph takes one of these rather than a + /// GFont so that it can be tested with no GL context -- asking a profile for + /// a font registers a texture, and TextureManager::refresh asserts without + /// one, which in a debug build is a modal box and so arrives as a hang. + typedef U32 (*TextWidthFn)(void* context, const char* text); + + /// Breaks one paragraph into the lines it is drawn as. The word-fitting half + /// of getLineList, separated from the font for the same reason as above. + static vector wrapParagraph(const string& paragraph, S32 totalWidth, TextWidthFn measure, void* context); + virtual vector getLineList(const char* text, GuiControlProfile* profile, S32 totalWidth); virtual void renderTextLine(const Point2I& startPoint, const string line, GuiControlProfile* profile, F32 rotationInDegrees, U32 ibeamPosAtLineStart, U32 lineNumber); @@ -935,6 +947,13 @@ class GuiControl : public SimGroup, public virtual Tickable virtual void setDataField(StringTableEntry slotName, const char* array, const char* value); + /// Where a block of text starts, given how tall it is and how tall the room + /// is. Static and public because it reads nothing off the control, and + /// because it is the step that turns a line count into a position -- which is + /// how one stray line in the line list becomes a caption drawn a line too + /// high. Tested beside wrapParagraph for exactly that reason. + static S32 getTextVerticalOffset(S32 textHeight, S32 totalHeight, VertAlignmentType align); + protected: bool mPreviouslyAwake; virtual void interpolateTick(F32 delta) {}; @@ -942,7 +961,6 @@ class GuiControl : public SimGroup, public virtual Tickable virtual void advanceTime(F32 timeDelta) {}; S32 getTextHorizontalOffset(S32 textWidth, S32 totalWidth, AlignmentType align); - S32 getTextVerticalOffset(S32 textHeight, S32 totalHeight, VertAlignmentType align); AlignmentType getAlignmentType(); VertAlignmentType getVertAlignmentType(); AlignmentType getAlignmentType(GuiControlProfile* profile); diff --git a/engine/source/testing/tests/guiTextWrapTests.cc b/engine/source/testing/tests/guiTextWrapTests.cc new file mode 100644 index 000000000..29074ba80 --- /dev/null +++ b/engine/source/testing/tests/guiTextWrapTests.cc @@ -0,0 +1,185 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +#ifndef _GUICONTROL_H_ +#include "gui/guiControl.h" +#endif + +#ifndef _GUITYPES_H_ +#include "gui/guiTypes.h" +#endif + +//----------------------------------------------------------------------------- +// Where a wrapped control breaks its lines. +// +// The case these exist for: a single word too long to fit, with no space to +// break at. The word-fitting loop pushes such a word as a line of its own and +// clears the line it was building, and the push at the foot of the loop then +// added a SECOND, empty line after it -- so a one-word caption was two lines +// tall. +// +// That was never only a cosmetic problem, and it was never only a bottom-aligned +// one. The line count becomes blockHeight, and blockHeight is what +// getTextVerticalOffset positions from, what mTextExtend sizes a control from, +// and what renderText compares against the room available to decide whether the +// text fits at all. Bottom alignment is simply where it showed: the empty line +// took the bottom slot and pushed the word up out of it. The last test here is +// that reasoning written down. +// +// wrapParagraph takes a measuring function rather than a GFont so that all of +// this can be checked with no canvas: asking a profile for a font registers a +// texture, and TextureManager::refresh asserts without a GL context -- which in +// a debug build is a modal box, so the failure arrives as a hang. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +// A stand-in for a font: every character is ten pixels wide, spaces included. +// Real fonts are proportional, but nothing here is about the widths themselves, +// only about which words land on which line -- and a width that can be worked +// out in the head is what makes the numbers in these tests readable. +static U32 tenPerChar(void* context, const char* text) +{ + TORQUE_UNUSED(context); + return (U32)(dStrlen(text) * 10); +} + +static vector wrap(const char* paragraph, const S32 totalWidth) +{ + return GuiControl::wrapParagraph(string(paragraph), totalWidth, &tenPerChar, NULL); +} + +//----------------------------------------------------------------------------- +// The ordinary cases, so the fix below is known not to have cost them. +//----------------------------------------------------------------------------- + +TEST(GuiTextWrapTests, TextThatFitsIsOneLine) +{ + vector lines = wrap("ab", 100); + + ASSERT_EQ(1U, lines.size()); + EXPECT_STREQ("ab", lines[0].c_str()); +} + +TEST(GuiTextWrapTests, TextThatDoesNotFitBreaksAtTheSpace) +{ + // "aaa bbb" wants 70 and has 50, so it breaks; each half fits on its own. + vector lines = wrap("aaa bbb", 50); + + ASSERT_EQ(2U, lines.size()); + EXPECT_STREQ("aaa ", lines[0].c_str()); + EXPECT_STREQ("bbb", lines[1].c_str()); +} + +// An empty paragraph still owes one empty line. It is what a blank line between +// two others is made of, and what gives an empty multi-line text box somewhere +// to put its caret -- so the guard against the stray line must not swallow it. +TEST(GuiTextWrapTests, AnEmptyParagraphIsStillOneLine) +{ + vector lines = wrap("", 100); + + ASSERT_EQ(1U, lines.size()); + EXPECT_STREQ("", lines[0].c_str()); +} + +//----------------------------------------------------------------------------- +// The word that cannot be broken. +//----------------------------------------------------------------------------- + +// "BlankCircle" wants 110 and has 100. There is nowhere to break it, so it takes +// one line and is clipped -- one line, not one line and an empty one after it. +TEST(GuiTextWrapTests, AWordTooLongToFitIsStillOneLine) +{ + vector lines = wrap("BlankCircle", 100); + + ASSERT_EQ(1U, lines.size()); + EXPECT_STREQ("BlankCircle ", lines[0].c_str()); +} + +TEST(GuiTextWrapTests, AWordTooLongToFitAtTheEndAddsNoEmptyLine) +{ + vector lines = wrap("hi BlankCircle", 100); + + ASSERT_EQ(2U, lines.size()); + EXPECT_STREQ("hi ", lines[0].c_str()); + EXPECT_STREQ("BlankCircle ", lines[1].c_str()); +} + +// The same word first rather than last. This one never had the bug -- the word +// after it refills the line the push at the foot of the loop empties -- and it +// is here so that a future fix cannot cure the end case by breaking this one. +TEST(GuiTextWrapTests, AWordTooLongToFitAtTheStartKeepsWhatFollows) +{ + vector lines = wrap("BlankCircle hi", 100); + + ASSERT_EQ(2U, lines.size()); + EXPECT_STREQ("BlankCircle ", lines[0].c_str()); + EXPECT_STREQ("hi", lines[1].c_str()); +} + +// Two of them running together, which is the shape a wrapped list of long asset +// names takes: one line each, and nothing between them. +TEST(GuiTextWrapTests, TwoWordsTooLongToFitTakeOneLineEach) +{ + vector lines = wrap("BlankCircle CannonballSprite", 100); + + ASSERT_EQ(2U, lines.size()); + EXPECT_STREQ("BlankCircle ", lines[0].c_str()); + EXPECT_STREQ("CannonballSprite ", lines[1].c_str()); +} + +//----------------------------------------------------------------------------- +// Why the stray line mattered, and to which alignments. +//----------------------------------------------------------------------------- + +// One extra line moves the text by a whole line under BottomVAlign and by half a +// line under MiddleVAlign. Under TopVAlign it moves nothing at all -- which is +// the whole reason this looked like a bottom-aligned bug and was not one. +TEST(GuiTextWrapTests, AStrayLineMovesEveryAlignmentExceptTop) +{ + const S32 lineHeight = 16; + const S32 roomHeight = 34; + const S32 oneLine = lineHeight; + const S32 twoLines = lineHeight * 2; + + EXPECT_EQ(0, GuiControl::getTextVerticalOffset(oneLine, roomHeight, TopVAlign)); + EXPECT_EQ(0, GuiControl::getTextVerticalOffset(twoLines, roomHeight, TopVAlign)); + + // 18 down to 2: the caption climbs a full line height. + EXPECT_EQ(18, GuiControl::getTextVerticalOffset(oneLine, roomHeight, BottomVAlign)); + EXPECT_EQ(2, GuiControl::getTextVerticalOffset(twoLines, roomHeight, BottomVAlign)); + + // 9 down to 1: half of one. + EXPECT_EQ(9, GuiControl::getTextVerticalOffset(oneLine, roomHeight, MiddleVAlign)); + EXPECT_EQ(1, GuiControl::getTextVerticalOffset(twoLines, roomHeight, MiddleVAlign)); +} + +#endif // TORQUE_SHIPPING From 6f694e028e915ead4db076a12b8fb7abfc6d5863 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Sat, 8 Aug 2026 17:20:21 -0400 Subject: [PATCH 03/26] Tests: a log per suite, and a run that tidies up after itself Two things the harness left lying around. The log. Every engine started from the repo root writes console.log, so a test run and a hand-started editor share one file: they interleave, and in log mode 2, where the file is held open, whichever starts second produces a log nobody can read. That happened twice in one afternoon and both times looked like a broken test. run.ps1 now gives each suite tests/logs/.log and run-unit.ps1 takes tests/logs/unit.log. The log of a suite that failed also survives the rest of the run now, instead of being truncated by the next one. The engine side is a feature that was started and never finished: console.cc has carried a logFileName static, initialised to NULL and read by nothing, next to a hardcoded "console.log". Both open sites now go through it, and Con::setLogFileName is bound to script as setLogFileName(). It has to be called before setLogMode, and mode 2 is handled by closing the held file and reopening under the new name. The leftovers. Tests build project folders, and createTheme copies the stock cursor art into /themes/cursors/ the moment it is called -- before any save, and deleteTheme does not take it back. Suites working in a throwaway project lost it with the folder; the four that open PlanetX had been quietly accumulating art inside real content. Remove-TestArtifacts reads each test's own source for setProjectFolder and createTheme and removes what they make. It still runs before each test, which is the guarantee -- a killed test never gets to tidy up, so the next run cannot assume it did -- and now again over every test that ran, once the run is done. -Keep skips the sweep for picking over a failure. A suite cannot do this itself: script has no deleteDirectory binding and both artifacts are directories. Reading the source rather than watching the filesystem means the sweep can only ever remove a name a test itself names, so a run cannot eat work that happened to be in the tree -- but it does mean the name has to be spelled out. The runner warns beside any test that passes either call a variable, rather than silently skipping it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .gitignore | 9 ++ engine/source/console/console.cc | 40 +++++++- engine/source/console/console.h | 5 + engine/source/console/output_ScriptBinding.h | 13 +++ main.runAllUnitTests.cs | 6 ++ tests/README.md | 29 ++++++ tests/run-unit.ps1 | 12 ++- tests/run.ps1 | 98 +++++++++++++++++--- tests/smoke/assetPicker.cs | 2 +- tests/smoke/border.cs | 2 +- tests/smoke/borderPane.cs | 2 +- tests/smoke/clipboard.cs | 2 +- tests/smoke/colorPopup.cs | 2 +- tests/smoke/cursorPane.cs | 2 +- tests/smoke/cursorSlots.cs | 2 +- tests/smoke/font.cs | 2 +- tests/smoke/frameSet.cs | 2 +- tests/smoke/inspectorPane.cs | 2 +- tests/smoke/inspectorSpec.cs | 2 +- tests/smoke/inspectorText.cs | 2 +- tests/smoke/inspectorVariants.cs | 2 +- tests/smoke/listItems.cs | 2 +- tests/smoke/menuBar.cs | 2 +- tests/smoke/profileForm.cs | 2 +- tests/smoke/tabBook.cs | 2 +- tests/smoke/undo.cs | 2 +- tests/smoke/unsaved.cs | 2 +- 27 files changed, 213 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 3c91b43a5..e68bcf412 100755 --- a/.gitignore +++ b/.gitignore @@ -103,13 +103,22 @@ engine/compilers/android-studio/app/.cxx/ # tests build to have something to edit. A test that adds a project folder wants # a line here too; ProjectManager.setProjectFolder names them. /shots/ +# One console log per test, written by tests/run.ps1 and run-unit.ps1 so a run +# neither collides with a hand-started editor nor throws away the log of the +# suite that failed. +/tests/logs/ +/assetLibrarySmokeProject/ /assetPickerSmokeProject/ /borderPaneSmokeProject/ /borderSmokeProject/ /colorPopupShotProject/ /colorPopupSmokeProject/ +/cursorPaneSmokeProject/ +/cursorSlotsSmokeProject/ /fontShotProject/ /fontSmokeProject/ +/inspectorTextSmokeProject/ +/inspectorVariantsSmokeProject/ /profileFormShotProject/ /profileFormSmokeProject/ /smokeThemeProject/ diff --git a/engine/source/console/console.cc b/engine/source/console/console.cc index b37285c5e..d87b27d68 100755 --- a/engine/source/console/console.cc +++ b/engine/source/console/console.cc @@ -205,6 +205,14 @@ static bool active = false; static bool newLogFile; static const char *logFileName; +// Where the log is written. logFileName is NULL until something sets one, which +// is what "use the default" means -- the two are kept apart so that clearing the +// name gets the default back rather than an empty path. +static const char *currentLogFileName() +{ + return logFileName ? logFileName : defLogFileName; +} + static const int MaxCompletionBufferSize = 4096; static char completionBuffer[MaxCompletionBufferSize]; static char tabBuffer[MaxCompletionBufferSize] = {0}; @@ -436,7 +444,7 @@ static void log(const char *string) // In mode 1, we open, append, close on each log write. if ((consoleLogMode & 0x3) == 1) { - consoleLogFile.open(defLogFileName, FileStream::ReadWrite); + consoleLogFile.open(currentLogFileName(), FileStream::ReadWrite); } // Write to the log if its status is hunky-dory. @@ -1122,12 +1130,40 @@ void setLogMode(S32 newMode) else if ((newMode & 0x3) == 2) { // Starting mode 2, must open logfile. - consoleLogFile.open(defLogFileName, FileStream::Write); + consoleLogFile.open(currentLogFileName(), FileStream::Write); } consoleLogMode = newMode; } } +//------------------------------------------------------------------------------ + +// Where the log goes. Passing nothing puts it back to console.log. +// +// The name matters because the log lives at the working directory root and every +// copy of the engine shares it: a test harness and a hand-run of the game write +// the same file, and in mode 2 the first one to start holds it open, so the +// second produces a log nobody can read. +void setLogFileName(const char *name) +{ + StringTableEntry newName = (name && *name) ? StringTable->insert(name) : NULL; + if (newName == logFileName) + return; + + // Mode 2 holds the file open, so it has to be let go before the name moves. + const bool holdingOpen = ((consoleLogMode & 0x3) == 2); + if (holdingOpen) + consoleLogFile.close(); + + logFileName = newName; + + // The new file is a new file, whatever was written to the old one. + newLogFile = true; + + if (holdingOpen) + consoleLogFile.open(currentLogFileName(), FileStream::Write); +} + Namespace *lookupNamespace(const char *ns) { if(!ns) diff --git a/engine/source/console/console.h b/engine/source/console/console.h index 824e8ca56..7acc5047b 100755 --- a/engine/source/console/console.h +++ b/engine/source/console/console.h @@ -620,6 +620,11 @@ namespace Con void unlockLog(void); void setLogMode(S32 mode); + /// Redirects the console log. Pass NULL or an empty string to put it back to + /// console.log. Safe to call at any time: mode 2 holds the file open, so the + /// old one is closed and the new one opened in its place. + void setLogFileName(const char *name); + /// @} /// @name Dynamic Type System diff --git a/engine/source/console/output_ScriptBinding.h b/engine/source/console/output_ScriptBinding.h index 5145ecbd8..d67d89c12 100644 --- a/engine/source/console/output_ScriptBinding.h +++ b/engine/source/console/output_ScriptBinding.h @@ -139,6 +139,19 @@ ConsoleFunctionWithDocs(setLogMode, ConsoleVoid, 2, 2, ( mode )) Con::setLogMode(dAtoi(argv[1])); } +/*! Use the setLogFileName function to write the console log somewhere other than console.log. + The path is relative to the working directory, and the folders in it must already exist. Call this BEFORE setLogMode, or the opening lines land in the old file. + Every copy of the engine started from the same folder shares one log, so two running at once either interleave their output or, in mode 2, leave the second unable to open it at all. A test harness wanting its own log is the reason this exists. + @param fileName A string naming the file to log to. Pass "" to go back to console.log. + @return No return value. + @sa setLogMode +*/ +ConsoleFunctionWithDocs(setLogFileName, ConsoleVoid, 2, 2, ( fileName )) +{ + TORQUE_UNUSED( argc ); + Con::setLogFileName(argv[1]); +} + /*! Use the setEchoFileLoads function to enable/disable echoing of file loads (to console). This does not completely disable message, but rather adds additional methods when echoing is set to true. File loads will always echo a compile statement if compiling is required, and an exec statement at all times @param enable A boolean value. If this value is true, extra information will be dumped to the console when files are loaded. diff --git a/main.runAllUnitTests.cs b/main.runAllUnitTests.cs index a39caaa14..2e4492812 100644 --- a/main.runAllUnitTests.cs +++ b/main.runAllUnitTests.cs @@ -20,6 +20,12 @@ // IN THE SOFTWARE. //----------------------------------------------------------------------------- +// Somewhere other than the console.log at the repo root, which every copy of the +// engine started from this folder writes to -- and mode 2 below holds it open, +// so a run alongside a hand-started editor leaves a log neither can read. Before +// setLogMode, or the opening lines land in the old file. +setLogFileName("tests/logs/unit.log"); + // Set log mode. setLogMode(2); diff --git a/tests/README.md b/tests/README.md index aaefdb951..e2e12853a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -147,6 +147,35 @@ sequence; otherwise it is picked up automatically and run last, alphabetically. `smokeThemeProject`) before each test, and never touches `PlanetX` or `toybox`, which are real content. A test that has to inherit the previous one's folder — only the second half of a two-pass test — goes in `$KeepProject`. +- **`createTheme` writes cursor art the moment it is called**, into + `/themes/cursors/` — before any save, and `deleteTheme` does not + take it back. A suite working in its own throwaway project loses it with the + folder; a suite that opens `PlanetX` was leaving art behind inside real + content until the runner started sweeping it. + +## What a run leaves behind + +Nothing, by design. `Remove-TestArtifacts` reads each test's own source for +`setProjectFolder("…")` and `createTheme("…")` and removes what those two make. +It runs **before** each test — which is the guarantee, because a killed or +crashed test never gets to tidy up — and again over every test that ran once the +run is finished, so `git status` stays readable. + +Reading the source rather than watching the filesystem is deliberate: the sweep +can only ever remove a name a test itself names, so it cannot eat work that +happened to be in the tree at the time. + +**So spell the name out.** `setProjectFolder("mySmokeProject")`, not +`setProjectFolder($folder)` — a name held in a variable is invisible to the +sweep, and the folder simply stays. The runner prints a warning beside any test +that does this rather than letting it pass unnoticed. + +`tests\run.ps1 -Keep` skips the final sweep, for picking over the wreckage +of a failure. + +**A suite cannot tidy up after itself in script**: there is no `deleteDirectory` +binding, and the two things worth removing — a project folder and a theme's +cursor art — are both directories. That is why this lives in the runner. ## Known failures diff --git a/tests/run-unit.ps1 b/tests/run-unit.ps1 index 2141df8db..4c2c8b39e 100644 --- a/tests/run-unit.ps1 +++ b/tests/run-unit.ps1 @@ -48,7 +48,13 @@ $ErrorActionPreference = 'Stop' $repo = Split-Path -Parent $PSScriptRoot $exe = Join-Path $repo ($Release ? 'Torque2D.exe' : 'Torque2D_DEBUG.exe') $boot = Join-Path $repo 'main.runAllUnitTests.cs' -$log = Join-Path $repo 'console.log' + +# Its own log, not the console.log at the repo root that every engine started +# from this folder shares. main.runAllUnitTests.cs names the same path; the two +# have to agree. +$logDir = Join-Path $PSScriptRoot 'logs' +New-Item -ItemType Directory -Path $logDir -Force | Out-Null +$log = Join-Path $logDir 'unit.log' if (-not (Test-Path $exe)) { Write-Host "No $([IO.Path]::GetFileName($exe)) at the repo root. Build first:" -ForegroundColor Red @@ -60,7 +66,7 @@ if (-not (Test-Path $boot)) { exit 1 } -# The boot script sets logMode 2, which truncates console.log on open -- so a +# The boot script sets logMode 2, which truncates the log on open -- so a # stale log cannot be mistaken for this run's. Removing it first also means a # process that dies before opening the log leaves no results at all, rather than # the previous run's. @@ -91,7 +97,7 @@ finally { } if (-not (Test-Path $log)) { - Write-Host ' the run wrote no console.log' -ForegroundColor Red + Write-Host ' the run wrote no log' -ForegroundColor Red exit 1 } diff --git a/tests/run.ps1 b/tests/run.ps1 index 6c8b5c199..2c623808c 100644 --- a/tests/run.ps1 +++ b/tests/run.ps1 @@ -30,10 +30,15 @@ Seconds to let a test run before killing it. A debug build's AssertFatal is a modal message box, so a test that trips one hangs rather than crashing. +.PARAMETER Keep + Leave behind the project folders and theme art the tests built, instead of + sweeping them at the end. For picking over the wreckage of a failure. + .EXAMPLE tests\run.ps1 tests\run.ps1 colorPopup tests\run.ps1 -Shots + tests\run.ps1 undo -Keep #> [CmdletBinding()] param( @@ -41,17 +46,24 @@ param( [string]$Name = '*', [switch]$Shots, [switch]$List, - [int]$Timeout = 90 + [int]$Timeout = 90, + [switch]$Keep ) $ErrorActionPreference = 'Stop' $Root = Split-Path -Parent $PSScriptRoot $Exe = Join-Path $Root 'Torque2D_DEBUG.exe' -$Log = Join-Path $Root 'console.log' $Boot = Join-Path $Root '_boot.cs' $Dir = if ($Shots) { 'shots' } else { 'smoke' } +# One log per test, rather than the console.log at the repo root that every +# engine started from this folder writes to. Two reasons: a run no longer +# collides with a hand-started editor, and the log of a suite that failed is +# still there after the rest of the run has been and gone. +$LogDir = Join-Path $PSScriptRoot 'logs' +New-Item -ItemType Directory -Path $LogDir -Force | Out-Null + # Every suite is expected to pass, and none is on this list. It stays because a # genuinely known failure -- one that is not the suite's own fault and cannot be # fixed yet -- has to be written down as a number rather than remembered, or the @@ -68,11 +80,58 @@ $Expected = @{} # than starting from a clean one. Only the second half of a two-pass test. $KeepProject = @('bitmapPathRead') +# Everything a test builds outside its own head, removed by reading the test's +# own source for the two calls that make files. +# +# setProjectFolder("X") ProjectManager creates X/ at the repo root. Only the +# throwaway names are touched -- PlanetX and toybox are +# real content that several suites open, and deleting +# those would be a disaster. +# createTheme("Y") GuiProfileEditorLibrary::seedThemeCursors copies the +# stock cursor art into /themes/cursors/Y the +# moment the theme is made, whether or not it is ever +# saved -- and deleteTheme does not take it back, so a +# suite could not tidy this itself even if it tried. +# Suites working in a throwaway project lose it with the +# folder; the ones that open PlanetX were leaving art +# behind inside real content. +# +# Reading the source rather than watching the filesystem is deliberate: it can +# only ever remove a name the test itself names, so a run cannot eat work that +# happened to be in the tree at the time. +function Remove-TestArtifacts([string]$test) { + $body = Get-Content (Join-Path $PSScriptRoot "$Dir\$test.cs") -Raw + + # Only a spelled-out name can be read out of the source, so a test that hands + # either call a variable is one whose folder nobody deletes -- silently, and + # only noticed later as a stray directory. Say so instead. + foreach ($call in 'setProjectFolder', 'createTheme') { + $all = [regex]::Matches($body, [regex]::Escape($call) + '\(').Count + $literal = [regex]::Matches($body, [regex]::Escape($call) + '\("').Count + if ($all -gt $literal) { + Write-Host "[$test calls $call with a variable; that one cannot be swept] " -NoNewline -ForegroundColor Yellow + } + } + + foreach ($m in [regex]::Matches($body, 'setProjectFolder\("([^"]+)"')) { + $folder = $m.Groups[1].Value + if ($folder -match '(SmokeProject|ShotProject)$' -or $folder -eq 'smokeThemeProject') { + Remove-Item (Join-Path $Root $folder) -Recurse -Force -ErrorAction SilentlyContinue + } + } + + foreach ($m in [regex]::Matches($body, 'createTheme\("([^"]+)"')) { + $theme = $m.Groups[1].Value + Remove-Item (Join-Path $Root "*\themes\cursors\$theme") -Recurse -Force -ErrorAction SilentlyContinue + } +} + # The order matters for one pair only: bitmapPathWrite saves a profile that # bitmapPathRead boots fresh to read back. $Order = @( 'profileEditor', 'profileForm', 'border', 'borderPane', 'standalone', 'headerPane', 'colorPopup', 'themeApply', 'font', 'assetPicker', + 'assetLibrary', 'tooltipProfile', 'textClick', 'undo', 'clipboard', 'bitmapPathWrite', 'bitmapPathRead', 'toybox', 'planetX' ) @@ -109,27 +168,29 @@ foreach ($test in $tests) { Write-Host (" {0,-18} " -f $test) -NoNewline + $Log = Join-Path $LogDir "$test.log" Remove-Item $Log -ErrorAction SilentlyContinue # Start from a clean project folder. A test that finds one left by the last # run gets a cascade of "that name is already taken" and fails checks that - # have nothing to do with what it is testing. Only the throwaway folders a - # test builds for itself are removed -- PlanetX and toybox are real content - # that some of these suites open, and deleting those would be a disaster. + # have nothing to do with what it is testing. This is the guarantee the sweep + # at the foot of the file cannot make: a killed or crashed test never gets to + # tidy up, so the run that follows has to assume it did not. if ($test -notin $KeepProject) { - $body = Get-Content (Join-Path $PSScriptRoot "$Dir\$test.cs") -Raw - foreach ($m in [regex]::Matches($body, 'setProjectFolder\("([^"]+)"')) { - $folder = $m.Groups[1].Value - if ($folder -match '(SmokeProject|ShotProject)$' -or $folder -eq 'smokeThemeProject') { - Remove-Item (Join-Path $Root $folder) -Recurse -Force -ErrorAction SilentlyContinue - } - } + Remove-TestArtifacts $test } # The engine derives its working directory from the boot script's folder, so # the stub has to sit at the root. Only here is a plain "./" the repo root -- # inside a test it means tests/smoke, which is what the prelude exists to fix. + # + # setLogFileName comes first and before the test's own setLogMode: the log + # otherwise defaults to console.log at the repo root, which every copy of the + # engine started from this folder shares. A run alongside a hand-started + # editor then either interleaves with it or -- in log mode 2, where the file + # is held open -- produces a log this script cannot read at all. $stub = @( '// Generated by tests/run.ps1. Not tracked; safe to delete.' + "setLogFileName(`"tests/logs/$test.log`");" 'exec("./tests/lib/prelude.cs");' "exec(`"./$script`");" ) -join "`n" @@ -195,7 +256,7 @@ foreach ($test in $tests) { # A killed process nearly always means a fatal assert put a modal box up, and # the reason is the last thing the engine managed to log. Show it, so nobody - # has to open console.log or watch for the dialog to find out what happened. + # has to open the log or watch for the dialog to find out what happened. if ($hung) { $last = $lines | Where-Object { $_.Trim() } | Select-Object -Last 1 if ($last) { @@ -208,6 +269,17 @@ foreach ($test in $tests) { Remove-Item $Boot -ErrorAction SilentlyContinue +# Leave the tree as it was found. Cleaning before each test keeps the run +# correct; cleaning after keeps `git status` readable, which is the difference +# between spotting a stray file and scrolling past thirty of them. Done here +# rather than per test so the one handoff in the suite -- bitmapPathWrite saves +# a profile that bitmapPathRead boots fresh to read -- still works. +if (-not $Keep) { + foreach ($test in $tests) { + Remove-TestArtifacts $test + } +} + $bad = @($results | Where-Object { -not $_.Ok }) Write-Host "" diff --git a/tests/smoke/assetPicker.cs b/tests/smoke/assetPicker.cs index aef0c8a30..d7bcf451f 100644 --- a/tests/smoke/assetPicker.cs +++ b/tests/smoke/assetPicker.cs @@ -2,7 +2,7 @@ // Editor's Image Asset row: the new "asset" row kind, the one-shot asset query, // live substring filtering, the grid re-flow that filtering depends on, // selection, choosing, cancelling and dialog cleanup. -// Run: tests/run.ps1 assetPicker ; grep APSMOKE in console.log. +// Run: tests/run.ps1 assetPicker ; grep APSMOKE in tests/logs/. // Mode 1 rather than the usual 2: it opens, appends and closes the log on every // write, so a crash mid-run still leaves every line that got as far as being diff --git a/tests/smoke/border.cs b/tests/smoke/border.cs index dfb1d491a..bd2cf79a9 100644 --- a/tests/smoke/border.cs +++ b/tests/smoke/border.cs @@ -1,7 +1,7 @@ // Border-pane persistence smoke test. Drives the Profile Editor's Borders pane // through creating a custom border, editing a value, saving, and reloading from // disk -- for a themed profile and for a standalone (bundled) profile. -// Run: tests/run.ps1 border ; grep BSMOKE in console.log. +// Run: tests/run.ps1 border ; grep BSMOKE in tests/logs/. setLogMode(2); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/borderPane.cs b/tests/smoke/borderPane.cs index d25a8bcdc..379af3ec7 100644 --- a/tests/smoke/borderPane.cs +++ b/tests/smoke/borderPane.cs @@ -2,7 +2,7 @@ // (GuiProfileEditorBorderForm) that replaces the inspector when a border node is // selected: it verifies the three-way Properties toggle, that the shared grid // binds/edits the selected border in place, and that underfill commits. -// Run: tests/run.ps1 borderPane ; grep PBSMOKE in console.log. +// Run: tests/run.ps1 borderPane ; grep PBSMOKE in tests/logs/. setLogMode(2); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/clipboard.cs b/tests/smoke/clipboard.cs index 2333bd78f..edf712d2f 100644 --- a/tests/smoke/clipboard.cs +++ b/tests/smoke/clipboard.cs @@ -8,7 +8,7 @@ // is ONE undo step however much it puts back, a copy is a real copy (children, // dynamic fields, a frame set's frames, and no second helping of whatever a // control's class builds for itself), and no two controls end up sharing a name. -// Run: tests/run.ps1 clipboard ; grep CLIP in console.log. +// Run: tests/run.ps1 clipboard ; grep CLIP in tests/logs/. //----------------------------------------------------------------------------- setLogMode(2); diff --git a/tests/smoke/colorPopup.cs b/tests/smoke/colorPopup.cs index c0b38bdd8..8b383983a 100644 --- a/tests/smoke/colorPopup.cs +++ b/tests/smoke/colorPopup.cs @@ -4,7 +4,7 @@ // that matters most is that a color chosen from a swatch survives a rendered // frame unchanged -- the pickers re-read their color out of the framebuffer, and // used to overwrite whatever exact value the popup had been given. -// Run: tests/run.ps1 colorPopup ; grep CPSMOKE in console.log. +// Run: tests/run.ps1 colorPopup ; grep CPSMOKE in tests/logs/. setLogMode(1); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/cursorPane.cs b/tests/smoke/cursorPane.cs index 0a66eb533..6ef083f13 100644 --- a/tests/smoke/cursorPane.cs +++ b/tests/smoke/cursorPane.cs @@ -3,7 +3,7 @@ // cursor node is selected, the seeded per-theme art, the hot-spot magnifier and // its drag arithmetic, extras within a category, and the theme rename that has // to take the art folder with it. -// Run: tests/run.ps1 cursorPane ; grep CURSMOKE in console.log. +// Run: tests/run.ps1 cursorPane ; grep CURSMOKE in tests/logs/. setLogMode(2); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/cursorSlots.cs b/tests/smoke/cursorSlots.cs index 3698b7bc9..4a18ba223 100644 --- a/tests/smoke/cursorSlots.cs +++ b/tests/smoke/cursorSlots.cs @@ -6,7 +6,7 @@ // the theme holds a second cursor for that job and there is a choice to make. // Detaching a theme has to move them somewhere real as well: a GuiCursor* is as // raw a pointer as a profile's. -// Run: tests/run.ps1 cursorSlots ; grep CSSMOKE in console.log. +// Run: tests/run.ps1 cursorSlots ; grep CSSMOKE in tests/logs/. setLogMode(1); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/font.cs b/tests/smoke/font.cs index 6e51bad7f..710a900fd 100644 --- a/tests/smoke/font.cs +++ b/tests/smoke/font.cs @@ -3,7 +3,7 @@ // machine, the project keeps its caches in one predetermined folder nobody is // asked about, and Save bakes a cache for every face/size that was rendered -- // including the sizes a control's fontSizeAdjust produced, which no field names. -// Run: tests/run.ps1 font ; grep FSMOKE in console.log. +// Run: tests/run.ps1 font ; grep FSMOKE in tests/logs/. // Mode 1 rather than the usual 2: it opens, appends and closes the log on every // write, so a crash mid-run still leaves every line that got as far as being diff --git a/tests/smoke/frameSet.cs b/tests/smoke/frameSet.cs index 249d98d77..929589aa9 100644 --- a/tests/smoke/frameSet.cs +++ b/tests/smoke/frameSet.cs @@ -22,7 +22,7 @@ // build; it is here because frame-set persistence had no coverage at all, and // this is the shape a broken one takes. // -// Run: tests/run.ps1 frameSet ; grep FRAMESET in console.log. +// Run: tests/run.ps1 frameSet ; grep FRAMESET in tests/logs/. //----------------------------------------------------------------------------- setLogMode(2); diff --git a/tests/smoke/inspectorPane.cs b/tests/smoke/inspectorPane.cs index fe09c99ba..c2ea4c791 100644 --- a/tests/smoke/inspectorPane.cs +++ b/tests/smoke/inspectorPane.cs @@ -3,7 +3,7 @@ // of each family, checking that what it shows matches what the class can // actually use, that a commit reaches the control, and that reparenting into a // layout container makes the geometry it no longer owns go inert. -// Run: tests/run.ps1 inspectorPane ; grep IPSMOKE in console.log. +// Run: tests/run.ps1 inspectorPane ; grep IPSMOKE in tests/logs/. setLogMode(1); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/inspectorSpec.cs b/tests/smoke/inspectorSpec.cs index 5d078055a..30f1cfcb0 100644 --- a/tests/smoke/inspectorSpec.cs +++ b/tests/smoke/inspectorSpec.cs @@ -5,7 +5,7 @@ // // The checks are deliberately weighted toward the surprising entries: the ones // where the field name suggests the opposite of what the render path does. -// Run: tests/run.ps1 inspectorSpec ; grep ISSMOKE in console.log. +// Run: tests/run.ps1 inspectorSpec ; grep ISSMOKE in tests/logs/. setLogMode(1); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/inspectorText.cs b/tests/smoke/inspectorText.cs index edea5de69..52b370979 100644 --- a/tests/smoke/inspectorText.cs +++ b/tests/smoke/inspectorText.cs @@ -11,7 +11,7 @@ // So: the Category row exists where the class is ambiguous and nowhere else, // picking one moves the control onto that category's profile, and every field // GuiControl::renderText reads has a home. -// Run: tests/run.ps1 inspectorText ; grep ITSMOKE in console.log. +// Run: tests/run.ps1 inspectorText ; grep ITSMOKE in tests/logs/. setLogMode(1); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/inspectorVariants.cs b/tests/smoke/inspectorVariants.cs index b9d4f50fd..7d263b36f 100644 --- a/tests/smoke/inspectorVariants.cs +++ b/tests/smoke/inspectorVariants.cs @@ -14,7 +14,7 @@ // So an "Any" standalone can be picked in a row that exists, but can never be // the reason one appears -- otherwise a single uncategorised profile would put // a row on every slot of every control. -// Run: tests/run.ps1 inspectorVariants ; grep IVSMOKE in console.log. +// Run: tests/run.ps1 inspectorVariants ; grep IVSMOKE in tests/logs/. setLogMode(1); $Scripts::ignoreDSOs = true; diff --git a/tests/smoke/listItems.cs b/tests/smoke/listItems.cs index 6899ff2e3..1901fc183 100644 --- a/tests/smoke/listItems.cs +++ b/tests/smoke/listItems.cs @@ -7,7 +7,7 @@ // both, and checks the one control that must NOT take them: a GuiTreeViewCtrl // generates its rows from a root object, so a written-out set would be stale. // -// Run: tests/run.ps1 listItems ; grep LISTITEMS in console.log. +// Run: tests/run.ps1 listItems ; grep LISTITEMS in tests/logs/. //----------------------------------------------------------------------------- setLogMode(2); diff --git a/tests/smoke/menuBar.cs b/tests/smoke/menuBar.cs index aca88cbdb..8a18fef6e 100644 --- a/tests/smoke/menuBar.cs +++ b/tests/smoke/menuBar.cs @@ -15,7 +15,7 @@ // Runs on the real editor UI throughout, because all of it is gated on // isEditMode(). // -// Run: tests/run.ps1 menuBar ; grep MENUBAR in console.log. +// Run: tests/run.ps1 menuBar ; grep MENUBAR in tests/logs/. //----------------------------------------------------------------------------- setLogMode(2); diff --git a/tests/smoke/profileForm.cs b/tests/smoke/profileForm.cs index 949ac5da2..dc08fb35e 100644 --- a/tests/smoke/profileForm.cs +++ b/tests/smoke/profileForm.cs @@ -3,7 +3,7 @@ // is selected: it verifies the pane toggle, the category-driven field filter in // its four shapes, state greying, Show All, commits, per-field reset, and the // standalone category picker. -// Run: tests/run.ps1 profileForm ; grep PFSMOKE in console.log. +// Run: tests/run.ps1 profileForm ; grep PFSMOKE in tests/logs/. // Mode 1 rather than the usual 2: it opens, appends and closes the log on every // write, so a crash mid-run still leaves every line that got as far as being diff --git a/tests/smoke/tabBook.cs b/tests/smoke/tabBook.cs index beaa0735e..1c750dbfe 100644 --- a/tests/smoke/tabBook.cs +++ b/tests/smoke/tabBook.cs @@ -13,7 +13,7 @@ // isEditMode() is true - which needs the editor pushed onto the canvas rather // than merely registered. // -// Run: tests/run.ps1 tabBook ; grep TABBOOK in console.log. +// Run: tests/run.ps1 tabBook ; grep TABBOOK in tests/logs/. //----------------------------------------------------------------------------- setLogMode(2); diff --git a/tests/smoke/undo.cs b/tests/smoke/undo.cs index 9934e46f0..a8587fa97 100644 --- a/tests/smoke/undo.cs +++ b/tests/smoke/undo.cs @@ -6,7 +6,7 @@ // // Stack depth is checked as often as the values are. A gesture that records // eleven steps is as broken as one that records none, and only the count says -// so. Run: tests/run.ps1 undo ; grep UNDO in console.log. +// so. Run: tests/run.ps1 undo ; grep UNDO in tests/logs/. //----------------------------------------------------------------------------- setLogMode(2); diff --git a/tests/smoke/unsaved.cs b/tests/smoke/unsaved.cs index 0777014cf..6348b0fa0 100644 --- a/tests/smoke/unsaved.cs +++ b/tests/smoke/unsaved.cs @@ -10,7 +10,7 @@ // built on getUndoCount reads clean there, which is the one direction that must // never happen, so that case has a check of its own below. // -// Run: tests/run.ps1 unsaved ; grep SAVE in console.log. +// Run: tests/run.ps1 unsaved ; grep SAVE in tests/logs/. //----------------------------------------------------------------------------- setLogMode(2); From dea43e3254506a8685362a23e55622768c3c9f6a Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Sun, 9 Aug 2026 01:26:42 -0400 Subject: [PATCH 04/26] Asset Manager: an image asset inspector, in four blocks that reflow The Inspector tab was the stock C++ GuiInspector, which reflects every registered field into flat alphabetical groups. For an image asset that meant the eight cell values arrived split into "X Values" and "Y Values", so a cell's width never sat beside its height; an Asset Name box that silently did nothing, because AssetBase::setAssetName is a no-op once the manager owns the asset; an absolute path where the portable one belongs; and nothing at all about the picture -- its size, its frame count, or the fact that the cell values were being ignored. Image assets now get a pane of their own. The rest keep the inspector, which is why the plumbing is a superclass the next asset kind inherits. AssetInspectorPane grids, panels, rows, bind/refresh/commit AssetImageInspectorPane the layout, and what an ImageAsset holds AssetImageCellGrid the eight cell values as one X/Y table Four blocks of roughly equal size in one grid -- identity, frames, settings, description -- so the same pane is 1x4 in a tall narrow frame, 2x2 at the size the inspector opens at, and 4x1 across the foot of a wide screen. A readout under the cell table says what actually loaded and how it cut up, and a warning line surfaces three things that were previously a line in a console log and nothing on screen: an image that did not load, explicit frame mode being on, and a cell layout that does not fit. Absent on purpose: AssetInternal and AssetPrivate exist to keep an asset OUT of the editor; the asset id and file only restate what is on show; and Asset Name is read-only until renaming is done properly, since it changes the asset id and every file that refers to it. BlendColor is absent too, and moved rather than dropped. It tints the base layer that an asset's layers are composed onto, and does nothing at all when there are none -- ImageAsset::setBlendColor warns to the console and returns before the redraw and before the save. So it now lives in row 0 of the Image Layers tab, beside the thing it tints, where that tab's colors are pickers instead of four decimals in a text box. Row 0 wears a padlock while it is the only row: greying a swatch is invisible on its own, since GuiColorPopupCtrl fills its face with the color in every state. EditorFieldRow moves from the Gui Editor to EditorCore, since AssetAdmin depends only on EditorCore and is loaded before the Gui Editor. Two things it hardcoded now come from its owner: the color popup's class, which named a Gui Editor class EditorCore cannot see, and what a Find button measures a path against -- an asset's loose file is relative to the asset's folder, not the game root. Two clipping bugs found on the way. A scroller sized with VertSizing "height" keeps the gap it had to each edge, so a tab page resized twice on the way up left it 12 pixels taller than the page and the page ate the scroll bar's down arrow; "fill" recomputes and cannot drift. And a frame set moves its divider whatever the window in it thinks, so the inspector window's 500-pixel minimum meant 106 pixels of it hung off the right edge, clipped, taking the Find button with it. tests/smoke/assetImageInspector.cs is 97 checks, including the reflow at three widths, both clipping cases, and the layer color column end to end. tests/shots/assetImageInspector.cs takes seven pictures, one of them at 1600x900 because the test canvas is too narrow to show the 4x1 case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- editor/AssetAdmin/AssetAdmin.cs | 20 +- editor/AssetAdmin/AssetBase.cs | 19 +- editor/AssetAdmin/AssetInspector.cs | 99 ++- .../ImageEditor/AssetImageLayersEditRow.cs | 160 ++++- .../Inspector/AssetImageCellGrid.cs | 292 +++++++++ .../Inspector/AssetImageInspectorPane.cs | 332 ++++++++++ .../Inspector/AssetInspectorPane.cs | 460 ++++++++++++++ editor/AssetAdmin/Inspector/exec.cs | 3 + editor/EditorCore/EditorCore.cs | 5 + .../EditorFieldRow.cs} | 154 +++-- editor/GuiEditor/GuiEditor.cs | 1 - .../scripts/GuiEditorDynamicFields.cs | 8 +- .../scripts/GuiEditorInspectorPane.cs | 11 +- .../scripts/GuiEditorMenuItemBlock.cs | 4 +- .../scripts/GuiProfileEditorBorderGrid.cs | 2 +- .../scripts/GuiProfileEditorCursorForm.cs | 11 +- .../scripts/GuiProfileEditorProfileForm.cs | 15 +- .../scripts/GuiProfileEditorStateColorRow.cs | 2 +- tests/shots/assetImageInspector.cs | 176 ++++++ tests/shots/cursorPane.cs | 2 +- tests/smoke/assetImageInspector.cs | 575 ++++++++++++++++++ tests/smoke/assetPicker.cs | 4 +- tests/smoke/cursorPane.cs | 6 +- tests/smoke/cursorSlots.cs | 2 +- tests/smoke/inspectorPane.cs | 2 +- tests/smoke/inspectorText.cs | 4 +- tests/smoke/profileForm.cs | 2 +- tests/smoke/textClick.cs | 4 +- 28 files changed, 2257 insertions(+), 118 deletions(-) create mode 100644 editor/AssetAdmin/Inspector/AssetImageCellGrid.cs create mode 100644 editor/AssetAdmin/Inspector/AssetImageInspectorPane.cs create mode 100644 editor/AssetAdmin/Inspector/AssetInspectorPane.cs create mode 100644 editor/AssetAdmin/Inspector/exec.cs rename editor/{GuiEditor/scripts/GuiProfileEditorFieldRow.cs => EditorCore/EditorFieldRow.cs} (75%) create mode 100644 tests/shots/assetImageInspector.cs create mode 100644 tests/smoke/assetImageInspector.cs diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index a2665411b..e5fb57249 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -39,6 +39,7 @@ exec("./DeleteAssetDialog.cs"); exec("./ParticleEditor/exec.cs"); exec("./ImageEditor/exec.cs"); + exec("./Inspector/exec.cs"); %this.guiPage = EditorCore.RegisterEditor("Asset Manager", %this); %this.content = %this.createFrameSet(); @@ -73,13 +74,21 @@ %rightID = getWord(%idList, 1); %content.anchorFrame(%rightID); %content.setFrameSize(%rightID, 324); - + %ids = %content.createVerticalSplit(%leftID); %centerFrameID = getWord(%ids, 0); %inspectorFrameID = getWord(%ids, 1); %content.anchorFrame(%inspectorFrameID); %content.setFrameSize(%inspectorFrameID, 360); + // Kept because the only way to move a divider from script is to name the + // frame, and the ids are handed out once here and never again. The inspector + // is the bottom frame, so it opens wide and short -- which is why everything + // in it reflows. + %this.libraryFrameId = %rightID; + %this.previewFrameId = %centerFrameID; + %this.inspectorFrameId = %inspectorFrameID; + return %content; } @@ -125,7 +134,14 @@ VertSizing = "bottom"; text = "Asset Inspector"; Extent = "706 380"; - MinExtent = "500 250"; + + // Narrow enough to hold the cell table and its scroll bar, and no + // narrower. A frame set moves its divider whatever the window in the frame + // thinks, so a minimum the user can drag past is not a floor -- it is 106 + // pixels of window hanging off the right-hand edge, clipped away, taking + // the Find button and a column of settings with them. The pane reflows all + // the way down to one column, so there is nothing here to protect. + MinExtent = "260 200"; canMove = true; canClose = false; canMinimize = true; diff --git a/editor/AssetAdmin/AssetBase.cs b/editor/AssetAdmin/AssetBase.cs index d83231ee6..d2c008cad 100644 --- a/editor/AssetAdmin/AssetBase.cs +++ b/editor/AssetAdmin/AssetBase.cs @@ -1,10 +1,18 @@ //----------------------------------------------------------------------------- // What the Asset Manager does when an asset changes underneath it. // -// AssetBase::setAssetName, setAssetDescription and setAssetCategory all end in -// refreshAsset(), which fires this. The inspector edits all three, and the -// library now searches and sorts by all three -- so a tile that cached them has -// to be told, or the search box goes on answering about the old values. +// Every setter on an asset ends in refreshAsset(), which saves the asset's file +// and fires this. So this is the one place that hears about a change however it +// was made -- from the inspector, from the Explicit Frames or Image Layers tab, +// or as a cascade from some other asset that this one depends on. +// +// Three things have to be told: +// +// the library a tile caches the name, description and category it is +// searched and sorted by, and the inspector edits all three +// the preview the scene showing the asset is built from its values +// the inspector a change made on another tab -- explicit mode, a new layer -- +// is a change to what the inspector is showing //----------------------------------------------------------------------------- function AssetBase::onRefresh(%this) @@ -18,7 +26,10 @@ } AssetAdmin.libWindow.onAssetRefreshed(%this.getAssetId()); + AssetAdmin.inspector.onAssetRefreshed(%this); + // Redraws the preview. It does not re-enter the inspector: onClick only loads + // an asset into it when the selection actually moved. if(isObject(AssetAdmin.chosenButton)) { AssetAdmin.chosenButton.onClick(); diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index 0b38f4d87..45bdb4497 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -105,6 +105,19 @@ %this.inspector = %this.createInspector(); %this.insScroller.add(%this.inspector); + // The image asset's own pane, in place of the generic inspector for the one + // asset kind that has one so far. It shares the Inspector page with the + // inspector rather than taking a tab of its own -- it IS the inspector, for + // that kind of asset -- and chooseInspector decides which of the two is on + // show. Neither is ever rebuilt or freed. + %this.imageScroller = %this.createScroller(); + %this.imageScroller.setVisible(false); + %this.insPage.add(%this.imageScroller); + + %this.imagePane = %this.createImagePane(); + %this.imageScroller.add(%this.imagePane); + %this.imagePane.build(); + //Particle Graph Tool %this.scaleGraphPage = %this.createTabPage("Scale Graph", "AssetParticleGraphTool", ""); @@ -135,12 +148,20 @@ return %page; } +// Fill, not width/height. A scroller here is its tab page's only child and wants +// the whole content rect, and the two flags answer that differently: "height" +// keeps the gap to each edge that the control had when it was added, so a page +// resized twice -- once when it joined the book and again when the frame set +// gave the window its real size -- left the scroller 12 pixels taller than the +// page holding it, and the page clipped the 12 pixels at the bottom. That is the +// scroll bar's down arrow. Fill recomputes from the parent's content rect every +// time, so it cannot drift. (GuiEditorInspectorWindow says the same thing.) function AssetInspector::createScroller(%this) { %scroller = new GuiScrollCtrl() { - HorizSizing="width"; - VertSizing="height"; + HorizSizing="fill"; + VertSizing="fill"; Position="0 0"; Extent="700 320"; hScrollBar="alwaysOff"; @@ -194,6 +215,62 @@ return %inspector; } +// The scroller is 700 wide with a bar always on, so the pane lays out against +// what is left. "width" from there: the pane follows the frame as it is dragged, +// and its grids answer by reflowing into more or fewer columns. +function AssetInspector::createImagePane(%this) +{ + %width = 686; + + return new GuiChainCtrl() + { + class = "AssetImageInspectorPane"; + superclass = "AssetInspectorPane"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %width SPC 320; + IsVertical = true; + ChildSpacing = 6; + paneWidth = %width; + }; +} + +// Which of the two inspectors the Inspector page is showing. The one standing +// down is hidden rather than emptied, so nothing it holds is ever freed while +// the engine might be dispatching on it. +function AssetInspector::chooseInspector(%this, %useImagePane) +{ + %this.insScroller.setVisible(!%useImagePane); + %this.imageScroller.setVisible(%useImagePane); + + if(!%useImagePane) + { + %this.imagePane.unbind(); + } +} + +// What the title bar's delete button acts on. The generic inspector knows what +// it was handed; the image pane has to be asked, because it is not one. +function AssetInspector::inspectedObject(%this) +{ + if(%this.imageScroller.isVisible()) + { + return %this.imagePane.target; + } + return %this.inspector.getInspectObject(); +} + +// An asset changed -- possibly the one on show, possibly one it depends on. +// AssetBase::onRefresh sends every one of them here; the pane decides whether it +// is the one it is bound to. +function AssetInspector::onAssetRefreshed(%this, %asset) +{ + if(%this.imageScroller.isVisible()) + { + %this.imagePane.onAssetRefreshed(%asset); + } +} + function AssetInspector::hideInspector(%this) { %this.titlebar.setText(""); @@ -201,6 +278,9 @@ %this.tabBook.Visible = false; %this.emitterButtonBar.visible = false; %this.deleteAssetButton.visible = false; + + // Nothing is selected, so nothing is bound. The pane keeps its rows. + %this.chooseInspector(false); } function AssetInspector::resetInspector(%this) @@ -216,6 +296,10 @@ %this.emitterButtonBar.visible = false; %this.deleteAssetButton.visible = true; + + // Back to the generic inspector. The one asset kind with a pane of its own + // says so straight after. + %this.chooseInspector(false); } function AssetInspector::loadImageAsset(%this, %imageAsset, %assetID) @@ -226,13 +310,8 @@ %this.tabBook.selectPage(0); %this.titlebar.setText("Image Asset:" SPC %imageAsset.AssetName); - %this.inspector.clearHiddenFields(); - %this.inspector.addHiddenField("hidden"); - %this.inspector.addHiddenField("locked"); - %this.inspector.addHiddenField("AssetInternal"); - %this.inspector.addHiddenField("AssetPrivate"); - %this.inspector.addHiddenField("ExplicitMode"); - %this.inspector.inspect(%imageAsset); + %this.chooseInspector(true); + %this.imagePane.bind(%imageAsset, %assetID); %this.imageFrameEditPage.inspect(%imageAsset); %this.imageLayersEditPage.inspect(%imageAsset); @@ -349,7 +428,7 @@ function AssetInspector::deleteAsset(%this) { - %asset = %this.inspector.getInspectObject(); + %asset = %this.inspectedObject(); if(%this.titleDropDown.visible && %this.titleDropDown.getSelectedItem() != 0) { %asset = %asset.getOwner(); diff --git a/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs b/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs index 58f1226d0..d7ca02aec 100644 --- a/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs +++ b/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs @@ -1,4 +1,34 @@ +//----------------------------------------------------------------------------- +// One layer of a composed image: its picture, where it sits, and the color it is +// tinted with. +// +// Row 0 is not a layer the user added -- it is the asset's own image, the base +// that every other row is drawn onto, and its tint is the asset's BlendColor. +// The engine synthesizes it the moment a first real layer is added +// (ImageAsset::insertLayer), which is why its image and offset boxes are inert: +// they belong to the asset, not to a layer. +// +// Its color is inert too, but only while it is alone. ImageAsset::setBlendColor +// stores the value, warns to the console and returns before redrawing anything +// when there are no layers -- so with nothing composed onto the base there is +// nothing for a tint to show up on. Once a layer exists the picker comes to +// life. +// +// A locked picker wears a padlock rather than simply refusing to open. Every +// other inert control in the editor shows it -- a greyed text box plainly is one +// -- and a swatch does not: GuiColorPopupCtrl::onRender fills its face with the +// color whatever state the control is in, so an inert swatch is pixel for pixel +// a live one. The padlock is drawn at 30% black over a base color that is white +// until somebody has a reason to change it. +//----------------------------------------------------------------------------- + +// Where the color column sits, and how big the padlock over it is. +$AssetImageLayersEditRow::colorX = 392; +$AssetImageLayersEditRow::colorWidth = 164; +$AssetImageLayersEditRow::lockSize = 16; +$AssetImageLayersEditRow::lockTint = "0 0 0 77"; + function AssetImageLayersEditRow::onAdd(%this) { %this.errorColor = "255 0 0 255"; @@ -62,20 +92,7 @@ %this.add(%this.offsetYBox); %this.LayerColor = %this.scrubColor(%this.LayerColor); - %this.colorBox = new GuiTextEditCtrl() - { - HorizSizing="width"; - VertSizing="height"; - Position="392 3"; - Extent="164 32"; - Align = right; - Text = %this.LayerColor; - AltCommand = %this.getID() @ ".LayerColorChange();"; - FontColor = %this.errorColor; - InputMode = "AllText"; - }; - ThemeManager.setProfile(%this.colorBox, "textEditProfile"); - %this.add(%this.colorBox); + %this.buildColorColumn(); %this.buttonBar = new GuiChainCtrl() { @@ -101,6 +118,109 @@ %this.offsetXBox.active = false; %this.offsetYBox.active = false; } + + %this.refreshColorLock(); +} + +//----------------------------------------------------------------------------- +// The color column: a swatch, and a padlock over it for when the swatch is not +// yet worth using. +// +// A picker rather than the four numbers it used to be. The numbers are still +// there -- showColorValues puts an R/G/B/A row inside the popup, so an exact +// value can still be typed -- but a color is a thing you look at, and four +// decimals in a text box is the one form in which you cannot. +// +// Both live in a container of their own so the padlock can be centred on the +// swatch rather than on the row: "center" sizing recentres a control in its +// PARENT, and with the row as the parent the padlock would sit in the middle of +// the row instead of over the color. +//----------------------------------------------------------------------------- + +function AssetImageLayersEditRow::buildColorColumn(%this) +{ + %w = $AssetImageLayersEditRow::colorWidth; + %h = 32; + %lock = $AssetImageLayersEditRow::lockSize; + + %this.colorArea = new GuiControl() + { + HorizSizing = "width"; + VertSizing = "height"; + Position = $AssetImageLayersEditRow::colorX SPC 3; + Extent = %w SPC %h; + }; + ThemeManager.setProfile(%this.colorArea, "emptyProfile"); + %this.add(%this.colorArea); + + %this.colorBox = new GuiColorPopupCtrl() + { + HorizSizing = "width"; + VertSizing = "height"; + Position = "0 0"; + Extent = %w SPC %h; + showColorValues = true; + }; + ThemeManager.setProfile(%this.colorBox, "colorPickerProfile"); + ThemeManager.setProfile(%this.colorBox, "emptyProfile", "backgroundProfile"); + ThemeManager.setProfile(%this.colorBox, "colorPopupProfile", "popupProfile"); + ThemeManager.setProfile(%this.colorBox, "emptyProfile", "pickerProfile"); + ThemeManager.setProfile(%this.colorBox, "colorPickerSelectorProfile", "selectorProfile"); + ThemeManager.setProfile(%this.colorBox, "textEditProfile", "valueProfile"); + // Passed on to the popup's R/G/B/A boxes, which name their channel with a + // tooltip; without it each would fall back to a profile of its own. + ThemeManager.setProfile(%this.colorBox, "tipProfile", "TooltipProfile"); + %this.colorBox.Command = %this.getID() @ ".LayerColorChange();"; + %this.colorArea.add(%this.colorBox); + + %this.colorBox.setColorF(%this.LayerColor); + + // Drawn after the swatch, so it draws over it, and deaf to input so a click + // aimed at the swatch is not swallowed by the thing explaining why the swatch + // will not respond. + %this.lockIcon = new GuiSpriteCtrl() + { + HorizSizing = "center"; + VertSizing = "center"; + Position = ((%w - %lock) / 2) SPC ((%h - %lock) / 2); + Extent = %lock SPC %lock; + MinExtent = %lock SPC %lock; + Image = "EditorCore:EditorIcons16"; + ImageSize = "16 16"; + constrainProportions = "1"; + fullSize = "0"; + Frame = $EditorIcon::padlock_closed; + UseInput = false; + Visible = false; + }; + ThemeManager.setProfile(%this.lockIcon, "spriteProfile"); + %this.colorArea.add(%this.lockIcon); + + // Not the theme's label color, which is what every other icon in the editor + // takes: this one is read against the color being edited rather than against + // the panel, and the base is white until somebody has a reason to change it. + %this.lockIcon.setImageColor($AssetImageLayersEditRow::lockTint); +} + +// Row 0 alone is the asset's own image with nothing composed onto it, and a tint +// on that shows up nowhere. Every other row, and row 0 once it has company, is +// live. +function AssetImageLayersEditRow::refreshColorLock(%this) +{ + %this.setColorLocked(%this.LayerIndex == 0 && %this.LayerCount == 0); +} + +function AssetImageLayersEditRow::setColorLocked(%this, %locked) +{ + %this.colorLocked = %locked; + %this.colorBox.setActive(!%locked); + %this.lockIcon.setVisible(%locked); + + // The tooltip still reaches an inactive control -- the hit test does not ask + // whether a control is active -- so this is where the reason goes. + %this.colorBox.Tooltip = %locked + ? "This tints the base image that layers are drawn onto, and there are no layers yet. Add one and this unlocks." + : ""; } function AssetImageLayersEditRow::LayerImageChange(%this) @@ -154,10 +274,12 @@ } } +// getColorF, not getText: a layer's tint is a ColorF, so its four numbers run 0 +// to 1 and reading them off a ColorI swatch would round every one but white to +// black. function AssetImageLayersEditRow::LayerColorChange(%this) { - %color = %this.scrubColor(%this.colorBox.getText()); - %this.colorBox.setText(%color); + %color = %this.scrubColor(%this.colorBox.getColorF()); if(%color !$= %this.LayerColor) { @@ -186,10 +308,13 @@ return true; } +// Sent to every row whenever a layer is added or removed, which is also the only +// thing that can lock or unlock row 0's color. function AssetImageLayersEditRow::updateLayerCount(%this, %newCount) { %this.LayerCount = %newCount; %this.buttonBar.refreshEnabled(); + %this.refreshColorLock(); } function AssetImageLayersEditRow::MoveLayerUp(%this) @@ -213,7 +338,8 @@ %this.imageBox.setText(%this.LayerImage); %this.offsetXBox.setText(getWord(%this.LayerPosition, 0)); %this.offsetYBox.setText(getWord(%this.LayerPosition, 1)); - %this.colorBox.setText(%this.LayerColor); + %this.colorBox.setColorF(%this.LayerColor); + %this.refreshColorLock(); } function AssetImageLayersEditRow::onRemove(%this) diff --git a/editor/AssetAdmin/Inspector/AssetImageCellGrid.cs b/editor/AssetAdmin/Inspector/AssetImageCellGrid.cs new file mode 100644 index 000000000..37a08cb5f --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetImageCellGrid.cs @@ -0,0 +1,292 @@ + +//----------------------------------------------------------------------------- +// The eight values that cut an image into frames, as one small table: +// +// X Y +// Count [ 4 ] [ 2 ] +// Size [128 ] [128 ] +// Offset [ 0 ] [ 0 ] +// Stride [ 0 ] [ 0 ] +// +// [x] Row order +// +// Eight captioned field rows would say the same thing, and the stock inspector +// tries: it reflects them into an "X Values" group and a "Y Values" group, so a +// cell's width is four fields away from its height and the pairs that have to be +// read together never appear together. They are two columns of one table, and +// this draws them as one -- which also keeps them one cell of the pane's grid, +// so no reflow can ever split a pair across a column break. +// +// The table never writes the asset. It hands each edit to its owner -- +// owner.onCellGridCommit(%field, %value) -- so the pane stays the only thing +// that touches the asset, and the only thing that has to know that every write +// saves the file. The same reason GuiProfileEditorBorderGrid reports to its +// host rather than editing through it. +// +// The creator sets owner inline, then calls build() once after adding the table +// to its container -- the container decides how wide the cell is, so build() has +// to run after the add. It records the laid-out height in .gridHeight. +//----------------------------------------------------------------------------- + +function AssetImageCellGrid::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +// Row title TAB X field TAB Y field TAB X tip TAB Y tip. +function AssetImageCellGrid::rowTable(%this) +{ + return + "Count" TAB "CellCountX" TAB "CellCountY" TAB + "How many cells across the image." TAB + "How many cells down the image." NL + "Size" TAB "CellWidth" TAB "CellHeight" TAB + "The width of one cell, in pixels." TAB + "The height of one cell, in pixels." NL + "Offset" TAB "CellOffsetX" TAB "CellOffsetY" TAB + "The gap between the left edge of the image and the first cell." TAB + "The gap between the top edge of the image and the first cell." NL + "Stride" TAB "CellStrideX" TAB "CellStrideY" TAB + "From one cell's left edge to the next one's. Leave at 0 to use the cell width." TAB + "From one cell's top edge to the next one's. Leave at 0 to use the cell height."; +} + +// Every field the table edits, in one list, for the pane's greying. +function AssetImageCellGrid::fields(%this) +{ + return "CellCountX CellCountY CellWidth CellHeight CellOffsetX CellOffsetY CellStrideX CellStrideY CellRowOrder"; +} + +function AssetImageCellGrid::build(%this) +{ + // The width available, which is not the width taken: the table is eight boxes + // of two or three digits, and past a certain point a wider block only buys + // whitespace between them. So it measures what it is given, sizes its boxes + // to fit, and then shrinks to what it actually used. + // + // Which is also why it carries no sizing flag. A block that grows leaves the + // table where it is rather than stretching it into a row of very wide number + // boxes, and a block can never get narrower than the table needs -- the + // blocks have a 300-pixel floor and the table wants 216. + %avail = getWord(%this.getExtent(), 0); + %x0 = 4; + %labelW = 52; + %gap = 6; + %rowH = 26; + %boxH = 22; + %headerH = 16; + + // The boxes hold two or three digits, so they are sized for that -- a number + // box the width of half a pane reads as a text field with a number lost in + // it. Narrow enough and they give ground instead of clipping. + %room = %avail - %x0 - %labelW - %gap - 4; + %boxW = mGetMin(72, mGetMax(34, (%room - %gap) / 2)); + %w = %x0 + %labelW + %gap + %boxW + %gap + %boxW + 4; + + %colX[0] = %x0 + %labelW + %gap; + %colX[1] = %colX[0] + %boxW + %gap; + + // The two column captions. Nothing else says which box is which axis. + %axis = "X" TAB "Y"; + for(%c = 0; %c < 2; %c++) + { + %cap = new GuiControl() + { + Position = %colX[%c] SPC 2; + Extent = %boxW SPC %headerH; + Text = getField(%axis, %c); + align = "center"; + vAlign = "middle"; + UseInput = false; + }; + ThemeManager.setProfile(%cap, "labelProfile"); + %this.add(%cap); + } + + %rows = %this.rowTable(); + %count = getRecordCount(%rows); + for(%r = 0; %r < %count; %r++) + { + %rec = getRecord(%rows, %r); + %y = %headerH + 4 + (%r * %rowH); + + %label = new GuiControl() + { + Position = %x0 SPC %y; + Extent = %labelW SPC %boxH; + Text = getField(%rec, 0); + align = "left"; + vAlign = "middle"; + UseInput = false; + }; + ThemeManager.setProfile(%label, "labelProfile"); + %this.add(%label); + + for(%c = 0; %c < 2; %c++) + { + %this.makeBox(%colX[%c], %y, %boxW, %boxH, + getField(%rec, 1 + %c), getField(%rec, 3 + %c)); + } + } + + // Which way the frame numbers run. It only means anything once the image is + // cut both ways, but it is the last thing about the cut, so it belongs here + // rather than in a section of its own. + %uy = %headerH + 8 + (%count * %rowH); + %cbW = %w - %x0 - 4; + %this.rowOrderBox = new GuiCheckBoxCtrl() + { + Position = %x0 SPC %uy; + Extent = %cbW SPC 26; + Text = "Row order"; + boxOffset = "0 4"; + boxExtent = "18 18"; + textOffset = "26 4"; + textExtent = (%cbW - 26) SPC 18; + Tooltip = "Number the frames left to right, then top to bottom. Turn it off to number them down each column instead."; + Command = %this.getID() @ ".commitRowOrder();"; + }; + ThemeManager.setProfile(%this.rowOrderBox, "checkboxProfile"); + ThemeManager.setProfile(%this.rowOrderBox, "tipProfile", "TooltipProfile"); + %this.add(%this.rowOrderBox); + + %this.gridHeight = %uy + 30; + %this.setExtent(%w, %this.gridHeight); +} + +// One numeric box. The class is what gives it the arrow keys; the tip is kept on +// the box as well as set on it, because greying the table replaces every tooltip +// with the reason and has to be able to put them back. +function AssetImageCellGrid::makeBox(%this, %x, %y, %w, %h, %field, %tip) +{ + %box = new GuiTextEditCtrl() + { + class = "AssetImageCellInput"; + Position = %x SPC %y; + Extent = %w SPC %h; + inputMode = "Number"; + align = "center"; + Tooltip = %tip; + tipText = %tip; + cellField = %field; + grid = %this; + }; + ThemeManager.setProfile(%box, "textEditProfile"); + ThemeManager.setProfile(%box, "tipProfile", "TooltipProfile"); + %box.AltCommand = %this.getID() @ ".commitBox(" @ %box.getID() @ ");"; + %box.ReturnCommand = %this.getID() @ ".commitBox(" @ %box.getID() @ ");"; + %this.add(%box); + %this.box[%field] = %box; + return %box; +} + +//----------------------------------------------------------------------------- +// Values. +//----------------------------------------------------------------------------- + +// Load the asset's nine values. The populating guard keeps setText and +// setStateOn from echoing straight back through the commits. +function AssetImageCellGrid::load(%this, %asset) +{ + if(!isObject(%asset)) + { + return; + } + + %this.populating = true; + + %rows = %this.rowTable(); + %count = getRecordCount(%rows); + for(%r = 0; %r < %count; %r++) + { + %rec = getRecord(%rows, %r); + for(%c = 0; %c < 2; %c++) + { + %field = getField(%rec, 1 + %c); + %this.box[%field].setText(%asset.getFieldValue(%field)); + } + } + + %this.rowOrderBox.setStateOn(%asset.getCellRowOrder()); + + %this.populating = false; +} + +// Inert but still readable, which is what the table becomes in explicit frame +// mode: the values are still the ones the asset holds, they are simply not the +// ones it cuts by. Blanking them would lose what a user is about to go back to. +function AssetImageCellGrid::setEnabled(%this, %enabled, %reason) +{ + %rows = %this.rowTable(); + %count = getRecordCount(%rows); + for(%r = 0; %r < %count; %r++) + { + %rec = getRecord(%rows, %r); + for(%c = 0; %c < 2; %c++) + { + %box = %this.box[getField(%rec, 1 + %c)]; + %box.setActive(%enabled); + %box.Tooltip = %enabled ? %box.tipText : %reason; + } + } + + %this.rowOrderBox.setActive(%enabled); + %this.enabled = %enabled; +} + +//----------------------------------------------------------------------------- +// Commit. Nothing here writes the asset -- see the header. +//----------------------------------------------------------------------------- + +function AssetImageCellGrid::commitBox(%this, %box) +{ + // mFloor, not the text: a box left holding "12.0" would write "12.0" into a + // field the engine reads as a whole number of pixels. + %this.notify(%box.cellField, mFloor(%box.getText())); +} + +function AssetImageCellGrid::commitRowOrder(%this) +{ + %this.notify("CellRowOrder", %this.rowOrderBox.getStateOn()); +} + +function AssetImageCellGrid::notify(%this, %field, %value) +{ + if(%this.populating || !isObject(%this.owner)) + { + return; + } + + %this.owner.onCellGridCommit(%field, %value); +} + +//----------------------------------------------------------------------------- +// The numeric boxes: up and down nudge by one. A second class in this file, for +// the reason EditorFieldRow keeps its two -- it exists only to route one engine +// callback back to the widget that owns the box, and a file of its own would say +// nothing this one does not. +// +// Clicking places the caret, as it does in every other box in the editor; see +// the note in EditorFieldRow for why nothing re-selects here. +//----------------------------------------------------------------------------- + +function AssetImageCellInput::onUpArrow(%this) +{ + %this.nudge(1); +} + +function AssetImageCellInput::onDownArrow(%this) +{ + %this.nudge(-1); +} + +function AssetImageCellInput::nudge(%this, %delta) +{ + // None of these nine can be negative, and a cell count of zero is what the + // asset already means by "one row" -- so the floor is where the field's own + // validation is, not somewhere this decides. + %value = %this.getText() + %delta; + %this.setText(mGetMax(0, %value)); + %this.selectAllText(); + %this.grid.commitBox(%this); +} diff --git a/editor/AssetAdmin/Inspector/AssetImageInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetImageInspectorPane.cs new file mode 100644 index 000000000..0105d31f2 --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetImageInspectorPane.cs @@ -0,0 +1,332 @@ + +//----------------------------------------------------------------------------- +// The Asset Manager's Inspector tab for an image asset, in place of the generic +// C++ GuiInspector. Everything about laying fields out and moving values between +// them and the asset is in AssetInspectorPane; this is only what an ImageAsset +// holds and how it should read. +// +// There are no collapsible sections. The Gui Editor has them because a control +// carries dozens of fields; an image asset carries eleven, and eleven fit. So +// everything is in the open, as four blocks of roughly equal size in one grid: +// +// Identity name, category, the file +// Frames the eight cell values as one table, the row order, and a line +// saying what actually loaded and how it cut up +// Settings filter, color depth, and when it unloads +// Description the prose the library is searched by +// +// Four blocks and not four columns: the grid decides how many columns it can +// afford and the blocks flow into them, so the same pane is 1x4 in a tall narrow +// frame, 2x2 at the size the inspector opens at, and 4x1 across the foot of a +// wide screen. That last one is the case that made this a grid -- laid out as a +// stack, a 1600-pixel inspector was a column of fields down the left and a +// description box a yard wide, with most of the panel empty. +// +// AssetName is shown but not editable. AssetBase::setAssetName does nothing once +// the asset manager owns the asset (assetBase.h), so the stock inspector has +// always offered a box that silently did not work. A real rename is +// AssetDatabase.renameDeclaredAsset, which changes the asset id and rewrites +// every file referring to it -- its own piece of work, not a side effect of +// typing in a box. +// +// Five of the asset's values are deliberately absent. AssetInternal and +// AssetPrivate exist to keep an asset OUT of the editor, so an editor is the one +// place they are no use. The asset id is the module and the name, both of which +// are on show; the asset file is where the manager put it, and wanting a +// different one means wanting a different asset. +// +// BlendColor is the fifth, and it is absent because it is not really a property +// of the image: it tints the BASE LAYER that an asset's layers are composed +// onto, does nothing at all when there are no layers, and is edited in the row +// it belongs to on the Image Layers tab -- where the layer it tints is on screen +// beside it. Offered here it was a color picker that did nothing on nearly every +// asset in the library. +// +// ExplicitMode belongs to another tab and is only read here, to grey the cell +// table: the frames then come from the Explicit Frames tab. It reaches the pane +// the way any other change does -- the setter calls refreshAsset, which fires +// AssetBase::onRefresh, which tells the inspector. +//----------------------------------------------------------------------------- + +// The narrowest a block may get, and how many of them there are. +// +// 300 is chosen against the three widths that matter. The identity block has to +// hold a path and a Find button, which sets the floor; and with 4-pixel spacing +// the grid takes 1 column below 608, 2 up to 912, and 4 once it has 1216 -- so a +// tall narrow frame stacks, the size the inspector opens at is 2x2, and the foot +// of a wide screen is a single row of four. +$AssetImageInspectorPane::cellWidth = 300; +$AssetImageInspectorPane::cellCount = 4; + +// Deep enough that an empty description box reads as "the prose goes here" and +// stands about as tall as the blocks beside it. +$AssetImageInspectorPane::descriptionHeight = 150; + +function AssetImageInspectorPane::onAdd(%this) +{ + // onAdd does not chain, so the shared setup runs from here. + %this.init(); +} + +//----------------------------------------------------------------------------- +// Construction. Four blocks in one grid, then the warning beneath it. +//----------------------------------------------------------------------------- + +function AssetImageInspectorPane::buildPane(%this) +{ + %grid = %this.makeCellGrid(0, $AssetImageInspectorPane::cellWidth, + $AssetImageInspectorPane::cellCount); + %this.add(%grid); + %this.contentGrid = %grid; + + %this.buildIdentityCell(%grid); + %this.buildFramesCell(%grid); + %this.buildSettingsCell(%grid); + %this.buildDescriptionCell(%grid); + + %this.buildWarning(); + + // AssetName is the only row that is there to be read rather than changed, and + // nothing about a selection can make it editable, so it is said once. + %this.nameRow.setEnabled(false, + "Renaming an asset changes its id and every file that refers to it, so it is not done from here yet."); +} + +// What the asset is: its name, the category the library groups it under, and the +// picture itself. +function AssetImageInspectorPane::buildIdentityCell(%this, %grid) +{ + %cell = %this.makeCell(%grid); + %this.identityChain = %cell; + + %this.nameRow = %this.addFieldRow(%cell, "AssetName", "Asset Name", "text", ""); + %this.categoryRow = %this.addFieldRow(%cell, "AssetCategory", "Category", "text", ""); + %this.fileRow = %this.addFieldRow(%cell, "ImageFile", "Image File", "file", ""); +} + +// How it is cut into frames, and what that produced. The readout is in this +// block rather than across the pane because it is an answer to the table above +// it -- "512 x 512 pixels, 64 frames" is only interesting beside the numbers +// that decided it. +function AssetImageInspectorPane::buildFramesCell(%this, %grid) +{ + %cell = %this.makeCell(%grid); + %this.framesChain = %cell; + + // One widget rather than eight rows -- see AssetImageCellGrid for why. + %this.cellGrid = new GuiControl() + { + class = "AssetImageCellGrid"; + Position = "0 0"; + Extent = getWord(%cell.getExtent(), 0) SPC 160; + owner = %this; + }; + %cell.add(%this.cellGrid); + %this.cellGrid.build(); + + // Read-only, because none of it is a value the asset holds: it is the size of + // the picture that loaded and the number of frames the cell values actually + // produced. Wrapped, because a block is a quarter of the pane at its widest. + %this.infoLabel = %this.makeInfoLabel(%cell, "labelProfile"); + %this.infoLabel.textWrap = true; + %this.infoLabel.textExtend = true; + %this.infoLabel.vAlign = "top"; +} + +// How it is drawn, and when it lets go of its texture. +function AssetImageInspectorPane::buildSettingsCell(%this, %grid) +{ + %cell = %this.makeCell(%grid); + %this.settingsChain = %cell; + + %this.addFieldRow(%cell, "FilterMode", %this.labelFor("FilterMode"), + "enum", %this.enumItemsFor("FilterMode")); + %this.addFieldRow(%cell, "Force16bit", %this.labelFor("Force16bit"), "bool", ""); + %this.addFieldRow(%cell, "AssetAutoUnload", %this.labelFor("AssetAutoUnload"), "bool", ""); +} + +// What it is for. One field, and it fills the block: the library searches by +// this, so it is worth writing more than a few words in. +function AssetImageInspectorPane::buildDescriptionCell(%this, %grid) +{ + %cell = %this.makeCell(%grid); + %this.descriptionChain = %cell; + + %this.descriptionRow = %this.addFieldRow(%cell, "AssetDescription", + %this.labelFor("AssetDescription"), "multiline", ""); +} + +// Below the grid rather than in a block, and the only thing that is. A warning +// is a sentence, and a sentence read across the whole pane is one or two lines +// where the same sentence in a quarter of it is six. +// +// Hidden while there is nothing to say, so it costs no height -- a chain skips +// its hidden children. textWrap with textExtend so it takes the lines it needs; +// rendering is where that is measured, being the only place a font can be asked +// how wide a word is. +function AssetImageInspectorPane::buildWarning(%this) +{ + %this.warningLabel = %this.makeInfoLabel(%this, "overrideLabelProfile"); + %this.warningLabel.textWrap = true; + %this.warningLabel.textExtend = true; + %this.warningLabel.vAlign = "top"; + %this.warningLabel.setVisible(false); +} + +//----------------------------------------------------------------------------- +// Field presentation. +//----------------------------------------------------------------------------- + +function AssetImageInspectorPane::labelFor(%this, %field) +{ + switch$(%field) + { + case "AssetName": return "Asset Name"; + case "AssetCategory": return "Category"; + case "AssetDescription": return "Description"; + case "AssetAutoUnload": return "Auto Unload"; + case "ImageFile": return "Image File"; + case "FilterMode": return "Filter"; + case "Force16bit": return "16 Bit Color"; + } + return %field; +} + +function AssetImageInspectorPane::kindFor(%this, %field) +{ + switch$(%field) + { + case "AssetAutoUnload" or "Force16bit": return "bool"; + case "AssetDescription": return "multiline"; + case "ImageFile": return "file"; + case "FilterMode": return "enum"; + } + return "text"; +} + +function AssetImageInspectorPane::enumItemsFor(%this, %field) +{ + // The engine's own labels are NEAREST, BILINEAR and DEFAULT (textureFilterLookup + // in ImageAsset.cc). Both the lookup on the way in and the drop-down's own + // search ignore case, so these can read as words. + if(%field $= "FilterMode") + { + return "Default" TAB "Nearest" TAB "Bilinear"; + } + return ""; +} + +// The description has a block to itself, so it is not three lines deep. +function AssetImageInspectorPane::editorHeightFor(%this, %field) +{ + return (%field $= "AssetDescription") + ? $AssetImageInspectorPane::descriptionHeight : 0; +} + +// The stored path is absolute -- setImageFile expands whatever it is given +// against the asset's own folder -- and an absolute path is neither readable nor +// portable. Show the collapsed form, which is what the file on disk says. +function AssetImageInspectorPane::readField(%this, %field) +{ + if(%field $= "ImageFile") + { + return %this.target.getRelativeImageFile(); + } + return %this.target.getFieldValue(%field); +} + +//----------------------------------------------------------------------------- +// Loading. Everything the row loop does not reach. +//----------------------------------------------------------------------------- + +function AssetImageInspectorPane::refreshExtras(%this) +{ + %asset = %this.target; + + %this.cellGrid.load(%asset); + %this.cellGrid.setEnabled(!%asset.getExplicitMode(), + "Explicit frame mode is on, so the frames come from the Explicit Frames tab and these values are not used."); + + %this.infoLabel.setText(%this.describeImage(%asset)); + %this.showWarning(%this.warningFor(%asset)); +} + +// What loaded and what it cut into. Everything here is asked of the asset, never +// stored on it, which is why it is a line of text and not a row. +function AssetImageInspectorPane::describeImage(%this, %asset) +{ + %w = %asset.getImageWidth(); + %h = %asset.getImageHeight(); + + if(%w <= 0 || %h <= 0) + { + return "No image loaded."; + } + + // No "pixels" after the size. It is the one word here that says nothing a + // reader of an image asset did not already know, and without it the whole + // line fits on one line in a block a quarter of the pane wide. + %frames = %asset.getFrameCount(); + %text = %w @ " x " @ %h @ ", " @ %frames SPC ((%frames == 1) ? "frame" : "frames"); + + // Worth saying either way. A texture whose sides are not powers of two is + // legal here but is the first thing to look at when one will not load on a + // phone or in a browser. + return %text @ ", " @ (%asset.getIsImagePOT() ? "power of two" : "not a power of two"); +} + +// The three ways an image asset ends up looking wrong, in the order they matter. +// Each of them is currently a line in the console log and nothing on screen. +function AssetImageInspectorPane::warningFor(%this, %asset) +{ + if(%asset.getImageWidth() <= 0 || %asset.getImageHeight() <= 0) + { + // The size cap is the likeliest cause and the least discoverable one: + // TextureManager refuses a bitmap over 2048 on either side and says so + // only in the log, leaving a sprite that draws nothing at all. + return "This image did not load. Check that the file is where the path says, and that neither side is over 2048 pixels -- the engine refuses anything larger."; + } + + if(%asset.getExplicitMode()) + { + return "Explicit frame mode is on. The frames are the ones listed on the Explicit Frames tab, and the cell values above are not being used."; + } + + // The cut did not survive calculateImage, which warns to the console and + // falls back to treating the whole image as one frame. + %cx = %asset.getCellCountX(); + %cy = %asset.getCellCountY(); + if(%cx > 0 && %cy > 0 && %asset.getFrameCount() != (%cx * %cy)) + { + return "These cell values do not fit the image, so it is being used as a single frame. Check the sizes and offsets against the image size above."; + } + + return ""; +} + +// A chain skips hidden children when it lays out, but nothing re-lays it out on +// setVisible -- so the pane has to ask, and only when the answer changed. +function AssetImageInspectorPane::showWarning(%this, %text) +{ + %show = (%text !$= ""); + %this.warningLabel.setText(%text); + + if(%this.warningLabel.isVisible() == %show) + { + return; + } + + %this.warningLabel.setVisible(%show); + %this.forceLayout(); +} + +//----------------------------------------------------------------------------- +// Commits. +//----------------------------------------------------------------------------- + +// One of the cell table's nine values. It goes through commitValue like a row's +// would, so the pane stays the only thing that writes to the asset. +function AssetImageInspectorPane::onCellGridCommit(%this, %field, %value) +{ + %this.commitValue(%field, %value); +} diff --git a/editor/AssetAdmin/Inspector/AssetInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs new file mode 100644 index 000000000..c44b46df0 --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs @@ -0,0 +1,460 @@ + +//----------------------------------------------------------------------------- +// The shared half of an Asset Manager inspector pane: everything about laying +// fields out and moving values between them and an asset, and nothing about +// which fields an asset has. +// +// It exists because the stock C++ GuiInspector reflects every registered field +// into flat alphabetical groups, which for an ImageAsset means the eight cell +// values arrive split into "X Values" and "Y Values" -- so a cell's width never +// sits beside its height -- with nothing pinned open and nothing said about the +// image itself. Only image assets have a pane so far; the rest still use the +// inspector, which is why the pane's knowledge of a particular asset lives in +// the subclass rather than here. +// +// Layout is the arrangement GuiEditorInspectorPane and GuiProfileEditorProfileForm +// both use, and for the same reason: a vertical chain of blocks, each laying its +// fields out in a GuiGridCtrl, so widening the inspector frame reflows the cells +// into more columns instead of leaving dead space. The Asset Manager needs that +// more than either of them -- its inspector is the BOTTOM frame of the frame set +// (AssetAdmin::createFrameSet), so it opens about 700 wide and 360 tall and is +// dragged to whatever shape suits the work. +// +// The pane owns every write to the asset; its rows only marshal values. +// +// Subclassing. onAdd does not chain in TorqueScript (TORQUE_SCRIPT.md rule 8), +// so a subclass's onAdd calls init() first and the base drives from there: +// +// init() call from the subclass's onAdd +// build() call once after adding the pane to its scroller; it calls +// buildPane() which the subclass defines -- its whole layout +// labelFor() \ the subclass's field tables. The defaults are the field +// kindFor() } name and a text box, which is what an unlisted field +// enumItemsFor() / degrades to rather than vanishing. +// refreshExtras() anything the row loop does not reach +// afterCommit() anything the rest of the editor has to be told +// +// A subclass may also override readField/writeField, which is how a field whose +// stored form is not its editable form (an asset's loose file path) is handled +// without the row or the loop knowing. +//----------------------------------------------------------------------------- + +function AssetInspectorPane::init(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); + + // The narrowest a cell column may get. The grids run in Variable mode, so + // they fit as many columns as the pane can hold at this width and share the + // remainder evenly -- which is what makes dragging the frame wider add + // columns rather than whitespace. + %this.rowWidth = 220; + + // Half of that, for the sections whose fields are read in pairs. + %this.pairWidth = 152; + + %this.rowFields = ""; + %this.panelList = ""; + %this.target = ""; + %this.assetId = ""; +} + +//----------------------------------------------------------------------------- +// Construction. +//----------------------------------------------------------------------------- + +function AssetInspectorPane::build(%this) +{ + %this.buildPane(); + %this.forceLayout(); + %this.setVisible(false); +} + +// A GuiPanelCtrl learns its collapsed height only from parentResized -- its +// constructor defaults to 64x64 whatever Extent it was given, and a chain +// positions its children without ever resizing them. Nudging the width by a +// pixel and back forces exactly one parentResized through every child and +// leaves the widths where they started. +// +// Measured from the pane's CURRENT width rather than the width it was built at: +// the pane is HorizSizing "width" inside a filling scroller, so by the time +// anything calls this it is as wide as the frame, and nudging from the build +// width would snap it narrow until the next parent resize pushed it back. +function AssetInspectorPane::forceLayout(%this) +{ + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + %this.resize(0, 0, %w + 1, %h); + %this.resize(0, 0, %w, %h); +} + +// The grid configuration every block here uses. A hidden cell is skipped rather +// than left as a hole, so filtering closes the gap (GuiGridCtrl::resize). +// +// %cellW is the NARROWEST a column may be, not its width: the grid fits as many +// columns as the pane can hold at that size and shares the remainder evenly. +// Omit it for the ordinary one-field-per-row width. +// +// %maxCols caps the count, and a grid whose cells are blocks rather than single +// fields wants it. GuiGridCtrl works out its chain length from the width alone +// and never asks how many children it has (GetGridItemWidth), so a four-block +// grid on a wide screen computes six columns, fills four and leaves two empty -- +// and the blocks come out narrower than the width they were given. Capping at +// the number of blocks makes the last step 4-across rather than 4-of-6. +function AssetInspectorPane::makeCellGrid(%this, %y, %cellW, %maxCols) +{ + %grid = new GuiGridCtrl() + { + HorizSizing = "width"; + Position = "0" SPC %y; + Extent = %this.paneWidth SPC 4; + CellModeX = "variable"; + CellModeY = "variable"; + CellSizeX = (%cellW $= "") ? %this.rowWidth : %cellW; + CellSizeY = 48; + CellSpacingX = 4; + CellSpacingY = 4; + MaxColCount = (%maxCols $= "") ? 0 : %maxCols; + MaxRowCount = 0; + OrderMode = "lrtb"; + IsExtentDynamic = true; + }; + ThemeManager.setProfile(%grid, "emptyProfile"); + return %grid; +} + +// One cell of such a grid: a vertical chain that measures itself from what is +// put in it, so the grid's row grows to the tallest block rather than to a +// number written here. Added to the grid before anything goes in it, because the +// grid sizes a cell as it arrives and everything inside lays out to that width. +function AssetInspectorPane::makeCell(%this, %grid, %spacing) +{ + %cell = %this.makeChain(0, (%spacing $= "") ? 2 : %spacing); + %grid.add(%cell); + return %cell; +} + +// A plain vertical chain, for the places that stack full-width blocks rather +// than flowing cells. +function AssetInspectorPane::makeChain(%this, %y, %spacing) +{ + %chain = new GuiChainCtrl() + { + HorizSizing = "width"; + Position = "0" SPC %y; + Extent = %this.paneWidth SPC 4; + IsVertical = true; + ChildSpacing = %spacing; + }; + ThemeManager.setProfile(%chain, "emptyProfile"); + return %chain; +} + +// A collapsible section. Its cells sit in an inner grid rather than directly on +// the panel: GuiExpandCtrl::toggleHiddenChildren force-writes mVisible on every +// direct child whenever it expands, collapses or resizes, which would undo any +// filtering. Grandchildren are left alone and the grid skips the hidden ones. +function AssetInspectorPane::makeSectionPanel(%this, %title) +{ + %headerH = 24; + + %panel = new GuiPanelCtrl() + { + HorizSizing = "width"; + Text = %title; + Position = "0 0"; + Extent = %this.paneWidth SPC %headerH; + MinExtent = "80" SPC %headerH; + }; + ThemeManager.setProfile(%panel, "panelProfile"); + return %panel; +} + +// A read-only line of text. Used for the things an asset can only be asked, +// never told -- its size, how many frames it cuts into, why it did not load. +// +// Sized to the container it is going into rather than to the pane, and added +// here rather than by the caller, because a GuiChainCtrl positions its children +// without resizing them: a label authored at the pane's width and dropped into a +// block a quarter that wide does not wrap, it hangs off the end and is clipped. +// The sizing flag takes it from there. +function AssetInspectorPane::makeInfoLabel(%this, %container, %profile) +{ + %label = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = getWord(%container.getExtent(), 0) SPC 20; + MinExtent = "0 0"; + Text = ""; + align = "left"; + vAlign = "middle"; + UseInput = false; + }; + ThemeManager.setProfile(%label, %profile); + %container.add(%label); + return %label; +} + +//----------------------------------------------------------------------------- +// Rows. +//----------------------------------------------------------------------------- + +// Build a row without claiming a name for it, for the rare widget that reads +// like a field but is not one -- it must stay out of the registry the refresh +// loop walks. +function AssetInspectorPane::makeFieldRow(%this, %container, %field, %label, %kind, %enumItems) +{ + %row = new GuiControl() + { + class = "EditorFieldRow"; + + // A grid resizes every cell it lays out, which makes the flag moot there; + // a chain does not, so a row in one follows the pane's width from here. + HorizSizing = "width"; + Position = "0 0"; + Extent = getWord(%container.getExtent(), 0) SPC 48; + fieldName = %field; + labelText = %label; + kind = %kind; + enumItems = %enumItems; + editorHeight = %this.editorHeightFor(%field); + owner = %this; + }; + %container.add(%row); + %row.build(); + + // The row's reset button means "back to the theme's stamped value", which has + // no analogue for an asset: there is no layer under it to fall back to. + %row.resetButton.setVisible(false); + return %row; +} + +function AssetInspectorPane::addFieldRow(%this, %container, %field, %label, %kind, %enumItems) +{ + %row = %this.makeFieldRow(%container, %field, %label, %kind, %enumItems); + + %this.row[%field] = %row; + %this.rowFields = (%this.rowFields $= "") ? %field : (%this.rowFields SPC %field); + return %row; +} + +// A collapsible section holding one row per named field, with the labels and +// kinds coming from the subclass's tables. +function AssetInspectorPane::buildSection(%this, %key, %title, %fields, %cellW) +{ + %panel = %this.makeSectionPanel(%title); + %this.add(%panel); + + %grid = %this.makeCellGrid(24, %cellW); + %panel.add(%grid); + + %this.panel[%key] = %panel; + %this.panelFields[%key] = %fields; + %this.panelList = (%this.panelList $= "") ? %key : (%this.panelList SPC %key); + + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%fields, %i); + %this.addFieldRow(%grid, %field, %this.labelFor(%field), + %this.kindFor(%field), %this.enumItemsFor(%field)); + } + return %panel; +} + +//----------------------------------------------------------------------------- +// Field presentation. The defaults answer for any field at all, so a subclass +// that forgets one gets a text box named after it rather than nothing. +//----------------------------------------------------------------------------- + +function AssetInspectorPane::labelFor(%this, %field) +{ + return %field; +} + +function AssetInspectorPane::kindFor(%this, %field) +{ + return "text"; +} + +function AssetInspectorPane::enumItemsFor(%this, %field) +{ + return ""; +} + +// How deep a paragraph box should be. Zero takes the row's own three lines, +// which is right for a field sharing a block with others and wrong for one that +// has a whole cell of the grid to fill. +function AssetInspectorPane::editorHeightFor(%this, %field) +{ + return 0; +} + +//----------------------------------------------------------------------------- +// Binding. There is no rebuild here at all: the pane is built once for the kind +// of asset it edits, and binding only ever loads values -- so a selection change +// can never free a control the engine is mid-dispatch on. +//----------------------------------------------------------------------------- + +function AssetInspectorPane::bind(%this, %asset, %assetId) +{ + if(!isObject(%asset)) + { + %this.unbind(); + return; + } + + %this.target = %asset; + %this.assetId = %assetId; + + // What a "file" row's Find button measures its answer against. An asset's + // loose files are stored relative to the folder the asset itself lives in, + // not to the game root -- see EditorFieldRow::pathBase. + %this.findBase = AssetDatabase.getAssetPath(%assetId); + + %this.refresh(); + %this.forceLayout(); + %this.setVisible(true); +} + +// Nothing selected. The rows keep their values and the whole pane stops drawing, +// so nothing stale is left on show. +function AssetInspectorPane::unbind(%this) +{ + %this.target = ""; + %this.assetId = ""; + %this.setVisible(false); +} + +//----------------------------------------------------------------------------- +// Loading values. The populating guard keeps every setText / setColorF / +// setStateOn from echoing straight back through the commits. +//----------------------------------------------------------------------------- + +function AssetInspectorPane::refresh(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + %this.populating = true; + + %count = getWordCount(%this.rowFields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%this.rowFields, %i); + %row = %this.row[%field]; + if(isObject(%row)) + { + %row.setValue(%this.readField(%field)); + } + } + + %this.refreshExtras(); + + %this.populating = false; +} + +// Whatever the row loop does not reach: read-only readouts, widgets that are not +// field rows, and any greying that depends on the asset's current state. +function AssetInspectorPane::refreshExtras(%this) +{ +} + +// An asset changed underneath us -- possibly this pane's own commit coming back +// round, because every asset setter ends in refreshAsset(), and possibly a +// change made on one of the other tabs. +// +// The committing guard is what stops the bounce. Nothing here rebuilds, so the +// re-entry is not dangerous, but reloading every row in the middle of a commit +// would overwrite the box the user is still in. +function AssetInspectorPane::onAssetRefreshed(%this, %asset) +{ + if(%this.committing || !isObject(%this.target) || %this.target != %asset) + { + return; + } + + %this.refresh(); +} + +function AssetInspectorPane::readField(%this, %field) +{ + return %this.target.getFieldValue(%field); +} + +function AssetInspectorPane::writeField(%this, %field, %value) +{ + %this.target.setFieldValue(%field, %value); +} + +//----------------------------------------------------------------------------- +// Commits. Every write to the asset goes through here. +// +// Each of them also writes the asset's file: every setter on AssetBase and +// ImageAsset ends in refreshAsset(), which saves the .asset.taml then and there +// and cascades to anything depending on it. That is why a row commits on blur +// and on Enter and never per keystroke -- EditorFieldRow's AltCommand and +// ReturnCommand -- and why an unchanged row is left alone rather than written +// back over itself. +//----------------------------------------------------------------------------- + +function AssetInspectorPane::onFieldRowCommit(%this, %row) +{ + if(%this.populating || !isObject(%this.target) || !%row.hasChanged()) + { + return; + } + + %this.commitValue(%row.fieldName, %row.getValue()); + %row.markClean(); +} + +// One value, written and announced. Kept apart from the row handler so the +// widgets that are not rows -- a cell table, a toggle -- reach the asset the +// same way, through the same guard. +function AssetInspectorPane::commitValue(%this, %field, %value) +{ + if(!isObject(%this.target)) + { + return; + } + + // The write lands in refreshAsset, which comes straight back at + // onAssetRefreshed. The values here are already what the asset holds, so the + // bounce has nothing to say; what follows it does. + %this.committing = true; + %this.writeField(%field, %value); + %this.committing = false; + + %this.refresh(); + %this.afterCommit(); +} + +function AssetInspectorPane::onFieldRowReset(%this, %row) +{ +} + +// Everything a write has to tell the rest of the editor. +function AssetInspectorPane::afterCommit(%this) +{ +} + +//----------------------------------------------------------------------------- +// Enabling. A field an asset is currently ignoring stays visible but inert, with +// a tooltip saying why -- blanking it would leave no way to see what it holds. +//----------------------------------------------------------------------------- + +function AssetInspectorPane::setRowsEnabled(%this, %fields, %enabled, %reason) +{ + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %row = %this.row[getWord(%fields, %i)]; + if(isObject(%row)) + { + %row.setEnabled(%enabled, %reason); + } + } +} diff --git a/editor/AssetAdmin/Inspector/exec.cs b/editor/AssetAdmin/Inspector/exec.cs new file mode 100644 index 000000000..3f49bdb53 --- /dev/null +++ b/editor/AssetAdmin/Inspector/exec.cs @@ -0,0 +1,3 @@ +exec("./AssetInspectorPane.cs"); +exec("./AssetImageCellGrid.cs"); +exec("./AssetImageInspectorPane.cs"); diff --git a/editor/EditorCore/EditorCore.cs b/editor/EditorCore/EditorCore.cs index 4b68b7e30..b08636258 100644 --- a/editor/EditorCore/EditorCore.cs +++ b/editor/EditorCore/EditorCore.cs @@ -59,6 +59,11 @@ exec("./EditorToggleIcon.cs"); exec("./EditorChoiceRow.cs"); + // The field cell, here for the same reason: the Gui Editor grew it, and the + // Asset Manager's inspector panes are built at create time from a module that + // loads before the Gui Editor does. + exec("./EditorFieldRow.cs"); + exec("./EditorAssetPickerDialog.cs"); exec("./EditorAssetPickerItem.cs"); exec("./EditorPreferences.cs"); diff --git a/editor/GuiEditor/scripts/GuiProfileEditorFieldRow.cs b/editor/EditorCore/EditorFieldRow.cs similarity index 75% rename from editor/GuiEditor/scripts/GuiProfileEditorFieldRow.cs rename to editor/EditorCore/EditorFieldRow.cs index 2976759ea..3e99887b6 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorFieldRow.cs +++ b/editor/EditorCore/EditorFieldRow.cs @@ -1,38 +1,57 @@ //----------------------------------------------------------------------------- -// One field cell in the Gui Profile Editor's profile pane: a caption above an -// editor sized to the field's type, with a reset button that appears only while -// the field is overridden away from its theme's stamped value. +// One field cell in an editor's properties pane: a caption above an editor +// sized to the field's type, with a reset button beside it. +// +// Grown in the Gui Profile Editor and now shared -- the Gui Editor's inspector +// pane, the Profile Editor's three forms and the Asset Manager's asset panes all +// build their rows from this, which is why it lives in EditorCore rather than in +// the module that first wanted it. // // Caption-above-editor rather than caption-beside-editor because these are grid // cells: the pane flows them left-to-right and wraps into as many columns as the -// Properties pane is wide, the way the native inspector does, so a cell has to -// stay narrow. It also stops long captions ("Horizontal Align") from clipping. +// pane is wide, the way the native inspector does, so a cell has to stay narrow. +// It also stops long captions ("Horizontal Align") from clipping. // // The grid resizes every cell it lays out, so the widgets carry sizing flags // rather than fixed geometry: the caption and editor follow the cell width and // the reset button stays pinned to its right edge. // -// The row owns its widgets and nothing else. It never reads or writes the -// profile -- it hands values to its owner and takes them back, so the owner -// stays the single place that knows about theme overrides, array-indexed -// fields, and dirty marking. Commits arrive at owner.onProfileRowCommit(%row) -// and reset clicks at owner.onProfileRowReset(%row). +// The row owns its widgets and nothing else. It never reads or writes the thing +// being edited -- it hands values to its owner and takes them back, so the owner +// stays the single place that knows about theme overrides, array-indexed fields, +// and dirty marking. Commits arrive at owner.onFieldRowCommit(%row) and reset +// clicks at owner.onFieldRowReset(%row). // // The creator sets these inline: fieldName, labelText, kind, owner, and for kind -// "enum" the tab-separated enumItems. Call build() once after adding the row to -// its container -- the container decides the cell width, so build() has to run -// after the add. It records the laid-out height in .rowHeight. +// "enum" the tab-separated enumItems. Optionally swatchWidth (a "color" row that +// should not fill its cell) and editorHeight (how deep a "multiline" box is). +// Call build() once after adding the row to its container -- the container +// decides the cell width, so build() has to run after the add. It records the +// laid-out height in .rowHeight. +// +// Two things a row takes from its OWNER rather than from itself, because they +// are decisions the whole pane makes once: +// +// swatchClass the class a color popup wears. Empty gives a plain one; the +// Profile Editor points it at GuiProfileEditorColorPopup, which +// fills the swatch row from the theme in its tree. That class +// belongs to the Gui Editor module and cannot be named here. +// findBase what a "file" row's Find button makes its path relative to. +// The default is the game root, which is what a bitmap path +// means; an asset's loose file is relative to the asset's own +// folder instead. // -// Kinds: text, number, point, bool, color, enum, dropdown, file, asset. +// Kinds: text, number, decimal, point, pointf, bool, color, enum, dropdown, +// file, asset, multiline. //----------------------------------------------------------------------------- -function GuiProfileEditorFieldRow::onAdd(%this) +function EditorFieldRow::onAdd(%this) { ThemeManager.setProfile(%this, "emptyProfile"); } -function GuiProfileEditorFieldRow::build(%this) +function EditorFieldRow::build(%this) { // The grid sizes a cell the moment it is added, which is before build() // runs, so lay out against the width we actually have rather than the @@ -50,9 +69,16 @@ %captioned = %this.labelText !$= ""; %editorY = %captioned ? (%labelH + 4) : 2; - // A multiline row is three lines of a wrapped text box rather than one line of - // a plain one. Everything else about it is a text row. - %editorH = (%this.kind $= "multiline") ? 62 : 24; + // A multiline row is a wrapped text box several lines deep rather than one + // line of a plain one. Everything else about it is a text row. Three lines + // unless the creator asked for a particular depth -- a row that has a grid + // cell to itself can afford more, and an empty box the size of its neighbours + // says "this is where the prose goes" in a way a three-line one does not. + %editorH = 24; + if(%this.kind $= "multiline") + { + %editorH = (%this.editorHeight > 0) ? %this.editorHeight : 62; + } %h = %editorY + %editorH + 4; // The editor stops short of the reset button so the two never overlap once @@ -108,7 +134,7 @@ { %this.editor = new GuiDropDownCtrl() { - class = "GuiProfileEditorRowDropDown"; + class = "EditorFieldRowDropDown"; HorizSizing = "width"; Position = %pad SPC %editorY; Extent = %editorW SPC 22; @@ -192,7 +218,7 @@ class = "EditorIconButton"; // something to be chosen rather than typed. The caller supplies the method the // button calls; everything else about the two rows is identical, including the // button keeping its place at the cell's right edge as the grid widens. -function GuiProfileEditorFieldRow::makeFindRow(%this, %pad, %editorY, %editorW, %command) +function EditorFieldRow::makeFindRow(%this, %pad, %editorY, %editorW, %command) { %buttonW = 56; %this.editor = %this.makeInput(%pad, %editorY, %editorW - %buttonW - 4, 22, false, "width"); @@ -211,14 +237,14 @@ class = "EditorIconButton"; // True where the field holds a real number rather than a whole one. Kept as a // question about the kind rather than a flag on the row, because it is asked // from three places and has to answer the same way in all of them. -function GuiProfileEditorFieldRow::isDecimalKind(%this) +function EditorFieldRow::isDecimalKind(%this) { return %this.kind $= "decimal" || %this.kind $= "pointf"; } // A text box that commits on blur (AltCommand) and on Enter, matching how the // native inspector and the border grid apply their edits. -function GuiProfileEditorFieldRow::makeInput(%this, %x, %y, %w, %h, %numeric, %sizing) +function EditorFieldRow::makeInput(%this, %x, %y, %w, %h, %numeric, %sizing) { %decimal = %this.isDecimalKind(); @@ -231,7 +257,7 @@ class = "EditorIconButton"; // a text box meant nudge() swallowed both arrows and did nothing with // them, which is what stopped the caret moving between lines in the // multi-line box. - class = %numeric ? "GuiProfileEditorRowInput" : ""; + class = %numeric ? "EditorFieldRowInput" : ""; HorizSizing = %sizing; Position = %x SPC %y; Extent = %w SPC %h; @@ -256,11 +282,15 @@ class = %numeric ? "GuiProfileEditorRowInput" : ""; return %box; } -function GuiProfileEditorFieldRow::makeSwatch(%this, %x, %y, %w, %h) +// The swatch's class comes from the owner, not from here: the Profile Editor +// wants one that fills its swatch row from the theme under the tree, and that +// class lives in the Gui Editor module, which EditorCore knows nothing about. +// Empty gives the plain popup, which is what a pane with no theme to offer wants. +function EditorFieldRow::makeSwatch(%this, %x, %y, %w, %h) { %swatch = new GuiColorPopupCtrl() { - class = "GuiProfileEditorColorPopup"; + class = isObject(%this.owner) ? %this.owner.swatchClass : ""; // A fixed-width swatch stays put as the cell widens; a full-width one // follows it. HorizSizing = (%this.swatchWidth > 0) ? "anchorLeft" : "width"; @@ -291,25 +321,25 @@ class = "GuiProfileEditorColorPopup"; // from a text box that merely lost focus. The recorded form is whatever the // widget reads back, not what was passed in, because the two differ: a ColorI // field holding "White" comes back out of the swatch as "255 255 255 255". -function GuiProfileEditorFieldRow::setValue(%this, %value) +function EditorFieldRow::setValue(%this, %value) { %this.applyValue(%value); %this.lastValue = %this.getValue(); } // True when the widget now holds something other than what was loaded into it. -function GuiProfileEditorFieldRow::hasChanged(%this) +function EditorFieldRow::hasChanged(%this) { return %this.getValue() !$= %this.lastValue; } // Accept the widget's current contents as the new baseline, after a commit. -function GuiProfileEditorFieldRow::markClean(%this) +function EditorFieldRow::markClean(%this) { %this.lastValue = %this.getValue(); } -function GuiProfileEditorFieldRow::applyValue(%this, %value) +function EditorFieldRow::applyValue(%this, %value) { %kind = %this.kind; if(%kind $= "bool") @@ -344,7 +374,7 @@ class = "GuiProfileEditorColorPopup"; } } -function GuiProfileEditorFieldRow::getValue(%this) +function EditorFieldRow::getValue(%this) { %kind = %this.kind; if(%kind $= "bool") @@ -374,7 +404,7 @@ class = "GuiProfileEditorColorPopup"; // "12.0" into a Point2I. A real one is not: flooring a font size multiplier // turns every 1.5 into a 1, which is how a control that would not resize looked // like a control whose font size did nothing. -function GuiProfileEditorFieldRow::numberIn(%this, %box) +function EditorFieldRow::numberIn(%this, %box) { return %this.isDecimalKind() ? %box.getText() : mFloor(%box.getText()); } @@ -385,7 +415,7 @@ class = "GuiProfileEditorColorPopup"; // %items is tab-separated. The current selection survives a refill even when // the new list does not contain it (a font face outside the directory). -function GuiProfileEditorFieldRow::fillItems(%this, %items) +function EditorFieldRow::fillItems(%this, %items) { %selected = %this.currentItem; %this.editor.clearItems(); @@ -406,7 +436,7 @@ class = "GuiProfileEditorColorPopup"; } } -function GuiProfileEditorFieldRow::selectItem(%this, %value) +function EditorFieldRow::selectItem(%this, %value) { %this.currentItem = %value; if(%value $= "") @@ -432,7 +462,7 @@ class = "GuiProfileEditorColorPopup"; // A field the current control never reads stays visible but inert, so its value // is never lost -- the pane's Show All puts it back in reach. -function GuiProfileEditorFieldRow::setEnabled(%this, %enabled, %reason) +function EditorFieldRow::setEnabled(%this, %enabled, %reason) { %this.editor.setActive(%enabled); if(isObject(%this.editorY)) @@ -449,13 +479,13 @@ class = "GuiProfileEditorColorPopup"; // One field wears a different name depending on the category (cursorColor is a // text caret in one control and a focus rectangle in another), so the pane can // retitle a row after it is built. -function GuiProfileEditorFieldRow::setLabelText(%this, %text) +function EditorFieldRow::setLabelText(%this, %text) { %this.labelText = %text; %this.label.setText(%text); } -function GuiProfileEditorFieldRow::setOverridden(%this, %overridden) +function EditorFieldRow::setOverridden(%this, %overridden) { ThemeManager.setProfile(%this.label, %overridden ? "overrideLabelProfile" : "labelProfile"); %this.resetButton.setVisible(%overridden); @@ -467,7 +497,7 @@ class = "GuiProfileEditorColorPopup"; // mark spurious theme overrides. //----------------------------------------------------------------------------- -function GuiProfileEditorFieldRow::commit(%this) +function EditorFieldRow::commit(%this) { if(!isObject(%this.owner) || %this.owner.populating) { @@ -477,29 +507,49 @@ class = "GuiProfileEditorColorPopup"; { %this.currentItem = %this.editor.getText(); } - %this.owner.onProfileRowCommit(%this); + %this.owner.onFieldRowCommit(%this); } -function GuiProfileEditorFieldRow::onResetClicked(%this) +function EditorFieldRow::onResetClicked(%this) { if(isObject(%this.owner)) { - %this.owner.onProfileRowReset(%this); + %this.owner.onFieldRowReset(%this); } } +// What a chosen path is written back relative to. The game root is the right +// answer for a bitmap named in a profile -- it is the only form that means the +// same thing on somebody else's machine -- but an asset's loose file is stored +// relative to the asset's own folder, so a pane that edits one sets findBase. +function EditorFieldRow::pathBase(%this) +{ + if(isObject(%this.owner) && %this.owner.findBase !$= "") + { + return %this.owner.findBase; + } + return getMainDotCsDir(); +} + // The Find button on a "file" row. Picks a file and writes its path back into -// the box relative to the game root, which is the only form that means the same -// thing on somebody else's machine. -function GuiProfileEditorFieldRow::onFindClicked(%this) +// the box relative to whatever pathBase() says it should be measured from. +function EditorFieldRow::onFindClicked(%this) { + // Where the path is measured from, and where the dialog opens. They are the + // same folder when a pane has named one; with no base the path is the game + // root's but the dialog still opens on the project, which is where the + // pictures are. + %base = %this.pathBase(); + %start = (isObject(%this.owner) && %this.owner.findBase !$= "") + ? %base : pathConcat(%base, ProjectManager.getProjectFolder()); + %dialog = new OpenFileDialog() { Filters = "Image Files (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp|All Files (*.*)|*.*"; ChangePath = false; MultipleFiles = false; DefaultFile = ""; - defaultPath = pathConcat(getMainDotCsDir(), ProjectManager.getProjectFolder()); + defaultPath = %start; title = "Choose an Image"; }; %result = %dialog.execute(); @@ -511,7 +561,7 @@ class = "GuiProfileEditorColorPopup"; return; } - %this.editor.setText(makeRelativePath(%fileName, getMainDotCsDir())); + %this.editor.setText(makeRelativePath(%fileName, %base)); %this.commit(); } @@ -519,14 +569,14 @@ class = "GuiProfileEditorColorPopup"; // native inspector's browse button uses it too; it hands back the chosen id // through onAssetPicked. Whatever the box holds now is passed along so the // picker opens on the current choice. -function GuiProfileEditorFieldRow::onFindAssetClicked(%this) +function EditorFieldRow::onFindAssetClicked(%this) { EditorCore.openAssetPicker(%this, "onAssetPicked", %this.editor.getText(), "ImageAsset"); } // An asset id is already portable -- it names a module and an asset, not a // place on this machine -- so unlike a bitmap path it goes in as it came out. -function GuiProfileEditorFieldRow::onAssetPicked(%this, %assetId) +function EditorFieldRow::onAssetPicked(%this, %assetId) { %this.editor.setText(%assetId); %this.commit(); @@ -547,17 +597,17 @@ class = "GuiProfileEditorColorPopup"; // still selects everything - GuiTextEditCtrl::setFirstResponder does that - and // a click now does what a click does. -function GuiProfileEditorRowInput::onUpArrow(%this) +function EditorFieldRowInput::onUpArrow(%this) { %this.nudge(1); } -function GuiProfileEditorRowInput::onDownArrow(%this) +function EditorFieldRowInput::onDownArrow(%this) { %this.nudge(-1); } -function GuiProfileEditorRowInput::nudge(%this, %delta) +function EditorFieldRowInput::nudge(%this, %delta) { if(!%this.numeric) { @@ -572,7 +622,7 @@ class = "GuiProfileEditorColorPopup"; %this.row.commit(); } -function GuiProfileEditorRowDropDown::onSelect(%this) +function EditorFieldRowDropDown::onSelect(%this) { %this.owner.call(%this.selectMethod); } diff --git a/editor/GuiEditor/GuiEditor.cs b/editor/GuiEditor/GuiEditor.cs index b25eeb902..53a2c34f5 100644 --- a/editor/GuiEditor/GuiEditor.cs +++ b/editor/GuiEditor/GuiEditor.cs @@ -40,7 +40,6 @@ exec("./scripts/GuiProfileEditorBorderSetter.cs"); exec("./scripts/GuiProfileEditorBorderForm.cs"); exec("./scripts/GuiProfileEditorFieldSpec.cs"); - exec("./scripts/GuiProfileEditorFieldRow.cs"); exec("./scripts/GuiProfileEditorStateColorRow.cs"); exec("./scripts/GuiProfileEditorProfileForm.cs"); exec("./scripts/GuiProfileEditorCursorForm.cs"); diff --git a/editor/GuiEditor/scripts/GuiEditorDynamicFields.cs b/editor/GuiEditor/scripts/GuiEditorDynamicFields.cs index dbaf90886..772579fb0 100644 --- a/editor/GuiEditor/scripts/GuiEditorDynamicFields.cs +++ b/editor/GuiEditor/scripts/GuiEditorDynamicFields.cs @@ -146,7 +146,7 @@ { %row = new GuiControl() { - class = "GuiProfileEditorFieldRow"; + class = "EditorFieldRow"; Position = "0 0"; fieldName = %name; labelText = %name; @@ -158,7 +158,7 @@ class = "GuiProfileEditorFieldRow"; // The row's reset button is already a small icon pinned to the cell's right // edge, which is exactly where a remove wants to be, so it wears the bin; - // the row calls owner.onProfileRowReset when it is clicked, and for a + // the row calls owner.onFieldRowReset when it is clicked, and for a // dynamic field "reset" means "take it away". %row.resetButton.icon.setImageFrame($EditorIcon::trash); %row.resetButton.Tooltip = "Remove this field"; @@ -182,7 +182,7 @@ class = "GuiProfileEditorFieldRow"; // Editing. //----------------------------------------------------------------------------- -function GuiEditorDynamicFields::onProfileRowCommit(%this, %row) +function GuiEditorDynamicFields::onFieldRowCommit(%this, %row) { if(!isObject(%this.target) || !%row.hasChanged()) { @@ -214,7 +214,7 @@ class = "GuiProfileEditorFieldRow"; // Remove. A dynamic field is cleared by writing "" to it -- there is no delete // -- and the row goes with it, so this rebinds rather than trying to unpick one // cell from the grid. -function GuiEditorDynamicFields::onProfileRowReset(%this, %row) +function GuiEditorDynamicFields::onFieldRowReset(%this, %row) { if(!isObject(%this.target)) { diff --git a/editor/GuiEditor/scripts/GuiEditorInspectorPane.cs b/editor/GuiEditor/scripts/GuiEditorInspectorPane.cs index 1cb256108..d8e1ee478 100644 --- a/editor/GuiEditor/scripts/GuiEditorInspectorPane.cs +++ b/editor/GuiEditor/scripts/GuiEditorInspectorPane.cs @@ -38,6 +38,11 @@ { ThemeManager.setProfile(%this, "emptyProfile"); + // The font color swatch gets the popup that offers the theme's six colors. + // EditorFieldRow lives in EditorCore and cannot name a Gui Editor class, so + // the pane that wants one says so -- see its header. + %this.swatchClass = "GuiProfileEditorColorPopup"; + %this.spec = new ScriptObject() { class = "GuiEditorControlSpec"; @@ -364,7 +369,7 @@ class = "GuiEditorTextBlock"; { %row = new GuiControl() { - class = "GuiProfileEditorFieldRow"; + class = "EditorFieldRow"; // A grid resizes every cell it lays out, which makes the flag moot there; // a chain does not, so a row in one follows the pane's width from here. @@ -1520,7 +1525,7 @@ class = "GuiProfileEditorFieldRow"; // Commits. Every write to the control goes through here. //----------------------------------------------------------------------------- -function GuiEditorInspectorPane::onProfileRowCommit(%this, %row) +function GuiEditorInspectorPane::onFieldRowCommit(%this, %row) { if(%this.populating || !isObject(%this.target)) { @@ -1714,7 +1719,7 @@ class = "GuiProfileEditorFieldRow"; // else, which has no analogue for a control -- so every other row here hides // the button. The font color row keeps it, meaning "stop overriding the // profile's". -function GuiEditorInspectorPane::onProfileRowReset(%this, %row) +function GuiEditorInspectorPane::onFieldRowReset(%this, %row) { if(%row.fieldName !$= "fontColor" || !isObject(%this.target)) { diff --git a/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs b/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs index a134d24f3..45a8e317d 100644 --- a/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs +++ b/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs @@ -256,7 +256,7 @@ class = "EditorChoiceRow"; // written itself, per keystroke, so it puts the old value back and writes it // once - which is what makes an edit one step on the undo stack however many // keys it took. -function GuiEditorMenuItemBlock::onProfileRowCommit(%this, %row) +function GuiEditorMenuItemBlock::onFieldRowCommit(%this, %row) { if(%this.populating || !isObject(%this.pane.target)) { @@ -299,7 +299,7 @@ class = "EditorChoiceRow"; } // Nothing here has a reset button, so this is only ever the row asking politely. -function GuiEditorMenuItemBlock::onProfileRowReset(%this, %row) +function GuiEditorMenuItemBlock::onFieldRowReset(%this, %row) { } diff --git a/editor/GuiEditor/scripts/GuiProfileEditorBorderGrid.cs b/editor/GuiEditor/scripts/GuiProfileEditorBorderGrid.cs index b28949c5d..7496be560 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorBorderGrid.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorBorderGrid.cs @@ -246,7 +246,7 @@ class = "GuiProfileEditorBorderInput"; //----------------------------------------------------------------------------- // The numeric input boxes: up/down arrows nudge by 1. Clicking places the caret, // as it does in every other box in the editor - see the note in -// GuiProfileEditorFieldRow for why nothing re-selects here. +// EditorFieldRow for why nothing re-selects here. //----------------------------------------------------------------------------- function GuiProfileEditorBorderInput::onUpArrow(%this) diff --git a/editor/GuiEditor/scripts/GuiProfileEditorCursorForm.cs b/editor/GuiEditor/scripts/GuiProfileEditorCursorForm.cs index 223fc574c..11476a921 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorCursorForm.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorCursorForm.cs @@ -36,6 +36,11 @@ { ThemeManager.setProfile(%this, "emptyProfile"); %this.rowWidth = 200; + + // Every color row this form builds gets the popup that offers the selected + // theme's six colors. EditorFieldRow lives in EditorCore and cannot name a + // Gui Editor class, so the pane that wants one says so -- see its header. + %this.swatchClass = "GuiProfileEditorColorPopup"; } function GuiProfileEditorCursorForm::build(%this) @@ -289,7 +294,7 @@ class = "GuiProfileEditorColorPopup"; { %row = new GuiControl() { - class = "GuiProfileEditorFieldRow"; + class = "EditorFieldRow"; Position = "0 0"; fieldName = %field; labelText = %label; @@ -416,7 +421,7 @@ class = "GuiProfileEditorFieldRow"; // Edits. //----------------------------------------------------------------------------- -function GuiProfileEditorCursorForm::onProfileRowCommit(%this, %row) +function GuiProfileEditorCursorForm::onFieldRowCommit(%this, %row) { if(%this.populating || !isObject(%this.target)) { @@ -436,7 +441,7 @@ class = "GuiProfileEditorFieldRow"; %this.afterCommit(); } -function GuiProfileEditorCursorForm::onProfileRowReset(%this, %row) +function GuiProfileEditorCursorForm::onFieldRowReset(%this, %row) { %theme = %this.currentTheme(); if(!isObject(%theme) || !isObject(%this.target)) diff --git a/editor/GuiEditor/scripts/GuiProfileEditorProfileForm.cs b/editor/GuiEditor/scripts/GuiProfileEditorProfileForm.cs index 7d23a073a..4897798d2 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorProfileForm.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorProfileForm.cs @@ -36,6 +36,11 @@ { ThemeManager.setProfile(%this, "emptyProfile"); + // Every color row this form builds gets the popup that offers the selected + // theme's six colors. EditorFieldRow lives in EditorCore and cannot name a + // Gui Editor class, so the pane that wants one says so -- see its header. + %this.swatchClass = "GuiProfileEditorColorPopup"; + %this.fieldSpec = new ScriptObject() { class = "GuiProfileEditorFieldSpec"; @@ -146,7 +151,7 @@ class = "GuiProfileEditorFieldSpec"; // starts with none -- setting it there also picks the preview's sample. %this.categoryDrop = new GuiDropDownCtrl() { - class = "GuiProfileEditorRowDropDown"; + class = "EditorFieldRowDropDown"; Position = "38 28"; Extent = "150 22"; ConstantThumbHeight = false; @@ -290,7 +295,7 @@ class = "GuiProfileEditorRowDropDown"; { %row = new GuiControl() { - class = "GuiProfileEditorFieldRow"; + class = "EditorFieldRow"; Position = "0 0"; fieldName = %field; labelText = %label; @@ -668,7 +673,7 @@ class = "GuiProfileEditorStateColorRow"; // synchronous rebuild here can free a control the engine is mid-event on). //----------------------------------------------------------------------------- -function GuiProfileEditorProfileForm::onProfileRowCommit(%this, %row) +function GuiProfileEditorProfileForm::onFieldRowCommit(%this, %row) { if(%this.populating || !isObject(%this.target)) { @@ -699,7 +704,7 @@ class = "GuiProfileEditorStateColorRow"; return; } - // As in onProfileRowCommit: a swatch that came back holding what was loaded + // As in onFieldRowCommit: a swatch that came back holding what was loaded // into it is not an edit, and must not record a theme override. if(!%row.hasChanged(%index)) { @@ -711,7 +716,7 @@ class = "GuiProfileEditorStateColorRow"; %this.afterCommit(); } -function GuiProfileEditorProfileForm::onProfileRowReset(%this, %row) +function GuiProfileEditorProfileForm::onFieldRowReset(%this, %row) { %theme = %this.currentTheme(); if(!isObject(%theme) || !isObject(%this.target)) diff --git a/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs b/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs index 18ff118df..bce4085d4 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs @@ -46,7 +46,7 @@ function GuiProfileEditorStateColorRow::build(%this) { // The grid has already sized this cell by the time build() runs, so lay out - // against the width we actually have. See GuiProfileEditorFieldRow::build. + // against the width we actually have. See EditorFieldRow::build. %w = getWord(%this.getExtent(), 0); %pad = 4; %resetW = 24; diff --git a/tests/shots/assetImageInspector.cs b/tests/shots/assetImageInspector.cs new file mode 100644 index 000000000..23cf562c8 --- /dev/null +++ b/tests/shots/assetImageInspector.cs @@ -0,0 +1,176 @@ +// Visual harness for the Asset Manager's image inspector. Four shots: +// +// 0 the pane as it opens -- the wide, short bottom frame the inspector is, +// where the four blocks sit two by two +// 1 the same width with the frame dragged taller, where everything fits +// 2 tall and narrow, where every block stacks into one column +// 3 wide again, with an impossible cell cut, to see the warning line +// 4 on a 1600-wide screen, where the four blocks become a single row +// 5 the Image Layers tab, base row alone: the color picker wears a padlock +// 6 the same with a layer added, so the base color unlocks +// +// What only a picture can settle: whether the identity block and the cell table +// balance beside each other, whether the info and warning lines read as +// statements rather than as fields, and whether the whole thing survives being +// reshaped. None of that is checkable by assertion beyond "the numbers are what +// I wrote" -- tests/smoke/assetImageInspector.cs does that part. +// +// Run: tests/run.ps1 -Shots assetImageInspector ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +// Gems is 512 x 512 cut into 8 x 8 cells of 64, so every readout has something +// to say. +$aiAssetId = "ToyAssets:Gems"; + +testExec("editor/main.cs"); +schedule(2500, 0, "aOpenProject"); + +function aOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + // The pane writes the asset's file on every commit, so shot 3 works on a copy + // -- the same rule the smoke test follows. tests/run.ps1 sweeps the folder by + // reading the spelled-out name out of this file. + createPath(testRoot("shots/")); + ProjectManager.setProjectFolder("assetImageInspectorShotProject"); + EditorPreferences.path = testRoot("shots/assetImageInspectorShotPrefs.taml"); + + %copy = testRoot("assetImageInspectorShotProject/ToyAssets"); + pathCopy(testRoot("toybox/ToyAssets"), %copy, false); + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + + schedule(2500, 0, "aOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function aOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "aSelectAsset"); +} + +function aSelectAsset() +{ + AssetAdmin.Dictionary["ImageAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + $aiTile = AssetAdmin.Dictionary["ImageAsset"].getButton($aiAssetId); + $aiTile.onClick(); + + $aiPane = AssetAdmin.inspector.imagePane; + schedule(1200, 0, "aDefaultShot"); +} + +function aGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging, so a + // tree that has never run a shot writes nothing and says nothing. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/assetImageInspector" @ %name @ ".png"), "PNG"); +} + +function aDefaultShot() +{ + aGrab(0); + + // The same width with the divider dragged up, which is the shape somebody + // editing an asset would settle into: everything fits with no scrolling. + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, 480); + + schedule(1200, 0, "aTallShot"); +} + +function aTallShot() +{ + aGrab(1); + + // Tall and narrow: give the library most of the width and the inspector most + // of what is left of the height. This is the shape the reflow exists for. + %canvas = Canvas.getExtent(); + AssetAdmin.content.setFrameSize(AssetAdmin.libraryFrameId, + getWord(%canvas, 0) - 400); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, + getWord(%canvas, 1) - 220); + + schedule(1200, 0, "aNarrowShot"); +} + +function aNarrowShot() +{ + aGrab(2); + + AssetAdmin.content.setFrameSize(AssetAdmin.libraryFrameId, 324); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, 360); + + schedule(1200, 0, "aWarnShot"); +} + +// Eight cells of 128 needs 1024 pixels and the image is 512, so the asset +// refuses the cut and falls back to one frame. The pane says so. +function aWarnShot() +{ + %grid = $aiPane.cellGrid; + %grid.box["CellWidth"].setText(128); + %grid.commitBox(%grid.box["CellWidth"]); + + schedule(900, 0, "aWideScreen"); +} + +// The case the grid exists for, and the only one the default test window is too +// small to show: at 1600 the inspector runs the width of the screen and the four +// blocks become a single row instead of a column of fields with a yard of empty +// panel beside it. +function aWideScreen() +{ + aGrab(3); + + %grid = $aiPane.cellGrid; + %grid.box["CellWidth"].setText(64); + %grid.commitBox(%grid.box["CellWidth"]); + + setScreenMode(1600, 900, 32, false); + schedule(1500, 0, "aLayersTab"); +} + +// The color the inspector used to carry, in the row it belongs to. Row 0 is the +// asset's own image -- the base every layer is drawn onto -- and its tint is +// locked while it is alone, because there is nothing composed onto it for a tint +// to show up on. +// +// Pages for an image asset: Inspector, Explicit Frames, Image Layers. +function aLayersTab() +{ + aGrab(4); + + AssetAdmin.inspector.tabBook.selectPage(2); + schedule(900, 0, "aLockedShot"); +} + +function aLockedShot() +{ + aGrab(5); + + AssetAdmin.inspector.imageLayersEditPage.addNewLayer(); + schedule(900, 0, "aFinish"); +} + +function aFinish() +{ + aGrab(6); + + echo("SHOTS DONE"); + schedule(500, 0, "quit"); +} diff --git a/tests/shots/cursorPane.cs b/tests/shots/cursorPane.cs index 81e274460..4a946c239 100644 --- a/tests/shots/cursorPane.cs +++ b/tests/shots/cursorPane.cs @@ -92,7 +92,7 @@ function sGrabTinted() %d = GuiEditor.profileEditorDialog; %d.cursorForm.onAnchorPreset(0.5, 0.5); %d.cursorForm.row["hotSpot"].applyValue("6 -4"); - %d.cursorForm.onProfileRowCommit(%d.cursorForm.row["hotSpot"]); + %d.cursorForm.onFieldRowCommit(%d.cursorForm.row["hotSpot"]); schedule(500, 0, "sGrabAnchored"); } diff --git a/tests/smoke/assetImageInspector.cs b/tests/smoke/assetImageInspector.cs new file mode 100644 index 000000000..8310874a0 --- /dev/null +++ b/tests/smoke/assetImageInspector.cs @@ -0,0 +1,575 @@ +// Asset Manager image-inspector smoke test. Drives the custom pane that replaced +// the generic GuiInspector for image assets: the four reflowing blocks, the +// read-only rows, the commit path, and the notification chain that carries a +// change made anywhere else back to the pane. Then the Image Layers tab's color +// column, which is where the blend color lives now. +// Run: tests/run.ps1 assetImageInspector ; grep AIMG in tests/logs/. +// +// Driven by calling the pane rather than by posting input. Where a row sits on +// screen depends on how many columns the grid chose and how far the scroller has +// been dragged, neither of which script can read -- so a click at a computed +// point would be testing the arithmetic in this file. +// +// NOTE: a COPY of toybox/ToyAssets, never the module itself. Committing a field +// writes the asset straight back to its own file -- every setter ends in +// refreshAsset -- so aimed at the repository copy this test would rewrite tracked +// content. The copy goes inside the throwaway project folder, which +// tests/run.ps1 deletes before every run. +// +// NOTE: EditorPreferences writes to the tester's real per-user application data +// folder. Step 1 redirects it for the duration, as assetLibrary does. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function aiCheck(%label, %cond) +{ + if(%cond) echo("AIMG PASS: " @ %label); + else echo("AIMG FAIL: " @ %label); +} + +// A control that ends past the bottom of the one holding it is a control whose +// last few pixels are clipped away, and on a scroller those pixels are its down +// arrow. Both scrollers are checked, because they are built the same way. +function aiFitsParent(%label, %child, %parent) +{ + %bottom = getWord(%child.getPosition(), 1) + getWord(%child.getExtent(), 1); + %room = getWord(%parent.getExtent(), 1); + aiCheck(%label SPC "(" @ %bottom SPC "of" SPC %room @ ")", %bottom <= %room); +} + +// Gems is 512 x 512 cut into 8 x 8 cells of 64 -- a picture whose every number +// is checkable, and a power of two, so the info line has all three things to say. +$aiAssetId = "ToyAssets:Gems"; + +function aiLoadFixtureAssets() +{ + %copy = testRoot("assetImageInspectorSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "aiStep1"); + +//----------------------------------------------------------------------------- +// Opening the Asset Manager on an image asset. +//----------------------------------------------------------------------------- + +function aiStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable, here and in the copy path + // above: tests/run.ps1 finds the folder to delete by reading this file for + // setProjectFolder("..."), so a name it cannot see is a folder it cannot + // sweep. + ProjectManager.setProjectFolder("assetImageInspectorSmokeProject"); + EditorPreferences.path = testRoot("shots/assetImageInspectorSmokePrefs.taml"); + + aiCheck("fixture asset module registered", aiLoadFixtureAssets()); + + // Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, + // GuiEditor. Selecting the tab is what calls AssetAdmin::open. + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "aiStep2"); +} + +//----------------------------------------------------------------------------- +// The pane exists and stands down until an image asset is chosen. +//----------------------------------------------------------------------------- + +function aiStep2() +{ + $aiInspector = AssetAdmin.inspector; + $aiPane = $aiInspector.imagePane; + + aiCheck("image pane built", isObject($aiPane)); + aiCheck("image pane is an AssetImageInspectorPane", + $aiPane.getClassNamespace() $= "AssetImageInspectorPane"); + aiCheck("image pane inherits the shared pane", + $aiPane.getSuperClassNamespace() $= "AssetInspectorPane"); + aiCheck("image pane starts hidden", !$aiInspector.imageScroller.isVisible()); + aiCheck("generic inspector is the one on show", $aiInspector.insScroller.isVisible()); + + $aiTile = AssetAdmin.Dictionary["ImageAsset"].getButton($aiAssetId); + aiCheck("the fixture gave the library " @ $aiAssetId, isObject($aiTile)); + + if(!isObject($aiTile)) + { + echo("AIMG ABORT: no fixture asset, the rest of the run would prove nothing"); + schedule(300, 0, "quit"); + return; + } + + $aiTile.onClick(); + schedule(400, 0, "aiStep3"); +} + +//----------------------------------------------------------------------------- +// Binding. The pane takes over the Inspector page for an image asset. +//----------------------------------------------------------------------------- + +function aiStep3() +{ + $aiAsset = AssetDatabase.acquireAsset($aiAssetId); + + aiCheck("image pane is on show", $aiInspector.imageScroller.isVisible()); + aiCheck("generic inspector stood down", !$aiInspector.insScroller.isVisible()); + aiCheck("pane is bound to the asset", $aiPane.target == $aiAsset); + aiCheck("pane knows the asset id", $aiPane.assetId $= $aiAssetId); + aiCheck("the delete button acts on the bound asset", + $aiInspector.inspectedObject() == $aiAsset); + + // A "file" row's Find button measures its answer from the asset's own folder, + // not from the game root -- that is what the stored path is relative to. + aiCheck("find base is the asset's folder", + $aiPane.findBase $= AssetDatabase.getAssetPath($aiAssetId)); + + // The tab page is resized twice on the way up -- once when it joins the book, + // again when the frame set gives the window its real size -- and a scroller + // that kept its original gap to each edge came out taller than the page, + // which clipped its down arrow off the bottom. + aiFitsParent("image scroller fits its page", + $aiInspector.imageScroller, $aiInspector.insPage); + aiFitsParent("generic inspector scroller fits its page", + $aiInspector.insScroller, $aiInspector.insPage); + aiFitsParent("tab book fits the inspector", + $aiInspector.tabBook, $aiInspector); + + schedule(200, 0, "aiStep4"); +} + +//----------------------------------------------------------------------------- +// The header: what the asset is. +//----------------------------------------------------------------------------- + +function aiStep4() +{ + aiCheck("name row shows the asset name", $aiPane.nameRow.getValue() $= "Gems"); + + // The stored path is absolute; the collapsed one is what the file on disk + // says and the only one that means anything to a reader. + %file = $aiPane.fileRow.getValue(); + aiCheck("file row shows the relative path (" @ %file @ ")", %file $= "Gems.png"); + aiCheck("file row is a file row, so it has a Find button", + isObject($aiPane.fileRow.findButton)); + + // Renaming is not wired up, so the one row that cannot be changed says why. + aiCheck("name row is read-only", !$aiPane.nameRow.editor.isActive()); + aiCheck("name row says why it is read-only", + strstr($aiPane.nameRow.editor.Tooltip, "Renaming") != -1); + + // Four values the pane deliberately does not carry: two that exist to keep an + // asset out of the editor, and two that only restate what is already on show. + aiCheck("internal is not offered", !isObject($aiPane.row["AssetInternal"])); + aiCheck("private is not offered", !isObject($aiPane.row["AssetPrivate"])); + aiCheck("asset id is not offered", !isObject($aiPane.assetIdRow)); + aiCheck("asset file is not offered", !isObject($aiPane.assetFileRow)); + + // And the ones that are not. + aiCheck("category row is editable", $aiPane.categoryRow.editor.isActive()); + aiCheck("file row is editable", $aiPane.fileRow.editor.isActive()); + + schedule(200, 0, "aiStep5"); +} + +//----------------------------------------------------------------------------- +// The cell table, and the line that says what the engine made of it. +//----------------------------------------------------------------------------- + +function aiStep5() +{ + %grid = $aiPane.cellGrid; + aiCheck("cell table built", isObject(%grid)); + aiCheck("cell count X loaded", %grid.box["CellCountX"].getText() == 8); + aiCheck("cell count Y loaded", %grid.box["CellCountY"].getText() == 8); + aiCheck("cell width loaded", %grid.box["CellWidth"].getText() == 64); + aiCheck("cell height loaded", %grid.box["CellHeight"].getText() == 64); + aiCheck("cell offset X loaded", %grid.box["CellOffsetX"].getText() == 0); + aiCheck("cell stride Y loaded", %grid.box["CellStrideY"].getText() == 0); + aiCheck("row order loaded", %grid.rowOrderBox.getStateOn() == $aiAsset.getCellRowOrder()); + + // Width beside height, which is the whole reason the table exists: the stock + // inspector puts them in an "X Values" group and a "Y Values" group four + // fields apart. + aiCheck("width and height share a line", + getWord(%grid.box["CellWidth"].getPosition(), 1) + == getWord(%grid.box["CellHeight"].getPosition(), 1)); + aiCheck("width and height are side by side", + getWord(%grid.box["CellWidth"].getPosition(), 0) + < getWord(%grid.box["CellHeight"].getPosition(), 0)); + + %info = $aiPane.infoLabel.getText(); + aiCheck("info line gives the size (" @ %info @ ")", strstr(%info, "512 x 512") == 0); + aiCheck("info line counts the frames", strstr(%info, "64 frames") != -1); + aiCheck("info line reports power of two", strstr(%info, "power of two") != -1); + aiCheck("nothing to warn about", !$aiPane.warningLabel.isVisible()); + + // The info line lives in the frames block, and a block is a quarter of the + // pane at its widest -- so it has to be as wide as its block and no wider. A + // chain positions its children without resizing them, so a label authored at + // the pane's width hangs off the end of the block and is clipped rather than + // wrapped. + aiCheck("info line is sized to its block", + getWord($aiPane.infoLabel.getExtent(), 0) + <= getWord($aiPane.framesChain.getExtent(), 0)); + aiCheck("cell table is sized to its block", + getWord($aiPane.cellGrid.getExtent(), 0) + <= getWord($aiPane.framesChain.getExtent(), 0)); + + schedule(200, 0, "aiStep6"); +} + +//----------------------------------------------------------------------------- +// Committing. A cell value written from the table reaches the asset, and what +// the asset made of it comes back. +//----------------------------------------------------------------------------- + +function aiStep6() +{ + %grid = $aiPane.cellGrid; + + %grid.box["CellCountX"].setText(4); + %grid.commitBox(%grid.box["CellCountX"]); + + aiCheck("a cell commit reaches the asset", $aiAsset.getCellCountX() == 4); + aiCheck("the asset recut itself (" @ $aiAsset.getFrameCount() @ ")", + $aiAsset.getFrameCount() == 32); + + // The commit reloads the pane, so the readout is the asset's answer rather + // than the number that was typed. + aiCheck("info line followed the recut", + strstr($aiPane.infoLabel.getText(), "32 frames") != -1); + aiCheck("the table reloaded from the asset", + %grid.box["CellCountX"].getText() == 4); + + // An impossible cut: 8 cells of 128 needs 1024 pixels and the image is 512, + // so calculateImage refuses it and falls back to one frame. That used to be a + // line in the console and nothing on screen. + %grid.box["CellWidth"].setText(128); + %grid.commitBox(%grid.box["CellWidth"]); + %grid.box["CellCountX"].setText(8); + %grid.commitBox(%grid.box["CellCountX"]); + + aiCheck("an impossible cut warns", + $aiPane.warningLabel.isVisible() && + strstr($aiPane.warningLabel.getText(), "do not fit") != -1); + + // Back to what the asset was authored with. + %grid.box["CellWidth"].setText(64); + %grid.commitBox(%grid.box["CellWidth"]); + + aiCheck("the warning clears again", !$aiPane.warningLabel.isVisible()); + aiCheck("restored to 64 frames", $aiAsset.getFrameCount() == 64); + + schedule(200, 0, "aiStep7"); +} + +//----------------------------------------------------------------------------- +// A change made anywhere else. Explicit frame mode belongs to another tab, and +// turning it on has to reach the pane -- refreshAsset fires AssetBase::onRefresh, +// which tells the inspector. +//----------------------------------------------------------------------------- + +function aiStep7() +{ + $aiAsset.setExplicitMode(true); + + aiCheck("explicit mode greys the cell table", !$aiPane.cellGrid.enabled); + aiCheck("a greyed box says why", + strstr($aiPane.cellGrid.box["CellCountX"].Tooltip, "Explicit frame mode") != -1); + aiCheck("explicit mode warns", + $aiPane.warningLabel.isVisible() && + strstr($aiPane.warningLabel.getText(), "Explicit Frames tab") != -1); + + $aiAsset.setExplicitMode(false); + + aiCheck("leaving explicit mode gives the table back", $aiPane.cellGrid.enabled); + aiCheck("a box gets its own tip back", + strstr($aiPane.cellGrid.box["CellCountX"].Tooltip, "How many cells") == 0); + aiCheck("and the warning goes", !$aiPane.warningLabel.isVisible()); + + schedule(200, 0, "aiStep8"); +} + +//----------------------------------------------------------------------------- +// The sections. A field row commits the same way the table does. +//----------------------------------------------------------------------------- + +function aiStep8() +{ + // No collapsible sections: eleven fields fit in the open, and a section header + // is a thing to click before you can read anything. + aiCheck("nothing is hidden behind a panel", !isObject($aiPane.panel["Rendering"]) + && !isObject($aiPane.panel["Asset"])); + + // Four blocks in one grid, which is what lets the same pane be a column, a + // square or a row. + aiCheck("the content is one grid of four blocks", + isObject($aiPane.contentGrid) && $aiPane.contentGrid.getCount() == 4); + aiCheck("the grid is capped at four columns", + $aiPane.contentGrid.MaxColCount == 4); + + aiCheck("filter is a drop-down", $aiPane.row["FilterMode"].kind $= "enum"); + aiCheck("filter offers three modes", + $aiPane.row["FilterMode"].editor.getItemCount() == 3); + + // The blend color is not here. It tints the base layer that an asset's layers + // are composed onto, does nothing at all when there are no layers -- which is + // nearly every image asset -- and is edited on the Image Layers tab, in the + // row for the thing it tints. Offered here it was a picker that did nothing. + aiCheck("blend color is not offered here", !isObject($aiPane.row["BlendColor"])); + + // A description is a paragraph, and the library searches by it. + %row = $aiPane.row["AssetDescription"]; + aiCheck("description is a paragraph box", %row.kind $= "multiline"); + + %row.editor.setText("a smoke test description"); + $aiPane.onFieldRowCommit(%row); + aiCheck("a row commit reaches the asset", + $aiAsset.AssetDescription $= "a smoke test description"); + aiCheck("the library tile heard about it", + strstr($aiTile.searchKey, "a smoke test description") != -1); + + // An unchanged row is left alone: every write saves the asset's file, so a + // box the user only tabbed through must not rewrite it. + $aiAsset.AssetCategory = "Smoke"; + $aiPane.refresh(); + %before = $aiPane.categoryRow.getValue(); + $aiPane.onFieldRowCommit($aiPane.categoryRow); + aiCheck("an untouched row writes nothing", + %before $= "Smoke" && $aiAsset.AssetCategory $= "Smoke"); + + schedule(200, 0, "aiStep9"); +} + +//----------------------------------------------------------------------------- +// Reflow. The inspector is the bottom frame of the frame set, so it opens wide +// and short and is dragged to whatever shape suits the work. +//----------------------------------------------------------------------------- + +// How many columns the four blocks are currently spread across: the number of +// them sharing the top row. Read from where the grid actually put them rather +// than from the arithmetic that placed them, so it disagrees when the layout is +// wrong. +function aiColumnCount() +{ + %grid = $aiPane.contentGrid; + %topY = getWord(%grid.getObject(0).getPosition(), 1); + + %count = 0; + for(%i = 0; %i < %grid.getCount(); %i++) + { + if(getWord(%grid.getObject(%i).getPosition(), 1) == %topY) + { + %count++; + } + } + return %count; +} + +// The reflow, at the three widths it was designed against. Driven by resizing +// the pane, because the widest of them is wider than the test canvas -- the +// frame-driven case follows in aiStep9Narrow, and that is the one that catches +// the width failing to arrive. +function aiStep9() +{ + %h = getWord($aiPane.getExtent(), 1); + + // Put back afterwards. The pane follows its scroller by the CHANGE in width + // rather than by recomputing from it, so a width written here by hand is one + // the scroller never agrees to take back -- and the frame-driven checks below + // would then be measuring this line rather than the editor. + %natural = getWord($aiPane.getExtent(), 0); + + $aiPane.resize(0, 0, 380, %h); + aiCheck("a narrow pane stacks the blocks (" @ aiColumnCount() @ " across)", + aiColumnCount() == 1); + + $aiPane.resize(0, 0, 672, %h); + aiCheck("the pane as it opens is two by two (" @ aiColumnCount() @ " across)", + aiColumnCount() == 2); + + // The foot of a wide screen. The cap is what stops the grid working out six + // columns from the width, filling four of them and leaving two empty. + $aiPane.resize(0, 0, 1600, %h); + aiCheck("a wide pane is a single row (" @ aiColumnCount() @ " across)", + aiColumnCount() == 4); + aiCheck("wide, the blocks share the width evenly", + getWord($aiPane.settingsChain.getExtent(), 0) + == getWord($aiPane.identityChain.getExtent(), 0)); + aiCheck("wide, the blocks reach the right-hand edge", + getWord($aiPane.descriptionChain.getPosition(), 0) + + getWord($aiPane.descriptionChain.getExtent(), 0) >= 1580); + + $aiPane.resize(0, 0, %natural, %h); + + // Leave the library most of the width and the inspector most of the height, + // so the inspector becomes the tall narrow column the reflow exists for. + AssetAdmin.content.setFrameSize(AssetAdmin.libraryFrameId, + getWord(Canvas.getExtent(), 0) - 400); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, + getWord(Canvas.getExtent(), 1) - 220); + + schedule(400, 0, "aiStep9Narrow"); +} + +function aiStep9Narrow() +{ + // The window has to fit the frame the divider left it. A frame set moves its + // divider whatever the window's MinExtent says, and a window that refuses to + // follow simply hangs off the right-hand edge and is clipped -- so the + // measurement that catches it is against the library beside it, not against + // anything inside the window. + %right = getWord(AssetAdmin.inspectorWindow.getPosition(), 0) + + getWord(AssetAdmin.inspectorWindow.getExtent(), 0); + %libLeft = getWord(AssetAdmin.libWindow.getPosition(), 0); + aiCheck("narrow, the inspector stays out of the library (" @ %right SPC %libLeft @ ")", + %right <= %libLeft); + + %paneW = getWord($aiPane.getExtent(), 0); + %viewW = getWord($aiInspector.imageScroller.getExtent(), 0); + aiCheck("narrow, the pane fits what can be seen of it (" @ %paneW SPC %viewW @ ")", + %paneW <= %viewW); + + aiCheck("narrow, the content grid fits the pane", + getWord($aiPane.contentGrid.getExtent(), 0) <= %paneW); + aiCheck("narrow, the description fits the pane", + getWord($aiPane.descriptionRow.getExtent(), 0) <= %paneW); + aiCheck("narrow, the blocks stack (" @ aiColumnCount() @ " across)", + aiColumnCount() == 1); + + // The Find button is the right-hand end of the widest row in the header, so + // it is the first thing to fall off the edge when the pane does not shrink. + %find = $aiPane.fileRow.findButton; + aiCheck("narrow, the Find button is still on screen", + getWord(%find.getPosition(), 0) + getWord(%find.getExtent(), 0) <= %paneW); + + AssetAdmin.content.setFrameSize(AssetAdmin.libraryFrameId, 324); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, 360); + + schedule(400, 0, "aiStep10"); +} + +//----------------------------------------------------------------------------- +// Unbinding. Choosing an asset of another kind hands the page back. +//----------------------------------------------------------------------------- + +function aiStep10() +{ + $aiInspector.loadFontAsset($aiAsset, $aiAssetId); + + aiCheck("another asset kind gets the generic inspector", + $aiInspector.insScroller.isVisible() && !$aiInspector.imageScroller.isVisible()); + aiCheck("the image pane let go of its asset", !isObject($aiPane.target)); + + // And back, so nothing about the swap is one-way. + $aiTile.onClick(); + $aiInspector.loadImageAsset($aiAsset, $aiAssetId); + aiCheck("and it takes the page back", $aiInspector.imageScroller.isVisible()); + aiCheck("rebound to the asset", $aiPane.target == $aiAsset); + + schedule(300, 0, "aiStep11"); +} + +//----------------------------------------------------------------------------- +// The Image Layers tab, which is where the blend color went. Row 0 is not a +// layer anybody added -- it is the asset's own image, the base every other row +// is drawn onto, and its tint IS the asset's BlendColor. It is locked while it +// is alone, because a tint on a base with nothing composed onto it shows up +// nowhere. +//----------------------------------------------------------------------------- + +// Rows in the chain: 0 is the header, 1 is the base, and a layer N is at N + 1. +function aiLayerRow(%index) +{ + return $aiLayers.rowChain.getObject(%index + 1); +} + +function aiStep11() +{ + $aiLayers = $aiInspector.imageLayersEditPage; + %base = aiLayerRow(0); + + aiCheck("the layers tab has a base row", isObject(%base) && %base.LayerIndex == 0); + aiCheck("the color column is a picker, not a text box", + %base.colorBox.getClassName() $= "GuiColorPopupCtrl"); + aiCheck("the picker offers the exact numbers too", %base.colorBox.showColorValues); + + // Alone, so locked. + aiCheck("the base color is locked while it is alone", + $aiAsset.getLayerCount() == 0 && !%base.colorBox.isActive()); + aiCheck("a padlock says so without being hovered", %base.lockIcon.isVisible()); + aiCheck("the padlock is drawn dark over the swatch (" @ %base.lockIcon.imageColor @ ")", + getWord(%base.lockIcon.imageColor, 0) == 0 + && getWord(%base.lockIcon.imageColor, 3) < 128); + aiCheck("and the swatch says why if you do hover", + strstr(%base.colorBox.Tooltip, "no layers yet") != -1); + + $aiLayers.addNewLayer(); + schedule(300, 0, "aiStep12"); +} + +function aiStep12() +{ + %base = aiLayerRow(0); + %layer = aiLayerRow(1); + + aiCheck("adding a layer built a row for it", isObject(%layer) && %layer.LayerIndex == 1); + aiCheck("a layer unlocks the base color", + %base.colorBox.isActive() && !%base.lockIcon.isVisible()); + aiCheck("the base swatch stops explaining itself", %base.colorBox.Tooltip $= ""); + aiCheck("a real layer's color was never locked", + %layer.colorBox.isActive() && !%layer.lockIcon.isVisible()); + + // The base row now writes the asset's blend color. Through the picker, as a + // ColorF -- the same four numbers read as a ColorI would round to black. + %base.colorBox.setColorF("1 0.5 0.25 1"); + %base.LayerColorChange(); + + %blend = $aiAsset.getBlendColor(); + aiCheck("the base row writes the asset's blend color (" @ %blend @ ")", + getWord(%blend, 0) > 0.9 && getWord(%blend, 1) > 0.4 && getWord(%blend, 1) < 0.6); + aiCheck("and the layer above keeps its own color (" @ $aiAsset.getLayerBlendColor(1) @ ")", + getWord($aiAsset.getLayerBlendColor(1), 1) > 0.9); + + // And a layer row writes that layer's. + %layer.colorBox.setColorF("0.25 1 0.5 1"); + %layer.LayerColorChange(); + aiCheck("a layer row writes its own color (" @ $aiAsset.getLayerBlendColor(1) @ ")", + getWord($aiAsset.getLayerBlendColor(1), 0) < 0.4); + + // Through the button's own path, schedule and all, rather than reaching past + // it -- the deferral exists because the button raising the event is about to + // be deleted. + %layer.RemoveLayer(); + schedule(400, 0, "aiStep13"); +} + +function aiStep13() +{ + %base = aiLayerRow(0); + + aiCheck("taking the layer away locks the base color again", + $aiAsset.getLayerCount() == 0 && !%base.colorBox.isActive()); + aiCheck("and the padlock comes back", %base.lockIcon.isVisible()); + + AssetDatabase.releaseAsset($aiAssetId); + schedule(400, 0, "quit"); +} diff --git a/tests/smoke/assetPicker.cs b/tests/smoke/assetPicker.cs index d7bcf451f..9f0db240b 100644 --- a/tests/smoke/assetPicker.cs +++ b/tests/smoke/assetPicker.cs @@ -326,7 +326,7 @@ function fStep6() // Reset must undo an asset the same as any other field. %form = GuiEditor.profileEditorDialog.profileForm; - %form.onProfileRowReset($fRow); + %form.onFieldRowReset($fRow); fCheck("reset cleared the asset override", !$fTheme.isFieldOverridden(%form.target, "imageAsset")); fCheck("reset hid the row's reset button", !$fRow.resetButton.isVisible()); @@ -346,7 +346,7 @@ function fStep6() // This block used to drive the native GuiInspector, whose GuiInspectorTypeAsset // baked an EditorCore.openAssetPicker call straight into a "..." button's // Command. The Gui Editor no longer builds an inspector -- GuiEditorInspectorPane -// replaced it -- so the path under test is now GuiProfileEditorFieldRow's +// replaced it -- so the path under test is now EditorFieldRow's // "asset" kind, which routes the click through onFindAssetClicked instead of a // baked-in command string. Same promise, one indirection later. // diff --git a/tests/smoke/cursorPane.cs b/tests/smoke/cursorPane.cs index 6ef083f13..079f822ee 100644 --- a/tests/smoke/cursorPane.cs +++ b/tests/smoke/cursorPane.cs @@ -144,20 +144,20 @@ function cStep2() // either has to move it. It used to report the pixel the dot had left, // which reads as the magnifier disagreeing with its own numbers. %d.cursorForm.row["hotSpot"].applyValue("5 6"); - %d.cursorForm.onProfileRowCommit(%d.cursorForm.row["hotSpot"]); + %d.cursorForm.onFieldRowCommit(%d.cursorForm.row["hotSpot"]); cCheck("a typed nudge moved where the cursor points", %editor.getEffectiveHotSpot() $= "5 6"); cCheck("the readout followed the typed nudge", strstr(%d.cursorForm.readout.getText(), "5, 6") >= 0); %d.cursorForm.row["hotSpot"].applyValue("1 1"); - %d.cursorForm.onProfileRowCommit(%d.cursorForm.row["hotSpot"]); + %d.cursorForm.onFieldRowCommit(%d.cursorForm.row["hotSpot"]); // --- A tint edit is an override; the art is not. --- // applyValue rather than setValue: setValue also records the new value as // the baseline, so the commit that follows would see nothing changed -- the // guard that stops a text box committing a field the user only tabbed past. %d.cursorForm.row["color"].applyValue("10 20 30 255"); - %d.cursorForm.onProfileRowCommit(%d.cursorForm.row["color"]); + %d.cursorForm.onFieldRowCommit(%d.cursorForm.row["color"]); cCheck("tint committed to the cursor", getWord(%cursor.color, 0) == 10 && getWord(%cursor.color, 2) == 30); cCheck("tint counts as a theme override", %theme.isFieldOverridden(%cursor, "color")); diff --git a/tests/smoke/cursorSlots.cs b/tests/smoke/cursorSlots.cs index 4a18ba223..6fea40779 100644 --- a/tests/smoke/cursorSlots.cs +++ b/tests/smoke/cursorSlots.cs @@ -125,7 +125,7 @@ function sStep3() %row = %pane.row["nWSECursor"]; %row.applyValue($sExtra.getName()); - %pane.onProfileRowCommit(%row); + %pane.onFieldRowCommit(%row); sCheck("choosing the extra wrote it to the control", $sWindow.nWSECursor $= $sExtra.getName()); diff --git a/tests/smoke/inspectorPane.cs b/tests/smoke/inspectorPane.cs index c2ea4c791..cef671212 100644 --- a/tests/smoke/inspectorPane.cs +++ b/tests/smoke/inspectorPane.cs @@ -609,7 +609,7 @@ function pStep8() pCheck("dynamic field survived reselect", isObject(%dyn.row["smokeTag"])); // Remove, which is the row's reset button repurposed. - %dyn.onProfileRowReset(%dyn.row["smokeTag"]); + %dyn.onFieldRowReset(%dyn.row["smokeTag"]); schedule(100, 0, "pStep9"); } diff --git a/tests/smoke/inspectorText.cs b/tests/smoke/inspectorText.cs index 52b370979..7089ca4b0 100644 --- a/tests/smoke/inspectorText.cs +++ b/tests/smoke/inspectorText.cs @@ -215,7 +215,7 @@ function tStep4() // movement, and isMethod answers for the class -- so the spinner class on a // text box swallowed both arrows and left the caret unable to change line. tCheck("the spinner class is on the number box", - %row.editor.class $= "GuiProfileEditorRowInput"); + %row.editor.class $= "EditorFieldRowInput"); tCheck("and not on the text box", %pane.row["text"].editor.class $= ""); tCheck("so the text box has no onUpArrow to claim the key", !%pane.row["text"].editor.isMethod("onUpArrow")); @@ -337,7 +337,7 @@ function tStep6() tCheck("the revert appeared", %row.resetButton.isVisible()); // The revert is the only way back to the profile's color. - %pane.onProfileRowReset(%row); + %pane.onFieldRowReset(%row); tCheck("revert turned the override off", !$tHeading.overrideFontColor); tCheck("the swatch fell back to the profile's color", %row.getValue() $= tProfileOf($tHeading).fontColor); diff --git a/tests/smoke/profileForm.cs b/tests/smoke/profileForm.cs index dc08fb35e..aa9072f13 100644 --- a/tests/smoke/profileForm.cs +++ b/tests/smoke/profileForm.cs @@ -237,7 +237,7 @@ function fStep4() %form.onShowAllToggled(); // --- Reset puts a field back to the theme's stamped value. --- - %form.onProfileRowReset(%form.row["fontSize"]); + %form.onFieldRowReset(%form.row["fontSize"]); fCheck("field override cleared by reset", !$fTheme.isFieldOverridden(%profile, "fontSize")); fCheck("field value restamped by reset", %profile.fontSize == %oldSize); fCheck("field reset button hidden again", !%form.row["fontSize"].resetButton.isVisible()); diff --git a/tests/smoke/textClick.cs b/tests/smoke/textClick.cs index 552c284e9..a7d1d57b2 100644 --- a/tests/smoke/textClick.cs +++ b/tests/smoke/textClick.cs @@ -3,7 +3,7 @@ // Driven by textClick.input.ps1, which posts a real WM_LBUTTONDOWN/UP into the // middle of the box, so the whole engine input path is exercised. // -// Old behaviour: GuiProfileEditorRowInput::onTouchDown re-selected everything +// Old behaviour: EditorFieldRowInput::onTouchDown re-selected everything // after the engine had placed the caret, which left the caret at the END of the // text and the selection anchored at the start. //----------------------------------------------------------------------------- @@ -49,7 +49,7 @@ function clickStep1() $box = new GuiTextEditCtrl() { - class = "GuiProfileEditorRowInput"; + class = "EditorFieldRowInput"; Position = "200 200"; Extent = "500 30"; Text = $clickText; From f1ce6e270cb5bb3765d923542e9cee9360a32200 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 10:59:55 -0400 Subject: [PATCH 05/26] An animation kept the frame numbers from an image's old cut AnimationAsset::onAssetRefresh called nothing but its empty parent, so the validated frame list was only ever built when the animation itself changed. A refresh reaches it from two directions, though: the asset manager walks the depended-on list, so re-cutting the image underneath an animation refreshes the animation too -- and that pass did nothing. The list then still held indices from the old cut, and getImageFrameArea clamps an out-of-range index to the last frame rather than failing. So an animation whose image had been cut into fewer cells went on playing, silently, out of the wrong frames. Nothing in the log, nothing in the editor. One call to validateFrames() puts it right, and it is cheap: a pass over a list of tens of integers, on an asset's own refresh. The test needs a real image asset with a real texture behind it, which rules out a unit test -- loading a font or a texture with no GL context trips a modal assert that arrives as a hang. So it is a smoke suite, on the barbarian death animation: 25 frames drawn from the last five rows of a 10 x 10 sheet, which halving the cut puts entirely out of range. It fails two checks without the fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- engine/source/2d/assets/AnimationAsset.cc | 9 +- tests/smoke/animationFrameValidation.cs | 154 ++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/smoke/animationFrameValidation.cs diff --git a/engine/source/2d/assets/AnimationAsset.cc b/engine/source/2d/assets/AnimationAsset.cc index a9f57a033..9d6582d20 100755 --- a/engine/source/2d/assets/AnimationAsset.cc +++ b/engine/source/2d/assets/AnimationAsset.cc @@ -135,12 +135,19 @@ void AnimationAsset::onRemove() //------------------------------------------------------------------------------ -void AnimationAsset::onAssetRefresh( void ) +void AnimationAsset::onAssetRefresh( void ) { // Ignore if not yet added to the sim. if ( !isProperlyAdded() ) return; + // Re-validate the frames. A refresh reaches us both when we were changed + // ourselves and when the image asset we depend on was, and the image may have + // been re-cut into a different number of cells. Without this the validated + // list keeps indices from the old cut, and getImageFrameArea() clamps them to + // the last frame -- so the animation plays the wrong art and says nothing. + validateFrames(); + // Call parent. Parent::onAssetRefresh(); } diff --git a/tests/smoke/animationFrameValidation.cs b/tests/smoke/animationFrameValidation.cs new file mode 100644 index 000000000..7ec2de012 --- /dev/null +++ b/tests/smoke/animationFrameValidation.cs @@ -0,0 +1,154 @@ +// An animation asset re-validates its frames when the image underneath it is +// re-cut. AnimationAsset::onAssetRefresh used to call nothing but its empty +// parent, so the validated list kept indices from the old cut -- and +// ImageAsset::getImageFrameArea CLAMPS an out-of-range index rather than +// failing, so the animation quietly played the wrong art and said nothing. +// Run: tests/run.ps1 animationFrameValidation ; grep AFV in tests/logs/. +// +// Here rather than in a C++ unit test because it needs a real image asset with a +// real texture behind it, and a unit test has no GL context -- loading one trips +// a modal assert that arrives as a hang. +// +// NOTE: a COPY of toybox/ToyAssets, never the module itself. setCellCountY ends +// in refreshAsset, which writes the .asset.taml straight back to its own file; +// aimed at the repository copy this test would rewrite tracked content. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function afvCheck(%label, %cond) +{ + if(%cond) echo("AFV PASS: " @ %label); + else echo("AFV FAIL: " @ %label); +} + +// The barbarian death animation: 10 x 10 cells of 96, and 25 frames drawn from +// the last five rows -- so halving the cut leaves every one of them out of range, +// which is the case that used to go unnoticed. +$afvAnimId = "ToyAssets:TD_Barbarian_Death"; +$afvImageId = "ToyAssets:TD_Barbarian_CompSprite"; + +function afvLoadFixtureAssets() +{ + %copy = testRoot("animationFrameValidationSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "afvStep1"); + +//----------------------------------------------------------------------------- +// The fixture, uncut. +//----------------------------------------------------------------------------- + +function afvStep1() +{ + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."), so a name it + // cannot see is a folder it cannot sweep. + ProjectManager.setProjectFolder("animationFrameValidationSmokeProject"); + + afvCheck("fixture asset module registered", afvLoadFixtureAssets()); + + // Both have to be acquired, not just named. AssetManager::refreshAsset walks + // the depended-on list but skips anything that is not loaded, so an animation + // nobody holds never hears that its image moved. + $afvAnim = AssetDatabase.acquireAsset($afvAnimId); + $afvImage = AssetDatabase.acquireAsset($afvImageId); + + afvCheck("animation asset acquired", isObject($afvAnim)); + afvCheck("image asset acquired", isObject($afvImage)); + + $afvSpecified = trim($afvAnim.getAnimationFrames()); + + afvCheck("image is cut into 100 frames", $afvImage.getFrameCount() == 100); + afvCheck("animation specifies 25 frames", $afvAnim.getAnimationFrameCount() == 25); + afvCheck("nothing is clamped while every frame is in range", + trim($afvAnim.getAnimationFrames(true)) $= $afvSpecified); + + schedule(300, 0, "afvStep2"); +} + +//----------------------------------------------------------------------------- +// Re-cut the image to half the rows. Every frame the animation names is now out +// of range, and the validated list must say so. +//----------------------------------------------------------------------------- + +function afvStep2() +{ + $afvImage.setCellCountY(5); + + afvCheck("image is now cut into 50 frames", $afvImage.getFrameCount() == 50); + + // What the user asked for does not change. Only what will be drawn does -- + // which is the distinction the inspector's warning line is built on. + afvCheck("the specified list is untouched by a re-cut", + trim($afvAnim.getAnimationFrames()) $= $afvSpecified); + + %validated = trim($afvAnim.getAnimationFrames(true)); + + afvCheck("the validated list no longer matches what was specified", + %validated !$= $afvSpecified); + afvCheck("the validated list is still 25 frames long", + $afvAnim.getAnimationFrameCount(true) == 25); + + // validateNumericalFrames clamps an out-of-range frame to frameCount - 1 + // rather than dropping it, so all 25 collapse onto frame 49. + afvCheck("every out-of-range frame clamped to the last one", afvAllWords(%validated, 49)); + + schedule(300, 0, "afvStep3"); +} + +//----------------------------------------------------------------------------- +// Put the rows back. Validation is a live derivation, not a one-way trip. +//----------------------------------------------------------------------------- + +function afvStep3() +{ + $afvImage.setCellCountY(10); + + afvCheck("image is back to 100 frames", $afvImage.getFrameCount() == 100); + afvCheck("the validated list recovers when the frames come back", + trim($afvAnim.getAnimationFrames(true)) $= $afvSpecified); + + AssetDatabase.releaseAsset($afvAnimId); + AssetDatabase.releaseAsset($afvImageId); + + echo("AFV DONE"); + schedule(200, 0, "quit"); +} + +//----------------------------------------------------------------------------- + +function afvAllWords(%list, %value) +{ + %count = getWordCount(%list); + if(%count == 0) + { + return false; + } + + for(%i = 0; %i < %count; %i++) + { + if(getWord(%list, %i) != %value) + { + return false; + } + } + return true; +} From 9b81e997e788fb1f6a9dd4c2018ee186986af8cc Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 11:02:44 -0400 Subject: [PATCH 06/26] Drawing one frame of a sheet was a tree's private business GuiTreeViewCtrl::drawIconFrame is three lines that have nothing to do with trees: given an image asset and a frame number, stretch that frame into a rect. GuiEditorExplorerTree already reached up into its base class for it to draw the eye and padlock icons, and the animation editor's frame palette and timeline will both want the same thing while being no relation to a tree at all. So it moves to guiDefaultControlRender as renderImageAssetFrame, beside the two functions a reader would otherwise find first and misuse. The comment explaining why this is not renderStretchedImageAsset moves with it and now sits directly under that function, where the contrast is visible: one reads its sheet off a profile and can only draw what a control is wearing, the other is handed the asset; one clears the bitmap modulation and would throw away a row's ink, the other leaves it alone; and one takes the frame as a U8, so it quietly cannot reach past frame 255 of a sheet that may have a thousand -- which the palette, pointed at whatever image the user chose, certainly can. No behavior change. treeIcons (11 checks) and explorerGutter (26) cover both call sites and are green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- engine/source/gui/editor/guiEditorExplorerTree.cc | 3 ++- engine/source/gui/guiDefaultControlRender.cc | 14 ++++++++++++++ engine/source/gui/guiDefaultControlRender.h | 14 ++++++++++++++ engine/source/gui/guiTreeViewCtrl.cc | 15 +-------------- engine/source/gui/guiTreeViewCtrl.h | 9 --------- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/engine/source/gui/editor/guiEditorExplorerTree.cc b/engine/source/gui/editor/guiEditorExplorerTree.cc index 4b46282ed..e723e15b0 100644 --- a/engine/source/gui/editor/guiEditorExplorerTree.cc +++ b/engine/source/gui/editor/guiEditorExplorerTree.cc @@ -22,6 +22,7 @@ #include "gui/editor/guiEditorExplorerTree.h" #include "graphics/dgl.h" +#include "gui/guiDefaultControlRender.h" #include "gui/guiCanvas.h" #include "gui/editor/guiEditCtrl.h" @@ -234,7 +235,7 @@ void GuiEditorExplorerTree::renderGutterCell(const RectI& cell, const Point2I& c // put the row's own back afterwards, so the icon is the same ink on every row. if (showIcon && frame >= 0 && !mStateImageAsset.isNull()) { - drawIconFrame(box, mStateImageAsset, (U32)frame); + renderImageAssetFrame(box, mStateImageAsset, (U32)frame); } } diff --git a/engine/source/gui/guiDefaultControlRender.cc b/engine/source/gui/guiDefaultControlRender.cc index 2fbd493b8..8a1470425 100755 --- a/engine/source/gui/guiDefaultControlRender.cc +++ b/engine/source/gui/guiDefaultControlRender.cc @@ -403,6 +403,20 @@ void renderStretchedImageAsset(RectI &bounds, U8 frame, GuiControlProfile *profi } } +// Renders one frame of a sheet, keeping the caller's bitmap modulation. +void renderImageAssetFrame(const RectI &bounds, ImageAsset *imageAsset, U32 frame) +{ + if (imageAsset == NULL || !imageAsset->isAssetValid() || frame >= imageAsset->getFrameCount()) + { + return; + } + + const ImageAsset::FrameArea::PixelArea& pixelArea = imageAsset->getImageFrameArea(frame).mPixelArea; + RectI srcRect(pixelArea.mPixelOffset, Point2I(pixelArea.mPixelWidth, pixelArea.mPixelHeight)); + + dglDrawBitmapStretchSR(imageAsset->getImageTexture(), bounds, srcRect); +} + //Renders a color bullet at or one pixel smaller than maxSize. //It shrinks the given box until it is less than or equal to the //maxSize in the x direction. diff --git a/engine/source/gui/guiDefaultControlRender.h b/engine/source/gui/guiDefaultControlRender.h index f5785e6c3..498761dff 100755 --- a/engine/source/gui/guiDefaultControlRender.h +++ b/engine/source/gui/guiDefaultControlRender.h @@ -41,6 +41,20 @@ void renderSizableBorderedTexture(RectI &bounds, TextureHandle &texture, RectI & void renderFixedBitmapBordersFilled(RectI &bounds, S32 baseMultiplier, GuiControlProfile *profile); void renderStretchedBitmap(RectI &bounds, U8 frame, GuiControlProfile *profile); void renderStretchedImageAsset(RectI &bounds, U8 frame, GuiControlProfile *profile); + +/// One frame of a sheet, stretched to fill bounds, drawn with whatever bitmap +/// modulation is current. +/// +/// The plain counterpart to renderStretchedImageAsset just above, and the +/// differences are the whole reason it exists. That one reads the sheet off a +/// PROFILE, so it can only ever draw the sheet a control is wearing; this one is +/// handed the asset, so a control can draw frames of something it merely holds. +/// That one's first act is dglClearBitmapModulation, which throws away a tint +/// already set for a row's state; this one leaves the modulation alone, so a +/// white mask inherits whatever ink the caller established. And that one takes +/// the frame as a U8, which quietly cannot reach past frame 255 of a sheet that +/// may have a thousand. +void renderImageAssetFrame(const RectI &bounds, ImageAsset *imageAsset, U32 frame); void renderColorBullet(RectI &bounds, ColorI &color, S32 maxSize, bool useCircle = false); void renderTriangleIcon(RectI &bounds, ColorI &color, GuiDirection pointsToward, S32 maxSize); diff --git a/engine/source/gui/guiTreeViewCtrl.cc b/engine/source/gui/guiTreeViewCtrl.cc index 08f36ef4f..936a82355 100755 --- a/engine/source/gui/guiTreeViewCtrl.cc +++ b/engine/source/gui/guiTreeViewCtrl.cc @@ -115,19 +115,6 @@ void GuiTreeViewCtrl::setIconImageAsset(const char* pImageAssetID) } } -void GuiTreeViewCtrl::drawIconFrame(const RectI& dst, ImageAsset* sheet, U32 frame) -{ - if (sheet == NULL || !sheet->isAssetValid() || frame >= sheet->getFrameCount()) - { - return; - } - - const ImageAsset::FrameArea::PixelArea& pixelArea = sheet->getImageFrameArea(frame).mPixelArea; - RectI srcRect(pixelArea.mPixelOffset, Point2I(pixelArea.mPixelWidth, pixelArea.mPixelHeight)); - - dglDrawBitmapStretchSR(sheet->getImageTexture(), dst, srcRect); -} - void GuiTreeViewCtrl::renderItemIcon(RectI& contentRect, TreeItem* treeItem, GuiControlState currentState) { if (!treeItem || treeItem->iconFrame < 0 || mIconImageAsset.isNull()) @@ -143,7 +130,7 @@ void GuiTreeViewCtrl::renderItemIcon(RectI& contentRect, TreeItem* treeItem, Gui } // The modulation is still the row's font color, set before any of this drew. - drawIconFrame(dst, mIconImageAsset, (U32)treeItem->iconFrame); + renderImageAssetFrame(dst, mIconImageAsset, (U32)treeItem->iconFrame); contentRect.point.x += advance; contentRect.extent.x -= advance; diff --git a/engine/source/gui/guiTreeViewCtrl.h b/engine/source/gui/guiTreeViewCtrl.h index ca6c48194..562dcf55f 100755 --- a/engine/source/gui/guiTreeViewCtrl.h +++ b/engine/source/gui/guiTreeViewCtrl.h @@ -134,15 +134,6 @@ class GuiTreeViewCtrl : public GuiListBoxCtrl virtual void renderItemGutter(const RectI& itemRect, RectI& contentRect, TreeItem* treeItem, GuiControlState currentState) { } virtual void renderItemIcon(RectI& contentRect, TreeItem* treeItem, GuiControlState currentState); - /// One frame of a sheet, drawn with whatever bitmap modulation is current. - /// - /// Deliberately not renderStretchedImageAsset: that one reads the asset off - /// the PROFILE, and its first act is dglClearBitmapModulation - which here - /// would throw away the row's font color. The sheets are white masks, the - /// modulation is already set for the row's state, so an icon inherits the - /// row's normal / highlight / selected / disabled ink for nothing. - void drawIconFrame(const RectI& dst, ImageAsset* sheet, U32 frame); - /// Which frame of the sheet a row should wear, asked of script once while the /// tree is being built. -1 - the answer when no sheet is set, no handler /// exists, or the handler declines - means no icon and no width spent. From 7ea4d52060cccf012ea031e31c42d7e54ed9cbbe Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 11:13:46 -0400 Subject: [PATCH 07/26] Windows pathCopy could not make a folder, or refuse itself PlatformFileIOTests.PathCopyAndRename has been failing, and it was failing on its first line, which hid two real bugs behind a third thing that was the test's own fault. The test's fault first: unitTestWriteFile opens a file for writing in a folder that does not exist yet -- the scratch root is deleted at the top of the case, deliberately, because pathCopy making its own destination is part of what is being tested. File::open does not make a path, it just fails. So the very first write failed and every assertion after it was unreachable. With that out of the way, two genuine gaps in the Win32 layer: pathCopy would not create the folder its destination sits in. The directory branch makes folders as it walks, so a tree copy worked; a single file copied into a new folder handed the path straight to ::CopyFile, which fails. The two halves of one function disagreed about whose job the path was. createPath has to run before the backslash conversion, because it splits on forward slashes only and would find nothing to make afterwards. And it would copy a directory into itself, recursing until the path outgrew MAX_PATH. The UNIX build has refused that since pathCopy was implemented there; Windows never got the guard. Ported across, comparing without case as a Windows path must and accepting either separator, since both names are backslashed by the time the directory branch sees them. Found while adding an unrelated test file, which is the only reason the run was looked at closely. All 230 unit tests pass now. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- engine/source/platformWin32/winFileio.cc | 24 +++++++++++++++++++ .../testing/tests/platformFileIoTests.cc | 7 ++++++ 2 files changed, 31 insertions(+) diff --git a/engine/source/platformWin32/winFileio.cc b/engine/source/platformWin32/winFileio.cc index 37f77a255..4ef12a787 100755 --- a/engine/source/platformWin32/winFileio.cc +++ b/engine/source/platformWin32/winFileio.cc @@ -148,6 +148,15 @@ bool Platform::pathCopy(const char *fromName, const char *toName, bool nooverwri backslash(filebuf); fromName = filebuf; + // The folder the destination sits in may not exist yet: copying a single file + // into a new folder is an ordinary thing to ask for, and the directory branch + // below already makes its folders as it walks. Without this ::CopyFile simply + // fails, and the two halves of pathCopy disagree about who makes the path. + // + // Before the backslash conversion, because createPath splits on forward + // slashes only and would find nothing to make afterwards. + Platform::createPath(toName); + static char filebuf2[2048]; dStrcpy(filebuf2, toName); backslash(filebuf2); @@ -179,6 +188,21 @@ bool Platform::pathCopy(const char *fromName, const char *toName, bool nooverwri if ((Platform::isDirectory(toName) || Platform::isFile(toName)) && nooverwrite) return false; + // Refuse to copy a tree into itself, which would recurse until the path + // outgrew MAX_PATH. The same guard the UNIX build has carried since + // pathCopy was implemented there; this one was missed. Both names are in + // backslash form by now, and a Windows path compares without case. + dsize_t fromLen = dStrlen(fromName); + while (fromLen > 1 && (fromName[fromLen - 1] == '\\' || fromName[fromLen - 1] == '/')) + fromLen--; // a trailing separator would put the destination past the comparison + + if (dStrnicmp(fromName, toName, fromLen) == 0 && + (toName[fromLen] == '\\' || toName[fromLen] == '/' || toName[fromLen] == '\0')) + { + Con::errorf("Platform::pathCopy: %s is inside %s", toName, fromName); + return false; + } + Vector directoryInfo; Platform::dumpDirectories(fromName, directoryInfo, -1); diff --git a/engine/source/testing/tests/platformFileIoTests.cc b/engine/source/testing/tests/platformFileIoTests.cc index bb05fc173..9481d7efc 100755 --- a/engine/source/testing/tests/platformFileIoTests.cc +++ b/engine/source/testing/tests/platformFileIoTests.cc @@ -145,6 +145,13 @@ static void unitTestCopyPath( char* buffer, U32 bufferSize, const char* relative static bool unitTestWriteFile( const char* path, const char* contents ) { + // Every one of these writes into a folder that does not exist yet -- the + // scratch root is deleted at the top of the test, and pathCopy making its + // destination is part of what is being tested. File::open(Write) does not + // make one, it just fails, so the very first write failed and took the whole + // case with it. + Platform::createPath( path ); + File file; if ( file.open( path, File::Write ) != File::Ok ) return false; From 5bb754ad1778c18a6efa73a032f9c7f81bd8b80a Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 11:14:04 -0400 Subject: [PATCH 08/26] A grid of an image's frames, and the arithmetic of where cell N sits The animation editor needs to draw the same thing twice: the palette of every frame an image offers, and the timeline of the frames an animation plays. This is what they will share -- an image asset to draw from, and where the cells go. It has to be C++. Script cannot ask an image where a frame IS: in implicit cell mode getFrameSize is the only per-frame question with an answer, and a grid needs the source rect, which ImageAsset::getImageFrameArea has and no binding exposes. A grid of GuiSpriteCtrls was never an option. The layout is entirely static functions taking everything they use, so the renderer and the hit test call the same function with the same numbers and cannot drift apart -- the discipline GuiEditorExplorerTree's gutter uses, and for the same reason: a disagreement between where a cell is drawn and where it is clicked is experienced as clicking the wrong frame, which is a maddening bug to be told about and an easy one to test away. So there are 22 of them. The ones worth naming, because each is a mistake that would otherwise ship: getColumnsFor asks its question of a width one pad wider than the real one, because n cells span n advances LESS the gap the last one does not need. The naive width/advance loses a column at exactly the width that fits it. It also never answers zero -- a pane dragged narrower than one cell is ordinary, and zero is what the row arithmetic divides by. cellAt returns -1 for the gaps and for the empty tail of a short last row. A click on nothing must not become a click on the nearest something, and on the timeline the gap is where the insertion caret lives -- a different question with a different answer. getContentExtent leaves no trailing gap. It is what the scroller is told, and a pad of overshoot there is a scroll bar for a gap. Each subclass says which axis it grows along by overriding getDesiredExtent, one line each: a palette is as wide as its scroller and as tall as its rows, a timeline the other way about. Inferring it from the column count was tried first and read as a riddle. Named GuiEdit... so both copies of the palette-refusal rule refuse it by prefix and no icon table needs an entry; palette (152 checks) and inspectorSpec (114) confirm it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- cmake/EngineSources.cmake | 2 + .../gui/editor/guiEditFrameStripCtrl.cc | 470 ++++++++++++++++++ .../source/gui/editor/guiEditFrameStripCtrl.h | 211 ++++++++ .../testing/tests/guiFrameStripLayoutTests.cc | 369 ++++++++++++++ 4 files changed, 1052 insertions(+) create mode 100644 engine/source/gui/editor/guiEditFrameStripCtrl.cc create mode 100644 engine/source/gui/editor/guiEditFrameStripCtrl.h create mode 100644 engine/source/testing/tests/guiFrameStripLayoutTests.cc diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index df89124f0..8b1a4998f 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -202,6 +202,7 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/gui/editor/guiEditorCursorCtrl.cc ${TORQUE_SRC}/gui/editor/guiDebugger.cc ${TORQUE_SRC}/gui/editor/guiEditCtrl.cc + ${TORQUE_SRC}/gui/editor/guiEditFrameStripCtrl.cc ${TORQUE_SRC}/gui/editor/guiEditorExplorerTree.cc ${TORQUE_SRC}/gui/editor/guiGraphCtrl.cc ${TORQUE_SRC}/gui/editor/guiInspector.cc @@ -341,6 +342,7 @@ set(TORQUE_ENGINE_SOURCES # ---- testing/tests ---- ${TORQUE_SRC}/testing/tests/guiControlReparentTests.cc ${TORQUE_SRC}/testing/tests/guiCursorHotSpotTests.cc + ${TORQUE_SRC}/testing/tests/guiFrameStripLayoutTests.cc ${TORQUE_SRC}/testing/tests/guiHitTestTests.cc ${TORQUE_SRC}/testing/tests/guiProfileThemeTests.cc ${TORQUE_SRC}/testing/tests/guiScrollLayoutTests.cc diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl.cc b/engine/source/gui/editor/guiEditFrameStripCtrl.cc new file mode 100644 index 000000000..15b03f741 --- /dev/null +++ b/engine/source/gui/editor/guiEditFrameStripCtrl.cc @@ -0,0 +1,470 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#include "gui/editor/guiEditFrameStripCtrl.h" +#include "graphics/dgl.h" +#include "gui/guiDefaultControlRender.h" +#include "console/consoleTypes.h" + +IMPLEMENT_CONOBJECT(GuiEditFrameStripCtrl); + +GuiEditFrameStripCtrl::GuiEditFrameStripCtrl() +{ + mImageAssetID = StringTable->EmptyString; + mImageAsset = NULL; + mCellSize = smDefaultCellSize; + mCellPad = smDefaultCellPad; + mShowFrameNumbers = true; + // Spelled out because ColorI's constructor leaves its components + // uninitialised, so a field nobody sets is whatever was on the stack. + mNumberColor.set(255, 255, 255, 160); + mHoverColor.set(255, 255, 255, 90); + mHoverCell = -1; + mActive = true; +} + +//----------------------------------------------------------------------------- +// The layout. Every one of these takes what it uses and touches no member, so +// the paint and the hit test can share them and a unit test can reach them. +//----------------------------------------------------------------------------- + +S32 GuiEditFrameStripCtrl::getCellAdvance(S32 cellSize, S32 cellPad) +{ + return getMax(1, cellSize) + getMax(0, cellPad); +} + +S32 GuiEditFrameStripCtrl::getColumnsFor(S32 contentWidth, S32 cellSize, S32 cellPad) +{ + // n cells span n advances less the pad the last one does not need, so the + // question "how many fit" is asked of a width one pad wider than the real one. + const S32 advance = getCellAdvance(cellSize, cellPad); + const S32 columns = (contentWidth + getMax(0, cellPad)) / advance; + + return getMax(1, columns); +} + +S32 GuiEditFrameStripCtrl::getRowCountFor(S32 cellCount, S32 columns) +{ + if (cellCount < 1) + { + return 0; + } + + columns = getMax(1, columns); + return ((cellCount + columns) - 1) / columns; +} + +RectI GuiEditFrameStripCtrl::getCellRect(S32 index, S32 columns, S32 cellSize, S32 cellPad) +{ + columns = getMax(1, columns); + cellSize = getMax(1, cellSize); + + const S32 advance = getCellAdvance(cellSize, cellPad); + const S32 row = index / columns; + const S32 column = index % columns; + + return RectI(column * advance, row * advance, cellSize, cellSize); +} + +S32 GuiEditFrameStripCtrl::cellAt(const Point2I& local, S32 columns, S32 cellCount, S32 cellSize, S32 cellPad) +{ + if (cellCount < 1 || local.x < 0 || local.y < 0) + { + return -1; + } + + columns = getMax(1, columns); + cellSize = getMax(1, cellSize); + + const S32 advance = getCellAdvance(cellSize, cellPad); + + // The remainder of the advance is the gap after a cell. Landing in it is + // landing on nothing, which is a different answer from landing on a neighbour. + if ((local.x % advance) >= cellSize || (local.y % advance) >= cellSize) + { + return -1; + } + + const S32 column = local.x / advance; + if (column >= columns) + { + return -1; + } + + const S32 index = ((local.y / advance) * columns) + column; + + // Past the end: the tail of a short last row is empty, not the last cell. + return (index < cellCount) ? index : -1; +} + +Point2I GuiEditFrameStripCtrl::getContentExtent(S32 cellCount, S32 columns, S32 cellSize, S32 cellPad) +{ + if (cellCount < 1) + { + return Point2I(0, 0); + } + + columns = getMax(1, columns); + cellSize = getMax(1, cellSize); + + const S32 advance = getCellAdvance(cellSize, cellPad); + const S32 usedColumns = getMin(cellCount, columns); + const S32 rows = getRowCountFor(cellCount, columns); + + // One advance per cell, less the trailing gap the last one in each direction + // does not need. + return Point2I((usedColumns * advance) - getMax(0, cellPad), + (rows * advance) - getMax(0, cellPad)); +} + +//----------------------------------------------------------------------------- + +void GuiEditFrameStripCtrl::initPersistFields() +{ + Parent::initPersistFields(); + + addProtectedField("Image", TypeAssetId, Offset(mImageAssetID, GuiEditFrameStripCtrl), &setImage, &getImage, + "The image asset whose frames the cells are drawn from."); + addField("CellSize", TypeS32, Offset(mCellSize, GuiEditFrameStripCtrl), + "The pixel size of the square one frame draws in."); + addField("CellPad", TypeS32, Offset(mCellPad, GuiEditFrameStripCtrl), + "The gap between two cells."); + addField("ShowFrameNumbers", TypeBool, Offset(mShowFrameNumbers, GuiEditFrameStripCtrl), + "Whether each cell is labelled with the image frame it is showing."); + addField("NumberColor", TypeColorI, Offset(mNumberColor, GuiEditFrameStripCtrl), + "The ink those labels are drawn in."); + addField("HoverColor", TypeColorI, Offset(mHoverColor, GuiEditFrameStripCtrl), + "The wash over the cell under the pointer."); +} + +//----------------------------------------------------------------------------- + +void GuiEditFrameStripCtrl::setImageAssetId(const char* pImageAssetID) +{ + // Sanity! + AssertFatal(pImageAssetID != NULL, "Cannot use a NULL asset ID."); + + mImageAssetID = StringTable->insert(pImageAssetID); + + // Resolved now rather than deferred to onWake, as GuiTreeViewCtrl's icon + // sheet is: nothing is lending this asset to anyone, so there is no refcount + // to wait on. An empty id clears, which is how the grid is emptied. + if (mImageAssetID != StringTable->EmptyString) + { + mImageAsset = pImageAssetID; + } + else + { + mImageAsset.clear(); + } + + mHoverCell = -1; + updateExtent(); +} + +S32 GuiEditFrameStripCtrl::getImageFrameCount() const +{ + if (mImageAsset.isNull() || !mImageAsset->isAssetValid()) + { + return 0; + } + + return (S32)mImageAsset->getFrameCount(); +} + +void GuiEditFrameStripCtrl::setCellSize(S32 cellSize) +{ + const S32 clamped = mClamp(cellSize, smMinCellSize, smMaxCellSize); + if (clamped == mCellSize) + { + return; + } + + mCellSize = clamped; + updateExtent(); +} + +S32 GuiEditFrameStripCtrl::getColumnCount() +{ + Point2I offset(0, 0); + const RectI content = getContentRect(offset); + + return getColumnsFor(content.extent.x, mCellSize, mCellPad); +} + +//----------------------------------------------------------------------------- + +bool GuiEditFrameStripCtrl::onWake() +{ + if (!Parent::onWake()) + { + return false; + } + + updateExtent(); + return true; +} + +void GuiEditFrameStripCtrl::onSleep() +{ + mHoverCell = -1; + Parent::onSleep(); +} + +//----------------------------------------------------------------------------- + +RectI GuiEditFrameStripCtrl::getContentRect(const Point2I& offset) +{ + // NORMAL state in both the paint and the hit test. A profile that pads its + // highlighted state differently would otherwise shift every cell the moment + // the pointer arrived, so a click would land where the cell used to be. + Point2I contentOffset = offset; + Point2I contentExtent = mBounds.extent; + + return getInnerRect(contentOffset, contentExtent, NormalState, mProfile); +} + +Point2I GuiEditFrameStripCtrl::getOuterExtentForCells() +{ + Point2I inner = getContentExtent(getCellCount(), getColumnCount(), mCellSize, mCellPad); + + // The chrome the cells sit inside is paid for on top of them. + return getOuterExtent(inner, NormalState, mProfile); +} + +void GuiEditFrameStripCtrl::updateExtent() +{ + // A GuiScrollCtrl takes its content size from this control's own extent + // (calcChildExtents), so resizing IS how the bars are told. Nothing else has + // to be notified. + const Point2I desired = getDesiredExtent(); + + if (desired == mBounds.extent) + { + return; + } + + resize(mBounds.point, desired); +} + +void GuiEditFrameStripCtrl::parentResized(const Point2I& oldParentExtent, const Point2I& newParentExtent) +{ + Parent::parentResized(oldParentExtent, newParentExtent); + + // Recomputed from the width this control now has, never from a remembered + // one. A scroller calls this a second time with only the delta its bars cost + // (notifyChildrenOfBarChange), and a pure recompute settles on that pass + // instead of stacking one adjustment on the other. + updateExtent(); +} + +//----------------------------------------------------------------------------- + +void GuiEditFrameStripCtrl::onRender(Point2I offset, const RectI& updateRect) +{ + RectI ctrlRect = applyMargins(offset, mBounds.extent, NormalState, mProfile); + if (!ctrlRect.isValidRect()) + { + return; + } + + renderUniversalRect(ctrlRect, mProfile, NormalState); + + RectI fillRect = applyBorders(ctrlRect.point, ctrlRect.extent, NormalState, mProfile); + RectI contentRect = applyPadding(fillRect.point, fillRect.extent, NormalState, mProfile); + if (!contentRect.isValidRect()) + { + return; + } + + const S32 cellCount = getCellCount(); + const S32 columns = getColumnCount(); + + const RectI oldClip = dglGetClipRect(); + RectI clipRect = contentRect; + if (clipRect.intersect(oldClip)) + { + dglSetClipRect(clipRect); + + for (S32 i = 0; i < cellCount; ++i) + { + RectI cellRect = getCellRect(i, columns, mCellSize, mCellPad); + cellRect.point += contentRect.point; + + // A sheet can have a thousand frames and a scroller shows a dozen. + // Culling here is the difference between drawing what is on screen and + // drawing all of it and letting the clip throw it away. + if (!cellRect.overlaps(clipRect)) + { + continue; + } + + renderCell(i, cellRect, i == mHoverCell); + } + + renderOverlay(contentRect); + + dglSetClipRect(oldClip); + } + + renderChildControls(offset, contentRect, updateRect); +} + +void GuiEditFrameStripCtrl::renderCell(S32 index, const RectI& cellRect, bool isHovered) +{ + const S32 frame = getFrameAt(index); + + // White is untinted. Set every cell rather than once for the loop, because a + // subclass drawing its own chrome between cells will have changed it. + dglSetBitmapModulation(ColorF(1.0f, 1.0f, 1.0f, 1.0f)); + renderImageAssetFrame(cellRect, mImageAsset, (U32)frame); + dglClearBitmapModulation(); + + if (isHovered) + { + dglDrawRectFill(cellRect, mHoverColor); + } + + if (!mShowFrameNumbers) + { + return; + } + + GFont* font = mProfile->getFont(mFontSizeAdjust); + if (font == NULL) + { + return; + } + + char buffer[16]; + dSprintf(buffer, sizeof(buffer), "%d", frame); + + const S32 textWidth = font->getStrWidth((const UTF8*)buffer); + const S32 textHeight = (S32)font->getHeight(); + + // Only when the label fits inside the cell it belongs to. A number spilling + // over its neighbours is worse than no number. + if (textWidth > cellRect.extent.x || textHeight > cellRect.extent.y) + { + return; + } + + const Point2I textPoint(cellRect.point.x + ((cellRect.extent.x - textWidth) / 2), + (cellRect.point.y + cellRect.extent.y) - textHeight); + + dglSetBitmapModulation(mNumberColor); + dglDrawText(font, textPoint, (const UTF8*)buffer); + dglClearBitmapModulation(); +} + +//----------------------------------------------------------------------------- + +S32 GuiEditFrameStripCtrl::cellAtGlobal(const Point2I& globalPoint) +{ + Point2I origin(0, 0); + const RectI content = getContentRect(origin); + + Point2I local = globalToLocalCoord(globalPoint); + local -= content.point; + + return cellAt(local, getColumnCount(), getCellCount(), mCellSize, mCellPad); +} + +RectI GuiEditFrameStripCtrl::getCellRectGlobal(S32 index) +{ + if (index < 0 || index >= getCellCount()) + { + return RectI(0, 0, 0, 0); + } + + Point2I origin(0, 0); + const RectI content = getContentRect(origin); + + RectI cellRect = getCellRect(index, getColumnCount(), mCellSize, mCellPad); + cellRect.point += content.point; + cellRect.point = localToGlobalCoord(cellRect.point); + + return cellRect; +} + +//----------------------------------------------------------------------------- + +void GuiEditFrameStripCtrl::onTouchMove(const GuiEvent& event) +{ + const S32 cell = cellAtGlobal(event.mousePoint); + if (cell != mHoverCell) + { + mHoverCell = cell; + setUpdate(); + } + + Parent::onTouchMove(event); +} + +void GuiEditFrameStripCtrl::onTouchLeave(const GuiEvent& event) +{ + if (mHoverCell != -1) + { + mHoverCell = -1; + setUpdate(); + } + + Parent::onTouchLeave(event); +} + +void GuiEditFrameStripCtrl::onMouseWheelUp(const GuiEvent& event) +{ + // A magnifier over the art, which is what a wheel over a grid of pictures + // should do. The scroller only gets the wheel when this declines it, which is + // why the step reports back: at either end there is nothing to zoom and the + // gesture should go back to scrolling. + const S32 before = mCellSize; + setCellSize(mCellSize + 8); + + if (mCellSize == before) + { + Parent::onMouseWheelUp(event); + return; + } + + if (isMethod("onCellSizeChanged")) + { + Con::executef(this, 2, "onCellSizeChanged", Con::getIntArg(mCellSize)); + } +} + +void GuiEditFrameStripCtrl::onMouseWheelDown(const GuiEvent& event) +{ + const S32 before = mCellSize; + setCellSize(mCellSize - 8); + + if (mCellSize == before) + { + Parent::onMouseWheelDown(event); + return; + } + + if (isMethod("onCellSizeChanged")) + { + Con::executef(this, 2, "onCellSizeChanged", Con::getIntArg(mCellSize)); + } +} diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl.h b/engine/source/gui/editor/guiEditFrameStripCtrl.h new file mode 100644 index 000000000..d096977f5 --- /dev/null +++ b/engine/source/gui/editor/guiEditFrameStripCtrl.h @@ -0,0 +1,211 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#ifndef _GUI_EDIT_FRAME_STRIP_CTRL_H_ +#define _GUI_EDIT_FRAME_STRIP_CTRL_H_ + +#ifndef _GUICONTROL_H_ +#include "gui/guiControl.h" +#endif + +#ifndef _IMAGE_ASSET_H_ +#include "2d/assets/ImageAsset.h" +#endif + +//----------------------------------------------------------------------------- +// A grid of an image asset's frames: what the Asset Manager's animation editor +// draws twice, once as the palette of every frame the image offers and once as +// the timeline of the frames an animation actually plays. +// +// The two are separate classes because almost nothing about them matches -- one +// derives its cells from the image and wraps them into rows, the other holds an +// editable list and keeps it in a single scrolling line; one appends on a click, +// the other selects and scrubs; only one has a keyboard. What they do share is +// this: an image asset to draw from, and the arithmetic of where cell N sits. +// +// This has to be C++ rather than a grid of GuiSpriteCtrls because script cannot +// ask an image where a frame IS. In implicit cell mode getFrameSize is the only +// per-frame question with an answer, and a palette needs the source rect -- +// which ImageAsset::getImageFrameArea has and no binding exposes. +// +// All of the layout is static functions taking everything they use. The renderer +// and the hit test then call the same function with the same numbers and cannot +// drift apart, which is the discipline GuiEditorExplorerTree's gutter uses; and +// it can be tested, which matters because nothing here can be tested any other +// way. A unit test has no GL context, so a suite that builds one of these and +// asks it to draw loads a texture, trips a modal assert, and hangs. +// +// Editor-only, and not offered to anyone building a Gui: the palette refuses +// every class whose name begins "GuiEdit", in both copies of that rule +// (GuiEditorControlIcons::isPlaceableClass, generated, and +// GuiEditorControlSpec::isPlaceableClass, hand-typed for its drift guard). +//----------------------------------------------------------------------------- + +class GuiEditFrameStripCtrl : public GuiControl +{ +private: + typedef GuiControl Parent; + +protected: + /// The sheet, held as an id and a pointer both. + /// + /// TypeAssetId rather than TypeImageAssetPtr, for the reason GuiTreeViewCtrl + /// and GuiSpriteCtrl both split it this way: a TypeImageAssetPtr field + /// acquires the asset the instant the field is written, which during a + /// .gui.taml load is before there is anywhere sensible to put the failure. + /// The id is the persisted truth; the pointer is resolved from it. + StringTableEntry mImageAssetID; + AssetPtr mImageAsset; + + S32 mCellSize; ///< The square one frame draws in. + S32 mCellPad; ///< The gap between two cells, and nothing else. + bool mShowFrameNumbers; ///< Whether each cell is labelled with the image frame it shows. + ColorI mNumberColor; + ColorI mHoverColor; + S32 mHoverCell; ///< -1 when the pointer is off the cells or outside. + + /// Lay the control out to fit its cells and tell the scroller. + /// + /// A GuiScrollCtrl reads its content size from the child's own extent + /// (calcChildExtents), so there is nothing else to notify: resizing IS the + /// notification. Called from onWake, from parentResized, and from every + /// setter that can change how many cells there are or how big they draw. + void updateExtent(); + + /// What the cells need, chrome included. + Point2I getOuterExtentForCells(); + + /// The extent this control wants to be. + /// + /// The two subclasses grow along opposite axes and each keeps whatever its + /// sizing flags gave it on the other -- a palette is as wide as its scroller + /// and as tall as its rows, a timeline is as tall as its scroller and as wide + /// as its cells. Saying which is which here, in one line each, beats trying to + /// infer it from the column count. + virtual Point2I getDesiredExtent() { return mBounds.extent; } + + /// The rect the cells live in, given where the control is being drawn. + /// + /// Measured with NORMAL-state insets in both the paint and the hit test, so a + /// profile that pads a hovered control differently cannot make cells jitter + /// under the pointer or make a click land one cell over. + RectI getContentRect(const Point2I& offset); + + /// One cell. The base draws the frame and, if asked, its number; a subclass + /// overrides to put a selection or a playhead on top of that. + virtual void renderCell(S32 index, const RectI& cellRect, bool isHovered); + + /// Anything drawn over the whole grid rather than per cell -- an insertion + /// caret, say. Called inside the content clip, after every cell. + virtual void renderOverlay(const RectI& contentRect) { } + +public: + // A cell is square and its size is in pixels of screen, not of art: a 16x16 + // sprite and a 256x256 sprite both get the same box, because the question the + // grid answers is "which frame is this", not "how big is it". + // + // constexpr rather than const: a test asserting against one binds it to a + // const reference, which would odr-use a plain static const and fail to link. + static constexpr S32 smMinCellSize = 16; + static constexpr S32 smMaxCellSize = 128; + static constexpr S32 smDefaultCellSize = 48; + static constexpr S32 smDefaultCellPad = 4; + + /// Cell to cell, including the gap. The one number the rest of the layout is + /// built from, so it exists to be named rather than repeated. + static S32 getCellAdvance(S32 cellSize, S32 cellPad); + + /// How many cells fit across a content width. + /// + /// Never zero. A width narrower than a single cell still reports one column, + /// which shows a clipped cell -- the alternative is a zero the row arithmetic + /// divides by. + static S32 getColumnsFor(S32 contentWidth, S32 cellSize, S32 cellPad); + + /// How many rows a given number of cells needs. + static S32 getRowCountFor(S32 cellCount, S32 columns); + + /// Where cell N draws, relative to the content rect's top left. + static RectI getCellRect(S32 index, S32 columns, S32 cellSize, S32 cellPad); + + /// Which cell a point falls on, measured from the same top left, or -1. + /// + /// -1 for the gaps between cells and for the empty tail of a short last row, + /// both deliberately: a click on nothing must not become a click on the + /// nearest something. + static S32 cellAt(const Point2I& local, S32 columns, S32 cellCount, S32 cellSize, S32 cellPad); + + /// The space the cells take up altogether, with no trailing gap on the last + /// row or column -- a pad of overshoot is a scroll bar for nothing. + static Point2I getContentExtent(S32 cellCount, S32 columns, S32 cellSize, S32 cellPad); + + GuiEditFrameStripCtrl(); + static void initPersistFields(); + + bool onWake(); + void onSleep(); + void onRender(Point2I offset, const RectI& updateRect); + void parentResized(const Point2I& oldParentExtent, const Point2I& newParentExtent); + + void onTouchMove(const GuiEvent& event); + void onTouchLeave(const GuiEvent& event); + void onMouseWheelUp(const GuiEvent& event); + void onMouseWheelDown(const GuiEvent& event); + + /// How many cells there are. The palette derives it from the image, the + /// timeline from its own list; the base has none, so it draws nothing. + virtual S32 getCellCount() const { return 0; } + + /// Which image frame cell N shows. They are the same number for a palette and + /// quite different for a timeline, where one image frame can fill many cells. + virtual S32 getFrameAt(S32 index) const { return index; } + + /// How many columns to lay out in. The palette fits as many as the width + /// allows; the timeline answers with its cell count, which is one row. + virtual S32 getColumnCount(); + + void setImageAssetId(const char* pImageAssetID); + inline StringTableEntry getImageAssetId() const { return mImageAssetID; } + inline ImageAsset* getImageAssetObject() const { return mImageAsset; } + + /// How many frames the sheet has, or 0 when there is no usable one. + S32 getImageFrameCount() const; + + void setCellSize(S32 cellSize); + inline S32 getCellSize() const { return mCellSize; } + + /// Which cell a point in canvas coordinates falls on, or -1. The whole hit + /// test, and what every gesture in both subclasses starts with. + S32 cellAtGlobal(const Point2I& globalPoint); + + /// Where cell N is on the canvas, for a test that must not hard-code a point: + /// a stale coordinate reports a missing cell, which is exactly what a broken + /// hit test reports, and the test would be lying either way. + RectI getCellRectGlobal(S32 index); + + static bool setImage(void* obj, const char* data) { static_cast(obj)->setImageAssetId(data); return false; } + static const char* getImage(void* obj, const char* data) { return static_cast(obj)->getImageAssetId(); } + + DECLARE_CONOBJECT(GuiEditFrameStripCtrl); +}; + +#endif //_GUI_EDIT_FRAME_STRIP_CTRL_H_ diff --git a/engine/source/testing/tests/guiFrameStripLayoutTests.cc b/engine/source/testing/tests/guiFrameStripLayoutTests.cc new file mode 100644 index 000000000..e82a3dfa7 --- /dev/null +++ b/engine/source/testing/tests/guiFrameStripLayoutTests.cc @@ -0,0 +1,369 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +#ifndef _GUI_EDIT_FRAME_STRIP_CTRL_H_ +#include "gui/editor/guiEditFrameStripCtrl.h" +#endif + +//----------------------------------------------------------------------------- +// Where cell N of a frame grid sits, and which cell a point lands on. +// +// The animation editor draws this grid twice -- as the palette of every frame an +// image offers, wrapped into rows, and as the timeline of the frames an +// animation plays, in one long line. Both call the same statics, which is what +// stops the picture and the hit test disagreeing about where a cell is. +// +// It cannot be tested any other way. Drawing one of these loads a texture, and +// this suite runs with no canvas and so no GL context to make one in; +// TextureManager asserts, and in a debug build an assert is a modal box, so the +// run hangs rather than fails. So the arithmetic takes everything it uses and +// these tests call it directly. They construct nothing. +// +// Throughout: cells are 20 wide with a 4 pixel gap, so the advance is 24 and +// every number below can be checked in your head. +//----------------------------------------------------------------------------- + +static const S32 sCell = 20; +static const S32 sPad = 4; +static const S32 sAdvance = 24; + +//----------------------------------------------------------------------------- +// getColumnsFor -- how many cells fit across a width. +// +// The trap is the last cell's gap. n cells span n advances LESS one pad, because +// nothing follows the last one; asking the naive question (width / advance) +// loses a column at exactly the width that fits it perfectly. +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, AnExactFitIsNotShortOneColumn ) +{ + // Four cells and the three gaps between them: 4*20 + 3*4 = 92. + ASSERT_EQ( GuiEditFrameStripCtrl::getColumnsFor( 92, sCell, sPad ), 4 ) + << "A width that exactly holds four cells holds four cells."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, OnePixelShortLosesTheColumn ) +{ + ASSERT_EQ( GuiEditFrameStripCtrl::getColumnsFor( 91, sCell, sPad ), 3 ) + << "One pixel under a perfect fit cannot show the fourth cell whole."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheGapAloneDoesNotBuyAColumn ) +{ + // 92 through 115 are all four columns: the fifth needs its gap AND its cell. + ASSERT_EQ( GuiEditFrameStripCtrl::getColumnsFor( 115, sCell, sPad ), 4 ) + << "Room for the gap but not the cell after it is still four columns."; + ASSERT_EQ( GuiEditFrameStripCtrl::getColumnsFor( 116, sCell, sPad ), 5 ) + << "One more pixel completes the fifth cell."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, ThereIsAlwaysAtLeastOneColumn ) +{ + // Not a nicety. getCellRect and cellAt both divide by the column count, and a + // pane dragged narrower than a single cell is an ordinary thing to do. + ASSERT_EQ( GuiEditFrameStripCtrl::getColumnsFor( 1, sCell, sPad ), 1 ) + << "A width of one pixel still reports one column, clipped, not zero."; + ASSERT_EQ( GuiEditFrameStripCtrl::getColumnsFor( 0, sCell, sPad ), 1 ) + << "So does a width of nothing."; + ASSERT_EQ( GuiEditFrameStripCtrl::getColumnsFor( -40, sCell, sPad ), 1 ) + << "And so does a negative one, which a mid-resize measurement can be."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// getRowCountFor +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, APartialRowIsStillARow ) +{ + ASSERT_EQ( GuiEditFrameStripCtrl::getRowCountFor( 8, 4 ), 2 ) + << "Eight cells in fours is two full rows."; + ASSERT_EQ( GuiEditFrameStripCtrl::getRowCountFor( 9, 4 ), 3 ) + << "One cell over needs a third row to put it on."; + ASSERT_EQ( GuiEditFrameStripCtrl::getRowCountFor( 1, 4 ), 1 ) + << "A single cell is one row, not a fraction of one."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, NoCellsIsNoRows ) +{ + ASSERT_EQ( GuiEditFrameStripCtrl::getRowCountFor( 0, 4 ), 0 ) + << "An empty timeline occupies no rows, so it asks for no height."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// getCellRect -- the picture's half of the contract. +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, TheFirstCellIsAtTheOrigin ) +{ + const RectI cell = GuiEditFrameStripCtrl::getCellRect( 0, 4, sCell, sPad ); + + ASSERT_EQ( cell.point.x, 0 ) << "Cell zero starts at the content rect's left edge."; + ASSERT_EQ( cell.point.y, 0 ) << "And at its top."; + ASSERT_EQ( cell.extent.x, sCell ) << "A cell is its cell size wide."; + ASSERT_EQ( cell.extent.y, sCell ) << "And square."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, NeighboursStepByTheAdvanceNotTheCell ) +{ + // The gap belongs between cells, so the step is 24 and not 20. Getting this + // wrong overlaps every cell by the pad and is invisible until the pad changes. + const RectI first = GuiEditFrameStripCtrl::getCellRect( 0, 4, sCell, sPad ); + const RectI second = GuiEditFrameStripCtrl::getCellRect( 1, 4, sCell, sPad ); + + ASSERT_EQ( second.point.x - first.point.x, sAdvance ) + << "Two cells side by side are one advance apart."; + ASSERT_EQ( second.point.y, first.point.y ) + << "And on the same row."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheNextRowStartsUnderTheFirstColumn ) +{ + const RectI first = GuiEditFrameStripCtrl::getCellRect( 0, 4, sCell, sPad ); + const RectI wrapped = GuiEditFrameStripCtrl::getCellRect( 4, 4, sCell, sPad ); + + ASSERT_EQ( wrapped.point.x, first.point.x ) + << "Cell four begins the second row, so it is back at the left."; + ASSERT_EQ( wrapped.point.y - first.point.y, sAdvance ) + << "One advance down, by the same reasoning as across."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, OneColumnMeansOneCellPerRow ) +{ + // This is the timeline's layout inverted, and the case a palette dragged very + // narrow falls into. + const RectI second = GuiEditFrameStripCtrl::getCellRect( 1, 1, sCell, sPad ); + + ASSERT_EQ( second.point.x, 0 ) << "With one column every cell is in it."; + ASSERT_EQ( second.point.y, sAdvance ) << "So the second cell is on the second row."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// cellAt -- the hit test's half, which must agree with getCellRect exactly. +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, EveryCellFindsItselfBackAgain ) +{ + // A 3 x 4 grid, round-tripped. If the two functions ever disagree about where + // a cell is, this is where it shows -- and it is the disagreement, not either + // function alone, that a user experiences as clicking the wrong frame. + const S32 columns = 3; + const S32 count = 12; + + for ( S32 i = 0; i < count; ++i ) + { + const RectI cell = GuiEditFrameStripCtrl::getCellRect( i, columns, sCell, sPad ); + + const Point2I topLeft( cell.point.x, cell.point.y ); + const Point2I middle( cell.point.x + (sCell / 2), cell.point.y + (sCell / 2) ); + const Point2I bottomRight( (cell.point.x + sCell) - 1, (cell.point.y + sCell) - 1 ); + + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( topLeft, columns, count, sCell, sPad ), i ) + << "A cell's own top left corner belongs to it."; + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( middle, columns, count, sCell, sPad ), i ) + << "So does its middle."; + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( bottomRight, columns, count, sCell, sPad ), i ) + << "And its last pixel, which is one short of the next advance."; + } + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheGapBelongsToNobody ) +{ + // Landing between two cells must not round to the nearer one. The gap is + // where a click means "not that one", and on the timeline it is also where + // the insertion caret lives -- a different question with a different answer. + const Point2I inTheGap( sCell + 1, sCell / 2 ); + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( inTheGap, 4, 12, sCell, sPad ), -1 ) + << "A point in the horizontal gap is on no cell."; + + const Point2I belowTheRow( sCell / 2, sCell + 1 ); + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( belowTheRow, 4, 12, sCell, sPad ), -1 ) + << "Nor is one in the gap between rows."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheEmptyTailOfALastRowIsEmpty ) +{ + // Ten cells in fours leaves two empty places on the third row. They look like + // cells to the arithmetic -- same row, valid column -- and are not. + const S32 columns = 4; + const S32 count = 10; + + const RectI wouldBeTen = GuiEditFrameStripCtrl::getCellRect( 10, columns, sCell, sPad ); + const Point2I middle( wouldBeTen.point.x + (sCell / 2), wouldBeTen.point.y + (sCell / 2) ); + + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( middle, columns, count, sCell, sPad ), -1 ) + << "The eleventh place holds no cell, so a click there hits nothing."; + + const RectI ninth = GuiEditFrameStripCtrl::getCellRect( 9, columns, sCell, sPad ); + const Point2I onNine( ninth.point.x + (sCell / 2), ninth.point.y + (sCell / 2) ); + + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( onNine, columns, count, sCell, sPad ), 9 ) + << "But the last real cell on that row is still hittable."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, PastTheLastColumnIsNotTheNextRow ) +{ + // x beyond the grid must not wrap. Divide without the column check and a + // click to the right of a four-column grid lands on row+1, column 0. + const Point2I pastTheRight( (4 * sAdvance) + 2, sCell / 2 ); + + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( pastTheRight, 4, 12, sCell, sPad ), -1 ) + << "Right of the last column is off the grid, not onto the next row."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, NegativeCoordinatesHitNothing ) +{ + // Integer division truncates towards zero, so -1 / 24 is 0 -- a point above + // and left of the grid would otherwise land on cell zero. + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( Point2I( -1, 5 ), 4, 12, sCell, sPad ), -1 ) + << "Left of the grid is off it."; + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( Point2I( 5, -1 ), 4, 12, sCell, sPad ), -1 ) + << "So is above it."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, AnEmptyGridHasNothingToHit ) +{ + ASSERT_EQ( GuiEditFrameStripCtrl::cellAt( Point2I( 5, 5 ), 4, 0, sCell, sPad ), -1 ) + << "An animation with no frames yet answers every click with nothing."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// getContentExtent -- what the scroller is told, and therefore whether a bar +// appears. A trailing pad here is a scroll bar for a gap. +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, TheLastCellPaysNoTrailingGap ) +{ + // Four cells across: 4*20 + 3*4 = 92, not 96. + const Point2I extent = GuiEditFrameStripCtrl::getContentExtent( 4, 4, sCell, sPad ); + + ASSERT_EQ( extent.x, 92 ) << "One row of four is four cells and the three gaps between them."; + ASSERT_EQ( extent.y, sCell ) << "And exactly one cell tall, with no gap under it."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, AWrappedGridIsAsWideAsItsColumnsAndAsTallAsItsRows ) +{ + // Ten cells in fours: three rows, the last of them short, but the width is + // still four columns because two of them are full. + const Point2I extent = GuiEditFrameStripCtrl::getContentExtent( 10, 4, sCell, sPad ); + + ASSERT_EQ( extent.x, 92 ) << "Four columns wide."; + ASSERT_EQ( extent.y, 68 ) << "Three rows: 3*20 + 2*4."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, FewerCellsThanColumnsDoesNotPadTheWidth ) +{ + // Two frames in a palette wide enough for eight. Claiming eight columns of + // width would give the scroller a horizontal bar over empty space. + const Point2I extent = GuiEditFrameStripCtrl::getContentExtent( 2, 8, sCell, sPad ); + + ASSERT_EQ( extent.x, 44 ) << "Two cells and the one gap between them."; + ASSERT_EQ( extent.y, sCell ) << "One row."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, NoCellsTakeNoRoom ) +{ + const Point2I extent = GuiEditFrameStripCtrl::getContentExtent( 0, 4, sCell, sPad ); + + ASSERT_EQ( extent.x, 0 ) << "An empty grid asks for no width..."; + ASSERT_EQ( extent.y, 0 ) << "...and no height, rather than one empty cell's worth."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheExtentAgreesWithTheLastCellsRect ) +{ + // The two are computed apart, so this is the check that they cannot drift: + // whatever the extent claims must be exactly enough to hold the last cell. + const S32 columns = 4; + const S32 count = 10; + + const Point2I extent = GuiEditFrameStripCtrl::getContentExtent( count, columns, sCell, sPad ); + const RectI last = GuiEditFrameStripCtrl::getCellRect( count - 1, columns, sCell, sPad ); + + ASSERT_LE( last.point.y + last.extent.y, extent.y ) + << "The last cell must fit inside the height the scroller was given."; + ASSERT_EQ( GuiEditFrameStripCtrl::getRowCountFor( count, columns ) * sAdvance - sPad, extent.y ) + << "And the height must be exactly the rows, not a row and a bit."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// getCellAdvance +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, TheAdvanceIsACellAndItsGap ) +{ + ASSERT_EQ( GuiEditFrameStripCtrl::getCellAdvance( sCell, sPad ), sAdvance ) + << "The one number the rest of the layout is built from."; + ASSERT_EQ( GuiEditFrameStripCtrl::getCellAdvance( sCell, 0 ), sCell ) + << "No gap means cells touch."; + + SUCCEED(); +} + +#endif // TORQUE_SHIPPING From 436bab6ce0a6be4488c8ea0824736dbfb16f24cf Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 11:21:50 -0400 Subject: [PATCH 09/26] The palette you drag frames from, and the timeline you drag them to Two grids over the shared arithmetic, and almost nothing else in common -- which is why they are two classes rather than one with a mode flag. The palette derives its cells from the image and wraps them into rows; the timeline holds an editable list in one scrolling line. One appends on a click, the other selects and scrubs. Only one has a keyboard. A flag would have made every method an if. The palette's whole job is the fork between a click and a drag, five pixels of slop apart, which is the same fork GuiEditorControlTile makes. Both ends report to script rather than acting: what a dropped frame MEANS is the timeline's business. It also has to guard the double-fire -- a release that followed a drag is that drag ending, not a click, or a dragged frame would be both dropped where it was let go and appended to the end. The timeline holds a COPY of the list and never writes the asset, reporting one onFramesChanged per completed gesture. That matters more than the usual separation would suggest: AnimationAsset::setAnimationFrames has no equality guard, so every call rewrites the .animation.taml, and a per-drag-tick write would be hundreds of file writes for one reorder. Two things in it are worth reading twice. Repeats of one frame are drawn joined across the gap, because the asset format has no per-frame duration -- every frame gets AnimationTime divided by the count -- so naming a frame twice is the ONLY way to hold a pose, and a run of duplicates has to read as one held frame rather than as somebody's mistake. And insertionAt counts cell CENTRES, not edges, so the caret flips halfway across a cell where a person expects "before this one" to become "after it"; measuring from the edge makes the caret lag the pointer by half a frame. The drop and the caret call it with the same numbers, so what was shown is what happens. The playhead is read in onPreRender, which recurses from the canvas every frame -- the cheapest correct poll and the documented place to mark yourself dirty. It reads getAnimationFrame, the slot, not getCurrentAnimationFrame, the image frame: one image frame can fill several slots and the marker has to be on the one actually playing. Selection and playhead are drawn differently on purpose, an outline against a bar, because they are usually the same cell and scrubbing sets one by reading the other. Ten more layout tests, 32 in all. palette (152 checks) confirms all three classes are refused from the Gui Editor by their prefix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- cmake/EngineSources.cmake | 2 + .../gui/editor/guiEditFramePaletteCtrl.cc | 144 ++++ .../gui/editor/guiEditFramePaletteCtrl.h | 77 +++ .../gui/editor/guiEditFrameStripCtrl.cc | 2 + .../guiEditFrameStripCtrl_ScriptBinding.h | 129 ++++ .../gui/editor/guiEditFrameTimelineCtrl.cc | 632 ++++++++++++++++++ .../gui/editor/guiEditFrameTimelineCtrl.h | 169 +++++ .../guiEditFrameTimelineCtrl_ScriptBinding.h | 183 +++++ .../testing/tests/guiFrameStripLayoutTests.cc | 152 +++++ 9 files changed, 1490 insertions(+) create mode 100644 engine/source/gui/editor/guiEditFramePaletteCtrl.cc create mode 100644 engine/source/gui/editor/guiEditFramePaletteCtrl.h create mode 100644 engine/source/gui/editor/guiEditFrameStripCtrl_ScriptBinding.h create mode 100644 engine/source/gui/editor/guiEditFrameTimelineCtrl.cc create mode 100644 engine/source/gui/editor/guiEditFrameTimelineCtrl.h create mode 100644 engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index 8b1a4998f..1dad5a489 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -202,7 +202,9 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/gui/editor/guiEditorCursorCtrl.cc ${TORQUE_SRC}/gui/editor/guiDebugger.cc ${TORQUE_SRC}/gui/editor/guiEditCtrl.cc + ${TORQUE_SRC}/gui/editor/guiEditFramePaletteCtrl.cc ${TORQUE_SRC}/gui/editor/guiEditFrameStripCtrl.cc + ${TORQUE_SRC}/gui/editor/guiEditFrameTimelineCtrl.cc ${TORQUE_SRC}/gui/editor/guiEditorExplorerTree.cc ${TORQUE_SRC}/gui/editor/guiGraphCtrl.cc ${TORQUE_SRC}/gui/editor/guiInspector.cc diff --git a/engine/source/gui/editor/guiEditFramePaletteCtrl.cc b/engine/source/gui/editor/guiEditFramePaletteCtrl.cc new file mode 100644 index 000000000..cb86dbfb8 --- /dev/null +++ b/engine/source/gui/editor/guiEditFramePaletteCtrl.cc @@ -0,0 +1,144 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#include "gui/editor/guiEditFramePaletteCtrl.h" +#include "console/consoleTypes.h" + +IMPLEMENT_CONOBJECT(GuiEditFramePaletteCtrl); + +GuiEditFramePaletteCtrl::GuiEditFramePaletteCtrl() +{ + mPressed = false; + mPressCell = -1; + mPressAt.set(0, 0); + mDragged = false; +} + +//----------------------------------------------------------------------------- + +S32 GuiEditFramePaletteCtrl::getCellCount() const +{ + return getImageFrameCount(); +} + +Point2I GuiEditFramePaletteCtrl::getDesiredExtent() +{ + // The width belongs to the scroller -- HorizSizing keeps this control as wide + // as the space inside it -- and the height is however many rows that width + // produced. Growing the width too would give the scroller something to scroll + // sideways over, which is the one thing a wrapping palette must never do. + return Point2I(mBounds.extent.x, getOuterExtentForCells().y); +} + +//----------------------------------------------------------------------------- + +void GuiEditFramePaletteCtrl::onTouchDown(const GuiEvent& event) +{ + const S32 cell = cellAtGlobal(event.mousePoint); + if (cell == -1) + { + // A press on the gaps or past the last frame is not the start of + // anything, so let it bubble -- the pane behind may want it. + Parent::onTouchDown(event); + return; + } + + mPressed = true; + mDragged = false; + mPressCell = cell; + mPressAt = event.mousePoint; + + // Held so the drag can be measured even as the pointer leaves. Handed + // straight back the moment this becomes a real drag, because the + // GuiDragAndDropCtrl script builds does its own capturing. + mouseLock(); +} + +void GuiEditFramePaletteCtrl::onTouchDragged(const GuiEvent& event) +{ + if (!mPressed || mDragged) + { + return; + } + + const Point2I travelled = event.mousePoint - mPressAt; + if (mAbs(travelled.x) < smDragSlop && mAbs(travelled.y) < smDragSlop) + { + return; + } + + mDragged = true; + mPressed = false; + mouseUnlock(); + + // Script makes the payload and the drag control: what a frame looks like + // while it is in flight, and where it may be dropped, are both questions + // about the editor rather than about this grid. + if (isMethod("onFrameDragBegan")) + { + Con::executef(this, 4, "onFrameDragBegan", + Con::getIntArg(getFrameAt(mPressCell)), + Con::getIntArg(event.mousePoint.x), + Con::getIntArg(event.mousePoint.y)); + } +} + +void GuiEditFramePaletteCtrl::onTouchUp(const GuiEvent& event) +{ + const bool wasPressed = mPressed; + const S32 pressCell = mPressCell; + + mPressed = false; + mPressCell = -1; + + // Safe whether or not this control is the one holding the capture: + // GuiCanvas::mouseUnlock ignores a control that is not. + mouseUnlock(); + + // A release that followed a drag is that drag ending, not a click. Without + // this the frame would be both dropped where it was let go AND appended to + // the end, which is the double-fire GuiEditorControlTile guards against with + // the same flag. + if (mDragged) + { + mDragged = false; + return; + } + + if (!wasPressed) + { + Parent::onTouchUp(event); + return; + } + + // Released somewhere else entirely: the press is abandoned rather than + // applied to whichever cell happens to be under the pointer now. + if (cellAtGlobal(event.mousePoint) != pressCell) + { + return; + } + + if (isMethod("onFrameClicked")) + { + Con::executef(this, 2, "onFrameClicked", Con::getIntArg(getFrameAt(pressCell))); + } +} diff --git a/engine/source/gui/editor/guiEditFramePaletteCtrl.h b/engine/source/gui/editor/guiEditFramePaletteCtrl.h new file mode 100644 index 000000000..fba6fc8ef --- /dev/null +++ b/engine/source/gui/editor/guiEditFramePaletteCtrl.h @@ -0,0 +1,77 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#ifndef _GUI_EDIT_FRAME_PALETTE_CTRL_H_ +#define _GUI_EDIT_FRAME_PALETTE_CTRL_H_ + +#ifndef _GUI_EDIT_FRAME_STRIP_CTRL_H_ +#include "gui/editor/guiEditFrameStripCtrl.h" +#endif + +//----------------------------------------------------------------------------- +// Every frame an image asset offers, wrapped into rows and scrolled vertically: +// the right-hand pane of the Asset Manager's animation editor, and the source +// end of the drag that builds a timeline. +// +// Cells are derived, not held -- frame N of the sheet is cell N -- so there is +// no state here to keep in step with anything. What the palette owns is the +// gesture: a press that becomes either a click or a drag depending on whether it +// moved, which is the same fork the Gui Editor's control palette makes. +// +// Both ends report to script rather than acting, because what a dropped frame +// means is the timeline's business, not this control's. +//----------------------------------------------------------------------------- + +class GuiEditFramePaletteCtrl : public GuiEditFrameStripCtrl +{ +private: + typedef GuiEditFrameStripCtrl Parent; + +protected: + bool mPressed; ///< A press is in progress and has not yet become a drag. + S32 mPressCell; ///< The cell it started on. + Point2I mPressAt; ///< Where, so the slop can be measured. + bool mDragged; ///< It travelled far enough to be a drag, so the release is not a click. + +public: + // The distance a press has to travel before it stops being a click. The same + // five pixels GuiEditorControlTile uses, so the two palettes in the editor + // feel the same under the hand. + static constexpr S32 smDragSlop = 5; + + GuiEditFramePaletteCtrl(); + + /// One cell per frame of the sheet. + S32 getCellCount() const; + + /// As wide as the scroller made it, as tall as its rows need. The scroller + /// runs vertically, so the width is not this control's to choose. + Point2I getDesiredExtent(); + + void onTouchDown(const GuiEvent& event); + void onTouchDragged(const GuiEvent& event); + void onTouchUp(const GuiEvent& event); + + DECLARE_CONOBJECT(GuiEditFramePaletteCtrl); +}; + +#endif //_GUI_EDIT_FRAME_PALETTE_CTRL_H_ diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl.cc b/engine/source/gui/editor/guiEditFrameStripCtrl.cc index 15b03f741..26c24ddb2 100644 --- a/engine/source/gui/editor/guiEditFrameStripCtrl.cc +++ b/engine/source/gui/editor/guiEditFrameStripCtrl.cc @@ -25,6 +25,8 @@ #include "gui/guiDefaultControlRender.h" #include "console/consoleTypes.h" +#include "guiEditFrameStripCtrl_ScriptBinding.h" + IMPLEMENT_CONOBJECT(GuiEditFrameStripCtrl); GuiEditFrameStripCtrl::GuiEditFrameStripCtrl() diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl_ScriptBinding.h b/engine/source/gui/editor/guiEditFrameStripCtrl_ScriptBinding.h new file mode 100644 index 000000000..118eab388 --- /dev/null +++ b/engine/source/gui/editor/guiEditFrameStripCtrl_ScriptBinding.h @@ -0,0 +1,129 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +ConsoleMethodGroupBeginWithDocs(GuiEditFrameStripCtrl, GuiControl) + +/*! Sets the image asset whose frames the cells are drawn from. + An empty id empties the grid. + @param imageAssetId The asset id of the image. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, setImageAsset, ConsoleVoid, 3, 3, (imageAssetId)) +{ + object->setImageAssetId(argv[2]); +} + +/*! Gets the image asset the cells are drawn from. + @return The asset id, or an empty string when none is set. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, getImageAsset, ConsoleString, 2, 2, ()) +{ + return object->getImageAssetId(); +} + +/*! Gets how many frames the image asset offers. + This is not the same as the number of cells: a timeline has as many cells as + the animation has slots, which may repeat a frame or leave frames out. + @return The frame count, or 0 when there is no usable image. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, getImageFrameCount, ConsoleInt, 2, 2, ()) +{ + return object->getImageFrameCount(); +} + +/*! Gets how many cells are laid out. + @return The cell count. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, getCellCount, ConsoleInt, 2, 2, ()) +{ + return object->getCellCount(); +} + +/*! Gets which image frame a cell is showing. + @param index The cell index. + @return The image frame index, or -1 when the cell does not exist. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, getFrameAt, ConsoleInt, 3, 3, (index)) +{ + const S32 index = dAtoi(argv[2]); + if (index < 0 || index >= object->getCellCount()) + { + return -1; + } + + return object->getFrameAt(index); +} + +/*! Sets the pixel size of the square one frame draws in, clamped to what the + control will show. + @param cellSize The size in pixels. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, setCellSize, ConsoleVoid, 3, 3, (cellSize)) +{ + object->setCellSize(dAtoi(argv[2])); +} + +/*! Gets the pixel size of the square one frame draws in. + @return The size in pixels. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, getCellSize, ConsoleInt, 2, 2, ()) +{ + return object->getCellSize(); +} + +/*! Gets where a cell is on the canvas. + For a test that must not hard-code a point: a stale coordinate reports a + missing cell, which is exactly what a broken hit test reports. + @param index The cell index. + @return The rect as "x y width height" in global coordinates, or "0 0 0 0". +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, getCellRect, ConsoleString, 3, 3, (index)) +{ + const RectI cellRect = object->getCellRectGlobal(dAtoi(argv[2])); + + char* buffer = Con::getReturnBuffer(64); + dSprintf(buffer, 64, "%d %d %d %d", cellRect.point.x, cellRect.point.y, cellRect.extent.x, cellRect.extent.y); + return buffer; +} + +/*! Gets which cell a point on the canvas falls on. + @param x The global x coordinate. + @param y The global y coordinate. + @return The cell index, or -1 for the gaps between cells and anywhere off them. +*/ +ConsoleMethodWithDocs(GuiEditFrameStripCtrl, getCellAtPoint, ConsoleInt, 3, 4, (x, y)) +{ + Point2I point(0, 0); + if (argc == 3) + { + dSscanf(argv[2], "%d %d", &point.x, &point.y); + } + else + { + point.set(dAtoi(argv[2]), dAtoi(argv[3])); + } + + return object->cellAtGlobal(point); +} + +ConsoleMethodGroupEndWithDocs(GuiEditFrameStripCtrl) diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc new file mode 100644 index 000000000..61b790bea --- /dev/null +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc @@ -0,0 +1,632 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#include "gui/editor/guiEditFrameTimelineCtrl.h" +#include "graphics/dgl.h" +#include "console/consoleTypes.h" + +#include "guiEditFrameTimelineCtrl_ScriptBinding.h" + +IMPLEMENT_CONOBJECT(GuiEditFrameTimelineCtrl); + +GuiEditFrameTimelineCtrl::GuiEditFrameTimelineCtrl() +{ + mSelected = -1; + mCaret = -1; + mPlayhead = -1; + mPreview = NULL; + + mPressed = false; + mDragging = false; + mDragFrom = -1; + mPressAt.set(0, 0); + mDragOutside = false; + + // Spelled out because ColorI's constructor leaves its components + // uninitialised. + mSelectColor.set(120, 190, 255, 255); + mPlayheadColor.set(255, 210, 90, 255); + mCaretColor.set(255, 255, 255, 230); + mHoldColor.set(255, 255, 255, 60); + mRemoveColor.set(255, 90, 90, 110); +} + +void GuiEditFrameTimelineCtrl::initPersistFields() +{ + Parent::initPersistFields(); + + addField("SelectColor", TypeColorI, Offset(mSelectColor, GuiEditFrameTimelineCtrl), + "The outline around the picked slot."); + addField("PlayheadColor", TypeColorI, Offset(mPlayheadColor, GuiEditFrameTimelineCtrl), + "The bar over the slot the preview is showing."); + addField("CaretColor", TypeColorI, Offset(mCaretColor, GuiEditFrameTimelineCtrl), + "The insertion mark shown while a frame is being dragged in."); + addField("HoldColor", TypeColorI, Offset(mHoldColor, GuiEditFrameTimelineCtrl), + "The join drawn between repeats of one frame, which is how a hold reads."); + addField("RemoveColor", TypeColorI, Offset(mRemoveColor, GuiEditFrameTimelineCtrl), + "The wash over a slot that would be removed if the drag were released here."); +} + +//----------------------------------------------------------------------------- +// The two statics this class adds. Both take everything they use, so the caret +// the user sees and the index a drop lands on come from the same arithmetic. +//----------------------------------------------------------------------------- + +S32 GuiEditFrameTimelineCtrl::insertionAt(const Point2I& local, S32 cellCount, S32 cellSize, S32 cellPad) +{ + if (cellCount < 1) + { + return 0; + } + + cellSize = getMax(1, cellSize); + + const S32 advance = getCellAdvance(cellSize, cellPad); + + // Measured from the first cell's CENTRE rather than its edge, so the boundary + // between "before this cell" and "after it" sits halfway across it. + const S32 fromFirstCentre = local.x - (cellSize / 2); + const S32 index = (fromFirstCentre < 0) ? 0 : ((fromFirstCentre / advance) + 1); + + return mClamp(index, 0, cellCount); +} + +RectI GuiEditFrameTimelineCtrl::getCaretRect(S32 insertIndex, S32 cellCount, S32 cellSize, S32 cellPad, S32 height) +{ + cellSize = getMax(1, cellSize); + cellCount = getMax(0, cellCount); + height = getMax(0, height); + + const S32 advance = getCellAdvance(cellSize, cellPad); + const S32 pad = getMax(0, cellPad); + const S32 contentWidth = (cellCount < 1) ? 0 : ((cellCount * advance) - pad); + + insertIndex = mClamp(insertIndex, 0, cellCount); + + S32 x; + if (insertIndex <= 0) + { + x = 0; + } + else if (insertIndex >= cellCount) + { + // Flush with the right-hand edge. There is no gap after the last cell to + // sit in, and a caret drawn past the content is a caret clipped away. + x = getMax(0, contentWidth - smCaretWidth); + } + else + { + // Centred in the gap, which touches neither neighbour. + x = (insertIndex * advance) - (pad / 2) - (smCaretWidth / 2); + } + + return RectI(x, 0, smCaretWidth, height); +} + +//----------------------------------------------------------------------------- + +S32 GuiEditFrameTimelineCtrl::getFrameAt(S32 index) const +{ + if (index < 0 || index >= mSlots.size()) + { + return -1; + } + + return mSlots[index]; +} + +Point2I GuiEditFrameTimelineCtrl::getDesiredExtent() +{ + // The height belongs to the scroller; the width is however far the cells + // reach, which is what gives the horizontal bar something to scroll. + return Point2I(getOuterExtentForCells().x, mBounds.extent.y); +} + +//----------------------------------------------------------------------------- + +const char* GuiEditFrameTimelineCtrl::getFrames() +{ + if (mSlots.size() == 0) + { + return ""; + } + + // 12 characters is room for a signed 32-bit number and its separator. + const U32 bufferSize = (mSlots.size() * 12) + 1; + char* buffer = Con::getReturnBuffer(bufferSize); + U32 offset = 0; + + for (S32 i = 0; i < mSlots.size(); ++i) + { + offset += dSprintf(buffer + offset, bufferSize - offset, (i == 0) ? "%d" : " %d", mSlots[i]); + } + + return buffer; +} + +void GuiEditFrameTimelineCtrl::setFrames(const char* frames) +{ + mSlots.clear(); + + if (frames != NULL) + { + // Split on the same three separators AnimationAsset::setAnimationFrames + // uses, so a list can be handed straight from one to the other. + char* copy = dStrdup(frames); + for (const char* token = dStrtok(copy, " \t\n"); token != NULL; token = dStrtok(NULL, " \t\n")) + { + mSlots.push_back(dAtoi(token)); + } + dFree(copy); + } + + if (mSelected >= mSlots.size()) + { + mSelected = -1; + } + mCaret = -1; + + updateExtent(); + setUpdate(); +} + +bool GuiEditFrameTimelineCtrl::insertFrame(S32 slot, S32 frame) +{ + if (slot < 0 || slot > mSlots.size() || frame < 0) + { + return false; + } + + mSlots.insert(slot); + mSlots[slot] = frame; + + // The picked slot moves along with everything else it was standing after. + if (mSelected >= slot) + { + mSelected++; + } + + updateExtent(); + setUpdate(); + return true; +} + +bool GuiEditFrameTimelineCtrl::removeSlot(S32 slot) +{ + if (slot < 0 || slot >= mSlots.size()) + { + return false; + } + + mSlots.erase(slot); + + if (mSelected == slot) + { + // Stay on the same position rather than jumping to the start, so holding + // Delete walks along the list removing one after another. + mSelected = (slot < mSlots.size()) ? slot : (mSlots.size() - 1); + } + else if (mSelected > slot) + { + mSelected--; + } + + updateExtent(); + setUpdate(); + return true; +} + +bool GuiEditFrameTimelineCtrl::moveSlot(S32 from, S32 to) +{ + if (from < 0 || from >= mSlots.size() || to < 0 || to > mSlots.size()) + { + return false; + } + + // `to` is an insertion point measured against the list as it stands, so once + // the slot is lifted out everything past it has shuffled down by one. + const S32 frame = mSlots[from]; + const S32 landing = (to > from) ? (to - 1) : to; + + if (landing == from) + { + return false; + } + + mSlots.erase(from); + mSlots.insert(landing); + mSlots[landing] = frame; + + mSelected = landing; + + setUpdate(); + return true; +} + +void GuiEditFrameTimelineCtrl::setSelectedSlot(S32 slot) +{ + const S32 settled = (slot >= 0 && slot < mSlots.size()) ? slot : -1; + if (settled == mSelected) + { + return; + } + + mSelected = settled; + setUpdate(); +} + +void GuiEditFrameTimelineCtrl::selectSlotAndNotify(S32 slot) +{ + setSelectedSlot(slot); + + // Arrow keys scrub the preview exactly as a click does. Walking the list with + // the keyboard and watching the frame change is the whole point of having + // them; a selection that moved silently would be a highlight and nothing more. + if (isMethod("onSlotSelected")) + { + Con::executef(this, 3, "onSlotSelected", Con::getIntArg(mSelected), Con::getIntArg(getFrameAt(mSelected))); + } +} + +void GuiEditFrameTimelineCtrl::setPreviewSprite(SpriteBase* sprite) +{ + mPreview = sprite; + mPlayhead = -1; + setUpdate(); +} + +//----------------------------------------------------------------------------- + +S32 GuiEditFrameTimelineCtrl::showCaretAt(const Point2I& globalPoint) +{ + Point2I origin(0, 0); + const RectI content = getContentRect(origin); + + Point2I local = globalToLocalCoord(globalPoint); + local -= content.point; + + const S32 caret = insertionAt(local, mSlots.size(), mCellSize, mCellPad); + if (caret != mCaret) + { + mCaret = caret; + setUpdate(); + } + + return caret; +} + +void GuiEditFrameTimelineCtrl::clearCaret() +{ + if (mCaret == -1) + { + return; + } + + mCaret = -1; + setUpdate(); +} + +bool GuiEditFrameTimelineCtrl::insertFrameAtPoint(const Point2I& globalPoint, S32 frame) +{ + Point2I origin(0, 0); + const RectI content = getContentRect(origin); + + Point2I local = globalToLocalCoord(globalPoint); + local -= content.point; + + const bool inserted = insertFrame(insertionAt(local, mSlots.size(), mCellSize, mCellPad), frame); + if (inserted) + { + notifyFramesChanged(); + } + + return inserted; +} + +void GuiEditFrameTimelineCtrl::notifyFramesChanged() +{ + if (isMethod("onFramesChanged")) + { + Con::executef(this, 1, "onFramesChanged"); + } +} + +//----------------------------------------------------------------------------- + +S32 GuiEditFrameTimelineCtrl::readPlayhead() +{ + if (mPreview.isNull() || mSlots.size() == 0) + { + return -1; + } + + if (mPreview->isStaticFrameProvider() || !mPreview->isAnimationValid()) + { + return -1; + } + + // The ANIMATION frame -- the slot -- not getCurrentAnimationFrame, which is + // the image frame. One image frame can fill several slots, and the marker has + // to be on the one actually playing. + const S32 slot = mPreview->getAnimationFrame(); + + return (slot >= 0 && slot < mSlots.size()) ? slot : -1; +} + +void GuiEditFrameTimelineCtrl::onPreRender() +{ + Parent::onPreRender(); + + // preRender recurses from the canvas every frame, which makes this the + // cheapest correct place to notice the preview has moved on, and the + // documented place for a control to mark itself dirty. One integer read. + const S32 playhead = readPlayhead(); + if (playhead != mPlayhead) + { + mPlayhead = playhead; + setUpdate(); + } +} + +//----------------------------------------------------------------------------- + +void GuiEditFrameTimelineCtrl::renderCell(S32 index, const RectI& cellRect, bool isHovered) +{ + Parent::renderCell(index, cellRect, isHovered); + + // The slot being carried draws faded in the place it came from, so the list + // still reads as continuous while it is being rearranged. + if (mDragging && index == mDragFrom) + { + dglDrawRectFill(cellRect, mDragOutside ? mRemoveColor : mHoldColor); + } + + // Selected and playing are drawn differently on purpose -- an outline and a + // bar -- because they are frequently the same cell and the user needs to see + // both. Scrubbing sets one and reads the other. + if (index == mSelected) + { + dglDrawRect(cellRect, mSelectColor); + } + + if (index == mPlayhead) + { + RectI marker = cellRect; + marker.extent.y = 3; + dglDrawRectFill(marker, mPlayheadColor); + } +} + +void GuiEditFrameTimelineCtrl::renderOverlay(const RectI& contentRect) +{ + const S32 columns = getColumnCount(); + + // A run of the same frame is a hold -- the only way the format has of making + // one frame last longer. Joining the repeats across the gap says "this is one + // pose held" rather than "somebody added the same frame twice by accident". + for (S32 i = 1; i < mSlots.size(); ++i) + { + if (mSlots[i] != mSlots[i - 1]) + { + continue; + } + + const RectI previous = getCellRect(i - 1, columns, mCellSize, mCellPad); + const RectI current = getCellRect(i, columns, mCellSize, mCellPad); + + const S32 left = contentRect.point.x + previous.point.x + previous.extent.x; + const S32 right = contentRect.point.x + current.point.x; + if (right <= left) + { + continue; + } + + RectI join(left, contentRect.point.y + previous.point.y + (previous.extent.y / 3), + right - left, getMax(1, previous.extent.y / 3)); + dglDrawRectFill(join, mHoldColor); + } + + if (mCaret < 0) + { + return; + } + + RectI caret = getCaretRect(mCaret, mSlots.size(), mCellSize, mCellPad, mCellSize); + caret.point += contentRect.point; + dglDrawRectFill(caret, mCaretColor); +} + +//----------------------------------------------------------------------------- + +void GuiEditFrameTimelineCtrl::onTouchDown(const GuiEvent& event) +{ + // Keys only arrive at the first responder, and Delete is how a slot is + // removed, so the click that picks a slot has to claim focus as well. The + // profile must allow it -- listBoxProfile and treeViewProfile are the theme's + // two with canKeyFocus set. + setFirstResponder(); + + const S32 cell = cellAtGlobal(event.mousePoint); + if (cell == -1) + { + Parent::onTouchDown(event); + return; + } + + mPressed = true; + mDragging = false; + mDragOutside = false; + mDragFrom = cell; + mPressAt = event.mousePoint; + + setSelectedSlot(cell); + + if (isMethod("onSlotSelected")) + { + Con::executef(this, 3, "onSlotSelected", Con::getIntArg(cell), Con::getIntArg(getFrameAt(cell))); + } + + // Held for the whole gesture. This is also what makes "dragged off the + // timeline" observable at all: without the capture, dragging out of the + // control simply stops delivering events here. + mouseLock(); +} + +void GuiEditFrameTimelineCtrl::onTouchDragged(const GuiEvent& event) +{ + if (!mPressed) + { + return; + } + + if (!mDragging) + { + const Point2I travelled = event.mousePoint - mPressAt; + if (mAbs(travelled.x) < smDragSlop && mAbs(travelled.y) < smDragSlop) + { + return; + } + + mDragging = true; + } + + // Policed against this control's own bounds, in the same global space the + // event arrives in. Inside, the caret shows where the slot would land; + // outside, releasing throws it away. + RectI globalBounds(localToGlobalCoord(Point2I(0, 0)), mBounds.extent); + const bool outside = !globalBounds.pointInRect(event.mousePoint); + + if (outside != mDragOutside) + { + mDragOutside = outside; + setUpdate(); + } + + if (outside) + { + clearCaret(); + return; + } + + showCaretAt(event.mousePoint); +} + +void GuiEditFrameTimelineCtrl::onTouchUp(const GuiEvent& event) +{ + if (!mPressed) + { + Parent::onTouchUp(event); + return; + } + + const bool wasDragging = mDragging; + const bool wasOutside = mDragOutside; + const S32 from = mDragFrom; + const S32 caret = mCaret; + + mPressed = false; + mDragging = false; + mDragOutside = false; + mDragFrom = -1; + clearCaret(); + mouseUnlock(); + + // A press that never moved is a selection, and that already happened on the + // way down. + if (!wasDragging) + { + return; + } + + const bool changed = wasOutside ? removeSlot(from) : moveSlot(from, caret); + + if (changed) + { + notifyFramesChanged(); + } +} + +bool GuiEditFrameTimelineCtrl::onKeyDown(const GuiEvent& event) +{ + switch (event.keyCode) + { + case KEY_DELETE: + case KEY_BACKSPACE: + { + if (mSelected < 0) + { + break; + } + + if (removeSlot(mSelected)) + { + notifyFramesChanged(); + } + return true; + } + + case KEY_LEFT: + { + if (mSlots.size() == 0) + { + break; + } + selectSlotAndNotify(getMax(0, mSelected - 1)); + return true; + } + + case KEY_RIGHT: + { + if (mSlots.size() == 0) + { + break; + } + selectSlotAndNotify(getMin(mSlots.size() - 1, mSelected + 1)); + return true; + } + + case KEY_HOME: + { + if (mSlots.size() == 0) + { + break; + } + selectSlotAndNotify(0); + return true; + } + + case KEY_END: + { + if (mSlots.size() == 0) + { + break; + } + selectSlotAndNotify(mSlots.size() - 1); + return true; + } + + default: + break; + } + + return Parent::onKeyDown(event); +} diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl.h b/engine/source/gui/editor/guiEditFrameTimelineCtrl.h new file mode 100644 index 000000000..a8f303136 --- /dev/null +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl.h @@ -0,0 +1,169 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#ifndef _GUI_EDIT_FRAME_TIMELINE_CTRL_H_ +#define _GUI_EDIT_FRAME_TIMELINE_CTRL_H_ + +#ifndef _GUI_EDIT_FRAME_STRIP_CTRL_H_ +#include "gui/editor/guiEditFrameStripCtrl.h" +#endif + +#ifndef _SPRITE_BASE_H_ +#include "2d/core/SpriteBase.h" +#endif + +//----------------------------------------------------------------------------- +// The frames an animation plays, in order, with a marker on the one being shown +// in the preview above: the bottom pane of the Asset Manager's animation editor. +// +// The cells are an editable list, not a derivation, and the same image frame may +// appear in several of them. That repetition is load-bearing -- the asset format +// has no per-frame duration, every frame gets AnimationTime divided by the count, +// so the only way to hold a pose is to name it twice. A run of duplicates is +// drawn joined by a dimmed divider, which is what makes a hold legible as one +// held frame rather than as somebody's mistake. +// +// The list here is a COPY. This control never writes the asset: it reports one +// onFramesChanged per completed gesture and script decides what that means, +// which is the contract AssetImageCellGrid established. It matters more than +// usual here because AnimationAsset::setAnimationFrames has no equality guard, +// so every call rewrites the .animation.taml -- a per-drag-tick write would be +// hundreds of file writes for one reorder. +//----------------------------------------------------------------------------- + +class GuiEditFrameTimelineCtrl : public GuiEditFrameStripCtrl +{ +private: + typedef GuiEditFrameStripCtrl Parent; + +protected: + Vector mSlots; + S32 mSelected; ///< -1 when nothing is picked. + S32 mCaret; ///< Insertion point under a hovering drag, -1 when none. + S32 mPlayhead; ///< The slot the preview is showing, -1 when unknown. + + /// The sprite whose playback the marker follows. + /// + /// SimObjectPtr rather than a delete notification: the preview sprite is + /// destroyed and remade every time the preview scene is cleared, and this + /// nulls itself and re-points on assignment with no bookkeeping at all. The + /// image stays an AssetPtr, because the asset system refcounts that already. + SimObjectPtr mPreview; + + bool mPressed; ///< A press is in progress on a cell. + bool mDragging; ///< It travelled far enough to be a reorder. + S32 mDragFrom; ///< The slot it picked up. + Point2I mPressAt; + bool mDragOutside; ///< The pointer has left, so releasing removes rather than moves. + + ColorI mSelectColor; + ColorI mPlayheadColor; + ColorI mCaretColor; + ColorI mHoldColor; + ColorI mRemoveColor; + + /// What the preview is showing, or -1 when there is nothing to ask. + S32 readPlayhead(); + + void renderCell(S32 index, const RectI& cellRect, bool isHovered); + void renderOverlay(const RectI& contentRect); + + /// Tell script the list changed. One call per completed gesture, never per + /// drag tick, so script has exactly one place to commit from. + void notifyFramesChanged(); + + /// Pick a slot and say so, which is what makes the preview scrub to it. + void selectSlotAndNotify(S32 slot); + +public: + static constexpr S32 smDragSlop = 5; + static constexpr S32 smCaretWidth = 2; + + /// Where a dropped frame would go, as an index into the list from 0 to the + /// count inclusive. + /// + /// Counts cell CENTRES left of the point, not edges, so the caret flips + /// halfway across a cell -- which is where a person expects "before this one" + /// to become "after it". Never -1: unlike a click, which has to land on a + /// frame, a drop always has somewhere to go. + static S32 insertionAt(const Point2I& local, S32 cellCount, S32 cellSize, S32 cellPad); + + /// The bar drawn at an insertion point, relative to the content rect. + /// + /// Sits in the gap before the named cell, touching neither it nor the one + /// before. The one past the last cell is the exception: there is no gap after + /// the last cell -- getContentExtent deliberately leaves no trailing pad -- so + /// it goes flush against the right edge of the content, overlapping that + /// cell's last couple of pixels. Anywhere further right would be clipped away + /// and the user would see no caret at all. + static RectI getCaretRect(S32 insertIndex, S32 cellCount, S32 cellSize, S32 cellPad, S32 height); + + GuiEditFrameTimelineCtrl(); + static void initPersistFields(); + + S32 getCellCount() const { return mSlots.size(); } + S32 getFrameAt(S32 index) const; + + /// One row, always. Passing the cell count as the column count is what turns + /// the shared grid arithmetic into a single line. + S32 getColumnCount() { return getMax(1, mSlots.size()); } + + /// As tall as the scroller made it, as wide as its cells need -- the mirror + /// image of the palette, because this one scrolls sideways. + Point2I getDesiredExtent(); + + void onPreRender(); + + void onTouchDown(const GuiEvent& event); + void onTouchDragged(const GuiEvent& event); + void onTouchUp(const GuiEvent& event); + bool onKeyDown(const GuiEvent& event); + + /// The list, as a space-separated string. Trimmed, deliberately unlike + /// AnimationAsset::getAnimationFrames, which leaves a trailing space. + const char* getFrames(); + void setFrames(const char* frames); + + bool insertFrame(S32 slot, S32 frame); + bool removeSlot(S32 slot); + bool moveSlot(S32 from, S32 to); + + void setSelectedSlot(S32 slot); + inline S32 getSelectedSlot() const { return mSelected; } + inline S32 getPlayheadSlot() const { return mPlayhead; } + + void setPreviewSprite(SpriteBase* sprite); + inline void clearPreviewSprite() { mPreview = NULL; mPlayhead = -1; } + + /// Show the insertion caret for a point on the canvas, and where it would go. + S32 showCaretAt(const Point2I& globalPoint); + void clearCaret(); + + /// Put a frame in at the point a drag was released. The caret the user saw + /// and the index used here come out of the same static, so what was shown is + /// what happens. + bool insertFrameAtPoint(const Point2I& globalPoint, S32 frame); + + DECLARE_CONOBJECT(GuiEditFrameTimelineCtrl); +}; + +#endif //_GUI_EDIT_FRAME_TIMELINE_CTRL_H_ diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h b/engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h new file mode 100644 index 000000000..4cf83bea5 --- /dev/null +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h @@ -0,0 +1,183 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +ConsoleMethodGroupBeginWithDocs(GuiEditFrameTimelineCtrl, GuiEditFrameStripCtrl) + +/*! Gets the frames the timeline holds, in order. + Trimmed, deliberately unlike AnimationAsset::getAnimationFrames, which leaves + a trailing space. + @return The frame indices, space separated, or an empty string. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, getFrames, ConsoleString, 2, 2, ()) +{ + return object->getFrames(); +} + +/*! Replaces the whole list. + Separators are space, tab and newline, matching what + AnimationAsset::setAnimationFrames accepts, so a list can be handed straight + from one to the other. + @param frames The frame indices, space separated. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, setFrames, ConsoleVoid, 3, 3, (frames)) +{ + object->setFrames(argv[2]); +} + +/*! Puts a frame into the list at a slot. + @param slot Where it goes, from 0 to the count inclusive. + @param frame The image frame index. + @return Whether it went in. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, insertFrame, ConsoleBool, 4, 4, (slot, frame)) +{ + return object->insertFrame(dAtoi(argv[2]), dAtoi(argv[3])); +} + +/*! Takes a slot out of the list. + @param slot The slot index. + @return Whether there was one to take. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, removeSlot, ConsoleBool, 3, 3, (slot)) +{ + return object->removeSlot(dAtoi(argv[2])); +} + +/*! Moves a slot to a new place in the list. + @param from The slot to lift. + @param to Where to put it, measured against the list as it stands now. + @return Whether anything moved. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, moveSlot, ConsoleBool, 4, 4, (from, to)) +{ + return object->moveSlot(dAtoi(argv[2]), dAtoi(argv[3])); +} + +/*! Picks a slot, or -1 for none. + @param slot The slot index. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, setSelectedSlot, ConsoleVoid, 3, 3, (slot)) +{ + object->setSelectedSlot(dAtoi(argv[2])); +} + +/*! Gets the picked slot. + @return The slot index, or -1 when nothing is picked. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, getSelectedSlot, ConsoleInt, 2, 2, ()) +{ + return object->getSelectedSlot(); +} + +/*! Gets the slot the preview is currently showing. + @return The slot index, or -1 when there is no preview or it is not playing + this animation. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, getPlayheadSlot, ConsoleInt, 2, 2, ()) +{ + return object->getPlayheadSlot(); +} + +/*! Points the playhead marker at a sprite's playback. + @param sprite The preview sprite. + @return Whether it was a sprite. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, setPreviewSprite, ConsoleBool, 3, 3, (sprite)) +{ + SpriteBase* sprite = dynamic_cast(Sim::findObject(argv[2])); + if (sprite == NULL) + { + Con::warnf("GuiEditFrameTimelineCtrl::setPreviewSprite() - '%s' is not a sprite.", argv[2]); + return false; + } + + object->setPreviewSprite(sprite); + return true; +} + +/*! Forgets the preview sprite, so the playhead marker stands down. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, clearPreviewSprite, ConsoleVoid, 2, 2, ()) +{ + object->clearPreviewSprite(); +} + +/*! Gets where a slot is on the canvas. + @param slot The slot index. + @return The rect as "x y width height" in global coordinates. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, getSlotRect, ConsoleString, 3, 3, (slot)) +{ + const RectI slotRect = object->getCellRectGlobal(dAtoi(argv[2])); + + char* buffer = Con::getReturnBuffer(64); + dSprintf(buffer, 64, "%d %d %d %d", slotRect.point.x, slotRect.point.y, slotRect.extent.x, slotRect.extent.y); + return buffer; +} + +/*! Shows the insertion caret for a point on the canvas. + @param x The global x coordinate, or "x y" as one argument. + @param y The global y coordinate. + @return The slot a drop released here would land in. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, showCaretAt, ConsoleInt, 3, 4, (x, y)) +{ + Point2I point(0, 0); + if (argc == 3) + { + dSscanf(argv[2], "%d %d", &point.x, &point.y); + } + else + { + point.set(dAtoi(argv[2]), dAtoi(argv[3])); + } + + return object->showCaretAt(point); +} + +/*! Hides the insertion caret. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, clearCaret, ConsoleVoid, 2, 2, ()) +{ + object->clearCaret(); +} + +/*! Puts a frame in where a drag was released. + Uses the same arithmetic the caret was drawn from, so what was shown is what + happens. + @param point The global point as "x y". + @param frame The image frame index. + @return Whether it went in. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, insertFrameAtPoint, ConsoleBool, 4, 4, (point, frame)) +{ + Point2I point(0, 0); + dSscanf(argv[2], "%d %d", &point.x, &point.y); + + return object->insertFrameAtPoint(point, dAtoi(argv[3])); +} + +ConsoleMethodGroupEndWithDocs(GuiEditFrameTimelineCtrl) diff --git a/engine/source/testing/tests/guiFrameStripLayoutTests.cc b/engine/source/testing/tests/guiFrameStripLayoutTests.cc index e82a3dfa7..8c18bb8d4 100644 --- a/engine/source/testing/tests/guiFrameStripLayoutTests.cc +++ b/engine/source/testing/tests/guiFrameStripLayoutTests.cc @@ -31,6 +31,10 @@ #include "gui/editor/guiEditFrameStripCtrl.h" #endif +#ifndef _GUI_EDIT_FRAME_TIMELINE_CTRL_H_ +#include "gui/editor/guiEditFrameTimelineCtrl.h" +#endif + //----------------------------------------------------------------------------- // Where cell N of a frame grid sits, and which cell a point lands on. // @@ -366,4 +370,152 @@ TEST( GuiFrameStripLayoutTests, TheAdvanceIsACellAndItsGap ) SUCCEED(); } +//----------------------------------------------------------------------------- +// insertionAt -- where a dragged frame would land. +// +// A different question from cellAt, with a different shape of answer. cellAt +// asks "which cell is this", and the gaps belong to nobody; this asks "where +// would it go", and every point has an answer including the gaps, the ends, and +// an empty timeline. Getting the two confused is how a drop lands one slot off. +// +// Cells are 20 with a 4 gap, so with three of them the centres are at 10, 34 +// and 58. +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, LeftOfEverythingInsertsAtTheStart ) +{ + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 0, 5 ), 3, sCell, sPad ), 0 ) + << "The very first pixel is before the first frame."; + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( -20, 5 ), 3, sCell, sPad ), 0 ) + << "So is anywhere left of the strip, which a drag arriving from the palette is."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, RightOfEverythingAppends ) +{ + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 500, 5 ), 3, sCell, sPad ), 3 ) + << "Past the last frame is the end of the list, not off it."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheBoundaryIsTheCellCentreNotItsEdge ) +{ + // This is the whole design of insertionAt. Halfway across a cell is where a + // person expects "before this one" to become "after it"; using the cell's + // edge instead makes the caret lag the pointer by half a frame. + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 9, 5 ), 3, sCell, sPad ), 0 ) + << "One pixel left of the first cell's centre is still before it."; + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 10, 5 ), 3, sCell, sPad ), 1 ) + << "Its centre is where it flips to after."; + + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 33, 5 ), 3, sCell, sPad ), 1 ) + << "The same one cell along."; + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 34, 5 ), 3, sCell, sPad ), 2 ) + << "And it flips at that cell's centre too."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheGapStillHasAnAnswer ) +{ + // Unlike cellAt, which returns -1 here. A drag hovering over the gap between + // two frames is asking the clearest question there is. + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 21, 5 ), 3, sCell, sPad ), 1 ) + << "Between cell 0 and cell 1 is slot 1, not nothing."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, AnEmptyTimelineTakesTheFirstFrame ) +{ + ASSERT_EQ( GuiEditFrameTimelineCtrl::insertionAt( Point2I( 40, 5 ), 0, sCell, sPad ), 0 ) + << "The first frame dragged into an empty animation goes at slot 0, wherever it was dropped."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// getCaretRect -- and it must agree with insertionAt, because the caret is the +// promise the drop then has to keep. +//----------------------------------------------------------------------------- + +TEST( GuiFrameStripLayoutTests, TheCaretSitsBetweenTwoCellsAndTouchesNeither ) +{ + const RectI caret = GuiEditFrameTimelineCtrl::getCaretRect( 1, 3, sCell, sPad, sCell ); + + const RectI before = GuiEditFrameStripCtrl::getCellRect( 0, 3, sCell, sPad ); + const RectI after = GuiEditFrameStripCtrl::getCellRect( 1, 3, sCell, sPad ); + + ASSERT_GE( caret.point.x, before.point.x + before.extent.x ) + << "The caret starts at or after the left neighbour's right edge."; + ASSERT_LE( caret.point.x + caret.extent.x, after.point.x ) + << "And ends at or before the right neighbour's left edge."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheCaretForSlotZeroIsAtTheStart ) +{ + const RectI caret = GuiEditFrameTimelineCtrl::getCaretRect( 0, 3, sCell, sPad, sCell ); + + ASSERT_EQ( caret.point.x, 0 ) + << "There is no gap before the first cell to sit in, so it goes at the edge."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheAppendCaretStaysInsideTheContent ) +{ + // There is no trailing gap after the last cell -- getContentExtent leaves + // none, deliberately -- so the append caret overlaps that cell's last pixels + // rather than sitting past the content, where it would simply be clipped away + // and the user would see no caret at all. + const S32 count = 3; + const Point2I content = GuiEditFrameStripCtrl::getContentExtent( count, count, sCell, sPad ); + const RectI caret = GuiEditFrameTimelineCtrl::getCaretRect( count, count, sCell, sPad, sCell ); + const RectI last = GuiEditFrameStripCtrl::getCellRect( count - 1, count, sCell, sPad ); + + ASSERT_EQ( caret.point.x + caret.extent.x, content.x ) + << "The append caret ends exactly at the right edge of the content."; + ASSERT_GT( caret.point.x, last.point.x ) + << "And is at the far end of the last cell, so it reads as after it."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, TheCaretIsVisibleOnAnEmptyTimeline ) +{ + // An empty animation has no cells and therefore no content width, and the + // caret must still be somewhere drawable -- this is the first thing a user + // building an animation from nothing will see. + const RectI caret = GuiEditFrameTimelineCtrl::getCaretRect( 0, 0, sCell, sPad, sCell ); + + ASSERT_EQ( caret.point.x, 0 ) << "At the start."; + ASSERT_GT( caret.extent.x, 0 ) << "With a width."; + ASSERT_EQ( caret.extent.y, sCell ) << "And the height it was asked for."; + + SUCCEED(); +} + +TEST( GuiFrameStripLayoutTests, EveryInsertionPointHasItsOwnCaret ) +{ + // Two slots must never share a caret position: if they did, the user could + // not tell from the picture which of two places a drop was about to go. + const S32 count = 4; + S32 previousX = -1; + + for ( S32 i = 0; i <= count; ++i ) + { + const RectI caret = GuiEditFrameTimelineCtrl::getCaretRect( i, count, sCell, sPad, sCell ); + + ASSERT_GT( caret.point.x, previousX ) + << "Each insertion point is strictly right of the one before it."; + previousX = caret.point.x; + } + + SUCCEED(); +} + #endif // TORQUE_SHIPPING From 2f68664244c533b2e7157871dc95df1890394203 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 11:24:13 -0400 Subject: [PATCH 10/26] Room for the preview to be split three ways, taken by nothing yet The animation editor needs the preview area divided into the art, the frames available and the timeline, with dividers a user can drag. This puts the frame set that will do it in place and changes nothing else -- the whole point of the commit is that the Asset Manager looks and behaves exactly as it did. That it can be invisible is what makes "build the nest always, split on demand" safe. GuiFrameSetCtrl::resize hands its one frame its own extent with no insets, so unsplit it is a pass-through; and splitting later never reparents anything, because splitFrame only rewrites which frame holds a control and removing one collapses the frame and hoists its twin. The background sprite, the SceneWindow, the scene and the audio overlay all stay exactly where they are for the life of the editor, whatever the stage does around them. previewHost is the layer that looks like a pointless wrapper. GuiWindowCtrl finds its dock target by casting its parent's FIRST child to GuiFrameSetCtrl; today that child is the background sprite, the cast fails, and window docking is quietly off in the Asset Manager. Making the frame set child zero would switch docking on by accident, aimed at the animation split -- so the Asset Inspector would offer to dock into frames the stage deletes out from under it. One plain control in between keeps the answer no. It is commented in place, because the next reader will otherwise remove it. assetLibrary (81), assetImageInspector (97) and assetPicker (63) are green, and the inspector's seven screenshots are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- editor/AssetAdmin/AssetAdmin.cs | 41 ++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index e5fb57249..6277264b7 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -170,6 +170,45 @@ class = "AssetInspector"; function AssetAdmin::buildAssetWindow(%this) { + // Two layers between the frame and the preview, and each earns its place. + // + // previewFrames is a frame set so the preview can be split three ways for an + // animation -- the art, the frames available, and the timeline -- with + // dividers the user can drag. Unsplit it is a pass-through: GuiFrameSetCtrl + // resizes its one frame to its own extent with no insets, so every other + // asset type sees exactly what it saw before. Splitting it later never + // reparents anything either, because splitFrame only rewrites which frame + // holds a control, and removing one collapses the frame and hoists its twin. + // + // previewHost looks like a pointless wrapper and is not. GuiWindowCtrl finds + // its dock target as a cast of its parent's FIRST child to GuiFrameSetCtrl. + // Today that child is the background sprite, the cast fails, and docking is + // quietly off in the Asset Manager. Put the frame set there instead and + // docking switches itself on, aimed at the animation split -- so the Asset + // Inspector window would offer to dock into frames the stage later deletes + // out from under it. One plain control in between keeps that answer "no". + %this.previewHost = new GuiControl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = "0 0"; + Extent = "100 100"; + }; + ThemeManager.setProfile(%this.previewHost, "emptyProfile"); + %this.content.add(%this.previewHost); + + %this.previewFrames = new GuiFrameSetCtrl() + { + HorizSizing = "width"; + VertSizing = "height"; + Position = "0 0"; + Extent = "100 100"; + DividerThickness = 6; + }; + ThemeManager.setProfile(%this.previewFrames, "frameSetProfile"); + ThemeManager.setProfile(%this.previewFrames, "dropButtonProfile", "dropButtonProfile"); + %this.previewHost.add(%this.previewFrames); + %this.background = new GuiSpriteCtrl() { HorizSizing = "right"; VertSizing = "bottom"; @@ -185,7 +224,7 @@ class = "AssetInspector"; constrainProportions = "1"; }; ThemeManager.setProfile(%this.background, "emptyProfile"); - %this.content.add(%this.background); + %this.previewFrames.add(%this.background); %this.assetScene = new Scene(); %this.assetScene.setScenePause(true); From 1c0699b0f22128e4f745e245c99c8b8eff6a8036 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 11:44:52 -0400 Subject: [PATCH 11/26] An animation editor where the preview used to be Choosing an animation asset now splits the preview three ways: the art playing on the left, every frame the image offers on the right, and the frames the animation actually plays along the bottom. Choosing anything else puts it back. AssetAnimationStage owns that. It is a ScriptObject rather than a control because what it manages is a shape -- two panes and the frame set they live in -- and because it gives the five asset kinds that have never heard of animation a single line to ignore: retainFor, called once above the selection chain, which keeps the split up for the asset it already shows and takes it down otherwise. Frames get into the timeline by dragging or by clicking, and both end in the same appendFrame. A click is a drop that never moved, and giving it its own path into the list would mean two places to remember to commit. Three things cost real time and are worth writing down. setFrameSize is the only thing that lays a frame set out, and a layout can only size the controls already in their frames. Sizing the frames before adding the panes produced a split with all the right frames and a palette still 100 x 100, parked behind the preview where nothing whatever could be seen of it. Both sizes now come last, after both panes are in. The two panes take fill on the axis their scroller cannot scroll and let the scroller own the other. That is legal exactly because the bar is alwaysOff there, and it is what lets the palette learn its real width -- which it must have before it can work out how many columns fit. It had been using "width", which preserves the gap it was built with, and the gap was wrong. And moving a divider resizes the SceneWindow, whose onExtentChange answers by re-clicking the selected tile -- which lands back in the stage. So building and collapsing are both shut for the duration. Without that, deleting the first pane re-entered select() while built was still true and the second call reached for a pane that was already half gone. Ids are cleared on teardown too, not just deleted: a field still holding a freed id will answer isObject() about whatever took that id next, and the symptom is a call into a live object that has never heard of the method. 30 checks in tests/smoke/assetAnimationTimeline.cs, including that the split collapses to exactly one frame for any other asset and that the preview window and its scene are the same objects throughout. Four screenshots, and the log is clean of script errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationPalettePane.cs | 116 +++++ .../Animation/AssetAnimationStage.cs | 412 ++++++++++++++++++ .../Animation/AssetAnimationTimelinePane.cs | 216 +++++++++ .../Animation/GuiEditFramePaletteCtrl.cs | 144 ++++++ .../Animation/GuiEditFrameTimelineCtrl.cs | 58 +++ editor/AssetAdmin/Animation/exec.cs | 27 ++ editor/AssetAdmin/AssetAdmin.cs | 21 +- editor/AssetAdmin/AssetDictionaryButton.cs | 9 + editor/AssetAdmin/AssetPreviewSprite.cs | 40 ++ editor/AssetAdmin/AssetWindow.cs | 10 +- tests/shots/assetAnimation.cs | 132 ++++++ tests/smoke/assetAnimationTimeline.cs | 213 +++++++++ 12 files changed, 1396 insertions(+), 2 deletions(-) create mode 100644 editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs create mode 100644 editor/AssetAdmin/Animation/AssetAnimationStage.cs create mode 100644 editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs create mode 100644 editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs create mode 100644 editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs create mode 100644 editor/AssetAdmin/Animation/exec.cs create mode 100644 editor/AssetAdmin/AssetPreviewSprite.cs create mode 100644 tests/shots/assetAnimation.cs create mode 100644 tests/smoke/assetAnimationTimeline.cs diff --git a/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs new file mode 100644 index 000000000..171b27af4 --- /dev/null +++ b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs @@ -0,0 +1,116 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The right-hand pane of the animation editor: every frame the animation's image +// has to offer, scrolled vertically, to click or drag into the timeline. +// +// The pane is the scroller and the caption; the grid inside it is C++, because +// script cannot ask an image where one of its frames is. +//----------------------------------------------------------------------------- + +$AssetAnimationPalettePane::captionHeight = 20; + +function AssetAnimationPalettePane::onAdd(%this) +{ + ThemeManager.setProfile(%this, "panelProfile"); + + %this.caption = new GuiControl() + { + HorizSizing = "width"; + VertSizing = "bottom"; + Position = "0 0"; + Extent = "100" SPC $AssetAnimationPalettePane::captionHeight; + Text = "Frames"; + }; + ThemeManager.setProfile(%this.caption, "labelProfile"); + %this.add(%this.caption); + + // "height", not "fill": fill means "be the whole of the parent", which here + // would put the scroller over the caption. Anchoring both edges keeps the + // caption's height at the top and takes everything below it. + %this.scroller = new GuiScrollCtrl() + { + HorizSizing = "width"; + VertSizing = "height"; + Position = "0" SPC $AssetAnimationPalettePane::captionHeight; + Extent = "100 80"; + hScrollBar = "alwaysOff"; + vScrollBar = "dynamic"; + constantThumbHeight = false; + scrollBarThickness = 14; + showArrowButtons = false; + }; + ThemeManager.setProfile(%this.scroller, "scrollingPanelProfile"); + ThemeManager.setProfile(%this.scroller, "tinyThumbProfile", "ThumbProfile"); + ThemeManager.setProfile(%this.scroller, "tinyTrackProfile", "TrackProfile"); + ThemeManager.setProfile(%this.scroller, "tinyScrollArrowProfile", "ArrowProfile"); + %this.add(%this.scroller); + + // No class= on the grid. The C++ class owns that namespace, and setting class + // to the same name makes Namespace::classLinkTo complain every time the + // editor opens. The owner is passed as a plain field instead. + // "fill" across, and it is legal precisely because the horizontal bar is + // alwaysOff: GuiScrollCtrl only refuses fill on an axis it can scroll, where + // filling would clamp the content to what is already visible. Across, there + // is nothing to scroll and fill is how the grid asks for the real width -- + // which it must have before it can work out how many columns fit. + %this.strip = new GuiEditFramePaletteCtrl() + { + pane = %this; + HorizSizing = "fill"; + VertSizing = "bottom"; + Position = "0 0"; + Extent = "100 100"; + CellSize = 48; + CellPad = 4; + ShowFrameNumbers = true; + }; + ThemeManager.setProfile(%this.strip, "emptyProfile"); + %this.scroller.add(%this.strip); +} + +function AssetAnimationPalettePane::load(%this, %imageAssetId) +{ + %this.strip.setImageAsset(%imageAssetId); + %this.refreshCaption(); +} + +function AssetAnimationPalettePane::reload(%this) +{ + // The image may have been re-cut, so the frame count has moved. Setting the + // same id again is what makes the grid ask it afresh. + %this.strip.setImageAsset(%this.strip.getImageAsset()); + %this.refreshCaption(); +} + +function AssetAnimationPalettePane::refreshCaption(%this) +{ + %count = %this.strip.getImageFrameCount(); + if(%count == 1) + { + %this.caption.setText("1 frame"); + return; + } + + %this.caption.setText(%count SPC "frames"); +} diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs new file mode 100644 index 000000000..d100fe445 --- /dev/null +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -0,0 +1,412 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// Turns the Asset Manager's preview into an animation editor while an animation +// asset is selected, and puts it back the moment anything else is. +// +// A manager rather than a control: it owns two panes and the shape of the frame +// set they live in, and it is the one place that knows an animation is on show. +// Everything that used to ask "is this an animation?" now asks this instead. +// +// The split is built on demand and collapsed on the way out. It has to be, not +// merely for tidiness: the other five asset kinds want the whole preview area, +// and a frame set with three frames cannot give it to them. +//----------------------------------------------------------------------------- + +$AssetAnimationStage::defaultPaletteWidth = 220; +$AssetAnimationStage::defaultTimelineHeight = 150; + +// A frame set hands out ids from 1, and the root is the first. Named because +// createVerticalSplit(1) reads as a magic number otherwise. +$AssetAnimationStage::rootFrameId = 1; + +function AssetAnimationStage::onAdd(%this) +{ + %this.built = false; + %this.assetId = ""; + %this.playing = false; +} + +function AssetAnimationStage::onRemove(%this) +{ + %this.teardown(); +} + +//----------------------------------------------------------------------------- +// Selection. +// +// retainFor is called for EVERY asset, whatever its kind, from the one place a +// selection is made. Saying "keep the split if it is for this asset, otherwise +// take it down" in one method is what keeps the five branches that have nothing +// to do with animation completely untouched. +//----------------------------------------------------------------------------- + +function AssetAnimationStage::retainFor(%this, %animationAssetId) +{ + if(%animationAssetId $= "" || !%this.canEdit(%animationAssetId)) + { + %this.teardown(); + return false; + } + + return true; +} + +// Named cells are out of scope for now, and the reason is not squeamishness: the +// engine's named-frame API does not round-trip. Its getter formats a string +// through %d, and the field joins with commas while the setter splits on +// whitespace, so a named list does not survive its own TAML file. Rather than +// build a timeline on top of that, such an asset keeps the plain inspector and +// the old single-sprite preview, which have always worked for it. +function AssetAnimationStage::canEdit(%this, %animationAssetId) +{ + %asset = AssetDatabase.acquireAsset(%animationAssetId); + if(!isObject(%asset)) + { + return false; + } + + %named = %asset.getNamedCellsMode(); + AssetDatabase.releaseAsset(%animationAssetId); + + return !%named; +} + +function AssetAnimationStage::select(%this, %imageAsset, %animationAsset, %assetId) +{ + if(%this.busy || !%this.retainFor(%assetId)) + { + return; + } + + %this.build(); + + // The editor closing resizes the canvas, and AssetWindow::onExtentChange + // answers a resize by re-clicking the selected tile -- so a selection can + // arrive while the panes are being torn down around it. Nothing to do then + // but stand down quietly. + if(!isObject(%this.palettePane) || !isObject(%this.timelinePane)) + { + return; + } + + // Borrowed, not acquired. AssetDictionaryButton::loadAnimationAsset already + // holds both of these and releases them in its onRemove, and this stage never + // outlives a selection -- a second acquire here would be a second release to + // remember somewhere else. + %this.animationAsset = %animationAsset; + %this.imageAsset = %imageAsset; + %this.assetId = %assetId; + %this.imageAssetId = %animationAsset.getImage(); + + %this.palettePane.load(%this.imageAssetId); + %this.timelinePane.load(%this.imageAssetId, trim(%animationAsset.getAnimationFrames())); +} + +//----------------------------------------------------------------------------- +// Building and collapsing the split. +//----------------------------------------------------------------------------- + +// Building and collapsing both move the dividers, which resizes the SceneWindow, +// whose onExtentChange answers a resize by re-clicking the selected tile -- which +// lands back here. So both are shut for the duration. +// +// Without it, deleting the first pane re-entered select() while built was still +// true, and the second call reached for a pane that was already half gone. +function AssetAnimationStage::build(%this) +{ + if(%this.built || %this.busy) + { + return; + } + + %this.busy = true; + %frames = %this.admin.previewFrames; + + // One split at a time, and the pane it makes room for added straight after. + // GuiFrameSetCtrl::assignChildToFrame puts a child in the empty frame its + // bounds fall inside, or failing that the first empty frame it walks to -- + // neither of which is worth relying on. Adding while exactly one frame is + // empty makes the answer certain. + // + // splitFrame leaves the existing control -- the preview background -- in + // child1 and anchors it, so the art stays top left through both splits and is + // never reparented. + %ids = %frames.createVerticalSplit($AssetAnimationStage::rootFrameId); + %topId = getWord(%ids, 0); + %this.timelineFrameId = getWord(%ids, 1); + %frames.anchorFrame(%this.timelineFrameId); + + %this.timelinePane = new GuiControl() + { + class = "AssetAnimationTimelinePane"; + stage = %this; + HorizSizing = "width"; + VertSizing = "height"; + Position = "0 0"; + Extent = "100 100"; + }; + %frames.add(%this.timelinePane); + + %ids = %frames.createHorizontalSplit(%topId); + %this.paletteFrameId = getWord(%ids, 1); + %frames.anchorFrame(%this.paletteFrameId); + + %this.palettePane = new GuiControl() + { + class = "AssetAnimationPalettePane"; + stage = %this; + HorizSizing = "width"; + VertSizing = "height"; + Position = "0 0"; + Extent = "100 100"; + }; + %frames.add(%this.palettePane); + + // Both sizes LAST, and that ordering is the whole of it. setFrameSize is the + // only thing here that lays the tree out -- it ends in a resize of the whole + // frame set -- and a layout can only size the controls that are already in + // their frames. Sized before the panes were added, the split came out with + // the right frames and a palette still at the 100 x 100 it was built with, + // sitting behind the preview where nothing could be seen of it. + %frames.setFrameSize(%this.timelineFrameId, + EditorPreferences.get("assetAnimationTimelineHeight", $AssetAnimationStage::defaultTimelineHeight)); + %frames.setFrameSize(%this.paletteFrameId, + EditorPreferences.get("assetAnimationPaletteWidth", $AssetAnimationStage::defaultPaletteWidth)); + + %this.built = true; + %this.busy = false; +} + +function AssetAnimationStage::teardown(%this) +{ + if(!%this.built || %this.busy) + { + return; + } + + %this.busy = true; + %this.rememberSizes(); + + // Deleting each pane collapses the frame that held it and hoists its twin's + // subtree, so two deletes take the tree back to one frame holding the preview + // background -- which was never moved and does not have to be put back. + if(isObject(%this.palettePane)) + { + %this.palettePane.delete(); + } + if(isObject(%this.timelinePane)) + { + %this.timelinePane.delete(); + } + + // Forgotten, not merely deleted. Sim ids are handed out again once they are + // free, so a field still holding a deleted pane's id will happily answer + // isObject() -- about whatever object took that id next. The symptom is a + // call into a live object that has never heard of the method, which reads + // like a namespace problem and is nothing of the sort. + %this.palettePane = ""; + %this.timelinePane = ""; + %this.previewSprite = ""; + + %this.built = false; + %this.assetId = ""; + %this.animationAsset = ""; + %this.imageAsset = ""; + %this.playing = false; + %this.busy = false; +} + +// A frame set has no divider-moved callback -- there is no Con::executef +// anywhere in guiFrameSetCtrl.cc -- so the sizes are read at the two moments the +// split is going away, which is the last chance to see where the user left them. +function AssetAnimationStage::rememberSizes(%this) +{ + if(!%this.built) + { + return; + } + + %layout = %this.admin.previewFrames.getFrameLayout(); + + %paletteWidth = %this.frameSizeFrom(%layout, %this.paletteFrameId, 0); + %timelineHeight = %this.frameSizeFrom(%layout, %this.timelineFrameId, 1); + + if(%paletteWidth > 0) + { + EditorPreferences.set("assetAnimationPaletteWidth", %paletteWidth); + } + if(%timelineHeight > 0) + { + EditorPreferences.set("assetAnimationTimelineHeight", %timelineHeight); + } +} + +// getFrameLayout is eight words per frame: +// id child1 child2 isVertical extentX extentY isAnchored controlID +function AssetAnimationStage::frameSizeFrom(%this, %layout, %frameId, %axis) +{ + %count = getWordCount(%layout); + for(%i = 0; %i < %count; %i += 8) + { + if(getWord(%layout, %i) == %frameId) + { + return getWord(%layout, %i + 4 + %axis); + } + } + + return 0; +} + +//----------------------------------------------------------------------------- +// The preview. +//----------------------------------------------------------------------------- + +// The preview scene is cleared and rebuilt from scratch by the display path, so +// the sprite the timeline follows is a different object every time. Said here +// rather than found by the timeline, because the timeline should not have to +// know how the preview is made. +function AssetAnimationStage::onPreviewRebuilt(%this, %sprite) +{ + if(!%this.built || %this.busy || !isObject(%sprite) || !isObject(%this.timelinePane)) + { + return; + } + + %this.previewSprite = %sprite; + %this.timelinePane.setPreviewSprite(%sprite); +} + +// Put a finished animation back in a state where it can be moved. +// +// The single answer to a trap that otherwise looks like a dead control: +// ImageFrameProviderCore::updateAnimation returns immediately once +// mAnimationFinished is set, and setAnimationFrame goes through it -- so a +// non-cycling preview that has run to the end cannot be scrubbed at all. +// playAnimation is the only way back, and it clears the pause on its way, so the +// pause has to be put back afterwards. +function AssetAnimationStage::armPreview(%this) +{ + if(!isObject(%this.previewSprite)) + { + return false; + } + + if(%this.previewSprite.getIsAnimationFinished()) + { + %this.previewSprite.playAnimation(%this.assetId); + %this.previewSprite.pauseAnimation(!%this.playing); + } + + return true; +} + +// The one place a slot is ever set. +function AssetAnimationStage::scrubTo(%this, %slot) +{ + if(!%this.armPreview()) + { + return; + } + + %count = %this.timelinePane.strip.getCellCount(); + if(%count < 1) + { + return; + } + + %this.previewSprite.setAnimationFrame(mClamp(%slot, 0, %count - 1)); +} + +// Clicking a slot stops first, deliberately: scrubbing a running preview shows a +// frame for a thirtieth of a second and then moves on, which reads as the click +// having done nothing. +function AssetAnimationStage::onSlotSelected(%this, %slot, %frame) +{ + %this.stop(); + %this.scrubTo(%slot); +} + +// Pausing, not stopping. SpriteBase::stopAnimation sets the finished flag, which +// is what kills setAnimationFrame -- so a preview stopped that way could never be +// scrubbed again. Pausing halts it just as visibly and leaves every other gesture +// alive. +function AssetAnimationStage::stop(%this) +{ + if(!isObject(%this.previewSprite)) + { + return; + } + + %this.playing = false; + %this.previewSprite.pauseAnimation(true); +} + +function AssetAnimationStage::play(%this) +{ + if(!%this.armPreview()) + { + return; + } + + %this.playing = true; + %this.previewSprite.pauseAnimation(false); +} + +// A one-shot animation reached its end on its own. Nothing to do to the preview +// -- the engine has already parked it on the last frame -- but the editor's idea +// of "playing" is now wrong, and a transport bar reading from it would be too. +function AssetAnimationStage::onPreviewFinished(%this) +{ + %this.playing = false; +} + +//----------------------------------------------------------------------------- +// Editing. Every path that changes the list ends here, and this is the only +// place in the editor that writes the animation's frames. +//----------------------------------------------------------------------------- + +function AssetAnimationStage::commitFrames(%this, %frames) +{ + if(!%this.built || !isObject(%this.animationAsset)) + { + return; + } + + // Guarded because the write comes straight back: every asset setter ends in + // refreshAsset, which rewrites the .animation.taml and fires onRefresh + // synchronously, inside this call. + %this.committing = true; + %this.animationAsset.setAnimationFrames(%frames); + %this.committing = false; +} + +function AssetAnimationStage::appendFrame(%this, %frame) +{ + if(!%this.built) + { + return; + } + + %this.timelinePane.appendFrame(%frame); +} diff --git a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs new file mode 100644 index 000000000..9436c8298 --- /dev/null +++ b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs @@ -0,0 +1,216 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The bottom pane of the animation editor: the frames the animation plays, in +// order, and the target of every drag out of the palette. +// +// The DROP TARGET is this pane rather than the grid inside it, on purpose. The +// grid is only as wide as its cells, so with four frames in a wide pane most of +// what looks like the timeline is not the grid at all -- and a frame let go over +// that empty space obviously means "put it at the end", not "nowhere". +//----------------------------------------------------------------------------- + +$AssetAnimationTimelinePane::captionHeight = 20; + +function AssetAnimationTimelinePane::onAdd(%this) +{ + ThemeManager.setProfile(%this, "panelProfile"); + + %this.caption = new GuiControl() + { + HorizSizing = "width"; + VertSizing = "bottom"; + Position = "0 0"; + Extent = "100" SPC $AssetAnimationTimelinePane::captionHeight; + Text = "Timeline"; + }; + ThemeManager.setProfile(%this.caption, "labelProfile"); + %this.add(%this.caption); + + // "height", not "fill": fill would put the scroller over the caption. + %this.scroller = new GuiScrollCtrl() + { + HorizSizing = "width"; + VertSizing = "height"; + Position = "0" SPC $AssetAnimationTimelinePane::captionHeight; + Extent = "100 80"; + hScrollBar = "dynamic"; + vScrollBar = "alwaysOff"; + constantThumbHeight = false; + scrollBarThickness = 14; + showArrowButtons = false; + }; + ThemeManager.setProfile(%this.scroller, "scrollingPanelProfile"); + ThemeManager.setProfile(%this.scroller, "tinyThumbProfile", "ThumbProfile"); + ThemeManager.setProfile(%this.scroller, "tinyTrackProfile", "TrackProfile"); + ThemeManager.setProfile(%this.scroller, "tinyScrollArrowProfile", "ArrowProfile"); + %this.add(%this.scroller); + + // listBoxProfile for its canKeyFocus. Without a profile that allows focus the + // strip never becomes first responder and the Delete key never arrives, which + // looks exactly like a broken key handler. + // The mirror of the palette: "fill" down the axis whose bar is alwaysOff, so + // the row is as tall as the scroller, and "right" across, where the strip + // sets its own width from its cells and the bar scrolls it. + %this.strip = new GuiEditFrameTimelineCtrl() + { + pane = %this; + HorizSizing = "right"; + VertSizing = "fill"; + Position = "0 0"; + Extent = "100 100"; + CellSize = 48; + CellPad = 4; + ShowFrameNumbers = true; + }; + ThemeManager.setProfile(%this.strip, "listBoxProfile"); + %this.scroller.add(%this.strip); +} + +function AssetAnimationTimelinePane::load(%this, %imageAssetId, %frames) +{ + %this.strip.setImageAsset(%imageAssetId); + %this.strip.setFrames(%frames); + %this.refreshCaption(); +} + +function AssetAnimationTimelinePane::setPreviewSprite(%this, %sprite) +{ + %this.strip.setPreviewSprite(%sprite); +} + +function AssetAnimationTimelinePane::refreshCaption(%this) +{ + %count = %this.strip.getCellCount(); + if(%count == 0) + { + %this.caption.setText("Timeline - empty"); + return; + } + + %this.caption.setText("Timeline -" SPC %count SPC (%count == 1 ? "frame" : "frames")); +} + +//----------------------------------------------------------------------------- +// Everything that changes the list ends up in commitFrames, and only here. The +// grid reports that it changed; deciding what that means to the asset is this +// pane's job, and writing it is the stage's. +//----------------------------------------------------------------------------- + +function AssetAnimationTimelinePane::commitFrames(%this) +{ + %this.refreshCaption(); + %this.stage.commitFrames(%this.strip.getFrames()); +} + +function AssetAnimationTimelinePane::appendFrame(%this, %frame) +{ + %this.strip.insertFrame(%this.strip.getCellCount(), %frame); + %this.commitFrames(); +} + +function AssetAnimationTimelinePane::setFrames(%this, %frames) +{ + %this.strip.setFrames(%frames); + %this.commitFrames(); +} + +function AssetAnimationTimelinePane::appendFrames(%this, %frames) +{ + %existing = %this.strip.getFrames(); + %this.strip.setFrames(%existing $= "" ? %frames : (%existing SPC %frames)); + %this.commitFrames(); +} + +//----------------------------------------------------------------------------- +// The drop, and the two traps in GuiDragAndDropCtrl that shape all of it. +// +// findDragTarget hit-tests from the drag control's PARENT, and +// GuiControl::findHitControl ends in a bare "return this" without ever testing +// its own bounds -- so a drop anywhere on the screen arrives here, over the +// library, over the menu bar, over the inspector. The target has to police its +// own boundary; nobody else will. +// +// And %position is not where the cursor is. GuiDragAndDropCtrl::sendDragEvent +// builds it from the drag control's own bounds, which are local to that outer +// frame set. So nothing here measures with it -- the payload is asked instead, +// which a drag grabbed by the middle answers exactly. +//----------------------------------------------------------------------------- + +function AssetAnimationTimelinePane::onControlDragged(%this, %payload, %position) +{ + %cursor = %this.cursorFrom(%payload); + if(!%this.isOverStrip(%cursor)) + { + %this.strip.clearCaret(); + return; + } + + %this.strip.showCaretAt(%cursor); +} + +function AssetAnimationTimelinePane::onControlDragExit(%this, %payload, %position) +{ + %this.strip.clearCaret(); +} + +function AssetAnimationTimelinePane::onControlDropped(%this, %payload, %position) +{ + %this.strip.clearCaret(); + + %cursor = %this.cursorFrom(%payload); + if(!%this.isOverStrip(%cursor)) + { + return; + } + + if(%this.strip.insertFrameAtPoint(%cursor, %payload.frameIndex)) + { + %this.commitFrames(); + } +} + +// The middle of the payload, which is where the cursor is holding it. +function AssetAnimationTimelinePane::cursorFrom(%this, %payload) +{ + %at = %payload.getGlobalPosition(); + %size = %payload.getExtent(); + + return mFloor(getWord(%at, 0) + (getWord(%size, 0) / 2)) SPC + mFloor(getWord(%at, 1) + (getWord(%size, 1) / 2)); +} + +// Measured against the SCROLLER, not the grid. The grid stops at its last cell; +// the scroller is the whole timeline as far as anyone looking at it is concerned. +function AssetAnimationTimelinePane::isOverStrip(%this, %point) +{ + %at = %this.scroller.getGlobalPosition(); + %size = %this.scroller.getExtent(); + + %x = getWord(%point, 0); + %y = getWord(%point, 1); + + return %x >= getWord(%at, 0) && %y >= getWord(%at, 1) && + %x < getWord(%at, 0) + getWord(%size, 0) && + %y < getWord(%at, 1) + getWord(%size, 1); +} diff --git a/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs b/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs new file mode 100644 index 000000000..66f0e336e --- /dev/null +++ b/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs @@ -0,0 +1,144 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The script half of the frame palette: what a click means, and what a frame +// looks like while it is being dragged. +// +// Note there is no class= on the control this belongs to, and there must not be: +// the C++ class owns this namespace. Setting class to the same name makes +// Namespace::classLinkTo log "cannot change namespace parent linkage" every time +// the editor opens. The owning pane arrives as an ordinary field, %this.pane. +//----------------------------------------------------------------------------- + +// How big the thing under the cursor is while a frame is in flight. Bigger than a +// palette cell on purpose: it is being carried, and it has to stay findable +// against the preview art behind it. +$GuiEditFramePaletteCtrl::payloadSize = 56; + +//----------------------------------------------------------------------------- +// A click is a drop that never moved. +// +// It routes to the same appendFrame the drop path ends in rather than adding the +// frame itself. Two ways into the list would each have to remember to commit, to +// re-arm the preview and to keep the selection honest, and the second one would +// go stale the first time any of that changed. +//----------------------------------------------------------------------------- + +function GuiEditFramePaletteCtrl::onFrameClicked(%this, %frame) +{ + if(!isObject(%this.pane) || !isObject(%this.pane.stage)) + { + return; + } + + %this.pane.stage.appendFrame(%frame); +} + +//----------------------------------------------------------------------------- +// The drag. The control tells us it has begun and hands over the frame; making +// the payload and deciding where it may be dropped are the editor's business, +// not the grid's. +//----------------------------------------------------------------------------- + +function GuiEditFramePaletteCtrl::onFrameDragBegan(%this, %frame, %x, %y) +{ + if(!isObject(%this.pane) || !isObject(%this.pane.stage)) + { + return; + } + + %payload = %this.makePayload(%frame); + if(!isObject(%payload)) + { + return; + } + + // The drag control lives in the outer frame set, for two reasons. It has to + // be an ancestor of the timeline, because GuiDragAndDropCtrl::findDragTarget + // hit-tests from its own PARENT downwards; and the payload has to be able to + // travel over the whole page, which a control parented into the palette could + // not do. + %host = AssetAdmin.content; + + // Position is relative to that parent, so the drag control's own offset has + // to come out of the cursor position or the payload jumps on the first frame. + %hostAt = %host.getGlobalPosition(); + %xOffset = (getWord(%payload.extent, 0) / 2) + getWord(%hostAt, 0); + %yOffset = (getWord(%payload.extent, 1) / 2) + getWord(%hostAt, 1); + + %dragCtrl = new GuiDragAndDropCtrl() + { + canSaveDynamicFields = "0"; + Profile = "GuiDragAndDropProfile"; + HorizSizing = "anchorLeft"; + VertSizing = "anchorTop"; + Position = (%x - %xOffset) SPC (%y - %yOffset); + Extent = %payload.extent; + MinExtent = "16 16"; + Visible = "1"; + deleteOnMouseUp = true; + }; + + %dragCtrl.add(%payload); + %host.add(%dragCtrl); + + // Grabbed by the middle, which is what lets the drop target work out where + // the cursor is from the payload alone -- the position the drop callback is + // handed is in the drag control's parent's space and cannot be used. + %dragCtrl.startDragging(%xOffset, %yOffset); +} + +function GuiEditFramePaletteCtrl::makePayload(%this, %frame) +{ + %size = $GuiEditFramePaletteCtrl::payloadSize; + + %payload = new GuiSpriteCtrl() + { + canSaveDynamicFields = "0"; + Position = "0 0"; + Extent = %size SPC %size; + imageColor = "255 255 255 255"; + singleFrameBitmap = "0"; + tileImage = "0"; + fullSize = "1"; + constrainProportions = "1"; + + // What the drop reads back. The payload IS the message. + frameIndex = %frame; + }; + ThemeManager.setProfile(%payload, "emptyProfile"); + + %payload.setImage(%this.getImageAsset(), %frame); + + return %payload; +} + +//----------------------------------------------------------------------------- +// Wheel zoom. Remembered, because a person who wants big thumbnails wants them +// every time they open the editor, not once. +//----------------------------------------------------------------------------- + +function GuiEditFramePaletteCtrl::onCellSizeChanged(%this, %cellSize) +{ + EditorPreferences.set("assetAnimationPaletteCellSize", %cellSize); +} diff --git a/editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs b/editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs new file mode 100644 index 000000000..d0467d67e --- /dev/null +++ b/editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs @@ -0,0 +1,58 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The script half of the timeline grid: what a changed list and a picked slot +// mean to the rest of the editor. +// +// As with the palette, there is no class= on the control -- the C++ class owns +// this namespace -- and the owning pane arrives as %this.pane. +// +// Both handlers are deliberately thin. The grid has already done the editing by +// the time it says anything, exactly once per completed gesture; all that is +// left is to tell the asset and the preview. +//----------------------------------------------------------------------------- + +function GuiEditFrameTimelineCtrl::onFramesChanged(%this) +{ + if(!isObject(%this.pane)) + { + return; + } + + %this.pane.commitFrames(); +} + +function GuiEditFrameTimelineCtrl::onSlotSelected(%this, %slot, %frame) +{ + if(!isObject(%this.pane) || !isObject(%this.pane.stage)) + { + return; + } + + %this.pane.stage.onSlotSelected(%slot, %frame); +} + +function GuiEditFrameTimelineCtrl::onCellSizeChanged(%this, %cellSize) +{ + EditorPreferences.set("assetAnimationTimelineCellSize", %cellSize); +} diff --git a/editor/AssetAdmin/Animation/exec.cs b/editor/AssetAdmin/Animation/exec.cs new file mode 100644 index 000000000..316dfc605 --- /dev/null +++ b/editor/AssetAdmin/Animation/exec.cs @@ -0,0 +1,27 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +exec("./AssetAnimationStage.cs"); +exec("./AssetAnimationPalettePane.cs"); +exec("./AssetAnimationTimelinePane.cs"); +exec("./GuiEditFramePaletteCtrl.cs"); +exec("./GuiEditFrameTimelineCtrl.cs"); diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index 6277264b7..e20058944 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -40,6 +40,8 @@ exec("./ParticleEditor/exec.cs"); exec("./ImageEditor/exec.cs"); exec("./Inspector/exec.cs"); + exec("./Animation/exec.cs"); + exec("./AssetPreviewSprite.cs"); %this.guiPage = EditorCore.RegisterEditor("Asset Manager", %this); %this.content = %this.createFrameSet(); @@ -48,6 +50,15 @@ %this.buildInspector(); %this.buildLibrary(); + // The manager that turns the preview into an animation editor and back. It + // owns the two panes it builds and deletes them in its own onRemove, so + // deleting it is the whole of the teardown. + %this.animationStage = new ScriptObject() + { + class = "AssetAnimationStage"; + admin = %this; + }; + EditorCore.FinishRegistration(%this.guiPage); %this.isOpen = false; @@ -286,7 +297,10 @@ class = AssetWindow; function AssetAdmin::destroy(%this) { - + if(isObject(%this.animationStage)) + { + %this.animationStage.delete(); + } } function AssetAdmin::open(%this) @@ -299,6 +313,11 @@ class = AssetWindow; function AssetAdmin::close(%this) { + // The last chance to see where the user left the animation editor's dividers: + // a frame set announces nothing when one is dragged, so the sizes are read at + // the moments the split is about to go away. + %this.animationStage.rememberSizes(); + %this.libWindow.unloadAssets(); %this.assetScene.setScenePause(true); diff --git a/editor/AssetAdmin/AssetDictionaryButton.cs b/editor/AssetAdmin/AssetDictionaryButton.cs index e8a54fbdd..0d258c554 100644 --- a/editor/AssetAdmin/AssetDictionaryButton.cs +++ b/editor/AssetAdmin/AssetDictionaryButton.cs @@ -298,9 +298,18 @@ class = "AssetDictionarySprite"; AssetAdmin.audioPlayButtonContainer.setVisible(false); AssetAdmin.AssetWindow.setVisible(true); + // One line, above the whole chain, and every branch below is untouched. The + // stage keeps the animation split up if this asset is the one it is already + // showing and takes it down otherwise, which is the entire answer for the + // five asset kinds that have never heard of it. + AssetAdmin.animationStage.retainFor(%this.AnimationAssetID); + + // The animation branch has to stay first: an animation tile caches its image + // asset too, so the image branch would swallow it. if(isObject(%this.AnimationAsset) && %this.AnimationAssetID !$= "") { AssetAdmin.AssetWindow.displayAnimationAsset(%this.imageAsset, %this.AnimationAsset, %this.AnimationAssetID); + AssetAdmin.animationStage.select(%this.imageAsset, %this.AnimationAsset, %this.AnimationAssetID); if(%firstLoad) { AssetAdmin.inspector.loadAnimationAsset(%this.AnimationAsset, %this.AnimationAssetID); diff --git a/editor/AssetAdmin/AssetPreviewSprite.cs b/editor/AssetAdmin/AssetPreviewSprite.cs new file mode 100644 index 000000000..2cd5df5d8 --- /dev/null +++ b/editor/AssetAdmin/AssetPreviewSprite.cs @@ -0,0 +1,40 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The sprite showing an animation in the Asset Manager's preview. +// +// It exists for one callback. A non-cycling animation reaching its end is the one +// thing about playback that the engine announces and nothing else would notice -- +// the preview simply stops, and a Play button left showing "stop" would be lying +// about what it does next. +//----------------------------------------------------------------------------- + +function AssetPreviewSprite::onAnimationEnd(%this) +{ + if(!isObject(AssetAdmin.animationStage)) + { + return; + } + + AssetAdmin.animationStage.onPreviewFinished(); +} diff --git a/editor/AssetAdmin/AssetWindow.cs b/editor/AssetAdmin/AssetWindow.cs index 8f0268c4d..b767fa4d7 100644 --- a/editor/AssetAdmin/AssetWindow.cs +++ b/editor/AssetAdmin/AssetWindow.cs @@ -118,8 +118,11 @@ AssetAdmin.AssetScene.clear(true); %size = %this.getWorldSize(%imageAsset.getFrameSize(0)); - new Sprite() + %sprite = new Sprite() { + // It needs a class only so onAnimationEnd has somewhere to land -- that is + // how the transport bar learns a one-shot animation has finished. + class = "AssetPreviewSprite"; Scene = AssetAdmin.AssetScene; Animation = %assetID; size = %size; @@ -128,6 +131,11 @@ Position = "0 0"; BodyType = static; }; + + // This sprite is a different object every time -- the scene is cleared and + // rebuilt above -- so whatever was following the old one has to be told. + AssetAdmin.previewSprite = %sprite; + AssetAdmin.animationStage.onPreviewRebuilt(%sprite); } function AssetWindow::displayParticleAsset(%this, %particleAsset, %assetID) diff --git a/tests/shots/assetAnimation.cs b/tests/shots/assetAnimation.cs new file mode 100644 index 000000000..e05839743 --- /dev/null +++ b/tests/shots/assetAnimation.cs @@ -0,0 +1,132 @@ +// Visual harness for the Asset Manager's animation editor. Four shots: +// +// 0 the stage as it opens -- preview and transport left, the image's frames +// right, the timeline along the bottom of both +// 1 a timeline with a hold in it, to judge how a run of one frame reads +// 2 the insertion caret mid-hover, which is the promise a drop then keeps +// 3 an image asset selected, where the split must collapse back to one preview +// +// What only a picture can settle: whether the three sections balance at the size +// the editor opens at, whether a run of repeated frames reads as one held pose +// rather than as a mistake, and whether the caret is findable against the art. +// tests/smoke/assetAnimationTimeline.cs does the checkable half. +// +// Run: tests/run.ps1 -Shots assetAnimation ; look in shots/. +// +// NOTE: a COPY of toybox/ToyAssets, never the module itself. Editing a timeline +// writes the .animation.taml straight back to its own file. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +// The barbarian death animation: 25 frames drawn from a 10 x 10 sheet, and the +// "death is frames 28 to 32" case that started all this. +$aaAnimId = "ToyAssets:TD_Barbarian_Death"; +$aaImageId = "ToyAssets:TD_Barbarian_CompSprite"; + +testExec("editor/main.cs"); +schedule(2500, 0, "aaOpenProject"); + +function aaOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + createPath(testRoot("shots/")); + ProjectManager.setProjectFolder("assetAnimationShotProject"); + EditorPreferences.path = testRoot("shots/assetAnimationShotPrefs.taml"); + + %copy = testRoot("assetAnimationShotProject/ToyAssets"); + pathCopy(testRoot("toybox/ToyAssets"), %copy, false); + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + + schedule(2500, 0, "aaOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function aaOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "aaSelectAsset"); +} + +function aaSelectAsset() +{ + AssetAdmin.Dictionary["AnimationAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + $aaTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($aaAnimId); + $aaTile.onClick(); + + $aaStage = AssetAdmin.animationStage; + schedule(1200, 0, "aaOpeningShot"); +} + +function aaGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging, so a + // tree that has never run a shot writes nothing and says nothing. + screenShot(testRoot("shots/" @ %name @ ".png"), "PNG"); +} + +function aaOpeningShot() +{ + aaGrab("assetAnimation0"); + schedule(400, 0, "aaHoldShot"); +} + +function aaHoldShot() +{ + $aaStage.timelinePane.setFrames("55 56 56 56 57 58 59"); + schedule(500, 0, "aaGrabHold"); +} + +function aaGrabHold() +{ + aaGrab("assetAnimation1"); + + // A shot only needs the paint, so the caret is asked for directly rather than + // driven by a real drag -- tests/smoke/assetAnimationDrag.cs does that part + // with real posted input. + %slotRect = $aaStage.timelinePane.strip.getSlotRect(2); + %x = getWord(%slotRect, 0) + 4; + %y = getWord(%slotRect, 1) + (getWord(%slotRect, 3) / 2); + $aaStage.timelinePane.strip.showCaretAt(%x SPC %y); + + schedule(400, 0, "aaGrabCaret"); +} + +function aaGrabCaret() +{ + aaGrab("assetAnimation2"); + $aaStage.timelinePane.strip.clearCaret(); + + // Selecting anything else must take the split back down to one preview. + AssetAdmin.Dictionary["ImageAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + %imageTile = AssetAdmin.Dictionary["ImageAsset"].getButton($aaImageId); + if(isObject(%imageTile)) + { + %imageTile.onClick(); + } + + schedule(800, 0, "aaGrabCollapsed"); +} + +function aaGrabCollapsed() +{ + aaGrab("assetAnimation3"); + + echo("SHOTS DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs new file mode 100644 index 000000000..38c8164fe --- /dev/null +++ b/tests/smoke/assetAnimationTimeline.cs @@ -0,0 +1,213 @@ +// The Asset Manager's animation editor: the three-way split, the palette, the +// timeline, and the collapse back to a plain preview. +// Run: tests/run.ps1 assetAnimationTimeline ; grep AANI in tests/logs/. +// +// Driven by calling the panes rather than by posting input. Where a cell lands +// depends on the reflow and on how far the scroller has been dragged, neither of +// which script can read -- so a click at a computed point would be testing the +// arithmetic in this file. tests/smoke/assetAnimationDrag.cs does the one gesture +// that genuinely needs a real pointer. +// +// NOTE: a COPY of toybox/ToyAssets, never the module itself. Every timeline edit +// writes the .animation.taml straight back to its own file. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function aniCheck(%label, %cond) +{ + if(%cond) echo("AANI PASS: " @ %label); + else echo("AANI FAIL: " @ %label); +} + +// The barbarian death animation: 25 frames drawn from a 10 x 10 sheet. +$aniAnimId = "ToyAssets:TD_Barbarian_Death"; +$aniImageId = "ToyAssets:TD_Barbarian_CompSprite"; + +function aniLoadFixtureAssets() +{ + %copy = testRoot("assetAnimationTimelineSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "aniStep1"); + +//----------------------------------------------------------------------------- + +function aniStep1() +{ + ProjectManager.setProjectFolder("assetAnimationTimelineSmokeProject"); + EditorPreferences.path = testRoot("shots/assetAnimationTimelineSmokePrefs.taml"); + createPath(testRoot("shots/")); + + aniCheck("fixture asset module registered", aniLoadFixtureAssets()); + + EditorCore.tabBook.selectPage(2); + + schedule(700, 0, "aniStep2"); +} + +//----------------------------------------------------------------------------- +// The stage stands down until an animation is chosen. +//----------------------------------------------------------------------------- + +function aniStep2() +{ + $aniStage = AssetAdmin.animationStage; + + aniCheck("the animation stage exists", isObject($aniStage)); + aniCheck("it starts collapsed", !$aniStage.built); + + // Eight words per frame, so one frame is a frame set that has never split. + aniCheck("the preview is one frame to begin with", + getWordCount(AssetAdmin.previewFrames.getFrameLayout()) == 8); + + $aniTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($aniAnimId); + aniCheck("the animation tile is in the library", isObject($aniTile)); + + $aniTile.onClick(); + + schedule(600, 0, "aniStep3"); +} + +//----------------------------------------------------------------------------- +// Choosing one splits the preview three ways. +//----------------------------------------------------------------------------- + +function aniStep3() +{ + aniCheck("the stage built", $aniStage.built); + aniCheck("the preview is split", getWordCount(AssetAdmin.previewFrames.getFrameLayout()) > 8); + + aniCheck("the palette pane exists", isObject($aniStage.palettePane)); + aniCheck("the timeline pane exists", isObject($aniStage.timelinePane)); + + if(!isObject($aniStage.palettePane) || !isObject($aniStage.timelinePane)) + { + echo("AANI DONE"); + schedule(200, 0, "quit"); + return; + } + + $aniPalette = $aniStage.palettePane.strip; + $aniTimeline = $aniStage.timelinePane.strip; + + // The panes must be sized by their frames, not left at the extent they were + // built with. setFrameSize is the only thing that lays the tree out, so + // sizing the frames before the panes were added produced exactly this: the + // right frames, and a palette still 100 x 100 and parked behind the preview + // where nothing could be seen of it. + aniCheck("the palette pane was sized by its frame", + $aniStage.palettePane.getExtent() !$= "100 100"); + aniCheck("the palette pane sits to the right of the preview", + getWord($aniStage.palettePane.getGlobalPosition(), 0) > 0); + aniCheck("the timeline pane sits below it", + getWord($aniStage.timelinePane.getGlobalPosition(), 1) > + getWord($aniStage.palettePane.getGlobalPosition(), 1)); + + aniCheck("the palette shows every frame of the image (100)", + $aniPalette.getImageFrameCount() == 100); + // 100 frames at four columns is far taller than the pane, which is what gives + // the scroller something to scroll. + aniCheck("the palette is taller than the room it has", + getWord($aniPalette.getExtent(), 1) > getWord($aniStage.palettePane.getExtent(), 1)); + aniCheck("the palette has a cell per frame", + $aniPalette.getCellCount() == 100); + aniCheck("the timeline holds the animation's 25 frames", + $aniTimeline.getCellCount() == 25); + aniCheck("the timeline matches what the asset says", + $aniTimeline.getFrames() $= trim($aniStage.animationAsset.getAnimationFrames())); + + schedule(300, 0, "aniStep4"); +} + +//----------------------------------------------------------------------------- +// Editing, and the file that gets written. +//----------------------------------------------------------------------------- + +function aniStep4() +{ + %asset = $aniStage.animationAsset; + + // A palette click appends, which is the same path a drop ends in. + $aniStage.palettePane.strip.onFrameClicked(7); + + aniCheck("clicking a palette frame appends it", $aniTimeline.getCellCount() == 26); + aniCheck("it went on the end", $aniTimeline.getFrameAt(25) == 7); + aniCheck("the asset was written", trim(%asset.getAnimationFrames()) $= $aniTimeline.getFrames()); + + // Removing. + $aniTimeline.setSelectedSlot(0); + $aniTimeline.removeSlot(0); + $aniStage.timelinePane.commitFrames(); + + aniCheck("removing a slot shortens the list", $aniTimeline.getCellCount() == 25); + aniCheck("the asset followed", trim(%asset.getAnimationFrames()) $= $aniTimeline.getFrames()); + + // Reordering. + $aniTimeline.setFrames("10 11 12"); + $aniStage.timelinePane.commitFrames(); + $aniTimeline.moveSlot(0, 3); + $aniStage.timelinePane.commitFrames(); + + aniCheck("a slot moved to the end", $aniTimeline.getFrames() $= "11 12 10"); + + schedule(300, 0, "aniStep5"); +} + +//----------------------------------------------------------------------------- +// Selecting anything else takes the split down again. +//----------------------------------------------------------------------------- + +function aniStep5() +{ + %imageTile = AssetAdmin.Dictionary["ImageAsset"].getButton($aniImageId); + aniCheck("the image tile is in the library", isObject(%imageTile)); + + %imageTile.onClick(); + + schedule(500, 0, "aniStep6"); +} + +function aniStep6() +{ + aniCheck("the stage collapsed", !$aniStage.built); + aniCheck("the preview is one frame again", + getWordCount(AssetAdmin.previewFrames.getFrameLayout()) == 8); + + // The guarantee that makes the whole restructure safe: the preview window and + // its scene are the same objects they always were. + aniCheck("the preview window survived", isObject(AssetAdmin.assetWindow)); + aniCheck("it still has its scene", AssetAdmin.assetWindow.getScene() == AssetAdmin.assetScene); + + // And it rebuilds. + $aniTile.onClick(); + + schedule(500, 0, "aniStep7"); +} + +function aniStep7() +{ + aniCheck("choosing the animation again rebuilds the split", $aniStage.built); + aniCheck("the preview is split again", + getWordCount(AssetAdmin.previewFrames.getFrameLayout()) > 8); + + echo("AANI DONE"); + schedule(200, 0, "quit"); +} From 626897ad6f16ac6be8f325c236be46eec9ba5829 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 11:49:06 -0400 Subject: [PATCH 12/26] Editing a timeline no longer restarts the animation it is editing Every asset setter ends in refreshAsset, and the Asset Manager's answer to a refreshed asset has always been to re-click the selected tile -- which clears the preview scene and builds a new sprite. For an image or a font that is exactly right: the picture is a pure function of the asset. For an animation being edited it means dragging one frame restarts playback from the beginning, which makes the timeline useless for the thing it exists to do. So AssetBase::onRefresh now asks AssetAdmin::refreshPreview, which gives the animation stage first refusal. The stage takes the refresh only for the asset it is actually showing, keeps the scene and the sprite it has, and re-reads the values that moved. Everything else falls through to the old path untouched. Two engine behaviours make that harder than it sounds, and both are handled by the same short function. The engine has already restarted playback by the time script hears about it. AssetManager::refreshAsset notifies every AssetPtr pointing at the asset before firing the script onRefresh -- and for a sprite that notification IS playAnimation, from slot zero. So the slot is captured before the write rather than read after it, when the sprite has already forgotten. And playAnimation opens by clearing the pause, so a paused preview comes back playing and has to be paused again. The playhead is restored by SLOT, not by image frame. A slot's meaning shifts when something is inserted before it, so the preview can appear to skip a frame -- but tracking the image frame instead breaks the moment a frame appears twice, which is exactly what a hold is. A resize gets the same treatment: a divider moving used to rebuild the whole preview through onExtentChange, so the animation restarted every time the palette was widened. The sprite is already there and only its size is wrong. Also fixes the stage never learning about its sprite on a first selection: the tile displays before it selects, so displayAnimationAsset announced the new sprite while there was still no stage built to hear it. select() now asks for it rather than waiting to be told, which covers both orders. 38 checks, including that the sprite is the same object across an edit, that the playhead stays where it was, and that a change raised from outside the editor still reloads the strip. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationStage.cs | 115 ++++++++++++++++++ editor/AssetAdmin/AssetAdmin.cs | 23 ++++ editor/AssetAdmin/AssetBase.cs | 5 +- editor/AssetAdmin/AssetWindow.cs | 8 ++ tests/smoke/assetAnimationTimeline.cs | 43 +++++++ 5 files changed, 190 insertions(+), 4 deletions(-) diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index d100fe445..cab9ce09c 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -45,6 +45,10 @@ %this.built = false; %this.assetId = ""; %this.playing = false; + + // -1 rather than unset: an unset field reads as an empty string, which is not + // less than zero, so every "have I got one?" test below would pass with it. + %this.resumeSlot = -1; } function AssetAnimationStage::onRemove(%this) @@ -121,6 +125,12 @@ %this.palettePane.load(%this.imageAssetId); %this.timelinePane.load(%this.imageAssetId, trim(%animationAsset.getAnimationFrames())); + + // Adopt the sprite the preview has already made. displayAnimationAsset builds + // it and announces it BEFORE this runs -- the tile displays first and selects + // second -- so on a first selection that announcement arrives while there is + // no stage to hear it. Asking here covers both orders. + %this.onPreviewRebuilt(%this.admin.previewSprite); } //----------------------------------------------------------------------------- @@ -381,6 +391,101 @@ class = "AssetAnimationPalettePane"; %this.playing = false; } +//----------------------------------------------------------------------------- +// Absorbing a refresh instead of being rebuilt by one. +// +// Every asset setter ends in refreshAsset, which rewrites the file and fires +// onRefresh -- and the editor's answer to onRefresh has always been to re-click +// the selected tile, which clears the preview scene and builds a new sprite. For +// an image that is exactly right. For an animation being edited it means the +// preview restarts from frame one every time a frame is dragged. +// +// So the stage says "I've got this" for the asset it is showing, and the old +// path is untouched for everything else. +//----------------------------------------------------------------------------- + +function AssetAnimationStage::absorbRefresh(%this, %asset) +{ + if(!%this.built || %this.busy || !isObject(%asset)) + { + return false; + } + + %isAnimation = (%asset == %this.animationAsset); + %isImage = (%asset == %this.imageAsset); + + if(!%isAnimation && !%isImage) + { + return false; + } + + // Unless the strip is what produced this value in the first place. Reloading + // it from the asset mid-commit would throw away the selection and the caret + // for a list it already holds. The same guard shape AssetInspectorPane uses. + if(%isAnimation && !%this.committing) + { + %this.timelinePane.load(%this.imageAssetId, trim(%this.animationAsset.getAnimationFrames())); + } + + // The image may have been re-cut, so the palette's frame count has moved -- + // and so has what the animation's frames mean. + if(%isImage) + { + %this.palettePane.reload(); + } + + %this.resyncPreview(); + return true; +} + +// Put the preview back the way it was before the write disturbed it. +// +// Three cases, because ImageFrameProviderCore::onAssetRefreshed has already had +// its say by the time this runs: it calls playAnimation on a RUNNING preview, +// restarting it from slot zero, and does nothing at all to a finished one. And +// playAnimation opens by clearing the pause, so a paused preview comes back +// playing. +function AssetAnimationStage::resyncPreview(%this) +{ + if(!isObject(%this.previewSprite)) + { + return; + } + + // The slot the commit put aside, or -- for a change raised from somewhere + // other than this editor -- the last one the marker was drawn on. + %slot = %this.resumeSlot; + if(%slot < 0) + { + %slot = %this.timelinePane.strip.getPlayheadSlot(); + } + + %this.armPreview(); + %this.previewSprite.pauseAnimation(!%this.playing); + + // Restored by SLOT, not by image frame. A slot's meaning shifts when + // something is inserted before it, so the preview can appear to skip -- but + // tracking the image frame instead breaks the moment a frame appears twice, + // which is exactly what a hold is. + if(%slot >= 0) + { + %this.scrubTo(%slot); + } +} + +// A divider moved, or the editor was resized. The sprite is already there and +// only its size is wrong, so there is nothing to rebuild. +function AssetAnimationStage::resizePreview(%this) +{ + if(!%this.built || %this.busy || !isObject(%this.previewSprite) || !isObject(%this.imageAsset)) + { + return false; + } + + %this.previewSprite.setSize(%this.admin.assetWindow.getWorldSize(%this.imageAsset.getFrameSize(0))); + return true; +} + //----------------------------------------------------------------------------- // Editing. Every path that changes the list ends here, and this is the only // place in the editor that writes the animation's frames. @@ -393,12 +498,22 @@ class = "AssetAnimationPalettePane"; return; } + // Where the preview is, captured BEFORE the write, because the engine + // restarts playback in the middle of it: AssetManager::refreshAsset notifies + // every AssetPtr pointing at the asset -- which for a sprite means + // playAnimation, from slot zero -- and only then fires the script onRefresh + // this editor listens on. By the time we are asked to put things back, the + // sprite has already forgotten where it was. + %this.resumeSlot = isObject(%this.previewSprite) ? %this.previewSprite.getAnimationFrame() : -1; + // Guarded because the write comes straight back: every asset setter ends in // refreshAsset, which rewrites the .animation.taml and fires onRefresh // synchronously, inside this call. %this.committing = true; %this.animationAsset.setAnimationFrames(%frames); %this.committing = false; + + %this.resumeSlot = -1; } function AssetAnimationStage::appendFrame(%this, %frame) diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index e20058944..07281aa09 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -295,6 +295,29 @@ class = AssetWindow; %this.background.add(%this.audioPlayButtonContainer); } +// Something about the selected asset changed and the preview has to catch up. +// +// The old answer was to re-click the tile, which rebuilds the preview scene from +// nothing. That is right for an image or a font, where the picture is a pure +// function of the asset -- and quite wrong for an animation being edited, where +// it would clear the running sprite and start again from frame one on every +// single change. Dragging one frame in the timeline would restart the playback. +function AssetAdmin::refreshPreview(%this, %asset) +{ + // A live edit to what is already on show: the stage keeps the scene it has + // and re-reads only what moved, so the preview does not blink and the + // playhead does not jump back to the start. + if(%this.animationStage.absorbRefresh(%asset)) + { + return; + } + + if(isObject(%this.chosenButton)) + { + %this.chosenButton.onClick(); + } +} + function AssetAdmin::destroy(%this) { if(isObject(%this.animationStage)) diff --git a/editor/AssetAdmin/AssetBase.cs b/editor/AssetAdmin/AssetBase.cs index d2c008cad..666de580a 100644 --- a/editor/AssetAdmin/AssetBase.cs +++ b/editor/AssetAdmin/AssetBase.cs @@ -30,8 +30,5 @@ // Redraws the preview. It does not re-enter the inspector: onClick only loads // an asset into it when the selection actually moved. - if(isObject(AssetAdmin.chosenButton)) - { - AssetAdmin.chosenButton.onClick(); - } + AssetAdmin.refreshPreview(%this); } diff --git a/editor/AssetAdmin/AssetWindow.cs b/editor/AssetAdmin/AssetWindow.cs index b767fa4d7..86c357105 100644 --- a/editor/AssetAdmin/AssetWindow.cs +++ b/editor/AssetAdmin/AssetWindow.cs @@ -249,6 +249,14 @@ class = "AssetPreviewSprite"; %this.setCameraArea(%area); %this.setViewLimitOn(%area); + // The animation stage resizes the sprite it already has rather than letting + // the whole preview be rebuilt, which would restart the animation every time + // a divider moved. + if(AssetAdmin.animationStage.resizePreview()) + { + return; + } + if(isObject(AssetAdmin.chosenButton)) { AssetAdmin.chosenButton.onClick(); diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs index 38c8164fe..ffd8f4dd0 100644 --- a/tests/smoke/assetAnimationTimeline.cs +++ b/tests/smoke/assetAnimationTimeline.cs @@ -168,6 +168,49 @@ function aniStep4() aniCheck("a slot moved to the end", $aniTimeline.getFrames() $= "11 12 10"); + schedule(300, 0, "aniStepPreview"); +} + +//----------------------------------------------------------------------------- +// The refresh storm. Every timeline edit writes the asset, and the editor's +// answer to a written asset used to be "rebuild the preview from nothing" -- +// which would restart the animation on every dragged frame. +//----------------------------------------------------------------------------- + +function aniStepPreview() +{ + $aniTimeline.setFrames("20 21 22 23 24 25 26 27"); + $aniStage.timelinePane.commitFrames(); + + $aniSprite = AssetAdmin.previewSprite; + aniCheck("there is a preview sprite", isObject($aniSprite)); + + $aniStage.play(); + schedule(400, 0, "aniStepPreview2"); +} + +function aniStepPreview2() +{ + // Park it somewhere that is not the start, so a restart would be obvious. + $aniStage.stop(); + $aniStage.scrubTo(5); + + aniCheck("the preview scrubbed to slot 5", $aniSprite.getAnimationFrame() == 5); + + // An edit, which writes the file and comes straight back through onRefresh. + $aniStage.timelinePane.appendFrame(28); + + aniCheck("the edit landed", $aniTimeline.getCellCount() == 9); + aniCheck("the preview sprite was NOT rebuilt", AssetAdmin.previewSprite == $aniSprite); + aniCheck("the playhead did not jump back to the start", $aniSprite.getAnimationFrame() == 5); + aniCheck("the preview is still paused", !$aniStage.playing); + + // And a refresh raised from somewhere else entirely still reaches the strip. + $aniStage.animationAsset.setAnimationFrames("30 31 32"); + + aniCheck("an outside change reloads the timeline", $aniTimeline.getFrames() $= "30 31 32"); + aniCheck("and still did not rebuild the preview", AssetAdmin.previewSprite == $aniSprite); + schedule(300, 0, "aniStep5"); } From 30445a24f1dc1fcf5275d1791dfc154636476c04 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 12:00:25 -0400 Subject: [PATCH 13/26] Play it, loop it, and fill it from "frames 28 to 32" The transport sits over the preview as an overlay, where the audio play button already sits and proof that one there receives clicks over the SceneWindow. It costs no layout and takes no room from the art. Five buttons, and three of them have to show a state, which is why the row is assembled from EditorToggleIcons by hand rather than from an EditorButtonBar of momentary buttons. Stop pauses rather than stopping, and that is not a shortcut. SpriteBase's stopAnimation sets the finished flag, updateAnimation returns immediately on it, and setAnimationFrame goes through updateAnimation -- so a preview stopped that way can never be scrubbed again. Pausing halts it just as visibly, leaves the playhead where you stopped to look at it, and keeps every other gesture alive. armPreview is the way back for a preview that finished on its own, and every path that moves the playhead goes through it. The suite asserts that directly: stop the animation the engine's way, then scrub, and the scrub still works. Loop is the asset's AnimationCycle, so it writes the file like any other edit. Keep-frame-rate is not: it decides what the EDITOR does on the user's behalf, so it is a preference and it is remembered. Uniform timing means AnimationTime is shared out over however many frames there are, so adding one makes every frame play faster and the animation stops lasting as long. Which of those a person wants depends on whether they are lengthening a walk cycle or dropping in a hold, so it is a switch, off by default, and both numbers are always on show. The range builder is a plain object with no dialog attached, because it is the part worth checking and the dialog's feedback line is its own answer read back rather than a second description that could drift. Three stages, and the order is the design: the stepped run, then the ping-pong reverse MINUS both shared end frames, and only then the hold. Hold last is what makes "shared" mean one frame rather than N slots -- keep the ends and the turn at each end lasts twice as long as everything else, which reads as a stutter. Eight rows of table assert it. Two script-only stumbles worth recording: getMax and mClampF do not exist in TorqueScript. The first is a link error you see; the second silently returns nothing, which setAnimationTime then wrote as zero -- and a zero animation time divides by zero in the playback integrator. It is spelled out by hand now, with a note that mClamp would round a sub-second animation down to nothing. 61 checks, and six screenshots including the dialog with a ping-pong read back before it is applied. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationFrameRange.cs | 164 +++++++++++++++ .../Animation/AssetAnimationRangeDialog.cs | 196 ++++++++++++++++++ .../Animation/AssetAnimationStage.cs | 103 +++++++++ .../Animation/AssetAnimationTransportBar.cs | 152 ++++++++++++++ editor/AssetAdmin/Animation/exec.cs | 3 + editor/AssetAdmin/AssetAdmin.cs | 50 +++++ tests/shots/assetAnimation.cs | 40 +++- tests/smoke/assetAnimationTimeline.cs | 122 +++++++++++ 8 files changed, 828 insertions(+), 2 deletions(-) create mode 100644 editor/AssetAdmin/Animation/AssetAnimationFrameRange.cs create mode 100644 editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs create mode 100644 editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs diff --git a/editor/AssetAdmin/Animation/AssetAnimationFrameRange.cs b/editor/AssetAdmin/Animation/AssetAnimationFrameRange.cs new file mode 100644 index 000000000..1e2101578 --- /dev/null +++ b/editor/AssetAdmin/Animation/AssetAnimationFrameRange.cs @@ -0,0 +1,164 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// Turning "the death animation is frames 28 to 32" into a list of frames. +// +// Kept apart from the dialog that asks for the numbers, because it is the part +// worth checking: the dialog shows the answer back to the user before they +// commit to it, and the test asks for the same answer without opening anything. +// +// Nothing here touches an asset or a control. +//----------------------------------------------------------------------------- + +// A guard against a fat-fingered hold. AnimationAsset::getAnimationFrames formats +// into a fixed 4096-byte buffer, so the ENGINE quietly truncates somewhere near a +// thousand frames; this stops well short of that. +$AssetAnimationFrameRange::maxFrames = 512; + +//----------------------------------------------------------------------------- +// Three stages, and the order of them is the whole design. +// +// 1. the stepped run from start to end, counting down when end is the smaller +// 2. if ping-pong, the reverse of that MINUS both shared end frames +// 3. then every element repeated %hold times +// +// Hold goes last so that "shared end frames" means one frame rather than N slots. +// Dropping those two is what stops the turn at each end lasting twice as long as +// every other frame, which reads as a stutter. +//----------------------------------------------------------------------------- + +function AssetAnimationFrameRange::build(%this, %start, %end, %step, %hold, %pingPong) +{ + %start = mFloor(%start); + %end = mFloor(%end); + %step = mGetMax(1, mFloor(%step)); + %hold = mGetMax(1, mFloor(%hold)); + + %forward = ""; + %direction = (%end >= %start) ? %step : -%step; + + for(%frame = %start; (%direction > 0) ? (%frame <= %end) : (%frame >= %end); %frame += %direction) + { + %forward = (%forward $= "") ? %frame : (%forward SPC %frame); + } + + %list = %forward; + + if(%pingPong) + { + // From the second-to-last back to the second: both ends are already in the + // run and playing them twice is what makes a ping-pong stutter. + %count = getWordCount(%forward); + for(%i = %count - 2; %i >= 1; %i--) + { + %list = %list SPC getWord(%forward, %i); + } + } + + if(%hold > 1) + { + %held = ""; + %count = getWordCount(%list); + for(%i = 0; %i < %count; %i++) + { + %frame = getWord(%list, %i); + for(%r = 0; %r < %hold; %r++) + { + %held = (%held $= "") ? %frame : (%held SPC %frame); + } + } + %list = %held; + } + + return %list; +} + +// Why a given set of numbers cannot be used, or "" when it can. +// +// Takes the image's frame count so the message can name it: counting from one is +// the mistake people actually make, and "0 to 99" says more than "out of range". +function AssetAnimationFrameRange::problemWith(%this, %start, %end, %step, %hold, %pingPong, %imageFrameCount) +{ + if(%start $= "" || %end $= "") + { + return "Give a first and last frame."; + } + + if(%start < 0 || %end < 0) + { + return "Frames start at 0."; + } + + if(%imageFrameCount > 0 && (%start >= %imageFrameCount || %end >= %imageFrameCount)) + { + return "This image has" SPC %imageFrameCount SPC "frames, numbered 0 to" SPC (%imageFrameCount - 1) @ "."; + } + + if(%step < 1) + { + return "A step of less than 1 would never get there."; + } + + if(%hold < 1) + { + return "Every frame has to be held at least once."; + } + + %count = getWordCount(%this.build(%start, %end, %step, %hold, %pingPong)); + if(%count > $AssetAnimationFrameRange::maxFrames) + { + return "That would make" SPC %count SPC "frames, and" SPC + $AssetAnimationFrameRange::maxFrames SPC "is the most an animation can hold here."; + } + + return ""; +} + +// What the user is about to get, in words. The strongest argument for keeping the +// builder callable without a dialog: this line is the builder's own answer read +// back, not a second description of it that could drift. +function AssetAnimationFrameRange::describe(%this, %frames, %mode, %existingCount) +{ + %count = getWordCount(%frames); + if(%count == 0) + { + return ""; + } + + %shown = %frames; + if(%count > 12) + { + %shown = ""; + for(%i = 0; %i < 10; %i++) + { + %shown = (%shown $= "") ? getWord(%frames, %i) : (%shown SPC getWord(%frames, %i)); + } + %shown = %shown SPC "..." SPC getWord(%frames, %count - 1); + } + + %tail = (%mode $= "append") + ? ("appended to the" SPC %existingCount SPC ((%existingCount == 1) ? "already there" : "already there")) + : "replacing what is there"; + + return %shown SPC "-" SPC %count SPC ((%count == 1) ? "frame," : "frames,") SPC %tail @ "."; +} diff --git a/editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs b/editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs new file mode 100644 index 000000000..40857f2e6 --- /dev/null +++ b/editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs @@ -0,0 +1,196 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// "The death animation is frames 28 to 32", asked for in a dialog. +// +// Dragging twenty-five frames one at a time is the thing this exists to spare +// people. The arithmetic lives in AssetAnimationFrameRange, which knows nothing +// about controls; this asks for the numbers and shows the answer back before +// anything is written. +// +// That feedback line is the whole point of the dialog rather than a nicety -- +// step, hold and ping-pong interact in ways nobody should have to predict, and +// reading the actual frames out is quicker than explaining the rules. +//----------------------------------------------------------------------------- + +function AssetAnimationRangeDialog::init(%this, %width, %height) +{ + %window = %this.getObject(0); + %content = %window.getObject(0); + + %form = new GuiGridCtrl() + { + class = "EditorForm"; + extent = %width SPC %height; + cellSizeX = %width / 2; + cellSizeY = 50; + cellModeX = "fixed"; + cellModeY = "fixed"; + maxColCount = 2; + }; + %form.addListener(%this); + + %half = %width / 2; + + %item = %form.addFormItem("First Frame", %half SPC 30); + %this.startBox = %form.createTextEditItem(%item); + + %item = %form.addFormItem("Last Frame", %half SPC 30); + %this.endBox = %form.createTextEditItem(%item); + + %item = %form.addFormItem("Step", %half SPC 30); + %this.stepBox = %form.createTextEditItem(%item); + + %item = %form.addFormItem("Hold Each Frame", %half SPC 30); + %this.holdBox = %form.createTextEditItem(%item); + + %item = %form.addFormItem("Ping-pong", %half SPC 30); + %this.pingPongBox = %form.createCheckboxItem(%item); + + %item = %form.addFormItem("Mode", %half SPC 30); + %this.modeDropDown = %form.createDropDownItem(%item); + %this.modeDropDown.add("Append to the timeline", 0); + %this.modeDropDown.add("Replace the timeline", 1); + %this.modeDropDown.setSelected(0); + + %content.add(%form); + + // Every box re-asks the same question on every keystroke, so Apply is only + // ever live when the numbers make sense and the line below always describes + // what is about to happen. + %command = %this.getId() @ ".validate();"; + %this.startBox.Command = %command; + %this.endBox.Command = %command; + %this.stepBox.Command = %command; + %this.holdBox.Command = %command; + %this.pingPongBox.Command = %command; + %this.modeDropDown.Command = %command; + + %this.feedback = new GuiControl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = "12 168"; + Extent = (%width - 24) SPC 70; + text = ""; + textWrap = true; + textExtend = true; + }; + ThemeManager.setProfile(%this.feedback, "infoProfile"); + %content.add(%this.feedback); + + %this.cancelButton = new GuiButtonCtrl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = (%width - 222) SPC (%height - 42); + Extent = "100 30"; + Text = "Cancel"; + Command = %this.getID() @ ".onClose();"; + }; + ThemeManager.setProfile(%this.cancelButton, "buttonProfile"); + %content.add(%this.cancelButton); + + %this.applyButton = new GuiButtonCtrl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = (%width - 112) SPC (%height - 44); + Extent = "100 34"; + Text = "Apply"; + Command = %this.getID() @ ".onApply();"; + }; + ThemeManager.setProfile(%this.applyButton, "primaryButtonProfile"); + %content.add(%this.applyButton); + + %this.startBox.setText(0); + %this.endBox.setText(mGetMax(0, %this.imageFrameCount() - 1)); + %this.stepBox.setText(1); + %this.holdBox.setText(1); + + %this.validate(); +} + +function AssetAnimationRangeDialog::imageFrameCount(%this) +{ + if(!isObject(%this.stage) || !isObject(%this.stage.imageAsset)) + { + return 0; + } + + return %this.stage.imageAsset.getFrameCount(); +} + +function AssetAnimationRangeDialog::mode(%this) +{ + return (%this.modeDropDown.getSelected() == 1) ? "replace" : "append"; +} + +function AssetAnimationRangeDialog::validate(%this) +{ + %range = AssetAdmin.frameRange; + + %start = %this.startBox.getText(); + %end = %this.endBox.getText(); + %step = %this.stepBox.getText(); + %hold = %this.holdBox.getText(); + %pingPong = %this.pingPongBox.getValue(); + + %problem = %range.problemWith(%start, %end, %step, %hold, %pingPong, %this.imageFrameCount()); + if(%problem !$= "") + { + %this.applyButton.setActive(false); + %this.feedback.setText(%problem); + return false; + } + + %frames = %range.build(%start, %end, %step, %hold, %pingPong); + + %this.applyButton.setActive(true); + %this.feedback.setText(%range.describe(%frames, %this.mode(), + %this.stage.timelinePane.strip.getCellCount())); + + return true; +} + +function AssetAnimationRangeDialog::onApply(%this) +{ + if(!%this.validate()) + { + return; + } + + %frames = AssetAdmin.frameRange.build(%this.startBox.getText(), %this.endBox.getText(), + %this.stepBox.getText(), %this.holdBox.getText(), %this.pingPongBox.getValue()); + + if(%this.mode() $= "replace") + { + %this.stage.setFrames(%frames); + } + else + { + %this.stage.appendFrames(%frames); + } + + %this.onClose(); +} diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index cab9ce09c..c2c272f75 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -126,6 +126,9 @@ %this.palettePane.load(%this.imageAssetId); %this.timelinePane.load(%this.imageAssetId, trim(%animationAsset.getAnimationFrames())); + %this.admin.transportBarContainer.setVisible(true); + %this.admin.transportBar.refresh(); + // Adopt the sprite the preview has already made. displayAnimationAsset builds // it and announces it BEFORE this runs -- the tile displays first and selects // second -- so on a first selection that announcement arrives while there is @@ -217,6 +220,7 @@ class = "AssetAnimationPalettePane"; %this.busy = true; %this.rememberSizes(); + %this.admin.transportBarContainer.setVisible(false); // Deleting each pane collapses the frame that held it and hoists its twin's // subtree, so two deletes take the tree back to one frame holding the preview @@ -498,6 +502,11 @@ class = "AssetAnimationPalettePane"; return; } + // How many frames there were, asked of the ASSET rather than of the strip: + // the strip already holds the edited list by the time it reports, so it can + // no longer say what the animation used to be. + %before = %this.animationAsset.getAnimationFrameCount(); + // Where the preview is, captured BEFORE the write, because the engine // restarts playback in the middle of it: AssetManager::refreshAsset notifies // every AssetPtr pointing at the asset -- which for a sprite means @@ -514,6 +523,56 @@ class = "AssetAnimationPalettePane"; %this.committing = false; %this.resumeSlot = -1; + + %this.keepFrameRate(%before); +} + +// Hold the per-frame rate steady across an edit, when the user has asked for it. +// +// Uniform timing means AnimationTime is shared out over however many frames there +// are, so adding one makes every frame play faster and the animation no longer +// lasts as long. Which of those two a person wants is genuinely a matter of what +// they are doing -- lengthening a walk cycle, or dropping in a hold without +// speeding everything up -- so it is a switch, off by default, and the info line +// in the inspector always shows both numbers either way. +function AssetAnimationStage::keepFrameRate(%this, %beforeCount) +{ + %afterCount = %this.timelinePane.strip.getCellCount(); + + if(%beforeCount < 1 || %afterCount == %beforeCount) + { + return; + } + + if(!EditorPreferences.get("assetAnimationKeepFrameRate", false)) + { + return; + } + + %time = %this.animationAsset.getAnimationTime() * (%afterCount / %beforeCount); + + // Clamped by hand rather than with mClamp, which rounds to a whole number and + // would turn every animation shorter than a second into no time at all. The + // floor matters: a time of zero divides by zero in ImageFrameProviderCore, so + // nothing may ever write one. + if(%time < 0.01) { %time = 0.01; } + if(%time > 3600) { %time = 3600; } + + %this.committing = true; + %this.animationAsset.setAnimationTime(%time); + %this.committing = false; +} + +function AssetAnimationStage::setCycle(%this, %on) +{ + if(!isObject(%this.animationAsset)) + { + return; + } + + // Writes the file, like every other asset edit. The write comes back through + // absorbRefresh, which re-arms the preview; there is nothing else to do. + %this.animationAsset.setAnimationCycle(%on); } function AssetAnimationStage::appendFrame(%this, %frame) @@ -525,3 +584,47 @@ class = "AssetAnimationPalettePane"; %this.timelinePane.appendFrame(%frame); } + +function AssetAnimationStage::setFrames(%this, %frames) +{ + if(!%this.built) + { + return; + } + + %this.timelinePane.setFrames(%frames); +} + +function AssetAnimationStage::appendFrames(%this, %frames) +{ + if(!%this.built) + { + return; + } + + %this.timelinePane.appendFrames(%frames); +} + +function AssetAnimationStage::openRangeDialog(%this) +{ + if(!%this.built) + { + return; + } + + %width = 460; + %height = 260; + + %dialog = new GuiControl() + { + class = "AssetAnimationRangeDialog"; + superclass = "EditorDialog"; + dialogSize = (%width + 8) SPC (%height + 8); + dialogCanClose = true; + dialogText = "Frame Range"; + stage = %this; + }; + %dialog.init(%width, %height); + + Canvas.pushDialog(%dialog); +} diff --git a/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs new file mode 100644 index 000000000..e9313b689 --- /dev/null +++ b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs @@ -0,0 +1,152 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// Play, rewind, loop, and the two switches that decide what an edit does: the +// bar over the animation preview. +// +// It sits as an overlay on the preview background rather than in a strip of its +// own, which is where the audio play button already sits and proof that an +// overlay there receives clicks over the SceneWindow. It costs no layout and +// takes no room from the art. +// +// Not built from EditorButtonBar: that makes EditorIconButtons, which are +// momentary. Three of these five have to SHOW a state, which is what +// EditorToggleIcon exists for, so the row is assembled by hand. +//----------------------------------------------------------------------------- + +$AssetAnimationTransportBar::buttonSize = 24; +$AssetAnimationTransportBar::spacing = 4; + +function AssetAnimationTransportBar::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); + + %this.playButton = %this.addToggle("Play", $EditorIcon::playback_stop, $EditorIcon::playback_play, + "Stop the preview", "Play the preview"); + + %this.addButton("rewind", $EditorIcon::playback_rew, "Back to the first frame"); + + %this.loopButton = %this.addToggle("Loop", $EditorIcon::playback_reload, $EditorIcon::playback_reload, + "Looping. Click to play once and stop on the last frame.", + "Playing once. Click to loop."); + + %this.rateButton = %this.addToggle("KeepRate", $EditorIcon::stop_watch, $EditorIcon::stop_watch, + "Keeping the frame rate: adding or removing frames rewrites the animation's time to match.", + "Keeping the animation's time: adding a frame makes every frame play faster."); + + %this.addButton("openRangeDialog", $EditorIcon::list_num, "Fill the timeline from a range of frames"); +} + +function AssetAnimationTransportBar::addToggle(%this, %name, %frameOn, %frameOff, %tipOn, %tipOff) +{ + %size = $AssetAnimationTransportBar::buttonSize; + + %button = new GuiCheckBoxCtrl() + { + class = "EditorToggleIcon"; + Position = "0 0"; + Extent = %size SPC %size; + frameOn = %frameOn; + frameOff = %frameOff; + tipOn = %tipOn; + tipOff = %tipOff; + toggleName = %name; + owner = %this; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %this.add(%button); + + return %button; +} + +function AssetAnimationTransportBar::addButton(%this, %method, %frame, %tooltip) +{ + %size = $AssetAnimationTransportBar::buttonSize; + + %button = new GuiButtonCtrl() + { + class = "EditorIconButton"; + Position = "0 0"; + Extent = %size SPC %size; + Frame = %frame; + Command = %this.getId() @ "." @ %method @ "();"; + Tooltip = %tooltip; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %this.add(%button); + + return %button; +} + +//----------------------------------------------------------------------------- +// One handler, switching on which toggle spoke. +//----------------------------------------------------------------------------- + +function AssetAnimationTransportBar::onToggleIconChanged(%this, %button) +{ + switch$(%button.toggleName) + { + case "Play": + if(%button.getValue()) { %this.stage.play(); } + else { %this.stage.stop(); } + + case "Loop": + %this.stage.setCycle(%button.getValue()); + + case "KeepRate": + // An editor preference, not a property of the asset: it decides what + // the editor does on the user's behalf, and the user who wants it + // wants it next time too. + EditorPreferences.set("assetAnimationKeepFrameRate", %button.getValue()); + } +} + +function AssetAnimationTransportBar::rewind(%this) +{ + // The playing state is left alone on purpose. Rewinding while it plays + // restarts the run, which is what a rewind is. + %this.stage.scrubTo(0); +} + +function AssetAnimationTransportBar::openRangeDialog(%this) +{ + %this.stage.openRangeDialog(); +} + +//----------------------------------------------------------------------------- +// Reading the state back out. Called whenever something else may have moved it. +//----------------------------------------------------------------------------- + +function AssetAnimationTransportBar::refresh(%this) +{ + if(!isObject(%this.stage.animationAsset)) + { + return; + } + + %this.playButton.setValue(%this.stage.playing); + %this.loopButton.setValue(%this.stage.animationAsset.getAnimationCycle()); + %this.rateButton.setValue(EditorPreferences.get("assetAnimationKeepFrameRate", false)); +} diff --git a/editor/AssetAdmin/Animation/exec.cs b/editor/AssetAdmin/Animation/exec.cs index 316dfc605..9f3fcb14d 100644 --- a/editor/AssetAdmin/Animation/exec.cs +++ b/editor/AssetAdmin/Animation/exec.cs @@ -23,5 +23,8 @@ exec("./AssetAnimationStage.cs"); exec("./AssetAnimationPalettePane.cs"); exec("./AssetAnimationTimelinePane.cs"); +exec("./AssetAnimationTransportBar.cs"); +exec("./AssetAnimationFrameRange.cs"); +exec("./AssetAnimationRangeDialog.cs"); exec("./GuiEditFramePaletteCtrl.cs"); exec("./GuiEditFrameTimelineCtrl.cs"); diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index 07281aa09..c32bd872e 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -58,6 +58,11 @@ class = "AssetAnimationStage"; admin = %this; }; + // The range arithmetic, kept as an object so the dialog and the tests share + // one handle and no new global function appears. + %this.frameRange = new ScriptObject() { class = "AssetAnimationFrameRange"; }; + + %this.buildTransportBar(); EditorCore.FinishRegistration(%this.guiPage); @@ -295,6 +300,47 @@ class = AssetWindow; %this.background.add(%this.audioPlayButtonContainer); } +// The animation transport, overlaid on the preview exactly as the audio play +// button above is -- which is what proves an overlay here receives clicks over +// the SceneWindow. Built once and only shown or hidden, because the stage comes +// and goes many times in a session and this does not have to. +function AssetAdmin::buildTransportBar(%this) +{ + %this.transportBarContainer = new GuiControl() + { + position = "0 0"; + extent = %this.background.extent; + HorizSizing = "width"; + VertSizing = "height"; + Visible = "0"; + }; + ThemeManager.setProfile(%this.transportBarContainer, "emptyProfile"); + + // "top" anchors the BOTTOM edge -- the sizing names read the opposite way + // round to how they sound -- so the bar keeps the gap it is given below it + // and rides the bottom of the preview as the frame grows. The position is set + // against the extent the background has now, which is why it is a gap rather + // than a coordinate. + %barGap = 8; + %barTop = getWord(%this.background.extent, 1) - $AssetAnimationTransportBar::buttonSize - %barGap; + + %this.transportBar = new GuiChainCtrl() + { + class = "AssetAnimationTransportBar"; + stage = %this.animationStage; + HorizSizing = "center"; + VertSizing = "top"; + Position = "0" SPC %barTop; + Extent = "160" SPC $AssetAnimationTransportBar::buttonSize; + IsVertical = false; + ChildSpacing = $AssetAnimationTransportBar::spacing; + IsExtentDynamic = true; + }; + %this.transportBarContainer.add(%this.transportBar); + + %this.background.add(%this.transportBarContainer); +} + // Something about the selected asset changed and the preview has to catch up. // // The old answer was to re-click the tile, which rebuilds the preview scene from @@ -324,6 +370,10 @@ class = AssetWindow; { %this.animationStage.delete(); } + if(isObject(%this.frameRange)) + { + %this.frameRange.delete(); + } } function AssetAdmin::open(%this) diff --git a/tests/shots/assetAnimation.cs b/tests/shots/assetAnimation.cs index e05839743..27b14064c 100644 --- a/tests/shots/assetAnimation.cs +++ b/tests/shots/assetAnimation.cs @@ -4,7 +4,8 @@ // right, the timeline along the bottom of both // 1 a timeline with a hold in it, to judge how a run of one frame reads // 2 the insertion caret mid-hover, which is the promise a drop then keeps -// 3 an image asset selected, where the split must collapse back to one preview +// 3 the frame range dialog, showing a ping-pong read back before it is applied +// 4 an image asset selected, where the split must collapse back to one preview // // What only a picture can settle: whether the three sections balance at the size // the editor opens at, whether a run of repeated frames reads as one held pose @@ -110,6 +111,41 @@ function aaGrabCaret() aaGrab("assetAnimation2"); $aaStage.timelinePane.strip.clearCaret(); + // The range dialog, with a ping-pong in the feedback line -- the thing that + // makes step, hold and ping-pong comprehensible without explaining the rules. + $aaStage.openRangeDialog(); + schedule(500, 0, "aaFillRangeDialog"); +} + +function aaFillRangeDialog() +{ + // A pushed dialog is the last thing on the canvas. + $aaDialog = Canvas.getObject(Canvas.getCount() - 1); + if(isObject($aaDialog)) + { + $aaDialog.startBox.setText(28); + $aaDialog.endBox.setText(32); + $aaDialog.pingPongBox.setStateOn(true); + $aaDialog.validate(); + } + + schedule(400, 0, "aaGrabRange"); +} + +function aaGrabRange() +{ + aaGrab("assetAnimation3"); + + if(isObject($aaDialog)) + { + $aaDialog.onClose(); + } + + schedule(400, 0, "aaCollapse"); +} + +function aaCollapse() +{ // Selecting anything else must take the split back down to one preview. AssetAdmin.Dictionary["ImageAsset"].setExpanded(true); AssetAdmin.libWindow.relayout(); @@ -125,7 +161,7 @@ function aaGrabCaret() function aaGrabCollapsed() { - aaGrab("assetAnimation3"); + aaGrab("assetAnimation4"); echo("SHOTS DONE"); schedule(300, 0, "quit"); diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs index ffd8f4dd0..c2fa4f162 100644 --- a/tests/smoke/assetAnimationTimeline.cs +++ b/tests/smoke/assetAnimationTimeline.cs @@ -211,6 +211,128 @@ function aniStepPreview2() aniCheck("an outside change reloads the timeline", $aniTimeline.getFrames() $= "30 31 32"); aniCheck("and still did not rebuild the preview", AssetAdmin.previewSprite == $aniSprite); + schedule(300, 0, "aniStepTransport"); +} + +//----------------------------------------------------------------------------- +// The transport, and the trap that makes a stopped preview unscrubbable. +//----------------------------------------------------------------------------- + +function aniStepTransport() +{ + %bar = AssetAdmin.transportBar; + aniCheck("the transport bar exists", isObject(%bar)); + aniCheck("it is on show for an animation", AssetAdmin.transportBarContainer.isVisible()); + + $aniStage.timelinePane.setFrames("40 41 42 43 44 45"); + + $aniStage.play(); + aniCheck("play starts it", $aniStage.playing); + + $aniStage.stop(); + aniCheck("stop halts it", !$aniStage.playing); + + $aniStage.scrubTo(3); + aniCheck("scrubbing moves the preview", $aniSprite.getAnimationFrame() == 3); + + // The trap, asserted head on. SpriteBase::stopAnimation sets the finished + // flag, and ImageFrameProviderCore::updateAnimation returns immediately on it + // -- so setAnimationFrame is dead and the preview could never be scrubbed + // again. armPreview is the only way back, and every path that moves the + // playhead goes through it. + $aniSprite.stopAnimation(); + $aniStage.scrubTo(1); + aniCheck("scrubbing still works after the animation has finished", + $aniSprite.getAnimationFrame() == 1); + + // Rewind leaves the playing state alone. + %bar.rewind(); + aniCheck("rewind goes to the first slot", $aniSprite.getAnimationFrame() == 0); + + schedule(300, 0, "aniStepToggles"); +} + +//----------------------------------------------------------------------------- +// The two toggles: one writes the asset, one is an editor preference. +//----------------------------------------------------------------------------- + +function aniStepToggles() +{ + %asset = $aniStage.animationAsset; + + %wasCycling = %asset.getAnimationCycle(); + $aniStage.setCycle(!%wasCycling); + aniCheck("the loop toggle writes the asset's cycle flag", + %asset.getAnimationCycle() == !%wasCycling); + $aniStage.setCycle(%wasCycling); + + // Keep frame rate off: the count changes and the time does not. + EditorPreferences.set("assetAnimationKeepFrameRate", false); + $aniStage.timelinePane.setFrames("0 1 2 3"); + %asset.setAnimationTime(1.0); + + $aniStage.timelinePane.appendFrame(4); + aniCheck("with keep-rate off the animation time is left alone", + mAbs(%asset.getAnimationTime() - 1.0) < 0.001); + + // Keep frame rate on: 4 frames at 1.0s becomes 5 frames at 1.25s, so each + // frame still lasts a quarter of a second. + EditorPreferences.set("assetAnimationKeepFrameRate", true); + $aniStage.timelinePane.setFrames("0 1 2 3"); + %asset.setAnimationTime(1.0); + + $aniStage.timelinePane.appendFrame(4); + aniCheck("with keep-rate on the time grows with the frame count (" @ + %asset.getAnimationTime() @ " from " @ $aniTimeline.getCellCount() @ " frames)", + mAbs(%asset.getAnimationTime() - 1.25) < 0.001); + + EditorPreferences.set("assetAnimationKeepFrameRate", false); + + schedule(200, 0, "aniStepRange"); +} + +//----------------------------------------------------------------------------- +// The range builder. Eight rows, no dialog: it is a pure function and the +// dialog's feedback line is this same answer read back. +//----------------------------------------------------------------------------- + +function aniStepRange() +{ + %r = AssetAdmin.frameRange; + + aniCheck("a plain range", %r.build(28, 32, 1, 1, false) $= "28 29 30 31 32"); + aniCheck("a reversed range counts down", %r.build(32, 28, 1, 1, false) $= "32 31 30 29 28"); + aniCheck("a step skips frames", %r.build(0, 8, 2, 1, false) $= "0 2 4 6 8"); + aniCheck("a hold repeats each frame", %r.build(0, 3, 1, 2, false) $= "0 0 1 1 2 2 3 3"); + + // Both ends appear once, not twice: keeping them would hold the turn at each + // end for twice as long as every other frame, which reads as a stutter. + aniCheck("ping-pong drops the shared end frames", %r.build(0, 3, 1, 1, true) $= "0 1 2 3 2 1"); + + // Hold applies AFTER ping-pong, so "shared" means one frame, not N slots. + aniCheck("hold applies to the ping-pong too", + %r.build(0, 3, 1, 2, true) $= "0 0 1 1 2 2 3 3 2 2 1 1"); + + aniCheck("a single frame cannot ping-pong", %r.build(0, 0, 1, 1, true) $= "0"); + aniCheck("a step past the end still gives the first frame", %r.build(5, 5, 3, 1, false) $= "5"); + + // And it refuses what it should. + aniCheck("a frame outside the image is refused", + %r.problemWith(0, 500, 1, 1, false, 100) !$= ""); + aniCheck("an enormous hold is refused", + %r.problemWith(0, 99, 1, 100, false, 100) !$= ""); + aniCheck("a sensible range is not refused", + %r.problemWith(28, 32, 1, 1, false, 100) $= ""); + + // Applied through the stage, both ways. + $aniStage.timelinePane.setFrames("1 2"); + $aniStage.appendFrames(%r.build(28, 30, 1, 1, false)); + aniCheck("appending adds to what is there", + $aniTimeline.getFrames() $= "1 2 28 29 30"); + + $aniStage.setFrames(%r.build(28, 30, 1, 1, false)); + aniCheck("replacing does not", $aniTimeline.getFrames() $= "28 29 30"); + schedule(300, 0, "aniStep5"); } From 54a9b1b1c73a94b1d2d8a08a2dde9ad37f9a3c91 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 12:08:43 -0400 Subject: [PATCH 14/26] An inspector for animations, and room for the next one Animation assets got the generic GuiInspector: General, SimBase, Namespace Linking, Dynamic Fields, and somewhere among them the things that actually matter. They now get the same treatment image assets got -- three blocks that reflow, only the fields worth showing, and a line saying what the numbers add up to. The most important thing about the pane is a field that is not in it. AnimationFrames is the timeline, in the editor above; a box of space-separated numbers beside a timeline editing the same list is two sources of truth, and the box is the one that cannot say which frame 67 is. Named frames are absent for a harder reason: the engine's named-frame API does not round-trip through its own file, so an asset in that mode keeps the generic inspector rather than being offered a pane that would quietly lose work. The info line is the part the separate fields cannot say: "25 frames, 2.08 s, 12.0 per second. Frames are 96 x 96, from ToyAssets:TD_Barbarian_CompSprite (1024 x 1024, 100 frames)." Uniform timing means the rate is a consequence of two other fields, so it has to be shown rather than worked out. Four warnings, and the one worth reading twice compares the specified frame list against the validated one. That is the only comparison script can make and it is exactly the right one, because the engine CLAMPS an out-of-range frame to the last one instead of dropping it -- so the animation goes on playing and shows the wrong art with nothing said. It needs the validateFrames fix from earlier in this branch to have anything to compare against. chooseInspector was a boolean with two literal isVisible() tests reading it back from the far side of the file. It is a key registry now: registerPane names a pane, chooseInspector shows one and unbinds the rest, and activePaneObject is the single accessor the other two went through. imageScroller and imagePane stay as named handles onto it -- assetImageInspector names them in eight assertions, and that suite passing unchanged at 97 checks is the proof this refactor preserved behavior. The five copies of the same four addHiddenField lines are now one inspectStock. 48 checks. Two things needed a second look and are worth knowing: addFieldRow takes the label and kind as arguments rather than asking labelFor/kindFor, so rows built without them come out captionless; and makeInfoLabel gives one line of 20 pixels, so a sentence needs textWrap and textExtend or it is not drawn at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationStage.cs | 24 ++ editor/AssetAdmin/AssetInspector.cs | 166 +++++++--- .../Inspector/AssetAnimationInspectorPane.cs | 304 ++++++++++++++++++ editor/AssetAdmin/Inspector/exec.cs | 1 + tests/smoke/assetAnimationInspector.cs | 240 ++++++++++++++ 5 files changed, 683 insertions(+), 52 deletions(-) create mode 100644 editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs create mode 100644 tests/smoke/assetAnimationInspector.cs diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index c2c272f75..551fe6de5 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -563,6 +563,30 @@ class = "AssetAnimationPalettePane"; %this.committing = false; } +// The inspector wrote something. Most of its fields do not concern the stage -- +// the refresh they raise is absorbed like any other -- but changing the image +// asset moves what every frame number means, so the palette and the timeline +// both have to be re-pointed at it. +function AssetAnimationStage::onInspectorCommit(%this) +{ + if(!%this.built || %this.busy || !isObject(%this.animationAsset)) + { + return; + } + + %imageAssetId = %this.animationAsset.getImage(); + if(%imageAssetId $= %this.imageAssetId) + { + return; + } + + %this.imageAssetId = %imageAssetId; + %this.palettePane.load(%imageAssetId); + %this.timelinePane.load(%imageAssetId, trim(%this.animationAsset.getAnimationFrames())); + + %this.admin.transportBar.refresh(); +} + function AssetAnimationStage::setCycle(%this, %on) { if(!isObject(%this.animationAsset)) diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index 45bdb4497..1dd9fddeb 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -105,18 +105,18 @@ %this.inspector = %this.createInspector(); %this.insScroller.add(%this.inspector); - // The image asset's own pane, in place of the generic inspector for the one - // asset kind that has one so far. It shares the Inspector page with the - // inspector rather than taking a tab of its own -- it IS the inspector, for - // that kind of asset -- and chooseInspector decides which of the two is on - // show. Neither is ever rebuilt or freed. - %this.imageScroller = %this.createScroller(); - %this.imageScroller.setVisible(false); - %this.insPage.add(%this.imageScroller); - - %this.imagePane = %this.createImagePane(); - %this.imageScroller.add(%this.imagePane); - %this.imagePane.build(); + // An asset kind with a pane of its own gets it here, sharing the Inspector + // page with the generic inspector rather than taking a tab -- for that kind + // of asset the pane IS the inspector, and chooseInspector decides which one is + // on show. None of them is ever rebuilt or freed. + %this.registerPane("Image", %this.createImagePane()); + %this.registerPane("Animation", %this.createAnimationPane()); + + // Named handles for the two the tests and the load methods reach for + // directly. The registry is the truth; these are just shorter. + %this.imageScroller = %this.paneScroller["Image"]; + %this.imagePane = %this.pane["Image"]; + %this.animationPane = %this.pane["Animation"]; //Particle Graph Tool %this.scaleGraphPage = %this.createTabPage("Scale Graph", "AssetParticleGraphTool", ""); @@ -235,28 +235,85 @@ class = "AssetImageInspectorPane"; }; } -// Which of the two inspectors the Inspector page is showing. The one standing -// down is hidden rather than emptied, so nothing it holds is ever freed while -// the engine might be dispatching on it. -function AssetInspector::chooseInspector(%this, %useImagePane) +// Give a custom pane its own scroller on the Inspector page and remember it +// under a key. Built once, here, and never rebuilt or freed. +function AssetInspector::registerPane(%this, %key, %pane) { - %this.insScroller.setVisible(!%useImagePane); - %this.imageScroller.setVisible(%useImagePane); + %scroller = %this.createScroller(); + %scroller.setVisible(false); + %this.insPage.add(%scroller); - if(!%useImagePane) + %scroller.add(%pane); + %pane.build(); + + %this.paneScroller[%key] = %scroller; + %this.pane[%key] = %pane; + %this.paneKeys = (%this.paneKeys $= "") ? %key : (%this.paneKeys SPC %key); +} + +function AssetInspector::createAnimationPane(%this) +{ + %width = 686; + + return new GuiChainCtrl() + { + class = "AssetAnimationInspectorPane"; + superclass = "AssetInspectorPane"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %width SPC 320; + IsVertical = true; + ChildSpacing = 6; + paneWidth = %width; + }; +} + +// Which inspector the Inspector page is showing: a registered pane by key, or "" +// for the generic one. The panes standing down are hidden rather than emptied, +// so nothing they hold is ever freed while the engine might be dispatching on it +// -- but they are unbound, so a stale target cannot be written to. +function AssetInspector::chooseInspector(%this, %key) +{ + %this.insScroller.setVisible(%key $= ""); + %this.activePane = ""; + + %count = getWordCount(%this.paneKeys); + for(%i = 0; %i < %count; %i++) { - %this.imagePane.unbind(); + %thisKey = getWord(%this.paneKeys, %i); + %chosen = (%thisKey $= %key); + + %this.paneScroller[%thisKey].setVisible(%chosen); + + if(%chosen) + { + %this.activePane = %this.pane[%thisKey]; + } + else + { + %this.pane[%thisKey].unbind(); + } } } +// The pane currently standing in for the inspector, or "" when the generic one +// is on show. The one accessor everything below goes through, so there is no +// second place that has to know how many panes there are. +function AssetInspector::activePaneObject(%this) +{ + return %this.activePane; +} + // What the title bar's delete button acts on. The generic inspector knows what -// it was handed; the image pane has to be asked, because it is not one. +// it was handed; a pane has to be asked, because it is not one. function AssetInspector::inspectedObject(%this) { - if(%this.imageScroller.isVisible()) + %pane = %this.activePaneObject(); + if(isObject(%pane)) { - return %this.imagePane.target; + return %pane.target; } + return %this.inspector.getInspectObject(); } @@ -265,12 +322,25 @@ class = "AssetImageInspectorPane"; // is the one it is bound to. function AssetInspector::onAssetRefreshed(%this, %asset) { - if(%this.imageScroller.isVisible()) + %pane = %this.activePaneObject(); + if(isObject(%pane)) { - %this.imagePane.onAssetRefreshed(%asset); + %pane.onAssetRefreshed(%asset); } } +// The four fields no asset kind wants to see, and the inspect call that follows +// them. Repeated verbatim in five load methods before this. +function AssetInspector::inspectStock(%this, %asset) +{ + %this.inspector.clearHiddenFields(); + %this.inspector.addHiddenField("hidden"); + %this.inspector.addHiddenField("locked"); + %this.inspector.addHiddenField("AssetInternal"); + %this.inspector.addHiddenField("AssetPrivate"); + %this.inspector.inspect(%asset); +} + function AssetInspector::hideInspector(%this) { %this.titlebar.setText(""); @@ -280,7 +350,7 @@ class = "AssetImageInspectorPane"; %this.deleteAssetButton.visible = false; // Nothing is selected, so nothing is bound. The pane keeps its rows. - %this.chooseInspector(false); + %this.chooseInspector(""); } function AssetInspector::resetInspector(%this) @@ -299,7 +369,7 @@ class = "AssetImageInspectorPane"; // Back to the generic inspector. The one asset kind with a pane of its own // says so straight after. - %this.chooseInspector(false); + %this.chooseInspector(""); } function AssetInspector::loadImageAsset(%this, %imageAsset, %assetID) @@ -310,7 +380,7 @@ class = "AssetImageInspectorPane"; %this.tabBook.selectPage(0); %this.titlebar.setText("Image Asset:" SPC %imageAsset.AssetName); - %this.chooseInspector(true); + %this.chooseInspector("Image"); %this.imagePane.bind(%imageAsset, %assetID); %this.imageFrameEditPage.inspect(%imageAsset); @@ -322,12 +392,19 @@ class = "AssetImageInspectorPane"; %this.resetInspector(); %this.titlebar.setText("Animation Asset:" SPC %animationAsset.AssetName); - %this.inspector.clearHiddenFields(); - %this.inspector.addHiddenField("hidden"); - %this.inspector.addHiddenField("locked"); - %this.inspector.addHiddenField("AssetInternal"); - %this.inspector.addHiddenField("AssetPrivate"); - %this.inspector.inspect(%animationAsset); + // Named cells fall back to the generic inspector, and not out of caution: the + // engine's named-frame API does not round-trip -- its getter formats strings + // through %d, and the field joins with commas while the setter splits on + // whitespace, so a named list does not survive its own TAML file. A pane built + // on that would be a pane that quietly loses work. + if(%animationAsset.getNamedCellsMode()) + { + %this.inspectStock(%animationAsset); + return; + } + + %this.chooseInspector("Animation"); + %this.animationPane.bind(%animationAsset, %assetID); } function AssetInspector::loadParticleAsset(%this, %particleAsset, %assetID) @@ -392,12 +469,7 @@ class = "AssetImageInspectorPane"; %this.resetInspector(); %this.titlebar.setText("Font Asset:" SPC %fontAsset.AssetName); - %this.inspector.clearHiddenFields(); - %this.inspector.addHiddenField("hidden"); - %this.inspector.addHiddenField("locked"); - %this.inspector.addHiddenField("AssetInternal"); - %this.inspector.addHiddenField("AssetPrivate"); - %this.inspector.inspect(%fontAsset); + %this.inspectStock(%fontAsset); } function AssetInspector::loadAudioAsset(%this, %audioAsset, %assetID) @@ -405,12 +477,7 @@ class = "AssetImageInspectorPane"; %this.resetInspector(); %this.titlebar.setText("Audio Asset:" SPC %audioAsset.AssetName); - %this.inspector.clearHiddenFields(); - %this.inspector.addHiddenField("hidden"); - %this.inspector.addHiddenField("locked"); - %this.inspector.addHiddenField("AssetInternal"); - %this.inspector.addHiddenField("AssetPrivate"); - %this.inspector.inspect(%audioAsset); + %this.inspectStock(%audioAsset); } function AssetInspector::loadSpineAsset(%this, %spineAsset, %assetID) @@ -418,12 +485,7 @@ class = "AssetImageInspectorPane"; %this.resetInspector(); %this.titlebar.setText("Spine Asset:" SPC %spineAsset.AssetName); - %this.inspector.clearHiddenFields(); - %this.inspector.addHiddenField("hidden"); - %this.inspector.addHiddenField("locked"); - %this.inspector.addHiddenField("AssetInternal"); - %this.inspector.addHiddenField("AssetPrivate"); - %this.inspector.inspect(%spineAsset); + %this.inspectStock(%spineAsset); } function AssetInspector::deleteAsset(%this) diff --git a/editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs new file mode 100644 index 000000000..d65be9d8b --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs @@ -0,0 +1,304 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The inspector for an animation asset, in place of the generic one: three +// blocks that reflow, and a line saying what the numbers add up to. +// +// The single most important thing about it is a field that is NOT here. +// AnimationFrames is the timeline, in the editor above. A box of space-separated +// numbers beside a timeline editing the same list is two sources of truth, and +// the one that is only a box cannot say which frame 67 is. +// +// Also absent, each for a checkable reason: +// NamedAnimationFrames, NamedCellsMode v1 is numeric, and the engine's named +// frame API does not round-trip through +// its own file -- so this pane is never +// shown for such an asset rather than +// offering a switch into it +// AssetInternal, AssetPrivate they exist to keep an asset out of the +// editor +// asset id, asset file the module and the name are on show, +// and the file is where the manager put it +//----------------------------------------------------------------------------- + +$AssetAnimationInspectorPane::cellWidth = 300; +$AssetAnimationInspectorPane::cellCount = 3; +$AssetAnimationInspectorPane::descriptionHeight = 150; + +// A time of zero has no length, and worse: ImageFrameProviderCore divides the +// total by the frame count to get a frame's length and then divides by that. +$AssetAnimationInspectorPane::minimumTime = 0.01; + +function AssetAnimationInspectorPane::onAdd(%this) +{ + %this.init(); +} + +function AssetAnimationInspectorPane::buildPane(%this) +{ + %grid = %this.makeCellGrid(0, $AssetAnimationInspectorPane::cellWidth, + $AssetAnimationInspectorPane::cellCount); + %this.add(%grid); + %this.contentGrid = %grid; + + %this.buildIdentityCell(%grid); + %this.buildPlaybackCell(%grid); + %this.buildDescriptionCell(%grid); + + %this.buildWarning(); + + %this.nameRow.setEnabled(false, "Renaming an asset changes its id and every file that refers to it, " @ + "so it is not something the inspector can do safely on its own."); +} + +// addFieldRow takes the label and the kind as arguments rather than asking the +// tables for them, so every call here would otherwise repeat the same two +// lookups. One place to go through them is also one place to be wrong. +function AssetAnimationInspectorPane::addField(%this, %container, %field) +{ + return %this.addFieldRow(%container, %field, %this.labelFor(%field), + %this.kindFor(%field), %this.enumItemsFor(%field)); +} + +function AssetAnimationInspectorPane::buildIdentityCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.identityChain = %chain; + + %this.nameRow = %this.addField(%chain, "AssetName"); + %this.addField(%chain, "AssetCategory"); + + // Kind "asset": EditorFieldRow's Find button opens the picker already filtered + // to image assets, which is the only asset an animation can name. + %this.addField(%chain, "Image"); +} + +function AssetAnimationInspectorPane::buildPlaybackCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.playbackChain = %chain; + + %this.addField(%chain, "AnimationTime"); + %this.addField(%chain, "AnimationCycle"); + %this.addField(%chain, "RandomStart"); + + // Wrapping and extending: makeInfoLabel gives a label one line of 20 pixels, + // which is right for a short readout and not for a sentence about frame + // counts, sizes and rates. Without them the line is simply not drawn. + %this.infoLabel = %this.makeInfoLabel(%chain, "labelProfile"); + %this.infoLabel.textWrap = true; + %this.infoLabel.textExtend = true; +} + +function AssetAnimationInspectorPane::buildDescriptionCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.descriptionChain = %chain; + + %this.addField(%chain, "AssetDescription"); +} + +function AssetAnimationInspectorPane::buildWarning(%this) +{ + %this.warningLabel = %this.makeInfoLabel(%this, "overrideLabelProfile"); + %this.warningLabel.textWrap = true; + %this.warningLabel.textExtend = true; + %this.warningLabel.setVisible(false); +} + +//----------------------------------------------------------------------------- +// The field tables. +//----------------------------------------------------------------------------- + +function AssetAnimationInspectorPane::labelFor(%this, %field) +{ + switch$(%field) + { + case "AssetName": return "Asset Name"; + case "AssetCategory": return "Category"; + case "Image": return "Image Asset"; + case "AnimationTime": return "Animation Time (seconds)"; + case "AnimationCycle": return "Loop"; + case "RandomStart": return "Start On A Random Frame"; + case "AssetDescription": return "Description"; + } + + return %field; +} + +function AssetAnimationInspectorPane::kindFor(%this, %field) +{ + switch$(%field) + { + case "Image": return "asset"; + case "AnimationTime": return "decimal"; + case "AnimationCycle": return "bool"; + case "RandomStart": return "bool"; + case "AssetDescription": return "multiline"; + } + + return "text"; +} + +function AssetAnimationInspectorPane::editorHeightFor(%this, %field) +{ + if(%field $= "AssetDescription") + { + return $AssetAnimationInspectorPane::descriptionHeight; + } + + return 0; +} + +//----------------------------------------------------------------------------- +// Reading and writing. +// +// RandomStart has no script accessor at all -- it is field-only -- which is +// exactly what the base's readField and writeField already do through +// getFieldValue and setFieldValue. No override; the note is here because the +// missing accessor looks like an oversight rather than a decision. +//----------------------------------------------------------------------------- + +function AssetAnimationInspectorPane::writeField(%this, %field, %value) +{ + if(%field $= "AnimationTime") + { + // Floored rather than refused, because the number a person is halfway + // through typing passes through here on its way to a sensible one. + if(%value < $AssetAnimationInspectorPane::minimumTime) + { + %value = $AssetAnimationInspectorPane::minimumTime; + } + %this.target.setAnimationTime(%value); + return; + } + + %this.target.setFieldValue(%field, %value); +} + +function AssetAnimationInspectorPane::refreshExtras(%this) +{ + %this.infoLabel.setText(%this.describeAnimation(%this.target)); + %this.showWarning(%this.warningFor(%this.target)); +} + +// Everything the numbers add up to, which is the question the fields cannot +// answer separately: how long, how many, and therefore how fast. +function AssetAnimationInspectorPane::describeAnimation(%this, %asset) +{ + %count = %asset.getAnimationFrameCount(); + %time = %asset.getAnimationTime(); + + %line = %count SPC ((%count == 1) ? "frame," : "frames,") SPC %time SPC "s"; + + if(%count > 0 && %time > 0) + { + %line = %line @ "," SPC mFloatLength(%count / %time, 1) SPC "per second"; + } + %line = %line @ "."; + + %image = AssetDatabase.acquireAsset(%asset.getImage()); + if(isObject(%image)) + { + %frameSize = %image.getFrameSize(0); + %line = %line SPC "Frames are" SPC getWord(%frameSize, 0) SPC "x" SPC getWord(%frameSize, 1) @ + ", from" SPC %asset.getImage() SPC "(" @ %image.getImageWidth() SPC "x" SPC + %image.getImageHeight() @ "," SPC %image.getFrameCount() SPC "frames)."; + + AssetDatabase.releaseAsset(%asset.getImage()); + } + + return %line; +} + +// In the order they matter. Only the first is shown, because the first is the one +// that has to be fixed before any of the others can be judged. +function AssetAnimationInspectorPane::warningFor(%this, %asset) +{ + %imageId = %asset.getImage(); + if(%imageId $= "") + { + return "This animation has no image asset, so there is nothing to play."; + } + + %image = AssetDatabase.acquireAsset(%imageId); + %valid = isObject(%image) && %image.getFrameCount() > 0; + if(isObject(%image)) + { + AssetDatabase.releaseAsset(%imageId); + } + + if(!%valid) + { + return "The image asset" SPC %imageId SPC "did not load, so there is nothing to play."; + } + + // Specified against validated is the only comparison script can make, and it + // is exactly the right one: validateNumericalFrames CLAMPS an out-of-range + // frame to the last one rather than dropping it, so the animation keeps + // playing and quietly shows the wrong art. + if(trim(%asset.getAnimationFrames()) !$= trim(%asset.getAnimationFrames(true))) + { + return "Some frames are outside the image's" SPC %image.getFrameCount() SPC + "and are being clamped to the nearest one. The timeline shows what was asked for; " @ + "the preview shows what is being drawn."; + } + + if(%asset.getAnimationFrameCount() == 0) + { + return "This animation has no frames yet. Drag one in from the palette, or use Frame Range."; + } + + if(%asset.getAnimationTime() <= 0) + { + return "An animation time of zero has no length, so nothing plays."; + } + + return ""; +} + +// forceLayout only when the visibility actually changed: a chain skips hidden +// children when it lays out, and nothing re-lays it out on setVisible. +function AssetAnimationInspectorPane::showWarning(%this, %text) +{ + %wanted = (%text !$= ""); + %changed = (%wanted != %this.warningLabel.isVisible()); + + %this.warningLabel.setText(%text); + %this.warningLabel.setVisible(%wanted); + + if(%changed) + { + %this.forceLayout(); + } +} + +// The timeline is showing the same list, so it has to hear about a change made +// here -- picking a different image asset moves every frame's meaning. +function AssetAnimationInspectorPane::afterCommit(%this) +{ + if(isObject(AssetAdmin.animationStage)) + { + AssetAdmin.animationStage.onInspectorCommit(); + } +} diff --git a/editor/AssetAdmin/Inspector/exec.cs b/editor/AssetAdmin/Inspector/exec.cs index 3f49bdb53..3b8bc15cb 100644 --- a/editor/AssetAdmin/Inspector/exec.cs +++ b/editor/AssetAdmin/Inspector/exec.cs @@ -1,3 +1,4 @@ exec("./AssetInspectorPane.cs"); exec("./AssetImageCellGrid.cs"); +exec("./AssetAnimationInspectorPane.cs"); exec("./AssetImageInspectorPane.cs"); diff --git a/tests/smoke/assetAnimationInspector.cs b/tests/smoke/assetAnimationInspector.cs new file mode 100644 index 000000000..42ca78e30 --- /dev/null +++ b/tests/smoke/assetAnimationInspector.cs @@ -0,0 +1,240 @@ +// Asset Manager animation-inspector smoke test. Drives the custom pane that +// replaced the generic GuiInspector for animation assets: the three reflowing +// blocks, the field that is deliberately NOT offered, the derived info line, and +// each of the warnings. +// Run: tests/run.ps1 assetAnimationInspector ; grep AAIN in tests/logs/. +// +// Driven by calling the pane rather than by posting input, for the same reason +// assetImageInspector is: where a row sits depends on how many columns the grid +// chose and how far the scroller has been dragged, neither of which script can +// read. +// +// NOTE: a COPY of toybox/ToyAssets. Committing a field writes the asset straight +// back to its own file -- every setter ends in refreshAsset. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function ainCheck(%label, %cond) +{ + if(%cond) echo("AAIN PASS: " @ %label); + else echo("AAIN FAIL: " @ %label); +} + +// 25 frames from a 10 x 10 sheet at 2.083 seconds: every number in the info line +// is one you can check. +$ainAssetId = "ToyAssets:TD_Barbarian_Death"; + +function ainLoadFixtureAssets() +{ + %copy = testRoot("assetAnimationInspectorSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "ainStep1"); + +//----------------------------------------------------------------------------- + +function ainStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."). + ProjectManager.setProjectFolder("assetAnimationInspectorSmokeProject"); + EditorPreferences.path = testRoot("shots/assetAnimationInspectorSmokePrefs.taml"); + + ainCheck("fixture asset module registered", ainLoadFixtureAssets()); + + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "ainStep2"); +} + +//----------------------------------------------------------------------------- +// The pane exists, and stands down until an animation is chosen. +//----------------------------------------------------------------------------- + +function ainStep2() +{ + $ainInspector = AssetAdmin.inspector; + $ainPane = $ainInspector.animationPane; + + ainCheck("animation pane built", isObject($ainPane)); + ainCheck("it is an AssetAnimationInspectorPane", + $ainPane.getClassNamespace() $= "AssetAnimationInspectorPane"); + ainCheck("it inherits the shared pane", + $ainPane.getSuperClassNamespace() $= "AssetInspectorPane"); + ainCheck("it starts hidden", !$ainInspector.paneScroller["Animation"].isVisible()); + ainCheck("the generic inspector is the one on show", $ainInspector.insScroller.isVisible()); + + // The registry knows both panes and nothing else. + ainCheck("both panes are registered", $ainInspector.paneKeys $= "Image Animation"); + + $ainTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($ainAssetId); + ainCheck("the animation tile is in the library", isObject($ainTile)); + + $ainTile.onClick(); + + schedule(600, 0, "ainStep3"); +} + +//----------------------------------------------------------------------------- +// Choosing one hands the page to the pane. +//----------------------------------------------------------------------------- + +function ainStep3() +{ + ainCheck("the pane took the page", $ainInspector.paneScroller["Animation"].isVisible()); + ainCheck("the generic inspector stood down", !$ainInspector.insScroller.isVisible()); + ainCheck("the image pane stood down too", !$ainInspector.imageScroller.isVisible()); + ainCheck("the pane is bound to the asset", $ainPane.target == $ainTile.AnimationAsset); + ainCheck("the inspector reports the pane's asset as the inspected one", + $ainInspector.inspectedObject() == $ainTile.AnimationAsset); + + // The strongest single statement the pane makes. AnimationFrames is the + // timeline; a box of numbers beside it would be a second source of truth. + ainCheck("AnimationFrames is NOT offered as a row", !isObject($ainPane.row["AnimationFrames"])); + ainCheck("neither is NamedAnimationFrames", !isObject($ainPane.row["NamedAnimationFrames"])); + ainCheck("nor NamedCellsMode", !isObject($ainPane.row["NamedCellsMode"])); + ainCheck("nor AssetInternal", !isObject($ainPane.row["AssetInternal"])); + + // The three blocks and what is in them. + ainCheck("the identity block exists", isObject($ainPane.identityChain)); + ainCheck("the playback block exists", isObject($ainPane.playbackChain)); + ainCheck("the description block exists", isObject($ainPane.descriptionChain)); + + ainCheck("asset name row", isObject($ainPane.row["AssetName"])); + ainCheck("category row", isObject($ainPane.row["AssetCategory"])); + ainCheck("image row", isObject($ainPane.row["Image"])); + ainCheck("animation time row", isObject($ainPane.row["AnimationTime"])); + ainCheck("loop row", isObject($ainPane.row["AnimationCycle"])); + ainCheck("random start row", isObject($ainPane.row["RandomStart"])); + ainCheck("description row", isObject($ainPane.row["AssetDescription"])); + + // Renaming an asset changes its id and every file naming it, so the row is + // readable and inert. + ainCheck("the name row is not editable", !$ainPane.row["AssetName"].isEnabled()); + + schedule(300, 0, "ainStep4"); +} + +//----------------------------------------------------------------------------- +// The derived line, which is the whole reason the block exists. +//----------------------------------------------------------------------------- + +function ainStep4() +{ + %asset = $ainPane.target; + %info = $ainPane.infoLabel.getText(); + + ainCheck("the info line counts the frames (" @ %info @ ")", strstr(%info, "25 frames") != -1); + ainCheck("it names the image", strstr(%info, "TD_Barbarian_CompSprite") != -1); + ainCheck("it gives the frame size", strstr(%info, "96 x 96") != -1); + ainCheck("it gives the sheet size and count", strstr(%info, "100 frames") != -1); + + // 25 frames in 2.5 seconds is 10 a second, and the line has to say so. + %asset.setAnimationTime(2.5); + %info = $ainPane.infoLabel.getText(); + ainCheck("it works out the frame rate (" @ %info @ ")", strstr(%info, "10.0 per second") != -1); + + schedule(300, 0, "ainStep5"); +} + +//----------------------------------------------------------------------------- +// Each warning, appearing and clearing. +//----------------------------------------------------------------------------- + +function ainStep5() +{ + %asset = $ainPane.target; + + ainCheck("no warning to begin with", !$ainPane.warningLabel.isVisible()); + + // Frames outside the image are clamped by the engine rather than refused, so + // the animation goes on playing and shows the wrong art. That is what this + // line exists to say. + %good = trim(%asset.getAnimationFrames()); + %asset.setAnimationFrames("55 56 900"); + ainCheck("out-of-range frames are called out", $ainPane.warningLabel.isVisible()); + ainCheck("and the warning says which way round it is", + strstr($ainPane.warningLabel.getText(), "clamped") != -1); + + %asset.setAnimationFrames(%good); + ainCheck("the warning clears when they are back in range", !$ainPane.warningLabel.isVisible()); + + // An animation with nothing in it. + %asset.setAnimationFrames(""); + ainCheck("an empty animation is called out", $ainPane.warningLabel.isVisible()); + ainCheck("and it says what to do about it", + strstr($ainPane.warningLabel.getText(), "Frame Range") != -1); + + %asset.setAnimationFrames(%good); + ainCheck("that clears too", !$ainPane.warningLabel.isVisible()); + + schedule(300, 0, "ainStep6"); +} + +//----------------------------------------------------------------------------- +// Committing, and the floor under the animation time. +//----------------------------------------------------------------------------- + +function ainStep6() +{ + %asset = $ainPane.target; + + $ainPane.commitValue("AssetCategory", "smokeCategory"); + ainCheck("a committed field reaches the asset", %asset.AssetCategory $= "smokeCategory"); + + // Zero has no length, and worse: the playback integrator divides the total by + // the frame count and then divides by that. + $ainPane.commitValue("AnimationTime", 0); + ainCheck("an animation time of zero is floored, not written (" @ %asset.getAnimationTime() @ ")", + %asset.getAnimationTime() > 0); + + $ainPane.commitValue("AnimationTime", 1.5); + ainCheck("a sensible time is written as given", + mAbs(%asset.getAnimationTime() - 1.5) < 0.001); + + // RandomStart has no script accessor at all -- it is field-only -- so this is + // the path the base's readField and writeField take. + $ainPane.commitValue("RandomStart", true); + ainCheck("a field with no accessor still commits", %asset.RandomStart); + $ainPane.commitValue("RandomStart", false); + + schedule(300, 0, "ainStep7"); +} + +//----------------------------------------------------------------------------- +// Standing down for another asset kind. +//----------------------------------------------------------------------------- + +function ainStep7() +{ + %imageTile = AssetAdmin.Dictionary["ImageAsset"].getButton("ToyAssets:TD_Barbarian_CompSprite"); + %imageTile.onClick(); + + ainCheck("the animation pane stood down", !$ainInspector.paneScroller["Animation"].isVisible()); + ainCheck("the image pane took over", $ainInspector.imageScroller.isVisible()); + ainCheck("exactly one pane is on show at a time", !$ainInspector.insScroller.isVisible()); + ainCheck("the animation pane was unbound", !isObject($ainPane.target)); + + echo("AAIN DONE"); + schedule(200, 0, "quit"); +} From 2a80ece99731fbcfefec83ec0a0164134d90ac3e Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 12:20:31 -0400 Subject: [PATCH 15/26] The two gestures worth posting a real click for Two suites, split by what actually needs a pointer. The drop path goes in the timeline suite, driven by calling the callbacks with a payload parked at real coordinates. That is not a shortcut around the interesting part, it IS the interesting part: GuiDragAndDropCtrl hit-tests from its own parent and findHitControl answers "me" without ever testing its bounds, so a drop anywhere on the screen arrives at the timeline and the boundary check is the timeline's own to make. The suite drops a frame over the asset library and asserts nothing changed, then drops one on the left half of a slot and asserts it went in before that slot -- the caret's promise, kept. The palette-to-timeline DRAG is deliberately not tested with posted input, and the reason is written down rather than left as a gap: a GuiDragAndDropCtrl gesture follows the real cursor, which a posted WM_MOUSEMOVE does not move. What would be proved is the engine's capture, not this feature's code -- and this feature's share of it, the boundary policing and reading the cursor back off the payload, is script and is covered above. What does need real clicks is the touch path in the two grids, which is new code: the press, the five pixels of slop that decide click from drag, the capture taken and given back, and the suppression that stops a released drag also counting as a click. So assetAnimationClick posts two, and like explorerGutter it is handed the points rather than knowing them -- where a cell lands depends on how many columns the palette wrapped into and how far its scroller sits, and a hard-coded point that drifted off the cell would report a control that never fired, which is exactly what a broken hit test reports. All 51 smoke suites and 240 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- tests/smoke/assetAnimationClick.cs | 164 ++++++++++++++++++++++ tests/smoke/assetAnimationClick.input.ps1 | 51 +++++++ tests/smoke/assetAnimationTimeline.cs | 64 +++++++++ 3 files changed, 279 insertions(+) create mode 100644 tests/smoke/assetAnimationClick.cs create mode 100644 tests/smoke/assetAnimationClick.input.ps1 diff --git a/tests/smoke/assetAnimationClick.cs b/tests/smoke/assetAnimationClick.cs new file mode 100644 index 000000000..a0f1e2a37 --- /dev/null +++ b/tests/smoke/assetAnimationClick.cs @@ -0,0 +1,164 @@ +// Real clicks on the animation editor's two grids: one on a palette frame, +// which must append it, and one on a timeline slot, which must select it and +// scrub the preview there. +// Run: tests/run.ps1 assetAnimationClick ; grep ACLK in tests/logs/. +// +// These have to be real. What is being checked is the touch path in +// GuiEditFrameStripCtrl and its two subclasses -- the press, the five pixels of +// slop that decide whether it was a click or a drag, the capture taken and given +// back, and the suppression that stops a released drag also counting as a click. +// Calling onFrameClicked from script would skip every one of them, which is what +// tests/smoke/assetAnimationTimeline.cs already does. +// +// The DRAG from the palette to the timeline is deliberately not here. A +// GuiDragAndDropCtrl gesture cannot be driven by posted mouse messages -- it +// follows the real cursor, which a posted WM_MOUSEMOVE does not move -- so the +// drop path is tested by calling the callbacks with a payload parked at real +// coordinates, in that same suite. That is where its traps are anyway: the +// boundary policing and the cursor-from-the-payload conversion are script, and +// the capture is the engine's. +// +// Neither point is written here. Where a cell lands depends on the reflow and on +// the theme's borders, so the engine works it out and leaves it in a file for the +// PowerShell side. A hard-coded point that drifted off the cell would report a +// control that never fired -- which is precisely what a broken hit test reports. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function clkCheck(%label, %cond) +{ + if(%cond) echo("ACLK PASS: " @ %label); + else echo("ACLK FAIL: " @ %label); +} + +$clkAnimId = "ToyAssets:TD_Barbarian_Death"; + +function clkPostTarget(%point) +{ + createPath(testRoot("shots/")); + %file = new FileObject(); + %file.openForWrite(testRoot("shots/assetAnimationClickTarget.txt")); + %file.writeLine(%point); + %file.close(); + %file.delete(); +} + +function clkCentreOf(%rect) +{ + return (getWord(%rect, 0) + (getWord(%rect, 2) / 2)) SPC + (getWord(%rect, 1) + (getWord(%rect, 3) / 2)); +} + +testExec("editor/main.cs"); +schedule(2500, 0, "clkOpenProject"); + +// The long way in, and it has to be. A posted click goes to whatever is actually +// on screen, so an editor opened the short way leaves the clicks landing on the +// project selector -- which reads exactly like a broken hit test. +function clkOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + createPath(testRoot("shots/")); + ProjectManager.setProjectFolder("assetAnimationClickSmokeProject"); + EditorPreferences.path = testRoot("shots/assetAnimationClickSmokePrefs.taml"); + + %copy = testRoot("assetAnimationClickSmokeProject/ToyAssets"); + pathCopy(testRoot("toybox/ToyAssets"), %copy, false); + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + + schedule(2500, 0, "clkOpenEditor"); +} + +function clkOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "clkSelectAsset"); +} + +function clkSelectAsset() +{ + AssetAdmin.Dictionary["AnimationAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + %tile = AssetAdmin.Dictionary["AnimationAsset"].getButton($clkAnimId); + clkCheck("the animation tile is in the library", isObject(%tile)); + %tile.onClick(); + + $clkStage = AssetAdmin.animationStage; + schedule(1200, 0, "clkAimAtPalette"); +} + +//----------------------------------------------------------------------------- +// A click on a palette frame appends it. +//----------------------------------------------------------------------------- + +function clkAimAtPalette() +{ + clkCheck("the stage is built", $clkStage.built); + + // A short, known list so the append is unambiguous. + $clkStage.timelinePane.setFrames("10 11 12"); + $clkBefore = $clkStage.timelinePane.strip.getCellCount(); + + %rect = $clkStage.palettePane.strip.getCellRect(5); + clkCheck("the palette reported where frame 5 is (" @ %rect @ ")", getWord(%rect, 2) > 0); + + clkPostTarget(clkCentreOf(%rect)); + schedule(2000, 0, "clkCheckPalette"); +} + +function clkCheckPalette() +{ + %strip = $clkStage.timelinePane.strip; + + clkCheck("clicking a palette frame appended one (" @ %strip.getFrames() @ ")", + %strip.getCellCount() == $clkBefore + 1); + clkCheck("and it appended the frame that was clicked", + %strip.getFrameAt(%strip.getCellCount() - 1) == 5); + clkCheck("the asset was written", + trim($clkStage.animationAsset.getAnimationFrames()) $= %strip.getFrames()); + + schedule(400, 0, "clkAimAtTimeline"); +} + +//----------------------------------------------------------------------------- +// A click on a timeline slot selects it and scrubs the preview to it. +//----------------------------------------------------------------------------- + +function clkAimAtTimeline() +{ + $clkStage.play(); + + %rect = $clkStage.timelinePane.strip.getSlotRect(2); + clkCheck("the timeline reported where slot 2 is (" @ %rect @ ")", getWord(%rect, 2) > 0); + + clkPostTarget(clkCentreOf(%rect)); + schedule(2000, 0, "clkCheckTimeline"); +} + +function clkCheckTimeline() +{ + clkCheck("clicking a slot selects it (" @ $clkStage.timelinePane.strip.getSelectedSlot() @ ")", + $clkStage.timelinePane.strip.getSelectedSlot() == 2); + + // Stopping first is deliberate: scrubbing a running preview shows the frame + // for a thirtieth of a second and then moves on, which reads as the click + // having done nothing. + clkCheck("and stops the preview", !$clkStage.playing); + clkCheck("and scrubs it to that slot (" @ AssetAdmin.previewSprite.getAnimationFrame() @ ")", + AssetAdmin.previewSprite.getAnimationFrame() == 2); + + echo("ACLK DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/assetAnimationClick.input.ps1 b/tests/smoke/assetAnimationClick.input.ps1 new file mode 100644 index 000000000..fdfb1622a --- /dev/null +++ b/tests/smoke/assetAnimationClick.input.ps1 @@ -0,0 +1,51 @@ +# Input for assetAnimationClick.cs. Posts two real clicks: one on a frame in the +# palette, then one on a slot in the timeline. +# +# Neither point is written here. Where a cell lands depends on how many columns +# the palette wrapped into, how far its scroller sits, and the theme's borders -- +# so the engine works it out with getCellRect / getSlotRect and leaves it in a +# file for this script to pick up. A hard-coded point that drifted off the cell +# would report a control that never fired, which is exactly what a broken hit +# test reports, and the test would be lying either way. +# +# The clicks have to be real. What is being checked is the touch path in the two +# grids: the press, the five pixels of slop that decide click from drag, the +# capture taken and given back, and the suppression that stops a released drag +# also counting as a click. Calling onFrameClicked from script skips all of it. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +$target = Join-Path $PSScriptRoot "..\..\shots\assetAnimationClickTarget.txt" + +# Anything left by an earlier run would be clicked before this run has even +# opened the editor. +if (Test-Path $target) { Remove-Item $target -Force } + +function Wait-ForTarget { + param([string]$Path, [int]$Seconds = 25) + + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path $Path) { + $line = (Get-Content $Path -TotalCount 1) + if ($line -and $line.Trim()) { return $line.Trim().Split(' ') } + } + Start-Sleep -Milliseconds 250 + } + return $null +} + +$labels = @('a palette frame', 'a timeline slot') +foreach ($label in $labels) { + $point = Wait-ForTarget -Path $target + if (-not $point) { + Write-Host " the engine never reported $label" + return + } + + Remove-Item $target -Force + + Send-EngineClick -Hwnd $Hwnd -X ([int]$point[0]) -Y ([int]$point[1]) + Write-Host " clicked $label at ($($point[0]),$($point[1]))" +} diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs index c2fa4f162..e21802593 100644 --- a/tests/smoke/assetAnimationTimeline.cs +++ b/tests/smoke/assetAnimationTimeline.cs @@ -211,6 +211,70 @@ function aniStepPreview2() aniCheck("an outside change reloads the timeline", $aniTimeline.getFrames() $= "30 31 32"); aniCheck("and still did not rebuild the preview", AssetAdmin.previewSprite == $aniSprite); + schedule(300, 0, "aniStepDrop"); +} + +//----------------------------------------------------------------------------- +// The drop, and the two traps in GuiDragAndDropCtrl that shape it. +// +// The callbacks are called directly with a real payload parked at real +// coordinates. That is not a shortcut around the interesting part -- it IS the +// interesting part. GuiDragAndDropCtrl hit-tests from its own parent and +// findHitControl answers "me" without testing its bounds, so the drop arrives +// here from anywhere on screen and the boundary check is the timeline's own. +//----------------------------------------------------------------------------- + +function aniMakePayload(%frame, %globalPoint) +{ + // The drop reads the cursor from the payload's middle, because the position + // it is handed is in the drag control's parent's space and useless here. + %payload = new GuiSpriteCtrl() + { + Position = "0 0"; + Extent = "40 40"; + frameIndex = %frame; + }; + ThemeManager.setProfile(%payload, "emptyProfile"); + AssetAdmin.content.add(%payload); + + %payload.setPosition(getWord(%globalPoint, 0) - 20, getWord(%globalPoint, 1) - 20); + return %payload; +} + +function aniStepDrop() +{ + %pane = $aniStage.timelinePane; + $aniStage.timelinePane.setFrames("10 11 12 13"); + + // Parked over the library, which is nowhere near the timeline. + %away = AssetAdmin.libWindow.getGlobalPosition(); + %payload = aniMakePayload(99, + (getWord(%away, 0) + 40) SPC (getWord(%away, 1) + 40)); + + %pane.onControlDropped(%payload, "0 0"); + aniCheck("a drop outside the timeline changes nothing", + $aniTimeline.getFrames() $= "10 11 12 13"); + %payload.delete(); + + // Over the left half of slot 2, which is before it. + %slotRect = $aniTimeline.getSlotRect(2); + %point = (getWord(%slotRect, 0) + 2) SPC + (getWord(%slotRect, 1) + (getWord(%slotRect, 3) / 2)); + + %payload = aniMakePayload(99, %point); + %pane.onControlDropped(%payload, "0 0"); + aniCheck("a drop on a slot's left half goes in before it (" @ $aniTimeline.getFrames() @ ")", + $aniTimeline.getFrames() $= "10 11 99 12 13"); + %payload.delete(); + + // And the caret agrees with where the drop landed, which is the promise the + // user was shown. + %payload = aniMakePayload(98, %point); + aniCheck("the caret shows the slot the drop would use", + $aniTimeline.showCaretAt(%payload.getGlobalPosition()) >= 0); + %pane.onControlDragExit(%payload, "0 0"); + %payload.delete(); + schedule(300, 0, "aniStepTransport"); } From a2a22e99984a2c96d93cf977459ae2b435d68d2e Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Mon, 10 Aug 2026 19:21:38 -0400 Subject: [PATCH 16/26] Eight things wrong with the animation editor Feedback from using it, and one of them turned out to be a real bug rather than a rough edge. The palette had no scrollbar, so the only way to reach a frame near the bottom was to shrink the cells until they all fitted. Two causes. The wheel handler zoomed instead of scrolling, and by taking the event it stopped the wheel ever reaching the scroller -- a wheel over a scrolling list of pictures means scroll, so that handler is gone and cell size stays a field a pane sets. And the vertical bar was dynamic, which has to be decided from the strip's height during the very layout pass in which the strip is working that height out; a sheet worth opening the palette for has more frames than fit, so it is simply always on. The timeline drew a yellow bar and a blue box out of hard-coded colors, which ignored the theme. Every color now comes off the profile: HighlightState fill for hover, SelectedState for the picked cell and for the run joining a held frame, DisabledState for a cell that dragging further would discard, and the selected and highlight FONT colors for the playhead and the caret -- inks rather than fills, so they stay legible on the cell they sit on. Backgrounds moved to behind the art rather than over it, which they had to: a theme's fills are opaque, and a hover painted on top hid the frame the pointer was hovering over. Play and Stop are two buttons with one hidden rather than one toggle. A toggle says "this setting is on"; these say "here is what pressing me will do", which is what a transport means -- and it is why the button could not get stuck. Play is half again the size of the rest, and the order now reads rewind, play, gap, then the three that are settings. The toggles drew their icons at 16 against the push buttons' 20, so EditorToggleIcon takes an iconSize now, defaulted to what every existing caller already gets. The Stop button stayed showing after anything other than the Stop button halted the preview -- clicking a slot to scrub, dragging a frame off the timeline, or a one-shot animation reaching its end. Every one of those goes through the stage, so the stage tells the bar. And the two captions used labelProfile, which is meant for text on the window background: near-black on dark blue under Lab Coat. panelProfile is what the Asset Inspector's own title bar wears and its font is the theme's color5. The bug the tests caught while fixing the rest: with keep-frame-rate on, a commit writes the asset TWICE, and the remembered playhead was being cleared between the two. The second refresh then fell back to the strip's cached marker -- a value onPreRender updates once a frame, so mid-script it is whatever the last drawn frame said -- and scrubbed to it, undoing the restore the first refresh had just made. The playhead went back to zero after every edit. One slot now covers both writes, and the fallback is gone: a refresh this editor did not cause has nothing to restore and should leave the preview alone. 73 checks in the timeline suite now. All 51 smoke suites and 240 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationPalettePane.cs | 13 ++- .../Animation/AssetAnimationStage.cs | 60 ++++++++---- .../Animation/AssetAnimationTimelinePane.cs | 4 +- .../Animation/AssetAnimationTransportBar.cs | 98 ++++++++++++++++--- .../Animation/GuiEditFramePaletteCtrl.cs | 9 -- .../Animation/GuiEditFrameTimelineCtrl.cs | 5 - editor/EditorCore/EditorToggleIcon.cs | 10 +- .../gui/editor/guiEditFrameStripCtrl.cc | 79 ++++++--------- .../source/gui/editor/guiEditFrameStripCtrl.h | 15 +-- .../gui/editor/guiEditFrameTimelineCtrl.cc | 73 +++++++------- .../gui/editor/guiEditFrameTimelineCtrl.h | 8 +- tests/shots/assetAnimation.cs | 7 ++ tests/smoke/assetAnimationTimeline.cs | 30 +++++- 13 files changed, 258 insertions(+), 153 deletions(-) diff --git a/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs index 171b27af4..a155ac24d 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs @@ -42,7 +42,11 @@ Extent = "100" SPC $AssetAnimationPalettePane::captionHeight; Text = "Frames"; }; - ThemeManager.setProfile(%this.caption, "labelProfile"); + // panelProfile, which is what the Asset Inspector's own title bar wears: its + // font is the theme's color5, legible on the panel fill behind it. labelProfile + // is meant for a caption on the window background and comes out near-black on + // dark blue under Lab Coat. + ThemeManager.setProfile(%this.caption, "panelProfile"); %this.add(%this.caption); // "height", not "fill": fill means "be the whole of the parent", which here @@ -55,7 +59,12 @@ Position = "0" SPC $AssetAnimationPalettePane::captionHeight; Extent = "100 80"; hScrollBar = "alwaysOff"; - vScrollBar = "dynamic"; + + // Always on, not dynamic. A sheet worth opening the palette for has more + // frames than fit, so the bar is all but permanent anyway -- and a + // dynamic bar has to be decided from the strip's height, which the strip + // works out during the very layout pass that would have to notice it. + vScrollBar = "alwaysOn"; constantThumbHeight = false; scrollBarThickness = 14; showArrowButtons = false; diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index 551fe6de5..590fbcbfc 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -374,6 +374,22 @@ class = "AssetAnimationPalettePane"; %this.playing = false; %this.previewSprite.pauseAnimation(true); + %this.refreshTransport(); +} + +// Every path that changes the playing state ends here, and there are more of +// them than the two buttons: clicking a slot stops in order to scrub, dragging a +// frame off the timeline stops, and a one-shot animation stops itself by reaching +// the end. Each of those left a Stop button showing over a stopped preview until +// the bar was told. +function AssetAnimationStage::refreshTransport(%this) +{ + if(!%this.built || !isObject(%this.admin.transportBar)) + { + return; + } + + %this.admin.transportBar.refresh(); } function AssetAnimationStage::play(%this) @@ -385,14 +401,16 @@ class = "AssetAnimationPalettePane"; %this.playing = true; %this.previewSprite.pauseAnimation(false); + %this.refreshTransport(); } // A one-shot animation reached its end on its own. Nothing to do to the preview -// -- the engine has already parked it on the last frame -- but the editor's idea -// of "playing" is now wrong, and a transport bar reading from it would be too. +// -- the engine has already parked it on the last frame -- but the button still +// says Stop, over something that has already stopped. function AssetAnimationStage::onPreviewFinished(%this) { %this.playing = false; + %this.refreshTransport(); } //----------------------------------------------------------------------------- @@ -456,24 +474,25 @@ class = "AssetAnimationPalettePane"; return; } - // The slot the commit put aside, or -- for a change raised from somewhere - // other than this editor -- the last one the marker was drawn on. - %slot = %this.resumeSlot; - if(%slot < 0) - { - %slot = %this.timelinePane.strip.getPlayheadSlot(); - } - %this.armPreview(); %this.previewSprite.pauseAnimation(!%this.playing); - // Restored by SLOT, not by image frame. A slot's meaning shifts when - // something is inserted before it, so the preview can appear to skip -- but - // tracking the image frame instead breaks the moment a frame appears twice, - // which is exactly what a hold is. - if(%slot >= 0) + // Only when a commit of ours put a slot aside. One write raises more than one + // refresh -- the asset manager fans them out as it re-reads the file it just + // wrote -- and the later ones arrive with nothing remembered. + // + // The obvious fallback, asking the strip where its marker was drawn, is a + // trap: that marker is a cached value updated once a frame in onPreRender, so + // mid-script it is whatever the last rendered frame said. Restoring from it + // undid the correct restore the first refresh had just made, which is how the + // playhead ended up back at zero after every edit. + // + // And by SLOT, not by image frame: a slot's meaning shifts when something is + // inserted before it, so the preview can appear to skip -- but tracking the + // image frame breaks the moment a frame appears twice, which is what a hold is. + if(%this.resumeSlot >= 0) { - %this.scrubTo(%slot); + %this.scrubTo(%this.resumeSlot); } } @@ -522,9 +541,14 @@ class = "AssetAnimationPalettePane"; %this.animationAsset.setAnimationFrames(%frames); %this.committing = false; - %this.resumeSlot = -1; - + // Before the slot is forgotten, because this writes the asset a second time + // and every write restarts playback. Cleared only once BOTH are done, so the + // one remembered slot covers the pair -- forgetting it in between left the + // second refresh with nothing to restore, and the preview back at frame zero + // after every edit. %this.keepFrameRate(%before); + + %this.resumeSlot = -1; } // Hold the per-frame rate steady across an edit, when the user has asked for it. diff --git a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs index 9436c8298..3ec4da755 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs @@ -44,7 +44,9 @@ Extent = "100" SPC $AssetAnimationTimelinePane::captionHeight; Text = "Timeline"; }; - ThemeManager.setProfile(%this.caption, "labelProfile"); + // panelProfile for its color5 font, as the palette's caption is and the Asset + // Inspector's title bar is. See the note there. + ThemeManager.setProfile(%this.caption, "panelProfile"); %this.add(%this.caption); // "height", not "fill": fill would put the scroller over the caption. diff --git a/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs index e9313b689..cc55e920c 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs @@ -30,21 +30,43 @@ // takes no room from the art. // // Not built from EditorButtonBar: that makes EditorIconButtons, which are -// momentary. Three of these five have to SHOW a state, which is what -// EditorToggleIcon exists for, so the row is assembled by hand. +// momentary, and two of these have to SHOW a state. +// +// Play and Stop are two buttons with one hidden rather than one toggle, and the +// difference is not cosmetic. A toggle says "this setting is on"; these two say +// "here is what will happen if you press me", which is a different promise and +// the one a transport makes. It also means the button cannot get stuck showing +// Stop after something else halted the preview -- there is no state to fall out +// of step, only whichever button is currently on show. +// +// Order reads left to right as rewind, then the big play, then a gap, then the +// three that are settings rather than actions. //----------------------------------------------------------------------------- $AssetAnimationTransportBar::buttonSize = 24; +$AssetAnimationTransportBar::playSize = 36; +$AssetAnimationTransportBar::iconSize = 20; $AssetAnimationTransportBar::spacing = 4; +$AssetAnimationTransportBar::gap = 16; function AssetAnimationTransportBar::onAdd(%this) { ThemeManager.setProfile(%this, "emptyProfile"); - %this.playButton = %this.addToggle("Play", $EditorIcon::playback_stop, $EditorIcon::playback_play, - "Stop the preview", "Play the preview"); + %this.addButton("rewind", $EditorIcon::playback_rew, "Back to the first frame", + $AssetAnimationTransportBar::buttonSize); - %this.addButton("rewind", $EditorIcon::playback_rew, "Back to the first frame"); + // The one you reach for, so it is half again the size of the rest. They sit + // in the same place, and exactly one of them is ever visible. + %this.playButton = %this.addButton("play", $EditorIcon::playback_play, "Play the preview", + $AssetAnimationTransportBar::playSize); + %this.stopButton = %this.addButton("stop", $EditorIcon::playback_stop, "Stop the preview", + $AssetAnimationTransportBar::playSize); + %this.stopButton.setVisible(false); + + // A chain lays out what it can see, so an empty control is how a gap is + // spelled -- there is no spacing-before on a child. + %this.addSpacer($AssetAnimationTransportBar::gap); %this.loopButton = %this.addToggle("Loop", $EditorIcon::playback_reload, $EditorIcon::playback_reload, "Looping. Click to play once and stop on the last frame.", @@ -54,7 +76,8 @@ "Keeping the frame rate: adding or removing frames rewrites the animation's time to match.", "Keeping the animation's time: adding a frame makes every frame play faster."); - %this.addButton("openRangeDialog", $EditorIcon::list_num, "Fill the timeline from a range of frames"); + %this.addButton("openRangeDialog", $EditorIcon::list_num, "Fill the timeline from a range of frames", + $AssetAnimationTransportBar::buttonSize); } function AssetAnimationTransportBar::addToggle(%this, %name, %frameOn, %frameOff, %tipOn, %tipOff) @@ -66,6 +89,12 @@ class = "EditorToggleIcon"; Position = "0 0"; Extent = %size SPC %size; + + // Matching EditorIconButton, which draws its picture at 20 in the same + // 24 pixel button. At the toggle's own default of 16 the row read as two + // sizes of button rather than one. + iconSize = $AssetAnimationTransportBar::iconSize; + frameOn = %frameOn; frameOff = %frameOff; tipOn = %tipOn; @@ -80,15 +109,12 @@ class = "EditorToggleIcon"; return %button; } -function AssetAnimationTransportBar::addButton(%this, %method, %frame, %tooltip) +function AssetAnimationTransportBar::addButton(%this, %method, %frame, %tooltip, %size) { - %size = $AssetAnimationTransportBar::buttonSize; - %button = new GuiButtonCtrl() { class = "EditorIconButton"; Position = "0 0"; - Extent = %size SPC %size; Frame = %frame; Command = %this.getId() @ "." @ %method @ "();"; Tooltip = %tooltip; @@ -97,9 +123,32 @@ class = "EditorIconButton"; ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); %this.add(%button); + // After the add, because EditorIconButton::onAdd sets its own 24 x 24 and + // would undo anything the new{} block said. The icon inside it is sized + // against the button, so both grow together. + if(%size !$= "" && %size != $AssetAnimationTransportBar::buttonSize) + { + %button.setExtent(%size, %size); + %button.icon.setExtent(%size - 4, %size - 4); + } + return %button; } +function AssetAnimationTransportBar::addSpacer(%this, %width) +{ + %spacer = new GuiControl() + { + Position = "0 0"; + Extent = %width SPC $AssetAnimationTransportBar::buttonSize; + UseInput = false; + }; + ThemeManager.setProfile(%spacer, "emptyProfile"); + %this.add(%spacer); + + return %spacer; +} + //----------------------------------------------------------------------------- // One handler, switching on which toggle spoke. //----------------------------------------------------------------------------- @@ -108,10 +157,6 @@ class = "EditorIconButton"; { switch$(%button.toggleName) { - case "Play": - if(%button.getValue()) { %this.stage.play(); } - else { %this.stage.stop(); } - case "Loop": %this.stage.setCycle(%button.getValue()); @@ -123,6 +168,16 @@ class = "EditorIconButton"; } } +function AssetAnimationTransportBar::play(%this) +{ + %this.stage.play(); +} + +function AssetAnimationTransportBar::stop(%this) +{ + %this.stage.stop(); +} + function AssetAnimationTransportBar::rewind(%this) { // The playing state is left alone on purpose. Rewinding while it plays @@ -139,14 +194,27 @@ class = "EditorIconButton"; // Reading the state back out. Called whenever something else may have moved it. //----------------------------------------------------------------------------- +// Called from every path that can change the playing state, and there are more +// of them than the two buttons: clicking a slot stops to scrub, dragging a frame +// out stops, and a one-shot animation stops itself by reaching the end. Each of +// those used to leave a Stop button on show over a preview that had stopped. function AssetAnimationTransportBar::refresh(%this) { + %playing = %this.stage.playing; + %this.playButton.setVisible(!%playing); + %this.stopButton.setVisible(%playing); + + // A chain lays out only the children it can see, and nothing re-lays it out + // when one is hidden -- so swapping the two is a resize away from leaving a + // hole where the other one was. + %this.resize(getWord(%this.getPosition(), 0), getWord(%this.getPosition(), 1), + getWord(%this.getExtent(), 0), getWord(%this.getExtent(), 1)); + if(!isObject(%this.stage.animationAsset)) { return; } - %this.playButton.setValue(%this.stage.playing); %this.loopButton.setValue(%this.stage.animationAsset.getAnimationCycle()); %this.rateButton.setValue(EditorPreferences.get("assetAnimationKeepFrameRate", false)); } diff --git a/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs b/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs index 66f0e336e..cf6f87e51 100644 --- a/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs +++ b/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs @@ -133,12 +133,3 @@ return %payload; } -//----------------------------------------------------------------------------- -// Wheel zoom. Remembered, because a person who wants big thumbnails wants them -// every time they open the editor, not once. -//----------------------------------------------------------------------------- - -function GuiEditFramePaletteCtrl::onCellSizeChanged(%this, %cellSize) -{ - EditorPreferences.set("assetAnimationPaletteCellSize", %cellSize); -} diff --git a/editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs b/editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs index d0467d67e..675806d74 100644 --- a/editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs +++ b/editor/AssetAdmin/Animation/GuiEditFrameTimelineCtrl.cs @@ -51,8 +51,3 @@ %this.pane.stage.onSlotSelected(%slot, %frame); } - -function GuiEditFrameTimelineCtrl::onCellSizeChanged(%this, %cellSize) -{ - EditorPreferences.set("assetAnimationTimelineCellSize", %cellSize); -} diff --git a/editor/EditorCore/EditorToggleIcon.cs b/editor/EditorCore/EditorToggleIcon.cs index ed25f0ea6..93bf45ecb 100644 --- a/editor/EditorCore/EditorToggleIcon.cs +++ b/editor/EditorCore/EditorToggleIcon.cs @@ -48,12 +48,18 @@ %this.textOffset = "0 0"; %this.textExtent = "0 0"; + // 16 is the size the segmented rows and the header panes have always drawn at. + // A creator sitting a toggle beside an EditorIconButton wants 20, which is + // what that one uses -- same 24 x 24 button, a visibly bigger picture on it, + // and a row of the two together looked mismatched until this could be said. + %iconSize = (%this.iconSize $= "") ? 16 : %this.iconSize; + %this.icon = new GuiSpriteCtrl() { HorizSizing = "center"; VertSizing = "center"; - Extent = "16 16"; - MinExtent = "16 16"; + Extent = %iconSize SPC %iconSize; + MinExtent = %iconSize SPC %iconSize; Position = "0 0"; Image = "EditorCore:EditorIcons16"; ImageSize = "16 16"; diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl.cc b/engine/source/gui/editor/guiEditFrameStripCtrl.cc index 26c24ddb2..1f5d4fae3 100644 --- a/engine/source/gui/editor/guiEditFrameStripCtrl.cc +++ b/engine/source/gui/editor/guiEditFrameStripCtrl.cc @@ -36,10 +36,6 @@ GuiEditFrameStripCtrl::GuiEditFrameStripCtrl() mCellSize = smDefaultCellSize; mCellPad = smDefaultCellPad; mShowFrameNumbers = true; - // Spelled out because ColorI's constructor leaves its components - // uninitialised, so a field nobody sets is whatever was on the stack. - mNumberColor.set(255, 255, 255, 160); - mHoverColor.set(255, 255, 255, 90); mHoverCell = -1; mActive = true; } @@ -152,10 +148,6 @@ void GuiEditFrameStripCtrl::initPersistFields() "The gap between two cells."); addField("ShowFrameNumbers", TypeBool, Offset(mShowFrameNumbers, GuiEditFrameStripCtrl), "Whether each cell is labelled with the image frame it is showing."); - addField("NumberColor", TypeColorI, Offset(mNumberColor, GuiEditFrameStripCtrl), - "The ink those labels are drawn in."); - addField("HoverColor", TypeColorI, Offset(mHoverColor, GuiEditFrameStripCtrl), - "The wash over the cell under the pointer."); } //----------------------------------------------------------------------------- @@ -331,21 +323,35 @@ void GuiEditFrameStripCtrl::onRender(Point2I offset, const RectI& updateRect) renderChildControls(offset, contentRect, updateRect); } +bool GuiEditFrameStripCtrl::getCellBackColor(S32 index, bool isHovered, ColorI& color) +{ + if (!isHovered) + { + return false; + } + + color = mProfile->getFillColor(HighlightState); + return true; +} + void GuiEditFrameStripCtrl::renderCell(S32 index, const RectI& cellRect, bool isHovered) { const S32 frame = getFrameAt(index); + // The background first, so an opaque theme color sits behind the art rather + // than over it. + ColorI backColor; + if (getCellBackColor(index, isHovered, backColor)) + { + dglDrawRectFill(cellRect, backColor); + } + // White is untinted. Set every cell rather than once for the loop, because a // subclass drawing its own chrome between cells will have changed it. dglSetBitmapModulation(ColorF(1.0f, 1.0f, 1.0f, 1.0f)); renderImageAssetFrame(cellRect, mImageAsset, (U32)frame); dglClearBitmapModulation(); - if (isHovered) - { - dglDrawRectFill(cellRect, mHoverColor); - } - if (!mShowFrameNumbers) { return; @@ -373,7 +379,7 @@ void GuiEditFrameStripCtrl::renderCell(S32 index, const RectI& cellRect, bool is const Point2I textPoint(cellRect.point.x + ((cellRect.extent.x - textWidth) / 2), (cellRect.point.y + cellRect.extent.y) - textHeight); - dglSetBitmapModulation(mNumberColor); + dglSetBitmapModulation(mProfile->getFontColor(isHovered ? HighlightState : NormalState)); dglDrawText(font, textPoint, (const UTF8*)buffer); dglClearBitmapModulation(); } @@ -433,40 +439,11 @@ void GuiEditFrameStripCtrl::onTouchLeave(const GuiEvent& event) Parent::onTouchLeave(event); } -void GuiEditFrameStripCtrl::onMouseWheelUp(const GuiEvent& event) -{ - // A magnifier over the art, which is what a wheel over a grid of pictures - // should do. The scroller only gets the wheel when this declines it, which is - // why the step reports back: at either end there is nothing to zoom and the - // gesture should go back to scrolling. - const S32 before = mCellSize; - setCellSize(mCellSize + 8); - - if (mCellSize == before) - { - Parent::onMouseWheelUp(event); - return; - } - - if (isMethod("onCellSizeChanged")) - { - Con::executef(this, 2, "onCellSizeChanged", Con::getIntArg(mCellSize)); - } -} - -void GuiEditFrameStripCtrl::onMouseWheelDown(const GuiEvent& event) -{ - const S32 before = mCellSize; - setCellSize(mCellSize - 8); - - if (mCellSize == before) - { - Parent::onMouseWheelDown(event); - return; - } - - if (isMethod("onCellSizeChanged")) - { - Con::executef(this, 2, "onCellSizeChanged", Con::getIntArg(mCellSize)); - } -} +// Deliberately no mouse wheel handler. +// +// It used to zoom the cells, and that was wrong twice over: a wheel over a +// scrolling list of pictures means scroll, and taking the event meant the wheel +// never reached the scroller -- so shrinking the cells until they all fitted was +// the only way to reach the frames at the bottom. Left unhandled, the event +// bubbles to the scroller and does what everyone expects. Cell size is still a +// field a pane can set. diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl.h b/engine/source/gui/editor/guiEditFrameStripCtrl.h index d096977f5..6f26325d4 100644 --- a/engine/source/gui/editor/guiEditFrameStripCtrl.h +++ b/engine/source/gui/editor/guiEditFrameStripCtrl.h @@ -79,8 +79,6 @@ class GuiEditFrameStripCtrl : public GuiControl S32 mCellSize; ///< The square one frame draws in. S32 mCellPad; ///< The gap between two cells, and nothing else. bool mShowFrameNumbers; ///< Whether each cell is labelled with the image frame it shows. - ColorI mNumberColor; - ColorI mHoverColor; S32 mHoverCell; ///< -1 when the pointer is off the cells or outside. /// Lay the control out to fit its cells and tell the scroller. @@ -110,8 +108,15 @@ class GuiEditFrameStripCtrl : public GuiControl /// under the pointer or make a click land one cell over. RectI getContentRect(const Point2I& offset); - /// One cell. The base draws the frame and, if asked, its number; a subclass - /// overrides to put a selection or a playhead on top of that. + /// What to paint behind a cell's art, if anything. + /// + /// Behind rather than over, which matters: a theme's fill colors are opaque, + /// so a hover drawn on top of the frame would hide the very thing the pointer + /// is hovering over. Returns false to leave the cell's background alone. + virtual bool getCellBackColor(S32 index, bool isHovered, ColorI& color); + + /// One cell: the background, the frame, and its number. A subclass overrides + /// to put a selection or a playhead on top of that. virtual void renderCell(S32 index, const RectI& cellRect, bool isHovered); /// Anything drawn over the whole grid rather than per cell -- an insertion @@ -168,8 +173,6 @@ class GuiEditFrameStripCtrl : public GuiControl void onTouchMove(const GuiEvent& event); void onTouchLeave(const GuiEvent& event); - void onMouseWheelUp(const GuiEvent& event); - void onMouseWheelDown(const GuiEvent& event); /// How many cells there are. The palette derives it from the image, the /// timeline from its own list; the base has none, so it draws nothing. diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc index 61b790bea..ab534db13 100644 --- a/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc @@ -40,31 +40,24 @@ GuiEditFrameTimelineCtrl::GuiEditFrameTimelineCtrl() mDragFrom = -1; mPressAt.set(0, 0); mDragOutside = false; - - // Spelled out because ColorI's constructor leaves its components - // uninitialised. - mSelectColor.set(120, 190, 255, 255); - mPlayheadColor.set(255, 210, 90, 255); - mCaretColor.set(255, 255, 255, 230); - mHoldColor.set(255, 255, 255, 60); - mRemoveColor.set(255, 90, 90, 110); } -void GuiEditFrameTimelineCtrl::initPersistFields() -{ - Parent::initPersistFields(); - - addField("SelectColor", TypeColorI, Offset(mSelectColor, GuiEditFrameTimelineCtrl), - "The outline around the picked slot."); - addField("PlayheadColor", TypeColorI, Offset(mPlayheadColor, GuiEditFrameTimelineCtrl), - "The bar over the slot the preview is showing."); - addField("CaretColor", TypeColorI, Offset(mCaretColor, GuiEditFrameTimelineCtrl), - "The insertion mark shown while a frame is being dragged in."); - addField("HoldColor", TypeColorI, Offset(mHoldColor, GuiEditFrameTimelineCtrl), - "The join drawn between repeats of one frame, which is how a hold reads."); - addField("RemoveColor", TypeColorI, Offset(mRemoveColor, GuiEditFrameTimelineCtrl), - "The wash over a slot that would be removed if the drag were released here."); -} +//----------------------------------------------------------------------------- +// Every color here comes off the profile, so the timeline follows the editor's +// theme like everything else. A profile offers four fills and four font colors, +// and this is what each is used for: +// +// fill HighlightState the cell under the pointer +// fill SelectedState the picked cell, and the run joining a held frame +// fill DisabledState a cell that dragging further would throw away +// font SelectedState the playhead bar -- an ink rather than a fill, so +// it stays legible against the selected cell it is +// frequently sitting on top of +// font HighlightState the insertion caret +// +// The strip wears listBoxProfile, which has all of them set for exactly this +// kind of use. +//----------------------------------------------------------------------------- //----------------------------------------------------------------------------- // The two statics this class adds. Both take everything they use, so the caret @@ -389,30 +382,38 @@ void GuiEditFrameTimelineCtrl::onPreRender() //----------------------------------------------------------------------------- -void GuiEditFrameTimelineCtrl::renderCell(S32 index, const RectI& cellRect, bool isHovered) +bool GuiEditFrameTimelineCtrl::getCellBackColor(S32 index, bool isHovered, ColorI& color) { - Parent::renderCell(index, cellRect, isHovered); - - // The slot being carried draws faded in the place it came from, so the list - // still reads as continuous while it is being rearranged. + // The slot being carried shows where it would end up: greyed while it is + // still over the strip, and in the disabled fill once dragging further would + // throw it away. if (mDragging && index == mDragFrom) { - dglDrawRectFill(cellRect, mDragOutside ? mRemoveColor : mHoldColor); + color = mProfile->getFillColor(mDragOutside ? DisabledState : HighlightState); + return true; } - // Selected and playing are drawn differently on purpose -- an outline and a - // bar -- because they are frequently the same cell and the user needs to see - // both. Scrubbing sets one and reads the other. if (index == mSelected) { - dglDrawRect(cellRect, mSelectColor); + color = mProfile->getFillColor(SelectedState); + return true; } + return Parent::getCellBackColor(index, isHovered, color); +} + +void GuiEditFrameTimelineCtrl::renderCell(S32 index, const RectI& cellRect, bool isHovered) +{ + Parent::renderCell(index, cellRect, isHovered); + + // Selected and playing are drawn differently on purpose -- a background and a + // bar -- because they are frequently the same cell and the user needs to see + // both. Scrubbing sets one and reads the other. if (index == mPlayhead) { RectI marker = cellRect; marker.extent.y = 3; - dglDrawRectFill(marker, mPlayheadColor); + dglDrawRectFill(marker, mProfile->getFontColor(SelectedState)); } } @@ -442,7 +443,7 @@ void GuiEditFrameTimelineCtrl::renderOverlay(const RectI& contentRect) RectI join(left, contentRect.point.y + previous.point.y + (previous.extent.y / 3), right - left, getMax(1, previous.extent.y / 3)); - dglDrawRectFill(join, mHoldColor); + dglDrawRectFill(join, mProfile->getFillColor(SelectedState)); } if (mCaret < 0) @@ -452,7 +453,7 @@ void GuiEditFrameTimelineCtrl::renderOverlay(const RectI& contentRect) RectI caret = getCaretRect(mCaret, mSlots.size(), mCellSize, mCellPad, mCellSize); caret.point += contentRect.point; - dglDrawRectFill(caret, mCaretColor); + dglDrawRectFill(caret, mProfile->getFontColor(HighlightState)); } //----------------------------------------------------------------------------- diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl.h b/engine/source/gui/editor/guiEditFrameTimelineCtrl.h index a8f303136..fd115f99f 100644 --- a/engine/source/gui/editor/guiEditFrameTimelineCtrl.h +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl.h @@ -75,15 +75,10 @@ class GuiEditFrameTimelineCtrl : public GuiEditFrameStripCtrl Point2I mPressAt; bool mDragOutside; ///< The pointer has left, so releasing removes rather than moves. - ColorI mSelectColor; - ColorI mPlayheadColor; - ColorI mCaretColor; - ColorI mHoldColor; - ColorI mRemoveColor; - /// What the preview is showing, or -1 when there is nothing to ask. S32 readPlayhead(); + bool getCellBackColor(S32 index, bool isHovered, ColorI& color); void renderCell(S32 index, const RectI& cellRect, bool isHovered); void renderOverlay(const RectI& contentRect); @@ -118,7 +113,6 @@ class GuiEditFrameTimelineCtrl : public GuiEditFrameStripCtrl static RectI getCaretRect(S32 insertIndex, S32 cellCount, S32 cellSize, S32 cellPad, S32 height); GuiEditFrameTimelineCtrl(); - static void initPersistFields(); S32 getCellCount() const { return mSlots.size(); } S32 getFrameAt(S32 index) const; diff --git a/tests/shots/assetAnimation.cs b/tests/shots/assetAnimation.cs index 27b14064c..d1ec8dce5 100644 --- a/tests/shots/assetAnimation.cs +++ b/tests/shots/assetAnimation.cs @@ -88,6 +88,13 @@ function aaOpeningShot() function aaHoldShot() { $aaStage.timelinePane.setFrames("55 56 56 56 57 58 59"); + + // A picked slot and a playhead somewhere else, so the shot shows both at once + // -- they are frequently the same cell and have to stay tellable apart. + $aaStage.stop(); + $aaStage.scrubTo(5); + $aaStage.timelinePane.strip.setSelectedSlot(1); + schedule(500, 0, "aaGrabHold"); } diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs index e21802593..6ebdfc095 100644 --- a/tests/smoke/assetAnimationTimeline.cs +++ b/tests/smoke/assetAnimationTimeline.cs @@ -58,6 +58,11 @@ function aniStep1() aniCheck("fixture asset module registered", aniLoadFixtureAssets()); + // Spelled out rather than inherited. The preferences file lives in shots/ and + // survives between runs, so a suite that left this on would change what every + // commit below does to the animation's time. + EditorPreferences.set("assetAnimationKeepFrameRate", false); + EditorCore.tabBook.selectPage(2); schedule(700, 0, "aniStep2"); @@ -202,7 +207,8 @@ function aniStepPreview2() aniCheck("the edit landed", $aniTimeline.getCellCount() == 9); aniCheck("the preview sprite was NOT rebuilt", AssetAdmin.previewSprite == $aniSprite); - aniCheck("the playhead did not jump back to the start", $aniSprite.getAnimationFrame() == 5); + aniCheck("the playhead did not jump back to the start (" @ $aniSprite.getAnimationFrame() @ + " of " @ $aniTimeline.getCellCount() @ ")", $aniSprite.getAnimationFrame() == 5); aniCheck("the preview is still paused", !$aniStage.playing); // And a refresh raised from somewhere else entirely still reaches the strip. @@ -290,11 +296,33 @@ function aniStepTransport() $aniStage.timelinePane.setFrames("40 41 42 43 44 45"); + // Play and Stop are two buttons with one hidden, not one toggle, so "which is + // on show" is the only state there is and it cannot fall out of step. + aniCheck("it offers Play while stopped", %bar.playButton.isVisible()); + aniCheck("and not Stop", !%bar.stopButton.isVisible()); + $aniStage.play(); aniCheck("play starts it", $aniStage.playing); + aniCheck("and the button becomes Stop", %bar.stopButton.isVisible()); + aniCheck("with Play hidden", !%bar.playButton.isVisible()); $aniStage.stop(); aniCheck("stop halts it", !$aniStage.playing); + aniCheck("and the button goes back to Play", %bar.playButton.isVisible()); + + // The button has to follow every path that stops the preview, not just the + // one that is a button. Clicking a slot stops in order to scrub, and used to + // leave Stop showing over something that had stopped. + $aniStage.play(); + $aniStage.onSlotSelected(2, $aniTimeline.getFrameAt(2)); + aniCheck("clicking a slot stops the preview", !$aniStage.playing); + aniCheck("and the button followed it", %bar.playButton.isVisible()); + + // So does a one-shot animation reaching its end on its own. + $aniStage.play(); + $aniStage.onPreviewFinished(); + aniCheck("an animation finishing stops it", !$aniStage.playing); + aniCheck("and the button followed that too", %bar.playButton.isVisible()); $aniStage.scrubTo(3); aniCheck("scrubbing moves the preview", $aniSprite.getAnimationFrame() == 3); From d3879d84b21ab50897c5b0fbc6c689a44a757aec Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 11 Aug 2026 00:40:49 -0400 Subject: [PATCH 17/26] An icon button's picture and the sprite holding it are two different sizes Both widgets hard-coded their numbers, and a caller wanting a bigger button had no way to ask. Setting the sizes afterwards did not work either: EditorIconButton forces its own extent in onAdd, and its hover handlers animate the icon to numbers of their own, so anything a caller set survived exactly until the pointer first crossed it. They take buttonSize and iconSize now, defaulted to what every existing caller already gets. What took three attempts to get right is what those two names mean. GuiSpriteCtrl::growTo animates mImageSize -- the PICTURE -- and leaves the sprite control alone. So there are three numbers, not two: the button, the sprite holding the picture, and the picture itself. Conflating the last two is what made a 36 pixel button animate its icon from 32 down to 28 on first hover, which looked like the icon exploding and never recovering. iconSize is therefore the picture. The sprite holding it is deliberately larger, because a sprite clamps its picture to its own content rect -- a holder the same size as the artwork loses a pixel or two of it to the profile's insets, and the symptom is subtle and awful: the icon comes up small, the hover appears to grow it, and it stays grown. The slack is what stops that, and there is a comment telling the next person not to tidy it away. The defaults reproduce the original numbers exactly: a 24 button, a 20 sprite, a 16 picture that goes to 18 under the pointer. EditorToggleIcon gets the same arrangement and the same meaning for iconSize -- the two are frequently sat next to each other and had no reason to disagree about what a size is. headerPane, toggleTip, profileForm, menuBar and the rest of the suites that drive these two are unchanged and green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- editor/EditorCore/EditorIconButton.cs | 49 +++++++++++++++++++++++---- editor/EditorCore/EditorToggleIcon.cs | 16 +++++---- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/editor/EditorCore/EditorIconButton.cs b/editor/EditorCore/EditorIconButton.cs index 0cebcac94..96534fa34 100644 --- a/editor/EditorCore/EditorIconButton.cs +++ b/editor/EditorCore/EditorIconButton.cs @@ -1,19 +1,54 @@ +// The button, and the picture on it, are both sizeable now. +// +// They have to be said as fields rather than set afterwards, because this +// forces its own extent here and the hover handlers below animate the icon to +// numbers of their own -- so anything a caller set was undone by onAdd, and then +// undone again by the first hover. +// +// iconSize is the size of the PICTURE, not of the sprite control holding it, and +// keeping those two apart is the whole trick here. +// +// GuiSpriteCtrl::growTo animates mImageSize -- what is drawn -- and leaves the +// control alone. The sprite then clamps its picture to its own content rect, so +// the control has to be bigger than the biggest the picture will ever grow to or +// the hover is clipped. Conflating the two gave a 36 pixel button a picture that +// animated 32 down to 28: it looked like the icon exploded on hover and never +// went back. +// +// Defaults reproduce the numbers this button has always used: a 24 button, a 20 +// sprite, a 16 picture that goes to 18 under the pointer. +$EditorIconButton::defaultButtonSize = 24; +$EditorIconButton::defaultIconSize = 16; + +// Room around the picture for it to grow into without being clamped. +$EditorIconButton::iconSlack = 4; +$EditorIconButton::hoverGrowth = 2; + function EditorIconButton::onAdd(%this) { %this.text = ""; - %this.extent = "24 24"; + + %buttonSize = (%this.buttonSize $= "") ? $EditorIconButton::defaultButtonSize : %this.buttonSize; + %this.iconSize = (%this.iconSize $= "") ? $EditorIconButton::defaultIconSize : %this.iconSize; + + %this.extent = %buttonSize SPC %buttonSize; + + // The container, sized to hold the picture at its hovered size with room to + // spare. Never equal to the picture: the sprite clamps to its content rect. + %holder = %this.iconSize + $EditorIconButton::iconSlack; + %this.icon = new GuiSpriteCtrl() { HorizSizing="center"; VertSizing="center"; - Extent = "20 20"; - minExtent = "20 20"; + Extent = %holder SPC %holder; + minExtent = %holder SPC %holder; Position = "0 0"; constrainProportions = "1"; fullSize = "0"; Image = "EditorCore:EditorIcons16"; - ImageSize = "16 16"; + ImageSize = %this.iconSize SPC %this.iconSize; ImageColor = ThemeManager.activeTheme.iconButtonProfile.FontColor; Frame = %this.frame; Tooltip = %this.Tooltip; @@ -42,7 +77,9 @@ return; } %this.icon.fadeTo(ThemeManager.activeTheme.iconButtonProfile.fontColorHL, 200, "EaseInOut"); - %this.icon.growTo("18 18", 200, "EaseInOut"); + + %hover = %this.iconSize + $EditorIconButton::hoverGrowth; + %this.icon.growTo(%hover SPC %hover, 200, "EaseInOut"); } function EditorIconButton::onTouchLeave(%this) @@ -52,7 +89,7 @@ return; } %this.icon.fadeTo(ThemeManager.activeTheme.iconButtonProfile.fontColor, 200, "EaseInOut"); - %this.icon.growTo("16 16", 200, "EaseInOut"); + %this.icon.growTo(%this.iconSize SPC %this.iconSize, 200, "EaseInOut"); } function EditorIconButton::onTouchDown(%this) diff --git a/editor/EditorCore/EditorToggleIcon.cs b/editor/EditorCore/EditorToggleIcon.cs index 93bf45ecb..b467291c0 100644 --- a/editor/EditorCore/EditorToggleIcon.cs +++ b/editor/EditorCore/EditorToggleIcon.cs @@ -48,21 +48,23 @@ %this.textOffset = "0 0"; %this.textExtent = "0 0"; - // 16 is the size the segmented rows and the header panes have always drawn at. - // A creator sitting a toggle beside an EditorIconButton wants 20, which is - // what that one uses -- same 24 x 24 button, a visibly bigger picture on it, - // and a row of the two together looked mismatched until this could be said. + // iconSize is the PICTURE, and the sprite holding it is deliberately bigger -- + // the same arrangement, and the same reason, as EditorIconButton: a sprite + // clamps its picture to its own content rect, so a control the same size as + // the artwork loses a pixel or two of it to the profile's insets. The two + // widgets are frequently sat next to each other and have to agree. %iconSize = (%this.iconSize $= "") ? 16 : %this.iconSize; + %holder = %iconSize + 4; %this.icon = new GuiSpriteCtrl() { HorizSizing = "center"; VertSizing = "center"; - Extent = %iconSize SPC %iconSize; - MinExtent = %iconSize SPC %iconSize; + Extent = %holder SPC %holder; + MinExtent = %holder SPC %holder; Position = "0 0"; Image = "EditorCore:EditorIcons16"; - ImageSize = "16 16"; + ImageSize = %iconSize SPC %iconSize; constrainProportions = "1"; fullSize = "0"; Frame = %this.frameOff; From dd9943cf654205ae618be96e33af5b912a17f570 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Tue, 11 Aug 2026 00:41:17 -0400 Subject: [PATCH 18/26] The dragged frame, the timeline's colors, and a transport that fits Four things wrong with the animation editor, and the first was the one that mattered. A frame dragged out of the palette always showed frame 0 under the cursor, and only became the right frame once it was dropped. GuiSpriteCtrl::setImage returns early when the control is not awake -- it keeps the asset id and throws the frame away -- and a drag payload is built detached, so the frame never landed. onWake then re-applies the image from the Frame FIELD, which nothing had set. Setting the fields is the fix; setImageFrame after the payload is on the canvas makes it right whichever order the waking happens in. The test reports 0 without it. The timeline was drawing hard-coded colors, so it ignored the theme. Borrowing listBoxProfile did not work either, because three of its fields mean something else there: a list row's hover is deliberately a whisper, which over a picture is no change at all, and its selected FONT color is the ink drawn ON a selected row, so the playhead bar was dark-on-dark and could not be seen. So the grids get frameGridProfile, which exists for this and says in BaseTheme what each of its six colors is for. The accent goes to the playhead -- the one thing that has to be findable while the animation runs -- and the selection is a quieter raised surface, so the two stay legible when they land on the same cell, which while scrubbing is most of the time. Backgrounds moved behind the art rather than over it, because a theme's fills are opaque and a hover painted on top hid the frame the pointer was hovering over. The transport bar was clipping its big play button, and the cause is a one-line-of-difference bug worth knowing about: a GuiChainCtrl is born VERTICAL, and its resize refuses to change whichever axis is currently the length. Extent was being set before IsVertical, so the height was rejected, the bar stayed at the constructor's mEditOpenSpace of 30, and a 36 pixel button was centred in it -- three pixels off each end. IsVertical now comes first. A chain never grows to fit a taller child, so the height is stated and commented as such. The toggles looked smaller than the push buttons at the same extent, because they are: a GuiButtonCtrl paints across its whole rect and a GuiCheckBoxCtrl paints a box that onRender clamps into the CONTENT rect, inside the borders. With a 2 pixel border all round that is 24 against 20, and no boxExtent can fix it -- the clamp will not let the box out. The toggle is built that much bigger instead, and the amount is read from the profile rather than written as 4. And choosing an animation arrived showing Play over a preview that was already running. A sprite built with an Animation on it does not wait to be started, so the playing state is read off the sprite now instead of being assumed false. The palette also lost its wheel-zoom. It consumed the event, so the wheel never reached the scroller and shrinking the cells until they all fitted was the only way to reach the frames at the bottom. 85 checks in the timeline suite. All 51 smoke suites and 240 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationPalettePane.cs | 9 ++- .../Animation/AssetAnimationStage.cs | 13 +++- .../Animation/AssetAnimationTimelinePane.cs | 12 +-- .../Animation/AssetAnimationTransportBar.cs | 53 ++++++++----- .../Animation/GuiEditFramePaletteCtrl.cs | 17 ++++- editor/AssetAdmin/AssetAdmin.cs | 26 ++++++- .../EditorCore/Themes/BaseTheme/BaseTheme.cs | 53 +++++++++++++ .../gui/editor/guiEditFrameTimelineCtrl.cc | 6 +- tests/smoke/assetAnimationTimeline.cs | 75 +++++++++++++++++++ 9 files changed, 229 insertions(+), 35 deletions(-) diff --git a/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs index a155ac24d..7a09bc9c1 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs @@ -32,7 +32,7 @@ function AssetAnimationPalettePane::onAdd(%this) { - ThemeManager.setProfile(%this, "panelProfile"); + ThemeManager.setProfile(%this, "emptyProfile"); %this.caption = new GuiControl() { @@ -67,7 +67,7 @@ vScrollBar = "alwaysOn"; constantThumbHeight = false; scrollBarThickness = 14; - showArrowButtons = false; + showArrowButtons = true; }; ThemeManager.setProfile(%this.scroller, "scrollingPanelProfile"); ThemeManager.setProfile(%this.scroller, "tinyThumbProfile", "ThumbProfile"); @@ -94,7 +94,10 @@ CellPad = 4; ShowFrameNumbers = true; }; - ThemeManager.setProfile(%this.strip, "emptyProfile"); + // The same profile the timeline wears, so hover and frame numbers look the + // same in both grids -- which matters when a frame is being dragged from one + // to the other. + ThemeManager.setProfile(%this.strip, "frameGridProfile"); %this.scroller.add(%this.strip); } diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index 590fbcbfc..ed3068d4f 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -127,13 +127,17 @@ %this.timelinePane.load(%this.imageAssetId, trim(%animationAsset.getAnimationFrames())); %this.admin.transportBarContainer.setVisible(true); - %this.admin.transportBar.refresh(); // Adopt the sprite the preview has already made. displayAnimationAsset builds // it and announces it BEFORE this runs -- the tile displays first and selects // second -- so on a first selection that announcement arrives while there is // no stage to hear it. Asking here covers both orders. + // + // Before the bar is refreshed, because adopting the sprite is what settles + // whether the animation is playing. %this.onPreviewRebuilt(%this.admin.previewSprite); + + %this.admin.transportBar.refresh(); } //----------------------------------------------------------------------------- @@ -309,6 +313,13 @@ class = "AssetAnimationPalettePane"; %this.previewSprite = %sprite; %this.timelinePane.setPreviewSprite(%sprite); + + // A sprite built with an Animation on it is ALREADY RUNNING -- nothing had to + // press play -- so the editor's idea of the playing state has to be read off + // the sprite rather than assumed. It was initialised false, so selecting an + // animation put a Play button over a preview that was busy playing. + %this.playing = !%sprite.getIsAnimationFinished(); + %this.refreshTransport(); } // Put a finished animation back in a state where it can be moved. diff --git a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs index 3ec4da755..ade5ccbda 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs @@ -34,7 +34,7 @@ function AssetAnimationTimelinePane::onAdd(%this) { - ThemeManager.setProfile(%this, "panelProfile"); + ThemeManager.setProfile(%this, "emptyProfile"); %this.caption = new GuiControl() { @@ -60,7 +60,7 @@ vScrollBar = "alwaysOff"; constantThumbHeight = false; scrollBarThickness = 14; - showArrowButtons = false; + showArrowButtons = true; }; ThemeManager.setProfile(%this.scroller, "scrollingPanelProfile"); ThemeManager.setProfile(%this.scroller, "tinyThumbProfile", "ThumbProfile"); @@ -68,9 +68,6 @@ ThemeManager.setProfile(%this.scroller, "tinyScrollArrowProfile", "ArrowProfile"); %this.add(%this.scroller); - // listBoxProfile for its canKeyFocus. Without a profile that allows focus the - // strip never becomes first responder and the Delete key never arrives, which - // looks exactly like a broken key handler. // The mirror of the palette: "fill" down the axis whose bar is alwaysOff, so // the row is as tall as the scroller, and "right" across, where the strip // sets its own width from its cells and the bar scrolls it. @@ -85,7 +82,10 @@ CellPad = 4; ShowFrameNumbers = true; }; - ThemeManager.setProfile(%this.strip, "listBoxProfile"); + // frameGridProfile, not listBoxProfile: the grids read six of its colors for + // things a list has no equivalent of, and BaseTheme documents which is which. + // It also carries the canKeyFocus the Delete key depends on. + ThemeManager.setProfile(%this.strip, "frameGridProfile"); %this.scroller.add(%this.strip); } diff --git a/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs index cc55e920c..645c920be 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs @@ -45,7 +45,6 @@ $AssetAnimationTransportBar::buttonSize = 24; $AssetAnimationTransportBar::playSize = 36; -$AssetAnimationTransportBar::iconSize = 20; $AssetAnimationTransportBar::spacing = 4; $AssetAnimationTransportBar::gap = 16; @@ -80,21 +79,37 @@ $AssetAnimationTransportBar::buttonSize); } +// How much bigger a toggle has to be than a push button to LOOK the same size. +// +// They draw differently. A GuiButtonCtrl paints its chrome across the whole +// control less its margins; a GuiCheckBoxCtrl paints a box that +// GuiCheckBoxCtrl::onRender clamps into the CONTENT rect -- inside the borders +// and padding as well. iconButtonProfile has a 2 pixel border on all four sides, +// so a 24 pixel toggle drew a 20 pixel box beside a 24 pixel button, and no +// amount of boxExtent fixed it: the clamp will not let the box out. +// +// So the toggle is built that much larger and its box comes out the right size. +// Read from the profile rather than written as 4, because a theme is free to +// give the button a different border. +function AssetAnimationTransportBar::chromeInset(%this) +{ + %profile = ThemeManager.activeTheme.iconButtonProfile; + + return (%profile.borderLeft.border + %profile.borderRight.border) SPC + (%profile.borderTop.border + %profile.borderBottom.border); +} + function AssetAnimationTransportBar::addToggle(%this, %name, %frameOn, %frameOff, %tipOn, %tipOff) { %size = $AssetAnimationTransportBar::buttonSize; + %inset = %this.chromeInset(); %button = new GuiCheckBoxCtrl() { class = "EditorToggleIcon"; Position = "0 0"; - Extent = %size SPC %size; - - // Matching EditorIconButton, which draws its picture at 20 in the same - // 24 pixel button. At the toggle's own default of 16 the row read as two - // sizes of button rather than one. - iconSize = $AssetAnimationTransportBar::iconSize; - + VertSizing = "center"; + Extent = (%size + getWord(%inset, 0)) SPC (%size + getWord(%inset, 1)); frameOn = %frameOn; frameOff = %frameOff; tipOn = %tipOn; @@ -111,10 +126,23 @@ class = "EditorToggleIcon"; function AssetAnimationTransportBar::addButton(%this, %method, %frame, %tooltip, %size) { + %size = (%size $= "") ? $AssetAnimationTransportBar::buttonSize : %size; + + // Said in the block, not set afterwards. EditorIconButton forces its own + // extent in onAdd and its hover handlers animate the icon to sizes of their + // own, so a resize applied after the add survived exactly until the pointer + // first crossed it -- and the chain had already sized itself around the + // smaller button by then, which is what clipped the big one. %button = new GuiButtonCtrl() { class = "EditorIconButton"; Position = "0 0"; + VertSizing = "center"; + // buttonSize only. The icon is deliberately left at its default, so the + // big play button is a bigger BUTTON with the same picture on it as the + // rest -- which is what makes it easy to find without making it look like + // a different kind of control. + buttonSize = %size; Frame = %frame; Command = %this.getId() @ "." @ %method @ "();"; Tooltip = %tooltip; @@ -123,15 +151,6 @@ class = "EditorIconButton"; ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); %this.add(%button); - // After the add, because EditorIconButton::onAdd sets its own 24 x 24 and - // would undo anything the new{} block said. The icon inside it is sized - // against the button, so both grow together. - if(%size !$= "" && %size != $AssetAnimationTransportBar::buttonSize) - { - %button.setExtent(%size, %size); - %button.icon.setExtent(%size - 4, %size - 4); - } - return %button; } diff --git a/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs b/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs index cf6f87e51..94ead6d1c 100644 --- a/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs +++ b/editor/AssetAdmin/Animation/GuiEditFramePaletteCtrl.cs @@ -102,6 +102,11 @@ %dragCtrl.add(%payload); %host.add(%dragCtrl); + // Again, now that it is on the canvas and awake. The fields above are what + // onWake reads, and this is what makes the frame right whichever order the + // waking happens in -- setImageFrame on an awake control is unambiguous. + %payload.setImageFrame(%frame); + // Grabbed by the middle, which is what lets the drop target work out where // the cursor is from the payload alone -- the position the drop callback is // handed is in the drag control's parent's space and cannot be used. @@ -123,13 +128,21 @@ fullSize = "1"; constrainProportions = "1"; + // The FIELDS, not setImage(). A payload is built detached, and + // GuiSpriteCtrl::setImage returns early when the control is not awake -- + // it records the asset id and drops the frame on the floor. Then onWake + // re-applies the image from mImageAssetId and mFrame, so whatever the + // Frame field says is what actually gets shown. Setting the image the + // obvious way left every dragged frame showing frame 0, right up until it + // was dropped and the correct one went in. + Image = %this.getImageAsset(); + Frame = %frame; + // What the drop reads back. The payload IS the message. frameIndex = %frame; }; ThemeManager.setProfile(%payload, "emptyProfile"); - %payload.setImage(%this.getImageAsset(), %frame); - return %payload; } diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index c32bd872e..b91f2b947 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -321,8 +321,8 @@ class = AssetWindow; // and rides the bottom of the preview as the frame grows. The position is set // against the extent the background has now, which is why it is a gap rather // than a coordinate. - %barGap = 8; - %barTop = getWord(%this.background.extent, 1) - $AssetAnimationTransportBar::buttonSize - %barGap; + %barGap = 16; + %barTop = (getWord(%this.background.extent, 1) - $AssetAnimationTransportBar::playSize) - %barGap; %this.transportBar = new GuiChainCtrl() { @@ -331,11 +331,29 @@ class = "AssetAnimationTransportBar"; HorizSizing = "center"; VertSizing = "top"; Position = "0" SPC %barTop; - Extent = "160" SPC $AssetAnimationTransportBar::buttonSize; + + // IsVertical BEFORE Extent, and the order is the whole thing. + // + // A chain sizes itself along its LENGTH and leaves the cross axis alone -- + // and GuiChainCtrl::resize enforces that by refusing whichever axis is + // currently the length. A GuiChainCtrl is born VERTICAL, so an Extent + // applied before this line is read as "you may not change my height", the + // height stays at the constructor's mEditOpenSpace of 30, and the 36 pixel + // play button is laid out centred in 30 -- three pixels off the top and + // three off the bottom, which is exactly how it was being clipped. + // + // Fields are applied in the order they are written, so this is a + // one-line-of-difference bug and worth the paragraph. IsVertical = false; + + // The tallest button. Nothing computes this: a chain never grows to fit a + // taller child. (IsExtentDynamic would not help either -- it is a + // GuiGridCtrl field and a chain never reads it.) + Extent = "160" SPC $AssetAnimationTransportBar::playSize; + ChildSpacing = $AssetAnimationTransportBar::spacing; - IsExtentDynamic = true; }; + ThemeManager.setProfile(%this.transportBar, "emptyProfile"); %this.transportBarContainer.add(%this.transportBar); %this.background.add(%this.transportBarContainer); diff --git a/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs b/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs index fa4da87d3..ecfe9973c 100644 --- a/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs +++ b/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs @@ -63,6 +63,7 @@ %this.makeDropDownProfile(); %this.makeWindowProfile(); %this.makeListBoxProfile(); + %this.makeFrameGridProfile(); %this.makeTreeViewProfile(); %this.makeGraphProfile(); %this.makeTextDisplayProfile(); @@ -1886,6 +1887,58 @@ }; } +//----------------------------------------------------------------------------- +// The animation editor's frame grids -- the palette of an image's frames and the +// timeline of the frames an animation plays. +// +// They had been borrowing listBoxProfile, because it is the one with canKeyFocus, +// and three of its fields mean something different there to what they have to +// mean here: +// +// fillColorHL a list row's hover is deliberately a whisper -- color1 nudged +// by 4 -- which over a picture is no change at all +// fontColorSL is the ink drawn ON a selected row, so it is dark against the +// accent. Used as a playhead bar it vanished completely +// fillColorSL the accent, which reads well as a selection but leaves nothing +// distinct for the playhead sitting on top of it +// +// So the grids get their own, and what each field is for is written down here +// because there is no other way to know from the far end: +// +// fillColor the strip behind the cells +// fillColorHL the cell under the pointer -- a real, visible change +// fillColorSL the picked cell: a raised surface, NOT the accent, so that +// the playhead stays legible on top of it +// fillColorNA the cell a drag would discard if released now +// fontColor the frame numbers, quiet enough to read art through +// fontColorHL the insertion caret, which is transient and wants to be seen +// fontColorSL the playhead. The accent, and the only thing here that has to +// be findable at a glance while the animation runs +//----------------------------------------------------------------------------- +function BaseTheme::makeFrameGridProfile(%this) +{ + %this.frameGridProfile = new GuiControlProfile() + { + fillColor = %this.adjustValue(%this.color1, 2); + fillColorHL = %this.color2; + fillColorSL = %this.color3; + fillColorNA = %this.setAlpha(%this.color3, 110); + + fontType = %this.font[3]; + fontDirectory = %this.fontDirectory; + fontSize = %this.fontSize; + fontColor = %this.setAlpha(%this.color4, 200); + fontColorHL = %this.color4; + fontColorSL = %this.color5; + + // The Delete key only reaches a control that can hold focus, and the + // timeline's whole keyboard depends on it. + canKeyFocus = true; + + borderDefault = %this.emptyBorder; + }; +} + function BaseTheme::makeTreeViewProfile(%this) { %this.treeViewProfile = new GuiControlProfile () diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc index ab534db13..88b2a47e4 100644 --- a/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc @@ -54,9 +54,11 @@ GuiEditFrameTimelineCtrl::GuiEditFrameTimelineCtrl() // it stays legible against the selected cell it is // frequently sitting on top of // font HighlightState the insertion caret +// font NormalState the frame numbers (drawn by the base class) // -// The strip wears listBoxProfile, which has all of them set for exactly this -// kind of use. +// The strips wear frameGridProfile, which exists for this and nothing else -- +// BaseTheme::makeFrameGridProfile says what each field is chosen to be and why +// borrowing listBoxProfile got two of them wrong. //----------------------------------------------------------------------------- //----------------------------------------------------------------------------- diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs index 6ebdfc095..e6181b947 100644 --- a/tests/smoke/assetAnimationTimeline.cs +++ b/tests/smoke/assetAnimationTimeline.cs @@ -113,6 +113,14 @@ function aniStep3() $aniPalette = $aniStage.palettePane.strip; $aniTimeline = $aniStage.timelinePane.strip; + // A sprite built with an Animation on it is already running -- nothing + // pressed play -- so choosing an animation has to arrive with the transport + // already saying Stop. It said Play over a preview that was busy playing, + // because the flag was initialised false rather than read off the sprite. + aniCheck("choosing an animation arrives playing", $aniStage.playing); + aniCheck("so the transport offers Stop", AssetAdmin.transportBar.stopButton.isVisible()); + aniCheck("and not Play", !AssetAdmin.transportBar.playButton.isVisible()); + // The panes must be sized by their frames, not left at the extent they were // built with. setFrameSize is the only thing that lays the tree out, so // sizing the frames before the panes were added produced exactly this: the @@ -252,6 +260,24 @@ function aniStepDrop() %pane = $aniStage.timelinePane; $aniStage.timelinePane.setFrames("10 11 12 13"); + // What the thing under the cursor is actually showing. + // + // It showed frame 0 whatever was picked up, and only became right once it was + // dropped. GuiSpriteCtrl::setImage returns early when the control is not + // awake -- it keeps the asset id and discards the frame -- and a payload is + // built detached, so the frame never landed. onWake then re-applies the image + // from the Frame FIELD, which nothing had set. + // + // Asserted on the real makePayload rather than on a sprite built here, because + // the bug was entirely in how that one is built. + %payload = $aniStage.palettePane.strip.makePayload(7); + AssetAdmin.content.add(%payload); + + aniCheck("a drag payload shows the frame it was made for (" @ %payload.getImageFrame() @ ")", + %payload.getImageFrame() == 7); + aniCheck("and carries it for the drop", %payload.frameIndex == 7); + %payload.delete(); + // Parked over the library, which is nowhere near the timeline. %away = AssetAdmin.libWindow.getGlobalPosition(); %payload = aniMakePayload(99, @@ -296,8 +322,57 @@ function aniStepTransport() $aniStage.timelinePane.setFrames("40 41 42 43 44 45"); + // The bar is as tall as its tallest button, and the tallest button fits in it. + // + // A chain does not grow to fit a taller child -- and worse, GuiChainCtrl is + // born VERTICAL and its resize refuses to change whichever axis is currently + // the length, so an Extent set before IsVertical silently kept the + // constructor's 30. The big play button was then centred in 30 and lost three + // pixels off each end. + %barHeight = getWord(%bar.getExtent(), 1); + %playHeight = getWord(%bar.playButton.getExtent(), 1); + + aniCheck("the bar is as tall as its biggest button (" @ %barHeight @ " vs " @ %playHeight @ ")", + %barHeight >= %playHeight); + aniCheck("so the play button is not clipped at the top", + getWord(%bar.playButton.getPosition(), 1) >= 0); + aniCheck("nor at the bottom", + getWord(%bar.playButton.getPosition(), 1) + %playHeight <= %barHeight); + + // A toggle draws a box clamped inside its borders; a push button paints its + // whole rect. Same extent means the toggle LOOKS smaller, so it is built + // bigger by exactly the border and the two come out matching. + %inset = %bar.chromeInset(); + %toggleDrawn = getWord(%bar.loopButton.getExtent(), 0) - getWord(%inset, 0); + + aniCheck("a toggle draws the same size as a push button (" @ %toggleDrawn @ ")", + %toggleDrawn == $AssetAnimationTransportBar::buttonSize); + + // Every button draws the same size of picture, whatever size the button is. + // + // GuiSpriteCtrl::growTo animates the PICTURE and leaves the sprite control + // alone, so a button that scaled both together had its icon animate from 32 + // down to 28 on the first hover -- it looked like the icon exploded and never + // went back. The big play button is a bigger button, not a bigger icon. + %rewindIcon = %bar.getObject(0).icon; + aniCheck("the play button's picture matches the small buttons' (" @ + %bar.playButton.icon.imageSize @ " vs " @ %rewindIcon.imageSize @ ")", + %bar.playButton.icon.imageSize $= %rewindIcon.imageSize); + aniCheck("and so does a toggle's", + %bar.loopButton.icon.imageSize $= %rewindIcon.imageSize); + + // The sprite holding it must be bigger than the picture at its hovered size, + // or the hover is clamped away by the sprite's own content rect. + aniCheck("the picture has room to grow into (" @ %rewindIcon.getExtent() @ ")", + getWord(%rewindIcon.getExtent(), 0) >= + getWord(%rewindIcon.imageSize, 0) + $EditorIconButton::hoverGrowth); + // Play and Stop are two buttons with one hidden, not one toggle, so "which is // on show" is the only state there is and it cannot fall out of step. + // + // Stopped explicitly rather than relying on an earlier step having done it, + // so this cannot pass by inheritance. + $aniStage.stop(); aniCheck("it offers Play while stopped", %bar.playButton.isVisible()); aniCheck("and not Stop", !%bar.stopButton.isVisible()); From d4122a7320a279180a5b0d3432308c83c4a54352 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 12 Aug 2026 00:07:56 -0400 Subject: [PATCH 19/26] An asset you can change your mind about Editing an asset wrote its file. Every setter ended in refreshAsset, and refreshAsset ended in Taml::write -- so dragging a particle graph key was indistinguishable from deciding to keep it, and there was no way back. It was also wrong outside the editor: a running game rewrote its own content as a side effect of a setter. refreshAsset now marks the asset unsaved and announces the change. saveAsset writes, and nothing else does. The private-asset branch was already exactly that shape, so the two paths are one. Around that: Save, Revert, Duplicate and Undo/Redo on the inspector's title bar, a badge on unsaved library tiles, and one Save All / Discard All / Cancel prompt in front of Close Project and Exit. Any number of assets may be unsaved at once; switching between them deliberately asks nothing. The dirty flag and the snapshots are in C++ because two of the editors never reach TorqueScript -- GuiParticleGraphInspector does every key drag itself, and the stock GuiInspector writes emitter fields straight onto the object. A recorder built out of script writes would have been blind to the particle editor, which is the thing that most needed undo. Script keeps only the policy: what counts as one step and what it is called. Undo is whole-asset snapshots of unowned clones. Unowned is the point: with no owning manager every setter is inert, so taking one marks nothing, notifies nobody and loads no bitmap. A restore copies onto the LIVE object, never replaces it -- every AssetPtr holds a raw pointer that a swap would null. That rested on copyTo, which was broken in four places, all of them live bugs today via clone() and acquireAsset(id, true): ImageAsset copied cell COUNT into cell OFFSET, never copied image layers, and dropped explicit cells unless ExplicitMode happened to be on AnimationAsset chose between numbered and named frames by reading the TARGET's mode, still the default at that point ParticleAssetEmitter same shape, so an animated emitter copied as a blank static one -- and ParticleAsset inherited it Fixed by not listing fields at all: AssetBase::copyTo walks the field table via copyFieldsFrom, and each type overrides copyAssetStateTo only for what no field describes. assetStateCopyTests enumerates the field table rather than a list of its own, so a field added later is covered the day it lands. Also fixed on the way through, each found by the work above: - a named-cells animation did not survive its own file. The vector type's getter joins with commas; the setter split on whitespace alone. - setAnimationFrames had no "ignore no change" guard, so writing the same list back counted as an edit and left an undo step that put nothing back. - a dropped frame committed twice: insertFrameAtPoint announces itself and the handler announced it again. Two presses of undo to remove one frame. - refreshAsset's onRefresh callback never reached dependents, contradicting what the editor assumed. It fires from the manager now, with a flag saying whether the asset was changed or merely reads from something that was. - the dependency and loose-file graphs were rebuilt by re-parsing the file that had just been written, so with no write they went stale. Rebuilt in memory. - unloadAsset would delete an asset holding unsaved work. - preloadAsset marked every preloaded asset unsaved at startup. - findAssetPrivate's five-argument binding called findAssetInternal. - the Frame Range dialog's Mode list was filled with GuiControl::add, so it was empty, read "none", and Replace could not be picked. - three dialogs placed their buttons using the window's height rather than the content's, which is 34 less; the buttons sat below the fold. Asset ids are resolved to modules by path, never through AssetDefinition::mpModuleDefinition -- the editor calls clearDatabase() when it picks up a project, which frees every ModuleDefinition and leaves that pointer dangling. 252 unit tests and 52 smoke suites pass. assetDirtySave is the new one, and most of what it asserts is about the FILE rather than the object, because not touching it is the whole point. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- cmake/EngineSources.cmake | 1 + .../Animation/AssetAnimationPalettePane.cs | 6 +- .../Animation/AssetAnimationRangeDialog.cs | 50 +- .../Animation/AssetAnimationStage.cs | 24 +- .../Animation/AssetAnimationTimelinePane.cs | 20 +- editor/AssetAdmin/AssetAdmin.cs | 102 ++ .../AssetAdmin/AssetAdminConfirmSaveDialog.cs | 117 +++ editor/AssetAdmin/AssetBase.cs | 51 +- editor/AssetAdmin/AssetDictionaryButton.cs | 69 +- editor/AssetAdmin/AssetInspector.cs | 273 ++++- editor/AssetAdmin/AssetLibraryWindow.cs | 16 + editor/AssetAdmin/AssetUndoRecorder.cs | 478 +++++++++ editor/AssetAdmin/DuplicateAssetDialog.cs | 189 ++++ .../Inspector/AssetInspectorPane.cs | 8 +- editor/AssetAdmin/NewAudioAssetDialog.cs | 6 +- editor/AssetAdmin/NewFontAssetDialog.cs | 6 +- editor/AssetAdmin/NewImageAssetDialog.cs | 6 +- editor/EditorCore/EditorCore.cs | 33 +- editor/EditorCore/EditorDialog.cs | 24 + .../EditorCore/Themes/BaseTheme/BaseTheme.cs | 13 + engine/source/2d/assets/AnimationAsset.cc | 93 +- engine/source/2d/assets/AnimationAsset.h | 1 - engine/source/2d/assets/FontAsset.cc | 17 - engine/source/2d/assets/FontAsset.h | 1 - engine/source/2d/assets/ImageAsset.cc | 72 +- engine/source/2d/assets/ImageAsset.h | 2 +- engine/source/2d/assets/ParticleAsset.cc | 17 +- engine/source/2d/assets/ParticleAsset.h | 2 +- .../source/2d/assets/ParticleAssetEmitter.cc | 54 +- engine/source/assets/assetBase.cc | 125 ++- engine/source/assets/assetBase.h | 38 + .../source/assets/assetBase_ScriptBinding.h | 69 ++ engine/source/assets/assetDefinition.h | 10 + engine/source/assets/assetManager.cc | 985 ++++++++++++++++-- engine/source/assets/assetManager.h | 68 ++ .../assets/assetManager_ScriptBinding.h | 136 ++- engine/source/audio/AudioAsset.cc | 22 - engine/source/audio/AudioAsset.h | 1 - .../testing/tests/assetStateCopyTests.cc | 402 +++++++ tests/shots/assetDialogs.cs | 164 +++ tests/shots/assetDirtyMark.cs | 110 ++ tests/smoke/assetDirtySave.cs | 665 ++++++++++++ tests/smoke/assetLibrary.cs | 12 + 43 files changed, 4225 insertions(+), 333 deletions(-) create mode 100644 editor/AssetAdmin/AssetAdminConfirmSaveDialog.cs create mode 100644 editor/AssetAdmin/AssetUndoRecorder.cs create mode 100644 editor/AssetAdmin/DuplicateAssetDialog.cs create mode 100644 engine/source/testing/tests/assetStateCopyTests.cc create mode 100644 tests/shots/assetDialogs.cs create mode 100644 tests/shots/assetDirtyMark.cs create mode 100644 tests/smoke/assetDirtySave.cs diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index 1dad5a489..75be35543 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -342,6 +342,7 @@ set(TORQUE_ENGINE_SOURCES # ---- testing ---- ${TORQUE_SRC}/testing/unitTesting.cc # ---- testing/tests ---- + ${TORQUE_SRC}/testing/tests/assetStateCopyTests.cc ${TORQUE_SRC}/testing/tests/guiControlReparentTests.cc ${TORQUE_SRC}/testing/tests/guiCursorHotSpotTests.cc ${TORQUE_SRC}/testing/tests/guiFrameStripLayoutTests.cc diff --git a/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs index 7a09bc9c1..32710226b 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationPalettePane.cs @@ -70,9 +70,9 @@ showArrowButtons = true; }; ThemeManager.setProfile(%this.scroller, "scrollingPanelProfile"); - ThemeManager.setProfile(%this.scroller, "tinyThumbProfile", "ThumbProfile"); - ThemeManager.setProfile(%this.scroller, "tinyTrackProfile", "TrackProfile"); - ThemeManager.setProfile(%this.scroller, "tinyScrollArrowProfile", "ArrowProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelThumbProfile", "ThumbProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelTrackProfile", "TrackProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelArrowProfile", "ArrowProfile"); %this.add(%this.scroller); // No class= on the grid. The C++ class owns that namespace, and setting class diff --git a/editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs b/editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs index 40857f2e6..65c2f2f51 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationRangeDialog.cs @@ -68,10 +68,12 @@ class = "EditorForm"; %this.pingPongBox = %form.createCheckboxItem(%item); %item = %form.addFormItem("Mode", %half SPC 30); + // addItem, not add. GuiControl::add is what was being called here -- it takes a + // CONTROL and puts it inside this one -- so the list stayed empty, the box + // read "none", and the second choice could not be picked at all. %this.modeDropDown = %form.createDropDownItem(%item); - %this.modeDropDown.add("Append to the timeline", 0); - %this.modeDropDown.add("Replace the timeline", 1); - %this.modeDropDown.setSelected(0); + %this.modeDropDown.addItem("Append to the timeline"); + %this.modeDropDown.addItem("Replace the timeline"); %content.add(%form); @@ -86,12 +88,19 @@ class = "EditorForm"; %this.pingPongBox.Command = %command; %this.modeDropDown.Command = %command; + // Below whatever the form actually came out as, rather than below a number + // written here: the grid decides its own height from how many rows six items + // make, and a seventh field would silently land underneath this. + %formBottom = getWord(%form.getPosition(), 1) + getWord(%form.getExtent(), 1); + + // The answer line. textExtend grows it downward for a long answer, which is + // why the buttons sit well clear of where it starts rather than just under it. %this.feedback = new GuiControl() { - HorizSizing = "right"; - VertSizing = "bottom"; - Position = "12 168"; - Extent = (%width - 24) SPC 70; + HorizSizing = "width"; + VertSizing = "anchorTop"; + Position = "12" SPC (%formBottom + 8); + Extent = (%width - 24) SPC 90; text = ""; textWrap = true; textExtend = true; @@ -99,11 +108,16 @@ class = "EditorForm"; ThemeManager.setProfile(%this.feedback, "infoProfile"); %content.add(%this.feedback); + // Measured from the room the content actually has, not from the dialog's own + // height -- the title bar and border take 34 of it, and buttons placed + // without allowing for that fall off the bottom. + %bottom = %this.contentHeight() - 12; + %this.cancelButton = new GuiButtonCtrl() { - HorizSizing = "right"; - VertSizing = "bottom"; - Position = (%width - 222) SPC (%height - 42); + HorizSizing = "anchorRight"; + VertSizing = "anchorBottom"; + Position = (%width - 222) SPC (%bottom - 32); Extent = "100 30"; Text = "Cancel"; Command = %this.getID() @ ".onClose();"; @@ -113,9 +127,9 @@ class = "EditorForm"; %this.applyButton = new GuiButtonCtrl() { - HorizSizing = "right"; - VertSizing = "bottom"; - Position = (%width - 112) SPC (%height - 44); + HorizSizing = "anchorRight"; + VertSizing = "anchorBottom"; + Position = (%width - 112) SPC (%bottom - 34); Extent = "100 34"; Text = "Apply"; Command = %this.getID() @ ".onApply();"; @@ -123,6 +137,12 @@ class = "EditorForm"; ThemeManager.setProfile(%this.applyButton, "primaryButtonProfile"); %content.add(%this.applyButton); + // Down here with the rest of the starting values, and not beside the add()s + // that fill the list, because a drop down that has not been added to anything + // yet is not awake -- and the selection made on it then does not stick. The + // list showed "none" until the user opened it. + %this.modeDropDown.setSelected(0); + %this.startBox.setText(0); %this.endBox.setText(mGetMax(0, %this.imageFrameCount() - 1)); %this.stepBox.setText(1); @@ -141,9 +161,11 @@ class = "EditorForm"; return %this.stage.imageAsset.getFrameCount(); } +// getSelectedItem, not getSelected: the latter is not a method on a drop down at +// all, so this always answered "append" and Replace was unreachable. function AssetAnimationRangeDialog::mode(%this) { - return (%this.modeDropDown.getSelected() == 1) ? "replace" : "append"; + return (%this.modeDropDown.getSelectedItem() == 1) ? "replace" : "append"; } function AssetAnimationRangeDialog::validate(%this) diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index ed3068d4f..6fc154eee 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -545,20 +545,27 @@ class = "AssetAnimationPalettePane"; // sprite has already forgotten where it was. %this.resumeSlot = isObject(%this.previewSprite) ? %this.previewSprite.getAnimationFrame() : -1; - // Guarded because the write comes straight back: every asset setter ends in - // refreshAsset, which rewrites the .animation.taml and fires onRefresh - // synchronously, inside this call. + // One transaction around both writes. Keep Frame Rate makes this path change + // the asset twice for a single thing the user did, and two undo steps for one + // dropped frame is two presses of undo to put it back. + AssetAdmin.undoRecorder.begin("Set Frames"); + + // Guarded because the change comes straight back: every asset setter ends in + // refreshAsset, which announces the change and fires onRefresh synchronously, + // inside this call. %this.committing = true; %this.animationAsset.setAnimationFrames(%frames); %this.committing = false; - // Before the slot is forgotten, because this writes the asset a second time - // and every write restarts playback. Cleared only once BOTH are done, so the + // Before the slot is forgotten, because this changes the asset a second time + // and every change restarts playback. Cleared only once BOTH are done, so the // one remembered slot covers the pair -- forgetting it in between left the // second refresh with nothing to restore, and the preview back at frame zero // after every edit. %this.keepFrameRate(%before); + AssetAdmin.undoRecorder.end(); + %this.resumeSlot = -1; } @@ -671,8 +678,12 @@ class = "AssetAnimationPalettePane"; return; } + // Six fields over three rows is 150, the answer line is 90 with room to grow + // into, and the buttons want 34 and a margin at the bottom. Plus the 34 the + // title bar and border take out of the window before the content sees any of + // it. %width = 460; - %height = 260; + %height = 340; %dialog = new GuiControl() { @@ -680,6 +691,7 @@ class = "AssetAnimationRangeDialog"; superclass = "EditorDialog"; dialogSize = (%width + 8) SPC (%height + 8); dialogCanClose = true; + dialogResizable = false; dialogText = "Frame Range"; stage = %this; }; diff --git a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs index ade5ccbda..73af9a2a8 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs @@ -63,9 +63,9 @@ showArrowButtons = true; }; ThemeManager.setProfile(%this.scroller, "scrollingPanelProfile"); - ThemeManager.setProfile(%this.scroller, "tinyThumbProfile", "ThumbProfile"); - ThemeManager.setProfile(%this.scroller, "tinyTrackProfile", "TrackProfile"); - ThemeManager.setProfile(%this.scroller, "tinyScrollArrowProfile", "ArrowProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelThumbProfile", "ThumbProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelTrackProfile", "TrackProfile"); + ThemeManager.setProfile(%this.scroller, "scrollingPanelArrowProfile", "ArrowProfile"); %this.add(%this.scroller); // The mirror of the palette: "fill" down the axis whose bar is alwaysOff, so @@ -186,10 +186,16 @@ return; } - if(%this.strip.insertFrameAtPoint(%cursor, %payload.frameIndex)) - { - %this.commitFrames(); - } + // Deliberately no commitFrames() here. insertFrameAtPoint announces the change + // itself -- notifyFramesChanged fires onFramesChanged, which comes straight + // back to this pane's commitFrames -- so committing again wrote the asset + // twice for one dropped frame. That was invisible while a change just rewrote + // the same file; with undo it is a step that puts nothing back, so a dropped + // frame took two presses of undo to remove and left a dead redo behind it. + // + // insertFrame (the plain one the palette click uses) does NOT announce, which + // is why appendFrame above still has to. + %this.strip.insertFrameAtPoint(%cursor, %payload.frameIndex); } // The middle of the payload, which is where the cursor is holding it. diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index b91f2b947..47c52c71d 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -42,6 +42,13 @@ exec("./Inspector/exec.cs"); exec("./Animation/exec.cs"); exec("./AssetPreviewSprite.cs"); + exec("./AssetUndoRecorder.cs"); + exec("./DuplicateAssetDialog.cs"); + exec("./AssetAdminConfirmSaveDialog.cs"); + + // Undo, redo and the record of what is unsaved. Built before the inspector, + // which asks it what to grey out as soon as it has a document bar. + %this.undoRecorder = new ScriptObject() { class = "AssetUndoRecorder"; }; %this.guiPage = EditorCore.RegisterEditor("Asset Manager", %this); %this.content = %this.createFrameSet(); @@ -392,6 +399,11 @@ class = "AssetAnimationTransportBar"; { %this.frameRange.delete(); } + // Its onRemove drops every snapshot it is holding. + if(isObject(%this.undoRecorder)) + { + %this.undoRecorder.delete(); + } } function AssetAdmin::open(%this) @@ -402,6 +414,96 @@ class = "AssetAnimationTransportBar"; %this.isOpen = true; } +//----------------------------------------------------------------------------- +// Unsaved assets. +// +// Any number of assets can be left unsaved at once, and switching between them -- +// or away from the Asset Manager entirely -- deliberately does not ask about it. +// That is the point: trying a particle out, going to look at the image it uses, +// and coming back should not cost three dialogs. +// +// The question is asked once, at the moments the work would actually be lost: +// closing the project and leaving the application. See EditorCore::guardedCommand. +// +// The window's X cannot ask. quit() is posted straight from the window procedure +// with no script in between, and onPreExit runs inside shutdown, long past the +// point where a dialog could be shown. The Gui Editor has always had the same +// hole; it is not one this can close from here. +//----------------------------------------------------------------------------- + +function AssetAdmin::hasUnsavedAssets(%this) +{ + return AssetDatabase.getDirtyAssetCount() > 0; +} + +function AssetAdmin::saveAllAssets(%this) +{ + // Compiled before saving, because saving is what takes them off the list. + %query = new AssetQuery(); + AssetDatabase.findAssetDirty(%query, true); + + %count = %query.getCount(); + for(%i = 0; %i < %count; %i++) + { + %assetId = %query.getAsset(%i); + + if(AssetDatabase.saveAsset(%assetId)) + { + %this.undoRecorder.onAssetSaved(%assetId); + } + } + + %query.delete(); + + %this.inspector.refreshDocumentBar(); +} + +// Ask about the unsaved assets, then hand %command on to whatever guards after +// this one. +function AssetAdmin::guardAssets(%this, %command) +{ + %this.pendingCommand = %command; + + %count = AssetDatabase.getDirtyAssetCount(); + %noun = (%count == 1) ? "asset has" : "assets have"; + + // The message line is 64 with room to grow into, and the buttons want 34 and a + // margin. Plus the 34 the title bar and border take out of the window before + // the content sees any of it. + %width = 460; + %height = 170; + %dialog = new GuiControl() + { + class = "AssetAdminConfirmSaveDialog"; + superclass = "EditorDialog"; + dialogSize = (%width + 8) SPC (%height + 8); + dialogCanClose = true; + dialogResizable = false; + dialogText = "Unsaved Assets"; + message = %count SPC %noun SPC "changes that have not been saved."; + }; + %dialog.init(%width, %height); + + Canvas.pushDialog(%dialog); +} + +// Carry on with what the user originally asked for. +function AssetAdmin::runPendingCommand(%this) +{ + %command = %this.pendingCommand; + %this.pendingCommand = ""; + + if(%command !$= "") + { + EditorCore.guardedCommandAfterAssets(%command); + } +} + +function AssetAdmin::dropPendingCommand(%this) +{ + %this.pendingCommand = ""; +} + function AssetAdmin::close(%this) { // The last chance to see where the user left the animation editor's dividers: diff --git a/editor/AssetAdmin/AssetAdminConfirmSaveDialog.cs b/editor/AssetAdmin/AssetAdminConfirmSaveDialog.cs new file mode 100644 index 000000000..ed5cdb8ac --- /dev/null +++ b/editor/AssetAdmin/AssetAdminConfirmSaveDialog.cs @@ -0,0 +1,117 @@ +//----------------------------------------------------------------------------- +// What stands between unsaved assets and the two commands that would discard +// them: Close Project and Exit. +// +// Modelled on GuiEditorConfirmSaveDialog, and the same three answers, because the +// question is the same one: the third option people actually want is "save it, +// then carry on with what I asked for", and making them Cancel, save by hand and +// repeat themselves is not really an option at all. +// +// Where it differs is that this is about a set rather than a document. There is +// no Save As to go wrong, so Save All is unconditional and the interrupted +// command resumes immediately after it. +// +// It owns none of the decision. AssetAdmin holds the command that was +// interrupted; each button here only says which way to go. See +// AssetAdmin::guardAssets. +//----------------------------------------------------------------------------- + +function AssetAdminConfirmSaveDialog::init(%this, %width, %height) +{ + %window = %this.getObject(0); + %content = %window.getObject(0); + + %this.feedback = new GuiControl() + { + HorizSizing = "width"; + VertSizing = "anchorTop"; + Position = "12 12"; + Extent = (%width - 24) SPC 64; + text = %this.message; + textWrap = true; + textExtend = true; + }; + ThemeManager.setProfile(%this.feedback, "infoProfile"); + %content.add(%this.feedback); + + // Measured from the room the content actually has, not from the dialog's own + // height -- the title bar and border take 34 of it. + %bottom = %this.contentHeight() - 12; + + // Right to left in the order they escalate: abandon what you asked for, go + // through with it, or write the files first. + // + // The middle one is wider than the other two because "Discard All" does not + // fit in the 100 the others use -- it came out as "Discard A". + %this.cancelButton = new GuiButtonCtrl() + { + HorizSizing = "anchorRight"; + VertSizing = "anchorBottom"; + Position = (%width - 356) SPC (%bottom - 32); + Extent = "100 30"; + Text = "Cancel"; + Command = %this.getID() @ ".onCancel();"; + }; + ThemeManager.setProfile(%this.cancelButton, "buttonProfile"); + %content.add(%this.cancelButton); + + %this.discardButton = new GuiButtonCtrl() + { + HorizSizing = "anchorRight"; + VertSizing = "anchorBottom"; + Position = (%width - 246) SPC (%bottom - 32); + Extent = "120 30"; + Text = "Discard All"; + Command = %this.getID() @ ".onDiscard();"; + }; + ThemeManager.setProfile(%this.discardButton, "buttonProfile"); + %content.add(%this.discardButton); + + %this.saveButton = new GuiButtonCtrl() + { + HorizSizing = "anchorRight"; + VertSizing = "anchorBottom"; + Position = (%width - 116) SPC (%bottom - 34); + Extent = "100 34"; + Text = "Save All"; + Command = %this.getID() @ ".onSave();"; + }; + ThemeManager.setProfile(%this.saveButton, "primaryButtonProfile"); + %content.add(%this.saveButton); +} + +function AssetAdminConfirmSaveDialog::onCancel(%this) +{ + AssetAdmin.dropPendingCommand(); + %this.closeNow(); +} + +// The assets stay unsaved, and the command goes ahead anyway. It resumes AFTER +// this guard rather than at the top of the chain, or the assets would still be +// unsaved and the same question would be asked forever. +function AssetAdminConfirmSaveDialog::onDiscard(%this) +{ + %this.closeNow(); + AssetAdmin.runPendingCommand(); +} + +function AssetAdminConfirmSaveDialog::onSave(%this) +{ + %this.closeNow(); + AssetAdmin.saveAllAssets(); + AssetAdmin.runPendingCommand(); +} + +// The window X, which is the same answer as Cancel: no to the question asked. +function AssetAdminConfirmSaveDialog::onClose(%this) +{ + %this.onCancel(); +} + +// Not the shared EditorCore.dialog slot: the Gui Editor's own confirm dialog can +// follow this one within the scheduled delay, and the two would race for it. +function AssetAdminConfirmSaveDialog::closeNow(%this) +{ + Canvas.popDialog(%this); + EditorCore.schedule(100, "deleteDialogObject", %this); +} diff --git a/editor/AssetAdmin/AssetBase.cs b/editor/AssetAdmin/AssetBase.cs index 666de580a..fefe0cf15 100644 --- a/editor/AssetAdmin/AssetBase.cs +++ b/editor/AssetAdmin/AssetBase.cs @@ -1,13 +1,26 @@ //----------------------------------------------------------------------------- // What the Asset Manager does when an asset changes underneath it. // -// Every setter on an asset ends in refreshAsset(), which saves the asset's file -// and fires this. So this is the one place that hears about a change however it -// was made -- from the inspector, from the Explicit Frames or Image Layers tab, -// or as a cascade from some other asset that this one depends on. +// Every setter on an asset ends in refreshAsset(), which marks it unsaved and +// fires this. So this is the one place that hears about a change however it was +// made -- from the inspector, from the Explicit Frames or Image Layers tab, from +// the particle graph editor down in C++, or as a cascade from some other asset +// that this one depends on. // -// Three things have to be told: +// Note refreshAsset does NOT write the file any more. Saving is a thing the user +// asks for; see AssetInspector's document bar and [[AssetUndoRecorder]]. // +// %direct separates the two reasons this fires: +// +// true this asset was changed, and now has unsaved work in it +// false something this asset READS FROM was changed, so whatever it derives +// from that needs rebuilding -- but nothing it saves has moved, and it +// is not unsaved on account of it +// +// Four things have to be told: +// +// the recorder a direct change is one step of undo, and it needs the state +// from before it, which is the snapshot it has been holding // the library a tile caches the name, description and category it is // searched and sorted by, and the inspector edits all three // the preview the scene showing the asset is built from its values @@ -15,7 +28,7 @@ // is a change to what the inspector is showing //----------------------------------------------------------------------------- -function AssetBase::onRefresh(%this) +function AssetBase::onRefresh(%this, %direct) { // The library has nothing loaded while the Asset Manager is shut, and this // also fires as assets are acquired during the load itself, before there is @@ -25,6 +38,10 @@ return; } + // First, so that the step records the state from before anything below reacts + // to the change. + AssetAdmin.undoRecorder.onAssetChanged(%this, %direct); + AssetAdmin.libWindow.onAssetRefreshed(%this.getAssetId()); AssetAdmin.inspector.onAssetRefreshed(%this); @@ -32,3 +49,25 @@ // an asset into it when the selection actually moved. AssetAdmin.refreshPreview(%this); } + +//----------------------------------------------------------------------------- +// An asset gained or lost unsaved changes. +// +// A global, fired by AssetManager::markAssetDirty, saveAsset, revertAsset and +// setAssetDirty -- only on the edge, never on every change. It exists so the +// library can mark a tile without asking every asset every frame, and it is +// separate from onRefresh because the two do not coincide: a change to an already +// unsaved asset fires onRefresh and not this, and a save fires this and not +// onRefresh. +//----------------------------------------------------------------------------- + +function onAssetDirtyChanged(%assetId) +{ + if(!isObject(AssetAdmin) || !AssetAdmin.isOpen) + { + return; + } + + AssetAdmin.libWindow.onAssetDirtyChanged(%assetId); + AssetAdmin.inspector.refreshDocumentBar(); +} diff --git a/editor/AssetAdmin/AssetDictionaryButton.cs b/editor/AssetAdmin/AssetDictionaryButton.cs index 0d258c554..5a5d55b98 100644 --- a/editor/AssetAdmin/AssetDictionaryButton.cs +++ b/editor/AssetAdmin/AssetDictionaryButton.cs @@ -36,13 +36,21 @@ $AssetDictionaryButton::rowArt = 28; $AssetDictionaryButton::rowTextLeft = 36; +// The square badge in the corner that says an asset has unsaved changes. +$AssetDictionaryButton::dirtyMark = 16; + function AssetDictionaryButton::onAdd(%this) { %this.buildSearchKey(); %this.buildCaption(); + %this.buildDirtyMark(); %this.call("load" @ %this.type, %this.assetID); + // The asset may already have unsaved changes -- a tile built by a duplicate, + // or the library being reopened on a project left mid-edit. + %this.refreshDirtyMark(); + if(%this.viewMode $= "") { %this.viewMode = "grid"; @@ -88,6 +96,43 @@ %this.Tooltip = %this.assetName; } +// The badge that says this asset has changes that have not been saved. +// +// A control of its own rather than an asterisk on the end of the caption, so that +// the mark never becomes part of the name: the library is searched and sorted by +// what the caption holds, and a name that grows a " *" is a name that sorts +// somewhere else and stops matching a search for itself. +// +// UseInput is off so it cannot swallow the click that selects the tile it sits on. +function AssetDictionaryButton::buildDirtyMark(%this) +{ + %size = $AssetDictionaryButton::dirtyMark; + + %this.dirtyMark = new GuiControl() + { + HorizSizing = "anchorRight"; + VertSizing = "anchorTop"; + Position = "0 0"; + Extent = %size SPC %size; + MinExtent = "0 0"; + Text = "*"; + UseInput = false; + Visible = false; + }; + ThemeManager.setProfile(%this.dirtyMark, "impactProfile"); + %this.add(%this.dirtyMark); +} + +// The asset's unsaved state changed. Only the badge appears or goes; nothing +// about the tile's placement, its caption or its keys depends on it. +function AssetDictionaryButton::refreshDirtyMark(%this) +{ + if(isObject(%this.dirtyMark)) + { + %this.dirtyMark.setVisible(AssetDatabase.isAssetDirty(%this.assetID)); + } +} + function AssetDictionaryButton::buildCaption(%this) { %this.caption = new GuiControl() @@ -227,13 +272,14 @@ class = "AssetDictionarySprite"; { %this.viewMode = %mode; - if(!isObject(%this.icon) || !isObject(%this.caption)) + if(!isObject(%this.icon) || !isObject(%this.caption) || !isObject(%this.dirtyMark)) { return; } %w = getWord(%this.getExtent(), 0); %h = getWord(%this.getExtent(), 1); + %mark = $AssetDictionaryButton::dirtyMark; if(%mode $= "rows") { @@ -245,13 +291,19 @@ class = "AssetDictionarySprite"; %this.icon.setExtent(%art, %art); %this.icon.setPosition(4, (%h - %art) / 2); + // The caption stops short of the badge rather than running under it: a row + // is one line of text with nothing above it to move the mark out of. %this.caption.HorizSizing = "width"; %this.caption.VertSizing = "center"; - %this.caption.setExtent(%w - %left - 4, %art); + %this.caption.setExtent(%w - %left - 8 - %mark, %art); %this.caption.setPosition(%left, (%h - %art) / 2); %this.caption.align = "left"; %this.caption.vAlign = "middle"; %this.caption.textWrap = false; + + %this.dirtyMark.VertSizing = "center"; + %this.dirtyMark.setExtent(%mark, %mark); + %this.dirtyMark.setPosition(%w - %mark - 4, (%h - %mark) / 2); } else { @@ -266,14 +318,25 @@ class = "AssetDictionarySprite"; %this.caption.applySizing(); // And that fill is how the button's own border inset gets measured: what - // the caption reports back after filling IS the content rect. + // the caption reports back after filling IS the content rect, and where it + // sits IS that rect's origin. + %innerW = getWord(%this.caption.getExtent(), 0); %innerH = getWord(%this.caption.getExtent(), 1); + %innerX = getWord(%this.caption.getPosition(), 0); + %innerY = getWord(%this.caption.getPosition(), 1); %this.icon.HorizSizing = "center"; %this.icon.VertSizing = "anchorTop"; %this.icon.setExtent(%art, %art); %this.icon.setPosition(0, (%innerH - %band - %art) / 2); %this.icon.applySizing(); + + // Hard into the top right of the content rect, over the corner of the + // picture. Measured from the caption rather than from the button's own + // extent so the badge lands inside the border rather than under it. + %this.dirtyMark.VertSizing = "anchorTop"; + %this.dirtyMark.setExtent(%mark, %mark); + %this.dirtyMark.setPosition(%innerX + %innerW - %mark, %innerY); } } diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index 1dd9fddeb..94bc5aa3f 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -82,6 +82,31 @@ %this.emitterButtonBar.addButton("MoveEmitterForward", $EditorIcon::rnd_br_down, "Move Emitter Forward", "getMoveEmitterForwardEnabled"); %this.emitterButtonBar.addButton("RemoveEmitter", $EditorIcon::round_delete, "Remove Emitter", "getRemoveEmitterEnabled"); + // What the user does to a whole asset rather than to a field of one: save the + // changes, throw them away, branch them, or step through them. + // + // Five 24 pixel buttons at 4 apart is 136 wide, and it sits to the LEFT of the + // delete button, which is itself pinned to the right edge. The emitter bar + // starts at 340 and runs to about 448, so there is room for both. + %this.documentButtonBar = new GuiChainCtrl() + { + Class = "EditorButtonBar"; + HorizSizing = "left"; + Position = "514 5"; + Extent = "0 24"; + ChildSpacing = 4; + IsVertical = false; + Tool = %this; + Visible = false; + }; + ThemeManager.setProfile(%this.documentButtonBar, "emptyProfile"); + %this.add(%this.documentButtonBar); + %this.documentButtonBar.addButton("SaveAsset", $EditorIcon::save, "Save Asset", "getSaveAssetEnabled"); + %this.documentButtonBar.addButton("RevertAsset", $EditorIcon::reload, "Revert Asset", "getRevertAssetEnabled"); + %this.documentButtonBar.addButton("DuplicateAsset", $EditorIcon::clipboard_copy, "Duplicate Asset", ""); + %this.documentButtonBar.addButton("UndoAsset", $EditorIcon::undo, "Undo", "getUndoAssetEnabled", "getUndoAssetTooltip"); + %this.documentButtonBar.addButton("RedoAsset", $EditorIcon::redo, "Redo", "getRedoAssetEnabled", "getRedoAssetTooltip"); + %this.tabBook = new GuiTabBookCtrl() { Class = AssetInspectorTabBook; @@ -348,11 +373,236 @@ class = "AssetAnimationInspectorPane"; %this.tabBook.Visible = false; %this.emitterButtonBar.visible = false; %this.deleteAssetButton.visible = false; + %this.documentButtonBar.visible = false; // Nothing is selected, so nothing is bound. The pane keeps its rows. %this.chooseInspector(""); } +//----------------------------------------------------------------------------- +// The document bar: Save, Revert, Duplicate, Undo, Redo. +// +// These act on the asset as a whole. Note the asset they act on is the ASSET, +// never the emitter that may be showing in the inspector instead -- an emitter +// has no file of its own, and saving one means saving the particle asset that +// owns it. documentAsset() is what settles that. +//----------------------------------------------------------------------------- + +// Start keeping undo history for an asset, and show the bar. Every load method +// calls this with whatever it just put on show. +function AssetInspector::beginDocument(%this, %asset) +{ + if(!isObject(%asset)) + { + return; + } + + AssetAdmin.undoRecorder.track(%asset); + + %this.documentButtonBar.visible = true; + %this.refreshDocumentBar(); +} + +// The asset the document buttons act on. +// +// inspectedObject() answers with the emitter when one is selected in the title +// dropdown, because that is what the field rows are editing. An emitter is not a +// document, so ask it who owns it. +function AssetInspector::documentAsset(%this) +{ + %asset = %this.inspectedObject(); + + if(!isObject(%asset)) + { + return 0; + } + + if(%this.titleDropDown.visible && %this.titleDropDown.getSelectedItem() != 0) + { + return %asset.getOwner(); + } + + return %asset; +} + +function AssetInspector::refreshDocumentBar(%this) +{ + if(%this.documentButtonBar.visible) + { + %this.documentButtonBar.refreshEnabled(); + } +} + +function AssetInspector::getSaveAssetEnabled(%this) +{ + %asset = %this.documentAsset(); + + return isObject(%asset) && %asset.isAssetDirty(); +} + +// Revert is offered for exactly as long as there is something to throw away. +function AssetInspector::getRevertAssetEnabled(%this) +{ + return %this.getSaveAssetEnabled(); +} + +function AssetInspector::getUndoAssetEnabled(%this) +{ + %asset = %this.documentAsset(); + + return isObject(%asset) && AssetAdmin.undoRecorder.getUndoCount(%asset.getAssetId()) > 0; +} + +function AssetInspector::getRedoAssetEnabled(%this) +{ + %asset = %this.documentAsset(); + + return isObject(%asset) && AssetAdmin.undoRecorder.getRedoCount(%asset.getAssetId()) > 0; +} + +function AssetInspector::getUndoAssetTooltip(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset)) + { + return "Undo"; + } + + %label = AssetAdmin.undoRecorder.getUndoLabel(%asset.getAssetId()); + + return (%label $= "") ? "Undo" : ("Undo" SPC %label); +} + +function AssetInspector::getRedoAssetTooltip(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset)) + { + return "Redo"; + } + + %label = AssetAdmin.undoRecorder.getRedoLabel(%asset.getAssetId()); + + return (%label $= "") ? "Redo" : ("Redo" SPC %label); +} + +function AssetInspector::SaveAsset(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset) || !%asset.isAssetDirty()) + { + return; + } + + %assetId = %asset.getAssetId(); + + if(!%asset.saveAsset()) + { + return; + } + + AssetAdmin.undoRecorder.onAssetSaved(%assetId); + %this.refreshDocumentBar(); +} + +function AssetInspector::RevertAsset(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset) || !%asset.isAssetDirty()) + { + return; + } + + if(!%asset.revertAsset()) + { + return; + } + + // The undo history described the document that was just thrown away. + AssetAdmin.undoRecorder.onAssetReverted(%asset); + + // A revert can change anything, including which tabs and rows apply, so this + // reloads rather than refreshing in place. + %this.reloadDocument(%asset); +} + +function AssetInspector::UndoAsset(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset)) + { + return; + } + + if(AssetAdmin.undoRecorder.undo(%asset)) + { + %this.reloadDocument(%asset); + } +} + +function AssetInspector::RedoAsset(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset)) + { + return; + } + + if(AssetAdmin.undoRecorder.redo(%asset)) + { + %this.reloadDocument(%asset); + } +} + +function AssetInspector::DuplicateAsset(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset)) + { + return; + } + + // One field is 50, the feedback line is 96 with room to grow into, and the + // buttons want 34 and a margin. Plus the 34 the title bar and border take out + // of the window before the content sees any of it. + %width = 420; + %height = 250; + + %dialog = new GuiControl() + { + class = "DuplicateAssetDialog"; + superclass = "EditorDialog"; + dialogSize = (%width + 8) SPC (%height + 8); + dialogCanClose = true; + dialogResizable = false; + dialogText = "Duplicate Asset"; + sourceAssetId = %asset.getAssetId(); + }; + %dialog.init(%width, %height); + + Canvas.pushDialog(%dialog); +} + +// Put the asset back on show from scratch. +// +// An undo or a revert can move anything, including the things that decide which +// tabs exist -- explicit mode, the emitter list, named cells mode -- so the tile +// is re-clicked rather than the rows being refreshed in place. Clearing +// chosenButton is what makes onClick treat it as a fresh selection instead of one +// of the re-clicks it usually gets. +function AssetInspector::reloadDocument(%this, %asset) +{ + %button = AssetAdmin.chosenButton; + + if(isObject(%button)) + { + AssetAdmin.chosenButton = ""; + %button.onClick(); + } + + %this.refreshDocumentBar(); +} + function AssetInspector::resetInspector(%this) { %this.titlebar.setText(""); @@ -380,6 +630,8 @@ class = "AssetAnimationInspectorPane"; %this.tabBook.selectPage(0); %this.titlebar.setText("Image Asset:" SPC %imageAsset.AssetName); + %this.beginDocument(%imageAsset); + %this.chooseInspector("Image"); %this.imagePane.bind(%imageAsset, %assetID); @@ -391,12 +643,17 @@ class = "AssetAnimationInspectorPane"; { %this.resetInspector(); %this.titlebar.setText("Animation Asset:" SPC %animationAsset.AssetName); - - // Named cells fall back to the generic inspector, and not out of caution: the - // engine's named-frame API does not round-trip -- its getter formats strings - // through %d, and the field joins with commas while the setter splits on - // whitespace, so a named list does not survive its own TAML file. A pane built - // on that would be a pane that quietly loses work. + %this.beginDocument(%animationAsset); + + // Named cells still fall back to the generic inspector. + // + // The reason they used to is now gone: NamedAnimationFrames is a + // TypeStringTableEntryVector, whose getter joins with commas, and + // setNamedAnimationFrames split on whitespace alone -- so a named list did not + // survive its own TAML file, and a pane built on it would have quietly lost + // work. The setter accepts commas now (AnimationAsset.cc), so a named-cells + // pane is buildable. It is simply not built yet, which is a job of its own + // rather than a hazard. if(%animationAsset.getNamedCellsMode()) { %this.inspectStock(%animationAsset); @@ -411,6 +668,7 @@ class = "AssetAnimationInspectorPane"; { %this.resetInspector(); %this.titleDropDown.visible = true; + %this.beginDocument(%particleAsset); %this.refreshParticleTitleDropDown(%particleAsset, 0); %this.titleDropDown.Command = %this.getId() @ ".onChooseParticleAsset(" @ %particleAsset.getId() @ ");"; @@ -468,6 +726,7 @@ class = "AssetAnimationInspectorPane"; { %this.resetInspector(); %this.titlebar.setText("Font Asset:" SPC %fontAsset.AssetName); + %this.beginDocument(%fontAsset); %this.inspectStock(%fontAsset); } @@ -476,6 +735,7 @@ class = "AssetAnimationInspectorPane"; { %this.resetInspector(); %this.titlebar.setText("Audio Asset:" SPC %audioAsset.AssetName); + %this.beginDocument(%audioAsset); %this.inspectStock(%audioAsset); } @@ -484,6 +744,7 @@ class = "AssetAnimationInspectorPane"; { %this.resetInspector(); %this.titlebar.setText("Spine Asset:" SPC %spineAsset.AssetName); + %this.beginDocument(%spineAsset); %this.inspectStock(%spineAsset); } diff --git a/editor/AssetAdmin/AssetLibraryWindow.cs b/editor/AssetAdmin/AssetLibraryWindow.cs index 7ce81a178..6434aca8b 100644 --- a/editor/AssetAdmin/AssetLibraryWindow.cs +++ b/editor/AssetAdmin/AssetLibraryWindow.cs @@ -375,6 +375,22 @@ class = "EditorChoiceRow"; } } +// An asset gained or lost unsaved changes. Only the tile's caption is affected -- +// no re-sort and no re-filter, because the mark is not part of the name anything +// is sorted or searched by. +function AssetLibraryWindow::onAssetDirtyChanged(%this, %assetID) +{ + for(%i = 0; %i < %this.dictionaryCount; %i++) + { + %button = %this.dictionary[%i].getButton(%assetID); + if(isObject(%button)) + { + %button.refreshDirtyMark(); + return; + } + } +} + function AssetLibraryWindow::unloadAssets(%this) { for(%i = 0; %i < %this.dictionaryCount; %i++) diff --git a/editor/AssetAdmin/AssetUndoRecorder.cs b/editor/AssetAdmin/AssetUndoRecorder.cs new file mode 100644 index 000000000..01c889fe9 --- /dev/null +++ b/editor/AssetAdmin/AssetUndoRecorder.cs @@ -0,0 +1,478 @@ +//----------------------------------------------------------------------------- +// Undo and redo for assets. +// +// The unit of undo here is a whole asset, snapshotted. That is not the shape the +// Gui Editor uses -- its recorder stores individual field writes -- and the +// reason for the difference is that two of the Asset Manager's editors never +// reach TorqueScript at all. GuiParticleGraphInspector does every graph key drag +// in C++, and the stock GuiInspector writes particle, emitter, font and audio +// fields straight onto the object. A recorder built out of script-side writes +// would be blind to both, which is to say blind to the particle editor, which is +// the thing that most needed undo in the first place. +// +// What every change does reach is AssetBase::onRefresh. So this keeps a snapshot +// of each asset as it currently stands -- the baseline -- and when a change is +// announced, the baseline is what the asset looked like before it. Push that, +// take a new one, and the history builds itself no matter who made the change or +// in what language. +// +// Snapshots are engine objects (AssetBase::createStateSnapshot). They are +// unowned, so making one is inert: no file, no dirty mark, no notification, and +// for an image no texture work. +// +// Each asset gets its own history, because assets are edited independently and +// any of them can be left unsaved. See [[AssetAdmin]]. +// +// NOTE ON ARGUMENTS: the methods that need to read or write an asset take the +// asset OBJECT, not its id, and every caller already has one. Looking an asset up +// by id from script means acquireAsset/releaseAsset, and that pair is not free of +// consequences: releasing an asset whose reference count was zero unloads it, so +// merely asking about an asset could delete it. +//----------------------------------------------------------------------------- + +// How many steps to keep per asset. A snapshot of a particle asset with a few +// emitters is not free, and no one is stepping back further than this by hand. +$AssetUndoRecorder::maxSteps = 50; + +function AssetUndoRecorder::onAdd(%this) +{ + // The snapshots live in groups so that dropping a history deletes every + // snapshot in it -- a SimSet would leave them behind. + %this.history = new SimGroup(); + + // Nesting depth of the current transaction, and whether it has already + // pushed a step. See begin(). + %this.txDepth = 0; + %this.txPushed = false; + + // Set by whoever is about to change something, consumed by the step it + // produces. + %this.pendingLabel = ""; + + // True while this recorder is the one doing the changing, so that replaying a + // step does not record itself. + %this.replaying = false; +} + +function AssetUndoRecorder::onRemove(%this) +{ + if(isObject(%this.history)) + { + %this.history.deleteObjects(); + %this.history.delete(); + } +} + +//----------------------------------------------------------------------------- +// Per-asset bookkeeping. +// +// Three things are kept for each asset: the baseline snapshot, a group of undo +// steps and a group of redo steps, all indexed by asset id. +//----------------------------------------------------------------------------- + +function AssetUndoRecorder::isTracking(%this, %assetId) +{ + return isObject(%this.undoGroup[%assetId]); +} + +// Start keeping history for an asset, if we are not already. Called when an +// asset is loaded into the inspector. +function AssetUndoRecorder::track(%this, %asset) +{ + if(!isObject(%asset)) + { + return; + } + + %assetId = %asset.getAssetId(); + if(%assetId $= "" || %this.isTracking(%assetId)) + { + return; + } + + %this.undoGroup[%assetId] = new SimGroup(); + %this.redoGroup[%assetId] = new SimGroup(); + %this.history.add(%this.undoGroup[%assetId]); + %this.history.add(%this.redoGroup[%assetId]); + + %this.baseline[%assetId] = %this.takeSnapshot(%asset); +} + +// Forget an asset's history. Reverting does this, because the steps describe a +// document that no longer exists; so does deleting the asset. +function AssetUndoRecorder::forget(%this, %assetId) +{ + if(!%this.isTracking(%assetId)) + { + return; + } + + %this.undoGroup[%assetId].deleteObjects(); + %this.undoGroup[%assetId].delete(); + %this.undoGroup[%assetId] = ""; + + %this.redoGroup[%assetId].deleteObjects(); + %this.redoGroup[%assetId].delete(); + %this.redoGroup[%assetId] = ""; + + if(isObject(%this.baseline[%assetId])) + { + %this.baseline[%assetId].delete(); + } + %this.baseline[%assetId] = ""; +} + +// A snapshot of the asset as it stands, tagged with what its unsaved state was +// at the time. The tag is what lets an undo that lands back on the saved state +// report the asset as clean again. +// +// Every snapshot goes into the history group immediately, including the ones that +// are only ever a baseline. createStateSnapshot hands back a registered object +// that belongs to nobody, and a baseline is not in either stack -- so without a +// home here it would still be alive after this recorder was deleted. Pushing one +// onto a stack later just reparents it, which is what a SimGroup add does. +function AssetUndoRecorder::takeSnapshot(%this, %asset) +{ + %snapshot = %asset.createStateSnapshot(); + + if(isObject(%snapshot)) + { + %this.setWasDirty(%snapshot, %asset.isAssetDirty()); + %this.setStepLabel(%snapshot, ""); + %this.history.add(%snapshot); + } + + return %snapshot; +} + +//----------------------------------------------------------------------------- +// What the recorder knows about each snapshot: what to call the step, and +// whether the asset counted as unsaved at that point. +// +// Kept HERE, keyed by the snapshot's id, and deliberately not as fields on the +// snapshot itself. A snapshot is copied onto the live asset when it is restored, +// and copyFieldsFrom carries dynamic fields across -- so a stepLabel written on a +// snapshot ends up on the asset, and from there into the asset's .taml the next +// time it is saved. Which is exactly what happened: real content files grew +// stepLabel="Set Frames" wasDirty="1" the first time anyone undid and saved. +// +// An object id can be reused once its snapshot has been deleted, but every +// snapshot has both of these written by takeSnapshot before anything reads them, +// so a recycled id is always overwritten rather than inherited. +//----------------------------------------------------------------------------- + +function AssetUndoRecorder::setStepLabel(%this, %snapshot, %label) +{ + %this.stepLabelOf[%snapshot] = %label; +} + +function AssetUndoRecorder::getStepLabel(%this, %snapshot) +{ + return %this.stepLabelOf[%snapshot]; +} + +function AssetUndoRecorder::setWasDirty(%this, %snapshot, %wasDirty) +{ + %this.wasDirtyOf[%snapshot] = %wasDirty; +} + +function AssetUndoRecorder::getWasDirty(%this, %snapshot) +{ + return %this.wasDirtyOf[%snapshot]; +} + +//----------------------------------------------------------------------------- +// Transactions. +// +// One thing the user did should be one step, and a couple of paths in the Asset +// Manager write twice for a single action -- committing animation frames also +// rewrites the frame rate when Keep Frame Rate is on. Wrapping those in +// begin/end folds them together: the first change inside the transaction pushes +// a step, and the rest only move the baseline forward. +// +// Nesting is by depth, so an inner begin/end pair inside an outer one is +// absorbed rather than closing the transaction early. +//----------------------------------------------------------------------------- + +function AssetUndoRecorder::begin(%this, %label) +{ + if(%this.txDepth == 0) + { + %this.txPushed = false; + %this.pendingLabel = %label; + } + + %this.txDepth++; +} + +function AssetUndoRecorder::end(%this) +{ + %this.txDepth--; + + if(%this.txDepth <= 0) + { + %this.txDepth = 0; + %this.txPushed = false; + %this.pendingLabel = ""; + } +} + +// What the next step will be called. Set by the script that is about to make a +// change and knows what to call it; anything that does not say gets "Edit", +// which is what every change made in C++ gets. +function AssetUndoRecorder::setLabel(%this, %label) +{ + if(%this.txDepth == 0) + { + %this.pendingLabel = %label; + } +} + +//----------------------------------------------------------------------------- +// Recording. +//----------------------------------------------------------------------------- + +// An asset announced a change. %direct is false when the announcement is only a +// cascade -- something this asset reads from was changed, and nothing this asset +// saves has moved -- so there is nothing to record. +function AssetUndoRecorder::onAssetChanged(%this, %asset, %direct) +{ + if(%this.replaying || !%direct || !isObject(%asset)) + { + return; + } + + %assetId = %asset.getAssetId(); + if(!%this.isTracking(%assetId)) + { + return; + } + + // Inside a transaction, only the first change opens a step. + if(%this.txDepth > 0 && %this.txPushed) + { + %this.rebaseline(%asset); + return; + } + + %before = %this.baseline[%assetId]; + if(!isObject(%before)) + { + %this.rebaseline(%asset); + return; + } + + %this.setStepLabel(%before, (%this.pendingLabel $= "") ? "Edit" : %this.pendingLabel); + + %this.undoGroup[%assetId].add(%before); + %this.trimHistory(%assetId); + + // A new change is a new future: whatever was undone is no longer reachable. + %this.redoGroup[%assetId].deleteObjects(); + + // The baseline object was handed to the undo group, so this is a fresh one + // rather than a move. + %this.baseline[%assetId] = %this.takeSnapshot(%asset); + + if(%this.txDepth > 0) + { + %this.txPushed = true; + } + else + { + %this.pendingLabel = ""; + } + + %this.refreshUI(); +} + +// Move the baseline to where the asset is now, without recording anything. +function AssetUndoRecorder::rebaseline(%this, %asset) +{ + %assetId = %asset.getAssetId(); + + if(isObject(%this.baseline[%assetId])) + { + %this.baseline[%assetId].delete(); + } + + %this.baseline[%assetId] = %this.takeSnapshot(%asset); +} + +function AssetUndoRecorder::trimHistory(%this, %assetId) +{ + %group = %this.undoGroup[%assetId]; + + while(%group.getCount() > $AssetUndoRecorder::maxSteps) + { + // The oldest step is the one at the front. + %oldest = %group.getObject(0); + %group.remove(%oldest); + %oldest.delete(); + } +} + +//----------------------------------------------------------------------------- +// Stepping. +//----------------------------------------------------------------------------- + +function AssetUndoRecorder::getUndoCount(%this, %assetId) +{ + return %this.isTracking(%assetId) ? %this.undoGroup[%assetId].getCount() : 0; +} + +function AssetUndoRecorder::getRedoCount(%this, %assetId) +{ + return %this.isTracking(%assetId) ? %this.redoGroup[%assetId].getCount() : 0; +} + +function AssetUndoRecorder::getUndoLabel(%this, %assetId) +{ + %count = %this.getUndoCount(%assetId); + + return (%count == 0) ? "" : %this.getStepLabel(%this.undoGroup[%assetId].getObject(%count - 1)); +} + +function AssetUndoRecorder::getRedoLabel(%this, %assetId) +{ + %count = %this.getRedoCount(%assetId); + + return (%count == 0) ? "" : %this.getStepLabel(%this.redoGroup[%assetId].getObject(%count - 1)); +} + +function AssetUndoRecorder::undo(%this, %asset) +{ + if(!isObject(%asset)) + { + return false; + } + + %assetId = %asset.getAssetId(); + if(%this.getUndoCount(%assetId) == 0) + { + return false; + } + + %group = %this.undoGroup[%assetId]; + %step = %group.getObject(%group.getCount() - 1); + %group.remove(%step); + + // Where we are now becomes the way back. + %current = %this.baseline[%assetId]; + %this.setStepLabel(%current, %this.getStepLabel(%step)); + %this.redoGroup[%assetId].add(%current); + + %this.applyStep(%asset, %step); + + return true; +} + +function AssetUndoRecorder::redo(%this, %asset) +{ + if(!isObject(%asset)) + { + return false; + } + + %assetId = %asset.getAssetId(); + if(%this.getRedoCount(%assetId) == 0) + { + return false; + } + + %group = %this.redoGroup[%assetId]; + %step = %group.getObject(%group.getCount() - 1); + %group.remove(%step); + + %current = %this.baseline[%assetId]; + %this.setStepLabel(%current, %this.getStepLabel(%step)); + %this.undoGroup[%assetId].add(%current); + + %this.applyStep(%asset, %step); + + return true; +} + +// Put a snapshot back onto the asset and make it the new baseline. +// +// The snapshot is not deleted: it becomes the baseline, which is exactly what it +// describes. The replaying guard stops the change this causes from being +// recorded as a fresh step. +function AssetUndoRecorder::applyStep(%this, %asset, %step) +{ + %assetId = %asset.getAssetId(); + + %this.replaying = true; + %asset.restoreStateSnapshot(%step); + %this.replaying = false; + + // restoreStateSnapshot deliberately leaves the unsaved state alone, because + // only this knows what the restored state means: back at the last save is + // clean, anywhere else is not. + AssetDatabase.setAssetDirty(%assetId, %this.getWasDirty(%step)); + + // Taken off its stack above, so it needs a home again as the baseline. + %this.history.add(%step); + %this.baseline[%assetId] = %step; + + %this.refreshUI(); +} + +//----------------------------------------------------------------------------- +// Saving and reverting. +// +// Both settle the asset against its file, so both change what every step in its +// history means about being saved. +//----------------------------------------------------------------------------- + +// After a save, the state we are in is the saved one -- but every step still in +// the history describes a state that is not, and so does every redo step. +function AssetUndoRecorder::onAssetSaved(%this, %assetId) +{ + if(!%this.isTracking(%assetId)) + { + return; + } + + %undoGroup = %this.undoGroup[%assetId]; + for(%i = 0; %i < %undoGroup.getCount(); %i++) + { + %this.setWasDirty(%undoGroup.getObject(%i), true); + } + + %redoGroup = %this.redoGroup[%assetId]; + for(%i = 0; %i < %redoGroup.getCount(); %i++) + { + %this.setWasDirty(%redoGroup.getObject(%i), true); + } + + if(isObject(%this.baseline[%assetId])) + { + %this.setWasDirty(%this.baseline[%assetId], false); + } + + %this.refreshUI(); +} + +// A revert throws the document away and starts again from the file, so the steps +// that described the old one go with it. +function AssetUndoRecorder::onAssetReverted(%this, %asset) +{ + if(!isObject(%asset)) + { + return; + } + + %this.forget(%asset.getAssetId()); + %this.track(%asset); + + %this.refreshUI(); +} + +function AssetUndoRecorder::refreshUI(%this) +{ + if(isObject(AssetAdmin.inspector)) + { + AssetAdmin.inspector.refreshDocumentBar(); + } +} diff --git a/editor/AssetAdmin/DuplicateAssetDialog.cs b/editor/AssetAdmin/DuplicateAssetDialog.cs new file mode 100644 index 000000000..a04e8c187 --- /dev/null +++ b/editor/AssetAdmin/DuplicateAssetDialog.cs @@ -0,0 +1,189 @@ +//----------------------------------------------------------------------------- +// Branch an asset. +// +// Duplicating copies the asset AS IT STANDS IN MEMORY, unsaved edits and all -- +// that is what makes it useful next to undo. Try something on a particle, decide +// you want to keep both, duplicate, and the copy has what is on screen rather +// than what was last written to disk. +// +// The copy is written and declared immediately. A declared asset has to have a +// file: there is no such thing as an asset that exists only in memory and still +// appears in the library. So the copy starts saved, and the original keeps +// whatever unsaved state it had. +// +// The engine does the work in AssetManager::duplicateAsset. This is the name and +// the module, and the checks that stop the copy landing somewhere that will not +// have it. +//----------------------------------------------------------------------------- + +function DuplicateAssetDialog::init(%this, %width, %height) +{ + //Get the dialog contents + %window = %this.getObject(0); + %content = %window.getObject(0); + + %form = new GuiGridCtrl() + { + class = "EditorForm"; + extent = %width SPC %height; + cellSizeX = %width; + cellSizeY = 50; + }; + %form.addListener(%this); + + %item = %form.addFormItem("New Asset Name", %width SPC 30); + %this.assetNameBox = %form.createTextEditItem(%item); + %this.assetNameBox.Command = %this.getId() @ ".Validate();"; + + %content.add(%form); + + // Below whatever the form actually came out as, rather than below a number + // written here. + %formBottom = getWord(%form.getPosition(), 1) + getWord(%form.getExtent(), 1); + + // The feedback line says quite a lot in the refusal cases -- the library + // module one runs to three lines at this width -- and textExtend grows the + // control downward to fit. Hence the height it starts at, and the clearance + // below it before the buttons. + %this.feedback = new GuiControl() + { + HorizSizing = "width"; + VertSizing = "anchorTop"; + Position = "12" SPC (%formBottom + 8); + Extent = (%width - 24) SPC 96; + text = ""; + textWrap = true; + textExtend = true; + }; + ThemeManager.setProfile(%this.feedback, "infoProfile"); + + // Measured from the room the content actually has, not from the dialog's own + // height -- the title bar and border take 34 of it. + %bottom = %this.contentHeight() - 12; + + %this.cancelButton = new GuiButtonCtrl() + { + HorizSizing = "anchorRight"; + VertSizing = "anchorBottom"; + Position = (%width - 222) SPC (%bottom - 32); + Extent = "100 30"; + Text = "Cancel"; + Command = %this.getID() @ ".onClose();"; + }; + ThemeManager.setProfile(%this.cancelButton, "buttonProfile"); + + %this.duplicateButton = new GuiButtonCtrl() + { + HorizSizing = "anchorRight"; + VertSizing = "anchorBottom"; + Position = (%width - 112) SPC (%bottom - 34); + Extent = "100 34"; + Text = "Duplicate"; + Command = %this.getID() @ ".onDuplicate();"; + }; + ThemeManager.setProfile(%this.duplicateButton, "primaryButtonProfile"); + + %content.add(%this.feedback); + %content.add(%this.cancelButton); + %content.add(%this.duplicateButton); + + // A name that is free to begin with, so the dialog opens ready to go. + %this.assetNameBox.setText(%this.suggestName()); + + %this.Validate(); +} + +// "thing" becomes "thingCopy", then "thingCopy2", "thingCopy3" and so on until +// one of them is not taken. +function DuplicateAssetDialog::suggestName(%this) +{ + %sourceName = AssetDatabase.getAssetName(%this.sourceAssetId); + %moduleName = getUnit(%this.sourceAssetId, 0, ":"); + + %candidate = %sourceName @ "Copy"; + %suffix = 2; + + while(AssetDatabase.isDeclaredAsset(%moduleName @ ":" @ %candidate)) + { + %candidate = %sourceName @ "Copy" @ %suffix; + %suffix++; + } + + return %candidate; +} + +// Where the copy will be written: beside the asset it came from, with the same +// file extension, so the module's DeclaredAssets glob picks it up. A copy that +// landed under a different extension would be written and then never seen again. +function DuplicateAssetDialog::targetPath(%this, %assetName) +{ + %sourcePath = AssetDatabase.getAssetFilePath(%this.sourceAssetId); + + // fileBase strips only the last extension, and these are doubled -- + // "rocket.image.taml" -- so take everything after the first dot of the name. + %sourceFile = fileName(%sourcePath); + %firstDot = strpos(%sourceFile, "."); + %extension = (%firstDot == -1) ? "asset.taml" : getSubStr(%sourceFile, %firstDot + 1, strlen(%sourceFile)); + + return pathConcat(filePath(%sourcePath), %assetName @ "." @ %extension); +} + +function DuplicateAssetDialog::Validate(%this) +{ + %this.duplicateButton.active = false; + + %assetName = %this.assetNameBox.getText(); + %moduleName = getUnit(%this.sourceAssetId, 0, ":"); + + if(%assetName $= "") + { + %this.feedback.setText("The copy must have an Asset Name."); + return false; + } + + if(AssetDatabase.isDeclaredAsset(%moduleName @ ":" @ %assetName)) + { + %this.feedback.setText("An asset by this name already exists in this module. Try choosing a different name."); + return false; + } + + %module = AssetDatabase.getAssetModule(%this.sourceAssetId); + if(isObject(%module) && %module.Synchronized) + { + %this.feedback.setText("You cannot add assets to a library module. Updates to the module would remove your assets. Instead, copy this asset into your own module."); + return false; + } + + %this.duplicateButton.active = true; + %this.feedback.setText("The copy will be made beside the original, and will include any changes you have not saved."); + return true; +} + +function DuplicateAssetDialog::onDuplicate(%this) +{ + if(!%this.Validate()) + { + return; + } + + %assetName = %this.assetNameBox.getText(); + %moduleName = getUnit(%this.sourceAssetId, 0, ":"); + %assetId = %moduleName @ ":" @ %assetName; + %assetType = AssetDatabase.getAssetType(%this.sourceAssetId); + + if(!AssetDatabase.duplicateAsset(%this.sourceAssetId, %this.targetPath(%assetName), %assetName)) + { + %this.feedback.setText("The copy could not be made. See the console for what went wrong."); + return; + } + + // Put it in the library and select it, the same way a newly created asset is. + %button = AssetAdmin.Dictionary[%assetType].getButton(%assetId); + if(!isObject(%button)) + { + %button = AssetAdmin.Dictionary[%assetType].addButton(%assetId); + } + %button.onClick(); + + %this.onClose(); +} diff --git a/editor/AssetAdmin/Inspector/AssetInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs index c44b46df0..73c5c4332 100644 --- a/editor/AssetAdmin/Inspector/AssetInspectorPane.cs +++ b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs @@ -421,7 +421,13 @@ class = "EditorFieldRow"; return; } - // The write lands in refreshAsset, which comes straight back at + // Name the undo step after the field being changed, so the tooltip reads + // "Undo Cell Width" rather than "Undo Edit". Only the paths that know what + // they did can do this; a particle graph drag happens entirely in C++ and gets + // the generic name. + AssetAdmin.undoRecorder.setLabel(%field); + + // The change lands in refreshAsset, which comes straight back at // onAssetRefreshed. The values here are already what the asset holds, so the // bounce has nothing to say; what follows it does. %this.committing = true; diff --git a/editor/AssetAdmin/NewAudioAssetDialog.cs b/editor/AssetAdmin/NewAudioAssetDialog.cs index ae7ea6b18..5f82565bd 100644 --- a/editor/AssetAdmin/NewAudioAssetDialog.cs +++ b/editor/AssetAdmin/NewAudioAssetDialog.cs @@ -169,8 +169,10 @@ class = "EditorForm"; %moduleDef = ModuleDatabase.findModule(%moduleName, %moduleVersion); AssetDatabase.addDeclaredAsset(%moduleDef, %assetPath); - //Refresh the asset so that the loose file will be a path relative to the asset file. - AssetDatabase.refreshAsset(%assetID); + //Save the asset so that the loose file will be a path relative to the asset + //file. That collapse happens in onTamlPreWrite, so it only happens on a + //write -- and refreshAsset no longer writes. + AssetDatabase.saveAsset(%assetID); //Do we already have this button? %button = AssetAdmin.Dictionary["AudioAsset"].getButton(%assetID); diff --git a/editor/AssetAdmin/NewFontAssetDialog.cs b/editor/AssetAdmin/NewFontAssetDialog.cs index 54f0483e5..a4384e1f1 100644 --- a/editor/AssetAdmin/NewFontAssetDialog.cs +++ b/editor/AssetAdmin/NewFontAssetDialog.cs @@ -169,8 +169,10 @@ class = "EditorForm"; %moduleDef = ModuleDatabase.findModule(%moduleName, %moduleVersion); AssetDatabase.addDeclaredAsset(%moduleDef, %assetPath); - //Refresh the asset so that the loose file will be a path relative to the asset file. - AssetDatabase.refreshAsset(%assetID); + //Save the asset so that the loose file will be a path relative to the asset + //file. That collapse happens in onTamlPreWrite, so it only happens on a + //write -- and refreshAsset no longer writes. + AssetDatabase.saveAsset(%assetID); //Do we already have this button? %button = AssetAdmin.Dictionary["FontAsset"].getButton(%assetID); diff --git a/editor/AssetAdmin/NewImageAssetDialog.cs b/editor/AssetAdmin/NewImageAssetDialog.cs index 3e25a0287..a7497f01d 100644 --- a/editor/AssetAdmin/NewImageAssetDialog.cs +++ b/editor/AssetAdmin/NewImageAssetDialog.cs @@ -176,8 +176,10 @@ class = "EditorForm"; %moduleDef = ModuleDatabase.findModule(%moduleName, %moduleVersion); AssetDatabase.addDeclaredAsset(%moduleDef, %assetPath); - //Refresh the asset so that the loose file will be a path relative to the asset file. - AssetDatabase.refreshAsset(%assetID); + //Save the asset so that the loose file will be a path relative to the asset + //file. That collapse happens in onTamlPreWrite, so it only happens on a + //write -- and refreshAsset no longer writes. + AssetDatabase.saveAsset(%assetID); //Do we already have this button? %button = AssetAdmin.Dictionary["ImageAsset"].getButton(%assetID); diff --git a/editor/EditorCore/EditorCore.cs b/editor/EditorCore/EditorCore.cs index b08636258..b06ebcbcf 100644 --- a/editor/EditorCore/EditorCore.cs +++ b/editor/EditorCore/EditorCore.cs @@ -481,14 +481,35 @@ } } -// Run %command, unless there is a Gui being authored with changes in it - in -// which case the Gui Editor asks about those first and runs it afterwards, or -// not at all. Used by Close Project and Exit, which are the two commands in this -// menu that discard a document without being able to give it back. +// Run %command, unless an editor is holding something the command would discard +// without being able to give it back. Used by Close Project and Exit. // -// The Gui Editor is the only editor with a document to lose. If another ever -// grows one, this is where it joins in. +// Two editors have something to lose now, and they are asked one at a time. Each +// guard either runs what it was given or takes the decision away and hands it on +// once the user has answered, so the chain reads: +// +// guardedCommand the Asset Manager's unsaved assets, then... +// guardedCommandAfterAssets the Gui Editor's unsaved document, then... +// eval the command itself +// +// A third editor with a document joins at the front of that chain, not by being +// added to a list: the answers are not interchangeable, and each guard needs to +// name the one that follows it. function EditorCore::guardedCommand(%this, %command) +{ + if(isObject(AssetAdmin) && AssetAdmin.hasUnsavedAssets()) + { + AssetAdmin.guardAssets(%command); + return; + } + + %this.guardedCommandAfterAssets(%command); +} + +// The rest of the chain, once the Asset Manager has been dealt with. Discarding +// unsaved assets comes back in here rather than at the top, because the assets +// are still unsaved and asking again would never end. +function EditorCore::guardedCommandAfterAssets(%this, %command) { if(isObject(GuiEditor)) { diff --git a/editor/EditorCore/EditorDialog.cs b/editor/EditorCore/EditorDialog.cs index 2308b13a6..4e1a4dd61 100644 --- a/editor/EditorCore/EditorDialog.cs +++ b/editor/EditorCore/EditorDialog.cs @@ -20,10 +20,32 @@ // IN THE SOFTWARE. //----------------------------------------------------------------------------- +// The room a dialog's content actually gets: the window's extent less its +// border, and less the 30 pixel title bar above it. +// +// Worth having as a function because getting it wrong is invisible until the +// content is tall enough to reach the bottom -- a button positioned from the +// dialog's own height rather than from this lands below the fold, reachable only +// by scrolling, which is how both the Frame Range and Duplicate dialogs shipped. +function EditorDialog::contentWidth(%this) +{ + return getWord(%this.dialogSize, 0) - 8; +} + +function EditorDialog::contentHeight(%this) +{ + return getWord(%this.dialogSize, 1) - 34; +} + function EditorDialog::onAdd(%this) { ThemeManager.setProfile(%this, "overlayProfile"); + // Resizable unless the dialog says otherwise. A form whose contents are laid + // out at fixed positions gains nothing from being dragged bigger and loses + // something from being dragged smaller, so those set dialogResizable = false. + %resizable = (%this.dialogResizable $= "") ? true : %this.dialogResizable; + %this.window = new GuiWindowCtrl() { class = "EditorDialogWindow"; @@ -36,6 +58,8 @@ class = "EditorDialogWindow"; canMove = true; CanMinimize = false; CanMaximize = false; + resizeWidth = %resizable; + resizeHeight = %resizable; titleHeight = 30; dialog = %this; }; diff --git a/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs b/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs index ecfe9973c..a3bb52e1c 100644 --- a/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs +++ b/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs @@ -2077,6 +2077,19 @@ borderRight = %spacerBorder; borderBottom = %spacerBorder; }; + + %this.impactProfile = new GuiControlProfile() + { + fillColor = %this.color5; + fontType = %this.font[3]; + fontDirectory = %this.fontDirectory; + fontSize = 16; + fontColor = %this.color1; + align = "center"; + vAlign = "middle"; + + borderDefault = %this.emptyBorder; + }; } function BaseTheme::makeGuiEditorProfile(%this) diff --git a/engine/source/2d/assets/AnimationAsset.cc b/engine/source/2d/assets/AnimationAsset.cc index 9d6582d20..f306a1f8e 100755 --- a/engine/source/2d/assets/AnimationAsset.cc +++ b/engine/source/2d/assets/AnimationAsset.cc @@ -154,34 +154,6 @@ void AnimationAsset::onAssetRefresh( void ) //------------------------------------------------------------------------------ -void AnimationAsset::copyTo(SimObject* object) -{ - // Call to parent. - Parent::copyTo(object); - - // Cast to asset. - AnimationAsset* pAsset = static_cast(object); - - // Sanity! - AssertFatal(pAsset != NULL, "AnimationAsset::copyTo() - Object is not the correct type."); - - // Copy state. - pAsset->setImage( getImage().getAssetId() ); - - // Are we in named cells mode? - if ( !pAsset->getNamedCellsMode() ) - pAsset->setAnimationFrames( Con::getData( TypeS32Vector, (void*)&getSpecifiedAnimationFrames(), 0 ) ); - else - pAsset->setNamedAnimationFrames( Con::getData( TypeStringTableEntryVector, (void*)&getSpecifiedNamedAnimationFrames(), 0 ) ); - - pAsset->setAnimationTime( getAnimationTime() ); - pAsset->setAnimationCycle( getAnimationCycle() ); - pAsset->setRandomStart( getRandomStart() ); - pAsset->setNamedCellsMode( getNamedCellsMode() ); -} - -//------------------------------------------------------------------------------ - void AnimationAsset::setImage( const char* pAssetId ) { // Ignore no change. @@ -205,6 +177,35 @@ void AnimationAsset::setAnimationFrames( const char* pAnimationFrames ) // Debug Profiling. PROFILE_SCOPE(AnimationAsset_SetAnimationFrames); + // Ignore no change, as every setter on AssetBase already does. + // + // This one did not, so writing the same frame list back counted as a change: + // it announced itself, marked the asset unsaved, and -- once the Asset Manager + // started recording undo -- left a step that put nothing back. The mode is + // part of the comparison because this setter also clears named-cells mode, so + // an identical list still has work to do if that mode is on. + if ( !mNamedCellsMode ) + { + const U32 currentCount = StringUnit::getUnitCount( pAnimationFrames, " \t\n" ); + + if ( currentCount == (U32)mAnimationFrames.size() ) + { + bool changed = false; + + for( U32 frameIndex = 0; frameIndex < currentCount; ++frameIndex ) + { + if ( dAtoi( StringUnit::getUnit( pAnimationFrames, frameIndex, " \t\n" ) ) != mAnimationFrames[frameIndex] ) + { + changed = true; + break; + } + } + + if ( !changed ) + return; + } + } + // Clear any existing frames. mAnimationFrames.clear(); @@ -229,19 +230,51 @@ void AnimationAsset::setAnimationFrames( const char* pAnimationFrames ) //------------------------------------------------------------------------------ +// The comma is not decoration. This field is a TypeStringTableEntryVector, and +// that type's getter -- the one TAML writes through, and the one copyFieldsFrom +// reads through -- joins its entries with commas (consoleTypes.cc, ConsoleGetType +// for TypeStringTableEntryVector). Splitting on whitespace alone meant everything +// that was written came back as a single frame named "head,body,tail": a named +// cells animation did not survive being saved and loaded, nor being copied. +// +// Numbered frames never had the problem, because TypeS32Vector's getter joins +// with spaces, which is what the sibling setter below already splits on. void AnimationAsset::setNamedAnimationFrames( const char* pAnimationFrames ) { + // Ignore no change, for the same reason as the numbered setter above. + if ( mNamedCellsMode ) + { + const U32 currentCount = StringUnit::getUnitCount( pAnimationFrames, " \t\n," ); + + if ( currentCount == (U32)mNamedAnimationFrames.size() ) + { + bool changed = false; + + for( U32 frameIndex = 0; frameIndex < currentCount; ++frameIndex ) + { + if ( StringTable->insert( StringUnit::getUnit( pAnimationFrames, frameIndex, " \t\n," ) ) != mNamedAnimationFrames[frameIndex] ) + { + changed = true; + break; + } + } + + if ( !changed ) + return; + } + } + // Clear any existing frames. mNamedAnimationFrames.clear(); // Fetch frame count. - const U32 frameCount = StringUnit::getUnitCount( pAnimationFrames, " \t\n" ); + const U32 frameCount = StringUnit::getUnitCount( pAnimationFrames, " \t\n," ); // Iterate frames. for( U32 frameIndex = 0; frameIndex < frameCount; ++frameIndex ) { // Store frame. - mNamedAnimationFrames.push_back( StringTable->insert( StringUnit::getUnit( pAnimationFrames, frameIndex, " \t\n" ) ) ); + mNamedAnimationFrames.push_back( StringTable->insert( StringUnit::getUnit( pAnimationFrames, frameIndex, " \t\n," ) ) ); } mNamedCellsMode = true; diff --git a/engine/source/2d/assets/AnimationAsset.h b/engine/source/2d/assets/AnimationAsset.h index d44e15d23..1693dceb7 100755 --- a/engine/source/2d/assets/AnimationAsset.h +++ b/engine/source/2d/assets/AnimationAsset.h @@ -62,7 +62,6 @@ class AnimationAsset : public AssetBase static void initPersistFields(); virtual bool onAdd(); virtual void onRemove(); - virtual void copyTo(SimObject* object); void setImage( const char* pAssetId ); inline const AssetPtr& getImage( void ) const { return mImageAsset; } diff --git a/engine/source/2d/assets/FontAsset.cc b/engine/source/2d/assets/FontAsset.cc index d86eb98b7..08bedafef 100644 --- a/engine/source/2d/assets/FontAsset.cc +++ b/engine/source/2d/assets/FontAsset.cc @@ -162,23 +162,6 @@ void FontAsset::setFontFile( const char* pFontFile ) //------------------------------------------------------------------------------ -void FontAsset::copyTo(SimObject* object) -{ - // Call to parent. - Parent::copyTo(object); - - // Cast to asset. - FontAsset* pAsset = static_cast(object); - - // Sanity! - AssertFatal(pAsset != NULL, "FontAsset::copyTo() - Object is not the correct type."); - - // Copy state. - pAsset->setFontFile( getFontFile() ); -} - -//------------------------------------------------------------------------------ - void FontAsset::initializeAsset( void ) { // Call parent. diff --git a/engine/source/2d/assets/FontAsset.h b/engine/source/2d/assets/FontAsset.h index 548ac94ae..e029c8cfa 100644 --- a/engine/source/2d/assets/FontAsset.h +++ b/engine/source/2d/assets/FontAsset.h @@ -61,7 +61,6 @@ class FontAsset : public AssetBase static void initPersistFields(); virtual bool onAdd(); virtual void onRemove(); - virtual void copyTo(SimObject* object); void setFontFile( const char* pFontFile ); inline StringTableEntry getFontFile( void ) const { return mFontFile; } diff --git a/engine/source/2d/assets/ImageAsset.cc b/engine/source/2d/assets/ImageAsset.cc index fdcaec31e..4a35a597e 100755 --- a/engine/source/2d/assets/ImageAsset.cc +++ b/engine/source/2d/assets/ImageAsset.cc @@ -270,53 +270,57 @@ void ImageAsset::setImageFile( const char* pImageFile ) //------------------------------------------------------------------------------ -void ImageAsset::copyTo(SimObject* object) +// The two things about an image that no persist field describes: the explicit +// cells and the image layers. Both are written as TAML custom nodes, so the +// generic field copy in AssetBase::copyTo cannot see either of them. +// +// Neither loop redraws. insertLayer only loads a layer's bitmap when the target +// is owned, so a copy into an unowned scratch object touches no texture at all +// -- which is what lets a snapshot be taken with no GL context. A copy onto a +// live asset does want the composite rebuilt, so that happens once, at the end, +// rather than once per layer. +void ImageAsset::copyAssetStateTo( AssetBase* pTarget ) { - // Call to parent. - Parent::copyTo(object); - // Cast to asset. - ImageAsset* pAsset = static_cast(object); + ImageAsset* pAsset = dynamic_cast( pTarget ); // Sanity! - AssertFatal(pAsset != NULL, "ImageAsset::copyTo() - Object is not the correct type."); - - // Copy state. - pAsset->setImageFile( getImageFile() ); - pAsset->setForce16Bit( getForce16Bit() ); - pAsset->setFilterMode( getFilterMode() ); - pAsset->setExplicitMode( getExplicitMode() ); - pAsset->setCellRowOrder( getCellRowOrder() ); - pAsset->setCellOffsetX( getCellCountX() ); - pAsset->setCellOffsetY( getCellCountY() ); - pAsset->setCellStrideX( getCellStrideX() ); - pAsset->setCellStrideY( getCellStrideY() ); - pAsset->setCellCountX( getCellCountX() ); - pAsset->setCellCountY( getCellCountY() ); - pAsset->setCellWidth( getCellWidth() ); - pAsset->setCellHeight( getCellHeight() ); - - // Finish if not in explicit mode. - if ( !getExplicitMode() ) - return; - - // Fetch the explicit cell count. - const S32 explicitCellCount = getExplicitCellCount(); - - // Finish if no explicit cells exist. - if ( explicitCellCount == 0 ) - return; + AssertFatal( pAsset != NULL, "ImageAsset::copyAssetStateTo() - Object is not the correct type." ); - // Copy explicit cells. + // Copy the explicit cells. Note this happens whatever ExplicitMode says: the + // cells outlive being switched out of explicit mode, and a copy that dropped + // them would quietly destroy the user's work the moment they toggled it off. pAsset->clearExplicitCells(); + const S32 explicitCellCount = getExplicitCellCount(); for( S32 index = 0; index < explicitCellCount; ++index ) { // Fetch the cell pixel area. - const FrameArea::PixelArea& pixelArea = getImageFrameArea( index ).mPixelArea; + const FrameArea::PixelArea& pixelArea = mExplicitFrames[index]; // Add the explicit cell. pAsset->addExplicitCell( pixelArea.mPixelOffset.x, pixelArea.mPixelOffset.y, pixelArea.mPixelWidth, pixelArea.mPixelHeight, pixelArea.mRegionName ); } + + // Copy the image layers. Layer zero is the base image, made from ImageFile + // and BlendColor, so it is never copied -- insertLayer builds it. + while( pAsset->getLayerCount() > 0 ) + pAsset->removeLayer( 1, false ); + + const U32 layerCount = mImageLayers.size(); + for( U32 index = 1; index < layerCount; ++index ) + { + const ImageLayer& layer = mImageLayers[index]; + pAsset->addLayer( layer.mImageFile, layer.mPosition, layer.mBlendColor, false ); + } + + // The base layer is built by the first insertLayer above, from whatever + // BlendColor the field copy already wrote, so it only needs setting when + // there were layers to build it. + if ( layerCount > 0 ) + pAsset->setBlendColor( getBlendColor(), false ); + + // One rebuild, and only for a live asset. + pAsset->completeLayerChange( pAsset->getOwned() ); } //------------------------------------------------------------------------------ diff --git a/engine/source/2d/assets/ImageAsset.h b/engine/source/2d/assets/ImageAsset.h index 3b55ea61b..3817cc6d2 100755 --- a/engine/source/2d/assets/ImageAsset.h +++ b/engine/source/2d/assets/ImageAsset.h @@ -215,7 +215,7 @@ class ImageAsset : public AssetBase static void initPersistFields(); virtual bool onAdd(); virtual void onRemove(); - virtual void copyTo(SimObject* object); + virtual void copyAssetStateTo(AssetBase* pTarget); void setImageFile( const char* pImageFile ); inline StringTableEntry getImageFile( void ) const { return mImageFile; }; diff --git a/engine/source/2d/assets/ParticleAsset.cc b/engine/source/2d/assets/ParticleAsset.cc index a039f2c4a..b0e778ac8 100644 --- a/engine/source/2d/assets/ParticleAsset.cc +++ b/engine/source/2d/assets/ParticleAsset.cc @@ -162,20 +162,17 @@ void ParticleAsset::initPersistFields() //------------------------------------------------------------------------------ -void ParticleAsset::copyTo(SimObject* object) +// Lifetime and LifeMode are persist fields, so AssetBase::copyTo already carried +// them. What is left is what no field describes: the particle fields, which are +// data-key curves written as TAML custom nodes, and the emitters, which are Taml +// children. +void ParticleAsset::copyAssetStateTo( AssetBase* pTarget ) { // Fetch particle asset object. - ParticleAsset* pParticleAsset = static_cast( object ); + ParticleAsset* pParticleAsset = dynamic_cast( pTarget ); // Sanity! - AssertFatal( pParticleAsset != NULL, "ParticleAsset::copyTo() - Object is not the correct type."); - - // Copy parent. - Parent::copyTo( object ); - - // Copy fields. - pParticleAsset->setLifetime( getLifetime() ); - pParticleAsset->setLifeMode( getLifeMode() ); + AssertFatal( pParticleAsset != NULL, "ParticleAsset::copyAssetStateTo() - Object is not the correct type."); // Copy particle fields. mParticleFields.copyTo( pParticleAsset->mParticleFields ); diff --git a/engine/source/2d/assets/ParticleAsset.h b/engine/source/2d/assets/ParticleAsset.h index ea40c423c..fc05cc682 100644 --- a/engine/source/2d/assets/ParticleAsset.h +++ b/engine/source/2d/assets/ParticleAsset.h @@ -83,7 +83,7 @@ class ParticleAsset : public AssetBase, public TamlChildren virtual ~ParticleAsset(); static void initPersistFields(); - virtual void copyTo(SimObject* object); + virtual void copyAssetStateTo(AssetBase* pTarget); virtual void onDeleteNotify( SimObject* object ); // Asset validation. diff --git a/engine/source/2d/assets/ParticleAssetEmitter.cc b/engine/source/2d/assets/ParticleAssetEmitter.cc index 3f49876cd..4efb91de0 100644 --- a/engine/source/2d/assets/ParticleAssetEmitter.cc +++ b/engine/source/2d/assets/ParticleAssetEmitter.cc @@ -330,40 +330,27 @@ void ParticleAssetEmitter::copyTo(SimObject* object) // Copy parent. Parent::copyTo( object ); - // Copy fields. - pParticleAssetEmitter->setEmitterName( getEmitterName() ); - pParticleAssetEmitter->setEmitterType( getEmitterType() ); - pParticleAssetEmitter->setEmitterOffset( getEmitterOffset() ); - pParticleAssetEmitter->setEmitterSize( getEmitterSize() ); - pParticleAssetEmitter->setEmitterAngle( getEmitterAngle() ); - pParticleAssetEmitter->setFixedAspect( getFixedAspect() ); - pParticleAssetEmitter->setFixedForceAngle( getFixedForceAngle() ); - pParticleAssetEmitter->setOrientationType( getOrientationType() ); - pParticleAssetEmitter->setKeepAligned( getKeepAligned() ); - pParticleAssetEmitter->setAlignedAngleOffset( getAlignedAngleOffset() ); - pParticleAssetEmitter->setRandomAngleOffset( getRandomAngleOffset() ); - pParticleAssetEmitter->setRandomArc( getRandomArc() ); - pParticleAssetEmitter->setFixedAngleOffset( getFixedAngleOffset() ); - pParticleAssetEmitter->setPivotPoint( getPivotPoint() ); - pParticleAssetEmitter->setLinkEmissionRotation( getLinkEmissionRotation() ); - pParticleAssetEmitter->setIntenseParticles( getIntenseParticles() ); - pParticleAssetEmitter->setSingleParticle( getSingleParticle() ); - pParticleAssetEmitter->setAttachPositionToEmitter( getAttachPositionToEmitter() ); - pParticleAssetEmitter->setAttachRotationToEmitter( getAttachRotationToEmitter() ); - pParticleAssetEmitter->setOldestInFront( getOldestInFront() ); - - pParticleAssetEmitter->setBlendMode( getBlendMode() ); - pParticleAssetEmitter->setSrcBlendFactor( getSrcBlendFactor() ); - pParticleAssetEmitter->setDstBlendFactor( getDstBlendFactor() ); - pParticleAssetEmitter->setAlphaTest( getAlphaTest() ); - - pParticleAssetEmitter->setRandomImageFrame( getRandomImageFrame() ); - - // Static provider? - if ( pParticleAssetEmitter->isStaticFrameProvider() ) + // Copy every persist field off the field table rather than by hand. The hand + // written list this replaced had drifted, and would drift again every time a + // field was added. + pParticleAssetEmitter->copyFieldsFrom( this, SimObject::CopyFields_SkipName + | SimObject::CopyFields_SkipParentGroup + | SimObject::CopyFields_SkipScriptClass ); + + // Image and Animation are mutually exclusive, and both are ordinary persist + // fields whose setters decide the mode: setImage sets static mode, setAnimation + // clears it and clears the image asset. So after the generic copy above the + // mode is whichever of the two the field table happens to list last -- which is + // Animation, meaning every static emitter would arrive animated and blank. + // + // Settle it here, from the SOURCE's mode. Reading the TARGET's is the bug this + // replaced: a fresh emitter defaults to static, so an animated emitter copied + // as a blank static one, silently, including through clone() and + // acquireAsset(id, true). + if ( isStaticFrameProvider() ) { // Named image frame? - if ( pParticleAssetEmitter->isUsingNamedImageFrame() ) + if ( isUsingNamedImageFrame() ) pParticleAssetEmitter->setImage( getImage(), getNamedImageFrame() ); else pParticleAssetEmitter->setImage( getImage(), getImageFrame() ); @@ -373,7 +360,8 @@ void ParticleAssetEmitter::copyTo(SimObject* object) pParticleAssetEmitter->setAnimation( getAnimation() ); } - // Copy particle fields. + // Copy particle fields. These are data-key curves written as TAML custom + // nodes, so no persist field describes them. mParticleFields.copyTo( pParticleAssetEmitter->mParticleFields ); } diff --git a/engine/source/assets/assetBase.cc b/engine/source/assets/assetBase.cc index e81b173eb..774dded50 100755 --- a/engine/source/assets/assetBase.cc +++ b/engine/source/assets/assetBase.cc @@ -90,9 +90,22 @@ void AssetBase::initPersistFields() //------------------------------------------------------------------------------ +// Every asset type used to list its own fields here, by hand, and every one of +// those lists had drifted from the fields it was meant to carry: ImageAsset +// copied its cell COUNT into its cell OFFSET and never copied image layers at +// all, and AnimationAsset and ParticleAssetEmitter both branched on the TARGET's +// mode -- still the default at that point -- to decide what to copy, so an +// animated emitter copied as a blank static one. +// +// So do not list fields. copyFieldsFrom walks the field table, which is the same +// list TAML persists, reading through the source's getDataFn and writing through +// the target's setDataFn. A field added tomorrow is copied the day it is added. +// What is left over is the state no field describes -- custom nodes and Taml +// children -- and that is what copyAssetStateTo is for. void AssetBase::copyTo(SimObject* object) { - // Call to parent. + // Call to parent. This sets the script class and links the namespaces, and + // must run before anything that could look for a script callback. Parent::copyTo(object); // Cast to asset. @@ -101,12 +114,16 @@ void AssetBase::copyTo(SimObject* object) // Sanity! AssertFatal(pAsset != NULL, "AssetBase::copyTo() - Object is not the correct type."); - // Copy state. - pAsset->setAssetName( getAssetName() ); - pAsset->setAssetDescription( getAssetDescription() ); - pAsset->setAssetCategory( getAssetCategory() ); - pAsset->setAssetAutoUnload( getAssetAutoUnload() ); - pAsset->setAssetInternal( getAssetInternal() ); + // The SimObject name is not the AssetName, and neither is wanted here: the + // name because two objects answering to one name is a bug, the parent group + // because writing the field would MOVE the copy into the original's group, + // and class/superclass because Parent::copyTo just set them. + pAsset->copyFieldsFrom( this, SimObject::CopyFields_SkipName + | SimObject::CopyFields_SkipParentGroup + | SimObject::CopyFields_SkipScriptClass ); + + // Whatever the field table could not describe. + copyAssetStateTo( pAsset ); } //----------------------------------------------------------------------------- @@ -265,6 +282,16 @@ StringTableEntry AssetBase::collapseAssetFilePath( const char* pAssetFilePath ) //----------------------------------------------------------------------------- +// The asset changed. This marks it unsaved and announces it; it does not write +// the file. See AssetManager::refreshAsset. +// +// The early-out is what makes an unowned asset inert, which is what +// createStateSnapshot relies on: a copy runs every setter on the copy, and none +// of them should mean anything. +// +// The onRefresh script callback used to fire from here. It now fires from +// AssetManager::notifyAssetRefresh instead, so that it also reaches the assets +// that depend on this one -- which is what the editor always assumed it did. void AssetBase::refreshAsset( void ) { // Debug Profiling. @@ -276,12 +303,86 @@ void AssetBase::refreshAsset( void ) // Yes, so refresh the asset via the asset manager. mpOwningAssetManager->refreshAsset( getAssetId() ); +} + +//----------------------------------------------------------------------------- + +bool AssetBase::saveAsset( void ) +{ + if ( mpOwningAssetManager == NULL ) + return false; + + return mpOwningAssetManager->saveAsset( getAssetId() ); +} + +//----------------------------------------------------------------------------- + +bool AssetBase::revertAsset( void ) +{ + if ( mpOwningAssetManager == NULL ) + return false; + + return mpOwningAssetManager->revertAsset( getAssetId() ); +} + +//----------------------------------------------------------------------------- + +AssetBase* AssetBase::createStateSnapshot( void ) +{ + // Debug Profiling. + PROFILE_SCOPE(AssetBase_CreateStateSnapshot); + + // Create an empty asset of this asset's type. + AssetBase* pSnapshot = dynamic_cast( ConsoleObject::create( getClassName() ) ); + + if ( pSnapshot == NULL ) + { + // Warn. + Con::warnf( "AssetBase::createStateSnapshot() - Could not create an asset of type '%s'.", getClassName() ); + return NULL; + } + + if ( !pSnapshot->registerObject() ) + { + // Warn. + Con::warnf( "AssetBase::createStateSnapshot() - Could not register the snapshot asset." ); + delete pSnapshot; + return NULL; + } + + // The snapshot is unowned, so every setter this runs is inert. + copyTo( pSnapshot ); + + return pSnapshot; +} + +//----------------------------------------------------------------------------- + +bool AssetBase::restoreStateSnapshot( AssetBase* pSnapshot ) +{ + // Debug Profiling. + PROFILE_SCOPE(AssetBase_RestoreStateSnapshot); + + if ( pSnapshot == NULL ) + return false; + + // Copying between two different kinds of asset would be nonsense, and + // copyFieldsFrom would silently do nothing at all. + if ( pSnapshot->getClassRep() != getClassRep() ) + { + // Warn. + Con::warnf( "AssetBase::restoreStateSnapshot() - Cannot restore a '%s' snapshot onto a '%s'.", + pSnapshot->getClassName(), getClassName() ); + return false; + } + + // One announcement for the whole restore, and replaying an edit is not + // making one. + AssetManager::ScopedAssetUpdate scopedUpdate( mpOwningAssetManager, true ); + + pSnapshot->copyTo( this ); - //Inform those who want to know - if (isMethod("onRefresh")) - { - Con::executef(this, 1, "onRefresh"); - } + return true; } //----------------------------------------------------------------------------- diff --git a/engine/source/assets/assetBase.h b/engine/source/assets/assetBase.h index dd75df0c1..d7bd36798 100755 --- a/engine/source/assets/assetBase.h +++ b/engine/source/assets/assetBase.h @@ -76,6 +76,15 @@ class AssetBase : public SimObject static void initPersistFields(); virtual void copyTo(SimObject* object); + /// The state a copy has to carry that no persist field describes. + /// + /// copyTo copies every persist field generically, off the field table, so an + /// asset only overrides this if it keeps state somewhere else -- which in + /// practice means the state it writes as TAML custom nodes, or its Taml + /// children. ImageAsset's explicit cells and image layers and ParticleAsset's + /// emitters are the whole list; the other asset types need nothing here. + virtual void copyAssetStateTo( AssetBase* pTarget ) {} + /// Asset configuration. inline void setAssetName( const char* pAssetName ) { if ( mpOwningAssetManager == NULL ) mpAssetDefinition->mAssetName = StringTable->insert(pAssetName); } inline StringTableEntry getAssetName( void ) const { return mpAssetDefinition->mAssetName; } @@ -102,8 +111,37 @@ class AssetBase : public SimObject virtual bool isAssetValid( void ) const { return true; } + /// Whether the asset has changes that have not been saved. + inline bool getAssetDirty( void ) const { return mpAssetDefinition->mAssetDirty; } + + /// Mark the asset changed: unsaved, and announced to everything watching it. + /// + /// NOTE: this does NOT write the asset's file. Saving is saveAsset. void refreshAsset( void ); + /// Save the asset's file, and revert it to what that file already holds. + bool saveAsset( void ); + bool revertAsset( void ); + + /// A detached copy of everything this asset currently holds. + /// + /// The copy is registered but unowned, and that is the point: with no owning + /// asset manager every setter's refreshAsset returns immediately, so taking a + /// snapshot marks nothing dirty, notifies nobody, and -- for an ImageAsset -- + /// loads no bitmap and touches no texture. + /// + /// The caller owns the returned object and must deleteObject() it. + AssetBase* createStateSnapshot( void ); + + /// Put a snapshot back onto this asset. + /// + /// The snapshot is copied ONTO this object rather than replacing it, because + /// every AssetPtr in the scene holds a raw pointer to this object. The whole + /// restore counts as one change, and does not itself mark the asset dirty -- + /// the caller decides what the dirty state should be afterwards, since undoing + /// back to the last saved state is clean and undoing to anywhere else is not. + bool restoreStateSnapshot( AssetBase* pSnapshot ); + /// Declare Console Object. DECLARE_CONOBJECT( AssetBase ); diff --git a/engine/source/assets/assetBase_ScriptBinding.h b/engine/source/assets/assetBase_ScriptBinding.h index 85f956511..89f91fade 100755 --- a/engine/source/assets/assetBase_ScriptBinding.h +++ b/engine/source/assets/assetBase_ScriptBinding.h @@ -23,6 +23,8 @@ ConsoleMethodGroupBeginWithDocs(AssetBase, SimObject) /*! Refresh the asset. + This marks the asset as having unsaved changes and tells everything watching it + that it changed. It does NOT write the asset's file -- use saveAsset for that. @return No return value. */ ConsoleMethodWithDocs( AssetBase, refreshAsset, ConsoleVoid, 2, 2, ()) @@ -40,4 +42,71 @@ ConsoleMethodWithDocs( AssetBase, getAssetId, ConsoleString, 2, 2, ()) return object->getAssetId(); } +//----------------------------------------------------------------------------- + +/*! Whether the asset has changes that have not been saved. + @return Whether the asset has unsaved changes or not. +*/ +ConsoleMethodWithDocs( AssetBase, isAssetDirty, ConsoleBool, 2, 2, ()) +{ + return object->getAssetDirty(); +} + +//----------------------------------------------------------------------------- + +/*! Write the asset to its file. + @return Whether the save was successful or not. +*/ +ConsoleMethodWithDocs( AssetBase, saveAsset, ConsoleBool, 2, 2, ()) +{ + return object->saveAsset(); +} + +//----------------------------------------------------------------------------- + +/*! Discard the asset's unsaved changes and reload it from its file. + @return Whether the revert was successful or not. +*/ +ConsoleMethodWithDocs( AssetBase, revertAsset, ConsoleBool, 2, 2, ()) +{ + return object->revertAsset(); +} + +//----------------------------------------------------------------------------- + +/*! Takes a detached copy of everything the asset currently holds. + The copy is a registered object that the caller owns and must delete. Taking one + changes nothing about this asset: it is not marked as changed and nobody is notified. + @return The snapshot object's Id, or 0 if the snapshot could not be taken. +*/ +ConsoleMethodWithDocs( AssetBase, createStateSnapshot, ConsoleInt, 2, 2, ()) +{ + AssetBase* pSnapshot = object->createStateSnapshot(); + + return pSnapshot == NULL ? 0 : pSnapshot->getId(); +} + +//----------------------------------------------------------------------------- + +/*! Puts a snapshot taken by createStateSnapshot back onto this asset. + The asset object itself is kept, so anything already using the asset keeps working. + This does not mark the asset as changed -- the caller decides what the asset's + unsaved state should be afterwards. + @param snapshot The snapshot object to restore. + @return Whether the restore was successful or not. +*/ +ConsoleMethodWithDocs( AssetBase, restoreStateSnapshot, ConsoleBool, 3, 3, (snapshot)) +{ + AssetBase* pSnapshot = Sim::findObject( argv[2] ); + + if ( pSnapshot == NULL ) + { + // Warn. + Con::warnf( "AssetBase::restoreStateSnapshot() - Could not find the snapshot object '%s'.", argv[2] ); + return false; + } + + return object->restoreStateSnapshot( pSnapshot ); +} + ConsoleMethodGroupEndWithDocs(AssetBase) diff --git a/engine/source/assets/assetDefinition.h b/engine/source/assets/assetDefinition.h index 10949ec4b..c28ac190a 100755 --- a/engine/source/assets/assetDefinition.h +++ b/engine/source/assets/assetDefinition.h @@ -58,6 +58,7 @@ struct AssetDefinition mAssetLoadedCount = 0; mAssetUnloadedCount = 0; mAssetRefreshEnable = true; + mAssetDirty = false; mAssetLooseFiles.clear(); // Reset persisted state. @@ -77,6 +78,15 @@ struct AssetDefinition U32 mAssetLoadedCount; U32 mAssetUnloadedCount; bool mAssetRefreshEnable; + + /// Whether the asset has been changed in memory since it was last saved. + /// + /// This lives on the definition and not on the asset because the definition + /// outlives the asset: unloadAsset deletes mpAssetBase and keeps the + /// definition, so a flag on the asset would be destroyed along with the + /// unsaved edits, and along with the record that there were any. + bool mAssetDirty; + Vector mAssetLooseFiles; /// Persisted state. diff --git a/engine/source/assets/assetManager.cc b/engine/source/assets/assetManager.cc index ff3d60a93..c9010cf15 100755 --- a/engine/source/assets/assetManager.cc +++ b/engine/source/assets/assetManager.cc @@ -26,6 +26,12 @@ #include "assetPtr.h" #endif +// For the module database, which is how a file is matched to the module that +// owns it. See findModuleForPath. +#ifndef _MODULE_MANAGER_H +#include "module/moduleManager.h" +#endif + #ifndef _REFERENCED_ASSETS_H_ #include "assets/referencedAssets.h" #endif @@ -75,6 +81,8 @@ AssetManager::AssetManager() : mMaxLoadedExternalAssetsCount( 0 ), mMaxLoadedPrivateAssetsCount( 0 ), mAcquiredReferenceCount( 0 ), + mAssetBatchDepth( 0 ), + mDirtySuppressDepth( 0 ), mEchoInfo( false ), mIgnoreAutoUnload( false ) { @@ -82,6 +90,36 @@ AssetManager::AssetManager() : //----------------------------------------------------------------------------- +AssetManager::ScopedAssetUpdate::ScopedAssetUpdate( AssetManager* pAssetManager, const bool suppressDirty ) : + mpAssetManager( pAssetManager ), + mSuppressDirty( suppressDirty ) +{ + if ( mpAssetManager == NULL ) + return; + + mpAssetManager->mAssetBatchDepth++; + + if ( mSuppressDirty ) + mpAssetManager->mDirtySuppressDepth++; +} + +AssetManager::ScopedAssetUpdate::~ScopedAssetUpdate() +{ + if ( mpAssetManager == NULL ) + return; + + if ( mSuppressDirty ) + mpAssetManager->mDirtySuppressDepth--; + + mpAssetManager->mAssetBatchDepth--; + + // The outermost scope is what actually announces everything that piled up. + if ( mpAssetManager->mAssetBatchDepth == 0 ) + mpAssetManager->drainRefreshNotifications(); +} + +//----------------------------------------------------------------------------- + bool AssetManager::onAdd() { // Call parent. @@ -1121,6 +1159,17 @@ bool AssetManager::deleteAsset( const char* pAssetId, const bool deleteLooseFile //----------------------------------------------------------------------------- +// An asset changed. Mark it unsaved and tell everything that cares. +// +// This used to write the asset's file, here, on every setter -- so trying a +// particle out was indistinguishable from deciding to keep it, and a running +// game silently rewrote its own content. The write now lives in saveAsset, and +// nothing else calls it. +// +// The private-asset branch that used to sit here was already exactly this: notify +// and do not write. So the two paths are one path now, and the only thing a +// private asset does differently is that it never becomes dirty, having no file +// to be out of step with. bool AssetManager::refreshAsset( const char* pAssetId ) { // Debug Profiling. @@ -1140,156 +1189,238 @@ bool AssetManager::refreshAsset( const char* pAssetId ) return false; } + // Finish if the asset is not loaded. There is nothing in memory to have + // changed, and nothing to notify. + if ( pAssetDefinition->mpAssetBase == NULL ) + return true; + // Info. if ( mEchoInfo ) { Con::printSeparator(); Con::printf( "Asset Manager: Started refreshing Asset Id '%s'...", pAssetId ); - } + } - // Fetch asset Id. - StringTableEntry assetId = StringTable->insert( pAssetId ); + // Mark the asset as having unsaved changes. + markAssetDirty( pAssetDefinition ); - // Is the asset private? - if ( pAssetDefinition->mAssetPrivate ) + // Tell the asset, the asset pointers watching it, script, and anything that + // depends on it. This one is a direct change; the cascade is not. + notifyAssetRefresh( pAssetDefinition, true ); + + // Info. + if ( mEchoInfo ) { - // Yes, so notify asset of asset refresh only. - pAssetDefinition->mpAssetBase->onAssetRefresh(); + Con::printSeparator(); + Con::printf( "Asset Manager: Finished refreshing Asset Id '%s'.", pAssetId ); + } - // Asset refresh notifications. - for( typeAssetPtrRefreshHash::iterator refreshNotifyItr = mAssetPtrRefreshNotifications.begin(); refreshNotifyItr != mAssetPtrRefreshNotifications.end(); ++refreshNotifyItr ) - { - // Fetch pointed asset. - StringTableEntry pointedAsset = refreshNotifyItr->key->getAssetId(); + return true; +} - // Ignore if the pointed asset is not the asset or a dependency. - if ( pointedAsset == StringTable->EmptyString || ( pointedAsset != assetId && !doesAssetDependOn( pointedAsset, assetId ) ) ) - continue; +//----------------------------------------------------------------------------- - // Perform refresh notification callback. - refreshNotifyItr->value->onAssetRefreshed( refreshNotifyItr->key ); - } - } - // Is the asset definition allowed to refresh? - else if ( pAssetDefinition->mAssetRefreshEnable ) +void AssetManager::markAssetDirty( AssetDefinition* pAssetDefinition ) +{ + // Sanity! + AssertFatal( pAssetDefinition != NULL, "Cannot mark a NULL asset definition dirty." ); + + // A private asset has no file, so it can never be out of step with one. An + // asset whose refresh is disabled is not ours to track either. + if ( pAssetDefinition->mAssetPrivate || !pAssetDefinition->mAssetRefreshEnable ) + return; + + // The dependency and loose-file graphs used to be rebuilt by re-parsing the + // file this call had just written. There is no write now, so they are + // recalculated from the asset in memory instead -- otherwise they would sit + // stale for as long as the asset stayed unsaved, and a dependent would stop + // being told when the asset it reads from changed. + // + // This happens even when dirty marking is suppressed, because it is + // bookkeeping rather than a judgement about unsaved work: a revert can + // repoint an animation at a different image, and the graph has to follow it + // whether or not the asset ends up dirty. + updateAssetDependencies( pAssetDefinition ); + + // Finish if dirty marking is suppressed. A snapshot restore replays every + // setter, and replaying an edit is not making one. + if ( mDirtySuppressDepth > 0 ) + return; + + // Finish if already dirty. Only the clean-to-dirty edge is worth announcing. + if ( pAssetDefinition->mAssetDirty ) + return; + + // Mark dirty. + pAssetDefinition->mAssetDirty = true; + + // Tell script, so an editor can mark the asset without polling for it. + Con::executef( 2, "onAssetDirtyChanged", pAssetDefinition->mAssetId ); +} + +//----------------------------------------------------------------------------- + +// Queue an announcement, and make it now unless a batch is open. +// +// Everything queues, always. The queue doubles as the visited set, and that is +// the only thing standing between this and an unbounded recursion: two assets +// that depend on each other used to call each other here forever, and +// ParticleAssetEmitter::onAssetRefreshed answers a notification by raising +// another one on its owner. +void AssetManager::notifyAssetRefresh( AssetDefinition* pAssetDefinition, const bool direct ) +{ + // Sanity! + AssertFatal( pAssetDefinition != NULL, "Cannot notify a NULL asset definition." ); + + // Fetch asset Id. + StringTableEntry assetId = pAssetDefinition->mAssetId; + + // Already spoken for? Note this scans what has already been dispatched in the + // current drain as well as what is still waiting, which is what makes a cycle + // terminate rather than merely take turns. + for( Vector::iterator pendingItr = mPendingRefreshNotifications.begin(); pendingItr != mPendingRefreshNotifications.end(); ++pendingItr ) { - // Yes, so fetch the asset. - AssetBase* pAssetBase = pAssetDefinition->mpAssetBase; + if ( pendingItr->mAssetId != assetId ) + continue; - // Is the asset loaded? - if ( pAssetBase != NULL ) - { - // Yes, so notify asset of asset refresh. - pAssetBase->onAssetRefresh(); + // An asset reached both ways in one batch was changed, whatever else also + // happened to it. + if ( direct ) + pendingItr->mDirect = true; - // Save asset. - mTaml.write( pAssetBase, pAssetDefinition->mAssetBaseFilePath ); - - // Remove asset dependencies. - removeAssetDependencies( pAssetId ); + return; + } - // Find any new dependencies. - TamlAssetDeclaredVisitor assetDeclaredVisitor; + PendingRefresh pendingRefresh; + pendingRefresh.mAssetId = assetId; + pendingRefresh.mDirect = direct; + mPendingRefreshNotifications.push_back( pendingRefresh ); - // Parse the filename. - if ( !mTaml.parse( pAssetDefinition->mAssetBaseFilePath, assetDeclaredVisitor ) ) - { - // Warn. - Con::warnf( "Asset Manager: Failed to parse file containing asset declaration: '%s'.\nDependencies are now incorrect!", pAssetDefinition->mAssetBaseFilePath ); - return false; - } + // Inside a batch the drain happens when the outermost scope closes. + if ( mAssetBatchDepth == 0 ) + drainRefreshNotifications(); +} - // Fetch asset dependencies. - TamlAssetDeclaredVisitor::typeAssetIdVector& assetDependencies = assetDeclaredVisitor.getAssetDependencies(); +//----------------------------------------------------------------------------- - // Are there any asset dependences? - if ( assetDependencies.size() > 0 ) - { - // Yes, so iterate dependencies. - for( TamlAssetDeclaredVisitor::typeAssetIdVector::iterator assetDependencyItr = assetDependencies.begin(); assetDependencyItr != assetDependencies.end(); ++assetDependencyItr ) - { - // Fetch dependency asset Id. - StringTableEntry dependencyAssetId = *assetDependencyItr; +void AssetManager::drainRefreshNotifications( void ) +{ + // Hold the depth for the whole drain, so a notification raised by one of the + // callbacks below joins this queue instead of starting a second drain + // underneath this one. + mAssetBatchDepth++; - // Insert depends-on. - mAssetDependsOn.insertEqual( assetId, dependencyAssetId ); + // Deliberately indexed and deliberately not popped: entries stay in the + // vector after they are dispatched so that the duplicate check in + // notifyAssetRefresh can still see them. + for( U32 index = 0; index < (U32)mPendingRefreshNotifications.size(); ++index ) + { + // Deliberately by value: dispatching can append to the vector and move it + // out from under a reference. + const PendingRefresh pendingRefresh = mPendingRefreshNotifications[index]; - // Insert is-depended-on. - mAssetIsDependedOn.insertEqual( dependencyAssetId, assetId ); - } - } + AssetDefinition* pAssetDefinition = findAsset( pendingRefresh.mAssetId ); - // Fetch asset loose files. - TamlAssetDeclaredVisitor::typeLooseFileVector& assetLooseFiles = assetDeclaredVisitor.getAssetLooseFiles(); + if ( pAssetDefinition != NULL ) + dispatchAssetRefresh( pAssetDefinition, pendingRefresh.mDirect ); + } - // Clear any existing loose files. - pAssetDefinition->mAssetLooseFiles.clear(); + mPendingRefreshNotifications.clear(); - // Are there any loose files? - if ( assetLooseFiles.size() > 0 ) - { - // Yes, so iterate loose files. - for( TamlAssetDeclaredVisitor::typeLooseFileVector::iterator assetLooseFileItr = assetLooseFiles.begin(); assetLooseFileItr != assetLooseFiles.end(); ++assetLooseFileItr ) - { - // Store loose file. - pAssetDefinition->mAssetLooseFiles.push_back( *assetLooseFileItr ); - } - } + mAssetBatchDepth--; +} - // Asset refresh notifications. - for( typeAssetPtrRefreshHash::iterator refreshNotifyItr = mAssetPtrRefreshNotifications.begin(); refreshNotifyItr != mAssetPtrRefreshNotifications.end(); ++refreshNotifyItr ) - { - // Fetch pointed asset. - StringTableEntry pointedAsset = refreshNotifyItr->key->getAssetId(); +//----------------------------------------------------------------------------- - // Ignore if the pointed asset is not the asset or a dependency. - if ( pointedAsset == StringTable->EmptyString || ( pointedAsset != assetId && !doesAssetDependOn( pointedAsset, assetId ) ) ) - continue; +void AssetManager::dispatchAssetRefresh( AssetDefinition* pAssetDefinition, const bool direct ) +{ + // Sanity! + AssertFatal( pAssetDefinition != NULL, "Cannot dispatch a NULL asset definition." ); - // Perform refresh notification callback. - refreshNotifyItr->value->onAssetRefreshed( refreshNotifyItr->key ); - } + // Fetch asset Id. + StringTableEntry assetId = pAssetDefinition->mAssetId; - // Find is-depends-on entry. - typeAssetIsDependedOnHash::iterator isDependedOnItr = mAssetIsDependedOn.find( assetId ); + // Fetch the asset. + AssetBase* pAssetBase = pAssetDefinition->mpAssetBase; - // Is asset depended on? - if ( isDependedOnItr != mAssetIsDependedOn.end() ) - { - // Yes, so compiled them. - Vector dependedOn; + // Finish if the asset is not loaded. + if ( pAssetBase == NULL ) + return; - // Iterate all dependencies. - while( isDependedOnItr != mAssetIsDependedOn.end() && isDependedOnItr->key == assetId ) - { - dependedOn.push_back( isDependedOnItr->value ); + // Notify the asset itself, so it can re-derive whatever it caches. + pAssetBase->onAssetRefresh(); - // Next dependency. - isDependedOnItr++; - } + // Asset refresh notifications. + for( typeAssetPtrRefreshHash::iterator refreshNotifyItr = mAssetPtrRefreshNotifications.begin(); refreshNotifyItr != mAssetPtrRefreshNotifications.end(); ++refreshNotifyItr ) + { + // Fetch pointed asset. + StringTableEntry pointedAsset = refreshNotifyItr->key->getAssetId(); - // Refresh depended-on assets. - for ( Vector::iterator isDependedOnItr = dependedOn.begin(); isDependedOnItr != dependedOn.end(); ++isDependedOnItr ) - { - // Refresh dependency asset. - refreshAsset( *isDependedOnItr ); - } - } - } + // Ignore if the pointed asset is not the asset or a dependency. + if ( pointedAsset == StringTable->EmptyString || ( pointedAsset != assetId && !doesAssetDependOn( pointedAsset, assetId ) ) ) + continue; + + // Perform refresh notification callback. + refreshNotifyItr->value->onAssetRefreshed( refreshNotifyItr->key ); } - // Info. - if ( mEchoInfo ) + // Inform those who want to know. + // + // This used to fire from AssetBase::refreshAsset, which meant it reached the + // asset a setter was called on and nothing else -- so an asset never heard + // that something it depends on had changed, which is precisely what the + // cascade below exists to tell it. + if ( pAssetBase->isMethod( "onRefresh" ) ) { - Con::printSeparator(); - Con::printf( "Asset Manager: Finished refreshing Asset Id '%s'.", pAssetId ); + Con::executef( pAssetBase, 2, "onRefresh", direct ? "1" : "0" ); } - return true; + // Find is-depends-on entry. + typeAssetIsDependedOnHash::iterator isDependedOnItr = mAssetIsDependedOn.find( assetId ); + + // Is asset depended on? + if ( isDependedOnItr != mAssetIsDependedOn.end() ) + { + // Yes, so compiled them. + Vector dependedOn; + + // Iterate all dependencies. + while( isDependedOnItr != mAssetIsDependedOn.end() && isDependedOnItr->key == assetId ) + { + dependedOn.push_back( isDependedOnItr->value ); + + // Next dependency. + isDependedOnItr++; + } + + // Notify depended-on assets. + // + // Notify, and deliberately not refresh: nothing a dependent PERSISTS + // changes when the asset it reads from is re-cut. An AnimationAsset + // rebuilds mValidatedFrames, which is not a persist field. Marking it + // dirty would tell the user they have unsaved work in a file they never + // touched. + for ( Vector::iterator dependedOnItr = dependedOn.begin(); dependedOnItr != dependedOn.end(); ++dependedOnItr ) + { + // Find the dependent asset. + AssetDefinition* pDependentDefinition = findAsset( *dependedOnItr ); + + if ( pDependentDefinition != NULL ) + notifyAssetRefresh( pDependentDefinition, false ); + } + } } //----------------------------------------------------------------------------- +// Re-derive every loaded asset's runtime state and tell everything that watches +// them. +// +// This used to rewrite every declared asset file in the project, and with +// includeUnloaded it loaded each one purely so it could write it back out again. +// Since refreshAsset no longer writes, this no longer does either -- and it +// suppresses dirty marking, because re-deriving is not editing. The verb for +// "write everything" is saveAllDirtyAssets. void AssetManager::refreshAllAssets( const bool includeUnloaded ) { // Debug Profiling. @@ -1302,6 +1433,10 @@ void AssetManager::refreshAllAssets( const bool includeUnloaded ) Con::printf( "Asset Manager: Started refreshing ALL assets." ); } + // One announcement per asset for the whole sweep, and nothing here counts as + // a user edit. + ScopedAssetUpdate scopedUpdate( this, true ); + Vector assetsToRelease; // Are we including unloaded assets? @@ -1355,6 +1490,565 @@ void AssetManager::refreshAllAssets( const bool includeUnloaded ) } } +// The loaded module a file belongs to, by path. +// +// The longest matching module path wins, so a module nested inside another one +// is preferred over its container. +ModuleDefinition* AssetManager::findModuleForPath( const char* pFilePath ) +{ + // Sanity! + AssertFatal( pFilePath != NULL, "Cannot find a module for a NULL path." ); + + ModuleManager::typeConstModuleDefinitionVector modules; + ModuleDatabase.findModules( false, modules ); + + ModuleDefinition* pBestModule = NULL; + U32 bestLength = 0; + + for( ModuleManager::typeConstModuleDefinitionVector::iterator moduleItr = modules.begin(); moduleItr != modules.end(); ++moduleItr ) + { + ModuleDefinition* pModuleDefinition = const_cast( *moduleItr ); + + StringTableEntry modulePath = pModuleDefinition->getModulePath(); + + if ( modulePath == StringTable->EmptyString || !Con::isBasePath( pFilePath, modulePath ) ) + continue; + + const U32 pathLength = dStrlen( modulePath ); + + if ( pBestModule != NULL && pathLength <= bestLength ) + continue; + + pBestModule = pModuleDefinition; + bestLength = pathLength; + } + + return pBestModule; +} + +//----------------------------------------------------------------------------- + +// Collect the asset ids and loose files an object's fields refer to. +// +// Both kinds are recognised by the console type's prefix rather than by naming +// the types, so this covers TypeAssetId, TypeImageAssetPtr, TypeAnimationAssetPtr +// and anything declared like them in future without being told about it. The +// prefix is the same thing TAML writes into the file and the same thing +// TamlAssetDeclaredVisitor keys on when reading it back, so the two agree by +// construction. +static void collectAssetFieldReferences( SimObject* pSimObject, Vector& dependencies, Vector& looseFiles ) +{ + static StringTableEntry assetIdPrefix = StringTable->insert( ASSET_ID_FIELD_PREFIX ); + static StringTableEntry assetLooseFilePrefix = StringTable->insert( ASSET_LOOSE_FILE_FIELD_PREFIX ); + + const AbstractClassRep::FieldList& fields = pSimObject->getFieldList(); + + for( U32 index = 0; index < (U32)fields.size(); ++index ) + { + const AbstractClassRep::Field& field = fields[index]; + + // Skip the group markers. + if ( field.type == AbstractClassRep::StartGroupFieldType || + field.type == AbstractClassRep::EndGroupFieldType || + field.type == AbstractClassRep::DepricatedFieldType ) + continue; + + // Fetch the console type. + ConsoleBaseType* pConsoleType = ConsoleBaseType::getType( field.type ); + + if ( pConsoleType == NULL ) + continue; + + // Fetch the prefix that marks this field as naming an asset. + StringTableEntry typePrefix = pConsoleType->getTypePrefix(); + + if ( typePrefix != assetIdPrefix && typePrefix != assetLooseFilePrefix ) + continue; + + // Fetch the value. + StringTableEntry value = StringTable->insert( pSimObject->getDataField( StringTable->insert( field.pFieldname ), NULL ) ); + + if ( value == StringTable->EmptyString ) + continue; + + if ( typePrefix == assetIdPrefix ) + dependencies.push_back( value ); + else + looseFiles.push_back( value ); + } +} + +//----------------------------------------------------------------------------- + +// Recalculate what an asset depends on, from the asset in memory. +// +// This exists because refreshAsset no longer writes. The dependency and +// loose-file graphs used to be rebuilt by re-parsing the file that had just been +// written, so they were always current; with a save that may be a long way off, +// they would otherwise stay as they were when the asset was last saved. +// +// That is not cosmetic. The cascade in notifyAssetRefresh is the only way an +// AnimationAsset hears that its image changed -- it registers no refresh notify +// of its own -- so a stale graph means repointing an animation at another image +// quietly stops it being re-cut when that image is edited. +void AssetManager::updateAssetDependencies( AssetDefinition* pAssetDefinition ) +{ + // Sanity! + AssertFatal( pAssetDefinition != NULL, "Cannot update dependencies for a NULL asset definition." ); + + // Fetch the asset. + AssetBase* pAssetBase = pAssetDefinition->mpAssetBase; + + if ( pAssetBase == NULL ) + return; + + // Fetch asset Id. + StringTableEntry assetId = pAssetDefinition->mAssetId; + + Vector dependencies; + Vector looseFiles; + + // The asset itself. + collectAssetFieldReferences( pAssetBase, dependencies, looseFiles ); + + // And its Taml children, which is how a ParticleAsset carries its emitters -- + // and the emitters are where a particle asset's image and animation + // dependencies actually live. + TamlChildren* pChildren = dynamic_cast( pAssetBase ); + + if ( pChildren != NULL ) + { + const U32 childCount = pChildren->getTamlChildCount(); + + for( U32 index = 0; index < childCount; ++index ) + { + SimObject* pChild = pChildren->getTamlChild( index ); + + if ( pChild != NULL ) + collectAssetFieldReferences( pChild, dependencies, looseFiles ); + } + } + + // Out with the old. + removeAssetDependencies( assetId ); + + // In with the new. + for( Vector::iterator dependencyItr = dependencies.begin(); dependencyItr != dependencies.end(); ++dependencyItr ) + { + // Insert depends-on. + mAssetDependsOn.insertEqual( assetId, *dependencyItr ); + + // Insert is-depended-on. + mAssetIsDependedOn.insertEqual( *dependencyItr, assetId ); + } + + // Loose files are stored expanded, which is what both the field getters and + // TamlAssetDeclaredVisitor produce. + pAssetDefinition->mAssetLooseFiles.clear(); + + for( Vector::iterator looseFileItr = looseFiles.begin(); looseFileItr != looseFiles.end(); ++looseFileItr ) + { + pAssetDefinition->mAssetLooseFiles.push_back( *looseFileItr ); + } +} + +//----------------------------------------------------------------------------- + +bool AssetManager::isAssetDirty( const char* pAssetId ) +{ + // Sanity! + AssertFatal( pAssetId != NULL, "Cannot check a NULL asset Id for changes." ); + + // Find asset. + AssetDefinition* pAssetDefinition = findAsset( pAssetId ); + + return pAssetDefinition != NULL && pAssetDefinition->mAssetDirty; +} + +//----------------------------------------------------------------------------- + +// Say outright whether an asset counts as unsaved. +// +// This exists for undo. Restoring a snapshot deliberately does not decide the +// dirty state, because only the caller knows what the state it restored means: +// stepping back to what was last saved is clean, and stepping back to anywhere +// else is not. Everything else should reach this through refreshAsset, saveAsset +// or revertAsset rather than setting the flag by hand. +bool AssetManager::setAssetDirty( const char* pAssetId, const bool assetDirty ) +{ + // Sanity! + AssertFatal( pAssetId != NULL, "Cannot set the unsaved state of a NULL asset Id." ); + + // Find asset. + AssetDefinition* pAssetDefinition = findAsset( pAssetId ); + + // Did we find the asset? + if ( pAssetDefinition == NULL ) + { + // No, so warn. + Con::warnf( "Asset Manager: Failed to set the unsaved state of asset Id '%s' as it does not exist.", pAssetId ); + return false; + } + + // A private asset has no file to be out of step with. + if ( pAssetDefinition->mAssetPrivate ) + return false; + + // Finish if no change. Only the edge is worth announcing. + if ( pAssetDefinition->mAssetDirty == assetDirty ) + return true; + + pAssetDefinition->mAssetDirty = assetDirty; + + Con::executef( 2, "onAssetDirtyChanged", pAssetDefinition->mAssetId ); + + return true; +} + +//----------------------------------------------------------------------------- + +U32 AssetManager::getDirtyAssetCount( void ) const +{ + U32 dirtyCount = 0; + + for( typeDeclaredAssetsHash::const_iterator assetItr = mDeclaredAssets.begin(); assetItr != mDeclaredAssets.end(); ++assetItr ) + { + if ( assetItr->value->mAssetDirty ) + dirtyCount++; + } + + return dirtyCount; +} + +//----------------------------------------------------------------------------- + +// Write the asset's file. The only place in the engine that does. +bool AssetManager::writeAssetDefinitionFile( AssetDefinition* pAssetDefinition ) +{ + // Sanity! + AssertFatal( pAssetDefinition != NULL, "Cannot write a NULL asset definition." ); + + // Fetch the asset. + AssetBase* pAssetBase = pAssetDefinition->mpAssetBase; + + // Finish if the asset is not loaded. There is nothing in memory to write. + if ( pAssetBase == NULL ) + return false; + + // Save asset. + if ( !mTaml.write( pAssetBase, pAssetDefinition->mAssetBaseFilePath ) ) + { + // Warn. + Con::warnf( "Asset Manager: Failed to write asset Id '%s' to '%s'.", pAssetDefinition->mAssetId, pAssetDefinition->mAssetBaseFilePath ); + return false; + } + + // Remove asset dependencies. + removeAssetDependencies( pAssetDefinition->mAssetId ); + + // Find any new dependencies. + TamlAssetDeclaredVisitor assetDeclaredVisitor; + + // Parse the filename. + // + // The file that was just written is the authority on what this asset depends + // on and which loose files it uses. updateAssetDependencies keeps those two + // lists roughly right while the asset is unsaved; this is where they are made + // exactly right. + if ( !mTaml.parse( pAssetDefinition->mAssetBaseFilePath, assetDeclaredVisitor ) ) + { + // Warn. + Con::warnf( "Asset Manager: Failed to parse file containing asset declaration: '%s'.\nDependencies are now incorrect!", pAssetDefinition->mAssetBaseFilePath ); + return false; + } + + // Fetch asset Id. + StringTableEntry assetId = pAssetDefinition->mAssetId; + + // Fetch asset dependencies. + TamlAssetDeclaredVisitor::typeAssetIdVector& assetDependencies = assetDeclaredVisitor.getAssetDependencies(); + + // Iterate dependencies. + for( TamlAssetDeclaredVisitor::typeAssetIdVector::iterator assetDependencyItr = assetDependencies.begin(); assetDependencyItr != assetDependencies.end(); ++assetDependencyItr ) + { + // Fetch dependency asset Id. + StringTableEntry dependencyAssetId = *assetDependencyItr; + + // Insert depends-on. + mAssetDependsOn.insertEqual( assetId, dependencyAssetId ); + + // Insert is-depended-on. + mAssetIsDependedOn.insertEqual( dependencyAssetId, assetId ); + } + + // Fetch asset loose files. + TamlAssetDeclaredVisitor::typeLooseFileVector& assetLooseFiles = assetDeclaredVisitor.getAssetLooseFiles(); + + // Clear any existing loose files. + pAssetDefinition->mAssetLooseFiles.clear(); + + // Iterate loose files. + for( TamlAssetDeclaredVisitor::typeLooseFileVector::iterator assetLooseFileItr = assetLooseFiles.begin(); assetLooseFileItr != assetLooseFiles.end(); ++assetLooseFileItr ) + { + // Store loose file. + pAssetDefinition->mAssetLooseFiles.push_back( *assetLooseFileItr ); + } + + return true; +} + +//----------------------------------------------------------------------------- + +bool AssetManager::saveAsset( const char* pAssetId ) +{ + // Debug Profiling. + PROFILE_SCOPE(AssetManager_SaveAsset); + + // Sanity! + AssertFatal( pAssetId != NULL, "Cannot save a NULL asset Id." ); + + // Find asset. + AssetDefinition* pAssetDefinition = findAsset( pAssetId ); + + // Did we find the asset? + if ( pAssetDefinition == NULL ) + { + // No, so warn. + Con::warnf( "Asset Manager: Failed to save asset Id '%s' as it does not exist.", pAssetId ); + return false; + } + + // A private asset has no file of its own to be written to. + if ( pAssetDefinition->mAssetPrivate ) + { + // Warn. + Con::warnf( "Asset Manager: Cannot save asset Id '%s' as it is a private asset.", pAssetId ); + return false; + } + + // Write it. + if ( !writeAssetDefinitionFile( pAssetDefinition ) ) + return false; + + // Saved, so no longer out of step with the file. + if ( pAssetDefinition->mAssetDirty ) + { + pAssetDefinition->mAssetDirty = false; + + Con::executef( 2, "onAssetDirtyChanged", pAssetDefinition->mAssetId ); + } + + return true; +} + +//----------------------------------------------------------------------------- + +U32 AssetManager::saveAllDirtyAssets( void ) +{ + // Debug Profiling. + PROFILE_SCOPE(AssetManager_SaveAllDirtyAssets); + + // Compile the list first: saving an asset can change the dependency hashes, + // and iterating the declared assets while that happens is asking for trouble. + Vector dirtyAssets; + + for( typeDeclaredAssetsHash::iterator assetItr = mDeclaredAssets.begin(); assetItr != mDeclaredAssets.end(); ++assetItr ) + { + if ( assetItr->value->mAssetDirty ) + dirtyAssets.push_back( assetItr->value->mAssetId ); + } + + U32 savedCount = 0; + + for( Vector::iterator assetItr = dirtyAssets.begin(); assetItr != dirtyAssets.end(); ++assetItr ) + { + if ( saveAsset( *assetItr ) ) + savedCount++; + } + + return savedCount; +} + +//----------------------------------------------------------------------------- + +// Throw away the in-memory changes and go back to what is on disk. +// +// The asset object itself is deliberately NOT replaced. Every AssetPtr holds a +// raw pointer to it (assetPtr.h), so swapping the object would leave every +// Sprite, ImageFrameProvider and ParticlePlayer in the scene pointing at a +// deleted asset. Instead the file is read into a scratch object and copied onto +// the live one, which keeps the identity everything else is holding. +bool AssetManager::revertAsset( const char* pAssetId ) +{ + // Debug Profiling. + PROFILE_SCOPE(AssetManager_RevertAsset); + + // Sanity! + AssertFatal( pAssetId != NULL, "Cannot revert a NULL asset Id." ); + + // Find asset. + AssetDefinition* pAssetDefinition = findAsset( pAssetId ); + + // Did we find the asset? + if ( pAssetDefinition == NULL ) + { + // No, so warn. + Con::warnf( "Asset Manager: Failed to revert asset Id '%s' as it does not exist.", pAssetId ); + return false; + } + + // Fetch the asset. + AssetBase* pAssetBase = pAssetDefinition->mpAssetBase; + + // Finish if the asset is not loaded. Nothing is in memory to put back. + if ( pAssetBase == NULL ) + return false; + + // A private asset has no file to revert to. + if ( pAssetDefinition->mAssetPrivate ) + { + // Warn. + Con::warnf( "Asset Manager: Cannot revert asset Id '%s' as it is a private asset.", pAssetId ); + return false; + } + + // Read the saved asset into a scratch object. + AssetBase* pSavedAsset = mTaml.read( pAssetDefinition->mAssetBaseFilePath ); + + if ( pSavedAsset == NULL ) + { + // Warn. + Con::warnf( "Asset Manager: Failed to revert asset Id '%s' as its file '%s' could not be read.", pAssetId, pAssetDefinition->mAssetBaseFilePath ); + return false; + } + + // The scratch object has to be the same kind of asset, or copying it across + // would be nonsense. + if ( pSavedAsset->getClassRep() != pAssetBase->getClassRep() ) + { + // Warn. + Con::warnf( "Asset Manager: Failed to revert asset Id '%s' as its file describes a '%s' and the loaded asset is a '%s'.", + pAssetId, pSavedAsset->getClassName(), pAssetBase->getClassName() ); + pSavedAsset->deleteObject(); + return false; + } + + { + // Putting an asset back is not editing it, and the whole restore is one + // change as far as anything watching is concerned. + ScopedAssetUpdate scopedUpdate( this, true ); + + pSavedAsset->copyTo( pAssetBase ); + } + + // Done with the scratch object. + pSavedAsset->deleteObject(); + + // Back in step with the file. + if ( pAssetDefinition->mAssetDirty ) + { + pAssetDefinition->mAssetDirty = false; + + Con::executef( 2, "onAssetDirtyChanged", pAssetDefinition->mAssetId ); + } + + return true; +} + +//----------------------------------------------------------------------------- + +// Write a copy of an asset to a new file and declare it. +// +// The copy is made through an unowned scratch object rather than by copying the +// file, so that what is duplicated is the asset as it stands in memory, unsaved +// edits included. Unowned is also what makes setAssetName work: it is a no-op on +// an asset the manager already owns. +bool AssetManager::duplicateAsset( const char* pAssetId, const char* pTargetFilePath, const char* pTargetAssetName ) +{ + // Debug Profiling. + PROFILE_SCOPE(AssetManager_DuplicateAsset); + + // Sanity! + AssertFatal( pAssetId != NULL, "Cannot duplicate a NULL asset Id." ); + AssertFatal( pTargetFilePath != NULL, "Cannot duplicate an asset to a NULL file path." ); + AssertFatal( pTargetAssetName != NULL, "Cannot duplicate an asset to a NULL asset name." ); + + // Find asset. + AssetDefinition* pAssetDefinition = findAsset( pAssetId ); + + // Did we find the asset? + if ( pAssetDefinition == NULL ) + { + // No, so warn. + Con::warnf( "Asset Manager: Failed to duplicate asset Id '%s' as it does not exist.", pAssetId ); + return false; + } + + // Fetch the asset. + AssetBase* pAssetBase = pAssetDefinition->mpAssetBase; + + // Finish if the asset is not loaded. + if ( pAssetBase == NULL ) + { + // Warn. + Con::warnf( "Asset Manager: Failed to duplicate asset Id '%s' as it is not loaded.", pAssetId ); + return false; + } + + // Take a copy. + AssetBase* pCopiedAsset = pAssetBase->createStateSnapshot(); + + if ( pCopiedAsset == NULL ) + { + // Warn. + Con::warnf( "Asset Manager: Failed to duplicate asset Id '%s' as it could not be copied.", pAssetId ); + return false; + } + + // Name it. + pCopiedAsset->setAssetName( pTargetAssetName ); + + // Write it. + const bool written = mTaml.write( pCopiedAsset, pTargetFilePath ); + + // Done with the copy either way. + pCopiedAsset->deleteObject(); + + if ( !written ) + { + // Warn. + Con::warnf( "Asset Manager: Failed to duplicate asset Id '%s' as '%s' could not be written.", pAssetId, pTargetFilePath ); + return false; + } + + // Which module the copy belongs to. + // + // Deliberately NOT pAssetDefinition->mpModuleDefinition. Re-scanning a path + // that already holds a registered module destroys the old ModuleDefinition + // and builds a new one, and nothing tells the assets that point at the old + // one -- so that pointer can be dangling, and dereferencing it produces + // whatever happens to be in the freed memory. Looking the module up by path, + // now, cannot be stale. + ModuleDefinition* pTargetModule = findModuleForPath( pTargetFilePath ); + + if ( pTargetModule == NULL ) + { + // Warn. + Con::warnf( "Asset Manager: Duplicated asset Id '%s' to '%s' but no loaded module owns that location, so it could not be declared.", pAssetId, pTargetFilePath ); + return false; + } + + // Declare it, so it becomes an asset rather than a stray file. + if ( !addDeclaredAsset( pTargetModule, pTargetFilePath ) ) + { + // Warn. + Con::warnf( "Asset Manager: Duplicated asset Id '%s' to '%s' but could not declare it.", pAssetId, pTargetFilePath ); + return false; + } + + return true; +} + //----------------------------------------------------------------------------- void AssetManager::registerAssetPtrRefreshNotify( AssetPtrBase* pAssetPtrBase, AssetPtrCallback* pCallback ) @@ -1900,6 +2594,68 @@ S32 AssetManager::findAssetPrivate( AssetQuery* pAssetQuery, const bool assetPri //----------------------------------------------------------------------------- +S32 AssetManager::findAssetDirty( AssetQuery* pAssetQuery, const bool assetDirty, const bool assetQueryAsSource ) +{ + // Debug Profiling. + PROFILE_SCOPE(AssetManager_FindAssetDirty); + + // Sanity! + AssertFatal( pAssetQuery != NULL, "Cannot use NULL asset query." ); + + // Reset result count. + S32 resultCount = 0; + + // Use asset-query as the source? + if ( assetQueryAsSource ) + { + AssetQuery filteredAssets; + + // Yes, so iterate asset query. + for( Vector::iterator assetItr = pAssetQuery->begin(); assetItr != pAssetQuery->end(); ++assetItr ) + { + // Fetch asset definition. + AssetDefinition* pAssetDefinition = findAsset( *assetItr ); + + // Skip if this is not the asset we want. + if ( pAssetDefinition == NULL || + pAssetDefinition->mAssetDirty != assetDirty ) + continue; + + // Store as result. + filteredAssets.push_back( pAssetDefinition->mAssetId ); + + // Increase result count. + resultCount++; + } + + // Set asset query. + pAssetQuery->set( filteredAssets ); + } + else + { + // No, so iterate declared assets. + for( typeDeclaredAssetsHash::iterator assetItr = mDeclaredAssets.begin(); assetItr != mDeclaredAssets.end(); ++assetItr ) + { + // Fetch asset definition. + AssetDefinition* pAssetDefinition = assetItr->value; + + // Skip if this is not the asset we want. + if ( assetDirty != pAssetDefinition->mAssetDirty ) + continue; + + // Store as result. + pAssetQuery->push_back( pAssetDefinition->mAssetId ); + + // Increase result count. + resultCount++; + } + } + + return resultCount; +} + +//----------------------------------------------------------------------------- + S32 AssetManager::findAssetType( AssetQuery* pAssetQuery, const char* pAssetType, const bool assetQueryAsSource ) { // Debug Profiling. @@ -2931,6 +3687,15 @@ void AssetManager::unloadAsset( AssetDefinition* pAssetDefinition ) // Debug Profiling. PROFILE_SCOPE(AssetManager_UnloadAsset); + // An asset with unsaved changes stays resident. + // + // Unloading destroys the asset object, and with it every change the user has + // made and not yet saved. Releasing the last reference -- which is as + // ordinary as closing the inspector on it -- would otherwise throw that work + // away with no warning and no way back. + if ( pAssetDefinition->mAssetDirty ) + return; + // Destroy the asset. pAssetDefinition->mpAssetBase->deleteObject(); diff --git a/engine/source/assets/assetManager.h b/engine/source/assets/assetManager.h index a10f54e12..5a4f7995a 100755 --- a/engine/source/assets/assetManager.h +++ b/engine/source/assets/assetManager.h @@ -99,6 +99,23 @@ class AssetManager : public SimObject, public ModuleCallbacks /// Asset pointer refresh notifications. typeAssetPtrRefreshHash mAssetPtrRefreshNotifications; + /// One queued announcement. + /// + /// mDirect separates "this asset was changed" from "something this asset + /// reads from was changed". Both have to be announced -- a dependent needs to + /// re-derive what it caches -- but only the first is an edit to the asset, + /// and an editor recording undo steps has no other way to tell them apart. + struct PendingRefresh + { + typeAssetId mAssetId; + bool mDirect; + }; + + /// Refresh batching. See ScopedAssetUpdate. + U32 mAssetBatchDepth; + U32 mDirtySuppressDepth; + Vector mPendingRefreshNotifications; + /// Miscellaneous. bool mEchoInfo; bool mIgnoreAutoUnload; @@ -326,11 +343,52 @@ class AssetManager : public SimObject, public ModuleCallbacks bool deleteAsset( const char* pAssetId, const bool deleteLooseFiles, const bool deleteDependencies ); // Asset refresh notification. + // + // NOTE: refreshAsset does NOT write the asset's file. It marks the asset + // dirty and tells everything that cares that the asset changed. Writing is + // saveAsset, and only ever saveAsset. That split is what lets the editors + // let a user try something and then put it back -- and it stops a running + // game rewriting its own content files as a side effect of a setter. bool refreshAsset( const char* pAssetId ); void refreshAllAssets( const bool includeUnloaded = false ); void registerAssetPtrRefreshNotify( AssetPtrBase* pAssetPtrBase, AssetPtrCallback* pCallback ); void unregisterAssetPtrRefreshNotify( AssetPtrBase* pAssetPtrBase ); + /// Unsaved changes. + bool isAssetDirty( const char* pAssetId ); + bool setAssetDirty( const char* pAssetId, const bool assetDirty ); + U32 getDirtyAssetCount( void ) const; + bool saveAsset( const char* pAssetId ); + U32 saveAllDirtyAssets( void ); + bool revertAsset( const char* pAssetId ); + bool duplicateAsset( const char* pAssetId, const char* pTargetFilePath, const char* pTargetAssetName ); + + /// Collapses a run of asset changes into a single notification, and + /// optionally stops them marking anything dirty. + /// + /// Two things need this. A restore replays every setter on an asset, each of + /// which calls refreshAsset, and one undo step should read as one change and + /// should not itself count as an edit. And the notification loop can come + /// back round: ParticleAssetEmitter::onAssetRefreshed calls refreshAsset on + /// its owner, so an image change reaches the owning particle asset once per + /// emitter pointed at it. + /// + /// Notifications raised inside the scope are queued, deduplicated, and + /// drained when the outermost scope closes. The drain holds the depth, so a + /// notification raised by a callback joins the same drain rather than + /// starting a new one -- which also bounds what used to be an unguarded + /// recursion through mutually dependent assets. + class ScopedAssetUpdate + { + public: + ScopedAssetUpdate( AssetManager* pAssetManager, const bool suppressDirty = false ); + ~ScopedAssetUpdate(); + + private: + AssetManager* mpAssetManager; + bool mSuppressDirty; + }; + /// Asset tags. bool loadAssetTags( ModuleDefinition* pModuleDefinition ); bool saveAssetTags( void ); @@ -360,6 +418,7 @@ class AssetManager : public SimObject, public ModuleCallbacks S32 findAssetAutoUnload( AssetQuery* pAssetQuery, const bool assetAutoUnload, const bool assetQueryAsSource = false ); S32 findAssetInternal( AssetQuery* pAssetQuery, const bool assetInternal, const bool assetQueryAsSource = false ); S32 findAssetPrivate( AssetQuery* pAssetQuery, const bool assetPrivate, const bool assetQueryAsSource = false ); + S32 findAssetDirty( AssetQuery* pAssetQuery, const bool assetDirty, const bool assetQueryAsSource = false ); S32 findAssetType( AssetQuery* pAssetQuery, const char* pAssetType, const bool assetQueryAsSource = false ); S32 findAssetDependsOn( AssetQuery* pAssetQuery, const char* pAssetId ); S32 findAssetIsDependedOn( AssetQuery* pAssetQuery, const char* pAssetId ); @@ -382,6 +441,15 @@ class AssetManager : public SimObject, public ModuleCallbacks void removeAssetLooseFiles( const char* pAssetId ); void unloadAsset( AssetDefinition* pAssetDefinition ); + /// The pieces refreshAsset and saveAsset are built from. + void markAssetDirty( AssetDefinition* pAssetDefinition ); + void notifyAssetRefresh( AssetDefinition* pAssetDefinition, const bool direct ); + void dispatchAssetRefresh( AssetDefinition* pAssetDefinition, const bool direct ); + bool writeAssetDefinitionFile( AssetDefinition* pAssetDefinition ); + void updateAssetDependencies( AssetDefinition* pAssetDefinition ); + ModuleDefinition* findModuleForPath( const char* pFilePath ); + void drainRefreshNotifications( void ); + /// Module callbacks. virtual void onModulePreLoad( ModuleDefinition* pModuleDefinition ); virtual void onModulePreUnload( ModuleDefinition* pModuleDefinition ); diff --git a/engine/source/assets/assetManager_ScriptBinding.h b/engine/source/assets/assetManager_ScriptBinding.h index bb225579d..f8f696afa 100755 --- a/engine/source/assets/assetManager_ScriptBinding.h +++ b/engine/source/assets/assetManager_ScriptBinding.h @@ -393,7 +393,16 @@ ConsoleMethodWithDocs(AssetManager, preloadAsset, ConsoleVoid, 3, 3, (assetId)) return; } - // Set the asset to auto-unload false + // Set the asset to auto-unload false. + // + // This is a setter like any other, so it marks the asset as changed -- and + // preloading a project's worth of assets at startup would otherwise greet the + // user with a pile of unsaved assets they had never touched. Suppress it: + // keeping an asset resident is a decision about this run, not an edit to the + // asset. (It used to WRITE AssetAutoUnload="0" into the content file for the + // same reason, which was worse.) + AssetManager::ScopedAssetUpdate scopedUpdate( object, true ); + pAssetBase->setAssetAutoUnload(false); // Release asset. @@ -438,6 +447,8 @@ ConsoleMethodWithDocs( AssetManager, deleteAsset, ConsoleBool, 5, 5, (assetId, d //----------------------------------------------------------------------------- /*! Refresh the specified asset Id. + This marks the asset as having unsaved changes and tells everything watching it + that it changed. It does NOT write the asset's file -- use saveAsset for that. @param assetId The selected asset Id. @return No return value. */ @@ -448,6 +459,89 @@ ConsoleMethodWithDocs( AssetManager, refreshAsset, ConsoleVoid, 3, 3, (assetId)) //----------------------------------------------------------------------------- +/*! Whether the specified asset has changes that have not been saved. + @param assetId The selected asset Id. + @return Whether the asset has unsaved changes or not. +*/ +ConsoleMethodWithDocs( AssetManager, isAssetDirty, ConsoleBool, 3, 3, (assetId)) +{ + return object->isAssetDirty( argv[2] ); +} + +//----------------------------------------------------------------------------- + +/*! Sets whether the specified asset counts as having unsaved changes. + This is for undo, which is the only thing that knows whether the state it just + restored is the saved one. Everything else should use refreshAsset, saveAsset + or revertAsset rather than setting the flag directly. + @param assetId The selected asset Id. + @param assetDirty Whether the asset has unsaved changes or not. + @return Whether the flag was set or not. +*/ +ConsoleMethodWithDocs( AssetManager, setAssetDirty, ConsoleBool, 4, 4, (assetId, assetDirty)) +{ + return object->setAssetDirty( argv[2], dAtob(argv[3]) ); +} + +//----------------------------------------------------------------------------- + +/*! Gets how many declared assets have changes that have not been saved. + @return The number of assets with unsaved changes. +*/ +ConsoleMethodWithDocs( AssetManager, getDirtyAssetCount, ConsoleInt, 2, 2, ()) +{ + return (S32)object->getDirtyAssetCount(); +} + +//----------------------------------------------------------------------------- + +/*! Write the specified asset to its file. + @param assetId The selected asset Id. + @return Whether the save was successful or not. +*/ +ConsoleMethodWithDocs( AssetManager, saveAsset, ConsoleBool, 3, 3, (assetId)) +{ + return object->saveAsset( argv[2] ); +} + +//----------------------------------------------------------------------------- + +/*! Write every asset that has unsaved changes to its file. + @return The number of assets that were saved. +*/ +ConsoleMethodWithDocs( AssetManager, saveAllDirtyAssets, ConsoleInt, 2, 2, ()) +{ + return (S32)object->saveAllDirtyAssets(); +} + +//----------------------------------------------------------------------------- + +/*! Discard the specified asset's unsaved changes and reload it from its file. + The asset object itself is kept, so anything already using the asset keeps working. + @param assetId The selected asset Id. + @return Whether the revert was successful or not. +*/ +ConsoleMethodWithDocs( AssetManager, revertAsset, ConsoleBool, 3, 3, (assetId)) +{ + return object->revertAsset( argv[2] ); +} + +//----------------------------------------------------------------------------- + +/*! Write a copy of the specified asset to a new file and declare it. + The copy is taken from the asset as it stands in memory, so any unsaved changes are included. + @param assetId The asset Id to copy. + @param targetFilePath The file to write the copy to. + @param targetAssetName The asset name to give the copy. + @return Whether the duplication was successful or not. +*/ +ConsoleMethodWithDocs( AssetManager, duplicateAsset, ConsoleBool, 5, 5, (assetId, targetFilePath, targetAssetName)) +{ + return object->duplicateAsset( argv[2], argv[3], argv[4] ); +} + +//----------------------------------------------------------------------------- + /*! Refresh all declared assets. @param Whether to include currently unloaded assets in the refresh or not. Optional: Defaults to false. Refreshing all assets can be an expensive (time-consuming) operation to perform. @@ -721,7 +815,45 @@ ConsoleMethodWithDocs( AssetManager, findAssetPrivate, ConsoleInt, 4, 5, (assetQ const bool assetQueryAsSource = dAtob(argv[4]); // Perform query. - return object->findAssetInternal( pAssetQuery, assetPrivate, assetQueryAsSource ); + return object->findAssetPrivate( pAssetQuery, assetPrivate, assetQueryAsSource ); +} + +//----------------------------------------------------------------------------- + +/*! Performs an asset query searching for assets with unsaved changes. + @param assetQuery The asset query object that will be populated with the results. + @param assetDirty The unsaved-changes flag to search for. + @param assetQueryAsSource Whether to use the asset query as the data-source rather than the asset managers database or not. Doing this effectively filters the asset query. Optional: Defaults to false. + @return The number of asset Ids found or (-1) if an error occurred. +*/ +ConsoleMethodWithDocs( AssetManager, findAssetDirty, ConsoleInt, 4, 5, (assetQuery, assetDirty, [assetQueryAsSource?])) +{ + // Fetch asset query. + AssetQuery* pAssetQuery = Sim::findObject( argv[2] ); + + // Did we find the asset query? + if ( pAssetQuery == NULL ) + { + // No, so warn. + Con::warnf( "AssetManager::findAssetDirty() - Could not find the asset query object '%s'.", argv[2] ); + return -1; + } + + // Fetch unsaved-changes flag. + const bool assetDirty = dAtob(argv[3]); + + // Any more arguments? + if ( argc == 4 ) + { + // No, so perform query. + return object->findAssetDirty( pAssetQuery, assetDirty ); + } + + // Fetch asset-query-as-source flag. + const bool assetQueryAsSource = dAtob(argv[4]); + + // Perform query. + return object->findAssetDirty( pAssetQuery, assetDirty, assetQueryAsSource ); } //----------------------------------------------------------------------------- diff --git a/engine/source/audio/AudioAsset.cc b/engine/source/audio/AudioAsset.cc index 980113049..8738d7371 100755 --- a/engine/source/audio/AudioAsset.cc +++ b/engine/source/audio/AudioAsset.cc @@ -126,28 +126,6 @@ void AudioAsset::initPersistFields() //------------------------------------------------------------------------------ -void AudioAsset::copyTo(SimObject* object) -{ - // Call to parent. - Parent::copyTo(object); - - // Cast to asset. - AudioAsset* pAsset = static_cast(object); - - // Sanity! - AssertFatal(pAsset != NULL, "AudioAsset::copyTo() - Object is not the correct type."); - - // Copy state. - pAsset->setAudioFile( getAudioFile() ); - pAsset->setVolume( getVolume() ); - pAsset->setVolumeChannel( getVolumeChannel() ); - pAsset->setLooping( getLooping() ); - pAsset->setStreaming( getStreaming() ); - pAsset->setPriority( getPriority() ); -} - -//-------------------------------------------------------------------------- - void AudioAsset::initializeAsset( void ) { // Call parent. diff --git a/engine/source/audio/AudioAsset.h b/engine/source/audio/AudioAsset.h index d3801dc85..b0f934751 100755 --- a/engine/source/audio/AudioAsset.h +++ b/engine/source/audio/AudioAsset.h @@ -63,7 +63,6 @@ class AudioAsset: public AssetBase public: AudioAsset(); static void initPersistFields(); - virtual void copyTo(SimObject* object); void setAudioFile( const char* pAudioFile ); inline StringTableEntry getAudioFile( void ) const { return mAudioFile; } diff --git a/engine/source/testing/tests/assetStateCopyTests.cc b/engine/source/testing/tests/assetStateCopyTests.cc new file mode 100644 index 000000000..1d954325e --- /dev/null +++ b/engine/source/testing/tests/assetStateCopyTests.cc @@ -0,0 +1,402 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +#ifndef _ASSET_BASE_H_ +#include "assets/assetBase.h" +#endif + +#ifndef _IMAGE_ASSET_H_ +#include "2d/assets/ImageAsset.h" +#endif + +#ifndef _ANIMATION_ASSET_H_ +#include "2d/assets/AnimationAsset.h" +#endif + +#ifndef _PARTICLE_ASSET_H_ +#include "2d/assets/ParticleAsset.h" +#endif + +#ifndef _PARTICLE_ASSET_EMITTER_H_ +#include "2d/assets/ParticleAssetEmitter.h" +#endif + +#ifndef _AUDIO_ASSET_H_ +#include "audio/AudioAsset.h" +#endif + +#ifndef _FONT_ASSET_H_ +#include "2d/assets/FontAsset.h" +#endif + +#ifndef _CONSOLE_H_ +#include "console/console.h" +#endif + +//----------------------------------------------------------------------------- +// Copying an asset's state onto another asset of the same type. +// +// This is what clone(), acquireAsset(id, true) and the Asset Manager's undo all +// stand on, and until these tests existed every asset type got it wrong. Each +// type listed its own fields by hand, and every one of those lists had drifted +// from the fields it was supposed to carry: +// +// ImageAsset copied its cell COUNT into its cell OFFSET, never +// copied image layers at all, and dropped explicit cells +// entirely unless ExplicitMode happened to be on. +// AnimationAsset chose between numbered and named frames by reading the +// TARGET's NamedCellsMode -- still the default -- and +// then set that mode afterwards. +// ParticleAssetEmitter same shape: it read the TARGET's frame-provider mode, +// which defaults to static, so an animated emitter +// copied as a blank static one. +// +// The fix was to stop listing fields: AssetBase::copyTo walks the field table, +// and each type overrides copyAssetStateTo only for the state no field describes +// -- TAML custom nodes and Taml children. The last two tests here are what keep +// it that way, by enumerating the field table rather than a list of their own. +// +// None of this needs a canvas or a GL context. An asset that the asset manager +// does not own never reaches initializeAsset or onAssetRefresh, so no texture is +// ever registered: ImageAsset::onAdd does nothing, and insertLayer only loads a +// layer's bitmap when the asset is owned. +//----------------------------------------------------------------------------- + +static StringTableEntry assetFieldName( const char* name ) +{ + return StringTable->insert( name ); +} + +static const char* readAssetField( SimObject* object, const char* name ) +{ + return object->getDataField( assetFieldName( name ), NULL ); +} + +static void writeAssetField( SimObject* object, const char* name, const char* value ) +{ + object->setDataField( assetFieldName( name ), NULL, value ); +} + +// A registered, unowned asset of the given type. Unowned is the point: every +// setter's refreshAsset() returns immediately without an owning manager, so +// nothing here writes a file, marks anything dirty, or touches a texture. +template static T* newScratchAsset( void ) +{ + T* pAsset = new T(); + pAsset->registerObject(); + return pAsset; +} + +//----------------------------------------------------------------------------- +// One test per defect. Each of these failed before the hand-written copyTo +// overrides were replaced. +//----------------------------------------------------------------------------- + +TEST( AssetStateCopyTests, ImageAssetCarriesCellOffsetsRatherThanCounts ) +{ + ImageAsset* source = newScratchAsset(); + source->setCellCountX( 4 ); + source->setCellCountY( 5 ); + source->setCellOffsetX( 7 ); + source->setCellOffsetY( 9 ); + + ImageAsset* target = newScratchAsset(); + source->copyTo( target ); + + ASSERT_EQ( target->getCellOffsetX(), 7 ) + << "The cell offset was copied from the cell COUNT."; + ASSERT_EQ( target->getCellOffsetY(), 9 ) + << "The cell offset was copied from the cell COUNT."; + ASSERT_EQ( target->getCellCountX(), 4 ); + ASSERT_EQ( target->getCellCountY(), 5 ); + + source->deleteObject(); + target->deleteObject(); +} + +TEST( AssetStateCopyTests, ImageAssetCarriesImageLayers ) +{ + ImageAsset* source = newScratchAsset(); + source->addLayer( "one.png", Point2I( 3, 4 ), ColorF( 1.0f, 0.5f, 0.25f, 1.0f ), false ); + source->addLayer( "two.png", Point2I( 5, 6 ), ColorF( 0.5f, 0.5f, 0.5f, 0.5f ), false ); + + ImageAsset* target = newScratchAsset(); + source->copyTo( target ); + + ASSERT_EQ( target->getLayerCount(), 2u ) + << "Image layers were never copied at all."; + ASSERT_STREQ( target->getLayerImage( 1 ), "one.png" ); + ASSERT_STREQ( target->getLayerImage( 2 ), "two.png" ); + ASSERT_EQ( target->getLayerPosition( 2 ).x, 5 ); + ASSERT_EQ( target->getLayerPosition( 2 ).y, 6 ); + + source->deleteObject(); + target->deleteObject(); +} + +// Explicit cells have no unit test on purpose. addExplicitCell validates every +// cell against getImageWidth()/getImageHeight(), so it refuses to add anything at +// all until a real bitmap is loaded -- which needs a file on disk and a GL +// context, and so belongs in tests/smoke. What is asserted there is that the +// cells survive a copy even with ExplicitMode off, because the cells outlive +// being switched out of explicit mode and the old copy dropped them. + +TEST( AssetStateCopyTests, AnimationAssetCarriesNamedFrames ) +{ + AnimationAsset* source = newScratchAsset(); + source->setNamedCellsMode( true ); + source->setNamedAnimationFrames( "head body tail" ); + + AnimationAsset* target = newScratchAsset(); + source->copyTo( target ); + + ASSERT_TRUE( target->getNamedCellsMode() ); + ASSERT_EQ( target->getSpecifiedNamedAnimationFrames().size(), 3 ) + << "The copy asked the TARGET whether it was in named-cells mode, and the " + "target was still in the default numbered mode, so the named frames " + "were never copied."; + + source->deleteObject(); + target->deleteObject(); +} + +// These two assert the frame-provider MODE and not the asset id, because an +// AssetPtr will not hold an id that does not resolve and no real image or +// animation asset exists in a unit test. The mode is the defect anyway: what +// went wrong was an animated emitter arriving static and blank, and it is the +// mode that decides which of the two exclusive fields the emitter then reads. +TEST( AssetStateCopyTests, ParticleAssetEmitterCarriesAnimatedMode ) +{ + ParticleAssetEmitter* source = newScratchAsset(); + source->setAnimation( "SomeModule:someAnimation" ); + ASSERT_FALSE( source->isStaticFrameProvider() ) << "Test setup failed."; + + ParticleAssetEmitter* target = newScratchAsset(); + source->copyTo( target ); + + ASSERT_FALSE( target->isStaticFrameProvider() ) + << "The copy asked the TARGET whether it was a static frame provider, and " + "a fresh emitter defaults to static, so an animated emitter copied as a " + "blank static one."; + + source->deleteObject(); + target->deleteObject(); +} + +// The mirror of the above, and the reason copyTo cannot simply copy every field +// and stop: Image and Animation are both persist fields, setAnimation clears +// static mode unconditionally, and Animation is listed after Image in the field +// table. So a purely generic field copy leaves EVERY static emitter animated. +TEST( AssetStateCopyTests, ParticleAssetEmitterCarriesStaticMode ) +{ + ParticleAssetEmitter* source = newScratchAsset(); + source->setImage( "SomeModule:someImage", 3 ); + ASSERT_TRUE( source->isStaticFrameProvider() ) << "Test setup failed."; + + ParticleAssetEmitter* target = newScratchAsset(); + source->copyTo( target ); + + ASSERT_TRUE( target->isStaticFrameProvider() ) + << "Animation is listed after Image in the field table, so the generic " + "field copy left the emitter animated."; + + source->deleteObject(); + target->deleteObject(); +} + +TEST( AssetStateCopyTests, ParticleAssetCarriesEmitters ) +{ + ParticleAsset* source = newScratchAsset(); + ParticleAssetEmitter* pFirst = source->createEmitter(); + pFirst->setEmitterName( "sparks" ); + ParticleAssetEmitter* pSecond = source->createEmitter(); + pSecond->setEmitterName( "smoke" ); + + ParticleAsset* target = newScratchAsset(); + source->copyTo( target ); + + ASSERT_EQ( target->getEmitterCount(), 2u ); + ASSERT_STREQ( target->getEmitter( 0 )->getEmitterName(), "sparks" ); + ASSERT_STREQ( target->getEmitter( 1 )->getEmitterName(), "smoke" ); + ASSERT_NE( target->getEmitter( 0 ), pFirst ) + << "The emitters must be copies, not the originals shared into two assets."; + + source->deleteObject(); + target->deleteObject(); +} + +//----------------------------------------------------------------------------- +// The two that stop this drifting again. +// +// Both walk the field table rather than a list written here, so a field added to +// any asset type tomorrow is covered the day it is added -- which is the whole +// reason the hand-written copies were removed. +//----------------------------------------------------------------------------- + +// A value that is legal for the field's type and differs from the default. Asset +// id fields are left alone: they name assets that do not exist in a unit test, +// and the emitter's Image/Animation pair is asserted on its own above. +// Enums and asset-id fields deliberately return NULL: an enum needs a value from +// its own table, and an asset id would have to name an asset that exists. The +// emitter's Image/Animation pair is the one case where that matters, and it has +// two tests of its own above. +static const char* distinctValueForType( const U32 type, const S32 index ) +{ + static char buffer[64]; + + if ( type == (U32)TypeBool ) + return "1"; + + if ( type == (U32)TypeS32 ) + { + dSprintf( buffer, sizeof( buffer ), "%d", 3 + index ); + return buffer; + } + + if ( type == (U32)TypeF32 ) + { + dSprintf( buffer, sizeof( buffer ), "%d.5", 2 + index ); + return buffer; + } + + if ( type == (U32)TypeVector2 ) + { + dSprintf( buffer, sizeof( buffer ), "%d %d", 6 + index, 7 + index ); + return buffer; + } + + if ( type == (U32)TypeColorF ) + return "0.25 0.5 0.75 1"; + + if ( type == (U32)TypeString || type == (U32)TypeCaseString ) + { + dSprintf( buffer, sizeof( buffer ), "value%d", index ); + return buffer; + } + + return NULL; +} + +static bool isCopyableField( const AbstractClassRep::Field& field ) +{ + // A group marker is not a field. Note the engine's spelling of the third one. + if ( field.type == AbstractClassRep::StartGroupFieldType || + field.type == AbstractClassRep::EndGroupFieldType || + field.type == AbstractClassRep::DepricatedFieldType ) + return false; + + // AssetName is a no-op once an asset is owned and is deliberately not part of + // a copy's identity; AssetPrivate has no setter at all. + if ( field.pFieldname == StringTable->insert( "AssetName" ) || + field.pFieldname == StringTable->insert( "AssetPrivate" ) ) + return false; + + return distinctValueForType( field.type, 0 ) != NULL; +} + +// Write a distinct value into every field this test knows how to write, then +// assert a copy answers with all of them. +template static void assertEveryFieldCopies( const char* pTypeName ) +{ + T* source = newScratchAsset(); + T* target = newScratchAsset(); + + const AbstractClassRep::FieldList& fields = source->getFieldList(); + + S32 written = 0; + for( U32 index = 0; index < (U32)fields.size(); ++index ) + { + const AbstractClassRep::Field& field = fields[index]; + if ( !isCopyableField( field ) ) + continue; + + const char* pValue = distinctValueForType( field.type, index ); + writeAssetField( source, field.pFieldname, pValue ); + ++written; + } + + ASSERT_GT( written, 0 ) << pTypeName << " has no writable fields; the test is broken."; + + source->copyTo( target ); + + for( U32 index = 0; index < (U32)fields.size(); ++index ) + { + const AbstractClassRep::Field& field = fields[index]; + if ( !isCopyableField( field ) ) + continue; + + // getDataField answers out of Con::getData's rotating buffer, so the two + // reads cannot both be live at once -- comparing them directly would + // compare the second string with itself and pass no matter what. + char expected[256]; + dStrncpy( expected, readAssetField( source, field.pFieldname ), sizeof( expected ) - 1 ); + expected[sizeof( expected ) - 1] = '\0'; + + ASSERT_STREQ( readAssetField( target, field.pFieldname ), expected ) + << pTypeName << "::" << field.pFieldname << " did not copy. If this field " + "is new, it needs nothing: copyTo walks the field table. If it keeps " + "its state somewhere a field cannot describe, it belongs in that " + "type's copyAssetStateTo."; + } + + source->deleteObject(); + target->deleteObject(); +} + +TEST( AssetStateCopyTests, EveryImageAssetFieldCopies ) +{ + assertEveryFieldCopies( "ImageAsset" ); +} + +TEST( AssetStateCopyTests, EveryAnimationAssetFieldCopies ) +{ + assertEveryFieldCopies( "AnimationAsset" ); +} + +TEST( AssetStateCopyTests, EveryAudioAssetFieldCopies ) +{ + assertEveryFieldCopies( "AudioAsset" ); +} + +TEST( AssetStateCopyTests, EveryFontAssetFieldCopies ) +{ + assertEveryFieldCopies( "FontAsset" ); +} + +TEST( AssetStateCopyTests, EveryParticleAssetFieldCopies ) +{ + assertEveryFieldCopies( "ParticleAsset" ); +} + +TEST( AssetStateCopyTests, EveryParticleAssetEmitterFieldCopies ) +{ + assertEveryFieldCopies( "ParticleAssetEmitter" ); +} + +#endif // TORQUE_SHIPPING diff --git a/tests/shots/assetDialogs.cs b/tests/shots/assetDialogs.cs new file mode 100644 index 000000000..f31fa59e6 --- /dev/null +++ b/tests/shots/assetDialogs.cs @@ -0,0 +1,164 @@ +// Visual harness for the Asset Manager's three dialogs. Four shots: +// +// 0 Duplicate, as it opens +// 1 Duplicate, showing its longest refusal message -- the one that grows the +// feedback line and used to push the buttons out of sight +// 2 Frame Range, with its answer line filled in +// 3 Unsaved Assets, the guard that stands in front of Close Project and Exit +// +// All three lay their buttons out from the room the CONTENT has rather than from +// the dialog's own height, and the difference between those two is the 34 pixels +// the title bar and border take. Getting it wrong is invisible until something +// reaches the bottom of the dialog, which is exactly what these shots check. +// +// Run: tests/run.ps1 -Shots assetDialogs ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "aqOpenProject"); + +function aqOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + createPath(testRoot("shots/")); + EditorPreferences.path = testRoot("shots/assetDialogsShotPrefs.taml"); + + schedule(2500, 0, "aqOpenEditor"); +} + +function aqOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "aqLoadAssets"); +} + +// Registered after the editor has opened: the project selector calls +// ModuleDatabase.clearDatabase(), which would take this module with it. +function aqLoadAssets() +{ + ModuleDatabase.scanModules(testRoot("toybox/ToyAssets")); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + AssetAdmin.libWindow.loadAssets(); + + AssetAdmin.Dictionary["ImageAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + schedule(1000, 0, "aqSelectImage"); +} + +function aqSelectImage() +{ + %tile = AssetAdmin.Dictionary["ImageAsset"].getButton("ToyAssets:Gems"); + if(isObject(%tile)) + { + %tile.onClick(); + } + + schedule(800, 0, "aqDuplicateShot"); +} + +function aqGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/assetDialogs" @ %name @ ".png"), "PNG"); +} + +function aqDuplicateShot() +{ + AssetAdmin.inspector.DuplicateAsset(); + schedule(600, 0, "aqDuplicateGrab"); +} + +function aqDuplicateGrab() +{ + aqGrab(0); + + // The longest thing the dialog ever says, forced directly so the shot does + // not depend on finding a library module to be refused by. + $aqDialog = Canvas.getObject(Canvas.getCount() - 1); + $aqDialog.feedback.setText("You cannot add assets to a library module. Updates to the module would remove your assets. Instead, copy this asset into your own module."); + + schedule(600, 0, "aqDuplicateLongGrab"); +} + +function aqDuplicateLongGrab() +{ + aqGrab(1); + + $aqDialog.onClose(); + schedule(600, 0, "aqSelectAnimation"); +} + +//----------------------------------------------------------------------------- +// The Frame Range dialog, which needs an animation open for the stage to exist. +//----------------------------------------------------------------------------- + +function aqSelectAnimation() +{ + AssetAdmin.Dictionary["AnimationAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + %tile = AssetAdmin.Dictionary["AnimationAsset"].getButton("ToyAssets:TD_Knight_MoveSouth"); + if(isObject(%tile)) + { + %tile.onClick(); + } + + schedule(1200, 0, "aqRangeShot"); +} + +function aqRangeShot() +{ + AssetAdmin.animationStage.openRangeDialog(); + schedule(800, 0, "aqRangeGrab"); +} + +function aqRangeGrab() +{ + aqGrab(2); + + $aqDialog = Canvas.getObject(Canvas.getCount() - 1); + $aqDialog.onClose(); + + schedule(600, 0, "aqGuardShot"); +} + +//----------------------------------------------------------------------------- +// The unsaved-assets guard. Reached in the editor from Torque2D -> Close Project +// or Torque2D -> Exit; raised here through the same call those two make. +//----------------------------------------------------------------------------- + +function aqGuardShot() +{ + AssetDatabase.setAssetDirty("ToyAssets:Gems", true); + AssetDatabase.setAssetDirty("ToyAssets:Blocks", true); + + EditorCore.guardedCommand("echo(\"the guarded command ran\");"); + schedule(800, 0, "aqGuardGrab"); +} + +function aqGuardGrab() +{ + aqGrab(3); + + // Cancel rather than Discard: nothing here should go on to run the command. + %dialog = Canvas.getObject(Canvas.getCount() - 1); + if(isObject(%dialog)) + { + %dialog.onCancel(); + } + + schedule(600, 0, "quit"); +} diff --git a/tests/shots/assetDirtyMark.cs b/tests/shots/assetDirtyMark.cs new file mode 100644 index 000000000..c771365bd --- /dev/null +++ b/tests/shots/assetDirtyMark.cs @@ -0,0 +1,110 @@ +// Visual harness for the unsaved-changes badge on a library tile. Two shots: +// +// 0 tile mode -- the square badge in the top right corner of the tile +// 1 row mode -- the same badge at the right hand end of the row +// +// The badge is a control of its own wearing impactProfile, so what it looks like +// is entirely a matter of the theme and the two numbers that place it. Neither is +// checkable by assertion beyond "the extent is what I wrote", which is what +// tests/smoke/assetDirtySave.cs already covers -- this is for looking at. +// +// Three assets are dirtied rather than one, so the shot shows the badge against a +// picture, against a busier picture, and next to a tile that is NOT marked. +// +// Run: tests/run.ps1 -Shots assetDirtyMark ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "adOpenProject"); + +function adOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + // Switching the view below writes a preference, and unredirected that lands + // in the real per-user folder -- so running the harness would change how the + // editor opens for the person who ran it. + createPath(testRoot("shots/")); + EditorPreferences.path = testRoot("shots/assetDirtyMarkShotPrefs.taml"); + + schedule(2500, 0, "adOpenEditor"); +} + +function adOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "adLoadAssets"); +} + +// Registered AFTER the editor has opened, and not before it: the project +// selector calls ModuleDatabase.clearDatabase(), which deletes every +// ModuleDefinition and would take this fixture's module with it. +// +// NOTE: this dirties assets in the repository's own toybox/ToyAssets, in memory +// only. Nothing here saves, so nothing on disk is touched -- which is the whole +// point of the change being photographed. +function adLoadAssets() +{ + ModuleDatabase.scanModules(testRoot("toybox/ToyAssets")); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + AssetAdmin.libWindow.loadAssets(); + + AssetAdmin.Dictionary["ImageAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + schedule(1200, 0, "adDirtyAssets"); +} + +function adDirtyAssets() +{ + // Chosen because they are near the top of the alphabetical grid and so are + // actually in the shot: a badge over a light picture, a badge over a dark one, + // and unmarked tiles either side of both. + adDirty("ToyAssets:Asteroids"); + adDirty("ToyAssets:Blocks"); + adDirty("ToyAssets:brick_02"); + + schedule(600, 0, "adTileShot"); +} + +// A change the asset will accept whatever it currently holds, made only in +// memory. setAssetDirty says outright what a real edit would have said. +function adDirty(%assetId) +{ + if(AssetDatabase.isDeclaredAsset(%assetId)) + { + AssetDatabase.setAssetDirty(%assetId, true); + } +} + +function adGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging, so a + // tree that has never run a shot writes nothing and says nothing. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/assetDirtyMark" @ %name @ ".png"), "PNG"); +} + +function adTileShot() +{ + adGrab(0); + + AssetAdmin.libWindow.setViewMode("rows"); + schedule(1200, 0, "adRowShot"); +} + +function adRowShot() +{ + adGrab(1); + schedule(600, 0, "quit"); +} diff --git a/tests/smoke/assetDirtySave.cs b/tests/smoke/assetDirtySave.cs new file mode 100644 index 000000000..2a8284509 --- /dev/null +++ b/tests/smoke/assetDirtySave.cs @@ -0,0 +1,665 @@ +// Asset Manager unsaved-changes smoke test. Drives the whole of what replaced +// "every edit writes the file": an edit marks the asset unsaved and leaves the +// file alone, Save writes it, Revert puts it back, Undo and Redo step through the +// session, and Duplicate branches what is on screen including the unsaved part. +// Run: tests/run.ps1 assetDirtySave ; grep ADS in tests/logs/. +// +// This is the suite that would catch the old behavior coming back, so most of the +// assertions are about the FILE rather than about the object: the point of the +// change is that editing stopped touching it. +// +// Driven by calling the inspector's own document-bar methods rather than by +// clicking them, for the same reason assetImageInspector does -- where a button +// lands depends on layout arithmetic this file would then be testing. +// +// NOTE: a COPY of toybox/ToyAssets, never the module itself. Saving genuinely +// writes, so aimed at the repository copy this test would rewrite tracked +// content. The copy goes inside the throwaway project folder, which +// tests/run.ps1 deletes before every run. +// +// NOTE: EditorPreferences writes to the tester's real per-user application data +// folder. Step 1 redirects it for the duration, as assetLibrary does. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function adsCheck(%label, %cond) +{ + if(%cond) echo("ADS PASS: " @ %label); + else echo("ADS FAIL: " @ %label); +} + +// Gems is 512 x 512 cut into 8 x 8 cells of 64. +$adsAssetId = "AdsFixture:Gems"; +$adsAnimationId = "AdsFixture:TD_Knight_MoveSouth"; + +// The whole asset file as one string, which is how "did the file change?" is +// asked below. These files are a few hundred bytes; there is no need to be +// cleverer than reading them. +function adsReadAssetFile(%assetId) +{ + %path = AssetDatabase.getAssetFilePath(%assetId); + + %file = new FileObject(); + if(!%file.openForRead(%path)) + { + %file.delete(); + return ""; + } + + %contents = ""; + while(!%file.isEOF()) + { + %contents = %contents @ %file.readLine() @ "\n"; + } + + %file.close(); + %file.delete(); + + return %contents; +} + +// The fixture is a COPY of ToyAssets re-badged under a module id of its own. +// +// The rename is not tidiness. The editor scans the whole of toybox/ on the way +// up, which registers the real ToyAssets/1 -- so a fixture that kept that id is +// a second module claiming it, and which of the two wins depends on the order +// the two scans happen to finish in. That made this suite fail differently on +// every run, and left assets pointing at a module definition that had been +// superseded. +function adsWriteFixtureModule(%path) +{ + %file = new FileObject(); + if(!%file.openForWrite(%path)) + { + %file.delete(); + return false; + } + + %file.writeLine(""); + %file.writeLine(" "); + %file.writeLine(""); + + %file.close(); + %file.delete(); + + return true; +} + +function adsLoadFixtureAssets() +{ + %copy = testRoot("assetDirtySaveSmokeProject/AdsFixture"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + if(!adsWriteFixtureModule(%copy @ "/1/module.taml")) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("AdsFixture", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "adsStep1"); + +//----------------------------------------------------------------------------- +// Opening the Asset Manager on an image asset. +//----------------------------------------------------------------------------- + +function adsStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."), so a name it + // cannot see is a folder it cannot sweep. + ProjectManager.setProjectFolder("assetDirtySaveSmokeProject"); + EditorPreferences.path = testRoot("shots/assetDirtySaveSmokePrefs.taml"); + + // Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, + // GuiEditor. Selecting the tab is what calls AssetAdmin::open. + EditorCore.tabBook.selectPage(2); + + schedule(800, 0, "adsStep1b"); +} + +// The fixture is registered HERE, after the editor has settled, and not before +// it. +// +// EditorProjectSelector calls ModuleDatabase.clearDatabase() when it picks up a +// project, and that deletes every ModuleDefinition object and rescans. Assets +// declared before it survive in the asset manager -- their AssetDefinitions are +// untouched -- but the ModuleDefinition each one points at has been freed. A +// fixture registered ahead of that wipe therefore has assets that work and a +// module that is gone, which is not a state the editor ever puts a real project +// in, and not one worth writing a test around. +function adsStep1b() +{ + adsCheck("fixture asset module registered", adsLoadFixtureAssets()); + + // The library was built before the fixture existed, so it has to be told. + AssetAdmin.libWindow.loadAssets(); + + schedule(600, 0, "adsStep2"); +} + +function adsStep2() +{ + $adsInspector = AssetAdmin.inspector; + $adsTile = AssetAdmin.Dictionary["ImageAsset"].getButton($adsAssetId); + + adsCheck("the fixture gave the library " @ $adsAssetId, isObject($adsTile)); + + if(!isObject($adsTile)) + { + echo("ADS ABORT: no fixture asset, the rest of the run would prove nothing"); + schedule(300, 0, "quit"); + return; + } + + $adsTile.onClick(); + schedule(400, 0, "adsStep3"); +} + +//----------------------------------------------------------------------------- +// A freshly opened asset is clean, and the document bar says so. +//----------------------------------------------------------------------------- + +function adsStep3() +{ + $adsAsset = AssetDatabase.acquireAsset($adsAssetId); + + adsCheck("asset loaded", isObject($adsAsset)); + adsCheck("a freshly opened asset has nothing unsaved", !$adsAsset.isAssetDirty()); + adsCheck("and the database agrees", !AssetDatabase.isAssetDirty($adsAssetId)); + adsCheck("nothing in the project is unsaved yet", AssetDatabase.getDirtyAssetCount() == 0); + + adsCheck("the document bar is on show", $adsInspector.documentButtonBar.isVisible()); + adsCheck("Save is greyed with nothing to save", !$adsInspector.getSaveAssetEnabled()); + adsCheck("Revert is greyed with nothing to put back", !$adsInspector.getRevertAssetEnabled()); + adsCheck("Undo is greyed with nothing done yet", !$adsInspector.getUndoAssetEnabled()); + adsCheck("Redo is greyed with nothing undone yet", !$adsInspector.getRedoAssetEnabled()); + + adsCheck("the tile is not marked", !$adsTile.dirtyMark.isVisible()); + + // Everything below compares against this. + $adsFileAtStart = adsReadAssetFile($adsAssetId); + $adsCellWidthAtStart = $adsAsset.getCellWidth(); + + adsCheck("read the asset file (" @ strlen($adsFileAtStart) @ " bytes)", strlen($adsFileAtStart) > 0); + + schedule(200, 0, "adsStep4"); +} + +//----------------------------------------------------------------------------- +// The change this whole feature exists for: an edit does NOT write the file. +//----------------------------------------------------------------------------- + +function adsStep4() +{ + $adsAsset.setCellWidth(32); + + adsCheck("the edit reached the asset", $adsAsset.getCellWidth() == 32); + adsCheck("the asset now has unsaved changes", $adsAsset.isAssetDirty()); + adsCheck("and it is counted", AssetDatabase.getDirtyAssetCount() == 1); + + // The point of the exercise. + adsCheck("the file on disk was NOT touched", adsReadAssetFile($adsAssetId) $= $adsFileAtStart); + + adsCheck("Save is offered now", $adsInspector.getSaveAssetEnabled()); + adsCheck("Revert is offered now", $adsInspector.getRevertAssetEnabled()); + adsCheck("the tile is marked unsaved", $adsTile.dirtyMark.isVisible()); + adsCheck("the mark is a control of its own, not part of the caption", + $adsTile.caption.getText() $= $adsTile.assetName); + adsCheck("so the name it sorts and searches by is untouched", + $adsTile.assetName !$= "" && strstr($adsTile.assetName, "*") == -1); + adsCheck("and the mark is square", + getWord($adsTile.dirtyMark.getExtent(), 0) == getWord($adsTile.dirtyMark.getExtent(), 1)); + + // An edit is an undo step. + adsCheck("the edit became an undo step", + AssetAdmin.undoRecorder.getUndoCount($adsAssetId) == 1); + adsCheck("and nothing is waiting to be redone", + AssetAdmin.undoRecorder.getRedoCount($adsAssetId) == 0); + + schedule(200, 0, "adsStep5"); +} + +//----------------------------------------------------------------------------- +// Undo and redo. +//----------------------------------------------------------------------------- + +function adsStep5() +{ + AssetAdmin.undoRecorder.undo($adsAsset); + + adsCheck("undo put the value back (" @ $adsAsset.getCellWidth() @ ")", + $adsAsset.getCellWidth() == $adsCellWidthAtStart); + adsCheck("undoing back to the saved state leaves nothing unsaved", + !$adsAsset.isAssetDirty()); + adsCheck("the tile mark went with it", !$adsTile.dirtyMark.isVisible()); + adsCheck("and there is something to redo", + AssetAdmin.undoRecorder.getRedoCount($adsAssetId) == 1); + + AssetAdmin.undoRecorder.redo($adsAsset); + + adsCheck("redo brought the value back (" @ $adsAsset.getCellWidth() @ ")", + $adsAsset.getCellWidth() == 32); + adsCheck("and the asset is unsaved again", $adsAsset.isAssetDirty()); + adsCheck("the file is still untouched through all of that", + adsReadAssetFile($adsAssetId) $= $adsFileAtStart); + + schedule(200, 0, "adsStep6"); +} + +//----------------------------------------------------------------------------- +// A second edit, so there is a stack rather than a single step -- and so the +// redo stack is seen to be dropped when the future is rewritten. +//----------------------------------------------------------------------------- + +function adsStep6() +{ + $adsAsset.setCellHeight(16); + + adsCheck("two edits, two steps", AssetAdmin.undoRecorder.getUndoCount($adsAssetId) == 2); + + AssetAdmin.undoRecorder.undo($adsAsset); + adsCheck("undo took the second edit back (" @ $adsAsset.getCellHeight() @ ")", + $adsAsset.getCellHeight() != 16); + adsCheck("the first edit is still in place", $adsAsset.getCellWidth() == 32); + adsCheck("still unsaved, because this is not where it was saved", + $adsAsset.isAssetDirty()); + + // Making a change after an undo is what drops the redo stack. + $adsAsset.setCellHeight(24); + adsCheck("a new change dropped what was waiting to be redone", + AssetAdmin.undoRecorder.getRedoCount($adsAssetId) == 0); + + schedule(200, 0, "adsStep7"); +} + +//----------------------------------------------------------------------------- +// Saving. +//----------------------------------------------------------------------------- + +function adsStep7() +{ + $adsInspector.SaveAsset(); + + adsCheck("saving cleared the unsaved state", !$adsAsset.isAssetDirty()); + adsCheck("and the count went with it", AssetDatabase.getDirtyAssetCount() == 0); + adsCheck("the tile mark cleared", !$adsTile.dirtyMark.isVisible()); + adsCheck("Save is greyed again", !$adsInspector.getSaveAssetEnabled()); + + $adsFileAfterSave = adsReadAssetFile($adsAssetId); + adsCheck("the file changed this time", $adsFileAfterSave !$= $adsFileAtStart); + adsCheck("and it holds the edited value", + strstr($adsFileAfterSave, "CellWidth=\"32\"") != -1); + + // The history survives a save -- undo still works, it just means the asset is + // unsaved again. + adsCheck("the history survived the save", + AssetAdmin.undoRecorder.getUndoCount($adsAssetId) > 0); + + AssetAdmin.undoRecorder.undo($adsAsset); + adsCheck("undoing past the save makes it unsaved again", $adsAsset.isAssetDirty()); + + schedule(200, 0, "adsStep8"); +} + +//----------------------------------------------------------------------------- +// Reverting. +//----------------------------------------------------------------------------- + +function adsStep8() +{ + // Somewhere clearly different from the file, so the revert has something to + // undo. + $adsAsset.setCellWidth(8); + adsCheck("moved away from the saved file again", $adsAsset.getCellWidth() == 8); + adsCheck("which is unsaved", $adsAsset.isAssetDirty()); + + $adsInspector.RevertAsset(); + + schedule(400, 0, "adsStep9"); +} + +function adsStep9() +{ + // Reverting reloads the tile, which re-acquires the asset. It is the same + // object either way -- that is the promise revertAsset makes, since every + // Sprite in the scene is holding a pointer to it. + adsCheck("revert went back to what was saved (" @ $adsAsset.getCellWidth() @ ")", + $adsAsset.getCellWidth() == 32); + adsCheck("revert cleared the unsaved state", !$adsAsset.isAssetDirty()); + adsCheck("the asset object was kept, not swapped", + AssetDatabase.acquireAsset($adsAssetId) == $adsAsset); + AssetDatabase.releaseAsset($adsAssetId); + + adsCheck("the file was left as it was saved", + adsReadAssetFile($adsAssetId) $= $adsFileAfterSave); + + // A revert throws the document away, so the steps that described it go too. + adsCheck("revert cleared the undo history", + AssetAdmin.undoRecorder.getUndoCount($adsAssetId) == 0); + + schedule(200, 0, "adsStep10"); +} + +//----------------------------------------------------------------------------- +// Duplicating, including the unsaved part. +//----------------------------------------------------------------------------- + +function adsStep10() +{ + // An unsaved edit, so the copy can be seen to include it. + $adsAsset.setCellWidth(64); + adsCheck("an unsaved edit to copy", $adsAsset.isAssetDirty()); + + $adsCopyId = "AdsFixture:GemsCopy"; + + // NOTE: deliberately not asserted through AssetDatabase.getAssetModule here. + // That reads AssetDefinition::mpModuleDefinition, which a module re-scan + // leaves dangling -- the editor scans the whole repository root on the way up, + // and this fixture sits inside it. duplicateAsset looks the module up by path + // for exactly that reason, so what matters is the outcome below. + + %path = pathConcat(AssetDatabase.getAssetPath($adsAssetId), "GemsCopy.asset.taml"); + adsCheck("duplicate reported success", + AssetDatabase.duplicateAsset($adsAssetId, %path, "GemsCopy")); + + adsCheck("the copy is a declared asset", AssetDatabase.isDeclaredAsset($adsCopyId)); + + %copy = AssetDatabase.acquireAsset($adsCopyId); + adsCheck("the copy loads", isObject(%copy)); + + if(isObject(%copy)) + { + adsCheck("the copy took the UNSAVED value (" @ %copy.getCellWidth() @ ")", + %copy.getCellWidth() == 64); + adsCheck("the copy has its own name", %copy.AssetName $= "GemsCopy"); + adsCheck("the copy starts saved", !%copy.isAssetDirty()); + AssetDatabase.releaseAsset($adsCopyId); + } + + adsCheck("the original is still unsaved", $adsAsset.isAssetDirty()); + adsCheck("and the original's file still has the saved value", + adsReadAssetFile($adsAssetId) $= $adsFileAfterSave); + + schedule(200, 0, "adsStep11"); +} + +//----------------------------------------------------------------------------- +// Save All, and the guard that asks before the work is thrown away. +//----------------------------------------------------------------------------- + +function adsStep11() +{ + adsCheck("the Asset Manager reports unsaved work", AssetAdmin.hasUnsavedAssets()); + adsCheck("one asset is unsaved", AssetDatabase.getDirtyAssetCount() == 1); + + // A second unsaved asset, to prove Save All is not just Save. + %other = AssetDatabase.acquireAsset("AdsFixture:Football"); + if(isObject(%other)) + { + %other.AssetDescription = "changed by the smoke test"; + adsCheck("a second asset is unsaved now", AssetDatabase.getDirtyAssetCount() == 2); + + AssetAdmin.saveAllAssets(); + + adsCheck("Save All saved both", AssetDatabase.getDirtyAssetCount() == 0); + adsCheck("and the Asset Manager stops reporting unsaved work", + !AssetAdmin.hasUnsavedAssets()); + AssetDatabase.releaseAsset("AdsFixture:Football"); + } + else + { + echo("ADS SKIP: no second fixture asset, Save All checked with one"); + AssetAdmin.saveAllAssets(); + adsCheck("Save All saved the one", AssetDatabase.getDirtyAssetCount() == 0); + } + + // With nothing unsaved the guard should not interrupt: the command runs. + $adsGuardRan = false; + EditorCore.guardedCommand("$adsGuardRan = true;"); + adsCheck("with nothing unsaved the guard runs the command straight through", + $adsGuardRan); + + schedule(200, 0, "adsStep12"); +} + +//----------------------------------------------------------------------------- +// The guard when there IS something to lose. +//----------------------------------------------------------------------------- + +function adsStep12() +{ + $adsAsset.setCellWidth(128); + adsCheck("something unsaved again", AssetAdmin.hasUnsavedAssets()); + + $adsGuardRan = false; + EditorCore.guardedCommand("$adsGuardRan = true;"); + + adsCheck("the guard held the command back", !$adsGuardRan); + + %dialog = Canvas.getContent().getObject(Canvas.getContent().getCount() - 1); + schedule(300, 0, "adsStep13"); +} + +function adsStep13() +{ + // The dialog is pushed onto the Canvas, so it is the Canvas' last child. + %dialog = Canvas.getObject(Canvas.getCount() - 1); + adsCheck("a dialog was raised", isObject(%dialog)); + + if(isObject(%dialog) && %dialog.getClassNamespace() $= "AssetAdminConfirmSaveDialog") + { + adsCheck("it is the unsaved-assets dialog", true); + + // Save All, which writes and then lets the command through. + %dialog.onSave(); + schedule(400, 0, "adsStep14"); + return; + } + + adsCheck("it is the unsaved-assets dialog", false); + schedule(300, 0, "quit"); +} + +function adsStep14() +{ + adsCheck("answering Save All saved the work", AssetDatabase.getDirtyAssetCount() == 0); + adsCheck("and let the held command through", $adsGuardRan); + + AssetDatabase.releaseAsset($adsAssetId); + schedule(200, 0, "adsStep15"); +} + +//----------------------------------------------------------------------------- +// Particles, which is what this whole change was for. +// +// A particle asset is the one kind whose editing mostly happens in C++ -- the +// graph editor drags data keys around and calls refreshAsset itself on mouse-up, +// and the emitter's scalar fields go through the stock GuiInspector. None of that +// passes through TorqueScript, so none of it could be recorded by a script-side +// recorder. What is asserted here is that it is nonetheless tracked, because the +// tracking hangs off the change notification rather than off the edit. +// +// It also exercises the emitter deep-copy: an undo rebuilds the emitter list from +// the snapshot, so emitters surviving with their values intact is the thing that +// used to be broken in ParticleAsset::copyTo. +//----------------------------------------------------------------------------- + +function adsStep15() +{ + $adsParticleId = "AdsFixture:bonfire"; + + %tile = AssetAdmin.Dictionary["ParticleAsset"].getButton($adsParticleId); + adsCheck("the library has the particle asset", isObject(%tile)); + + if(!isObject(%tile)) + { + schedule(300, 0, "quit"); + return; + } + + %tile.onClick(); + schedule(400, 0, "adsStep16"); +} + +function adsStep16() +{ + $adsParticle = AssetDatabase.acquireAsset($adsParticleId); + + adsCheck("the particle asset loaded", isObject($adsParticle)); + adsCheck("it has emitters", $adsParticle.getEmitterCount() > 0); + adsCheck("and starts with nothing unsaved", !$adsParticle.isAssetDirty()); + + $adsEmitterCount = $adsParticle.getEmitterCount(); + $adsParticleFileAtStart = adsReadAssetFile($adsParticleId); + + // A field on an EMITTER, which is what the stock inspector writes. The + // emitter has no file of its own -- it forwards to the asset that owns it. + %emitter = $adsParticle.getEmitter(0); + $adsEmitterAngleAtStart = %emitter.EmitterAngle; + %emitter.EmitterAngle = 45; + + adsCheck("the emitter took the value (" @ %emitter.EmitterAngle @ ")", + %emitter.EmitterAngle == 45); + adsCheck("changing an emitter leaves the ASSET unsaved", $adsParticle.isAssetDirty()); + adsCheck("and the particle file was NOT touched", + adsReadAssetFile($adsParticleId) $= $adsParticleFileAtStart); + adsCheck("the emitter change became an undo step", + AssetAdmin.undoRecorder.getUndoCount($adsParticleId) == 1); + + AssetAdmin.undoRecorder.undo($adsParticle); + schedule(300, 0, "adsStep17"); +} + +function adsStep17() +{ + adsCheck("undo kept every emitter (" @ $adsParticle.getEmitterCount() @ " of " @ $adsEmitterCount @ ")", + $adsParticle.getEmitterCount() == $adsEmitterCount); + + // The emitters are rebuilt by the restore, so this is deliberately a fresh + // handle rather than the one from before. + %emitter = $adsParticle.getEmitter(0); + adsCheck("undo put the emitter value back (" @ %emitter.EmitterAngle @ ")", + %emitter.EmitterAngle == $adsEmitterAngleAtStart); + adsCheck("the emitter kept its name", %emitter.EmitterName !$= ""); + adsCheck("undoing back to the saved state leaves nothing unsaved", + !$adsParticle.isAssetDirty()); + adsCheck("the particle file was never written", + adsReadAssetFile($adsParticleId) $= $adsParticleFileAtStart); + + AssetDatabase.releaseAsset($adsParticleId); + schedule(200, 0, "adsStep18"); +} + +//----------------------------------------------------------------------------- +// One thing the user did is one press of undo. +// +// Adding a frame used to take two, because the drop path committed the change +// twice -- insertFrameAtPoint announces itself AND the handler committed again -- +// and setAnimationFrames had no "ignore no change" guard, so the second commit +// was recorded as a step that put nothing back. +//----------------------------------------------------------------------------- + +function adsStep18() +{ + %tile = AssetAdmin.Dictionary["AnimationAsset"].getButton($adsAnimationId); + adsCheck("the library has the animation asset", isObject(%tile)); + + if(!isObject(%tile)) + { + schedule(300, 0, "quit"); + return; + } + + %tile.onClick(); + schedule(500, 0, "adsStep19"); +} + +function adsStep19() +{ + $adsAnimation = AssetDatabase.acquireAsset($adsAnimationId); + %stage = AssetAdmin.animationStage; + + adsCheck("the animation asset loaded", isObject($adsAnimation)); + adsCheck("the animation stage is built", %stage.built); + + if(!isObject($adsAnimation) || !%stage.built) + { + schedule(300, 0, "quit"); + return; + } + + $adsFramesAtStart = $adsAnimation.getAnimationFrames(); + + // The palette-click path: append one frame. + %stage.appendFrame(0); + + adsCheck("adding a frame is exactly one undo step (" @ AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) @ ")", + AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) == 1); + + // Writing the same list back is not a change, so it must not become a step. + %stage.commitFrames($adsAnimation.getAnimationFrames()); + + adsCheck("committing an unchanged frame list adds no step (" @ AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) @ ")", + AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) == 1); + + // And one press of undo puts the frame back. + AssetAdmin.undoRecorder.undo($adsAnimation); + schedule(300, 0, "adsStep20"); +} + +function adsStep20() +{ + adsCheck("one undo removed the frame (" @ $adsAnimation.getAnimationFrames() @ ")", + $adsAnimation.getAnimationFrames() $= $adsFramesAtStart); + adsCheck("and there is nothing further to undo", + AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) == 0); + + // A restore copies the snapshot's DYNAMIC fields onto the live asset too, so + // anything the recorder wrote on a snapshot would end up in the asset's file + // the next time it was saved. Real content files grew stepLabel="Set Frames" + // wasDirty="1" this way. + adsCheck("undo left no recorder bookkeeping on the asset", + $adsAnimation.stepLabel $= "" && $adsAnimation.wasDirty $= ""); + adsCheck("with exactly one thing to redo", + AssetAdmin.undoRecorder.getRedoCount($adsAnimationId) == 1); + + AssetAdmin.undoRecorder.redo($adsAnimation); + schedule(300, 0, "adsStep21"); +} + +function adsStep21() +{ + adsCheck("one redo put the frame back (" @ $adsAnimation.getAnimationFrames() @ ")", + $adsAnimation.getAnimationFrames() !$= $adsFramesAtStart); + adsCheck("and nothing further to redo", + AssetAdmin.undoRecorder.getRedoCount($adsAnimationId) == 0); + + AssetDatabase.releaseAsset($adsAnimationId); + schedule(400, 0, "quit"); +} diff --git a/tests/smoke/assetLibrary.cs b/tests/smoke/assetLibrary.cs index fba546900..55229705a 100644 --- a/tests/smoke/assetLibrary.cs +++ b/tests/smoke/assetLibrary.cs @@ -191,6 +191,18 @@ function alStep1() // Before anything can toggle a view and write one. EditorPreferences.path = testRoot("shots/assetLibrarySmokePrefs.taml"); + // And after something has already READ one. AssetLibraryWindow::onAdd takes + // its view mode from the preferences when the editor module loads, which is + // before the redirect above -- from the tester's own file, in their real + // application data folder. So a developer who last left the library in rows + // mode failed the "starts in grid mode" check below, on their machine only, + // for reasons nothing in this file mentioned. + // + // Put it back to the documented default. What the checks below are actually + // worth is that the tile LAYOUT matches the mode, and that survives this. + EditorPreferences.set("assetLibraryViewMode", "grid"); + AssetAdmin.libWindow.setViewMode("grid"); + alCheck("fixture asset module registered", alLoadFixtureAssets()); // Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, From 7aa5e18ddbd3fa2f97ab6380ed94f5bc4ccd2286 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 12 Aug 2026 13:42:16 -0400 Subject: [PATCH 20/26] A menu bar that belongs to whoever is in front File, Edit, Layout and Select were written into the shared bar in EditorCore and switched on when the Gui Editor opened. Every command in them named GuiEditor. The Asset Manager grew the same class of features last commit -- save, revert, duplicate, undo, redo -- and could not join them: setMenuActive matches by item TEXT across the whole tree, so a second editor toggling Undo or Delete would flip the first editor's items too. So each editor owns its menus now and lends them to the bar for as long as it is the one open. The bar always reads Torque2D | the open editor's menus | Theme. Nothing became generic: the Gui Editor's item still says "Save Gui..." and runs GuiEditor.SaveGui(), the Asset Manager's says "Save Asset" and runs AssetAdmin.inspector.SaveAsset(), and Ctrl+S means both. The Console and the Project Manager show the two permanent ends and needed no code at all -- the outgoing editor's close() clears the bar, where before they sat behind four greyed-out menus that would never do anything. The Asset Manager's File carries New Asset (five kinds), Save Asset, Save All Assets and Revert Asset; its Edit carries Undo, Redo, Duplicate and Delete, with Undo and Redo naming the step the way the document bar's tooltips already did. Revert, Delete and the five New items have no accelerator, for the reason the Gui Editor's Revert has none. Four engine facts shaped the mechanism, and they are worth knowing before touching a menu again: build into the bar GuiMenuItemCtrl learns which bar it belongs to when it is added to one, and a submenu learns it from its parent when IT is added; nothing back-fills it. A tree built standalone and handed over whole leaves every descendant with no bar, and openMenu dereferences it. The old literal only worked because the VM adds a parent to its group before compiling its sub-objects. EditorMenuSet::addMenu returns an already-attached empty menu, so the rule is the shape of the code rather than something to remember. append only onChildAdded links the sibling chain the keyboard walk follows by taking end()-2, and childrenReordered rebuilds the layout but not the chain. So the fixed Theme tail comes off and goes back on around every swap. move, never remove SimSet::remove leaves a control registered with no group at all. Each set parks its menus in a SimGroup of its own. rebuild accelerators The canvas keeps one flat global list, rebuilt only when a dialog is pushed or popped. A tab change is neither. That last one was a bug already, not a new hazard. GuiMenuItemCtrl::onAction checks its own active flag and never its parent menu's, and buildAcceleratorMap filters nothing -- so with File greyed out, Ctrl+N still ran GuiEditor.NewGui() from inside the Asset Manager. Physically removing the items is what fixes it, plus a GuiCanvas::rebuildAcceleratorMap() for the moment nothing else notices. setContentControl keeps its own walk deliberately: it descends until it reaches a control that takes input, and sharing that with the dialog paths would leave the editor's shortcuts live underneath an open dropdown. setMenuActive is now used nowhere in editor/. It was a text-compare walk of the whole tree PLUS a full profile re-apply per top-level menu, fired 22 times per selection change; greying is item.setActive() on a held handle. That deleted the menuUndo/menuRedo/menuPaste caches and both forceRefreshMenu twins, which existed only to dodge that cost. Items answering one shared question -- thirteen on "is anything selected" -- are groups instead, so toggleMenuItems is four calls. Two bugs found on the way, both caught by tests: - a group's count starts unset, and using "" as an index writes to a slot nothing reads back. The FIRST item of every group silently stopped greying: Cut, Align Top, Space Vertically, Bring to Front. The clipboard suite caught it before the code ran anywhere else. - AssetInspector::documentAsset deduced the asset from the bound pane, and every load method binds its pane AFTER calling beginDocument -- so the refresh that follows a load had nothing to ask. It also never cleared, so with nothing selected it answered with the asset before last. Neither showed while only the document bar asked, because the bar is hidden in exactly those moments; the menus never are. It returns what beginDocument was handed now, which settles the particle case directly (an emitter has no file; its owner does) and let deleteAsset drop its own copy of the dropdown logic. tests/smoke/menuSwap.cs covers the bar's order, the two same-named File menus told apart by object rather than by text, parked menus staying alive in their own editor's group, the Theme radio group surviving the round trip, and the group registry. Its input script presses Ctrl+N in both editors: once where it must reach nothing and once where it must make a new document, because a shortcut that reaches nothing and a shortcut that was never pressed look identical from inside. Remove the rebuildAcceleratorMap call and it fails with the sentinel wiped. 53 smoke suites and 252 unit tests green; tests/shots/menuSwap.cs is the visual. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- editor/AssetAdmin/AssetAdmin.cs | 75 +++ editor/AssetAdmin/AssetAdminMenus.cs | 83 +++ editor/AssetAdmin/AssetInspector.cs | 53 +- editor/AssetAdmin/NewAssetButton.cs | 91 +--- editor/EditorCore/EditorCore.cs | 296 +++-------- editor/EditorCore/EditorMenu.cs | 89 ++++ editor/EditorCore/EditorMenuSet.cs | 138 +++++ editor/GuiEditor/GuiEditor.cs | 58 ++- editor/GuiEditor/scripts/GuiEditorBrain.cs | 27 +- .../GuiEditor/scripts/GuiEditorClipboard.cs | 26 +- editor/GuiEditor/scripts/GuiEditorMenus.cs | 133 +++++ .../scripts/GuiEditorUndoRecorder.cs | 37 +- engine/source/gui/guiCanvas.cc | 33 +- engine/source/gui/guiCanvas.h | 17 + engine/source/gui/guiCanvas_ScriptBinding.h | 24 + tests/lib/input.ps1 | 57 ++ tests/shots/menuSwap.cs | 106 ++++ tests/smoke/assetDirtySave.cs | 21 + tests/smoke/menuSwap.cs | 488 ++++++++++++++++++ tests/smoke/menuSwap.input.ps1 | 48 ++ 20 files changed, 1460 insertions(+), 440 deletions(-) create mode 100644 editor/AssetAdmin/AssetAdminMenus.cs create mode 100644 editor/EditorCore/EditorMenu.cs create mode 100644 editor/EditorCore/EditorMenuSet.cs create mode 100644 editor/GuiEditor/scripts/GuiEditorMenus.cs create mode 100644 tests/shots/menuSwap.cs create mode 100644 tests/smoke/menuSwap.cs create mode 100644 tests/smoke/menuSwap.input.ps1 diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index 47c52c71d..a2c9f18c6 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -46,6 +46,10 @@ exec("./DuplicateAssetDialog.cs"); exec("./AssetAdminConfirmSaveDialog.cs"); + // File and Edit, which this editor owns and lends to the shared bar for as + // long as it is the one open. + exec("./AssetAdminMenus.cs"); + // Undo, redo and the record of what is unsaved. Built before the inspector, // which asks it what to grey out as soon as it has a document bar. %this.undoRecorder = new ScriptObject() { class = "AssetUndoRecorder"; }; @@ -71,6 +75,15 @@ class = "AssetAnimationStage"; %this.buildTransportBar(); + // After the inspector, which its refresh asks what to grey out. Built into + // the shared bar and taken straight back off again; open() puts it on. + %this.menus = new ScriptObject() + { + class = "AssetAdminMenus"; + superclass = "EditorMenuSet"; + tool = %this; + }; + EditorCore.FinishRegistration(%this.guiPage); %this.isOpen = false; @@ -404,6 +417,11 @@ class = "AssetAnimationTransportBar"; { %this.undoRecorder.delete(); } + // Takes itself off the bar first if it is still on it. + if(isObject(%this.menus)) + { + %this.menus.delete(); + } } function AssetAdmin::open(%this) @@ -412,6 +430,61 @@ class = "AssetAnimationTransportBar"; %this.assetScene.setScenePause(false); %this.isOpen = true; + + // After loadAssets, so what the menus grey themselves against is the library + // as it stands rather than as it was left. + EditorCore.setEditorMenus(%this.menus); +} + +//----------------------------------------------------------------------------- +// Making one. +// +// Reached two ways: the New button on each library group, and the File menu's +// New Asset submenu. Named methods rather than one newAsset(%kind), because both +// callers want to name a command - the menu carries its command as a string, and +// a string that reads AssetAdmin.newImageAsset() can be found by searching for +// it. "Bitmap Font" would defeat a title built out of the type name anyway. +//----------------------------------------------------------------------------- + +function AssetAdmin::openNewAssetDialog(%this, %class, %title, %height) +{ + %width = 700; + %dialog = new GuiControl() + { + class = %class; + superclass = "EditorDialog"; + dialogSize = (%width + 8) SPC (%height + 8); + dialogCanClose = true; + dialogText = %title; + }; + %dialog.init(%width, %height); + + Canvas.pushDialog(%dialog); +} + +function AssetAdmin::newImageAsset(%this) +{ + %this.openNewAssetDialog("NewImageAssetDialog", "New Image Asset", 340); +} + +function AssetAdmin::newAnimationAsset(%this) +{ + %this.openNewAssetDialog("NewAnimationAssetDialog", "New Animation Asset", 390); +} + +function AssetAdmin::newParticleAsset(%this) +{ + %this.openNewAssetDialog("NewParticleAssetDialog", "New Particle Asset", 440); +} + +function AssetAdmin::newFontAsset(%this) +{ + %this.openNewAssetDialog("NewFontAssetDialog", "New Bitmap Font Asset", 340); +} + +function AssetAdmin::newAudioAsset(%this) +{ + %this.openNewAssetDialog("NewAudioAssetDialog", "New Audio Asset", 340); } //----------------------------------------------------------------------------- @@ -515,4 +588,6 @@ class = "AssetAdminConfirmSaveDialog"; %this.assetScene.setScenePause(true); %this.isOpen = false; + + EditorCore.setEditorMenus(""); } diff --git a/editor/AssetAdmin/AssetAdminMenus.cs b/editor/AssetAdmin/AssetAdminMenus.cs new file mode 100644 index 000000000..abe1294d4 --- /dev/null +++ b/editor/AssetAdmin/AssetAdminMenus.cs @@ -0,0 +1,83 @@ +//----------------------------------------------------------------------------- +// The Asset Manager's two menus: File and Edit. +// +// They are called File and Edit and they say nothing the Gui Editor's File and +// Edit say, which is the whole arrangement: EditorCore swaps whole sets in and +// out, so only one editor's menus are ever on the bar and both are free to mean +// what their own editor means. Ctrl+S saves the asset here and the Gui there, +// and neither has to know the other exists. See EditorMenuSet. +// +// There is no Asset menu, because every command in this editor is about an +// asset - an Asset menu would be the whole menu bar. +// +// Everything below already existed as a command on the inspector's document bar +// or on AssetAdmin, with the same predicates. This is a second way in, not new +// behavior. +//----------------------------------------------------------------------------- + +function AssetAdminMenus::onAdd(%this) +{ + %this.init(); +} + +function AssetAdminMenus::build(%this) +{ + %file = %this.addMenu("File"); + + // A submenu rather than five items, and no accelerator on any of them: there + // is no single "new asset" here for Ctrl+N to mean, and picking a favorite of + // the five would be arbitrary. + %new = %file.addSubMenu("New Asset"); + %new.addItem("Image Asset...", "AssetAdmin.newImageAsset();"); + %new.addItem("Animation Asset...", "AssetAdmin.newAnimationAsset();"); + %new.addItem("Particle Asset...", "AssetAdmin.newParticleAsset();"); + %new.addItem("Bitmap Font Asset...", "AssetAdmin.newFontAsset();"); + %new.addItem("Audio Asset...", "AssetAdmin.newAudioAsset();"); + %file.addSeparator(); + + %this.save = %file.addItem("Save Asset", "AssetAdmin.inspector.SaveAsset();", "Ctrl S"); + %this.saveAll = %file.addItem("Save All Assets", "AssetAdmin.saveAllAssets();", "Ctrl-Shift S"); + %file.addSeparator(); + + // No accelerator, for the reason the Gui Editor's Revert has none: it throws + // away everything since the last save and cannot be taken back. + %this.revert = %file.addItem("Revert Asset", "AssetAdmin.inspector.RevertAsset();"); + + %edit = %this.addMenu("Edit"); + + // These two carry the step label - "Undo Move Frame" - the way the document + // bar's tooltips already do, so their text is rewritten on every refresh. + // Anything looking for them must hold the handle rather than search by text. + %this.undo = %edit.addItem("Undo", "AssetAdmin.inspector.UndoAsset();", "Ctrl Z"); + %this.redo = %edit.addItem("Redo", "AssetAdmin.inspector.RedoAsset();", "Ctrl-Shift Z"); + %edit.addSeparator(); + %this.duplicate = %edit.addItem("Duplicate Asset...", "AssetAdmin.inspector.DuplicateAsset();", "Ctrl D"); + %edit.addSeparator(); + + // No accelerator: this offers to take the asset's files off disk with it. + %this.deleteAsset = %edit.addItem("Delete Asset...", "AssetAdmin.inspector.deleteAsset();"); +} + +// Called when the set goes back on the bar, and by AssetInspector on every +// change to the document - which is often, so this stays down to reading the +// same predicates the document bar's buttons read and writing a flag each. +function AssetAdminMenus::refresh(%this) +{ + %inspector = %this.tool.inspector; + %hasDocument = isObject(%inspector.documentAsset()); + + %this.save.setActive(%inspector.getSaveAssetEnabled()); + %this.saveAll.setActive(%this.tool.hasUnsavedAssets()); + %this.revert.setActive(%inspector.getRevertAssetEnabled()); + + %this.undo.setActive(%inspector.getUndoAssetEnabled()); + %this.redo.setActive(%inspector.getRedoAssetEnabled()); + %this.duplicate.setActive(%hasDocument); + %this.deleteAsset.setActive(%hasDocument); + + // The same text the buttons put in their tooltips. setText goes through the + // bar's own update, and a dropdown re-measures itself every time it opens, so + // a longer label is not clipped. + %this.undo.setText(%inspector.getUndoAssetTooltip()); + %this.redo.setText(%inspector.getRedoAssetTooltip()); +} diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index 94bc5aa3f..b105d72b1 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -374,6 +374,7 @@ class = "AssetAnimationInspectorPane"; %this.emitterButtonBar.visible = false; %this.deleteAssetButton.visible = false; %this.documentButtonBar.visible = false; + %this.document = ""; // Nothing is selected, so nothing is bound. The pane keeps its rows. %this.chooseInspector(""); @@ -399,30 +400,32 @@ class = "AssetAnimationInspectorPane"; AssetAdmin.undoRecorder.track(%asset); + %this.document = %asset; %this.documentButtonBar.visible = true; %this.refreshDocumentBar(); } -// The asset the document buttons act on. +// The asset the document commands act on: the one beginDocument was handed, +// which is the one with a file. // -// inspectedObject() answers with the emitter when one is selected in the title -// dropdown, because that is what the field rows are editing. An emitter is not a -// document, so ask it who owns it. +// Remembered rather than worked out from what is on screen. Deducing it went +// through inspectedObject(), and that has two holes: it is answered by the +// active pane, which every load method binds AFTER calling beginDocument, so +// during the refresh that follows a load there is no pane to ask yet; and when +// there is no pane it falls through to whatever the generic inspector was last +// given, which nothing clears when the selection goes away. Neither showed while +// only the document bar asked - the bar is hidden in exactly those moments - but +// the menus are never hidden, and both "this asset" and "no asset" are things +// they have to be able to say. +// +// It also settles the particle case for free. An emitter is what the rows edit +// and what inspectedObject answers with, but an emitter has no file of its own; +// saving one means saving the particle asset that owns it. That asset is what +// beginDocument was given, so it is what comes back here whichever emitter the +// title dropdown is showing. function AssetInspector::documentAsset(%this) { - %asset = %this.inspectedObject(); - - if(!isObject(%asset)) - { - return 0; - } - - if(%this.titleDropDown.visible && %this.titleDropDown.getSelectedItem() != 0) - { - return %asset.getOwner(); - } - - return %asset; + return isObject(%this.document) ? %this.document : 0; } function AssetInspector::refreshDocumentBar(%this) @@ -431,6 +434,14 @@ class = "AssetAnimationInspectorPane"; { %this.documentButtonBar.refreshEnabled(); } + + // Outside the guard above, deliberately. The bar is hidden whenever nothing + // is selected; the menus never are, and "nothing is selected" is exactly what + // they have to be able to say. The predicates answer correctly either way. + if(isObject(AssetAdmin.menus)) + { + AssetAdmin.menus.refresh(); + } } function AssetInspector::getSaveAssetEnabled(%this) @@ -751,10 +762,12 @@ class = "DuplicateAssetDialog"; function AssetInspector::deleteAsset(%this) { - %asset = %this.inspectedObject(); - if(%this.titleDropDown.visible && %this.titleDropDown.getSelectedItem() != 0) + // The asset, never the emitter showing in its place - deleting one emitter of + // a particle is RemoveEmitter's job, and this offers to take files off disk. + %asset = %this.documentAsset(); + if(!isObject(%asset)) { - %asset = %asset.getOwner(); + return; } %width = 700; diff --git a/editor/AssetAdmin/NewAssetButton.cs b/editor/AssetAdmin/NewAssetButton.cs index b97ba37be..252b633b0 100644 --- a/editor/AssetAdmin/NewAssetButton.cs +++ b/editor/AssetAdmin/NewAssetButton.cs @@ -1,91 +1,10 @@ //NewAssetButton.cs +// The library's per-group New button. The dialogs it opens are also on the File +// menu, so the bodies live on AssetAdmin and this is only the button half - .type +// is the asset class name the group holds ("ImageAsset"), which is exactly the +// back half of AssetAdmin::newImageAsset. function NewAssetButton::onClick(%this) { - %this.call("onNew" @ %this.type); -} - -function NewAssetButton::onNewImageAsset(%this) -{ - %width = 700; - %height = 340; - %dialog = new GuiControl() - { - class = "NewImageAssetDialog"; - superclass = "EditorDialog"; - dialogSize = (%width + 8) SPC (%height + 8); - dialogCanClose = true; - dialogText = "New Image Asset"; - }; - %dialog.init(%width, %height); - - Canvas.pushDialog(%dialog); -} - -function NewAssetButton::onNewAnimationAsset(%this) -{ - %width = 700; - %height = 390; - %dialog = new GuiControl() - { - class = "NewAnimationAssetDialog"; - superclass = "EditorDialog"; - dialogSize = (%width + 8) SPC (%height + 8); - dialogCanClose = true; - dialogText = "New Animation Asset"; - }; - %dialog.init(%width, %height); - - Canvas.pushDialog(%dialog); -} - -function NewAssetButton::onNewParticleAsset(%this) -{ - %width = 700; - %height = 440; - %dialog = new GuiControl() - { - class = "NewParticleAssetDialog"; - superclass = "EditorDialog"; - dialogSize = (%width + 8) SPC (%height + 8); - dialogCanClose = true; - dialogText = "New Particle Asset"; - }; - %dialog.init(%width, %height); - - Canvas.pushDialog(%dialog); -} - -function NewAssetButton::onNewFontAsset(%this) -{ - %width = 700; - %height = 340; - %dialog = new GuiControl() - { - class = "NewFontAssetDialog"; - superclass = "EditorDialog"; - dialogSize = (%width + 8) SPC (%height + 8); - dialogCanClose = true; - dialogText = "New Bitmap Font Asset"; - }; - %dialog.init(%width, %height); - - Canvas.pushDialog(%dialog); -} - -function NewAssetButton::onNewAudioAsset(%this) -{ - %width = 700; - %height = 340; - %dialog = new GuiControl() - { - class = "NewAudioAssetDialog"; - superclass = "EditorDialog"; - dialogSize = (%width + 8) SPC (%height + 8); - dialogCanClose = true; - dialogText = "New Audio Asset"; - }; - %dialog.init(%width, %height); - - Canvas.pushDialog(%dialog); + AssetAdmin.call("new" @ %this.type); } diff --git a/editor/EditorCore/EditorCore.cs b/editor/EditorCore/EditorCore.cs index b06ebcbcf..14050d6e4 100644 --- a/editor/EditorCore/EditorCore.cs +++ b/editor/EditorCore/EditorCore.cs @@ -52,6 +52,11 @@ exec("./EditorIconButton.cs"); exec("./EditorButtonBar.cs"); + // Before initGui builds the bar, and before any editor's create runs to hang + // its own menus off it. + exec("./EditorMenu.cs"); + exec("./EditorMenuSet.cs"); + // The segmented toggle row. It lives here rather than in the Gui Editor that // first grew it because editor/main.cs loads AssetAdmin FIRST, so anything the // Asset Manager builds at create time cannot come out of a module loaded after @@ -85,6 +90,13 @@ { EditorPreferences.delete(); } + + // Empty by now - every editor deletes its own menus before this runs, and + // each module is unloaded before the one it depends on. + if(isObject(%this.menuPark)) + { + %this.menuPark.delete(); + } } function EditorCore::initGui(%this) @@ -108,11 +120,14 @@ Command = "EditorCore.close();"; }; - // Both go through the Gui Editor's guard, because both throw away a - // Gui that is being authored and neither is undoable. GuiEditor is - // named directly, as it is by every item in the File, Edit, Layout - // and Select menus below; the isObject test is what keeps quitting - // working if the Gui Editor module ever fails to load. + // Both go through the guard chain, because both throw away work an + // editor is holding and neither is undoable. The chain names each + // editor in turn and tests it with isObject, which is what keeps + // quitting working if a module ever fails to load. + // + // These two and Close Tools are the only commands written here. Every + // other menu belongs to whichever editor is open and arrives with it - + // see setEditorMenus. // // The window's own X cannot be guarded this way. It posts the quit // from the window procedure with no script in between. @@ -126,217 +141,6 @@ Command = "EditorCore.guardedCommand(\"quit();\");"; }; }; - new GuiMenuItemCtrl() { - Text = "File"; - Active = "0"; - - new GuiMenuItemCtrl() { - Text = "New Gui"; - Command = "GuiEditor.NewGui();"; - Accelerator = "Ctrl N"; - }; - new GuiMenuItemCtrl() { - Text = "Open Gui..."; - Command = "GuiEditor.OpenGui();"; - Accelerator = "Ctrl O"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - new GuiMenuItemCtrl() { - Text = "Save Gui..."; - Command = "GuiEditor.SaveGui();"; - Accelerator = "Ctrl S"; - }; - new GuiMenuItemCtrl() { - Text = "Save Gui As..."; - Command = "GuiEditor.SaveGuiAs();"; - Accelerator = "Ctrl-Shift S"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - // Offered only once the Gui has a file to go back to; GuiEditor - // keeps that up to date in refreshFileMenu. No accelerator - it - // throws away everything since the last save, and that is not a - // thing to have a shortcut for. - new GuiMenuItemCtrl() { - Text = "Revert"; - Command = "GuiEditor.Revert();"; - Active = "0"; - }; - }; - new GuiMenuItemCtrl() { - Text = "Edit"; - Active = "0"; - - new GuiMenuItemCtrl() { - Text = "Undo"; - Command = "GuiEditor.Undo();"; - Accelerator = "Ctrl Z"; - }; - new GuiMenuItemCtrl() { - Text = "Redo"; - Command = "GuiEditor.Redo();"; - Accelerator = "Ctrl-Shift Z"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - new GuiMenuItemCtrl() { - Text = "Cut"; - Command = "GuiEditor.Cut();"; - Accelerator = "Ctrl X"; - }; - new GuiMenuItemCtrl() { - Text = "Copy"; - Command = "GuiEditor.Copy();"; - Accelerator = "Ctrl C"; - }; - new GuiMenuItemCtrl() { - Text = "Paste"; - Command = "GuiEditor.Paste();"; - Accelerator = "Ctrl V"; - }; - new GuiMenuItemCtrl() { - Text = "Duplicate"; - Command = "GuiEditor.Duplicate();"; - Accelerator = "Ctrl D"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - // DeleteSelection, not Delete: delete is a console method on every - // SimObject, so GuiEditor.Delete() would quietly destroy the editor - // rather than the selection. - // - // The accelerator cannot double-fire with the Delete key the canvas - // and the Explorer tree already handle themselves. The canvas - // consults accelerators only once the first responder has passed on - // the key -- the same thing that lets a text box in the properties - // pane keep Ctrl+C. What it adds is Delete working while focus is in - // a tool window. - new GuiMenuItemCtrl() { - Text = "Delete"; - Command = "GuiEditor.DeleteSelection();"; - Accelerator = "Delete"; - }; - }; - new GuiMenuItemCtrl() { - Text = "Layout"; - Active = "0"; - - new GuiMenuItemCtrl() { - Text = "Nudge Up"; - Command = "GuiEditor.brain.moveSelection(0,-1);"; - Accelerator = "Up"; - }; - new GuiMenuItemCtrl() { - Text = "Nudge Down"; - Command = "GuiEditor.brain.moveSelection(0,1);"; - Accelerator = "Down"; - }; - new GuiMenuItemCtrl() { - Text = "Nudge Left"; - Command = "GuiEditor.brain.moveSelection(-1,0);"; - Accelerator = "Left"; - }; - new GuiMenuItemCtrl() { - Text = "Nudge Right"; - Command = "GuiEditor.brain.moveSelection(1,0);"; - Accelerator = "Right"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - new GuiMenuItemCtrl() { - Text = "Shrink Height"; - Command = "GuiEditor.changeExtent(0,-1);"; - Accelerator = "Ctrl Up"; - }; - new GuiMenuItemCtrl() { - Text = "Expand Height"; - Command = "GuiEditor.changeExtent(0, 1);"; - Accelerator = "Ctrl Down"; - }; - new GuiMenuItemCtrl() { - Text = "Shrink Width"; - Command = "GuiEditor.changeExtent(-1,0);"; - Accelerator = "Ctrl Left"; - }; - new GuiMenuItemCtrl() { - Text = "Expand Width"; - Command = "GuiEditor.changeExtent(1,0);"; - Accelerator = "Ctrl Right"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - new GuiMenuItemCtrl() { - Text = "Align Top"; - Command = "GuiEditor.Justify(3);"; - Accelerator = "Ctrl T"; - }; - new GuiMenuItemCtrl() { - Text = "Align Bottom"; - Command = "GuiEditor.Justify(4);"; - Accelerator = "Ctrl B"; - }; - new GuiMenuItemCtrl() { - Text = "Align Left"; - Command = "GuiEditor.Justify(0);"; - Accelerator = "Ctrl L"; - }; - new GuiMenuItemCtrl() { - Text = "Align Right"; - Command = "GuiEditor.Justify(2);"; - Accelerator = "Ctrl R"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - new GuiMenuItemCtrl() { - Text = "Center Horizontally"; - Command = "GuiEditor.Justify(1);"; - }; - new GuiMenuItemCtrl() { - Text = "Space Vertically"; - Command = "GuiEditor.Justify(5);"; - }; - new GuiMenuItemCtrl() { - Text = "Space Horizontally"; - Command = "GuiEditor.Justify(6);"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - new GuiMenuItemCtrl() { - Text = "Bring to Front"; - Command = "GuiEditor.BringToFront();"; - Accelerator = "Ctrl-Shift Up"; - }; - new GuiMenuItemCtrl() { - Text = "Push to Back"; - Command = "GuiEditor.PushToBack();"; - Accelerator = "Ctrl-Shift Down"; - }; - new GuiMenuItemCtrl() { Text = "-"; }; - new GuiMenuItemCtrl() { - Text = "Set Grid Size..."; - Command = "GuiEditor.SetGridSize();"; - Accelerator = "Ctrl-Shift G"; - }; - new GuiMenuItemCtrl() { - Text = "Snap to Grid"; - Toggle = "1"; - IsOn = "1"; - Command = "GuiEditor.SnapToGrid(true);"; - AltCommand = "GuiEditor.SnapToGrid(false);"; - Accelerator = "Ctrl G"; - }; - }; - new GuiMenuItemCtrl() { - Text = "Select"; - Active = "0"; - - new GuiMenuItemCtrl() { - Text = "Select All"; - Command = "GuiEditor.brain.SelectAll();"; - Accelerator = "Ctrl A"; - }; - // Ctrl-Shift A rather than Ctrl D, which Duplicate has: this is what - // deselect is bound to nearly everywhere else, and it pairs with - // Ctrl A above. - new GuiMenuItemCtrl() { - Text = "Deselect"; - Command = "GuiEditor.brain.clearSelection();"; - Accelerator = "Ctrl-Shift A"; - }; - }; new GuiMenuItemCtrl() { Text = "Theme"; @@ -378,6 +182,18 @@ ThemeManager.setProfile(%this.menuBar, "scrollingPanelArrowProfile", "ArrowProfile"); ThemeManager.setProfile(%this.menuBar, "scrollingPanelTrackProfile", "TrackProfile"); + // The bar reads Torque2D | the open editor's menus | Theme, and the two ends + // are the only parts that never change. Theme has to be held onto by hand + // because it has to come off and go back on around every swap - see + // setEditorMenus - and it is taken by position rather than by name because + // naming it would put back exactly the by-text coupling the swap exists to + // remove. Last child, so this is still right once the Gui Editor's menus have + // moved out of the literal above. + %this.themeMenu = %this.menuBar.getObject(%this.menuBar.getCount() - 1); + + // Where a set's menus wait while another editor has the bar. + %this.menuPark = new SimGroup(); + %this.baseGui.add(%this.menuBar); %this.tabBook = new GuiTabBookCtrl() @@ -520,6 +336,52 @@ eval(%command); } +// Put %menuSet's menus on the bar and take the last editor's off, so the bar +// always reads Torque2D | the open editor's menus | Theme. Pass "" for an editor +// with no menus of its own, which is what the Console and the Project Manager +// are. Called from every editor's open() and close(). +// +// Theme comes off and goes back on around the swap, and that is not fussiness. +// The bar links the chain its keyboard walk follows by assuming each new menu +// was appended - it takes the second-to-last child as the new one's neighbour - +// and reordering afterwards repairs the layout but not the chain. Appending is +// the only move that leaves the bar correct, so the fixed tail has to move. +// +// Nothing here is deleted. Each set parks its own menus, which is what keeps +// them alive, keeps them out of the accelerator walk, and keeps the bar they +// remember - set once, never filled in again - pointing at a real bar. +function EditorCore::setEditorMenus(%this, %menuSet) +{ + if(%this.activeMenus == %menuSet) + { + return; + } + + %this.menuPark.add(%this.themeMenu); + + if(isObject(%this.activeMenus)) + { + %this.activeMenus.detach(); + } + %this.activeMenus = %menuSet; + if(isObject(%menuSet)) + { + %menuSet.attach(); + } + + %this.menuBar.add(%this.themeMenu); + + // The canvas keeps one flat list of accelerators and rebuilds it only when a + // dialog is pushed or popped. A tab change is neither, so without this the + // menus that just arrived have dead shortcuts and the ones that just left + // still have live ones - a parked item is still active and still visible, so + // its command would run. + if(isObject(Canvas)) + { + Canvas.rebuildAcceleratorMap(); + } +} + function EditorCore::RegisterEditor(%this, %name, %editor) { %this.page[%name] = new GuiTabPageCtrl() @@ -714,4 +576,4 @@ class = "EditorAssetPickerDialog"; %dialog.init(%width, %height); Canvas.pushDialog(%dialog); -} \ No newline at end of file +} diff --git a/editor/EditorCore/EditorMenu.cs b/editor/EditorCore/EditorMenu.cs new file mode 100644 index 000000000..88328754e --- /dev/null +++ b/editor/EditorCore/EditorMenu.cs @@ -0,0 +1,89 @@ +//----------------------------------------------------------------------------- +// One menu on the shared bar, or one submenu inside another - they are the same +// control and the same class, which is why nesting needs nothing extra. +// +// Never construct one of these directly. EditorMenuSet::addMenu makes the +// top-level ones and addSubMenu makes the rest, and both exist to enforce the +// one rule this control has: +// +// A MENU MUST BE CREATED EMPTY, PUT IN ITS PARENT, AND ONLY THEN FILLED. +// +// GuiMenuItemCtrl learns which bar it belongs to when it is added to one, and a +// submenu learns it from its parent at the moment IT is added. Nothing ever +// fills that in afterwards, so a tree built standalone and handed to the bar +// whole leaves every descendant with no bar at all - and the first time such a +// menu is opened, the engine dereferences it. The methods below add first and +// return the thing to be filled, so the rule is the shape of the code rather +// than something to remember. +//----------------------------------------------------------------------------- + +// A command. %accelerator and %group are both optional. +// +// %group names a set of items that grey out together, and is the answer to menus +// like the Gui Editor's Layout, where thirteen items are not thirteen questions +// but one - is anything selected. The set flips a whole group in one call. Items +// whose state is their own are greyed through the handle this returns instead. +function EditorMenu::addItem(%this, %text, %command, %accelerator, %group) +{ + %item = new GuiMenuItemCtrl() + { + Text = %text; + Command = %command; + Accelerator = %accelerator; + }; + %this.add(%item); + + if(%group !$= "") + { + %this.set.addToGroup(%group, %item); + } + + return %item; +} + +// A menu inside this one. Returned empty, to be filled the same way this was. +function EditorMenu::addSubMenu(%this, %text) +{ + %menu = new GuiMenuItemCtrl() + { + Class = "EditorMenu"; + Text = %text; + set = %this.set; + }; + %this.add(%menu); + + return %menu; +} + +// A checkable item. Command runs when it is switched on and %altCommand when it +// is switched off, which is the engine's own split and not a convention we could +// change here. +function EditorMenu::addToggle(%this, %text, %command, %altCommand, %accelerator, %isOn) +{ + %item = new GuiMenuItemCtrl() + { + Text = %text; + Toggle = "1"; + IsOn = %isOn; + Command = %command; + AltCommand = %altCommand; + Accelerator = %accelerator; + }; + %this.add(%item); + + return %item; +} + +// The horizontal rule between groups of commands. The text IS the separator - +// the engine decides an item is one by finding "-" there when it is added, which +// is also why this has to go through the same add path as everything else. +function EditorMenu::addSeparator(%this) +{ + %item = new GuiMenuItemCtrl() + { + Text = "-"; + }; + %this.add(%item); + + return %item; +} diff --git a/editor/EditorCore/EditorMenuSet.cs b/editor/EditorCore/EditorMenuSet.cs new file mode 100644 index 000000000..8a76c9be8 --- /dev/null +++ b/editor/EditorCore/EditorMenuSet.cs @@ -0,0 +1,138 @@ +//----------------------------------------------------------------------------- +// The menus one editor puts on the shared bar. +// +// The bar is shared but the menus are not: File means "new Gui, open Gui, save +// Gui" in the Gui Editor and "new asset, save asset, revert asset" in the Asset +// Manager, and neither is a version of the other. So each editor owns a set, +// builds it once, and hands it to EditorCore.setEditorMenus when it opens. Only +// one set is ever on the bar; the rest are parked in a group of their own. +// +// Parking rather than greying is what makes the shortcuts right. The canvas +// keeps one flat list of accelerators built by walking whatever is on show, and +// it does not check whether an item is active before firing it - only whether +// the item ITSELF is, never its menu. Greying File therefore left Ctrl+N still +// running the Gui Editor's New Gui from inside the Asset Manager. An item that +// is not in the tree is not in that list at all. +// +// Subclass this: set class to your own name and superclass to EditorMenuSet, +// then define build(), which is called once with the bar ready to be added to, +// and refresh(), which is called every time the set goes back on the bar and +// whenever the editor's state changes underneath it. +//----------------------------------------------------------------------------- + +function EditorMenuSet::init(%this) +{ + // Somewhere for the menus to live while another editor has the bar. A group + // rather than nothing at all: a control taken out of its parent with no new + // home is registered and unreachable, which is a leak wearing a disguise. + %this.parked = new SimGroup(); + %this.menuCount = 0; + + %this.build(); + + // Built into the bar, because that is the only place a menu can be built. + // Nobody has opened this editor yet, so take them straight back off. + %this.detach(); +} + +// Overridden by every subclass. Here to say so out loud when one forgets. +function EditorMenuSet::build(%this) +{ + warn("EditorMenuSet::build - " @ %this.class @ " has no build method, so its menu set is empty."); +} + +// Overridden by any subclass with something to grey out. Called on attach, so +// the menus look new every time the editor is opened rather than carrying the +// state they had when it was last closed. +function EditorMenuSet::refresh(%this) +{ +} + +// A top-level menu, empty, already on the bar and ready to be filled. +function EditorMenuSet::addMenu(%this, %text) +{ + %menu = new GuiMenuItemCtrl() + { + Class = "EditorMenu"; + Text = %text; + set = %this; + }; + EditorCore.menuBar.add(%menu); + + %this.menu[%this.menuCount] = %menu; + %this.menuCount++; + + return %menu; +} + +function EditorMenuSet::attach(%this) +{ + for(%i = 0; %i < %this.menuCount; %i++) + { + EditorCore.menuBar.add(%this.menu[%i]); + } + + %this.refresh(); +} + +function EditorMenuSet::detach(%this) +{ + for(%i = 0; %i < %this.menuCount; %i++) + { + %this.parked.add(%this.menu[%i]); + } +} + +//----------------------------------------------------------------------------- +// Groups: items that grey out together. +//----------------------------------------------------------------------------- + +function EditorMenuSet::addToGroup(%this, %group, %item) +{ + // A group is named on the spot by whoever adds to it, so the first add finds + // no count at all. Left as the empty string it reads as zero in the addition + // below but writes the item to the index "" rather than 0, and the read back + // in setGroupActive finds nothing there - the first item of every group would + // silently never grey. + %count = %this.groupCount[%group]; + if(%count $= "") + { + %count = 0; + } + + %this.groupItem[%group, %count] = %item; + %this.groupCount[%group] = %count + 1; +} + +function EditorMenuSet::setGroupActive(%this, %group, %active) +{ + %count = %this.groupCount[%group]; + for(%i = 0; %i < %count; %i++) + { + %this.groupItem[%group, %i].setActive(%active); + } +} + +function EditorMenuSet::onRemove(%this) +{ + // If we are the set on show, come off it properly first: the bar's own + // bookkeeping is repaired by the remove, and EditorCore stops pointing at + // something about to stop existing. + if(isObject(EditorCore) && EditorCore.activeMenus == %this) + { + EditorCore.setEditorMenus(""); + } + + for(%i = 0; %i < %this.menuCount; %i++) + { + if(isObject(%this.menu[%i])) + { + %this.menu[%i].delete(); + } + } + + if(isObject(%this.parked)) + { + %this.parked.delete(); + } +} diff --git a/editor/GuiEditor/GuiEditor.cs b/editor/GuiEditor/GuiEditor.cs index 53a2c34f5..48780f914 100644 --- a/editor/GuiEditor/GuiEditor.cs +++ b/editor/GuiEditor/GuiEditor.cs @@ -72,8 +72,22 @@ // Copy, cut and paste, which is undo's machinery plus a deep clone. exec("./scripts/GuiEditorClipboard.cs"); + // File, Edit, Layout and Select, which this editor owns and lends to the + // shared bar for as long as it is the one open. + exec("./scripts/GuiEditorMenus.cs"); + %this.guiPage = EditorCore.RegisterEditor("Gui Editor", %this); + // Built here, because a menu can only be built into the bar and EditorCore + // has made it by now - every editor module depends on EditorCore. The set + // takes itself back off again immediately; open() puts it on. + %this.menus = new ScriptObject() + { + class = "GuiEditorMenus"; + superclass = "EditorMenuSet"; + tool = %this; + }; + // What the control palette can offer and what each entry looks like. Built // before the palette window, which reads it as it populates. Generated from // the icon sheets, so the table and the art cannot disagree. @@ -362,6 +376,12 @@ class = "SimulatedCanvas"; { %this.clipboard.delete(); } + + // Takes itself off the bar first if it is still on it. + if(isObject(%this.menus)) + { + %this.menus.delete(); + } } function GuiEditor::open(%this, %content) @@ -373,19 +393,11 @@ class = "SimulatedCanvas"; %this.adoptTheme(""); } - EditorCore.menuBar.setMenuActive("File", true); - EditorCore.menuBar.setMenuActive("Edit", true); - EditorCore.menuBar.setMenuActive("Layout", true); - EditorCore.menuBar.setMenuActive("Select", true); - - // Undo and Redo are greyed from the stacks, Cut and Copy from the selection, - // and Paste from whether anything has been copied. All three of the last are - // cached against what the menu was last told, so they are forced here: the - // menu looks new every time the editor is opened. - %this.undoRecorder.forceRefreshMenu(); - %this.clipboard.forceRefreshMenu(); - %this.brain.toggleMenuItems(); - %this.refreshFileMenu(); + // Puts the four menus on the shared bar, and refreshes them on the way: Undo + // and Redo grey from the stacks, Cut and Copy from the selection, Paste from + // whether anything has been copied, and Revert from whether the document has + // a file. The menus look new every time the editor is opened. + EditorCore.setEditorMenus(%this.menus); // The window title is the other thing that looks new every time: the tools // window was built with a placeholder and has not been told about the @@ -398,10 +410,7 @@ class = "SimulatedCanvas"; function GuiEditor::close(%this) { editorMode(false); - EditorCore.menuBar.setMenuActive("File", false); - EditorCore.menuBar.setMenuActive("Edit", false); - EditorCore.menuBar.setMenuActive("Layout", false); - EditorCore.menuBar.setMenuActive("Select", false); + EditorCore.setEditorMenus(""); } //MENU FUNCTIONS--------------------------------------------------------------- @@ -657,18 +666,13 @@ class = "GuiProfileEditorDialog"; } // Revert is the only File item whose offer changes, and what it turns on is -// whether the document has a file to go back to. -// -// Deliberately NOT called from refreshDocumentTitle, which runs on every edit: -// setMenuActive walks the whole menu tree by item text and re-applies every -// item's profile, which is the cost GuiEditorUndoRecorder::refreshMenu keeps a -// cache to avoid paying per keystroke. Whether the document has a file changes -// far more rarely than the document does - on a save, a new one and an open - -// so this is called from those three and from open(), where the menu is -// rebuilt-looking. +// whether the document has a file to go back to. That changes far more rarely +// than the document does - on a save, a new one and an open - so it is called +// from those three rather than from refreshDocumentTitle, which runs on every +// edit. The menu set refreshes it for itself when it goes back on the bar. function GuiEditor::refreshFileMenu(%this) { - EditorCore.menuBar.setMenuActive("Revert", %this.filePath !$= ""); + %this.menus.refreshFile(); } //----------------------------------------------------------------------------- diff --git a/editor/GuiEditor/scripts/GuiEditorBrain.cs b/editor/GuiEditor/scripts/GuiEditorBrain.cs index 8ad70b519..5c38cc2e2 100644 --- a/editor/GuiEditor/scripts/GuiEditorBrain.cs +++ b/editor/GuiEditor/scripts/GuiEditorBrain.cs @@ -643,29 +643,10 @@ return %list; } +// Everything in Layout and Select, and the half of Edit that acts on controls, +// answers one question: how much is selected. The menus know which items belong +// to which threshold; all this has to say is the number. function GuiEditorBrain::toggleMenuItems(%this) { - %count = %this.getSelected().getCount(); - EditorCore.menuBar.setMenuActive("Deselect", %count != 0); - EditorCore.menuBar.setMenuActive("Cut", %count != 0); - EditorCore.menuBar.setMenuActive("Copy", %count != 0); - EditorCore.menuBar.setMenuActive("Duplicate", %count != 0); - EditorCore.menuBar.setMenuActive("Delete", %count != 0); - EditorCore.menuBar.setMenuActive("Nudge Up", %count != 0); - EditorCore.menuBar.setMenuActive("Nudge Down", %count != 0); - EditorCore.menuBar.setMenuActive("Nudge Left", %count != 0); - EditorCore.menuBar.setMenuActive("Nudge Right", %count != 0); - EditorCore.menuBar.setMenuActive("Expand Height", %count != 0); - EditorCore.menuBar.setMenuActive("Shrink Height", %count != 0); - EditorCore.menuBar.setMenuActive("Expand Width", %count != 0); - EditorCore.menuBar.setMenuActive("Shrink Width", %count != 0); - EditorCore.menuBar.setMenuActive("Align Top", %count > 1); - EditorCore.menuBar.setMenuActive("Align Bottom", %count > 1); - EditorCore.menuBar.setMenuActive("Align Left", %count > 1); - EditorCore.menuBar.setMenuActive("Align Right", %count > 1); - EditorCore.menuBar.setMenuActive("Center Horizontally", %count > 1); - EditorCore.menuBar.setMenuActive("Space Vertically", %count > 2); - EditorCore.menuBar.setMenuActive("Space Horizontally", %count > 2); - EditorCore.menuBar.setMenuActive("Bring to Front", %count == 1); - EditorCore.menuBar.setMenuActive("Push to Back", %count == 1); + GuiEditor.menus.refreshSelection(%this.getSelected().getCount()); } \ No newline at end of file diff --git a/editor/GuiEditor/scripts/GuiEditorClipboard.cs b/editor/GuiEditor/scripts/GuiEditorClipboard.cs index 6aebf3362..d416efd0c 100644 --- a/editor/GuiEditor/scripts/GuiEditorClipboard.cs +++ b/editor/GuiEditor/scripts/GuiEditorClipboard.cs @@ -46,11 +46,6 @@ // carries on where this one left off, instead of landing on what is already // there. %this.stepCount = 0; - - // What the Edit menu was last told about Paste. setMenuActive walks the whole - // menu tree and re-applies every item's profile, so it is worth not saying - // the same thing twice. - %this.menuPaste = -1; } function GuiEditorClipboard::onRemove(%this) @@ -539,25 +534,8 @@ function GuiEditorClipboard::refreshMenu(%this) { - %has = %this.isEmpty() ? 0 : 1; - - if(%has == %this.menuPaste) - { - return; - } - - %this.menuPaste = %has; - - if(isObject(EditorCore) && isObject(EditorCore.menuBar)) + if(isObject(%this.owner) && isObject(%this.owner.menus)) { - EditorCore.menuBar.setMenuActive("Paste", %has); + %this.owner.menus.refreshPaste(%this.isEmpty() ? 0 : 1); } } - -// The menu looks rebuilt every time the editor is opened, and the cache above -// would otherwise decide there was nothing to say. -function GuiEditorClipboard::forceRefreshMenu(%this) -{ - %this.menuPaste = -1; - %this.refreshMenu(); -} diff --git a/editor/GuiEditor/scripts/GuiEditorMenus.cs b/editor/GuiEditor/scripts/GuiEditorMenus.cs new file mode 100644 index 000000000..5f4f316de --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorMenus.cs @@ -0,0 +1,133 @@ +//----------------------------------------------------------------------------- +// The Gui Editor's four menus: File, Edit, Layout and Select. +// +// They used to be written into the shared bar in EditorCore, greyed in when this +// editor opened and greyed out when it closed. They live here now because they +// were never shared - every command in them names GuiEditor - and because the +// Asset Manager wanted a File and an Edit of its own that mean something else. +// EditorCore swaps whole sets in and out; see EditorMenuSet. +// +// The greying divides in two. Revert, Undo, Redo and Paste each answer their own +// question and are held by name. Everything in Layout and Select answers the +// same one - how much is selected - so those thirteen, five, two and two items +// are groups, and refreshSelection flips them with four calls instead of +// twenty-two. +//----------------------------------------------------------------------------- + +function GuiEditorMenus::onAdd(%this) +{ + %this.init(); +} + +function GuiEditorMenus::build(%this) +{ + %file = %this.addMenu("File"); + %file.addItem("New Gui", "GuiEditor.NewGui();", "Ctrl N"); + %file.addItem("Open Gui...", "GuiEditor.OpenGui();", "Ctrl O"); + %file.addSeparator(); + %file.addItem("Save Gui...", "GuiEditor.SaveGui();", "Ctrl S"); + %file.addItem("Save Gui As...", "GuiEditor.SaveGuiAs();", "Ctrl-Shift S"); + %file.addSeparator(); + + // Offered only once the Gui has a file to go back to; refreshFile keeps that + // up to date. No accelerator - it throws away everything since the last save, + // and that is not a thing to have a shortcut for. + %this.revert = %file.addItem("Revert", "GuiEditor.Revert();"); + + %edit = %this.addMenu("Edit"); + %this.undo = %edit.addItem("Undo", "GuiEditor.Undo();", "Ctrl Z"); + %this.redo = %edit.addItem("Redo", "GuiEditor.Redo();", "Ctrl-Shift Z"); + %edit.addSeparator(); + %edit.addItem("Cut", "GuiEditor.Cut();", "Ctrl X", "selection"); + %edit.addItem("Copy", "GuiEditor.Copy();", "Ctrl C", "selection"); + %this.paste = %edit.addItem("Paste", "GuiEditor.Paste();", "Ctrl V"); + %edit.addItem("Duplicate", "GuiEditor.Duplicate();", "Ctrl D", "selection"); + %edit.addSeparator(); + + // DeleteSelection, not Delete: delete is a console method on every SimObject, + // so GuiEditor.Delete() would quietly destroy the editor rather than the + // selection. + // + // The accelerator cannot double-fire with the Delete key the canvas and the + // Explorer tree already handle themselves. The canvas consults accelerators + // only once the first responder has passed on the key -- the same thing that + // lets a text box in the properties pane keep Ctrl+C. What it adds is Delete + // working while focus is in a tool window. + %edit.addItem("Delete", "GuiEditor.DeleteSelection();", "Delete", "selection"); + + %layout = %this.addMenu("Layout"); + %layout.addItem("Nudge Up", "GuiEditor.brain.moveSelection(0,-1);", "Up", "selection"); + %layout.addItem("Nudge Down", "GuiEditor.brain.moveSelection(0,1);", "Down", "selection"); + %layout.addItem("Nudge Left", "GuiEditor.brain.moveSelection(-1,0);", "Left", "selection"); + %layout.addItem("Nudge Right", "GuiEditor.brain.moveSelection(1,0);", "Right", "selection"); + %layout.addSeparator(); + %layout.addItem("Shrink Height", "GuiEditor.changeExtent(0,-1);", "Ctrl Up", "selection"); + %layout.addItem("Expand Height", "GuiEditor.changeExtent(0, 1);", "Ctrl Down", "selection"); + %layout.addItem("Shrink Width", "GuiEditor.changeExtent(-1,0);", "Ctrl Left", "selection"); + %layout.addItem("Expand Width", "GuiEditor.changeExtent(1,0);", "Ctrl Right", "selection"); + %layout.addSeparator(); + %layout.addItem("Align Top", "GuiEditor.Justify(3);", "Ctrl T", "align"); + %layout.addItem("Align Bottom", "GuiEditor.Justify(4);", "Ctrl B", "align"); + %layout.addItem("Align Left", "GuiEditor.Justify(0);", "Ctrl L", "align"); + %layout.addItem("Align Right", "GuiEditor.Justify(2);", "Ctrl R", "align"); + %layout.addSeparator(); + %layout.addItem("Center Horizontally", "GuiEditor.Justify(1);", "", "align"); + %layout.addItem("Space Vertically", "GuiEditor.Justify(5);", "", "space"); + %layout.addItem("Space Horizontally", "GuiEditor.Justify(6);", "", "space"); + %layout.addSeparator(); + %layout.addItem("Bring to Front", "GuiEditor.BringToFront();", "Ctrl-Shift Up", "restack"); + %layout.addItem("Push to Back", "GuiEditor.PushToBack();", "Ctrl-Shift Down", "restack"); + %layout.addSeparator(); + %layout.addItem("Set Grid Size...", "GuiEditor.SetGridSize();", "Ctrl-Shift G"); + %layout.addToggle("Snap to Grid", "GuiEditor.SnapToGrid(true);", "GuiEditor.SnapToGrid(false);", "Ctrl G", true); + + %select = %this.addMenu("Select"); + %select.addItem("Select All", "GuiEditor.brain.SelectAll();", "Ctrl A"); + + // Ctrl-Shift A rather than Ctrl D, which Duplicate has: this is what deselect + // is bound to nearly everywhere else, and it pairs with Ctrl A above. + %select.addItem("Deselect", "GuiEditor.brain.clearSelection();", "Ctrl-Shift A", "selection"); +} + +//----------------------------------------------------------------------------- +// Greying. Each of these is called by whoever owns the answer; refresh is what +// EditorCore calls when the set goes back on the bar, so the menus look new +// every time the editor is opened rather than carrying the state they had when +// it was last closed. +//----------------------------------------------------------------------------- + +function GuiEditorMenus::refresh(%this) +{ + %this.refreshFile(); + %this.tool.undoRecorder.refreshMenu(); + %this.tool.clipboard.refreshMenu(); + %this.tool.brain.toggleMenuItems(); +} + +// Revert is the only File item whose offer changes, and what it turns on is +// whether the document has a file to go back to. +function GuiEditorMenus::refreshFile(%this) +{ + %this.revert.setActive(%this.tool.filePath !$= ""); +} + +function GuiEditorMenus::refreshUndo(%this, %undoCount, %redoCount) +{ + %this.undo.setActive(%undoCount > 0); + %this.redo.setActive(%redoCount > 0); +} + +function GuiEditorMenus::refreshPaste(%this, %hasCopy) +{ + %this.paste.setActive(%hasCopy); +} + +// Everything in Layout and Select, plus the half of Edit that acts on controls. +// The thresholds are here rather than at the call site because the groups are. +function GuiEditorMenus::refreshSelection(%this, %count) +{ + %this.setGroupActive("selection", %count != 0); + %this.setGroupActive("align", %count > 1); + %this.setGroupActive("space", %count > 2); + %this.setGroupActive("restack", %count == 1); +} diff --git a/editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs b/editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs index 8241c8734..2519b0534 100644 --- a/editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs +++ b/editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs @@ -55,11 +55,6 @@ %this.hierCtrlCount = 0; %this.watchCount = 0; %this.replayTouched = ""; - - // What the Edit menu was last told, so a run of nudges does not walk the - // whole menu tree once per key. - %this.menuUndo = -1; - %this.menuRedo = -1; } function GuiEditorUndoRecorder::onRemove(%this) @@ -1019,34 +1014,14 @@ class = "GuiEditorUndoAction"; return isObject(%manager) ? %manager.getRedoCount() : 0; } -// setMenuActive walks the whole menu tree by item text and re-applies every -// item's profile, so it is worth knowing what it was last told: a run of -// nudges would otherwise do that once per key press. +// Called on every recorded change, which is often - a run of nudges is one per +// key press. That used to be worth caching against, because greying an item meant +// walking the whole menu tree by item text and re-applying every item's profile. +// The set holds the two items by handle now, and this is two flag writes. function GuiEditorUndoRecorder::refreshMenu(%this) { - %undo = %this.undoCount(); - %redo = %this.redoCount(); - - if(%undo == %this.menuUndo && %redo == %this.menuRedo) + if(isObject(%this.owner) && isObject(%this.owner.menus)) { - return; + %this.owner.menus.refreshUndo(%this.undoCount(), %this.redoCount()); } - - %this.menuUndo = %undo; - %this.menuRedo = %redo; - - if(isObject(EditorCore) && isObject(EditorCore.menuBar)) - { - EditorCore.menuBar.setMenuActive("Undo", %undo > 0); - EditorCore.menuBar.setMenuActive("Redo", %redo > 0); - } -} - -// The menu is rebuilt-looking every time the editor is opened, and the cache -// above would otherwise decide there was nothing to say. -function GuiEditorUndoRecorder::forceRefreshMenu(%this) -{ - %this.menuUndo = -1; - %this.menuRedo = -1; - %this.refreshMenu(); } diff --git a/engine/source/gui/guiCanvas.cc b/engine/source/gui/guiCanvas.cc index e1b0ae2c4..47f08d9c5 100755 --- a/engine/source/gui/guiCanvas.cc +++ b/engine/source/gui/guiCanvas.cc @@ -1042,6 +1042,15 @@ void GuiCanvas::setContentControl(GuiControl *gui) resetUpdateRegions(); //rebuild the accelerator map + // + // Deliberately NOT rebuildAcceleratorMap(): this walks down from the top until + // it reaches a control that takes input, so a new content control that lets + // input through leaves whatever is under it still able to answer a shortcut. + // The dialog paths take the topmost control and nothing else, which is what + // stops a menu's dropdown from leaving the editor's own accelerators live + // underneath it. The two are the same in every ordinary case - mUseInput + // defaults to true, so this loop almost always stops on its first pass - but + // they are not the same rule, and sharing one would quietly change the other. mAcceleratorMap.clear(); for(iterator i = end(); i != begin() ; ) @@ -1068,6 +1077,16 @@ GuiControl *GuiCanvas::getContentControl() return NULL; } +void GuiCanvas::rebuildAcceleratorMap() +{ + mAcceleratorMap.clear(); + if (size() > 0) + { + GuiControl *ctrl = static_cast(last()); + ctrl->buildAcceleratorMap(); + } +} + void GuiCanvas::pushDialogControl(GuiControl *gui, S32 layer) { //add the gui @@ -1106,12 +1125,7 @@ void GuiCanvas::pushDialogControl(GuiControl *gui, S32 layer) resetUpdateRegions(); //rebuild the accelerator map - mAcceleratorMap.clear(); - if (size() > 0) - { - GuiControl *ctrl = static_cast(last()); - ctrl->buildAcceleratorMap(); - } + rebuildAcceleratorMap(); refreshMouseControl(); } @@ -1169,12 +1183,7 @@ void GuiCanvas::popDialogControl(GuiControl *gui) resetUpdateRegions(); //rebuild the accelerator map - mAcceleratorMap.clear(); - if (size() > 0) - { - GuiControl *ctrl = static_cast(last()); - ctrl->buildAcceleratorMap(); - } + rebuildAcceleratorMap(); refreshMouseControl(); } diff --git a/engine/source/gui/guiCanvas.h b/engine/source/gui/guiCanvas.h index 9c31dc518..a5a5ab3f0 100755 --- a/engine/source/gui/guiCanvas.h +++ b/engine/source/gui/guiCanvas.h @@ -254,6 +254,23 @@ class GuiCanvas : public GuiControl /// Removes a specific dialog control /// @param gui Dialog to remove from the dialog stack virtual void popDialogControl(GuiControl *gui); + + /// Throws away the accelerator table and walks the topmost control to build it + /// again. + /// + /// Pushing and popping a dialog do this for themselves, and for a long time + /// they were the only things that ever changed which controls were on show. + /// They are not any more: the editor swaps whole menus in and out of its menu + /// bar when the open editor changes, and nothing about that touches the dialog + /// stack. Without this the menus that just arrived have dead shortcuts and the + /// ones that just left still have live ones - the table holds raw pointers and + /// is not filtered by whether a control is still parented, active or visible. + void rebuildAcceleratorMap(); + + /// How many accelerators the canvas is currently listening for. There is no + /// way to ask which one is which; this exists so a test can see that a rebuild + /// changed something, since nothing else about the table is observable. + inline U32 getAcceleratorCount() const { return (U32)mAcceleratorMap.size(); } ///@} /// This turns on/off front-buffer rendering diff --git a/engine/source/gui/guiCanvas_ScriptBinding.h b/engine/source/gui/guiCanvas_ScriptBinding.h index c305f72af..3931cb293 100644 --- a/engine/source/gui/guiCanvas_ScriptBinding.h +++ b/engine/source/gui/guiCanvas_ScriptBinding.h @@ -116,6 +116,30 @@ ConsoleMethodWithDocs( GuiCanvas, popLayer, ConsoleVoid, 2, 3, ( layer )) Canvas->popDialogControl(layer); } +/*! Throw away the accelerator table and build it again from the topmost control. + Call this after changing which controls are on show by any means other than + pushing or popping a dialog - those two do it for themselves. + The table holds raw pointers and is not filtered by whether a control is still + parented, active or visible, so a control taken out of the tree keeps answering + its shortcut until this is called, and one just put in has none. + @return No return value. + @sa getAcceleratorCount +*/ +ConsoleMethodWithDocs( GuiCanvas, rebuildAcceleratorMap, ConsoleVoid, 2, 2, ()) +{ + Canvas->rebuildAcceleratorMap(); +} + +/*! How many accelerators the canvas is currently listening for. There is no way + to ask which - this is for confirming that a rebuild changed something. + @return The number of entries in the accelerator table. + @sa rebuildAcceleratorMap +*/ +ConsoleMethodWithDocs( GuiCanvas, getAcceleratorCount, ConsoleInt, 2, 2, ()) +{ + return (S32)Canvas->getAcceleratorCount(); +} + /*! Use the cursorOn method to enable the cursor. @return No return value */ diff --git a/tests/lib/input.ps1 b/tests/lib/input.ps1 index 1a2266d5a..26b2b4e11 100644 --- a/tests/lib/input.ps1 +++ b/tests/lib/input.ps1 @@ -143,3 +143,60 @@ function Send-EngineKey { Start-Sleep -Milliseconds 120 [TorqueInput]::PostMessage($Hwnd, $script:WM_KEYUP, [IntPtr]$vk, [IntPtr]$up) | Out-Null } + +# The letter keys menu accelerators are built out of. vk, scan code; none of them +# is an extended key, unlike everything in $EngineKeys above. +$script:EngineChars = @{ + 'A' = @(0x41, 0x1E) + 'N' = @(0x4E, 0x31) + 'S' = @(0x53, 0x1F) + 'V' = @(0x56, 0x2F) + 'Z' = @(0x5A, 0x2C) +} +$script:EngineMods = @{ + 'CTRL' = @(0x11, 0x1D) + 'SHIFT' = @(0x10, 0x2A) +} + +# A chord: Ctrl+S, Ctrl-Shift+Z. Posted as the four or six messages a real +# keyboard sends, because that is how the engine learns a modifier is held - it +# does not ask Windows, it tracks the key events (winWindow.cc, modifierKeys). +# +# The modifier is always released, whatever happens to the key in between. A +# stuck Ctrl would silently change the meaning of every press for the rest of the +# run. +function Send-EngineChord { + param([IntPtr]$Hwnd, [string]$Key, [switch]$Ctrl, [switch]$Shift) + + if (-not $script:EngineChars.ContainsKey($Key)) { throw "Send-EngineChord: unknown key '$Key'" } + + function script:PostKey([IntPtr]$h, [int]$vk, [int]$scan, [bool]$make) { + # repeat count 1 | scan << 16, no extended bit + $lp = 0x00000001 -bor ($scan -shl 16) + if (-not $make) { $lp = $lp -bor (1 -shl 30) -bor [int]::MinValue } + $msg = if ($make) { $script:WM_KEYDOWN } else { $script:WM_KEYUP } + [TorqueInput]::PostMessage($h, $msg, [IntPtr]$vk, [IntPtr]$lp) | Out-Null + } + + $held = @() + if ($Ctrl) { $held += 'CTRL' } + if ($Shift) { $held += 'SHIFT' } + + try { + foreach ($m in $held) { + script:PostKey $Hwnd $script:EngineMods[$m][0] $script:EngineMods[$m][1] $true + Start-Sleep -Milliseconds 60 + } + + script:PostKey $Hwnd $script:EngineChars[$Key][0] $script:EngineChars[$Key][1] $true + Start-Sleep -Milliseconds 120 + script:PostKey $Hwnd $script:EngineChars[$Key][0] $script:EngineChars[$Key][1] $false + } + finally { + [array]::Reverse($held) + foreach ($m in $held) { + Start-Sleep -Milliseconds 60 + script:PostKey $Hwnd $script:EngineMods[$m][0] $script:EngineMods[$m][1] $false + } + } +} diff --git a/tests/shots/menuSwap.cs b/tests/shots/menuSwap.cs new file mode 100644 index 000000000..36458c69a --- /dev/null +++ b/tests/shots/menuSwap.cs @@ -0,0 +1,106 @@ +// Visual harness for the menu bar swapping with the editor tab. Three shots of +// the same strip, at the top of each: +// +// 0 Console Torque2D | Theme, and nothing between them +// 1 Gui Editor Torque2D | File | Edit | Layout | Select | Theme +// 2 Asset Manager Torque2D | File | Edit | Theme +// +// What to look for is that the bar in 1 and 2 has two menus with the same names +// in the same place holding entirely different commands, and that Theme is still +// the last item in all three - the bar can only be added to at the end, so the +// fixed tail comes off and goes back on around every swap. +// +// The dropdowns are not here. A menu opens from a real mouse position and the bar +// refuses to hand a click to a child (findHitControl answers "me"), so a harness +// with no input script cannot open one; what is IN each menu is asserted instead +// by tests/smoke/menuSwap.cs. +// +// Run: tests/run.ps1 -Shots menuSwap ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "mssOpenProject"); + +function mssOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + createPath(testRoot("shots/")); + EditorPreferences.path = testRoot("shots/menuSwapShotPrefs.taml"); + + schedule(2500, 0, "mssOpenEditor"); +} + +function mssGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/menuSwap" @ %name @ ".png"), "PNG"); +} + +function mssOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(0); + schedule(1200, 0, "mssConsoleGrab"); +} + +function mssConsoleGrab() +{ + mssGrab(0); + + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "mssGuiGrab"); +} + +function mssGuiGrab() +{ + mssGrab(1); + + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "mssLoadAssets"); +} + +// Registered after the editor has opened: the project selector calls +// ModuleDatabase.clearDatabase(), which would take this module with it. The +// library is loaded so the Asset Manager shot is of a working editor rather than +// an empty one. +function mssLoadAssets() +{ + ModuleDatabase.scanModules(testRoot("toybox/ToyAssets")); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + AssetAdmin.libWindow.loadAssets(); + + AssetAdmin.Dictionary["ImageAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + schedule(1000, 0, "mssSelectImage"); +} + +function mssSelectImage() +{ + %tile = AssetAdmin.Dictionary["ImageAsset"].getButton("ToyAssets:Gems"); + if(isObject(%tile)) + { + %tile.onClick(); + } + + schedule(900, 0, "mssAssetGrab"); +} + +function mssAssetGrab() +{ + mssGrab(2); + + echo("SHOTS DONE"); + schedule(600, 0, "quit"); +} diff --git a/tests/smoke/assetDirtySave.cs b/tests/smoke/assetDirtySave.cs index 2a8284509..e237eca23 100644 --- a/tests/smoke/assetDirtySave.cs +++ b/tests/smoke/assetDirtySave.cs @@ -200,6 +200,18 @@ function adsStep3() adsCheck("Undo is greyed with nothing done yet", !$adsInspector.getUndoAssetEnabled()); adsCheck("Redo is greyed with nothing undone yet", !$adsInspector.getRedoAssetEnabled()); + // The File and Edit menus offer the same five commands as the bar beside them + // and answer to the same predicates. Held by handle, never looked up by text: + // Undo and Redo carry the step label in their text and it changes underneath. + $adsMenus = AssetAdmin.menus; + adsCheck("the Asset Manager has a menu set", isObject($adsMenus)); + adsCheck("menu Save is greyed too", !$adsMenus.save.Active); + adsCheck("menu Revert is greyed too", !$adsMenus.revert.Active); + adsCheck("menu Undo is greyed too", !$adsMenus.undo.Active); + adsCheck("menu Redo is greyed too", !$adsMenus.redo.Active); + adsCheck("Save All is greyed with nothing unsaved anywhere", !$adsMenus.saveAll.Active); + adsCheck("Duplicate is offered, because there is a document", $adsMenus.duplicate.Active); + adsCheck("the tile is not marked", !$adsTile.dirtyMark.isVisible()); // Everything below compares against this. @@ -228,6 +240,13 @@ function adsStep4() adsCheck("Save is offered now", $adsInspector.getSaveAssetEnabled()); adsCheck("Revert is offered now", $adsInspector.getRevertAssetEnabled()); + adsCheck("and the menu followed", $adsMenus.save.Active && $adsMenus.revert.Active); + adsCheck("Save All woke up with it", $adsMenus.saveAll.Active); + + // The step label is the one thing the menu says that the bar only whispers in + // a tooltip. + adsCheck("menu Undo names the step (" @ $adsMenus.undo.getText() @ ")", + $adsMenus.undo.Active && strstr($adsMenus.undo.getText(), "Undo") == 0); adsCheck("the tile is marked unsaved", $adsTile.dirtyMark.isVisible()); adsCheck("the mark is a control of its own, not part of the caption", $adsTile.caption.getText() $= $adsTile.assetName); @@ -310,6 +329,8 @@ function adsStep7() adsCheck("and the count went with it", AssetDatabase.getDirtyAssetCount() == 0); adsCheck("the tile mark cleared", !$adsTile.dirtyMark.isVisible()); adsCheck("Save is greyed again", !$adsInspector.getSaveAssetEnabled()); + adsCheck("and so is the menu's", !$adsMenus.save.Active); + adsCheck("with nothing unsaved anywhere, Save All went too", !$adsMenus.saveAll.Active); $adsFileAfterSave = adsReadAssetFile($adsAssetId); adsCheck("the file changed this time", $adsFileAfterSave !$= $adsFileAtStart); diff --git a/tests/smoke/menuSwap.cs b/tests/smoke/menuSwap.cs new file mode 100644 index 000000000..ff0fe6732 --- /dev/null +++ b/tests/smoke/menuSwap.cs @@ -0,0 +1,488 @@ +// The shared menu bar swapping whole menus as the editor tab changes. +// Run: tests/run.ps1 menuSwap ; grep MSW in tests/logs/. +// +// The bar always reads Torque2D | the open editor's menus | Theme. An editor +// with no menus of its own -- the Console, the Project Manager -- leaves the two +// permanent ones and nothing between them. +// +// What is worth asserting here rather than anywhere else: +// +// * Theme ends up LAST every time. The bar links the chain its keyboard walk +// follows by assuming each new menu was appended, so the fixed tail has to +// come off and go back on around every swap. If that stops happening, this is +// what notices. +// * The two File menus are told apart BY OBJECT. Both are called "File" and +// that is the whole point of swapping rather than sharing -- a check that +// looked them up by text could not tell which editor's bar it was reading. +// * Nothing is deleted. A parked menu is still an object, in its own editor's +// group rather than in the bar. +// +// The accelerator half is in menuSwap.input.ps1, because the only way to prove a +// shortcut reaches the right editor is to press it. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function mswCheck(%label, %cond) +{ + if(%cond) echo("MSW PASS: " @ %label); + else echo("MSW FAIL: " @ %label); +} + +// The bar's top-level menus as a space-separated list of their texts, which is +// the shape the order checks below read best in. +function mswBarText() +{ + %bar = EditorCore.menuBar; + %list = ""; + for(%i = 0; %i < %bar.getCount(); %i++) + { + %text = %bar.getObject(%i).getText(); + %list = (%list $= "") ? %text : (%list @ "|" @ %text); + } + + return %list; +} + +function mswLastMenu() +{ + %bar = EditorCore.menuBar; + return %bar.getObject(%bar.getCount() - 1); +} + +// Pages register in module load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. Selecting a tab is what calls close() on the old editor and open() +// on the new one, which is what moves the menus. +$mswConsole = 0; +$mswProjects = 1; +$mswAssets = 2; +$mswGui = 3; + +testExec("editor/main.cs"); +schedule(2500, 0, "mswStep1"); + +//----------------------------------------------------------------------------- +// The Console has no menus, so the bar is only its two permanent ends. This is +// also the state the editor starts in -- EditorCore::open selects page 0. +//----------------------------------------------------------------------------- + +function mswStep1() +{ + ProjectManager.setProjectFolder("menuSwapSmokeProject"); + + mswCheck("the menu bar exists", isObject(EditorCore.menuBar)); + mswCheck("EditorCore holds the Theme menu", isObject(EditorCore.themeMenu)); + mswCheck("and a group to park menus in", isObject(EditorCore.menuPark)); + + EditorCore.tabBook.selectPage($mswConsole); + schedule(400, 0, "mswStep2"); +} + +function mswStep2() +{ + mswCheck("the Console leaves only the permanent menus (" @ mswBarText() @ ")", + mswBarText() $= "Torque2D|Theme"); + mswCheck("no set is on the bar", !isObject(EditorCore.activeMenus)); + + $mswAcceleratorsBare = Canvas.getAcceleratorCount(); + mswCheck("the canvas is listening for some accelerators", $mswAcceleratorsBare > 0); + + EditorCore.tabBook.selectPage($mswGui); + schedule(600, 0, "mswStep3"); +} + +//----------------------------------------------------------------------------- +// The Gui Editor brings four. +//----------------------------------------------------------------------------- + +function mswStep3() +{ + mswCheck("the Gui Editor's menus arrived (" @ mswBarText() @ ")", + mswBarText() $= "Torque2D|File|Edit|Layout|Select|Theme"); + mswCheck("Theme is still last", mswLastMenu() == EditorCore.themeMenu); + mswCheck("and the set on the bar is the Gui Editor's", + EditorCore.activeMenus == GuiEditor.menus); + + $mswGuiFile = EditorCore.menuBar.getObject(1); + mswCheck("the File menu on the bar is the Gui Editor's own object", + $mswGuiFile == GuiEditor.menus.revert.getGroup()); + + // Four menus of items is a lot of accelerators; the table has to have grown. + $mswAcceleratorsGui = Canvas.getAcceleratorCount(); + mswCheck("the accelerator table grew with them (" @ $mswAcceleratorsBare @ " -> " + @ $mswAcceleratorsGui @ ")", $mswAcceleratorsGui > $mswAcceleratorsBare); + + EditorCore.tabBook.selectPage($mswAssets); + schedule(800, 0, "mswStep4"); +} + +//----------------------------------------------------------------------------- +// The Asset Manager brings two, called the same thing and meaning something +// else. +//----------------------------------------------------------------------------- + +function mswStep4() +{ + mswCheck("the Asset Manager's menus replaced them (" @ mswBarText() @ ")", + mswBarText() $= "Torque2D|File|Edit|Theme"); + mswCheck("Theme is still last", mswLastMenu() == EditorCore.themeMenu); + mswCheck("the set on the bar is the Asset Manager's", + EditorCore.activeMenus == AssetAdmin.menus); + + // By object, not by text. Both menus are called "File". + %file = EditorCore.menuBar.getObject(1); + mswCheck("the File menu is a different object from the Gui Editor's", + %file != $mswGuiFile); + mswCheck("and it is the one the Asset Manager built", + %file == AssetAdmin.menus.save.getGroup()); + + // Nothing was thrown away on the way out. + mswCheck("the Gui Editor's Revert still exists while parked", + isObject(GuiEditor.menus.revert)); + mswCheck("its File menu is parked in the Gui Editor's own group", + $mswGuiFile.getGroup() == GuiEditor.menus.parked); + mswCheck("which is not the bar", GuiEditor.menus.parked != EditorCore.menuBar); + + // A parked item is not accelerator-mediated, so its command still runs when + // something asks for it directly. Parking takes away the shortcut, not the + // menu item. + mswCheck("a parked item is still active and visible", + $mswGuiFile.isVisible()); + + schedule(300, 0, "mswStep5"); +} + +//----------------------------------------------------------------------------- +// Back and forth. The bar repairs its own bookkeeping on every add and remove, +// and that is the part most likely to rot. +//----------------------------------------------------------------------------- + +function mswStep5() +{ + $mswThemeOnAt = ""; + $mswCycle = 0; + mswCycle(); +} + +function mswCycle() +{ + $mswCycle++; + + if($mswCycle > 3) + { + schedule(400, 0, "mswStep6"); + return; + } + + EditorCore.tabBook.selectPage($mswGui); + schedule(400, 0, "mswCycleBack"); +} + +function mswCycleBack() +{ + mswCheck("cycle " @ $mswCycle @ ": the Gui Editor's four came back", + mswBarText() $= "Torque2D|File|Edit|Layout|Select|Theme"); + + EditorCore.tabBook.selectPage($mswAssets); + schedule(400, 0, "mswCycleCheck"); +} + +function mswCycleCheck() +{ + mswCheck("cycle " @ $mswCycle @ ": and the Asset Manager's two came back", + mswBarText() $= "Torque2D|File|Edit|Theme"); + mswCheck("cycle " @ $mswCycle @ ": Theme is still last", + mswLastMenu() == EditorCore.themeMenu); + + mswCycle(); +} + +//----------------------------------------------------------------------------- +// The Theme menu is a radio group, and it made all those trips off and back on +// the bar. Exactly one of its four is still on, and it is the same one. +//----------------------------------------------------------------------------- + +function mswStep6() +{ + %theme = EditorCore.themeMenu; + %on = 0; + %which = ""; + for(%i = 0; %i < %theme.getCount(); %i++) + { + %item = %theme.getObject(%i); + if(%item.IsOn) + { + %on++; + %which = %item.getText(); + } + } + + mswCheck("the Theme menu still has four themes", %theme.getCount() == 4); + mswCheck("exactly one is chosen (" @ %which @ ")", %on == 1); + mswCheck("and it is the one the editor started on", %which $= "Construction Vest"); + + EditorCore.tabBook.selectPage($mswProjects); + schedule(500, 0, "mswStep7"); +} + +//----------------------------------------------------------------------------- +// The Project Manager, like the Console, has none - and needed no code to say +// so. The editor it replaced took its own menus with it. +//----------------------------------------------------------------------------- + +function mswStep7() +{ + mswCheck("the Project Manager leaves the permanent menus (" @ mswBarText() @ ")", + mswBarText() $= "Torque2D|Theme"); + mswCheck("no set is on the bar", !isObject(EditorCore.activeMenus)); + mswCheck("and the accelerator table shrank back (" @ Canvas.getAcceleratorCount() @ ")", + Canvas.getAcceleratorCount() == $mswAcceleratorsBare); + + // Both editors' menus are alive, parked, and nowhere near the bar. + mswCheck("the Gui Editor's menus are all parked", + GuiEditor.menus.parked.getCount() == GuiEditor.menus.menuCount); + mswCheck("so are the Asset Manager's", + AssetAdmin.menus.parked.getCount() == AssetAdmin.menus.menuCount); + + schedule(300, 0, "mswStep8"); +} + +//----------------------------------------------------------------------------- +// A set refreshes itself as it goes back on the bar, so what it shows is the +// editor's state now rather than the state it was carrying when it came off. +//----------------------------------------------------------------------------- + +function mswStep8() +{ + EditorCore.tabBook.selectPage($mswGui); + schedule(500, 0, "mswStep9"); +} + +function mswStep9() +{ + mswCheck("an untitled document leaves Revert greyed", !GuiEditor.menus.revert.Active); + + // Move the document underneath the menu without telling it. Revert is offered + // on whether the document has a file to go back to, and this is the field it + // reads -- written directly, so nothing along the way calls refreshFileMenu. + $mswFilePathWas = GuiEditor.filePath; + GuiEditor.filePath = "notARealFile.gui.taml"; + mswCheck("and it stays greyed until something asks again", + !GuiEditor.menus.revert.Active); + + EditorCore.tabBook.selectPage($mswConsole); + schedule(400, 0, "mswStep10"); +} + +function mswStep10() +{ + EditorCore.tabBook.selectPage($mswGui); + schedule(500, 0, "mswStep11"); +} + +function mswStep11() +{ + // The contract that replaced forceRefreshMenu: going back on the bar re-asks + // the editor rather than showing what the menu was carrying when it left. + mswCheck("coming back re-asked the editor, and Revert is offered now", + GuiEditor.menus.revert.Active); + + GuiEditor.filePath = $mswFilePathWas; + GuiEditor.refreshFileMenu(); + mswCheck("putting the document back greys it again", !GuiEditor.menus.revert.Active); + + schedule(200, 0, "mswStep12"); +} + +//----------------------------------------------------------------------------- +// Groups: the items that grey together because they all answer one question. +// Driven directly rather than through a selection, so this says what it means -- +// that the group registry holds every item that was put in it. +//----------------------------------------------------------------------------- + +function mswStep12() +{ + %menus = GuiEditor.menus; + + mswCheck("the selection group holds thirteen items", + %menus.groupCount["selection"] == 13); + mswCheck("the align group holds five", %menus.groupCount["align"] == 5); + mswCheck("the space group holds two", %menus.groupCount["space"] == 2); + mswCheck("the restack group holds two", %menus.groupCount["restack"] == 2); + + // The first item of each group specifically. A group's count starts life + // unset, and reading it as an index rather than as a number wrote the first + // item to a slot nothing ever reads back -- Cut, Align Top, Space Vertically + // and Bring to Front would each have stopped greying, and nothing else would. + mswCheck("the first item of the selection group is registered (" @ %menus.groupItem["selection", 0].getText() @ ")", + isObject(%menus.groupItem["selection", 0])); + mswCheck("so is the first of align (" @ %menus.groupItem["align", 0].getText() @ ")", + isObject(%menus.groupItem["align", 0])); + mswCheck("so is the first of space (" @ %menus.groupItem["space", 0].getText() @ ")", + isObject(%menus.groupItem["space", 0])); + mswCheck("so is the first of restack (" @ %menus.groupItem["restack", 0].getText() @ ")", + isObject(%menus.groupItem["restack", 0])); + + %menus.refreshSelection(0); + mswCheck("nothing selected greys every item of every group", + mswGroupAllActive(%menus, "selection") == 0 && + mswGroupAllActive(%menus, "align") == 0 && + mswGroupAllActive(%menus, "space") == 0 && + mswGroupAllActive(%menus, "restack") == 0); + + %menus.refreshSelection(1); + mswCheck("one selected offers the whole selection group", + mswGroupAllActive(%menus, "selection") == 1); + mswCheck("and restack, which is a one-control command", + mswGroupAllActive(%menus, "restack") == 1); + mswCheck("but not align, which needs something to align to", + mswGroupAllActive(%menus, "align") == 0); + + %menus.refreshSelection(2); + mswCheck("two selected offers align", mswGroupAllActive(%menus, "align") == 1); + mswCheck("but not spacing, which needs a middle", mswGroupAllActive(%menus, "space") == 0); + mswCheck("and restack is gone again", mswGroupAllActive(%menus, "restack") == 0); + + %menus.refreshSelection(3); + mswCheck("three selected offers spacing", mswGroupAllActive(%menus, "space") == 1); + + // Leave the menus describing the editor rather than the test. + GuiEditor.brain.toggleMenuItems(); + + schedule(200, 0, "mswStep13"); +} + +//----------------------------------------------------------------------------- +// The accelerators, which are the reason for swapping rather than greying. +// +// The canvas keeps one flat list of shortcuts, built by walking whatever is on +// show, and it does not check whether an item is active before firing it - only +// whether the item itself is, never its menu. So greying File left Ctrl+N still +// running the Gui Editor's New Gui from inside the Asset Manager. Nothing about +// that is visible from script: the list has no read-back beyond its size, and +// the only way to prove a key reaches the right editor is to press it. +// +// Ctrl+N, because it is the Gui Editor's alone - the Asset Manager has no Ctrl+N +// at all, so "nothing happened" is the whole answer. What it does is observable +// without a dialog: New Gui throws the document's file path away. +// +// Both rounds matter. Without the second, "nothing happened" in the first would +// be equally well explained by the key never arriving. +//----------------------------------------------------------------------------- + +$mswSentinel = "notARealFile.gui.taml"; + +function mswTargetFile() +{ + return testRoot("shots/menuSwapTarget.txt"); +} + +function mswAskForKey(%round) +{ + %file = new FileObject(); + if(!%file.openForWrite(mswTargetFile())) + { + %file.delete(); + return false; + } + %file.writeLine(%round); + %file.close(); + %file.delete(); + + return true; +} + +function mswStep13() +{ + createPath(testRoot("shots/")); + + // Something for New Gui to visibly throw away. + GuiEditor.filePath = $mswSentinel; + + // Register the Gui Editor's shortcuts the way ordinary use does, so the swap + // that follows has something real to take away again. + // + // A dialog going up and coming down is what rebuilds the canvas's accelerator + // list, and opening any menu does exactly this - the bar pushes a full-canvas + // background at layer 99 while a dropdown is showing and pops it on close. So + // a user who opens one menu and then changes tab has left the outgoing + // editor's shortcuts in the list. Without them being taken out, Ctrl+N below + // would still reach the Gui Editor from inside the Asset Manager, which is the + // bug this whole arrangement exists to prevent. + %pushed = new GuiControl() { Position = "0 0"; Extent = "1 1"; }; + Canvas.pushDialog(%pushed); + Canvas.popDialog(%pushed); + %pushed.delete(); + + mswCheck("a dialog round trip left the Gui Editor's shortcuts registered (" + @ Canvas.getAcceleratorCount() @ ")", + Canvas.getAcceleratorCount() == $mswAcceleratorsGui); + + EditorCore.tabBook.selectPage($mswAssets); + schedule(600, 0, "mswStep14"); +} + +function mswStep14() +{ + mswCheck("the Asset Manager has the bar for the key test", + EditorCore.activeMenus == AssetAdmin.menus); + mswCheck("and the Gui Editor's shortcuts left with its menus (" + @ Canvas.getAcceleratorCount() @ ")", + Canvas.getAcceleratorCount() < $mswAcceleratorsGui); + + mswCheck("asked for round 1 (Ctrl+N with the Asset Manager open)", mswAskForKey(1)); + schedule(4000, 0, "mswStep15"); +} + +function mswStep15() +{ + // The Gui Editor's New Gui is parked. Its shortcut must have gone with it. + mswCheck("Ctrl+N did not reach the parked Gui Editor (" @ GuiEditor.filePath @ ")", + GuiEditor.filePath $= $mswSentinel); + + EditorCore.tabBook.selectPage($mswGui); + schedule(600, 0, "mswStep16"); +} + +function mswStep16() +{ + mswCheck("the Gui Editor has the bar again", + EditorCore.activeMenus == GuiEditor.menus); + mswCheck("and its document still has the sentinel to lose", + GuiEditor.filePath $= $mswSentinel); + + mswCheck("asked for round 2 (Ctrl+N with the Gui Editor open)", mswAskForKey(2)); + schedule(4000, 0, "mswStep17"); +} + +function mswStep17() +{ + // The control. If this fails the first round proved nothing - it would only + // have shown that no key arrived at all. + mswCheck("Ctrl+N reached the open Gui Editor and made a new document", + GuiEditor.filePath $= ""); + + echo("MSW DONE"); + schedule(300, 0, "quit"); +} + +// 1 if every item in the group is active, 0 if none is, -1 if they disagree -- +// which would mean the group had stopped being one thing. +function mswGroupAllActive(%menus, %group) +{ + %count = %menus.groupCount[%group]; + %active = 0; + for(%i = 0; %i < %count; %i++) + { + if(%menus.groupItem[%group, %i].Active) + { + %active++; + } + } + + if(%active == 0) return 0; + if(%active == %count) return 1; + return -1; +} diff --git a/tests/smoke/menuSwap.input.ps1 b/tests/smoke/menuSwap.input.ps1 new file mode 100644 index 000000000..c34317c57 --- /dev/null +++ b/tests/smoke/menuSwap.input.ps1 @@ -0,0 +1,48 @@ +# Input for menuSwap.cs. Posts Ctrl+N twice: once with the Asset Manager open, +# where it must reach nothing, and once with the Gui Editor open, where it must +# make a new document. +# +# The second is the control. A shortcut that reaches nothing and a shortcut that +# was never pressed look identical from inside the engine, so the run only means +# something if the same chord is seen to work when it should. +# +# The engine says when it is ready by writing the round number to a file, the +# same handshake menuBarClick uses - and for the same reason. Guessing at a delay +# here would post the key while a tab was still opening. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +$target = Join-Path $PSScriptRoot "..\..\shots\menuSwapTarget.txt" + +# Anything an earlier run left would be answered before this run has switched a +# single tab. +if (Test-Path $target) { Remove-Item $target -Force } + +function Wait-ForRound { + param([string]$Path, [int]$Seconds = 40) + + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path $Path) { + $line = (Get-Content $Path -TotalCount 1) + if ($line -and $line.Trim()) { return $line.Trim() } + } + Start-Sleep -Milliseconds 250 + } + return $null +} + +$rounds = @('with the Asset Manager open', 'with the Gui Editor open') +foreach ($label in $rounds) { + $round = Wait-ForRound -Path $target + if (-not $round) { + Write-Host " the engine never asked for the press $label" + return + } + + Remove-Item $target -Force + + Send-EngineChord -Hwnd $Hwnd -Key 'N' -Ctrl + Write-Host " pressed Ctrl+N $label" +} From 453500af1f7795e8a14ef1d7f4b85c5f7329635a Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 12 Aug 2026 22:36:58 -0400 Subject: [PATCH 21/26] What a font asset actually contains Font assets get an inspector pane of their own in place of the stock GuiInspector: three reflowing blocks, and a line saying what came out of the .fnt -- the native size, the glyph count, and the pages at the size they actually loaded at. That readout is the point. A FontAsset registers exactly one field of its own, so a pane that merely rearranged fields would not have earned its place. What it adds is the two ways a bitmap font goes wrong silently: a .fnt that did not parse, and a page image named inside the .fnt that is missing. Both were a line in the console log and nothing on screen. None of it could be put on screen until BitmapFont could be trusted: - the constructor had an empty body, so mSize, mLineHeight, mBaseline, mWidth, mHeight and mPages were whatever was on the heap. The last two are the divisors ProcessCharacter uses to turn glyph rects into texture coordinates, so this was worse than cosmetic. - buildFontData returned on a failed open without clearing anything, so an asset pointed at a missing file kept the glyphs of the font it used to have, and "did not load" was undetectable from outside. - nothing ever cleared mChar or mKerning -- only the page list and the textures -- so pointing an asset at a SECOND .fnt left the union of both fonts and a glyph count that only ever grew. Re-pointing that file is exactly what this pane makes easy for the first time. getRelativeFontFile mirrors ImageAsset's: the field holds the expanded absolute path, which is neither readable nor portable in a text box. The read-only queries behind the info line are new bindings. BitmapFont needed one accessor for the glyph count and deliberately none for mWidth/mHeight, which are what the .fnt declares rather than what loaded. Two things the shared pane grew here and the sound pane will use next: tipFor, because a field's registered doc string is empty on nearly everything, and fileFilters/fileTitle on EditorFieldRow, because a "file" row had been a bitmap everywhere and an image filter offers a font asset nothing it can choose. bitmapFontParseTests drives the parser directly. It touches only the console, the string table and the stream, so unlike anything reaching buildFontData -- which loads page textures through TextureManager -- it runs without a GL context. assetAnimationInspector was asserting the pane registry's exact contents, and was checking the read-only name row with row[...].isEnabled(). There is no such binding anywhere, so that logged "Unknown command" and had been passing vacuously; it asks the box now, as the image suite already did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- cmake/EngineSources.cmake | 1 + editor/AssetAdmin/AssetInspector.cs | 24 +- .../Inspector/AssetFontInspectorPane.cs | 312 +++++++++++++++++ .../Inspector/AssetInspectorPane.cs | 28 +- editor/AssetAdmin/Inspector/exec.cs | 1 + editor/EditorCore/EditorFieldRow.cs | 71 +++- engine/source/2d/assets/FontAsset.cc | 13 +- engine/source/2d/assets/FontAsset.h | 49 +++ .../2d/assets/FontAsset_ScriptBinding.h | 104 ++++++ engine/source/bitmapFont/BitmapFont.cc | 26 ++ engine/source/bitmapFont/BitmapFont.h | 16 + .../testing/tests/bitmapFontParseTests.cc | 187 +++++++++++ tests/smoke/assetAnimationInspector.cs | 10 +- tests/smoke/assetFontInspector.cs | 317 ++++++++++++++++++ tests/smoke/assetImageInspector.cs | 14 +- 15 files changed, 1153 insertions(+), 20 deletions(-) create mode 100644 editor/AssetAdmin/Inspector/AssetFontInspectorPane.cs create mode 100644 engine/source/testing/tests/bitmapFontParseTests.cc create mode 100644 tests/smoke/assetFontInspector.cs diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index 75be35543..83e51323a 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -343,6 +343,7 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/testing/unitTesting.cc # ---- testing/tests ---- ${TORQUE_SRC}/testing/tests/assetStateCopyTests.cc + ${TORQUE_SRC}/testing/tests/bitmapFontParseTests.cc ${TORQUE_SRC}/testing/tests/guiControlReparentTests.cc ${TORQUE_SRC}/testing/tests/guiCursorHotSpotTests.cc ${TORQUE_SRC}/testing/tests/guiFrameStripLayoutTests.cc diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index b105d72b1..cbe18564b 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -136,12 +136,14 @@ // on show. None of them is ever rebuilt or freed. %this.registerPane("Image", %this.createImagePane()); %this.registerPane("Animation", %this.createAnimationPane()); + %this.registerPane("Font", %this.createFontPane()); - // Named handles for the two the tests and the load methods reach for + // Named handles for the ones the tests and the load methods reach for // directly. The registry is the truth; these are just shorter. %this.imageScroller = %this.paneScroller["Image"]; %this.imagePane = %this.pane["Image"]; %this.animationPane = %this.pane["Animation"]; + %this.fontPane = %this.pane["Font"]; //Particle Graph Tool %this.scaleGraphPage = %this.createTabPage("Scale Graph", "AssetParticleGraphTool", ""); @@ -293,6 +295,23 @@ class = "AssetAnimationInspectorPane"; }; } +function AssetInspector::createFontPane(%this) +{ + %width = 686; + + return new GuiChainCtrl() + { + class = "AssetFontInspectorPane"; + superclass = "AssetInspectorPane"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %width SPC 320; + IsVertical = true; + ChildSpacing = 6; + paneWidth = %width; + }; +} + // Which inspector the Inspector page is showing: a registered pane by key, or "" // for the generic one. The panes standing down are hidden rather than emptied, // so nothing they hold is ever freed while the engine might be dispatching on it @@ -739,7 +758,8 @@ class = "DuplicateAssetDialog"; %this.titlebar.setText("Font Asset:" SPC %fontAsset.AssetName); %this.beginDocument(%fontAsset); - %this.inspectStock(%fontAsset); + %this.chooseInspector("Font"); + %this.fontPane.bind(%fontAsset, %assetID); } function AssetInspector::loadAudioAsset(%this, %audioAsset, %assetID) diff --git a/editor/AssetAdmin/Inspector/AssetFontInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetFontInspectorPane.cs new file mode 100644 index 000000000..b791b3bc1 --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetFontInspectorPane.cs @@ -0,0 +1,312 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The inspector for a font asset, in place of the generic one. +// +// A FontAsset is the thinnest asset in the library: it registers exactly one +// field of its own, the .fnt file, and everything else about it is inherited +// from AssetBase. So there is very little to arrange, and what makes this worth +// a pane at all is the line that is NOT a field -- what the .fnt turned out to +// contain, and whether it loaded. +// +// Three blocks, the shape AssetAnimationInspectorPane uses: +// +// Identity the name and the category +// Font the file, what came out of it, and when it lets go +// Description the prose the library is searched by +// +// The file row is in the Font block rather than in Identity, which is where the +// image and animation panes put theirs. The rule both of those follow is that +// the readout sits in the block holding the thing it answers, and here the +// readout answers the file: "128 px, 97 glyphs, 2 pages of 512 x 512" is a +// remark about the .fnt, not about the asset's name. +// +// A bitmap font is a .fnt descriptor plus one or more page images, and the pages +// are named INSIDE the .fnt rather than in the asset file. That is why a missing +// page is its own warning: nothing in the asset refers to it, so there is +// nowhere else the loss could show up. +// +// AssetName is shown but not editable, for the reason the other panes give: +// AssetBase::setAssetName does nothing once the asset manager owns the asset, so +// a box that accepted typing would silently do nothing. A real rename is +// AssetDatabase.renameDeclaredAsset. +// +// Absent, each for a checkable reason: +// AssetInternal, AssetPrivate they exist to keep an asset OUT of the editor +// asset id, asset file the module and the name are on show, and the +// file is where the manager put it +//----------------------------------------------------------------------------- + +$AssetFontInspectorPane::cellWidth = 300; +$AssetFontInspectorPane::cellCount = 3; +$AssetFontInspectorPane::descriptionHeight = 150; + +function AssetFontInspectorPane::onAdd(%this) +{ + // onAdd does not chain, so the shared setup runs from here. + %this.init(); + + // What the file row's Find button offers. A "file" row was a bitmap + // everywhere until this pane, and an image filter offers nothing a font + // asset can use. + %this.fileFilters = "Bitmap Font (*.fnt)|*.fnt|All Files (*.*)|*.*"; + %this.fileTitle = "Choose a Bitmap Font File"; +} + +//----------------------------------------------------------------------------- +// Construction. +//----------------------------------------------------------------------------- + +function AssetFontInspectorPane::buildPane(%this) +{ + %grid = %this.makeCellGrid(0, $AssetFontInspectorPane::cellWidth, + $AssetFontInspectorPane::cellCount); + %this.add(%grid); + %this.contentGrid = %grid; + + %this.buildIdentityCell(%grid); + %this.buildFontCell(%grid); + %this.buildDescriptionCell(%grid); + + %this.buildWarning(); + + %this.nameRow.setEnabled(false, "Renaming an asset changes its id and every file that refers to it, " @ + "so it is not something the inspector can do safely on its own."); +} + +// addFieldRow takes the label and the kind as arguments rather than asking the +// tables for them, so every call here would otherwise repeat the same lookups. +function AssetFontInspectorPane::addField(%this, %container, %field) +{ + return %this.addFieldRow(%container, %field, %this.labelFor(%field), + %this.kindFor(%field), %this.enumItemsFor(%field)); +} + +function AssetFontInspectorPane::buildIdentityCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.identityChain = %chain; + + %this.nameRow = %this.addField(%chain, "AssetName"); + %this.addField(%chain, "AssetCategory"); +} + +function AssetFontInspectorPane::buildFontCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.fontChain = %chain; + + %this.fileRow = %this.addField(%chain, "FontFile"); + + // Read-only, because none of it is a value the asset holds: it is what the + // last parse of the .fnt produced. Wrapped and extending, or a sentence in a + // block a third of the pane wide is simply not drawn. + %this.infoLabel = %this.makeInfoLabel(%chain, "labelProfile"); + %this.infoLabel.textWrap = true; + %this.infoLabel.textExtend = true; + %this.infoLabel.vAlign = "top"; + + %this.addField(%chain, "AssetAutoUnload"); +} + +function AssetFontInspectorPane::buildDescriptionCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.descriptionChain = %chain; + + %this.addField(%chain, "AssetDescription"); +} + +// Below the grid rather than in a block. A warning is a sentence, and a sentence +// read across the whole pane is one or two lines where the same sentence in a +// third of it is five. +function AssetFontInspectorPane::buildWarning(%this) +{ + %this.warningLabel = %this.makeInfoLabel(%this, "overrideLabelProfile"); + %this.warningLabel.textWrap = true; + %this.warningLabel.textExtend = true; + %this.warningLabel.vAlign = "top"; + %this.warningLabel.setVisible(false); +} + +//----------------------------------------------------------------------------- +// The field tables. +//----------------------------------------------------------------------------- + +function AssetFontInspectorPane::labelFor(%this, %field) +{ + switch$(%field) + { + case "AssetName": return "Asset Name"; + case "AssetCategory": return "Category"; + case "AssetDescription": return "Description"; + case "AssetAutoUnload": return "Auto Unload"; + case "FontFile": return "Font File"; + } + + return %field; +} + +function AssetFontInspectorPane::kindFor(%this, %field) +{ + switch$(%field) + { + case "FontFile": return "file"; + case "AssetAutoUnload": return "bool"; + case "AssetDescription": return "multiline"; + } + + return "text"; +} + +function AssetFontInspectorPane::tipFor(%this, %field) +{ + switch$(%field) + { + case "FontFile": return "The .fnt descriptor written by AngelCode BMFont, in its text format. " @ + "The page images are named inside it, and are loaded from beside it."; + case "AssetAutoUnload": return "Let go of the font's textures when nothing is using it any more."; + } + + return ""; +} + +function AssetFontInspectorPane::editorHeightFor(%this, %field) +{ + if(%field $= "AssetDescription") + { + return $AssetFontInspectorPane::descriptionHeight; + } + + return 0; +} + +// The stored path is absolute -- setFontFile expands whatever it is given against +// the asset's own folder -- and an absolute path is neither readable nor +// portable. Show the collapsed form, which is what the file on disk says. +function AssetFontInspectorPane::readField(%this, %field) +{ + if(%field $= "FontFile") + { + return %this.target.getRelativeFontFile(); + } + + return %this.target.getFieldValue(%field); +} + +//----------------------------------------------------------------------------- +// Loading. Everything the row loop does not reach. +//----------------------------------------------------------------------------- + +function AssetFontInspectorPane::refreshExtras(%this) +{ + %this.infoLabel.setText(%this.describeFont(%this.target)); + %this.showWarning(%this.warningFor(%this.target)); +} + +// What the .fnt held. Everything here is asked of the asset and none of it is +// stored on it, which is why it is a line of text and not a set of rows. +// +// The page size is the size of the texture that actually loaded, not the scaleW +// and scaleH the .fnt declares -- the same choice the image pane makes when it +// reports the picture it got rather than the one it asked for. +function AssetFontInspectorPane::describeFont(%this, %asset) +{ + %glyphs = %asset.getGlyphCount(); + + if(%glyphs == 0) + { + return "No font loaded."; + } + + %line = %asset.getFontSize() SPC "px"; + + // Only when it says something the size did not. These are equal in every font + // shipped with the engine, and a line that repeats itself reads as a mistake. + if(%asset.getLineHeight() != %asset.getFontSize()) + { + %line = %line @ "," SPC %asset.getLineHeight() SPC "line height"; + } + + %line = %line @ "," SPC %glyphs SPC ((%glyphs == 1) ? "glyph" : "glyphs"); + + %pages = %asset.getPageCount(); + %line = %line @ "," SPC %pages SPC ((%pages == 1) ? "page" : "pages"); + + // Nothing to say about the size of a page that did not load; the warning + // below covers that case instead. + %pageWidth = %asset.getPageWidth(0); + if(%pageWidth > 0) + { + %line = %line SPC "of" SPC %pageWidth SPC "x" SPC %asset.getPageHeight(0); + } + + return %line @ "."; +} + +// In the order they matter. Only the first is shown, because the first is the +// one that has to be fixed before any of the others can be judged. +// +// Both of these are currently a line in the console log and nothing on screen. +function AssetFontInspectorPane::warningFor(%this, %asset) +{ + if(%asset.getGlyphCount() == 0) + { + return "This font did not load. Check that the file is where the path says, and that it is the " @ + "TEXT format AngelCode BMFont writes -- the binary and XML variants are not read."; + } + + %declared = %asset.getPageCount(); + %loaded = %asset.getLoadedPageCount(); + + if(%loaded < %declared) + { + return (%declared - %loaded) SPC "of this font's" SPC %declared SPC "page images did not load, so " @ + "some characters will be missing. The pages are named inside the .fnt file rather than here, " @ + "and are loaded from the folder beside it."; + } + + return ""; +} + +// forceLayout only when the visibility actually changed: a chain skips hidden +// children when it lays out, and nothing re-lays it out on setVisible. +function AssetFontInspectorPane::showWarning(%this, %text) +{ + %wanted = (%text !$= ""); + %changed = (%wanted != %this.warningLabel.isVisible()); + + %this.warningLabel.setText(%text); + %this.warningLabel.setVisible(%wanted); + + if(%changed) + { + %this.forceLayout(); + } +} + +// No afterCommit. The preview is a TextSprite wearing this font and it does have +// to be rebuilt when the file changes -- but that already happens: the commit +// ends in refreshAsset, and AssetAdmin::refreshPreview re-clicks the selected +// tile, which is what built the TextSprite in the first place. Doing it here as +// well would build it twice. diff --git a/editor/AssetAdmin/Inspector/AssetInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs index 73c5c4332..498b56037 100644 --- a/editor/AssetAdmin/Inspector/AssetInspectorPane.cs +++ b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs @@ -8,9 +8,9 @@ // into flat alphabetical groups, which for an ImageAsset means the eight cell // values arrive split into "X Values" and "Y Values" -- so a cell's width never // sits beside its height -- with nothing pinned open and nothing said about the -// image itself. Only image assets have a pane so far; the rest still use the -// inspector, which is why the pane's knowledge of a particular asset lives in -// the subclass rather than here. +// image itself. Image, animation, font and audio assets have panes; particle and +// spine assets still use the inspector, which is why the pane's knowledge of a +// particular asset lives in the subclass rather than here. // // Layout is the arrangement GuiEditorInspectorPane and GuiProfileEditorProfileForm // both use, and for the same reason: a vertical chain of blocks, each laying its @@ -29,8 +29,9 @@ // build() call once after adding the pane to its scroller; it calls // buildPane() which the subclass defines -- its whole layout // labelFor() \ the subclass's field tables. The defaults are the field -// kindFor() } name and a text box, which is what an unlisted field -// enumItemsFor() / degrades to rather than vanishing. +// kindFor() } name, a text box and no tooltip, which is what an +// enumItemsFor() } unlisted field degrades to rather than vanishing. +// tipFor() / // refreshExtras() anything the row loop does not reach // afterCommit() anything the rest of the editor has to be told // @@ -226,6 +227,11 @@ class = "EditorFieldRow"; // The row's reset button means "back to the theme's stamped value", which has // no analogue for an asset: there is no layer under it to fall back to. %row.resetButton.setVisible(false); + + // After build(), because the widgets the tooltip goes on do not exist until + // then. Empty for most fields, which is the same as not having one. + %row.setTooltip(%this.tipFor(%field)); + return %row; } @@ -282,6 +288,18 @@ class = "EditorFieldRow"; return ""; } +// What a field means, for the ones whose name does not say it. +// +// The engine would be the obvious place to get this from and is not one: a +// field's registered doc string is empty on all six of AudioAsset's and on most +// of everything else, so the stock inspector has nothing to show either. Left +// empty here, which is the same as having no tooltip; a pane answers for the +// handful of its own fields that need explaining. +function AssetInspectorPane::tipFor(%this, %field) +{ + return ""; +} + // How deep a paragraph box should be. Zero takes the row's own three lines, // which is right for a field sharing a block with others and wrong for one that // has a whole cell of the grid to fill. diff --git a/editor/AssetAdmin/Inspector/exec.cs b/editor/AssetAdmin/Inspector/exec.cs index 3b8bc15cb..1315ad52b 100644 --- a/editor/AssetAdmin/Inspector/exec.cs +++ b/editor/AssetAdmin/Inspector/exec.cs @@ -2,3 +2,4 @@ exec("./AssetImageCellGrid.cs"); exec("./AssetAnimationInspectorPane.cs"); exec("./AssetImageInspectorPane.cs"); +exec("./AssetFontInspectorPane.cs"); diff --git a/editor/EditorCore/EditorFieldRow.cs b/editor/EditorCore/EditorFieldRow.cs index 3e99887b6..2febdfb87 100644 --- a/editor/EditorCore/EditorFieldRow.cs +++ b/editor/EditorCore/EditorFieldRow.cs @@ -28,7 +28,8 @@ // should not fill its cell) and editorHeight (how deep a "multiline" box is). // Call build() once after adding the row to its container -- the container // decides the cell width, so build() has to run after the add. It records the -// laid-out height in .rowHeight. +// laid-out height in .rowHeight. setTooltip() after that gives the row a standing +// explanation of its field, which survives being greyed and re-enabled. // // Two things a row takes from its OWNER rather than from itself, because they // are decisions the whole pane makes once: @@ -41,6 +42,9 @@ // The default is the game root, which is what a bitmap path // means; an asset's loose file is relative to the asset's own // folder instead. +// fileFilters what that Find button's dialog offers, and +// fileTitle what it calls itself. Both default to bitmaps, which is what a +// "file" row was everywhere until fonts and sounds got panes. // // Kinds: text, number, decimal, point, pointf, bool, color, enum, dropdown, // file, asset, multiline. @@ -460,6 +464,38 @@ class = isObject(%this.owner) ? %this.owner.swatchClass : ""; // Filtering, enabling and the override marker. //----------------------------------------------------------------------------- +// What the row says about itself when it is working normally, as opposed to the +// reason setEnabled gives for it not being. +// +// The two share one tooltip, so they have to be told about each other: a row that +// is greyed and then re-enabled used to come back with no tooltip at all, because +// setEnabled blanked it. The explanation is kept here so enabling can put it back. +// +// A field's own doc string would be the obvious source and is not one -- the +// engine's is empty on every AudioAsset field and on most others, so this is set +// by the pane that knows what the field means (AssetInspectorPane::tipFor). +function EditorFieldRow::setTooltip(%this, %tip) +{ + %this.baseTip = %tip; + %this.applyTooltip(%tip); +} + +// One tooltip on every widget the row owns. The editor alone is not enough on a +// "file" or "asset" row, where the Find button is half the row's hit area, nor on +// a point row, where the second box is half of it. +function EditorFieldRow::applyTooltip(%this, %tip) +{ + %this.editor.Tooltip = %tip; + if(isObject(%this.editorY)) + { + %this.editorY.Tooltip = %tip; + } + if(isObject(%this.findButton)) + { + %this.findButton.Tooltip = %tip; + } +} + // A field the current control never reads stays visible but inert, so its value // is never lost -- the pane's Show All puts it back in reach. function EditorFieldRow::setEnabled(%this, %enabled, %reason) @@ -473,7 +509,9 @@ class = isObject(%this.owner) ? %this.owner.swatchClass : ""; { %this.findButton.setActive(%enabled); } - %this.editor.Tooltip = %enabled ? "" : %reason; + + // Enabling restores what the row normally says rather than blanking it. + %this.applyTooltip(%enabled ? %this.baseTip : %reason); } // One field wears a different name depending on the category (cursorColor is a @@ -531,6 +569,31 @@ class = isObject(%this.owner) ? %this.owner.swatchClass : ""; return getMainDotCsDir(); } +// What the Find dialog offers, and what it calls itself. +// +// Bitmaps by default: a "file" row was a picture everywhere until the Asset +// Manager grew panes for fonts and sounds, whose loose file is a .fnt or a .wav +// and for which an image filter offers nothing that can be chosen. Taken from +// the owner rather than the row for the same reason findBase is -- it is one +// decision a pane makes about the one loose file it edits. +function EditorFieldRow::fileFilters(%this) +{ + if(isObject(%this.owner) && %this.owner.fileFilters !$= "") + { + return %this.owner.fileFilters; + } + return "Image Files (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp|All Files (*.*)|*.*"; +} + +function EditorFieldRow::fileTitle(%this) +{ + if(isObject(%this.owner) && %this.owner.fileTitle !$= "") + { + return %this.owner.fileTitle; + } + return "Choose an Image"; +} + // The Find button on a "file" row. Picks a file and writes its path back into // the box relative to whatever pathBase() says it should be measured from. function EditorFieldRow::onFindClicked(%this) @@ -545,12 +608,12 @@ class = isObject(%this.owner) ? %this.owner.swatchClass : ""; %dialog = new OpenFileDialog() { - Filters = "Image Files (*.png;*.jpg;*.jpeg;*.bmp)|*.png;*.jpg;*.jpeg;*.bmp|All Files (*.*)|*.*"; + Filters = %this.fileFilters(); ChangePath = false; MultipleFiles = false; DefaultFile = ""; defaultPath = %start; - title = "Choose an Image"; + title = %this.fileTitle(); }; %result = %dialog.execute(); %fileName = %dialog.fileName; diff --git a/engine/source/2d/assets/FontAsset.cc b/engine/source/2d/assets/FontAsset.cc index 08bedafef..a848bceb1 100644 --- a/engine/source/2d/assets/FontAsset.cc +++ b/engine/source/2d/assets/FontAsset.cc @@ -192,6 +192,17 @@ void FontAsset::onAssetRefresh( void ) void FontAsset::buildFontData( void ) { + // Before the open, not after it. + // + // This runs again every time the font file changes, and what it used to clear + // was the page list and the textures -- never the glyphs or the kerning, which + // are maps that parseFont adds to. So pointing an asset at a second .fnt left + // the union of both fonts and a glyph count that only ever grew. And on a file + // that would not open it cleared nothing at all and returned, so a broken path + // kept the font it used to have and there was no way to tell from the outside + // that anything was wrong. + mBitmapFont.clear(); + FileStream fStream; if (!fStream.open(mFontFile, FileStream::Read)) @@ -200,13 +211,11 @@ void FontAsset::buildFontData( void ) return; } - mBitmapFont.mPageName.clear(); mBitmapFont.parseFont(fStream); fStream.close(); //load the images - mBitmapFont.mTexture.clear(); for (auto iter = mBitmapFont.mPageName.begin(); iter != mBitmapFont.mPageName.end(); iter++) { mBitmapFont.mTexture.push_back(mBitmapFont.LoadTexture(expandAssetFilePath(*iter))); diff --git a/engine/source/2d/assets/FontAsset.h b/engine/source/2d/assets/FontAsset.h index e029c8cfa..10deb51a5 100644 --- a/engine/source/2d/assets/FontAsset.h +++ b/engine/source/2d/assets/FontAsset.h @@ -65,8 +65,57 @@ class FontAsset : public AssetBase void setFontFile( const char* pFontFile ); inline StringTableEntry getFontFile( void ) const { return mFontFile; } + /// The font file as it is stored on disk -- relative to the folder the asset + /// itself lives in. getFontFile answers the expanded absolute path, which is + /// what the engine needs and is neither readable nor portable anywhere else. + inline StringTableEntry getRelativeFontFile( void ) const { return collapseAssetFilePath(mFontFile); } + inline TextureHandle& getImageTexture(U16 pageID) { return mBitmapFont.mTexture[pageID]; } + /// What the .fnt turned out to hold. + /// + /// None of this is a value the asset stores -- it is the result of the last + /// parse, so it can only be asked, never told. An editor needs it to say + /// whether a font loaded and what came out of it; before this there was no way + /// to find out from script at all. + inline U32 getGlyphCount( void ) const { return mBitmapFont.getCharacterCount(); } + inline U32 getPageCount( void ) const { return (U32)mBitmapFont.mPageName.size(); } + inline U32 getFontSize( void ) const { return (U32)mBitmapFont.mSize; } + inline U32 getLineHeight( void ) const { return (U32)mBitmapFont.mLineHeight; } + inline U32 getBaseline( void ) const { return (U32)mBitmapFont.mBaseline; } + + /// How many of the declared pages actually have a texture. A page is named + /// inside the .fnt rather than in the asset file, so a missing page image is + /// invisible from the asset and this is the only way to notice it. + inline U32 getLoadedPageCount( void ) const + { + U32 loaded = 0; + for ( U32 index = 0; index < (U32)mBitmapFont.mTexture.size(); ++index ) + { + if ( mBitmapFont.mTexture[index].NotNull() ) + loaded++; + } + return loaded; + } + + inline StringTableEntry getPageFile( const U32 pageIndex ) const + { + return ( pageIndex < (U32)mBitmapFont.mPageName.size() ) + ? mBitmapFont.mPageName[pageIndex] : StringTable->EmptyString; + } + + inline U32 getPageWidth( const U32 pageIndex ) const + { + return ( pageIndex < (U32)mBitmapFont.mTexture.size() && mBitmapFont.mTexture[pageIndex].NotNull() ) + ? mBitmapFont.mTexture[pageIndex].getWidth() : 0; + } + + inline U32 getPageHeight( const U32 pageIndex ) const + { + return ( pageIndex < (U32)mBitmapFont.mTexture.size() && mBitmapFont.mTexture[pageIndex].NotNull() ) + ? mBitmapFont.mTexture[pageIndex].getHeight() : 0; + } + /// Declare Console Object. DECLARE_CONOBJECT(FontAsset); diff --git a/engine/source/2d/assets/FontAsset_ScriptBinding.h b/engine/source/2d/assets/FontAsset_ScriptBinding.h index 3a1742c7f..0735c7949 100644 --- a/engine/source/2d/assets/FontAsset_ScriptBinding.h +++ b/engine/source/2d/assets/FontAsset_ScriptBinding.h @@ -42,6 +42,110 @@ ConsoleMethodWithDocs(FontAsset, getFontFile, ConsoleString, 2, 2, ()) return object->getFontFile(); } +//----------------------------------------------------------------------------- + +/*! Gets the Font file as a path relative to the asset file. + @return Returns the Font file relative to the asset file. +*/ +ConsoleMethodWithDocs(FontAsset, getRelativeFontFile, ConsoleString, 2, 2, ()) +{ + return object->getRelativeFontFile(); +} + +//----------------------------------------------------------------------------- + +/*! Gets the number of glyphs the font holds. + @return Returns the glyph count, or zero if the font did not load. +*/ +ConsoleMethodWithDocs(FontAsset, getGlyphCount, ConsoleInt, 2, 2, ()) +{ + return (S32)object->getGlyphCount(); +} + +//----------------------------------------------------------------------------- + +/*! Gets the number of texture pages the font declares. + @return Returns the page count. +*/ +ConsoleMethodWithDocs(FontAsset, getPageCount, ConsoleInt, 2, 2, ()) +{ + return (S32)object->getPageCount(); +} + +//----------------------------------------------------------------------------- + +/*! Gets the number of declared pages whose image actually loaded. Fewer than + getPageCount() means a page image named inside the .fnt file is missing. + @return Returns the loaded page count. +*/ +ConsoleMethodWithDocs(FontAsset, getLoadedPageCount, ConsoleInt, 2, 2, ()) +{ + return (S32)object->getLoadedPageCount(); +} + +//----------------------------------------------------------------------------- + +/*! Gets the size the font was generated at, in pixels. + @return Returns the native font size. +*/ +ConsoleMethodWithDocs(FontAsset, getFontSize, ConsoleInt, 2, 2, ()) +{ + return (S32)object->getFontSize(); +} + +//----------------------------------------------------------------------------- + +/*! Gets the distance between baselines, in pixels. + @return Returns the line height. +*/ +ConsoleMethodWithDocs(FontAsset, getLineHeight, ConsoleInt, 2, 2, ()) +{ + return (S32)object->getLineHeight(); +} + +//----------------------------------------------------------------------------- + +/*! Gets the distance from the top of a line to the baseline, in pixels. + @return Returns the baseline. +*/ +ConsoleMethodWithDocs(FontAsset, getBaseline, ConsoleInt, 2, 2, ()) +{ + return (S32)object->getBaseline(); +} + +//----------------------------------------------------------------------------- + +/*! Gets the image file for a page, as named inside the .fnt file. + @param pageIndex The zero-based page index. + @return Returns the page image file, or an empty string if there is no such page. +*/ +ConsoleMethodWithDocs(FontAsset, getPageFile, ConsoleString, 3, 3, (int pageIndex)) +{ + return object->getPageFile( (U32)dAtoi(argv[2]) ); +} + +//----------------------------------------------------------------------------- + +/*! Gets the width of a page's loaded texture, in pixels. + @param pageIndex The zero-based page index. + @return Returns the page width, or zero if the page did not load. +*/ +ConsoleMethodWithDocs(FontAsset, getPageWidth, ConsoleInt, 3, 3, (int pageIndex)) +{ + return (S32)object->getPageWidth( (U32)dAtoi(argv[2]) ); +} + +//----------------------------------------------------------------------------- + +/*! Gets the height of a page's loaded texture, in pixels. + @param pageIndex The zero-based page index. + @return Returns the page height, or zero if the page did not load. +*/ +ConsoleMethodWithDocs(FontAsset, getPageHeight, ConsoleInt, 3, 3, (int pageIndex)) +{ + return (S32)object->getPageHeight( (U32)dAtoi(argv[2]) ); +} + //------------------------------------------------------------------------------ ConsoleMethodGroupEndWithDocs(FontAsset) diff --git a/engine/source/bitmapFont/BitmapFont.cc b/engine/source/bitmapFont/BitmapFont.cc index a2173a0fd..92c7b1147 100644 --- a/engine/source/bitmapFont/BitmapFont.cc +++ b/engine/source/bitmapFont/BitmapFont.cc @@ -32,9 +32,35 @@ namespace font { + // Every one of these used to be left at whatever was on the heap. It did not + // show while the only reader was TextSprite -- a font that failed to load drew + // nothing either way -- but an editor puts the numbers on screen, and mWidth and + // mHeight are worse than cosmetic: ProcessCharacter divides the glyph rects by + // them to make texture coordinates, so a .fnt with char lines but no common line + // produced garbage UVs rather than none. BitmapFont::BitmapFont() + : mWidth(0), + mHeight(0), + mPages(0), + mLineHeight(0), + mBaseline(0), + mSize(0) { + } + void BitmapFont::clear() + { + mChar.clear(); + mKerning.clear(); + mPageName.clear(); + mTexture.clear(); + + mWidth = 0; + mHeight = 0; + mPages = 0; + mLineHeight = 0; + mBaseline = 0; + mSize = 0; } bool BitmapFont::parseFont(Stream& io_rStream) diff --git a/engine/source/bitmapFont/BitmapFont.h b/engine/source/bitmapFont/BitmapFont.h index 4d96e526c..395b6ad63 100644 --- a/engine/source/bitmapFont/BitmapFont.h +++ b/engine/source/bitmapFont/BitmapFont.h @@ -61,9 +61,25 @@ namespace font std::vector mTexture; BitmapFont(); + + /// Forget everything read from a .fnt file. + /// + /// parseFont ADDS to what is already here -- it has no clear of its own, and + /// mChar and mKerning are maps, so parsing a second font over a first leaves + /// the union of the two and a glyph count that only ever grows. Anything that + /// re-reads a font has to come through here first. + void clear(); + bool parseFont(Stream& io_rStream); TextureHandle LoadTexture(StringTableEntry fileName); const BitmapFontCharacter& getCharacter(const U16 charID) { return mChar[charID]; } + + /// How many glyphs the font actually holds. + /// + /// Const where getCharacter above cannot be: mChar[charID] on a std::map + /// inserts a default-constructed character for a glyph the font does not + /// have, and size() does not. + inline U32 getCharacterCount(void) const { return (U32)mChar.size(); } inline const F32 getSizeRatio(const F32 size) { return size / mLineHeight; } inline const S16 getKerning(U16 first, U16 second) { return (S16)mKerning[make_pair(first, second)]; } diff --git a/engine/source/testing/tests/bitmapFontParseTests.cc b/engine/source/testing/tests/bitmapFontParseTests.cc new file mode 100644 index 000000000..1d563e973 --- /dev/null +++ b/engine/source/testing/tests/bitmapFontParseTests.cc @@ -0,0 +1,187 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +#ifndef _PLATFORM_H_ +#include "platform/platform.h" +#endif + +#ifndef _FILESTREAM_H_ +#include "io/fileStream.h" +#endif + +#ifndef _BITMAP_FONT_H_ +#include "bitmapFont/BitmapFont.h" +#endif + +//----------------------------------------------------------------------------- +// What a .fnt file turns into. +// +// This is the half of the font path a unit test can reach. parseFont touches +// only the console, the string table and the stream, so it runs fine here -- +// but FontAsset::buildFontData goes on to call LoadTexture for each page, and +// that is TextureManager, which needs a GL context the test harness does not +// have. So everything below drives BitmapFont directly and nothing here ever +// makes a FontAsset. (FontAsset's FIELDS are covered by assetStateCopyTests, +// which works because an unowned asset never reaches initializeAsset and so +// never builds its font data either.) +// +// The fixtures are the three fonts shipped in ToyAssets. Their numbers are read +// out of the files rather than computed, so a test failing here means either +// the parser changed or somebody re-generated the art. +//----------------------------------------------------------------------------- + +#define ARIAL_FNT "toybox/ToyAssets/1/assets/fonts/Arial.fnt" +#define ORATOR_FNT "toybox/ToyAssets/1/assets/fonts/Orator Bold.fnt" + +//----------------------------------------------------------------------------- + +static bool parseFontFile( font::BitmapFont& bitmapFont, const char* pPath ) +{ + FileStream stream; + + if ( !stream.open( pPath, FileStream::Read ) ) + return false; + + bitmapFont.parseFont( stream ); + stream.close(); + + return true; +} + +//----------------------------------------------------------------------------- +// A font nobody has read a file into yet answers zero for everything. +// +// It used to answer whatever was on the heap: the constructor had an empty body +// and not one of the six scalars was initialized. Nothing noticed while the only +// reader was TextSprite, which draws nothing either way -- but mWidth and mHeight +// are the divisors ProcessCharacter uses to turn glyph rects into texture +// coordinates, and an editor puts the rest on screen. +//----------------------------------------------------------------------------- +TEST( BitmapFontParseTests, DefaultConstructedFontIsEmpty ) +{ + font::BitmapFont bitmapFont; + + ASSERT_EQ( bitmapFont.mSize, 0 ) << "A font with no file read into it must report no size."; + ASSERT_EQ( bitmapFont.mLineHeight, 0 ) << "A font with no file read into it must report no line height."; + ASSERT_EQ( bitmapFont.mBaseline, 0 ) << "A font with no file read into it must report no baseline."; + ASSERT_EQ( bitmapFont.getCharacterCount(), 0U ) << "A font with no file read into it must hold no glyphs."; + ASSERT_EQ( bitmapFont.mPageName.size(), 0U ) << "A font with no file read into it must declare no pages."; + ASSERT_EQ( bitmapFont.mTexture.size(), 0U ) << "A font with no file read into it must hold no textures."; +} + +//----------------------------------------------------------------------------- +// The whole of the header, against a file whose contents are known. +//----------------------------------------------------------------------------- +TEST( BitmapFontParseTests, ParsesTheArialFixture ) +{ + font::BitmapFont bitmapFont; + + ASSERT_TRUE( parseFontFile( bitmapFont, ARIAL_FNT ) ) + << "Could not open " ARIAL_FNT " -- unit tests run from the repository root."; + + // info face="Arial" size=128 + ASSERT_EQ( bitmapFont.mSize, 128 ) << "Wrong native size."; + + // common lineHeight=128 base=103 scaleW=512 scaleH=512 pages=2 + ASSERT_EQ( bitmapFont.mLineHeight, 128 ) << "Wrong line height."; + ASSERT_EQ( bitmapFont.mBaseline, 103 ) << "Wrong baseline."; + + // Two page lines, and the names are read out from between the quotes. + ASSERT_EQ( bitmapFont.mPageName.size(), 2U ) << "Wrong page count."; + ASSERT_STREQ( bitmapFont.mPageName[0], "Arial_0.png" ) << "Wrong first page file."; + ASSERT_STREQ( bitmapFont.mPageName[1], "Arial_1.png" ) << "Wrong second page file."; + + // 97 char lines, all with distinct ids. + ASSERT_EQ( bitmapFont.getCharacterCount(), 97U ) << "Wrong glyph count."; + + // A glyph that is definitely in the file, to prove the char lines were read + // and not merely counted. 'A' is 65. + ASSERT_GT( bitmapFont.getCharacter( 65 ).mXAdvance, 0.0f ) << "'A' has no advance."; +} + +//----------------------------------------------------------------------------- +// clear() puts a font back to how it started. +//----------------------------------------------------------------------------- +TEST( BitmapFontParseTests, ClearForgetsTheFile ) +{ + font::BitmapFont bitmapFont; + + ASSERT_TRUE( parseFontFile( bitmapFont, ARIAL_FNT ) ) << "Could not open " ARIAL_FNT "."; + ASSERT_EQ( bitmapFont.getCharacterCount(), 97U ) << "The fixture did not parse."; + + bitmapFont.clear(); + + ASSERT_EQ( bitmapFont.mSize, 0 ) << "Size survived a clear."; + ASSERT_EQ( bitmapFont.mLineHeight, 0 ) << "Line height survived a clear."; + ASSERT_EQ( bitmapFont.mBaseline, 0 ) << "Baseline survived a clear."; + ASSERT_EQ( bitmapFont.getCharacterCount(), 0U ) << "Glyphs survived a clear."; + ASSERT_EQ( bitmapFont.mPageName.size(), 0U ) << "Pages survived a clear."; +} + +//----------------------------------------------------------------------------- +// Reading a second font over a first replaces it rather than joining it. +// +// This is the contract FontAsset::buildFontData rests on. parseFont has no clear +// of its own and mChar is a map, so without the clear the count becomes the union +// of the two fonts and only ever grows -- and re-pointing FontFile is exactly +// what an inspector makes easy. Arial and Orator Bold overlap almost completely +// (97 glyphs each) while disagreeing about every scalar, so an accumulating +// parse shows up in the glyph count only if the two are compared, and in the +// scalars either way. +//----------------------------------------------------------------------------- +TEST( BitmapFontParseTests, ASecondFontReplacesTheFirst ) +{ + font::BitmapFont bitmapFont; + + ASSERT_TRUE( parseFontFile( bitmapFont, ARIAL_FNT ) ) << "Could not open " ARIAL_FNT "."; + ASSERT_EQ( bitmapFont.mSize, 128 ) << "The first fixture did not parse."; + ASSERT_EQ( bitmapFont.mPageName.size(), 2U ) << "The first fixture did not parse."; + + bitmapFont.clear(); + + ASSERT_TRUE( parseFontFile( bitmapFont, ORATOR_FNT ) ) << "Could not open " ORATOR_FNT "."; + + // info face="Orator Std" size=72 / common lineHeight=72 base=56 pages=2 + ASSERT_EQ( bitmapFont.mSize, 72 ) << "The second font kept the first one's size."; + ASSERT_EQ( bitmapFont.mLineHeight, 72 ) << "The second font kept the first one's line height."; + ASSERT_EQ( bitmapFont.mBaseline, 56 ) << "The second font kept the first one's baseline."; + + // Not four. Both fonts declare two pages, so a page list that was appended to + // rather than replaced is the one number that would not merely be wrong but + // doubled. + ASSERT_EQ( bitmapFont.mPageName.size(), 2U ) << "The page list accumulated instead of being replaced."; + ASSERT_STREQ( bitmapFont.mPageName[0], "Orator Bold_0.png" ) << "Wrong first page file."; + + // Both fonts hold 97 glyphs over the same character ids, so this is a union + // that would look identical either way -- it is here to say so, not to catch + // anything the scalars above would miss. + ASSERT_EQ( bitmapFont.getCharacterCount(), 97U ) << "Wrong glyph count."; +} + +#endif // TORQUE_SHIPPING diff --git a/tests/smoke/assetAnimationInspector.cs b/tests/smoke/assetAnimationInspector.cs index 42ca78e30..af66486c7 100644 --- a/tests/smoke/assetAnimationInspector.cs +++ b/tests/smoke/assetAnimationInspector.cs @@ -84,8 +84,8 @@ function ainStep2() ainCheck("it starts hidden", !$ainInspector.paneScroller["Animation"].isVisible()); ainCheck("the generic inspector is the one on show", $ainInspector.insScroller.isVisible()); - // The registry knows both panes and nothing else. - ainCheck("both panes are registered", $ainInspector.paneKeys $= "Image Animation"); + // The registry knows every pane, in the order they were registered. + ainCheck("all three panes are registered", $ainInspector.paneKeys $= "Image Animation Font"); $ainTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($ainAssetId); ainCheck("the animation tile is in the library", isObject($ainTile)); @@ -130,7 +130,11 @@ function ainStep3() // Renaming an asset changes its id and every file naming it, so the row is // readable and inert. - ainCheck("the name row is not editable", !$ainPane.row["AssetName"].isEnabled()); + // + // Asked of the box rather than of the row: there is no isEnabled binding on + // anything, so the row form of this check answered "Unknown command" and + // passed for years without testing a thing. + ainCheck("the name row is not editable", !$ainPane.row["AssetName"].editor.isActive()); schedule(300, 0, "ainStep4"); } diff --git a/tests/smoke/assetFontInspector.cs b/tests/smoke/assetFontInspector.cs new file mode 100644 index 000000000..f82800b1b --- /dev/null +++ b/tests/smoke/assetFontInspector.cs @@ -0,0 +1,317 @@ +// Asset Manager font-inspector smoke test. Drives the custom pane that replaced +// the generic GuiInspector for font assets: the three reflowing blocks, the +// relative file path, the line describing what the .fnt held, and both warnings. +// Run: tests/run.ps1 assetFontInspector ; grep AFNT in tests/logs/. +// +// Driven by calling the pane rather than by posting input, for the same reason +// assetImageInspector is: where a row sits depends on how many columns the grid +// chose and how far the scroller has been dragged, neither of which script can +// read. +// +// NOTE: a COPY of toybox/ToyAssets. Nothing here saves, but re-pointing FontFile +// at a missing file is exactly the sort of edit that should not happen to real +// content if a later change ever makes a commit write. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function afnCheck(%label, %cond) +{ + if(%cond) echo("AFNT PASS: " @ %label); + else echo("AFNT FAIL: " @ %label); +} + +// Arial.fnt: size 128, lineHeight 128, base 103, two 512 x 512 pages, 97 glyphs. +// Every number the info line prints is one that can be read out of the file. +$afnAssetId = "ToyAssets:ArialFont"; + +function afnLoadFixtureAssets() +{ + %copy = testRoot("assetFontInspectorSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "afnStep1"); + +//----------------------------------------------------------------------------- + +function afnStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."). + ProjectManager.setProjectFolder("assetFontInspectorSmokeProject"); + EditorPreferences.path = testRoot("shots/assetFontInspectorSmokePrefs.taml"); + + afnCheck("fixture asset module registered", afnLoadFixtureAssets()); + + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "afnStep2"); +} + +//----------------------------------------------------------------------------- +// The pane exists, and stands down until a font is chosen. +//----------------------------------------------------------------------------- + +function afnStep2() +{ + $afnInspector = AssetAdmin.inspector; + $afnPane = $afnInspector.fontPane; + + afnCheck("font pane built", isObject($afnPane)); + afnCheck("it is an AssetFontInspectorPane", + $afnPane.getClassNamespace() $= "AssetFontInspectorPane"); + afnCheck("it inherits the shared pane", + $afnPane.getSuperClassNamespace() $= "AssetInspectorPane"); + afnCheck("it starts hidden", !$afnInspector.paneScroller["Font"].isVisible()); + afnCheck("the generic inspector is the one on show", $afnInspector.insScroller.isVisible()); + afnCheck("the font pane is in the registry", strstr($afnInspector.paneKeys, "Font") != -1); + + $afnTile = AssetAdmin.Dictionary["FontAsset"].getButton($afnAssetId); + afnCheck("the font tile is in the library", isObject($afnTile)); + + $afnTile.onClick(); + + schedule(600, 0, "afnStep3"); +} + +//----------------------------------------------------------------------------- +// Choosing one hands the page to the pane. +//----------------------------------------------------------------------------- + +function afnStep3() +{ + afnCheck("the pane took the page", $afnInspector.paneScroller["Font"].isVisible()); + afnCheck("the generic inspector stood down", !$afnInspector.insScroller.isVisible()); + afnCheck("the pane is bound to the asset", $afnPane.target == $afnTile.FontAsset); + afnCheck("the inspector reports the pane's asset as the inspected one", + $afnInspector.inspectedObject() == $afnTile.FontAsset); + + // The three blocks and what is in them. + afnCheck("the identity block exists", isObject($afnPane.identityChain)); + afnCheck("the font block exists", isObject($afnPane.fontChain)); + afnCheck("the description block exists", isObject($afnPane.descriptionChain)); + afnCheck("the grid holds three blocks", $afnPane.contentGrid.getCount() == 3); + + afnCheck("asset name row", isObject($afnPane.row["AssetName"])); + afnCheck("category row", isObject($afnPane.row["AssetCategory"])); + afnCheck("font file row", isObject($afnPane.row["FontFile"])); + afnCheck("auto unload row", isObject($afnPane.row["AssetAutoUnload"])); + afnCheck("description row", isObject($afnPane.row["AssetDescription"])); + + // The two that exist to keep an asset OUT of the editor. + afnCheck("AssetInternal is NOT offered", !isObject($afnPane.row["AssetInternal"])); + afnCheck("nor is AssetPrivate", !isObject($afnPane.row["AssetPrivate"])); + + // Renaming an asset changes its id and every file naming it, so the row is + // readable and inert. Asked of the box: there is no isEnabled binding. + afnCheck("the name row is not editable", !$afnPane.row["AssetName"].editor.isActive()); + afnCheck("and it says why", $afnPane.row["AssetName"].editor.Tooltip !$= ""); + + // The tooltip hook, which is most of what this pane adds over a list of + // labels -- FontAsset's own registered doc strings are empty. + afnCheck("the file row explains itself", $afnPane.row["FontFile"].editor.Tooltip !$= ""); + afnCheck("and the Find button carries the same tip", + $afnPane.row["FontFile"].findButton.Tooltip $= $afnPane.row["FontFile"].editor.Tooltip); + + schedule(300, 0, "afnStep4"); +} + +//----------------------------------------------------------------------------- +// The path, and the line describing the file it names. +//----------------------------------------------------------------------------- + +function afnStep4() +{ + %asset = $afnPane.target; + + // The whole reason getRelativeFontFile was added. The field itself holds the + // expanded absolute path, which is neither readable nor portable. + %shown = $afnPane.row["FontFile"].getValue(); + afnCheck("the file row shows the relative path (" @ %shown @ ")", %shown $= "Arial.fnt"); + afnCheck("which is not what the field holds", %asset.FontFile !$= %shown); + + %info = $afnPane.infoLabel.getText(); + afnCheck("the info line gives the native size (" @ %info @ ")", strstr(%info, "128 px") != -1); + afnCheck("it counts the glyphs", strstr(%info, "97 glyphs") != -1); + afnCheck("it counts the pages", strstr(%info, "2 pages") != -1); + afnCheck("it gives the page size", strstr(%info, "512 x 512") != -1); + + // Line height equals the native size in this font, and the line says so once + // rather than twice. + afnCheck("it does not repeat the size as a line height", + strstr(%info, "line height") == -1); + + // The queries behind the line. + afnCheck("the asset reports its glyph count", %asset.getGlyphCount() == 97); + afnCheck("and its page count", %asset.getPageCount() == 2); + afnCheck("and that both pages loaded", %asset.getLoadedPageCount() == 2); + afnCheck("and its baseline", %asset.getBaseline() == 103); + + schedule(300, 0, "afnStep5"); +} + +//----------------------------------------------------------------------------- +// The warning, appearing and clearing. +// +// This is the case the engine used to make untestable: buildFontData returned on +// a failed open without clearing anything, so a font pointed at a missing file +// went on reporting the glyphs of the one it used to have. +//----------------------------------------------------------------------------- + +function afnStep5() +{ + %asset = $afnPane.target; + + afnCheck("no warning to begin with", !$afnPane.warningLabel.isVisible()); + + $afnPane.commitValue("FontFile", "NoSuchFont.fnt"); + + afnCheck("a font that did not load is called out", $afnPane.warningLabel.isVisible()); + afnCheck("and the warning says what to check", + strstr($afnPane.warningLabel.getText(), "TEXT format") != -1); + afnCheck("the glyph count went to zero rather than keeping the old font's", + %asset.getGlyphCount() == 0); + afnCheck("and the info line says so", $afnPane.infoLabel.getText() $= "No font loaded."); + + $afnPane.commitValue("FontFile", "Arial.fnt"); + + afnCheck("the warning clears when the file is put back", !$afnPane.warningLabel.isVisible()); + afnCheck("and the glyphs come back", %asset.getGlyphCount() == 97); + + // Not 194. Re-reading a font used to ADD to the glyph map rather than replace + // it, because nothing cleared mChar -- so a font asset pointed at a second + // file reported the union of the two and only ever grew. + $afnPane.commitValue("FontFile", "Orator Bold.fnt"); + afnCheck("a second font replaces the first rather than joining it (" @ + %asset.getGlyphCount() @ " glyphs)", %asset.getGlyphCount() == 97); + afnCheck("and the scalars are the second font's, not the first's", + %asset.getFontSize() == 72 && %asset.getBaseline() == 56); + + $afnPane.commitValue("FontFile", "Arial.fnt"); + afnCheck("and back again", %asset.getFontSize() == 128); + + schedule(300, 0, "afnStep6"); +} + +//----------------------------------------------------------------------------- +// Committing. +//----------------------------------------------------------------------------- + +function afnStep6() +{ + %asset = $afnPane.target; + + $afnPane.commitValue("AssetCategory", "smokeCategory"); + afnCheck("a committed field reaches the asset", %asset.AssetCategory $= "smokeCategory"); + + $afnPane.commitValue("AssetDescription", "A font, for smoke testing."); + afnCheck("so does the description", %asset.AssetDescription $= "A font, for smoke testing."); + + // AssetAutoUnload is a real, working field on a font asset -- unlike on an + // audio one, where the engine forces it off and the pane leaves it out. + $afnPane.commitValue("AssetAutoUnload", false); + afnCheck("auto unload commits", !%asset.AssetAutoUnload); + $afnPane.commitValue("AssetAutoUnload", true); + + // Editing marks the document unsaved, which is what lights up Save and Undo. + afnCheck("the asset is dirty after an edit", %asset.isAssetDirty()); + afnCheck("and the inspector offers to save it", $afnInspector.getSaveAssetEnabled()); + + schedule(300, 0, "afnStepReflow"); +} + +//----------------------------------------------------------------------------- +// Reflow. The inspector is the bottom frame of the frame set, so it opens wide +// and short and is dragged to whatever shape suits the work. Three blocks in a +// grid with a 300 floor: one column when narrow, three across a wide screen. +//----------------------------------------------------------------------------- + +// How many blocks are sharing the top row. Read from where the grid actually put +// them rather than from the arithmetic that placed them, so it disagrees when +// the layout is wrong. +function afnColumnCount() +{ + %grid = $afnPane.contentGrid; + %topY = getWord(%grid.getObject(0).getPosition(), 1); + + %count = 0; + for(%i = 0; %i < %grid.getCount(); %i++) + { + if(getWord(%grid.getObject(%i).getPosition(), 1) == %topY) + { + %count++; + } + } + return %count; +} + +function afnStepReflow() +{ + %h = getWord($afnPane.getExtent(), 1); + + // Put back afterwards. The pane follows its scroller by the CHANGE in width + // rather than by recomputing from it, so a width written here by hand is one + // the scroller never agrees to take back. + %natural = getWord($afnPane.getExtent(), 0); + + $afnPane.resize(0, 0, 380, %h); + afnCheck("a narrow pane stacks the blocks (" @ afnColumnCount() @ " across)", + afnColumnCount() == 1); + + $afnPane.resize(0, 0, 672, %h); + afnCheck("the pane as it opens is two across (" @ afnColumnCount() @ ")", + afnColumnCount() == 2); + + // The cap is what stops the grid working out five columns from the width, + // filling three of them and leaving two empty with the blocks too narrow. + $afnPane.resize(0, 0, 1600, %h); + afnCheck("a wide pane is a single row (" @ afnColumnCount() @ " across)", + afnColumnCount() == 3); + afnCheck("wide, the blocks share the width evenly", + getWord($afnPane.fontChain.getExtent(), 0) + == getWord($afnPane.identityChain.getExtent(), 0)); + afnCheck("wide, the blocks reach the right-hand edge", + getWord($afnPane.descriptionChain.getPosition(), 0) + + getWord($afnPane.descriptionChain.getExtent(), 0) >= 1580); + + $afnPane.resize(0, 0, %natural, %h); + + schedule(300, 0, "afnStep7"); +} + +//----------------------------------------------------------------------------- +// Standing down for another asset kind. +//----------------------------------------------------------------------------- + +function afnStep7() +{ + %imageTile = AssetAdmin.Dictionary["ImageAsset"].getButton("ToyAssets:TD_Barbarian_CompSprite"); + %imageTile.onClick(); + + afnCheck("the font pane stood down", !$afnInspector.paneScroller["Font"].isVisible()); + afnCheck("the image pane took over", $afnInspector.imageScroller.isVisible()); + afnCheck("exactly one pane is on show at a time", !$afnInspector.insScroller.isVisible()); + afnCheck("the font pane was unbound", !isObject($afnPane.target)); + + echo("AFNT DONE"); + schedule(200, 0, "quit"); +} diff --git a/tests/smoke/assetImageInspector.cs b/tests/smoke/assetImageInspector.cs index 8310874a0..d16696371 100644 --- a/tests/smoke/assetImageInspector.cs +++ b/tests/smoke/assetImageInspector.cs @@ -473,10 +473,16 @@ function aiStep9Narrow() function aiStep10() { - $aiInspector.loadFontAsset($aiAsset, $aiAssetId); - - aiCheck("another asset kind gets the generic inspector", - $aiInspector.insScroller.isVisible() && !$aiInspector.imageScroller.isVisible()); + // A real font asset, chosen the way a user would. This used to hand the IMAGE + // asset to loadFontAsset and check that the generic inspector took over -- + // which stopped meaning anything when font assets got a pane of their own. + // The question is the same either way: does the image pane hand the page back. + %fontTile = AssetAdmin.Dictionary["FontAsset"].getButton("ToyAssets:ArialFont"); + aiCheck("the fixture gave the library a font asset too", isObject(%fontTile)); + %fontTile.onClick(); + + aiCheck("another asset kind takes the page from the image pane", + $aiInspector.paneScroller["Font"].isVisible() && !$aiInspector.imageScroller.isVisible()); aiCheck("the image pane let go of its asset", !isObject($aiPane.target)); // And back, so nothing about the swap is one-way. From 90501209486a8d80bd192cc66ee39c447ca55258 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Wed, 12 Aug 2026 22:38:49 -0400 Subject: [PATCH 22/26] A sound you can hear no matter how the game is mixing it Audio assets get an inspector pane of their own in place of the stock GuiInspector: three reflowing blocks, with the length and format of the file sitting under the file itself, where there was room for it. Half of what this pane adds is the tooltips. An AudioAsset registers six fields and gives every one of them an empty doc string, so the generic inspector offered six labels and explained none of them -- least of all VolumeChannel, whose numbering is a per-game convention the engine attaches no meaning to. AssetAutoUnload is left out, and not because it is uninteresting. AudioAsset::initializeAsset calls setAssetAutoUnload(false) unconditionally, so every audio asset reads false whatever its file says and a tick would silently come back off on the next load. A checkbox that cannot be changed is worse than no checkbox: it invites the attempt. The preview no longer goes through alxPlay, which is the fix for a real complaint: turn a game's music channel down to nothing and every music asset in the library became unplayable in the editor. Not quiet -- unplayable. alxCreateSource refuses to build a source at all on a muted channel, so there was no handle, and nothing to distinguish that from a broken file. alxPlayPreview keeps the asset's file, looping and streaming flags but forces full volume on a reserved channel and writes AL_GAIN directly to get past the master volume. It never touches the game's own channels, so auditioning an effect cannot blast the music playing behind it. That write survives because a 2D source is AL_SOURCE_RELATIVE, which is exactly what alxUpdateMaxDistance skips each frame; only alxUpdateTypeGain would recompute it, and that runs when somebody moves the mixer, which while a preview is playing is a thing they meant to do. The game still owns the audio driver, and nothing here changes that -- a shipped game must not depend on anything under editor/. But the Asset Manager can be opened before a project is picked, or against one with no audio module, and there the driver simply is not running. AssetAdmin::ensureAudioDriver covers that case and no other. It asks OpenALIsInitialized first, which is new and is not a nicety: OpenALInit BEGINS by calling OpenALShutdown, so starting a driver that is already up drops every playing source and resets the channel volumes, silently undoing the project's own SetMusicVolume. Two engine bugs found on the way: - setVolume and setVolumeChannel compared the value they were handed against the one they held and clamped only afterwards, so handing either an out-of-range number read as a change every single time -- calling refreshAsset and marking the asset unsaved for an edit that moved nothing. - alxGetAudioLength acquired the asset and released it on none of its three return paths, so every call raised the reference count for good. The pane clamps in writeField as well, which is how it avoids the first of those rather than relying on it. Note mClamp, not mClampF -- the latter is the C++ name and is not bound to script at all, so it returns an empty string and quietly writes a zero. Nothing warns about the channel being muted, deliberately. mAudioChannelVolumes is a static global filled in only by OpenALInit, so before the driver starts every channel reads zero and such a warning cannot tell "somebody muted this" from "nothing has made a sound yet" -- and with the preview no longer caring about the game's mix, it would be warning about something that no longer affects what the user is looking at. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- editor/AssetAdmin/AssetAdmin.cs | 41 ++ editor/AssetAdmin/AssetAudioPlayButton.cs | 8 +- editor/AssetAdmin/AssetInspector.cs | 22 +- editor/AssetAdmin/AssetWindow.cs | 7 + .../Inspector/AssetSoundInspectorPane.cs | 378 +++++++++++++++ editor/AssetAdmin/Inspector/exec.cs | 1 + engine/source/audio/AudioAsset.cc | 22 +- engine/source/audio/AudioAsset.h | 5 + .../source/audio/AudioAsset_ScriptBinding.h | 53 +++ engine/source/audio/audio.cc | 55 +++ engine/source/audio/audio_ScriptBinding.cc | 70 ++- engine/source/platform/platformAL.h | 8 + engine/source/platform/platformAudio.h | 25 +- tests/smoke/assetAnimationInspector.cs | 2 +- tests/smoke/assetSoundInspector.cs | 442 ++++++++++++++++++ 15 files changed, 1125 insertions(+), 14 deletions(-) create mode 100644 editor/AssetAdmin/Inspector/AssetSoundInspectorPane.cs create mode 100644 engine/source/audio/AudioAsset_ScriptBinding.h create mode 100644 tests/smoke/assetSoundInspector.cs diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index a2c9f18c6..554a416e1 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -294,6 +294,47 @@ class = AssetWindow; %this.background.add(%this.assetWindow); } +// Start the audio driver only if nothing else already has. +// +// The game owns audio. A project's Audio module calls OpenALInitDriver in its +// create function and then sets the master and channel volumes, and that is the +// arrangement -- nothing here should replace it, and nothing in a shipped game +// may depend on the editor being present at all. +// +// But the Asset Manager can be opened before any project is picked, or against a +// project with no audio module, and there the driver is simply not running: with +// no context alxPlay answers with a null handle, and .wav is not even a +// registered resource extension until OpenALInitDriver registers it. So this is a +// fallback for that case and nothing more. +// +// Asking first is essential rather than tidy. OpenALInit BEGINS by calling +// OpenALShutdown, so calling it when the driver is already up tears down the one +// the game just built: every playing sound stops and every channel volume goes +// back to 1, silently undoing the project's own audio settings. +// +// On demand rather than at editor startup, because an editor that takes the sound +// card the moment it opens is a nuisance, and most of a session never plays +// anything. Never shut down again either, for the same reason -- it is not ours +// to stop. +// +// The attempt is remembered rather than the result, so a machine with no audio +// device says so once instead of on every sound in the library. +function AssetAdmin::ensureAudioDriver(%this) +{ + if(OpenALIsInitialized()) + { + return true; + } + + if(!%this.audioDriverTried) + { + %this.audioDriverTried = true; + %this.audioDriverReady = OpenALInitDriver(); + } + + return %this.audioDriverReady; +} + function AssetAdmin::buildAudioPlayButton(%this) { %this.audioPlayButtonContainer = new GuiControl() diff --git a/editor/AssetAdmin/AssetAudioPlayButton.cs b/editor/AssetAdmin/AssetAudioPlayButton.cs index b0e2417f9..b9b4f4a98 100644 --- a/editor/AssetAdmin/AssetAudioPlayButton.cs +++ b/editor/AssetAdmin/AssetAudioPlayButton.cs @@ -7,7 +7,13 @@ } else { - %this.sound = alxPlay(%this.assetID); + // alxPlayPreview, not alxPlay: an editor auditions the ASSET, not the + // asset as the project currently loaded happens to be mixing it. Played + // through alxPlay, a game that had turned its music channel down to + // nothing made every music asset here silent -- and not quietly silent, + // since the engine refuses to create a source on a muted channel at all, + // so there was no handle and no way to tell that from a broken file. + %this.sound = alxPlayPreview(%this.assetID); %this.setText("Stop"); if(!%this.asset.Looping) diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index cbe18564b..5abe32ebe 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -137,6 +137,7 @@ %this.registerPane("Image", %this.createImagePane()); %this.registerPane("Animation", %this.createAnimationPane()); %this.registerPane("Font", %this.createFontPane()); + %this.registerPane("Sound", %this.createSoundPane()); // Named handles for the ones the tests and the load methods reach for // directly. The registry is the truth; these are just shorter. @@ -144,6 +145,7 @@ %this.imagePane = %this.pane["Image"]; %this.animationPane = %this.pane["Animation"]; %this.fontPane = %this.pane["Font"]; + %this.soundPane = %this.pane["Sound"]; //Particle Graph Tool %this.scaleGraphPage = %this.createTabPage("Scale Graph", "AssetParticleGraphTool", ""); @@ -312,6 +314,23 @@ class = "AssetFontInspectorPane"; }; } +function AssetInspector::createSoundPane(%this) +{ + %width = 686; + + return new GuiChainCtrl() + { + class = "AssetSoundInspectorPane"; + superclass = "AssetInspectorPane"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %width SPC 320; + IsVertical = true; + ChildSpacing = 6; + paneWidth = %width; + }; +} + // Which inspector the Inspector page is showing: a registered pane by key, or "" // for the generic one. The panes standing down are hidden rather than emptied, // so nothing they hold is ever freed while the engine might be dispatching on it @@ -768,7 +787,8 @@ class = "DuplicateAssetDialog"; %this.titlebar.setText("Audio Asset:" SPC %audioAsset.AssetName); %this.beginDocument(%audioAsset); - %this.inspectStock(%audioAsset); + %this.chooseInspector("Sound"); + %this.soundPane.bind(%audioAsset, %assetID); } function AssetInspector::loadSpineAsset(%this, %spineAsset, %assetID) diff --git a/editor/AssetAdmin/AssetWindow.cs b/editor/AssetAdmin/AssetWindow.cs index 86c357105..10c17fc50 100644 --- a/editor/AssetAdmin/AssetWindow.cs +++ b/editor/AssetAdmin/AssetWindow.cs @@ -181,6 +181,13 @@ class = "AssetPreviewSprite"; { AssetAdmin.AssetScene.clear(true); + // Before anything tries to make a sound. Nothing in the editor starts the + // audio driver -- only a game module ever did -- so until now alxPlay had no + // context to play through and answered with a null handle, and .wav was not + // even a registered resource extension. The Play button worked in the sense + // that it changed its own label. + AssetAdmin.ensureAudioDriver(); + AssetAdmin.audioPlayButtonContainer.setVisible(true); AssetAdmin.AssetWindow.setVisible(false); diff --git a/editor/AssetAdmin/Inspector/AssetSoundInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetSoundInspectorPane.cs new file mode 100644 index 000000000..06d132991 --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetSoundInspectorPane.cs @@ -0,0 +1,378 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The inspector for an audio asset, in place of the generic one. Three blocks, +// the shape AssetAnimationInspectorPane uses: +// +// Identity the name, the category, the sound file, and how long that file +// is -- the one thing about a sound that no field says +// Playback how loud, on which channel, and the three flags +// Description the prose the library is searched by +// +// An AudioAsset registers six fields of its own and not one of them carries a +// doc string, so the generic inspector showed six labels and explained none of +// them. Half the value of this pane is tipFor below. +// +// The preview is unchanged: selecting a sound in the library auditions it +// through the Play button overlaid on the preview area +// (AssetAdmin::buildAudioPlayButton, AssetWindow::displayAudioAsset). This pane +// deliberately does not grow a transport of its own. +// +// Absent, each for a checkable reason: +// +// AssetAutoUnload NOT hidden because it is uninteresting -- it is +// hidden because it does not work here. +// AudioAsset::initializeAsset calls +// setAssetAutoUnload(false) unconditionally, so +// every audio asset reads false whatever its file +// says, and a tick would silently come back off +// the next time the asset was loaded. A checkbox +// that cannot be changed is worse than no +// checkbox: it invites the attempt. +// AssetInternal, AssetPrivate they exist to keep an asset OUT of the editor +// asset id, asset file the module and the name are on show, and the +// file is where the manager put it +// the eight 3D fields is3D, referenceDistance, maxDistance, the cone +// family and environmentLevel are commented out +// of AudioAsset::initPersistFields, and mIs3D is +// hard-set false in the constructor. A sound +// played from an asset id is never positional; +// the positional path is SceneObject::playSound +// with an AudioDescription datablock, which is a +// different object with its own fields. +//----------------------------------------------------------------------------- + +$AssetSoundInspectorPane::cellWidth = 300; +$AssetSoundInspectorPane::cellCount = 3; +$AssetSoundInspectorPane::descriptionHeight = 150; + +// The gain at or below which alxCreateSource refuses to make a source at all +// rather than making a quiet one (MIN_GAIN, audio.cc). A sound this quiet is not +// faint, it is absent, which is worth saying out loud. +$AssetSoundInspectorPane::minimumGain = 0.05; + +// Audio::AudioVolumeChannels - 1. Every channel has its own volume and there is +// no engine-side naming for any of them. +$AssetSoundInspectorPane::maxChannel = 31; + +function AssetSoundInspectorPane::onAdd(%this) +{ + // onAdd does not chain, so the shared setup runs from here. + %this.init(); + + // What the file row's Find button offers. These are the formats the engine + // actually registers with the resource manager (OpenALInitDriver) -- the same + // list NewAudioAssetDialog uses. + %this.fileFilters = "Audio Files (*.wav;*.ogg)|*.wav;*.ogg|All Files (*.*)|*.*"; + %this.fileTitle = "Choose an Audio File"; +} + +//----------------------------------------------------------------------------- +// Construction. +//----------------------------------------------------------------------------- + +function AssetSoundInspectorPane::buildPane(%this) +{ + %grid = %this.makeCellGrid(0, $AssetSoundInspectorPane::cellWidth, + $AssetSoundInspectorPane::cellCount); + %this.add(%grid); + %this.contentGrid = %grid; + + %this.buildIdentityCell(%grid); + %this.buildPlaybackCell(%grid); + %this.buildDescriptionCell(%grid); + + %this.buildWarning(); + + %this.nameRow.setEnabled(false, "Renaming an asset changes its id and every file that refers to it, " @ + "so it is not something the inspector can do safely on its own."); +} + +function AssetSoundInspectorPane::addField(%this, %container, %field) +{ + return %this.addFieldRow(%container, %field, %this.labelFor(%field), + %this.kindFor(%field), %this.enumItemsFor(%field)); +} + +function AssetSoundInspectorPane::buildIdentityCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.identityChain = %chain; + + %this.nameRow = %this.addField(%chain, "AssetName"); + %this.addField(%chain, "AssetCategory"); + %this.fileRow = %this.addField(%chain, "AudioFile"); + + // Under the file, which is what it describes -- the length and the format are + // both facts about that .wav, not about how loudly it is played. It also has + // room to breathe here: the playback block is five rows deep and this block is + // three, so the space at the bottom of this one was going spare. + %this.infoLabel = %this.makeInfoLabel(%chain, "labelProfile"); + %this.infoLabel.textWrap = true; + %this.infoLabel.textExtend = true; + %this.infoLabel.vAlign = "top"; +} + +function AssetSoundInspectorPane::buildPlaybackCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.playbackChain = %chain; + + %this.addField(%chain, "Volume"); + %this.addField(%chain, "VolumeChannel"); + %this.addField(%chain, "Looping"); + %this.addField(%chain, "Streaming"); + %this.addField(%chain, "Priority"); +} + +function AssetSoundInspectorPane::buildDescriptionCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.descriptionChain = %chain; + + %this.addField(%chain, "AssetDescription"); +} + +function AssetSoundInspectorPane::buildWarning(%this) +{ + %this.warningLabel = %this.makeInfoLabel(%this, "overrideLabelProfile"); + %this.warningLabel.textWrap = true; + %this.warningLabel.textExtend = true; + %this.warningLabel.vAlign = "top"; + %this.warningLabel.setVisible(false); +} + +//----------------------------------------------------------------------------- +// The field tables. +//----------------------------------------------------------------------------- + +function AssetSoundInspectorPane::labelFor(%this, %field) +{ + switch$(%field) + { + case "AssetName": return "Asset Name"; + case "AssetCategory": return "Category"; + case "AssetDescription": return "Description"; + case "AudioFile": return "Audio File"; + case "VolumeChannel": return "Volume Channel"; + } + + return %field; +} + +function AssetSoundInspectorPane::kindFor(%this, %field) +{ + switch$(%field) + { + case "AudioFile": return "file"; + case "Volume": return "decimal"; + case "VolumeChannel": return "number"; + case "Looping" or "Streaming" or "Priority": return "bool"; + case "AssetDescription": return "multiline"; + } + + return "text"; +} + +// The engine's own doc strings for all six of these fields are the empty string, +// so there is nothing to inherit and nowhere else a reader could find this out. +function AssetSoundInspectorPane::tipFor(%this, %field) +{ + switch$(%field) + { + case "AudioFile": return "A .wav or .ogg file. Those are the only two formats the engine reads."; + + case "Volume": return "How loud this sound is, from 0 to 1, before the channel volume is applied."; + + case "VolumeChannel": return "Which of the 32 mixer channels (0 to" SPC + $AssetSoundInspectorPane::maxChannel @ ") this sound plays on. Each channel has its own volume, " @ + "so a game can fade music without touching effects. This project's scripts use 0 for music and " @ + "1 for effects, but that is a convention -- the engine attaches no meaning to any channel."; + + case "Looping": return "Repeat until something stops it. Looping sounds are also never dropped " @ + "when the mixer runs short of voices."; + + case "Streaming": return "Decode a piece at a time while it plays rather than loading the whole " @ + "file up front. Worth it for music, wasteful for a short effect."; + + case "Priority": return "Keep this sound when the mixer runs out of voices and has to drop one."; + } + + return ""; +} + +function AssetSoundInspectorPane::editorHeightFor(%this, %field) +{ + if(%field $= "AssetDescription") + { + return $AssetSoundInspectorPane::descriptionHeight; + } + + return 0; +} + +// The stored path is absolute -- setAudioFile expands whatever it is given +// against the asset's own folder -- and an absolute path is neither readable nor +// portable. Show the collapsed form, which is what the file on disk says. +function AssetSoundInspectorPane::readField(%this, %field) +{ + if(%field $= "AudioFile") + { + return %this.target.getRelativeAudioFile(); + } + + return %this.target.getFieldValue(%field); +} + +// Both of these are clamped by the engine as well. Clamping here too is not +// belt and braces: setVolume and setVolumeChannel compare the value they were +// given against the one they hold and clamp only afterwards, so handing them +// something out of range reads as a change every single time and marks the asset +// unsaved for an edit that moves nothing. Sending only values that are already +// in range means that never comes up. +function AssetSoundInspectorPane::writeField(%this, %field, %value) +{ + // mClamp, not mClampF. The latter is the C++ name and is not bound to script + // at all, so it would return an empty string here and quietly write a zero. + if(%field $= "Volume") + { + %this.target.Volume = mClamp(%value, 0, 1); + return; + } + + if(%field $= "VolumeChannel") + { + %this.target.VolumeChannel = mFloor(mClamp(%value, 0, $AssetSoundInspectorPane::maxChannel)); + return; + } + + %this.target.setFieldValue(%field, %value); +} + +//----------------------------------------------------------------------------- +// Loading. Everything the row loop does not reach. +//----------------------------------------------------------------------------- + +function AssetSoundInspectorPane::refreshExtras(%this) +{ + %this.infoLabel.setText(%this.describeSound(%this.target)); + %this.showWarning(%this.warningFor(%this.target)); +} + +// How long the sound is, in milliseconds, measured at most once per file. +// +// Not measured in refreshExtras, which runs on every bind, every commit and +// every refresh an asset announces: alxGetAudioLength decodes the file to find +// out, and doing that to a music track each time somebody types a letter in the +// description box is not free. The answer only changes when the file does. +function AssetSoundInspectorPane::lengthOf(%this, %asset) +{ + %file = %asset.getRelativeAudioFile(); + + if(%this.measuredId $= %this.assetId && %this.measuredFile $= %file) + { + return %this.measuredLength; + } + + %this.measuredId = %this.assetId; + %this.measuredFile = %file; + %this.measuredLength = alxGetAudioLength(%this.assetId); + + return %this.measuredLength; +} + +// The one thing about a sound that is not on show as a field: how long it is. +// +// A length of zero is reported as unknown rather than as an error. It means the +// buffer could not be read, and the reasons for that are mostly not the asset's +// fault -- no audio device on the machine, or a driver that would not start. +// +// The channel's current volume is deliberately NOT reported here, though it is +// the best answer to "why can I not hear it". alxGetChannelVolume reads a plain +// global array that is only filled in when the audio driver starts, so before +// that every channel answers zero -- and an editor that said "channel 0 is at 0%" +// about every sound in the library would be worse than saying nothing. +function AssetSoundInspectorPane::describeSound(%this, %asset) +{ + %length = %this.lengthOf(%asset); + %format = strupr(getSubStr(fileExt(%asset.getRelativeAudioFile()), 1, 8)); + + if(%length > 0) + { + %line = mFloatLength(%length / 1000, 2) SPC "s"; + %line = (%format $= "") ? %line : (%line @ "," SPC %format); + } + else + { + %line = (%format $= "") ? "Length unknown" : (%format @ ", length unknown"); + } + + return %line @ "."; +} + +// In the order they matter. Only the first is shown, because the first is the one +// that has to be fixed before any of the others can be judged. +function AssetSoundInspectorPane::warningFor(%this, %asset) +{ + %ext = fileExt(%asset.getRelativeAudioFile()); + + // The stream factory answers with nothing at all for any other extension, so + // the sound simply never plays and says nothing about why. + if(%asset.Streaming && %ext !$= ".wav" && %ext !$= ".ogg") + { + return "Streaming only works for .wav and .ogg files. This one is" SPC + (%ext $= "" ? "not either" : %ext) @ ", so it will not play at all while Streaming is on."; + } + + // Not "quiet" -- absent. alxCreateSource refuses to make a source whose gain + // has fallen this low, and it makes the exception for looping and streaming + // sounds, which are never dropped this way. + if(%asset.Volume <= $AssetSoundInspectorPane::minimumGain && !%asset.Looping && !%asset.Streaming) + { + return "A volume of" SPC $AssetSoundInspectorPane::minimumGain SPC "or below means this sound is " @ + "not created at all rather than played quietly. Raise it, or turn on Looping or Streaming, " @ + "which are exempt."; + } + + // A muted channel is not warned about, for the reason describeSound gives: + // the channel volumes are zero until the audio driver starts, so the check + // cannot tell "somebody muted this" from "nothing has made a sound yet". + + return ""; +} + +// forceLayout only when the visibility actually changed: a chain skips hidden +// children when it lays out, and nothing re-lays it out on setVisible. +function AssetSoundInspectorPane::showWarning(%this, %text) +{ + %wanted = (%text !$= ""); + %changed = (%wanted != %this.warningLabel.isVisible()); + + %this.warningLabel.setText(%text); + %this.warningLabel.setVisible(%wanted); + + if(%changed) + { + %this.forceLayout(); + } +} diff --git a/editor/AssetAdmin/Inspector/exec.cs b/editor/AssetAdmin/Inspector/exec.cs index 1315ad52b..98d2823af 100644 --- a/editor/AssetAdmin/Inspector/exec.cs +++ b/editor/AssetAdmin/Inspector/exec.cs @@ -3,3 +3,4 @@ exec("./AssetAnimationInspectorPane.cs"); exec("./AssetImageInspectorPane.cs"); exec("./AssetFontInspectorPane.cs"); +exec("./AssetSoundInspectorPane.cs"); diff --git a/engine/source/audio/AudioAsset.cc b/engine/source/audio/AudioAsset.cc index 8738d7371..8e447d98d 100755 --- a/engine/source/audio/AudioAsset.cc +++ b/engine/source/audio/AudioAsset.cc @@ -32,6 +32,9 @@ #include "console/consoleTypes.h" #endif +// Script bindings. +#include "AudioAsset_ScriptBinding.h" + //----------------------------------------------------------------------------- ConsoleType( audioAssetPtr, TypeAudioAssetPtr, sizeof(AssetPtr), ASSET_ID_FIELD_PREFIX ) @@ -175,12 +178,20 @@ void AudioAsset::setAudioFile( const char* pAudioFile ) void AudioAsset::setVolume( const F32 volume ) { + // Clamp first, then compare. + // + // The other way round -- which is what this used to do -- compares the raw + // value against the stored one, so handing it 5.0 twice does not read as no + // change: it clamps to 1.0, calls refreshAsset and marks the asset unsaved + // every time, for an edit that moves nothing. + const F32 clampedVolume = mClampF( volume, 0.0f, 1.0f ); + // Ignore no change. - if ( mIsEqual( volume, mDescription.mVolume ) ) + if ( mIsEqual( clampedVolume, mDescription.mVolume ) ) return; // Update. - mDescription.mVolume = mClampF(volume, 0.0f, 1.0f);; + mDescription.mVolume = clampedVolume; // Refresh the asset. refreshAsset(); @@ -190,12 +201,15 @@ void AudioAsset::setVolume( const F32 volume ) void AudioAsset::setVolumeChannel( const S32 volumeChannel ) { + // Clamp first, then compare -- see setVolume above. + const S32 clampedChannel = mClamp( volumeChannel, 0, Audio::AudioVolumeChannels-1 ); + // Ignore no change. - if ( volumeChannel == mDescription.mVolumeChannel ) + if ( clampedChannel == mDescription.mVolumeChannel ) return; // Update. - mDescription.mVolumeChannel = mClamp( volumeChannel, 0, Audio::AudioVolumeChannels-1 ); + mDescription.mVolumeChannel = clampedChannel; // Refresh the asset. refreshAsset(); diff --git a/engine/source/audio/AudioAsset.h b/engine/source/audio/AudioAsset.h index b0f934751..b2f536daf 100755 --- a/engine/source/audio/AudioAsset.h +++ b/engine/source/audio/AudioAsset.h @@ -67,6 +67,11 @@ class AudioAsset: public AssetBase void setAudioFile( const char* pAudioFile ); inline StringTableEntry getAudioFile( void ) const { return mAudioFile; } + /// The audio file as it is stored on disk -- relative to the folder the asset + /// itself lives in. getAudioFile answers the expanded absolute path, which is + /// what the mixer needs and is neither readable nor portable anywhere else. + inline StringTableEntry getRelativeAudioFile( void ) const { return collapseAssetFilePath(mAudioFile); } + void setVolume( const F32 volume ); inline F32 getVolume( void ) const { return mDescription.mVolume; } diff --git a/engine/source/audio/AudioAsset_ScriptBinding.h b/engine/source/audio/AudioAsset_ScriptBinding.h new file mode 100644 index 000000000..ca4f7ea83 --- /dev/null +++ b/engine/source/audio/AudioAsset_ScriptBinding.h @@ -0,0 +1,53 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// One method, and the shortness of this file is not an oversight. +// +// AudioAsset has never had console methods of its own. Its six fields are all +// registered with setters and getters, so script reads and writes them by name +// -- %asset.Volume, %asset.Looping -- and there is nothing a wrapper would add. +// +// The one thing a field cannot answer is the audio file in the form it is +// stored in. TypeAssetLooseFilePath keeps the expanded absolute path in memory +// and collapses it only on the way to a TAML file, so reading the field gives +// something machine-specific that no editor should put in a text box and no +// user should be asked to retype. collapseAssetFilePath is protected on +// AssetBase, so script cannot reach it without this. ImageAsset solved the same +// problem the same way (getRelativeImageFile). +//----------------------------------------------------------------------------- + +ConsoleMethodGroupBeginWithDocs(AudioAsset, AssetBase) + +//----------------------------------------------------------------------------- + +/*! Gets the audio file as a path relative to the asset file. + @return Returns the audio file relative to the asset file. +*/ +ConsoleMethodWithDocs(AudioAsset, getRelativeAudioFile, ConsoleString, 2, 2, ()) +{ + return object->getRelativeAudioFile(); +} + +//----------------------------------------------------------------------------- + +ConsoleMethodGroupEndWithDocs(AudioAsset) diff --git a/engine/source/audio/audio.cc b/engine/source/audio/audio.cc index d68f95edb..12d18be5c 100755 --- a/engine/source/audio/audio.cc +++ b/engine/source/audio/audio.cc @@ -1001,6 +1001,55 @@ AUDIOHANDLE alxPlay(const AudioAsset *profile, const MatrixF *transform, const P return(handle); } +//-------------------------------------------------------------------------- +// Audition an asset as authored, ignoring the running game's mix. +// +// An editor previewing an asset wants to hear the ASSET. Played through alxPlay +// it hears the asset as this particular game happens to be mixing it right now, +// and the failure is not a quiet sound but no sound at all: alxCreateSource +// refuses to build a source on a muted channel (see the mAudioChannelVolumes +// test there), so a game that has turned its music down to nothing makes every +// music asset in the library unplayable in the editor, with nothing to say why. +// +// Three things are overridden and nothing else is. The file, the looping flag +// and the streaming flag are the asset's own, because those change what the +// sound IS rather than how loudly it is mixed. +AUDIOHANDLE alxPlayPreview(const AudioAsset *profile) +{ + if(profile == NULL) + return NULL_AUDIOHANDLE; + + Audio::Description desc = profile->getAudioDescription(); + desc.mVolume = 1.f; + desc.mVolumeChannel = Audio::AudioPreviewChannel; + + // The channel is reserved for this, but nothing enforces that, and a muted one + // would refuse to create the source at all. + mAudioChannelVolumes[Audio::AudioPreviewChannel] = 1.f; + + AUDIOHANDLE handle = alxCreateSource(desc, profile->getAudioFile(), NULL, NULL); + if(handle == NULL_AUDIOHANDLE) + return NULL_AUDIOHANDLE; + + handle = alxPlay(handle); + if(handle == NULL_AUDIOHANDLE) + return NULL_AUDIOHANDLE; + + // Past the master volume too, which alxSourcePlay has just folded into the + // gain. Safe to write over the top: a 2D source is AL_SOURCE_RELATIVE, and + // that is exactly what alxUpdateMaxDistance skips, so nothing recomputes this + // every frame. Only alxUpdateTypeGain would, and that runs when somebody moves + // the mixer -- which, while a preview is playing, is a thing they meant to do. + const U32 index = alxFindIndex(handle); + if(index < mNumSources) + { + mSourceVolume[index] = 1.f; + alSourcef(mSource[index], AL_GAIN, Audio::linearToDB(1.f)); + } + + return handle; +} + bool alxPause( AUDIOHANDLE handle ) { if(handle == NULL_AUDIOHANDLE) @@ -2474,6 +2523,12 @@ void shutdownContext() } +//-------------------------------------------------------------------------- +bool OpenALIsInitialized() +{ + return mContext != NULL; +} + //-------------------------------------------------------------------------- bool OpenALInit() { diff --git a/engine/source/audio/audio_ScriptBinding.cc b/engine/source/audio/audio_ScriptBinding.cc index c1ca8ee58..de95fd7b1 100644 --- a/engine/source/audio/audio_ScriptBinding.cc +++ b/engine/source/audio/audio_ScriptBinding.cc @@ -191,6 +191,20 @@ ConsoleFunctionWithDocs(OpenALInitDriver, ConsoleBool, 1, 1, ()) return false; } +//----------------------------------------------- +/*! Use the OpenALIsInitialized function to find out whether the OpenAL driver is + already running. + Worth asking before OpenALInitDriver: that call shuts the driver down before + it starts it, so calling it a second time drops every playing sound and resets + the channel volumes. + @return Returns true if there is a live OpenAL context. + @sa OpenALInitDriver +*/ +ConsoleFunctionWithDocs(OpenALIsInitialized, ConsoleBool, 1, 1, ()) +{ + return Audio::OpenALIsInitialized(); +} + //----------------------------------------------- /*! Use the OpenALShutdownDriver function to stop/shut down the OpenAL driver. After this is called, you must restart the driver with OpenALInitDriver to execute any new sound operations. @@ -255,16 +269,25 @@ ConsoleFunctionWithDocs(alxGetAudioLength, ConsoleInt, 2, 2, ( audio-assetId )) Resource buffer = AudioBuffer::find( pAudioAsset->getAudioFile() ); + // Every path out of here used to return without releasing what was acquired + // above, so each call raised the reference count by one and the asset could + // never be unloaded. One answer, one release. + S32 length = 0; + if ( !buffer.isNull() ) { - ALuint alBuffer = buffer->getALBuffer(); - return alxGetWaveLen( alBuffer ); + length = (S32)alxGetWaveLen( buffer->getALBuffer() ); + } + else + { + // Warn. + Con::warnf( "alxGetAudioLength() - Could not find audio file '%s' for asset '%s'.", pAudioAsset->getAudioFile(), pAssetId ); } - // Warn. - Con::warnf( "alxGetAudioLength() - Could not find audio file '%s' for asset '%s'.", pAudioAsset->getAudioFile(), pAssetId ); + // Release asset. + AssetDatabase.releaseAsset( pAssetId ); - return 0; + return length; } //-------------------------------------------------------------------------- @@ -481,6 +504,43 @@ ConsoleFunctionWithDocs(alxPlay, ConsoleInt, 2, 2, (audio-assetId)) return handle; } +//----------------------------------------------- +/*! Use the alxPlayPreview function to audition an audio asset as it was authored, + ignoring the running game's mixer settings. + Same file, looping and streaming behaviour as alxPlay, but played at full volume + on a reserved channel and past the master volume. This exists because the engine + will not create a source at all on a channel the game has muted, so a game with + its music turned down makes every music asset unplayable in an editor. Stop it + with alxStop, exactly like alxPlay. For editors -- a game wants alxPlay. + @param audio-assetId The asset Id to audition. + @return The handle of the playing source or 0 on error. + @sa alxPlay, alxStop +*/ +ConsoleFunctionWithDocs(alxPlayPreview, ConsoleInt, 2, 2, (audio-assetId)) +{ + // Fetch asset Id. + const char* pAssetId = argv[1]; + + // Acquire audio asset. + AudioAsset* pAudioAsset = AssetDatabase.acquireAsset( pAssetId ); + + // Did we get the audio asset? + if ( pAudioAsset == NULL ) + { + // No, so warn. + Con::warnf( "alxPlayPreview() - Could not find audio asset '%s'.", pAssetId ); + return NULL_AUDIOHANDLE; + } + + // Fetch audio handle. + AUDIOHANDLE handle = alxPlayPreview( pAudioAsset ); + + // Release asset. + AssetDatabase.releaseAsset( pAssetId ); + + return handle; +} + /*! Use the alxPause function to pause a currently playing sound as specified by handle. @param handle The ID (a non-negative integer) corresponding to a previously set up sound source. @return No return value. diff --git a/engine/source/platform/platformAL.h b/engine/source/platform/platformAL.h index 14d7e2fec..0c51760b9 100755 --- a/engine/source/platform/platformAL.h +++ b/engine/source/platform/platformAL.h @@ -81,6 +81,14 @@ namespace Audio bool OpenALInit(); void OpenALShutdown(); +/// Whether there is a live OpenAL context. +/// +/// OpenALInit begins by shutting the driver down, so calling it a second time is +/// destructive: it drops every playing source and resets the channel volumes. +/// Anything that wants to start the driver only if nobody else has -- an editor, +/// say -- has to be able to ask first. +bool OpenALIsInitialized(); + bool OpenALDLLInit(); void OpenALDLLShutdown(); diff --git a/engine/source/platform/platformAudio.h b/engine/source/platform/platformAudio.h index 66073466e..a9093d397 100755 --- a/engine/source/platform/platformAudio.h +++ b/engine/source/platform/platformAudio.h @@ -47,8 +47,21 @@ typedef U32 AUDIOHANDLE; namespace Audio { enum Constants { - - AudioVolumeChannels = 32 + + AudioVolumeChannels = 32, + + /// The channel an editor auditions an asset on. + /// + /// Reserved so a preview cannot be silenced by, or interfere with, the + /// running game's mix: a game that has turned its music channel down to + /// nothing would otherwise make the Asset Manager unable to play a music + /// asset at all, because alxCreateSource refuses to build a source on a + /// muted channel rather than building a quiet one. + /// + /// The last channel rather than the first free one, because channel + /// numbering is a per-game convention with no engine meaning and games + /// count up from zero. + AudioPreviewChannel = AudioVolumeChannels - 1 }; //-------------------------------------- @@ -101,6 +114,14 @@ void alxStopAll(); // one-shot helper alxPlay functions, create and play in one call AUDIOHANDLE alxPlay(const AudioAsset *profile, const MatrixF *transform=NULL, const Point3F *velocity=NULL); +/// Audition an asset as it was authored, ignoring the running game's mix. +/// +/// Same file, looping and streaming flags as alxPlay, but at full volume on +/// Audio::AudioPreviewChannel and past the master volume, so an editor hears the +/// asset itself rather than the asset as this particular game happens to be +/// mixing it. For editor previews only -- a game wants alxPlay. +AUDIOHANDLE alxPlayPreview(const AudioAsset *profile); + // Source void alxSourcef(AUDIOHANDLE handle, ALenum pname, ALfloat value); diff --git a/tests/smoke/assetAnimationInspector.cs b/tests/smoke/assetAnimationInspector.cs index af66486c7..743534298 100644 --- a/tests/smoke/assetAnimationInspector.cs +++ b/tests/smoke/assetAnimationInspector.cs @@ -85,7 +85,7 @@ function ainStep2() ainCheck("the generic inspector is the one on show", $ainInspector.insScroller.isVisible()); // The registry knows every pane, in the order they were registered. - ainCheck("all three panes are registered", $ainInspector.paneKeys $= "Image Animation Font"); + ainCheck("all four panes are registered", $ainInspector.paneKeys $= "Image Animation Font Sound"); $ainTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($ainAssetId); ainCheck("the animation tile is in the library", isObject($ainTile)); diff --git a/tests/smoke/assetSoundInspector.cs b/tests/smoke/assetSoundInspector.cs new file mode 100644 index 000000000..84d7b8ad0 --- /dev/null +++ b/tests/smoke/assetSoundInspector.cs @@ -0,0 +1,442 @@ +// Asset Manager sound-inspector smoke test. Drives the custom pane that replaced +// the generic GuiInspector for audio assets: the three reflowing blocks, the +// field the engine will not let anyone change, the two clamps, and each warning. +// Run: tests/run.ps1 assetSoundInspector ; grep ASND in tests/logs/. +// +// Nothing here asserts a duration. alxGetAudioLength decodes the file to answer, +// which needs OpenAL running, and a machine with no audio device answers zero for +// a perfectly good file. The pane reports that as "length unknown" rather than as +// an error, and the test only checks the parts that do not depend on a device. +// +// Selecting an audio tile auditions it (AssetWindow::displayAudioAsset calls the +// play button), and every commit re-clicks the tile through +// AssetAdmin::refreshPreview, so this suite makes noise on a machine that has +// speakers. buttonSound is a short one-shot for exactly that reason, and the last +// step stops it. +// +// NOTE: a COPY of toybox/ToyAssets, for the same reason the other asset suites +// take one. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function asnCheck(%label, %cond) +{ + if(%cond) echo("ASND PASS: " @ %label); + else echo("ASND FAIL: " @ %label); +} + +// TD_ButtonSound.wav -- short, one-shot, and not music. +$asnAssetId = "ToyAssets:buttonSound"; + +function asnLoadFixtureAssets() +{ + %copy = testRoot("assetSoundInspectorSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "asnStep1"); + +//----------------------------------------------------------------------------- + +function asnStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."). + ProjectManager.setProjectFolder("assetSoundInspectorSmokeProject"); + EditorPreferences.path = testRoot("shots/assetSoundInspectorSmokePrefs.taml"); + + asnCheck("fixture asset module registered", asnLoadFixtureAssets()); + + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "asnStep2"); +} + +//----------------------------------------------------------------------------- +// The pane exists, and stands down until a sound is chosen. +//----------------------------------------------------------------------------- + +function asnStep2() +{ + $asnInspector = AssetAdmin.inspector; + $asnPane = $asnInspector.soundPane; + + asnCheck("sound pane built", isObject($asnPane)); + asnCheck("it is an AssetSoundInspectorPane", + $asnPane.getClassNamespace() $= "AssetSoundInspectorPane"); + asnCheck("it inherits the shared pane", + $asnPane.getSuperClassNamespace() $= "AssetInspectorPane"); + asnCheck("it starts hidden", !$asnInspector.paneScroller["Sound"].isVisible()); + asnCheck("the sound pane is in the registry", strstr($asnInspector.paneKeys, "Sound") != -1); + + $asnTile = AssetAdmin.Dictionary["AudioAsset"].getButton($asnAssetId); + asnCheck("the sound tile is in the library", isObject($asnTile)); + + $asnTile.onClick(); + + schedule(600, 0, "asnStep3"); +} + +//----------------------------------------------------------------------------- +// Choosing one hands the page to the pane. +//----------------------------------------------------------------------------- + +function asnStep3() +{ + asnCheck("the pane took the page", $asnInspector.paneScroller["Sound"].isVisible()); + asnCheck("the generic inspector stood down", !$asnInspector.insScroller.isVisible()); + asnCheck("the pane is bound to the asset", $asnPane.target == $asnTile.AudioAsset); + + // The three blocks and what is in them. + asnCheck("the identity block exists", isObject($asnPane.identityChain)); + asnCheck("the playback block exists", isObject($asnPane.playbackChain)); + asnCheck("the description block exists", isObject($asnPane.descriptionChain)); + asnCheck("the grid holds three blocks", $asnPane.contentGrid.getCount() == 3); + + asnCheck("asset name row", isObject($asnPane.row["AssetName"])); + asnCheck("category row", isObject($asnPane.row["AssetCategory"])); + asnCheck("audio file row", isObject($asnPane.row["AudioFile"])); + asnCheck("volume row", isObject($asnPane.row["Volume"])); + asnCheck("volume channel row", isObject($asnPane.row["VolumeChannel"])); + asnCheck("looping row", isObject($asnPane.row["Looping"])); + asnCheck("streaming row", isObject($asnPane.row["Streaming"])); + asnCheck("priority row", isObject($asnPane.row["Priority"])); + asnCheck("description row", isObject($asnPane.row["AssetDescription"])); + + // The one that is left out because it does not work, rather than because it + // is uninteresting: AudioAsset::initializeAsset forces it off on every load, + // so a checkbox here would silently undo itself. + asnCheck("AssetAutoUnload is NOT offered", !isObject($asnPane.row["AssetAutoUnload"])); + asnCheck("and the engine is still forcing it off", !$asnPane.target.AssetAutoUnload); + + asnCheck("AssetInternal is NOT offered", !isObject($asnPane.row["AssetInternal"])); + asnCheck("nor is AssetPrivate", !isObject($asnPane.row["AssetPrivate"])); + + // None of the 3D fields is registered at all, so a sound played from an asset + // id is never positional. Worth pinning: they are commented out rather than + // deleted, and putting them back would put them on this pane. + asnCheck("no 3D fields", !isObject($asnPane.row["is3D"]) && !isObject($asnPane.row["maxDistance"])); + + asnCheck("the name row is not editable", !$asnPane.row["AssetName"].editor.isActive()); + + schedule(300, 0, "asnStep4"); +} + +//----------------------------------------------------------------------------- +// The tooltips. AudioAsset registers six fields and gives every one of them an +// empty doc string, so this pane is the only place any of them is explained. +//----------------------------------------------------------------------------- + +function asnStep4() +{ + asnCheck("volume explains itself", $asnPane.row["Volume"].editor.Tooltip !$= ""); + asnCheck("the channel explains itself", $asnPane.row["VolumeChannel"].editor.Tooltip !$= ""); + asnCheck("and says the naming is only a convention", + strstr($asnPane.row["VolumeChannel"].editor.Tooltip, "convention") != -1); + asnCheck("streaming explains itself", $asnPane.row["Streaming"].editor.Tooltip !$= ""); + asnCheck("priority explains itself", $asnPane.row["Priority"].editor.Tooltip !$= ""); + asnCheck("looping explains itself", $asnPane.row["Looping"].editor.Tooltip !$= ""); + + // The row that carries no tip keeps an empty one rather than inheriting. + asnCheck("the category row has none", $asnPane.row["AssetCategory"].editor.Tooltip $= ""); + + // A greyed row shows the reason it is greyed; re-enabling puts the standing + // explanation back rather than blanking it. + %row = $asnPane.row["Streaming"]; + %tip = %row.editor.Tooltip; + %row.setEnabled(false, "a reason"); + asnCheck("greying a row swaps in the reason", %row.editor.Tooltip $= "a reason"); + %row.setEnabled(true, ""); + asnCheck("re-enabling puts the field's own tip back", %row.editor.Tooltip $= %tip); + + schedule(300, 0, "asnStep5"); +} + +//----------------------------------------------------------------------------- +// The path, and the line describing the file it names. +//----------------------------------------------------------------------------- + +function asnStep5() +{ + %asset = $asnPane.target; + + // The whole reason getRelativeAudioFile was added. + %shown = $asnPane.row["AudioFile"].getValue(); + asnCheck("the file row shows the relative path (" @ %shown @ ")", %shown $= "TD_ButtonSound.wav"); + asnCheck("which is not what the field holds", %asset.AudioFile !$= %shown); + + // The format comes from the file name, so it is there whether or not the + // audio driver came up. The length is not asserted: it needs a real device. + %info = $asnPane.infoLabel.getText(); + asnCheck("the info line names the format (" @ %info @ ")", strstr(%info, "WAV") != -1); + + // Selecting the sound above should have started the driver -- nothing else in + // the editor ever does, so before this the Play button had no context to play + // through. A machine with no sound card legitimately answers false. + asnCheck("the audio driver was started on demand", AssetAdmin.audioDriverTried); + + schedule(300, 0, "asnStep6"); +} + +//----------------------------------------------------------------------------- +// The clamps. +// +// The engine clamps these too, but it compares before it clamps -- so handing it +// an out-of-range value reads as a change every time and marks the asset unsaved +// for an edit that moves nothing. The pane sends only values already in range. +//----------------------------------------------------------------------------- + +function asnStep6() +{ + %asset = $asnPane.target; + + $asnPane.commitValue("Volume", 5); + asnCheck("a volume over one is clamped (" @ %asset.Volume @ ")", mAbs(%asset.Volume - 1) < 0.001); + + $asnPane.commitValue("Volume", -1); + asnCheck("and under zero (" @ %asset.Volume @ ")", mAbs(%asset.Volume) < 0.001); + + $asnPane.commitValue("Volume", 0.75); + asnCheck("a sensible volume is written as given", mAbs(%asset.Volume - 0.75) < 0.001); + + $asnPane.commitValue("VolumeChannel", 99); + asnCheck("a channel over 31 is clamped (" @ %asset.VolumeChannel @ ")", %asset.VolumeChannel == 31); + + $asnPane.commitValue("VolumeChannel", -4); + asnCheck("and under zero", %asset.VolumeChannel == 0); + + $asnPane.commitValue("VolumeChannel", 1); + asnCheck("a real channel is written as given", %asset.VolumeChannel == 1); + + // The engine's own clamp, now that it compares against what it will store + // rather than against the raw value it was handed. + // + // Both fields have to be sitting AT the limit for this to mean anything: it + // is re-sending a value that clamps to what is already there that used to + // read as a change, call refreshAsset and mark the asset unsaved for an edit + // that moved nothing. + $asnPane.commitValue("Volume", 1); + $asnPane.commitValue("VolumeChannel", 31); + %asset.saveAsset(); + asnCheck("saving clears the dirty flag", !%asset.isAssetDirty()); + + %asset.Volume = 5; + asnCheck("re-sending an out-of-range volume is not treated as a change", + !%asset.isAssetDirty()); + %asset.VolumeChannel = 99; + asnCheck("nor an out-of-range channel", !%asset.isAssetDirty()); + asnCheck("and neither value moved", + mAbs(%asset.Volume - 1) < 0.001 && %asset.VolumeChannel == 31); + + $asnPane.commitValue("VolumeChannel", 0); + + schedule(300, 0, "asnStep7"); +} + +//----------------------------------------------------------------------------- +// Each warning, appearing and clearing. +//----------------------------------------------------------------------------- + +function asnStep7() +{ + %asset = $asnPane.target; + + $asnPane.commitValue("Volume", 1); + asnCheck("no warning to begin with", !$asnPane.warningLabel.isVisible()); + + // Not "quiet" -- absent. alxCreateSource refuses to make a source at all once + // the gain reaches MIN_GAIN, and a slider at 0.01 looks like it should work. + $asnPane.commitValue("Volume", 0.01); + asnCheck("a volume below the cull threshold is called out", $asnPane.warningLabel.isVisible()); + asnCheck("and the warning says it is not created at all", + strstr($asnPane.warningLabel.getText(), "not created at all") != -1); + + // Looping sounds are exempt from the cull, so the same volume is fine there. + $asnPane.commitValue("Looping", true); + asnCheck("a looping sound is exempt", !$asnPane.warningLabel.isVisible()); + $asnPane.commitValue("Looping", false); + $asnPane.commitValue("Volume", 1); + asnCheck("and the warning clears with the volume back up", !$asnPane.warningLabel.isVisible()); + + // The stream factory answers with nothing at all for any other extension, so + // the sound never plays and says nothing about why. + $asnPane.commitValue("Streaming", true); + asnCheck("streaming a .wav is fine", !$asnPane.warningLabel.isVisible()); + + $asnPane.commitValue("AudioFile", "../fonts/Arial.fnt"); + asnCheck("streaming something that is neither .wav nor .ogg is called out", + $asnPane.warningLabel.isVisible()); + asnCheck("and the warning names the two formats", + strstr($asnPane.warningLabel.getText(), ".wav and .ogg") != -1); + + $asnPane.commitValue("AudioFile", "TD_ButtonSound.wav"); + $asnPane.commitValue("Streaming", false); + asnCheck("that clears too", !$asnPane.warningLabel.isVisible()); + + schedule(300, 0, "asnStep8"); +} + +//----------------------------------------------------------------------------- +// Committing the plain fields. +//----------------------------------------------------------------------------- + +function asnStep8() +{ + %asset = $asnPane.target; + + $asnPane.commitValue("AssetCategory", "smokeCategory"); + asnCheck("a committed field reaches the asset", %asset.AssetCategory $= "smokeCategory"); + + $asnPane.commitValue("AssetDescription", "A sound, for smoke testing."); + asnCheck("so does the description", %asset.AssetDescription $= "A sound, for smoke testing."); + + // Priority has no script accessor -- AudioAsset has none at all -- so this is + // the base's readField/writeField path through getFieldValue. + $asnPane.commitValue("Priority", true); + asnCheck("a field with no accessor still commits", %asset.Priority); + $asnPane.commitValue("Priority", false); + + asnCheck("the asset is dirty after an edit", %asset.isAssetDirty()); + asnCheck("and the inspector offers to save it", $asnInspector.getSaveAssetEnabled()); + + schedule(300, 0, "asnStepReflow"); +} + +//----------------------------------------------------------------------------- +// Reflow. Three blocks in a grid with a 300 floor: one column when the inspector +// is dragged narrow, three across the foot of a wide screen. +//----------------------------------------------------------------------------- + +// How many blocks are sharing the top row, read from where the grid actually put +// them rather than from the arithmetic that placed them. +function asnColumnCount() +{ + %grid = $asnPane.contentGrid; + %topY = getWord(%grid.getObject(0).getPosition(), 1); + + %count = 0; + for(%i = 0; %i < %grid.getCount(); %i++) + { + if(getWord(%grid.getObject(%i).getPosition(), 1) == %topY) + { + %count++; + } + } + return %count; +} + +function asnStepReflow() +{ + %h = getWord($asnPane.getExtent(), 1); + + // Put back afterwards -- the pane follows its scroller by the CHANGE in width. + %natural = getWord($asnPane.getExtent(), 0); + + $asnPane.resize(0, 0, 380, %h); + asnCheck("a narrow pane stacks the blocks (" @ asnColumnCount() @ " across)", + asnColumnCount() == 1); + + $asnPane.resize(0, 0, 672, %h); + asnCheck("the pane as it opens is two across (" @ asnColumnCount() @ ")", + asnColumnCount() == 2); + + $asnPane.resize(0, 0, 1600, %h); + asnCheck("a wide pane is a single row (" @ asnColumnCount() @ " across)", + asnColumnCount() == 3); + asnCheck("wide, the blocks share the width evenly", + getWord($asnPane.playbackChain.getExtent(), 0) + == getWord($asnPane.identityChain.getExtent(), 0)); + asnCheck("wide, the blocks reach the right-hand edge", + getWord($asnPane.descriptionChain.getPosition(), 0) + + getWord($asnPane.descriptionChain.getExtent(), 0) >= 1580); + + $asnPane.resize(0, 0, %natural, %h); + + schedule(300, 0, "asnStepPreview"); +} + +//----------------------------------------------------------------------------- +// The preview auditions the ASSET, not the asset as the running game is mixing +// it. +// +// This is the case that made the whole thing necessary: a game that turns its +// music channel down to nothing does not merely make a music asset quiet in the +// editor, it makes it unplayable -- alxCreateSource refuses to build a source on +// a muted channel, so there is no handle and nothing to distinguish that from a +// broken file. +//----------------------------------------------------------------------------- + +function asnStepPreview() +{ + %asset = $asnPane.target; + %channel = %asset.VolumeChannel; + + // Everything below needs a real device. A machine without one is not a + // failure, so say what was skipped rather than asserting into the dark. + if(!OpenALIsInitialized()) + { + echo("ASND SKIP: no audio driver, so the preview path was not exercised"); + schedule(300, 0, "asnStep9"); + return; + } + + %was = alxGetChannelVolume(%channel); + alxSetChannelVolume(%channel, 0); + + %plain = alxPlay($asnAssetId); + asnCheck("alxPlay on a muted channel gives no handle at all", %plain == 0); + + %preview = alxPlayPreview($asnAssetId); + asnCheck("alxPlayPreview plays it anyway", %preview != 0); + asnCheck("and it really is playing", alxIsPlaying(%preview)); + alxStop(%preview); + + alxSetChannelVolume(%channel, %was); + asnCheck("the game's own channel volume is put back untouched", + alxGetChannelVolume(%channel) == %was); + + // The preview channel is the editor's, and no asset should be sitting on it. + asnCheck("the asset was not moved onto the preview channel", + %asset.VolumeChannel == %channel); + + schedule(300, 0, "asnStep9"); +} + +//----------------------------------------------------------------------------- +// Standing down for another asset kind. +//----------------------------------------------------------------------------- + +function asnStep9() +{ + AssetAdmin.audioPlayButton.resetSound(); + + %imageTile = AssetAdmin.Dictionary["ImageAsset"].getButton("ToyAssets:TD_Barbarian_CompSprite"); + %imageTile.onClick(); + + asnCheck("the sound pane stood down", !$asnInspector.paneScroller["Sound"].isVisible()); + asnCheck("the image pane took over", $asnInspector.imageScroller.isVisible()); + asnCheck("exactly one pane is on show at a time", !$asnInspector.insScroller.isVisible()); + asnCheck("the sound pane was unbound", !isObject($asnPane.target)); + + echo("ASND DONE"); + schedule(200, 0, "quit"); +} From f854d68861edbedd3d07272aa56335e94dbaf210 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 13 Aug 2026 19:34:37 -0400 Subject: [PATCH 23/26] Which of an emitter's thirty knobs are actually connected Particle assets get inspector panes of their own in place of the stock GuiInspector, and were the last editable asset kind still on it. Two panes, swapped by the emitter dropdown that already sat in the title bar: a small one for the effect, and a five-block one for an emitter. The emitter pane is the point. A ParticleAssetEmitter registers about thirty persistent fields and a large fraction of them are inert depending on four or five of the others -- a single-particle emitter ignores ten, a POINT emitter ignores its size and its angle, a fixed-aspect one ignores every Size-Y curve. The generic inspector listed all thirty flat and alphabetically, so the one thing it could not tell you was which knobs were live. Two ways of saying "this does not apply", and the difference is deliberate. Alternatives SWAP: an emitter draws an image or an animation and never both, and an orientation has exactly one offset, so showing the other arm invites filling in both when one would silently win. A field that is real, holds a value, and is merely unread by this mode is GREYED, with the reason as its tooltip -- hiding those would lose the value from sight and make the pane jump about as you tried modes. The blocks are five rows each, and that is a constraint on the grouping rather than an outcome of it. A GuiGridCtrl row is as tall as its tallest cell, so unequal blocks do not give a short column and a long one -- they give columns of the same height with the short ones mostly empty, which is what makes a wide layout read as unplanned. The first cut was 7/4/2/6/6. Three fields moved to fix it and each reads better where it landed: aiming joined orientation (both answer "which way", where emission answers "where"), PivotPoint joined it too (it is the point a particle is rotated about), and AlphaTest went to Particle Image (it is a threshold on that image's own alpha). The emitter's name is a header above the grid, not a sixth block -- the same failure in miniature. SELECTING A PARTICLE ASSET CRASHED THE EDITOR BEFORE ANY OF THIS, on unmodified HEAD. beginDocument snapshots for undo, copyFieldsFrom walks the whole field table, and every numeric-frame emitter's empty NamedFrame therefore reached ImageAsset::containsNamedRegion -> dStrcmp(mRegionName, ""). PixelArea's default constructor was empty and its four-argument setArea never set mRegionName, so every frame of every ordinary cell-mode image carried an indeterminate pointer. Initialised now; containsNamedRegion refuses an empty name, which it must independently, or an empty name would MATCH an unnamed frame and flip an emitter into named-frame mode; and setNamedImageFrame refuses one too. Five more engine defects, all confirmed by reading the render path: - the emitter's BlendMode, SrcBlendFactor and DstBlendFactor round-tripped through TAML and were read by nothing. sceneRender used mBlendMode and the two factors, which ParticlePlayer does not declare -- they resolved to the inherited SceneObject members, so one setting on the player covered every emitter and the per-emitter fields did nothing. IntenseParticles still overrides, as it always did. - quantityVaritationField was initialised from getQuantityBaseField, so the QuantityVariation graph did nothing and every emitter got a spurious half-base jitter instead. This one is content-visible: bonfire emitted 5 to 15 per interval and now emits ten. - the console setEmitterAngle stored mDegToRad(angle) and its getter handed back mRadToDeg(stored), so the two agreed with each other and with nothing else. The persist field writes what it is given and configureParticle does mDegToRad(getEmitterAngle()), so degrees is what the file and the renderer both mean. - AlphaChannelScale was sampled at time zero, which reads its first key and discards the curve the Scale Graph tab exists to draw. - setTargetPosition has no refreshAsset and DELIBERATELY so -- AngleToy steers an emitter at the cursor with it on every mouse move, and a refresh there rebuilds every emitter node per frame. Commented in place so nobody corrects it; the pane asks for the refresh itself. getFieldValue was the expensive one. ParticleAsset and ParticleAssetEmitter each declared getFieldValue(time), the graph sampler, which SHADOWED SimObject::getFieldValue(fieldName) -- the call the whole editor reads rows with. Asking an emitter for EmitterName sampled whichever curve was selected at dAtof("EmitterName") == 0 seconds and returned a plausible 1.0. Every row on both panes showed "1" while a hundred and forty assertions passed, because they checked the asset and writes were never shadowed. Renamed to getFieldValueAtTime; no script called it, the graph editor being C++. The suite now asserts what a row SHOWS, not only what the object holds. The preview grew a transport: play/pause, stop, restart, a cycling speed, and per-emitter solo and switch-off. Its chrome came out into EditorTransportBar in EditorCore, shared with the animation bar. Solo and switch-off are player state and never touch the asset -- but every edit rebuilds the preview, so the bar re-applies them keyed on asset id and resets only when the asset really changes. Stop is the immediate form: the graceful one leaves mPlaying set until the last particle dies AND pauses every emitter, which is the flag solo writes in. isStaticMode is a new binding because the mode cannot be inferred from the assets: an emitter switched to animation before an animation is chosen is animated holding nothing, which is indistinguishable from static holding nothing. EditorFieldRow gained a per-row assetType, having hardcoded ImageAsset since the animation pane; the emitter has an image row and an animation row side by side. The emitter button bar was rerouted off inspector.getInspectObject, which stops answering the moment index 0 leaves the stock inspector, and its remove-last path no longer asks for getEmitter(-1). assetParticleInspector drives both panes, the gating, the chrome and the transport, and counts visible rows per block so the balance cannot rot. Its screenshot harness is what caught the shadowed reads. Four cases went into assetStateCopyTests for the parts reachable without a canvas. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationTransportBar.cs | 125 +-- editor/AssetAdmin/AssetAdmin.cs | 74 +- editor/AssetAdmin/AssetDictionaryButton.cs | 5 + editor/AssetAdmin/AssetInspector.cs | 225 ++++-- editor/AssetAdmin/AssetWindow.cs | 24 +- .../Inspector/AssetEmitterInspectorPane.cs | 710 +++++++++++++++++ .../Inspector/AssetInspectorPane.cs | 9 + .../Inspector/AssetParticleInspectorPane.cs | 339 ++++++++ editor/AssetAdmin/Inspector/exec.cs | 2 + .../AssetParticleTransportBar.cs | 306 ++++++++ editor/AssetAdmin/ParticleEditor/exec.cs | 1 + editor/EditorCore/EditorCore.cs | 4 + editor/EditorCore/EditorFieldRow.cs | 16 +- editor/EditorCore/EditorTransportBar.cs | 154 ++++ engine/source/2d/assets/ImageAsset.cc | 7 + engine/source/2d/assets/ImageAsset.h | 12 +- .../source/2d/assets/ParticleAssetEmitter.cc | 8 + .../source/2d/assets/ParticleAssetEmitter.h | 7 + .../ParticleAssetEmitter_ScriptBinding.h | 48 +- .../2d/assets/ParticleAsset_ScriptBinding.h | 9 +- .../source/2d/sceneobject/ParticlePlayer.cc | 28 +- .../testing/tests/assetStateCopyTests.cc | 74 ++ tests/shots/assetParticleInspector.cs | 205 +++++ tests/smoke/assetAnimationInspector.cs | 3 +- tests/smoke/assetAnimationTimeline.cs | 2 +- tests/smoke/assetParticleInspector.cs | 739 ++++++++++++++++++ 26 files changed, 2936 insertions(+), 200 deletions(-) create mode 100644 editor/AssetAdmin/Inspector/AssetEmitterInspectorPane.cs create mode 100644 editor/AssetAdmin/Inspector/AssetParticleInspectorPane.cs create mode 100644 editor/AssetAdmin/ParticleEditor/AssetParticleTransportBar.cs create mode 100644 editor/EditorCore/EditorTransportBar.cs create mode 100644 tests/shots/assetParticleInspector.cs create mode 100644 tests/smoke/assetParticleInspector.cs diff --git a/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs index 645c920be..0d5a3db3d 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationTransportBar.cs @@ -24,13 +24,9 @@ // Play, rewind, loop, and the two switches that decide what an edit does: the // bar over the animation preview. // -// It sits as an overlay on the preview background rather than in a strip of its -// own, which is where the audio play button already sits and proof that an -// overlay there receives clicks over the SceneWindow. It costs no layout and -// takes no room from the art. -// -// Not built from EditorButtonBar: that makes EditorIconButtons, which are -// momentary, and two of these have to SHOW a state. +// The chrome -- the sized buttons, the toggles, the gaps -- is EditorTransportBar +// in EditorCore, shared with the particle preview's bar. What is left here is +// what these particular buttons do. // // Play and Stop are two buttons with one hidden rather than one toggle, and the // difference is not cosmetic. A toggle says "this setting is on"; these two say @@ -43,29 +39,22 @@ // three that are settings rather than actions. //----------------------------------------------------------------------------- -$AssetAnimationTransportBar::buttonSize = 24; -$AssetAnimationTransportBar::playSize = 36; -$AssetAnimationTransportBar::spacing = 4; -$AssetAnimationTransportBar::gap = 16; - function AssetAnimationTransportBar::onAdd(%this) { - ThemeManager.setProfile(%this, "emptyProfile"); + %this.init(); %this.addButton("rewind", $EditorIcon::playback_rew, "Back to the first frame", - $AssetAnimationTransportBar::buttonSize); + $EditorTransportBar::buttonSize); // The one you reach for, so it is half again the size of the rest. They sit // in the same place, and exactly one of them is ever visible. %this.playButton = %this.addButton("play", $EditorIcon::playback_play, "Play the preview", - $AssetAnimationTransportBar::playSize); + $EditorTransportBar::playSize); %this.stopButton = %this.addButton("stop", $EditorIcon::playback_stop, "Stop the preview", - $AssetAnimationTransportBar::playSize); + $EditorTransportBar::playSize); %this.stopButton.setVisible(false); - // A chain lays out what it can see, so an empty control is how a gap is - // spelled -- there is no spacing-before on a child. - %this.addSpacer($AssetAnimationTransportBar::gap); + %this.addSpacer($EditorTransportBar::gap); %this.loopButton = %this.addToggle("Loop", $EditorIcon::playback_reload, $EditorIcon::playback_reload, "Looping. Click to play once and stop on the last frame.", @@ -76,96 +65,7 @@ "Keeping the animation's time: adding a frame makes every frame play faster."); %this.addButton("openRangeDialog", $EditorIcon::list_num, "Fill the timeline from a range of frames", - $AssetAnimationTransportBar::buttonSize); -} - -// How much bigger a toggle has to be than a push button to LOOK the same size. -// -// They draw differently. A GuiButtonCtrl paints its chrome across the whole -// control less its margins; a GuiCheckBoxCtrl paints a box that -// GuiCheckBoxCtrl::onRender clamps into the CONTENT rect -- inside the borders -// and padding as well. iconButtonProfile has a 2 pixel border on all four sides, -// so a 24 pixel toggle drew a 20 pixel box beside a 24 pixel button, and no -// amount of boxExtent fixed it: the clamp will not let the box out. -// -// So the toggle is built that much larger and its box comes out the right size. -// Read from the profile rather than written as 4, because a theme is free to -// give the button a different border. -function AssetAnimationTransportBar::chromeInset(%this) -{ - %profile = ThemeManager.activeTheme.iconButtonProfile; - - return (%profile.borderLeft.border + %profile.borderRight.border) SPC - (%profile.borderTop.border + %profile.borderBottom.border); -} - -function AssetAnimationTransportBar::addToggle(%this, %name, %frameOn, %frameOff, %tipOn, %tipOff) -{ - %size = $AssetAnimationTransportBar::buttonSize; - %inset = %this.chromeInset(); - - %button = new GuiCheckBoxCtrl() - { - class = "EditorToggleIcon"; - Position = "0 0"; - VertSizing = "center"; - Extent = (%size + getWord(%inset, 0)) SPC (%size + getWord(%inset, 1)); - frameOn = %frameOn; - frameOff = %frameOff; - tipOn = %tipOn; - tipOff = %tipOff; - toggleName = %name; - owner = %this; - }; - ThemeManager.setProfile(%button, "iconButtonProfile"); - ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); - %this.add(%button); - - return %button; -} - -function AssetAnimationTransportBar::addButton(%this, %method, %frame, %tooltip, %size) -{ - %size = (%size $= "") ? $AssetAnimationTransportBar::buttonSize : %size; - - // Said in the block, not set afterwards. EditorIconButton forces its own - // extent in onAdd and its hover handlers animate the icon to sizes of their - // own, so a resize applied after the add survived exactly until the pointer - // first crossed it -- and the chain had already sized itself around the - // smaller button by then, which is what clipped the big one. - %button = new GuiButtonCtrl() - { - class = "EditorIconButton"; - Position = "0 0"; - VertSizing = "center"; - // buttonSize only. The icon is deliberately left at its default, so the - // big play button is a bigger BUTTON with the same picture on it as the - // rest -- which is what makes it easy to find without making it look like - // a different kind of control. - buttonSize = %size; - Frame = %frame; - Command = %this.getId() @ "." @ %method @ "();"; - Tooltip = %tooltip; - }; - ThemeManager.setProfile(%button, "iconButtonProfile"); - ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); - %this.add(%button); - - return %button; -} - -function AssetAnimationTransportBar::addSpacer(%this, %width) -{ - %spacer = new GuiControl() - { - Position = "0 0"; - Extent = %width SPC $AssetAnimationTransportBar::buttonSize; - UseInput = false; - }; - ThemeManager.setProfile(%spacer, "emptyProfile"); - %this.add(%spacer); - - return %spacer; + $EditorTransportBar::buttonSize); } //----------------------------------------------------------------------------- @@ -222,12 +122,7 @@ class = "EditorIconButton"; %playing = %this.stage.playing; %this.playButton.setVisible(!%playing); %this.stopButton.setVisible(%playing); - - // A chain lays out only the children it can see, and nothing re-lays it out - // when one is hidden -- so swapping the two is a resize away from leaving a - // hole where the other one was. - %this.resize(getWord(%this.getPosition(), 0), getWord(%this.getPosition(), 1), - getWord(%this.getExtent(), 0), getWord(%this.getExtent(), 1)); + %this.relayout(); if(!isObject(%this.stage.animationAsset)) { diff --git a/editor/AssetAdmin/AssetAdmin.cs b/editor/AssetAdmin/AssetAdmin.cs index 554a416e1..614741721 100644 --- a/editor/AssetAdmin/AssetAdmin.cs +++ b/editor/AssetAdmin/AssetAdmin.cs @@ -75,6 +75,10 @@ class = "AssetAnimationStage"; %this.buildTransportBar(); + // After the inspector, whose title dropdown the solo and mute switches ask + // which emitter is selected. + %this.buildParticleTransportBar(); + // After the inspector, which its refresh asks what to grey out. Built into // the shared bar and taken straight back off again; open() puts it on. %this.menus = new ScriptObject() @@ -383,11 +387,12 @@ class = AssetWindow; // against the extent the background has now, which is why it is a gap rather // than a coordinate. %barGap = 16; - %barTop = (getWord(%this.background.extent, 1) - $AssetAnimationTransportBar::playSize) - %barGap; + %barTop = (getWord(%this.background.extent, 1) - $EditorTransportBar::playSize) - %barGap; %this.transportBar = new GuiChainCtrl() { class = "AssetAnimationTransportBar"; + superclass = "EditorTransportBar"; stage = %this.animationStage; HorizSizing = "center"; VertSizing = "top"; @@ -410,9 +415,9 @@ class = "AssetAnimationTransportBar"; // The tallest button. Nothing computes this: a chain never grows to fit a // taller child. (IsExtentDynamic would not help either -- it is a // GuiGridCtrl field and a chain never reads it.) - Extent = "160" SPC $AssetAnimationTransportBar::playSize; + Extent = "160" SPC $EditorTransportBar::playSize; - ChildSpacing = $AssetAnimationTransportBar::spacing; + ChildSpacing = $EditorTransportBar::spacing; }; ThemeManager.setProfile(%this.transportBar, "emptyProfile"); %this.transportBarContainer.add(%this.transportBar); @@ -420,6 +425,69 @@ class = "AssetAnimationTransportBar"; %this.background.add(%this.transportBarContainer); } +// The particle transport, built the same way and in the same place as the +// animation one above. Two bars rather than one with swappable buttons: they +// share no state and drive different objects, and the chrome they do share is +// EditorTransportBar. +function AssetAdmin::buildParticleTransportBar(%this) +{ + %this.particleTransportBarContainer = new GuiControl() + { + position = "0 0"; + extent = %this.background.extent; + HorizSizing = "width"; + VertSizing = "height"; + Visible = "0"; + }; + ThemeManager.setProfile(%this.particleTransportBarContainer, "emptyProfile"); + + %barGap = 16; + %barTop = (getWord(%this.background.extent, 1) - $EditorTransportBar::playSize) - %barGap; + + %this.particleTransportBar = new GuiChainCtrl() + { + class = "AssetParticleTransportBar"; + superclass = "EditorTransportBar"; + HorizSizing = "center"; + VertSizing = "top"; + Position = "0" SPC %barTop; + + // IsVertical BEFORE Extent, as on the animation bar -- a chain refuses a + // resize along whatever axis is currently its length, and it is born + // vertical, so an Extent written first is read as "you may not change my + // height" and the big play button is laid out clipped into 30 pixels. + IsVertical = false; + Extent = "260" SPC $EditorTransportBar::playSize; + ChildSpacing = $EditorTransportBar::spacing; + }; + ThemeManager.setProfile(%this.particleTransportBar, "emptyProfile"); + %this.particleTransportBarContainer.add(%this.particleTransportBar); + + %this.background.add(%this.particleTransportBarContainer); +} + +// A particle preview was just built. The bar drives that player and nothing else, +// so it is handed the new one and everything it was holding about the old one is +// dropped. +function AssetAdmin::showParticleTransport(%this, %player, %assetId) +{ + if(!isObject(%this.particleTransportBar)) + { + return; + } + + %this.particleTransportBarContainer.setVisible(true); + %this.particleTransportBar.onPreviewRebuilt(%assetId); +} + +function AssetAdmin::hideParticleTransport(%this) +{ + if(isObject(%this.particleTransportBarContainer)) + { + %this.particleTransportBarContainer.setVisible(false); + } +} + // Something about the selected asset changed and the preview has to catch up. // // The old answer was to re-click the tile, which rebuilds the preview scene from diff --git a/editor/AssetAdmin/AssetDictionaryButton.cs b/editor/AssetAdmin/AssetDictionaryButton.cs index 5a5d55b98..9c5f5bbef 100644 --- a/editor/AssetAdmin/AssetDictionaryButton.cs +++ b/editor/AssetAdmin/AssetDictionaryButton.cs @@ -367,6 +367,11 @@ class = "AssetDictionarySprite"; // five asset kinds that have never heard of it. AssetAdmin.animationStage.retainFor(%this.AnimationAssetID); + // Same idea for the particle transport: the particle branch below puts it back + // up with the player it just built, so the only thing that has to happen here + // is that it is not left over the preview of something that has no transport. + AssetAdmin.hideParticleTransport(); + // The animation branch has to stay first: an animation tile caches its image // asset too, so the image branch would swallow it. if(isObject(%this.AnimationAsset) && %this.AnimationAssetID !$= "") diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index 5abe32ebe..60b4df849 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -139,6 +139,13 @@ %this.registerPane("Font", %this.createFontPane()); %this.registerPane("Sound", %this.createSoundPane()); + // A particle asset takes two, because the dropdown beside the title chooses + // between the effect and one of its emitters and those are different objects + // with different fields. They are two panes rather than one that rebuilds for + // the same reason as all the others: a pane is built once and only ever bound. + %this.registerPane("Particle", %this.createParticlePane()); + %this.registerPane("Emitter", %this.createEmitterPane()); + // Named handles for the ones the tests and the load methods reach for // directly. The registry is the truth; these are just shorter. %this.imageScroller = %this.paneScroller["Image"]; @@ -146,6 +153,8 @@ %this.animationPane = %this.pane["Animation"]; %this.fontPane = %this.pane["Font"]; %this.soundPane = %this.pane["Sound"]; + %this.particlePane = %this.pane["Particle"]; + %this.emitterPane = %this.pane["Emitter"]; //Particle Graph Tool %this.scaleGraphPage = %this.createTabPage("Scale Graph", "AssetParticleGraphTool", ""); @@ -331,6 +340,40 @@ class = "AssetSoundInspectorPane"; }; } +function AssetInspector::createParticlePane(%this) +{ + %width = 686; + + return new GuiChainCtrl() + { + class = "AssetParticleInspectorPane"; + superclass = "AssetInspectorPane"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %width SPC 320; + IsVertical = true; + ChildSpacing = 6; + paneWidth = %width; + }; +} + +function AssetInspector::createEmitterPane(%this) +{ + %width = 686; + + return new GuiChainCtrl() + { + class = "AssetEmitterInspectorPane"; + superclass = "AssetInspectorPane"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %width SPC 320; + IsVertical = true; + ChildSpacing = 6; + paneWidth = %width; + }; +} + // Which inspector the Inspector page is showing: a registered pane by key, or "" // for the generic one. The panes standing down are hidden rather than emptied, // so nothing they hold is ever freed while the engine might be dispatching on it @@ -738,28 +781,28 @@ class = "DuplicateAssetDialog"; %this.titleDropDown.setCurSel(%index); } +// Index 0 is the effect; anything above it is one of its emitters. Each gets a +// pane of its own and the graph tab that belongs with it. function AssetInspector::onChooseParticleAsset(%this, %particleAsset) { %index = %this.titleDropDown.getSelectedItem(); - %this.inspector.clearHiddenFields(); %curSel = %this.tabBook.getSelectedPage(); - if(%index == 0) + + if(%index <= 0) { - %this.inspector.addHiddenField("hidden"); - %this.inspector.addHiddenField("locked"); - %this.inspector.addHiddenField("AssetInternal"); - %this.inspector.addHiddenField("AssetPrivate"); - %this.inspector.inspect(%particleAsset); + %this.chooseInspector("Particle"); + %this.particlePane.bind(%particleAsset, %particleAsset.getAssetId()); %this.tabBook.removeIfMember(%this.emitterGraphPage); %this.tabBook.add(%this.scaleGraphPage); %this.scaleGraphPage.inspect(%particleAsset); } - else if(%index > 0) + else { - %this.inspector.addHiddenField("hidden"); - %this.inspector.addHiddenField("locked"); - %this.inspector.inspect(%particleAsset.getEmitter(%index - 1)); + %emitter = %particleAsset.getEmitter(%index - 1); + + %this.chooseInspector("Emitter"); + %this.emitterPane.bind(%emitter, %particleAsset.getAssetId()); %this.tabBook.removeIfMember(%this.scaleGraphPage); %this.tabBook.add(%this.emitterGraphPage); @@ -769,6 +812,44 @@ class = "DuplicateAssetDialog"; %this.emitterButtonBar.visible = true; %this.emitterButtonBar.refreshEnabled(); + + // Solo and mute act on whichever emitter is selected, so moving the selection + // moves what they isolate -- and on the effect itself there is nothing to + // isolate and both stand down. + if(isObject(AssetAdmin.particleTransportBar)) + { + AssetAdmin.particleTransportBar.refresh(); + } +} + +// Re-label the dropdown without disturbing what is selected or rebuilding the +// pane under it. Renaming an emitter is the only thing that needs this: the name +// is a field on the pane and the caption is a copy of it in the title bar. +function AssetInspector::refreshEmitterLabels(%this) +{ + %asset = %this.documentAsset(); + if(!isObject(%asset) || !%this.titleDropDown.isVisible()) + { + return; + } + + %this.refreshParticleTitleDropDown(%asset, %this.titleDropDown.getSelectedItem()); +} + +// Which emitter the dropdown is on, or "" when it is on the effect itself. The +// one place that knows how the list maps to the asset, so nothing below has to +// repeat the minus one. +function AssetInspector::selectedEmitter(%this) +{ + %asset = %this.documentAsset(); + %index = %this.titleDropDown.getSelectedItem(); + + if(!isObject(%asset) || %index <= 0 || %index > %asset.getEmitterCount()) + { + return ""; + } + + return %asset.getEmitter(%index - 1); } function AssetInspector::loadFontAsset(%this, %fontAsset, %assetID) @@ -826,12 +907,24 @@ class = "DeleteAssetDialog"; Canvas.pushDialog(%dialog); } +//----------------------------------------------------------------------------- +// The emitter bar beside the dropdown. +// +// Every one of these used to start from %this.inspector.getInspectObject() -- +// the generic inspector -- and work out from the dropdown index whether what it +// answered was the asset or one of its emitters. That only held while index 0 +// went through the generic inspector, which it no longer does: the effect has a +// pane of its own now and the generic inspector is never handed a particle at +// all. They go through documentAsset() and selectedEmitter() instead, which are +// true whichever inspector is on show. +//----------------------------------------------------------------------------- + function AssetInspector::addEmitter(%this) { - %asset = %this.inspector.getInspectObject(); - if(%this.titleDropDown.getSelectedItem() != 0) + %asset = %this.documentAsset(); + if(!isObject(%asset)) { - %asset = %asset.getOwner(); + return; } %width = 700; @@ -852,88 +945,108 @@ class = "NewParticleEmitterDialog"; function AssetInspector::MoveEmitterForward(%this) { - %emitter = %this.inspector.getInspectObject(); - %asset = %emitter.getOwner(); + %asset = %this.documentAsset(); %index = %this.titleDropDown.getSelectedItem(); - %asset.moveEmitter(%index-1, %index); - %this.refreshParticleTitleDropDown(%asset, %index+1); + if(!isObject(%asset) || %index <= 0 || %index >= %asset.getEmitterCount()) + { + return; + } + + %asset.moveEmitter(%index - 1, %index); + %this.refreshParticleTitleDropDown(%asset, %index + 1); %asset.refreshAsset(); + %this.onChooseParticleAsset(%asset); } function AssetInspector::MoveEmitterBackward(%this) { - %emitter = %this.inspector.getInspectObject(); - %asset = %emitter.getOwner(); + %asset = %this.documentAsset(); %index = %this.titleDropDown.getSelectedItem(); - %asset.moveEmitter(%index-1, %index-2); - %this.refreshParticleTitleDropDown(%asset, %index-1); + // Index 1 is the FIRST emitter, so it has nowhere to go: moveEmitter(0, -1) + // is what the missing half of this test used to ask for. + if(!isObject(%asset) || %index <= 1) + { + return; + } + + %asset.moveEmitter(%index - 1, %index - 2); + %this.refreshParticleTitleDropDown(%asset, %index - 1); %asset.refreshAsset(); + %this.onChooseParticleAsset(%asset); } function AssetInspector::RemoveEmitter(%this) { - %emitter = %this.inspector.getInspectObject(); - %asset = %emitter.getOwner(); - %asset.RemoveEmitter(%emitter, true); + %asset = %this.documentAsset(); + %emitter = %this.selectedEmitter(); + + if(!isObject(%asset) || !isObject(%emitter)) + { + return; + } %index = %this.titleDropDown.getSelectedItem(); - %this.titleDropDown.deleteItem(%index); + %asset.RemoveEmitter(%emitter, true); - if(%this.titleDropDown.getItemCount() <= %index) + // Rebuilt rather than deleteItem'd, so the captions cannot drift out of step + // with the emitters they name. + // + // Selection falls back to the emitter that took this one's place, or to the + // last one if this was the last -- and to the EFFECT at index 0 when the one + // removed was the only emitter. That last case is why this is clamped at all: + // it used to clamp to an item index of 0 and then ask for getEmitter(-1). + %count = %asset.getEmitterCount(); + if(%index > %count) { - %index = %this.titleDropDown.getItemCount() - 1; + %index = %count; } - %this.titleDropDown.setCurSel(%index); - %this.inspector.inspect(%asset.getEmitter(%index - 1)); - %this.emitterGraphPage.inspect(%asset, %index - 1); - %this.emitterButtonBar.refreshEnabled(); + + %this.refreshParticleTitleDropDown(%asset, %index); %asset.refreshAsset(); + %this.onChooseParticleAsset(%asset); } +//----------------------------------------------------------------------------- +// What the bar greys itself against. All three read the dropdown, which is the +// thing the buttons act through -- they used to read emitterGraphPage.emitterID, +// a tab page that is only on the book while an emitter is selected. +//----------------------------------------------------------------------------- + function AssetInspector::getMoveEmitterForwardEnabled(%this) { - if(isObject(%this.titleDropDown) && %this.titleDropDown.getSelectedItem() <= 0) + %asset = %this.documentAsset(); + %index = %this.titleDropDown.getSelectedItem(); + + if(!isObject(%asset) || %index <= 0) { return false; } - if(isObject(%this.inspector)) - { - %asset = %this.inspector.getInspectObject(); - %emitterID = %this.emitterGraphPage.emitterID; - return %emitterID != (%asset.getOwner().getEmitterCount() - 1); - } - return false; + // The last emitter is at item index getEmitterCount(). + return %index < %asset.getEmitterCount(); } function AssetInspector::getMoveEmitterBackwardEnabled(%this) { - if(isObject(%this.titleDropDown) && %this.titleDropDown.getSelectedItem() <= 0) - { - return false; - } - if(isObject(%this.inspector)) - { - return %this.emitterGraphPage.emitterID != 0; - } - return false; + return isObject(%this.documentAsset()) && %this.titleDropDown.getSelectedItem() > 1; } function AssetInspector::getRemoveEmitterEnabled(%this) { - if(isObject(%this.titleDropDown) && %this.titleDropDown.getSelectedItem() <= 0) + %asset = %this.documentAsset(); + + if(!isObject(%asset) || %this.titleDropDown.getSelectedItem() <= 0) { return false; } - if(isObject(%this.inspector)) - { - %asset = %this.inspector.getInspectObject(); - return %asset.getOwner().getEmitterCount() > 1; - } - return false; + + // An effect with no emitters at all draws nothing, so the last one is not + // removable. RemoveEmitter above still handles the empty case, because a + // predicate is a greyed button rather than a guarantee. + return %asset.getEmitterCount() > 1; } diff --git a/editor/AssetAdmin/AssetWindow.cs b/editor/AssetAdmin/AssetWindow.cs index 10c17fc50..8568fd2d5 100644 --- a/editor/AssetAdmin/AssetWindow.cs +++ b/editor/AssetAdmin/AssetWindow.cs @@ -46,7 +46,7 @@ Scene = AssetAdmin.AssetScene; Image = %assetID; size = %size; - BlandColor = "1 1 1 1"; + BlendColor = "1 1 1 1"; SceneLayer = 1; Position = "0 0"; BodyType = static; @@ -126,7 +126,7 @@ class = "AssetPreviewSprite"; Scene = AssetAdmin.AssetScene; Animation = %assetID; size = %size; - BlandColor = "1 1 1 1"; + BlendColor = "1 1 1 1"; SceneLayer = 1; Position = "0 0"; BodyType = static; @@ -142,16 +142,28 @@ class = "AssetPreviewSprite"; { AssetAdmin.AssetScene.clear(true); - new ParticlePlayer() + // Fitted to the camera like the image and font previews are, rather than left + // at a hardcoded ten metres. A particle player's own size does not bound what + // it draws -- the emitters do that -- but it is what the emitter offsets and + // the size scale are measured against, so an effect authored around one scale + // arrived at another. + %size = %this.getWorldSize("10 10"); + + %player = new ParticlePlayer() { Scene = AssetAdmin.AssetScene; Particle = %assetID; - size = "10 10"; - BlandColor = "1 1 1 1"; + size = %size; + BlendColor = "1 1 1 1"; SceneLayer = 1; Position = "0 0"; BodyType = static; }; + + // A different object every time -- the scene is cleared above -- so the + // transport, which drives this and nothing else, has to be handed the new one. + AssetAdmin.previewPlayer = %player; + AssetAdmin.showParticleTransport(%player, %assetID); } function AssetWindow::displayFontAsset(%this, %fontAsset, %assetID) @@ -165,7 +177,7 @@ class = "AssetPreviewSprite"; Font = %assetID; fontSize = 4; size = %size; - BlandColor = "1 1 1 1"; + BlendColor = "1 1 1 1"; SceneLayer = 1; Position = "0 0"; BodyType = static; diff --git a/editor/AssetAdmin/Inspector/AssetEmitterInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetEmitterInspectorPane.cs new file mode 100644 index 000000000..53a1fda75 --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetEmitterInspectorPane.cs @@ -0,0 +1,710 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The inspector for one emitter of a particle asset. The dropdown in the title +// bar chooses which; index 0 is the asset itself and gets +// AssetParticleInspectorPane instead. +// +// This is the pane the whole exercise was for. An emitter registers about thirty +// persistent fields, and which of them are connected to anything depends on four +// or five of the others -- a single-particle emitter ignores ten, a POINT emitter +// ignores its size and its angle, a fixed-aspect emitter ignores every Size-Y +// curve. The generic inspector listed all thirty flat, so the one thing it could +// not tell you was which knobs were live. +// +// A header, then five blocks laid out by the same reflowing grid as every other +// pane: +// +// Emission where particles are born +// Aim & Orientation which way they are sent and which way they face +// Particle Image what one looks like +// Particle the switches that change what the other blocks mean +// Render how it is drawn and in what order +// +// THE BLOCKS ARE SIZED BEFORE THEY ARE THEMED. A grid row is as tall as its +// tallest cell, so blocks of unequal length do not make a short column and a long +// one -- they make columns of the SAME height with the short ones mostly empty, +// and the whole pane reads as badly laid out rather than as densely packed. Five +// rows each is therefore a constraint on the grouping, not an outcome of it, and +// the first cut (7 / 4 / 2 / 6 / 6) failed it badly. +// +// Two of the placements follow from that constraint and are worth defending on +// their own terms as well: +// +// PivotPoint sits with Orientation, not with the particle switches, because it +// is the point a particle is positioned and ROTATED about -- an orientation +// concern wherever it is filed. +// +// AlphaTest sits with Particle Image, not with Render, because it is a +// threshold on the image's own alpha channel. It belongs beside the image whose +// transparency it reads. +// +// Aiming (IsTargeting / TargetPosition) moved out of Emission and in beside +// Orientation, because both answer "which way", where Emission answers "where". +// +// The name and the "Emitter 1 of 2" line are the HEADER, above the grid and +// across the full width, rather than a sixth block -- a two-row Identity block +// beside a five-row one is the same failure in miniature. The same reasoning puts +// the image pane's warning line outside its grid. +// +// TWO WAYS OF SAYING "this does not apply", and the difference is deliberate. +// +// SWAP when the fields are alternatives -- an emitter draws an image OR an +// animation, never both, and an orientation has exactly one offset. The +// arm that is not in use is hidden outright, because showing an +// Animation row beside an Image row invites you to fill in both and one +// of them would silently win. +// +// GREY when the field is real, holds a value, and this mode simply does not +// read it. Hiding those would lose the value from sight and make the +// pane jump around as you tried modes; greying keeps it where it was +// and puts the reason in the tooltip. +// +// The 32 graph fields (Quantity, SizeX, Speed, Spin, the colour channels, and +// each one's Variation and Life curves) are not here. Every one of them is a +// curve over time rather than a number, and the Emitter Graph tab beside this one +// is where a curve is drawn. +// +// Absent, each for a checkable reason: +// PhysicsParticle, PhysicsParticleType their addProtectedField calls are +// commented out in ParticleAssetEmitter.cc, +// so they are not fields at all -- the +// members and the enum table exist, and +// nothing reads them +// hidden, locked SimObject bookkeeping, as everywhere +//----------------------------------------------------------------------------- + +$AssetEmitterInspectorPane::cellWidth = 300; +$AssetEmitterInspectorPane::cellCount = 5; +$AssetEmitterInspectorPane::headerWidth = 300; + +function AssetEmitterInspectorPane::onAdd(%this) +{ + // onAdd does not chain, so the shared setup runs from here. + %this.init(); +} + +//----------------------------------------------------------------------------- +// Construction. +//----------------------------------------------------------------------------- + +function AssetEmitterInspectorPane::buildPane(%this) +{ + %this.buildHeader(); + + %grid = %this.makeCellGrid(0, $AssetEmitterInspectorPane::cellWidth, + $AssetEmitterInspectorPane::cellCount); + %this.add(%grid); + %this.contentGrid = %grid; + + // Reading order across a wide screen: where they are born, which way they go, + // what they look like, how they behave, how they are drawn. + %this.buildEmissionCell(%grid); + %this.buildOrientationCell(%grid); + %this.buildImageCell(%grid); + %this.buildBehaviorCell(%grid); + %this.buildRenderCell(%grid); +} + +// The name and the line placing this emitter within the effect, above the grid +// and outside it. The name box keeps a column's width rather than the pane's -- +// a name is a short thing, and a text box a metre wide invites a sentence. +function AssetEmitterInspectorPane::buildHeader(%this) +{ + %chain = %this.makeChain(0, 4); + %this.add(%chain); + %this.headerChain = %chain; + + %row = %this.addFieldRow(%chain, "EmitterName", %this.labelFor("EmitterName"), "text", ""); + %row.HorizSizing = "right"; + %row.setExtent($AssetEmitterInspectorPane::headerWidth, %row.rowHeight); + + %this.infoLabel = %this.makeInfoLabel(%chain, "labelProfile"); + %this.infoLabel.textWrap = true; + %this.infoLabel.textExtend = true; + %this.infoLabel.vAlign = "top"; +} + +// addFieldRow takes the label and the kind as arguments rather than asking the +// tables for them, so every call here would otherwise repeat the same lookups. +function AssetEmitterInspectorPane::addField(%this, %container, %field) +{ + return %this.addFieldRow(%container, %field, %this.labelFor(%field), + %this.kindFor(%field), %this.enumItemsFor(%field)); +} + +function AssetEmitterInspectorPane::buildEmissionCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.emissionChain = %chain; + + // Type first: it decides whether the two rows under it are read. + %this.addField(%chain, "EmitterType"); + %this.addField(%chain, "EmitterSize"); + %this.addField(%chain, "EmitterAngle"); + %this.addField(%chain, "EmitterOffset"); + %this.addField(%chain, "LinkEmissionRotation"); +} + +function AssetEmitterInspectorPane::buildImageCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.imageChain = %chain; + + // Not a field. There is no StaticMode on an emitter: the mode is a side + // effect of whichever of Image and Animation was written last, so the picker + // exists to make that choice sayable rather than accidental. It is built + // WITHOUT registering, so the refresh loop never tries to read a field of this + // name off the emitter -- refreshExtras sets it instead, and writeField below + // turns a change of it into the write that actually moves the mode. + %this.sourceRow = %this.makeFieldRow(%chain, "Source", "Drawn With", "dropdown", ""); + %this.sourceRow.fillItems("Static Image" TAB "Animation"); + + %this.addField(%chain, "Image"); + %this.addField(%chain, "RandomImageFrame"); + %this.addField(%chain, "Frame"); + %this.addField(%chain, "NamedFrame"); + %this.addField(%chain, "Animation"); + + // A threshold on this image's own alpha channel, so it belongs beside the + // image rather than in Render with the blend rows. + %this.addField(%chain, "AlphaTest"); +} + +// Aim and orientation: which way particles are sent, and which way they face +// once they are out. Only one of the three orientation arms is ever on show, so +// the block is five rows in the default state and six for the two arms that +// carry a second control. +function AssetEmitterInspectorPane::buildOrientationCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.orientationChain = %chain; + + %this.addField(%chain, "IsTargeting"); + %this.addField(%chain, "TargetPosition"); + + %this.addField(%chain, "OrientationType"); + %this.addField(%chain, "FixedAngleOffset"); + %this.addField(%chain, "AlignedAngleOffset"); + %this.addField(%chain, "KeepAligned"); + %this.addField(%chain, "RandomAngleOffset"); + %this.addField(%chain, "RandomArc"); + + %this.addField(%chain, "PivotPoint"); +} + +function AssetEmitterInspectorPane::buildBehaviorCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.behaviorChain = %chain; + + %this.addField(%chain, "SingleParticle"); + %this.addField(%chain, "FixedAspect"); + %this.addField(%chain, "FixedForceAngle"); + %this.addField(%chain, "AttachPositionToEmitter"); + %this.addField(%chain, "AttachRotationToEmitter"); +} + +function AssetEmitterInspectorPane::buildRenderCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.renderChain = %chain; + + %this.addField(%chain, "OldestInFront"); + %this.addField(%chain, "IntenseParticles"); + %this.addField(%chain, "BlendMode"); + %this.addField(%chain, "SrcBlendFactor"); + %this.addField(%chain, "DstBlendFactor"); +} + +//----------------------------------------------------------------------------- +// The field tables. +//----------------------------------------------------------------------------- + +function AssetEmitterInspectorPane::labelFor(%this, %field) +{ + switch$(%field) + { + case "EmitterName": return "Emitter Name"; + + case "EmitterType": return "Shape"; + case "EmitterSize": return "Shape Size"; + case "EmitterAngle": return "Shape Angle"; + case "EmitterOffset": return "Offset"; + case "LinkEmissionRotation": return "Follow Player Rotation"; + case "IsTargeting": return "Aim At A Point"; + case "TargetPosition": return "Target Position"; + + case "Image": return "Image"; + case "RandomImageFrame": return "Random Frame"; + case "Frame": return "Frame"; + case "NamedFrame": return "Named Frame"; + case "Animation": return "Animation"; + + case "OrientationType": return "Orientation"; + case "FixedAngleOffset": return "Angle"; + case "AlignedAngleOffset": return "Angle From Travel"; + case "KeepAligned": return "Keep Aligned"; + case "RandomAngleOffset": return "Centre Angle"; + case "RandomArc": return "Arc"; + + case "SingleParticle": return "Single Particle"; + case "FixedAspect": return "Fixed Aspect"; + case "FixedForceAngle": return "Fixed Force Angle"; + case "AttachPositionToEmitter": return "Attach Position"; + case "AttachRotationToEmitter": return "Attach Rotation"; + case "PivotPoint": return "Pivot Point"; + + case "OldestInFront": return "Oldest In Front"; + case "IntenseParticles": return "Intense (Additive)"; + case "BlendMode": return "Blending"; + case "SrcBlendFactor": return "Source Factor"; + case "DstBlendFactor": return "Destination Factor"; + case "AlphaTest": return "Alpha Test"; + } + + return %field; +} + +function AssetEmitterInspectorPane::kindFor(%this, %field) +{ + switch$(%field) + { + case "EmitterType" or "OrientationType" or "SrcBlendFactor" or "DstBlendFactor": + return "enum"; + + case "EmitterSize" or "EmitterOffset" or "TargetPosition" or "PivotPoint": + return "pointf"; + + case "EmitterAngle" or "FixedForceAngle" or "FixedAngleOffset" or "AlignedAngleOffset" or + "RandomAngleOffset" or "RandomArc" or "AlphaTest": + return "decimal"; + + case "Frame": + return "number"; + + case "Image" or "Animation": + return "asset"; + + case "LinkEmissionRotation" or "IsTargeting" or "RandomImageFrame" or "KeepAligned" or + "SingleParticle" or "FixedAspect" or "AttachPositionToEmitter" or + "AttachRotationToEmitter" or "OldestInFront" or "IntenseParticles" or "BlendMode": + return "bool"; + } + + return "text"; +} + +function AssetEmitterInspectorPane::enumItemsFor(%this, %field) +{ + // The engine's own labels, from the enum tables the fields are registered + // with. The drop-down's search ignores case, so the readable ones can be + // written as words; the GL blend factors are left exactly as they are, + // because they are names rather than English and an editor that renamed them + // would not match anything written about them. + switch$(%field) + { + // EmitterTypeTable, ParticleAssetEmitter.cc + case "EmitterType": return "Point" TAB "Line" TAB "Box" TAB "Disk" TAB "Ellipse" TAB "Torus"; + + // OrientationTypeTable, ParticleAssetEmitter.cc + case "OrientationType": return "Fixed" TAB "Aligned" TAB "Random"; + + // srcBlendFactorTable, SceneObject.cc + case "SrcBlendFactor": return "ZERO" TAB "ONE" TAB "DST_COLOR" TAB "ONE_MINUS_DST_COLOR" TAB + "SRC_ALPHA" TAB "ONE_MINUS_SRC_ALPHA" TAB "DST_ALPHA" TAB "ONE_MINUS_DST_ALPHA" TAB + "SRC_ALPHA_SATURATE"; + + // dstBlendFactorTable, SceneObject.cc + case "DstBlendFactor": return "ZERO" TAB "ONE" TAB "SRC_COLOR" TAB "ONE_MINUS_SRC_COLOR" TAB + "SRC_ALPHA" TAB "ONE_MINUS_SRC_ALPHA" TAB "DST_ALPHA" TAB "ONE_MINUS_DST_ALPHA"; + } + + return ""; +} + +// The emitter's two asset rows want different pickers, which is the whole reason +// EditorFieldRow learned to be told. +function AssetEmitterInspectorPane::assetTypeFor(%this, %field) +{ + switch$(%field) + { + case "Image": return "ImageAsset"; + case "Animation": return "AnimationAsset"; + } + + return ""; +} + +// None of these fields carries a doc string in the engine, so the pane is where +// the explanation lives. The ones whose names already say it are left out. +function AssetEmitterInspectorPane::tipFor(%this, %field) +{ + switch$(%field) + { + case "Source": return "Whether each particle draws a still frame of an image asset or plays an " @ + "animation. An emitter is one or the other -- choosing here clears the other one."; + + case "EmitterType": return "The shape particles are born on. Point emits from a single spot. Line " @ + "uses the X size as its half-length. Box and Disk fill their area; Ellipse and Torus emit on " @ + "the edge, Torus between an outer and an inner radius."; + + case "EmitterSize": return "The shape's dimensions, in world units."; + + case "EmitterAngle": return "How far the shape itself is turned, in degrees. This rotates where " @ + "particles are born, not which way they travel."; + + case "EmitterOffset": return "Where the shape sits relative to the player's position."; + + case "LinkEmissionRotation": return "Add the player's own rotation to the emission angle, so " @ + "turning the player turns the spray with it."; + + case "IsTargeting": return "Aim particles at a fixed point instead of using the Emission Angle " @ + "graph. The Emission Arc graph still spreads them around that aim."; + + case "TargetPosition": return "The point particles are aimed at while Aim At A Point is on."; + + case "Image": return "The image asset each particle draws a frame of."; + + case "RandomImageFrame": return "Give every particle a random frame of the image instead of the " @ + "one chosen below. A sheet of different sparks or leaves is what this is for."; + + case "Frame": return "Which frame of the image each particle draws, counting from zero."; + + case "NamedFrame": return "Which named cell of the image each particle draws. Only an image with " @ + "explicit named cells has these."; + + case "Animation": return "The animation asset each particle plays. Every particle starts its own " @ + "copy from the beginning."; + + case "OrientationType": return "Which way a particle faces. Fixed points them all one way. Aligned " @ + "points them along their direction of travel. Random gives each one its own angle."; + + case "FixedAngleOffset": return "The angle every particle faces, in degrees."; + + case "AlignedAngleOffset": return "Added to the direction of travel, in degrees -- so art drawn " @ + "pointing up rather than right can be corrected here."; + + case "KeepAligned": return "Keep turning particles as they change direction, instead of aiming " @ + "them once when they are born."; + + case "RandomAngleOffset": return "The middle of the range random angles are drawn from, in degrees."; + + case "RandomArc": return "How wide that range is, in degrees. 360 is a completely free angle."; + + case "SingleParticle": return "Emit one immortal particle at the offset instead of a stream. It " @ + "never moves and never expires, so the whole Emission block, and the Lifetime and Quantity " @ + "graphs, stop being read."; + + case "FixedAspect": return "Keep particles square: the Size-Y graphs are ignored and height " @ + "follows width."; + + case "FixedForceAngle": return "Which way the Fixed Force graph pushes, in degrees. 90 is up, " @ + "which is what a rising flame or smoke wants."; + + case "AttachPositionToEmitter": return "Carry particles with the player as it moves, instead of " @ + "leaving them behind in the world where they were born."; + + case "AttachRotationToEmitter": return "Turn those carried particles with the player as well. " @ + "Only read while Attach Position is on."; + + case "PivotPoint": return "The point within a particle that it is positioned and rotated about, " @ + "as a fraction of its size from the centre."; + + case "OldestInFront": return "Draw the oldest particles on top of the newest, rather than the " @ + "other way round."; + + case "IntenseParticles": return "Force additive blending, which makes overlapping particles glow. " @ + "Overrides the three blending rows below."; + + case "BlendMode": return "Blend particles with what is behind them. Turning this off draws them " @ + "opaque, edges and all."; + + case "SrcBlendFactor": return "How much of the particle's own colour goes into the blend. With " @ + "Destination Factor, these are the two halves of the OpenGL blend equation."; + + case "DstBlendFactor": return "How much of what is already on screen survives the blend. " @ + "ONE_MINUS_SRC_ALPHA is ordinary transparency; ONE is additive."; + + case "AlphaTest": return "Discard any pixel less opaque than this, before blending. Below zero " @ + "turns the test off, which is the default."; + } + + return ""; +} + +//----------------------------------------------------------------------------- +// Reading and writing. +//----------------------------------------------------------------------------- + +// The mode is not a persistent field -- it is a side effect of which of setImage +// and setAnimation ran last. Ask the engine rather than inferring it from which +// asset is set: an emitter switched to animation before an animation has been +// chosen is in animated mode holding nothing, and "no animation asset" would +// read that as static. isStaticMode exists for this. +function AssetEmitterInspectorPane::isAnimated(%this) +{ + return isObject(%this.target) && !%this.target.isStaticMode(); +} + +function AssetEmitterInspectorPane::writeField(%this, %field, %value) +{ + switch$(%field) + { + // The picker's own row. Writing whichever field defines the wanted mode IS + // the mode switch: both setters set the mode and clear the other asset. + // Writing the arm's current value rather than an empty one keeps a choice + // that was made earlier and then switched away from. + case "Source": + if(%value $= "Animation") + { + %this.target.setFieldValue("Animation", %this.target.getAnimation()); + } + else + { + %this.target.setFieldValue("Image", %this.target.getImage()); + } + return; + + // The one emitter setter with no refreshAsset of its own, and deliberately + // so -- a target position is aimed at something that moves, and AngleToy + // writes it on every mouse move. See the comment on + // ParticleAssetEmitter::setTargetPosition. An editor writing it once has to + // ask for the refresh itself, or nothing is marked dirty and the preview + // does not move. + case "TargetPosition": + %this.target.setFieldValue(%field, %value); + %this.target.refreshAsset(); + return; + } + + %this.target.setFieldValue(%field, %value); +} + +// The refresh chain announces the ASSET. This pane's target is one of its +// emitters, so the base class's "is this mine?" test has to be asked one step up +// -- otherwise every emitter edit would bounce off and the pane would show +// whatever the engine had clamped the value to only after the next selection. +function AssetEmitterInspectorPane::onAssetRefreshed(%this, %asset) +{ + if(%this.committing || !isObject(%this.target)) + { + return; + } + if(%this.target.getOwner() != %asset) + { + return; + } + + %this.refresh(); +} + +//----------------------------------------------------------------------------- +// Loading. Everything the row loop does not reach. +//----------------------------------------------------------------------------- + +function AssetEmitterInspectorPane::refreshExtras(%this) +{ + %this.sourceRow.setValue(%this.isAnimated() ? "Animation" : "Static Image"); + + %this.infoLabel.setText(%this.describeEmitter(%this.target)); + %this.applyGating(); +} + +// Where this emitter sits in the effect and what it draws. The dropdown above +// says which one is selected but not how many there are, and render order is the +// reason the order matters. +function AssetEmitterInspectorPane::describeEmitter(%this, %emitter) +{ + %asset = %emitter.getOwner(); + if(!isObject(%asset)) + { + return ""; + } + + %count = %asset.getEmitterCount(); + %index = -1; + for(%i = 0; %i < %count; %i++) + { + if(%asset.getEmitter(%i) == %emitter) + { + %index = %i; + break; + } + } + + %line = "Emitter" SPC (%index + 1) SPC "of" SPC %count; + + // Drawn in list order, so the last one is on top of the rest. + if(%count > 1) + { + if(%index == %count - 1) + { + %line = %line @ ", drawn last (on top)"; + } + else if(%index == 0) + { + %line = %line @ ", drawn first (behind)"; + } + } + + return %line @ "."; +} + +//----------------------------------------------------------------------------- +// The gating. One method, run on every refresh, because every rule here reads a +// field the row loop has just reloaded. +//----------------------------------------------------------------------------- + +function AssetEmitterInspectorPane::applyGating(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + %this.gateSource(); + %this.gateEmission(); + %this.gateOrientation(); + %this.gateBehavior(); + %this.gateRender(); + + // Swapping arms changes how tall the blocks are, and a chain lays out only + // the children it can see -- nothing re-lays it out on setVisible. + %this.forceLayout(); +} + +// SWAP. An emitter draws one or the other, never both. +function AssetEmitterInspectorPane::gateSource(%this) +{ + %animated = %this.isAnimated(); + + %this.row["Animation"].setVisible(%animated); + + %this.row["Image"].setVisible(!%animated); + %this.row["RandomImageFrame"].setVisible(!%animated); + + // SWAP again, one level down: an image is addressed by number or by name, and + // which one is in play is decided by whichever was written last. Only an image + // with explicit named cells can be addressed by name at all. + %named = !%animated && %this.target.isUsingNamedImageFrame(); + %this.row["Frame"].setVisible(!%animated && !%named); + %this.row["NamedFrame"].setVisible(%named); + + // GREY. A random frame is still a frame of the same image, so the row below + // keeps its value -- it is simply not the one used. + if(!%animated) + { + %this.setRowsEnabled("Frame NamedFrame", !%this.target.getRandomImageFrame(), + "Random Frame is on, so each particle picks its own and this one is not used."); + } +} + +function AssetEmitterInspectorPane::gateEmission(%this) +{ + // GREY, the widest rule on the pane. A single particle sits at the offset and + // never moves, so nothing about the shape or the direction is read. + if(%this.target.getSingleParticle()) + { + %this.setRowsEnabled("EmitterType EmitterSize EmitterAngle LinkEmissionRotation IsTargeting TargetPosition", + false, "Single Particle is on: one particle sits at the offset and never moves, so nothing " @ + "about the emitter's shape or direction is read."); + return; + } + + %this.setRowsEnabled("EmitterType LinkEmissionRotation IsTargeting", true, ""); + + // GREY. A point has no size and no angle; a torus has a size but its branch in + // ParticlePlayer never applies the rotation. + %type = %this.target.getEmitterType(); + + %this.setRowsEnabled("EmitterSize", %type !$= "POINT", + "A Point emitter emits from one spot, so it has no size. Choose another shape to use this."); + + %this.setRowsEnabled("EmitterAngle", %type !$= "POINT" && %type !$= "TORUS", + (%type $= "POINT") ? + "A Point emitter emits from one spot, so turning it changes nothing." : + "A Torus is the same shape whichever way it is turned, and the engine does not apply this to it."); + + // GREY. Aiming replaces the Emission Angle graph rather than this row, so what + // goes inert here is the target when aiming is off. + %this.setRowsEnabled("TargetPosition", %this.target.getIsTargeting(), + "Only read while Aim At A Point is on."); +} + +// SWAP. Each orientation has exactly one set of controls; the other two would be +// three more rows that do nothing. +function AssetEmitterInspectorPane::gateOrientation(%this) +{ + %type = %this.target.getOrientationType(); + + %this.row["FixedAngleOffset"].setVisible(%type $= "FIXED"); + + %this.row["AlignedAngleOffset"].setVisible(%type $= "ALIGNED"); + %this.row["KeepAligned"].setVisible(%type $= "ALIGNED"); + + %this.row["RandomAngleOffset"].setVisible(%type $= "RANDOM"); + %this.row["RandomArc"].setVisible(%type $= "RANDOM"); +} + +function AssetEmitterInspectorPane::gateBehavior(%this) +{ + // GREY. Attaching rotation is read only from inside the position-attach test, + // so on its own it does nothing at all. + %this.setRowsEnabled("AttachRotationToEmitter", %this.target.getAttachPositionToEmitter(), + "Only read while Attach Position is on -- particles have to be carried with the player before " @ + "they can be turned with it."); +} + +function AssetEmitterInspectorPane::gateRender(%this) +{ + // GREY. Intense particles force additive blending before the blend rows are + // consulted at all. + if(%this.target.getIntenseParticles()) + { + %this.setRowsEnabled("BlendMode SrcBlendFactor DstBlendFactor", false, + "Intense (Additive) is on, which forces additive blending and overrides these."); + return; + } + + %this.setRowsEnabled("BlendMode", true, ""); + + // GREY. With blending off there is nothing for the two factors to weigh. + %this.setRowsEnabled("SrcBlendFactor DstBlendFactor", %this.target.getBlendMode(), + "Blending is off, so these are not used."); +} + +// The commit rebuilt the preview player's emitter nodes, and it may have renamed +// this emitter -- which the dropdown in the title bar is showing. +function AssetEmitterInspectorPane::afterCommit(%this) +{ + if(isObject(AssetAdmin.inspector)) + { + AssetAdmin.inspector.refreshEmitterLabels(); + } + + if(isObject(AssetAdmin.particleTransportBar)) + { + AssetAdmin.particleTransportBar.refresh(); + } +} diff --git a/editor/AssetAdmin/Inspector/AssetInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs index 498b56037..c35d68beb 100644 --- a/editor/AssetAdmin/Inspector/AssetInspectorPane.cs +++ b/editor/AssetAdmin/Inspector/AssetInspectorPane.cs @@ -219,6 +219,7 @@ class = "EditorFieldRow"; kind = %kind; enumItems = %enumItems; editorHeight = %this.editorHeightFor(%field); + assetType = %this.assetTypeFor(%field); owner = %this; }; %container.add(%row); @@ -308,6 +309,14 @@ class = "EditorFieldRow"; return 0; } +// What kind of asset an "asset" row offers to pick. Empty leaves the row on its +// own default of ImageAsset, which is what every asset row on every pane wanted +// until an emitter turned out to reference an animation as readily as an image. +function AssetInspectorPane::assetTypeFor(%this, %field) +{ + return ""; +} + //----------------------------------------------------------------------------- // Binding. There is no rebuild here at all: the pane is built once for the kind // of asset it edits, and binding only ever loads values -- so a selection change diff --git a/editor/AssetAdmin/Inspector/AssetParticleInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetParticleInspectorPane.cs new file mode 100644 index 000000000..c56316dc8 --- /dev/null +++ b/editor/AssetAdmin/Inspector/AssetParticleInspectorPane.cs @@ -0,0 +1,339 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The inspector for a particle asset, in place of the generic one. This is the +// ASSET half; the emitter dropdown in the title bar swaps to +// AssetEmitterInspectorPane for everything below index 0. +// +// A ParticleAsset registers two persistent fields of its own -- Lifetime and +// LifeMode -- and that is genuinely all of it. Everything else an effect is made +// of lives either on its emitters or in the nine scale graphs, and neither is a +// value a text box can hold. So this pane is small on purpose, and what it adds +// over a list of two fields is the pair of readouts: what the effect is made of, +// and whether any of it will actually draw. +// +// Three blocks, the shape AssetFontInspectorPane uses: +// +// Identity the name and the category +// Effect how long it runs and what it does when it gets there +// Description the prose the library is searched by +// +// The nine *Scale fields (LifetimeScale, QuantityScale, SizeXScale ... ) are not +// here and cannot be: each is a curve over the effect's age rather than a number, +// and the Scale Graph tab beside this one is where a curve is drawn. Same for the +// emitters, which are objects rather than values. +// +// AssetName is shown but not editable, for the reason the other panes give: +// AssetBase::setAssetName does nothing once the asset manager owns the asset, so +// a box that accepted typing would silently do nothing. A real rename is +// AssetDatabase.renameDeclaredAsset. +// +// Absent, each for a checkable reason: +// AssetInternal, AssetPrivate they exist to keep an asset OUT of the editor +// asset id, asset file the module and the name are on show, and the +// file is where the manager put it +//----------------------------------------------------------------------------- + +$AssetParticleInspectorPane::cellWidth = 300; +$AssetParticleInspectorPane::cellCount = 3; +$AssetParticleInspectorPane::descriptionHeight = 150; + +function AssetParticleInspectorPane::onAdd(%this) +{ + // onAdd does not chain, so the shared setup runs from here. + %this.init(); +} + +//----------------------------------------------------------------------------- +// Construction. +//----------------------------------------------------------------------------- + +function AssetParticleInspectorPane::buildPane(%this) +{ + %grid = %this.makeCellGrid(0, $AssetParticleInspectorPane::cellWidth, + $AssetParticleInspectorPane::cellCount); + %this.add(%grid); + %this.contentGrid = %grid; + + %this.buildIdentityCell(%grid); + %this.buildEffectCell(%grid); + %this.buildDescriptionCell(%grid); + + %this.buildWarning(); + + %this.nameRow.setEnabled(false, "Renaming an asset changes its id and every file that refers to it, " @ + "so it is not something the inspector can do safely on its own."); +} + +// addFieldRow takes the label and the kind as arguments rather than asking the +// tables for them, so every call here would otherwise repeat the same lookups. +function AssetParticleInspectorPane::addField(%this, %container, %field) +{ + return %this.addFieldRow(%container, %field, %this.labelFor(%field), + %this.kindFor(%field), %this.enumItemsFor(%field)); +} + +function AssetParticleInspectorPane::buildIdentityCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.identityChain = %chain; + + %this.nameRow = %this.addField(%chain, "AssetName"); + %this.addField(%chain, "AssetCategory"); +} + +function AssetParticleInspectorPane::buildEffectCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.effectChain = %chain; + + // LifeMode first, because it decides whether the Lifetime under it means + // anything at all. + %this.lifeModeRow = %this.addField(%chain, "LifeMode"); + %this.lifetimeRow = %this.addField(%chain, "Lifetime"); + + // What the effect is made of. Read-only: an emitter is not a value, and the + // dropdown in the title bar is where one is chosen. + %this.infoLabel = %this.makeInfoLabel(%chain, "labelProfile"); + %this.infoLabel.textWrap = true; + %this.infoLabel.textExtend = true; + %this.infoLabel.vAlign = "top"; +} + +function AssetParticleInspectorPane::buildDescriptionCell(%this, %grid) +{ + %chain = %this.makeCell(%grid, 4); + %this.descriptionChain = %chain; + + %this.addField(%chain, "AssetDescription"); +} + +// Below the grid rather than in a block. A warning is a sentence, and a sentence +// read across the whole pane is one or two lines where the same sentence in a +// third of it is five. +function AssetParticleInspectorPane::buildWarning(%this) +{ + %this.warningLabel = %this.makeInfoLabel(%this, "overrideLabelProfile"); + %this.warningLabel.textWrap = true; + %this.warningLabel.textExtend = true; + %this.warningLabel.vAlign = "top"; + %this.warningLabel.setVisible(false); +} + +//----------------------------------------------------------------------------- +// The field tables. +//----------------------------------------------------------------------------- + +function AssetParticleInspectorPane::labelFor(%this, %field) +{ + switch$(%field) + { + case "AssetName": return "Asset Name"; + case "AssetCategory": return "Category"; + case "AssetDescription": return "Description"; + case "LifeMode": return "Life Mode"; + case "Lifetime": return "Lifetime (seconds)"; + } + + return %field; +} + +function AssetParticleInspectorPane::kindFor(%this, %field) +{ + switch$(%field) + { + case "LifeMode": return "enum"; + case "Lifetime": return "decimal"; + case "AssetDescription": return "multiline"; + } + + return "text"; +} + +function AssetParticleInspectorPane::enumItemsFor(%this, %field) +{ + // The engine's labels are INFINITE, CYCLE, STOP and KILL (LifeModeTable in + // ParticleAsset.cc). Both the lookup on the way in and the drop-down's own + // search ignore case, so these can read as words. + if(%field $= "LifeMode") + { + return "Infinite" TAB "Cycle" TAB "Stop" TAB "Kill"; + } + + return ""; +} + +function AssetParticleInspectorPane::tipFor(%this, %field) +{ + switch$(%field) + { + case "LifeMode": return "What happens when the effect reaches its lifetime. Infinite never gets " @ + "there. Cycle starts it again from the beginning. Stop stops emitting and lets the particles " @ + "already out live their own lifetimes. Kill deletes the player outright, which is what a " @ + "one-shot explosion wants."; + + case "Lifetime": return "How long the effect runs before its life mode takes over, in seconds. " @ + "This is the EFFECT's clock -- the one the scale graphs are drawn against -- not how long an " @ + "individual particle lasts, which is the emitter's Lifetime."; + + case "AssetCategory": return "A word of your own for grouping assets in the library. Nothing in " @ + "the engine reads it."; + + case "AssetDescription": return "What this effect is for. The library's search box reads it."; + } + + return ""; +} + +function AssetParticleInspectorPane::editorHeightFor(%this, %field) +{ + if(%field $= "AssetDescription") + { + return $AssetParticleInspectorPane::descriptionHeight; + } + + return 0; +} + +//----------------------------------------------------------------------------- +// Loading. Everything the row loop does not reach. +//----------------------------------------------------------------------------- + +function AssetParticleInspectorPane::refreshExtras(%this) +{ + %this.infoLabel.setText(%this.describeEffect(%this.target)); + %this.applyGating(); + %this.showWarning(%this.warningFor(%this.target)); +} + +// The pane's one gating rule. An infinite effect never reaches its lifetime, so +// the number is not read -- greyed rather than hidden, because it still holds a +// value that comes back the moment the mode changes. +function AssetParticleInspectorPane::applyGating(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + %infinite = (%this.target.getLifeMode() $= "INFINITE"); + + %this.setRowsEnabled("Lifetime", !%infinite, + "An infinite effect never reaches its lifetime, so this is not read. Choose another life mode " @ + "to use it."); +} + +// What the effect is made of. Asked of the asset, stored nowhere on it, which is +// why it is a line of text rather than a set of rows. +function AssetParticleInspectorPane::describeEffect(%this, %asset) +{ + %count = %asset.getEmitterCount(); + + if(%count == 0) + { + return "No emitters."; + } + + %names = ""; + for(%i = 0; %i < %count; %i++) + { + %name = %asset.getEmitter(%i).getEmitterName(); + if(%name $= "") + { + %name = "(unnamed)"; + } + + %names = (%names $= "") ? %name : (%names @ ", " @ %name); + } + + return %count SPC ((%count == 1) ? "emitter" : "emitters") @ ":" SPC %names @ "."; +} + +// In the order they matter. Only the first is shown, because the first is the one +// that has to be fixed before any of the others can be judged. +function AssetParticleInspectorPane::warningFor(%this, %asset) +{ + // ParticleAsset::isAssetValid is exactly this test, and an invalid particle + // asset draws nothing at all. + if(%asset.getEmitterCount() == 0) + { + return "This effect has no emitters, so nothing will be drawn. Add one with the + button beside " @ + "the dropdown above."; + } + + // An emitter with neither an image nor an animation is skipped outright by + // ParticlePlayer, both when it builds its emitter nodes and when it renders -- + // silently, so without this there is nothing to see and nothing said. + %blank = 0; + %count = %asset.getEmitterCount(); + for(%i = 0; %i < %count; %i++) + { + %emitter = %asset.getEmitter(%i); + if(%emitter.getImage() $= "" && %emitter.getAnimation() $= "") + { + %blank++; + } + } + + if(%blank > 0) + { + if(%blank == %count) + { + return ((%count == 1) ? "This effect's emitter has" : "None of this effect's emitters have") SPC + "an image or an animation, so nothing will be drawn. Choose one on the emitter's page."; + } + + return %blank SPC "of this effect's" SPC %count SPC "emitters have no image or animation and will " @ + "not be drawn. Choose one on each emitter's page."; + } + + return ""; +} + +// forceLayout only when the visibility actually changed: a chain skips hidden +// children when it lays out, and nothing re-lays it out on setVisible. +function AssetParticleInspectorPane::showWarning(%this, %text) +{ + %wanted = (%text !$= ""); + %changed = (%wanted != %this.warningLabel.isVisible()); + + %this.warningLabel.setText(%text); + %this.warningLabel.setVisible(%wanted); + + if(%changed) + { + %this.forceLayout(); + } +} + +// The commit ends in refreshAsset, which rebuilds the preview player's emitter +// nodes -- so the transport's play/stop state is no longer whatever it was, and +// the solo it was holding has been rebuilt away. The bar is the only thing that +// knows either, so it is the only thing that has to be told. +function AssetParticleInspectorPane::afterCommit(%this) +{ + if(isObject(AssetAdmin.particleTransportBar)) + { + AssetAdmin.particleTransportBar.refresh(); + } +} diff --git a/editor/AssetAdmin/Inspector/exec.cs b/editor/AssetAdmin/Inspector/exec.cs index 98d2823af..66a465399 100644 --- a/editor/AssetAdmin/Inspector/exec.cs +++ b/editor/AssetAdmin/Inspector/exec.cs @@ -4,3 +4,5 @@ exec("./AssetImageInspectorPane.cs"); exec("./AssetFontInspectorPane.cs"); exec("./AssetSoundInspectorPane.cs"); +exec("./AssetParticleInspectorPane.cs"); +exec("./AssetEmitterInspectorPane.cs"); diff --git a/editor/AssetAdmin/ParticleEditor/AssetParticleTransportBar.cs b/editor/AssetAdmin/ParticleEditor/AssetParticleTransportBar.cs new file mode 100644 index 000000000..8afd16b36 --- /dev/null +++ b/editor/AssetAdmin/ParticleEditor/AssetParticleTransportBar.cs @@ -0,0 +1,306 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The bar over the particle preview: restart, play/pause, stop, a speed, and the +// two switches that reduce the effect to the one emitter being tuned. +// +// The chrome is EditorTransportBar in EditorCore, shared with the animation +// preview's bar; what is here is what these buttons do. +// +// Play and Pause are two buttons with one hidden rather than one toggle, for the +// reason the animation bar gives: a toggle says "this setting is on", a transport +// says "here is what will happen if you press me", and two buttons cannot get +// stuck showing the wrong state because there is no state to get stuck -- only +// whichever button is on show. +// +// SOLO AND EMITTER-OFF ARE NOT ASSET STATE. They are ParticlePlayer::setEmitterVisible +// and setEmitterPaused on the preview's own player, so nothing they do can dirty +// the effect or reach its file. That also means they do not survive: every edit +// ends in refreshAsset, ParticlePlayer::onAssetRefreshed rebuilds every emitter +// node, and the rebuilt nodes are all visible and running again. reapply() is +// called from the panes' afterCommit for exactly that reason. +// +// Both act on the emitter the title dropdown has selected, which is the one whose +// fields are on show -- so "solo" always means "the one I am looking at". On the +// effect itself (index 0) there is no emitter to isolate and both stand down. +//----------------------------------------------------------------------------- + +$AssetParticleTransportBar::speeds = "0.1 0.25 0.5 1 2"; +$AssetParticleTransportBar::defaultSpeedIndex = 3; + +function AssetParticleTransportBar::onAdd(%this) +{ + %this.init(); + + %this.addButton("restart", $EditorIcon::playback_rew, "Play the effect again from the beginning", + $EditorTransportBar::buttonSize); + + // The one you reach for, so it is half again the size of the rest. They sit in + // the same place, and exactly one of them is ever visible. + %this.playButton = %this.addButton("play", $EditorIcon::playback_play, "Play the preview", + $EditorTransportBar::playSize); + %this.pauseButton = %this.addButton("pause", $EditorIcon::playback_pause, "Pause the preview", + $EditorTransportBar::playSize); + %this.pauseButton.setVisible(false); + + %this.addButton("stop", $EditorIcon::playback_stop, "Stop the preview and clear its particles", + $EditorTransportBar::buttonSize); + + %this.addSpacer($EditorTransportBar::gap); + + // Not a toggle: five speeds cycled by pressing, with the current one in the + // tooltip. A slider would need a label to be readable and a label needs room + // the bar does not have over the art. + %this.speedIndex = $AssetParticleTransportBar::defaultSpeedIndex; + %this.speedButton = %this.addButton("cycleSpeed", $EditorIcon::stop_watch, "", + $EditorTransportBar::buttonSize); + + %this.addSpacer($EditorTransportBar::gap); + + %this.soloButton = %this.addToggle("Solo", $EditorIcon::eye, $EditorIcon::eye_inv, + "Showing this emitter only. Click to show them all again.", + "Showing every emitter. Click to show only the selected one."); + + // on / off rather than the speaker pair this started with. Nothing here makes + // a sound, and a crossed-out speaker reads as "muted audio" however it is + // captioned -- what the button actually does is switch one emitter off. + %this.emitterOffButton = %this.addToggle("PauseEmitter", $EditorIcon::off, $EditorIcon::on, + "This emitter is switched off. Click to let it emit again.", + "This emitter is emitting. Click to switch just this one off."); +} + +//----------------------------------------------------------------------------- +// What the buttons do. All of it is on the preview's player, none of it on the +// asset. +//----------------------------------------------------------------------------- + +function AssetParticleTransportBar::player(%this) +{ + return isObject(AssetAdmin.previewPlayer) ? AssetAdmin.previewPlayer : ""; +} + +function AssetParticleTransportBar::play(%this) +{ + %player = %this.player(); + if(!isObject(%player)) + { + return; + } + + // Paused and stopped are different states with one button between them: a + // paused effect resumes where it was, a stopped one has to be started again. + if(%player.getIsPlaying()) + { + %player.setPaused(false); + } + else + { + %player.play(true); + } + + %this.refresh(); +} + +function AssetParticleTransportBar::pause(%this) +{ + %player = %this.player(); + if(isObject(%player)) + { + %player.setPaused(true); + } + + %this.refresh(); +} + +// stop(false, false): free the particles now, and do not kill the effect. +// +// Not stop(TRUE, false), the "let the particles finish" form, for two reasons. +// It leaves mPlaying set until the last particle dies, so getIsPlaying goes on +// answering true and the bar would offer Pause over an effect that had been +// stopped -- and Pause on a stopped effect does nothing, so the button would lie +// twice. It also pauses every emitter to do its waiting, which is the same flag +// solo and emitter-off are written in, so a graceful stop would quietly undo them. +// +// Killing is the other thing this is not: that deletes the player and leaves the +// preview with nothing in it and nothing to restart. +function AssetParticleTransportBar::stop(%this) +{ + %player = %this.player(); + if(isObject(%player)) + { + %player.stop(false, false); + } + + %this.refresh(); +} + +function AssetParticleTransportBar::restart(%this) +{ + %player = %this.player(); + if(isObject(%player)) + { + // True clears the particles already out, so what follows is the effect + // from nothing rather than the effect over its own tail. + %player.play(true); + %player.setPaused(false); + } + + %this.refresh(); +} + +function AssetParticleTransportBar::cycleSpeed(%this) +{ + %count = getWordCount($AssetParticleTransportBar::speeds); + %this.speedIndex = (%this.speedIndex + 1) % %count; + + %this.applySpeed(); + %this.refresh(); +} + +function AssetParticleTransportBar::speed(%this) +{ + return getWord($AssetParticleTransportBar::speeds, %this.speedIndex); +} + +function AssetParticleTransportBar::applySpeed(%this) +{ + %player = %this.player(); + if(isObject(%player)) + { + %player.setTimeScale(%this.speed()); + } +} + +//----------------------------------------------------------------------------- +// Solo and emitter-off, on the emitter the dropdown has selected. +//----------------------------------------------------------------------------- + +function AssetParticleTransportBar::onToggleIconChanged(%this, %button) +{ + switch$(%button.toggleName) + { + case "Solo": + %this.soloOn = %button.getValue(); + + case "PauseEmitter": + %this.emitterOff = %button.getValue(); + } + + %this.reapply(); +} + +// The selected emitter's index, or -1 when the dropdown is on the effect itself. +function AssetParticleTransportBar::selectedIndex(%this) +{ + if(!isObject(AssetAdmin.inspector) || !AssetAdmin.inspector.titleDropDown.isVisible()) + { + return -1; + } + + return AssetAdmin.inspector.titleDropDown.getSelectedItem() - 1; +} + +// Push the whole visible/paused state onto the player. Written as "say it for +// every emitter" rather than "change the one that moved", because the player's +// emitter nodes are rebuilt from the asset on every edit and arrive visible and +// running -- so there is nothing to change, only something to say again. +function AssetParticleTransportBar::reapply(%this) +{ + %player = %this.player(); + %asset = isObject(AssetAdmin.inspector) ? AssetAdmin.inspector.documentAsset() : ""; + + if(!isObject(%player) || !isObject(%asset)) + { + return; + } + + %selected = %this.selectedIndex(); + %count = %asset.getEmitterCount(); + + for(%i = 0; %i < %count; %i++) + { + %isSelected = (%i == %selected); + + // Solo hides everything except the selected one. With nothing selected + // there is nothing to solo, so everything stays visible. + %visible = !%this.soloOn || %selected < 0 || %isSelected; + + // Switching off pauses only the selected one -- the opposite shape to solo, and the + // pair is what lets you hear one emitter or everything but it. + %paused = %this.emitterOff && %isSelected; + + %player.setEmitterVisible(%visible, %i); + %player.setEmitterPaused(%paused, %i); + } +} + +//----------------------------------------------------------------------------- +// Reading the state back out. Called whenever something else may have moved it: +// a new selection, an edit that rebuilt the player, or one of the buttons above. +//----------------------------------------------------------------------------- + +function AssetParticleTransportBar::refresh(%this) +{ + %player = %this.player(); + + %running = isObject(%player) && %player.getIsPlaying() && !%player.getPaused(); + %this.playButton.setVisible(!%running); + %this.pauseButton.setVisible(%running); + %this.relayout(); + + %this.speedButton.Tooltip = "Preview speed:" SPC %this.speed() @ "x. Click for the next one."; + + // Neither switch means anything while the effect itself is selected, and a + // switch left on from the last emitter would be a lie about this one. + %onEmitter = %this.selectedIndex() >= 0; + %this.soloButton.setActive(%onEmitter); + %this.emitterOffButton.setActive(%onEmitter); + + %this.applySpeed(); + %this.reapply(); +} + +// A new preview player was built, for the asset named here. +// +// The switches are kept when it is the same effect and cleared when it is not, +// which is the distinction that makes solo usable at all: EVERY edit rebuilds the +// preview -- the commit ends in refreshAsset and AssetAdmin::refreshPreview +// re-clicks the tile -- so a bar that reset on every rebuild would drop the solo +// the moment you changed the field you were soloing to look at. +// +// The speed is not part of that: it is how you are watching rather than what you +// are watching, so it carries across assets like a preference. +function AssetParticleTransportBar::onPreviewRebuilt(%this, %assetId) +{ + if(%this.assetId !$= %assetId) + { + %this.assetId = %assetId; + %this.soloOn = false; + %this.emitterOff = false; + } + + %this.soloButton.setValue(%this.soloOn); + %this.emitterOffButton.setValue(%this.emitterOff); + + %this.refresh(); +} diff --git a/editor/AssetAdmin/ParticleEditor/exec.cs b/editor/AssetAdmin/ParticleEditor/exec.cs index 412844eb8..afe6c7aab 100644 --- a/editor/AssetAdmin/ParticleEditor/exec.cs +++ b/editor/AssetAdmin/ParticleEditor/exec.cs @@ -2,3 +2,4 @@ exec("./AssetParticleGraphUnit.cs"); exec("./ParticleGraphCameraController.cs"); exec("./NewParticleEmitterDialog.cs"); +exec("./AssetParticleTransportBar.cs"); diff --git a/editor/EditorCore/EditorCore.cs b/editor/EditorCore/EditorCore.cs index 14050d6e4..1416aacb4 100644 --- a/editor/EditorCore/EditorCore.cs +++ b/editor/EditorCore/EditorCore.cs @@ -64,6 +64,10 @@ exec("./EditorToggleIcon.cs"); exec("./EditorChoiceRow.cs"); + // The chrome a preview's transport bar is made of. Both the Asset Manager's + // bars build on it, and it uses EditorToggleIcon and EditorIconButton above. + exec("./EditorTransportBar.cs"); + // The field cell, here for the same reason: the Gui Editor grew it, and the // Asset Manager's inspector panes are built at create time from a module that // loads before the Gui Editor does. diff --git a/editor/EditorCore/EditorFieldRow.cs b/editor/EditorCore/EditorFieldRow.cs index 2febdfb87..dfdc515ca 100644 --- a/editor/EditorCore/EditorFieldRow.cs +++ b/editor/EditorCore/EditorFieldRow.cs @@ -46,6 +46,13 @@ // fileTitle what it calls itself. Both default to bitmaps, which is what a // "file" row was everywhere until fonts and sounds got panes. // +// One thing a row takes from ITSELF, because a pane can hold rows that want +// different answers -- the emitter pane has an image row and an animation row +// side by side: +// +// assetType what an "asset" row's Find button offers to pick. Defaults to +// ImageAsset, which is what every asset row was until then. +// // Kinds: text, number, decimal, point, pointf, bool, color, enum, dropdown, // file, asset, multiline. //----------------------------------------------------------------------------- @@ -632,9 +639,16 @@ class = isObject(%this.owner) ? %this.owner.swatchClass : ""; // native inspector's browse button uses it too; it hands back the chosen id // through onAssetPicked. Whatever the box holds now is passed along so the // picker opens on the current choice. +// +// assetType is per ROW rather than per pane -- unlike findBase and fileFilters +// above -- because the emitter pane carries an Image row and an Animation row in +// the same block and they want different lists. It defaults to ImageAsset, which +// was hardcoded here until the second kind of asset row existed. function EditorFieldRow::onFindAssetClicked(%this) { - EditorCore.openAssetPicker(%this, "onAssetPicked", %this.editor.getText(), "ImageAsset"); + %type = (%this.assetType $= "") ? "ImageAsset" : %this.assetType; + + EditorCore.openAssetPicker(%this, "onAssetPicked", %this.editor.getText(), %type); } // An asset id is already portable -- it names a module and an asset, not a diff --git a/editor/EditorCore/EditorTransportBar.cs b/editor/EditorCore/EditorTransportBar.cs new file mode 100644 index 000000000..9b97c7df2 --- /dev/null +++ b/editor/EditorCore/EditorTransportBar.cs @@ -0,0 +1,154 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// The chrome a transport bar is made of: sized icon buttons, stateful toggles, +// and the gaps between them. What each button DOES belongs to the subclass; this +// knows only how one should look and how to put it on the chain. +// +// A bar of these sits as an overlay on a preview rather than in a strip of its +// own -- which is where the audio play button already sat, and proof that an +// overlay there receives clicks over a SceneWindow. It costs no layout and takes +// no room from the art. +// +// Not built from EditorButtonBar: that makes EditorIconButtons, which are +// momentary, and a transport usually has at least one button that has to SHOW a +// state. +// +// A subclass is a GuiChainCtrl with class = its own name and superclass = +// "EditorTransportBar". onAdd does not chain in TorqueScript, so the subclass's +// onAdd calls %this.init() first and then adds its buttons. +// +// Used by AssetAnimationTransportBar and AssetParticleTransportBar. +//----------------------------------------------------------------------------- + +$EditorTransportBar::buttonSize = 24; +$EditorTransportBar::playSize = 36; +$EditorTransportBar::spacing = 4; +$EditorTransportBar::gap = 16; + +function EditorTransportBar::init(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +// How much bigger a toggle has to be than a push button to LOOK the same size. +// +// They draw differently. A GuiButtonCtrl paints its chrome across the whole +// control less its margins; a GuiCheckBoxCtrl paints a box that +// GuiCheckBoxCtrl::onRender clamps into the CONTENT rect -- inside the borders +// and padding as well. iconButtonProfile has a 2 pixel border on all four sides, +// so a 24 pixel toggle drew a 20 pixel box beside a 24 pixel button, and no +// amount of boxExtent fixed it: the clamp will not let the box out. +// +// So the toggle is built that much larger and its box comes out the right size. +// Read from the profile rather than written as 4, because a theme is free to +// give the button a different border. +function EditorTransportBar::chromeInset(%this) +{ + %profile = ThemeManager.activeTheme.iconButtonProfile; + + return (%profile.borderLeft.border + %profile.borderRight.border) SPC + (%profile.borderTop.border + %profile.borderBottom.border); +} + +// A button that shows which of two states it is in, and reports the change to +// the bar as onToggleIconChanged. +function EditorTransportBar::addToggle(%this, %name, %frameOn, %frameOff, %tipOn, %tipOff) +{ + %size = $EditorTransportBar::buttonSize; + %inset = %this.chromeInset(); + + %button = new GuiCheckBoxCtrl() + { + class = "EditorToggleIcon"; + Position = "0 0"; + VertSizing = "center"; + Extent = (%size + getWord(%inset, 0)) SPC (%size + getWord(%inset, 1)); + frameOn = %frameOn; + frameOff = %frameOff; + tipOn = %tipOn; + tipOff = %tipOff; + toggleName = %name; + owner = %this; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %this.add(%button); + + return %button; +} + +function EditorTransportBar::addButton(%this, %method, %frame, %tooltip, %size) +{ + %size = (%size $= "") ? $EditorTransportBar::buttonSize : %size; + + // Said in the block, not set afterwards. EditorIconButton forces its own + // extent in onAdd and its hover handlers animate the icon to sizes of their + // own, so a resize applied after the add survived exactly until the pointer + // first crossed it -- and the chain had already sized itself around the + // smaller button by then, which is what clipped the big one. + %button = new GuiButtonCtrl() + { + class = "EditorIconButton"; + Position = "0 0"; + VertSizing = "center"; + // buttonSize only. The icon is deliberately left at its default, so the + // big play button is a bigger BUTTON with the same picture on it as the + // rest -- which is what makes it easy to find without making it look like + // a different kind of control. + buttonSize = %size; + Frame = %frame; + Command = %this.getId() @ "." @ %method @ "();"; + Tooltip = %tooltip; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %this.add(%button); + + return %button; +} + +// A chain lays out what it can see, so an empty control is how a gap is spelled +// -- there is no spacing-before on a child. +function EditorTransportBar::addSpacer(%this, %width) +{ + %spacer = new GuiControl() + { + Position = "0 0"; + Extent = %width SPC $EditorTransportBar::buttonSize; + UseInput = false; + }; + ThemeManager.setProfile(%spacer, "emptyProfile"); + %this.add(%spacer); + + return %spacer; +} + +// A chain lays out only the children it can see, and nothing re-lays it out when +// one is hidden -- so swapping two buttons over is a resize away from leaving a +// hole where the other one was. Every subclass that hides a button needs this. +function EditorTransportBar::relayout(%this) +{ + %this.resize(getWord(%this.getPosition(), 0), getWord(%this.getPosition(), 1), + getWord(%this.getExtent(), 0), getWord(%this.getExtent(), 1)); +} diff --git a/engine/source/2d/assets/ImageAsset.cc b/engine/source/2d/assets/ImageAsset.cc index 4a35a597e..cf0ff79f7 100755 --- a/engine/source/2d/assets/ImageAsset.cc +++ b/engine/source/2d/assets/ImageAsset.cc @@ -672,6 +672,13 @@ S32 ImageAsset::getExplicitCellIndex(const char* regionName) bool ImageAsset::containsNamedRegion(const char* regionName) { + // No name is not a name. Without this, an empty string would match the first + // frame of any ordinary cell-mode image -- every one of which now carries an + // empty region name -- and callers read a true here as "this image addresses + // its frames by name", which such an image does not. + if ( regionName == NULL || *regionName == 0 ) + return false; + for( typeFrameAreaVector::iterator frameItr = mFrames.begin(); frameItr != mFrames.end(); ++frameItr ) { // Grab the current pixelArea diff --git a/engine/source/2d/assets/ImageAsset.h b/engine/source/2d/assets/ImageAsset.h index 3817cc6d2..818712996 100755 --- a/engine/source/2d/assets/ImageAsset.h +++ b/engine/source/2d/assets/ImageAsset.h @@ -68,7 +68,14 @@ class ImageAsset : public AssetBase class PixelArea { public: - PixelArea() {} + // mRegionName is read by dStrcmp in four places (containsNamedRegion, + // getExplicitCellIndex, getCellByName, removeExplicitCell), and only a + // NAMED cell ever went through the overload that sets it -- so every + // frame of an ordinary cell-mode image carried an indeterminate + // pointer, and asking such an image whether it contains a named frame + // dereferenced it. An empty name is the honest answer for a frame that + // has none. + PixelArea() : mPixelWidth( 0 ), mPixelHeight( 0 ), mRegionName( StringTable->EmptyString ) {} PixelArea( const S32 pixelFrameOffsetX, const S32 pixelFrameOffsetY, const U32 pixelFrameWidth, const U32 pixelFrameHeight ) { setArea( pixelFrameOffsetX, pixelFrameOffsetY, pixelFrameWidth, pixelFrameHeight ); @@ -82,13 +89,14 @@ class ImageAsset : public AssetBase mPixelOffset.set( pixelFrameOffsetX, pixelFrameOffsetY ); mPixelWidth = pixelFrameWidth; mPixelHeight = pixelFrameHeight; + mRegionName = StringTable->EmptyString; }; inline void setArea( const S32 pixelFrameOffsetX, const S32 pixelFrameOffsetY, const U32 pixelFrameWidth, const U32 pixelFrameHeight, const char* regionName ) { mPixelOffset.set( pixelFrameOffsetX, pixelFrameOffsetY ); mPixelWidth = pixelFrameWidth; mPixelHeight = pixelFrameHeight; - mRegionName = StringTable->insert(regionName); + mRegionName = ( regionName == NULL ) ? StringTable->EmptyString : StringTable->insert(regionName); }; inline RectI toRectI(void) const { return RectI(mPixelOffset, Point2I(mPixelWidth, mPixelHeight)); } diff --git a/engine/source/2d/assets/ParticleAssetEmitter.cc b/engine/source/2d/assets/ParticleAssetEmitter.cc index 4efb91de0..69d81566b 100644 --- a/engine/source/2d/assets/ParticleAssetEmitter.cc +++ b/engine/source/2d/assets/ParticleAssetEmitter.cc @@ -560,6 +560,14 @@ bool ParticleAssetEmitter::setImageFrame( const U32 frame ) bool ParticleAssetEmitter::setNamedImageFrame( const char* frameName ) { + // No name is not a name, and asking for one is not an error worth warning + // about: copyFieldsFrom walks the whole field table, so the NamedFrame of + // every numeric-frame emitter arrives here empty on every copy and clone. + // Taking it would flip the emitter into named-frame mode, which is how a + // static emitter could copy as one addressing a frame called "". + if ( frameName == NULL || *frameName == 0 ) + return false; + // Check Existing Image. if ( mImageAsset.isNull() ) { diff --git a/engine/source/2d/assets/ParticleAssetEmitter.h b/engine/source/2d/assets/ParticleAssetEmitter.h index 9b0e0c11c..5ad6a6f5c 100644 --- a/engine/source/2d/assets/ParticleAssetEmitter.h +++ b/engine/source/2d/assets/ParticleAssetEmitter.h @@ -191,6 +191,13 @@ class ParticleAssetEmitter : public SimObject, protected AssetPtrCallback inline bool getPhysicsParticles(void) const { return mPhysicsParticles; } //Phyiscs particles end--- //Particle Target + // The one setter here that deliberately does NOT refreshAsset(), so do not + // "fix" it to match its neighbours. A target position is aimed at something + // that moves: AngleToy steers an emitter at the cursor by calling this on + // every mouse move. A refresh from here would mark the asset dirty and run the + // whole notify chain per frame, and ParticlePlayer::onAssetRefreshed rebuilds + // every emitter node -- so following the cursor would drop the live particles + // continuously. An editor writing this field asks for the refresh itself. inline void setTargetPosition(const Vector2& targetPos) { mTargetPosition = targetPos; } inline const Vector2& getTargetPosition(void) const { return mTargetPosition; } inline void setIsTargeting(const bool targetParticle) { mTargetParticle = targetParticle; refreshAsset(); } diff --git a/engine/source/2d/assets/ParticleAssetEmitter_ScriptBinding.h b/engine/source/2d/assets/ParticleAssetEmitter_ScriptBinding.h index 02c01d78d..3bd3b231e 100755 --- a/engine/source/2d/assets/ParticleAssetEmitter_ScriptBinding.h +++ b/engine/source/2d/assets/ParticleAssetEmitter_ScriptBinding.h @@ -234,25 +234,33 @@ ConsoleMethodWithDocs(ParticleAssetEmitter, getEmitterSize, ConsoleString, 2, 2, //------------------------------------------------------------------------------ -/*! Sets the emitter angle. - @param angle The angle of the emitter. +/*! Sets the emitter angle in degrees. + @param angle The angle of the emitter, in degrees. @return No return value. */ ConsoleMethodWithDocs(ParticleAssetEmitter, setEmitterAngle, ConsoleVoid, 3, 3, (angle)) { // Set Rotation. - object->setEmitterAngle( mDegToRad( dAtof(argv[2]) ) ); + // + // Degrees, stored as degrees. This used to store mDegToRad(angle) and hand + // back mRadToDeg(stored), which disagreed with both of the other two ways in + // to the same value: the EmitterAngle persist field writes what it is given, + // and ParticlePlayer::configureParticle does mDegToRad(getEmitterAngle()) when + // it places the particle. So TAML and the renderer both read the member as + // degrees, and only this pair of methods thought otherwise -- a value set + // through script came out of the renderer 57 times too small. + object->setEmitterAngle( dAtof(argv[2]) ); } //----------------------------------------------------------------------------- -/*! Gets the emitter angle. - @return (float angle) The emitter's current angle. +/*! Gets the emitter angle in degrees. + @return (float angle) The emitter's current angle, in degrees. */ ConsoleMethodWithDocs(ParticleAssetEmitter, getEmitterAngle, ConsoleFloat, 2, 2, ()) { // Return angle. - return mRadToDeg( object->getEmitterAngle()); + return object->getEmitterAngle(); } //----------------------------------------------------------------------------- @@ -768,6 +776,23 @@ ConsoleMethodWithDocs(ParticleAssetEmitter, getAnimation, ConsoleString, 2, 2, ( //------------------------------------------------------------------------------ +/*! Gets whether the emitter draws a still frame of an image asset rather than + playing an animation asset. + + The mode is a side effect of which of setImage and setAnimation ran last -- + each clears the other's asset -- and it is NOT the same question as "which + asset is set": an emitter switched to animation before an animation was + chosen is in animated mode holding nothing, which is indistinguishable from + a static one holding nothing if you only look at the assets. + @return (bool staticMode) Whether the emitter is in static-image mode. +*/ +ConsoleMethodWithDocs(ParticleAssetEmitter, isStaticMode, ConsoleBool, 2, 2, ()) +{ + return object->isStaticFrameProvider(); +} + +//------------------------------------------------------------------------------ + /*! Sets whether to use render blending or not. @param blendMode Whether to use render blending or not. @return No return value. @@ -1077,11 +1102,18 @@ ConsoleMethodWithDocs(ParticleAssetEmitter, getMaxTime, ConsoleFloat, 2, 2, ()) //----------------------------------------------------------------------------- -/*! Get the fields' value at the specified time. +/*! Get the selected graph field's value at the specified time. + + Named getFieldValueAtTime rather than getFieldValue because SimObject already + has a getFieldValue(fieldName), and this SHADOWED it -- on a particle asset or + an emitter, the ordinary "read me a persistent field by name" call every other + object answers instead sampled whichever curve happened to be selected, at + dAtof(fieldName) == 0 seconds, and handed back a number. It did not fail: it + returned a plausible 1.0, so an editor reading EmitterName got "1". @param time The time to sample the field value at. @return The fields' value at the specified time or always 0.0 if no field is selected. */ -ConsoleMethodWithDocs(ParticleAssetEmitter, getFieldValue, ConsoleFloat, 3, 3, (time)) +ConsoleMethodWithDocs(ParticleAssetEmitter, getFieldValueAtTime, ConsoleFloat, 3, 3, (time)) { return object->getParticleFields().getFieldValue( dAtof(argv[2]) ); } diff --git a/engine/source/2d/assets/ParticleAsset_ScriptBinding.h b/engine/source/2d/assets/ParticleAsset_ScriptBinding.h index 98ab3670a..a60a8c018 100755 --- a/engine/source/2d/assets/ParticleAsset_ScriptBinding.h +++ b/engine/source/2d/assets/ParticleAsset_ScriptBinding.h @@ -289,11 +289,16 @@ ConsoleMethodWithDocs(ParticleAsset, getMaxTime, ConsoleFloat, 2, 2, ()) //----------------------------------------------------------------------------- -/*! Get the fields' value at the specified time. +/*! Get the selected graph field's value at the specified time. + + Named getFieldValueAtTime rather than getFieldValue because SimObject already + has a getFieldValue(fieldName), and this SHADOWED it -- see the matching note + on ParticleAssetEmitter. Reading Lifetime or LifeMode off a particle asset by + name answered with a sample of a curve. @param time The time to sample the field value at. @return The fields' value at the specified time or always 0.0 if no field is selected. */ -ConsoleMethodWithDocs(ParticleAsset, getFieldValue, ConsoleFloat, 3, 3, (time)) +ConsoleMethodWithDocs(ParticleAsset, getFieldValueAtTime, ConsoleFloat, 3, 3, (time)) { return object->getParticleFields().getFieldValue( dAtof(argv[2]) ); } diff --git a/engine/source/2d/sceneobject/ParticlePlayer.cc b/engine/source/2d/sceneobject/ParticlePlayer.cc index 4c52650aa..276b1b7b6 100644 --- a/engine/source/2d/sceneobject/ParticlePlayer.cc +++ b/engine/source/2d/sceneobject/ParticlePlayer.cc @@ -363,7 +363,7 @@ void ParticlePlayer::integrateObject( const F32 totalTime, const F32 elapsedTime // Fetch the quantity base and variation fields. const ParticleAssetField& quantityBaseField = pParticleAssetEmitter->getQuantityBaseField(); - const ParticleAssetField& quantityVaritationField = pParticleAssetEmitter->getQuantityBaseField(); + const ParticleAssetField& quantityVaritationField = pParticleAssetEmitter->getQuantityVariationField(); // Fetch the emissions. const F32 baseEmission = quantityBaseField.getFieldValue( particlePlayerAge ); @@ -582,9 +582,17 @@ void ParticlePlayer::sceneRender( const SceneRenderState* pSceneRenderState, con else { // No, so set standard blend options. - if ( mBlendMode ) + // + // The EMITTER's blend options, not the player's. These are per-emitter + // fields that round-tripped through TAML and were read by nothing but + // their own write predicates -- this branch used mBlendMode / + // mSrcBlendFactor / mDstBlendFactor, which ParticlePlayer does not + // declare, so they resolved to the inherited SceneObject members and + // one setting covered every emitter at once. Intense particles above + // still override, as they always did. + if ( pParticleAssetEmitter->getBlendMode() ) { - pBatchRenderer->setBlendMode( mSrcBlendFactor, mDstBlendFactor ); + pBatchRenderer->setBlendMode( pParticleAssetEmitter->getSrcBlendFactor(), pParticleAssetEmitter->getDstBlendFactor() ); } else { @@ -1254,10 +1262,16 @@ void ParticlePlayer::configureParticle( EmitterNode* pEmitterNode, ParticleSyste const ParticleAssetField& alphaChannelScale = pParticleAsset->getAlphaChannelScaleField(); // Calculate the color. + // + // The channel fields are sampled at the PARTICLE's age -- zero here, because + // this particle is being born -- while the asset's alpha scale, like every + // other asset-scope scale field, is sampled at the EFFECT's age. It used to be + // sampled at zero as well, which read only its first key and threw away the + // rest of the curve the Scale Graph tab lets you draw. pParticleNode->mColor.set( mClampF( redChannel.getFieldValue( 0.0f ), redChannel.getMinValue(), redChannel.getMaxValue() ), mClampF( greenChannel.getFieldValue( 0.0f ),greenChannel.getMinValue(), greenChannel.getMaxValue() ), mClampF( blueChannel.getFieldValue( 0.0f ), blueChannel.getMinValue(),blueChannel.getMaxValue() ), - mClampF( alphaChannel.getFieldValue( 0.0f ) * alphaChannelScale.getFieldValue( 0.0f ), alphaChannel.getMinValue(), alphaChannel.getMaxValue() ) ); + mClampF( alphaChannel.getFieldValue( 0.0f ) * alphaChannelScale.getFieldValue( particlePlayerAge ), alphaChannel.getMinValue(), alphaChannel.getMaxValue() ) ); // ********************************************************************************************************************** @@ -1390,10 +1404,14 @@ void ParticlePlayer::integrateParticle( EmitterNode* pEmitterNode, ParticleSyste const ParticleAssetField& alphaChannelScale = pParticleAsset->getAlphaChannelScaleField(); // Calculate the color. + // + // Two different clocks, as in configureParticle above: the channel fields run + // on the particle's normalized age, the asset's alpha scale on the effect's + // age (mAge). Sampling the scale at zero read only its first key. pParticleNode->mColor.set( mClampF( redChannel.getFieldValue( particleAge ), redChannel.getMinValue(), redChannel.getMaxValue() ), mClampF( greenChannel.getFieldValue( particleAge ),greenChannel.getMinValue(), greenChannel.getMaxValue() ), mClampF( blueChannel.getFieldValue( particleAge ), blueChannel.getMinValue(),blueChannel.getMaxValue() ), - mClampF( alphaChannel.getFieldValue( particleAge ) * alphaChannelScale.getFieldValue( 0.0f ), alphaChannel.getMinValue(), alphaChannel.getMaxValue() ) ); + mClampF( alphaChannel.getFieldValue( particleAge ) * alphaChannelScale.getFieldValue( mAge ), alphaChannel.getMinValue(), alphaChannel.getMaxValue() ) ); // ********************************************************************************************************************** diff --git a/engine/source/testing/tests/assetStateCopyTests.cc b/engine/source/testing/tests/assetStateCopyTests.cc index 1d954325e..a2608665e 100644 --- a/engine/source/testing/tests/assetStateCopyTests.cc +++ b/engine/source/testing/tests/assetStateCopyTests.cc @@ -230,6 +230,80 @@ TEST( AssetStateCopyTests, ParticleAssetEmitterCarriesStaticMode ) target->deleteObject(); } +//----------------------------------------------------------------------------- +// The particle emitter's console API, which the Asset Manager's emitter pane is +// the first thing to lean on. Each of these is a defect the pane found. +//----------------------------------------------------------------------------- + +// EmitterAngle is stored in DEGREES: the persist field writes what it is given +// and ParticlePlayer::configureParticle does mDegToRad(getEmitterAngle()) when it +// places the particle. The console setter used to store mDegToRad(angle) and the +// getter to hand back mRadToDeg(stored), so the two of them agreed with each +// other and with nothing else -- an angle set from script reached the renderer 57 +// times too small, and reading the field by name gave a different number from +// reading it by method. +TEST( AssetStateCopyTests, EmitterAngleIsDegreesEverywhere ) +{ + ParticleAssetEmitter* emitter = newScratchAsset(); + + Con::evaluatef( "%d.setEmitterAngle( 90 );", emitter->getId() ); + + ASSERT_FLOAT_EQ( emitter->getEmitterAngle(), 90.0f ) + << "The console setter stored something other than the degrees it was given."; + ASSERT_STREQ( readAssetField( emitter, "EmitterAngle" ), "90" ) + << "The field and the console setter disagree about the unit."; + + emitter->deleteObject(); +} + +// SimObject::getFieldValue(fieldName) is how everything in the editor reads a +// persistent field by name. ParticleAssetEmitter and ParticleAsset each declared +// a getFieldValue(time) of their own -- the graph sampler -- which SHADOWED it, +// so the ordinary call returned a sample of whichever curve was selected at +// dAtof(fieldName) == 0 seconds. It did not fail; it returned a plausible 1. +TEST( AssetStateCopyTests, EmitterFieldsAreReadableByName ) +{ + ParticleAssetEmitter* emitter = newScratchAsset(); + emitter->setEmitterName( "smoke" ); + + const char* name = Con::evaluatef( "return %d.getFieldValue( EmitterName );", emitter->getId() ); + + ASSERT_STREQ( name, "smoke" ) + << "getFieldValue answered with a graph sample rather than the field."; + + emitter->deleteObject(); +} + +// An emitter in animated mode holding no animation asset is indistinguishable +// from a static one holding no image if you only look at the assets, so the mode +// has to be askable directly. The pane's source picker depends on it. +TEST( AssetStateCopyTests, EmitterReportsItsFrameProviderMode ) +{ + ParticleAssetEmitter* emitter = newScratchAsset(); + + emitter->setAnimation( "" ); + ASSERT_STREQ( Con::evaluatef( "return %d.isStaticMode();", emitter->getId() ), "0" ) + << "An emitter with no animation asset still reported static mode."; + + emitter->setImage( "" ); + ASSERT_STREQ( Con::evaluatef( "return %d.isStaticMode();", emitter->getId() ), "1" ); + + emitter->deleteObject(); +} + +// setNamedImageFrame is handed an empty name by every copy and clone, because +// copyFieldsFrom walks the whole field table and a numeric-frame emitter's +// NamedFrame is empty. Taking it would flip the emitter into named-frame mode. +TEST( AssetStateCopyTests, EmptyNamedFrameIsRefused ) +{ + ParticleAssetEmitter* emitter = newScratchAsset(); + + ASSERT_FALSE( emitter->setNamedImageFrame( "" ) ); + ASSERT_FALSE( emitter->isUsingNamedImageFrame() ); + + emitter->deleteObject(); +} + TEST( AssetStateCopyTests, ParticleAssetCarriesEmitters ) { ParticleAsset* source = newScratchAsset(); diff --git a/tests/shots/assetParticleInspector.cs b/tests/shots/assetParticleInspector.cs new file mode 100644 index 000000000..494657e6d --- /dev/null +++ b/tests/shots/assetParticleInspector.cs @@ -0,0 +1,205 @@ +// Visual harness for the Asset Manager's particle and emitter inspectors. Shots: +// +// 0 the effect pane as the inspector opens -- the wide, short bottom frame, +// where its three blocks sit in a row and the Lifetime row is greyed +// 1 the emitter pane at the same size, where six blocks wrap into two rows +// 2 the emitter pane with the frame dragged taller, everything at once +// 3 tall and narrow, where every block stacks into one column +// 4 the same emitter with Single Particle on -- the greying rule that reaches +// furthest, and the one worth looking at rather than asserting +// 5 an animated emitter, where the source swap has taken the image rows away +// and put the animation row in their place +// 6 on a 1600-wide screen, where the six blocks become a single row +// 7 the effect pane with no emitters, for the warning line +// +// What only a picture can settle: whether six blocks of unequal height still +// read as a row rather than as a ragged pile, whether a greyed row is legible +// enough to be worth keeping on show, and whether the swap leaves a hole where +// the hidden rows were. tests/smoke/assetParticleInspector.cs does the part that +// is checkable by assertion. +// +// Run: tests/run.ps1 -Shots assetParticleInspector ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +// bonfire's two emitters differ in exactly the ways the pane reshapes for: the +// first draws a still image, the second plays an animation. +$apAssetId = "ToyAssets:bonfire"; + +testExec("editor/main.cs"); +schedule(2500, 0, "apOpenProject"); + +function apOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + // A copy: shot 4 and shot 7 both edit. tests/run.ps1 sweeps the folder by + // reading the spelled-out name out of this file. + createPath(testRoot("shots/")); + ProjectManager.setProjectFolder("assetParticleInspectorShotProject"); + EditorPreferences.path = testRoot("shots/assetParticleInspectorShotPrefs.taml"); + + %copy = testRoot("assetParticleInspectorShotProject/ToyAssets"); + pathCopy(testRoot("toybox/ToyAssets"), %copy, false); + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + + schedule(2500, 0, "apOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function apOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "apSelectAsset"); +} + +function apSelectAsset() +{ + AssetAdmin.Dictionary["ParticleAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + $apTile = AssetAdmin.Dictionary["ParticleAsset"].getButton($apAssetId); + $apTile.onClick(); + + $apInspector = AssetAdmin.inspector; + $apPane = $apInspector.particlePane; + $apEmitterPane = $apInspector.emitterPane; + $apAsset = $apTile.ParticleAsset; + + schedule(1200, 0, "apEffectShot"); +} + +function apGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging, so a + // tree that has never run a shot writes nothing and says nothing. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/assetParticleInspector" @ %name @ ".png"), "PNG"); +} + +function apSelect(%index) +{ + $apInspector.titleDropDown.setSelected(%index); + $apInspector.onChooseParticleAsset($apAsset); +} + +function apEffectShot() +{ + apGrab(0); + + // The first emitter: a LINE drawing a still image. + apSelect(1); + + schedule(1200, 0, "apEmitterShot"); +} + +function apEmitterShot() +{ + apGrab(1); + + // The shape somebody tuning an emitter would settle into: everything at once + // with no scrolling. + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, 520); + + schedule(1200, 0, "apTallShot"); +} + +function apTallShot() +{ + apGrab(2); + + // Tall and narrow: give the library most of the width and the inspector most + // of what is left of the height. This is the shape the reflow exists for. + %canvas = Canvas.getExtent(); + AssetAdmin.content.setFrameSize(AssetAdmin.libraryFrameId, + getWord(%canvas, 0) - 400); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, + getWord(%canvas, 1) - 220); + + schedule(1200, 0, "apNarrowShot"); +} + +function apNarrowShot() +{ + apGrab(3); + + AssetAdmin.content.setFrameSize(AssetAdmin.libraryFrameId, 324); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, 520); + + schedule(900, 0, "apSingleShot"); +} + +// The widest greying rule on the pane: one immortal particle at the offset means +// the whole Emission block stops being read. +function apSingleShot() +{ + $apEmitterPane.commitValue("SingleParticle", true); + + schedule(900, 0, "apAnimatedShot"); +} + +function apAnimatedShot() +{ + apGrab(4); + + $apEmitterPane.commitValue("SingleParticle", false); + + // The second emitter plays an animation, so the image rows are gone rather + // than greyed. + apSelect(2); + + schedule(1200, 0, "apWideScreen"); +} + +// The case the grid exists for, and the only one the default test window is too +// small to show: at 1600 the inspector runs the width of the screen and six +// blocks become a single row instead of a column with a yard of empty panel +// beside it. +function apWideScreen() +{ + apGrab(5); + + setScreenMode(1600, 900, false); + + schedule(2000, 0, "apWideShot"); +} + +function apWideShot() +{ + AssetAdmin.content.setFrameSize(AssetAdmin.libraryFrameId, 324); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, 420); + + schedule(1200, 0, "apWarnShot"); +} + +function apWarnShot() +{ + apGrab(6); + + // An effect that draws nothing. It can arrive from a file in this state, so + // the pane has to say so rather than showing an empty preview and no reason. + apSelect(0); + $apAsset.clearEmitters(); + $apAsset.refreshAsset(); + + schedule(1200, 0, "apDoneShot"); +} + +function apDoneShot() +{ + apGrab(7); + + echo("APSHOT DONE"); + schedule(400, 0, "quit"); +} diff --git a/tests/smoke/assetAnimationInspector.cs b/tests/smoke/assetAnimationInspector.cs index 743534298..c8b93abde 100644 --- a/tests/smoke/assetAnimationInspector.cs +++ b/tests/smoke/assetAnimationInspector.cs @@ -85,7 +85,8 @@ function ainStep2() ainCheck("the generic inspector is the one on show", $ainInspector.insScroller.isVisible()); // The registry knows every pane, in the order they were registered. - ainCheck("all four panes are registered", $ainInspector.paneKeys $= "Image Animation Font Sound"); + ainCheck("all six panes are registered", + $ainInspector.paneKeys $= "Image Animation Font Sound Particle Emitter"); $ainTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($ainAssetId); ainCheck("the animation tile is in the library", isObject($ainTile)); diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs index e6181b947..6c8af1241 100644 --- a/tests/smoke/assetAnimationTimeline.cs +++ b/tests/smoke/assetAnimationTimeline.cs @@ -346,7 +346,7 @@ function aniStepTransport() %toggleDrawn = getWord(%bar.loopButton.getExtent(), 0) - getWord(%inset, 0); aniCheck("a toggle draws the same size as a push button (" @ %toggleDrawn @ ")", - %toggleDrawn == $AssetAnimationTransportBar::buttonSize); + %toggleDrawn == $EditorTransportBar::buttonSize); // Every button draws the same size of picture, whatever size the button is. // diff --git a/tests/smoke/assetParticleInspector.cs b/tests/smoke/assetParticleInspector.cs new file mode 100644 index 000000000..5cb8685ab --- /dev/null +++ b/tests/smoke/assetParticleInspector.cs @@ -0,0 +1,739 @@ +// Asset Manager particle-inspector smoke test. Drives the two custom panes that +// replaced the generic GuiInspector for particle assets: the small effect pane at +// dropdown index 0, the six-block emitter pane above it, and the gating that +// decides which of an emitter's thirty fields are live. +// Run: tests/run.ps1 assetParticleInspector ; grep APRT in tests/logs/. +// +// Driven by calling the panes rather than by posting input, for the same reason +// assetImageInspector is: where a row sits depends on how many columns the grid +// chose and how far the scroller has been dragged, neither of which script can +// read. +// +// NOTE: a COPY of toybox/ToyAssets. This one really does edit -- SingleParticle, +// EmitterType and EmitterName are all written -- so it must not touch the real +// content tree. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function aprtCheck(%label, %cond) +{ + if(%cond) echo("APRT PASS: " @ %label); + else echo("APRT FAIL: " @ %label); +} + +// bonfire has exactly two emitters and they differ in the two ways the emitter +// pane cares most about: +// +// "smoke" a LINE emitter drawing a still frame of an ImageAsset +// "flames" a LINE emitter playing an AnimationAsset, with IntenseParticles on +// +// so the source swap and the blend greying both have a real case to read, and +// neither had to be set up by the test. +$aprtAssetId = "ToyAssets:bonfire"; + +function aprtLoadFixtureAssets() +{ + %copy = testRoot("assetParticleInspectorSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +// How many rows of a block are actually on show. The swaps hide rows rather than +// removing them, so getCount() is not the answer. +function aprtVisibleRows(%chain) +{ + %shown = 0; + for(%i = 0; %i < %chain.getCount(); %i++) + { + if(%chain.getObject(%i).isVisible()) + { + %shown++; + } + } + return %shown; +} + +// Choosing an entry in the title dropdown, the way the dialog and the button bar +// do it: move the selection, then tell the inspector. +function aprtSelect(%index) +{ + $aprtInspector.titleDropDown.setSelected(%index); + $aprtInspector.onChooseParticleAsset($aprtAsset); +} + +testExec("editor/main.cs"); +schedule(2000, 0, "aprtStep1"); + +//----------------------------------------------------------------------------- + +function aprtStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."). + ProjectManager.setProjectFolder("assetParticleInspectorSmokeProject"); + EditorPreferences.path = testRoot("shots/assetParticleInspectorSmokePrefs.taml"); + + aprtCheck("fixture asset module registered", aprtLoadFixtureAssets()); + + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "aprtStep2"); +} + +//----------------------------------------------------------------------------- +// Both panes exist, and stand down until a particle asset is chosen. +//----------------------------------------------------------------------------- + +function aprtStep2() +{ + $aprtInspector = AssetAdmin.inspector; + $aprtPane = $aprtInspector.particlePane; + $aprtEmitterPane = $aprtInspector.emitterPane; + + aprtCheck("particle pane built", isObject($aprtPane)); + aprtCheck("it is an AssetParticleInspectorPane", + $aprtPane.getClassNamespace() $= "AssetParticleInspectorPane"); + aprtCheck("it inherits the shared pane", + $aprtPane.getSuperClassNamespace() $= "AssetInspectorPane"); + + aprtCheck("emitter pane built", isObject($aprtEmitterPane)); + aprtCheck("it is an AssetEmitterInspectorPane", + $aprtEmitterPane.getClassNamespace() $= "AssetEmitterInspectorPane"); + aprtCheck("it inherits the shared pane too", + $aprtEmitterPane.getSuperClassNamespace() $= "AssetInspectorPane"); + + aprtCheck("both are in the registry", + strstr($aprtInspector.paneKeys, "Particle") != -1 && + strstr($aprtInspector.paneKeys, "Emitter") != -1); + + aprtCheck("the particle pane starts hidden", !$aprtInspector.paneScroller["Particle"].isVisible()); + aprtCheck("and so does the emitter pane", !$aprtInspector.paneScroller["Emitter"].isVisible()); + aprtCheck("the generic inspector is the one on show", $aprtInspector.insScroller.isVisible()); + + $aprtTile = AssetAdmin.Dictionary["ParticleAsset"].getButton($aprtAssetId); + aprtCheck("the particle tile is in the library", isObject($aprtTile)); + + $aprtTile.onClick(); + + schedule(600, 0, "aprtStep3"); +} + +//----------------------------------------------------------------------------- +// Choosing one hands the page to the EFFECT pane -- the particle asset used to +// be the last editable kind still going through the generic inspector. +//----------------------------------------------------------------------------- + +function aprtStep3() +{ + $aprtAsset = $aprtTile.ParticleAsset; + + aprtCheck("the particle pane took the page", $aprtInspector.paneScroller["Particle"].isVisible()); + aprtCheck("the generic inspector stood down", !$aprtInspector.insScroller.isVisible()); + aprtCheck("the emitter pane is not also on show", + !$aprtInspector.paneScroller["Emitter"].isVisible()); + aprtCheck("the pane is bound to the asset", $aprtPane.target == $aprtAsset); + aprtCheck("the inspector reports the pane's asset as the inspected one", + $aprtInspector.inspectedObject() == $aprtAsset); + + // The three blocks and what is in them. + aprtCheck("the identity block exists", isObject($aprtPane.identityChain)); + aprtCheck("the effect block exists", isObject($aprtPane.effectChain)); + aprtCheck("the description block exists", isObject($aprtPane.descriptionChain)); + aprtCheck("the grid holds three blocks", $aprtPane.contentGrid.getCount() == 3); + + aprtCheck("asset name row", isObject($aprtPane.row["AssetName"])); + aprtCheck("category row", isObject($aprtPane.row["AssetCategory"])); + aprtCheck("life mode row", isObject($aprtPane.row["LifeMode"])); + aprtCheck("lifetime row", isObject($aprtPane.row["Lifetime"])); + aprtCheck("description row", isObject($aprtPane.row["AssetDescription"])); + + // The two that exist to keep an asset OUT of the editor. + aprtCheck("AssetInternal is NOT offered", !isObject($aprtPane.row["AssetInternal"])); + aprtCheck("nor is AssetPrivate", !isObject($aprtPane.row["AssetPrivate"])); + + // A scale field is a curve over the effect's age, not a number, so no text box + // can hold one -- they belong to the Scale Graph tab. + aprtCheck("no scale graph is offered as a field", !isObject($aprtPane.row["QuantityScale"])); + aprtCheck("nor is the alpha one", !isObject($aprtPane.row["AlphaChannelScale"])); + + aprtCheck("the name row is not editable", !$aprtPane.row["AssetName"].editor.isActive()); + aprtCheck("the life mode row explains itself", $aprtPane.row["LifeMode"].editor.Tooltip !$= ""); + + // What the rows actually SHOW, which is a different question from what the + // asset holds and the one the assertions used to skip. ParticleAsset had its + // own getFieldValue(time) shadowing SimObject's getFieldValue(fieldName), so + // every row on both panes read back a sample of a graph curve -- a plausible + // "1" in every box -- while every check on the asset went on passing. + aprtCheck("the name row shows the asset's name (" @ $aprtPane.row["AssetName"].getValue() @ ")", + $aprtPane.row["AssetName"].getValue() $= $aprtAsset.AssetName); + aprtCheck("the life mode row shows the mode", + $aprtPane.row["LifeMode"].getValue() $= $aprtAsset.getLifeMode()); + + schedule(300, 0, "aprtStep4"); +} + +//----------------------------------------------------------------------------- +// What the effect pane says about the effect, and its one gating rule. +//----------------------------------------------------------------------------- + +function aprtStep4() +{ + %info = $aprtPane.infoLabel.getText(); + aprtCheck("the info line counts the emitters (" @ %info @ ")", + strstr(%info, "2 emitters") != -1); + aprtCheck("and names them", strstr(%info, "smoke") != -1 && strstr(%info, "flames") != -1); + + aprtCheck("no warning for a working effect", !$aprtPane.warningLabel.isVisible()); + + // bonfire writes neither field, so it is INFINITE -- and an infinite effect + // never reaches its lifetime, so the number under it is not read. + aprtCheck("the effect is infinite", $aprtAsset.getLifeMode() $= "INFINITE"); + aprtCheck("so the lifetime row is inert", !$aprtPane.row["Lifetime"].editor.isActive()); + aprtCheck("and says why", strstr($aprtPane.row["Lifetime"].editor.Tooltip, "infinite") != -1); + + $aprtPane.commitValue("LifeMode", "CYCLE"); + aprtCheck("choosing a finite mode takes", $aprtAsset.getLifeMode() $= "CYCLE"); + aprtCheck("and the lifetime row comes live", $aprtPane.row["Lifetime"].editor.isActive()); + + $aprtPane.commitValue("LifeMode", "INFINITE"); + aprtCheck("and goes inert again", !$aprtPane.row["Lifetime"].editor.isActive()); + + schedule(300, 0, "aprtStep5"); +} + +//----------------------------------------------------------------------------- +// The dropdown swaps to the emitter pane. Index 1 is "smoke": a LINE emitter +// drawing a still image. +//----------------------------------------------------------------------------- + +function aprtStep5() +{ + aprtSelect(1); + + $aprtEmitter = $aprtAsset.getEmitter(0); + + aprtCheck("the emitter pane took the page", $aprtInspector.paneScroller["Emitter"].isVisible()); + aprtCheck("the effect pane stood down", !$aprtInspector.paneScroller["Particle"].isVisible()); + aprtCheck("the generic inspector is still down", !$aprtInspector.insScroller.isVisible()); + aprtCheck("the pane is bound to the emitter", $aprtEmitterPane.target == $aprtEmitter); + + // The document is still the ASSET, because that is what has a file to save. + aprtCheck("the document is still the particle asset", + $aprtInspector.documentAsset() == $aprtAsset); + + // A header above the grid, then five blocks in it. The name is not a block: + // a grid row is as tall as its tallest cell, so a two-row Identity block + // beside the seven-row Emission one was two thirds empty at every width. + aprtCheck("the header exists", isObject($aprtEmitterPane.headerChain)); + aprtCheck("and is not in the grid", + $aprtEmitterPane.headerChain.getGroup() != $aprtEmitterPane.contentGrid); + + aprtCheck("the emission block exists", isObject($aprtEmitterPane.emissionChain)); + aprtCheck("the image block exists", isObject($aprtEmitterPane.imageChain)); + aprtCheck("the orientation block exists", isObject($aprtEmitterPane.orientationChain)); + aprtCheck("the behavior block exists", isObject($aprtEmitterPane.behaviorChain)); + aprtCheck("the render block exists", isObject($aprtEmitterPane.renderChain)); + aprtCheck("the grid holds five blocks", $aprtEmitterPane.contentGrid.getCount() == 5); + + // THE BLOCKS ARE BALANCED. A grid row is as tall as its tallest cell, so + // unequal blocks do not give a short column and a long one -- they give + // columns of equal height with the short ones mostly empty, which is what + // makes a wide layout look unplanned. The first cut was 7 / 4 / 2 / 6 / 6. + // + // Five is the number in the default state: a LINE emitter drawing a numbered + // frame of a still image with FIXED orientation, which is what bonfire's first + // emitter is. The swaps move it by one either way and nothing else should. + $aprtBlocks = "emissionChain orientationChain imageChain behaviorChain renderChain"; + for(%i = 0; %i < 5; %i++) + { + // getFieldValue, because TorqueScript has no %obj.%name form -- and this + // is the very call the graph sampler used to shadow. + %name = getWord($aprtBlocks, %i); + %rows = aprtVisibleRows($aprtEmitterPane.getFieldValue(%name)); + aprtCheck("the " @ %name @ " block shows five rows (" @ %rows @ ")", %rows == 5); + } + + aprtCheck("the info line places the emitter (" @ $aprtEmitterPane.infoLabel.getText() @ ")", + strstr($aprtEmitterPane.infoLabel.getText(), "Emitter 1 of 2") != -1); + + // The 32 graph fields belong to the Emitter Graph tab, not here. + aprtCheck("no graph field is offered as a row", !isObject($aprtEmitterPane.row["Quantity"])); + aprtCheck("nor a variation of one", !isObject($aprtEmitterPane.row["SizeXVariation"])); + aprtCheck("nor a colour channel", !isObject($aprtEmitterPane.row["RedChannel"])); + + // Not registered as fields in the engine at all -- their addProtectedField + // calls are commented out. + aprtCheck("physics particles are not offered", + !isObject($aprtEmitterPane.row["PhysicsParticle"])); + + // What the rows SHOW, checked against the emitter rather than assumed. See + // the note on the effect pane above: ParticleAssetEmitter had its own + // getFieldValue(time) shadowing SimObject's, so every one of these read "1". + aprtCheck("the name row shows the emitter's name (" @ + $aprtEmitterPane.row["EmitterName"].getValue() @ ")", + $aprtEmitterPane.row["EmitterName"].getValue() $= "smoke"); + aprtCheck("the shape row shows its type", + $aprtEmitterPane.row["EmitterType"].getValue() $= $aprtEmitter.getEmitterType()); + aprtCheck("the shape size row shows the size (" @ + $aprtEmitterPane.row["EmitterSize"].getValue() @ ")", + $aprtEmitterPane.row["EmitterSize"].getValue() $= $aprtEmitter.getEmitterSize()); + aprtCheck("the image row shows the image", + $aprtEmitterPane.row["Image"].getValue() $= $aprtEmitter.getImage()); + aprtCheck("the fixed force angle row shows the angle", + $aprtEmitterPane.row["FixedForceAngle"].getValue() == $aprtEmitter.getFixedForceAngle()); + + schedule(300, 0, "aprtStep6"); +} + +//----------------------------------------------------------------------------- +// SWAP: image versus animation, and numeric frame versus named. +//----------------------------------------------------------------------------- + +function aprtStep6() +{ + aprtCheck("smoke draws a still image", !$aprtEmitterPane.isAnimated()); + aprtCheck("so the source picker says so", + $aprtEmitterPane.sourceRow.getValue() $= "Static Image"); + + aprtCheck("the image row is on show", $aprtEmitterPane.row["Image"].isVisible()); + aprtCheck("and the random-frame switch with it", + $aprtEmitterPane.row["RandomImageFrame"].isVisible()); + aprtCheck("the animation row is hidden", !$aprtEmitterPane.row["Animation"].isVisible()); + + // The image is addressed by number, so the named row is the one that is gone. + aprtCheck("the frame row is on show", $aprtEmitterPane.row["Frame"].isVisible()); + aprtCheck("the named frame row is hidden", !$aprtEmitterPane.row["NamedFrame"].isVisible()); + + aprtCheck("the image row picks images", + $aprtEmitterPane.row["Image"].assetType $= "ImageAsset"); + aprtCheck("and the animation row picks animations", + $aprtEmitterPane.row["Animation"].assetType $= "AnimationAsset"); + + // GREY, not swap: a random frame is still a frame of the same image. + aprtCheck("the frame row is live", $aprtEmitterPane.row["Frame"].editor.isActive()); + $aprtEmitterPane.commitValue("RandomImageFrame", true); + aprtCheck("turning on random frames makes it inert", + !$aprtEmitterPane.row["Frame"].editor.isActive()); + $aprtEmitterPane.commitValue("RandomImageFrame", false); + aprtCheck("and turning it off brings it back", + $aprtEmitterPane.row["Frame"].editor.isActive()); + + schedule(300, 0, "aprtStep7"); +} + +//----------------------------------------------------------------------------- +// GREY: the emission rules, driven by the shape and by Single Particle. +//----------------------------------------------------------------------------- + +function aprtStep7() +{ + aprtCheck("smoke is a LINE emitter", $aprtEmitter.getEmitterType() $= "LINE"); + aprtCheck("so its size is live", $aprtEmitterPane.row["EmitterSize"].editor.isActive()); + aprtCheck("and its angle is live", $aprtEmitterPane.row["EmitterAngle"].editor.isActive()); + + // A point emits from one spot, so neither means anything. + $aprtEmitterPane.commitValue("EmitterType", "POINT"); + aprtCheck("a POINT emitter has no size", !$aprtEmitterPane.row["EmitterSize"].editor.isActive()); + aprtCheck("and no angle", !$aprtEmitterPane.row["EmitterAngle"].editor.isActive()); + aprtCheck("and the size row says why", + strstr($aprtEmitterPane.row["EmitterSize"].editor.Tooltip, "one spot") != -1); + + // A torus is the same shape whichever way it is turned, and the engine's TORUS + // branch never applies the rotation -- but it does have a size. + $aprtEmitterPane.commitValue("EmitterType", "TORUS"); + aprtCheck("a TORUS has a size", $aprtEmitterPane.row["EmitterSize"].editor.isActive()); + aprtCheck("but still no angle", !$aprtEmitterPane.row["EmitterAngle"].editor.isActive()); + + $aprtEmitterPane.commitValue("EmitterType", "LINE"); + + // Targeting replaces the emission ANGLE graph; what goes inert here is the + // target position while targeting is off. + aprtCheck("targeting is off", !$aprtEmitter.getIsTargeting()); + aprtCheck("so the target position is inert", + !$aprtEmitterPane.row["TargetPosition"].editor.isActive()); + $aprtEmitterPane.commitValue("IsTargeting", true); + aprtCheck("turning targeting on brings it live", + $aprtEmitterPane.row["TargetPosition"].editor.isActive()); + + // The one setter in the engine with no refreshAsset of its own, because + // AngleToy writes it every mouse move. The pane asks for the refresh instead. + $aprtEmitterPane.commitValue("TargetPosition", "3 4"); + aprtCheck("a target position written through the pane takes", + $aprtEmitter.getTargetPosition() $= "3 4"); + aprtCheck("and marks the asset dirty, which the engine setter does not", + $aprtAsset.isAssetDirty()); + + $aprtEmitterPane.commitValue("IsTargeting", false); + + // The widest rule on the pane. + $aprtEmitterPane.commitValue("SingleParticle", true); + aprtCheck("a single particle has no shape", + !$aprtEmitterPane.row["EmitterType"].editor.isActive()); + aprtCheck("no size", !$aprtEmitterPane.row["EmitterSize"].editor.isActive()); + aprtCheck("no targeting", !$aprtEmitterPane.row["IsTargeting"].editor.isActive()); + aprtCheck("and the reason names Single Particle", + strstr($aprtEmitterPane.row["EmitterType"].editor.Tooltip, "Single Particle") != -1); + + // The switch that turned it all off is itself still live, or there would be no + // way back. + aprtCheck("but the switch itself stays live", + $aprtEmitterPane.row["SingleParticle"].editor.isActive()); + + $aprtEmitterPane.commitValue("SingleParticle", false); + aprtCheck("turning it off brings the shape back", + $aprtEmitterPane.row["EmitterType"].editor.isActive()); + + schedule(300, 0, "aprtStep8"); +} + +//----------------------------------------------------------------------------- +// SWAP: the orientation arms. GREY: attach rotation, and the blend rows. +//----------------------------------------------------------------------------- + +function aprtStep8() +{ + $aprtEmitterPane.commitValue("OrientationType", "FIXED"); + aprtCheck("FIXED shows its own angle", $aprtEmitterPane.row["FixedAngleOffset"].isVisible()); + aprtCheck("and hides the aligned one", !$aprtEmitterPane.row["AlignedAngleOffset"].isVisible()); + aprtCheck("and the random arc", !$aprtEmitterPane.row["RandomArc"].isVisible()); + + $aprtEmitterPane.commitValue("OrientationType", "ALIGNED"); + aprtCheck("ALIGNED shows its angle", $aprtEmitterPane.row["AlignedAngleOffset"].isVisible()); + aprtCheck("and Keep Aligned with it", $aprtEmitterPane.row["KeepAligned"].isVisible()); + aprtCheck("and hides the fixed angle", !$aprtEmitterPane.row["FixedAngleOffset"].isVisible()); + + $aprtEmitterPane.commitValue("OrientationType", "RANDOM"); + aprtCheck("RANDOM shows the centre angle", + $aprtEmitterPane.row["RandomAngleOffset"].isVisible()); + aprtCheck("and the arc", $aprtEmitterPane.row["RandomArc"].isVisible()); + aprtCheck("and hides Keep Aligned", !$aprtEmitterPane.row["KeepAligned"].isVisible()); + + $aprtEmitterPane.commitValue("OrientationType", "FIXED"); + + // Attach rotation is read only from inside the position-attach test. + aprtCheck("attach rotation is inert on its own", + !$aprtEmitterPane.row["AttachRotationToEmitter"].editor.isActive()); + $aprtEmitterPane.commitValue("AttachPositionToEmitter", true); + aprtCheck("attaching position brings it live", + $aprtEmitterPane.row["AttachRotationToEmitter"].editor.isActive()); + $aprtEmitterPane.commitValue("AttachPositionToEmitter", false); + + // Blending off leaves the two factors nothing to weigh. + aprtCheck("the blend factors are live", $aprtEmitterPane.row["SrcBlendFactor"].editor.isActive()); + $aprtEmitterPane.commitValue("BlendMode", false); + aprtCheck("turning blending off makes them inert", + !$aprtEmitterPane.row["SrcBlendFactor"].editor.isActive() && + !$aprtEmitterPane.row["DstBlendFactor"].editor.isActive()); + $aprtEmitterPane.commitValue("BlendMode", true); + + schedule(300, 0, "aprtStep9"); +} + +//----------------------------------------------------------------------------- +// The second emitter: an animation, with intense particles on. +//----------------------------------------------------------------------------- + +function aprtStep9() +{ + aprtSelect(2); + $aprtFlames = $aprtAsset.getEmitter(1); + + aprtCheck("the pane rebound to the second emitter", $aprtEmitterPane.target == $aprtFlames); + aprtCheck("without rebuilding itself", $aprtEmitterPane.contentGrid.getCount() == 5); + aprtCheck("the info line places it", + strstr($aprtEmitterPane.infoLabel.getText(), "Emitter 2 of 2") != -1); + + // Kept so step 10 can put it back: switching source clears the other asset, + // which is the point of the swap, and an emitter holding neither is what the + // effect pane's second warning is about. + $aprtFlamesAnim = $aprtFlames.getAnimation(); + + aprtCheck("flames plays an animation", $aprtEmitterPane.isAnimated()); + aprtCheck("so the source picker says so", + $aprtEmitterPane.sourceRow.getValue() $= "Animation"); + aprtCheck("the animation row is on show", $aprtEmitterPane.row["Animation"].isVisible()); + aprtCheck("the image row is hidden", !$aprtEmitterPane.row["Image"].isVisible()); + aprtCheck("and so are both frame rows", + !$aprtEmitterPane.row["Frame"].isVisible() && + !$aprtEmitterPane.row["NamedFrame"].isVisible()); + + // Intense particles force additive blending before the blend rows are read. + aprtCheck("intense particles is on", $aprtFlames.getIntenseParticles()); + aprtCheck("so the blend rows are inert", + !$aprtEmitterPane.row["BlendMode"].editor.isActive() && + !$aprtEmitterPane.row["SrcBlendFactor"].editor.isActive()); + aprtCheck("and the reason names it", + strstr($aprtEmitterPane.row["BlendMode"].editor.Tooltip, "Intense") != -1); + + $aprtEmitterPane.commitValue("IntenseParticles", false); + aprtCheck("turning it off brings blending back", + $aprtEmitterPane.row["BlendMode"].editor.isActive() && + $aprtEmitterPane.row["SrcBlendFactor"].editor.isActive()); + $aprtEmitterPane.commitValue("IntenseParticles", true); + + schedule(300, 0, "aprtStep10"); +} + +//----------------------------------------------------------------------------- +// The source swap, written. And the dropdown caption following a rename. +//----------------------------------------------------------------------------- + +function aprtStep10() +{ + // Switching an emitter to a still image and back. There is no StaticMode + // field: the mode is whichever of the two assets was written last, so this + // checks the picker really moves it rather than just relabelling. + $aprtEmitterPane.commitValue("Source", "Static Image"); + aprtCheck("switching to a still image takes", !$aprtEmitterPane.isAnimated()); + aprtCheck("and the animation is let go", $aprtFlames.getAnimation() $= ""); + aprtCheck("the image row appeared", $aprtEmitterPane.row["Image"].isVisible()); + aprtCheck("and the animation row went", !$aprtEmitterPane.row["Animation"].isVisible()); + + $aprtEmitterPane.commitValue("Image", "ToyAssets:Particles4"); + aprtCheck("an image can then be chosen", $aprtFlames.getImage() $= "ToyAssets:Particles4"); + + // Switching back with no animation chosen leaves the emitter in animated mode + // holding nothing. That is exactly the case that cannot be read off the + // assets -- "no animation asset" looks identical to static -- and is why the + // pane asks the engine through isStaticMode instead of guessing. + $aprtEmitterPane.commitValue("Source", "Animation"); + aprtCheck("and switching back takes as well", $aprtEmitterPane.isAnimated()); + aprtCheck("even with no animation chosen yet", $aprtFlames.getAnimation() $= ""); + aprtCheck("the image is let go in turn", $aprtFlames.getImage() $= ""); + + // Put the effect back together, or the emitter draws nothing and the warning + // step below is reading the mess this step made rather than the asset. + $aprtEmitterPane.commitValue("Animation", $aprtFlamesAnim); + aprtCheck("the animation can be chosen again", $aprtFlames.getAnimation() $= $aprtFlamesAnim); + + // The caption in the title bar is a copy of a field on the pane, so renaming + // has to reach it. + $aprtEmitterPane.commitValue("EmitterName", "embers"); + aprtCheck("the emitter renamed", $aprtFlames.getEmitterName() $= "embers"); + aprtCheck("and the dropdown caption followed it (" @ + $aprtInspector.titleDropDown.getText() @ ")", + strstr($aprtInspector.titleDropDown.getText(), "embers") != -1); + aprtCheck("without moving the selection", $aprtInspector.titleDropDown.getSelectedItem() == 2); + + schedule(300, 0, "aprtStep11"); +} + +//----------------------------------------------------------------------------- +// The emitter bar beside the dropdown. Every one of these used to start from the +// generic inspector, which a particle asset no longer goes through. +//----------------------------------------------------------------------------- + +function aprtStep11() +{ + // On the effect itself, none of the three apply. + aprtSelect(0); + aprtCheck("nothing to move forward from the effect", + !$aprtInspector.getMoveEmitterForwardEnabled()); + aprtCheck("nor backward", !$aprtInspector.getMoveEmitterBackwardEnabled()); + aprtCheck("nor to remove", !$aprtInspector.getRemoveEmitterEnabled()); + + // The first emitter cannot go back -- this is the moveEmitter(0, -1) that the + // missing half of the old test used to ask for. + aprtSelect(1); + aprtCheck("the first emitter cannot move backward", + !$aprtInspector.getMoveEmitterBackwardEnabled()); + aprtCheck("but it can move forward", $aprtInspector.getMoveEmitterForwardEnabled()); + + // And the last cannot go forward. + aprtSelect(2); + aprtCheck("the last emitter cannot move forward", + !$aprtInspector.getMoveEmitterForwardEnabled()); + aprtCheck("but it can move backward", $aprtInspector.getMoveEmitterBackwardEnabled()); + + // Reordering is render order, so it has to actually reorder. + $aprtInspector.MoveEmitterBackward(); + aprtCheck("moving it back reordered the asset", $aprtAsset.getEmitter(0) == $aprtFlames); + aprtCheck("and the selection followed the emitter", + $aprtInspector.titleDropDown.getSelectedItem() == 1); + aprtCheck("and the pane is still bound to it", $aprtEmitterPane.target == $aprtFlames); + + $aprtInspector.MoveEmitterForward(); + aprtCheck("and forward puts it back", $aprtAsset.getEmitter(1) == $aprtFlames); + + schedule(300, 0, "aprtStepTransport"); +} + +//----------------------------------------------------------------------------- +// The no-emitter warning, and standing down for another asset kind. +//----------------------------------------------------------------------------- + +function aprtStep12() +{ + // Removing the last emitter is refused, so the empty warning is reached by + // clearing the asset directly -- which is also what makes it worth having: + // an effect can arrive from a file in this state. + aprtSelect(0); + aprtCheck("no warning while it has emitters", !$aprtPane.warningLabel.isVisible()); + + $aprtAsset.clearEmitters(); + $aprtAsset.refreshAsset(); + + aprtCheck("an effect with no emitters is called out", $aprtPane.warningLabel.isVisible()); + aprtCheck("and the warning says nothing will be drawn", + strstr($aprtPane.warningLabel.getText(), "nothing will be drawn") != -1); + aprtCheck("and the info line agrees", $aprtPane.infoLabel.getText() $= "No emitters."); + + schedule(300, 0, "aprtStep13"); +} + +//----------------------------------------------------------------------------- +// The transport over the preview. None of it is asset state, so none of it may +// dirty the asset -- which is the one thing about it worth asserting hardest. +//----------------------------------------------------------------------------- + +function aprtStepTransport() +{ + // Back to a whole effect, and a freshly built preview player with it. + $aprtTile.onClick(); + + schedule(400, 0, "aprtStepTransport2"); +} + +function aprtStepTransport2() +{ + $aprtBar = AssetAdmin.particleTransportBar; + + aprtCheck("the particle transport exists", isObject($aprtBar)); + aprtCheck("it is on show over a particle preview", + AssetAdmin.particleTransportBarContainer.isVisible()); + aprtCheck("it inherits the shared transport chrome", + $aprtBar.getSuperClassNamespace() $= "EditorTransportBar"); + aprtCheck("it has a preview player to drive", isObject(AssetAdmin.previewPlayer)); + + %player = AssetAdmin.previewPlayer; + + // Play and Pause are one button's worth of space with exactly one on show. + aprtCheck("a fresh preview is playing", %player.getIsPlaying() && !%player.getPaused()); + aprtCheck("so the bar offers Pause", $aprtBar.pauseButton.isVisible()); + aprtCheck("and not Play", !$aprtBar.playButton.isVisible()); + + $aprtBar.pause(); + aprtCheck("pausing pauses the player", %player.getPaused()); + aprtCheck("and the bar offers Play again", $aprtBar.playButton.isVisible()); + aprtCheck("and not Pause", !$aprtBar.pauseButton.isVisible()); + + $aprtBar.play(); + aprtCheck("playing resumes it", !%player.getPaused()); + + // Stop lets the particles already out finish rather than killing the player, + // which would leave the preview empty. + $aprtBar.stop(); + aprtCheck("stopping stops emission", !%player.getIsPlaying()); + aprtCheck("but the player is still there", isObject(AssetAdmin.previewPlayer)); + + $aprtBar.restart(); + aprtCheck("restart starts it again", %player.getIsPlaying() && !%player.getPaused()); + + // Speed. + aprtCheck("the preview starts at normal speed", $aprtBar.speed() == 1); + $aprtBar.cycleSpeed(); + aprtCheck("cycling changes the speed", $aprtBar.speed() != 1); + aprtCheck("and the player took it", %player.getTimeScale() == $aprtBar.speed()); + aprtCheck("and the tooltip says which", + strstr($aprtBar.speedButton.Tooltip, $aprtBar.speed() @ "x") != -1); + + schedule(300, 0, "aprtStepSolo"); +} + +function aprtStepSolo() +{ + %player = AssetAdmin.previewPlayer; + + // On the effect itself there is no emitter to isolate. + aprtSelect(0); + aprtCheck("solo is unavailable on the effect", !$aprtBar.soloButton.isActive()); + aprtCheck("and so is mute", !$aprtBar.emitterOffButton.isActive()); + + aprtSelect(1); + aprtCheck("both come live on an emitter", + $aprtBar.soloButton.isActive() && $aprtBar.emitterOffButton.isActive()); + aprtCheck("every emitter is visible to begin with", + %player.getEmitterVisible(0) && %player.getEmitterVisible(1)); + + $aprtBar.soloOn = true; + $aprtBar.reapply(); + aprtCheck("solo keeps the selected emitter", %player.getEmitterVisible(0)); + aprtCheck("and hides the other one", !%player.getEmitterVisible(1)); + + // Moving the selection moves what is isolated -- solo means "the one I am + // looking at", not "the one I first pressed it on". + aprtSelect(2); + aprtCheck("solo follows the selection", %player.getEmitterVisible(1)); + aprtCheck("and lets the first one go", !%player.getEmitterVisible(0)); + + $aprtBar.soloOn = false; + $aprtBar.reapply(); + aprtCheck("clearing solo shows them all again", + %player.getEmitterVisible(0) && %player.getEmitterVisible(1)); + + // Mute is the opposite shape: it pauses only the selected one. + $aprtBar.emitterOff = true; + $aprtBar.reapply(); + aprtCheck("mute pauses the selected emitter", %player.getEmitterPaused(1)); + aprtCheck("and leaves the other running", !%player.getEmitterPaused(0)); + $aprtBar.emitterOff = false; + $aprtBar.reapply(); + + // The whole point of putting this on the player rather than the asset. + $aprtAsset.saveAsset(); + aprtCheck("none of the transport dirtied the asset", !$aprtAsset.isAssetDirty()); + $aprtBar.soloOn = true; + $aprtBar.reapply(); + $aprtBar.cycleSpeed(); + aprtCheck("and soloing still does not", !$aprtAsset.isAssetDirty()); + + // An edit rebuilds the whole preview -- the commit ends in refreshAsset and + // refreshPreview re-clicks the tile, so this is a DIFFERENT ParticlePlayER + // afterwards, which is why the check re-reads it rather than using the handle + // above. The rebuilt emitters all arrive visible, so the bar has to say the + // solo again or it silently comes undone on the first field you change. + $aprtEmitterPane.commitValue("OldestInFront", true); + + %rebuilt = AssetAdmin.previewPlayer; + aprtCheck("the edit rebuilt the preview player", %rebuilt != %player); + aprtCheck("solo survives an edit that rebuilt the emitters", + !%rebuilt.getEmitterVisible(0) && %rebuilt.getEmitterVisible(1)); + + $aprtBar.soloOn = false; + $aprtBar.reapply(); + + schedule(300, 0, "aprtStep12"); +} + +function aprtStep13() +{ + %imageTile = AssetAdmin.Dictionary["ImageAsset"].getButton("ToyAssets:TD_Barbarian_CompSprite"); + %imageTile.onClick(); + + aprtCheck("the particle pane stood down", !$aprtInspector.paneScroller["Particle"].isVisible()); + aprtCheck("the emitter pane too", !$aprtInspector.paneScroller["Emitter"].isVisible()); + aprtCheck("the image pane took over", $aprtInspector.imageScroller.isVisible()); + aprtCheck("both particle panes were unbound", + !isObject($aprtPane.target) && !isObject($aprtEmitterPane.target)); + + echo("APRT DONE"); + schedule(200, 0, "quit"); +} + From 7d8afb1481e91cac979f070d65840d7b6b09548c Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Thu, 13 Aug 2026 19:58:56 -0400 Subject: [PATCH 24/26] The preview that kept showing the asset you had just left Choosing an animation asset while any other kind was selected left the preview showing the previous asset. The split, the timeline and the transport bar all came up correctly around it; only the picture was of the wrong thing. AssetWindow::onExtentChange answers a resize by re-clicking the selected tile, which is how the preview refits when a divider moves. But a tile does not record itself as the selected one until the LAST line of its onClick, and choosing an animation is the one selection that resizes the preview from inside that call: AssetAnimationStage::build moves two dividers to make room for the palette and the timeline. So the sequence was -- display the animation, start building the split, get resized, re-click the tile that is still recorded as selected, which is the PREVIOUS one, and have it clear the scene and repaint its own asset over the animation sprite made a moment earlier. select() then resumed and adopted a sprite id that no longer pointed at anything, and stopped quietly. Which is why the two cases that worked did: an animation chosen as the first tile of a session has no previous tile to re-click, and animation to animation never builds a split because one is already up. The window now asks the stage for first refusal on a resize rather than calling resizePreview itself. While the stage is busy -- putting a split up or taking one down -- the resizes are its own doing and it says so, and the selection that started the rebuild paints the preview itself either side of them. That uncovered a second bug the first had been hiding. The sprite is measured against the whole preview area, because it is made before the split exists, and nothing put it right afterwards: it came out at 38.4 units where 26.7 fits. The re-click had been rebuilding it at the correct size by accident. select() now resizes it once, at the end, with the resizePreview a divider drag already uses. tests/smoke/assetPreviewSwitch.cs walks image -> animation -> font -> animation -> the same animation again and checks what the preview scene actually holds at each step. On the old code five of its twenty checks fail, and the report line of the first reads "100 object(s): Sprite(ToyAssets:TD_Barbarian_CompSprite)..." where one animation sprite belongs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- .../Animation/AssetAnimationStage.cs | 41 +++- editor/AssetAdmin/AssetWindow.cs | 10 +- tests/smoke/assetPreviewSwitch.cs | 231 ++++++++++++++++++ 3 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 tests/smoke/assetPreviewSwitch.cs diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index 6fc154eee..ee7e8fdbc 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -105,10 +105,10 @@ %this.build(); - // The editor closing resizes the canvas, and AssetWindow::onExtentChange - // answers a resize by re-clicking the selected tile -- so a selection can - // arrive while the panes are being torn down around it. Nothing to do then - // but stand down quietly. + // A selection that arrives with no panes to put anything in. absorbResize now + // closes the route this was written for -- a resize mid-teardown re-clicking + // the selected tile -- but build() can also decline, so nothing below may + // assume a pane is there. if(!isObject(%this.palettePane) || !isObject(%this.timelinePane)) { return; @@ -137,6 +137,13 @@ // whether the animation is playing. %this.onPreviewRebuilt(%this.admin.previewSprite); + // The sprite was measured against the whole preview area, because it was made + // before the split existed -- the tile displays first and selects second. The + // split has since taken a palette and a timeline out of that area, and the only + // thing that answers a resize while it is being built is the guard that stops + // the preview being rebuilt. So the sprite is put right here, once, at the end. + %this.resizePreview(); + %this.admin.transportBar.refresh(); } @@ -507,6 +514,32 @@ class = "AssetAnimationPalettePane"; } } +// A divider moved, or the editor was resized, and the window is asking whether it +// should answer that by rebuilding the preview from the selected tile. Two +// separate reasons it must not, and only one of them is about size. +// +// The first is the split being built or collapsed. Both move dividers, so both +// come back through here -- and the tile the window would re-click is the +// PREVIOUSLY selected one, because AssetDictionaryButton::onClick does not record +// its own tile until every branch below it has run. Selecting an animation while +// anything else was selected therefore repainted the preview with the asset the +// user had just navigated away from, on top of the animation sprite that had been +// made a moment earlier -- and cleared that sprite out from under the stage. There +// is nothing for the window to do in either case: the selection that started the +// rebuild paints the preview itself, before or after, and this is only a divider +// moving in the middle of it. +// +// The second is an animation already on show, which is the case below. +function AssetAnimationStage::absorbResize(%this) +{ + if(%this.busy) + { + return true; + } + + return %this.resizePreview(); +} + // A divider moved, or the editor was resized. The sprite is already there and // only its size is wrong, so there is nothing to rebuild. function AssetAnimationStage::resizePreview(%this) diff --git a/editor/AssetAdmin/AssetWindow.cs b/editor/AssetAdmin/AssetWindow.cs index 8568fd2d5..98358a329 100644 --- a/editor/AssetAdmin/AssetWindow.cs +++ b/editor/AssetAdmin/AssetWindow.cs @@ -268,10 +268,12 @@ class = "AssetPreviewSprite"; %this.setCameraArea(%area); %this.setViewLimitOn(%area); - // The animation stage resizes the sprite it already has rather than letting - // the whole preview be rebuilt, which would restart the animation every time - // a divider moved. - if(AssetAdmin.animationStage.resizePreview()) + // The animation stage gets first refusal. It resizes the sprite it already has + // rather than letting the whole preview be rebuilt, which would restart the + // animation every time a divider moved -- and while it is putting its split up + // or taking it down it answers for the resizes that causes, which are its own + // and not a reason to rebuild anything. + if(AssetAdmin.animationStage.absorbResize()) { return; } diff --git a/tests/smoke/assetPreviewSwitch.cs b/tests/smoke/assetPreviewSwitch.cs new file mode 100644 index 000000000..41aec9777 --- /dev/null +++ b/tests/smoke/assetPreviewSwitch.cs @@ -0,0 +1,231 @@ +// Switching the library's selection from one asset kind to another must leave the +// preview showing the asset that was chosen, and nothing else. +// Run: tests/run.ps1 assetPreviewSwitch ; grep APSW in tests/logs/. +// +// The case this exists for is image -> animation. Choosing an animation builds the +// three-way split, which resizes the SceneWindow, whose onExtentChange answers a +// resize by re-clicking the selected tile -- and the selected tile is not updated +// until the END of onClick, so the tile it re-clicks is the PREVIOUS one. That +// re-click cleared the preview scene and put the old asset back in it, on top of +// the animation sprite that had just been made. +// +// Every direction is checked rather than just that one, because the fix is in the +// resize path that all of them go through. +// +// NOTE: a COPY of toybox/ToyAssets, because selecting assets can write to them. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function pswCheck(%label, %cond) +{ + if(%cond) echo("APSW PASS: " @ %label); + else echo("APSW FAIL: " @ %label); +} + +$pswAnimId = "ToyAssets:TD_Barbarian_Death"; +$pswImageId = "ToyAssets:TD_Barbarian_CompSprite"; +$pswFontId = "ToyAssets:ArialFont"; + +function pswLoadFixtureAssets() +{ + %copy = testRoot("assetPreviewSwitchSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +// What the preview scene actually holds, as a line that can be read in the log. +// +// The first few only. An image preview is one sprite per cell, and the sheet this +// suite uses has a hundred of them -- a line naming them all overran the console's +// own buffer, which a debug build reports as a modal assert and a hung test. +function pswSceneReport() +{ + %scene = AssetAdmin.AssetScene; + %count = %scene.getCount(); + %shown = mGetMin(%count, 3); + %out = %count @ " object(s):"; + + for(%i = 0; %i < %shown; %i++) + { + %object = %scene.getObject(%i); + %out = %out SPC %object.getClassName(); + + if(%object.isMemberOfClass("SpriteBase")) + { + %out = %out @ "(" @ (%object.isStaticFrameProvider() ? %object.getImage() : %object.getAnimation()) @ ")"; + } + } + + if(%count > %shown) + { + %out = %out SPC "..."; + } + + return %out; +} + +function pswTile(%kind, %assetId) +{ + return AssetAdmin.Dictionary[%kind].getButton(%assetId); +} + +function pswSameSize(%a, %b) +{ + return mAbs(getWord(%a, 0) - getWord(%b, 0)) < 0.01 && + mAbs(getWord(%a, 1) - getWord(%b, 1)) < 0.01; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "pswStep1"); + +//----------------------------------------------------------------------------- + +function pswStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."). + ProjectManager.setProjectFolder("assetPreviewSwitchSmokeProject"); + EditorPreferences.path = testRoot("shots/assetPreviewSwitchSmokePrefs.taml"); + + pswCheck("fixture asset module registered", pswLoadFixtureAssets()); + + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "pswStep2"); +} + +//----------------------------------------------------------------------------- +// Image first, so that the animation is chosen with a tile already selected -- +// which is the whole point. Chosen as the first tile of the session, an animation +// has always worked, because there is no previous tile for the resize to re-click. +//----------------------------------------------------------------------------- + +function pswStep2() +{ + $pswImageTile = pswTile("ImageAsset", $pswImageId); + $pswAnimTile = pswTile("AnimationAsset", $pswAnimId); + $pswFontTile = pswTile("FontAsset", $pswFontId); + + pswCheck("the image tile is in the library", isObject($pswImageTile)); + pswCheck("the animation tile is in the library", isObject($pswAnimTile)); + pswCheck("the font tile is in the library", isObject($pswFontTile)); + + $pswImageTile.onClick(); + + schedule(600, 0, "pswStep3"); +} + +function pswStep3() +{ + pswCheck("the image is previewed (" @ pswSceneReport() @ ")", + AssetAdmin.AssetScene.getCount() > 0); + pswCheck("the animation stage is down for an image", !AssetAdmin.animationStage.built); + + $pswAnimTile.onClick(); + + schedule(900, 0, "pswStep4"); +} + +//----------------------------------------------------------------------------- +// The bug. +//----------------------------------------------------------------------------- + +function pswStep4() +{ + %stage = AssetAdmin.animationStage; + + pswCheck("the stage is built for an animation", %stage.built); + + // The preview holds the animation and only the animation. An image preview of + // a multi-frame sheet is one sprite per cell, so a count of 1 is already most + // of the statement, and the animation id is the rest of it. + pswCheck("the preview holds one object (" @ pswSceneReport() @ ")", + AssetAdmin.AssetScene.getCount() == 1); + pswCheck("and it is a sprite playing the animation", + AssetAdmin.AssetScene.getObject(0).getAnimation() $= $pswAnimId); + + // And the stage is following the sprite that is actually in the scene, rather + // than one that was cleared out from under it. + pswCheck("the preview sprite is live", isObject(AssetAdmin.previewSprite)); + pswCheck("the stage adopted the sprite in the scene", + %stage.previewSprite == AssetAdmin.AssetScene.getObject(0)); + + // And it is sized for the preview it actually ended up in. The sprite is made + // before the split exists, measured against a preview area that the split is + // about to take a palette and a timeline out of -- so a sprite nobody re-sized + // after the build is one that overflows the frame holding it. + pswCheck("the sprite is sized to the split preview (" @ %stage.previewSprite.getSize() @ ")", + pswSameSize(%stage.previewSprite.getSize(), + AssetAdmin.assetWindow.getWorldSize(%stage.imageAsset.getFrameSize(0)))); + + schedule(600, 0, "pswStep5"); +} + +//----------------------------------------------------------------------------- +// Back out again, and in from a third kind. +//----------------------------------------------------------------------------- + +function pswStep5() +{ + $pswFontTile.onClick(); + + schedule(900, 0, "pswStep6"); +} + +function pswStep6() +{ + pswCheck("the stage came down for the font", !AssetAdmin.animationStage.built); + pswCheck("the font is previewed alone (" @ pswSceneReport() @ ")", + AssetAdmin.AssetScene.getCount() == 1); + pswCheck("and it is the text sprite", + AssetAdmin.AssetScene.getObject(0).getClassName() $= "TextSprite"); + + $pswAnimTile.onClick(); + + schedule(900, 0, "pswStep7"); +} + +function pswStep7() +{ + pswCheck("the stage is built coming from a font", AssetAdmin.animationStage.built); + pswCheck("the preview holds one object (" @ pswSceneReport() @ ")", + AssetAdmin.AssetScene.getCount() == 1); + pswCheck("and it is a sprite playing the animation", + AssetAdmin.AssetScene.getObject(0).getAnimation() $= $pswAnimId); + + // Selecting the same animation again is the path that has always worked -- the + // split is already up, so nothing is resized and nothing re-clicks. Checked so + // that a fix for the other direction cannot quietly break it. + $pswAnimTile.onClick(); + + schedule(600, 0, "pswStep8"); +} + +function pswStep8() +{ + pswCheck("re-choosing the same animation keeps it (" @ pswSceneReport() @ ")", + AssetAdmin.AssetScene.getCount() == 1 && + AssetAdmin.AssetScene.getObject(0).getAnimation() $= $pswAnimId); + pswCheck("and the stage is still following it", + AssetAdmin.animationStage.previewSprite == AssetAdmin.AssetScene.getObject(0)); + + echo("APSW DONE"); + schedule(200, 0, "quit"); +} From 9e4ae28a98c25b098f671a7a9aa03328c1b25628 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Fri, 14 Aug 2026 22:27:21 -0400 Subject: [PATCH 25/26] What color a particle actually is at half its life An emitter's color is four curves -- RedChannel, GreenChannel, BlueChannel and AlphaChannel, each 0 to 1 over a normalized lifetime -- and the Emitter Graph tab offered them as four list entries opening four separate graphs. Every color was already reachable that way. What was not reachable was the question anyone actually opens them to ask: not "what does red do" but "what does this LOOK like over its life". Nobody answers that from three pictures and a mental model of additive mixing. Red, green and blue are now one "Color Channel" entry: the three curves layered on a single plot, with a strip under it showing the color they mix to across the particle's life. Alpha keeps its own entry and its own ordinary graph -- it is not a hue, and folding it into the strip would darken every reading of one. THE LIVE CHANNEL IS THE PARENT'S TARGET FIELD, and that one decision is why this is small. Three toggles down the left pick which channel a click edits; setting one calls setDisplayField, so the hit test, add, delete, drag, the refreshAsset on release -- and therefore snapshot undo -- are all GuiParticleGraphInspector's existing code, unchanged and unduplicated. GuiEditParticleColorGraph adds two things and only two: the other two channels drawn dim and read-only, and the strip. The other two are drawn at the same hue and a lower alpha rather than a darker shade, so they read as sitting behind the live curve rather than as three more colors; dglDrawLine blends, so the grid still shows through them. The strip is exact rather than sampled. Between two consecutive members of the union of the three channels' key times every channel is linear, so one interpolated quad per span is not an approximation of the gradient, it is the gradient. dglDrawBlendRangeBox looks like the tool for that and is not -- its stops are spaced EVENLY across the rect, so it cannot put a stop at 0.13 -- so this is one dglDrawBlendBox per span, with the pixel floored rather than rounded because rounding a pair of adjacent spans independently overlaps them by a pixel and a pixel of overlap between two opaque quads is a visible seam. It samples the key arrays and NOT ParticleAssetField::getFieldValue, which applies a RepeatTime warp and a ValueScale that the plotted curve ignores, and which reads key zero before checking there is one. Where those differ the strip has to agree with the picture directly above it rather than with the runtime, because that is what an editor is for. Neither honors them; said so in the class comment rather than leaving it to be discovered. The parent became a template method to make room: getUnderPlotBandHeight asks for a band between the plot and the x axis labels, renderUnderlay draws behind the curve, renderUnderPlot draws into the band. The layout arithmetic came out into two statics, and getUnderPlotReserve(0) == 0 is the invariant that keeps every existing graph pixel-identical -- it is the first thing the unit tests assert. Two orderings in there are load-bearing and commented as such: the band is reserved BEFORE the rect is snapped to the grid and placed against the snapped rect afterwards, because the snap moves the plot by up to nine pixels; and the offset-changed dirty check moved above the underlay hook, because renderPoints clears mDirty halfway through the frame and the strip is drawn after that. Anything the subclass cached on mDirty alone would have shown the previous frame's color on every frame the user was dragging. The toggles are EditorToggleIcon, whose refresh gained a getIconTint hook. The stock toggle tints with the editor's own inks, bright for on and dim for off, which is right for a switch -- but these three stand for red, green and blue, so the color IS the label and no theme can restyle it without lying. Fixed hues lifted off the primaries, matched to the curve each one controls. That hook is also why this needed no new theme profiles at all. ZOOM WAS DEAD ON EVERY 0-1 FIELD, and had been. ParticleGraphCameraController builds its levels by asking whether max > 1, > 10, > 100, so a field whose max is exactly 1.0 got one level and both zoom buttons answered "no" -- on all four color channels, on both axes. Unit-range fields now get four window widths (1 / 0.5 / 0.25 / 0.1), the last of which is the whole field, which is what makes zooming out unable to go past 0-1. Alpha had the same dead buttons for the same reason and gets the same fix; two 0-1 graphs sitting beside each other should not behave differently. Then the axis labels ate the graph. setDisplayArea kept the caller's string as the label, script hands it a float, and a script float is an F32 printed with "%.9g" -- so a tenth arrived as the eleven-character "0.100000001". The y labels are the entire reason the plot gives up a left margin, so the tightest zoom was spending a third of its width on rounding error. The label is now printed from the value it parsed; the window keeps the value, so nothing the camera computes has to agree with what is drawn to the pixel. Six engine defects fixed on the way through, all reachable before any of this: - getTargetField walked off an emitterless asset. mEmitterIndex = getEmitterCount() - 1 on an unsigned zero is 0xFFFFFFFF, getEmitter warns and returns NULL, and the dereference came BEFORE the AssertFatal meant to catch it -- which compiles out of shipping entirely. Routine here rather than exotic: the color channels exist only on emitters. Now findField, which returns NULL and warns, with guards at all five call sites and the empty-list guard in renderPoints that has to land with them, since the tail there indexes count - 1 unsigned. - the key repair deleted the key it had just inserted, given a first key at a negative time: addDataKey inserts in time order and refuses nothing below mMaxTime, so the new key at zero landed at index 1 and removeDataKey(1) took it straight back out. - the same loop skipped a key after every removal, continuing without stepping i back. Two adjacent bad keys left one behind for a frame. - setDisplayField(name, index) reset the selected point when the field name changed but not when the EMITTER did, so the same channel on a different emitter kept an index into the old key list. Exactly the color graph's normal usage. - dglDrawBlendBox's mobile and web path left GL_COLOR_ARRAY enabled pointing at squareColors, a stack local, so every later vertex-array draw read a dead frame for its colors. Its neighbours in the same file disable it. - initEmitter read %itemWidth, a local of init(), so variGraph and lifeGraph were built with a malformed Extent and positioned all sixteen of their buttons against it. The grid resizing cells afterwards is what had been hiding it. mGridRect and mCalculationOffset were uninitialized and onTouchDown reads the first; findHitGraphPoint returned -1 from a U32 and worked only because the wrap round-tripped through an S32. Both corrected in passing. guiParticleColorGraphTests covers what has no canvas: the reserve invariant, the grid snap including a negative extent that used to become an unsigned four billion, channel sampling, the three-way merge (cross-channel de-duplication, a window that clips, a stop budget that runs out and must still reach the far edge), and the joint property that a strictly increasing stop list produces pixels that never decrease at any rect width -- a negative-width RectI reaches dglDrawBlendBox as a reversed quad. tests/smoke/particleColorGraph.cs drives the collapse, the radio, the mix and the zoom; the shot harness carries the part only a picture settles, including the three hues on all four editor themes, since they are the one thing here a theme cannot restyle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- cmake/EngineSources.cmake | 2 + .../AssetParticleChannelToggle.cs | 46 ++ .../AssetParticleColorGraphUnit.cs | 93 +++ .../ParticleEditor/AssetParticleGraphTool.cs | 85 ++- .../ParticleEditor/AssetParticleGraphUnit.cs | 90 ++- .../ParticleGraphCameraController.cs | 22 + editor/AssetAdmin/ParticleEditor/exec.cs | 2 + editor/EditorCore/EditorToggleIcon.cs | 24 +- engine/source/graphics/dgl.cc | 4 + .../gui/editor/guiEditParticleColorGraph.cc | 510 ++++++++++++++++ .../gui/editor/guiEditParticleColorGraph.h | 238 ++++++++ .../guiEditParticleColorGraph_ScriptBinding.h | 92 +++ .../gui/editor/guiParticleGraphInspector.cc | 301 +++++++--- .../gui/editor/guiParticleGraphInspector.h | 123 +++- .../guiParticleGraphInspector_ScriptBinding.h | 17 + .../tests/guiParticleColorGraphTests.cc | 556 ++++++++++++++++++ tests/shots/particleColorGraph.cs | 251 ++++++++ tests/smoke/particleColorGraph.cs | 309 ++++++++++ 18 files changed, 2646 insertions(+), 119 deletions(-) create mode 100644 editor/AssetAdmin/ParticleEditor/AssetParticleChannelToggle.cs create mode 100644 editor/AssetAdmin/ParticleEditor/AssetParticleColorGraphUnit.cs create mode 100644 engine/source/gui/editor/guiEditParticleColorGraph.cc create mode 100644 engine/source/gui/editor/guiEditParticleColorGraph.h create mode 100644 engine/source/gui/editor/guiEditParticleColorGraph_ScriptBinding.h create mode 100644 engine/source/testing/tests/guiParticleColorGraphTests.cc create mode 100644 tests/shots/particleColorGraph.cs create mode 100644 tests/smoke/particleColorGraph.cs diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index 83e51323a..3b16495b8 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -205,6 +205,7 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/gui/editor/guiEditFramePaletteCtrl.cc ${TORQUE_SRC}/gui/editor/guiEditFrameStripCtrl.cc ${TORQUE_SRC}/gui/editor/guiEditFrameTimelineCtrl.cc + ${TORQUE_SRC}/gui/editor/guiEditParticleColorGraph.cc ${TORQUE_SRC}/gui/editor/guiEditorExplorerTree.cc ${TORQUE_SRC}/gui/editor/guiGraphCtrl.cc ${TORQUE_SRC}/gui/editor/guiInspector.cc @@ -348,6 +349,7 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/testing/tests/guiCursorHotSpotTests.cc ${TORQUE_SRC}/testing/tests/guiFrameStripLayoutTests.cc ${TORQUE_SRC}/testing/tests/guiHitTestTests.cc + ${TORQUE_SRC}/testing/tests/guiParticleColorGraphTests.cc ${TORQUE_SRC}/testing/tests/guiProfileThemeTests.cc ${TORQUE_SRC}/testing/tests/guiScrollLayoutTests.cc ${TORQUE_SRC}/testing/tests/guiTextEditTests.cc diff --git a/editor/AssetAdmin/ParticleEditor/AssetParticleChannelToggle.cs b/editor/AssetAdmin/ParticleEditor/AssetParticleChannelToggle.cs new file mode 100644 index 000000000..4afa0bf28 --- /dev/null +++ b/editor/AssetAdmin/ParticleEditor/AssetParticleChannelToggle.cs @@ -0,0 +1,46 @@ + +//----------------------------------------------------------------------------- +// One of the color graph's three channel buttons: a swatch that stays pressed. +// +// An EditorToggleIcon with one thing changed. The stock toggle tints its icon +// with the editor's own inks, bright for on and dim for off, which is right for a +// switch -- but these three buttons stand for red, green and blue, so the color +// IS the label. A themed ink would say which button is pressed and nothing about +// which curve it belongs to. +// +// So the tint is the channel's own hue, matched to what GuiEditParticleColorGraph +// draws that channel's curve in: full strength when the channel is live, faded +// when it is not, exactly as the curve is. The button and the line it controls +// are then visibly the same thing. +// +// Radio behavior belongs to the owner, not here: a checkbox flips itself, so +// clicking the live channel would switch it off. AssetParticleColorGraphUnit +// puts it back. +// +// The creator sets channel, owner, and tipOff inline. +//----------------------------------------------------------------------------- + +function AssetParticleChannelToggle::getIconTint(%this, %on) +{ + if(!%this.isActive()) + { + return ThemeManager.activeTheme.iconButtonProfile.fontColorNA; + } + + // Lifted off the primaries for the same reason the curves are: a pure blue + // swatch on a dark panel is close to unreadable. These stay unmistakably red, + // green and blue on every editor theme. + switch$(%this.channel) + { + case "Green": + %color = %on ? "90 220 110 255" : "90 220 110 130"; + + case "Blue": + %color = %on ? "105 155 255 255" : "105 155 255 130"; + + default: + %color = %on ? "255 95 95 255" : "255 95 95 130"; + } + + return %color; +} diff --git a/editor/AssetAdmin/ParticleEditor/AssetParticleColorGraphUnit.cs b/editor/AssetAdmin/ParticleEditor/AssetParticleColorGraphUnit.cs new file mode 100644 index 000000000..f1955b849 --- /dev/null +++ b/editor/AssetAdmin/ParticleEditor/AssetParticleColorGraphUnit.cs @@ -0,0 +1,93 @@ + +//----------------------------------------------------------------------------- +// The graph unit for an emitter's color, the one selection that shows something +// other than a single curve. +// +// It is an AssetParticleGraphUnit with two differences: the graph it builds is a +// GuiEditParticleColorGraph rather than a plain inspector, and it buys itself a +// second column down the left side for the three channel toggles. Everything +// else -- the zoom and pan buttons, the cameras, attaching to and detaching from +// the tool's grid -- is the superclass's and unchanged. +// +// Exactly one channel is live. The toggles are a radio group, and which one is +// pressed is asked of the graph rather than remembered here, so the buttons +// cannot drift out of step with what a click on the plot will edit. +//----------------------------------------------------------------------------- + +function AssetParticleColorGraphUnit::createGraph(%this) +{ + return new GuiEditParticleColorGraph(); +} + +function AssetParticleColorGraphUnit::getLeftInset(%this) +{ + // 30 for the zoom and pan column the superclass places, and 30 more for the + // channel toggles this unit adds outside it. + return 60; +} + +function AssetParticleColorGraphUnit::addExtraControls(%this) +{ + %this.channelCount = 3; + %this.channel[0] = "Red"; + %this.channel[1] = "Green"; + %this.channel[2] = "Blue"; + + for(%i = 0; %i < %this.channelCount; %i++) + { + %channel = %this.channel[%i]; + + %toggle = new GuiCheckBoxCtrl() + { + Class = "AssetParticleChannelToggle"; + superclass = "EditorToggleIcon"; + channel = %channel; + owner = %this; + frameOff = $EditorIcon::square_shape; + tipOff = "Edit the " @ %channel @ " channel"; + Position = "2" SPC (18 + (%i * 26)); + Extent = "24 24"; + }; + ThemeManager.setProfile(%toggle, "iconButtonProfile"); + %this.add(%toggle); + + %this.toggle[%channel] = %toggle; + } +} + +// Point the unit at an emitter and show it. The labels say Color rather than the +// Base Value the other units use: these are life curves, whatever the field +// collection files them under. +// +// The channel is whichever one was already live, so switching emitters leaves you +// looking at the same channel you were editing. Setting the field is what carries +// the emitter index, and the graph keeps its live channel in step with it. +function AssetParticleColorGraphUnit::setToColor(%this, %emitterID) +{ + %this.attach(); + %this.graph.setDisplayLabels("Time", "Color"); + %this.graph.setDisplayField(%this.graph.getActiveChannel() @ "Channel", %emitterID); + %this.refreshToggles(); +} + +// A checkbox has already flipped itself by the time this runs, so the live +// channel is put back on rather than being allowed to switch off, and the other +// two are cleared. +function AssetParticleColorGraphUnit::onToggleIconChanged(%this, %toggle) +{ + %this.graph.setActiveChannel(%toggle.channel); + %this.refreshToggles(); +} + +// The graph is asked which channel is live rather than told, so a channel set +// any other way still lights the right button. +function AssetParticleColorGraphUnit::refreshToggles(%this) +{ + %active = %this.graph.getActiveChannel(); + + for(%i = 0; %i < %this.channelCount; %i++) + { + %channel = %this.channel[%i]; + %this.toggle[%channel].setValue(%channel $= %active); + } +} diff --git a/editor/AssetAdmin/ParticleEditor/AssetParticleGraphTool.cs b/editor/AssetAdmin/ParticleEditor/AssetParticleGraphTool.cs index 2e5608dde..107908620 100644 --- a/editor/AssetAdmin/ParticleEditor/AssetParticleGraphTool.cs +++ b/editor/AssetAdmin/ParticleEditor/AssetParticleGraphTool.cs @@ -49,9 +49,11 @@ %this.addItem("Emission Force"); %this.addItem("Emission Angle"); %this.addItem("Emission Arc"); - %this.addItem("Red Channel"); - %this.addItem("Green Channel"); - %this.addItem("Blue Channel"); + + // One entry for red, green and blue together. They were three, and three + // pictures cannot answer what color a particle is at half its life -- which is + // the only thing anyone opens them to find out. + %this.addItem("Color Channel"); %this.addItem("Alpha Channel"); } @@ -107,14 +109,17 @@ ThemeManager.setProfile(%this.toolScroll, "scrollingPanelArrowProfile", "ArrowProfile"); %this.add(%this.toolScroll); - %itemWidth = 360; + // A field rather than a local: initEmitter builds three more units from it, and + // as a local it was simply empty there -- which made their authored Extent a + // malformed point, and every button in them was placed against it. + %this.itemWidth = 360; %this.toolGrid = new GuiGridCtrl() { HorizSizing="width"; VertSizing="height"; Position="0 0"; Extent = (getWord(%this.toolScroll.Extent, 0) - 14) SPC getWord(%this.extent, 1); - CellSizeX = %itemWidth; + CellSizeX = %this.itemWidth; CellSizeY = 0; CellModeX = variable; CellModeY = variable; @@ -131,8 +136,9 @@ HorizSizing="right"; VertSizing="bottom"; Position="0 0"; - Extent= %itemWidth SPC (getWord(%this.extent, 1) - 30); + Extent= %this.itemWidth SPC (getWord(%this.extent, 1) - 30); Text = "Base Value"; + Tool = %this.toolGrid; }; ThemeManager.setProfile(%this.baseGraph, "labelProfile"); %this.toolGrid.add(%this.baseGraph); @@ -146,7 +152,7 @@ HorizSizing="right"; VertSizing="bottom"; Position="0 0"; - Extent= %itemWidth SPC (getWord(%this.extent, 1) - 30); + Extent= %this.itemWidth SPC (getWord(%this.extent, 1) - 30); Text = "Variation"; Tool = %this.toolGrid; }; @@ -160,12 +166,47 @@ HorizSizing="right"; VertSizing="bottom"; Position="0 0"; - Extent= %itemWidth SPC (getWord(%this.extent, 1) - 30); + Extent= %this.itemWidth SPC (getWord(%this.extent, 1) - 30); Text = "Scale Over Particle Lifetime"; Tool = %this.toolGrid; }; ThemeManager.setProfile(%this.lifeGraph, "labelProfile"); %this.toolGrid.add(%this.lifeGraph); + + // The color unit is wider than the others: it is the only one on screen when + // it is showing, and the strip under its plot reads better with the room. It + // starts out of the grid, since the tool opens on Lifetime. + %this.colorGraph = new GuiControl() + { + Class = "AssetParticleColorGraphUnit"; + superclass = "AssetParticleGraphUnit"; + HorizSizing="right"; + VertSizing="bottom"; + Position="0 0"; + Extent= (%this.itemWidth * 2) SPC (getWord(%this.extent, 1) - 30); + Text = "Color Over Particle Lifetime"; + Tool = %this.toolGrid; + }; + ThemeManager.setProfile(%this.colorGraph, "labelProfile"); + %this.colorGraph.detach(); +} + +// The grid deletes the units inside it. A unit that is currently detached is not +// inside anything, so it is this tool's to delete. +function AssetParticleGraphEmitterTool::onRemove(%this) +{ + %this.deleteDetachedUnit(%this.baseGraph); + %this.deleteDetachedUnit(%this.variGraph); + %this.deleteDetachedUnit(%this.lifeGraph); + %this.deleteDetachedUnit(%this.colorGraph); +} + +function AssetParticleGraphEmitterTool::deleteDetachedUnit(%this, %unit) +{ + if(isObject(%unit) && !%this.toolGrid.isMember(%unit)) + { + %unit.delete(); + } } function AssetParticleGraphTool::addItem(%this, %item, %color) @@ -192,6 +233,10 @@ { %this.lifeGraph.graph.inspect(%asset); } + if(isObject(%this.colorGraph)) + { + %this.colorGraph.graph.inspect(%asset); + } %this.baseList.clearSelection(); %this.emitterID = %emitterID; %this.baseList.setCurSel(0); @@ -265,9 +310,7 @@ class = ParticleGraphCameraController; %graphTable[%i] = "EmissionForce"; %i++; %graphTable[%i] = "EmissionAngle"; %i++; %graphTable[%i] = "EmissionArc"; %i++; - %graphTable[%i] = "RedChannel"; %i++; - %graphTable[%i] = "GreenChannel"; %i++; - %graphTable[%i] = "BlueChannel"; %i++; + %graphTable[%i] = "ColorChannel"; %i++; %graphTable[%i] = "AlphaChannel"; for(%i = 0; %i < 11; %i++) @@ -281,6 +324,26 @@ class = ParticleGraphCameraController; } %name = %graphTable[%index]; + + // Color is the one selection that shows a different graph rather than a + // different field, so it swaps the whole set of units in the grid. + if(%name $= "ColorChannel") + { + %this.baseGraph.detach(); + %this.variGraph.detach(); + %this.lifeGraph.detach(); + + %this.colorGraph.setToColor(%this.emitterID); + + // Any of the three channels gives the same window: they are registered with + // identical bounds, 0 to 1 over a lifetime of 0 to 1. + %this.colorGraph.setValueController(%this.getValueController("RedChannel")); + %this.colorGraph.setTimeController(%this.getTimeController("RedChannel")); + return; + } + + %this.colorGraph.detach(); + %this.baseGraph.attach(); %this.baseGraph.setToBase(%name, %varTable[%index], %this.emitterID); %this.baseGraph.setValueController(%this.getValueController(%name)); %this.baseGraph.setTimeController(%this.getTimeController(%name)); diff --git a/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs b/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs index f579f6b00..bb1d43ccd 100644 --- a/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs +++ b/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs @@ -1,16 +1,23 @@ function AssetParticleGraphUnit::onAdd(%this) { - %this.graph = new GuiParticleGraphInspector() - { - HorizSizing="width"; - VertSizing="height"; - Position="30 18"; - Extent= (getWord(%this.extent, 0) - 40) SPC (getWord(%this.extent, 1) - 60); - }; + // Everything here is placed against the left inset rather than a literal 30, + // so a subclass wanting another column of its own down the left side moves the + // whole arrangement by overriding one number. + %inset = %this.getLeftInset(); + + %this.graph = %this.createGraph(); + %this.graph.HorizSizing = "width"; + %this.graph.VertSizing = "height"; + %this.graph.Position = %inset SPC 18; + %this.graph.Extent = (getWord(%this.extent, 0) - %inset - 10) SPC (getWord(%this.extent, 1) - 60); ThemeManager.setProfile(%this.graph, "graphProfile"); %this.add(%this.graph); + // The value buttons sit in the column immediately left of the graph, whatever + // the inset is. + %valueX = %inset - 28; + // Value zoom buttons. A plus and minus in a square rather than the magnifier // pair these used to wear: the icon set has a magnifier but no +/- variants // of it, and the squared pair stays distinct from the round plus and minus, @@ -20,7 +27,7 @@ { Class = "EditorIconButton"; Frame = $EditorIcon::sq_plus; - Position = "2" SPC (%center + 13); + Position = %valueX SPC (%center + 13); Command = %this.getId() @ ".valueZoomIn();"; Tooltip = "Zoom In"; }; @@ -31,7 +38,7 @@ { Class = "EditorIconButton"; Frame = $EditorIcon::sq_minus; - Position = "2" SPC (%center - 13); + Position = %valueX SPC (%center - 13); Command = %this.getId() @ ".valueZoomOut();"; Tooltip = "Zoom Out"; }; @@ -43,7 +50,7 @@ { Class = "EditorIconButton"; Frame = $EditorIcon::arrow_top; - Position = "2 18"; + Position = %valueX SPC 18; Command = %this.getId() @ ".valueMoveUp();"; Tooltip = "Move Graph Up"; }; @@ -54,7 +61,7 @@ { Class = "EditorIconButton"; Frame = $EditorIcon::arrow_bottom; - Position = "2" SPC (getWord(%this.extent, 1) - 66); + Position = %valueX SPC (getWord(%this.extent, 1) - 66); Command = %this.getId() @ ".valueMoveDown();"; Tooltip = "Move Graph Down"; }; @@ -62,7 +69,6 @@ %this.add(%this.valueMoveDownButton); //time zoom buttons - %center = 18 + mRound(getWord(%this.graph.extent, 0)); %bottom = getWord(%this.extent, 1) - 38; %this.timeZoomContainer = new GuiControl() { @@ -101,7 +107,7 @@ Class = "EditorIconButton"; Frame = $EditorIcon::arrow_left; HorizSizing = "right"; - Position = "30" SPC %bottom; + Position = %inset SPC %bottom; Command = %this.getId() @ ".timeMoveBack();"; Tooltip = "Move Graph Back"; }; @@ -119,6 +125,44 @@ }; ThemeManager.setProfile(%this.timeMoveForwardButton, "iconButtonProfile"); %this.add(%this.timeMoveForwardButton); + + %this.addExtraControls(); +} + +// The graph this unit wraps. A subclass showing something other than one curve +// answers with its own control and inherits every button above unchanged. +function AssetParticleGraphUnit::createGraph(%this) +{ + return new GuiParticleGraphInspector(); +} + +// How much of the unit's left edge belongs to buttons rather than to the graph. +function AssetParticleGraphUnit::getLeftInset(%this) +{ + return 30; +} + +// Anything a subclass wants in the room its inset bought. Nothing, here. +function AssetParticleGraphUnit::addExtraControls(%this) +{ +} + +// A unit that has nothing to show is taken out of the grid rather than emptied, +// so the cells that remain close up over it. +function AssetParticleGraphUnit::attach(%this) +{ + if(!%this.Tool.isMember(%this)) + { + %this.Tool.add(%this); + } +} + +function AssetParticleGraphUnit::detach(%this) +{ + if(%this.Tool.isMember(%this)) + { + %this.Tool.remove(%this); + } } function AssetParticleGraphUnit::setToScale(%this, %scaleName) @@ -137,17 +181,11 @@ { if(%variName $= "") { - if(%this.Tool.isMember(%this)) - { - %this.Tool.remove(%this); - } + %this.detach(); return; } - if(!%this.Tool.isMember(%this)) - { - %this.Tool.add(%this); - } + %this.attach(); %this.graph.setDisplayLabels("Time", "Variation"); %this.graph.setDisplayField(%variName, %emitterID); } @@ -156,17 +194,11 @@ { if(%lifeName $= "") { - if(%this.Tool.isMember(%this)) - { - %this.Tool.remove(%this); - } + %this.detach(); return; } - if(!%this.Tool.isMember(%this)) - { - %this.Tool.add(%this); - } + %this.attach(); %this.graph.setDisplayLabels("Time", "Scale"); %this.graph.setDisplayField(%lifeName, %emitterID); } diff --git a/editor/AssetAdmin/ParticleEditor/ParticleGraphCameraController.cs b/editor/AssetAdmin/ParticleEditor/ParticleGraphCameraController.cs index 868e44ffd..c9136eb6a 100644 --- a/editor/AssetAdmin/ParticleEditor/ParticleGraphCameraController.cs +++ b/editor/AssetAdmin/ParticleEditor/ParticleGraphCameraController.cs @@ -60,6 +60,28 @@ { %this.setupDegreeValue(); } + + // A field that never leaves 0-1 got exactly one zoom level out of the chain + // above, which left all four buttons dead on every color channel and on alpha. + if(%this.max <= 1) + { + %this.setupUnitValue(); + } +} + +// The zoom levels are window WIDTHS, so index 1 is the tightest view and the last +// is the whole field. That is what makes zooming out unable to go past 0-1: there +// is no wider window to ask for. +function ParticleGraphCameraController::setupUnitValue(%this) +{ + %this.currentPosition = %this.min; + + %this.zoomLevel[1] = 0.1; + %this.zoomLevel[2] = 0.25; + %this.zoomLevel[3] = 0.5; + %this.zoomLevel[4] = 1; + %this.zoomCount = 4; + %this.currentZoomLevel = 4; } function ParticleGraphCameraController::setupDegreeValue(%this) diff --git a/editor/AssetAdmin/ParticleEditor/exec.cs b/editor/AssetAdmin/ParticleEditor/exec.cs index afe6c7aab..e09346f5b 100644 --- a/editor/AssetAdmin/ParticleEditor/exec.cs +++ b/editor/AssetAdmin/ParticleEditor/exec.cs @@ -1,5 +1,7 @@ exec("./AssetParticleGraphTool.cs"); exec("./AssetParticleGraphUnit.cs"); +exec("./AssetParticleColorGraphUnit.cs"); +exec("./AssetParticleChannelToggle.cs"); exec("./ParticleGraphCameraController.cs"); exec("./NewParticleEmitterDialog.cs"); exec("./AssetParticleTransportBar.cs"); diff --git a/editor/EditorCore/EditorToggleIcon.cs b/editor/EditorCore/EditorToggleIcon.cs index b467291c0..d623ca8d2 100644 --- a/editor/EditorCore/EditorToggleIcon.cs +++ b/editor/EditorCore/EditorToggleIcon.cs @@ -116,11 +116,10 @@ } // The single place the icon's look is decided: which frame, which tooltip, and -// which of the profile's font colors tints it. +// what tints it. function EditorToggleIcon::refresh(%this) { %on = %this.getStateOn(); - %profile = ThemeManager.activeTheme.iconButtonProfile; // frameOn is optional. Where there is only one icon for the idea, the tint // alone carries the state. @@ -135,16 +134,25 @@ %this.icon.setImageFrame(%frame); } + %this.icon.setImageColor(%this.getIconTint(%on)); + + %this.Tooltip = %this.buildTip(%on); +} + +// What the icon is tinted with, split out so a toggle whose color is part of its +// meaning can answer differently. The two profile inks below say "on" and "off" +// in the editor's own palette, which is right for a switch but not for a button +// that stands for a color -- see AssetParticleChannelToggle. +function EditorToggleIcon::getIconTint(%this, %on) +{ + %profile = ThemeManager.activeTheme.iconButtonProfile; + if(!%this.isActive()) { - %this.icon.setImageColor(%profile.fontColorNA); - } - else - { - %this.icon.setImageColor(%on ? %profile.fontColorHL : %profile.fontColor); + return %profile.fontColorNA; } - %this.Tooltip = %this.buildTip(%on); + return %on ? %profile.fontColorHL : %profile.fontColor; } // Two lines, now that a control's text can hold a line break: what this is and diff --git a/engine/source/graphics/dgl.cc b/engine/source/graphics/dgl.cc index b2f1f5586..1bdf5f2d2 100755 --- a/engine/source/graphics/dgl.cc +++ b/engine/source/graphics/dgl.cc @@ -1646,6 +1646,10 @@ void dglDrawBlendBox(const RectI& bounds, ColorF& c1, ColorF& c2, ColorF& c3, Co glEnableClientState(GL_COLOR_ARRAY); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + // squareColors is a local. Leaving the color array enabled and pointed at it + // hands every later vertex-array draw a pointer into a dead stack frame. + glDisableClientState(GL_COLOR_ARRAY); } //-------------------------------------------------------------------------- diff --git a/engine/source/gui/editor/guiEditParticleColorGraph.cc b/engine/source/gui/editor/guiEditParticleColorGraph.cc new file mode 100644 index 000000000..9300ceff5 --- /dev/null +++ b/engine/source/gui/editor/guiEditParticleColorGraph.cc @@ -0,0 +1,510 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#include "console/console.h" +#include "console/consoleTypes.h" +#include "graphics/dgl.h" + +#ifndef _PARTICLE_ASSET_H_ +#include "2d/assets/ParticleAsset.h" +#endif + +#include "gui/editor/guiEditParticleColorGraph.h" + +#include "guiEditParticleColorGraph_ScriptBinding.h" + +IMPLEMENT_CONOBJECT(GuiEditParticleColorGraph); + +//----------------------------------------------------------------------------- +// Arithmetic +//----------------------------------------------------------------------------- + +S32 GuiEditParticleColorGraph::clampStripHeight(const S32 height) +{ + // Zero is a legal answer and means no strip. Anything else is dragged up to + // something a gradient can actually be read in. + if (height <= 0) + { + return 0; + } + + return mClamp(height, smMinStripHeight, smMaxStripHeight); +} + +F32 GuiEditParticleColorGraph::sampleChannel(const F32* times, const F32* values, const S32 count, const F32 time) +{ + if (times == NULL || values == NULL || count <= 0) + { + return 0.0f; + } + + // Flat before the first key and flat after the last, which is what the curve + // draws: renderPoints runs the final value out to the right edge, and there is + // nothing to the left of key zero because key zero is always at time zero. + if (time <= times[0]) + { + return values[0]; + } + + if (time >= times[count - 1]) + { + return values[count - 1]; + } + + for (S32 i = 1; i < count; i++) + { + if (time <= times[i]) + { + const F32 span = times[i] - times[i - 1]; + if (span <= 0.0f) + { + return values[i]; + } + + const F32 ratio = (time - times[i - 1]) / span; + return (values[i - 1] * (1.0f - ratio)) + (values[i] * ratio); + } + } + + return values[count - 1]; +} + +S32 GuiEditParticleColorGraph::buildGradientStops(const F32* redTimes, const S32 redCount, + const F32* greenTimes, const S32 greenCount, + const F32* blueTimes, const S32 blueCount, + const F32 windowMin, const F32 windowMax, + F32* outStops, const S32 maxStops) +{ + if (outStops == NULL || maxStops < 2 || (windowMax - windowMin) <= smStopEpsilon) + { + return 0; + } + + const F32* times[ChannelCount] = { redTimes, greenTimes, blueTimes }; + S32 counts[ChannelCount] = { redCount, greenCount, blueCount }; + S32 pen[ChannelCount] = { 0, 0, 0 }; + + for (S32 c = 0; c < ChannelCount; c++) + { + if (times[c] == NULL) + { + counts[c] = 0; + } + } + + S32 count = 0; + outStops[count++] = windowMin; + + // One slot is always held back so the window's far edge can be written no + // matter how many keys were merged. + while (count < (maxStops - 1)) + { + S32 which = -1; + F32 best = 0.0f; + + for (S32 c = 0; c < ChannelCount; c++) + { + // Skip everything at or before the stop just written. This is the + // de-duplication -- within a channel and across all three at once -- and + // it is also what clips the head of each list to the window. + while (pen[c] < counts[c] && times[c][pen[c]] <= (outStops[count - 1] + smStopEpsilon)) + { + pen[c]++; + } + + if (pen[c] < counts[c] && (which == -1 || times[c][pen[c]] < best)) + { + which = c; + best = times[c][pen[c]]; + } + } + + if (which == -1 || best >= (windowMax - smStopEpsilon)) + { + break; + } + + outStops[count++] = best; + pen[which]++; + } + + outStops[count++] = windowMax; + + return count; +} + +S32 GuiEditParticleColorGraph::timeToPixel(const F32 time, const F32 windowMin, const F32 windowMax, + const S32 rectLeft, const S32 rectWidth) +{ + const F32 span = windowMax - windowMin; + if (span <= 0.0f || rectWidth <= 0) + { + return rectLeft; + } + + const F32 ratio = (time - windowMin) / span; + + return mClamp(rectLeft + (S32)mFloor(ratio * (F32)rectWidth), rectLeft, rectLeft + rectWidth); +} + +StringTableEntry GuiEditParticleColorGraph::getChannelFieldName(const Channel channel) +{ + switch (channel) + { + case ChannelGreen: + return StringTable->insert("GreenChannel", true); + case ChannelBlue: + return StringTable->insert("BlueChannel", true); + default: + return StringTable->insert("RedChannel", true); + } +} + +GuiEditParticleColorGraph::Channel GuiEditParticleColorGraph::getChannelFromName(const char* name) +{ + if (name == NULL || *name == 0) + { + return ChannelCount; + } + + // dStricmp rather than a string table compare: this is the one place a + // caller's spelling is allowed, and both the short and the field name are + // accepted so script can pass either. + if (dStricmp(name, "Red") == 0 || dStricmp(name, "RedChannel") == 0) + { + return ChannelRed; + } + + if (dStricmp(name, "Green") == 0 || dStricmp(name, "GreenChannel") == 0) + { + return ChannelGreen; + } + + if (dStricmp(name, "Blue") == 0 || dStricmp(name, "BlueChannel") == 0) + { + return ChannelBlue; + } + + return ChannelCount; +} + +ColorI GuiEditParticleColorGraph::getChannelColor(const Channel channel, const bool isActive) +{ + // Lifted off the primaries: a pure blue line on a dark panel is close to + // unreadable, and pure red is not much better. These stay unmistakably red, + // green and blue while being legible on every editor theme. + // + // The inactive form is the same hue at a lower alpha rather than a darker one, + // so it reads as sitting behind the live curve rather than as a fourth color. + const U8 alpha = isActive ? 255 : 110; + + switch (channel) + { + case ChannelGreen: + return ColorI(90, 220, 110, alpha); + case ChannelBlue: + return ColorI(105, 155, 255, alpha); + default: + return ColorI(255, 95, 95, alpha); + } +} + +//----------------------------------------------------------------------------- + +GuiEditParticleColorGraph::GuiEditParticleColorGraph() +{ + mActiveChannel = ChannelRed; + mStripHeight = smDefaultStripHeight; + + // The parent's defaults describe a scale field, and they are what the very + // first frame draws with -- before any script has called setDisplayArea. + mTargetField = getChannelFieldName(ChannelRed); + mMinY = 0.0f; + mMinYLabel = StringTable->insert("0"); + mMaxY = 1.0f; + mMaxYLabel = StringTable->insert("1"); + mLabelY = StringTable->insert("Color", true); + + mCacheValid = false; + mCacheAsset = NULL; + mCacheEmitterIndex = 0; + mCacheMinX = 0.0f; + mCacheMaxX = 0.0f; +} + +void GuiEditParticleColorGraph::initPersistFields() +{ + Parent::initPersistFields(); + + addProtectedField("StripHeight", TypeS32, Offset(mStripHeight, GuiEditParticleColorGraph), + &setStripHeightField, &getStripHeightField, + "How tall the mixed color strip under the plot is drawn. Zero for no strip."); +} + +void GuiEditParticleColorGraph::setActiveChannel(const Channel channel) +{ + if (channel < 0 || channel >= ChannelCount) + { + return; + } + + // Through the parent's target field, which is the whole trick: the live + // channel is the field it edits, so the hit test, the drag and the refresh on + // release all belong to it and none of them are written twice. + setDisplayField(getChannelFieldName(channel)); +} + +void GuiEditParticleColorGraph::setDisplayField(const char* fieldName) +{ + const Channel channel = getChannelFromName(fieldName); + if (channel == ChannelCount) + { + Con::warnf("GuiEditParticleColorGraph::setDisplayField() - '%s' is not a color channel.", fieldName); + return; + } + + mActiveChannel = channel; + + // The channel's own spelling, never the caller's: the field lookup folds case + // but the string table entry is interned case-sensitively, so passing "red" + // straight through would make a second entry that compares equal to nothing. + Parent::setDisplayField(getChannelFieldName(channel)); +} + +void GuiEditParticleColorGraph::setStripHeight(const S32 height) +{ + const S32 clamped = clampStripHeight(height); + if (clamped == mStripHeight) + { + return; + } + + mStripHeight = clamped; + + // The band changes the plot rect, and mGridRect -- which is what a click is + // tested against -- is only rebuilt on a dirty frame. + mDirty = true; +} + +void GuiEditParticleColorGraph::setVariationGraphInspector(GuiParticleGraphInspector* object) +{ + Con::warnf("GuiEditParticleColorGraph::setVariationGraphInspector() - A color graph has no variation to shade."); +} + +ColorF GuiEditParticleColorGraph::sampleColorAtTime(const F32 time) +{ + F32 component[ChannelCount]; + for (S32 c = 0; c < ChannelCount; c++) + { + component[c] = mClampF(sampleChannel(mChannelTimes[c].address(), mChannelValues[c].address(), + (S32)mChannelTimes[c].size(), time), 0.0f, 1.0f); + } + + // Always opaque. The strip answers what hues a particle passes through; alpha + // is its own graph and mixing it in here would darken every reading. + return ColorF(component[ChannelRed], component[ChannelGreen], component[ChannelBlue], 1.0f); +} + +ColorF GuiEditParticleColorGraph::getColorAtTime(const F32 time) +{ + refreshChannelCaches(); + + return sampleColorAtTime(time); +} + +const char* GuiEditParticleColorGraph::getGradientStopList() +{ + refreshChannelCaches(); + + if (mGradientStops.size() == 0) + { + return StringTable->EmptyString; + } + + const S32 stopCount = (S32)mGradientStops.size(); + const U32 bufferSize = (U32)stopCount * 16; + char* buffer = Con::getReturnBuffer(bufferSize); + U32 offset = 0; + + for (S32 i = 0; i < stopCount; i++) + { + offset += dSprintf(buffer + offset, bufferSize - offset, (i == 0) ? "%g" : " %g", mGradientStops[i]); + } + + return buffer; +} + +//----------------------------------------------------------------------------- + +void GuiEditParticleColorGraph::refreshChannelCaches() +{ + const bool moved = !mCacheValid + || mDirty + || mCacheAsset != mTargetAsset + || mCacheEmitterIndex != mEmitterIndex + || mCacheMinX != mMinX + || mCacheMaxX != mMaxX; + + if (!moved) + { + return; + } + + for (S32 c = 0; c < ChannelCount; c++) + { + mChannelTimes[c].clear(); + mChannelValues[c].clear(); + } + mGradientStops.clear(); + + mCacheValid = true; + mCacheAsset = mTargetAsset; + mCacheEmitterIndex = mEmitterIndex; + mCacheMinX = mMinX; + mCacheMaxX = mMaxX; + + if (mTargetAsset == NULL) + { + return; + } + + // The active channel first. This runs before calculatePoints, so repairing it + // here means the curve above the strip and the strip itself are built from the + // same keys on the same frame. repairDataKeys is idempotent, so the call + // calculatePoints makes next walks the list and changes nothing. + for (S32 i = 0; i < ChannelCount; i++) + { + const Channel channel = (Channel)((mActiveChannel + i) % ChannelCount); + + ParticleAssetField* field = findField(getChannelFieldName(channel)); + if (field == NULL) + { + continue; + } + + repairDataKeys(field); + + const U32 count = field->getDataKeyCount(); + for (U32 k = 0; k < count; k++) + { + const ParticleAssetField::DataKey& key = field->getDataKey(k); + mChannelTimes[channel].push_back(key.mTime); + mChannelValues[channel].push_back(key.mValue); + } + } + + F32 stops[smMaxGradientStops]; + const S32 stopCount = buildGradientStops( + mChannelTimes[ChannelRed].address(), (S32)mChannelTimes[ChannelRed].size(), + mChannelTimes[ChannelGreen].address(), (S32)mChannelTimes[ChannelGreen].size(), + mChannelTimes[ChannelBlue].address(), (S32)mChannelTimes[ChannelBlue].size(), + mMinX, mMaxX, stops, smMaxGradientStops); + + for (S32 i = 0; i < stopCount; i++) + { + mGradientStops.push_back(stops[i]); + } +} + +void GuiEditParticleColorGraph::renderUnderlay(const RectI &plotRect) +{ + if (mTargetAsset == NULL) + { + return; + } + + refreshChannelCaches(); + + // The live channel is not drawn here. The parent draws it next, brighter and + // with the dots that say it is the one a click will reach. + for (S32 c = 0; c < ChannelCount; c++) + { + if (c != (S32)mActiveChannel) + { + renderChannelCurve(plotRect, (Channel)c); + } + } +} + +void GuiEditParticleColorGraph::renderChannelCurve(const RectI &plotRect, const Channel channel) +{ + const S32 count = (S32)mChannelTimes[channel].size(); + if (count == 0) + { + return; + } + + const ColorI lineColor = getChannelColor(channel, false); + + Point2I p1 = convertToRenderPoint(plotRect, mChannelTimes[channel][0], mChannelValues[channel][0]); + for (S32 i = 1; i < count; i++) + { + const Point2I p2 = convertToRenderPoint(plotRect, mChannelTimes[channel][i], mChannelValues[channel][i]); + renderLine(plotRect, p1, p2, lineColor); + p1 = p2; + } + + // The flat run out to the right edge, the same one renderPoints draws for the + // live channel. Without it a dim curve stops at its last key while the live one + // carries on, and the two look like they disagree. + const Point2I edge = Point2I(plotRect.point.x + plotRect.extent.x, p1.y); + if (p1.x < edge.x) + { + renderLine(plotRect, p1, edge, lineColor); + } +} + +void GuiEditParticleColorGraph::renderUnderPlot(const RectI &bandRect) +{ + // Draws only. Everything it reads was snapshotted in renderUnderlay, because + // by now the parent has cleared mDirty and an edit made this frame would not + // show up until the next one. + if (mTargetAsset == NULL || mGradientStops.size() < 2) + { + return; + } + + const S32 stopCount = (S32)mGradientStops.size(); + + S32 x1 = timeToPixel(mGradientStops[0], mMinX, mMaxX, bandRect.point.x, bandRect.extent.x); + ColorF c1 = sampleColorAtTime(mGradientStops[0]); + + for (S32 i = 1; i < stopCount; i++) + { + const S32 x2 = timeToPixel(mGradientStops[i], mMinX, mMaxX, bandRect.point.x, bandRect.extent.x); + ColorF c2 = sampleColorAtTime(mGradientStops[i]); + + if (x2 > x1) + { + RectI span = RectI(x1, bandRect.point.y, x2 - x1, bandRect.extent.y); + + // Corners are top left, top right, bottom right, bottom left, so a pure + // left to right ramp repeats each edge color top and bottom. The named + // locals are not tidiness: the signature takes non-const references and a + // temporary will not bind to one. + dglDrawBlendBox(span, c1, c2, c2, c1); + } + + x1 = x2; + c1 = c2; + } +} diff --git a/engine/source/gui/editor/guiEditParticleColorGraph.h b/engine/source/gui/editor/guiEditParticleColorGraph.h new file mode 100644 index 000000000..71d37939a --- /dev/null +++ b/engine/source/gui/editor/guiEditParticleColorGraph.h @@ -0,0 +1,238 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +#ifndef _GUI_EDIT_PARTICLE_COLOR_GRAPH_H_ +#define _GUI_EDIT_PARTICLE_COLOR_GRAPH_H_ + +#ifndef _GUIPARTICLEGRAPHINSPECTOR_H_ +#include "gui/editor/guiParticleGraphInspector.h" +#endif + +//----------------------------------------------------------------------------- +// An emitter's three color channels on one graph, with a strip under the plot +// showing the color they mix to across the particle's life. +// +// They used to be three graphs behind three list selections, which is the wrong +// shape for the question anyone actually asks of them: not "what does red do" +// but "what color is this at half its life". Nobody can answer that from three +// separate pictures, which is what the strip is for. +// +// One channel is live at a time. The live channel IS the parent's target field, +// so every part of editing it -- the hit test, add, delete, drag, the refresh on +// release and therefore undo -- is the parent's and unchanged. This class adds +// two things and only two: the other two channels drawn dim and read-only, and +// the strip. +// +// Like its parent it repairs the fields it draws as a side effect of drawing: a +// key forced to time zero, out-of-order keys dropped. The parent does that for +// whichever channel is selected; this does it for all three at once, which makes +// it predictable rather than dependent on what the user happened to click. The +// gradient needs it: its whole correctness argument is that between two +// breakpoints every channel is linear, and renderLine will happily draw a +// segment backwards given keys that go back in time. +// +// Note that neither the curves nor the strip apply the field's RepeatTime or +// ValueScale, so on a field carrying either they both disagree with what the +// particle will do at runtime. They agree with each other, which is the property +// an editor needs -- the strip is a reference for the picture directly above it. +// +// Editor-only, and not offered to anyone building a Gui: the palette refuses +// every class whose name begins "GuiEdit", in both copies of that rule +// (GuiEditorControlIcons::isPlaceableClass, generated, and +// GuiEditorControlSpec::isPlaceableClass, hand-typed for its drift guard). +//----------------------------------------------------------------------------- + +class GuiEditParticleColorGraph : public GuiParticleGraphInspector +{ +private: + typedef GuiParticleGraphInspector Parent; + +public: + enum Channel + { + ChannelRed = 0, + ChannelGreen, + ChannelBlue, + ChannelCount + }; + + /// The most stops the strip will draw. A window holding more keys than this + /// loses the detail in its tail and nothing else; the alternative is a heap + /// allocation per frame in a render path. + static constexpr S32 smMaxGradientStops = 128; + + /// Two stops closer together than this are one stop. Times run 0 to 1 and a + /// plot is a few hundred pixels wide, so nothing below this could be told + /// apart on screen anyway. + static constexpr F32 smStopEpsilon = 1.0e-5f; + + static constexpr S32 smDefaultStripHeight = 16; + static constexpr S32 smMinStripHeight = 6; + static constexpr S32 smMaxStripHeight = 64; + + //------------------------------------------------------------------------- + // Arithmetic. Everything these use is a parameter, so the tests can call them + // without building a control -- which they could not do anyway, since a unit + // test has no GL context and asking a profile for a font would load a texture + // and trip a modal assert. + //------------------------------------------------------------------------- + + /// A strip height the control will accept. Zero, meaning no strip at all, is + /// a legal answer and the only one below the minimum. + static S32 clampStripHeight(const S32 height); + + /// One channel's value at a time, read off the curve the graph DRAWS: linear + /// between keys, flat before the first and flat after the last. + /// + /// Deliberately not ParticleAssetField::getFieldValue, which applies a repeat + /// time and a value scale the plotted curve does not, and which reads key + /// zero before checking that there is one. + static F32 sampleChannel(const F32* times, const F32* values, const S32 count, const F32 time); + + /// The times at which the mixed color can bend: every key of every channel + /// inside the window, plus the window's two edges. Between two neighbours all + /// three channels are linear, so one interpolated quad per span is exact -- + /// which is why this is not dglDrawBlendRangeBox, whose stops are evenly + /// spaced and so could only approximate a key at 0.13. + /// + /// Each channel's times must already ascend; addDataKey inserts in order and + /// repairDataKeys guarantees the rest. The output strictly increases, which is + /// what the draw loop depends on. Returns the count written: 0 for a window + /// with no width, otherwise at least 2. + static S32 buildGradientStops(const F32* redTimes, const S32 redCount, + const F32* greenTimes, const S32 greenCount, + const F32* blueTimes, const S32 blueCount, + const F32 windowMin, const F32 windowMax, + F32* outStops, const S32 maxStops); + + /// Where a time lands across a rect. Floored and clamped, so two touching + /// spans share an edge exactly -- rounding a pair independently can overlap + /// them by a pixel, and a pixel of overlap between two opaque quads is a + /// visible seam. Flooring also matches convertToRenderPoint, so a bend in the + /// strip sits directly under the bend in the curve. + static S32 timeToPixel(const F32 time, const F32 windowMin, const F32 windowMax, + const S32 rectLeft, const S32 rectWidth); + + /// The field name a channel is, in the spelling ParticleAssetEmitter + /// registered it under. Never the caller's spelling: the field lookup folds + /// case but setDisplayField interns case-sensitively, so "redchannel" would + /// make a second entry that stops comparing equal to this one. + static StringTableEntry getChannelFieldName(const Channel channel); + + /// A channel from a name, taking "Red" and "RedChannel" alike, or ChannelCount + /// for anything that is neither. + static Channel getChannelFromName(const char* name); + + /// What a channel draws in. Fixed hues rather than profile colors, because no + /// theme can know which curve is the red one; lightened, because pure blue on + /// a dark panel is unreadable. The inactive form is the same hue at a lower + /// alpha -- dglDrawLine blends, so the grid shows through and the curve reads + /// as sitting behind the live one. + static ColorI getChannelColor(const Channel channel, const bool isActive); + + //------------------------------------------------------------------------- + + DECLARE_CONOBJECT(GuiEditParticleColorGraph); + GuiEditParticleColorGraph(); + static void initPersistFields(); + + /// Which channel is live: drawn bright, the only one with dots, and the only + /// one a click can reach. Setting it goes through the parent's target field, + /// which is what makes every editing path work with no code here. + void setActiveChannel(const Channel channel); + inline Channel getActiveChannel() const { return mActiveChannel; } + + /// The live channel and the field being edited are the same fact, so they are + /// written in one place. Setting the field is how the emitter index gets set -- + /// the parent's two argument form -- and doing it here means an emitter change + /// cannot leave the two disagreeing about which channel is live. + virtual void setDisplayField(const char* fieldName); + + /// The mixed color at a time, alpha always opaque. For a readout, and for a + /// test that has no way to look at what was drawn. + ColorF getColorAtTime(const F32 time); + + /// The stops the strip will draw, as a space separated list of times. + const char* getGradientStopList(); + + void setStripHeight(const S32 height); + inline S32 getStripHeight() const { return mStripHeight; } + + /// Refused. A color graph has no variation to shade, and getRenderPoints + /// rewrites the graph it is asked of to a one pixel grid rect behind its back. + virtual void setVariationGraphInspector(GuiParticleGraphInspector* object); + +protected: + virtual S32 getUnderPlotBandHeight() { return mStripHeight; } + virtual void renderUnderlay(const RectI &plotRect); + virtual void renderUnderPlot(const RectI &bandRect); + virtual ColorI getCurveColor() { return getChannelColor(mActiveChannel, true); } + + /// Rebuild the snapshot the dim curves and the strip both read. + /// + /// Takes no rect: nothing it builds is in pixels, which is what lets a console + /// getter call it before the control has ever drawn. Called from renderUnderlay + /// while the parent's mDirty is still set -- but the flag is only one of the + /// reasons to rebuild, because renderPoints clears it halfway through the frame + /// and the strip is drawn after that. Anything keyed on mDirty alone would show + /// the previous frame's color on every frame the user was dragging. + void refreshChannelCaches(); + + void renderChannelCurve(const RectI &plotRect, const Channel channel); + + /// The mixed color at a time, read straight off the snapshot. Refreshes + /// nothing, so it is safe to call for every stop of the strip. + ColorF sampleColorAtTime(const F32 time); + + static bool setStripHeightField(void* obj, const char* data) + { + static_cast(obj)->setStripHeight(dAtoi(data)); + return false; + } + static const char* getStripHeightField(void* obj, const char* data) + { + return Con::getIntArg(static_cast(obj)->getStripHeight()); + } + +private: + Channel mActiveChannel; + S32 mStripHeight; + + /// Each channel's keys, flat. Flat because buildGradientStops wants plain + /// arrays and so does the test, and because the render points are two divides + /// away and not worth a second cache. + Vector mChannelTimes[ChannelCount]; + Vector mChannelValues[ChannelCount]; + + /// The strip's stops, in time. Built with the curves, never in renderUnderPlot. + Vector mGradientStops; + + /// What the snapshot was built from. mDirty alone is not enough; see + /// refreshChannelCaches. + bool mCacheValid; + ParticleAsset* mCacheAsset; + U32 mCacheEmitterIndex; + F32 mCacheMinX; + F32 mCacheMaxX; +}; + +#endif //_GUI_EDIT_PARTICLE_COLOR_GRAPH_H_ diff --git a/engine/source/gui/editor/guiEditParticleColorGraph_ScriptBinding.h b/engine/source/gui/editor/guiEditParticleColorGraph_ScriptBinding.h new file mode 100644 index 000000000..a2cb84781 --- /dev/null +++ b/engine/source/gui/editor/guiEditParticleColorGraph_ScriptBinding.h @@ -0,0 +1,92 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +ConsoleMethodGroupBeginWithDocs(GuiEditParticleColorGraph, GuiParticleGraphInspector) + +/*! Sets which color channel the graph edits. + The other two channels stay on screen, drawn dim, and a click cannot reach them. + @param channel Red, Green or Blue. The field names RedChannel, GreenChannel and BlueChannel are accepted too. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditParticleColorGraph, setActiveChannel, ConsoleVoid, 3, 3, (channel)) +{ + GuiEditParticleColorGraph::Channel channel = GuiEditParticleColorGraph::getChannelFromName(argv[2]); + + if (channel == GuiEditParticleColorGraph::ChannelCount) + { + Con::warnf("GuiEditParticleColorGraph::setActiveChannel() - '%s' is not a color channel.", argv[2]); + return; + } + + object->setActiveChannel(channel); +} + +/*! Gets which color channel the graph is editing. + @return Red, Green or Blue. +*/ +ConsoleMethodWithDocs(GuiEditParticleColorGraph, getActiveChannel, ConsoleString, 2, 2, ()) +{ + switch (object->getActiveChannel()) + { + case GuiEditParticleColorGraph::ChannelGreen: + return "Green"; + case GuiEditParticleColorGraph::ChannelBlue: + return "Blue"; + default: + return "Red"; + } +} + +/*! Gets the color the three channels mix to at a point in the particle's life. + This is the color the strip under the plot is drawing at that time. + @param time Where in the particle's life to sample, from 0 to 1. + @return The color as "red green blue", each from 0 to 1. +*/ +ConsoleMethodWithDocs(GuiEditParticleColorGraph, getColorAtTime, ConsoleString, 3, 3, (time)) +{ + const ColorF color = object->getColorAtTime(dAtof(argv[2])); + + char* buffer = Con::getReturnBuffer(64); + dSprintf(buffer, 64, "%g %g %g", color.red, color.green, color.blue); + + return buffer; +} + +/*! Gets the times the mixed color strip bends at, across the visible time window. + Every key of every channel inside the window, plus the window's two edges. + @return A space separated list of times. +*/ +ConsoleMethodWithDocs(GuiEditParticleColorGraph, getGradientStops, ConsoleString, 2, 2, ()) +{ + return object->getGradientStopList(); +} + +/*! Sets how tall the mixed color strip under the plot is drawn. + @param height The height in pixels, or zero for no strip at all. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditParticleColorGraph, setStripHeight, ConsoleVoid, 3, 3, (height)) +{ + object->setStripHeight(dAtoi(argv[2])); +} + +ConsoleMethodGroupEndWithDocs(GuiEditParticleColorGraph) diff --git a/engine/source/gui/editor/guiParticleGraphInspector.cc b/engine/source/gui/editor/guiParticleGraphInspector.cc index 712763dd5..a98d1901c 100644 --- a/engine/source/gui/editor/guiParticleGraphInspector.cc +++ b/engine/source/gui/editor/guiParticleGraphInspector.cc @@ -59,6 +59,11 @@ GuiParticleGraphInspector::GuiParticleGraphInspector() mDirty = true; mPointList = Vector(); + // RectI and Point2I leave their members alone, and onTouchDown reads mGridRect. + // A control can be clicked after inspect() but before its first render. + mGridRect = RectI(0, 0, 0, 0); + mCalculationOffset = Point2I(0, 0); + setField("profile", "GuiDefaultProfile"); } @@ -86,23 +91,32 @@ void GuiParticleGraphInspector::setDisplayField(const char* fieldName) void GuiParticleGraphInspector::setDisplayField(const char* fieldName, U16 index) { + // The same field on a different emitter is a different curve, and a point index + // into the old one addresses whatever happens to sit at that slot in the new. + if (mEmitterIndex != (U32)index) + { + mSelectedIndex = -1; + } + mEmitterIndex = index; setDisplayField(fieldName); } void GuiParticleGraphInspector::setDisplayArea(StringTableEntry minX, StringTableEntry minY, StringTableEntry maxX, StringTableEntry maxY) { - mMinXLabel = minX; + char buffer[32]; + mMinX = dAtof(minX); + mMinXLabel = StringTable->insert(formatAxisLabel(mMinX, buffer, sizeof(buffer))); - mMinYLabel = minY; mMinY = dAtof(minY); + mMinYLabel = StringTable->insert(formatAxisLabel(mMinY, buffer, sizeof(buffer))); - mMaxXLabel = maxX; mMaxX = dAtof(maxX); + mMaxXLabel = StringTable->insert(formatAxisLabel(mMaxX, buffer, sizeof(buffer))); - mMaxYLabel = maxY; mMaxY = dAtof(maxY); + mMaxYLabel = StringTable->insert(formatAxisLabel(mMaxY, buffer, sizeof(buffer))); mDirty = true; } @@ -113,26 +127,102 @@ void GuiParticleGraphInspector::setDisplayLabels(const char* labelX, const char* mLabelY = StringTable->insert(labelY, true); } -ParticleAssetField* GuiParticleGraphInspector::getTargetField() +ParticleAssetField* GuiParticleGraphInspector::findField(StringTableEntry fieldName) { + if (mTargetAsset == NULL || fieldName == NULL || fieldName == StringTable->EmptyString) + { + return NULL; + } + ParticleAssetFieldCollection& collection = mTargetAsset->getParticleFields(); - ParticleAssetField* field = collection.findField(mTargetField); + ParticleAssetField* field = collection.findField(fieldName); + if (field != NULL) + { + return field; + } + + const U32 emitterCount = (U32)mTargetAsset->getEmitterCount(); + if (emitterCount == 0) + { + // An asset with no emitters yet. Subtracting one from an unsigned zero is + // how this used to walk off the end. + return NULL; + } + + // Clamp before asking rather than after: getEmitter warns on a bad index, and a + // bad index here is a per-frame condition, so asking politely first is the + // difference between a quiet editor and sixty warnings a second in the log. + if (mEmitterIndex >= emitterCount) + { + mEmitterIndex = emitterCount - 1; + } + + ParticleAssetEmitter* emitter = mTargetAsset->getEmitter(mEmitterIndex); + if (emitter == NULL) + { + return NULL; + } + + return emitter->getParticleFields().findField(fieldName); +} + +ParticleAssetField* GuiParticleGraphInspector::getTargetField() +{ + ParticleAssetField* field = findField(mTargetField); + + // A warning, not a fatal. A half-built asset with no emitters reaches here as a + // matter of course, and in a debug build an AssertFatal is a modal box -- which + // arrives as a hang rather than as a failure. + AssertWarn(field != NULL, "GuiParticleGraphInspector::getTargetField() - Unable to find the requested field."); + + return field; +} +bool GuiParticleGraphInspector::repairDataKeys(ParticleAssetField* field) +{ if (field == NULL) { - if (mEmitterIndex >= mTargetAsset->getEmitterCount()) + return false; + } + + bool changed = false; + F32 time = 0.0f; + U32 count = field->getDataKeyCount(); + + for (U32 i = 0; i < count; i++) + { + ParticleAssetField::DataKey key = field->getDataKey(i); + + // Force the first key to always be at time zero. + // + // Only when it sits after zero: addDataKey inserts in time order and refuses + // nothing below mMaxTime, so with a key at a negative time the new one lands + // at index 1 and the removal below would delete what was just added. + if (i == 0 && key.mTime > 0.0f) { - mEmitterIndex = mTargetAsset->getEmitterCount() - 1; + field->addDataKey(0.0f, key.mValue); + field->removeDataKey(1); + key = field->getDataKey(0); + count = field->getDataKeyCount(); + changed = true; } - ParticleAssetEmitter* emitter = mTargetAsset->getEmitter(mEmitterIndex); - ParticleAssetFieldCollection& emitterCollection = emitter->getParticleFields(); - field = emitterCollection.findField(mTargetField); - } + // Remove the point if it has a bad time. Do not advance i: the key that + // followed the one just removed now sits at this index and has not been + // looked at. + if (i > 0 && key.mTime <= time) + { + field->removeDataKey(i); + count--; + i--; + changed = true; + continue; + } - AssertFatal(field != NULL, "GuiParticleGraphInspector::getTargetField() - Unable to find the requested field."); + time = key.mTime; + } - return field; + return changed; } void GuiParticleGraphInspector::resize(const Point2I &newPosition, const Point2I &newExtent) @@ -164,6 +254,9 @@ void GuiParticleGraphInspector::onTouchDown(const GuiEvent &event) { //remove the point ParticleAssetField* field = getTargetField(); + if (!field) + return; + field->removeDataKey(mSelectedIndex); mDirty = true; @@ -172,6 +265,9 @@ void GuiParticleGraphInspector::onTouchDown(const GuiEvent &event) { //Time to create a new point! ParticleAssetField* field = getTargetField(); + if (!field) + return; + F32 time = getGraphTime(event.mousePoint.x); F32 value = getGraphValue(event.mousePoint.y); mSelectedIndex = field->addDataKey(time, value); @@ -191,6 +287,9 @@ void GuiParticleGraphInspector::onTouchDragged(const GuiEvent &event) { //Time to move the first point! ParticleAssetField* field = getTargetField(); + if (!field) + return; + F32 value = getGraphValue(point.y); field->setDataKeyValue(mSelectedIndex, value); @@ -200,6 +299,9 @@ void GuiParticleGraphInspector::onTouchDragged(const GuiEvent &event) { //Time to move a point! ParticleAssetField* field = getTargetField(); + if (!field) + return; + F32 time = getGraphTime(point.x); F32 value = getGraphValue(point.y); if (time == field->getDataKeyTime(mSelectedIndex) || field->doesKeyExist(time)) @@ -218,14 +320,14 @@ void GuiParticleGraphInspector::onTouchDragged(const GuiEvent &event) } } -U32 GuiParticleGraphInspector::findHitGraphPoint(const Point2I &point) +S32 GuiParticleGraphInspector::findHitGraphPoint(const Point2I &point) { - for (U32 i = 0; i < mPointList.size(); i++) + for (S32 i = 0; i < mPointList.size(); i++) { - F32 x = mPointList[i].mPoint.x - point.x; - F32 y = mPointList[i].mPoint.y - point.y; + F32 x = (F32)(mPointList[i].mPoint.x - point.x); + F32 y = (F32)(mPointList[i].mPoint.y - point.y); F32 dist = mSqrt((x * x) + (y * y)); - if (dist <= mRadius) + if (dist <= smPointRadius) { return i; } @@ -249,6 +351,70 @@ F32 GuiParticleGraphInspector::getGraphTime(const F32 x) return mMinX + ((mMaxX - mMinX) * ratio); } +const char* GuiParticleGraphInspector::formatAxisLabel(const F32 value, char* buffer, const U32 bufferSize) +{ + if (buffer == NULL || bufferSize == 0) + { + return ""; + } + + dSprintf(buffer, bufferSize, "%g", value); + + return buffer; +} + +S32 GuiParticleGraphInspector::getUnderPlotReserve(const S32 bandHeight) +{ + return (bandHeight > 0) ? (bandHeight + (2 * smUnderPlotGap)) : 0; +} + +RectI GuiParticleGraphInspector::snapRectToGrid(const RectI &rect, const S32 divisor) +{ + if (divisor < 1 || !rect.isValidRect()) + { + return rect; + } + + const S32 modX = rect.len_x() % divisor; + const S32 modY = rect.len_y() % divisor; + + return RectI(rect.point.x + (modX / 2), rect.point.y + (modY / 2), + rect.extent.x - modX, rect.extent.y - modY); +} + +RectI GuiParticleGraphInspector::calculatePlotRect(const RectI &contentRect) +{ + GFont *font = mProfile->getFont(mFontSizeAdjust); + const S32 fontHeight = (S32)font->getHeight(); + + //Make room for the graph labels, and for a band if a subclass asked for one + RectI rect = contentRect; + rect.extent.y -= (fontHeight + getUnderPlotReserve(getUnderPlotBandHeight())); + + const S32 xReduction = getMax(getMax(fontHeight, (S32)font->getStrWidth(mMaxYLabel)), (S32)font->getStrWidth(mMinYLabel)); + rect.extent.x -= xReduction; + rect.point.x += xReduction; + + return snapRectToGrid(rect, 10); +} + +RectI GuiParticleGraphInspector::getUnderPlotRect(const RectI &plotRect) +{ + const S32 bandHeight = getUnderPlotBandHeight(); + if (bandHeight <= 0 || !plotRect.isValidRect()) + { + return RectI(0, 0, 0, 0); + } + + return RectI(plotRect.point.x, plotRect.point.y + plotRect.extent.y + smUnderPlotGap, + plotRect.extent.x, bandHeight); +} + +S32 GuiParticleGraphInspector::getXLabelTop(const RectI &plotRect) +{ + return plotRect.point.y + plotRect.extent.y + getUnderPlotReserve(getUnderPlotBandHeight()) + 2; +} + void GuiParticleGraphInspector::onRender(Point2I offset, const RectI &updateRect) { RectI ctrlRect = applyMargins(offset, mBounds.extent, NormalState, mProfile); @@ -261,34 +427,32 @@ void GuiParticleGraphInspector::onRender(Point2I offset, const RectI &updateRect RectI fillRect = applyBorders(ctrlRect.point, ctrlRect.extent, NormalState, mProfile); RectI contentRect = applyPadding(fillRect.point, fillRect.extent, NormalState, mProfile); - //Make room for the graph labels - GFont *font = mProfile->getFont(mFontSizeAdjust); - U32 fontHeight = font->getHeight(); - contentRect.extent.y -= fontHeight; - U8 xReduction = getMax(getMax(fontHeight, font->getStrWidth(mMaxYLabel)), font->getStrWidth(mMinYLabel)); - contentRect.extent.x -= xReduction; - contentRect.point.x += xReduction; - - //reduce the contentRect to be divisible by 10 - U32 modX = contentRect.len_x() % 10; - U32 modY = contentRect.len_y() % 10; - contentRect.extent.set(contentRect.len_x() - modX, contentRect.len_y() - modY); - contentRect.point.set(contentRect.point.x + mFloor(modX / 2), contentRect.point.y + mFloor(modY / 2)); + RectI plotRect = calculatePlotRect(contentRect); //Draw the labels ColorI gridColor = mProfile->getFillColor(HighlightState); - renderLabels(contentRect, gridColor); + renderLabels(plotRect, gridColor); - if (contentRect.isValidRect()) + if (plotRect.isValidRect()) { - renderGrid(contentRect, gridColor); + renderGrid(plotRect, gridColor); + // Before the underlay rather than after it: a subclass caching anything of + // its own has to see the same dirty flag renderPoints is about to clear. if (mCalculationOffset != offset) { mDirty = true; } - renderPoints(contentRect, mProfile->getFillColor(SelectedState)); + + renderUnderlay(plotRect); + renderPoints(plotRect, getCurveColor()); mCalculationOffset = offset; + + const RectI bandRect = getUnderPlotRect(plotRect); + if (bandRect.isValidRect()) + { + renderUnderPlot(bandRect); + } } } @@ -302,19 +466,23 @@ void GuiParticleGraphInspector::renderLabels(const RectI &contentRect, const Col //Set the color used for the grid. This will also be used for the text. dglSetBitmapModulation(labelColor); + // The x row sits below anything a subclass reserved a band for, not directly + // under the plot -- otherwise the labels draw over the band. + const S32 xLabelTop = getXLabelTop(contentRect); + //x label textWidth = font->getStrWidth(mLabelX); - textPoint = Point2I(contentRect.point.x + (contentRect.extent.x / 2) - (textWidth / 2), contentRect.point.y + contentRect.extent.y + 2); + textPoint = Point2I(contentRect.point.x + (contentRect.extent.x / 2) - (textWidth / 2), xLabelTop); dglDrawText(font, textPoint, mLabelX, NULL, 0, 0); //x min label textWidth = font->getStrWidth(mMinXLabel); - textPoint = Point2I(contentRect.point.x + 1, contentRect.point.y + contentRect.extent.y + 2); + textPoint = Point2I(contentRect.point.x + 1, xLabelTop); dglDrawText(font, textPoint, mMinXLabel, NULL, 0, 0); //x max label textWidth = font->getStrWidth(mMaxXLabel); - textPoint = Point2I((contentRect.point.x + contentRect.extent.x - 1) - textWidth, contentRect.point.y + contentRect.extent.y + 2); + textPoint = Point2I((contentRect.point.x + contentRect.extent.x - 1) - textWidth, xLabelTop); dglDrawText(font, textPoint, mMaxXLabel, NULL, 0, 0); //y label @@ -379,34 +547,25 @@ void GuiParticleGraphInspector::calculatePoints(const RectI &contentRect) mGridRect = RectI(contentRect); mPointList.clear(); + + // Cleared here rather than at the end, so an early return still counts as + // having recalculated and the next frame does not try again. + mDirty = false; + ParticleAssetField* field = getTargetField(); - - F32 time = 0; - U32 count = field->getDataKeyCount(); - for (U32 i = 0; i < count; i++) + if (field == NULL) { - ParticleAssetField::DataKey key = field->getDataKey(i); - - //force the first key to always be at time zero - if (i == 0 && key.mTime != 0) - { - field->addDataKey(0, key.mValue); - field->removeDataKey(1); - key = field->getDataKey(0); - count = field->getDataKeyCount(); - } + return; + } - //Remove the point if it has a bad time - if (i > 0 && key.mTime <= time) - { - field->removeDataKey(i); - count--; - continue; - } - time = key.mTime; + if (repairDataKeys(field)) + { + // A removed key makes the selection an index into something else. + mSelectedIndex = -1; } Point2I p; + const U32 count = field->getDataKeyCount(); for (U32 i = 0; i < count; i++) { ParticleAssetField::DataKey key = field->getDataKey(i); @@ -436,6 +595,14 @@ void GuiParticleGraphInspector::renderPoints(const RectI &contentRect, const Col calculatePoints(contentRect); } + // The tail below indexes count - 1, and renderVariation walks size() - 1. + // Both are unsigned, and calculatePoints leaves the list empty when the + // field it wanted has gone. + if (mPointList.size() == 0) + { + return; + } + //get the cursor position Point2I cursorPt = Point2I(0, 0); GuiCanvas *root = getRoot(); @@ -566,25 +733,25 @@ void GuiParticleGraphInspector::renderDot(const RectI &contentRect, const Point2 { if(point.x >= contentRect.point.x && point.x <= contentRect.point.x + contentRect.extent.x && point.y >= contentRect.point.y && point.y <= contentRect.point.y + contentRect.extent.y) { - F32 x = cursorPt.x - point.x; - F32 y = cursorPt.y - point.y; + F32 x = (F32)(cursorPt.x - point.x); + F32 y = (F32)(cursorPt.y - point.y); F32 dist = mSqrt((x * x) + (y * y)); ColorI color; if (isSelected) { color = mProfile->getFontColor(SelectedState); } - else if (dist <= mRadius) + else if (dist <= smPointRadius) { color = mProfile->getFontColor(HighlightState); - } + } else { color = mProfile->getFontColor(NormalState); } - dglDrawCircleFill(point, mRadius, ColorI(0, 0, 0, 100)); - dglDrawCircleFill(point, mRadius - 2, color); + dglDrawCircleFill(point, smPointRadius, ColorI(0, 0, 0, 100)); + dglDrawCircleFill(point, smPointRadius - 2, color); } } diff --git a/engine/source/gui/editor/guiParticleGraphInspector.h b/engine/source/gui/editor/guiParticleGraphInspector.h index 06119b144..9db43bfae 100644 --- a/engine/source/gui/editor/guiParticleGraphInspector.h +++ b/engine/source/gui/editor/guiParticleGraphInspector.h @@ -27,26 +27,58 @@ #include "gui/guiControl.h" #endif +// Named here rather than included: this header only holds pointers to them, and +// the header that defines them pulls in most of the 2d layer. Anyone who needs to +// call through one of these pointers includes ParticleAsset.h themselves. +class ParticleAsset; +class ParticleAssetField; + +//----------------------------------------------------------------------------- +// One particle field's data keys drawn as an editable curve. +// +// Rendering repairs the data it draws: the first key is forced to time zero and +// any key that does not advance the time is deleted. That has always been true +// of this control; it is now in repairDataKeys where it can be said out loud and +// called from more than one place. +// +// The class is a template method. onRender owns the box model, the axis labels, +// the grid and the editable curve; a subclass adds to that picture through +// getUnderPlotBandHeight/renderUnderlay/renderUnderPlot/getCurveColor rather than +// by reimplementing any of it. The base asks for no band, so the base's layout is +// exactly what it always was. +//----------------------------------------------------------------------------- + class GuiParticleGraphInspector : public GuiControl { private: typedef GuiControl Parent; + +protected: StringTableEntry mTargetField; ParticleAsset* mTargetAsset; U32 mEmitterIndex; F32 mMinX, mMinY, mMaxX, mMaxY; //Display settings - StringTableEntry mLabelX, mLabelY; + StringTableEntry mLabelX, mLabelY; StringTableEntry mMaxYLabel, mMinYLabel, mMaxXLabel, mMinXLabel; - const F32 mRadius = 7; //size of a point - RectI mGridRect; Point2I mCalculationOffset; GuiParticleGraphInspector* mVariationInspector; public: + /// What a point draws at, and what a click has to land inside to hit one. + /// + /// static constexpr rather than a const member: a test binds it by const + /// reference, which would odr-use a static const and fail to link, and a + /// subclass's layout wants it without an instance in hand. + static constexpr F32 smPointRadius = 7.0f; + + /// The gap left above and below a band reserved under the plot. + static constexpr S32 smUnderPlotGap = 3; + + struct GraphPoint { GraphPoint() {} @@ -66,9 +98,41 @@ class GuiParticleGraphInspector : public GuiControl GuiParticleGraphInspector(); static void initPersistFields(); + /// What a band under the plot costs altogether: itself plus the gap above and + /// below it, or nothing at all when none was asked for. + /// + /// One function because the space reserved, the rect drawn into and the label + /// row pushed down by it are three formulas that have to agree, and three + /// chances to be a pixel out is two too many. + static S32 getUnderPlotReserve(const S32 bandHeight); + + /// An axis end label, printed from the value rather than kept as whatever text + /// the caller built it out of. + /// + /// Script hands these over as strings, and a script float is an F32 printed + /// with "%.9g" -- so a tenth arrives as "0.100000001". The y labels are the + /// whole reason the plot gives up a left margin, so eleven characters of + /// rounding error were costing a zoomed-in graph a third of its width. Six + /// significant digits is more than an axis end can usefully show, and it drops + /// the trailing zeros with them. + /// + /// Only the label is reprinted; the window itself keeps the value it parsed, so + /// nothing the camera computes has to agree with what is drawn to the pixel. + static const char* formatAxisLabel(const F32 value, char* buffer, const U32 bufferSize); + + /// Shrink a rect to a whole number of grid cells and re-center what is left. + /// The graph draws ten by ten, so a plot whose width is not a multiple of ten + /// has grid lines landing between pixels. + /// + /// A rect measured mid-resize can have a negative extent. That used to be an + /// unsigned modulus, which made the correction about four billion. + static RectI snapRectToGrid(const RectI &rect, const S32 divisor); + virtual void inspectObject(ParticleAsset* object); virtual void setDisplayField(const char* fieldName); virtual void setDisplayField(const char* fieldName, U16 index); + inline StringTableEntry getDisplayField() const { return mTargetField; } + inline U32 getEmitterIndex() const { return mEmitterIndex; } virtual void setDisplayArea(StringTableEntry minX, StringTableEntry minY, StringTableEntry maxX, StringTableEntry maxY); virtual void setDisplayLabels(const char* labelX, const char* labelY); virtual void setVariationGraphInspector(GuiParticleGraphInspector* object) { mVariationInspector = object; } @@ -84,10 +148,42 @@ class GuiParticleGraphInspector : public GuiControl Vector* getRenderPoints(); protected: - U32 findHitGraphPoint(const Point2I &point); + S32 findHitGraphPoint(const Point2I &point); F32 getGraphValue(const F32 y); F32 getGraphTime(const F32 x); + /// How tall a band under the plot this control wants, between the curve and + /// the x axis labels. Zero in the base, which is what keeps the base's layout + /// identical to what it has always drawn. + virtual S32 getUnderPlotBandHeight() { return 0; } + + /// Drawn after the grid and before the editable curve: whatever belongs behind + /// it. mDirty is still set when this is called on a recalculating frame, which + /// is what lets a subclass rebuild its own caches in step with the one + /// renderPoints is about to build. + virtual void renderUnderlay(const RectI &plotRect) { } + + /// Drawn into the band. Not called when no band was asked for. + virtual void renderUnderPlot(const RectI &bandRect) { } + + /// The color the curve and its dots draw in. + virtual ColorI getCurveColor() { return mProfile->getFillColor(SelectedState); } + + /// The plot area inside a content rect: the label rows and any band a subclass + /// reserved taken off, then snapped to the grid. Measures text, so it cannot + /// be tested -- everything in it that is arithmetic lives in the two statics. + RectI calculatePlotRect(const RectI &contentRect); + + /// The reserved band, placed against the SNAPPED plot rect rather than measured + /// from the content rect. The snap moves the plot by up to nine pixels and a + /// band measured independently would drift by exactly that much. An empty rect + /// when no band was asked for. + RectI getUnderPlotRect(const RectI &plotRect); + + /// The y the x axis label row starts at. A band sits between it and the plot, + /// so this is the only place that knows the order of the two. + S32 getXLabelTop(const RectI &plotRect); + void calculatePoints(const RectI &contentRect); Point2I convertToRenderPoint(const RectI& contentRect, F32 time, F32 value); void renderLabels(const RectI &contentRect, const ColorI &labelColor); @@ -98,7 +194,26 @@ class GuiParticleGraphInspector : public GuiControl void renderLine(const RectI &contentRect, const Point2I &point1, const Point2I &point2, const ColorI &lineColor); void renderQuad(const RectI& contentRect, const Point2I& point1, const Point2I& point2, const Point2I& point3, const Point2I& point4, const ColorI& quadColor); + /// The field a name refers to, or NULL. The asset's own collection first and + /// then the addressed emitter's, because a name is usually an emitter's but the + /// asset's are the ones that always exist. + /// + /// Never asserts and never dereferences a missing emitter. An asset with no + /// emitters is an ordinary state for a half-built particle, and it used to take + /// this through getEmitterCount() - 1 on an unsigned count of zero. + ParticleAssetField* findField(StringTableEntry fieldName); + + /// The field being edited, or NULL. ParticleAssetField* getTargetField(); + + /// Force a field's keys into the shape everything downstream assumes: a key at + /// time zero, and strictly increasing times after it. Idempotent, so a second + /// caller on the same frame walks the list and changes nothing. Returns true + /// when it changed something. + /// + /// This edits the asset as a side effect of drawing, which is what this control + /// has always done. + bool repairDataKeys(ParticleAssetField* field); }; #endif diff --git a/engine/source/gui/editor/guiParticleGraphInspector_ScriptBinding.h b/engine/source/gui/editor/guiParticleGraphInspector_ScriptBinding.h index 16897fd07..c000920b1 100644 --- a/engine/source/gui/editor/guiParticleGraphInspector_ScriptBinding.h +++ b/engine/source/gui/editor/guiParticleGraphInspector_ScriptBinding.h @@ -57,6 +57,23 @@ ConsoleMethodWithDocs(GuiParticleGraphInspector, setDisplayField, ConsoleVoid, 3 } } +/*! Gets the name of the particle field the graph is showing. + @return The field name. +*/ +ConsoleMethodWithDocs(GuiParticleGraphInspector, getDisplayField, ConsoleString, 2, 2, "()") +{ + return object->getDisplayField(); +} + +/*! Gets the index of the emitter the graph is reading its field from. + Meaningless for an asset level field, which no emitter owns. + @return The emitter index. +*/ +ConsoleMethodWithDocs(GuiParticleGraphInspector, getEmitterIndex, ConsoleInt, 2, 2, "()") +{ + return (S32)object->getEmitterIndex(); +} + /*! Sets the graph inspector to use to show variance. @param Inspector The GuiParticleGraphInspector that is tracking the variation. @return No return value. diff --git a/engine/source/testing/tests/guiParticleColorGraphTests.cc b/engine/source/testing/tests/guiParticleColorGraphTests.cc new file mode 100644 index 000000000..d4ff31ef7 --- /dev/null +++ b/engine/source/testing/tests/guiParticleColorGraphTests.cc @@ -0,0 +1,556 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +#ifndef _GUI_EDIT_PARTICLE_COLOR_GRAPH_H_ +#include "gui/editor/guiEditParticleColorGraph.h" +#endif + +//----------------------------------------------------------------------------- +// The color graph's arithmetic: how a plot gives up room for the strip under it, +// what a channel's value is between two keys, and where the mixed color is +// allowed to bend. +// +// None of it can be tested through a control. Drawing one asks its profile for a +// font, which loads one, which registers a texture -- and this suite runs with no +// canvas and so no GL context to make one in. TextureManager asserts, and in a +// debug build an assert is a modal box, so the whole run hangs rather than fails. +// So the arithmetic is statics taking everything they use, exactly as +// GuiEditFrameStripCtrl's layout is, and the statics are what these call. They +// construct nothing. +// +// Throughout: the window is 0 to 1 and rects are 100 wide starting at 10, so +// every pixel below can be checked in your head. +//----------------------------------------------------------------------------- + +typedef GuiEditParticleColorGraph ColorGraph; + +static const S32 sRectLeft = 10; +static const S32 sRectWidth = 100; + +// Every stop the merge can produce, so a test never has to guess a bound. +static F32 sStops[ColorGraph::smMaxGradientStops]; + +// The property the whole draw loop rests on: two stops that go backwards make a +// negative-width RectI, and dglDrawBlendBox draws that as a reversed quad. +static bool isStrictlyIncreasing(const F32* stops, const S32 count) +{ + for (S32 i = 1; i < count; i++) + { + if (stops[i] <= stops[i - 1]) + { + return false; + } + } + + return true; +} + +//----------------------------------------------------------------------------- +// The band under the plot +//----------------------------------------------------------------------------- + +TEST(GuiParticleColorGraphTests, AGraphWithNoStripGivesUpNoRoom) +{ + // The regression guard for every ordinary graph in the particle editor: the + // base class asks for no band, and its plot must not move by one pixel. + ASSERT_EQ(GuiParticleGraphInspector::getUnderPlotReserve(0), 0) + << "A graph that asked for no band reserved room for one anyway."; + + ASSERT_EQ(GuiParticleGraphInspector::getUnderPlotReserve(-8), 0) + << "A negative band height should mean no band, not a negative reservation."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AStripCostsItselfAndAGapEitherSide) +{ + const S32 gap = GuiParticleGraphInspector::smUnderPlotGap; + + ASSERT_EQ(GuiParticleGraphInspector::getUnderPlotReserve(16), 16 + (2 * gap)) + << "The strip has to clear the plot above it and the labels below it."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, TheStripSitsInsideTheRoomReservedForIt) +{ + // What getUnderPlotRect and getXLabelTop are built from: the band starts one + // gap below the plot and the labels start one gap below the band, so the two + // can never overlap whatever the height. + for (S32 height = 1; height <= 64; height++) + { + const S32 reserve = GuiParticleGraphInspector::getUnderPlotReserve(height); + const S32 bandTop = GuiParticleGraphInspector::smUnderPlotGap; + const S32 bandBottom = bandTop + height; + + ASSERT_LE(bandBottom, reserve) + << "height " << height << ": the strip ran past the room reserved for it."; + } + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AStripHeightIsClampedButZeroIsLeftAlone) +{ + ASSERT_EQ(ColorGraph::clampStripHeight(0), 0) + << "Zero is how you ask for no strip, so it must survive the clamp."; + ASSERT_EQ(ColorGraph::clampStripHeight(-4), 0) + << "A negative height means no strip rather than a clamped one."; + ASSERT_EQ(ColorGraph::clampStripHeight(2), ColorGraph::smMinStripHeight) + << "A strip too thin to read a gradient in is pushed up to the minimum."; + ASSERT_EQ(ColorGraph::clampStripHeight(1000), ColorGraph::smMaxStripHeight) + << "A strip is a reference, not the picture; it does not get the whole control."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Axis end labels +//----------------------------------------------------------------------------- + +TEST(GuiParticleColorGraphTests, ATenthIsLabelledAsATenth) +{ + // The bug this exists for. A script float is an F32 printed with "%.9g", so + // the tightest zoom level arrived as the string "0.100000001" -- and the y + // labels are what the plot gives up its left margin for, so eleven characters + // of rounding error were costing the graph a third of its width. + char buffer[32]; + + ASSERT_STREQ(GuiParticleGraphInspector::formatAxisLabel(0.1f, buffer, sizeof(buffer)), "0.1") + << "A tenth was labelled with its binary expansion instead of with a tenth."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AnAxisEndIsLabelledAsShortlyAsItCanBe) +{ + // Every window edge the zoom levels can produce, on both axes, at every field + // bound the particle editor registers. None of them may be long. + const F32 values[] = { 0.0f, 0.1f, 0.2f, 0.25f, 0.3f, 0.5f, 0.75f, 0.9f, 1.0f, + 10.0f, 100.0f, 360.0f, 1000.0f }; + char buffer[32]; + + for (S32 i = 0; i < (S32)(sizeof(values) / sizeof(values[0])); i++) + { + const char* label = GuiParticleGraphInspector::formatAxisLabel(values[i], buffer, sizeof(buffer)); + + ASSERT_LE((S32)dStrlen(label), 6) + << "value " << values[i] << " was labelled '" << label + << "', which the plot pays for in left margin."; + } + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AWholeNumberKeepsNoDecimalPoint) +{ + char buffer[32]; + + ASSERT_STREQ(GuiParticleGraphInspector::formatAxisLabel(0.0f, buffer, sizeof(buffer)), "0") + << "Zero should read as zero, not as a decimal expansion of it."; + ASSERT_STREQ(GuiParticleGraphInspector::formatAxisLabel(1.0f, buffer, sizeof(buffer)), "1") + << "One should read as one."; + ASSERT_STREQ(GuiParticleGraphInspector::formatAxisLabel(1000.0f, buffer, sizeof(buffer)), "1000") + << "The widest field bound must not turn into exponent notation."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, ALabelWithNowhereToGoIsEmptyRatherThanWritten) +{ + ASSERT_STREQ(GuiParticleGraphInspector::formatAxisLabel(0.5f, NULL, 32), "") + << "A missing buffer must be refused rather than written through."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Snapping the plot to the grid +//----------------------------------------------------------------------------- + +TEST(GuiParticleColorGraphTests, ThePlotShrinksToWholeGridCellsAndRecenters) +{ + // 97 wide loses 7 and moves right by 3; 83 tall loses 3 and moves down by 1. + const RectI snapped = GuiParticleGraphInspector::snapRectToGrid(RectI(10, 20, 97, 83), 10); + + ASSERT_EQ(snapped.extent.x, 90) << "The width was not reduced to whole grid cells."; + ASSERT_EQ(snapped.extent.y, 80) << "The height was not reduced to whole grid cells."; + ASSERT_EQ(snapped.point.x, 13) << "What the width gave up was not split either side."; + ASSERT_EQ(snapped.point.y, 21) << "What the height gave up was not split either side."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, SnappingAnAlreadySnappedPlotChangesNothing) +{ + const RectI once = GuiParticleGraphInspector::snapRectToGrid(RectI(10, 20, 90, 80), 10); + const RectI twice = GuiParticleGraphInspector::snapRectToGrid(once, 10); + + ASSERT_EQ(once.point.x, twice.point.x) << "Snapping is not idempotent horizontally."; + ASSERT_EQ(once.point.y, twice.point.y) << "Snapping is not idempotent vertically."; + ASSERT_EQ(once.extent.x, twice.extent.x) << "A second snap took more width away."; + ASSERT_EQ(once.extent.y, twice.extent.y) << "A second snap took more height away."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, ARectMeasuredMidResizeIsLeftAlone) +{ + // A control being dragged smaller than its own labels produces this. As an + // unsigned modulus the correction was about four billion, and the rect came + // back wider than the screen. + const RectI broken = RectI(10, 20, -30, -40); + const RectI snapped = GuiParticleGraphInspector::snapRectToGrid(broken, 10); + + ASSERT_EQ(snapped.extent.x, broken.extent.x) << "A negative width was 'corrected' into something else."; + ASSERT_EQ(snapped.extent.y, broken.extent.y) << "A negative height was 'corrected' into something else."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Reading a channel +//----------------------------------------------------------------------------- + +TEST(GuiParticleColorGraphTests, AChannelIsFlatOutsideItsKeys) +{ + const F32 times[] = { 0.0f, 0.5f }; + const F32 values[] = { 0.2f, 0.8f }; + + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(times, values, 2, -1.0f), 0.2f) + << "Before the first key a channel holds the first key's value."; + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(times, values, 2, 1.0f), 0.8f) + << "After the last key a channel holds the last key's value, which is the flat run the curve draws to the right edge."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AChannelIsLinearBetweenTwoKeys) +{ + const F32 times[] = { 0.0f, 1.0f }; + const F32 values[] = { 0.0f, 1.0f }; + + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(times, values, 2, 0.5f), 0.5f) + << "Halfway between two keys is the mean of their values."; + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(times, values, 2, 0.25f), 0.25f) + << "A quarter of the way along is a quarter of the way up."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, LandingOnAKeyReturnsThatKey) +{ + const F32 times[] = { 0.0f, 0.4f, 0.9f }; + const F32 values[] = { 0.1f, 0.7f, 0.3f }; + + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(times, values, 3, 0.4f), 0.7f) + << "Sampling exactly on a key must return it rather than interpolating past it."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, OneKeyIsTheWholeChannel) +{ + const F32 times[] = { 0.0f }; + const F32 values[] = { 0.6f }; + + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(times, values, 1, 0.0f), 0.6f) + << "A channel with one key has that value at the start."; + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(times, values, 1, 1.0f), 0.6f) + << "A channel with one key has that value everywhere."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AnEmptyChannelIsNotRead) +{ + // A field can be missing entirely -- an asset with no emitters yet, or a name + // that is not registered. Nothing should be dereferenced to find that out. + ASSERT_FLOAT_EQ(ColorGraph::sampleChannel(NULL, NULL, 0, 0.5f), 0.0f) + << "An absent channel should read as zero rather than read at all."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Where the mixed color bends +//----------------------------------------------------------------------------- + +TEST(GuiParticleColorGraphTests, AFlatColorIsOneSpan) +{ + const F32 times[] = { 0.0f }; + + const S32 count = ColorGraph::buildGradientStops(times, 1, times, 1, times, 1, + 0.0f, 1.0f, sStops, ColorGraph::smMaxGradientStops); + + ASSERT_EQ(count, 2) << "Three channels with nothing but a key at zero can only bend at the window's edges."; + ASSERT_FLOAT_EQ(sStops[0], 0.0f) << "The first stop is the left edge of the window."; + ASSERT_FLOAT_EQ(sStops[1], 1.0f) << "The last stop is the right edge of the window."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AKeyInOneChannelBendsTheMix) +{ + const F32 red[] = { 0.0f, 0.5f }; + const F32 flat[] = { 0.0f }; + + const S32 count = ColorGraph::buildGradientStops(red, 2, flat, 1, flat, 1, + 0.0f, 1.0f, sStops, ColorGraph::smMaxGradientStops); + + ASSERT_EQ(count, 3) << "A key in any one channel is a bend in the mixed color."; + ASSERT_FLOAT_EQ(sStops[1], 0.5f) << "The bend is at the key's time."; + ASSERT_TRUE(isStrictlyIncreasing(sStops, count)) << "The stops went backwards."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, TheSameTimeInTwoChannelsIsOneStop) +{ + const F32 keyed[] = { 0.0f, 0.5f }; + const F32 flat[] = { 0.0f }; + + const S32 count = ColorGraph::buildGradientStops(keyed, 2, keyed, 2, flat, 1, + 0.0f, 1.0f, sStops, ColorGraph::smMaxGradientStops); + + ASSERT_EQ(count, 3) << "Two channels bending at the same time is still one bend."; + ASSERT_TRUE(isStrictlyIncreasing(sStops, count)) << "A duplicate slipped through as a zero-width span."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, KeysOnTheWindowEdgesAreNotDuplicated) +{ + const F32 times[] = { 0.0f, 1.0f }; + + const S32 count = ColorGraph::buildGradientStops(times, 2, times, 2, times, 2, + 0.0f, 1.0f, sStops, ColorGraph::smMaxGradientStops); + + ASSERT_EQ(count, 2) << "The window's own edges are already stops; a key there must not be added twice."; + ASSERT_TRUE(isStrictlyIncreasing(sStops, count)) << "The stops went backwards."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, ZoomingInDropsTheKeysOutsideTheWindow) +{ + const F32 times[] = { 0.0f, 0.1f, 0.5f, 0.9f }; + const F32 flat[] = { 0.0f }; + + const S32 count = ColorGraph::buildGradientStops(times, 4, flat, 1, flat, 1, + 0.25f, 0.75f, sStops, ColorGraph::smMaxGradientStops); + + ASSERT_EQ(count, 3) << "Only the key at 0.5 is inside a 0.25 to 0.75 window."; + ASSERT_FLOAT_EQ(sStops[0], 0.25f) << "The strip starts where the plot starts."; + ASSERT_FLOAT_EQ(sStops[1], 0.5f) << "The one key inside the window was lost."; + ASSERT_FLOAT_EQ(sStops[2], 0.75f) << "The strip ends where the plot ends."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AWindowWithNoWidthDrawsNothing) +{ + const F32 times[] = { 0.0f, 0.5f }; + + ASSERT_EQ(ColorGraph::buildGradientStops(times, 2, times, 2, times, 2, + 0.5f, 0.5f, sStops, ColorGraph::smMaxGradientStops), 0) + << "A window with no width has no spans to draw."; + + ASSERT_EQ(ColorGraph::buildGradientStops(times, 2, times, 2, times, 2, + 1.0f, 0.0f, sStops, ColorGraph::smMaxGradientStops), 0) + << "An inverted window has no spans either, and must not be drawn backwards."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, MoreKeysThanStopsStillReachesTheEndOfTheWindow) +{ + // The documented degradation: the tail becomes one long linear span and + // nothing else goes wrong. What must not happen is the strip stopping short. + F32 many[40]; + for (S32 i = 0; i < 40; i++) + { + many[i] = (F32)i / 40.0f; + } + + const F32 flat[] = { 0.0f }; + const S32 count = ColorGraph::buildGradientStops(many, 40, flat, 1, flat, 1, + 0.0f, 1.0f, sStops, 8); + + ASSERT_EQ(count, 8) << "The merge wrote past the buffer it was given."; + ASSERT_FLOAT_EQ(sStops[0], 0.0f) << "The first stop is still the window's left edge."; + ASSERT_FLOAT_EQ(sStops[count - 1], 1.0f) << "The strip stopped short of the window's right edge."; + ASSERT_TRUE(isStrictlyIncreasing(sStops, count)) << "The stops went backwards."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Placing a stop on screen +//----------------------------------------------------------------------------- + +TEST(GuiParticleColorGraphTests, TheStripSpansExactlyTheRectItIsGiven) +{ + ASSERT_EQ(ColorGraph::timeToPixel(0.0f, 0.0f, 1.0f, sRectLeft, sRectWidth), sRectLeft) + << "The window's start must land on the rect's left edge."; + ASSERT_EQ(ColorGraph::timeToPixel(1.0f, 0.0f, 1.0f, sRectLeft, sRectWidth), sRectLeft + sRectWidth) + << "The window's end must land on the rect's right edge, so the strip lines up with the plot."; + ASSERT_EQ(ColorGraph::timeToPixel(0.5f, 0.0f, 1.0f, sRectLeft, sRectWidth), sRectLeft + 50) + << "Half the window is half the rect."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, ATimeOutsideTheWindowIsClampedToTheEdge) +{ + ASSERT_EQ(ColorGraph::timeToPixel(-2.0f, 0.0f, 1.0f, sRectLeft, sRectWidth), sRectLeft) + << "A time before the window must not draw to the left of the strip."; + ASSERT_EQ(ColorGraph::timeToPixel(3.0f, 0.0f, 1.0f, sRectLeft, sRectWidth), sRectLeft + sRectWidth) + << "A time after the window must not draw past the strip."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, ADegenerateRectOrWindowCollapsesToTheLeftEdge) +{ + ASSERT_EQ(ColorGraph::timeToPixel(0.5f, 0.0f, 1.0f, sRectLeft, 0), sRectLeft) + << "A rect with no width has one x, not a division by zero."; + ASSERT_EQ(ColorGraph::timeToPixel(0.5f, 1.0f, 0.0f, sRectLeft, sRectWidth), sRectLeft) + << "An inverted window has no x to map to, so nothing is drawn rather than something reversed."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, EverySpanOfTheStripHasAWidthThatIsNotNegative) +{ + // The two halves joined up. A stop list that increases must produce pixels + // that do not decrease, at every rect width -- flooring a pair independently + // is what would otherwise let one span start left of where the last ended. + const F32 red[] = { 0.0f, 0.13f, 0.31f, 0.86f }; + const F32 green[] = { 0.0f, 0.5f }; + const F32 blue[] = { 0.0f, 0.13f, 0.99f }; + + const S32 count = ColorGraph::buildGradientStops(red, 4, green, 2, blue, 3, + 0.0f, 1.0f, sStops, ColorGraph::smMaxGradientStops); + + ASSERT_TRUE(isStrictlyIncreasing(sStops, count)) << "The stops went backwards before they were ever placed."; + + for (S32 width = 1; width <= 400; width++) + { + S32 previous = ColorGraph::timeToPixel(sStops[0], 0.0f, 1.0f, sRectLeft, width); + + for (S32 i = 1; i < count; i++) + { + const S32 x = ColorGraph::timeToPixel(sStops[i], 0.0f, 1.0f, sRectLeft, width); + + ASSERT_GE(x, previous) + << "width " << width << ", stop " << i << ": a span of the strip would be drawn backwards."; + + previous = x; + } + } + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Naming a channel +//----------------------------------------------------------------------------- + +TEST(GuiParticleColorGraphTests, AChannelIsNamedEitherWayAndInAnyCase) +{ + ASSERT_EQ(ColorGraph::getChannelFromName("Red"), ColorGraph::ChannelRed) + << "The short name is what a script button would pass."; + ASSERT_EQ(ColorGraph::getChannelFromName("red"), ColorGraph::ChannelRed) + << "Script string compares fold case, so the lookup here has to as well."; + ASSERT_EQ(ColorGraph::getChannelFromName("RedChannel"), ColorGraph::ChannelRed) + << "The field's own name is the other spelling a caller will reach for."; + ASSERT_EQ(ColorGraph::getChannelFromName("GREENCHANNEL"), ColorGraph::ChannelGreen) + << "Case folding has to apply to the field name too."; + ASSERT_EQ(ColorGraph::getChannelFromName("Blue"), ColorGraph::ChannelBlue) + << "Blue is a channel."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, AnythingElseIsNotAChannel) +{ + ASSERT_EQ(ColorGraph::getChannelFromName(""), ColorGraph::ChannelCount) + << "An empty name must be refused rather than defaulting to red."; + ASSERT_EQ(ColorGraph::getChannelFromName(NULL), ColorGraph::ChannelCount) + << "A missing name must be refused without being read."; + ASSERT_EQ(ColorGraph::getChannelFromName("AlphaChannel"), ColorGraph::ChannelCount) + << "Alpha is a channel, but not one of this graph's -- it keeps its own."; + ASSERT_EQ(ColorGraph::getChannelFromName("purple"), ColorGraph::ChannelCount) + << "A name that is not a channel at all must be refused."; + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, EveryChannelSurvivesTheRoundTripThroughItsFieldName) +{ + // The trip a toggle button takes: a channel becomes the field name the graph + // edits, and getActiveChannel has to find its way back. + for (S32 c = 0; c < ColorGraph::ChannelCount; c++) + { + const ColorGraph::Channel channel = (ColorGraph::Channel)c; + + ASSERT_EQ(ColorGraph::getChannelFromName(ColorGraph::getChannelFieldName(channel)), channel) + << "channel " << c << ": a channel did not survive being turned into a field name and back."; + } + + SUCCEED(); +} + +TEST(GuiParticleColorGraphTests, EveryChannelDrawsInItsOwnColor) +{ + // The curve and the toggle beside it wear the same color; if two channels + // returned the same one, two curves would be indistinguishable. + for (S32 a = 0; a < ColorGraph::ChannelCount; a++) + { + const ColorI first = ColorGraph::getChannelColor((ColorGraph::Channel)a, true); + + ASSERT_GT(ColorGraph::getChannelColor((ColorGraph::Channel)a, false).alpha, 0) + << "channel " << a << ": an inactive curve is dimmed, not hidden."; + ASSERT_LT(ColorGraph::getChannelColor((ColorGraph::Channel)a, false).alpha, first.alpha) + << "channel " << a << ": the inactive curve is not dimmer than the live one."; + + for (S32 b = a + 1; b < ColorGraph::ChannelCount; b++) + { + const ColorI second = ColorGraph::getChannelColor((ColorGraph::Channel)b, true); + + ASSERT_FALSE(first.red == second.red && first.green == second.green && first.blue == second.blue) + << "channels " << a << " and " << b << " draw in the same color."; + } + } + + SUCCEED(); +} + +#endif // TORQUE_SHIPPING diff --git a/tests/shots/particleColorGraph.cs b/tests/shots/particleColorGraph.cs new file mode 100644 index 000000000..1ef17982e --- /dev/null +++ b/tests/shots/particleColorGraph.cs @@ -0,0 +1,251 @@ +// Visual harness for the emitter's unified color graph. Shots: +// +// 0 the color graph as the Color Channel entry opens it -- three curves on one +// plot, the three channel toggles down the left, and the mixed color strip +// under the plot +// 1 green made live, so the dim/bright pairing and the toggle lighting can be +// read against shot 0 +// 2 a color with somewhere to go: red falling away as green arrives, which is +// the picture the strip exists to show +// 3 the same, with the value axis at its tightest and the time axis part way +// in: the strip has to stay aligned with the curves above it, the x labels +// have to stay under it, and the "0.1" at the top of the axis has to be +// three characters rather than eleven +// 4 the tab dragged tall, and the whole list visible in one piece -- thirteen +// entries where there were fifteen. The unit keeps the height it was built +// with rather than growing into the room, which is what every graph unit +// here has always done; shot 5 shows an untouched one doing the same +// 5 the Alpha Channel beside it, still one ordinary curve -- what did NOT change +// 6 shot 2's colors under every other editor theme in turn. The three channel +// hues are the one thing here a theme cannot restyle, so this is where it is +// settled that they stay legible on a panel that is not the default's +// +// What only a picture can settle: whether three curves layered on one grid are +// still individually readable, whether the strip's gradient reads as the colors +// the curves describe, and whether the x axis labels ended up under the strip +// rather than on top of it. tests/smoke/particleColorGraph.cs does the part that +// is checkable by assertion. +// +// Run: tests/run.ps1 -Shots particleColorGraph ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +$pcgAssetId = "ToyAssets:bonfire"; + +// Where "Color Channel" and "Alpha Channel" sit in the emitter list. +$pcgColorIndex = 11; +$pcgAlphaIndex = 12; + +testExec("editor/main.cs"); +schedule(2500, 0, "pcgOpenProject"); + +function pcgOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + // A copy: this harness writes data keys. tests/run.ps1 sweeps the folder by + // reading the spelled-out name out of this file. + createPath(testRoot("shots/")); + ProjectManager.setProjectFolder("particleColorGraphShotProject"); + EditorPreferences.path = testRoot("shots/particleColorGraphShotPrefs.taml"); + + %copy = testRoot("particleColorGraphShotProject/ToyAssets"); + pathCopy(testRoot("toybox/ToyAssets"), %copy, false); + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(isObject(%module)) + { + AssetDatabase.addModuleDeclaredAssets(%module); + } + + schedule(2500, 0, "pcgOpenEditor"); +} + +function pcgOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(2); + schedule(1500, 0, "pcgSelectAsset"); +} + +function pcgSelectAsset() +{ + AssetAdmin.Dictionary["ParticleAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + %tile = AssetAdmin.Dictionary["ParticleAsset"].getButton($pcgAssetId); + %tile.onClick(); + + $pcgInspector = AssetAdmin.inspector; + $pcgAsset = %tile.ParticleAsset; + + // The first emitter. The Emitter Graph tab only exists for one. + $pcgInspector.titleDropDown.setSelected(1); + $pcgInspector.onChooseParticleAsset($pcgAsset); + + // Room to actually see a graph. The tab starts in a 360 tall bottom frame. + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, 460); + + schedule(1200, 0, "pcgOpenGraph"); +} + +function pcgGrab(%name) +{ + // screenShot does not create its folder and reports failure by logging, so a + // tree that has never run a shot writes nothing and says nothing. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/particleColorGraph" @ %name @ ".png"), "PNG"); +} + +// Selecting a row the way the tool's own inspect() does: clear first, or a list +// box that allows more than one selection simply adds to it. +function pcgSelect(%index) +{ + $pcgTool.baseList.clearSelection(); + $pcgTool.baseList.setCurSel(%index); + $pcgTool.onSelect(%index); +} + +function pcgOpenGraph() +{ + $pcgTool = $pcgInspector.emitterGraphPage; + + // Two pages for an emitter, not three: the Scale Graph tab belongs to the + // effect and is taken out of the book when an emitter is chosen. + $pcgInspector.tabBook.selectPage(1); + + pcgSelect($pcgColorIndex); + + schedule(1200, 0, "pcgDefaultShot"); +} + +function pcgDefaultShot() +{ + pcgGrab(0); + + // Green live. Everything about which curve is editable changes; nothing about + // the strip does, which is the pairing worth reading across the two shots. + $pcgTool.colorGraph.toggle["Green"].setStateOn(true); + $pcgTool.colorGraph.onToggleIconChanged($pcgTool.colorGraph.toggle["Green"]); + + schedule(800, 0, "pcgGreenShot"); +} + +function pcgGreenShot() +{ + pcgGrab(1); + + // A color with somewhere to go. bonfire's channels are close to flat, and a + // flat gradient says nothing about whether the gradient works. + %emitter = $pcgAsset.getEmitter(0); + + %emitter.selectField("RedChannel"); + %emitter.clearDataKeys(); + %emitter.setSingleDataKey(1); + %emitter.addDataKey(0.35, 0.9); + %emitter.addDataKey(1, 0); + + %emitter.selectField("GreenChannel"); + %emitter.clearDataKeys(); + %emitter.setSingleDataKey(0.1); + %emitter.addDataKey(0.6, 0.85); + %emitter.addDataKey(1, 1); + + %emitter.selectField("BlueChannel"); + %emitter.clearDataKeys(); + %emitter.setSingleDataKey(0); + %emitter.addDataKey(0.8, 0.2); + %emitter.addDataKey(1, 0.8); + + $pcgAsset.refreshAsset(); + $pcgTool.colorGraph.setToColor($pcgTool.emitterID); + + schedule(800, 0, "pcgRampShot"); +} + +function pcgRampShot() +{ + pcgGrab(2); + + // The value axis all the way in and the time axis part way. The strip follows + // the plot's window, so it has to magnify the same stretch of life the curves + // do -- and the tightest value window is 0 to 0.1, which is where an axis end + // label printed from a script float used to read "0.100000001" and take a + // third of the plot's width with it. + $pcgTool.colorGraph.timeZoomIn(); + $pcgTool.colorGraph.timeZoomIn(); + $pcgTool.colorGraph.timeMoveForward(); + $pcgTool.colorGraph.valueZoomIn(); + $pcgTool.colorGraph.valueZoomIn(); + $pcgTool.colorGraph.valueZoomIn(); + + schedule(800, 0, "pcgZoomShot"); +} + +function pcgZoomShot() +{ + pcgGrab(3); + + // Back out, then give the tab the height a tall inspector has -- enough for the + // whole list at once, which is where the collapse is easiest to read. + $pcgTool.colorGraph.timeZoomOut(); + $pcgTool.colorGraph.timeZoomOut(); + $pcgTool.colorGraph.valueZoomOut(); + $pcgTool.colorGraph.valueZoomOut(); + $pcgTool.colorGraph.valueZoomOut(); + + %canvas = Canvas.getExtent(); + AssetAdmin.content.setFrameSize(AssetAdmin.inspectorFrameId, getWord(%canvas, 1) - 220); + + schedule(800, 0, "pcgTallShot"); +} + +function pcgTallShot() +{ + pcgGrab(4); + + // Alpha: unchanged, and the reason the color entry is not simply "all four". + pcgSelect($pcgAlphaIndex); + + schedule(800, 0, "pcgAlphaShot"); +} + +function pcgAlphaShot() +{ + pcgGrab(5); + + // Back to the color graph, then walk the themes. The curves and the swatches + // are fixed hues by design -- no theme can know which curve is the red one -- + // so they are the one thing here that has to be looked at on every panel color + // rather than trusted to re-theme. + pcgSelect($pcgColorIndex); + $pcgTheme = 0; + + schedule(800, 0, "pcgThemeShot"); +} + +function pcgThemeShot() +{ + if($pcgTheme >= ThemeManager.themeList.getCount()) + { + echo("SHOTS DONE"); + quit(); + return; + } + + ThemeManager.setTheme($pcgTheme); + $pcgTheme++; + + schedule(800, 0, "pcgGrabTheme"); +} + +function pcgGrabTheme() +{ + pcgGrab("6_" @ ThemeManager.activeTheme.getClassNamespace()); + + schedule(400, 0, "pcgThemeShot"); +} diff --git a/tests/smoke/particleColorGraph.cs b/tests/smoke/particleColorGraph.cs new file mode 100644 index 000000000..b095702dd --- /dev/null +++ b/tests/smoke/particleColorGraph.cs @@ -0,0 +1,309 @@ +// Asset Manager color-graph smoke test. Drives the Emitter Graph tab's one +// collapsed entry -- red, green and blue on a single graph with a mixed-color +// strip under it -- and the channel toggles that pick which of the three a click +// on the plot will edit. +// Run: tests/run.ps1 particleColorGraph ; grep PCLR in tests/logs/. +// +// Driven by calling the tool rather than by posting input, for the same reason +// assetParticleInspector is: where a list row sits depends on the font, and where +// a graph key sits depends on a plot rect computed from it. +// +// NOTE: a COPY of toybox/ToyAssets. Selecting a color channel repairs the fields +// it draws -- that is what the graph has always done -- so it must not touch the +// real content tree. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function pclrCheck(%label, %cond) +{ + if(%cond) echo("PCLR PASS: " @ %label); + else echo("PCLR FAIL: " @ %label); +} + +// bonfire has two emitters, so the emitter index is a real value rather than +// always zero -- which is the case the graph used to keep its old channel through. +$pclrAssetId = "ToyAssets:bonfire"; + +// Where "Color Channel" and "Alpha Channel" sit in the emitter list now that the +// three color entries have become one. Eleven fields precede them. +$pclrColorIndex = 11; +$pclrAlphaIndex = 12; + +function pclrLoadFixtureAssets() +{ + %copy = testRoot("particleColorGraphSmokeProject/ToyAssets"); + + if(!pathCopy(testRoot("toybox/ToyAssets"), %copy, false)) + { + return false; + } + + ModuleDatabase.scanModules(%copy); + %module = ModuleDatabase.findModule("ToyAssets", 1); + if(!isObject(%module)) + { + return false; + } + AssetDatabase.addModuleDeclaredAssets(%module); + return true; +} + +// Selecting a row the way the tool's own inspect() does: clear first, or a list +// box that allows more than one selection simply adds to it. +function pclrSelect(%index) +{ + $pclrTool.baseList.clearSelection(); + $pclrTool.baseList.setCurSel(%index); + $pclrTool.onSelect(%index); +} + +function pclrIsShowing(%unit) +{ + return $pclrTool.toolGrid.isMember(%unit); +} + +testExec("editor/main.cs"); +schedule(2000, 0, "pclrStep1"); + +//----------------------------------------------------------------------------- + +function pclrStep1() +{ + createPath(testRoot("shots/")); + + // Spelled out rather than held in a variable: tests/run.ps1 finds the folder + // to delete by reading this file for setProjectFolder("..."). + ProjectManager.setProjectFolder("particleColorGraphSmokeProject"); + EditorPreferences.path = testRoot("shots/particleColorGraphSmokePrefs.taml"); + + pclrCheck("fixture asset module registered", pclrLoadFixtureAssets()); + + EditorCore.tabBook.selectPage(2); + + schedule(600, 0, "pclrStep2"); +} + +//----------------------------------------------------------------------------- +// Open the asset, then move the title dropdown off the effect and onto an +// emitter -- the Emitter Graph tab only exists for an emitter. +//----------------------------------------------------------------------------- + +function pclrStep2() +{ + $pclrInspector = AssetAdmin.inspector; + + %tile = AssetAdmin.Dictionary["ParticleAsset"].getButton($pclrAssetId); + pclrCheck("the particle tile is in the library", isObject(%tile)); + %tile.onClick(); + + schedule(600, 0, "pclrStep3"); +} + +function pclrStep3() +{ + $pclrAsset = $pclrInspector.inspectedObject(); + + // Index 0 is the effect; 1 is the first emitter. + $pclrInspector.titleDropDown.setSelected(1); + $pclrInspector.onChooseParticleAsset($pclrAsset); + + schedule(600, 0, "pclrStep4"); +} + +//----------------------------------------------------------------------------- +// The list collapsed three entries into one. +//----------------------------------------------------------------------------- + +function pclrStep4() +{ + $pclrTool = $pclrInspector.emitterGraphPage; + pclrCheck("the emitter graph tool is built", isObject($pclrTool)); + + %list = $pclrTool.baseList; + pclrCheck("the emitter list has thirteen entries, not fifteen", %list.getItemCount() == 13); + pclrCheck("red, green and blue became one entry", + %list.getItemText($pclrColorIndex) $= "Color Channel"); + pclrCheck("alpha kept its own", %list.getItemText($pclrAlphaIndex) $= "Alpha Channel"); + + $pclrColorUnit = $pclrTool.colorGraph; + pclrCheck("the color unit was built", isObject($pclrColorUnit)); + pclrCheck("it is an AssetParticleColorGraphUnit", + $pclrColorUnit.getClassNamespace() $= "AssetParticleColorGraphUnit"); + pclrCheck("it inherits the ordinary graph unit", + $pclrColorUnit.getSuperClassNamespace() $= "AssetParticleGraphUnit"); + pclrCheck("its graph is a GuiEditParticleColorGraph", + $pclrColorUnit.graph.getClassName() $= "GuiEditParticleColorGraph"); + + pclrCheck("it starts out of the grid", !pclrIsShowing($pclrColorUnit)); + + schedule(200, 0, "pclrStep5"); +} + +//----------------------------------------------------------------------------- +// Selecting it swaps the whole set of units in the grid. +//----------------------------------------------------------------------------- + +function pclrStep5() +{ + pclrSelect($pclrColorIndex); + + pclrCheck("selecting Color Channel shows the color unit", pclrIsShowing($pclrColorUnit)); + pclrCheck("the base graph stood down", !pclrIsShowing($pclrTool.baseGraph)); + pclrCheck("the variation graph stood down", !pclrIsShowing($pclrTool.variGraph)); + pclrCheck("the life graph stood down", !pclrIsShowing($pclrTool.lifeGraph)); + + %graph = $pclrColorUnit.graph; + pclrCheck("a channel is live", %graph.getActiveChannel() $= "Red"); + pclrCheck("and it is the field being edited", %graph.getDisplayField() $= "RedChannel"); + + schedule(200, 0, "pclrStep6"); +} + +//----------------------------------------------------------------------------- +// The toggles are a radio group, and the graph is the one that knows. +//----------------------------------------------------------------------------- + +function pclrStep6() +{ + %graph = $pclrColorUnit.graph; + + pclrCheck("the red toggle is lit", $pclrColorUnit.toggle["Red"].getValue()); + pclrCheck("the green toggle is not", !$pclrColorUnit.toggle["Green"].getValue()); + pclrCheck("the blue toggle is not", !$pclrColorUnit.toggle["Blue"].getValue()); + + // A checkbox flips itself before the owner hears about it, which is exactly + // what the owner has to put right. + $pclrColorUnit.toggle["Green"].setStateOn(true); + $pclrColorUnit.onToggleIconChanged($pclrColorUnit.toggle["Green"]); + + pclrCheck("clicking green makes green live", %graph.getActiveChannel() $= "Green"); + pclrCheck("and green is now the field a click edits", %graph.getDisplayField() $= "GreenChannel"); + pclrCheck("the green toggle is lit", $pclrColorUnit.toggle["Green"].getValue()); + pclrCheck("and red went out", !$pclrColorUnit.toggle["Red"].getValue()); + + // Clicking the live channel would switch its checkbox off on its own. + $pclrColorUnit.toggle["Green"].setStateOn(false); + $pclrColorUnit.onToggleIconChanged($pclrColorUnit.toggle["Green"]); + + pclrCheck("clicking the live channel leaves it live", %graph.getActiveChannel() $= "Green"); + pclrCheck("and lights its toggle back up", $pclrColorUnit.toggle["Green"].getValue()); + + schedule(200, 0, "pclrStep7"); +} + +//----------------------------------------------------------------------------- +// The mixed color, and the stops the strip bends at. +//----------------------------------------------------------------------------- + +function pclrStep7() +{ + %graph = $pclrColorUnit.graph; + %emitter = $pclrAsset.getEmitter(0); + + // Give the three channels a shape worth reading: red falls away, green rises, + // blue is left alone. Every value is set through the asset, so what the graph + // reports has to have come from the fields rather than from anything it kept. + %emitter.selectField("RedChannel"); + %emitter.clearDataKeys(); + %emitter.setSingleDataKey(1); + %emitter.addDataKey(1, 0); + + %emitter.selectField("GreenChannel"); + %emitter.clearDataKeys(); + %emitter.setSingleDataKey(0); + %emitter.addDataKey(0.5, 1); + + %emitter.selectField("BlueChannel"); + %emitter.clearDataKeys(); + %emitter.setSingleDataKey(0.25); + + $pclrColorUnit.setToColor(0); + + pclrCheck("at birth the mix is the three channels' first keys", + %graph.getColorAtTime(0) $= "1 0 0.25"); + pclrCheck("at death red has gone and green has arrived", + %graph.getColorAtTime(1) $= "0 1 0.25"); + pclrCheck("halfway is halfway down red's ramp", + getWord(%graph.getColorAtTime(0.5), 0) $= "0.5"); + pclrCheck("and the top of green's", + getWord(%graph.getColorAtTime(0.5), 1) $= "1"); + + // Red bends at 0 and 1, green at 0, 0.5 and 1, blue nowhere. With the window + // at 0 to 1 that is three stops: the two edges and green's key. + %stops = %graph.getGradientStops(); + pclrCheck("the strip bends where a channel has a key", getWordCount(%stops) == 3); + pclrCheck("the first stop is the start of life", getWord(%stops, 0) $= "0"); + pclrCheck("the middle stop is green's key", getWord(%stops, 1) $= "0.5"); + pclrCheck("the last stop is the end of life", getWord(%stops, 2) $= "1"); + + // A key in a channel that had none is a new bend in the mixed color. + %emitter.selectField("BlueChannel"); + %emitter.addDataKey(0.75, 1); + %graph.inspect($pclrAsset); + + pclrCheck("a key added to blue adds a stop", + getWordCount(%graph.getGradientStops()) == 4); + + schedule(200, 0, "pclrStep8"); +} + +//----------------------------------------------------------------------------- +// Zoom, which was dead on every 0-1 field. +//----------------------------------------------------------------------------- + +function pclrStep8() +{ + pclrCheck("the color graph can zoom in", $pclrColorUnit.valueZoomInButton.isActive()); + pclrCheck("and its time axis can too", $pclrColorUnit.timeZoomInButton.isActive()); + pclrCheck("but it is already as far out as 0-1 goes", + !$pclrColorUnit.valueZoomOutButton.isActive()); + + $pclrColorUnit.valueZoomIn(); + pclrCheck("zooming in lets you back out again", $pclrColorUnit.valueZoomOutButton.isActive()); + + // Alpha is still its own entry and its own ordinary graph, and it gained the + // same zoom for the same reason. + pclrSelect($pclrAlphaIndex); + + pclrCheck("alpha shows the ordinary graph", pclrIsShowing($pclrTool.baseGraph)); + pclrCheck("and the color unit stood down", !pclrIsShowing($pclrColorUnit)); + pclrCheck("alpha is the field it edits", + $pclrTool.baseGraph.graph.getDisplayField() $= "AlphaChannel"); + pclrCheck("alpha can zoom now too", $pclrTool.baseGraph.valueZoomInButton.isActive()); + + schedule(200, 0, "pclrStep9"); +} + +//----------------------------------------------------------------------------- +// The channel survives a trip through the other emitter. +//----------------------------------------------------------------------------- + +function pclrStep9() +{ + pclrSelect($pclrColorIndex); + + %graph = $pclrColorUnit.graph; + pclrCheck("the channel came back as it was left", %graph.getActiveChannel() $= "Green"); + + // The second emitter, through the same path the title dropdown uses. + $pclrInspector.titleDropDown.setSelected(2); + $pclrInspector.onChooseParticleAsset($pclrAsset); + + schedule(400, 0, "pclrStep10"); +} + +function pclrStep10() +{ + pclrSelect($pclrColorIndex); + + %graph = $pclrColorUnit.graph; + pclrCheck("switching emitters kept the channel", %graph.getActiveChannel() $= "Green"); + pclrCheck("and the toggles followed it", $pclrColorUnit.toggle["Green"].getValue()); + pclrCheck("the graph moved to the second emitter", $pclrTool.emitterID == 1); + + echo("PCLR DONE"); + quit(); +} From fa4d341dc02ecf0dfa3063bfcae48c9fa71dc9f6 Mon Sep 17 00:00:00 2001 From: Peter Robinson Date: Sat, 15 Aug 2026 21:28:29 -0400 Subject: [PATCH 26/26] Frames an animation can call by name An image asset in explicit mode cuts its sheet into cells that each carry a RegionName, and an animation on it can list those names -- block1 block2 block3 block4 -- instead of listing 0 1 2 3. The point is that a named list survives the sheet being re-cut or re-ordered, which a numbered one does not. The engine has played these correctly for as long as they have existed. ImageFrameProviderCore branches on the mode in all four places that matter, and the runtime path from validate through play and update to the frame area is mode-aware throughout. Only AUTHORING was broken, and by one character: AnimationAsset_ScriptBinding.h formatted a StringTableEntry -- a const char* -- through "%d", so getNamedAnimationFrames returned a row of pointer addresses. Nothing that asked an animation for its named frames could recover them, so the Asset Manager refused such an asset twice over: AssetInspector sent it to the stock inspector, and AssetAnimationStage::canEdit denied it a palette and a timeline. Both refusals carried comments explaining that the API did not round trip, and both were right. NAMED CELLS MODE IS NO LONGER A FLAG ANYBODY SETS. The field, its setter, its write function and the member are gone; getNamedCellsMode() is a live read of mImageAsset->getExplicitMode(). The image already held the only honest answer, and a stored copy could disagree with it the moment that image was re-cut -- and did worse than that, since a person could set the flag true on an image with no names at all and get an animation with no frames and no explanation. Nothing has to keep the two in step now because there is only one of them. The refresh cascade already reaches here: setExplicitMode ends in refreshAsset, AssetManager::updateAssetDependencies has the edge from the animation's Image field, and the drain loop is index-based over a growing vector, so the dependent animation is dispatched in the same drain that dispatched the image. The whole editor stays in INDEX space, and that is what kept this small. The palette shows cell N, the timeline holds cell N, a drag carries cell N, the range dialog builds "28 29 30"; only loading and committing know that names exist. Every gesture, the caret arithmetic, the hold detection and the undo transaction are unchanged. The one thing index space cannot carry is a name whose cell has been deleted. It resolves to no index, and EVERY such name resolves to the same -1, so a list round-tripped through indices would come back from a single edit with two broken frames merged into one and the other silently committed away. So the timeline keeps a parallel mSlotNames, always exactly the size of mSlots: mSlots stays the drawing truth and mSlotNames the authoring truth. setFrames takes indices and derives names, setNamedFrames takes names and derives indices, and both always fill both -- which is why appendFrame, insertFrameAtPoint and the range dialog needed no changes at all. A missing frame draws as an outlined empty cell in the theme's error color with the name it could not find under it, in the same ink, and says so again in its tooltip and in the inspector's warning line. It is kept rather than dropped because dropping it is a deletion the user never asked for and could not have seen happen. Cells label themselves in both grids now, so the payoff is visible where the work is done. That is one virtual on the shared base, so the palette and the timeline cannot label the same cell differently -- which would make dragging between them a guess. Long names clip with an ellipsis and the full text is in the tooltip; the clip measures and draws the same string rather than pairing getStrNWidth with dglDrawTextN, whose counts are bytes and UTF16 units respectively and disagree the moment a name is not ASCII. EVERY EXPLICIT CELL NOW HAS A NAME, which is the invariant the rest rests on. A cell stored without one is named Frame, seeded at its own index and walked past anything already taken -- not hypothetical, since deleting a cell from the middle renumbers every one after it. It happens in calculateExplicitMode, the one funnel every path ends in, because the TAML read pushes straight into mExplicitFrames and never goes near addExplicitCell. What stood there before was a "repair" in four places that could not have worked: it read dSscanf FROM the empty name INTO a U32 passed by value where a pointer was required, and never assigned a name to anything. Its guard never fired either -- it compared a console or TAML buffer against StringTable->EmptyString by POINTER, and neither is ever interned. The image editor's Add Cell button now asks the engine for the name instead of building "Frame" @ index itself with no uniqueness check, which is how adding a cell after deleting one from the middle produced a duplicate that the rename box beside it would have refused. Switching an image between explicit and cell mode converts the animations on it, so the switch is a decision rather than a commitment. Both lists are kept in memory and only the one in use is written, and the conversion runs from onAssetRefresh, setImage and initializeAsset -- NOT from validateFrames, which is where it obviously belongs and where it would have destroyed data. That is called from inside both frame setters, so setAnimationFrames("") -- what the editor sends when the timeline is emptied, and what copyFieldsFrom sends on every single copy -- would have seen an empty active list beside a full one and put the frames the user had just cleared straight back. validateFrames stays a pure derivation that touches neither specified list. initializeAsset is new here: settling this after the whole file is read is what makes the result independent of TAML field order, which mattered as soon as anything depended on Image and a frame list together. Gating the write on the mode is what closes the round trip. Both lists used to be written whenever they had content, and the named one is applied last and used to force named mode on -- so an animation given numbered frames after ever having had named ones came back from its own file named. That in turn made an older landmine reachable: onTamlCustomWrite gated the Cells node on explicit mode, so saving an image with the mode off deleted every cell in the file and with them the only thing that could ever resolve those names again. The cells are authored data that outlive the mode -- copyAssetStateTo says so in as many words -- so they are written whenever there are any. Which means the file has to state the mode out loud, because the read infers explicit mode from the presence of a Cells node and must keep doing so for every file written before this. A file that states it is believed; only a file that says nothing is inferred from. Engine defects fixed on the way, all reachable before any of this: - getExplicitCellOffset returned NULL from a Vector2-returning function when not in explicit mode, which selects Vector2(const char*), which calls setString on a null pointer and dereferences it. The image editor's swap-cells path reaches it. - all four getExplicitCell accessors indexed with Vector::at, which takes a U32 and only asserts -- so it is unchecked in release and at(-1) was a read at four billion. A failed name lookup is exactly what -1 means around here. - getExplicitCellName and getExplicitCellIndex refused to answer while explicit mode was off, which is precisely when a name has to be translated back into an index. The guards are off those two; the four mutators keep theirs. - getCellByName's empty-name guard was the same pointer comparison as above, so an empty name matched the first frame of any image whose cells are unnamed. - ImageFrameProviderCore::mUsingNamedFrame and mNamedImageFrame were never initialized by the constructor and never cleared by clearAssets, and validRender reads the first on the first frame of every static sprite -- an indeterminate true then dereferences an equally indeterminate name. - getNamedAnimationFrames sized its return buffer at a fixed 4096 that suits a list of integers. A region name has no length limit and dSprintf truncates in silence, so a long animation would have lost its tail and said nothing. Both it and getMissingFrames measure first. - mValidatedNameFrames was missing its VECTOR_SET_ASSOCIATION, and the dead mAnimationIntegration field is gone. Two new bindings exist to stop script having to branch. getFrameCount answers in whichever space the animation uses; getAnimationFrameCount refuses in named mode and returns -1, which read as "fewer than one" to Keep Frame Rate and as "-1 frames" on the inspector's info line. getMissingFrames returns the names no cell answers to, which is cheaper and more honest than N console calls per refresh. animationFrameConversionTests and imageAssetCellNameTests cover the arithmetic through the statics, because building a real explicit cell needs a bitmap and a unit test has no GL context to load one into: the round trip, a hold surviving it, everything that fails to resolve in either direction, and the naming search including the collision walk and the case fold. AnimationAssetCarriesNamedFrames lost its mode assertion with the field and gained a sibling asserting that BOTH lists survive a copy, which is what makes the mode switch reversible. animationFrameValidation carries the engine half -- most valuably that getNamedAnimationFrames returns four names and not four numbers -- plus the mode switch, the auto-naming, and the file keeping its cells with the mode off. assetAnimationTimeline drives the editor half through to reading the saved file back. The shot harness gains the two pictures that only a picture settles: whether a name reads at 48 pixels, and whether a missing frame is findable. The toybox 1234 image and 1234Animation are the demo pair, and the only named assets in the tree. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014Xh3XULt9PtdCtsaYHGnYE --- cmake/EngineSources.cmake | 2 + .../Animation/AssetAnimationStage.cs | 88 +++-- .../Animation/AssetAnimationTimelinePane.cs | 32 +- editor/AssetAdmin/AssetInspector.cs | 21 +- .../ImageEditor/AssetImageFrameEditTool.cs | 15 +- .../Inspector/AssetAnimationInspectorPane.cs | 45 ++- .../EditorCore/Themes/BaseTheme/BaseTheme.cs | 8 + engine/source/2d/assets/AnimationAsset.cc | 182 +++++++++-- engine/source/2d/assets/AnimationAsset.h | 61 +++- .../2d/assets/AnimationAsset_ScriptBinding.h | 98 +++++- engine/source/2d/assets/ImageAsset.cc | 217 +++++++++---- engine/source/2d/assets/ImageAsset.h | 34 +- .../source/2d/core/ImageFrameProviderCore.cc | 10 + .../gui/editor/guiEditFrameStripCtrl.cc | 141 +++++++- .../source/gui/editor/guiEditFrameStripCtrl.h | 37 ++- .../gui/editor/guiEditFrameTimelineCtrl.cc | 165 +++++++++- .../gui/editor/guiEditFrameTimelineCtrl.h | 35 ++ .../guiEditFrameTimelineCtrl_ScriptBinding.h | 33 ++ .../tests/animationFrameConversionTests.cc | 302 ++++++++++++++++++ .../testing/tests/assetStateCopyTests.cc | 51 ++- .../testing/tests/imageAssetCellNameTests.cc | 148 +++++++++ tests/shots/assetAnimation.cs | 52 ++- tests/smoke/animationFrameValidation.cs | 177 ++++++++++ tests/smoke/assetAnimationInspector.cs | 85 +++++ tests/smoke/assetAnimationTimeline.cs | 150 +++++++++ tests/smoke/assetDirtySave.cs | 7 +- tests/smoke/assetImageInspector.cs | 47 +++ .../animations/1234Animation.asset.taml | 2 +- .../ToyAssets/1/assets/images/1234.asset.taml | 13 +- 29 files changed, 2063 insertions(+), 195 deletions(-) create mode 100644 engine/source/testing/tests/animationFrameConversionTests.cc create mode 100644 engine/source/testing/tests/imageAssetCellNameTests.cc diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index 3b16495b8..e393c032f 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -343,6 +343,7 @@ set(TORQUE_ENGINE_SOURCES # ---- testing ---- ${TORQUE_SRC}/testing/unitTesting.cc # ---- testing/tests ---- + ${TORQUE_SRC}/testing/tests/animationFrameConversionTests.cc ${TORQUE_SRC}/testing/tests/assetStateCopyTests.cc ${TORQUE_SRC}/testing/tests/bitmapFontParseTests.cc ${TORQUE_SRC}/testing/tests/guiControlReparentTests.cc @@ -355,6 +356,7 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/testing/tests/guiTextEditTests.cc ${TORQUE_SRC}/testing/tests/guiTextWrapTests.cc ${TORQUE_SRC}/testing/tests/guiTreeRowLayoutTests.cc + ${TORQUE_SRC}/testing/tests/imageAssetCellNameTests.cc ${TORQUE_SRC}/testing/tests/namespaceLinkTests.cc ${TORQUE_SRC}/testing/tests/platformFileIoTests.cc ${TORQUE_SRC}/testing/tests/platformMemoryTests.cc diff --git a/editor/AssetAdmin/Animation/AssetAnimationStage.cs b/editor/AssetAdmin/Animation/AssetAnimationStage.cs index ee7e8fdbc..d82f15146 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationStage.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationStage.cs @@ -67,7 +67,7 @@ function AssetAnimationStage::retainFor(%this, %animationAssetId) { - if(%animationAssetId $= "" || !%this.canEdit(%animationAssetId)) + if(%animationAssetId $= "" || !AssetDatabase.isDeclaredAsset(%animationAssetId)) { %this.teardown(); return false; @@ -76,24 +76,42 @@ return true; } -// Named cells are out of scope for now, and the reason is not squeamishness: the -// engine's named-frame API does not round-trip. Its getter formats a string -// through %d, and the field joins with commas while the setter splits on -// whitespace, so a named list does not survive its own TAML file. Rather than -// build a timeline on top of that, such an asset keeps the plain inspector and -// the old single-sprite preview, which have always worked for it. -function AssetAnimationStage::canEdit(%this, %animationAssetId) +// Whether the animation on show addresses its frames by name. +// +// Asked of the asset, which asks the image: an image in explicit mode cuts itself +// into named cells, so an animation on it lists names. Nothing here sets it, and +// there is no flag to set -- changing the image, or that image's explicit mode, +// is what changes the answer. +// +// The whole editor stays in INDEX space either way. The palette shows cell N, the +// timeline holds cell N, a drag carries cell N; only loading and committing know +// about names at all. What that buys is that every gesture, the range dialog, the +// caret arithmetic and the hold detection are written once. +function AssetAnimationStage::namedMode(%this) +{ + return isObject(%this.animationAsset) && %this.animationAsset.getNamedCellsMode(); +} + +// Point the timeline at the asset's frames, in whichever space they are kept. +// +// One method because there were three call sites that each did it slightly +// differently -- selection, a refresh, and an inspector commit -- and a fourth +// spelling of it was how the named case would have been missed. +function AssetAnimationStage::loadTimeline(%this) { - %asset = AssetDatabase.acquireAsset(%animationAssetId); - if(!isObject(%asset)) + if(!isObject(%this.timelinePane) || !isObject(%this.animationAsset)) { - return false; + return; } - %named = %asset.getNamedCellsMode(); - AssetDatabase.releaseAsset(%animationAssetId); - - return !%named; + if(%this.namedMode()) + { + %this.timelinePane.loadNamed(%this.imageAssetId, trim(%this.animationAsset.getNamedAnimationFrames())); + } + else + { + %this.timelinePane.load(%this.imageAssetId, trim(%this.animationAsset.getAnimationFrames())); + } } function AssetAnimationStage::select(%this, %imageAsset, %animationAsset, %assetId) @@ -124,7 +142,7 @@ %this.imageAssetId = %animationAsset.getImage(); %this.palettePane.load(%this.imageAssetId); - %this.timelinePane.load(%this.imageAssetId, trim(%animationAsset.getAnimationFrames())); + %this.loadTimeline(); %this.admin.transportBarContainer.setVisible(true); @@ -464,14 +482,21 @@ class = "AssetAnimationPalettePane"; // for a list it already holds. The same guard shape AssetInspectorPane uses. if(%isAnimation && !%this.committing) { - %this.timelinePane.load(%this.imageAssetId, trim(%this.animationAsset.getAnimationFrames())); + %this.loadTimeline(); } // The image may have been re-cut, so the palette's frame count has moved -- // and so has what the animation's frames mean. + // + // It may also have changed explicit mode, which moves the animation between + // name space and index space entirely. The asset has already converted its own + // list by the time this runs -- that is what AnimationAsset::onAssetRefresh + // does -- so the timeline is reloaded here as well, from whichever list is now + // the live one. if(%isImage) { %this.palettePane.reload(); + %this.loadTimeline(); } %this.resyncPreview(); @@ -558,17 +583,24 @@ class = "AssetAnimationPalettePane"; // place in the editor that writes the animation's frames. //----------------------------------------------------------------------------- -function AssetAnimationStage::commitFrames(%this, %frames) +function AssetAnimationStage::commitFrames(%this) { - if(!%this.built || !isObject(%this.animationAsset)) + if(!%this.built || !isObject(%this.animationAsset) || !isObject(%this.timelinePane)) { return; } + %named = %this.namedMode(); + // How many frames there were, asked of the ASSET rather than of the strip: // the strip already holds the edited list by the time it reports, so it can // no longer say what the animation used to be. - %before = %this.animationAsset.getAnimationFrameCount(); + // + // getFrameCount rather than getAnimationFrameCount, because that one refuses + // to answer in named mode and returns -1 -- which reads as "fewer than one" + // to keepFrameRate below, and would have silently switched that feature off + // for every named animation. + %before = %this.animationAsset.getFrameCount(); // Where the preview is, captured BEFORE the write, because the engine // restarts playback in the middle of it: AssetManager::refreshAsset notifies @@ -586,8 +618,20 @@ class = "AssetAnimationPalettePane"; // Guarded because the change comes straight back: every asset setter ends in // refreshAsset, which announces the change and fires onRefresh synchronously, // inside this call. + // + // The list is asked of the strip in whichever space the asset keeps it. Both + // are always available -- the strip fills its index list and its name list + // together, whichever one it was given -- so this is a choice of which to hand + // over, not a conversion. %this.committing = true; - %this.animationAsset.setAnimationFrames(%frames); + if(%named) + { + %this.animationAsset.setNamedAnimationFrames(%this.timelinePane.strip.getNamedFrames()); + } + else + { + %this.animationAsset.setAnimationFrames(%this.timelinePane.strip.getFrames()); + } %this.committing = false; // Before the slot is forgotten, because this changes the asset a second time @@ -657,7 +701,7 @@ class = "AssetAnimationPalettePane"; %this.imageAssetId = %imageAssetId; %this.palettePane.load(%imageAssetId); - %this.timelinePane.load(%imageAssetId, trim(%this.animationAsset.getAnimationFrames())); + %this.loadTimeline(); %this.admin.transportBar.refresh(); } diff --git a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs index 73af9a2a8..5da3faf5d 100644 --- a/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs +++ b/editor/AssetAdmin/Animation/AssetAnimationTimelinePane.cs @@ -89,6 +89,11 @@ %this.scroller.add(%this.strip); } +// The image FIRST in both of these, and that order is load bearing. +// +// The strip fills its index list and its name list together, and it can only do +// that by asking the image what cell N is called or which cell is called N. Given +// the frames before the image, every name resolves to nothing. function AssetAnimationTimelinePane::load(%this, %imageAssetId, %frames) { %this.strip.setImageAsset(%imageAssetId); @@ -96,6 +101,13 @@ %this.refreshCaption(); } +function AssetAnimationTimelinePane::loadNamed(%this, %imageAssetId, %names) +{ + %this.strip.setImageAsset(%imageAssetId); + %this.strip.setNamedFrames(%names); + %this.refreshCaption(); +} + function AssetAnimationTimelinePane::setPreviewSprite(%this, %sprite) { %this.strip.setPreviewSprite(%sprite); @@ -119,10 +131,14 @@ // pane's job, and writing it is the stage's. //----------------------------------------------------------------------------- +// The list is no longer handed over here. The stage reads it off the strip in +// whichever space the asset keeps its frames, and only the stage knows which that +// is -- passing indices from here meant a named animation was committed as a row +// of numbers to a setter that refuses them. function AssetAnimationTimelinePane::commitFrames(%this) { %this.refreshCaption(); - %this.stage.commitFrames(%this.strip.getFrames()); + %this.stage.commitFrames(); } function AssetAnimationTimelinePane::appendFrame(%this, %frame) @@ -137,10 +153,20 @@ %this.commitFrames(); } +// Inserted one at a time rather than concatenated onto getFrames() and set back. +// +// The round trip through the index list was lossy once frames could be missing: a +// frame whose cell has been deleted is index -1, and rebuilding the list from +// indices would have turned every such frame into the same nameless hole. Adding +// to the end touches nothing that is already there. function AssetAnimationTimelinePane::appendFrames(%this, %frames) { - %existing = %this.strip.getFrames(); - %this.strip.setFrames(%existing $= "" ? %frames : (%existing SPC %frames)); + %count = getWordCount(%frames); + for(%i = 0; %i < %count; %i++) + { + %this.strip.insertFrame(%this.strip.getCellCount(), getWord(%frames, %i)); + } + %this.commitFrames(); } diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index 60b4df849..6785f085b 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -737,21 +737,14 @@ class = "DuplicateAssetDialog"; %this.titlebar.setText("Animation Asset:" SPC %animationAsset.AssetName); %this.beginDocument(%animationAsset); - // Named cells still fall back to the generic inspector. + // Named cells come here too now. // - // The reason they used to is now gone: NamedAnimationFrames is a - // TypeStringTableEntryVector, whose getter joins with commas, and - // setNamedAnimationFrames split on whitespace alone -- so a named list did not - // survive its own TAML file, and a pane built on it would have quietly lost - // work. The setter accepts commas now (AnimationAsset.cc), so a named-cells - // pane is buildable. It is simply not built yet, which is a job of its own - // rather than a hazard. - if(%animationAsset.getNamedCellsMode()) - { - %this.inspectStock(%animationAsset); - return; - } - + // They used to fall through to the generic inspector, and there were two good + // reasons at the time: setNamedAnimationFrames split on whitespace while the + // field joined with commas, so a named list did not survive its own TAML file; + // and getNamedAnimationFrames formatted a StringTableEntry through %d, so what + // came back was a row of pointers. Both are fixed, and the pane reads a named + // animation the same way it reads a numbered one. %this.chooseInspector("Animation"); %this.animationPane.bind(%animationAsset, %assetID); } diff --git a/editor/AssetAdmin/ImageEditor/AssetImageFrameEditTool.cs b/editor/AssetAdmin/ImageEditor/AssetImageFrameEditTool.cs index 2d681af03..1091d43c8 100644 --- a/editor/AssetAdmin/ImageEditor/AssetImageFrameEditTool.cs +++ b/editor/AssetAdmin/ImageEditor/AssetImageFrameEditTool.cs @@ -141,10 +141,17 @@ %this.startListening(%row); } +// The name is the engine's to choose, and then read back. +// +// This used to build "Frame" @ %index itself, with no uniqueness check at all -- +// so adding a cell after deleting one from the middle produced a second cell +// with a name that already existed, which onCellNameChange right below would have +// refused had a person typed it. The engine names an unnamed cell on the way +// through calculateExplicitMode, walking past any name already taken, and that is +// now the only place the rule lives. function AssetImageFrameEditTool::addNewCell(%this) { %index = %this.asset.getExplicitCellCount(); - %name = "Frame" @ %index; %x = 0; %y = 0; %width = %this.asset.getImageWidth(); @@ -152,7 +159,11 @@ %this.rowChain.callOnChildrenNoRecurse("updateCellCount", %index + 1); - %this.asset.addExplicitCell(%x, %y, %width, %height, %name); + // addExplicitCell refreshes the asset before it returns, so the name it picked + // is there to be read on the next line. + %this.asset.addExplicitCell(%x, %y, %width, %height, ""); + %name = %this.asset.getExplicitCellName(%index); + %this.addImageFrameRow(%name, %x SPC %y, %width, %height, %index); } diff --git a/editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs b/editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs index d65be9d8b..5aff153a4 100644 --- a/editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs +++ b/editor/AssetAdmin/Inspector/AssetAnimationInspectorPane.cs @@ -30,11 +30,14 @@ // the one that is only a box cannot say which frame 67 is. // // Also absent, each for a checkable reason: -// NamedAnimationFrames, NamedCellsMode v1 is numeric, and the engine's named -// frame API does not round-trip through -// its own file -- so this pane is never -// shown for such an asset rather than -// offering a switch into it +// NamedAnimationFrames the same field as AnimationFrames, in +// name space, and the timeline owns that +// one too +// NamedCellsMode not a field at all any more. Whether an +// animation names its frames is read from +// the image -- explicit mode means named +// cells -- so a switch here would be a +// second, disagreeing answer // AssetInternal, AssetPrivate they exist to keep an asset out of the // editor // asset id, asset file the module and the name are on show, @@ -206,7 +209,10 @@ // answer separately: how long, how many, and therefore how fast. function AssetAnimationInspectorPane::describeAnimation(%this, %asset) { - %count = %asset.getAnimationFrameCount(); + // getFrameCount, not getAnimationFrameCount: that one refuses to answer for an + // animation using named cells and returns -1, which read as "-1 frames" on + // this very line. + %count = %asset.getFrameCount(); %time = %asset.getAnimationTime(); %line = %count SPC ((%count == 1) ? "frame," : "frames,") SPC %time SPC "s"; @@ -253,18 +259,35 @@ return "The image asset" SPC %imageId SPC "did not load, so there is nothing to play."; } + // The two spaces fail differently, so they are reported differently. + // + // A named frame that no cell answers to is simply not drawn, and the timeline + // keeps it as an outlined gap -- so the useful thing to say is WHICH names, + // because the fix is either to put the cell back or to take the frame out. + if(%asset.getNamedCellsMode()) + { + %missing = trim(%asset.getMissingFrames()); + if(%missing !$= "") + { + %plural = (getWordCount(%missing) == 1); + return (%plural ? "The frame" : "The frames") SPC "\"" @ %missing @ "\"" SPC + (%plural ? "names a cell" : "name cells") SPC "the image no longer has, so" SPC + (%plural ? "it draws" : "they draw") SPC "nothing. Put the cell back on the " @ + "Explicit Frames tab, or take the frame out of the timeline."; + } + } + // A numbered frame out of range is CLAMPED to the last one rather than + // dropped, so the animation keeps playing and quietly shows the wrong art. // Specified against validated is the only comparison script can make, and it - // is exactly the right one: validateNumericalFrames CLAMPS an out-of-range - // frame to the last one rather than dropping it, so the animation keeps - // playing and quietly shows the wrong art. - if(trim(%asset.getAnimationFrames()) !$= trim(%asset.getAnimationFrames(true))) + // is exactly the right one. + else if(trim(%asset.getAnimationFrames()) !$= trim(%asset.getAnimationFrames(true))) { return "Some frames are outside the image's" SPC %image.getFrameCount() SPC "and are being clamped to the nearest one. The timeline shows what was asked for; " @ "the preview shows what is being drawn."; } - if(%asset.getAnimationFrameCount() == 0) + if(%asset.getFrameCount() == 0) { return "This animation has no frames yet. Drag one in from the palette, or use Frame Range."; } diff --git a/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs b/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs index a3bb52e1c..dc382c672 100644 --- a/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs +++ b/editor/EditorCore/Themes/BaseTheme/BaseTheme.cs @@ -1931,6 +1931,14 @@ fontColorHL = %this.color4; fontColorSL = %this.color5; + // Errors, in the sense the console profile uses this slot for. The + // timeline outlines a frame naming a cell the image no longer has, and this + // is the color of that outline and of its label. The four FILL colors are + // all spoken for -- background, hover, selected, and about-to-be-discarded + // during a drag -- which is why a missing frame is a border rather than a + // wash. + fontColorNA = "255 0 0 255"; + // The Delete key only reaches a control that can hold focus, and the // timeline's whole keyboard depends on it. canKeyFocus = true; diff --git a/engine/source/2d/assets/AnimationAsset.cc b/engine/source/2d/assets/AnimationAsset.cc index f306a1f8e..e03795a32 100755 --- a/engine/source/2d/assets/AnimationAsset.cc +++ b/engine/source/2d/assets/AnimationAsset.cc @@ -81,14 +81,13 @@ IMPLEMENT_CONOBJECT(AnimationAsset); AnimationAsset::AnimationAsset() : mAnimationTime(1.0f), mAnimationCycle(true), - mRandomStart(false), - mAnimationIntegration(0.0f), - mNamedCellsMode(false) + mRandomStart(false) { // Set Vector Associations. VECTOR_SET_ASSOCIATION( mAnimationFrames ); VECTOR_SET_ASSOCIATION( mNamedAnimationFrames ); - VECTOR_SET_ASSOCIATION( mValidatedFrames ); + VECTOR_SET_ASSOCIATION( mValidatedFrames ); + VECTOR_SET_ASSOCIATION( mValidatedNameFrames ); } //------------------------------------------------------------------------------ @@ -110,7 +109,9 @@ void AnimationAsset::initPersistFields() addProtectedField("AnimationTime", TypeF32, Offset(mAnimationTime, AnimationAsset), &setAnimationTime, &defaultProtectedGetFn, &defaultProtectedWriteFn, ""); addProtectedField("AnimationCycle", TypeBool, Offset(mAnimationCycle, AnimationAsset), &setAnimationCycle, &defaultProtectedGetFn, &writeAnimationCycle, ""); addProtectedField("RandomStart", TypeBool, Offset(mRandomStart, AnimationAsset), &setRandomStart, &defaultProtectedGetFn, &writeRandomStart, ""); - addProtectedField("NamedCellsMode", TypeBool, Offset(mNamedCellsMode, AnimationAsset), &setNamedCellsMode, &defaultProtectedGetFn, &writeNamedCellsMode, ""); + + // There is no NamedCellsMode field, and deliberately so. Whether an animation + // uses names is the image's business -- see getNamedCellsMode. } //------------------------------------------------------------------------------ @@ -141,11 +142,15 @@ void AnimationAsset::onAssetRefresh( void ) if ( !isProperlyAdded() ) return; - // Re-validate the frames. A refresh reaches us both when we were changed - // ourselves and when the image asset we depend on was, and the image may have - // been re-cut into a different number of cells. Without this the validated - // list keeps indices from the old cut, and getImageFrameArea() clamps them to - // the last frame -- so the animation plays the wrong art and says nothing. + // A refresh reaches us both when we were changed ourselves and when the image + // asset we depend on was, which is also the only warning we get that the image + // has changed which space our frames are counted in. + convertFramesForMode(); + + // Re-validate the frames. The image may have been re-cut into a different + // number of cells, and without this the validated list keeps indices from the + // old cut, and getImageFrameArea() clamps them to the last frame -- so the + // animation plays the wrong art and says nothing. validateFrames(); // Call parent. @@ -163,6 +168,10 @@ void AnimationAsset::setImage( const char* pAssetId ) // Update. mImageAsset = pAssetId; + // Repointing at an image that counts its frames differently is a mode switch + // like any other. + convertFramesForMode(); + // Validate frames. validateFrames(); @@ -181,10 +190,9 @@ void AnimationAsset::setAnimationFrames( const char* pAnimationFrames ) // // This one did not, so writing the same frame list back counted as a change: // it announced itself, marked the asset unsaved, and -- once the Asset Manager - // started recording undo -- left a step that put nothing back. The mode is - // part of the comparison because this setter also clears named-cells mode, so - // an identical list still has work to do if that mode is on. - if ( !mNamedCellsMode ) + // started recording undo -- left a step that put nothing back. The list is the + // whole comparison now; it used to have to consider the mode as well, because + // this setter also cleared named cells mode, and no longer does. { const U32 currentCount = StringUnit::getUnitCount( pAnimationFrames, " \t\n" ); @@ -219,7 +227,9 @@ void AnimationAsset::setAnimationFrames( const char* pAnimationFrames ) mAnimationFrames.push_back( dAtoi( StringUnit::getUnit( pAnimationFrames, frameIndex, " \t\n" ) ) ); } - mNamedCellsMode = false; + // The named list is left alone, deliberately. Both lists survive so that an + // image changing mode and changing back costs the animation nothing, and only + // the one in use is written to the file. // Validate frames. validateFrames(); @@ -242,7 +252,6 @@ void AnimationAsset::setAnimationFrames( const char* pAnimationFrames ) void AnimationAsset::setNamedAnimationFrames( const char* pAnimationFrames ) { // Ignore no change, for the same reason as the numbered setter above. - if ( mNamedCellsMode ) { const U32 currentCount = StringUnit::getUnitCount( pAnimationFrames, " \t\n," ); @@ -277,7 +286,7 @@ void AnimationAsset::setNamedAnimationFrames( const char* pAnimationFrames ) mNamedAnimationFrames.push_back( StringTable->insert( StringUnit::getUnit( pAnimationFrames, frameIndex, " \t\n," ) ) ); } - mNamedCellsMode = true; + // The numbered list is left alone; see the sibling setter above. // Validate frames. validateFrames(); @@ -333,17 +342,127 @@ void AnimationAsset::setRandomStart( const bool randomStart ) //------------------------------------------------------------------------------ -void AnimationAsset::setNamedCellsMode( const bool namedCellsMode ) +bool AnimationAsset::getNamedCellsMode( void ) const { - // Ignore no change. - if ( namedCellsMode == mNamedCellsMode) + // Asked of the image every time rather than cached, so there is nothing that + // can be left stale. An image in explicit mode has named cells; one cut into + // a grid does not. + return mImageAsset.notNull() && mImageAsset->getExplicitMode(); +} + +//------------------------------------------------------------------------------ + +void AnimationAsset::translateFrames( const Vector& indices, const Vector& cellNames, Vector& outNames ) +{ + outNames.clear(); + + for( Vector::const_iterator frameItr = indices.begin(); frameItr != indices.end(); ++frameItr ) + { + const S32 frame = *frameItr; + + if ( frame < 0 || frame >= cellNames.size() ) + continue; + + if ( cellNames[frame] == StringTable->EmptyString ) + continue; + + outNames.push_back( cellNames[frame] ); + } +} + +//------------------------------------------------------------------------------ + +void AnimationAsset::translateFrames( const Vector& names, const Vector& cellNames, Vector& outIndices ) +{ + outIndices.clear(); + + for( Vector::const_iterator frameItr = names.begin(); frameItr != names.end(); ++frameItr ) + { + StringTableEntry frame = *frameItr; + + if ( frame == StringTable->EmptyString ) + continue; + + for ( S32 cellIndex = 0; cellIndex < cellNames.size(); ++cellIndex ) + { + if ( cellNames[cellIndex] == frame ) + { + outIndices.push_back( cellIndex ); + break; + } + } + } +} + +//------------------------------------------------------------------------------ + +void AnimationAsset::convertFramesForMode( void ) +{ + // Nothing to translate against. + if ( mImageAsset.isNull() ) return; - // Update. - mNamedCellsMode = namedCellsMode; + const bool namedCellsMode = getNamedCellsMode(); - // Refresh the asset. - refreshAsset(); + // Only when the list the animation now needs has nothing in it and the other + // one does. That makes this idempotent, and it makes the switch reversible: + // going named leaves the numbers where they were, so coming back finds them + // rather than rebuilding them, and any editing done while named wins. + if ( namedCellsMode ) + { + if ( mNamedAnimationFrames.size() > 0 || mAnimationFrames.size() == 0 ) + return; + } + else + { + if ( mAnimationFrames.size() > 0 || mNamedAnimationFrames.size() == 0 ) + return; + } + + // What each cell is called, by index. Read from the explicit cells rather than + // the resolved frames, because this has to work while explicit mode is OFF -- + // which is exactly the case that translates names back into indices. + Vector cellNames; + const S32 cellCount = mImageAsset->getExplicitCellCount(); + for ( S32 cellIndex = 0; cellIndex < cellCount; ++cellIndex ) + { + cellNames.push_back( mImageAsset->getExplicitCellName( cellIndex ) ); + } + + if ( namedCellsMode ) + { + translateFrames( mAnimationFrames, cellNames, mNamedAnimationFrames ); + } + else + { + translateFrames( mNamedAnimationFrames, cellNames, mAnimationFrames ); + } +} + +//------------------------------------------------------------------------------ + +void AnimationAsset::getMissingFrames( Vector& missingFrames ) const +{ + missingFrames.clear(); + + if ( !getNamedCellsMode() ) + return; + + for( Vector::const_iterator frameItr = mNamedAnimationFrames.begin(); frameItr != mNamedAnimationFrames.end(); ++frameItr ) + { + if ( !mImageAsset->containsFrame( *frameItr ) ) + missingFrames.push_back( *frameItr ); + } +} + +//------------------------------------------------------------------------------ + +S32 AnimationAsset::getFrameCount( const bool validatedFrames ) const +{ + if ( getNamedCellsMode() ) + return validatedFrames ? mValidatedNameFrames.size() : mNamedAnimationFrames.size(); + + return validatedFrames ? mValidatedFrames.size() : mAnimationFrames.size(); } //------------------------------------------------------------------------------ @@ -447,7 +566,10 @@ void AnimationAsset::validateFrames( void ) if ( mImageAsset.isNull() ) return; - if (mNamedCellsMode) + // Only the list in use, and nothing else. This is a pure derivation -- it must + // not touch either specified list, because it runs from inside both setters + // and would otherwise be undoing the write that called it. + if (getNamedCellsMode()) { validateNamedFrames(); } @@ -471,6 +593,14 @@ void AnimationAsset::initializeAsset( void ) // Call parent. Parent::initializeAsset(); - // Currently there is no specific initialization required. + // Settle the frames once, now that the whole file has been read. + // + // Until now this relied on each field validating as TAML applied it, so + // whichever of Image and the two frame lists happened to be written last got + // the final say. That was survivable while nothing depended on more than one + // field at a time. Converting between the two spaces depends on all three, so + // it has to happen where all three are known to be in. + convertFramesForMode(); + validateFrames(); } diff --git a/engine/source/2d/assets/AnimationAsset.h b/engine/source/2d/assets/AnimationAsset.h index 1693dceb7..05044830d 100755 --- a/engine/source/2d/assets/AnimationAsset.h +++ b/engine/source/2d/assets/AnimationAsset.h @@ -51,10 +51,6 @@ class AnimationAsset : public AssetBase bool mAnimationCycle; bool mRandomStart; - F32 mAnimationIntegration; - - bool mNamedCellsMode; - public: AnimationAsset(); virtual ~AnimationAsset(); @@ -80,14 +76,51 @@ class AnimationAsset : public AssetBase inline bool getAnimationCycle( void ) const { return mAnimationCycle; } void setRandomStart( const bool randomStart ); inline bool getRandomStart( void ) const { return mRandomStart; } - void setNamedCellsMode( const bool namedCellsMode ); - inline bool getNamedCellsMode( void ) const { return mNamedCellsMode; } + + /// Whether this animation addresses its frames by name rather than by index. + /// + /// Asked of the image, not stored. An image in explicit mode cuts itself into + /// named cells and one in cell mode does not, so the image already holds the + /// only honest answer -- and a copy of it here could disagree with the image + /// it was copied from the moment that image was re-cut. It was a saved field + /// once, which meant a person could set it to true on an image that had no + /// names, and the animation had no frames and no explanation. + bool getNamedCellsMode( void ) const; // Frame validation. void validateFrames( void ); void validateNumericalFrames( void ); void validateNamedFrames( void ); + /// Put the frame list into the space the image now uses. + /// + /// Deliberately NOT called from the two frame setters, only from the three + /// places that mean "something outside changed". Setting an empty frame list + /// is a thing the editor does whenever the timeline is emptied, and something + /// copyFieldsFrom does on every single copy -- and from inside a setter this + /// could not tell that from "this list was never filled in", so it put the + /// frames the user had just cleared straight back. + void convertFramesForMode( void ); + + /// The specified names that no cell answers to. Empty when all of them do. + void getMissingFrames( Vector& missingFrames ) const; + + /// How many frames the animation has, in whichever space it is using. + S32 getFrameCount( const bool validatedFrames ) const; + + /// Translate a frame list from one space to the other against a table of cell + /// names, where entry N is what cell N is called. + /// + /// Statics taking their whole world rather than methods reaching for the image + /// asset, so they can be unit tested -- building a real ImageAsset with cells + /// needs a bitmap, and a unit test has no GL context to load one into. + /// + /// An entry that does not resolve is skipped. There is no honest name for an + /// index with no cell, and inventing one risks colliding with a cell somebody + /// really does name that later. + static void translateFrames( const Vector& indices, const Vector& cellNames, Vector& outNames ); + static void translateFrames( const Vector& names, const Vector& cellNames, Vector& outIndices ); + // Asset validation. virtual bool isAssetValid( void ) const; @@ -102,16 +135,22 @@ class AnimationAsset : public AssetBase static bool setImage( void* obj, const char* data ) { static_cast(obj)->setImage( data ); return false; } static bool writeImage( void* obj, StringTableEntry pFieldName ) { return static_cast(obj)->mImageAsset.notNull(); } static bool setAnimationFrames( void* obj, const char* data ) { static_cast(obj)->setAnimationFrames( data ); return false; } - static bool writeAnimationFrames( void* obj, StringTableEntry pFieldName ) { return static_cast(obj)->mAnimationFrames.size() > 0; } - static bool setNamedAnimationFrames( void* obj, const char* data ) { static_cast(obj)->setNamedAnimationFrames( data ); return false; } - static bool writeNamedAnimationFrames( void* obj, StringTableEntry pFieldName ) { return static_cast(obj)->mNamedAnimationFrames.size() > 0; } + static bool setNamedAnimationFrames( void* obj, const char* data ) { static_cast(obj)->setNamedAnimationFrames( data ); return false; } + + // Only the list the animation is actually using is written. + // + // Both lists are kept in memory, which is what lets an image change mode and + // change back without the animation losing anything. Writing both was a + // round trip that did not close: the named list is applied last and used to + // force named mode on, so an animation given numbered frames after ever + // having had named ones came back from its own file named. + static bool writeAnimationFrames( void* obj, StringTableEntry pFieldName ) { AnimationAsset* pAsset = static_cast(obj); return !pAsset->getNamedCellsMode() && pAsset->mAnimationFrames.size() > 0; } + static bool writeNamedAnimationFrames( void* obj, StringTableEntry pFieldName ) { AnimationAsset* pAsset = static_cast(obj); return pAsset->getNamedCellsMode() && pAsset->mNamedAnimationFrames.size() > 0; } static bool setAnimationTime( void* obj, const char* data ) { static_cast(obj)->setAnimationTime( dAtof(data) ); return false; } static bool setAnimationCycle( void* obj, const char* data ) { static_cast(obj)->setAnimationCycle( dAtob(data) ); return false; } static bool writeAnimationCycle( void* obj, StringTableEntry pFieldName ) { return static_cast(obj)->getAnimationCycle() == false; } static bool setRandomStart( void* obj, const char* data ) { static_cast(obj)->setRandomStart( dAtob(data) ); return false; } static bool writeRandomStart( void* obj, StringTableEntry pFieldName ) { return static_cast(obj)->getRandomStart() == true; } - static bool setNamedCellsMode( void* obj, const char* data ) { static_cast(obj)->setNamedCellsMode( dAtob(data) ); return false; } - static bool writeNamedCellsMode( void* obj, StringTableEntry pFieldName ) { return static_cast(obj)->getNamedCellsMode() == true; } }; #endif // _ANIMATION_ASSET_H_ \ No newline at end of file diff --git a/engine/source/2d/assets/AnimationAsset_ScriptBinding.h b/engine/source/2d/assets/AnimationAsset_ScriptBinding.h index 7e1081b31..8cad0d601 100755 --- a/engine/source/2d/assets/AnimationAsset_ScriptBinding.h +++ b/engine/source/2d/assets/AnimationAsset_ScriptBinding.h @@ -161,11 +161,6 @@ ConsoleMethodWithDocs(AnimationAsset, getNamedAnimationFrames, ConsoleString, 2, return StringTable->EmptyString; } - // Fetch a return buffer. - S32 bufferSize = 4096; - char* pBuffer = Con::getReturnBuffer( bufferSize ); - char* pReturnBuffer = pBuffer; - // Fetch validated frames flag. const bool validatedFrames = argc >= 3 ? dAtob( argv[2] ) : false; @@ -175,10 +170,33 @@ ConsoleMethodWithDocs(AnimationAsset, getNamedAnimationFrames, ConsoleString, 2, // Fetch frame count. const U32 frameCount = (U32)frames.size(); + // Measured rather than assumed, unlike the numbered sibling above. + // + // A cell name comes from a TAML attribute and has no length limit, so the + // fixed 4096 that serves a list of integers can genuinely be too small here + // -- and dSprintf truncates in silence, which would have shortened a long + // animation to whatever fitted and told nobody. + U32 bufferLength = 1; + for ( U32 frameIndex = 0; frameIndex < frameCount; ++frameIndex ) + { + bufferLength += dStrlen( frames[frameIndex] ) + 1; + } + + S32 bufferSize = (S32)bufferLength; + char* pBuffer = Con::getReturnBuffer( bufferSize ); + char* pReturnBuffer = pBuffer; + *pBuffer = 0; + // Format frames. + // + // As "%s". These are StringTableEntry -- const char* -- and formatting one + // through "%d" printed the pointer, so every named animation read back as a + // list of addresses. That single character is why the Asset Manager could + // not edit a named animation at all: nothing downstream could recover the + // names it had just asked for. for ( U32 frameIndex = 0; frameIndex < frameCount; ++frameIndex ) { - const S32 offset = dSprintf( pBuffer, bufferSize, "%d ", frames[frameIndex] ); + const S32 offset = dSprintf( pBuffer, bufferSize, "%s ", frames[frameIndex] ); pBuffer += offset; bufferSize -= offset; } @@ -257,23 +275,73 @@ ConsoleMethodWithDocs(AnimationAsset, getAnimationCycle, ConsoleBool, 2, 2, ()) //----------------------------------------------------------------------------- -/*! Sets whether the animation uses names for cells, instead of numerical index. - @param namedCellsMode True if it should be using named cells. - @return No return value. +/*! Gets whether the animation is using names for its cells. + + This is not a setting. It is read from the image asset: an image in explicit + mode cuts itself into named cells, so an animation on it addresses them by + name, and an image cut into a grid has no names to address. Change the image, + or change that image's explicit mode, to change this. + @return True if the animation is using named cells. */ -ConsoleMethodWithDocs(AnimationAsset, setNamedCellsMode, ConsoleVoid, 3, 3, ()) +ConsoleMethodWithDocs(AnimationAsset, getNamedCellsMode, ConsoleBool, 2, 2, ()) { - object->setNamedCellsMode( dAtob(argv[2] ) ); + return object->getNamedCellsMode(); } //----------------------------------------------------------------------------- -/*! Gets whether the animation is using names for its cells. - @return True if the animation is using named cells. +/*! Gets the count of frames that compose the animation, whether it is using named + cells or numbered ones. + @param validatedFrames - Whether to count only the validated frames or not. Optional: Default is false. + @return The number of frames that compose the animation. */ -ConsoleMethodWithDocs(AnimationAsset, getNamedCellsMode, ConsoleBool, 2, 2, ()) +ConsoleMethodWithDocs(AnimationAsset, getFrameCount, ConsoleInt, 2, 3, ([bool validatedFrames])) { - return object->getNamedCellsMode(); + // Fetch validated frames flag. + const bool validatedFrames = argc >= 3 ? dAtob( argv[2] ) : false; + + return object->getFrameCount( validatedFrames ); +} + +//----------------------------------------------------------------------------- + +/*! Gets the named frames that no cell of the image answers to. + + Empty when every frame resolves, and empty for an animation using numbered + frames, which cannot have this problem -- an out-of-range number is clamped. + @return The space separated names of the frames that cannot be found. +*/ +ConsoleMethodWithDocs(AnimationAsset, getMissingFrames, ConsoleString, 2, 2, ()) +{ + Vector missingFrames; + object->getMissingFrames( missingFrames ); + + // Fetch frame count. + const U32 frameCount = (U32)missingFrames.size(); + + if ( frameCount == 0 ) + return StringTable->EmptyString; + + // Measured, because a cell name has no length limit. + U32 bufferLength = 1; + for ( U32 frameIndex = 0; frameIndex < frameCount; ++frameIndex ) + { + bufferLength += dStrlen( missingFrames[frameIndex] ) + 1; + } + + S32 bufferSize = (S32)bufferLength; + char* pBuffer = Con::getReturnBuffer( bufferSize ); + char* pReturnBuffer = pBuffer; + *pBuffer = 0; + + for ( U32 frameIndex = 0; frameIndex < frameCount; ++frameIndex ) + { + const S32 offset = dSprintf( pBuffer, bufferSize, "%s ", missingFrames[frameIndex] ); + pBuffer += offset; + bufferSize -= offset; + } + + return pReturnBuffer; } ConsoleMethodGroupEndWithDocs(AnimationAsset) diff --git a/engine/source/2d/assets/ImageAsset.cc b/engine/source/2d/assets/ImageAsset.cc index cf0ff79f7..e3bb488f8 100755 --- a/engine/source/2d/assets/ImageAsset.cc +++ b/engine/source/2d/assets/ImageAsset.cc @@ -170,6 +170,7 @@ ImageAsset::ImageAsset() : mImageFile(StringTable->EmptyString), mForce16Bit(false), mLocalFilterMode(FILTER_INVALID), mExplicitMode(false), + mExplicitModeStated(false), mCellRowOrder(true), mCellOffsetX(0), mCellOffsetY(0), @@ -569,16 +570,27 @@ void ImageAsset::setCellHeight( const S32 cellheight ) //------------------------------------------------------------------------------ +// Every one of these four range-checks its index rather than trusting the +// caller. Vector::at takes a U32 and asserts, which means it is unchecked in +// a release build -- so at(-1) was a straight out-of-bounds read at index four +// billion. Reachable the moment a name fails to resolve, since a failed lookup +// is exactly what -1 means around here. Vector2 ImageAsset::getExplicitCellOffset(const S32 cellIndex) { if ( !getExplicitMode() ) { // No, so warn. Con::warnf( "ImageAsset() - Cannot perform explicit cell operation when not in explicit mode." ); - return NULL; + return Vector2::getZero(); } - - ImageAsset::FrameArea::PixelArea thisCell = mExplicitFrames.at(cellIndex); + + if ( cellIndex < 0 || cellIndex >= mExplicitFrames.size() ) + { + Con::warnf( "ImageAsset::getExplicitCellOffset() - Invalid Cell Index of %d.", cellIndex ); + return Vector2::getZero(); + } + + const ImageAsset::FrameArea::PixelArea& thisCell = mExplicitFrames[cellIndex]; return(thisCell.mPixelOffset); } @@ -593,8 +605,14 @@ S32 ImageAsset::getExplicitCellWidth(const S32 cellIndex) Con::warnf( "ImageAsset() - Cannot perform explicit cell operation when not in explicit mode." ); return (0); } - - ImageAsset::FrameArea::PixelArea thisCell = mExplicitFrames.at(cellIndex); + + if ( cellIndex < 0 || cellIndex >= mExplicitFrames.size() ) + { + Con::warnf( "ImageAsset::getExplicitCellWidth() - Invalid Cell Index of %d.", cellIndex ); + return (0); + } + + const ImageAsset::FrameArea::PixelArea& thisCell = mExplicitFrames[cellIndex]; return(thisCell.mPixelWidth); } @@ -609,24 +627,34 @@ S32 ImageAsset::getExplicitCellHeight(const S32 cellIndex) Con::warnf( "ImageAsset() - Cannot perform explicit cell operation when not in explicit mode." ); return (0); } - - ImageAsset::FrameArea::PixelArea thisCell = mExplicitFrames.at(cellIndex); + + if ( cellIndex < 0 || cellIndex >= mExplicitFrames.size() ) + { + Con::warnf( "ImageAsset::getExplicitCellHeight() - Invalid Cell Index of %d.", cellIndex ); + return (0); + } + + const ImageAsset::FrameArea::PixelArea& thisCell = mExplicitFrames[cellIndex]; return(thisCell.mPixelHeight); } //------------------------------------------------------------------------------ +// No explicit-mode guard, unlike its three siblings above, and neither has +// getExplicitCellIndex below. +// +// The cells outlive being switched out of explicit mode -- copyAssetStateTo +// carries them across on purpose, and they are still written to the file -- and +// asking what cell 3 is called is a question about that surviving data, not about +// the mode. It has to be answerable while the mode is off, because that is +// precisely when an animation's names are being translated back into indices. StringTableEntry ImageAsset::getExplicitCellName(const S32 cellIndex) { - if ( !getExplicitMode() ) - { - // No, so warn. - Con::warnf( "ImageAsset() - Cannot perform explicit cell operation when not in explicit mode." ); - return NULL; - } - - ImageAsset::FrameArea::PixelArea thisCell = mExplicitFrames.at(cellIndex); + if ( cellIndex < 0 || cellIndex >= mExplicitFrames.size() ) + return StringTable->EmptyString; + + const ImageAsset::FrameArea::PixelArea& thisCell = mExplicitFrames[cellIndex]; return(thisCell.mRegionName); } @@ -635,13 +663,10 @@ StringTableEntry ImageAsset::getExplicitCellName(const S32 cellIndex) S32 ImageAsset::getExplicitCellIndex(const char* regionName) { - if ( !getExplicitMode() ) - { - // No, so warn. - Con::warnf( "ImageAsset() - Cannot perform explicit cell operation when not in explicit mode." ); + // No name is not a name, and must not match the cells that have none. + if ( regionName == NULL || *regionName == 0 ) return -1; - } - + // Set up a frame counter S32 frameCounter = 0; @@ -732,14 +757,14 @@ bool ImageAsset::addExplicitCell( const S32 cellOffsetX, const S32 cellOffsetY, const S32 imageWidth = getImageWidth(); const S32 imageHeight = getImageHeight(); - // The region name cannot be empty - if ( regionName == StringTable->EmptyString ) - { - Con::warnf( "ImageAsset::addExplicitCell() - Cell name of '%s' is invalid or was not set.", regionName ); - U32 currentIndex = mExplicitFrames.size(); - Con::warnf( "- Setting to the next index in the frame list: '%i'", currentIndex ); - dSscanf(regionName, "%i", currentIndex); - } + // An empty name is allowed through, and named on the way out. + // + // calculateExplicitMode gives every unnamed cell a "Frame" before anything + // can read one, and it is the only place that does -- it is the single funnel + // that a cell arriving from TAML passes through as well as one arriving from + // here. What stood in this spot instead was a "repair" that could not have + // worked: it read dSscanf FROM the empty name INTO a U32 passed by value, + // where a pointer was required, and never assigned a name to anything. // The Cell Offset X needs to be within the image. if ( cellOffsetX < 0 || cellOffsetX >= imageWidth ) @@ -802,13 +827,7 @@ bool ImageAsset::insertExplicitCell( const S32 cellIndex, const S32 cellOffsetX, // Fetch the explicit frame count. const S32 explicitFramelCount = mExplicitFrames.size(); - // Region cannot be empty - if ( regionName == StringTable->EmptyString ) - { - Con::warnf( "ImageAsset::insertExplicitCell() - Cell name of '%s' is invalid or was not set.", regionName ); - Con::warnf( "- Setting to the next index in the frame list: '%i'", explicitFramelCount ); - dSscanf(regionName, "%i", explicitFramelCount); - } + // An empty name is named by calculateExplicitMode; see addExplicitCell. // The cell index needs to be in range. if ( cellIndex < 0 ) @@ -889,13 +908,7 @@ bool ImageAsset::setExplicitCell( const S32 cellIndex, const S32 cellOffsetX, co // Fetch the explicit frame count. const S32 explicitFrameCount = mExplicitFrames.size(); - // Region cannot be empty - if ( regionName == StringTable->EmptyString ) - { - Con::warnf( "ImageAsset::setExplicitCell() - Cell name of '%s' is invalid or was not set.", regionName ); - Con::warnf( "- Setting to the next index in the frame list: '%i'", explicitFrameCount ); - dSscanf(regionName, "%i", explicitFrameCount); - } + // An empty name is named by calculateExplicitMode; see addExplicitCell. // The cell index needs to be in range. if ( cellIndex < 0 || cellIndex >= explicitFrameCount ) @@ -1017,8 +1030,14 @@ bool ImageAsset::removeExplicitCell( const char* regionName ) ImageAsset::FrameArea& ImageAsset::getCellByName( const char* cellName) { - // If the cellName was empty - if (cellName == StringTable->EmptyString) + // If the cellName was empty. + // + // Tested by content, not by pointer. Comparing against StringTable->EmptyString + // only catches a string that has been interned, and the callers that matter -- + // script argv and TAML field buffers -- never have been, so the guard let an + // empty name through to match the first frame of any image whose cells are + // unnamed. + if (cellName == NULL || *cellName == 0) { // Warn and return a bad frame Con::warnf( "ImageAsset::getCellByName() - Empty cell name was passed." ); @@ -1365,6 +1384,66 @@ void ImageAsset::calculateImplicitMode( void ) //------------------------------------------------------------------------------ +StringTableEntry ImageAsset::nextAvailableCellName( const Vector& used, const S32 seedIndex ) +{ + char nameBuffer[32]; + + // Seeded at the cell's own index rather than at zero, so an image whose cells + // have never been named comes out Frame0, Frame1, Frame2 -- matching both the + // index a person reads off the frame grid and what the image editor's Add + // Cell button has always produced. + for ( S32 candidate = seedIndex; ; ++candidate ) + { + dSprintf( nameBuffer, sizeof(nameBuffer), "Frame%d", candidate ); + + StringTableEntry name = StringTable->insert( nameBuffer ); + + bool taken = false; + for ( Vector::const_iterator nameItr = used.begin(); nameItr != used.end(); ++nameItr ) + { + if ( *nameItr == name ) + { + taken = true; + break; + } + } + + if ( !taken ) + return name; + } +} + +//------------------------------------------------------------------------------ + +void ImageAsset::assignMissingExplicitCellNames( void ) +{ + // What is spoken for. Collected first and added to as we go, so two unnamed + // cells cannot be handed the same name as each other. + Vector usedNames; + for( typeExplicitFrameAreaVector::iterator frameItr = mExplicitFrames.begin(); frameItr != mExplicitFrames.end(); ++frameItr ) + { + if ( frameItr->mRegionName != StringTable->EmptyString ) + usedNames.push_back( frameItr->mRegionName ); + } + + S32 cellIndex = 0; + for( typeExplicitFrameAreaVector::iterator frameItr = mExplicitFrames.begin(); frameItr != mExplicitFrames.end(); ++frameItr, ++cellIndex ) + { + // A cell that already has a name keeps it, always. Renaming one silently + // would break every animation that addresses it -- which is the whole + // failure this naming exists to prevent. + if ( frameItr->mRegionName != StringTable->EmptyString ) + continue; + + StringTableEntry name = nextAvailableCellName( usedNames, cellIndex ); + + frameItr->mRegionName = name; + usedNames.push_back( name ); + } +} + +//------------------------------------------------------------------------------ + void ImageAsset::calculateExplicitMode( void ) { // Debug Profiling. @@ -1387,11 +1466,20 @@ void ImageAsset::calculateExplicitMode( void ) // Clear default frame. mFrames.clear(); + // Before anything reads a name off one of them. + assignMissingExplicitCellNames(); + // Are any explicit frames set. if ( mExplicitFrames.size() == 0 ) { // No, so set full-frame as default. - FrameArea frameArea( 0, 0, imageWidth, imageHeight, texelWidthScale, texelHeightScale ); + // + // Named, like every other explicit frame. An animation addresses an + // explicit image by name, so "explicit mode means every frame has a name" + // has to hold for this synthesized one too -- otherwise a cell-less + // explicit image is the one shape of image that can be animated by + // neither name nor index. + FrameArea frameArea( 0, 0, imageWidth, imageHeight, texelWidthScale, texelHeightScale, StringTable->insert( "Frame0" ) ); mFrames.push_back( frameArea ); return; @@ -1697,7 +1785,14 @@ void ImageAsset::onTamlCustomWrite( TamlCustomNodes& customNodes ) // Call parent. Parent::onTamlCustomWrite( customNodes ); - if (mExplicitMode && mExplicitFrames.size() > 0) + // Whenever there are cells, and NOT only while explicit mode is on. + // + // The cells are authored data that outlives the mode -- copyAssetStateTo says + // so in as many words, and turning the mode back on is meant to bring them + // back. Gated on the mode, saving an image with explicit mode switched off + // deleted every cell in the file, which silently and permanently unresolved + // the names of every animation built on it. + if (mExplicitFrames.size() > 0) { // Add cell custom node. TamlCustomNode* pCustomCellNodes = customNodes.addNode( cellCustomNodeCellsName ); @@ -1760,9 +1855,16 @@ void ImageAsset::loadTamlExplicitCells(const TamlCustomNodes& customNodes) // Continue if we have explicit cells. if ( pCustomCellNodes != NULL ) { - // Set explicit mode. - mExplicitMode = true; - + // Cells imply the mode, but only for a file that did not say. + // + // Every image written before the cells outlived the mode has a Cells node + // and no ExplicitMode attribute, and turning the mode on for those is the + // only thing that keeps them loading. A file that states the mode is + // believed either way -- the attribute is read before this runs. + if ( !mExplicitModeStated ) + mExplicitMode = true; + + // Fetch children cell nodes. const TamlCustomNodeVector& cellNodes = pCustomCellNodes->getChildren(); @@ -1833,18 +1935,9 @@ void ImageAsset::loadTamlExplicitCells(const TamlCustomNodes& customNodes) } } - // Does the region have a name - if ( regionName == StringTable->EmptyString ) - { - // No, so warn and set it to the next index - Con::warnf( "ImageAsset::onTamlCustomRead() - Cell name of '%s' is invalid or was not set.", regionName ); - - U32 currentIndex = mExplicitFrames.size(); - Con::warnf( "- Setting to the next index in the frame list: '%i'", currentIndex ); - - dSscanf(regionName, "%i", currentIndex); - } - + // A cell with no name in the file is named by calculateExplicitMode, + // which runs on the way out of the load; see addExplicitCell. + // Is cell offset valid? if ( cellOffset.x < 0 || cellOffset.y < 0 ) { diff --git a/engine/source/2d/assets/ImageAsset.h b/engine/source/2d/assets/ImageAsset.h index 818712996..997934dfc 100755 --- a/engine/source/2d/assets/ImageAsset.h +++ b/engine/source/2d/assets/ImageAsset.h @@ -198,6 +198,13 @@ class ImageAsset : public AssetBase bool mForce16Bit; TextureFilterMode mLocalFilterMode; bool mExplicitMode; + + /// Whether anything has set ExplicitMode on this object, as opposed to it + /// merely still holding its default. Read once, by the TAML load, to tell a + /// file that says "explicit mode is off" from an older one that says nothing + /// -- the first must be believed, the second has to be inferred from the cells. + bool mExplicitModeStated; + bool mCellRowOrder; S32 mCellOffsetX; S32 mCellOffsetY; @@ -295,7 +302,13 @@ class ImageAsset : public AssetBase bool removeExplicitCell( const char* regionName ); bool setExplicitCell( const S32 cellIndex, const S32 cellOffsetX, const S32 cellOffsetY, const S32 cellWidth, const S32 cellHeight, const char* regionName ); inline S32 getExplicitCellCount( void ) const { return mExplicitFrames.size(); } - + + /// The first "Frame" at or after seedIndex that nobody in used is already + /// called. Static, and takes its whole world, because naming is arithmetic + /// over a set of strings and needs no texture -- which is the only way it can + /// be unit tested, since a test has no GL context to load a bitmap into. + static StringTableEntry nextAvailableCellName( const Vector& used, const S32 seedIndex ); + static TextureFilterMode getFilterModeEnum(const char* label); static const char* getFilterModeDescription( TextureFilterMode filterMode ); @@ -329,6 +342,14 @@ class ImageAsset : public AssetBase void calculateImage( void ); void calculateImplicitMode( void ); void calculateExplicitMode( void ); + + /// Give every unnamed explicit cell a name, so an animation can address it. + /// + /// Called from calculateExplicitMode, which is the one funnel every path that + /// changes cells ends in -- the TAML read pushes straight into mExplicitFrames + /// and never goes near addExplicitCell, so naming in the mutators would miss + /// every cell that arrived from a file. + void assignMissingExplicitCellNames( void ); void setTextureFilter( const TextureFilterMode filterMode ); void completeLayerChange(const bool doRedraw); @@ -361,8 +382,15 @@ class ImageAsset : public AssetBase static bool setFilterMode( void* obj, const char* data ); static bool writeFilterMode( void* obj, StringTableEntry pFieldName ) { return static_cast(obj)->getFilterMode() != FILTER_INVALID; } - static bool setExplicitMode( void* obj, const char* data ) { static_cast(obj)->setExplicitMode(dAtob(data)); return false; } - static bool writeExplicitMode(void* obj, StringTableEntry pFieldName) { ImageAsset* pImageAsset = static_cast(obj); return pImageAsset->getExplicitMode(); } + static bool setExplicitMode( void* obj, const char* data ) { ImageAsset* pImageAsset = static_cast(obj); pImageAsset->mExplicitModeStated = true; pImageAsset->setExplicitMode(dAtob(data)); return false; } + + // Written whenever there are cells, not only while the mode is on. + // + // The cells are now saved even with the mode off, and the read turns the mode + // on for any file it finds cells in -- an inference that has to stay, because + // files written before this said nothing else. So a file with cells and the + // mode off has to say so out loud, or it comes back on. + static bool writeExplicitMode(void* obj, StringTableEntry pFieldName) { ImageAsset* pImageAsset = static_cast(obj); return pImageAsset->getExplicitMode() || pImageAsset->getExplicitCellCount() > 0; } static bool setCellRowOrder( void* obj, const char* data ) { static_cast(obj)->setCellRowOrder(dAtob(data)); return false; } static bool writeCellRowOrder( void* obj, StringTableEntry pFieldName ) { ImageAsset* pImageAsset = static_cast(obj); return !pImageAsset->getExplicitMode() && !pImageAsset->getCellRowOrder(); } diff --git a/engine/source/2d/core/ImageFrameProviderCore.cc b/engine/source/2d/core/ImageFrameProviderCore.cc index 4216cb5b1..ef016296f 100644 --- a/engine/source/2d/core/ImageFrameProviderCore.cc +++ b/engine/source/2d/core/ImageFrameProviderCore.cc @@ -96,6 +96,16 @@ void ImageFrameProviderCore::resetState( void ) mAnimationPaused = false; mAnimationFinished = true; + // Initialized here, and nowhere else was. + // + // Neither the constructor nor clearAssets touched these two, and they are read + // by validRender() on the very first frame of every static sprite -- so an + // indeterminate true sent it to dereference an equally indeterminate frame + // name. Every path into this class goes through resetState, which is also what + // makes clearing them on clearAssets free. + mUsingNamedFrame = false; + mNamedImageFrame = StringTable->EmptyString; + clearAssets(); } diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl.cc b/engine/source/gui/editor/guiEditFrameStripCtrl.cc index 1f5d4fae3..a3168a0a4 100644 --- a/engine/source/gui/editor/guiEditFrameStripCtrl.cc +++ b/engine/source/gui/editor/guiEditFrameStripCtrl.cc @@ -334,6 +334,120 @@ bool GuiEditFrameStripCtrl::getCellBackColor(S32 index, bool isHovered, ColorI& return true; } +void GuiEditFrameStripCtrl::getCellLabel(S32 index, char* buffer, U32 bufferSize) +{ + const S32 frame = getFrameAt(index); + + // The name, when the sheet has one for this frame. + // + // This is the whole visible point of naming a cell: an animation built out of + // "block1 block2" reads as those cells in the palette and the timeline, not as + // two numbers the person then has to look up somewhere else. + if (isNamedMode() && frame >= 0) + { + StringTableEntry name = mImageAsset->getExplicitCellName(frame); + if (name != StringTable->EmptyString) + { + dStrncpy(buffer, name, bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + return; + } + } + + dSprintf(buffer, bufferSize, "%d", frame); +} + +const ColorI& GuiEditFrameStripCtrl::getCellLabelColor(S32 index, bool isHovered) +{ + return mProfile->getFontColor(isHovered ? HighlightState : NormalState); +} + +//----------------------------------------------------------------------------- + +S32 GuiEditFrameStripCtrl::clipLabel(GFont* font, const char* text, S32 maxWidth, char* out, U32 outSize) +{ + static const char* ellipsis = "..."; + static const U32 ellipsisLength = 3; + + dStrncpy(out, text, outSize - 1); + out[outSize - 1] = '\0'; + + S32 width = (S32)font->getStrWidth((const UTF8*)out); + if (width <= maxWidth) + { + return width; + } + + const S32 ellipsisWidth = (S32)font->getStrWidth((const UTF8*)ellipsis); + if (ellipsisWidth > maxWidth) + { + out[0] = '\0'; + return 0; + } + + // Walked DOWN from the full length rather than up from nothing. A label that + // only just fails to fit is by far the commonest case, and each step costs a + // measurement of the whole string. + U32 length = dStrlen(out); + if (length > outSize - ellipsisLength - 1) + { + length = outSize - ellipsisLength - 1; + } + + while (length > 0) + { + out[length] = '\0'; + width = (S32)font->getStrWidth((const UTF8*)out) + ellipsisWidth; + if (width <= maxWidth) + { + break; + } + --length; + } + + dStrcat(out, ellipsis); + return width; +} + +//----------------------------------------------------------------------------- + +const char* GuiEditFrameStripCtrl::tipForPoint(const Point2I& globalPoint) +{ + const S32 cell = cellAtGlobal(globalPoint); + if (cell < 0 || cell >= getCellCount()) + { + return NULL; + } + + // The unclipped name. A cell is 48 pixels and a region name is whatever + // somebody typed, so the label in the cell is frequently the front of a word + // and this is the only place the rest of it can be read. + static char tipBuffer[256]; + getCellLabel(cell, tipBuffer, sizeof(tipBuffer)); + + return (tipBuffer[0] != '\0') ? tipBuffer : NULL; +} + +bool GuiEditFrameStripCtrl::renderTooltip(Point2I& cursorPos, const char* tipText) +{ + // Per cell rather than per control, the way GuiEditorExplorerTree's gutter + // does it: the canvas re-calls this every frame once the pointer has settled + // and decides whether to by comparing whole controls, so the text can follow + // the cursor from cell to cell with nothing having to invalidate it. + if (tipText == NULL) + { + const char* cellTip = tipForPoint(cursorPos); + if (cellTip != NULL) + { + tipText = cellTip; + } + } + + return Parent::renderTooltip(cursorPos, tipText); +} + +//----------------------------------------------------------------------------- + void GuiEditFrameStripCtrl::renderCell(S32 index, const RectI& cellRect, bool isHovered) { const S32 frame = getFrameAt(index); @@ -363,15 +477,26 @@ void GuiEditFrameStripCtrl::renderCell(S32 index, const RectI& cellRect, bool is return; } - char buffer[16]; - dSprintf(buffer, sizeof(buffer), "%d", frame); + // Sized for a name, not for a number. A region name comes out of a TAML + // attribute and has no length limit, so the 16 that comfortably held "%d" + // would have truncated most of them. + char buffer[128]; + getCellLabel(index, buffer, sizeof(buffer)); - const S32 textWidth = font->getStrWidth((const UTF8*)buffer); const S32 textHeight = (S32)font->getHeight(); - // Only when the label fits inside the cell it belongs to. A number spilling - // over its neighbours is worse than no number. - if (textWidth > cellRect.extent.x || textHeight > cellRect.extent.y) + // Height is still all-or-nothing -- there is no clipping a line of text + // vertically that leaves it readable -- but the width is now fitted rather + // than refused, because a name long enough to overflow its cell is the normal + // case rather than the exception a frame number was. + if (textHeight > cellRect.extent.y) + { + return; + } + + char clipped[132]; + const S32 textWidth = clipLabel(font, buffer, cellRect.extent.x, clipped, sizeof(clipped)); + if (textWidth == 0) { return; } @@ -379,8 +504,8 @@ void GuiEditFrameStripCtrl::renderCell(S32 index, const RectI& cellRect, bool is const Point2I textPoint(cellRect.point.x + ((cellRect.extent.x - textWidth) / 2), (cellRect.point.y + cellRect.extent.y) - textHeight); - dglSetBitmapModulation(mProfile->getFontColor(isHovered ? HighlightState : NormalState)); - dglDrawText(font, textPoint, (const UTF8*)buffer); + dglSetBitmapModulation(getCellLabelColor(index, isHovered)); + dglDrawText(font, textPoint, (const UTF8*)clipped); dglClearBitmapModulation(); } diff --git a/engine/source/gui/editor/guiEditFrameStripCtrl.h b/engine/source/gui/editor/guiEditFrameStripCtrl.h index 6f26325d4..6cd1b9a06 100644 --- a/engine/source/gui/editor/guiEditFrameStripCtrl.h +++ b/engine/source/gui/editor/guiEditFrameStripCtrl.h @@ -115,10 +115,23 @@ class GuiEditFrameStripCtrl : public GuiControl /// is hovering over. Returns false to leave the cell's background alone. virtual bool getCellBackColor(S32 index, bool isHovered, ColorI& color); - /// One cell: the background, the frame, and its number. A subclass overrides + /// One cell: the background, the frame, and its label. A subclass overrides /// to put a selection or a playhead on top of that. virtual void renderCell(S32 index, const RectI& cellRect, bool isHovered); + /// What cell N is called: the explicit cell's name when the image has one for + /// it, the frame index otherwise. + /// + /// One override point, so the palette and the timeline can never label the + /// same cell differently -- which would make dragging a frame from one to the + /// other a guess. + virtual void getCellLabel(S32 index, char* buffer, U32 bufferSize); + + /// What ink to draw that label in. Hover aside, the base has one answer; the + /// timeline has another for a frame whose cell has gone, so that the outline + /// and the name it explains are plainly the same piece of news. + virtual const ColorI& getCellLabelColor(S32 index, bool isHovered); + /// Anything drawn over the whole grid rather than per cell -- an insertion /// caret, say. Called inside the content clip, after every cell. virtual void renderOverlay(const RectI& contentRect) { } @@ -163,6 +176,28 @@ class GuiEditFrameStripCtrl : public GuiControl /// row or column -- a pad of overshoot is a scroll bar for nothing. static Point2I getContentExtent(S32 cellCount, S32 columns, S32 cellSize, S32 cellPad); + /// The longest prefix of text that fits maxWidth, with an ellipsis when it had + /// to cut. Writes what will actually be drawn into out and returns its width, + /// or 0 when not even the ellipsis fits -- draw nothing then, because "..." on + /// its own over a cell says less than the art already does. + /// + /// Measures and draws the same string rather than reaching for getStrNWidth and + /// dglDrawTextN, whose counts are not the same unit: the first takes bytes, the + /// second takes UTF16 units, so the two disagree the moment a name is not + /// plain ASCII. + static S32 clipLabel(GFont* font, const char* text, S32 maxWidth, char* out, U32 outSize); + + /// Whether this control's image names its frames -- the same question, asked of + /// the same place, that AnimationAsset::getNamedCellsMode answers. Derived from + /// the image rather than set, so the two cannot disagree about the asset they + /// are both looking at. + inline bool isNamedMode() const { return mImageAsset.notNull() && mImageAsset->getExplicitMode(); } + + /// The full text for the cell under a point, which is what makes a clipped + /// label readable, or "" for none. + virtual const char* tipForPoint(const Point2I& globalPoint); + bool renderTooltip(Point2I& cursorPos, const char* tipText); + GuiEditFrameStripCtrl(); static void initPersistFields(); diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc index 88b2a47e4..8d7dad540 100644 --- a/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl.cc @@ -158,9 +158,104 @@ const char* GuiEditFrameTimelineCtrl::getFrames() return buffer; } +StringTableEntry GuiEditFrameTimelineCtrl::nameForFrame(S32 frame) const +{ + if (!isNamedMode() || frame < 0) + { + return StringTable->EmptyString; + } + + return mImageAsset->getExplicitCellName(frame); +} + +StringTableEntry GuiEditFrameTimelineCtrl::getNameAt(S32 index) const +{ + if (index < 0 || index >= mSlotNames.size()) + { + return StringTable->EmptyString; + } + + return mSlotNames[index]; +} + +bool GuiEditFrameTimelineCtrl::isSlotMissing(S32 index) const +{ + if (index < 0 || index >= mSlots.size()) + { + return false; + } + + // A name that resolved to no cell. Only ever true in named mode: a numbered + // frame out of range is clamped by the asset rather than lost, so it has art + // to draw and nothing to report. + return mSlots[index] < 0 && getNameAt(index) != StringTable->EmptyString; +} + +const char* GuiEditFrameTimelineCtrl::getNamedFrames() +{ + if (mSlotNames.size() == 0) + { + return ""; + } + + // Measured, because a region name has no length limit. + U32 bufferSize = 1; + for (S32 i = 0; i < mSlotNames.size(); ++i) + { + bufferSize += dStrlen(mSlotNames[i]) + 1; + } + + char* buffer = Con::getReturnBuffer(bufferSize); + U32 offset = 0; + + for (S32 i = 0; i < mSlotNames.size(); ++i) + { + offset += dSprintf(buffer + offset, bufferSize - offset, (i == 0) ? "%s" : " %s", mSlotNames[i]); + } + + return buffer; +} + +void GuiEditFrameTimelineCtrl::setNamedFrames(const char* names) +{ + mSlots.clear(); + mSlotNames.clear(); + + if (names != NULL) + { + // Commas as well as whitespace, because that is what a named list looks + // like coming out of a TAML field -- TypeStringTableEntryVector joins with + // commas, and AnimationAsset::setNamedAnimationFrames splits on both for + // exactly the same reason. + char* copy = dStrdup(names); + for (const char* token = dStrtok(copy, " \t\n,"); token != NULL; token = dStrtok(NULL, " \t\n,")) + { + StringTableEntry name = StringTable->insert(token); + + mSlotNames.push_back(name); + + // -1 when the image has no such cell. Kept as a slot rather than + // skipped: the list the user gets back has to be the list they had, or + // the next edit commits a deletion nobody asked for. + mSlots.push_back(mImageAsset.notNull() ? mImageAsset->getExplicitCellIndex(name) : -1); + } + dFree(copy); + } + + if (mSelected >= mSlots.size()) + { + mSelected = -1; + } + mCaret = -1; + + updateExtent(); + setUpdate(); +} + void GuiEditFrameTimelineCtrl::setFrames(const char* frames) { mSlots.clear(); + mSlotNames.clear(); if (frames != NULL) { @@ -169,7 +264,10 @@ void GuiEditFrameTimelineCtrl::setFrames(const char* frames) char* copy = dStrdup(frames); for (const char* token = dStrtok(copy, " \t\n"); token != NULL; token = dStrtok(NULL, " \t\n")) { - mSlots.push_back(dAtoi(token)); + const S32 frame = dAtoi(token); + + mSlots.push_back(frame); + mSlotNames.push_back(nameForFrame(frame)); } dFree(copy); } @@ -194,6 +292,9 @@ bool GuiEditFrameTimelineCtrl::insertFrame(S32 slot, S32 frame) mSlots.insert(slot); mSlots[slot] = frame; + mSlotNames.insert(slot); + mSlotNames[slot] = nameForFrame(frame); + // The picked slot moves along with everything else it was standing after. if (mSelected >= slot) { @@ -213,6 +314,7 @@ bool GuiEditFrameTimelineCtrl::removeSlot(S32 slot) } mSlots.erase(slot); + mSlotNames.erase(slot); if (mSelected == slot) { @@ -240,6 +342,7 @@ bool GuiEditFrameTimelineCtrl::moveSlot(S32 from, S32 to) // `to` is an insertion point measured against the list as it stands, so once // the slot is lifted out everything past it has shuffled down by one. const S32 frame = mSlots[from]; + StringTableEntry name = getNameAt(from); const S32 landing = (to > from) ? (to - 1) : to; if (landing == from) @@ -251,6 +354,12 @@ bool GuiEditFrameTimelineCtrl::moveSlot(S32 from, S32 to) mSlots.insert(landing); mSlots[landing] = frame; + // Carried, not re-derived. A slot whose cell has gone has no index to look a + // name up from, and moving it must not be what finally loses it. + mSlotNames.erase(from); + mSlotNames.insert(landing); + mSlotNames[landing] = name; + mSelected = landing; setUpdate(); @@ -404,10 +513,52 @@ bool GuiEditFrameTimelineCtrl::getCellBackColor(S32 index, bool isHovered, Color return Parent::getCellBackColor(index, isHovered, color); } +void GuiEditFrameTimelineCtrl::getCellLabel(S32 index, char* buffer, U32 bufferSize) +{ + // A missing slot answers with the name that failed rather than with its + // frame, which is -1 and tells the user nothing about what they have lost. + if (isSlotMissing(index)) + { + dStrncpy(buffer, getNameAt(index), bufferSize - 1); + buffer[bufferSize - 1] = '\0'; + return; + } + + Parent::getCellLabel(index, buffer, bufferSize); +} + +const ColorI& GuiEditFrameTimelineCtrl::getCellLabelColor(S32 index, bool isHovered) +{ + // The same ink as the outline around it. A red box with a white word in it + // reads as two things -- a broken cell, and a name -- when it is one: this + // name is why the cell is broken. + if (isSlotMissing(index)) + { + return mProfile->getFontColor(DisabledState); + } + + return Parent::getCellLabelColor(index, isHovered); +} + void GuiEditFrameTimelineCtrl::renderCell(S32 index, const RectI& cellRect, bool isHovered) { Parent::renderCell(index, cellRect, isHovered); + // A frame whose cell has gone. Nothing was drawn for the art -- the frame is + // -1, and renderImageAssetFrame declines an out-of-range one -- so what is + // left is an empty cell with a name under it, which reads as a cell that has + // simply not loaded yet. + // + // Outlined rather than filled, and in the one profile color this control does + // not otherwise use. All four fills are spoken for, and the disabled one in + // particular already means "let go now and this is thrown away" during a drag + // -- the opposite of what this slot needs to say, which is that it is still + // here and wants attention. + if (isSlotMissing(index)) + { + dglDrawRect(cellRect, mProfile->getFontColor(DisabledState)); + } + // Selected and playing are drawn differently on purpose -- a background and a // bar -- because they are frequently the same cell and the user needs to see // both. Scrubbing sets one and reads the other. @@ -426,9 +577,19 @@ void GuiEditFrameTimelineCtrl::renderOverlay(const RectI& contentRect) // A run of the same frame is a hold -- the only way the format has of making // one frame last longer. Joining the repeats across the gap says "this is one // pose held" rather than "somebody added the same frame twice by accident". + // + // Compared by name while the image names its cells, because two DIFFERENT + // missing frames are both slot -1, and joining those would draw a hold of a + // pose that does not exist out of two unrelated broken frames. + const bool namedMode = isNamedMode(); + for (S32 i = 1; i < mSlots.size(); ++i) { - if (mSlots[i] != mSlots[i - 1]) + const bool sameFrame = namedMode + ? (getNameAt(i) == getNameAt(i - 1)) + : (mSlots[i] == mSlots[i - 1]); + + if (!sameFrame) { continue; } diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl.h b/engine/source/gui/editor/guiEditFrameTimelineCtrl.h index fd115f99f..09b6556cd 100644 --- a/engine/source/gui/editor/guiEditFrameTimelineCtrl.h +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl.h @@ -57,6 +57,18 @@ class GuiEditFrameTimelineCtrl : public GuiEditFrameStripCtrl protected: Vector mSlots; + + /// What each slot is called, parallel to mSlots and ALWAYS the same size. + /// + /// mSlots stays the drawing truth -- where the art comes from -- and this is + /// the authoring truth while the image names its cells. Both are needed + /// because a name whose cell has been deleted resolves to no index at all, and + /// every such name resolves to the same -1: carried in index space alone, two + /// different missing frames would come back from a single edit as one frame, + /// and committing would quietly delete the other. Empty strings throughout + /// when the image numbers its frames instead. + Vector mSlotNames; + S32 mSelected; ///< -1 when nothing is picked. S32 mCaret; ///< Insertion point under a hovering drag, -1 when none. S32 mPlayhead; ///< The slot the preview is showing, -1 when unknown. @@ -81,6 +93,16 @@ class GuiEditFrameTimelineCtrl : public GuiEditFrameStripCtrl bool getCellBackColor(S32 index, bool isHovered, ColorI& color); void renderCell(S32 index, const RectI& cellRect, bool isHovered); void renderOverlay(const RectI& contentRect); + void getCellLabel(S32 index, char* buffer, U32 bufferSize); + const ColorI& getCellLabelColor(S32 index, bool isHovered); + + /// Whether slot N names a cell the image no longer has. The name is kept and + /// shown rather than dropped, because dropping it is a deletion the user did + /// not ask for -- and one they could not undo, having never seen it happen. + bool isSlotMissing(S32 index) const; + + /// What name a frame index should carry, given the image. "" in numbered mode. + StringTableEntry nameForFrame(S32 frame) const; /// Tell script the list changed. One call per completed gesture, never per /// drag tick, so script has exactly one place to commit from. @@ -137,6 +159,19 @@ class GuiEditFrameTimelineCtrl : public GuiEditFrameStripCtrl const char* getFrames(); void setFrames(const char* frames); + /// The same list, by cell name, for an image that names its cells. + /// + /// The rule the pair keeps, and the reason nothing else has to think about it: + /// setFrames takes indices and derives the names, setNamedFrames takes names + /// and derives the indices, and BOTH always fill both vectors. So whichever + /// way a list arrives -- a palette click, a drop, a typed range, a load -- it + /// can be read back either way. + const char* getNamedFrames(); + void setNamedFrames(const char* names); + + /// What slot N is called, or "" when the image does not name its cells. + StringTableEntry getNameAt(S32 index) const; + bool insertFrame(S32 slot, S32 frame); bool removeSlot(S32 slot); bool moveSlot(S32 from, S32 to); diff --git a/engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h b/engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h index 4cf83bea5..f5723db56 100644 --- a/engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h +++ b/engine/source/gui/editor/guiEditFrameTimelineCtrl_ScriptBinding.h @@ -44,6 +44,39 @@ ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, setFrames, ConsoleVoid, 3, 3, (f object->setFrames(argv[2]); } +/*! Gets the frames the timeline holds, by cell name, in order. + + For an image in explicit mode, whose cells have names. A frame whose cell has + since been deleted still reports its name -- the timeline keeps it, and draws + it as an empty outlined slot, rather than dropping a frame the user never + asked to lose. + @return The cell names, space separated, or an empty string. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, getNamedFrames, ConsoleString, 2, 2, ()) +{ + return object->getNamedFrames(); +} + +/*! Replaces the whole list, by cell name. + Separators are space, tab, newline and comma, matching what + AnimationAsset::setNamedAnimationFrames accepts. + @param names The cell names, space separated. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, setNamedFrames, ConsoleVoid, 3, 3, (names)) +{ + object->setNamedFrames(argv[2]); +} + +/*! Gets the cell name in a slot. + @param slot The slot index. + @return The cell name, or an empty string when the image does not name its cells. +*/ +ConsoleMethodWithDocs(GuiEditFrameTimelineCtrl, getNameAt, ConsoleString, 3, 3, (slot)) +{ + return object->getNameAt(dAtoi(argv[2])); +} + /*! Puts a frame into the list at a slot. @param slot Where it goes, from 0 to the count inclusive. @param frame The image frame index. diff --git a/engine/source/testing/tests/animationFrameConversionTests.cc b/engine/source/testing/tests/animationFrameConversionTests.cc new file mode 100644 index 000000000..fdcf872a1 --- /dev/null +++ b/engine/source/testing/tests/animationFrameConversionTests.cc @@ -0,0 +1,302 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +// ImageAsset FIRST. AnimationAsset.h only forward-declares it, and holds an +// AssetPtr -- whose destructor asks the pointee for its class name, +// which needs the complete type. Every .cc that includes AnimationAsset.h has so +// far happened to include ImageAsset.h as well; this is the first that would not. +#ifndef _IMAGE_ASSET_H_ +#include "2d/assets/ImageAsset.h" +#endif + +#ifndef _ANIMATION_ASSET_H_ +#include "2d/assets/AnimationAsset.h" +#endif + +//----------------------------------------------------------------------------- +// Moving an animation's frame list between index space and name space. +// +// An image in explicit mode cuts itself into named cells, and an animation on it +// lists those names; an image cut into a grid has no names, and the animation +// lists indices. Switching an image between the two modes therefore switches +// every animation on it, and these functions are what stops that costing the +// animation its frames. +// +// Tested through the statics rather than through a real pair of assets, and that +// is not a shortcut: ImageAsset::addExplicitCell validates every cell against +// getImageWidth()/getImageHeight() and so refuses to add one until a bitmap is +// loaded, which needs a file and a GL context this suite does not have. So the +// translation takes a table of cell names -- entry N is what cell N is called -- +// and the caller builds that table from the image. What is left here is the +// arithmetic, which is the part that can be wrong. +// +// Throughout: a four cell sheet called head, body, tail, wing. +//----------------------------------------------------------------------------- + +static void buildCellNames( Vector& cellNames ) +{ + cellNames.clear(); + cellNames.push_back( StringTable->insert( "head" ) ); + cellNames.push_back( StringTable->insert( "body" ) ); + cellNames.push_back( StringTable->insert( "tail" ) ); + cellNames.push_back( StringTable->insert( "wing" ) ); +} + +static void buildIndices( Vector& indices, const S32 a = -1, const S32 b = -1, const S32 c = -1 ) +{ + indices.clear(); + if ( a >= 0 ) indices.push_back( a ); + if ( b >= 0 ) indices.push_back( b ); + if ( c >= 0 ) indices.push_back( c ); +} + +//----------------------------------------------------------------------------- +// The round trip. Everything else here is a way for this to go wrong. +//----------------------------------------------------------------------------- + +TEST( AnimationFrameConversionTests, IndicesBecomeNames ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + Vector indices; + buildIndices( indices, 0, 2, 3 ); + + Vector names; + AnimationAsset::translateFrames( indices, cellNames, names ); + + ASSERT_EQ( names.size(), 3 ); + ASSERT_EQ( names[0], StringTable->insert( "head" ) ); + ASSERT_EQ( names[1], StringTable->insert( "tail" ) ); + ASSERT_EQ( names[2], StringTable->insert( "wing" ) ); + + SUCCEED(); +} + +TEST( AnimationFrameConversionTests, NamesBecomeIndices ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + Vector names; + names.push_back( StringTable->insert( "head" ) ); + names.push_back( StringTable->insert( "tail" ) ); + names.push_back( StringTable->insert( "wing" ) ); + + Vector indices; + AnimationAsset::translateFrames( names, cellNames, indices ); + + ASSERT_EQ( indices.size(), 3 ); + ASSERT_EQ( indices[0], 0 ); + ASSERT_EQ( indices[1], 2 ); + ASSERT_EQ( indices[2], 3 ); + + SUCCEED(); +} + +TEST( AnimationFrameConversionTests, ARoundTripChangesNothing ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + Vector indices; + buildIndices( indices, 3, 1, 0 ); + + Vector names; + AnimationAsset::translateFrames( indices, cellNames, names ); + + Vector backAgain; + AnimationAsset::translateFrames( names, cellNames, backAgain ); + + ASSERT_EQ( backAgain.size(), 3 ); + ASSERT_EQ( backAgain[0], 3 ); + ASSERT_EQ( backAgain[1], 1 ); + ASSERT_EQ( backAgain[2], 0 ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// A hold. One frame repeated is the only way the format has of making a pose +// last longer -- there is no per-frame duration -- so a translation that +// helpfully removed the duplicate would silently change the timing. +//----------------------------------------------------------------------------- + +TEST( AnimationFrameConversionTests, ARepeatedFrameStaysRepeated ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + Vector indices; + buildIndices( indices, 1, 1, 1 ); + + Vector names; + AnimationAsset::translateFrames( indices, cellNames, names ); + + ASSERT_EQ( names.size(), 3 ) + << "A hold is three slots holding the same frame. Collapsing it to one " + "would make the pose a third as long."; + ASSERT_EQ( names[0], names[2] ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// What does not resolve. Skipped, in both directions. +// +// There is no honest name for an index with no cell behind it, and inventing one +// risks colliding with a cell somebody names that later -- at which point the +// animation would silently start playing real art in place of a hole. +//----------------------------------------------------------------------------- + +TEST( AnimationFrameConversionTests, AnIndexPastTheCellsIsDropped ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + Vector indices; + buildIndices( indices, 0, 9, 1 ); + + Vector names; + AnimationAsset::translateFrames( indices, cellNames, names ); + + ASSERT_EQ( names.size(), 2 ); + ASSERT_EQ( names[0], StringTable->insert( "head" ) ); + ASSERT_EQ( names[1], StringTable->insert( "body" ) ); + + SUCCEED(); +} + +TEST( AnimationFrameConversionTests, ANegativeIndexIsDropped ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + // -1 is what a name that resolved to nothing looks like once it has been + // through the timeline, so it can genuinely arrive here. + Vector indices; + indices.push_back( -1 ); + indices.push_back( 2 ); + + Vector names; + AnimationAsset::translateFrames( indices, cellNames, names ); + + ASSERT_EQ( names.size(), 1 ); + ASSERT_EQ( names[0], StringTable->insert( "tail" ) ); + + SUCCEED(); +} + +TEST( AnimationFrameConversionTests, ANameNoCellAnswersToIsDropped ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + Vector names; + names.push_back( StringTable->insert( "head" ) ); + names.push_back( StringTable->insert( "elbow" ) ); + names.push_back( StringTable->insert( "tail" ) ); + + Vector indices; + AnimationAsset::translateFrames( names, cellNames, indices ); + + ASSERT_EQ( indices.size(), 2 ); + ASSERT_EQ( indices[0], 0 ); + ASSERT_EQ( indices[1], 2 ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// An unnamed cell cannot be addressed by name, so an index pointing at one has +// nothing to become. This is the case the auto-naming in ImageAsset exists to +// make impossible; the translation still has to survive meeting it. +//----------------------------------------------------------------------------- + +TEST( AnimationFrameConversionTests, AnIndexOnAnUnnamedCellIsDropped ) +{ + Vector cellNames; + cellNames.push_back( StringTable->insert( "head" ) ); + cellNames.push_back( StringTable->EmptyString ); + cellNames.push_back( StringTable->insert( "tail" ) ); + + Vector indices; + buildIndices( indices, 0, 1, 2 ); + + Vector names; + AnimationAsset::translateFrames( indices, cellNames, names ); + + ASSERT_EQ( names.size(), 2 ); + ASSERT_EQ( names[0], StringTable->insert( "head" ) ); + ASSERT_EQ( names[1], StringTable->insert( "tail" ) ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// The empty cases, both of which are reached on a perfectly ordinary edit: an +// animation whose timeline has just been emptied, and an image with no cells cut +// yet. +//----------------------------------------------------------------------------- + +TEST( AnimationFrameConversionTests, AnEmptyListTranslatesToAnEmptyList ) +{ + Vector cellNames; + buildCellNames( cellNames ); + + Vector indices; + Vector names; + + // Seeded with something, so that clearing it is what is being observed rather + // than it merely never having been filled. + names.push_back( StringTable->insert( "stale" ) ); + + AnimationAsset::translateFrames( indices, cellNames, names ); + + ASSERT_EQ( names.size(), 0 ); + + SUCCEED(); +} + +TEST( AnimationFrameConversionTests, NoCellsMeansNothingResolves ) +{ + Vector cellNames; + + Vector indices; + buildIndices( indices, 0, 1, 2 ); + + Vector names; + AnimationAsset::translateFrames( indices, cellNames, names ); + + ASSERT_EQ( names.size(), 0 ); + + SUCCEED(); +} + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/assetStateCopyTests.cc b/engine/source/testing/tests/assetStateCopyTests.cc index a2608665e..486da6f32 100644 --- a/engine/source/testing/tests/assetStateCopyTests.cc +++ b/engine/source/testing/tests/assetStateCopyTests.cc @@ -167,20 +167,54 @@ TEST( AssetStateCopyTests, ImageAssetCarriesImageLayers ) // cells survive a copy even with ExplicitMode off, because the cells outlive // being switched out of explicit mode and the old copy dropped them. +// No mode is set here, and none can be: named cells mode is not a field any more, +// it is read from the image asset, and a unit test cannot give an animation a real +// image -- that needs a bitmap and a GL context. So this asserts the thing the +// copy is actually responsible for, which is that the named list travels. +// +// The old version of this test set the mode on the source first, which is what +// caught the original defect: the copy asked the TARGET which mode it was in, and +// the target was still numbered, so the named frames were silently not copied. +// That question no longer has a wrong answer to give, because both lists are +// copied unconditionally. The mode's own coverage went with the field; see the +// note beside assertEveryFieldCopies below. TEST( AssetStateCopyTests, AnimationAssetCarriesNamedFrames ) { AnimationAsset* source = newScratchAsset(); - source->setNamedCellsMode( true ); source->setNamedAnimationFrames( "head body tail" ); AnimationAsset* target = newScratchAsset(); source->copyTo( target ); - ASSERT_TRUE( target->getNamedCellsMode() ); - ASSERT_EQ( target->getSpecifiedNamedAnimationFrames().size(), 3 ) - << "The copy asked the TARGET whether it was in named-cells mode, and the " - "target was still in the default numbered mode, so the named frames " - "were never copied."; + ASSERT_EQ( target->getSpecifiedNamedAnimationFrames().size(), 3 ); + + // Compared by interned identity, not by spelling. StringTable->insert folds + // case by default, so it hands back whichever capitalisation reached it first + // -- "head" came back as "HEAD" here, from something else entirely that had + // already interned that word. Frame names are matched the same way everywhere + // else, so this is the comparison that means what the caller thinks it means. + ASSERT_EQ( target->getSpecifiedNamedAnimationFrames()[0], StringTable->insert( "head" ) ); + ASSERT_EQ( target->getSpecifiedNamedAnimationFrames()[2], StringTable->insert( "tail" ) ); + + source->deleteObject(); + target->deleteObject(); +} + +// Both lists survive a copy, not merely the one in use. An image can be switched +// between explicit and cell mode, which switches every animation on it between +// name and index space, and the list that is not in use is what makes that +// reversible -- a copy that dropped it would lose the frames on the way back. +TEST( AssetStateCopyTests, AnimationAssetCarriesBothFrameLists ) +{ + AnimationAsset* source = newScratchAsset(); + source->setAnimationFrames( "0 1 2" ); + source->setNamedAnimationFrames( "head body tail" ); + + AnimationAsset* target = newScratchAsset(); + source->copyTo( target ); + + ASSERT_EQ( target->getSpecifiedAnimationFrames().size(), 3 ); + ASSERT_EQ( target->getSpecifiedNamedAnimationFrames().size(), 3 ); source->deleteObject(); target->deleteObject(); @@ -448,6 +482,11 @@ TEST( AssetStateCopyTests, EveryImageAssetFieldCopies ) assertEveryFieldCopies( "ImageAsset" ); } +// This no longer covers named cells mode, and nothing else does either. That is +// on purpose: the mode stopped being a field, so there is nothing for a copy to +// carry -- it is read from whichever image the copy points at, which makes it +// right by construction. What a copy still has to carry is both frame lists, and +// AnimationAssetCarriesBothFrameLists above is what asserts that. TEST( AssetStateCopyTests, EveryAnimationAssetFieldCopies ) { assertEveryFieldCopies( "AnimationAsset" ); diff --git a/engine/source/testing/tests/imageAssetCellNameTests.cc b/engine/source/testing/tests/imageAssetCellNameTests.cc new file mode 100644 index 000000000..66c397aa0 --- /dev/null +++ b/engine/source/testing/tests/imageAssetCellNameTests.cc @@ -0,0 +1,148 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2013 GarageGames, LLC +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +#ifndef _IMAGE_ASSET_H_ +#include "2d/assets/ImageAsset.h" +#endif + +//----------------------------------------------------------------------------- +// Naming an explicit cell that arrived without a name. +// +// An animation on an explicit image addresses its frames by cell name, so a cell +// with no name is a frame no animation can reach. The engine names those on the +// way through calculateExplicitMode, which is the one funnel every path that +// changes cells ends in -- including the TAML read, which pushes straight into +// the vector without going near addExplicitCell. +// +// Tested through the static, because the naming has to be right in a place a +// unit test cannot reach: building a real cell needs a bitmap, and a bitmap needs +// a GL context this suite has none of. What the static needs is the set of names +// already spoken for and the index of the cell being named, which is exactly what +// the caller has. +// +// The seed is the cell's OWN index rather than zero, so a sheet whose cells have +// never been named comes out Frame0, Frame1, Frame2 -- matching the number the +// person reads off the frame grid beside it. +//----------------------------------------------------------------------------- + +TEST( ImageAssetCellNameTests, TheFirstCellOfAnUnnamedSheetIsFrameZero ) +{ + Vector used; + + ASSERT_EQ( ImageAsset::nextAvailableCellName( used, 0 ), StringTable->insert( "Frame0" ) ); + + SUCCEED(); +} + +TEST( ImageAssetCellNameTests, ACellIsNamedForItsOwnIndex ) +{ + Vector used; + + ASSERT_EQ( ImageAsset::nextAvailableCellName( used, 2 ), StringTable->insert( "Frame2" ) ) + << "Seeded at zero instead, the third cell of an unnamed sheet would be " + "called Frame0 and the grid beside it would say 2."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// The collisions, which are not hypothetical: delete a cell from the middle of a +// named sheet and every index after it now belongs to a cell called something +// else. +//----------------------------------------------------------------------------- + +TEST( ImageAssetCellNameTests, ATakenNameIsWalkedPast ) +{ + Vector used; + used.push_back( StringTable->insert( "Frame2" ) ); + + ASSERT_EQ( ImageAsset::nextAvailableCellName( used, 2 ), StringTable->insert( "Frame3" ) ); + + SUCCEED(); +} + +TEST( ImageAssetCellNameTests, ARunOfTakenNamesIsWalkedPast ) +{ + Vector used; + used.push_back( StringTable->insert( "Frame2" ) ); + used.push_back( StringTable->insert( "Frame3" ) ); + used.push_back( StringTable->insert( "Frame4" ) ); + + ASSERT_EQ( ImageAsset::nextAvailableCellName( used, 2 ), StringTable->insert( "Frame5" ) ); + + SUCCEED(); +} + +TEST( ImageAssetCellNameTests, ATakenNameBelowTheSeedIsNotWalkedPast ) +{ + // Frame0 and Frame1 being taken says nothing about Frame2. Searching from + // zero every time would step past them for no reason and number the cells + // further and further from their own indices. + Vector used; + used.push_back( StringTable->insert( "Frame0" ) ); + used.push_back( StringTable->insert( "Frame1" ) ); + + ASSERT_EQ( ImageAsset::nextAvailableCellName( used, 2 ), StringTable->insert( "Frame2" ) ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// A name that is not of the form the search generates cannot collide with one +// that is, so it must not be allowed to push the search along. +//----------------------------------------------------------------------------- + +TEST( ImageAssetCellNameTests, AnUnrelatedNameIsIgnored ) +{ + Vector used; + used.push_back( StringTable->insert( "head" ) ); + used.push_back( StringTable->insert( "body" ) ); + + ASSERT_EQ( ImageAsset::nextAvailableCellName( used, 0 ), StringTable->insert( "Frame0" ) ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Case. StringTable folds it by default, so "FRAME2" and "Frame2" are the same +// entry -- which means a cell somebody typed in capitals still blocks the name, +// as it must: getExplicitCellIndex would find either one for the other. +//----------------------------------------------------------------------------- + +TEST( ImageAssetCellNameTests, ATakenNameCollidesRegardlessOfCase ) +{ + Vector used; + used.push_back( StringTable->insert( "FRAME0" ) ); + + ASSERT_EQ( ImageAsset::nextAvailableCellName( used, 0 ), StringTable->insert( "Frame1" ) ); + + SUCCEED(); +} + +#endif // TORQUE_SHIPPING diff --git a/tests/shots/assetAnimation.cs b/tests/shots/assetAnimation.cs index d1ec8dce5..425125af5 100644 --- a/tests/shots/assetAnimation.cs +++ b/tests/shots/assetAnimation.cs @@ -1,4 +1,4 @@ -// Visual harness for the Asset Manager's animation editor. Four shots: +// Visual harness for the Asset Manager's animation editor. Seven shots: // // 0 the stage as it opens -- preview and transport left, the image's frames // right, the timeline along the bottom of both @@ -6,10 +6,13 @@ // 2 the insertion caret mid-hover, which is the promise a drop then keeps // 3 the frame range dialog, showing a ping-pong read back before it is applied // 4 an image asset selected, where the split must collapse back to one preview +// 5 a NAMED animation, where every cell is labelled with its region name +// 6 the same with a cell deleted, so one frame has a name and no art // // What only a picture can settle: whether the three sections balance at the size // the editor opens at, whether a run of repeated frames reads as one held pose -// rather than as a mistake, and whether the caret is findable against the art. +// rather than as a mistake, whether the caret is findable against the art, and +// whether a cell name is legible at 48 pixels over the frame it labels. // tests/smoke/assetAnimationTimeline.cs does the checkable half. // // Run: tests/run.ps1 -Shots assetAnimation ; look in shots/. @@ -27,6 +30,10 @@ $aaAnimId = "ToyAssets:TD_Barbarian_Death"; $aaImageId = "ToyAssets:TD_Barbarian_CompSprite"; +// The named pair: a 2x2 sheet cut explicitly into block1..block4. +$aaNamedAnimId = "ToyAssets:1234Animation"; +$aaNamedImageId = "ToyAssets:1234"; + testExec("editor/main.cs"); schedule(2500, 0, "aaOpenProject"); @@ -170,6 +177,47 @@ function aaGrabCollapsed() { aaGrab("assetAnimation4"); + // The named-cells case: 1234Animation lists four cells of an explicitly cut + // sheet by name, so every cell in both grids is labelled block1..block4 rather + // than 0..3. Only a picture can settle whether a word reads at 48 pixels, where + // the ellipsis falls, and whether a name is legible against the art under it. + AssetAdmin.Dictionary["AnimationAsset"].setExpanded(true); + AssetAdmin.libWindow.relayout(); + + %namedTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($aaNamedAnimId); + if(isObject(%namedTile)) + { + %namedTile.onClick(); + } + + schedule(1200, 0, "aaGrabNamed"); +} + +function aaGrabNamed() +{ + aaGrab("assetAnimation5"); + + // And a frame whose cell has gone, which draws as an outlined empty slot in + // the theme's error color with the name it could not find still under it. + $aaNamedImage = AssetDatabase.acquireAsset($aaNamedImageId); + if(isObject($aaNamedImage)) + { + $aaNamedImage.removeExplicitCell(1); + } + + schedule(700, 0, "aaGrabMissing"); +} + +function aaGrabMissing() +{ + aaGrab("assetAnimation6"); + + if(isObject($aaNamedImage)) + { + $aaNamedImage.insertExplicitCell(1, 32, 0, 32, 32, "block2"); + AssetDatabase.releaseAsset($aaNamedImageId); + } + echo("SHOTS DONE"); schedule(300, 0, "quit"); } diff --git a/tests/smoke/animationFrameValidation.cs b/tests/smoke/animationFrameValidation.cs index 7ec2de012..83942e4fe 100644 --- a/tests/smoke/animationFrameValidation.cs +++ b/tests/smoke/animationFrameValidation.cs @@ -129,12 +129,189 @@ function afvStep3() AssetDatabase.releaseAsset($afvAnimId); AssetDatabase.releaseAsset($afvImageId); + schedule(300, 0, "afvStep4"); +} + +//----------------------------------------------------------------------------- +// Named cells. A different fixture: the 1234 sheet is cut explicitly into four +// cells called block1..block4, and 1234Animation lists those names. +// +// The single most valuable assertion in this file is the first one below. +// getNamedAnimationFrames formatted a StringTableEntry -- a const char* -- through +// %d, so it returned a row of pointer values, and nothing that asked an animation +// for its named frames could recover them. That one character is why the Asset +// Manager could not edit a named animation at all. +//----------------------------------------------------------------------------- + +$afvNamedAnimId = "ToyAssets:1234Animation"; +$afvNamedImageId = "ToyAssets:1234"; + +function afvStep4() +{ + $afvNamedAnim = AssetDatabase.acquireAsset($afvNamedAnimId); + $afvNamedImage = AssetDatabase.acquireAsset($afvNamedImageId); + + afvCheck("named animation acquired", isObject($afvNamedAnim)); + afvCheck("explicit image acquired", isObject($afvNamedImage)); + + afvCheck("the image is in explicit mode", $afvNamedImage.getExplicitMode()); + afvCheck("it has four explicit cells", $afvNamedImage.getExplicitCellCount() == 4); + + // Derived, not stored. There is no NamedCellsMode field any more -- the answer + // is read from the image every time it is asked for. + afvCheck("the animation reports named cells mode from its image", + $afvNamedAnim.getNamedCellsMode()); + + %named = trim($afvNamedAnim.getNamedAnimationFrames()); + + afvCheck("named frames read back as names, not as pointers (" @ %named @ ")", + %named $= "block1 block2 block3 block4"); + afvCheck("there are four of them", $afvNamedAnim.getNamedAnimationFrameCount() == 4); + afvCheck("getFrameCount answers without caring which space it is in", + $afvNamedAnim.getFrameCount() == 4); + afvCheck("nothing is missing to begin with", trim($afvNamedAnim.getMissingFrames()) $= ""); + + schedule(300, 0, "afvStep5"); +} + +//----------------------------------------------------------------------------- +// Take a cell away. The name is KEPT rather than dropped -- dropping it is a +// deletion the user never asked for and could not have seen happen. +//----------------------------------------------------------------------------- + +function afvStep5() +{ + $afvNamedImage.removeExplicitCell(2); + + afvCheck("the image is down to three cells", $afvNamedImage.getExplicitCellCount() == 3); + afvCheck("the animation still specifies all four frames", + trim($afvNamedAnim.getNamedAnimationFrames()) $= "block1 block2 block3 block4"); + afvCheck("and names the one that no longer resolves (" @ trim($afvNamedAnim.getMissingFrames()) @ ")", + trim($afvNamedAnim.getMissingFrames()) $= "block3"); + + schedule(300, 0, "afvStep6"); +} + +//----------------------------------------------------------------------------- +// Put it back. Like the numeric case above, this is a live derivation. +//----------------------------------------------------------------------------- + +function afvStep6() +{ + $afvNamedImage.insertExplicitCell(2, 0, 32, 32, 32, "block3"); + + afvCheck("the cell is back", $afvNamedImage.getExplicitCellCount() == 4); + afvCheck("and it went back in the right place", + $afvNamedImage.getExplicitCellName(2) $= "block3"); + afvCheck("nothing is missing any more", trim($afvNamedAnim.getMissingFrames()) $= ""); + + schedule(300, 0, "afvStep7"); +} + +//----------------------------------------------------------------------------- +// A cell added with no name is named for us, so that "explicit mode means every +// frame can be addressed by name" holds without the user having to maintain it. +//----------------------------------------------------------------------------- + +function afvStep7() +{ + $afvNamedImage.addExplicitCell(0, 0, 32, 32, ""); + + afvCheck("the unnamed cell was named for its own index (" @ $afvNamedImage.getExplicitCellName(4) @ ")", + $afvNamedImage.getExplicitCellName(4) $= "Frame4"); + + // Now take the name the NEXT blank cell would want -- cell 5 is called Frame6, + // which is what cell 6 would otherwise be named -- and add that blank. The + // search has to walk past it. Not hypothetical: deleting a cell from the + // middle renumbers every one after it, so a sheet arrives in this state on its + // own. + $afvNamedImage.addExplicitCell(0, 0, 32, 32, "Frame6"); + $afvNamedImage.addExplicitCell(0, 0, 32, 32, ""); + + afvCheck("a taken name is walked past (" @ $afvNamedImage.getExplicitCellName(6) @ ")", + $afvNamedImage.getExplicitCellName(6) $= "Frame7"); + afvCheck("and the cell that took it keeps it", + $afvNamedImage.getExplicitCellName(5) $= "Frame6"); + + schedule(300, 0, "afvStep8"); +} + +//----------------------------------------------------------------------------- +// Turn explicit mode off and on. The animation moves between name space and +// index space and keeps its frames both ways, which is what makes re-cutting a +// sheet a decision rather than a commitment. +//----------------------------------------------------------------------------- + +function afvStep8() +{ + $afvNamedImage.setExplicitMode(false); + + afvCheck("the animation is no longer in named cells mode", + !$afvNamedAnim.getNamedCellsMode()); + afvCheck("its frames converted to indices (" @ trim($afvNamedAnim.getAnimationFrames()) @ ")", + trim($afvNamedAnim.getAnimationFrames()) $= "0 1 2 3"); + afvCheck("and getMissingFrames says nothing about a numbered animation", + trim($afvNamedAnim.getMissingFrames()) $= ""); + + // The cells have to SURVIVE the mode being off, in memory and in the file. + // + // The file used to gate its Cells node on explicit mode, so saving an image + // with the mode off deleted every cell -- and with them the only thing that + // could ever resolve the animation's names again. Which makes the state of + // this file the whole reason the mode is reversible at all. + afvCheck("the cells are still there with the mode off", + $afvNamedImage.getExplicitCellCount() == 7); + + AssetDatabase.saveAsset($afvNamedImageId); + + %text = afvReadFile(AssetDatabase.getAssetFilePath($afvNamedImageId)); + + afvCheck("the saved file kept its cells", strstr(%text, "block1") != -1); + afvCheck("and says out loud that explicit mode is off", + strstr(%text, "ExplicitMode=\"0\"") != -1 || strstr(%text, "ExplicitMode=\"false\"") != -1); + + schedule(300, 0, "afvStep9"); +} + +function afvStep9() +{ + $afvNamedImage.setExplicitMode(true); + + afvCheck("the animation is named again", $afvNamedAnim.getNamedCellsMode()); + + // The names come back unchanged rather than being rebuilt from the indices, + // because the named list was never cleared -- which is the whole reason both + // lists are kept. + afvCheck("and its names are the ones it started with (" @ trim($afvNamedAnim.getNamedAnimationFrames()) @ ")", + trim($afvNamedAnim.getNamedAnimationFrames()) $= "block1 block2 block3 block4"); + + AssetDatabase.releaseAsset($afvNamedAnimId); + AssetDatabase.releaseAsset($afvNamedImageId); + echo("AFV DONE"); schedule(200, 0, "quit"); } //----------------------------------------------------------------------------- +function afvReadFile(%path) +{ + %file = new FileObject(); + %text = ""; + + if(%file.openForRead(%path)) + { + while(!%file.isEOF()) + { + %text = %text @ %file.readLine() @ " "; + } + %file.close(); + } + %file.delete(); + + return %text; +} + function afvAllWords(%list, %value) { %count = getWordCount(%list); diff --git a/tests/smoke/assetAnimationInspector.cs b/tests/smoke/assetAnimationInspector.cs index c8b93abde..2d08f603f 100644 --- a/tests/smoke/assetAnimationInspector.cs +++ b/tests/smoke/assetAnimationInspector.cs @@ -113,7 +113,14 @@ function ainStep3() // timeline; a box of numbers beside it would be a second source of truth. ainCheck("AnimationFrames is NOT offered as a row", !isObject($ainPane.row["AnimationFrames"])); ainCheck("neither is NamedAnimationFrames", !isObject($ainPane.row["NamedAnimationFrames"])); + + // NamedCellsMode is not a field at all now -- it is read from the image, where + // explicit mode means named cells -- so a row offering to set it would be a + // second answer that could disagree with the image's. ainCheck("nor NamedCellsMode", !isObject($ainPane.row["NamedCellsMode"])); + ainCheck("and the asset has no such field to offer", + $ainPane.target.getFieldType("NamedCellsMode") $= ""); + ainCheck("nor AssetInternal", !isObject($ainPane.row["AssetInternal"])); // The three blocks and what is in them. @@ -240,6 +247,84 @@ function ainStep7() ainCheck("exactly one pane is on show at a time", !$ainInspector.insScroller.isVisible()); ainCheck("the animation pane was unbound", !isObject($ainPane.target)); + schedule(300, 0, "ainStep8"); +} + +//----------------------------------------------------------------------------- +// A named animation gets this pane too. +// +// It used to get the stock inspector instead, because the engine's named frame +// API did not survive its own file. Both reasons are fixed, so the fallback is +// gone -- and this asserts that it is, because a silent return to it would look +// exactly like the pane simply not having been built. +//----------------------------------------------------------------------------- + +$ainNamedAnimId = "ToyAssets:1234Animation"; + +function ainStep8() +{ + %tile = AssetAdmin.Dictionary["AnimationAsset"].getButton($ainNamedAnimId); + ainCheck("the named animation tile is in the library", isObject(%tile)); + + %tile.onClick(); + + schedule(500, 0, "ainStep9"); +} + +function ainStep9() +{ + ainCheck("a named animation gets the animation pane, not the stock inspector", + $ainInspector.paneScroller["Animation"].isVisible()); + ainCheck("the generic inspector stayed down", !$ainInspector.insScroller.isVisible()); + + %asset = $ainPane.target; + ainCheck("the pane is bound to the named animation", isObject(%asset)); + ainCheck("which is in named cells mode", %asset.getNamedCellsMode()); + + // The info line counts frames through getFrameCount, which answers in either + // space. Through getAnimationFrameCount it would have said "-1 frames," -- + // that one refuses to answer for a named animation. + %line = $ainPane.infoLabel.getText(); + ainCheck("the info line counts the four frames (" @ %line @ ")", + strstr(%line, "4 frames") != -1); + + ainCheck("no warning for a healthy named animation", !$ainPane.warningLabel.isVisible()); + + schedule(300, 0, "ainStep10"); +} + +//----------------------------------------------------------------------------- +// And the warning that only a named animation can raise. +//----------------------------------------------------------------------------- + +function ainStep10() +{ + %image = AssetDatabase.acquireAsset("ToyAssets:1234"); + %image.removeExplicitCell(1); + + schedule(400, 0, "ainStep11"); +} + +function ainStep11() +{ + ainCheck("a frame naming a cell that has gone is called out", + $ainPane.warningLabel.isVisible()); + ainCheck("and the warning names it (" @ $ainPane.warningLabel.getText() @ ")", + strstr($ainPane.warningLabel.getText(), "block2") != -1); + + %image = AssetDatabase.acquireAsset("ToyAssets:1234"); + %image.insertExplicitCell(1, 32, 0, 32, 32, "block2"); + + schedule(400, 0, "ainStep12"); +} + +function ainStep12() +{ + ainCheck("putting the cell back clears it", !$ainPane.warningLabel.isVisible()); + + AssetDatabase.releaseAsset("ToyAssets:1234"); + AssetDatabase.releaseAsset("ToyAssets:1234"); + echo("AAIN DONE"); schedule(200, 0, "quit"); } diff --git a/tests/smoke/assetAnimationTimeline.cs b/tests/smoke/assetAnimationTimeline.cs index 6c8af1241..ad1d5f505 100644 --- a/tests/smoke/assetAnimationTimeline.cs +++ b/tests/smoke/assetAnimationTimeline.cs @@ -540,6 +540,156 @@ function aniStep7() aniCheck("the preview is split again", getWordCount(AssetAdmin.previewFrames.getFrameLayout()) > 8); + schedule(300, 0, "aniStep8"); +} + +//----------------------------------------------------------------------------- +// A named animation. 1234Animation lists four cells of an explicitly cut sheet +// by name, and the editor used to refuse it outright: AssetAnimationStage::canEdit +// returned false for named cells, so it got no palette and no timeline, and +// AssetInspector sent it to the stock inspector as well. +// +// The whole editor still works in INDEX space -- the palette shows cell N, the +// timeline holds cell N, a drag carries cell N. Only loading and committing know +// about names, which is what keeps every gesture, the caret arithmetic and the +// hold detection written once. +//----------------------------------------------------------------------------- + +$aniNamedAnimId = "ToyAssets:1234Animation"; +$aniNamedImageId = "ToyAssets:1234"; + +function aniStep8() +{ + $aniNamedTile = AssetAdmin.Dictionary["AnimationAsset"].getButton($aniNamedAnimId); + aniCheck("the named animation tile is in the library", isObject($aniNamedTile)); + + $aniNamedTile.onClick(); + + schedule(600, 0, "aniStep9"); +} + +function aniStep9() +{ + aniCheck("a named animation builds the split too", $aniStage.built); + aniCheck("the stage knows it is in named mode", $aniStage.namedMode()); + + if(!$aniStage.built) + { + echo("AANI DONE"); + schedule(200, 0, "quit"); + return; + } + + $aniNamedTimeline = $aniStage.timelinePane.strip; + $aniNamedPalette = $aniStage.palettePane.strip; + + aniCheck("the palette shows the four explicit cells", + $aniNamedPalette.getCellCount() == 4); + aniCheck("the timeline holds four slots", $aniNamedTimeline.getCellCount() == 4); + + // Both lists, from one load. The strip fills its index list and its name list + // together whichever way it was given the frames, which is what lets every + // gesture below stay in index space. + aniCheck("the timeline read the names (" @ $aniNamedTimeline.getNamedFrames() @ ")", + $aniNamedTimeline.getNamedFrames() $= "block1 block2 block3 block4"); + aniCheck("and resolved them to indices (" @ $aniNamedTimeline.getFrames() @ ")", + $aniNamedTimeline.getFrames() $= "0 1 2 3"); + + schedule(300, 0, "aniStep10"); +} + +//----------------------------------------------------------------------------- +// Editing. The gestures are the numbered ones; what changes is what gets written. +//----------------------------------------------------------------------------- + +function aniStep10() +{ + // The palette-click path, with an index, exactly as for a numbered animation. + $aniStage.appendFrame(1); + + aniCheck("appending by index appends the right name (" @ $aniNamedTimeline.getNamedFrames() @ ")", + $aniNamedTimeline.getNamedFrames() $= "block1 block2 block3 block4 block2"); + + %asset = AssetDatabase.acquireAsset($aniNamedAnimId); + aniCheck("and the asset was written in name space (" @ trim(%asset.getNamedAnimationFrames()) @ ")", + trim(%asset.getNamedAnimationFrames()) $= "block1 block2 block3 block4 block2"); + AssetDatabase.releaseAsset($aniNamedAnimId); + + // Removing and reordering carry the names with them. + $aniNamedTimeline.removeSlot(4); + $aniNamedTimeline.moveSlot(0, 4); + $aniStage.timelinePane.commitFrames(); + + aniCheck("a move reorders the names (" @ $aniNamedTimeline.getNamedFrames() @ ")", + $aniNamedTimeline.getNamedFrames() $= "block2 block3 block4 block1"); + + schedule(300, 0, "aniStep11"); +} + +//----------------------------------------------------------------------------- +// A frame whose cell has gone. Kept, not dropped -- and it survives being +// committed, which is the part that would silently have deleted it. +//----------------------------------------------------------------------------- + +function aniStep11() +{ + $aniNamedImage = AssetDatabase.acquireAsset($aniNamedImageId); + $aniNamedImage.removeExplicitCell(0); + + schedule(300, 0, "aniStep12"); +} + +function aniStep12() +{ + aniCheck("the timeline still has all four slots", $aniNamedTimeline.getCellCount() == 4); + aniCheck("the missing one resolves to no frame", $aniNamedTimeline.getFrameAt(3) == -1); + aniCheck("but still knows what it was called", + $aniNamedTimeline.getNameAt(3) $= "block1"); + + // Committing without touching it must not be what finally loses it. + $aniStage.timelinePane.commitFrames(); + + %asset = AssetDatabase.acquireAsset($aniNamedAnimId); + aniCheck("committing writes the missing name back unchanged (" @ trim(%asset.getNamedAnimationFrames()) @ ")", + trim(%asset.getNamedAnimationFrames()) $= "block2 block3 block4 block1"); + aniCheck("and the asset reports it as missing", + trim(%asset.getMissingFrames()) $= "block1"); + AssetDatabase.releaseAsset($aniNamedAnimId); + + schedule(300, 0, "aniStep13"); +} + +//----------------------------------------------------------------------------- +// What reaches the file. Only the list the animation is actually using -- writing +// both was a round trip that did not close, because the named list is applied +// last and used to force named mode back on. +//----------------------------------------------------------------------------- + +function aniStep13() +{ + $aniNamedImage.insertExplicitCell(0, 0, 0, 32, 32, "block1"); + AssetDatabase.releaseAsset($aniNamedImageId); + + %path = AssetDatabase.getAssetFilePath($aniNamedAnimId); + %file = new FileObject(); + + %text = ""; + if(%file.openForRead(%path)) + { + while(!%file.isEOF()) + { + %text = %text @ %file.readLine() @ " "; + } + %file.close(); + } + %file.delete(); + + aniCheck("the saved file has NamedAnimationFrames", strstr(%text, "NamedAnimationFrames") != -1); + aniCheck("and does NOT also have AnimationFrames", + strstr(strreplace(%text, "NamedAnimationFrames", ""), "AnimationFrames") == -1); + aniCheck("and has no NamedCellsMode, which is not a field any more", + strstr(%text, "NamedCellsMode") == -1); + echo("AANI DONE"); schedule(200, 0, "quit"); } diff --git a/tests/smoke/assetDirtySave.cs b/tests/smoke/assetDirtySave.cs index e237eca23..f29605872 100644 --- a/tests/smoke/assetDirtySave.cs +++ b/tests/smoke/assetDirtySave.cs @@ -644,7 +644,12 @@ function adsStep19() AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) == 1); // Writing the same list back is not a change, so it must not become a step. - %stage.commitFrames($adsAnimation.getAnimationFrames()); + // + // No argument: the stage reads the list off the timeline itself now, because + // only it knows whether the asset keeps its frames by index or by name. The + // timeline has not been touched since the append, so this commits what is + // already there, which is exactly the no-change case being tested. + %stage.commitFrames(); adsCheck("committing an unchanged frame list adds no step (" @ AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) @ ")", AssetAdmin.undoRecorder.getUndoCount($adsAnimationId) == 1); diff --git a/tests/smoke/assetImageInspector.cs b/tests/smoke/assetImageInspector.cs index d16696371..1269a8b6a 100644 --- a/tests/smoke/assetImageInspector.cs +++ b/tests/smoke/assetImageInspector.cs @@ -305,6 +305,53 @@ function aiStep7() strstr($aiPane.cellGrid.box["CellCountX"].Tooltip, "How many cells") == 0); aiCheck("and the warning goes", !$aiPane.warningLabel.isVisible()); + schedule(200, 0, "aiStep7b"); +} + +//----------------------------------------------------------------------------- +// The Explicit Frames tab names the cells it adds -- or rather, it no longer +// does, and reads back the name the engine chose. +// +// It used to build "Frame" @ %index itself with no uniqueness check at all, so +// adding a cell after deleting one from the middle produced a duplicate name that +// the rename box right beside it would have refused. An animation addresses these +// cells BY name, so two cells answering to one name is not cosmetic. +//----------------------------------------------------------------------------- + +function aiStep7b() +{ + %tool = AssetAdmin.inspector.imageFrameEditPage; + aiCheck("the explicit frames tool exists", isObject(%tool)); + + // Through the checkbox, which is what a person clicks: it is toggleExplicitMode + // that adds the first cell for an image that has none. + %tool.explicitModeCheckbox.setStateOn(true); + %tool.toggleExplicitMode(); + + aiCheck("turning explicit mode on cuts a first cell", $aiAsset.getExplicitCellCount() == 1); + aiCheck("and the engine named it (" @ $aiAsset.getExplicitCellName(0) @ ")", + $aiAsset.getExplicitCellName(0) $= "Frame0"); + + %tool.addNewCell(); + + aiCheck("a second cell is named for its own index", + $aiAsset.getExplicitCellName(1) $= "Frame1"); + + // The row shows what the engine chose, rather than what the tool guessed. + %row = %tool.rowChain.getObject(%tool.rowChain.getCount() - 1); + aiCheck("the new row carries that name (" @ %row.CellName @ ")", %row.CellName $= "Frame1"); + aiCheck("and its name box shows it", %row.nameBox.getText() $= "Frame1"); + + // Every cell is addressable, which is the invariant the naming exists for. + aiCheck("the name resolves back to its index", $aiAsset.getExplicitCellIndex("Frame1") == 1); + + %tool.explicitModeCheckbox.setStateOn(false); + %tool.toggleExplicitMode(); + + // The cells outlive the mode -- turning it off must not throw them away, or + // every animation naming them would be unresolvable for good. + aiCheck("the cells survive leaving explicit mode", $aiAsset.getExplicitCellCount() == 2); + schedule(200, 0, "aiStep8"); } diff --git a/toybox/ToyAssets/1/assets/animations/1234Animation.asset.taml b/toybox/ToyAssets/1/assets/animations/1234Animation.asset.taml index ce33b99ab..2f384085d 100644 --- a/toybox/ToyAssets/1/assets/animations/1234Animation.asset.taml +++ b/toybox/ToyAssets/1/assets/animations/1234Animation.asset.taml @@ -1,5 +1,5 @@ diff --git a/toybox/ToyAssets/1/assets/images/1234.asset.taml b/toybox/ToyAssets/1/assets/images/1234.asset.taml index f39ca6e65..2bc9f670e 100644 --- a/toybox/ToyAssets/1/assets/images/1234.asset.taml +++ b/toybox/ToyAssets/1/assets/images/1234.asset.taml @@ -1,7 +1,10 @@ \ No newline at end of file + ImageFile="1234.png"> + + + + + + +