diff --git a/.gitignore b/.gitignore index cfe836a88..3c91b43a5 100755 --- a/.gitignore +++ b/.gitignore @@ -113,6 +113,7 @@ engine/compilers/android-studio/app/.cxx/ /profileFormShotProject/ /profileFormSmokeProject/ /smokeThemeProject/ +/unsavedSmokeProject/ # GuiDefaultProfile.fontDirectory is the unexpanded string "^EditorCore/gui/fonts" # (guiProfiles.cs), and anything that bakes a font-cache miss recorded against it # writes to a folder of that literal name. Nothing in the suite does, but it has diff --git a/CLAUDE.md b/CLAUDE.md index a2800fc47..5c15809fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,15 +44,22 @@ There are two suites, and they test different things. ### C++ unit tests (GoogleTest) -Vendored at `engine/source/testing/googleTest`. Tests live in `engine/source/testing/tests/` (e.g. `platformFileIoTests.cc`, `platformStringTests.cc`) and as `TEST(...)` blocks throughout the engine. +Vendored at `engine/source/testing/googleTest`. **Every test lives in `engine/source/testing/tests/`** (e.g. `guiTreeRowLayoutTests.cc`, `platformStringTests.cc`); each file is listed explicitly in `cmake/EngineSources.cmake`, so a new one needs a CMake edit and a re-configure to be compiled at all. -- Run **all** tests by launching the engine with the alternate boot script: `main.runAllUnitTests.cs`, which calls the `runAllUnitTests()` console function and quits. Point the executable at this script (or `exec` it) instead of the default `main.cs`. -- From the in-engine console you can invoke `runAllUnitTests()` directly, or run a subset via the test-name filter argument (forwarded to GoogleTest). +``` +tests\run-unit.ps1 all of them +tests\run-unit.ps1 GuiTreeRowLayoutTests.* one suite (a GoogleTest filter) +``` + +- Under the hood that launches the engine with the alternate boot script `main.runAllUnitTests.cs`, which calls `runAllUnitTests()` and quits. You can also invoke `runAllUnitTests()` from the in-engine console. +- **`runAllUnitTests()` takes no arguments.** It hands `InitGoogleTest` an empty argv, so a subset is selected with the `GTEST_FILTER` environment variable — which is what `run-unit.ps1`'s parameter sets. +- **What a unit test can reach.** The engine boots far enough to give it `Con`, `Sim`, the string table, the resource manager and `GuiDefaultProfile`, so it can `new` and `registerObject()` a control, read and write fields, run script via `Con::evaluate`, and round-trip TAML. It has **no canvas and no GL context**, so it must never wake a control or measure text: a font registers a texture and `TextureManager::refresh` asserts — which in a debug build is a modal box, so the failure arrives as a *hang*. Note this rules out adding rows to a list box or tree, since that calls `updateSize()` → `getFont()`. +- The established move when GUI logic is worth testing is to extract the arithmetic into a `static` that takes everything it uses, then test the static — see `GuiScrollCtrl::subtractScrollBars`, `GuiControl::splitParagraphs`, `GuiTreeViewCtrl::resolveIndent`. - Tests are compiled out of shipping builds (`TORQUE_SHIPPING` guards `unitTesting.h`). ### TorqueScript integration tests -`tests/` drives the real engine — a real canvas, the real editor, real posted mouse and keyboard input — and checks that it behaves. This is what covers the editors, which the unit tests do not reach. +`tests/` drives the real engine — a real canvas, the real editor, real posted mouse and keyboard input — and checks that it behaves. This is what covers the editors, which the unit tests do not reach. It is also much slower: one process per suite with a 90 second timeout each, against seconds for the whole unit run. **Prefer a unit test where the thing under test can be reached without a canvas**, and keep these for what genuinely needs one. ``` tests\run.ps1 every pass/fail suite (exits non-zero on a change) diff --git a/PlanetX/AppCore/1/appCore.cs b/PlanetX/AppCore/1/appCore.cs index ac0557488..856d192ed 100644 --- a/PlanetX/AppCore/1/appCore.cs +++ b/PlanetX/AppCore/1/appCore.cs @@ -26,9 +26,11 @@ exec("./scripts/constants.cs"); exec("./scripts/defaultPreferences.cs"); exec("./gui/guiCursors.cs"); - %this.createGuiCursors(); exec("./scripts/themes.cs"); %this.loadThemes(); + // After the themes, not before: the cursors a project uses are its theme's, + // and this installs them under the names the engine looks up. + %this.installThemeCursors(%this.cursorTheme()); exec("./scripts/canvas.cs"); // Initialize the canvas diff --git a/PlanetX/AppCore/1/gui/guiCursors.cs b/PlanetX/AppCore/1/gui/guiCursors.cs index bd3331565..7eb14ab73 100644 --- a/PlanetX/AppCore/1/gui/guiCursors.cs +++ b/PlanetX/AppCore/1/gui/guiCursors.cs @@ -20,15 +20,24 @@ // IN THE SOFTWARE. //----------------------------------------------------------------------------- -/// The mouse cursors a GUI names by convention: a text field asks for EditCursor, -/// a window's edges for LeftRightCursor and friends, and a control with none of -/// its own gets DefaultCursor. +/// The mouse cursors a GUI names by convention: a text field asks for +/// EditCursor, a window's edges for LeftRightCursor and friends, and a control +/// with none of its own gets DefaultCursor. Those names are hard-coded in the +/// engine (guiTextEditCtrl.cc, guiWindowCtrl.cc, guiFrameSetCtrl.cc, +/// guiEditCtrl.cc), so something has to answer to them. /// -/// This file used to build a project's ~70 GUI profiles as well. Those are now a -/// GuiProfileTheme (see scripts/themes.cs), which derives the whole set from six -/// colors and is editable in the GUI Profile Editor - so a project skins itself -/// by editing a theme rather than by forking a thousand lines of script. Cursors -/// have not moved into the theme yet, so they stay here. +/// This file used to answer by building seven cursors out of literals, the last +/// of the hand-written GUI furniture after the ~70 profiles became a +/// GuiProfileTheme. Now the theme owns cursors too - each one its own art, +/// tinted from the theme's palette - and this installs a chosen theme's set +/// under the canonical names. A control that names a cursor outright still wins; +/// this is only what everything else falls back to, including the canvas arrow. +/// +/// It is also callable at any time, which is how a game swaps between themes +/// that look nothing alike: +/// +/// AppCore.installThemeCursors(Combat); +/// Canvas.setCursor(DefaultCursor); /// Registers %object under %name, or - if something already holds the name - /// copies the new object's fields onto the existing one and throws the new one @@ -55,54 +64,106 @@ } } -function AppCore::createGuiCursors(%this) +/// Every theme the project loaded, as a space-separated list of ids. They live +/// in the Gui data group, which is where GuiProfileTheme::onAdd puts them. +function AppCore::getThemes(%this) { - %this.SafeCreateNamedObject("DefaultCursor", new GuiCursor() + %themes = ""; + if(!isObject(GuiDataGroup)) { - hotSpot = "1 1"; - renderOffset = "0 0"; - bitmapName = "^AppCore/gui/images/cursors/defaultCursor"; - }); + return %themes; + } - %this.SafeCreateNamedObject("LeftRightCursor", new GuiCursor() + for(%i = 0; %i < GuiDataGroup.getCount(); %i++) { - hotSpot = "0.5 0"; - renderOffset = "0.5 0.4"; - bitmapName = "^AppCore/gui/images/cursors/leftRight"; - }); + %object = GuiDataGroup.getObject(%i); + if(%object.getClassName() $= "GuiProfileTheme") + { + %themes = (%themes $= "") ? %object.getId() : (%themes SPC %object.getId()); + } + } + + return %themes; +} - %this.SafeCreateNamedObject("UpDownCursor", new GuiCursor() +/// Which theme's cursors become the canonical ones. A project with one theme +/// never has to think about this; a project with several says so by setting +/// $pref::AppCore::cursorTheme, and gets told when it hasn't. +function AppCore::cursorTheme(%this) +{ + %themes = %this.getThemes(); + %count = getWordCount(%themes); + if(%count == 0) { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/upDown"; - }); + return 0; + } - %this.SafeCreateNamedObject("NWSECursor", new GuiCursor() + if($pref::AppCore::cursorTheme !$= "") { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/NWSE"; - }); + for(%i = 0; %i < %count; %i++) + { + %theme = getWord(%themes, %i); + if(%theme.getName() $= $pref::AppCore::cursorTheme) + { + return %theme; + } + } + warn("AppCore::cursorTheme: $pref::AppCore::cursorTheme names '" @ $pref::AppCore::cursorTheme @ "', which is not a loaded theme."); + } - %this.SafeCreateNamedObject("NESWCursor", new GuiCursor() + if(%count == 1) { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/NESW"; - }); + return getWord(%themes, 0); + } - %this.SafeCreateNamedObject("MoveCursor", new GuiCursor() + for(%i = 0; %i < %count; %i++) { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/move"; - }); + %theme = getWord(%themes, %i); + if(%theme.getName() $= "Base") + { + return %theme; + } + } + + %first = getWord(%themes, 0); + warn("AppCore::cursorTheme: " @ %count @ " themes are loaded and none is named 'Base', so the cursors come from '" @ + %first.getName() @ "'. Set $pref::AppCore::cursorTheme to choose."); + return %first; +} + +/// Point the canonical cursor names at %theme's cursors. The names are copies +/// rather than the members themselves: a name can only belong to one object, +/// and a theme's members have to keep their own names for the Guis that +/// reference them. +function AppCore::installThemeCursors(%this, %theme) +{ + if(!isObject(%theme)) + { + warn("AppCore::installThemeCursors: no theme to install cursors from."); + return false; + } - %this.SafeCreateNamedObject("EditCursor", new GuiCursor() + %categories = %theme.getCursorCategoryNames(); + %count = getWordCount(%categories); + for(%i = 0; %i < %count; %i++) { - hotSpot = "0 0"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/ibeam"; - }); + %category = getWord(%categories, %i); + %cursor = %theme.getCursor(%category); + if(!isObject(%cursor)) + { + continue; + } + + // The category's canonical name comes from the engine's own table, so + // this never has to take a theme name apart to find it. + %this.SafeCreateNamedObject(%theme.getCursorCanonicalName(%category), new GuiCursor() + { + bitmapName = %cursor.bitmapName; + hotSpot = %cursor.hotSpot; + renderOffset = %cursor.renderOffset; + color = %cursor.color; + }); + } + + return true; } diff --git a/PlanetX/AppCore/1/scripts/defaultPreferences.cs b/PlanetX/AppCore/1/scripts/defaultPreferences.cs index 37543dd9d..c4a015b5e 100644 --- a/PlanetX/AppCore/1/scripts/defaultPreferences.cs +++ b/PlanetX/AppCore/1/scripts/defaultPreferences.cs @@ -35,6 +35,12 @@ $pref::iOS::EnableOtherOrientationRotation = 1; $pref::iOS::StatusBarType = 0; +/// AppCore. Which theme's cursors are installed under the names the engine +/// looks up when a control names none of its own - DefaultCursor, EditCursor +/// and the rest (see gui/guiCursors.cs). Empty is the usual answer: a project +/// with one theme uses it, and one with several is asked to say which. +$pref::AppCore::cursorTheme = ""; + /// T2D $pref::T2D::ParticlePlayerEmissionRateScale = 1.0; $pref::T2D::ParticlePlayerSizeScale = 1.0; diff --git a/PlanetX/AppCore/1/scripts/themes.cs b/PlanetX/AppCore/1/scripts/themes.cs index a85d48947..1a61994d0 100644 --- a/PlanetX/AppCore/1/scripts/themes.cs +++ b/PlanetX/AppCore/1/scripts/themes.cs @@ -65,6 +65,83 @@ return pathConcat(filePath(filePath(makeFullPath(%module.getModulePath(), getMainDotCsDir()))), "themes"); } +/// Where one theme keeps its cursor art. A folder per theme, because two themes +/// in the same project may want cursors that look nothing alike - a menu +/// pointer and a combat reticle - and sharing one folder would mean one +/// overwriting the other. +function AppCore::getThemeCursorsPath(%this, %theme) +{ + %path = %this.getThemesPath(); + if(%path $= "" || !isObject(%theme) || %theme.getName() $= "") + { + return ""; + } + return pathConcat(%path, "cursors", %theme.getName()); +} + +/// The stock cursor art every theme starts from, inside AppCore itself. It is +/// grayscale on purpose so a theme's tint colors it. +function AppCore::getStockCursorsPath(%this) +{ + %module = ModuleDatabase.findModule("AppCore", 1); + if(!isObject(%module)) + { + return ""; + } + return pathConcat(makeFullPath(%module.getModulePath(), getMainDotCsDir()), "gui/images/cursors"); +} + +/// Give %theme its own copy of the stock cursor art and point it at the folder. +/// Idempotent: pathCopy is asked not to overwrite, so a theme whose art is +/// already there (or has been edited) is left exactly as it is. +/// +/// Only the folder being absent triggers the copy, which keeps boot down to one +/// directory test per theme - and means Android, where pathCopy is unsupported, +/// never reaches it in a project that shipped its art. +function AppCore::seedThemeCursors(%this, %theme) +{ + %target = %this.getThemeCursorsPath(%theme); + if(%target $= "") + { + return false; + } + + // isDirectory rather than isFile: isFile answers out of the resource + // manager, which knows nothing about files written after the last scan. + if(!isDirectory(%target)) + { + %source = %this.getStockCursorsPath(); + if(%source $= "" || !isDirectory(%source)) + { + warn("AppCore::seedThemeCursors: no stock cursor art at " @ %source @ "."); + return false; + } + + createPath(%target @ "/"); + + %categories = %theme.getCursorCategoryNames(); + for(%i = 0; %i < getWordCount(%categories); %i++) + { + %file = %theme.getCursorStockFile(getWord(%categories, %i)); + if(%file $= "") + { + continue; + } + pathCopy(pathConcat(%source, %file), pathConcat(%target, %file)); + } + } + + // Assigning the folder restamps the theme, which fills in the bitmap of any + // cursor that has none yet. A cursor already pointing at art keeps it. + %directory = makeRelativePath(%target, getMainDotCsDir()); + if(%theme.cursorDirectory !$= %directory) + { + %theme.cursorDirectory = %directory; + } + + return true; +} + function AppCore::loadThemes(%this) { %path = %this.getThemesPath(); @@ -95,9 +172,10 @@ } /// Reads one file from the themes folder. Alongside themes it may hold stand-alone -/// profiles, which the Profile Editor writes as a one-profile bundle (and, from -/// older versions, as a bare profile). Loading is all these need: a profile puts -/// itself in the Gui data group under its own name, which is how a Gui finds it. +/// profiles, which the Profile Editor writes as a one-profile bundle - a SimSet, +/// or a SimGroup from older versions (and, older still, a bare profile). Loading +/// is all these need: a profile registers under its own name, which is how a Gui +/// finds it. function AppCore::loadTheme(%this, %file) { %object = TAMLRead(%file); @@ -118,10 +196,12 @@ } %this.repairFontDirectory(%object); + %this.seedThemeCursors(%object); return true; } - if(%class $= "ScriptGroup" || %class $= "GuiControlProfile") + // A bundle is a SimSet; SimGroup, which the older ones are, derives from it. + if(%object.isMemberOfClass("SimSet") || %class $= "GuiControlProfile") { return false; } @@ -171,6 +251,9 @@ borderSize = 1; }; + // Before the write, so the file records where the art went. + %this.seedThemeCursors(%theme); + %file = pathConcat(%path, "Base.taml"); TAMLWrite(%theme, %file); diff --git a/PlanetX/PlanetXGame/game.cs b/PlanetX/PlanetXGame/game.cs index df23d9581..2d223f144 100644 --- a/PlanetX/PlanetXGame/game.cs +++ b/PlanetX/PlanetXGame/game.cs @@ -49,8 +49,10 @@ // The weapon-upgrade catalog and this run's upgrade state. A named session // singleton (like PlanetXWindow/PlanetXScene) so any file can reach it; reset() - // at the start of each run clears it back to the stock blaster. - new ScriptObject(PlanetXUpgrades) { class = "PlanetXUpgrades"; }; + // at the start of each run clears it back to the stock blaster. Naming it is + // all it needs - the name is already the namespace upgrades.cs writes its + // methods in, so a class saying the same word again would say nothing. + new ScriptObject(PlanetXUpgrades); // Two-player co-op starts off; the title's "START 2 PLAYERS" turns it on. $PlanetX::twoPlayer = false; @@ -362,7 +364,7 @@ class = "PlanetXUpgradeScreen"; } // Co-op: take this player out of play. - Audio.PlaySound("PlanetXGame:playerDeath"); + Audio.PlaySound("PlanetXGame:playerDeathBurst"); %player.playDeathFx(); %player.goDown(); @@ -392,7 +394,7 @@ class = "PlanetXUpgradeScreen"; // ghost bolts off-screen during the hold before the dialog. if (isObject(%player) && !%player.downed) { - Audio.PlaySound("PlanetXGame:playerDeath"); + Audio.PlaySound("PlanetXGame:playerDeathBurst"); %player.playDeathFx(); %player.stopFiring(); %player.setLinearVelocity(0, 0); diff --git a/PlanetX/PlanetXGame/sound/playerDeath.audio.taml b/PlanetX/PlanetXGame/sound/playerDeath.audio.taml index 136acbc1f..e7238a62b 100644 --- a/PlanetX/PlanetXGame/sound/playerDeath.audio.taml +++ b/PlanetX/PlanetXGame/sound/playerDeath.audio.taml @@ -1,4 +1,4 @@ diff --git a/PlanetX/themes/PlanetX.taml b/PlanetX/themes/PlanetX.taml index 6e208af37..6d8a06da3 100644 --- a/PlanetX/themes/PlanetX.taml +++ b/PlanetX/themes/PlanetX.taml @@ -11,7 +11,8 @@ colorAccent="33 191 132 255" colorHighlight="234 72 72 255" colorWarning="166 38 70 255" - borderSize="2"> + borderSize="2" + cursorDirectory="PlanetX/themes/cursors/PlanetX"> @@ -57,6 +58,65 @@ + + + + + + + + @@ -121,7 +181,7 @@ category="WindowContent" /> + category="windowButton" /> @@ -148,7 +208,7 @@ category="TreeView" /> + category="frameSet" /> diff --git a/PlanetX/themes/cursors/PlanetX/NESW.png b/PlanetX/themes/cursors/PlanetX/NESW.png new file mode 100644 index 000000000..2f73696c8 Binary files /dev/null and b/PlanetX/themes/cursors/PlanetX/NESW.png differ diff --git a/PlanetX/themes/cursors/PlanetX/NWSE.png b/PlanetX/themes/cursors/PlanetX/NWSE.png new file mode 100644 index 000000000..c952a3e45 Binary files /dev/null and b/PlanetX/themes/cursors/PlanetX/NWSE.png differ diff --git a/PlanetX/themes/cursors/PlanetX/defaultCursor.png b/PlanetX/themes/cursors/PlanetX/defaultCursor.png new file mode 100644 index 000000000..a0d1ab9de Binary files /dev/null and b/PlanetX/themes/cursors/PlanetX/defaultCursor.png differ diff --git a/PlanetX/themes/cursors/PlanetX/ibeam.png b/PlanetX/themes/cursors/PlanetX/ibeam.png new file mode 100644 index 000000000..56079504c Binary files /dev/null and b/PlanetX/themes/cursors/PlanetX/ibeam.png differ diff --git a/PlanetX/themes/cursors/PlanetX/leftRight.png b/PlanetX/themes/cursors/PlanetX/leftRight.png new file mode 100644 index 000000000..c29b7a9f8 Binary files /dev/null and b/PlanetX/themes/cursors/PlanetX/leftRight.png differ diff --git a/PlanetX/themes/cursors/PlanetX/move.png b/PlanetX/themes/cursors/PlanetX/move.png new file mode 100644 index 000000000..70f9cd540 Binary files /dev/null and b/PlanetX/themes/cursors/PlanetX/move.png differ diff --git a/PlanetX/themes/cursors/PlanetX/upDown.png b/PlanetX/themes/cursors/PlanetX/upDown.png new file mode 100644 index 000000000..377217897 Binary files /dev/null and b/PlanetX/themes/cursors/PlanetX/upDown.png differ diff --git a/README.md b/README.md index 0b2c066af..a6193e146 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,13 @@ Torque2D 4.0: Rocket Edition is currently in progress. The major change with 4.0 The managers can be reached by opening the console using the console button in the Toybox or by pressing Tilde(~) + Ctrl. You will then notice tabs along that top for the various tools currently available. -Early Access 3 introduces a **full GUI Editor** built from the ground up, replacing the previous Gui Editor Toy. The new editor includes an inspector, tree view, menus, save/load dialogs, frame set layouts, color picker, and control reordering. It provides a complete visual workflow for creating and editing GUI layouts within the engine. +Early Access 3 introduces a **full GUI Editor**, built from the ground up to replace the previous Gui Editor Toy. You build a screen by dragging controls from an illustrated palette onto the canvas, or by clicking one to have it placed for you, and arrange them by dragging, by the arrow keys, or through the Layout menu's align and spacing commands. There is undo and redo, and cut, copy and paste work within a Gui and between them. + +Around the canvas sit two more panels. The **Explorer** shows the whole control tree, with a picture of each control's class, columns for hiding and locking, and drag-to-reparent. The **properties pane** shows only the fields the selected control's class actually reads — a chain never draws its own text, so it is not offered nine text fields it will ignore — and gives the common ones purpose-built editors rather than text boxes: an anchor picker for sizing, color swatches, an image picker you choose by looking at it, and editors for the things that used to be unreachable from an editor at all, such as a list box's rows and a menu bar's items. + +Guis are saved as either the classic `.gui` script or TAML, and the editor says which one loses what before you pick. A Gui you have changed is not thrown away without being asked about. + +Controls take their appearance from a **theme** rather than from profiles you wire up by hand. The **Gui Profile Editor** is where a theme is authored — profiles, borders, fonts and colors, against a live preview — and a control dropped onto the canvas arrives already wearing the right one. Set Theme re-skins an entire Gui. The Rocket Edition also features a revamped Gui System! Until now it has been a common practice among those seriously using T2D to avoid the Gui System as much as possible. We aim to fix that with the Rocket Edition. Explanation of how to use the updated Gui System can be found in the wiki in the [Gui Guide](https://github.com/TorqueGameEngines/Torque2D/wiki/GUI-Guide). diff --git a/cmake/EngineSources.cmake b/cmake/EngineSources.cmake index fbfe8ffb4..8b7181c48 100644 --- a/cmake/EngineSources.cmake +++ b/cmake/EngineSources.cmake @@ -46,7 +46,6 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/2d/experimental/composites/WaveComposite.cc # ---- 2d/gui ---- ${TORQUE_SRC}/2d/gui/SceneWindow.cc - ${TORQUE_SRC}/2d/gui/guiImageButtonCtrl.cc ${TORQUE_SRC}/2d/gui/guiSceneObjectCtrl.cc ${TORQUE_SRC}/2d/gui/guiSpriteCtrl.cc # ---- 2d/scene ---- @@ -200,8 +199,10 @@ set(TORQUE_ENGINE_SOURCES ${TORQUE_SRC}/gui/containers/guiTabPageCtrl.cc ${TORQUE_SRC}/gui/containers/guiWindowCtrl.cc # ---- gui/editor ---- + ${TORQUE_SRC}/gui/editor/guiEditorCursorCtrl.cc ${TORQUE_SRC}/gui/editor/guiDebugger.cc ${TORQUE_SRC}/gui/editor/guiEditCtrl.cc + ${TORQUE_SRC}/gui/editor/guiEditorExplorerTree.cc ${TORQUE_SRC}/gui/editor/guiGraphCtrl.cc ${TORQUE_SRC}/gui/editor/guiInspector.cc ${TORQUE_SRC}/gui/editor/guiInspectorTypes.cc @@ -338,10 +339,18 @@ set(TORQUE_ENGINE_SOURCES # ---- testing ---- ${TORQUE_SRC}/testing/unitTesting.cc # ---- testing/tests ---- + ${TORQUE_SRC}/testing/tests/guiControlReparentTests.cc + ${TORQUE_SRC}/testing/tests/guiCursorHotSpotTests.cc + ${TORQUE_SRC}/testing/tests/guiHitTestTests.cc ${TORQUE_SRC}/testing/tests/guiProfileThemeTests.cc + ${TORQUE_SRC}/testing/tests/guiScrollLayoutTests.cc + ${TORQUE_SRC}/testing/tests/guiTextEditTests.cc + ${TORQUE_SRC}/testing/tests/guiTreeRowLayoutTests.cc + ${TORQUE_SRC}/testing/tests/namespaceLinkTests.cc ${TORQUE_SRC}/testing/tests/platformFileIoTests.cc ${TORQUE_SRC}/testing/tests/platformMemoryTests.cc ${TORQUE_SRC}/testing/tests/platformStringTests.cc + ${TORQUE_SRC}/testing/tests/simObjectCloneTests.cc # ---- platform ---- ${TORQUE_SRC}/platform/CursorManager.cc ${TORQUE_SRC}/platform/Tickable.cc diff --git a/docs/superpowers/specs/2026-07-19-gui-profile-theme-design.md b/docs/superpowers/specs/2026-07-19-gui-profile-theme-design.md deleted file mode 100644 index 16c758d79..000000000 --- a/docs/superpowers/specs/2026-07-19-gui-profile-theme-design.md +++ /dev/null @@ -1,96 +0,0 @@ -# GuiProfileTheme — Design - -Date: 2026-07-19 -Branch: `Gui-Profile-Editor` -Status: approved (Peter, 2026-07-19) - -## Context - -Torque2D's GUI skinning rests on `GuiControlProfile`, originally designed for instant reskinning by profile swap. In practice profiles must be fine-tuned per control, many controls take several profiles (scroll = track/thumb/arrow; window = content + 3 buttons; menu = 4+ parts), and the `mCategory` field meant to express "what control is this profile for" is never read anywhere in the engine — it's a write-only persist field (`guiTypes.cc:437`, declared `guiTypes.h:253`). Missing profiles fall back by name convention to `GuiDefaultProfile` and, in release builds, null-deref (`guiControl.cc:1137-1192`, `AssertFatal` compiled out). - -Meanwhile, real theming exists only in TorqueScript, three times over: editor `BaseTheme` (~67 profiles, 2,310 lines, `editor/EditorCore/Themes/BaseTheme/BaseTheme.cs`), `AppCore::createGuiProfiles` (`library/AppCore/gui/guiProfiles.cs`), and legacy Sandbox globals. All share one shape: a small palette + fonts + two helpers (`adjustValue` = HSV-brightness shift, `setAlpha`), deriving every profile's state colors (base = palette color, HL = adjustValue(+10), SL = accent, NA = setAlpha(~80–150)). - -`GuiProfileTheme` formalizes that pattern as a C++ object — theme-wide values, auto-created profiles per category, per-field overrides, TAML persistence. It is the foundation for a future full-screen Profile Editor dialog in the GUI Editor; the theme is designed to be driven by that editor, not hand-authored. - -## Decisions (Peter, 2026-07-19) - -1. **Category list**: engine-defined static table in C++ (not script/data-driven). -2. **Propagation**: stamp-on-change — profile fields stay plain members; theme writes derived defaults into member profiles when theme values change, skipping overridden fields. No live fall-through reads, no rendering changes. -3. **Scope**: pure container — no changes to `GuiControl` or the profile-lookup/fallback chain. Lookup integration (active theme in `onWake` fallback) is a future branch. -4. **Theme value shape**: BaseTheme shape + destructive color — 3 semantic fonts + fontDirectory + fontSize, 6 semantic colors, borderSize. -5. **Architecture**: code-table theme — per-category C++ stamp functions porting the AppCore recipes. (Expression-driven derivation deliberately rejected for scope; can be layered later.) -6. **Category set**: full ~36-category table in v1, not a starter subset. - -## Design - -### Object model & ownership - -- **`GuiProfileTheme : SimObject`** — new files `engine/source/gui/guiProfileTheme.h`, `.cc`, `guiProfileTheme_ScriptBinding.h`. `DECLARE_CONOBJECT`/`IMPLEMENT_CONOBJECT`, `initPersistFields()` calling Parent first, registers into `Sim::getGuiDataGroup()` in `onAdd` (mirror `guiTypes.cc:445`). -- **Theme owns members**: lists of member `GuiControlProfile*` and `GuiBorderProfile*`. `onAdd` creates one profile per category-table entry (skipping any already present from TAML read), `onRemove` deletes all members. Ownership flows one way. -- **Members point back**: `GuiControlProfile` and `GuiBorderProfile` gain non-owning `GuiProfileTheme* mTheme` (never serialized; reconstructed at load). `mTheme == NULL` ⇒ standalone profile, behavior identical to today. **No changes to `GuiControl`.** -- **Delete safety both ways**: theme `deleteNotify()`s each member and overrides `onDeleteNotify` to drop the entry. Deleted *default* ⇒ recreated at next stamp (a theme is always complete). Deleted *extra* ⇒ removed. Theme deleted ⇒ members are deleted by `onRemove`; a member that outlives has its `mTheme` nulled via the member's own `onDeleteNotify`. - -### Category table (the engine-defined canon) - -Static table in `guiProfileTheme.cc`: `{ categoryName, profileNameSuffix, stampFunc }`. Theme sets `mCategory` on every member — making the field load-bearing for the first time. Auto-created member names: **``** (e.g. `DarkThemeButtonProfile`); extras get user names but keep the category (default extra name ``). Renaming a theme does not rename existing members (documented v1 limitation). - -v1 categories (36) — union of profile slots engine controls consume (GUI Guide slot map + AppCore inventory): - -> Default, Empty (transparent), Tooltip, Panel, Button, CheckBox, Radio, Label, TextEdit, Scroll, ScrollTrack, ScrollThumb, ScrollArrow, TabBook, Tab, TabPage, ListBox, DropDown, DropDownItem, Window, WindowContent, WindowButton, WindowCloseButton (destructive color), MenuBar, Menu, MenuItem, MenuContent, Overlay (menu/popup click-catcher), Progress, TreeView, FrameSet, FrameSetDropButton, ColorPicker, ColorSelector, ColorPopup, DragAndDrop - -A parallel smaller **border table** works identically for theme-owned `GuiBorderProfile`s (Default, Bright, Dark, button-bevel sides, etc., from AppCore's border profiles). `GuiBorderProfile` gains a `category` persist field for symmetry (it has none today). Control-profile stamp functions wire borders: `borderDefault` by object pointer, the four side-border fields by **name** (they are lazily name-resolved strings already — `guiTypes.cc:455-613`). - -Recipes: port from `library/AppCore/gui/guiProfiles.cs` (game-facing baseline), mapping its `color1..6` onto the semantic roles. Fixed bevel colors stay literal (`255 255 255 80` / `0 0 0 80` — deliberately hue-independent). Stamp functions set **all** persist fields of a profile (colors, fonts, alignment, geometry via borders, behavior flags); `bitmap`/`imageAsset` are left empty by stamps (user-overridable only). - -### Theme-wide values (persist fields) - -`fontBody`, `fontTitle`, `fontCode` (font type names), `fontDirectory`, `fontSize`; -`colorBackground`, `colorPanel`, `colorText`, `colorAccent`, `colorHighlight`, `colorWarning` (ColorI); -`borderSize` (S32). - -(Amended 2026-07-19 with Peter: the originally-planned `colorTextSubtle` was replaced by `colorHighlight` — AppCore's palette has two accents (blue interaction + yellow flavor) and no subtle-text color, so the 6 roles now map 1:1 onto AppCore's six palette entries with no dead fields.) - -Helpers as C++ statics on the class, exposed to script: `adjustValue(color, percent)` — HSV-value shift preserving hue/alpha, **fixing** BaseTheme's clamp bug (`mClamp(newValue, 0, 100)` should clamp the value fraction to 0..1, `BaseTheme.cs:2280-2309`) — and `setAlpha(color, alpha)`. - -### Stamping & override tracking (core mechanism) - -- `GuiProfileTheme::onStaticModified` (virtual on `SimObject`, `simObject.h:486`, fired from `setDataField` at `simObject.cc:558` — so script, editor inspector, and all three TAML readers hit it) ⇒ re-stamp all members synchronously (36 profiles × ~30 fields is trivial). -- **Stamp functions write raw member variables directly** (not `setDataField`) — so stamping never fires `onStaticModified`, needs no guard flag, and never marks overrides. -- Members override `onStaticModified`: when `mTheme != NULL`, any external field write adds the field name (`StringTableEntry`) to the member's override set; stamping skips fields in the set. Writes to `category` are ignored/not overridable (theme-managed). -- Override set: small `Vector` (pointer-compare, linear scan) inside a shared helper struct (e.g. `GuiThemeMembership { GuiProfileTheme* theme; Vector overrides; }`) embedded in both member classes; thin per-class glue, no multiple inheritance. -- `resetField`/`resetProfile` clear override entries and re-stamp. -- Note: `GuiControlProfile`'s constructor copy-from-`GuiDefaultProfile` (`guiTypes.cc:348-382`) runs before stamping and is simply overwritten — harmless, leave as-is. - -### TAML persistence - -- One theme file = theme values (ordinary persist fields) + all members as **TAML custom nodes** via `TamlCallbacks::onTamlCustomWrite/Read` (pattern: `engine/source/2d/assets/ParticleAsset.*`, also `SpriteBatch`, `guiFrameSetCtrl`; API in `engine/source/persistence/taml/tamlCustom.h`). -- Each member node writes: object name, category, and **only overridden fields** — enforced by overriding virtual `SimObject::writeField` (`simObject.h:673`; TAML consults it at `taml.cc:697,777`) to filter non-overridden fields when `mTheme != NULL` (always allow `name`/`category`). Standalone profile serialization unchanged. -- On read: members created from nodes and attached to the theme; fields applied via `setDataField` automatically re-mark themselves overridden (readers use `setPrefixedDataField` — `tamlXmlReader.cc:294`, `tamlBinaryReader.cc:241`, `tamlJSONReader.cc:258`). `onAdd` then creates any missing defaults and stamps everything. - -### Script API (`guiProfileTheme_ScriptBinding.h`, ConsoleMethodWithDocs style) - -`getProfile(category)`, `getProfiles(category)`, `createProfile(category [,name])`, `removeProfile(profile)` (extras only), `resetField(profile, field)`, `resetProfile(profile)`, `isFieldOverridden(profile, field)`, `restamp()`, `adjustValue(color, percent)`, `setAlpha(color, alpha)`; plus `getCategoryNames()` (static/console function) so the future editor enumerates the table. - -### Adjacent fix (small, justified) - -`GuiControlProfile` registers `deleteNotify` on its side-border profiles (`guiTypes.cc:479-613`) but never overrides `onDeleteNotify`, so deleting a border leaves dangling pointers (base is a no-op, `simObject.cc:904-906`). Since the theme now deletes/recreates borders routinely, this latent bug becomes live. Add `GuiControlProfile::onDeleteNotify` nulling the matching `mBorderDefault`/`mBorderLeft/Right/Top/Bottom` pointers. - -## Out of scope (explicitly) - -- The Profile Editor dialog (later task on this branch). -- Profile-lookup/fallback integration (`GuiControl::onWake` consulting an active theme) and the release-build null-profile crash fix — future branch. -- Migrating editor `BaseTheme`/`ThemeManager` or AppCore script themes to GuiProfileTheme. -- Theme-owned image assets / bitmap recipes; expression-driven derivation. - -## Testing - -GoogleTest in `engine/source/testing/tests/guiProfileThemeTests.cc`, run via `runAllUnitTests()`: - -- theme creates one profile per category with `mCategory` set; -- theme-value change propagates to non-overridden member fields; -- an overridden field survives re-stamp; reset restores derivation; -- TAML round-trip preserves values + override sets and writes only overridden fields; -- delete-safety in both directions; -- `adjustValue` clamp correctness (including the fixed over-brighten rail). - -Manual verification: editor boots and skins normally (standalone profiles unaffected); interactive console smoke (create theme, inspect members, edit theme color, override, reset, TAML round-trip, deletion behavior). diff --git a/editor/AssetAdmin/AssetInspector.cs b/editor/AssetAdmin/AssetInspector.cs index 882bd8d04..0b38f4d87 100644 --- a/editor/AssetAdmin/AssetInspector.cs +++ b/editor/AssetAdmin/AssetInspector.cs @@ -56,7 +56,7 @@ { HorizSizing = "left"; Class = "EditorIconButton"; - Frame = 48; + Frame = $EditorIcon::trash; Position = "660 5"; Command = %this.getId() @ ".deleteAsset();"; Tooltip = "Delete Asset"; @@ -77,10 +77,10 @@ }; ThemeManager.setProfile(%this.emitterButtonBar, "emptyProfile"); %this.add(%this.emitterButtonBar); - %this.emitterButtonBar.addButton("AddEmitter", 25, "Add Emitter", ""); - %this.emitterButtonBar.addButton("MoveEmitterBackward", 27, "Move Emitter Backward", "getMoveEmitterBackwardEnabled"); - %this.emitterButtonBar.addButton("MoveEmitterForward", 28, "Move Emitter Forward", "getMoveEmitterForwardEnabled"); - %this.emitterButtonBar.addButton("RemoveEmitter", 23, "Remove Emitter", "getRemoveEmitterEnabled"); + %this.emitterButtonBar.addButton("AddEmitter", $EditorIcon::round_plus, "Add Emitter", ""); + %this.emitterButtonBar.addButton("MoveEmitterBackward", $EditorIcon::rnd_br_up, "Move Emitter Backward", "getMoveEmitterBackwardEnabled"); + %this.emitterButtonBar.addButton("MoveEmitterForward", $EditorIcon::rnd_br_down, "Move Emitter Forward", "getMoveEmitterForwardEnabled"); + %this.emitterButtonBar.addButton("RemoveEmitter", $EditorIcon::round_delete, "Remove Emitter", "getRemoveEmitterEnabled"); %this.tabBook = new GuiTabBookCtrl() { diff --git a/editor/AssetAdmin/ImageEditor/AssetImageFrameEditRow.cs b/editor/AssetAdmin/ImageEditor/AssetImageFrameEditRow.cs index 69fdb6f3f..2b8d8c1fc 100644 --- a/editor/AssetAdmin/ImageEditor/AssetImageFrameEditRow.cs +++ b/editor/AssetAdmin/ImageEditor/AssetImageFrameEditRow.cs @@ -102,9 +102,9 @@ }; ThemeManager.setProfile(%this.buttonBar, "emptyProfile"); %this.add(%this.buttonBar); - %this.buttonBar.addButton("MoveCellUp", 2, "Move Cell Up", "getMoveCellUpEnabled"); - %this.buttonBar.addButton("MoveCellDown", 6, "Move Cell Down", "getMoveCellDownEnabled"); - %this.buttonBar.addButton("RemoveCell", 23, "Remove Cell", "getRemoveCellEnabled"); + %this.buttonBar.addButton("MoveCellUp", $EditorIcon::arrow_top, "Move Cell Up", "getMoveCellUpEnabled"); + %this.buttonBar.addButton("MoveCellDown", $EditorIcon::arrow_bottom, "Move Cell Down", "getMoveCellDownEnabled"); + %this.buttonBar.addButton("RemoveCell", $EditorIcon::round_delete, "Remove Cell", "getRemoveCellEnabled"); } function AssetImageFrameEditRow::CellNameChange(%this) diff --git a/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs b/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs index f4fa80740..58f1226d0 100644 --- a/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs +++ b/editor/AssetAdmin/ImageEditor/AssetImageLayersEditRow.cs @@ -91,9 +91,9 @@ if(%this.LayerIndex > 0) { - %this.buttonBar.addButton("MoveLayerUp", 2, "Move Layer Up", "getMoveLayerUpEnabled"); - %this.buttonBar.addButton("MoveLayerDown", 6, "Move Layer Down", "getMoveLayerDownEnabled"); - %this.buttonBar.addButton("RemoveLayer", 23, "Remove Layer", ""); + %this.buttonBar.addButton("MoveLayerUp", $EditorIcon::arrow_top, "Move Layer Up", "getMoveLayerUpEnabled"); + %this.buttonBar.addButton("MoveLayerDown", $EditorIcon::arrow_bottom, "Move Layer Down", "getMoveLayerDownEnabled"); + %this.buttonBar.addButton("RemoveLayer", $EditorIcon::round_delete, "Remove Layer", ""); } else { diff --git a/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs b/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs index 634917e0d..f579f6b00 100644 --- a/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs +++ b/editor/AssetAdmin/ParticleEditor/AssetParticleGraphUnit.cs @@ -11,12 +11,15 @@ ThemeManager.setProfile(%this.graph, "graphProfile"); %this.add(%this.graph); - //Value zoom buttons + // 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, + // which mean add and remove everywhere else in the editor. %center = 6 + mRound(getWord(%this.graph.extent, 1) / 2); %this.valueZoomInButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 0; + Frame = $EditorIcon::sq_plus; Position = "2" SPC (%center + 13); Command = %this.getId() @ ".valueZoomIn();"; Tooltip = "Zoom In"; @@ -27,7 +30,7 @@ %this.valueZoomOutButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 1; + Frame = $EditorIcon::sq_minus; Position = "2" SPC (%center - 13); Command = %this.getId() @ ".valueZoomOut();"; Tooltip = "Zoom Out"; @@ -39,7 +42,7 @@ %this.valueMoveUpButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 2; + Frame = $EditorIcon::arrow_top; Position = "2 18"; Command = %this.getId() @ ".valueMoveUp();"; Tooltip = "Move Graph Up"; @@ -50,7 +53,7 @@ %this.valueMoveDownButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 6; + Frame = $EditorIcon::arrow_bottom; Position = "2" SPC (getWord(%this.extent, 1) - 66); Command = %this.getId() @ ".valueMoveDown();"; Tooltip = "Move Graph Down"; @@ -73,7 +76,7 @@ %this.timeZoomInButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 0; + Frame = $EditorIcon::sq_plus; Position = "0 0"; Command = %this.getId() @ ".timeZoomIn();"; Tooltip = "Zoom In"; @@ -84,7 +87,7 @@ %this.timeZoomOutButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 1; + Frame = $EditorIcon::sq_minus; Position = "26 0"; Command = %this.getId() @ ".timeZoomOut();"; Tooltip = "Zoom Out"; @@ -96,7 +99,7 @@ %this.timeMoveBackButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 8; + Frame = $EditorIcon::arrow_left; HorizSizing = "right"; Position = "30" SPC %bottom; Command = %this.getId() @ ".timeMoveBack();"; @@ -108,7 +111,7 @@ %this.timeMoveForwardButton = new GuiButtonCtrl() { Class = "EditorIconButton"; - Frame = 4; + Frame = $EditorIcon::arrow_right; HorizSizing = "left"; Position = (getWord(%this.graph.extent, 0) + 6) SPC %bottom; Command = %this.getId() @ ".timeMoveForward();"; diff --git a/editor/EditorCore/EditorButtonBar.cs b/editor/EditorCore/EditorButtonBar.cs index 750eef8b0..a8f2fe5bb 100644 --- a/editor/EditorCore/EditorButtonBar.cs +++ b/editor/EditorCore/EditorButtonBar.cs @@ -1,5 +1,10 @@ -function EditorButtonBar::addButton(%this, %click, %frame, %tooltip, %enabled) +// %tooltipFunction is optional and names a method on the tool that answers the +// tip text. It exists for a button that does the same job to more than one kind +// of thing: a "new in this category" button whose tip says "Profile" while a +// cursor is selected is describing the wrong thing. Omit it and %tooltip is +// simply fixed, as it is for every other button. +function EditorButtonBar::addButton(%this, %click, %frame, %tooltip, %enabled, %tooltipFunction) { if(!isObject(%this.tool)) { @@ -15,6 +20,7 @@ Command = %this.tool.getId() @ "." @ %click @ "();"; Tooltip = %tooltip; EnabledFunction = %enabled; + TooltipFunction = %tooltipFunction; }; ThemeManager.setProfile(%button, "iconButtonProfile"); %this.add(%button); @@ -23,6 +29,8 @@ { %button.setActive(%this.tool.call(%enabled)); } + + return %button; } function EditorButtonBar::refreshEnabled(%this) @@ -34,6 +42,10 @@ { %button.setActive(%this.tool.call(%button.EnabledFunction)); } + if(%button.TooltipFunction !$= "") + { + %button.Tooltip = %this.tool.call(%button.TooltipFunction); + } } } diff --git a/editor/EditorCore/EditorCore.cs b/editor/EditorCore/EditorCore.cs index 97f14e468..e5ca147fa 100644 --- a/editor/EditorCore/EditorCore.cs +++ b/editor/EditorCore/EditorCore.cs @@ -22,6 +22,10 @@ function EditorCore::create( %this ) { + // First, and before any editor builds a control: every icon in every editor + // is a frame index into the shared sheets, and these are the names for them. + exec("./EditorIcons.cs"); + %this.editorKeyMap = new ActionMap(); if(!isObject(AppCore)) { @@ -83,14 +87,22 @@ 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. + // + // The window's own X cannot be guarded this way. It posts the quit + // from the window procedure with no script in between. new GuiMenuItemCtrl() { Text = "Close Project"; - Command = "restartInstance();"; + Command = "EditorCore.guardedCommand(\"restartInstance();\");"; }; new GuiMenuItemCtrl() { Text = "Exit"; - Command = "quit();"; + Command = "EditorCore.guardedCommand(\"quit();\");"; }; }; new GuiMenuItemCtrl() { @@ -118,6 +130,16 @@ 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"; @@ -149,6 +171,27 @@ 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"; @@ -198,46 +241,46 @@ new GuiMenuItemCtrl() { Text = "-"; }; new GuiMenuItemCtrl() { Text = "Align Top"; - Command = "GuiEditor.brain.Justify(3);"; + Command = "GuiEditor.Justify(3);"; Accelerator = "Ctrl T"; }; new GuiMenuItemCtrl() { Text = "Align Bottom"; - Command = "GuiEditor.brain.Justify(4);"; + Command = "GuiEditor.Justify(4);"; Accelerator = "Ctrl B"; }; new GuiMenuItemCtrl() { Text = "Align Left"; - Command = "GuiEditor.brain.Justify(0);"; + Command = "GuiEditor.Justify(0);"; Accelerator = "Ctrl L"; }; new GuiMenuItemCtrl() { Text = "Align Right"; - Command = "GuiEditor.brain.Justify(2);"; + Command = "GuiEditor.Justify(2);"; Accelerator = "Ctrl R"; }; new GuiMenuItemCtrl() { Text = "-"; }; new GuiMenuItemCtrl() { Text = "Center Horizontally"; - Command = "GuiEditor.brain.Justify(1);"; + Command = "GuiEditor.Justify(1);"; }; new GuiMenuItemCtrl() { Text = "Space Vertically"; - Command = "GuiEditor.brain.Justify(5);"; + Command = "GuiEditor.Justify(5);"; }; new GuiMenuItemCtrl() { Text = "Space Horizontally"; - Command = "GuiEditor.brain.Justify(6);"; + Command = "GuiEditor.Justify(6);"; }; new GuiMenuItemCtrl() { Text = "-"; }; new GuiMenuItemCtrl() { Text = "Bring to Front"; - Command = "GuiEditor.brain.BringToFront();"; + Command = "GuiEditor.BringToFront();"; Accelerator = "Ctrl-Shift Up"; }; new GuiMenuItemCtrl() { Text = "Push to Back"; - Command = "GuiEditor.brain.PushToBack();"; + Command = "GuiEditor.PushToBack();"; Accelerator = "Ctrl-Shift Down"; }; new GuiMenuItemCtrl() { Text = "-"; }; @@ -264,10 +307,13 @@ 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 D"; + Accelerator = "Ctrl-Shift A"; }; }; new GuiMenuItemCtrl() { @@ -414,6 +460,24 @@ } } +// 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. +// +// The Gui Editor is the only editor with a document to lose. If another ever +// grows one, this is where it joins in. +function EditorCore::guardedCommand(%this, %command) +{ + if(isObject(GuiEditor)) + { + GuiEditor.guardDocument(%command); + return; + } + + eval(%command); +} + function EditorCore::RegisterEditor(%this, %name, %editor) { %this.page[%name] = new GuiTabPageCtrl() diff --git a/editor/EditorCore/EditorIconButton.cs b/editor/EditorCore/EditorIconButton.cs index f7c48db9c..0cebcac94 100644 --- a/editor/EditorCore/EditorIconButton.cs +++ b/editor/EditorCore/EditorIconButton.cs @@ -29,25 +29,47 @@ } } +// Every hover and press handler below is guarded on isActive(), because the +// engine still delivers touch events to a disabled control: GuiControl:: +// findHitControl tests mVisible and mUseInput and never mActive. Without the +// guard, moving the pointer over a disabled button repainted its icon in the +// enabled hover color and the disabled look was gone until something else +// forced it back -- the button read as clickable when it was not. function EditorIconButton::onTouchEnter(%this) { + if(!%this.isActive()) + { + return; + } %this.icon.fadeTo(ThemeManager.activeTheme.iconButtonProfile.fontColorHL, 200, "EaseInOut"); %this.icon.growTo("18 18", 200, "EaseInOut"); } function EditorIconButton::onTouchLeave(%this) { + if(!%this.isActive()) + { + return; + } %this.icon.fadeTo(ThemeManager.activeTheme.iconButtonProfile.fontColor, 200, "EaseInOut"); %this.icon.growTo("16 16", 200, "EaseInOut"); } function EditorIconButton::onTouchDown(%this) { + if(!%this.isActive()) + { + return; + } %this.icon.fadeTo(ThemeManager.activeTheme.iconButtonProfile.fontColorSL, 200, "EaseInOut"); } function EditorIconButton::onTouchUp(%this) { + if(!%this.isActive()) + { + return; + } %this.icon.fadeTo(ThemeManager.activeTheme.iconButtonProfile.fontColorHL, 200, "EaseInOut"); } diff --git a/editor/EditorCore/EditorIcons.cs b/editor/EditorCore/EditorIcons.cs new file mode 100644 index 000000000..34fbaaa56 --- /dev/null +++ b/editor/EditorCore/EditorIcons.cs @@ -0,0 +1,324 @@ +//----------------------------------------------------------------------------- +// Frame indices into the editor icon sheets. GENERATED - do not hand-edit. +// +// EditorCore:editorIcons16/24/32/48 are the same 32x10 grid of the same 304 +// icons at four sizes, so one index names the same icon in every sheet and a +// control can change size without changing its Frame. +// +// Names come from the source art. Four icons ship in both an outline and a +// solid form under names that collapse together; the outline one carries an +// _alt suffix. Three began with a digit, which is not a legal identifier, and +// were turned around: grid_2x2, grid_3x3, grid_3x3_2. +// +// Indices are alphabetical, so inserting an icon reflows them. Reference an +// icon through its constant and a regenerated sheet costs nothing; a raw +// number will silently become the wrong picture. +//----------------------------------------------------------------------------- + +$EditorIcon::air_signal = 0; +$EditorIcon::align_bottom = 1; +$EditorIcon::align_center = 2; +$EditorIcon::align_just = 3; +$EditorIcon::align_left = 4; +$EditorIcon::align_middle = 5; +$EditorIcon::align_right = 6; +$EditorIcon::align_top = 7; +$EditorIcon::app_window = 8; +$EditorIcon::app_window_alt = 9; +$EditorIcon::app_window_black = 10; +$EditorIcon::app_window_black_alt = 11; +$EditorIcon::app_window_cross = 12; +$EditorIcon::app_window_cross_alt = 13; +$EditorIcon::app_window_shell = 14; +$EditorIcon::app_window_shell_alt = 15; +$EditorIcon::arrow_bottom = 16; +$EditorIcon::arrow_bottom_left = 17; +$EditorIcon::arrow_bottom_rigth = 18; +$EditorIcon::arrow_l = 19; +$EditorIcon::arrow_left = 20; +$EditorIcon::arrow_r = 21; +$EditorIcon::arrow_right = 22; +$EditorIcon::arrow_top = 23; +$EditorIcon::arrow_top_left = 24; +$EditorIcon::arrow_top_right = 25; +$EditorIcon::arrow_two_head = 26; +$EditorIcon::arrow_two_head_2 = 27; +$EditorIcon::attention = 28; +$EditorIcon::balance = 29; +$EditorIcon::battery = 30; +$EditorIcon::bell = 31; +$EditorIcon::book = 32; +$EditorIcon::book_side = 33; +$EditorIcon::bookmark_1 = 34; +$EditorIcon::bookmark_2 = 35; +$EditorIcon::box = 36; +$EditorIcon::br_down = 37; +$EditorIcon::br_next = 38; +$EditorIcon::br_prev = 39; +$EditorIcon::br_up = 40; +$EditorIcon::brackets = 41; +$EditorIcon::browser = 42; +$EditorIcon::brush = 43; +$EditorIcon::bug = 44; +$EditorIcon::burst = 45; +$EditorIcon::calc = 46; +$EditorIcon::calendar_1 = 47; +$EditorIcon::calendar_2 = 48; +$EditorIcon::cancel = 49; +$EditorIcon::case = 50; +$EditorIcon::cassette = 51; +$EditorIcon::cc = 52; +$EditorIcon::cert = 53; +$EditorIcon::chart_bar = 54; +$EditorIcon::chart_line = 55; +$EditorIcon::chart_line_2 = 56; +$EditorIcon::chart_pie = 57; +$EditorIcon::chat_bubble_message_square = 58; +$EditorIcon::checkbox_checked = 59; +$EditorIcon::checkbox_unchecked = 60; +$EditorIcon::checkmark = 61; +$EditorIcon::clip = 62; +$EditorIcon::clipboard_copy = 63; +$EditorIcon::clipboard_cut = 64; +$EditorIcon::clipboard_past = 65; +$EditorIcon::clock = 66; +$EditorIcon::cloud = 67; +$EditorIcon::cloud_rain = 68; +$EditorIcon::coffe_cup = 69; +$EditorIcon::cog = 70; +$EditorIcon::cogs = 71; +$EditorIcon::comp = 72; +$EditorIcon::compass = 73; +$EditorIcon::connect = 74; +$EditorIcon::contact = 75; +$EditorIcon::contact_card = 76; +$EditorIcon::cube = 77; +$EditorIcon::cur_bp = 78; +$EditorIcon::cur_dollar = 79; +$EditorIcon::cur_euro = 80; +$EditorIcon::cur_yen = 81; +$EditorIcon::cursor_H_split = 82; +$EditorIcon::cursor_V_split = 83; +$EditorIcon::cursor_arrow = 84; +$EditorIcon::cursor_drag_arrow = 85; +$EditorIcon::cursor_drag_arrow_2 = 86; +$EditorIcon::cursor_drag_hand = 87; +$EditorIcon::cursor_hand = 88; +$EditorIcon::dashboard = 89; +$EditorIcon::db = 90; +$EditorIcon::delete = 91; +$EditorIcon::delete_server = 92; +$EditorIcon::disconnected = 93; +$EditorIcon::doc_delete = 94; +$EditorIcon::doc_edit = 95; +$EditorIcon::doc_empty = 96; +$EditorIcon::doc_export = 97; +$EditorIcon::doc_import = 98; +$EditorIcon::doc_lines = 99; +$EditorIcon::doc_lines_stright = 100; +$EditorIcon::doc_minus = 101; +$EditorIcon::doc_new = 102; +$EditorIcon::doc_plus = 103; +$EditorIcon::document = 104; +$EditorIcon::download = 105; +$EditorIcon::eject = 106; +$EditorIcon::emotion_sad = 107; +$EditorIcon::emotion_smile = 108; +$EditorIcon::expand = 109; +$EditorIcon::export = 110; +$EditorIcon::eye = 111; +$EditorIcon::eye_inv = 112; +$EditorIcon::facebook = 113; +$EditorIcon::fastforward_next = 114; +$EditorIcon::fill = 115; +$EditorIcon::filter = 116; +$EditorIcon::fire = 117; +$EditorIcon::flag = 118; +$EditorIcon::flag_2 = 119; +$EditorIcon::folder = 120; +$EditorIcon::folder_arrow = 121; +$EditorIcon::folder_delete = 122; +$EditorIcon::folder_minus = 123; +$EditorIcon::folder_open = 124; +$EditorIcon::folder_plus = 125; +$EditorIcon::font_bold = 126; +$EditorIcon::font_italic = 127; +$EditorIcon::font_size = 128; +$EditorIcon::font_strokethrough = 129; +$EditorIcon::font_underline = 130; +$EditorIcon::game_pad = 131; +$EditorIcon::glasses = 132; +$EditorIcon::globe_1 = 133; +$EditorIcon::globe_2 = 134; +$EditorIcon::globe_3 = 135; +$EditorIcon::google = 136; +$EditorIcon::grid_2x2 = 137; +$EditorIcon::grid_3x3 = 138; +$EditorIcon::grid_3x3_2 = 139; +$EditorIcon::hand_1 = 140; +$EditorIcon::hand_2 = 141; +$EditorIcon::hand_contra = 142; +$EditorIcon::hand_pro = 143; +$EditorIcon::hanger = 144; +$EditorIcon::headphones = 145; +$EditorIcon::heart = 146; +$EditorIcon::heart_empty = 147; +$EditorIcon::home = 148; +$EditorIcon::image_text = 149; +$EditorIcon::import = 150; +$EditorIcon::inbox = 151; +$EditorIcon::indent_decrease = 152; +$EditorIcon::indent_increase = 153; +$EditorIcon::info = 154; +$EditorIcon::inject = 155; +$EditorIcon::invisible_light = 156; +$EditorIcon::invisible_revert = 157; +$EditorIcon::iphone = 158; +$EditorIcon::key = 159; +$EditorIcon::layers_1 = 160; +$EditorIcon::layers_2 = 161; +$EditorIcon::lightbulb = 162; +$EditorIcon::lighting = 163; +$EditorIcon::link = 164; +$EditorIcon::list_bullets = 165; +$EditorIcon::list_num = 166; +$EditorIcon::loading_throbber = 167; +$EditorIcon::lock_open = 168; +$EditorIcon::magic_wand = 169; +$EditorIcon::magic_wand_2 = 170; +$EditorIcon::mail = 171; +$EditorIcon::mail_2 = 172; +$EditorIcon::message_attention = 173; +$EditorIcon::mic = 174; +$EditorIcon::microphone = 175; +$EditorIcon::money = 176; +$EditorIcon::monitor = 177; +$EditorIcon::movie = 178; +$EditorIcon::music = 179; +$EditorIcon::music_square = 180; +$EditorIcon::net_comp = 181; +$EditorIcon::network = 182; +$EditorIcon::not_connected = 183; +$EditorIcon::notepad = 184; +$EditorIcon::notepad_2 = 185; +$EditorIcon::off = 186; +$EditorIcon::on = 187; +$EditorIcon::on_off = 188; +$EditorIcon::openid = 189; +$EditorIcon::padlock_closed = 190; +$EditorIcon::padlock_open = 191; +$EditorIcon::page_layout = 192; +$EditorIcon::paper_airplane = 193; +$EditorIcon::paragraph = 194; +$EditorIcon::pencil = 195; +$EditorIcon::phone = 196; +$EditorIcon::phone_1 = 197; +$EditorIcon::phone_2 = 198; +$EditorIcon::phone_touch = 199; +$EditorIcon::photo = 200; +$EditorIcon::picture = 201; +$EditorIcon::pin = 202; +$EditorIcon::pin_2 = 203; +$EditorIcon::pin_map = 204; +$EditorIcon::pin_map_down = 205; +$EditorIcon::pin_map_left = 206; +$EditorIcon::pin_map_right = 207; +$EditorIcon::pin_map_top = 208; +$EditorIcon::pin_sq_down = 209; +$EditorIcon::pin_sq_left = 210; +$EditorIcon::pin_sq_right = 211; +$EditorIcon::pin_sq_top = 212; +$EditorIcon::playback_ff = 213; +$EditorIcon::playback_next = 214; +$EditorIcon::playback_pause = 215; +$EditorIcon::playback_play = 216; +$EditorIcon::playback_prev = 217; +$EditorIcon::playback_rec = 218; +$EditorIcon::playback_reload = 219; +$EditorIcon::playback_rew = 220; +$EditorIcon::playback_stop = 221; +$EditorIcon::podcast = 222; +$EditorIcon::preso = 223; +$EditorIcon::print = 224; +$EditorIcon::push_pin = 225; +$EditorIcon::redo = 226; +$EditorIcon::refresh = 227; +$EditorIcon::reload = 228; +$EditorIcon::rewind_previous = 229; +$EditorIcon::rnd_br_down = 230; +$EditorIcon::rnd_br_first = 231; +$EditorIcon::rnd_br_last = 232; +$EditorIcon::rnd_br_next = 233; +$EditorIcon::rnd_br_prev = 234; +$EditorIcon::rnd_br_up = 235; +$EditorIcon::round = 236; +$EditorIcon::round_and_up = 237; +$EditorIcon::round_arrow_left = 238; +$EditorIcon::round_arrow_right = 239; +$EditorIcon::round_checkmark = 240; +$EditorIcon::round_delete = 241; +$EditorIcon::round_minus = 242; +$EditorIcon::round_plus = 243; +$EditorIcon::rss = 244; +$EditorIcon::rss_sq = 245; +$EditorIcon::sand = 246; +$EditorIcon::sat_dish = 247; +$EditorIcon::save = 248; +$EditorIcon::server = 249; +$EditorIcon::shapes = 250; +$EditorIcon::share = 251; +$EditorIcon::share_2 = 252; +$EditorIcon::shield = 253; +$EditorIcon::shield_2 = 254; +$EditorIcon::shop_cart = 255; +$EditorIcon::shopping_bag = 256; +$EditorIcon::shopping_bag_dollar = 257; +$EditorIcon::sound_high = 258; +$EditorIcon::sound_low = 259; +$EditorIcon::sound_mute = 260; +$EditorIcon::spechbubble = 261; +$EditorIcon::spechbubble_2 = 262; +$EditorIcon::spechbubble_sq = 263; +$EditorIcon::spechbubble_sq_line = 264; +$EditorIcon::sq_br_down = 265; +$EditorIcon::sq_br_first = 266; +$EditorIcon::sq_br_last = 267; +$EditorIcon::sq_br_next = 268; +$EditorIcon::sq_br_prev = 269; +$EditorIcon::sq_br_up = 270; +$EditorIcon::sq_down = 271; +$EditorIcon::sq_minus = 272; +$EditorIcon::sq_next = 273; +$EditorIcon::sq_plus = 274; +$EditorIcon::sq_prev = 275; +$EditorIcon::sq_up = 276; +$EditorIcon::square_shape = 277; +$EditorIcon::stairs_down = 278; +$EditorIcon::stairs_up = 279; +$EditorIcon::star = 280; +$EditorIcon::star_fav = 281; +$EditorIcon::star_fav_empty = 282; +$EditorIcon::stop_watch = 283; +$EditorIcon::sun = 284; +$EditorIcon::tag = 285; +$EditorIcon::tape = 286; +$EditorIcon::target = 287; +$EditorIcon::text_curstor = 288; +$EditorIcon::text_letter_t = 289; +$EditorIcon::top_right_expand = 290; +$EditorIcon::track = 291; +$EditorIcon::trash = 292; +$EditorIcon::twitter = 293; +$EditorIcon::twitter_2 = 294; +$EditorIcon::undo = 295; +$EditorIcon::user = 296; +$EditorIcon::users = 297; +$EditorIcon::vault = 298; +$EditorIcon::wallet = 299; +$EditorIcon::wifi_router = 300; +$EditorIcon::wireless_signal = 301; +$EditorIcon::wrench = 302; +$EditorIcon::wrench_plus = 303; +$EditorIcon::wrench_plus_2 = 304; +$EditorIcon::youtube = 305; +$EditorIcon::zoom = 306; diff --git a/editor/EditorCore/Themes/TorqueSuit/TorqueSuitTheme.cs b/editor/EditorCore/Themes/TorqueSuit/TorqueSuitTheme.cs index dc0c2b634..24e29c3e6 100644 --- a/editor/EditorCore/Themes/TorqueSuit/TorqueSuitTheme.cs +++ b/editor/EditorCore/Themes/TorqueSuit/TorqueSuitTheme.cs @@ -61,6 +61,9 @@ paddingHL = 2; paddingSL = 2; paddingNA = 2; + + borderColorSL = %this.color5; + borderSL = 2; }; %this.iconButtonProfile = new GuiControlProfile() diff --git a/editor/EditorCore/images/editorIcons16.asset.taml b/editor/EditorCore/images/editorIcons16.asset.taml index 090cb0c2b..e72bbaeaa 100644 --- a/editor/EditorCore/images/editorIcons16.asset.taml +++ b/editor/EditorCore/images/editorIcons16.asset.taml @@ -2,7 +2,7 @@ AssetName="editorIcons16" ImageFile="editorIcons16.png" CellCountX="32" - CellCountY="2" + CellCountY="10" CellWidth="16" CellHeight="16" AssetInternal="1" /> diff --git a/editor/EditorCore/images/editorIcons16.png b/editor/EditorCore/images/editorIcons16.png index 1cdac4710..0d09ee41d 100644 Binary files a/editor/EditorCore/images/editorIcons16.png and b/editor/EditorCore/images/editorIcons16.png differ diff --git a/editor/EditorCore/images/editorIcons24.asset.taml b/editor/EditorCore/images/editorIcons24.asset.taml new file mode 100644 index 000000000..155781c73 --- /dev/null +++ b/editor/EditorCore/images/editorIcons24.asset.taml @@ -0,0 +1,8 @@ + diff --git a/editor/EditorCore/images/editorIcons24.png b/editor/EditorCore/images/editorIcons24.png new file mode 100644 index 000000000..b019fe64b Binary files /dev/null and b/editor/EditorCore/images/editorIcons24.png differ diff --git a/editor/EditorCore/images/editorIcons32.asset.taml b/editor/EditorCore/images/editorIcons32.asset.taml new file mode 100644 index 000000000..3b4a3eda6 --- /dev/null +++ b/editor/EditorCore/images/editorIcons32.asset.taml @@ -0,0 +1,8 @@ + diff --git a/editor/EditorCore/images/editorIcons32.png b/editor/EditorCore/images/editorIcons32.png new file mode 100644 index 000000000..377e0eab7 Binary files /dev/null and b/editor/EditorCore/images/editorIcons32.png differ diff --git a/editor/EditorCore/images/editorIcons48.asset.taml b/editor/EditorCore/images/editorIcons48.asset.taml new file mode 100644 index 000000000..bda716ea7 --- /dev/null +++ b/editor/EditorCore/images/editorIcons48.asset.taml @@ -0,0 +1,8 @@ + diff --git a/editor/EditorCore/images/editorIcons48.png b/editor/EditorCore/images/editorIcons48.png new file mode 100644 index 000000000..0799816ae Binary files /dev/null and b/editor/EditorCore/images/editorIcons48.png differ diff --git a/editor/GuiEditor/GuiEditor.cs b/editor/GuiEditor/GuiEditor.cs index 5c1440a21..4ee717b15 100644 --- a/editor/GuiEditor/GuiEditor.cs +++ b/editor/GuiEditor/GuiEditor.cs @@ -23,15 +23,16 @@ function GuiEditor::create( %this ) { exec("./scripts/GuiEditorBrain.cs"); + exec("./scripts/GuiEditorControlIcons.cs"); exec("./scripts/GuiEditorControlListWindow.cs"); - exec("./scripts/GuiEditorControlListBox.cs"); + exec("./scripts/GuiEditorControlGroup.cs"); + exec("./scripts/GuiEditorControlTile.cs"); exec("./scripts/GuiEditorInspectorWindow.cs"); - exec("./scripts/GuiEditorInspector.cs"); exec("./scripts/GuiEditorExplorerWindow.cs"); exec("./scripts/GuiEditorExplorerTree.cs"); exec("./scripts/GuiEditorSaveGuiDialog.cs"); + exec("./scripts/GuiEditorConfirmSaveDialog.cs"); exec("./scripts/GuiEditorGridSizeDialog.cs"); - exec("./scripts/GuiEditorColorWindow.cs"); exec("./scripts/GuiEditorToolsWindow.cs"); exec("./scripts/GuiProfileEditorDialog.cs"); exec("./scripts/GuiProfileEditorColorPopup.cs"); @@ -42,6 +43,7 @@ exec("./scripts/GuiProfileEditorFieldRow.cs"); exec("./scripts/GuiProfileEditorStateColorRow.cs"); exec("./scripts/GuiProfileEditorProfileForm.cs"); + exec("./scripts/GuiProfileEditorCursorForm.cs"); exec("./scripts/ProfileThemeEditForm.cs"); exec("./scripts/GuiProfileEditorLibrary.cs"); exec("./scripts/GuiProfileEditorTree.cs"); @@ -51,8 +53,38 @@ exec("./scripts/GuiEditorThemeApplier.cs"); exec("./scripts/GuiEditorThemeDialog.cs"); + // 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"); + exec("./scripts/GuiEditorHeaderBlock.cs"); + exec("./scripts/GuiEditorDynamicFields.cs"); + exec("./scripts/GuiEditorItemRow.cs"); + exec("./scripts/GuiEditorItemsBlock.cs"); + exec("./scripts/GuiEditorInspectorPane.cs"); + + // Undo. The engine has owned the machinery all along - GuiEditCtrl holds an + // UndoManager and a trash group it never empties - and nothing had ever + // built it an action. + exec("./scripts/GuiEditorUndoAction.cs"); + exec("./scripts/GuiEditorUndoRecorder.cs"); + + // Copy, cut and paste, which is undo's machinery plus a deep clone. + exec("./scripts/GuiEditorClipboard.cs"); + %this.guiPage = EditorCore.RegisterEditor("Gui Editor", %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. + %this.controlIcons = new ScriptObject() + { + class = "GuiEditorControlIcons"; + }; + // The theme library and the applier are both wanted before the Profile // Editor is ever opened - the Set Theme button and every newly dropped // control go through them - so they are built with the editor rather than on @@ -73,6 +105,24 @@ class = "GuiEditorThemeApplier"; library = %this.themeLibrary; }; + // Every change to the Gui being authored goes through the recorder. It asks + // the brain for the UndoManager when it needs one, so it can be built before + // the brain is. + %this.undoRecorder = new ScriptObject() + { + class = "GuiEditorUndoRecorder"; + owner = %this; + }; + + // Holds copied controls for as long as the editor is open, which is longer + // than any one document: a copy taken from one Gui can be pasted into the + // next one opened. + %this.clipboard = new ScriptObject() + { + class = "GuiEditorClipboard"; + owner = %this; + }; + %this.content = %this.createFrameSet(); %this.brain = new GuiEditCtrl() @@ -221,29 +271,6 @@ class = "SimulatedCanvas"; %this.brain.root = %this.rootGui; %this.explorerWindow.inspect(%this.rootGui); - /* %this.colorWindow = new GuiWindowCtrl() - { - Class = "GuiEditorColorWindow"; - HorizSizing = "right"; - VertSizing = "bottom"; - Position = "610 0"; - Extent = "400 380"; - MinExtent = "100 100"; - text = "Color Test"; - canMove = true; - canClose = false; - canMinimize = true; - canMaximize = false; - resizeWidth = true; - resizeHeight = true; - }; - ThemeManager.setProfile(%this.colorWindow, "windowProfile"); - ThemeManager.setProfile(%this.colorWindow, "windowContentProfile", "ContentProfile"); - ThemeManager.setProfile(%this.colorWindow, "windowButtonProfile", "CloseButtonProfile"); - ThemeManager.setProfile(%this.colorWindow, "windowButtonProfile", "MinButtonProfile"); - ThemeManager.setProfile(%this.colorWindow, "windowButtonProfile", "MaxButtonProfile"); - %this.guiPage.add(%this.colorWindow); */ - EditorCore.FinishRegistration(%this.guiPage); } @@ -269,7 +296,12 @@ class = "SimulatedCanvas"; %leftID = getWord(%idList, 0); %rightID = getWord(%idList, 1); %content.anchorFrame(%rightID); - %content.setFrameSize(%rightID, 300); + + // 340, not 300: this column holds the control palette, whose grid view fits + // as many 100-pixel tiles per row as the width allows. At 300, once the + // scroll bar is taken out, that is two -- and the leftover is shared between + // them, so the tiles sit in gappy columns. 340 makes it three. + %content.setFrameSize(%rightID, 340); %ids = %content.createHorizontalSplit(%leftID); %inspectorFrameID = getWord(%ids, 0); @@ -312,6 +344,27 @@ class = "SimulatedCanvas"; { %this.themeLibrary.delete(); } + + if(isObject(%this.controlIcons)) + { + %this.controlIcons.delete(); + } + + // Empty the stacks while the brain (and so the UndoManager it owns) is still + // here, rather than leaving the actions to the manager's destructor during + // canvas teardown. + if(isObject(%this.undoRecorder)) + { + %this.undoRecorder.clear(); + %this.undoRecorder.delete(); + } + + // The copies it holds are real controls wearing real profiles, so they go the + // same way and for the same reason: before the profiles do. + if(isObject(%this.clipboard)) + { + %this.clipboard.delete(); + } } function GuiEditor::open(%this, %content) @@ -324,9 +377,24 @@ class = "SimulatedCanvas"; } EditorCore.menuBar.setMenuActive("File", true); - //EditorCore.menuBar.setMenuActive("Edit", true); //These features still need development + 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(); + + // 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 + // document since. + %this.refreshDocumentTitle(); + editorMode(true); } @@ -340,7 +408,18 @@ class = "SimulatedCanvas"; } //MENU FUNCTIONS--------------------------------------------------------------- +// +// The two that replace the document ask first and then do it. The asking half is +// what the menu calls; the doing half is what the guard runs once there is +// nothing left to lose. +//----------------------------------------------------------------------------- + function GuiEditor::NewGui(%this) +{ + %this.guardDocument("GuiEditor.newGuiNow();"); +} + +function GuiEditor::newGuiNow(%this) { %this.rootGui.clear(); %this.fileName = ""; @@ -351,13 +430,27 @@ class = "SimulatedCanvas"; %this.brain.clearSelection(); %this.explorerWindow.tree.refresh(); + // Every record on the stack names controls that have just been freed. + %this.undoRecorder.clear(); + // A new Gui joins the theme this session is working in, so the first control // dropped into it is already themed. %theme = %this.defaultTheme(); %this.themeName = isObject(%theme) ? %theme.getName() : ""; + + // Last, and after the clear above: emptying the stack leaves the recorder + // somewhere no replay can reach, which is right for a document that lost its + // records and wrong for this one, which has nothing in it to save. + %this.undoRecorder.markClean(); + %this.refreshFileMenu(); } function GuiEditor::OpenGui(%this) +{ + %this.guardDocument("GuiEditor.openGuiNow();"); +} + +function GuiEditor::openGuiNow(%this) { %path = pathConcat(getMainDotCsDir(), ProjectManager.getProjectFolder()); %dialog = new OpenFileDialog() @@ -373,46 +466,76 @@ class = "SimulatedCanvas"; if ( %result ) { - if(fileExt(%dialog.fileName) $= ".taml") - { - %guiContent = TAMLRead(%dialog.fileName); - %includesSimulatedCanvas = (%guiContent.class $= "SimulatedCanvas"); - } - else - { - exec(%dialog.fileName); - } - if(%includesSimulatedCanvas $= "") - { - %includesSimulatedCanvas = true; - } - if(isObject(%guiContent)) - { - %this.fileName = fileName(%dialog.fileName); - %this.filePath = %dialog.fileName; - %this.formatIndex = 0; - if(getSubStr(%dialog.fileName, strlen(%dialog.fileName) - 5, 5) $= ".taml") - { - %this.formatIndex = 1; - } - %this.folder = makeRelativePath(filePath(%dialog.fileName), getMainDotCsDir()); - %this.module = EditorCore.findModuleOfPath(%dialog.fileName); - %this.DisplayGuiContent(%guiContent, %includesSimulatedCanvas); - } - else - { - EditorCore.alert("Something went wrong while opening the Gui File. Gui Files should be structures with the root object assigned to %guiContent. If this file was made outside of the editor, you can change it manually and then open it in the Gui Editor."); - } + %this.loadGuiFile(%dialog.fileName); } // Cleanup %dialog.delete(); } +// Read a Gui file and make it the document. Everything about opening except +// choosing the file, so that Revert - which has already chosen - reads exactly +// what Open reads and the two cannot drift apart. +function GuiEditor::loadGuiFile(%this, %path) +{ + if(fileExt(%path) $= ".taml") + { + %guiContent = TAMLRead(%path); + %includesSimulatedCanvas = (%guiContent.class $= "SimulatedCanvas"); + } + else + { + exec(%path); + } + if(%includesSimulatedCanvas $= "") + { + %includesSimulatedCanvas = true; + } + if(isObject(%guiContent)) + { + %this.fileName = fileName(%path); + %this.filePath = %path; + %this.formatIndex = 0; + if(getSubStr(%path, strlen(%path) - 5, 5) $= ".taml") + { + %this.formatIndex = 1; + } + %this.folder = makeRelativePath(filePath(%path), getMainDotCsDir()); + %this.module = EditorCore.findModuleOfPath(%path); + %this.DisplayGuiContent(%guiContent, %includesSimulatedCanvas); + %this.refreshFileMenu(); + } + else + { + EditorCore.alert("Something went wrong while opening the Gui File. Gui Files should be structures with the root object assigned to %guiContent. If this file was made outside of the editor, you can change it manually and then open it in the Gui Editor."); + } +} + +// Throw away everything done since the last save by reading the file again. +// Guarded like the rest: it is the most deliberate discard there is, and it +// should still say what it is about to lose. +function GuiEditor::Revert(%this) +{ + if(%this.filePath $= "") + { + return; + } + + %this.guardDocument("GuiEditor.revertNow();"); +} + +function GuiEditor::revertNow(%this) +{ + %this.loadGuiFile(%this.filePath); +} + function GuiEditor::DisplayGuiContent(%this, %content, %includesSimulatedCanvas) { %this.rootGui.deleteObjects(); %this.brain.clearSelection(); + // The document the stack was recorded against has just been deleted. + %this.undoRecorder.clear(); + // Read off the root before it is unpacked - in the simulated-canvas case the // object carrying the field is deleted a few lines down. %recordedTheme = %content.guiTheme; @@ -440,6 +563,12 @@ class = "SimulatedCanvas"; } %this.adoptTheme(%recordedTheme); + + // What was just put on the canvas is what is on disk. The clear above left + // the recorder unreachable by any replay, which is the right answer for a + // document whose records were thrown away and the wrong one for a document + // that has only this moment been read in. + %this.undoRecorder.markClean(); } function GuiEditor::SaveGui(%this) @@ -502,6 +631,117 @@ class = "GuiProfileEditorDialog"; Canvas.pushDialog(%dialog); } +//THE DOCUMENT----------------------------------------------------------------- +// +// What is being edited and whether it has changes that are not on disk. The +// answer to the second lives on the undo recorder, which is already the one +// funnel every change goes through, so there is no second flag here to fall out +// of step with it. +//----------------------------------------------------------------------------- + +// A Gui that has never been saved has no name to show, so it is given one. It is +// what the file will be called if the user accepts the Save dialog's default, +// which is where the same string comes from. +function GuiEditor::documentName(%this) +{ + return (%this.fileName $= "") ? "untitled.gui" : %this.fileName; +} + +// Called by the recorder every time the document moves, and by the three places +// that change its file name. Guarded because the recorder is built before the +// window is (see create), so a record made in between would arrive early. +function GuiEditor::refreshDocumentTitle(%this) +{ + if(isObject(%this.guiToolsWindow)) + { + %this.guiToolsWindow.showDocument(%this.documentName(), + %this.undoRecorder.isModified()); + } +} + +// 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. +function GuiEditor::refreshFileMenu(%this) +{ + EditorCore.menuBar.setMenuActive("Revert", %this.filePath !$= ""); +} + +//----------------------------------------------------------------------------- +// The guard. +// +// Four commands throw the document away: New, Open, Revert, and - from the +// Torque2D menu - Close Project and Exit. Each of them hands what it was about +// to do to guardDocument instead of doing it, and gets it back either at once or +// once the user has answered for it. +// +// A command string rather than a method name, because a menu item in this +// codebase IS a command string: the caller passes exactly what it would have +// run, and quit() and restartInstance() - which belong to nobody - go through +// unchanged. +// +// Not guarded, and it cannot be: the window's own close button. quit() posts the +// quit message the moment it is called, and the X posts it straight from the +// window procedure with no script in between, so there is no moment at which to +// ask. onPreExit runs inside shutdown, long past it. +//----------------------------------------------------------------------------- + +function GuiEditor::guardDocument(%this, %command) +{ + if(!%this.undoRecorder.isModified()) + { + eval(%command); + return; + } + + %this.pendingCommand = %command; + + %width = 460; + %height = 150; + %dialog = new GuiControl() + { + class = "GuiEditorConfirmSaveDialog"; + superclass = "EditorDialog"; + dialogSize = (%width + 8) SPC (%height + 8); + dialogCanClose = true; + dialogText = "Unsaved Changes"; + message = "\"" @ %this.documentName() @ + "\" has changes that have not been saved."; + }; + %dialog.init(%width, %height); + + Canvas.pushDialog(%dialog); +} + +// Go through with whatever was interrupted. Called from exactly two places: the +// Discard button, and the end of a save that really did write a file. +function GuiEditor::runPendingCommand(%this) +{ + %command = %this.pendingCommand; + %this.pendingCommand = ""; + + if(%command !$= "") + { + eval(%command); + } +} + +// And the other end: nothing was saved and nothing else is going to happen. +// Cancel on the prompt, and Cancel on the Save As dialog it can lead to - which +// is the one that matters, because a save that was called off has to call off +// what it was saving for. +function GuiEditor::dropPendingCommand(%this) +{ + %this.pendingCommand = ""; +} + //THEMES----------------------------------------------------------------------- // // A Gui belongs to a theme. Setting one re-profiles every control in the @@ -540,9 +780,18 @@ class = "GuiEditorThemeDialog"; %this.themeName = %theme.getName(); %this.lastThemeName = %this.themeName; + // One undo step for the whole sweep, however many profile slots it fills. + %this.undoRecorder.begin("Set Theme", ""); %changed = %this.themeApplier.applyToChildren(%this.rootGui, %theme, %overrideStandalone); + %this.undoRecorder.end(); + %this.explorerWindow.tree.refresh(); + // The properties pane caches which profiles it offers, so it has to be told + // as well -- otherwise it goes on showing the profile the selected control + // wore before the sweep, from a theme that is no longer the Gui's. + %this.inspectorWindow.onRethemed(%this.inspectorWindow.pane.target); + echo("Gui Editor: " @ %this.themeName @ " applied to " @ %changed @ " profile slot(s)."); } @@ -618,6 +867,16 @@ class = "GuiEditorThemeDialog"; // moved off the doomed profile before the delete rather than after. function GuiEditor::detachTheme(%this, %theme, %profile) { + // The stack is full of profile ids that are about to stop resolving, and a + // detach is not itself something to undo - the profile it moved off will not + // exist to move back to. + %this.undoRecorder.clear(); + + // And the clipboard holds controls whose profile fields are raw pointers to + // the same doomed profiles. Nothing reads them while the copy sits in the + // stash, but a paste would - and by then the profile is gone. + %this.clipboard.clear(); + %this.themeApplier.detach(%this.rootGui, %theme, %profile); } @@ -628,7 +887,13 @@ class = "GuiEditorThemeDialog"; %theme = %this.themeByName(%this.themeName); if(isObject(%theme)) { + // Repairing the document after a revert is not an edit the user made, so + // it is not one they can take back. Suspended rather than cleared: the + // detach that preceded this already emptied the stack. + %this.undoRecorder.suspend(); %this.themeApplier.applyToChildren(%this.rootGui, %theme, false); + %this.undoRecorder.resume(); + %this.explorerWindow.tree.refresh(); } } @@ -651,6 +916,91 @@ class = "GuiEditorThemeDialog"; } } +//----------------------------------------------------------------------------- +// What the legacy .gui script format cannot carry. +// +// FileObject::writeObject walks a control's fields and its child objects, and +// that is the whole of it. Two things in the engine are neither: a list box or +// drop down's static rows, and a frame set's layout. Both are written as TAML +// custom nodes, so saving as .gui drops them silently - which the frame set has +// done since it was written, and which is worth saying out loud now that +// something people use every day is in the same boat. +// +// Returns a sentence naming what would go, or "" when there is nothing to say. +//----------------------------------------------------------------------------- + +function GuiEditor::tamlOnlyStateSummary(%this) +{ + %this.tamlOnlyRows = 0; + %this.tamlOnlyLists = 0; + %this.tamlOnlyFrameSets = 0; + %this.countTamlOnlyState(%this.rootGui); + + if(%this.tamlOnlyRows == 0 && %this.tamlOnlyFrameSets == 0) + { + return ""; + } + + // Only what the document actually holds gets named, in the heading and in + // the tally both: telling someone their frame layouts are at risk when there + // is not a frame set in the Gui is how a warning gets ignored. + %kinds = ""; + %parts = ""; + + if(%this.tamlOnlyRows > 0) + { + %kinds = "list rows"; + %parts = %this.tamlOnlyRows SPC + ((%this.tamlOnlyRows == 1) ? "row" : "rows") SPC "on" SPC + %this.tamlOnlyLists SPC ((%this.tamlOnlyLists == 1) ? "list" : "lists"); + } + + if(%this.tamlOnlyFrameSets > 0) + { + %kinds = (%kinds $= "") ? "frame layouts" : (%kinds @ " or frame layouts"); + + %frames = %this.tamlOnlyFrameSets SPC + ((%this.tamlOnlyFrameSets == 1) ? "frame layout" : "frame layouts"); + %parts = (%parts $= "") ? %frames : (%parts @ " and " @ %frames); + } + + return "This format cannot save" SPC %kinds @ ":" SPC %parts SPC + "would be lost. Save as TAML to keep them."; +} + +function GuiEditor::countTamlOnlyState(%this, %ctrl) +{ + if(!isObject(%ctrl)) + { + return; + } + + // A tree's rows are generated from a root object and are never written, so + // it is not a list for this purpose however much it derives from one. + if((%ctrl.isMemberOfClass("GuiListBoxCtrl") || %ctrl.isMemberOfClass("GuiDropDownCtrl")) && + !%ctrl.isMemberOfClass("GuiTreeViewCtrl")) + { + %rows = %ctrl.getItemCount(); + if(%rows > 0) + { + %this.tamlOnlyRows += %rows; + %this.tamlOnlyLists++; + } + } + + // An unsplit frame set has a layout of one frame holding one control, which + // is what it would be rebuilt as anyway. Eight numbers is one frame. + if(%ctrl.isMemberOfClass("GuiFrameSetCtrl") && getWordCount(%ctrl.getFrameLayout()) > 8) + { + %this.tamlOnlyFrameSets++; + } + + for(%i = 0; %i < %ctrl.getCount(); %i++) + { + %this.countTamlOnlyState(%ctrl.getObject(%i)); + } +} + function GuiEditor::SaveCore(%this, %filePath, %formatIndex, %folder, %module) { // Record the theme on whichever object is about to be written, so reopening @@ -665,6 +1015,15 @@ class = "GuiEditorThemeDialog"; if(%formatIndex == 0) { + // The save dialog says this in its feedback line, but a re-save never + // opens one: Ctrl+S goes straight here with the format the Gui was + // last written in. + %warning = %this.tamlOnlyStateSummary(); + if(%warning !$= "") + { + warn("Gui Editor: " @ %warning); + } + %fo = new FileObject(); %fo.openForWrite(%filePath); %fo.writeLine("//--- Created with the GuiEditor ---//"); @@ -701,47 +1060,219 @@ class = "GuiEditorThemeDialog"; %this.formatIndex = %formatIndex; %this.folder = %folder; %this.module = %module; + + // After the name is set, not before: marking clean refreshes the title, and + // the title is the name that was just written. + %this.undoRecorder.markClean(); + + // The file is on disk, so whatever was waiting on it can go ahead. This is + // one of only two places that releases it, and the only one reached by a + // save - which is what makes a cancelled Save As call the whole thing off + // rather than quietly continuing without a file. + %this.refreshFileMenu(); + %this.runPendingCommand(); } +//UNDO------------------------------------------------------------------------- +// +// The stack lives on the UndoManager the brain (a C++ GuiEditCtrl) has always +// owned; GuiEditorUndoRecorder is what fills it. Undoing writes to the same +// controls the editor writes to, so the recorder is suspended for the duration +// or the replay would record itself. +//----------------------------------------------------------------------------- + function GuiEditor::Undo(%this) { %undoManager = %this.brain.getUndoManager(); + if(%undoManager.getUndoCount() == 0) + { + return; + } + + %this.undoRecorder.suspend(); %undoManager.undo(); + %this.undoRecorder.resume(); + + %this.afterReplay(); } function GuiEditor::Redo(%this) { %undoManager = %this.brain.getUndoManager(); + if(%undoManager.getRedoCount() == 0) + { + return; + } + + %this.undoRecorder.suspend(); %undoManager.redo(); + %this.undoRecorder.resume(); - %count = %undoManager.getRedoCount(); + %this.afterReplay(); +} +// What the rest of the editor has to be told after a replay. The action reports +// which controls it touched on its way through, so the selection can land on +// what just changed - a Ctrl+Z that moves a control scrolled off the top of the +// canvas would otherwise look like nothing happened. +function GuiEditor::afterReplay(%this) +{ + %this.explorerWindow.tree.refresh(); + %this.selectAfterReplay(%this.undoRecorder.replayTouched); + %this.undoRecorder.refreshMenu(); } -function GuiEditor::Cut(%this) +function GuiEditor::selectAfterReplay(%this, %list) { - + %wanted = ""; + + for(%i = 0; %i < getWordCount(%list); %i++) + { + %ctrl = getWord(%list, %i); + + // Undoing an add puts the control in the trash, and redoing a delete + // puts it back there. Either way it is no longer part of the Gui, so + // there is nothing to select. + if(isObject(%ctrl) && %this.inDocument(%ctrl)) + { + %wanted = (%wanted $= "") ? %ctrl : (%wanted SPC %ctrl); + } + } + + %this.brain.restoreSelection(%wanted); +} + +function GuiEditor::inDocument(%this, %ctrl) +{ + %parent = %ctrl.getParent(); + while(isObject(%parent)) + { + if(%parent == %this.rootGui) + { + return true; + } + %parent = %parent.getParent(); + } + + return false; } +//CLIPBOARD-------------------------------------------------------------------- +// +// The copies live on GuiEditorClipboard; these three are the Edit menu's way in. +// Ctrl+X/C/V reach them as menu accelerators, which the canvas only consults +// once the first responder has passed on the key (guiCanvas.cc) - so a text box +// in the properties pane keeps Ctrl+C for its own text, and the canvas gets it +// only when nothing else wanted it. +//----------------------------------------------------------------------------- + function GuiEditor::Copy(%this) { - + %this.clipboard.copy(%this.brain.getSelected()); +} + +// Copy, then delete. Cut is those two things and has no third thing of its own, +// so it says so rather than keeping a second copy of what deleting means. +function GuiEditor::Cut(%this) +{ + if(!%this.clipboard.copy(%this.brain.getSelected())) + { + return; + } + + %this.DeleteSelection(); +} + +// What the Delete key already does, reachable from the Edit menu - which is the +// only place that says the command exists at all. The C++ moves the selection +// into the trash and announces it, and the recorder turns that into one undo +// step (GuiEditorBrain::onTrashSelection). Nothing is deleted for real, so this +// is undoable and the controls are still alive in the trash - which is also why +// a cut and paste keeps the names it had: a trashed control is not in the +// document, so nothing there holds its name. +// +// DeleteSelection rather than Delete, and it has to be: delete is a console +// method on every SimObject, so GuiEditor.Delete() would destroy the editor. +function GuiEditor::DeleteSelection(%this) +{ + %this.brain.deleteSelection(); + %this.brain.onDelete(); } function GuiEditor::Paste(%this) { - + %this.clipboard.paste(); +} + +// A copy that goes straight back into the parent it came from, leaving whatever +// is on the clipboard where it is. +function GuiEditor::Duplicate(%this) +{ + %this.clipboard.duplicate(%this.brain.getSelected()); } +//LAYOUT----------------------------------------------------------------------- +// +// The Layout menu's commands go through here rather than straight to the brain, +// because the brain's C++ says nothing when it aligns or restacks a selection - +// unlike a drag or a nudge, which it brackets with callbacks. Recording either +// side of the call is cheaper than teaching the engine to announce them. +//----------------------------------------------------------------------------- + function GuiEditor::changeExtent(%this, %x, %y) { %set = %this.brain.getSelected(); if(%set.getCount() >= 1) { + %this.undoRecorder.snapshot(%set); + %obj = %set.getObject(0); %ext = %obj.getExtent(); %obj.setExtent(getWord(%ext, 0) + %x, getWord(%ext, 1) + %y); + + // Same kind as a nudge, and for the same reason: holding the key down is + // one resize, not one per repeat. + %this.undoRecorder.commitGeometry("Resize Control", "resize"); + } +} + +function GuiEditor::Justify(%this, %mode) +{ + %this.undoRecorder.snapshot(%this.brain.getSelected()); + %this.brain.Justify(%mode); + %this.undoRecorder.commitGeometry("Align Controls", ""); +} + +function GuiEditor::BringToFront(%this) +{ + %this.restack("BringToFront", "Bring to Front"); +} + +function GuiEditor::PushToBack(%this) +{ + %this.restack("PushToBack", "Push to Back"); +} + +// Both do the same thing to the same one control - the C++ ignores anything but +// a single selection - and both change only its index among its siblings. +function GuiEditor::restack(%this, %method, %name) +{ + %set = %this.brain.getSelected(); + if(%set.getCount() != 1) + { + return; + } + + %ctrl = %set.getObject(0); + %parent = %ctrl.getParent(); + if(!isObject(%parent)) + { + return; } + + %oldIndex = %this.undoRecorder.indexOf(%parent, %ctrl); + %this.brain.call(%method); + %this.undoRecorder.recordMove(%ctrl, %parent, %oldIndex, %name); } function GuiEditor::SetGridSize(%this) @@ -761,13 +1292,20 @@ class = "GuiEditorGridSizeDialog"; Canvas.pushDialog(%dialog); } +// The Layout menu's Snap to Grid toggle, which says whether to use the grid and +// nothing about how big it is. Turning it back on asks the brain what the grid +// was rather than naming a number: setSnapToGrid(0) clears the flag and leaves +// the spacing alone precisely so that it can be picked back up here, and a size +// the user chose in Set Grid Size should not be thrown away by a switch that was +// never about the size. There is always an answer to pick up - the brain sets a +// grid of 10 in onAdd, and 0 only ever means "off". function GuiEditor::SnapToGrid(%this, %gridOn) { if(%gridOn) { - %this.brain.setSnapToGrid(10); + %this.brain.setSnapToGrid(%this.brain.getGridSize()); } - else + else { %this.brain.setSnapToGrid(0); } diff --git a/editor/GuiEditor/images/controlIcons128.asset.taml b/editor/GuiEditor/images/controlIcons128.asset.taml new file mode 100644 index 000000000..36989487f --- /dev/null +++ b/editor/GuiEditor/images/controlIcons128.asset.taml @@ -0,0 +1,8 @@ + diff --git a/editor/GuiEditor/images/controlIcons128.png b/editor/GuiEditor/images/controlIcons128.png new file mode 100644 index 000000000..6db50d89e Binary files /dev/null and b/editor/GuiEditor/images/controlIcons128.png differ diff --git a/editor/GuiEditor/images/controlIcons16.asset.taml b/editor/GuiEditor/images/controlIcons16.asset.taml new file mode 100644 index 000000000..4342901ce --- /dev/null +++ b/editor/GuiEditor/images/controlIcons16.asset.taml @@ -0,0 +1,8 @@ + diff --git a/editor/GuiEditor/images/controlIcons16.png b/editor/GuiEditor/images/controlIcons16.png new file mode 100644 index 000000000..a02e7eba8 Binary files /dev/null and b/editor/GuiEditor/images/controlIcons16.png differ diff --git a/editor/GuiEditor/images/controlIcons64.asset.taml b/editor/GuiEditor/images/controlIcons64.asset.taml new file mode 100644 index 000000000..e55469475 --- /dev/null +++ b/editor/GuiEditor/images/controlIcons64.asset.taml @@ -0,0 +1,8 @@ + diff --git a/editor/GuiEditor/images/controlIcons64.png b/editor/GuiEditor/images/controlIcons64.png new file mode 100644 index 000000000..af7305c12 Binary files /dev/null and b/editor/GuiEditor/images/controlIcons64.png differ diff --git a/editor/GuiEditor/module.taml b/editor/GuiEditor/module.taml index 209f270d9..37067c7c8 100644 --- a/editor/GuiEditor/module.taml +++ b/editor/GuiEditor/module.taml @@ -5,4 +5,9 @@ Description="Allows for the creation and editing of GUI files." ScriptFile="GuiEditor.cs" CreateFunction="create" - DestroyFunction="destroy" /> + DestroyFunction="destroy"> + + diff --git a/editor/GuiEditor/scripts/GuiEditorAnchorPicker.cs b/editor/GuiEditor/scripts/GuiEditorAnchorPicker.cs new file mode 100644 index 000000000..f51b590a7 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorAnchorPicker.cs @@ -0,0 +1,314 @@ + +//----------------------------------------------------------------------------- +// The sizing widget in the Gui Editor's properties pane header: which edges of +// a control stay put when its parent resizes. +// +// Four edge pins say directly what the field means -- which edges stay put -- +// and the readout along the bottom names the pair the Gui file will actually +// carry, so nothing is hidden. +// +// The names below are the anchor set, which now comes first in the engine's +// tables (guiControl.cc): +// +// anchorLeft the left edge stays put +// anchorRight the right edge stays put +// width both edges stay; the width follows the parent +// center neither edge; the control stays centred +// scale both edges scale with the parent +// fill position 0, extent = the parent's inner extent +// +// The original names said the opposite of what they did -- "right" pinned the +// LEFT edge, because parentResized has no branch for it and so nothing moves. +// They still load; they are simply never written any more. This widget only +// ever speaks the new set. +// +// Fill and Scale are not pin states -- fill also zeroes the position and +// measures against the parent's INNER rect, which no combination of pins can +// express -- so they are per-axis toggles that supersede the pins. Their rows +// are labelled H: and V: because two unlabelled rows of identical checkboxes +// gave no clue which axis was which. The chips are named for the values they +// set, so "Scale" is now the field's own word rather than this editor's. +// +// Nothing here has a fixed width. The pane lays its cells out in a GuiGridCtrl, +// which resizes each child to the column it computed, so a hard-coded width is +// overwritten anyway -- and a widget about sizing flags ought to use them. The +// pin cluster stays pinned left, the labels and the readout follow the width. +// +// The creator sets owner inline, then calls build(). Changes are reported to +// owner.onAnchorChanged(); the widget never writes to a control. +//----------------------------------------------------------------------------- + +function GuiEditorAnchorPicker::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +function GuiEditorAnchorPicker::build(%this) +{ + // Tall enough for the pin cluster and the readout beneath it. Height is the + // one dimension worth stating: the grid runs in variable-row mode, where a + // cell takes the taller of its child and the nominal cell size, so this is + // what reserves the room. Width is the grid's to decide. + %w = getWord(%this.getExtent(), 0); + %this.setExtent(%w, 124); + + %this.title = %this.makeLabel(4, 0, %w - 8, "Anchor", "width"); + + // The pin cluster. Each wears the arrow that points at the edge it holds. + %this.topPin = %this.makePin(34, 18, "top", $EditorIcon::arrow_top, "Pin the top edge"); + %this.leftPin = %this.makePin(8, 44, "left", $EditorIcon::arrow_left, "Pin the left edge"); + %this.rightPin = %this.makePin(60, 44, "right", $EditorIcon::arrow_right, "Pin the right edge"); + %this.bottomPin = %this.makePin(34, 70, "bottom", $EditorIcon::arrow_bottom, "Pin the bottom edge"); + + // The two special modes, one row per axis and each row named, so it is + // never a guess which axis a checkbox belongs to. + %this.hLabel = %this.makeLabel(96, 22, 20, "H:", "right"); + %this.hFill = %this.makeChip(118, 20, 56, "h", "fill", "Fill", + "Fill the parent horizontally"); + %this.hRel = %this.makeChip(178, 20, 80, "h", "scale", "Scale", + "Scale both edges with the parent's width"); + + %this.vLabel = %this.makeLabel(96, 62, 20, "V:", "right"); + %this.vFill = %this.makeChip(118, 60, 56, "v", "fill", "Fill", + "Fill the parent vertically"); + %this.vRel = %this.makeChip(178, 60, 80, "v", "scale", "Scale", + "Scale both edges with the parent's height"); + + // The resolved pair, along the bottom where it reads as the answer rather + // than as another control. + %this.readout = %this.makeLabel(8, 98, %w - 16, "", "width"); +} + +// %sizing is the raw enum, so "right" means pinned to the left and "width" +// means both edges -- the very naming this widget exists to hide, spelled out +// here because these are the only four places it is written by hand. +function GuiEditorAnchorPicker::makeLabel(%this, %x, %y, %w, %text, %sizing) +{ + %label = new GuiControl() + { + HorizSizing = %sizing; + Position = %x SPC %y; + Extent = %w SPC 20; + Text = %text; + align = "left"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%label, "labelProfile"); + %this.add(%label); + return %label; +} + +// A pin is a toggle, so it is a checkbox rather than a button: the state has to +// stay put, and only a checkbox refuses to act while it is disabled. +function GuiEditorAnchorPicker::makePin(%this, %x, %y, %edge, %frame, %tip) +{ + %pin = new GuiCheckBoxCtrl() + { + class = "GuiEditorToggleIcon"; + Position = %x SPC %y; + Extent = "24 24"; + frameOff = %frame; + frameOn = %frame; + tipOn = %tip; + tipOff = %tip; + toggleName = %edge; + owner = %this; + }; + ThemeManager.setProfile(%pin, "iconButtonProfile"); + ThemeManager.setProfile(%pin, "tipProfile", "TooltipProfile"); + %this.add(%pin); + return %pin; +} + +function GuiEditorAnchorPicker::makeChip(%this, %x, %y, %w, %axis, %mode, %text, %tip) +{ + %chip = new GuiCheckBoxCtrl() + { + Position = %x SPC %y; + Extent = %w SPC 22; + Text = %text; + boxOffset = "0 1"; + boxExtent = "16 16"; + textOffset = "19 1"; + textExtent = (%w - 19) SPC 21; + Tooltip = %tip; + Command = %this.getID() @ ".onChipClicked(\"" @ %axis @ "\",\"" @ %mode @ "\");"; + }; + ThemeManager.setProfile(%chip, "checkboxProfile"); + ThemeManager.setProfile(%chip, "tipProfile", "TooltipProfile"); + %this.add(%chip); + return %chip; +} + +//----------------------------------------------------------------------------- +// The enum on both sides of the widget. These two functions are the whole of +// the naming problem, kept together so the inversion is visible in one place. +//----------------------------------------------------------------------------- + +function GuiEditorAnchorPicker::readEnums(%this, %horiz, %vert) +{ + %this.hSpecial = ""; + %this.vSpecial = ""; + %this.pinLeft = false; + %this.pinRight = false; + %this.pinTop = false; + %this.pinBottom = false; + + // Both name sets are read, because a Gui written before the rename still + // carries the old ones and the engine will happily hand them back if the + // field was set from such a file in the same session. + switch$(%horiz) + { + case "fill": %this.hSpecial = "fill"; + case "scale" or "relative": %this.hSpecial = "scale"; + case "width": %this.pinLeft = true; %this.pinRight = true; + case "anchorLeft" or "right": %this.pinLeft = true; + case "anchorRight" or "left": %this.pinRight = true; + // "center" pins neither, which is the remaining state. + } + + switch$(%vert) + { + case "fill": %this.vSpecial = "fill"; + case "scale" or "relative": %this.vSpecial = "scale"; + case "height": %this.pinTop = true; %this.pinBottom = true; + case "anchorTop" or "bottom": %this.pinTop = true; + case "anchorBottom" or "top": %this.pinBottom = true; + } + + %this.updateWidgets(); +} + +function GuiEditorAnchorPicker::horizEnum(%this) +{ + if(%this.hSpecial !$= "") + { + return %this.hSpecial; + } + if(%this.pinLeft && %this.pinRight) + { + return "width"; + } + if(%this.pinLeft) + { + return "anchorLeft"; + } + if(%this.pinRight) + { + return "anchorRight"; + } + return "center"; +} + +function GuiEditorAnchorPicker::vertEnum(%this) +{ + if(%this.vSpecial !$= "") + { + return %this.vSpecial; + } + if(%this.pinTop && %this.pinBottom) + { + return "height"; + } + if(%this.pinTop) + { + return "anchorTop"; + } + if(%this.pinBottom) + { + return "anchorBottom"; + } + return "center"; +} + +//----------------------------------------------------------------------------- +// Interaction. +//----------------------------------------------------------------------------- + +// A pin toggled itself; read its state back rather than flipping our own copy, +// so the widget and the checkbox can never disagree. +function GuiEditorAnchorPicker::onToggleIconChanged(%this, %pin) +{ + if(%this.populating) + { + return; + } + + // A pin and a special cannot both be in effect, so touching a pin takes + // that axis back to the pins. + switch$(%pin.toggleName) + { + case "left": %this.pinLeft = %pin.getStateOn(); %this.hSpecial = ""; + case "right": %this.pinRight = %pin.getStateOn(); %this.hSpecial = ""; + case "top": %this.pinTop = %pin.getStateOn(); %this.vSpecial = ""; + case "bottom": %this.pinBottom = %pin.getStateOn(); %this.vSpecial = ""; + } + + %this.updateWidgets(); + %this.owner.onAnchorChanged(%this); +} + +function GuiEditorAnchorPicker::onChipClicked(%this, %axis, %mode) +{ + if(%this.populating) + { + return; + } + + // Clicking the mode that is already on turns it off, which drops the axis + // back to whatever its pins say. + if(%axis $= "h") + { + %this.hSpecial = (%this.hSpecial $= %mode) ? "" : %mode; + } + else + { + %this.vSpecial = (%this.vSpecial $= %mode) ? "" : %mode; + } + + %this.updateWidgets(); + %this.owner.onAnchorChanged(%this); +} + +// Show the current state. A pin is on only when its axis is actually being +// driven by pins -- with Fill in effect the pins are not what is happening, so +// they read as off. +function GuiEditorAnchorPicker::updateWidgets(%this) +{ + %this.populating = true; + + %hPinned = %this.hSpecial $= ""; + %vPinned = %this.vSpecial $= ""; + + %this.leftPin.setValue(%this.pinLeft && %hPinned); + %this.rightPin.setValue(%this.pinRight && %hPinned); + %this.topPin.setValue(%this.pinTop && %vPinned); + %this.bottomPin.setValue(%this.pinBottom && %vPinned); + + %this.hFill.setStateOn(%this.hSpecial $= "fill"); + %this.hRel.setStateOn(%this.hSpecial $= "scale"); + %this.vFill.setStateOn(%this.vSpecial $= "fill"); + %this.vRel.setStateOn(%this.vSpecial $= "scale"); + + %this.readout.setText(%this.horizEnum() @ " / " @ %this.vertEnum()); + + %this.populating = false; +} + +// Grey the axis where the parent owns the sizing anyway. Everything here is a +// checkbox, which will not act while inactive, so this disables the behaviour +// as well as the look. +function GuiEditorAnchorPicker::setAxisEnabled(%this, %horiz, %vert) +{ + %this.leftPin.setActive(%horiz); + %this.rightPin.setActive(%horiz); + %this.hFill.setActive(%horiz); + %this.hRel.setActive(%horiz); + %this.hLabel.setActive(%horiz); + + %this.topPin.setActive(%vert); + %this.bottomPin.setActive(%vert); + %this.vFill.setActive(%vert); + %this.vRel.setActive(%vert); + %this.vLabel.setActive(%vert); +} diff --git a/editor/GuiEditor/scripts/GuiEditorBrain.cs b/editor/GuiEditor/scripts/GuiEditorBrain.cs index e5c45b128..8ad70b519 100644 --- a/editor/GuiEditor/scripts/GuiEditorBrain.cs +++ b/editor/GuiEditor/scripts/GuiEditorBrain.cs @@ -5,8 +5,34 @@ %this.setSnapToGrid("10"); } +//----------------------------------------------------------------------------- +// A control arriving by drag. +// +// Both of these are only ever called about a point that is over the canvas - +// except that they are not. GuiDragAndDropCtrl hit-tests from the drag control's +// PARENT, which is this control, and GuiControl::findHitControl answers "me" +// when none of its children was hit, whatever the point it was asked about. So +// this hears about a drop anywhere on the screen: over the palette, over the +// explorer, over the menu bar. Left to itself it then adds the control at the +// cursor, which is how dragging one back onto the palette to change your mind +// put a control behind the palette. +// +// So each of them asks first. A drop that is not over the canvas is not a drop: +// nothing is added, and the payload dies with the drag control that carried it +// (deleteOnMouseUp, and a group deletes what it holds). +//----------------------------------------------------------------------------- + function GuiEditorBrain::onControlDragged(%this, %payload, %position) { + // Off the canvas: keep the container that is being worked in. Hit-testing a + // point outside the Gui answers the Gui itself, so without this a drag that + // strayed over a tool window quietly reset the add set to the root - and the + // click gesture places into the add set, so the next click moved with it. + if(!%this.isOverCanvas(%this.cursorFrom(%payload))) + { + return; + } + %x = getWord(%position, 0); %y = getWord(%position, 1); %target = %this.root.findHitControl(%x, %y); @@ -23,28 +49,386 @@ } function GuiEditorBrain::onControlDropped(%this, %payload, %position) +{ + if(!%this.isOverCanvas(%this.cursorFrom(%payload))) + { + return; + } + + %this.placeControl(%payload); +} + +// A control arriving from a gesture that has no cursor to be off the canvas - +// which is what clicking a palette tile is. The position was worked out from the +// container being worked in and is inside it by construction, so there is +// nothing left to police. +// +// It has to be a door of its own rather than the drag's, because the drag's +// question cannot be asked of every control. A GuiMenuBarCtrl pins itself to its +// parent's origin - resize throws away the position it is handed - so its +// payload sits at 0,0 however it is placed, the cursor test measures a point up +// in the editor's own chrome, and clicking Menu Bar in the palette did nothing +// at all. +function GuiEditorBrain::placeControl(%this, %payload) { %pos = %payload.getGlobalPosition(); %x = getWord(%pos, 0); %y = getWord(%pos, 1); + %this.acceptControl(%payload); + + %payload.setPositionGlobal(%x, %y); + %this.schedule(40, "finishControlDropped", %payload, %x, %y); +} + +//----------------------------------------------------------------------------- +// Where the canvas is, and where the pointer is over it. +// +// Everything here is in global coordinates. The position the two callbacks above +// are handed is not: GuiDragAndDropCtrl::sendDragEvent builds it from the drag +// control's own bounds, which are local to this control - which is why neither +// of them measures anything with it, and why the payload is asked instead. A +// drag grabs a control by the middle (see GuiEditorControlTile::beginDrag), so +// the middle of the payload is where the pointer is. +//----------------------------------------------------------------------------- + +function GuiEditorBrain::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)); +} + +function GuiEditorBrain::isOverCanvas(%this, %point) +{ + %canvas = %this.canvasRect(); + %x = getWord(%point, 0); + %y = getWord(%point, 1); + + return %x >= getWord(%canvas, 0) && %y >= getWord(%canvas, 1) && + %x < getWord(%canvas, 0) + getWord(%canvas, 2) && + %y < getWord(%canvas, 1) + getWord(%canvas, 3); +} + +// "x y width height" of the Gui being edited. +function GuiEditorBrain::canvasRect(%this) +{ + return %this.root.getGlobalPosition() SPC %this.root.getExtent(); +} + +// Where a control goes when the gesture never said - which is what a click on a +// palette tile is: the middle of the container being worked in, or of as much of +// that container as the canvas can show. +// +// The clipping is not fussiness. A Gui is authored at its own size, usually +// 1024x768, and the canvas frame is a few hundred pixels wide, so a container +// runs off the side of it more often than not - and the middle of one that does +// is behind the palette or the explorer, where the control cannot be seen or +// dragged. Clipping first means a container that fits, which is every container +// in a Gui the size of the canvas, still gets its own exact middle. +function GuiEditorBrain::centredPlacement(%this, %payload) +{ + %target = %this.getCurrentAddSet(); + if(!isObject(%target)) + { + %target = %this.root; + } + + %room = %this.visiblePartOf(%target); + %size = %payload.getExtent(); + + %x = getWord(%room, 0) + ((getWord(%room, 2) - getWord(%size, 0)) / 2); + %y = getWord(%room, 1) + ((getWord(%room, 3) - getWord(%size, 1)) / 2); + + return mFloor(%x) SPC mFloor(%y); +} + +// What a control and the canvas share, as "x y width height". A container with +// nothing on the canvas at all answers the whole canvas: there is no good +// placement inside it, and off-screen is not a better answer than visible. +function GuiEditorBrain::visiblePartOf(%this, %ctrl) +{ + %canvas = %this.canvasRect(); + %at = %ctrl.getGlobalPosition(); + %size = %ctrl.getExtent(); + + %left = mFloor(mGetMax(getWord(%at, 0), getWord(%canvas, 0))); + %top = mFloor(mGetMax(getWord(%at, 1), getWord(%canvas, 1))); + %right = mFloor(mGetMin(getWord(%at, 0) + getWord(%size, 0), + getWord(%canvas, 0) + getWord(%canvas, 2))); + %bottom = mFloor(mGetMin(getWord(%at, 1) + getWord(%size, 1), + getWord(%canvas, 1) + getWord(%canvas, 3))); + + if(%right <= %left || %bottom <= %top) + { + return %canvas; + } + + return %left SPC %top SPC (%right - %left) SPC (%bottom - %top); +} + +// Everything about a control arriving in the document except where it lands. +// +// A drop knows where the cursor was and so places the control in global +// coordinates, twice, because the container it landed in may have moved it. A +// paste has no cursor: it knows the position the control held in its old parent, +// which is a local one, and sets it before calling this - so the properties pane +// reads the position the control is going to keep. Everything else is the same +// for both, and lives here rather than in each caller: the undo record, the +// theme, the selection, and the events the Explorer tree and the panes listen +// for. +function GuiEditorBrain::acceptControl(%this, %payload) +{ + // The add itself is the undo step, recorded from onAddNewCtrl below. %this.addNewCtrl(%payload); - // A control arrives wearing whatever its C++ constructor named - usually - // GuiDefaultProfile - so put it on the Gui's theme straight away. Themed on - // arrival is the whole point: the drop is the last time anyone should have to - // think about which profile a button wants. + // Between the two, so the page is in the book before the theme walks it and + // after the book's own add has been recorded. A tab book with no pages is a + // book with nothing to put anything in, and the palette no longer offers the + // page that would fix that. + // + // The page is added with a plain add(), which announces nothing, so nothing + // records it: undo of the drop puts the book in the trash with its page + // inside, which is one step, which is what the user did. + if(%payload.isMemberOfClass("GuiTabBookCtrl") && %payload.getCount() == 0) + { + %this.newTabPage(%payload); + } + + // And the same for a menu bar, for the same reason and more sharply: the + // palette has never offered a GuiMenuItemCtrl, so before the "+" there was no + // way whatsoever to put anything in one. + if(%payload.isMemberOfClass("GuiMenuBarCtrl") && %payload.getCount() == 0) + { + %this.newMenuItem(%payload); + } + + %this.adoptControl(%payload); +} + +// The half of arriving that is the same however a control got here: the theme it +// takes on, and the events that tell the Explorer tree and the panes about it. +// +// Split out because a tab page arrives by neither of the routes above. Its book +// makes it, from the "+" tab, and it needs all of this and none of the +// placement. +// +// The undo record is deliberately NOT here, because what counts as one step +// differs by caller: a dropped control is a step of its own, and a page seeded +// inside a book that is itself arriving is part of that book arriving. +function GuiEditorBrain::adoptControl(%this, %ctrl) +{ + // A control arrives wearing whatever its C++ constructor named - a + // GuiWindowCtrl names five, from GuiWindowProfile down - so put it on the + // Gui's theme straight away. Themed on arrival is the whole point: the drop + // is the last time anyone should have to think about which profile a button + // wants. + // + // This has to run after the control has a parent, because the parent decides + // which category it takes (a control sitting directly on the root is the + // Gui's backdrop and gets Panel, not Label). But adding it is also what + // announces the selection, so everything that inspects the control has + // already read it wearing the constructor's profiles. Rethemed tells them to + // look again. + // + // None of it is recorded: theming a control that is arriving is part of it + // arriving, not a second thing the user did. Undo puts the whole control in + // the trash, where it keeps its profiles and its position, so redo has + // nothing to put back but the control itself. %theme = GuiEditor.themeByName(GuiEditor.themeName); if(isObject(%theme)) { - GuiEditor.themeApplier.applyToBranch(%payload, %theme, false); + GuiEditor.undoRecorder.suspend(); + GuiEditor.themeApplier.applyToBranch(%ctrl, %theme, false); + GuiEditor.undoRecorder.resume(); + + %this.postEvent("Rethemed", %ctrl); } - %payload.setPositionGlobal(%x, %y); %this.setFirstResponder(); - %this.postEvent("AddControl", %payload); - %this.postEvent("Inspect", %payload); - %this.schedule(40, "finishControlDropped", %payload, %x, %y); + %this.postEvent("AddControl", %ctrl); + %this.postEvent("Inspect", %ctrl); +} + +//----------------------------------------------------------------------------- +// Tab pages. +// +// A GuiTabPageCtrl is the one control the palette does not offer, because it is +// the one control that means nothing anywhere but inside a GuiTabBookCtrl. A +// book makes its own instead: one when the book is dropped, and one for every +// click on the "+" tab it draws at the end of its strip while the Gui is being +// authored. +//----------------------------------------------------------------------------- + +// GuiTabBookCtrl::requestNewPage, from a click on the "+" tab. +function GuiEditorBrain::onAddTabPage(%this, %book) +{ + if(!isObject(%book)) + { + return; + } + + GuiEditor.undoRecorder.begin("Add Tab Page", ""); + %page = %this.newTabPage(%book); + GuiEditor.undoRecorder.recordAdd(%page, "Add Tab Page"); + %this.adoptControl(%page); + GuiEditor.undoRecorder.end(); + + // By index, never by name: captions are not identities - two pages can both + // read "Page 3" once one has been deleted - and selectPageName takes the + // first match. + %book.selectPage(%book.getCount() - 1); + + // The add set first, because setting it clears the selection. Pointing it at + // the new page means the next control dropped lands in the page that was + // just made, which is the only reason anyone clicks "+". + %this.setCurrentAddSet(%page); + %this.selectList(%page); +} + +// The one place a page is made, so that a page seeded with its book and a page +// added later are the same object. +function GuiEditorBrain::newTabPage(%this, %book) +{ + %number = %this.freePageNumber(%book); + + %page = new GuiTabPageCtrl() + { + Text = "Page " @ %number; + }; + + // What C++ addNewPage names, so a book built in a project with no theme + // loaded still gets a page that draws like one. adoptControl writes over it + // a moment later wherever there IS a theme. + if(isObject(GuiTabPageProfile)) + { + %page.setProfile(GuiTabPageProfile); + } + + %book.add(%page); + + return %page; +} + +// The lowest number no tab in the book is already using. Counting the pages +// instead would repeat one: three pages, delete "Page 2", and the next page +// would be a second "Page 3". +// +// Bounded rather than open: among the numbers 1 to N+1 at least one is free of N +// pages, so the loop always finds an answer inside it. +function GuiEditorBrain::freePageNumber(%this, %book) +{ + %count = %book.getCount(); + + for(%n = 1; %n <= (%count + 1); %n++) + { + %taken = false; + for(%i = 0; %i < %count; %i++) + { + if(%book.getObject(%i).getText() $= ("Page " @ %n)) + { + %taken = true; + break; + } + } + + if(!%taken) + { + return %n; + } + } + + return %count + 1; +} + +//----------------------------------------------------------------------------- +// Menu items. +// +// The same arrangement as tab pages above, one level deeper. A GuiMenuItemCtrl +// is not in the palette either, and it nests: a bar holds menus, and each menu +// holds the commands. So the bar draws a "+" after the last menu, and an open +// menu draws a "+" at the foot of its list, and both arrive here. +//----------------------------------------------------------------------------- + +// GuiMenuBarCtrl::requestNewMenuItem, from a click on either "+". %parent is the +// menu to put it in, or empty for a top-level one. +function GuiEditorBrain::onAddMenuItem(%this, %bar, %parent) +{ + if(!isObject(%bar)) + { + return; + } + + if(!isObject(%parent)) + { + %parent = %bar; + } + + GuiEditor.undoRecorder.begin("Add Menu Item", ""); + %item = %this.newMenuItem(%parent); + GuiEditor.undoRecorder.recordAdd(%item, "Add Menu Item"); + %this.adoptControl(%item); + GuiEditor.undoRecorder.end(); + + // The add set first, because setting it clears the selection. Selecting the + // new item is also what opens the menu it went into - the bar works out which + // menu to show from the selection - so a menu made by the bar's "+" is + // already open and waiting for its first command. + %this.setCurrentAddSet(%item); + %this.selectList(%item); +} + +// The one place a menu item is made, so that an item seeded with its bar and an +// item added later are the same object. +function GuiEditorBrain::newMenuItem(%this, %parent) +{ + %number = %this.freeMenuNumber(%parent); + + %item = new GuiMenuItemCtrl() + { + Text = "Menu " @ %number; + }; + + // Added to its parent before it is given anything of its own: a menu item + // learns which bar it belongs to from the parent it arrives in, and reads + // that back the moment it gains a child. + %parent.add(%item); + + return %item; +} + +// The lowest number no item in this parent is already using. Numbering is per +// parent, so each menu's commands count from 1 rather than carrying on from the +// bar's. Counting the children instead would repeat one: three items, delete the +// second, and the next would be a second "Menu 3". +// +// Bounded rather than open: among the numbers 1 to N+1 at least one is free of N +// items, so the loop always finds an answer inside it. +function GuiEditorBrain::freeMenuNumber(%this, %parent) +{ + %count = %parent.getCount(); + + for(%n = 1; %n <= (%count + 1); %n++) + { + %taken = false; + for(%i = 0; %i < %count; %i++) + { + if(%parent.getObject(%i).getText() $= ("Menu " @ %n)) + { + %taken = true; + break; + } + } + + if(!%taken) + { + return %n; + } + } + + return %count + 1; } function GuiEditorBrain::finishControlDropped(%this, %payload, %x, %y) @@ -133,18 +517,140 @@ %this.toggleMenuItems(); } +// Something else asked for the selection to go. The Explorer tree does, from its +// own Delete key. function GuiEditorBrain::onObjectRemoved(%this, %ctrl) { %this.startRadioSilence(); %this.deleteSelection(); %this.endRadioSilence(); - %this.toggleMenuItems(); + + // Then say that it went, which the receiving half of this bus does not + // normally do. It has to here: postEvent does not deliver to whoever posted, + // so the tree that asked for this delete has told its own window nothing, + // and the window is what refreshes it. Announcing from here rather than at + // each caller is what makes every route into a delete - this one, the Delete + // key on the canvas, and Cut - leave the panes agreeing with the document. + // + // onDelete carries the menu update with it, so there is none of its own. + %this.onDelete(); +} + +//----------------------------------------------------------------------------- +// Undo. The C++ edit control has always announced every edit it makes at the +// moment it makes it - guiEditCtrl.cc calls all of these, and each one sits +// beside a bare "// undo" comment marking where the recording used to happen. +// Nothing implemented them until now. +// +// The pairs matter: what a drag or a nudge did is only known once it is over, +// so the pre half remembers where everything was and the post half works out +// what actually moved. A mouse-down that only selected records nothing. +//----------------------------------------------------------------------------- + +// Mouse-down on a selection, before a drag-move or a handle-resize, and the +// mouse-up that ends it. The pair is the whole gesture, and the gesture is one +// undo step however many times the C++ moves the selection inside it - and +// whatever else it does in there, which includes reparenting the selection into +// whatever container the pointer wandered over. See +// GuiEditorUndoRecorder::beginGesture. +function GuiEditorBrain::onPreEdit(%this, %selection) +{ + GuiEditor.undoRecorder.beginGesture(%selection); +} + +// The selection is handed over again here and deliberately not used: what the +// gesture has to be measured against is the snapshot taken when it began. +function GuiEditorBrain::onPostEdit(%this, %selection) +{ + GuiEditor.undoRecorder.endGesture(); +} + +// Arrow-key and menu nudges, which the C++ gives a callback of their own +// precisely so that a run of them can be folded into one action. +function GuiEditorBrain::onPreSelectionNudged(%this, %selection) +{ + GuiEditor.undoRecorder.snapshot(%selection); +} + +function GuiEditorBrain::onPostSelectionNudged(%this, %selection) +{ + GuiEditor.undoRecorder.commitGeometry("", "nudge"); +} + +// Fired before the controls are moved to the trash, which is the only moment +// where each one still knows the parent and index it has to go back to. +function GuiEditorBrain::onTrashSelection(%this, %selection) +{ + GuiEditor.undoRecorder.recordDeleteSelection(%selection); +} + +// Fired after the control has been put in the add set. +function GuiEditorBrain::onAddNewCtrl(%this, %ctrl) +{ + GuiEditor.undoRecorder.recordAdd(%ctrl, ""); +} + +// Put the selection on the controls a replay changed, and tell everyone. +function GuiEditorBrain::restoreSelection(%this, %list) +{ + %this.selectList(%list); +} + +// Select exactly these controls, and say so. +// +// The announcement has to be made here, because addSelection makes none: +// it is the receiving half of the bus - what this class calls, under radio +// silence, when the tree or the pane has already announced a selection - so it +// changes the C++ selection and says nothing. Calling it on its own leaves the +// canvas drawing handles round a control the properties pane has never heard +// of, and the clearSelection ahead of it has already emptied the pane. +function GuiEditorBrain::selectList(%this, %list) +{ + // Already the selection, with values that changed underneath it - which is + // the commonest undo there is: change a setting, press Ctrl+Z. Re-announcing + // would rebuild the whole properties pane, when all it needs is to re-read + // the control it is already showing. + if(%list $= %this.selectionList()) + { + %this.postEvent("Replayed"); + return; + } + + %this.clearSelection(); + + for(%i = 0; %i < getWordCount(%list); %i++) + { + %ctrl = getWord(%list, %i); + %this.addSelection(%ctrl); + + // What the C++ would have called had it done the selecting, so there is + // one definition of what announcing a selection means. + %this.onAddSelected(%ctrl); + } +} + +function GuiEditorBrain::selectionList(%this) +{ + %set = %this.getSelected(); + %list = ""; + + for(%i = 0; %i < %set.getCount(); %i++) + { + %ctrl = %set.getObject(%i); + %list = (%list $= "") ? %ctrl : (%list SPC %ctrl); + } + + return %list; } 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); diff --git a/editor/GuiEditor/scripts/GuiEditorChoiceRow.cs b/editor/GuiEditor/scripts/GuiEditorChoiceRow.cs new file mode 100644 index 000000000..2f2c45d80 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorChoiceRow.cs @@ -0,0 +1,157 @@ + +//----------------------------------------------------------------------------- +// A row of icon buttons where exactly one is chosen: a radio group that looks +// like a segmented control. Built for the properties pane's two alignment rows, +// where a drop-down was a poor fit -- there are only three or four values, they +// 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 +// 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 -- +// unlike a toggle, a radio cannot be un-picked, only replaced. +// +// The first entry is conventionally the "unset" one. For alignment that is +// GuiControl's "default", which getAlignmentType resolves to the profile's own +// alignment -- so it is a real value meaning "inherit", not an absence, and it +// gets a button with no icon rather than no button. +// +// The creator sets owner, fieldName and blockWidth inline, calls addChoice() +// once per value, then build(). Changes arrive at +// owner.onChoiceRowChanged(%row). +//----------------------------------------------------------------------------- + +function GuiEditorChoiceRow::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); + %this.choiceCount = 0; + %this.value = ""; +} + +// %icon may be "" for the entry that means "unset" -- GuiEditorToggleIcon draws +// nothing when its frame is empty, leaving a plain button. +function GuiEditorChoiceRow::addChoice(%this, %value, %icon, %tip) +{ + %i = %this.choiceCount; + %this.choiceValue[%i] = %value; + %this.choiceIcon[%i] = %icon; + %this.choiceTip[%i] = %tip; + %this.choiceCount = %i + 1; +} + +function GuiEditorChoiceRow::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 + // "V:", where the icons say the rest. + %labelW = (%this.labelWidth $= "") ? 68 : %this.labelWidth; + %size = 24; + %gap = 2; + + %this.setExtent(%labelW + ((%size + %gap) * %this.choiceCount), %size + 4); + + %this.label = new GuiControl() + { + Position = "0 2"; + Extent = (%labelW - 4) SPC %size; + Text = %this.labelText; + align = "left"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%this.label, "labelProfile"); + %this.add(%this.label); + + for(%i = 0; %i < %this.choiceCount; %i++) + { + %button = new GuiCheckBoxCtrl() + { + class = "GuiEditorToggleIcon"; + Position = (%labelW + (%i * (%size + %gap))) SPC 2; + Extent = %size SPC %size; + frameOn = %this.choiceIcon[%i]; + frameOff = %this.choiceIcon[%i]; + tipOn = %this.choiceTip[%i]; + tipOff = %this.choiceTip[%i]; + toggleName = %this.choiceValue[%i]; + owner = %this; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %this.add(%button); + %this.choiceButton[%i] = %button; + } +} + +// Hide a choice that does not apply to whatever is bound. The buttons are placed +// 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) +{ + for(%i = 0; %i < %this.choiceCount; %i++) + { + if(%this.choiceValue[%i] $= %value) + { + %this.choiceButton[%i].setVisible(%visible); + return; + } + } +} + +//----------------------------------------------------------------------------- +// Value. +//----------------------------------------------------------------------------- + +// 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) +{ + %this.value = %value; + %this.populating = true; + for(%i = 0; %i < %this.choiceCount; %i++) + { + %this.choiceButton[%i].setValue(%this.choiceValue[%i] $= %value); + } + %this.populating = false; +} + +function GuiEditorChoiceRow::getValue(%this) +{ + return %this.value; +} + +// 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) +{ + if(%this.populating) + { + return; + } + + %chosen = %button.toggleName; + if(%chosen $= %this.value) + { + // Clicking the current choice cannot clear it; put the button back down. + %this.setValue(%chosen); + return; + } + + %this.setValue(%chosen); + + if(isObject(%this.owner)) + { + %this.owner.onChoiceRowChanged(%this); + } +} + +function GuiEditorChoiceRow::setEnabled(%this, %enabled) +{ + %this.label.setActive(%enabled); + for(%i = 0; %i < %this.choiceCount; %i++) + { + %this.choiceButton[%i].setActive(%enabled); + } +} diff --git a/editor/GuiEditor/scripts/GuiEditorClipboard.cs b/editor/GuiEditor/scripts/GuiEditorClipboard.cs new file mode 100644 index 000000000..6aebf3362 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorClipboard.cs @@ -0,0 +1,563 @@ + +//----------------------------------------------------------------------------- +// The Gui Editor's clipboard. Owned by GuiEditor; the only thing that holds a +// copied control, and the only thing that puts one back. +// +// Copying controls is the subject rather than the stash in particular, which is +// why Duplicate lives here too: it is a copy that goes straight back where it +// came from without ever being held. +// +// A copy is a real copy, made at the moment Ctrl+C is pressed: the selection is +// deep-cloned into a SimGroup of this object's own, outside the document. That +// is what makes the clipboard a snapshot rather than a reference - editing or +// deleting the original afterwards does not change what will be pasted - and it +// is why a paste clones a second time, out of the stash, so the stash survives +// and can be pasted again. +// +// SimObject::deepClone is what does the copying (simObject.cc). Two things it +// promises are the reason a clipboard can be this small: it copies everything - +// fields, dynamic fields, the whole child tree, and a frame set's frame tree - +// and it runs no script lifecycle on the copy, so a control whose class builds +// children in onAdd is copied with the children it has rather than gaining a +// second set of them. +// +// What it deliberately does not copy is the name, because two controls +// answering to one name is a bug. The originals' names are carried across on +// the copy as a dynamic field, and paste turns them back into real names, made +// unique against the document. +// +// Holds live controls, so it has to be emptied when the profiles they wear are +// about to be freed - see GuiEditor::detachTheme, which does the same for the +// undo stack and for the same reason. +//----------------------------------------------------------------------------- + +function GuiEditorClipboard::onAdd(%this) +{ + // Where copies live. A SimGroup owns what it holds, so deleting it at + // teardown frees every copied tree with it. + %this.stash = new SimGroup(); + + %this.entryCount = 0; + + // Paste placement, counted per container: a paste into the container the copy + // came from steps away from the original so it can be seen, and each further + // paste into that container steps again. Kept per container rather than as one + // running count so that pasting into a second container and coming back + // 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) +{ + if(isObject(%this.stash)) + { + %this.stash.delete(); + } +} + +//----------------------------------------------------------------------------- +// Copying. +//----------------------------------------------------------------------------- + +function GuiEditorClipboard::copy(%this, %selection) +{ + if(!isObject(%selection) || %selection.getCount() == 0) + { + return false; + } + + %roots = %this.topLevel(%selection); + if(%roots $= "") + { + return false; + } + + %this.clear(); + + for(%i = 0; %i < getWordCount(%roots); %i++) + { + %ctrl = getWord(%roots, %i); + %copy = %ctrl.deepClone(); + if(!isObject(%copy)) + { + continue; + } + + // Out of the Gui group a fresh control registers itself into + // (guiControl.cc onAdd) and into the stash, which is where it stays. + %this.stash.add(%copy); + %this.stampNames(%ctrl, %copy); + + %n = %this.entryCount; + %this.entry[%n] = %copy; + %this.entrySource[%n] = %ctrl.getParent(); + %this.entryCount = %n + 1; + } + + %this.stepCount = 0; + %this.refreshMenu(); + + return %this.entryCount > 0; +} + +// The controls a copy actually has to take: those in the selection that no other +// selected control already contains. Selecting a panel and a button inside it is +// one copy, not two - the button is coming along inside the panel, and copying +// it again would paste a second one. +// +// Found by walking the document rather than reading the selection in order, +// which also settles the order: the entries come out in document order, so the +// pasted controls sit in the same z-order as the originals. +function GuiEditorClipboard::topLevel(%this, %selection) +{ + if(!isObject(%this.owner.rootGui)) + { + return ""; + } + + return %this.collectTopLevel(%this.owner.rootGui, %selection); +} + +function GuiEditorClipboard::collectTopLevel(%this, %parent, %selection) +{ + %list = ""; + + for(%i = 0; %i < %parent.getCount(); %i++) + { + %child = %parent.getObject(%i); + + if(%selection.isMember(%child)) + { + // Taken whole, and the walk stops here: anything selected below it is + // part of what was taken. + %list = (%list $= "") ? %child : (%list SPC %child); + continue; + } + + %deeper = %this.collectTopLevel(%child, %selection); + if(%deeper !$= "") + { + %list = (%list $= "") ? %deeper : (%list SPC %deeper); + } + } + + return %list; +} + +// Remember what every control in the copied tree was called, since deepClone +// deliberately leaves names behind. Kept on the copy itself rather than in a +// table here, so a tree of any shape carries its own answers; paste consumes the +// field and clears it. +// +// Walked by index, which pairs the two trees: a deep clone copies children in +// order, so the source's nth child and the copy's nth child are the same +// control. +function GuiEditorClipboard::stampNames(%this, %source, %copy) +{ + %name = %source.getName(); + if(%name !$= "") + { + %copy.clipName = %name; + } + + %count = %source.getCount(); + for(%i = 0; %i < %count && %i < %copy.getCount(); %i++) + { + %this.stampNames(%source.getObject(%i), %copy.getObject(%i)); + } +} + +//----------------------------------------------------------------------------- +// Pasting. +//----------------------------------------------------------------------------- + +function GuiEditorClipboard::paste(%this) +{ + if(%this.isEmpty()) + { + return false; + } + + %addSet = %this.owner.brain.getCurrentAddSet(); + if(!isObject(%addSet)) + { + %addSet = %this.owner.rootGui; + } + + %offset = %this.nextOffset(%addSet); + %pasted = ""; + + // However many controls it puts back, a paste is one thing the user did and + // so one thing they can take back. The adds inside record themselves into + // this transaction (GuiEditorUndoRecorder::recordAdd, from the brain's + // onAddNewCtrl). + %this.owner.undoRecorder.begin("Paste", ""); + + for(%i = 0; %i < %this.entryCount; %i++) + { + %entry = %this.entry[%i]; + if(!isObject(%entry)) + { + continue; + } + + %copy = %entry.deepClone(); + if(!isObject(%copy)) + { + continue; + } + + // Not everything can live everywhere: a tab page pasted onto a panel is + // a page nothing will ever draw a tab for. The clipboard is left alone, + // so selecting a tab book and pasting again does what was meant. The + // clone goes, because nothing else is holding it. + if(!%copy.canBeChildOf(%addSet)) + { + %copy.delete(); + continue; + } + + // Both before the control arrives, so they are part of it arriving rather + // than a second edit on top of it - and so that everything the arrival + // announces reads the name and the position the control is going to keep. + %this.applyNames(%copy); + %copy.Position = %this.pastePosition(%copy, %offset); + + // Through the brain, which is where theming on arrival, the undo record, + // the selection and the AddControl event that refreshes the Explorer tree + // all live. The control palette's click-to-place goes through the same + // door for the same reason; the only thing a paste does differently is + // place the control itself, which is why that half is not in here. + %this.owner.brain.acceptControl(%copy); + + %pasted = (%pasted $= "") ? %copy : (%pasted SPC %copy); + } + + %this.owner.undoRecorder.end(); + + // Each arrival announced its own control as the selection, so the last one + // would otherwise be the only one selected. + if(%pasted !$= "") + { + %this.owner.brain.selectList(%pasted); + } + + return %pasted !$= ""; +} + +// How far this paste steps away from where the copy was taken. +// +// Pasting into the container the control came from puts it one grid step off, so +// it is not hidden exactly behind the original; pasting somewhere else keeps the +// position it had. Every further paste into a container steps again, counted per +// container, so nothing a paste puts down is ever laid exactly on top of +// something an earlier paste put there. +function GuiEditorClipboard::nextOffset(%this, %addSet) +{ + %grid = %this.owner.brain.getGridSize(); + if(%grid <= 0) + { + %grid = 10; + } + + for(%i = 0; %i < %this.stepCount; %i++) + { + if(%this.stepParent[%i] == %addSet) + { + %this.stepValue[%i]++; + return %grid * %this.stepValue[%i]; + } + } + + // First paste into this container. Starting at one step out only makes sense + // where the original is; anywhere else the position it had is free. + %n = %this.stepCount; + %this.stepParent[%n] = %addSet; + %this.stepValue[%n] = (%addSet == %this.entrySource[0]) ? 1 : 0; + %this.stepCount = %n + 1; + + return %grid * %this.stepValue[%n]; +} + +// The position the copy takes in its new parent: the one it had in its old one, +// plus however far this paste is stepping. +// +// Local, and set before the control is added, which is the whole reason paste +// does its own placing rather than handing a point to onControlDropped. A global +// position would have to account for the border inset of the container it is +// landing in - and that inset is not knowable in advance: a control's +// mRenderInsetLT is written by its parent when the parent renders it +// (guiControl.cc renderChild), so a control that has never been drawn in this +// container does not have one yet. +// +// A container that places its own children is then free to overrule this, which +// is right: a chain or a grid decides where its children sit, and a control +// pasted into one belongs wherever the container puts it. +function GuiEditorClipboard::pastePosition(%this, %copy, %offset) +{ + %at = %copy.getPosition(); + + return (getWord(%at, 0) + %offset) SPC (getWord(%at, 1) + %offset); +} + +// Turn the copied names back into real ones, made unique. A control that had no +// name stays nameless. +function GuiEditorClipboard::applyNames(%this, %ctrl) +{ + %wanted = %ctrl.clipName; + if(%wanted !$= "") + { + %ctrl.setEditFieldValue("name", %this.freeName(%wanted)); + } + + // Assigning "" is how a dynamic field is removed, so the marker leaves no + // trace on the pasted control. + %ctrl.clipName = ""; + + for(%i = 0; %i < %ctrl.getCount(); %i++) + { + %this.applyNames(%ctrl.getObject(%i)); + } +} + +// okButton -> okButton2 -> okButton3. A name that already ends in a number +// counts on from it rather than growing another digit, so a copy of okButton2 is +// okButton3 and not okButton22. +function GuiEditorClipboard::freeName(%this, %wanted) +{ + if(!%this.nameTaken(%wanted)) + { + return %wanted; + } + + %stem = %wanted; + %next = 2; + + %end = strlen(%wanted) - 1; + while(%end >= 0 && %this.isDigit(getSubStr(%wanted, %end, 1))) + { + %end--; + } + + // Not a name that is nothing but digits, which has no stem to count from. + if(%end >= 0 && %end < (strlen(%wanted) - 1)) + { + %stem = getSubStr(%wanted, 0, %end + 1); + %next = getSubStr(%wanted, %end + 1, strlen(%wanted)) + 1; + } + + for(%i = %next; %i < %next + 1000; %i++) + { + %try = %stem @ %i; + if(!%this.nameTaken(%try)) + { + return %try; + } + } + + return ""; +} + +function GuiEditorClipboard::isDigit(%this, %char) +{ + return strpos("0123456789", %char) != -1; +} + +// Is anything in the document called this? +// +// The document is the whole question: Sim cannot answer it, because the editor +// runs in editor mode, where assignName stashes a control's name instead of +// registering it (simObject.cc) - so isObject() on the name would say no for +// every control the user has ever named in here. A control in the trash is not +// in the document and its name is free, which is what lets a cut and paste keep +// the name it had. +function GuiEditorClipboard::nameTaken(%this, %name) +{ + if(%name $= "" || !isObject(%this.owner.rootGui)) + { + return false; + } + + return %this.nameTakenBelow(%this.owner.rootGui, %name); +} + +function GuiEditorClipboard::nameTakenBelow(%this, %parent, %name) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + %child = %parent.getObject(%i); + + // strcmp, not $=: $= is case-insensitive, and two controls whose names + // differ only in case are two different names to everything else. + if(strcmp(%child.getName(), %name) == 0) + { + return true; + } + + if(%this.nameTakenBelow(%child, %name)) + { + return true; + } + } + + return false; +} + +//----------------------------------------------------------------------------- +// Duplicating. +// +// A copy that never lands in the stash: it goes straight back into the parent +// the original is in, one grid step off, and whatever is on the clipboard at the +// time is still on it afterwards. That is the whole reason it is not Ctrl+C +// followed by Ctrl+V. +// +// It is here rather than somewhere of its own because four of the five things it +// needs are already in this file and are exactly right - the reduction that +// stops a control being copied twice, the name carrying, the uniquifying, and +// the counting-on from a trailing number. +// +// Two things paste has to do that this does not. There is no canBeChildOf test, +// because the original is already a legal child of that parent. And there is no +// per-container step count: a duplicate is always one step from its own +// original, so there is nothing to remember between calls. +//----------------------------------------------------------------------------- + +function GuiEditorClipboard::duplicate(%this, %selection) +{ + if(!isObject(%selection) || %selection.getCount() == 0) + { + return false; + } + + %roots = %this.topLevel(%selection); + if(%roots $= "") + { + return false; + } + + %grid = %this.owner.brain.getGridSize(); + if(%grid <= 0) + { + %grid = 10; + } + + %made = ""; + + // However many controls it copies, a duplicate is one thing the user did. + // The adds inside record themselves into this transaction, the same way a + // paste's do. + %this.owner.undoRecorder.begin("Duplicate", ""); + + for(%i = 0; %i < getWordCount(%roots); %i++) + { + %ctrl = getWord(%roots, %i); + + %parent = %ctrl.getParent(); + if(!isObject(%parent)) + { + continue; + } + + %copy = %ctrl.deepClone(); + if(!isObject(%copy)) + { + continue; + } + + // Names first, and both halves, because deepClone leaves them behind on + // purpose. The copy is not in the document yet, so it cannot collide with + // itself while a free name is being looked for. + %this.stampNames(%ctrl, %copy); + %this.applyNames(%copy); + + %at = %ctrl.getPosition(); + %copy.Position = (getWord(%at, 0) + %grid) SPC (getWord(%at, 1) + %grid); + + // addNewControl puts the control in the add set (guiEditCtrl.cc), so the + // add set is what decides where a duplicate lands. Setting it also clears + // the selection, which is why the copies are selected at the end rather + // than as they arrive. + %this.owner.brain.setCurrentAddSet(%parent); + + // The same door a paste and a palette click go through: theming on + // arrival, the undo record, and the events the Explorer tree and the panes + // listen for. + %this.owner.brain.acceptControl(%copy); + + %made = (%made $= "") ? %copy : (%made SPC %copy); + } + + %this.owner.undoRecorder.end(); + + if(%made !$= "") + { + %this.owner.brain.selectList(%made); + } + + return %made !$= ""; +} + +//----------------------------------------------------------------------------- +// State, and what the Edit menu is told about it. +//----------------------------------------------------------------------------- + +function GuiEditorClipboard::isEmpty(%this) +{ + for(%i = 0; %i < %this.entryCount; %i++) + { + if(isObject(%this.entry[%i])) + { + return false; + } + } + + return true; +} + +function GuiEditorClipboard::clear(%this) +{ + for(%i = 0; %i < %this.entryCount; %i++) + { + if(isObject(%this.entry[%i])) + { + %this.entry[%i].delete(); + } + %this.entry[%i] = ""; + %this.entrySource[%i] = ""; + } + + %this.entryCount = 0; + %this.stepCount = 0; + %this.refreshMenu(); +} + +function GuiEditorClipboard::refreshMenu(%this) +{ + %has = %this.isEmpty() ? 0 : 1; + + if(%has == %this.menuPaste) + { + return; + } + + %this.menuPaste = %has; + + if(isObject(EditorCore) && isObject(EditorCore.menuBar)) + { + EditorCore.menuBar.setMenuActive("Paste", %has); + } +} + +// 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/GuiEditorColorWindow.cs b/editor/GuiEditor/scripts/GuiEditorColorWindow.cs deleted file mode 100644 index 264e77a51..000000000 --- a/editor/GuiEditor/scripts/GuiEditorColorWindow.cs +++ /dev/null @@ -1,67 +0,0 @@ - -function GuiEditorColorWindow::onAdd(%this) -{ - %ext = %this.getExtent(); - %this.grid = new GuiGridCtrl() - { - HorizSizing = "width"; - VertSizing = "height"; - Position = "7 10"; - Extent = (%ext.x - 23) SPC (%ext.y - 43); - CellSizeX = "40"; - CellSizeY = "40"; - CellModeX = "Absolute"; - CellModeY = "Absolute"; - MaxColCount = "4"; - IsExtentDynamic = "1"; - OrderMode = "LRTB"; - }; - ThemeManager.setProfile(%this.grid, "EmptyProfile"); - %this.add(%this.grid); - - //%this.addColorCtrl(1, "Pallet"); - //%this.addColorCtrl(6, "BlendColor"); - //%this.addColorCtrl(2, "HorizColor"); - //%this.addColorCtrl(3, "VertColor"); - //%this.addColorCtrl(4, "HorizBrightnessColor"); - //%this.addColorCtrl(5, "VertBrightnessColor"); - //%this.addColorCtrl(7, "HorizAlpha"); - //%this.addColorCtrl(8, "VertAlpha"); - //%this.addColorCtrl(9, "Dropper"); - %this.addColorCtrl(10, "Popup"); - %this.addColorCtrl(11, "Popup"); - %this.addColorCtrl(12, "Popup"); - %this.addColorCtrl(13, "Popup"); -} - -function GuiEditorColorWindow::addColorCtrl(%this, %i, %mode) -{ - if(%mode $= "Popup") - { - %this.colorCtrl[%i] = new GuiColorPopupCtrl() - { - HorizSizing = "width"; - VertSizing = "height"; - }; - ThemeManager.setProfile(%this.colorCtrl[%i], "colorPickerProfile"); - ThemeManager.setProfile(%this.colorCtrl[%i], "emptyProfile", "backgroundProfile"); - ThemeManager.setProfile(%this.colorCtrl[%i], "colorPopupProfile", "popupProfile"); - ThemeManager.setProfile(%this.colorCtrl[%i], "emptyProfile", "pickerProfile"); - ThemeManager.setProfile(%this.colorCtrl[%i], "colorPickerSelectorProfile", "selectorProfile"); - %this.grid.add(%this.colorCtrl[%i]); - - return; - } - - %this.colorCtrl[%i] = new GuiColorPickerCtrl() - { - HorizSizing = "width"; - VertSizing = "height"; - DisplayMode = %mode; - ShowSelector = "1"; - SelectorGap = "4"; - }; - ThemeManager.setProfile(%this.colorCtrl[%i], "colorPickerProfile"); - ThemeManager.setProfile(%this.colorCtrl[%i], "colorPickerSelectorProfile", "selectorProfile"); - %this.grid.add(%this.colorCtrl[%i]); -} \ No newline at end of file diff --git a/editor/GuiEditor/scripts/GuiEditorConfirmSaveDialog.cs b/editor/GuiEditor/scripts/GuiEditorConfirmSaveDialog.cs new file mode 100644 index 000000000..5f748f44c --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorConfirmSaveDialog.cs @@ -0,0 +1,110 @@ + +//----------------------------------------------------------------------------- +// What stands between a modified Gui and the four commands that would discard +// it: New, Open, Close Project and Exit. +// +// Three answers rather than the Profile Editor's two. Its confirm dialog asks +// about themes, which are files the user can put back by reverting; this asks +// about the document they are in the middle of making, and the answer they +// usually want is "save it, then carry on with what I asked for". Making them +// Cancel, press Ctrl+S and repeat themselves is not a real third option. +// +// It owns none of the decision. GuiEditor holds the command that was +// interrupted; each button here only says which way to go. See +// GuiEditor::guardDocument. +//----------------------------------------------------------------------------- + +function GuiEditorConfirmSaveDialog::init(%this, %width, %height) +{ + %window = %this.getObject(0); + %content = %window.getObject(0); + + %this.feedback = new GuiControl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = "12 12"; + Extent = (%width - 24) SPC (%height - 86); + text = %this.message; + textWrap = true; + }; + ThemeManager.setProfile(%this.feedback, "infoProfile"); + %content.add(%this.feedback); + + // Right to left in the order they escalate: abandon what you asked for, + // go through with it, or write the file first. + %this.cancelButton = new GuiButtonCtrl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = (%width - 336) SPC (%height - 62); + Extent = "100 30"; + Text = "Cancel"; + Command = %this.getID() @ ".onCancel();"; + }; + ThemeManager.setProfile(%this.cancelButton, "buttonProfile"); + %content.add(%this.cancelButton); + + %this.discardButton = new GuiButtonCtrl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = (%width - 226) SPC (%height - 62); + Extent = "100 30"; + Text = "Discard"; + Command = %this.getID() @ ".onDiscard();"; + }; + ThemeManager.setProfile(%this.discardButton, "buttonProfile"); + %content.add(%this.discardButton); + + %this.saveButton = new GuiButtonCtrl() + { + HorizSizing = "right"; + VertSizing = "bottom"; + Position = (%width - 116) SPC (%height - 64); + Extent = "100 34"; + Text = "Save"; + Command = %this.getID() @ ".onSave();"; + }; + ThemeManager.setProfile(%this.saveButton, "primaryButtonProfile"); + %content.add(%this.saveButton); +} + +function GuiEditorConfirmSaveDialog::onCancel(%this) +{ + GuiEditor.dropPendingCommand(); + %this.closeNow(); +} + +function GuiEditorConfirmSaveDialog::onDiscard(%this) +{ + %this.closeNow(); + GuiEditor.runPendingCommand(); +} + +// Closed before the save starts, because a Gui that has never been saved sends +// this straight to the Save As dialog and two of these stacked is one too many. +// +// Nothing is resumed here. SaveGui may or may not end in a file being written - +// Save As has its own Cancel - so the command that was interrupted is left +// waiting, and only a save that reaches the end of SaveCore releases it. +function GuiEditorConfirmSaveDialog::onSave(%this) +{ + %this.closeNow(); + GuiEditor.SaveGui(); +} + +// The window X, which is the same answer as Cancel: no to the question asked. +function GuiEditorConfirmSaveDialog::onClose(%this) +{ + %this.onCancel(); +} + +// Not the shared EditorCore.dialog slot: this dialog can be followed by the Save +// As dialog within the scheduled delay, and the two would race for it. The same +// reason GuiProfileEditorConfirmDialog does it this way. +function GuiEditorConfirmSaveDialog::closeNow(%this) +{ + Canvas.popDialog(%this); + EditorCore.schedule(100, "deleteDialogObject", %this); +} diff --git a/editor/GuiEditor/scripts/GuiEditorControlGroup.cs b/editor/GuiEditor/scripts/GuiEditorControlGroup.cs new file mode 100644 index 000000000..6bbcfa08a --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorControlGroup.cs @@ -0,0 +1,152 @@ +//----------------------------------------------------------------------------- +// One collapsible section of the control palette -- "Basics", "Layout" and so +// 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 +// 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. +// GuiExpandCtrl::toggleHiddenChildren force-writes mVisible on every DIRECT +// child of a panel whenever it expands, collapses or resizes -- so a tile +// parented to the panel would have its visibility rewritten out from under +// whatever set it. Grandchildren are left alone. +// +// Both view modes are this one grid with different cell metrics rather than two +// layouts. GuiGridCtrl treats CellSizeX as the NARROWEST a column may be, not +// its width: it fits as many columns as the pane affords and shares the +// remainder evenly (see GuiEditorInspectorPane::makeCellGrid). So grid mode asks +// for 100 and gets two or three columns depending on how wide the frame is +// dragged, and rows mode asks for something wider than the pane, which can only +// ever be one column. +// +// The creator sets Text and owner inline, calls addTile() per entry, then +// setMode(). There is no onRemove: the tiles were added to the grid and the grid +// to this panel, so deleting the panel frees the lot. +//----------------------------------------------------------------------------- + +// Width and height are separate numbers because they mean different things: the +// width is the NARROWEST a column may be and the reflow shares out the rest, +// while the height is exactly what a tile gets. +// +// A grid tile needs 108 of INNER height -- 56 of picture, a 44-pixel band for +// the name under it and 8 of air between and above. The 8 on top of that is the +// tile's own border: itemSelectProfile insets 3 pixels a side on the base theme +// and 4 on Torque Suit, and a cell sized to the interior would leave the fatter +// of the two clipping the bottom line of every name. The tile centers its +// picture in whatever room the inset actually leaves, so a theme that spends +// less than 4 gets slightly more air rather than a layout that drifts. +$GuiEditorControlGroup::gridCell = 100; +$GuiEditorControlGroup::gridCellHeight = 116; +$GuiEditorControlGroup::rowHeight = 40; +$GuiEditorControlGroup::headerHeight = 24; + +function GuiEditorControlGroup::onAdd(%this) +{ + %this.tileCount = 0; + + %this.grid = new GuiGridCtrl() + { + HorizSizing = "width"; + Position = "0" SPC $GuiEditorControlGroup::headerHeight; + Extent = "200 4"; + // Variable across, absolute down. Variable is what makes CellSizeX a + // minimum -- as many columns as fit, remainder shared -- and that is the + // whole reflow. Vertically it would instead size each row to its tallest + // child, which in row mode means a 116-pixel tile in a 40-pixel row. + CellModeX = "variable"; + CellModeY = "absolute"; + CellSizeX = $GuiEditorControlGroup::gridCell; + CellSizeY = $GuiEditorControlGroup::gridCellHeight; + CellSpacingX = 4; + CellSpacingY = 4; + MaxColCount = 0; + MaxRowCount = 0; + OrderMode = "lrtb"; + + // Without this the grid keeps the height it was built at, and the panel + // it lives in measures itself against that -- so a section would collapse + // to a sliver or leave a hole under its last row. + IsExtentDynamic = true; + }; + ThemeManager.setProfile(%this.grid, "emptyProfile"); + %this.add(%this.grid); +} + +function GuiEditorControlGroup::addTile(%this, %key) +{ + %tile = new GuiButtonCtrl() + { + class = "GuiEditorControlTile"; + HorizSizing = "width"; + VertSizing = "height"; + Position = "0 0"; + Extent = $GuiEditorControlGroup::gridCell SPC $GuiEditorControlGroup::gridCellHeight; + Text = ""; + key = %key; + owner = %this; + }; + ThemeManager.setProfile(%tile, "itemSelectProfile"); + %this.grid.add(%tile); + + %this.tile[%this.tileCount] = %tile; + %this.tileCount++; + + return %tile; +} + +//----------------------------------------------------------------------------- +// Modes. +//----------------------------------------------------------------------------- + +function GuiEditorControlGroup::setMode(%this, %mode) +{ + %this.mode = %mode; + %width = getWord(%this.getExtent(), 0); + + if(%mode $= "rows") + { + // One column, by asking for a cell wider than there is room for. + %this.grid.CellSizeX = %width; + %this.grid.CellSizeY = $GuiEditorControlGroup::rowHeight; + } + else + { + %this.grid.CellSizeX = $GuiEditorControlGroup::gridCell; + %this.grid.CellSizeY = $GuiEditorControlGroup::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, $GuiEditorControlGroup::headerHeight, %width, 4); + + for(%i = 0; %i < %this.tileCount; %i++) + { + %this.tile[%i].setMode(%mode); + } +} + +// A GuiPanelCtrl learns its own height only from parentResized -- its +// constructor defaults to 64x64 whatever Extent it was handed, and a chain +// positions its children without ever resizing them. Nudging the width by a +// pixel and back forces exactly one parentResized through the whole subtree and +// leaves the widths where they started. Same trick as +// GuiEditorInspectorPane::forceLayout. +function GuiEditorControlGroup::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); + + // A panel caches the height it opens to, measured from its children when it + // was last opened. Switching modes changes every one of those, so the cache + // has to be thrown away -- closing and reopening is what does it. Same move + // as AssetDictionary::fixSize, and skipped while collapsed so a section the + // user shut does not spring back open. + if(%this.getExpanded()) + { + %this.setExpanded(false); + %this.setExpanded(true); + } +} diff --git a/editor/GuiEditor/scripts/GuiEditorControlIcons.cs b/editor/GuiEditor/scripts/GuiEditorControlIcons.cs new file mode 100644 index 000000000..f0c41c8f5 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorControlIcons.cs @@ -0,0 +1,246 @@ +//----------------------------------------------------------------------------- +// The control palette's entries and their icons. GENERATED - do not hand-edit. +// +// GuiEditor:controlIcons16, controlIcons64 and controlIcons128 are the same 30 +// drawings at three sizes in the same 8x4 grid, so one index names the same icon +// in all of them and a tile can change size without changing its Frame. The +// sizes are source resolution, not tile size: the palette's grid view draws from +// the 128 sheet, its row view from the 64, and the Explorer tree from the 16. +// +// An entry is not always a class. A bare GuiControl is the wrapper, the backdrop, +// the line of text and the modal scrim, so it holds four entries that share a +// class and differ by the category they drop pre-stamped with. That is why this +// is a table and not a list of constants. +// +// Frame 0 is the fallback, deliberately. frameFor answers 0 for any key it does +// not know, and an unset Frame field reads as 0 too, so both failures land on a +// legible "unknown control" rather than on an arbitrary wrong picture. A control +// class added to the engine after this file was generated still reaches the +// palette -- see GuiEditorControlListWindow::populate -- it simply wears the +// question mark until someone draws it. +// +// Frame index is the order the sheet packs them in, which is deliberately NOT +// the order the palette shows them: display order comes from the group column +// and the group list below. That way regrouping the palette is a rerun that +// leaves the sheets untouched, and a reflowed index cannot repoint anything. +// Nothing should ever write a raw frame number; ask frameFor. +//----------------------------------------------------------------------------- + +function GuiEditorControlIcons::onAdd(%this) +{ + // The palette's collapsible sections, in the order they stack. + %this.groupList = "Basics" TAB "Layout" TAB "Input & Data" TAB "Advanced"; + + // Classes the palette will not offer, whatever the engine registers. The + // first group is editor and engine plumbing; the last three are real controls + // that nobody places by hand -- GuiDragAndDropCtrl is built at runtime to + // carry a drag payload, GuiMenuItemCtrl means nothing outside a menu bar, and + // a GuiTabPageCtrl is made by its book, from the + tab the book draws while + // the Gui is being authored. + %this.refusedNames = "GuiCanvas GuiDragAndDropCtrl GuiGraphCtrl GuiMenuItemCtrl GuiMessageVectorCtrl GuiParticleGraphInspector GuiSceneObjectCtrl GuiTabPageCtrl"; + %this.refusedPrefixes = "GuiConsole GuiEdit GuiInspector"; + %count = getWordCount(%this.refusedNames); + for(%i = 0; %i < %count; %i++) + { + %this.refused[getWord(%this.refusedNames, %i)] = true; + } + + // key TAB class TAB category TAB group TAB label + %table = + "unknown" TAB "" TAB "" TAB "" TAB "Unknown" NL + "GuiControl:Empty" TAB "GuiControl" TAB "Empty" TAB "Basics" TAB "Empty" NL + "GuiControl:Panel" TAB "GuiControl" TAB "Panel" TAB "Basics" TAB "Panel" NL + "GuiControl:Label" TAB "GuiControl" TAB "Label" TAB "Basics" TAB "Label" NL + "GuiControl:Overlay" TAB "GuiControl" TAB "Overlay" TAB "Advanced" TAB "Overlay" NL + "GuiButtonCtrl" TAB "GuiButtonCtrl" TAB "" TAB "Basics" TAB "Button" NL + "GuiCheckBoxCtrl" TAB "GuiCheckBoxCtrl" TAB "" TAB "Basics" TAB "Check Box" NL + "GuiRadioCtrl" TAB "GuiRadioCtrl" TAB "" TAB "Input & Data" TAB "Radio Button" NL + "GuiDropDownCtrl" TAB "GuiDropDownCtrl" TAB "" TAB "Basics" TAB "Drop Down" NL + "GuiColorPopupCtrl" TAB "GuiColorPopupCtrl" TAB "" TAB "Advanced" TAB "Color Popup" NL + "GuiTextEditCtrl" TAB "GuiTextEditCtrl" TAB "" TAB "Basics" TAB "Text Edit" NL + "GuiTextEditSliderCtrl" TAB "GuiTextEditSliderCtrl" TAB "" TAB "Input & Data" TAB "Number Box" NL + "GuiSliderCtrl" TAB "GuiSliderCtrl" TAB "" TAB "Input & Data" TAB "Slider" NL + "GuiProgressCtrl" TAB "GuiProgressCtrl" TAB "" TAB "Input & Data" TAB "Progress" NL + "GuiColorPickerCtrl" TAB "GuiColorPickerCtrl" TAB "" TAB "Advanced" TAB "Color Picker" NL + "GuiSpriteCtrl" TAB "GuiSpriteCtrl" TAB "" TAB "Basics" TAB "Sprite" NL + "SceneWindow" TAB "SceneWindow" TAB "" TAB "Advanced" TAB "Scene Window" NL + "GuiListBoxCtrl" TAB "GuiListBoxCtrl" TAB "" TAB "Input & Data" TAB "List Box" NL + "GuiTreeViewCtrl" TAB "GuiTreeViewCtrl" TAB "" TAB "Input & Data" TAB "Tree View" NL + "GuiMenuBarCtrl" TAB "GuiMenuBarCtrl" TAB "" TAB "Advanced" TAB "Menu Bar" NL + "GuiChainCtrl" TAB "GuiChainCtrl" TAB "" TAB "Layout" TAB "Chain" NL + "GuiGridCtrl" TAB "GuiGridCtrl" TAB "" TAB "Layout" TAB "Grid" NL + "GuiScrollCtrl" TAB "GuiScrollCtrl" TAB "" TAB "Layout" TAB "Scroll" NL + "GuiFrameSetCtrl" TAB "GuiFrameSetCtrl" TAB "" TAB "Advanced" TAB "Frame Set" NL + "GuiPanelCtrl" TAB "GuiPanelCtrl" TAB "" TAB "Layout" TAB "Panel" NL + "GuiExpandCtrl" TAB "GuiExpandCtrl" TAB "" TAB "Layout" TAB "Expand" NL + "GuiTabBookCtrl" TAB "GuiTabBookCtrl" TAB "" TAB "Layout" TAB "Tab Book" NL + "GuiTabPageCtrl" TAB "GuiTabPageCtrl" TAB "" TAB "Layout" TAB "Tab Page" NL + "GuiWindowCtrl" TAB "GuiWindowCtrl" TAB "" TAB "Layout" TAB "Window" NL + "GuiInputCtrl" TAB "GuiInputCtrl" TAB "" TAB "Advanced" TAB "Input"; + + %this.keyList = ""; + %count = getRecordCount(%table); + for(%i = 0; %i < %count; %i++) + { + %rec = getRecord(%table, %i); + %key = trim(getField(%rec, 0)); + + %this.frame[%key] = %i; + %this.ctrlClass[%key] = trim(getField(%rec, 1)); + %this.category[%key] = trim(getField(%rec, 2)); + %this.group[%key] = trim(getField(%rec, 3)); + %this.label[%key] = trim(getField(%rec, 4)); + + // The fallback is not something anyone drags, so it stays out of both the + // flat list and every group. It is what an unknown key resolves TO. + if(%i > 0) + { + %this.keyList = (%this.keyList $= "") ? %key : (%this.keyList TAB %key); + + // Which CLASSES the table accounts for, as opposed to which keys. The + // two differ for exactly one class: GuiControl is covered four times + // over, and by no key spelled "GuiControl". + %this.covered[%this.ctrlClass[%key]] = true; + + // A refused class keeps its row and everything the row carries -- its + // frame, its label, its place in covered[] -- and only loses its + // group. The row has to stay: frame IS the row index, so removing one + // repoints every icon below it onto the wrong art. And covered[] has + // to stay true, or the sweep over the class registry in + // GuiEditorControlListWindow::addUndrawnClasses offers the class back + // in an "Undrawn" group with a question mark for an icon. + // + // So the group list is the palette's view, and the key list is the + // table's. Only the first one refuses anything. + if(!%this.refused[%this.ctrlClass[%key]]) + { + %group = %this.group[%key]; + %held = %this.groupKeys[%group]; + %this.groupKeys[%group] = (%held $= "") ? %key : (%held TAB %key); + } + } + } +} + +// Every entry, tab separated, in frame order. The fallback is not among them. +function GuiEditorControlIcons::keys(%this) +{ + return %this.keyList; +} + +// The collapsible sections the palette builds, in the order they stack. +function GuiEditorControlIcons::groups(%this) +{ + return %this.groupList; +} + +// The entries in one section, tab separated. Their order within a section is +// frame order, which is already grouped by kind. +function GuiEditorControlIcons::keysInGroup(%this, %group) +{ + return %this.groupKeys[%group]; +} + +function GuiEditorControlIcons::groupFor(%this, %key) +{ + return %this.group[%key]; +} + +// Whether the palette can place a class at all. Generated from the same rule the +// sheet is built with, so the sweep for classes with no icon cannot disagree +// with the table above about what counts as placeable. +// +// GuiEditorControlSpec carries its own copy for its drift guard. That one +// answers "should this class be in the spec table"; this one answers "should the +// palette show it" -- same rule today, different questions, and this is the copy +// generated rather than typed. +function GuiEditorControlIcons::isPlaceableClass(%this, %name) +{ + if(%name $= "" || %this.refused[%name]) + { + return false; + } + + // SceneWindow is the one placeable control not named for the Gui hierarchy. + if(%name !$= "SceneWindow" && getSubStr(%name, 0, 3) !$= "Gui") + { + return false; + } + + %count = getWordCount(%this.refusedPrefixes); + for(%i = 0; %i < %count; %i++) + { + %prefix = getWord(%this.refusedPrefixes, %i); + if(getSubStr(%name, 0, strlen(%prefix)) $= %prefix) + { + return false; + } + } + + return true; +} + +// Frame 0 - the question mark - for anything this table has never heard of. +function GuiEditorControlIcons::frameFor(%this, %key) +{ + %frame = %this.frame[%key]; + return (%frame $= "") ? 0 : %frame; +} + +// The class to instantiate for an entry, and the profile category to stamp it +// with. An empty category means the class pins its own and there is nothing to +// choose - see GuiEditorThemeApplier::buildClassTable. +function GuiEditorControlIcons::classFor(%this, %key) +{ + // ctrlClass, not class: "class" is a real SimObject field, and an array named + // for it is a collision waiting for whoever adds the next lookup. + %name = %this.ctrlClass[%key]; + return (%name $= "") ? %key : %name; +} + +function GuiEditorControlIcons::categoryFor(%this, %key) +{ + return %this.category[%key]; +} + +// What the row view writes beside the icon. Falls back to the key, which for +// everything but the four GuiControl entries is the class name. +function GuiEditorControlIcons::labelFor(%this, %key) +{ + %label = %this.label[%key]; + return (%label $= "") ? %key : %label; +} + +// True when this key came from the table rather than from the runtime sweep over +// enumerateConsoleClasses. An unknown entry still works; it just has no icon. +function GuiEditorControlIcons::isKnown(%this, %key) +{ + return %this.frame[%key] !$= ""; +} + +// Whether some entry builds this class, which is not the same question as +// isKnown. A bare GuiControl has four entries and none of them is keyed +// "GuiControl", so asking isKnown about the class name says no and the sweep for +// undrawn classes would offer a fifth, iconless copy of a control the palette +// already shows four ways. +function GuiEditorControlIcons::coversClass(%this, %name) +{ + return %this.covered[%name] !$= ""; +} + +// The sheet to draw from at a given tile size. Each threshold is that sheet's +// own resolution, not a midpoint: past it the sheet would have to be enlarged, +// and enlarging is what looks soft. Taking the next one up and shrinking it +// costs nothing and stays sharp. +function GuiEditorControlIcons::sheetFor(%this, %tileSize) +{ + if(%tileSize > 64) + { + return "GuiEditor:controlIcons128"; + } + if(%tileSize > 16) + { + return "GuiEditor:controlIcons64"; + } + return "GuiEditor:controlIcons16"; +} diff --git a/editor/GuiEditor/scripts/GuiEditorControlListBox.cs b/editor/GuiEditor/scripts/GuiEditorControlListBox.cs deleted file mode 100644 index a056b9d4d..000000000 --- a/editor/GuiEditor/scripts/GuiEditorControlListBox.cs +++ /dev/null @@ -1,38 +0,0 @@ - -function GuiEditorControlListBox::onTouchDragged(%this, %index, %text) -{ - %position = GuiEditor.brain.getGlobalPosition(); - %cursorpos = Canvas.getCursorPos(); - - %class = %this.getItemText(%this.getSelectedItem()); - %payload = eval("return new " @ %class @ "();"); - if(!isObject(%payload)) - return; - - %xOffset = (getWord(%payload.extent, 0) / 2) + getWord(%position, 0); - %yOffset = (getWord(%payload.extent, 1) / 2) + getWord(%position, 1); - - // position where the drag will start, to prevent visible jumping. - %xPos = getWord(%cursorpos, 0) - %xOffset; - %yPos = getWord(%cursorpos, 1) - %yOffset; - - %dragCtrl = new GuiDragAndDropCtrl() { - canSaveDynamicFields = "0"; - Profile = "GuiDragAndDropProfile"; - HorizSizing = "right"; - VertSizing = "bottom"; - Position = %xPos SPC %yPos; - extent = %payload.extent; - MinExtent = "32 32"; - canSave = "1"; - Visible = "1"; - hovertime = "1000"; - Text = %text; - deleteOnMouseUp = true; - }; - - %dragCtrl.add(%payload); - GuiEditor.brain.add(%dragCtrl); - - %dragCtrl.startDragging(%xOffset, %yOffset); -} \ No newline at end of file diff --git a/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs b/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs index 321cb1d6b..a9a3bab38 100644 --- a/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs +++ b/editor/GuiEditor/scripts/GuiEditorControlListWindow.cs @@ -1,62 +1,282 @@ //GuiEditorControlListWindow.cs +// +// The control palette. Two view buttons at the top, then the placeable controls +// as collapsible groups of icon tiles. +// +// What this replaced: a GuiListBoxCtrl of raw class names, which told a person +// nothing about what any of them looked like. The class names now live in the +// tooltips and the pictures do the work. +// +// controls GuiEditorControlIcons, generated alongside the icon sheets +// groups one GuiEditorControlGroup per section, stacked in a chain +// tiles one GuiEditorControlTile per entry, inside each group's grid + +$GuiEditorControlListWindow::modeBarHeight = 28; function GuiEditorControlListWindow::onAdd(%this) { - %this.scroller = new GuiScrollCtrl() - { - HorizSizing="width"; - VertSizing="height"; - Position="0 0"; - Extent="242 355"; - hScrollBar="alwaysOff"; - vScrollBar="alwaysOn"; - constantThumbHeight="0"; - showArrowButtons="1"; - scrollBarThickness="14"; + %this.mode = "grid"; + %this.groupCount = 0; + + %this.buildModeRow(); + + // Built filling the whole content rect, then moved down under the mode bar 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 = "242 327"; + hScrollBar = "alwaysOff"; + vScrollBar = "dynamic"; + constantThumbHeight = "0"; + showArrowButtons = "1"; + scrollBarThickness = "14"; }; - ThemeManager.setProfile(%this.scroller, "emptyProfile"); - ThemeManager.setProfile(%this.scroller, "thumbProfile", "ThumbProfile"); - ThemeManager.setProfile(%this.scroller, "trackProfile", "TrackProfile"); - ThemeManager.setProfile(%this.scroller, "scrollArrowProfile", "ArrowProfile"); + 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 anywhere below. + // + // The scroller's horizontal bar is alwaysOff, so across is an axis that does + // not scroll: the room is bounded and the engine knows exactly what it is -- + // the inner rect, less the vertical bar when that is showing. Fill asks for + // precisely that, and keeps asking as the frame is dragged and as the bar + // comes and goes. Down is left alone; that axis scrolls, so the chain is as + // tall as its groups and GuiScrollCtrl refuses fill there. + %this.groupChain = new GuiChainCtrl() + { + HorizSizing = "fill"; + Position = "0 0"; + Extent = "228 4"; + IsVertical = true; + ChildSpacing = 2; + }; + ThemeManager.setProfile(%this.groupChain, "emptyProfile"); + %this.scroller.add(%this.groupChain); + + %this.populate(); +} + +// Put the scroller under the mode bar 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 the gaps the authored extent happened to +// start with -- which means guessing the title height and the border sizes, the +// exact guess that left every one of these windows clipping its last eight +// pixels when the title bar grew from 20 to 28. +// +// 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. +function GuiEditorControlListWindow::fitScroller(%this) +{ + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + + // Nudge the width by a pixel and back: one parentResized through every child, + // widths unchanged. Same move as GuiEditorInspectorPane::forceLayout. + %this.resize(0, 0, %w + 1, %h); + %this.resize(0, 0, %w, %h); - %this.listBox = new GuiListBoxCtrl() - { - class = "GuiEditorControlListBox"; - HorizSizing="width"; - VertSizing="height"; - Position="0 0"; - AllowMultipleSelections = "0"; - fitParentWidth = "1"; - }; - ThemeManager.setProfile(%this.listBox, "listBoxProfile"); - %this.scroller.add(%this.listBox); + %inner = %this.scroller.getExtent(); + %bar = $GuiEditorControlListWindow::modeBarHeight; - %this.populate(); + %this.scroller.HorizSizing = "width"; + %this.scroller.VertSizing = "height"; + %this.scroller.resize(0, %bar, getWord(%inner, 0), getWord(%inner, 1) - %bar); } function GuiEditorControlListWindow::onRemove(%this) { - if(isObject(%this.scroller)) - { - %this.scroller.delete(); - } + // The mode row and the scroller are this window's two children; the groups + // and their tiles hang off the scroller and go with it. + if(isObject(%this.modeRow)) + { + %this.modeRow.delete(); + } + if(isObject(%this.scroller)) + { + %this.scroller.delete(); + } } +//----------------------------------------------------------------------------- +// The view switch. GuiEditorChoiceRow 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. +//----------------------------------------------------------------------------- + +function GuiEditorControlListWindow::buildModeRow(%this) +{ + %this.modeRow = new GuiControl() + { + class = "GuiEditorChoiceRow"; + HorizSizing = "width"; + Position = "4 2"; + labelText = ""; + + // 4, not 0: build() sizes its caption at labelWidth - 4, so a zero here + // asks for a control four pixels wide in the wrong direction. + labelWidth = 4; + owner = %this; + fieldName = "mode"; + }; + %this.add(%this.modeRow); + + %this.modeRow.addChoice("grid", $EditorIcon::grid_2x2, "Show controls as a grid of large icons"); + %this.modeRow.addChoice("rows", $EditorIcon::list_bullets, "Show controls as a compact list"); + %this.modeRow.build(); + %this.modeRow.setValue(%this.mode); +} + +function GuiEditorControlListWindow::onChoiceRowChanged(%this, %row) +{ + %this.setMode(%row.getValue()); +} + +function GuiEditorControlListWindow::setMode(%this, %mode) +{ + %this.mode = %mode; + + for(%i = 0; %i < %this.groupCount; %i++) + { + %this.group[%i].setMode(%mode); + } + + %this.relayout(); +} + +// Each group has to be told to remeasure, and then the chain has to be told its +// children changed height. Neither happens on its own: a chain positions its +// children without resizing them, and a panel only learns its height from a +// parentResized it would otherwise never receive. +// +// No widths here. The chain fills the scroller, and the groups follow the chain, +// so how much room the vertical bar leaves is the engine's answer to give -- see +// GuiScrollCtrl::getInnerRect and preventUnsizedModes. This used to measure that +// room in script and hand it out, which held only until the frame was next +// dragged: "width" sizing adds the parent's CHANGE to a child's own width, so an +// authored number is offset forever and never replaced. tests/smoke/palette.cs +// sweeps the window across 31 widths rather than checking one, because a single +// static check is exactly what all three script attempts passed. +function GuiEditorControlListWindow::relayout(%this) +{ + for(%i = 0; %i < %this.groupCount; %i++) + { + %this.group[%i].forceLayout(); + } + + %w = getWord(%this.groupChain.getExtent(), 0); + %h = getWord(%this.groupChain.getExtent(), 1); + %this.groupChain.resize(0, 0, %w, %h); +} + +//----------------------------------------------------------------------------- +// Filling it. +// +// The generated table decides the order and the grouping. Then anything the +// engine registers that the table has never heard of is swept into a group of +// its own wearing the question-mark icon -- so a control class added after the +// icons were generated still appears and can still be placed. It just has no +// picture yet. +//----------------------------------------------------------------------------- + function GuiEditorControlListWindow::populate(%this) { - %controls = enumerateConsoleClasses("GuiControl"); - %this.listBox.clearItems(); - for(%i = 0; %i < getFieldCount(%controls); %i++) + %icons = GuiEditor.controlIcons; + %groups = %icons.groups(); + + for(%g = 0; %g < getFieldCount(%groups); %g++) + { + %name = getField(%groups, %g); + %group = %this.addGroup(%name); + + %keys = %icons.keysInGroup(%name); + for(%i = 0; %i < getFieldCount(%keys); %i++) + { + %group.addTile(getField(%keys, %i)); + } + + // A GuiExpandCtrl starts collapsed (mExpanded is false in the + // constructor), and it measures its open height from the children it has + // at the moment it opens -- so this has to come after the tiles, not with + // the panel. + %group.setExpanded(true); + } + + %this.addUndrawnClasses(); + %this.setMode(%this.mode); +} + +function GuiEditorControlListWindow::addGroup(%this, %title) +{ + %group = new GuiPanelCtrl() { - %field = getField(%controls, %i); + class = "GuiEditorControlGroup"; - if(%field !$= "GuiCanvas" && (%field $= "SceneWindow" || getSubStr(%field, 0, 3) $= "Gui") && - getSubStr(%field, 0, 10) !$= "GuiConsole" && getSubStr(%field, 0, 7) !$= "GuiEdit" && - getSubStr(%field, 0, 12) !$= "GuiInspector" && %field !$= "GuiMessageVectorCtrl" && - %field !$= "GuiParticleGraphInspector" && %field !$= "GuiGraphCtrl" && %field !$= "GuiSceneObjectCtrl") - { - %this.listBox.addItem(%field); - } + // Width, NOT fill, however much a group is meant to be exactly as wide as + // the chain. A GuiPanelCtrl sizes itself to its children -- + // GuiExpandCtrl::parentResized ends by writing mExpandedExtent straight + // into mBounds.extent -- and a direct write like that goes around resize, + // which is the only thing that honours fill. Asked to fill, a panel is + // clamped and then immediately overwrites the clamp with its own answer. + HorizSizing = "width"; + Position = "0 0"; + Extent = "242" SPC $GuiEditorControlGroup::headerHeight; + MinExtent = "80" SPC $GuiEditorControlGroup::headerHeight; + Text = %title; + command = ""; + owner = %this; + }; + ThemeManager.setProfile(%group, "panelProfile"); + %this.groupChain.add(%group); + + %this.group[%this.groupCount] = %group; + %this.groupCount++; + + return %group; +} + +// The runtime sweep, kept as a tail rather than as the whole list. Both the +// filter and the table come from GuiEditorControlIcons, which is generated from +// the same rule the sheets are built with -- so the sweep cannot disagree with +// the table about what counts as placeable, and this does not have to reach +// across into another window to ask. +function GuiEditorControlListWindow::addUndrawnClasses(%this) +{ + %icons = GuiEditor.controlIcons; + %classes = enumerateConsoleClasses("GuiControl"); + %group = 0; + + for(%i = 0; %i < getFieldCount(%classes); %i++) + { + %name = trim(getField(%classes, %i)); + + // coversClass, not isKnown: the four GuiControl entries are keyed + // "GuiControl:Empty" and so on, so asking whether "GuiControl" is a known + // KEY says no and would add a fifth, iconless copy of it. + if(!%icons.isPlaceableClass(%name) || %icons.coversClass(%name)) + { + continue; + } + + // Built only if something turns up, so the ordinary case shows four + // groups rather than five with an empty one. + if(!isObject(%group)) + { + %group = %this.addGroup("Undrawn"); + } + %group.addTile(%name); } -} \ No newline at end of file +} diff --git a/editor/GuiEditor/scripts/GuiEditorControlSpec.cs b/editor/GuiEditor/scripts/GuiEditorControlSpec.cs new file mode 100644 index 000000000..04af549a6 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorControlSpec.cs @@ -0,0 +1,1056 @@ + +//----------------------------------------------------------------------------- +// The context model behind the Gui Editor's properties pane: which of a +// control's fields actually matter for the kind of control it is. +// +// The sibling of GuiProfileEditorFieldSpec, and the same idea. A GuiControl +// registers a field for everything any control might want, but a given class +// reads a fraction of it: a GuiChainCtrl never draws its own text, so the nine +// text fields it inherits do nothing, and a GuiTabPageCtrl's position and +// extent are overwritten by its book on every layout pass. Editing either looks +// like it works and silently reverts. +// +// Unlike the profile spec there is no Show All. A profile field that a category +// never reads could still be read by some other category, so hiding it is a +// filter; here a hidden field is one the engine provably never looks at for +// this class, so there is nothing to reveal. +// +// Every entry below is derived from the class's render and layout paths, not +// from what the field name suggests. The surprising ones carry the source line +// that settles them. +// +// Four traits per class: +// +// textRole What the control's own "text" field does. +// render drawn through GuiControl::renderText -- every +// text field applies. +// caption drawn on the control's behalf by something else +// (a panel's header button, a tab book drawing its +// page's text with mTabProfile). The string matters; +// the control's own layout fields do not. +// placeholder drawn only while nothing is selected +// (guiDropDownCtrl.cc, getSelectedItem() == -1). +// Same fields as render, different label. +// proxy renderText runs, but with someone else's string +// (list and tree item text). Layout fields live, +// text and textID dead. +// inherited no onRender override, so GuiControl::onRender +// draws mText. Live, but nobody wants text on a +// grid -- it goes in a collapsed section rather +// than the header. +// none never drawn. +// flags fontAdjust reads mFontSizeAdjust outside renderText, so the +// field is live even where the text block is not. +// easing calls GuiEasingSupport::getFillColor, which is what +// makes the four ease* fields do anything. +// command fires Command on interaction rather than only +// through an accelerator, so it earns its own section. +// bare registers no GuiControl fields at all. +// hides Fields this class must drop from the shared rows despite its +// textRole -- the per-class exceptions. +// sections The class's own collapsible sections, declared below. +// +// A fifth trait, geometryMode, is a property of the control's PARENT rather +// than its class, so it is computed per control instead of tabled. +// +// Fields absent from everything here are never shown: canSave (its write +// function is defaultProtectedNotWriteFn, so it never even serializes), +// canSaveDynamicFields, and parentGroup. +// +// TypeGuiCursor fields were once in that list too, because GuiCursor had no +// category and GuiProfileTheme had no cursor table. Both now exist, so a cursor +// slot joins the Variants scheme on the same terms as a profile slot: the theme +// fills it silently, and a row appears only where the theme holds more than one +// cursor for that job. They stay "hidden" to the generic field walk regardless, +// so an unremarkable window shows no cursor rows at all. +// +// The spec is pure data with no UI; the pane owns one and asks it what to show. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::onAdd(%this) +{ + // class TAB textRole TAB flags TAB per-class hides + %table = + "GuiControl" TAB "render" TAB "" TAB "" NL + + // Buttons. Easing is narrower than the class tree suggests: only + // GuiButtonCtrl and GuiDropDownCtrl actually call getFillColor. + // GuiCheckBoxCtrl::onRender draws its box through renderInnerControl and + // never renders a universal rect for itself, so Radio inherits a dead + // set too. + "GuiButtonCtrl" TAB "render" TAB "command easing" TAB "" NL + "GuiCheckBoxCtrl" TAB "render" TAB "command" TAB "" NL + "GuiRadioCtrl" TAB "render" TAB "command" TAB "" NL + "GuiDropDownCtrl" TAB "placeholder" TAB "command easing" TAB "" NL + "GuiColorPopupCtrl" TAB "none" TAB "command" TAB "" NL + + // A text edit reads mTextWrap (it is the multi-line control) and + // getAlignmentType, but never mVAlignment or mTextExtend. + "GuiTextEditCtrl" TAB "render" TAB "command" TAB "vAlign textExtend" NL + "GuiTextEditSliderCtrl" TAB "render" TAB "command" TAB "vAlign textExtend" NL + + // The slider draws its value with dglDrawText, not renderText, so the + // text block is dead -- but guiSliderCtrl.cc still sizes that draw with + // getFont(mFontSizeAdjust). + "GuiSliderCtrl" TAB "none" TAB "command fontAdjust" TAB "" NL + "GuiColorPickerCtrl" TAB "render" TAB "command" TAB "" NL + "GuiProgressCtrl" TAB "render" TAB "" TAB "" NL + "GuiSpriteCtrl" TAB "none" TAB "" TAB "" NL + + // Lists render item text through renderText with their own profile, so + // the layout fields are live even though "text" is never drawn. Not + // textExtend: it would resize the whole list to fit one item. + "GuiListBoxCtrl" TAB "proxy" TAB "" TAB "" NL + "GuiTreeViewCtrl" TAB "proxy" TAB "" TAB "BindToGuiEditor" NL + + "GuiMenuBarCtrl" TAB "none" TAB "" TAB "" NL + "GuiMenuItemCtrl" TAB "caption" TAB "command bare" TAB "" NL + + // Layout containers. The chain draws only a "+" in edit mode. + "GuiChainCtrl" TAB "none" TAB "" TAB "" NL + "GuiGridCtrl" TAB "inherited" TAB "" TAB "" NL + "GuiScrollCtrl" TAB "none" TAB "" TAB "" NL + "GuiFrameSetCtrl" TAB "none" TAB "easing" TAB "" NL + + // A panel hands its text to the header button it owns; the button + // renders it, so the panel's own layout fields never run. + "GuiPanelCtrl" TAB "caption" TAB "" TAB "" NL + "GuiExpandCtrl" TAB "inherited" TAB "" TAB "" NL + + // The book draws each page's text as the tab label, using mTabProfile + // and the BOOK's mFontSizeAdjust -- so the layout fields belong to the + // book and the string belongs to the page. + "GuiTabBookCtrl" TAB "proxy" TAB "" TAB "textWrap" NL + "GuiTabPageCtrl" TAB "caption" TAB "" TAB "" NL + + "GuiWindowCtrl" TAB "render" TAB "" TAB "" NL + "GuiDragAndDropCtrl" TAB "inherited" TAB "" TAB "" NL + "GuiInputCtrl" TAB "inherited" TAB "command" TAB "" NL + "SceneWindow" TAB "none" TAB "" TAB ""; + + %this.classNames = ""; + %count = getRecordCount(%table); + for(%i = 0; %i < %count; %i++) + { + %rec = getRecord(%table, %i); + %name = trim(getField(%rec, 0)); + + // "class" is a real SimObject field, so the table uses textRole. + %this.textRole[%name] = trim(getField(%rec, 1)); + %this.flags[%name] = trim(getField(%rec, 2)); + %this.hides[%name] = trim(getField(%rec, 3)); + %this.sectionKeyList[%name] = ""; + + %this.classNames = (%this.classNames $= "") ? %name : (%this.classNames SPC %name); + } + + %this.buildSections(); + %this.buildHeaderValues(); + %this.buildLabels(); +} + +//----------------------------------------------------------------------------- +// The class-specific sections. Declared in full per class rather than +// accumulated up an inheritance chain: a section list reads as documentation of +// what the control is, and the handful of repeated lines cost less than a +// chain-walking rule that every future exception would have to fight. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::buildSections(%this) +{ + %this.addSection("GuiCheckBoxCtrl", "Box", "Check Box", + "boxOffset boxExtent textOffset textExtent"); + %this.addSection("GuiRadioCtrl", "Box", "Radio Button", + "boxOffset boxExtent textOffset textExtent"); + + %this.addSection("GuiDropDownCtrl", "DropDown", "Drop Down", "maxHeight"); + %this.addSection("GuiDropDownCtrl", "Scrollbar", "Scroll Bar", + "constantThumbHeight showArrowButtons scrollBarThickness"); + + %this.addSection("GuiColorPopupCtrl", "Popup", "Popup", + "popupSize barHeight showAlphaBar swatchColumns showColorValues valueMode valueBoxHeight"); + + %this.addSection("GuiTextEditCtrl", "Input", "Input", + "maxLength password returnCausesTab sinkAllKeyEvents"); + %this.addSection("GuiTextEditCtrl", "Commands", "Commands", + "returnCommand escapeCommand"); + %this.addSection("GuiTextEditSliderCtrl", "Input", "Input", + "maxLength password returnCausesTab sinkAllKeyEvents"); + %this.addSection("GuiTextEditSliderCtrl", "Commands", "Commands", + "returnCommand escapeCommand"); + %this.addSection("GuiTextEditSliderCtrl", "Number", "Number", "format"); + + %this.addSection("GuiSliderCtrl", "Slider", "Slider", "ticks"); + %this.addSection("GuiColorPickerCtrl", "Picker", "Picker", + "PickColor ShowSelector ActionOnMove"); + %this.addSection("GuiProgressCtrl", "Progress", "Progress", "animationTime"); + + // Image, Animation and Bitmap are three ways to say the same thing, so the + // pane shows one of the three at a time; sourceMode below picks which. + %this.addSection("GuiSpriteCtrl", "Fit", "Fit", + "fullSize imageSize constrainProportions clampImage tileImage positionOffset"); + %this.addSection("GuiSpriteCtrl", "Color", "Color", "imageColor"); + + %this.addSection("GuiListBoxCtrl", "List", "List", + "AllowMultipleSelections FitParentWidth"); + // IndentSize reads 0 for "one row height", which is the step the tree has + // always used; IconImage empty means a row draws no picture and spends no + // width on one. Claimed here rather than left to the Other section so the + // three arrive together, under the heading that explains them. + %this.addSection("GuiTreeViewCtrl", "Tree", "Tree", + "AllowReorder IndentSize IconImage IconSize"); + %this.addSection("GuiTreeViewCtrl", "List", "List", + "AllowMultipleSelections FitParentWidth"); + + %this.addSection("GuiMenuBarCtrl", "Scrollbar", "Scroll Bar", + "constantThumbHeight showArrowButtons scrollBarThickness"); + // No section for GuiMenuItemCtrl. Everything it has is in the header, in + // GuiEditorMenuItemBlock: a menu item has no profile, no geometry and no + // tooltip, so a header with a caption in it and three empty sections below + // was most of what the pane showed. Toggle, Radio and IsOn went with it - + // they are one decision, and the block draws them as one. + + %this.addSection("GuiGridCtrl", "Grid", "Grid", + "CellSpacingX CellSpacingY MaxColCount MaxRowCount OrderMode IsExtentDynamic"); + %this.addSection("GuiScrollCtrl", "Scrollbar", "Scroll Bar", + "constantThumbHeight showArrowButtons scrollBarThickness"); + + %this.addSection("GuiPanelCtrl", "Expand", "Expand", "easeExpand easeTimeExpand"); + %this.addSection("GuiExpandCtrl", "Expand", "Expand", "easeExpand easeTimeExpand"); + + %this.addSection("GuiTabBookCtrl", "Tabs", "Tabs", "MinTabWidth"); + + // The six window toggles have no section: they are an icon row beside Title + // Height in the header, where six switches take one line instead of six. + // See GuiEditorControlSpec::windowToggles. + %this.addSection("GuiWindowCtrl", "Grips", "Resize Grips", + "resizeRightWidth resizeBottomHeight"); + + %this.addSection("GuiDragAndDropCtrl", "Drag", "Drag", "deleteOnMouseUp"); + + %this.addSection("SceneWindow", "SceneInput", "Scene Input", + "lockMouse UseWindowInputEvents UseObjectInputEvents"); + %this.addSection("SceneWindow", "Background", "Background", + "UseBackgroundColor BackgroundColor"); + %this.addSection("SceneWindow", "Scrollbar", "Scroll Bar", + "constantThumbHeight showArrowButtons scrollBarThickness"); +} + +function GuiEditorControlSpec::addSection(%this, %class, %key, %title, %fields) +{ + %this.sectionTitleFor[%class, %key] = %title; + %this.sectionFieldsFor[%class, %key] = %fields; + + %list = %this.sectionKeyList[%class]; + %this.sectionKeyList[%class] = (%list $= "") ? %key : (%list SPC %key); +} + +function GuiEditorControlSpec::sectionKeys(%this, %class) +{ + return %this.sectionKeyList[%class]; +} + +function GuiEditorControlSpec::sectionTitle(%this, %class, %key) +{ + return %this.sectionTitleFor[%class, %key]; +} + +function GuiEditorControlSpec::sectionFields(%this, %class, %key) +{ + return %this.sectionFieldsFor[%class, %key]; +} + +// Whether the class gets the Items section: the static rows a list is authored +// with, saved with the Gui as TAML custom nodes. +// +// A function rather than a fifth column in the table above, because two classes +// out of thirty do not earn one; and a list of two rather than an ancestry test, +// because GuiTreeViewCtrl derives from GuiListBoxCtrl and must NOT have it. A +// tree's rows are generated from a root object, so anything typed into them +// would be gone the moment the tree next built itself - which is why +// GuiTreeViewCtrl::writesItems returns false on the engine side too. +function GuiEditorControlSpec::hasItemList(%this, %class) +{ + return %class $= "GuiListBoxCtrl" || %class $= "GuiDropDownCtrl"; +} + +//----------------------------------------------------------------------------- +// The principal value: the one or two fields that are the whole point of the +// control, promoted out of their section and into the always-visible header. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::buildHeaderValues(%this) +{ + %this.headerValues["GuiCheckBoxCtrl"] = "stateOn"; + %this.headerValues["GuiRadioCtrl"] = "stateOn groupNum"; + %this.headerValues["GuiColorPopupCtrl"] = "baseColor"; + %this.headerValues["GuiTextEditCtrl"] = "inputMode"; + %this.headerValues["GuiTextEditSliderCtrl"] = "inputMode range increment"; + %this.headerValues["GuiSliderCtrl"] = "range value"; + %this.headerValues["GuiColorPickerCtrl"] = "BaseColor DisplayMode"; + %this.headerValues["GuiProgressCtrl"] = "Variable"; + %this.headerValues["GuiChainCtrl"] = "IsVertical ChildSpacing"; + %this.headerValues["GuiGridCtrl"] = "CellModeX CellModeY CellSizeX CellSizeY"; + %this.headerValues["GuiScrollCtrl"] = "hScrollBar vScrollBar"; + %this.headerValues["GuiFrameSetCtrl"] = "DividerThickness"; + %this.headerValues["GuiTabBookCtrl"] = "TabPosition"; + %this.headerValues["GuiWindowCtrl"] = "titleHeight"; + + // GuiMenuItemCtrl is deliberately absent too: IsOn is one of the three fields + // GuiEditorMenuItemBlock draws as a single choice, so it is not a value the + // generic header block has anything to say about. + + // GuiSpriteCtrl is deliberately absent: its principal value is three + // mutually exclusive fields rather than a fixed list, so the pane asks + // spriteSourceFields below instead of reading a header value here. +} + +function GuiEditorControlSpec::headerValueFields(%this, %class) +{ + return %this.headerValues[%class]; +} + +// The three ways a GuiSpriteCtrl can name its picture. Only one is ever in +// effect, so offering all three at once invites setting two and wondering which +// won. The pane shows the fields of the mode the control is currently in. +function GuiEditorControlSpec::spriteSourceFields(%this, %mode) +{ + switch$(%mode) + { + case "Animation": return "Animation"; + case "Bitmap": return "Bitmap singleFrameBitmap"; + } + return "Image Frame NamedFrame"; +} + +function GuiEditorControlSpec::spriteSourceModes(%this) +{ + return "Image" TAB "Animation" TAB "Bitmap"; +} + +// Which of the three a control is actually using, judged by what it holds. +function GuiEditorControlSpec::spriteSourceModeOf(%this, %ctrl) +{ + if(%ctrl.Animation !$= "") + { + return "Animation"; + } + if(%ctrl.Image $= "" && %ctrl.Bitmap !$= "") + { + return "Bitmap"; + } + return "Image"; +} + +//----------------------------------------------------------------------------- +// The shared field groups. These rows are built once and filtered with +// setVisible, because every class has them -- only whether they mean anything +// changes. +//----------------------------------------------------------------------------- + +// Drawn by GuiControl::renderText, so they live or die with textRole. +function GuiEditorControlSpec::textFields(%this) +{ + return "text textID textWrap textExtend align vAlign fontSizeAdjust overrideFontColor fontColor"; +} + +// The subset a proxy role keeps: renderText still runs, just with a string the +// control did not get from its text field. +function GuiEditorControlSpec::textLayoutFields(%this) +{ + return "textWrap textExtend align vAlign fontSizeAdjust overrideFontColor fontColor"; +} + +function GuiEditorControlSpec::geometryFields(%this) +{ + return "Position Extent HorizSizing VertSizing MinExtent"; +} + +function GuiEditorControlSpec::layoutFields(%this) +{ + return "MinExtent isContainer"; +} + +function GuiEditorControlSpec::tooltipFields(%this) +{ + return "tooltip tooltipWidth hovertime"; +} + +function GuiEditorControlSpec::scriptingFields(%this) +{ + return "class superclass internalName Variable Command AltCommand Accelerator"; +} + +function GuiEditorControlSpec::commandFields(%this) +{ + return "Command AltCommand Variable Accelerator"; +} + +function GuiEditorControlSpec::localizationFields(%this) +{ + return "langTableMod textID"; +} + +// Each easing beside the time it takes, because that is how they are read: the +// section runs them in pairs and the grid puts two on a line. +function GuiEditorControlSpec::easingFields(%this) +{ + return "easeFillColorHL easeTimeFillColorHL easeFillColorSL easeTimeFillColorSL"; +} + +// The toggles the header draws as icons instead of rows: what the control does +// when the game runs. +function GuiEditorControlSpec::runtimeToggles(%this) +{ + return "Visible Active useInput"; +} + +// The two fields the pane deliberately does not offer AT ALL. They are editor +// working state -- neither is ever written to a file -- and they live in the +// Explorer tree's two columns, where a whole branch can be read at once. +// +// This still has to name them. buildOtherSection sweeps up every persist field +// no section claimed, so dropping them from here would not remove them from the +// pane: it would move them into "Other" as two generic checkboxes, which is the +// exact thing moving them out was meant to stop. +function GuiEditorControlSpec::editorToggles(%this) +{ + return "hidden locked"; +} + +// A window's six switches, which are what its title bar offers and what it lets +// you do to it. Six captioned checkboxes were a section of their own; six icons +// are a row that fits beside Title Height. +function GuiEditorControlSpec::windowToggles(%this) +{ + return "canMove canClose canMinimize canMaximize resizeWidth resizeHeight"; +} + +// Which of the header's three runtime switches a class actually has. +// +// Not simply "all of them unless bare". A bare class calls +// SimObject::initPersistFields rather than GuiControl's, and may then register +// some of GuiControl's names again for itself - which GuiMenuItemCtrl does with +// Active and Visible (see GuiMenuItemCtrl::initPersistFields). Those two are +// real fields on it and the switches work; useInput is not, and does not. +function GuiEditorControlSpec::stateToggles(%this, %class) +{ + if(%this.hasFlag(%class, "bare")) + { + return (%class $= "GuiMenuItemCtrl") ? "Visible Active" : ""; + } + + return "Visible Active useInput"; +} + +// Never shown anywhere, for any class. +function GuiEditorControlSpec::deadFields(%this) +{ + return "canSave canSaveDynamicFields parentGroup"; +} + +// Space-delimited membership. Wrapping both sides in spaces keeps text from +// matching inside textWrap. +function GuiEditorControlSpec::listHas(%this, %list, %item) +{ + return strstr(" " @ %list @ " ", " " @ %item @ " ") >= 0; +} + +//----------------------------------------------------------------------------- +// Class lookup. An unrecognized class -- a C++ subclass added after this table +// was written -- is treated as unknown, and an unknown class shows everything +// rather than less. The pane puts its own fields in a single "Other" section, +// which lands it back at roughly what the native inspector did. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::isKnownClass(%this, %class) +{ + return %class !$= "" && %this.textRole[%class] !$= ""; +} + +function GuiEditorControlSpec::textRoleFor(%this, %class) +{ + return %this.isKnownClass(%class) ? %this.textRole[%class] : "render"; +} + +function GuiEditorControlSpec::hasFlag(%this, %class, %flag) +{ + if(!%this.isKnownClass(%class)) + { + // An unknown class gets the permissive answer everywhere except "bare", + // which would strip its entire header. + return %flag !$= "bare"; + } + return %this.listHas(%this.flags[%class], %flag); +} + +function GuiEditorControlSpec::classHides(%this, %class, %field) +{ + return %this.isKnownClass(%class) && %this.listHas(%this.hides[%class], %field); +} + +//----------------------------------------------------------------------------- +// Geometry. This one is not a property of the control's class but of its +// parent's: four containers write their children's bounds outright, so the +// fields they own must be read-only wherever the child happens to sit. It has +// to be recomputed when a control is reparented, not only when it is selected. +//----------------------------------------------------------------------------- + +// full the control owns its position, extent and both sizing enums +// none the parent owns all of it +// chainV a vertical chain owns the Y position and the vertical sizing +// chainH a horizontal chain owns the X position and the horizontal sizing +// bar the control owns nothing but its height -- see below +function GuiEditorControlSpec::geometryModeOf(%this, %ctrl) +{ + if(!isObject(%ctrl)) + { + return "full"; + } + + // The one class that overrules its own geometry rather than its parent's. + // GuiMenuBarCtrl::resize throws away the position it is handed and passes + // (0,0), and onRender resizes the bar to the clip rect's width on every + // frame it draws -- but it keeps mBounds.extent.y, so the bar's height is + // its own and is the only geometry there is to set on it. + if(%ctrl.getClassName() $= "GuiMenuBarCtrl") + { + return "bar"; + } + + // A tab page's geometry is not its parent's doing alone -- the class is + // only ever a child of a book -- but the answer is the same either way. + %parent = %ctrl.getParent(); + if(!isObject(%parent)) + { + return "full"; + } + + switch$(%parent.getClassName()) + { + // guiGridCtrl.cc resize(): every child is resized into its cell. + case "GuiGridCtrl": return "none"; + + // guiFrameSetCtrl.cc: each frame resizes the control it holds. + case "GuiFrameSetCtrl": return "none"; + + // guiTabBookCtrl.cc: a page is forced to (0,0) and the page rect. + case "GuiTabBookCtrl": return "none"; + + // guiChainCtrl.cc positionChildren(): only the stacking axis is taken. + // The cross axis keeps the position the child was given, and the extent + // is the child's own on both axes -- the chain reads it to lay out. + // onChildAdded also rewrites a "center" sizing on the stacking axis, + // which is why that enum goes too. + case "GuiChainCtrl": return %parent.IsVertical ? "chainV" : "chainH"; + } + + // A GuiScrollCtrl is the container people expect to own its children and + // does not: it scrolls them and leaves their bounds alone. + return "full"; +} + +// Grey or gone. Where the PARENT owns a field the control still has one, and +// blanking it would leave no way to see where the parent put it -- so those are +// greyed, with a tooltip saying why. Where the control's own class overwrites a +// field every frame there is nothing to look at, so the row goes entirely. +function GuiEditorControlSpec::isGeometryFieldShown(%this, %mode, %field) +{ + if(%mode $= "bar") + { + return %field $= "Extent"; + } + return true; +} + +// True when the control, sitting where it is, may edit this geometry field. +function GuiEditorControlSpec::isGeometryFieldLive(%this, %mode, %field) +{ + if(%mode $= "none") + { + return false; + } + if(%mode $= "bar") + { + // Only the extent, and only half of it -- see liveExtentAxes. + return %field $= "Extent"; + } + if(%mode $= "chainV") + { + return %field !$= "VertSizing"; + } + if(%mode $= "chainH") + { + return %field !$= "HorizSizing"; + } + return true; +} + +// Which position axes the control still controls: "" , "x", "y" or "xy". +function GuiEditorControlSpec::livePositionAxes(%this, %mode) +{ + switch$(%mode) + { + case "none": return ""; + case "bar": return ""; + case "chainV": return "x"; + case "chainH": return "y"; + } + return "xy"; +} + +// The same question for the extent. A menu bar is the one control that owns one +// axis of its size and not the other: its width is rewritten to its parent's on +// every frame it draws, and its height is never touched. +function GuiEditorControlSpec::liveExtentAxes(%this, %mode) +{ + switch$(%mode) + { + case "none": return ""; + case "bar": return "y"; + } + return "xy"; +} + +//----------------------------------------------------------------------------- +// The single filter predicate for the shared rows. Everything the pane hides or +// shows among them goes through here, so the rules live in one place. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::isFieldVisible(%this, %class, %field) +{ + if(%this.listHas(%this.deadFields(), %field)) + { + return false; + } + + if(%this.classHides(%class, %field)) + { + return false; + } + + // A menu item calls SimObject::initPersistFields directly rather than + // GuiControl's, so it has none of these fields to show. + %bare = %this.hasFlag(%class, "bare"); + + %role = %this.textRoleFor(%class); + + if(%this.listHas(%this.textFields(), %field)) + { + if(%field $= "text" || %field $= "textID") + { + // Every role but proxy has a string of its own worth editing. + return %role !$= "none" && %role !$= "proxy"; + } + + // The layout half needs renderText to actually run on this control. + if(%role $= "caption") + { + return false; + } + + // A placeholder is one line by definition. It is what a drop-down draws + // while nothing is chosen and it stops being drawn the moment something + // is, so wrapping it -- or sizing the control to fit it -- describes a + // control that changes shape when it is used. + if(%role $= "placeholder" && (%field $= "textWrap" || %field $= "textExtend")) + { + return false; + } + if(%field $= "fontSizeAdjust" && %this.hasFlag(%class, "fontAdjust")) + { + return true; + } + return %role $= "render" || %role $= "placeholder" || + %role $= "inherited" || %role $= "proxy"; + } + + if(%bare) + { + return false; + } + + if(%field $= "isContainer") + { + // Dead where the control cannot draw children: GuiControl's + // setIsContainerFn forces the field false for those, so offering it + // would be a switch wired to nothing. + return false; + } + + if(%this.listHas(%this.easingFields(), %field)) + { + return %this.hasFlag(%class, "easing"); + } + + return true; +} + +// isContainer needs the control, not just its class -- rendersChildren() is a +// C++ answer fixed by the class, read rather than tabled so it cannot drift. +function GuiEditorControlSpec::isContainerFieldVisible(%this, %ctrl) +{ + return isObject(%ctrl) && !%this.hasFlag(%ctrl.getClassName(), "bare") && + %ctrl.rendersChildren(); +} + +// Where the class's text block goes. The block itself is one component holding +// every field GuiControl::renderText reads, so the only question left here is +// which of the two copies of it -- the header's or the Text section's -- the +// class wants. +// +// header the string, or the type it is drawn in, is a principal property. +// Proxy joins the three header roles: a list draws its items with +// this control's font, and the size of that is worth reaching for +// even though the "text" field itself is never drawn. +// section live, but not what anyone opens the pane for. A grid can draw text +// and nobody asks it to; a slider draws none but still sizes its +// value with fontSizeAdjust. +// none nothing in the block applies. +function GuiEditorControlSpec::textBlockHome(%this, %class) +{ + %role = %this.textRoleFor(%class); + if(%role $= "inherited") + { + return "section"; + } + if(%role $= "none") + { + return %this.hasFlag(%class, "fontAdjust") ? "section" : "none"; + } + return "header"; +} + +// The text block owns every field in textFields() except textID, which belongs +// to Localization and is the only one of the nine that is not about how the +// text is drawn. +// +// These three of them are ordinary field rows, so they go in the pane's shared +// registry and are filtered and loaded with everything else. The other four are +// two toggle icons and two segmented rows, which the block owns outright. +// +// overrideFontColor is in neither group: it has no row at all. The font color +// swatch is both fields, because picking a color IS turning the override on. +function GuiEditorControlSpec::textBlockRowFields(%this) +{ + return "text fontSizeAdjust fontColor"; +} + +//----------------------------------------------------------------------------- +// Profile category. Every class but one is pinned by +// GuiEditorThemeApplier::buildClassTable -- a check box wants a CheckBox +// profile, and there is nothing to ask about. A bare GuiControl is the +// exception: in 4.0 it is the wrapper, the backdrop, the line of text, the +// paragraph and the modal scrim, and which of those it is cannot be read off +// the class. The applier guesses from what the control holds when it is dropped +// (categoryForControl), which is a good default and a guess all the same, so +// the pane offers these four and lets the answer be corrected. +// +// The choice needs no storage: a profile carries the category it was stamped +// for, so what the control wears is the record of what it was told to be. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::categoryChoices(%this, %class) +{ + return (%class $= "GuiControl") ? "Empty Panel Label Overlay" : ""; +} + +// Which of the choices a profile's category names, spelled the way the choices +// spell it -- or "" if it names none of them. +// +// The case has to be thrown away to match and kept to answer. A category is +// stored with StringTable->insert, which interns case-insensitively and hands +// back whichever spelling reached the table first, so a profile stamped for +// "Empty" reads back as "empty" if anything else interned that word in lower +// case first. listHas cannot be used here: it is built on strstr, which unlike +// $= is case-sensitive. +function GuiEditorControlSpec::matchCategory(%this, %choices, %category) +{ + if(%category $= "") + { + return ""; + } + + %count = getWordCount(%choices); + for(%i = 0; %i < %count; %i++) + { + %choice = getWord(%choices, %i); + if(%choice $= %category) + { + return %choice; + } + } + return ""; +} + +// What to call the text field. A drop-down only ever shows it while nothing is +// selected, and a panel and a tab page hand theirs to something else to draw. +function GuiEditorControlSpec::textLabelFor(%this, %class) +{ + switch$(%this.textRoleFor(%class)) + { + case "placeholder": return "Placeholder"; + case "caption": return (%class $= "GuiTabPageCtrl") ? "Tab Caption" : "Header Text"; + } + return "Text"; +} + +//----------------------------------------------------------------------------- +// Dynamic fields. A GuiFrameSetCtrl serializes its whole frame tree into +// dynamic fields (guiFrameSetCtrl.cc writeFields), and hand-editing those can +// leave a Gui that will not load. Nothing else in the engine does this. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::dynamicHidePrefixes(%this, %class) +{ + if(%class $= "GuiFrameSetCtrl") + { + return "frame"; + } + return ""; +} + +function GuiEditorControlSpec::hidesDynamicField(%this, %class, %name) +{ + %prefixes = %this.dynamicHidePrefixes(%class); + %count = getWordCount(%prefixes); + for(%i = 0; %i < %count; %i++) + { + %prefix = getWord(%prefixes, %i); + if(strlwr(getSubStr(%name, 0, strlen(%prefix))) $= strlwr(%prefix)) + { + return true; + } + } + return false; +} + +//----------------------------------------------------------------------------- +// Field presentation. getFieldType answers with a console type's class name -- +// what ConsoleType's first argument spells -- so the mapping is from those, not +// from the TypeXxx constants the engine uses in initPersistFields. +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::kindForType(%this, %type) +{ + switch$(%type) + { + case "bool": return "bool"; + case "int" or "char": return "number"; + + // Separate kinds for the real-numbered fields, because the row rounds a + // whole-number one on the way out. A slider's value is a TypeF32 between + // its two bounds and fontSizeAdjust is a multiplier around 1: rounding + // either is the difference between the field working and not. + case "float": return "decimal"; + case "enumval": return "enum"; + case "Point2I": return "point"; + case "Point2F" or "Vector2": return "pointf"; + case "ColorI" or "ColorF" or "FluidColorI": return "color"; + case "filename": return "file"; + case "assetIdString": return "asset"; + case "GuiProfile": return "profile"; + // Both stay hidden from the generic path that fills the Other section. + // A border profile has nothing to offer there at all -- the Profile + // Editor owns borders and a control never names one. A cursor does, but + // only sometimes: buildVariantsSection adds its row deliberately, and + // only where the theme holds a choice. Answering "dropdown" here would + // instead put an empty one on every window, which is the noise the + // Variants rule exists to prevent. + case "GuiCursor" or "GuiBProfile": return "hidden"; + } + return "text"; +} + +// Human labels for the fields whose registered name reads badly in a caption. +// Anything absent falls back to the field name, which is usually right. +function GuiEditorControlSpec::buildLabels(%this) +{ + %this.label["HorizSizing"] = "Horizontal Sizing"; + %this.label["VertSizing"] = "Vertical Sizing"; + %this.label["MinExtent"] = "Minimum Extent"; + %this.label["useInput"] = "Accepts Input"; + %this.label["isContainer"] = "Accepts Children"; + %this.label["hovertime"] = "Hover Time"; + %this.label["tooltip"] = "Tooltip"; + %this.label["tooltipWidth"] = "Tooltip Width"; + %this.label["class"] = "Class"; + %this.label["langTableMod"] = "Language Table"; + %this.label["textID"] = "Text ID"; + %this.label["fontSizeAdjust"] = "Font Size Adjust"; + %this.label["overrideFontColor"] = "Override Font Color"; + %this.label["align"] = "Horizontal Align"; + %this.label["vAlign"] = "Vertical Align"; + %this.label["textWrap"] = "Wrap Text"; + %this.label["textExtend"] = "Extend To Fit Text"; + %this.label["stateOn"] = "Checked"; + %this.label["groupNum"] = "Radio Group"; + %this.label["IsVertical"] = "Vertical"; + %this.label["ChildSpacing"] = "Child Spacing"; + %this.label["IsExtentDynamic"] = "Grow To Fit"; + %this.label["constantThumbHeight"] = "Constant Thumb"; + %this.label["scrollBarThickness"] = "Bar Thickness"; + %this.label["showArrowButtons"] = "Arrow Buttons"; + %this.label["hScrollBar"] = "Horizontal Bar"; + %this.label["vScrollBar"] = "Vertical Bar"; + %this.label["maxHeight"] = "Max Height"; + %this.label["titleHeight"] = "Title Height"; + %this.label["resizeRightWidth"] = "Right Grip Width"; + %this.label["resizeBottomHeight"] = "Bottom Grip Height"; + %this.label["DividerThickness"] = "Divider Thickness"; + %this.label["MinTabWidth"] = "Min Tab Width"; + %this.label["TabPosition"] = "Tab Position"; + %this.label["AllowMultipleSelections"] = "Multiple Selection"; + %this.label["FitParentWidth"] = "Fit Parent Width"; + %this.label["AllowReorder"] = "Allow Reorder"; + %this.label["animationTime"] = "Animation Time"; + %this.label["easeExpand"] = "Expand Easing"; + %this.label["easeTimeExpand"] = "Expand Time"; + %this.label["easeFillColorHL"] = "Hover Easing"; + %this.label["easeFillColorSL"] = "Press Easing"; + %this.label["easeTimeFillColorHL"] = "Hover Time"; + %this.label["easeTimeFillColorSL"] = "Press Time"; + %this.label["sinkAllKeyEvents"] = "Sink Key Events"; + %this.label["returnCausesTab"] = "Return Tabs"; + %this.label["returnCommand"] = "Return Command"; + %this.label["escapeCommand"] = "Escape Command"; + %this.label["maxLength"] = "Max Length"; + %this.label["inputMode"] = "Input Mode"; + %this.label["deleteOnMouseUp"] = "Delete On Drop"; + %this.label["lockMouse"] = "Lock Mouse"; + %this.label["UseWindowInputEvents"] = "Window Input Events"; + %this.label["UseObjectInputEvents"] = "Object Input Events"; + %this.label["UseBackgroundColor"] = "Use Background Color"; + %this.label["BackgroundColor"] = "Background Color"; + %this.label["constrainProportions"] = "Keep Proportions"; + %this.label["positionOffset"] = "Position Offset"; + %this.label["singleFrameBitmap"] = "Single Frame"; + %this.label["NamedFrame"] = "Named Frame"; + %this.label["imageColor"] = "Image Color"; + %this.label["imageSize"] = "Image Size"; + %this.label["fullSize"] = "Full Size"; + %this.label["clampImage"] = "Clamp Image"; + %this.label["tileImage"] = "Tile Image"; + %this.label["baseColor"] = "Color"; + %this.label["BaseColor"] = "Color"; + %this.label["PickColor"] = "Picked Color"; + %this.label["DisplayMode"] = "Display Mode"; + %this.label["ActionOnMove"] = "Action On Move"; + %this.label["ShowSelector"] = "Show Selector"; + %this.label["showAlphaBar"] = "Alpha Bar"; + %this.label["showColorValues"] = "Color Values"; + %this.label["swatchColumns"] = "Swatch Columns"; + %this.label["valueBoxHeight"] = "Value Box Height"; + %this.label["valueMode"] = "Value Mode"; + %this.label["popupSize"] = "Popup Size"; + %this.label["barHeight"] = "Bar Height"; + %this.label["boxOffset"] = "Box Offset"; + %this.label["boxExtent"] = "Box Extent"; + %this.label["textOffset"] = "Text Offset"; + %this.label["textExtent"] = "Text Extent"; + %this.label["CellModeX"] = "Column Mode"; + %this.label["CellModeY"] = "Row Mode"; + %this.label["CellSizeX"] = "Column Size"; + %this.label["CellSizeY"] = "Row Size"; + %this.label["CellSpacingX"] = "Column Spacing"; + %this.label["CellSpacingY"] = "Row Spacing"; + %this.label["MaxColCount"] = "Max Columns"; + %this.label["MaxRowCount"] = "Max Rows"; + %this.label["OrderMode"] = "Order"; + %this.label["canMove"] = "Move"; + %this.label["canClose"] = "Close"; + %this.label["canMinimize"] = "Minimize"; + %this.label["canMaximize"] = "Maximize"; + %this.label["resizeWidth"] = "Resize Width"; + %this.label["resizeHeight"] = "Resize Height"; + %this.label["internalName"] = "Internal Name"; + %this.label["superclass"] = "Super Class"; + %this.label["AltCommand"] = "Alt Command"; + %this.label["IsOn"] = "On"; + + // The secondary profile slots, named for the part they style rather than + // for the field. These are the Variants rows; the slot-to-category mapping + // they are filtered by lives in GuiEditorThemeApplier::buildFieldTable. + %this.label["tooltipProfile"] = "Tooltip"; + %this.label["contentProfile"] = "Content"; + %this.label["closeButtonProfile"] = "Close Button"; + %this.label["minButtonProfile"] = "Minimise Button"; + %this.label["maxButtonProfile"] = "Maximise Button"; + %this.label["scrollProfile"] = "Scroller"; + %this.label["thumbProfile"] = "Thumb"; + %this.label["trackProfile"] = "Track"; + %this.label["arrowProfile"] = "Arrows"; + %this.label["listBoxProfile"] = "List"; + %this.label["tabBookProfile"] = "Tab Book"; + %this.label["tabProfile"] = "Tab"; + %this.label["tabPageProfile"] = "Tab Page"; + %this.label["dropButtonProfile"] = "Drop Button"; + %this.label["menuProfile"] = "Menu"; + %this.label["menuItemProfile"] = "Menu Item"; + %this.label["menuContentProfile"] = "Menu Content"; + %this.label["popupProfile"] = "Popup"; + %this.label["pickerProfile"] = "Picker"; + %this.label["selectorProfile"] = "Selector"; + %this.label["valueProfile"] = "Value Boxes"; + %this.label["backgroundProfile"] = "Background"; + + // Cursor slots. Named for the job rather than the direction the field is: + // "nWSECursor" describes the arrow's art, "Corner" describes what hovering + // there does, and only one of those is a question the person laying out a + // window is asking. + %this.label["editCursor"] = "Text Cursor"; + %this.label["leftRightCursor"] = "Horizontal Resize"; + %this.label["upDownCursor"] = "Vertical Resize"; + %this.label["nWSECursor"] = "Corner Resize"; +} + +function GuiEditorControlSpec::labelFor(%this, %field) +{ + %label = %this.label[%field]; + return (%label $= "") ? %field : %label; +} + +//----------------------------------------------------------------------------- +// Drift guard. The editor never calls this; the smoke test does, so a control +// class added to the engine cannot quietly fall through to the unknown-class +// fallback without anyone noticing. +// +// %engineNames is what enumerateConsoleClasses("GuiControl") returns: a +// tab-separated list, whose first entry it repeats. Duplicates cost nothing +// here, since each name is only looked up. The classes the Gui Editor's palette +// refuses are not this table's business, so they are dropped with the same rule +// the palette uses (GuiEditorControlListWindow::populate). +//----------------------------------------------------------------------------- + +function GuiEditorControlSpec::isPlaceableClass(%this, %name) +{ + if(%name $= "GuiCanvas" || %name $= "GuiMessageVectorCtrl" || + %name $= "GuiParticleGraphInspector" || %name $= "GuiGraphCtrl" || + %name $= "GuiSceneObjectCtrl") + { + return false; + } + if(%name !$= "SceneWindow" && getSubStr(%name, 0, 3) !$= "Gui") + { + return false; + } + return getSubStr(%name, 0, 10) !$= "GuiConsole" && + getSubStr(%name, 0, 7) !$= "GuiEdit" && + getSubStr(%name, 0, 12) !$= "GuiInspector"; +} + +function GuiEditorControlSpec::findMissingClasses(%this, %engineNames) +{ + %missing = ""; + %count = getFieldCount(%engineNames); + for(%i = 0; %i < %count; %i++) + { + %name = trim(getField(%engineNames, %i)); + if(%name $= "" || !%this.isPlaceableClass(%name) || %this.textRole[%name] !$= "") + { + continue; + } + %missing = (%missing $= "") ? %name : (%missing SPC %name); + } + return %missing; +} diff --git a/editor/GuiEditor/scripts/GuiEditorControlTile.cs b/editor/GuiEditor/scripts/GuiEditorControlTile.cs new file mode 100644 index 000000000..7f9fffe29 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorControlTile.cs @@ -0,0 +1,370 @@ +//----------------------------------------------------------------------------- +// One entry in the control palette: a picture of a control you can drag onto the +// canvas, or click to drop into whatever container is current. +// +// The tile holds a palette KEY rather than a class name. For all but four +// entries those are the same string, but a bare GuiControl appears four times -- +// as the wrapper, the backdrop, the line of text and the modal scrim -- and the +// key is what tells them apart. GuiEditorControlIcons turns a key into a class, +// a profile category, a label and a frame. +// +// Two shapes, set by setMode and driven by the group's grid changing its cell +// size underneath. Nothing here re-reads the extent on resize: the children +// carry sizing flags that keep them placed, so dragging the frame narrower +// reflows the grid and the tiles follow without script. +// +// grid a 56px picture with the name under it, wrapping to two lines +// rows a 32px picture at the left with the name beside it +// +// The creator sets key and owner inline, then calls setMode. +//----------------------------------------------------------------------------- + +// Both sizes come off the 64 sheet -- sheetFor only reaches for the 128 one +// above 64. Rows mode is at that sheet's own resolution and takes it 1:1; grid +// mode shrinks it by an eighth, which is a gentler downscale than 128 to 56 +// would be and holds the thin strokes better. +$GuiEditorControlTile::gridArt = 56; +$GuiEditorControlTile::rowArt = 32; +$GuiEditorControlTile::rowTextLeft = 40; + +// How much of a grid tile is kept clear for the name: two lines of the largest +// label font any shipped theme uses -- Torque Suit's fontSize - 2, which is 20 +// -- plus the two pixels labelProfile pads above and below. +// +// This is a budget the icon is placed against, not a box the text is put in. +// The caption itself fills the tile and bottom-aligns, so a third line would +// grow UP into the picture rather than off the bottom. Nothing in the table can +// reach three: the longest label wraps to two at this width, and the only other +// source of names is the Undrawn sweep, whose entries are raw class names -- one +// unbreakable word, kept to a single line and clipped across, with the full name +// in the tooltip either way. +$GuiEditorControlTile::gridCaption = 44; + +// How far the pointer must travel before a press counts as a drag rather than a +// click. Without it a shaky click starts a drag, and the two gestures do +// different things now that a click also places a control. +$GuiEditorControlTile::dragSlop = 5; + +function GuiEditorControlTile::onAdd(%this) +{ + %icons = GuiEditor.controlIcons; + + %this.text = ""; + %this.dragged = false; + + // The label reads "Check Box"; the tooltip says GuiCheckBoxCtrl. The class + // name is what someone types in script, so it should not disappear from the + // palette just because the tile is showing a friendlier one. Both modes show + // the label, so the tooltip is the only place the class name appears. + %this.tooltip = %icons.classFor(%this.key); + + %this.icon = new GuiSpriteCtrl() + { + Position = "0 0"; + Extent = "16 16"; + constrainProportions = "1"; + fullSize = "0"; + UseInput = false; + }; + ThemeManager.setProfile(%this.icon, "spriteProfile"); + %this.add(%this.icon); + + %this.caption = new GuiControl() + { + Position = "0 0"; + Extent = "16 16"; + Text = %icons.labelFor(%this.key); + align = "left"; + vAlign = "middle"; + Visible = false; + UseInput = false; + }; + ThemeManager.setProfile(%this.caption, "labelProfile"); + %this.add(%this.caption); + + ThemeManager.setProfile(%this, "tipProfile", "TooltipProfile"); + + %this.startListening(ThemeManager); + %this.refreshTint(); +} + +//----------------------------------------------------------------------------- +// Color. +// +// The sheets are greyscale, drawn to be modulated rather than to be shown as +// they are -- so the tint is not decoration, it is what makes the picture +// legible. Left alone a GuiSpriteCtrl blends with opaque white, which is why +// this was invisible on the theme the editor starts in: that one draws its text +// white too, so an icon nobody had tinted looked exactly right. On the light +// theme the same untinted art is a white smear on a pale panel. +// +// The color comes from the tile's OWN profile rather than from the caption's, +// because the icon is drawn on the tile. The two name the same color in every +// theme that ships, and the profile a thing is drawn on is the one that should +// decide when they stop agreeing. +// +// It has to be re-read on a theme change: ThemeManager swaps the profile object +// on everything that registered one, which is enough for backgrounds and text, +// but a color copied onto a sprite is a copy and stays behind. +//----------------------------------------------------------------------------- + +function GuiEditorControlTile::refreshTint(%this) +{ + %this.icon.setImageColor(ThemeManager.activeTheme.itemSelectProfile.fontColor); +} + +function GuiEditorControlTile::onThemeChange(%this, %theme) +{ + %this.refreshTint(); +} + +//----------------------------------------------------------------------------- +// Layout. +//----------------------------------------------------------------------------- + +function GuiEditorControlTile::setMode(%this, %mode) +{ + %this.mode = %mode; + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + + // One caption serves both modes, so each branch sets every property the other + // one touches. Leaving a mode to inherit what the last one happened to write + // is how a switch back stops being a switch back. + if(%mode $= "rows") + { + %art = $GuiEditorControlTile::rowArt; + %left = $GuiEditorControlTile::rowTextLeft; + + // anchorLeft holds the left edge and lets the right one move, so the + // picture stays put as the column widens; the caption takes the slack. + %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"; + + // A row is one line tall, so wrapping there would only ever hide the + // tail of a name the row has the width to show. + %this.caption.textWrap = false; + %this.caption.setVisible(true); + } + else + { + %art = $GuiEditorControlTile::gridArt; + %band = $GuiEditorControlTile::gridCaption; + + // The caption takes the whole inner rect and bottom-aligns its text + // inside it, so the ENGINE decides where the floor of the tile is. + // Nothing here has to know what the tile's own profile insets, which is + // a per-theme number -- 3 pixels a side on the base theme, 4 on Torque + // Suit -- that script has no way to ask for. Positioning a short band + // against the outer extent instead would hang it below the inner rect + // and renderChild would clip the last line's descenders off. + // + // Bottom alignment is also what makes a row read level: a one-line name + // like "Check Box" sits on the same line as the second line of "Radio + // Button" beside it, rather than floating half a line higher. + %this.caption.HorizSizing = "fill"; + %this.caption.VertSizing = "fill"; + %this.caption.align = "center"; + %this.caption.vAlign = "bottom"; + %this.caption.textWrap = true; + %this.caption.setVisible(true); + %this.caption.applySizing(); + + // And that fill is how the inset gets measured. What the caption now + // reports IS the inner rect, so the picture can be centered in the room + // left above the band whatever a theme's border costs, instead of + // sitting at a fixed offset a fatter border would push into the text. + %innerH = getWord(%this.caption.getExtent(), 1); + + // "center" is resolved against the inner rect too, so the picture and + // the name below it share one center line. Doing this arithmetically + // off %w would put the icon half a border to the right of its label. + %this.icon.HorizSizing = "center"; + %this.icon.VertSizing = "anchorTop"; + %this.icon.setExtent(%art, %art); + %this.icon.setPosition(0, (%innerH - %band - %art) / 2); + %this.icon.applySizing(); + } + + // The sheet is picked from the size the art is DRAWN at, not from the mode, + // so the two stay in step if either number changes. + %this.icon.setImage(GuiEditor.controlIcons.sheetFor(%art)); + %this.icon.setImageFrame(GuiEditor.controlIcons.frameFor(%this.key)); + %this.icon.imageSize = %art SPC %art; +} + +//----------------------------------------------------------------------------- +// Making one. Shared by both gestures so a dragged control and a clicked one +// cannot drift apart. +//----------------------------------------------------------------------------- + +function GuiEditorControlTile::makePayload(%this) +{ + %class = GuiEditor.controlIcons.classFor(%this.key); + %payload = eval("return new " @ %class @ "();"); + if(!isObject(%payload)) + { + return 0; + } + + // Only the four GuiControl faces ask for a category; every other class pins + // its own in GuiEditorThemeApplier::buildClassTable, so the field stays unset + // and the applier's own answer stands. It is consumed by the first theme + // pass -- see applyToBranch. + %category = GuiEditor.controlIcons.categoryFor(%this.key); + if(%category !$= "") + { + %payload.paletteCategory = %category; + } + + // A caption for the controls that wear one, taken from the palette's own + // label so the tile and the control it makes agree. The engine used to seed + // "Button" in the constructor, but a caption the author clears has to + // survive a save and a constructor default cannot let it: writeField drops + // every empty value, so a blank caption is written as an absent one and the + // default stands back up on read. Placing a control is the moment that + // wants a placeholder, so the placeholder lives here. + if(%this.takesCaption(GuiEditor.controlIcons.classFor(%this.key))) + { + %payload.setText(GuiEditor.controlIcons.labelFor(%this.key)); + } + + return %payload; +} + +//----------------------------------------------------------------------------- +// Whether a freshly placed control should arrive with a caption. The button +// family draws mText as its face and used to inherit "Button" from +// GuiButtonCtrl -- which is why a checkbox dropped from the palette read +// "Button" and not "Check Box". A drop down is deliberately absent: it draws +// mText only while nothing is selected, so its "none" is an empty state and +// not a caption to be replaced. +//----------------------------------------------------------------------------- + +function GuiEditorControlTile::takesCaption(%this, %class) +{ + return %class $= "GuiButtonCtrl" || + %class $= "GuiCheckBoxCtrl" || + %class $= "GuiRadioCtrl"; +} + +//----------------------------------------------------------------------------- +// Dragging. Lifted from the list box this replaced, offsets and all. +//----------------------------------------------------------------------------- + +function GuiEditorControlTile::onTouchDown(%this, %modifier, %position, %clickCount) +{ + %this.pressAt = Canvas.getCursorPos(); + %this.dragged = false; +} + +function GuiEditorControlTile::onTouchDragged(%this, %modifier, %position, %clickCount) +{ + if(%this.dragged) + { + return; + } + + %now = Canvas.getCursorPos(); + %dx = mAbs(getWord(%now, 0) - getWord(%this.pressAt, 0)); + %dy = mAbs(getWord(%now, 1) - getWord(%this.pressAt, 1)); + if(%dx < $GuiEditorControlTile::dragSlop && %dy < $GuiEditorControlTile::dragSlop) + { + return; + } + + %this.dragged = true; + %this.beginDrag(%now); +} + +function GuiEditorControlTile::beginDrag(%this, %cursorPos) +{ + %payload = %this.makePayload(); + if(!isObject(%payload)) + { + return; + } + + %position = GuiEditor.brain.getGlobalPosition(); + %xOffset = (getWord(%payload.extent, 0) / 2) + getWord(%position, 0); + %yOffset = (getWord(%payload.extent, 1) / 2) + getWord(%position, 1); + + // Where the drag starts, so the payload does not jump on the first frame. + %xPos = getWord(%cursorPos, 0) - %xOffset; + %yPos = getWord(%cursorPos, 1) - %yOffset; + + %dragCtrl = new GuiDragAndDropCtrl() + { + canSaveDynamicFields = "0"; + Profile = "GuiDragAndDropProfile"; + HorizSizing = "anchorLeft"; + VertSizing = "anchorTop"; + Position = %xPos SPC %yPos; + extent = %payload.extent; + MinExtent = "32 32"; + canSave = "1"; + Visible = "1"; + hovertime = "1000"; + Text = GuiEditor.controlIcons.classFor(%this.key); + deleteOnMouseUp = true; + }; + + %dragCtrl.add(%payload); + GuiEditor.brain.add(%dragCtrl); + %dragCtrl.startDragging(%xOffset, %yOffset); +} + +//----------------------------------------------------------------------------- +// Clicking. A click is a drop that never moved. +// +// It routes through the brain's own onControlDropped rather than adding the +// control itself, which is the whole point: that path is where theming, +// selection, the AddControl event and undo recording all live, and a second way +// into the document would have to reproduce every one of them and would go stale +// the first time one changed. +//----------------------------------------------------------------------------- + +function GuiEditorControlTile::onClick(%this) +{ + // A button keeps mDepressed through a drag -- it never overrides + // onTouchDragged -- so releasing after a drag fires onAction as well. Without + // this the gesture would place two controls. + if(%this.dragged) + { + %this.dragged = false; + return; + } + + %payload = %this.makePayload(); + if(!isObject(%payload)) + { + return; + } + + // Centred in the container being worked in, which the brain knows because it + // owns both the add set and the canvas the container has to be visible on. + // + // onControlDropped reads getGlobalPosition BEFORE addNewCtrl reparents, and + // puts the control back there afterwards. Nothing owns the payload yet, so + // its Position IS its global position. + %payload.Position = GuiEditor.brain.centredPlacement(%payload); + + // Deliberately not onControlDragged first: that picks the add set by + // hit-testing the cursor, and a click means "the container I am already + // working in", not "whatever is under this point". + // + // And placeControl rather than onControlDropped, because the cursor test that + // one opens with is a drag's question. A click has no cursor, the position + // above is already inside the visible part of the container, and a control + // that pins its own position never took it anyway. + GuiEditor.brain.placeControl(%payload); +} diff --git a/editor/GuiEditor/scripts/GuiEditorDynamicFields.cs b/editor/GuiEditor/scripts/GuiEditorDynamicFields.cs new file mode 100644 index 000000000..dbaf90886 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorDynamicFields.cs @@ -0,0 +1,277 @@ + +//----------------------------------------------------------------------------- +// The dynamic-field section of the Gui Editor's properties pane: the fields a +// script put on a control, which are not registered anywhere and so cannot come +// from GuiEditorControlSpec. +// +// Built fresh rather than ported. GuiInspectorDynamicGroup::createContent is +// broken in three ways that are not worth carrying over: +// +// - it hardcodes the profile names "EditorButton", "GuiButtonProfile" and +// "GuiTextProfile", which were 3.x script profiles and do not exist in 4.0, +// so its Add button and the shell around it wear nothing; +// - it calls registerObject("zAddButton") -- a fixed global name -- so +// inspecting a second object collides with the first, and in editor mode +// the name is never registered at all; +// - the Add button is created in createContent but has to be rescued and +// pushed back by clearFields on every refresh. +// +// What a control holds is filtered, not just listed. A GuiFrameSetCtrl +// serializes its entire frame tree into dynamic fields (frameID0, frameChild10, +// frameExtentX2 and so on -- see guiFrameSetCtrl.cc), and hand-editing those +// can leave a Gui that will not load. GuiEditorControlSpec::hidesDynamicField +// owns that list. +// +// Renaming is deliberately absent: remove and add again. The old inspector +// offered it, but a rename is a delete and an add anyway, and making it one +// visible step costs a name box per row that is wrong to touch by accident. +// +// A dynamic field cannot hold an empty value. SimFieldDictionary::setFieldValue +// frees the entry when the value is empty, so "" is not a value a field can +// have -- it is how a field is removed. That decides the shape of Add: naming a +// field cannot create it, because there is nothing to create it with. So Add +// puts up a row for the name and the field appears on the control the moment +// the row is given a value; a row left empty was never a field. It also makes +// the bin button and clearing the box the same operation, which is what the +// engine already believed. +// +// The creator sets pane and blockWidth inline, then calls build() once after +// adding it. Each row reports here rather than to the pane, because a dynamic +// field is written with setFieldValue on a name this section knows and the pane +// does not. +//----------------------------------------------------------------------------- + +function GuiEditorDynamicFields::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +function GuiEditorDynamicFields::build(%this) +{ + %this.fieldNames = ""; + + %this.grid = %this.pane.makeCellGrid(0); + %this.add(%this.grid); + + %this.buildAddRow(); +} + +// A name box and an Add button. Inline rather than a dialog: naming a field is +// the whole of the operation, so a modal for it would be ceremony. +function GuiEditorDynamicFields::buildAddRow(%this) +{ + %w = %this.blockWidth; + %buttonW = 56; + + %row = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 30; + }; + ThemeManager.setProfile(%row, "emptyProfile"); + %this.add(%row); + %this.addRow = %row; + + %this.nameBox = new GuiTextEditCtrl() + { + HorizSizing = "width"; + Position = "4 4"; + Extent = (%w - %buttonW - 16) SPC 22; + Tooltip = "Name for a new dynamic field"; + }; + ThemeManager.setProfile(%this.nameBox, "textEditProfile"); + ThemeManager.setProfile(%this.nameBox, "tipProfile", "TooltipProfile"); + %this.nameBox.ReturnCommand = %this.getID() @ ".onAddClicked();"; + %row.add(%this.nameBox); + + %this.addButton = new GuiButtonCtrl() + { + HorizSizing = "left"; + Position = (%w - %buttonW - 4) SPC 4; + Extent = %buttonW SPC 22; + Text = "Add"; + Command = %this.getID() @ ".onAddClicked();"; + }; + ThemeManager.setProfile(%this.addButton, "buttonProfile"); + %row.add(%this.addButton); +} + +//----------------------------------------------------------------------------- +// Binding. The rows are rebuilt per control rather than filtered: unlike a +// registered field there is no fixed set to hide from, and two controls rarely +// carry the same dynamic fields. +//----------------------------------------------------------------------------- + +function GuiEditorDynamicFields::bind(%this, %ctrl) +{ + // A named-but-empty row belongs to the control it was named on. Moving the + // selection abandons it, which is right: it was never a field. + if(%ctrl != %this.target) + { + %this.pendingField = ""; + } + + %this.target = %ctrl; + %this.grid.deleteObjects(); + %this.fieldNames = ""; + + if(!isObject(%ctrl)) + { + return; + } + + %class = %ctrl.getClassName(); + %count = %ctrl.getDynamicFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %name = getWord(%ctrl.getDynamicField(%i), 0); + if(%name $= "" || %this.pane.spec.hidesDynamicField(%class, %name)) + { + continue; + } + %this.addFieldRow(%name); + } + + // The row for a field that has been named but not yet given a value. It is + // last because it is the newest, and it disappears on its own if it is left + // empty and the selection moves. + if(%this.pendingField !$= "" && %ctrl.getFieldValue(%this.pendingField) $= "") + { + %this.addFieldRow(%this.pendingField); + } +} + +function GuiEditorDynamicFields::addFieldRow(%this, %name) +{ + %row = new GuiControl() + { + class = "GuiProfileEditorFieldRow"; + Position = "0 0"; + fieldName = %name; + labelText = %name; + kind = "text"; + owner = %this; + }; + %this.grid.add(%row); + %row.build(); + + // 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 + // dynamic field "reset" means "take it away". + %row.resetButton.icon.setImageFrame($EditorIcon::trash); + %row.resetButton.Tooltip = "Remove this field"; + %row.resetButton.setVisible(true); + + %row.setValue(%this.target.getFieldValue(%name)); + + %this.row[%name] = %row; + %this.fieldNames = (%this.fieldNames $= "") ? %name : (%this.fieldNames SPC %name); + return %row; +} + +// Is there anything to show? The section hides itself when a control carries no +// dynamic fields, which is most of them. +function GuiEditorDynamicFields::hasFields(%this) +{ + return %this.fieldNames !$= ""; +} + +//----------------------------------------------------------------------------- +// Editing. +//----------------------------------------------------------------------------- + +function GuiEditorDynamicFields::onProfileRowCommit(%this, %row) +{ + if(!isObject(%this.target) || !%row.hasChanged()) + { + return; + } + + // A dynamic field has no setEditFieldValue path, so the recorder writes it + // as its own kind of op -- but it goes through the recorder like every other + // write the editor makes. + %value = %row.getValue(); + GuiEditor.undoRecorder.writeDynamicField(%this.target, %row.fieldName, %value); + %row.markClean(); + %this.pane.afterCommit(); + + if(%row.fieldName $= %this.pendingField && %value !$= "") + { + // It is a real field now, so it will come back from the control's own + // list and no longer needs holding open. + %this.pendingField = ""; + } + else if(%value $= "") + { + // An emptied box is a removed field, the same as the bin button. + // Deferred for the same reason: this arrives from inside the row. + %this.schedule(0, "rebindDeferred"); + } +} + +// 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) +{ + if(!isObject(%this.target)) + { + return; + } + + GuiEditor.undoRecorder.writeDynamicField(%this.target, %row.fieldName, ""); + %this.pane.afterCommit(); + + // Deferred: this call arrives from the button inside the row that is about + // to be freed, so deleting the grid's children here would pull the control + // out from under the event being dispatched. + %this.schedule(0, "rebindDeferred"); +} + +function GuiEditorDynamicFields::rebindDeferred(%this) +{ + %this.bind(%this.target); + %this.pane.onDynamicFieldsChanged(); +} + +function GuiEditorDynamicFields::onAddClicked(%this) +{ + %name = trim(%this.nameBox.getText()); + if(%name $= "" || !isObject(%this.target)) + { + return; + } + + // A name that collides with a registered field would write the real field + // instead of making a dynamic one, which is not what the button says. + if(%this.target.getFieldType(%name) !$= "") + { + warn("GuiEditorDynamicFields: '" @ %name @ "' is a built-in field of " @ + %this.target.getClassName() @ ", not a dynamic one."); + return; + } + + if(%this.target.getFieldValue(%name) !$= "") + { + warn("GuiEditorDynamicFields: '" @ %name @ "' is already set on this control."); + return; + } + + // Named, not created: an empty dynamic field does not exist, so the row is + // what Add produces and the first value is what makes it a field. + %this.pendingField = %name; + %this.nameBox.setText(""); + + %this.bind(%this.target); + %this.pane.onDynamicFieldsChanged(); + + // Put the caret in the new row's box, since typing a value is the whole of + // what is left to do. + %row = %this.row[%name]; + if(isObject(%row)) + { + %row.editor.setFirstResponder(); + } +} diff --git a/editor/GuiEditor/scripts/GuiEditorExplorerTree.cs b/editor/GuiEditor/scripts/GuiEditorExplorerTree.cs index 0b519a1fe..e7899d454 100644 --- a/editor/GuiEditor/scripts/GuiEditorExplorerTree.cs +++ b/editor/GuiEditor/scripts/GuiEditorExplorerTree.cs @@ -43,12 +43,77 @@ %this.postEvent("ObjectRemoved", %item); } +// Drag-to-reorder in the tree rearranges the real control hierarchy in C++ +// (GuiTreeViewCtrl::reorderFromDrag), and can move several selected controls +// into several parents in one go. The pair brackets the whole rearrangement: +// the document's shape is remembered here and read again afterwards, and the +// difference is the undo step. +function GuiEditorExplorerTree::onPreReorder(%this) +{ + GuiEditor.undoRecorder.snapshotHierarchy(GuiEditor.rootGui); +} + +function GuiEditorExplorerTree::onPostReorder(%this) +{ + %this.rescueStranded(); + GuiEditor.undoRecorder.commitHierarchy("Reparent Control"); +} + +// A drag on the canvas puts a control under the pointer, so it lands inside the +// container it landed in. A drag here has no pointer: nothing supplies a +// position, and the control keeps the local one it held in its old parent. Move +// a button at x=400 into a container 100 wide and it is not clipped or half +// hidden, it is gone - and gone from the canvas is nearly gone for good, because +// the canvas is where you would reach for it. +// +// So each control that moved is offered the chance to come back into view. It is +// per axis and only for a control that is ENTIRELY outside: see +// GuiControl::rescuedPosition. Anything still visible, and anything that did not +// move, is left exactly as it was. +// +// Before commitHierarchy, and that ordering is the whole reason this is a +// separate call rather than something the C++ does inside the reorder. The undo +// step is built from what layoutOf reads at commit time - position, extent and +// both sizing fields - so rescuing first folds the correction into the same +// "Reparent Control" action. One Ctrl+Z then puts the control back in its old +// parent AT ITS OLD POSITION, which is what the user will expect, rather than +// leaving it rescued in a parent that no longer wants it there. +// +// The selection is the set that moved: reorderFromDrag moves every selected +// item, and it calls this back before refreshTree, so the rows are still the +// ones that were dragged. +function GuiEditorExplorerTree::rescueStranded(%this) +{ + %selected = %this.getSelectedItems(); + + for(%i = 0; %i < getWordCount(%selected); %i++) + { + // getSelectedItems answers "-1" for an empty selection rather than an + // empty string, so the index has to be looked at before it is used. + %index = getWord(%selected, %i); + if(%index < 0) + { + continue; + } + + %ctrl = %this.getItemID(%index); + if(isObject(%ctrl)) + { + %ctrl.pullIntoView(); + } + } +} + +// refreshItem rather than refreshItemText: a control's picture can change +// without its row moving. Re-profiling a bare GuiControl from a panel to a label +// is the same object in the same place wearing a different face, and the +// Category picker does exactly that. function GuiEditorExplorerTree::onPostApply(%this, %obj) { %index = %this.findItemID(%obj.getId()); if(%index > -1) { - %this.refreshItemText(%index); + %this.refreshItem(%index); } } @@ -59,4 +124,42 @@ return "Canvas Simulation"; } return ""; +} + +// Which frame of the tree's sheet a row wears. Asked once per row as the tree +// builds itself, never per draw. +function GuiEditorExplorerTree::onGetItemIcon(%this, %obj) +{ + // The simulated canvas is stage furniture, not a control in the document, + // and giving it a picture would say otherwise. + if(%obj == GuiEditor.rootGui) + { + return -1; + } + return GuiEditor.controlIcons.frameFor(%this.keyFor(%obj)); +} + +// A live control back to a palette entry. +// +// The two are not the same thing. Everything but a bare GuiControl is keyed by +// its class, but a GuiControl is the wrapper, the backdrop, the line of text and +// the modal scrim -- four entries sharing one class and told apart by the +// profile category they wear. So the class alone cannot pick the picture. +// +// The pane already owns that question, and asking it here is what keeps the +// Category dropdown and the row icon from disagreeing about the same control: +// currentCategory prefers the category stamped on the profile the control is +// actually wearing over the guess made from its shape. +function GuiEditorExplorerTree::keyFor(%this, %ctrl) +{ + %class = %ctrl.getClassName(); + if(%class $= "GuiControl") + { + return "GuiControl:" @ GuiEditor.inspectorWindow.pane.currentCategory(%ctrl); + } + + // frameFor answers 0 -- the question mark -- for anything it has never heard + // of, so a class with no icon reads as "no picture for this yet" rather than + // as some other control. + return %class; } \ No newline at end of file diff --git a/editor/GuiEditor/scripts/GuiEditorExplorerWindow.cs b/editor/GuiEditor/scripts/GuiEditorExplorerWindow.cs index 5f6eb6431..51e824fdf 100644 --- a/editor/GuiEditor/scripts/GuiEditorExplorerWindow.cs +++ b/editor/GuiEditor/scripts/GuiEditorExplorerWindow.cs @@ -3,8 +3,10 @@ { %this.scroller = new GuiScrollCtrl() { - HorizSizing="width"; - VertSizing="height"; + // Fill rather than width/height -- the window's only child wants its + // whole content rect. See GuiEditorControlListWindow for why. + HorizSizing="fill"; + VertSizing="fill"; Position="0 0"; Extent="392 355"; hScrollBar="alwaysOff"; @@ -19,19 +21,65 @@ ThemeManager.setProfile(%this.scroller, "scrollArrowProfile", "ArrowProfile"); %this.add(%this.scroller); - %this.tree = new GuiTreeViewCtrl() + // A real class rather than a script class on a plain tree. The two columns of + // editor state have to draw in row with each item and be hit tested by the + // pixel, and a list box refuses children, so there is nothing script could + // have hung them on. + // + // Note there is no class= here any more, and there must not be: the C++ class + // owns the namespace now, so GuiEditorExplorerTree.cs writes into it directly. + // Setting class= to the same name makes Namespace::classLinkTo log "cannot + // change namespace parent linkage" every time the editor opens. + %this.tree = new GuiEditorExplorerTree() { - class="GuiEditorExplorerTree"; HorizSizing="width"; VertSizing="height"; Position="0 0"; Extent="228 355"; BindToGuiEditor="1"; AllowReorder="1"; + + // A narrower step than the default, which is one row height. This tree + // spends more of its width on chrome than any other -- two columns, a + // triangle and an icon before a single letter of the name -- and at three + // levels deep the default step left almost nothing for the text. Twelve + // is still a clear step and buys back ten pixels a level. + IndentSize = 12; + + // The eye and the padlock. Frames come from EditorIcons.cs, never from + // the engine: that file is generated and alphabetical, so a number baked + // into C++ would silently become a different picture on the next rebuild. + StateIcons = "EditorCore:editorIcons16"; + EyeFrame = $EditorIcon::eye; + LockFrame = $EditorIcon::padlock_closed; + + // And a miniature of each control's own class, between the triangle and + // the text. Which frame is onGetItemIcon's answer, asked once per row as + // the tree builds -- the sheet is picked by size, same as everywhere else. + IconImage = GuiEditor.controlIcons.sheetFor(16); + IconSize = 16; + + // Named for the state each describes, because the eye reads inverted -- + // it is present when the control is NOT hidden. The bodies are the ones + // the properties pane used to carry; the headings are new, and say + // "Shown in the editor" rather than "Visible" because Visible is a real + // runtime flag that still lives in the pane and means something else. + ShownTip = "Shown in the editor - On" NL + "Drawn on the canvas as it will be in the game."; + HiddenTip = "Shown in the editor - Off" NL + "Hidden while you work. The control still draws when the game runs -- this only takes it out of the way on the canvas, so you can reach what is behind it. It is not saved with the Gui."; + LockedTip = "Locked - On" NL + "Cannot be picked or dragged on the canvas. Use this to stop a backdrop swallowing every click meant for what sits on top of it. It is not saved with the Gui."; + UnlockedTip = "Locked - Off" NL + "Can be picked and dragged on the canvas."; }; ThemeManager.setProfile(%this.tree, "treeViewProfile"); + ThemeManager.setProfile(%this.tree, "tipProfile", "TooltipProfile"); %this.scroller.add(%this.tree); - %this.tree.startListening(GuiEditor.inspectorWindow.inspector); + // The window itself, not a control inside it: the properties pane posts + // PostApply through its window so the tree can pick up a name that just + // changed. (It used to listen to the native GuiInspector, which is gone.) + %this.tree.startListening(GuiEditor.inspectorWindow); } function GuiEditorExplorerWindow::inspect(%this, %object) diff --git a/editor/GuiEditor/scripts/GuiEditorHeaderBlock.cs b/editor/GuiEditor/scripts/GuiEditorHeaderBlock.cs new file mode 100644 index 000000000..61d43621e --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorHeaderBlock.cs @@ -0,0 +1,458 @@ + +//----------------------------------------------------------------------------- +// The always-visible top of the Gui Editor's properties pane: what you need +// about the selected control without opening anything. +// +// There are not twenty-nine of these. The header is one shell with three +// swappable blocks, and a class only chooses which shape each block takes: +// +// identity name, profile and the state toggles. The same for everything +// except a menu item, which has no GuiControl fields at all. +// geometry position, extent and the two sizing enums -- but only the parts +// the control's PARENT has left it. A grid, frame set or tab book +// owns all of it; a chain owns one axis. See +// GuiEditorControlSpec::geometryModeOf. +// text the control's own string, when it has one that is drawn. Named +// for what it actually is: a window's title, a tab's caption, a +// drop-down's placeholder. +// value nothing, or the one or two fields that are the whole point of +// the control -- a slider's range, a sprite's image. +// +// The block owns its widgets and no values: every row reports to the pane, so +// the pane stays the only thing that writes to the control. The creator sets +// pane, spec and blockWidth inline, then calls build() once after adding it. +// +// The toggle row is four flags that change the Gui a player will run: whether a +// control draws, whether it responds, whether events reach it, and whether the +// editor may drop things into it. +// +// It used to be six. hidden and locked sat at the front, and they are not that +// kind of flag at all -- neither is ever written to a file, because both are +// working state rather than part of the document. Standing them next to the four +// that ARE the document read as a promise that they were too. They are the +// Explorer tree's two columns now, where a whole branch's state can be read at a +// glance and nothing suggests it will be saved. +//----------------------------------------------------------------------------- + +function GuiEditorHeaderBlock::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +function GuiEditorHeaderBlock::build(%this) +{ + %this.rowFields = ""; + %this.valueFields = ""; + + %this.buildIdentity(); + %this.buildToggles(); + %this.buildGeometry(); + %this.buildText(); + %this.buildMenuItem(); + + // The value grid starts empty; bindClass fills it, because what belongs + // there is the one thing here that changes with the class. Pair-width cells: + // a principal value is usually one or two short fields, and a window's title + // height wants its six switches beside it rather than under them. + %this.valueGrid = %this.pane.makeCellGrid(0, %this.pane.pairWidth); + %this.add(%this.valueGrid); +} + +//----------------------------------------------------------------------------- +// Identity. +//----------------------------------------------------------------------------- + +function GuiEditorHeaderBlock::buildIdentity(%this) +{ + %grid = %this.pane.makeCellGrid(0); + %this.add(%grid); + %this.identityGrid = %grid; + + %this.nameRow = %this.pane.addFieldRow(%grid, "name", "Name", "text", ""); + + // Category sits between the name and the profile because that is the order + // the two are decided in: what the control is, and then which of the theme's + // profiles for that answers it. It is not a field on the control -- the + // profile it picks is the record of the choice -- so it is built without a + // name in the registry, and the pane intercepts its commits. + %this.categoryRow = %this.pane.makeFieldRow(%grid, "category", "Category", "dropdown", ""); + + %this.profileRow = %this.pane.addFieldRow(%grid, "Profile", "Profile", "dropdown", ""); +} + +//----------------------------------------------------------------------------- +// State toggles. +//----------------------------------------------------------------------------- + +function GuiEditorHeaderBlock::buildToggles(%this) +{ + %w = %this.blockWidth; + + %row = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 28; + }; + ThemeManager.setProfile(%row, "emptyProfile"); + %this.add(%row); + %this.toggleRow = %row; + + // The four runtime flags, now that the sheet has art for them. They were + // captioned checkboxes, which cost so much width that only two fitted and + // useInput had to live in the Command section instead. Four icons fit in + // less than two captions did. + // + // Two of the four have a genuine pair (an eye against a struck-through one, + // on against off) and two do not, so those reuse one icon and let the + // pressed state carry the reading -- the same thing the anchor pins do. + // + // They start at 4 now. There used to be a gap here, between these and the two + // editor-only toggles that ran in front of them; the gap was the boundary + // between "changes the game" and "changes your view of it", and with hidden + // and locked gone to the Explorer tree there is no boundary left to mark. + %this.visibleButton = %this.makeIconToggle(%row, 4, "Visible", "Visible", + $EditorIcon::eye, $EditorIcon::invisible_light, + "Draws when the game runs. Its children draw with it -- hiding a control hides everything inside it.", + "Does not draw when the game runs, and neither do its children. A layout container skips a hidden control entirely, so hiding one can move its siblings."); + %this.activeButton = %this.makeIconToggle(%row, 32, "Active", "Active", + $EditorIcon::on, $EditorIcon::off, + "Responds when the game runs: it takes clicks and keys and draws in its ordinary colors.", + "Inert when the game runs. It still draws, in the profile's disabled colors, but ignores every click and key."); + %this.inputButton = %this.makeIconToggle(%row, 60, "useInput", "Accepts Input", + $EditorIcon::cursor_arrow, $EditorIcon::cursor_arrow, + "Touch and key events reach this control.", + "Touch and key events pass straight through to whatever is behind it. Turn this off on a backdrop or a label so it cannot swallow clicks meant for something else."); + %this.containerButton = %this.makeIconToggle(%row, 88, "isContainer", "Accepts Children", + $EditorIcon::folder_open, $EditorIcon::folder, + "The editor drops controls into this one when you draw them over it.", + "The editor never drops controls into this one. They land in its parent instead, on top of it."); +} + +// A checkbox wearing an icon, which is what a toggle button is here. It holds +// its own state and refuses to change it while inactive; the pane is told what +// the value became. +function GuiEditorHeaderBlock::makeIconToggle(%this, %row, %x, %field, %label, %frameOn, %frameOff, %tipOn, %tipOff) +{ + %button = new GuiCheckBoxCtrl() + { + class = "GuiEditorToggleIcon"; + Position = %x SPC 2; + Extent = "24 24"; + frameOn = %frameOn; + frameOff = %frameOff; + tipOn = %tipOn; + tipOff = %tipOff; + toggleName = %field; + toggleLabel = %label; + owner = %this; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %row.add(%button); + return %button; +} + +// A toggle icon changed. It has already flipped itself, so the pane is handed +// the new value rather than being asked to work it out. +function GuiEditorHeaderBlock::onToggleIconChanged(%this, %toggle) +{ + %this.pane.onToggleChanged(%toggle.toggleName, %toggle.getValue()); +} + +//----------------------------------------------------------------------------- +// A window's six switches. They were a section of six captioned checkboxes; +// as icons they are one cell, which fits beside Title Height in the value +// block -- the same trade the state toggles made in the row above. +//----------------------------------------------------------------------------- + +function GuiEditorHeaderBlock::buildWindowToggles(%this, %grid) +{ + %size = 24; + %gap = 2; + %fields = %this.spec.windowToggles(); + + %row = new GuiControl() + { + Position = "0 0"; + Extent = (getWordCount(%fields) * (%size + %gap)) SPC (%size + 4); + }; + ThemeManager.setProfile(%row, "emptyProfile"); + %grid.add(%row); + + // field TAB label TAB icon TAB on TAB off + %table = + "canMove" TAB "Move" TAB $EditorIcon::cursor_drag_arrow TAB + "The player can drag the window by its title bar." TAB + "The window stays where it is put. Use this for a dialog that should not be moved off what it is explaining." NL + "canClose" TAB "Close" TAB $EditorIcon::app_window_cross TAB + "The title bar carries a close button." TAB + "No close button. The game has to take the window down itself, which is what a modal dialog with its own buttons wants." NL + "canMinimize" TAB "Minimize" TAB $EditorIcon::round_minus TAB + "The title bar carries a minimise button, which rolls the window up to its title." TAB + "No minimise button." NL + "canMaximize" TAB "Maximize" TAB $EditorIcon::expand TAB + "The title bar carries a maximise button, which fills the window's parent." TAB + "No maximise button." NL + "resizeWidth" TAB "Resize Width" TAB $EditorIcon::arrow_two_head TAB + "The player can drag the window's left and right edges." TAB + "The width is fixed at whatever the Gui was saved with." NL + "resizeHeight" TAB "Resize Height" TAB $EditorIcon::arrow_two_head_2 TAB + "The player can drag the window's top and bottom edges." TAB + "The height is fixed at whatever the Gui was saved with."; + + %count = getRecordCount(%table); + for(%i = 0; %i < %count; %i++) + { + %rec = getRecord(%table, %i); + %field = getField(%rec, 0); + %this.windowButton[%field] = %this.makeIconToggle(%row, %i * (%size + %gap), + %field, getField(%rec, 1), getField(%rec, 2), getField(%rec, 2), + getField(%rec, 3), getField(%rec, 4)); + } + + %this.windowToggleRow = %row; +} + +// Load the six from the control, when the bound class has them. +function GuiEditorHeaderBlock::refreshWindowToggles(%this, %ctrl) +{ + if(!isObject(%this.windowToggleRow)) + { + return; + } + + %fields = %this.spec.windowToggles(); + for(%i = 0; %i < getWordCount(%fields); %i++) + { + %field = getWord(%fields, %i); + %this.windowButton[%field].setValue(%ctrl.getFieldValue(%field)); + } +} + +//----------------------------------------------------------------------------- +// Geometry. Which of these the control may edit is its parent's answer, so the +// pane recomputes it on selection AND on reparent, and passes the mode here. +//----------------------------------------------------------------------------- + +function GuiEditorHeaderBlock::buildGeometry(%this) +{ + %grid = %this.pane.makeCellGrid(0); + %this.add(%grid); + %this.geometryGrid = %grid; + + %this.positionRow = %this.pane.addFieldRow(%grid, "Position", "Position", "point", ""); + %this.extentRow = %this.pane.addFieldRow(%grid, "Extent", "Extent", "point", ""); + + // The two sizing enums share one widget, because they are one decision and + // their names read backwards -- see GuiEditorAnchorPicker. It goes in the + // same grid as a cell of its own so it reflows with everything else, and it + // carries no authored width: the grid resizes every cell to the column it + // computed, so a width set here would only be overwritten. + %this.anchorPicker = new GuiControl() + { + class = "GuiEditorAnchorPicker"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %this.pane.rowWidth SPC 124; + owner = %this; + }; + %grid.add(%this.anchorPicker); + %this.anchorPicker.build(); +} + +// The picker changed. Both enums are written, because a single click can move +// either of them and writing only the one that visibly changed would leave the +// other stale after a Fill is cleared. +function GuiEditorHeaderBlock::onAnchorChanged(%this, %picker) +{ + %this.pane.onSizingChanged(%picker.horizEnum(), %picker.vertEnum()); +} + +// Grey rather than hide, and say why. A control sitting in a grid still HAS a +// position; it is just not the control's to set, and blanking the row would +// leave no way to see where the grid put it. +function GuiEditorHeaderBlock::applyGeometryMode(%this, %mode) +{ + %spec = %this.spec; + %reason = "The parent container sets this."; + + // A field the control's own class rewrites every frame is not greyed but + // gone: there is no value to look at and nothing a tooltip could usefully + // say about it. A menu bar is always at the top of what it is in. + %this.positionRow.setVisible(%spec.isGeometryFieldShown(%mode, "Position")); + %this.extentRow.setVisible(%spec.isGeometryFieldShown(%mode, "Extent")); + %this.anchorPicker.setVisible(%spec.isGeometryFieldShown(%mode, "HorizSizing")); + + %this.anchorPicker.setAxisEnabled( + %spec.isGeometryFieldLive(%mode, "HorizSizing"), + %spec.isGeometryFieldLive(%mode, "VertSizing")); + + // Position and Extent are the two fields whose axes can disagree. A vertical + // chain stacks its children on Y and copies X straight back from the child; + // a menu bar's width is its parent's and its height is its own. The rows' + // own setEnabled works on both boxes at once, so the axes are set directly -- + // the same two widgets it would touch. + %this.applyAxes(%this.positionRow, %spec.livePositionAxes(%mode), %reason); + %this.applyAxes(%this.extentRow, %spec.liveExtentAxes(%mode), %reason); +} + +function GuiEditorHeaderBlock::applyAxes(%this, %row, %axes, %reason) +{ + %liveX = strstr(%axes, "x") >= 0; + %liveY = strstr(%axes, "y") >= 0; + + %row.editor.setActive(%liveX); + %row.editorY.setActive(%liveY); + %row.editor.Tooltip = %liveX ? "" : %reason; + %row.editorY.Tooltip = %liveY ? "" : %reason; +} + +//----------------------------------------------------------------------------- +// Text. +//----------------------------------------------------------------------------- + +// The whole text story is one component now -- the string, the two flags that +// change what it does to the control, both alignments, and the size and color +// it is drawn in. The header holds one copy of it and the pane's Text section +// holds the other; see GuiEditorTextBlock. +// +// textGrid, textRow, alignRow and vAlignRow stay as names for what the block +// owns, because they are how the pane and the smoke tests reach these widgets. +function GuiEditorHeaderBlock::buildText(%this) +{ + %this.textBlock = %this.pane.makeTextBlock(%this, 0); + + %this.textGrid = %this.textBlock; + %this.textRow = %this.textBlock.textRow; + %this.alignRow = %this.textBlock.alignRow; + %this.vAlignRow = %this.textBlock.vAlignRow; +} + +//----------------------------------------------------------------------------- +// Binding to a class. Everything above exists for every control; this decides +// which of it applies and what the value block holds. +//----------------------------------------------------------------------------- + +function GuiEditorHeaderBlock::bindClass(%this, %ctrl, %class) +{ + %spec = %this.spec; + %bare = %spec.hasFlag(%class, "bare"); + + // A menu item has no GuiControl fields at all -- it calls + // SimObject::initPersistFields rather than GuiControl's -- so it keeps its + // name and its caption and loses everything else here. + %this.profileRow.setVisible(!%bare); + + // Only one class in the palette is ambiguous enough to need this, and a + // class with no profile at all cannot use it either. + %this.categoryRow.setVisible(!%bare && %spec.categoryChoices(%class) !$= ""); + %this.geometryGrid.setVisible(!%bare); + + // Not simply !bare: a bare class has none of GuiControl's fields except the + // ones it turns round and registers again, and a menu item does that with + // Visible and Active. useInput it genuinely does not have. + %states = %spec.stateToggles(%class); + %this.visibleButton.setVisible(%spec.listHas(%states, "Visible")); + %this.activeButton.setVisible(%spec.listHas(%states, "Active")); + %this.inputButton.setVisible(%spec.listHas(%states, "useInput")); + + // isContainer is dead where the control cannot draw children -- GuiControl's + // setIsContainerFn forces the field false for those -- so the button goes + // rather than sitting there wired to nothing. + %this.containerButton.setVisible(%spec.isContainerFieldVisible(%ctrl)); + + // The text block is only in the header for the classes whose text is a + // principal property of them. A grid can technically draw text; it goes in a + // collapsed section instead of the first thing anyone sees. The pane places + // it and binds it -- it owns both copies. + %this.textBlock.setVisible(%spec.textBlockHome(%class) $= "header"); + + // A menu item's own fields, which are the only ones it has. It brings its own + // caption box, so the shared text block stands down for it. + %menuItem = (%class $= "GuiMenuItemCtrl"); + %this.menuItemBlock.setVisible(%menuItem); + if(%menuItem) + { + %this.textBlock.setVisible(false); + %this.menuItemBlock.bind(%ctrl); + } + + %this.buildValueRows(%ctrl, %class); + %this.resizeToFit(); +} + +//----------------------------------------------------------------------------- +// The menu item block. Its own file, because a menu item shares almost nothing +// with anything else in the palette - see GuiEditorMenuItemBlock. +//----------------------------------------------------------------------------- + +function GuiEditorHeaderBlock::buildMenuItem(%this) +{ + %this.menuItemBlock = new GuiChainCtrl() + { + class = "GuiEditorMenuItemBlock"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %this.blockWidth SPC 24; + IsVertical = true; + pane = %this.pane; + spec = %this.spec; + blockWidth = %this.blockWidth; + Visible = false; + }; + %this.add(%this.menuItemBlock); + %this.menuItemBlock.build(); +} + +// The principal value: whatever the control is for. Rebuilt rather than +// filtered, because these fields are the one part of the header that does not +// exist on every class -- there is no shared row to hide. +function GuiEditorHeaderBlock::buildValueRows(%this, %ctrl, %class) +{ + %this.pane.clearRows(%this.valueFields); + %this.valueGrid.deleteObjects(); + %this.valueFields = ""; + %this.windowToggleRow = ""; + + %fields = %this.spec.headerValueFields(%class); + + // A sprite names its picture three mutually exclusive ways, so the header + // shows the one it is actually using rather than all three. + if(%class $= "GuiSpriteCtrl") + { + %fields = %this.spec.spriteSourceFields(%this.spec.spriteSourceModeOf(%ctrl)); + } + + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%fields, %i); + %this.pane.addFieldRow(%this.valueGrid, %field, + %this.spec.labelFor(%field), %this.pane.kindFor(%ctrl, %field), + %this.pane.enumItemsFor(%ctrl, %field)); + %this.valueFields = (%this.valueFields $= "") ? %field : (%this.valueFields SPC %field); + } + + // A window's switches go in the same grid as its Title Height, so the two + // share a line rather than costing a section of their own. + if(%class $= "GuiWindowCtrl") + { + %this.buildWindowToggles(%this.valueGrid); + %count++; + } + + %this.valueGrid.setVisible(%count > 0); +} + +// A GuiChainCtrl positions its children without resizing them, and a grid only +// learns its height once something lays it out, so nudge the width by a pixel +// and back to force exactly one parentResized through every child. Same trick +// GuiProfileEditorProfileForm::build uses, and needed here for the same reason. +function GuiEditorHeaderBlock::resizeToFit(%this) +{ + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + %this.resize(0, 0, %w + 1, %h); + %this.resize(0, 0, %w, %h); +} diff --git a/editor/GuiEditor/scripts/GuiEditorInspector.cs b/editor/GuiEditor/scripts/GuiEditorInspector.cs deleted file mode 100644 index 63e024ba0..000000000 --- a/editor/GuiEditor/scripts/GuiEditorInspector.cs +++ /dev/null @@ -1,4 +0,0 @@ -function GuiEditorInspector::onPostApply(%this, %obj) -{ - %this.postEvent("PostApply", %obj); -} \ No newline at end of file diff --git a/editor/GuiEditor/scripts/GuiEditorInspectorPane.cs b/editor/GuiEditor/scripts/GuiEditorInspectorPane.cs new file mode 100644 index 000000000..1cb256108 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorInspectorPane.cs @@ -0,0 +1,1761 @@ + +//----------------------------------------------------------------------------- +// The Gui Editor's properties pane, in place of the generic C++ GuiInspector. +// +// The inspector reflected every registered field into flat alphabetical groups, +// which meant it offered a GuiChainCtrl nine text fields it never draws and a +// GuiTabPageCtrl four geometry fields its book overwrites on every layout pass. +// This asks GuiEditorControlSpec what the selected class actually reads and +// shows that, with the fields that matter most in a header that is always open. +// +// Layout is a vertical chain of blocks, the same arrangement +// GuiProfileEditorProfileForm uses and for the same reason: each block lays its +// fields out in a GuiGridCtrl, so widening the Properties frame reflows the +// cells into more columns instead of leaving dead space. +// +// Two kinds of block, and the difference matters: +// +// shared Every control has these fields, so the rows are built once and +// filtered with setVisible. Nothing is ever freed, so a selection +// change can never delete a control the engine is mid-dispatch on. +// class The class's own sections and the header's value block. These +// fields do not exist on other classes, so there is no shared row +// to hide and they are rebuilt when the selected class changes. +// +// Rebuilding is safe here in a way it was not in the Profile Editor's preview, +// because nothing inside this pane can change the target's class: rebuilds only +// ever arrive from a selection change, which originates on the canvas or in the +// explorer tree, never on a widget this pane owns. The one exception -- a +// sprite's source mode, which a commit CAN change -- is deferred to schedule(0) +// rather than run inside the commit. +// +// The pane owns every write to the control; its rows only marshal values. The +// creator sets paneWidth and window inline, then calls build() once after +// adding the pane to its scroller. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); + + %this.spec = new ScriptObject() + { + class = "GuiEditorControlSpec"; + }; +} + +function GuiEditorInspectorPane::onRemove(%this) +{ + %this.unbind(); + + if(isObject(%this.spec)) + { + %this.spec.delete(); + } +} + +//----------------------------------------------------------------------------- +// Construction. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::build(%this) +{ + %w = %this.paneWidth; + + // 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: an easing + // and the time it takes, a tooltip's width and its delay, a window's title + // height and its six switches. + %this.pairWidth = 152; + + %this.rowFields = ""; + %this.panelList = ""; + %this.classPanels = ""; + %this.boundClass = ""; + + %this.header = new GuiChainCtrl() + { + class = "GuiEditorHeaderBlock"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 40; + IsVertical = true; + ChildSpacing = 2; + blockWidth = %w; + pane = %this; + spec = %this.spec; + }; + %this.add(%this.header); + %this.header.build(); + + // The class's own sections live in a chain of their own so that rebuilding + // them cannot change where they sit: added straight to the outer chain they + // would land after the shared sections every time they were replaced. + %this.classChain = new GuiChainCtrl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 4; + IsVertical = true; + ChildSpacing = 6; + }; + ThemeManager.setProfile(%this.classChain, "emptyProfile"); + %this.add(%this.classChain); + + // Variants get a chain of their own because they are rebuilt more often + // than the class sections: whether a slot is worth showing depends on what + // the theme holds right now, which the Profile Editor can change while the + // same control stays selected. + %this.variantsChain = new GuiChainCtrl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 4; + IsVertical = true; + ChildSpacing = 6; + }; + ThemeManager.setProfile(%this.variantsChain, "emptyProfile"); + %this.add(%this.variantsChain); + + // The shared sections, in the order the work usually goes. + %this.buildTextSection(); + %this.buildItemsSection(); + + // isContainer and useInput are both in the header's icon row now that there + // is art for them, so neither has a row here. + %this.buildSection("Layout", "Layout", "MinExtent"); + %this.buildSection("Command", "Command", "Command AltCommand Variable Accelerator"); + + // Each easing beside the time it takes: hover on one line, press on the next. + %this.buildSection("Animation", "Animation", %this.spec.easingFields(), %this.pairWidth); + + %this.buildTooltipSection(); + %this.buildSection("Localization", "Localization", "langTableMod textID"); + %this.buildSection("Scripting", "Scripting", "class superclass internalName"); + + // Dynamic fields last, and in a section of their own rather than through + // buildSection: what it holds is not a fixed field list, so it owns its own + // rows and its own commits. + %this.dynamicPanel = %this.makeSectionPanel("Dynamic Fields"); + %this.add(%this.dynamicPanel); + + %this.dynamicFields = new GuiChainCtrl() + { + class = "GuiEditorDynamicFields"; + HorizSizing = "width"; + Position = "0 24"; + Extent = %w SPC 4; + IsVertical = true; + ChildSpacing = 4; + blockWidth = %w; + pane = %this; + }; + %this.dynamicPanel.add(%this.dynamicFields); + %this.dynamicFields.build(); + + %this.forceLayout(); +} + +// 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. +function GuiEditorInspectorPane::forceLayout(%this) +{ + %w = %this.paneWidth; + %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, matching what the native +// inspector gave its group grids. 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. So +// a section that wants its fields in pairs asks for half a pane rather than +// arranging them itself -- and still reflows to one column when the frame is +// dragged narrow. Omit it for the ordinary one-field-per-row width. +function GuiEditorInspectorPane::makeCellGrid(%this, %y, %cellW) +{ + %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 = 0; + MaxRowCount = 0; + OrderMode = "lrtb"; + IsExtentDynamic = true; + }; + ThemeManager.setProfile(%grid, "emptyProfile"); + return %grid; +} + +// 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 the +// filter. Grandchildren are left alone and the grid skips the hidden ones. +function GuiEditorInspectorPane::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; +} + +// The text block's second home. Not a buildSection: it holds one component +// rather than a list of rows, so its visibility is decided by which home the +// class wants (GuiEditorControlSpec::textBlockHome) and not by counting visible +// rows. It stays out of panelList for that reason. +// +// The three field rows inside a block are named here instead, with no row +// behind them yet: applyFilter points them at whichever block is in use, and +// then the shared filter and the shared value loop reach them like any other +// field. That is what stops two blocks fighting over one entry in the registry. +function GuiEditorInspectorPane::buildTextSection(%this) +{ + %this.textPanel = %this.makeSectionPanel("Text"); + %this.add(%this.textPanel); + + %this.sectionText = %this.makeTextBlock(%this.textPanel, 24); + + %fields = %this.spec.textBlockRowFields(); + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%fields, %i); + %this.row[%field] = ""; + %this.rowFields = (%this.rowFields $= "") ? %field : (%this.rowFields SPC %field); + } +} + +// The tooltip is a paragraph rather than a caption -- it is where an editor +// answers a question instead of sending someone looking -- so it gets the same +// three-line box the text block uses, at the full width of the pane. Its width +// and its delay are two small numbers that read as a pair, so they share the +// line beneath it. +function GuiEditorInspectorPane::buildTooltipSection(%this) +{ + %panel = %this.makeSectionPanel("Tooltip"); + %this.add(%panel); + + %chain = new GuiChainCtrl() + { + HorizSizing = "width"; + Position = "0 24"; + Extent = %this.paneWidth SPC 4; + IsVertical = true; + ChildSpacing = 2; + }; + ThemeManager.setProfile(%chain, "emptyProfile"); + %panel.add(%chain); + + %this.panel["Tooltip"] = %panel; + %this.panelFields["Tooltip"] = %this.spec.tooltipFields(); + %this.panelList = (%this.panelList $= "") ? "Tooltip" : (%this.panelList SPC "Tooltip"); + + %this.addFieldRow(%chain, "tooltip", %this.spec.labelFor("tooltip"), "multiline", ""); + + %grid = %this.makeCellGrid(0, %this.pairWidth); + %chain.add(%grid); + %this.addFieldRow(%grid, "tooltipWidth", %this.spec.labelFor("tooltipWidth"), "number", ""); + %this.addFieldRow(%grid, "hovertime", %this.spec.labelFor("hovertime"), "number", ""); +} + +// The static rows of a list box or a drop down, directly under the text section +// because they are what the control says: a list's rows are its caption. Built +// once and shown per class rather than rebuilt with the class sections, for the +// reason the header comment gives -- and it stays out of panelList and out of +// the row registry, because what it holds is not a field. +function GuiEditorInspectorPane::buildItemsSection(%this) +{ + %this.itemsPanel = %this.makeSectionPanel("Items"); + %this.add(%this.itemsPanel); + + %this.itemsBlock = new GuiChainCtrl() + { + class = "GuiEditorItemsBlock"; + HorizSizing = "width"; + Position = "0 24"; + Extent = %this.paneWidth SPC 4; + IsVertical = true; + ChildSpacing = 4; + blockWidth = %this.paneWidth; + pane = %this; + }; + %this.itemsPanel.add(%this.itemsBlock); + %this.itemsBlock.build(); +} + +// A row was added or removed, so the section is a different height. Same shape +// as onDynamicFieldsChanged, and for the same reason. +function GuiEditorInspectorPane::onItemsChanged(%this) +{ + %this.itemsBlock.resize(0, 24, %this.paneWidth, getWord(%this.itemsBlock.getExtent(), 1)); + %this.forceLayout(); +} + +function GuiEditorInspectorPane::makeTextBlock(%this, %parent, %y) +{ + %block = new GuiChainCtrl() + { + class = "GuiEditorTextBlock"; + HorizSizing = "width"; + Position = "0" SPC %y; + Extent = %this.paneWidth SPC 4; + IsVertical = true; + ChildSpacing = 2; + blockWidth = %this.paneWidth; + pane = %this; + spec = %this.spec; + }; + %parent.add(%block); + %block.build(); + return %block; +} + +function GuiEditorInspectorPane::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.spec.labelFor(%field), + %this.sharedKindFor(%field), %this.sharedEnumItemsFor(%field)); + } +} + +// Build a row without claiming a name for it. Two rows here are not fields of +// the control -- the Category picker, and the text block's copy in whichever of +// its two homes is not in use -- so they must stay out of the registry the +// filter and the value loop walk. +function GuiEditorInspectorPane::makeFieldRow(%this, %container, %field, %label, %kind, %enumItems) +{ + %row = new GuiControl() + { + class = "GuiProfileEditorFieldRow"; + + // 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; + owner = %this; + }; + %container.add(%row); + %row.build(); + + // This pane has no reset-to-default, so the row's reset button -- which + // means "back to the theme's stamped value" and has no analogue here -- + // never appears. The one exception is the font color row, where the button + // means "stop overriding the profile" and the text block asks for it back. + %row.resetButton.setVisible(false); + return %row; +} + +function GuiEditorInspectorPane::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; +} + +// Forget rows that are about to be deleted, so a later refresh does not reach +// through a dangling handle. +function GuiEditorInspectorPane::clearRows(%this, %fields) +{ + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%fields, %i); + %this.row[%field] = ""; + %this.rowFields = trim(strreplace(" " @ %this.rowFields @ " ", " " @ %field @ " ", " ")); + } +} + +//----------------------------------------------------------------------------- +// Field presentation. The shared rows are built before any control is selected, +// so their kinds come from a small table; a class row can ask the control +// itself, which is always more accurate. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::sharedKindFor(%this, %field) +{ + switch$(%field) + { + case "MinExtent": return "point"; + case "isContainer" or "textWrap" or "textExtend" or "overrideFontColor" or "useInput": return "bool"; + case "tooltipWidth" or "hovertime": return "number"; + + // A multiplier on the profile's font size, so 1.25 is an ordinary value + // for it and a whole-number row would round every one of them away. + case "fontSizeAdjust": return "decimal"; + case "fontColor": return "color"; + case "align" or "vAlign": return "enum"; + case "easeFillColorHL" or "easeFillColorSL": return "enum"; + case "easeTimeFillColorHL" or "easeTimeFillColorSL": return "number"; + } + return "text"; +} + +function GuiEditorInspectorPane::sharedEnumItemsFor(%this, %field) +{ + switch$(%field) + { + // "default" is offered now that the engine's tables expose it -- it is + // the value a control starts on, meaning "inherit the profile's". + case "align": return "default" TAB "left" TAB "center" TAB "right"; + case "vAlign": return "default" TAB "top" TAB "middle" TAB "bottom"; + case "easeFillColorHL" or "easeFillColorSL": return %this.easingItems(); + } + return ""; +} + +// The easing names the engine offers, taken from gEasingTable in guiControl.cc. +function GuiEditorInspectorPane::easingItems(%this) +{ + return "Linear" TAB "EaseIn" TAB "EaseOut" TAB "EaseInOut"; +} + +// A class row can read its own type off the control, which is exact. +function GuiEditorInspectorPane::kindFor(%this, %ctrl, %field) +{ + %kind = %this.spec.kindForType(%ctrl.getFieldType(%field)); + + // A profile slot is a short list of the theme's members for its category, + // not a free-text name. + if(%kind $= "profile") + { + return "dropdown"; + } + return %kind; +} + +// An enum's legal values are not exposed to script, so the ones that reach a +// class row are listed here. Anything missing falls back to a text box, which +// still edits the field correctly -- it just does not offer the choices. +function GuiEditorInspectorPane::enumItemsFor(%this, %ctrl, %field) +{ + switch$(%field) + { + // The anchor names, not the originals: these say which edge stays put + // rather than which one moves. The old set still loads but is never + // offered (guiControl.cc). + case "HorizSizing": return "anchorLeft" TAB "anchorRight" TAB "width" TAB "center" TAB "scale" TAB "fill"; + case "VertSizing": return "anchorTop" TAB "anchorBottom" TAB "height" TAB "center" TAB "scale" TAB "fill"; + case "hScrollBar" or "vScrollBar": return "alwaysOn" TAB "alwaysOff" TAB "dynamic"; + case "CellModeX" or "CellModeY": return "absolute" TAB "variable"; + case "OrderMode": return "lrtb" TAB "tblr"; + case "TabPosition": return "Top" TAB "Bottom"; + case "DisplayMode": return "Dropper" TAB "Pallet" TAB "BlendRange" TAB "HueRange" TAB "AlphaRange"; + case "valueMode": return "RGB" TAB "HSB" TAB "Hex"; + case "inputMode": return "AllText" TAB "Number" TAB "Decimal" TAB "AlphaNumeric"; + } + return ""; +} + +//----------------------------------------------------------------------------- +// Binding. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::bind(%this, %ctrl) +{ + if(!isObject(%ctrl)) + { + %this.unbind(); + return; + } + + // The sizing stash belongs to whichever control it was taken from. Moving + // the selection abandons it rather than carrying a stale position onto + // something else -- it exists only so that clicking through the modes on + // one control can be undone. + if(%ctrl != %this.target) + { + %this.clearStash("h"); + %this.clearStash("v"); + } + + %this.target = %ctrl; + %class = %ctrl.getClassName(); + + if(%class !$= %this.boundClass) + { + %this.buildClassSections(%class); + %this.boundClass = %class; + } + + // Always, not only on a class change: the header's value block depends on + // the control as well as its class (a sprite shows the source it is using). + %this.header.bindClass(%ctrl, %class); + %this.buildVariantsSection(); + %this.dynamicFields.bind(%ctrl); + %this.itemsBlock.bind(%this.spec.hasItemList(%class) ? %ctrl : ""); + + %this.applyFilter(); + %this.refresh(); + %this.forceLayout(); + %this.setVisible(true); +} + +// Nothing selected. The blocks keep their rows -- rebuilding them is what this +// pane goes out of its way to avoid -- and the whole pane simply stops drawing, +// so no stale values are left on show. +function GuiEditorInspectorPane::unbind(%this) +{ + %this.target = ""; + %this.clearStash("h"); + %this.clearStash("v"); + %this.setVisible(false); +} + +// Rebuild the sections that belong to this class alone. The shared sections and +// everything in the header shell are left standing. +function GuiEditorInspectorPane::buildClassSections(%this, %class) +{ + %count = getWordCount(%this.classPanels); + for(%i = 0; %i < %count; %i++) + { + %key = getWord(%this.classPanels, %i); + %this.clearRows(%this.panelFields[%key]); + %this.panel[%key] = ""; + %this.panelFields[%key] = ""; + } + %this.classChain.deleteObjects(); + %this.classPanels = ""; + + %keys = %this.spec.sectionKeys(%class); + %keyCount = getWordCount(%keys); + for(%i = 0; %i < %keyCount; %i++) + { + %key = getWord(%keys, %i); + %this.buildClassSection(%class, %key); + } + + // A class the table has never heard of gets everything it registers that is + // not already on show, so an uncovered control degrades to roughly what the + // native inspector did rather than to nothing. + if(!%this.spec.isKnownClass(%class)) + { + %this.buildOtherSection(%class); + } +} + +function GuiEditorInspectorPane::buildClassSection(%this, %class, %key) +{ + %fields = %this.spec.sectionFields(%class, %key); + %panel = %this.makeSectionPanel(%this.spec.sectionTitle(%class, %key)); + %this.classChain.add(%panel); + + %grid = %this.makeCellGrid(24); + %panel.add(%grid); + + %this.panel[%key] = %panel; + %this.panelFields[%key] = %fields; + %this.classPanels = (%this.classPanels $= "") ? %key : (%this.classPanels SPC %key); + + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%fields, %i); + %this.addFieldRow(%grid, %field, %this.spec.labelFor(%field), + %this.kindFor(%this.target, %field), + %this.enumItemsFor(%this.target, %field)); + } +} + +// Everything the control registers that nothing above has claimed. Uses the +// control's own field list, so it needs no knowledge of the class at all. +function GuiEditorInspectorPane::buildOtherSection(%this, %class) +{ + %claimed = %this.rowFields SPC %this.spec.geometryFields() SPC + %this.spec.deadFields() SPC %this.spec.runtimeToggles() SPC + %this.spec.editorToggles() SPC "name Profile"; + + %fields = ""; + %count = %this.target.getFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %field = %this.target.getField(%i); + if(%this.spec.listHas(%claimed, %field)) + { + continue; + } + if(%this.spec.kindForType(%this.target.getFieldType(%field)) $= "hidden") + { + continue; + } + %fields = (%fields $= "") ? %field : (%fields SPC %field); + } + + if(%fields $= "") + { + return; + } + + %panel = %this.makeSectionPanel("Other"); + %this.classChain.add(%panel); + %grid = %this.makeCellGrid(24); + %panel.add(%grid); + + %this.panel["Other"] = %panel; + %this.panelFields["Other"] = %fields; + %this.classPanels = (%this.classPanels $= "") ? "Other" : (%this.classPanels SPC "Other"); + + %fieldCount = getWordCount(%fields); + for(%i = 0; %i < %fieldCount; %i++) + { + %field = getWord(%fields, %i); + %this.addFieldRow(%grid, %field, %this.spec.labelFor(%field), + %this.kindFor(%this.target, %field), + %this.enumItemsFor(%this.target, %field)); + } +} + +//----------------------------------------------------------------------------- +// Variants: the control's secondary profile slots, shown only where there is +// something to choose between. +// +// Every slot other than Profile itself -- contentProfile, thumbProfile, +// closeButtonProfile and the rest -- is assigned by GuiEditorThemeApplier from +// the Gui's theme, and in the ordinary case there is exactly one profile in the +// theme for that slot's category. Showing a dropdown with one entry in it is +// noise, so the row only exists once a second candidate does. +// +// The count and the contents are deliberately different sets. An uncategorised +// standalone -- "Any" in the Profile Editor -- is offered in a row that already +// exists but never causes one to appear, because otherwise a single "Any" +// profile would sprout a Variants row on every slot of every control, which is +// exactly the complexity this pane exists to remove. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::buildVariantsSection(%this) +{ + if(isObject(%this.panel["Variants"])) + { + %this.clearRows(%this.panelFields["Variants"]); + %this.panel["Variants"] = ""; + %this.panelFields["Variants"] = ""; + %this.panelList = trim(strreplace(" " @ %this.panelList @ " ", " Variants ", " ")); + } + %this.variantsChain.deleteObjects(); + + %fields = %this.variantSlots(%this.target); + if(%fields $= "") + { + return; + } + + %panel = %this.makeSectionPanel("Variants"); + %this.variantsChain.add(%panel); + %grid = %this.makeCellGrid(24); + %panel.add(%grid); + + %this.panel["Variants"] = %panel; + %this.panelFields["Variants"] = %fields; + %this.panelList = %this.panelList SPC "Variants"; + + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%fields, %i); + %this.addFieldRow(%grid, %field, %this.spec.labelFor(%field), "dropdown", ""); + } +} + +// The slots worth showing: every TypeGuiProfile field except the control's own +// Profile, which the header already carries, and only where the narrow +// candidate set has more than one member. +// +// Cursor slots join on the same terms. A themed Gui gets its cursors filled by +// GuiEditorThemeApplier without being asked, and a theme almost always offers +// exactly one cursor per category -- so a row appears only once the theme holds +// a second cursor for that job and there is genuinely a choice to make. +function GuiEditorInspectorPane::variantSlots(%this, %ctrl) +{ + if(!isObject(%ctrl) || %this.spec.hasFlag(%ctrl.getClassName(), "bare")) + { + return ""; + } + + %fields = ""; + %count = %ctrl.getFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %field = %ctrl.getField(%i); + %type = %ctrl.getFieldType(%field); + + if(%type $= "GuiProfile" && %field !$= "Profile") + { + if(getWordCount(%this.profileAnchors(%ctrl, %field)) > 1) + { + %fields = (%fields $= "") ? %field : (%fields SPC %field); + } + } + else if(%type $= "GuiCursor") + { + if(getWordCount(%this.cursorAnchors(%ctrl, %field)) > 1) + { + %fields = (%fields $= "") ? %field : (%fields SPC %field); + } + } + } + return %fields; +} + +// A cursor slot's candidates: the active theme's members of the slot's cursor +// category, plus whatever the slot holds now. There is no stand-alone cursor to +// merge in - a cursor belongs to a theme or to nothing. +function GuiEditorInspectorPane::cursorAnchors(%this, %ctrl, %field) +{ + %category = GuiEditor.themeApplier.cursorCategoryForField(%field); + if(%category $= "") + { + return ""; + } + + %theme = GuiEditor.themeByName(GuiEditor.themeName); + %list = isObject(%theme) ? %theme.getCursors(%category) : ""; + + %current = GuiEditor.themeApplier.fieldCursor(%ctrl, %field); + if(isObject(%current)) + { + %list = %this.addUnique(%list, %current); + } + + return %list; +} + +// Why does this control show the Variants rows it shows? Type +// +// GuiEditor.inspectorWindow.pane.dumpSlots(); +// +// into the console with something selected. For each profile slot it prints the +// category, what the slot holds, and every candidate with the theme that owns +// it -- which is the only way to tell a theme member from a standalone that +// happens to share a name, and the fastest way to find out why a row appeared. +function GuiEditorInspectorPane::dumpSlots(%this) +{ + if(!isObject(%this.target)) + { + echo("dumpSlots: nothing selected."); + return; + } + + %applier = GuiEditor.themeApplier; + %library = GuiEditor.themeLibrary; + %themes = %library.getThemes(); + + echo("dumpSlots: " @ %this.target.getClassName() @ " '" @ %this.target.getName() @ + "', active theme '" @ GuiEditor.themeName @ "', " @ getWordCount(%themes) @ " theme(s) loaded."); + + for(%i = 0; %i < getWordCount(%themes); %i++) + { + %t = getWord(%themes, %i); + echo(" theme " @ %t.getName() @ " (" @ %t @ ") members=" @ %t.getProfileCount()); + } + for(%i = 0; %i < %library.standaloneFolder.getCount(); %i++) + { + %p = %library.standaloneFolder.getObject(%i).target; + if(isObject(%p)) + { + echo(" standalone " @ %p.getName() @ " (" @ %p @ ") category='" @ %p.category @ "'"); + } + } + + // themeOf needs the applier's theme list, which it only holds between these. + %applier.beginApply(); + %count = %this.target.getFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %field = %this.target.getField(%i); + if(%this.target.getFieldType(%field) !$= "GuiProfile") + { + continue; + } + + %cat = %this.categoryForSlot(%this.target, %field); + %cur = %applier.fieldProfile(%this.target, %field); + %anchors = %this.profileAnchors(%this.target, %field); + + echo(" slot " @ %field @ " category='" @ %cat @ "' current=" @ + (isObject(%cur) ? %cur.getName() @ "(" @ %cur @ ")" : "none") @ + " anchors=" @ getWordCount(%anchors) @ + (getWordCount(%anchors) > 1 ? " <-- ROW SHOWN" : "")); + + for(%a = 0; %a < getWordCount(%anchors); %a++) + { + %p = getWord(%anchors, %a); + %owner = %applier.themeOf(%p); + echo(" " @ %p.getName() @ " (" @ %p @ ") from " @ + (isObject(%owner) ? "theme " @ %owner.getName() : "standalone/unowned")); + } + } + %applier.endApply(); +} + +//----------------------------------------------------------------------------- +// Filtering. Nothing here creates or deletes a control -- it only decides what +// is visible and what is inert. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::applyFilter(%this) +{ + %spec = %this.spec; + %class = %this.boundClass; + + // Which copy of the text block this class uses, before anything reads a row: + // its three field rows are shared names pointing at whichever block is in + // play, so the mapping has to be right before the filter walks them. + %home = %spec.textBlockHome(%class); + %block = %this.activeTextBlock(); + %this.mapTextRows(%block); + + %count = getWordCount(%this.rowFields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%this.rowFields, %i); + %row = %this.row[%field]; + if(isObject(%row)) + { + %row.setVisible(%spec.isFieldVisible(%class, %field)); + } + } + + // isContainer is the engine's answer rather than the table's: a control + // that cannot draw children has the field forced false, so the switch would + // be wired to nothing. + // isContainer moved to the header's icon row, which decides its own + // visibility from the same rule -- see GuiEditorHeaderBlock::bindClass. + + // The block's own parts -- the caption, the two flags and the two alignments + // -- are its to filter. The header shows or hides its copy from the same + // answer (GuiEditorHeaderBlock::bindClass). + %this.textPanel.setVisible(%home $= "section"); + if(isObject(%block)) + { + %block.bindClass(%this.target, %class); + } + + // A section with nothing left to show gets out of the way entirely. + // + // The class sections as well as the shared ones. They were left out, and a + // class section whose every field turned out to be hidden stayed on screen as + // a header that could not be opened: the grid drops hidden cells, so it + // measures zero high, and GuiPanelCtrl's expanded extent comes out equal to + // its collapsed one. Clicking it flipped the arrow and nothing else. + %panels = %this.panelList SPC %this.classPanels; + %count = getWordCount(%panels); + for(%i = 0; %i < %count; %i++) + { + %key = getWord(%panels, %i); + %this.panel[%key].setVisible(%this.anyRowVisible(%this.panelFields[%key])); + } + + // The Add row is always worth showing, so the section stays open whenever a + // control is bound at all -- unlike the others, an empty one is still the + // only way to put a field on the control. + %this.dynamicPanel.setVisible(isObject(%this.target)); + + // Same for Items, on the two classes that have any: an empty list is exactly + // the case the section exists to fix. + %this.itemsPanel.setVisible(isObject(%this.target) && %spec.hasItemList(%class)); + + %this.header.applyGeometryMode(%spec.geometryModeOf(%this.target)); +} + +// A field was added or removed, so the section's height changed under the +// chain. Nothing above it moved, but the panel has to be told to re-measure. +function GuiEditorInspectorPane::onDynamicFieldsChanged(%this) +{ + %this.dynamicFields.resize(0, 24, %this.paneWidth, getWord(%this.dynamicFields.getExtent(), 1)); + %this.forceLayout(); +} + +// Which of the two text blocks the bound class uses, or nothing where none of +// it applies (a sprite draws no text and reads no font). +function GuiEditorInspectorPane::activeTextBlock(%this) +{ + switch$(%this.spec.textBlockHome(%this.boundClass)) + { + case "header": return %this.header.textBlock; + case "section": return %this.sectionText; + } + return ""; +} + +// Point the three shared names at the block in use. Without this the second +// block to be built would own the registry and the first would never load or +// filter -- the rows would be on screen holding whatever the last selection of +// the other kind left in them. +function GuiEditorInspectorPane::mapTextRows(%this, %block) +{ + %fields = %this.spec.textBlockRowFields(); + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%fields, %i); + %this.row[%field] = isObject(%block) ? %block.row[%field] : ""; + } +} + +function GuiEditorInspectorPane::anyRowVisible(%this, %fields) +{ + %count = getWordCount(%fields); + for(%i = 0; %i < %count; %i++) + { + %row = %this.row[getWord(%fields, %i)]; + if(isObject(%row) && %row.isVisible()) + { + return true; + } + } + return false; +} + +//----------------------------------------------------------------------------- +// Loading values. The populating guard keeps every setText / setColorI / +// setStateOn from echoing straight back through the commits. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::refresh(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + %this.populating = true; + + // Candidates before values: a drop-down row keeps a selection that is not + // in its list by inserting it, so filling the list afterwards would drop + // what the control actually wears. + %this.refreshProfileChoices(); + + %count = getWordCount(%this.rowFields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%this.rowFields, %i); + %row = %this.row[%field]; + + // Profile slots were just loaded by name above. Reading one back off + // the control here would undo that: the field answers with whatever + // name it holds, and a profile created this session has one the Sim + // never registered. + // + // fontColor is skipped for a different reason: what its swatch shows is + // the profile's color while the control is not overriding it, which is + // not what the field holds. The block works that out. + if(!isObject(%row) || %this.isProfileSlot(%field) || %field $= "fontColor") + { + continue; + } + %row.setValue(%this.readField(%field)); + } + + // What the text block holds beyond its three field rows: two segmented rows, + // two toggles, and the font color swatch. + %block = %this.activeTextBlock(); + if(isObject(%block)) + { + %block.load(%this.target); + } + + // A menu item's fields are none of them in the registry above -- the block + // keeps them out of it deliberately, so the bare rule cannot hide them -- so + // it reloads itself. This is the path an undo comes back through. + if(%this.header.menuItemBlock.isVisible()) + { + %this.header.menuItemBlock.bind(%this.target); + } + + // The static rows, for the same reason: they are not fields, so nothing above + // reaches them, and a replayed step can put back a caption, a switch or the + // whole order. + if(%this.itemsPanel.isVisible()) + { + %this.itemsBlock.refresh(); + } + + %this.refreshToggles(); + %this.header.anchorPicker.readEnums( + %this.target.getFieldValue("HorizSizing"), + %this.target.getFieldValue("VertSizing")); + + %this.populating = false; +} + +// The anchor picker moved. Both enums go in: one click can change either, and +// clearing a Fill hands the axis back to its pins, which the other field has to +// hear about too. +// +// Then the control is laid out immediately. Center and fill are positions the +// control should always be in rather than reactions to a size change, so +// leaving them until the next parent resize means picking one appears to do +// nothing -- the control sat still until you nudged it. +// +// Because those two overwrite geometry the user typed, the position and extent +// they are about to destroy are kept first, per axis, and handed back when the +// axis returns to a mode that does not own them. That makes clicking through +// Fill, Scale and back a lossless way to look at the options. The stash is +// deliberately shallow: it lives until the selection changes and is then gone, +// so it never has to be reconciled with anything else that moves the control. +function GuiEditorInspectorPane::onSizingChanged(%this, %horiz, %vert) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + // What the whole change starts from. Setting an axis to center or fill hands + // that axis's geometry to the engine, which lays the control out then and + // there -- so the enum and the move it causes are one change, and undoing + // the enum alone would leave the control sitting where centring put it. + %recorder = GuiEditor.undoRecorder; + %horizBefore = %this.target.getFieldValue("HorizSizing"); + %vertBefore = %this.target.getFieldValue("VertSizing"); + %posBefore = %this.target.getPosition(); + %extentBefore = %this.target.getExtent(); + + // Recorded afterwards as the one net change, rather than as each write the + // body makes: leaving or entering a mode writes the geometry twice, and an + // undo replaying those in reverse would stop on the intermediate values. + %recorder.suspend(); + + // Order matters, and got this wrong once. The stash has to be taken while + // the geometry is still the user's, which is now -- writing the enum alone + // moves nothing. The restore has to wait until AFTER the enum is written, + // because a control still set to center or fill overrides any position or + // extent written to it: the write appeared to land and read straight back + // out as the centred value. + %this.stashIfNeeded("h", %horiz); + %this.stashIfNeeded("v", %vert); + + %this.writeField("HorizSizing", %horiz); + %this.writeField("VertSizing", %vert); + + // A no-op for every mode that needs a size delta, which is what we want: + // they have nothing to react to. Center and fill apply now. + %this.target.applySizing(); + + %this.restoreIfNeeded("h", %horiz); + %this.restoreIfNeeded("v", %vert); + + %recorder.resume(); + + // Geometry first and the enums last, because the ops replay in reverse for + // an undo and the enums have to be off center or fill before the position + // and extent are written back -- the same rule the body above follows. + %recorder.begin("Change Sizing", ""); + %recorder.recordField(%this.target, "Position", %posBefore, %this.target.getPosition(), false); + %recorder.recordField(%this.target, "Extent", %extentBefore, %this.target.getExtent(), false); + %recorder.recordField(%this.target, "HorizSizing", %horizBefore, + %this.target.getFieldValue("HorizSizing"), false); + %recorder.recordField(%this.target, "VertSizing", %vertBefore, + %this.target.getFieldValue("VertSizing"), false); + %recorder.end(); + + %this.refreshGeometry(); + %this.afterCommit(); +} + +// The two modes that overwrite what the user set. Center takes the position; +// fill takes the position and the extent. +function GuiEditorInspectorPane::sizingOwnsGeometry(%this, %mode) +{ + return %mode $= "center" || %mode $= "fill"; +} + +// Entering one of those: keep what it is about to overwrite, unless something +// is already kept -- the first value is the one worth returning to. +function GuiEditorInspectorPane::stashIfNeeded(%this, %axis, %mode) +{ + if(!%this.sizingOwnsGeometry(%mode) || %this.stashed[%axis] !$= "") + { + return; + } + + %this.stashed[%axis] = true; + %this.stashPos[%axis] = %this.target.getPosition(); + %this.stashExtent[%axis] = %this.target.getExtent(); +} + +// Leaving one: give back what it took, on this axis only -- the other may still +// be filled, and restoring both would undo it. +function GuiEditorInspectorPane::restoreIfNeeded(%this, %axis, %mode) +{ + if(%this.sizingOwnsGeometry(%mode) || %this.stashed[%axis] $= "") + { + return; + } + + %pos = %this.target.getPosition(); + %extent = %this.target.getExtent(); + if(%axis $= "h") + { + %pos = getWord(%this.stashPos[%axis], 0) SPC getWord(%pos, 1); + %extent = getWord(%this.stashExtent[%axis], 0) SPC getWord(%extent, 1); + } + else + { + %pos = getWord(%pos, 0) SPC getWord(%this.stashPos[%axis], 1); + %extent = getWord(%extent, 0) SPC getWord(%this.stashExtent[%axis], 1); + } + + // Through the fields, not setPosition/setExtent. "scale" does not recompute + // its proportion every layout -- relPosBatteryH caches it in + // mStoredRelativePosH and keeps using it until resetStoredRelPos runs, and + // the only things that call that are the Position and Extent field setters + // (guiControl.h setPositionFn / setExtentFn). Restoring with the console + // methods left the proportion captured while the control was filled, so the + // next layout stretched it straight back out again. + %this.writeField("Position", %pos); + %this.writeField("Extent", %extent); + %this.clearStash(%axis); +} + +function GuiEditorInspectorPane::clearStash(%this, %axis) +{ + %this.stashed[%axis] = ""; + %this.stashPos[%axis] = ""; + %this.stashExtent[%axis] = ""; +} + +// Position and extent move without the pane being rebuilt -- dragging a control +// on the canvas ends in an Edit event -- so this is the cheap path that reloads +// values and touches nothing else. +function GuiEditorInspectorPane::refreshGeometry(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + %this.populating = true; + %this.header.positionRow.setValue(%this.target.getPosition()); + %this.header.extentRow.setValue(%this.target.getExtent()); + %this.populating = false; +} + +function GuiEditorInspectorPane::readField(%this, %field) +{ + // getFieldValue answers "" for a name in editor mode, where assignName + // stashes the name rather than registering it, so ask the object instead. + if(%field $= "name") + { + return %this.target.getName(); + } + return %this.target.getFieldValue(%field); +} + +function GuiEditorInspectorPane::writeField(%this, %field, %value) +{ + // Through the recorder, which does the setEditFieldValue itself. Every write + // the pane makes is an edit the user can take back, and routing them all + // through one place is what makes that true without each caller remembering + // to say so. + // + // (setEditFieldValue rather than a plain assignment brackets the write with + // inspectPreApply / inspectPostApply, which is what lets a control react to + // being re-profiled or resized from here.) + GuiEditor.undoRecorder.writeField(%this.target, %field, %value); +} + +function GuiEditorInspectorPane::refreshToggles(%this) +{ + %header = %this.header; + + // hidden and locked are not here. They are editor working state, never + // written to the document, and they live in the Explorer tree's two columns. + %header.visibleButton.setValue(%this.target.Visible); + %header.activeButton.setValue(%this.target.Active); + %header.inputButton.setValue(%this.target.useInput); + %header.containerButton.setValue(%this.target.isContainer); + + %header.refreshWindowToggles(%this.target); +} + +// A segmented row in the header picked a value. Same contract as the toggles: +// the widget holds the choice, the pane does the writing. +function GuiEditorInspectorPane::onHeaderChoiceChanged(%this, %field, %value) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + %this.writeField(%field, %value); + %this.afterCommit(); +} + +// A toggle reports the click and the value it flipped itself to, and the pane +// does the writing -- so that every write to the control still goes through one +// place, and a new toggle needs no case of its own here. +function GuiEditorInspectorPane::onToggleChanged(%this, %field, %value) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + %this.writeField(%field, %value); + %this.refreshToggles(); + %this.afterCommit(); +} + +//----------------------------------------------------------------------------- +// Profile slots. A slot is a choice among the theme's members for its category, +// never a list of every profile in the sim -- which is what the native +// inspector offered, applied by name, silently failing for anything made this +// session (editor mode does not register names). +//----------------------------------------------------------------------------- + +// The narrow set, which decides whether a slot is worth showing at all: this +// theme's members of the slot's category, any standalone profile stamped for +// that category, and whatever the slot currently holds. An uncategorised +// standalone is deliberately absent -- one of those would otherwise make a +// Variants row appear on every slot of every control. +// +// The current value counts so that a slot wearing something the theme does not +// offer is visible and changeable rather than silently stuck. In the ordinary +// case it is already one of the theme's members and dedupes away. +function GuiEditorInspectorPane::profileAnchors(%this, %ctrl, %field) +{ + %category = %this.categoryForSlot(%ctrl, %field); + if(%category $= "") + { + return ""; + } + + return %this.profileAnchorsFor(%category, + GuiEditor.themeApplier.fieldProfile(%ctrl, %field)); +} + +// The same set for a category named outright. Changing a control's category has +// to ask what the category it is moving TO can offer, which it cannot get by +// asking the control -- the control still wears the old one. +function GuiEditorInspectorPane::profileAnchorsFor(%this, %category, %current) +{ + %library = GuiEditor.themeLibrary; + %theme = GuiEditor.themeByName(GuiEditor.themeName); + + %list = isObject(%theme) ? %theme.getProfiles(%category) : ""; + %list = %this.addUnique(%list, %library.getStandaloneProfiles(%category)); + + if(isObject(%current)) + { + %list = %this.addUnique(%list, %current); + } + + return %list; +} + +// The wider set, which is what the drop-down actually offers once it exists. +// "Any" means usable as any control's main profile, not usable in any slot. +function GuiEditorInspectorPane::profileOptions(%this, %ctrl, %field) +{ + %list = %this.profileAnchors(%ctrl, %field); + return %this.addUnique(%list, GuiEditor.themeLibrary.getStandaloneProfiles("")); +} + +function GuiEditorInspectorPane::categoryForSlot(%this, %ctrl, %field) +{ + return GuiEditor.themeApplier.categoryForField(%field, %this.currentCategory(%ctrl)); +} + +//----------------------------------------------------------------------------- +// The control's own category. For every class but one this is the class's +// answer and there is nothing to ask: a check box wants a CheckBox profile. +// +// A bare GuiControl is the exception. The applier guesses at drop time from +// what the control holds -- root, or text, or neither -- and typing a caption +// afterwards re-runs nothing, so the guess needs to be correctable. What makes +// that work with no new state is that a profile carries the category it was +// stamped for: the control wearing a Label profile IS the record that it was +// told to be a Label, and it survives being saved, reloaded, and re-themed +// (GuiEditorThemeApplier::applyToControl carries it across a theme switch). +// +// Only a category the class actually offers counts. Anything else -- a +// hand-written Gui wearing a ListBox profile on a plain GuiControl -- falls +// back to the guess, and the profile it holds still shows up as a candidate. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::currentCategory(%this, %ctrl) +{ + %applier = GuiEditor.themeApplier; + %isRoot = (%ctrl.getParent() == %applier.rootContainer); + %guess = %applier.categoryForControl(%ctrl, %isRoot); + + %choices = %this.spec.categoryChoices(%ctrl.getClassName()); + if(%choices $= "") + { + return %guess; + } + + %current = %applier.fieldProfile(%ctrl, "Profile"); + if(isObject(%current)) + { + %match = %this.spec.matchCategory(%choices, %current.category); + if(%match !$= "") + { + return %match; + } + } + + return %guess; +} + +// Move the control onto a category: its main profile becomes that category's, +// so the picker and the profile can never disagree. The theme's own member +// first, then any standalone stamped for the category, and if the category is +// empty the list is refilled and the profile left alone rather than cleared. +function GuiEditorInspectorPane::setCategory(%this, %category) +{ + if(!isObject(%this.target) || %category $= "") + { + return; + } + + %theme = GuiEditor.themeByName(GuiEditor.themeName); + %profile = isObject(%theme) ? %theme.getProfile(%category) : 0; + if(!isObject(%profile)) + { + %profile = getWord(%this.profileAnchorsFor(%category, 0), 0); + } + + if(isObject(%profile)) + { + // By id, not by name: a profile made this session carries a name the Sim + // never registered, because the editor runs with assignName stashing + // names rather than adding them. + GuiEditor.undoRecorder.begin("Change Category", ""); + %this.writeField("Profile", %profile.getId()); + GuiEditor.undoRecorder.end(); + } + + // Everything downstream of the profile moves with it -- which profiles the + // slot now offers, and the font color the swatch falls back to. + %this.refresh(); + %this.afterCommit(); +} + +function GuiEditorInspectorPane::addUnique(%this, %list, %additions) +{ + %count = getWordCount(%additions); + for(%i = 0; %i < %count; %i++) + { + %item = getWord(%additions, %i); + if(%item $= "" || %this.spec.listHas(%list, %item)) + { + continue; + } + %list = (%list $= "") ? %item : (%list SPC %item); + } + return %list; +} + +// Fill every profile drop-down -- the header's Profile and any Variants row -- +// with its candidates and select what the control wears. +function GuiEditorInspectorPane::refreshProfileChoices(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + if(%this.header.categoryRow.isVisible()) + { + %this.fillCategoryRow(); + } + + if(%this.header.profileRow.isVisible()) + { + %this.fillProfileRow(%this.header.profileRow, "Profile"); + } + + %slots = %this.panelFields["Variants"]; + %count = getWordCount(%slots); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%slots, %i); + %row = %this.row[%field]; + if(!isObject(%row)) + { + continue; + } + + if(%this.isCursorSlot(%field)) + { + %this.fillCursorRow(%row, %field); + } + else + { + %this.fillProfileRow(%row, %field); + } + } +} + +// A cursor slot's candidates, by name, exactly as a profile slot's: the name is +// the label and the commit looks it back up, because a cursor created this +// session has a name the Sim may not resolve. +function GuiEditorInspectorPane::fillCursorRow(%this, %row, %field) +{ + %ids = %this.cursorAnchors(%this.target, %field); + %items = ""; + %count = getWordCount(%ids); + for(%i = 0; %i < %count; %i++) + { + %cursor = getWord(%ids, %i); + %name = %cursor.getName(); + if(%name $= "") + { + continue; + } + %items = (%items $= "") ? %name : (%items TAB %name); + %this.cursorByName[%name] = %cursor; + } + + %row.currentItem = ""; + %row.fillItems(%items); + + %current = GuiEditor.themeApplier.fieldCursor(%this.target, %field); + %row.setValue(isObject(%current) ? %current.getName() : ""); +} + +// The categories this class may take, and which one it is on. Unlike a profile +// row these are plain strings, so the name in the list is the value. +function GuiEditorInspectorPane::fillCategoryRow(%this) +{ + %row = %this.header.categoryRow; + %choices = %this.spec.categoryChoices(%this.boundClass); + + %items = ""; + %count = getWordCount(%choices); + for(%i = 0; %i < %count; %i++) + { + %item = getWord(%choices, %i); + %items = (%items $= "") ? %item : (%items TAB %item); + } + + // Same reason as a profile row: the list is recomputed from scratch on every + // bind, so a selection the new list does not hold is a ghost of the last one + // rather than something worth preserving. + %row.currentItem = ""; + %row.fillItems(%items); + %row.setValue(%this.currentCategory(%this.target)); +} + +// One slot's candidates, listed by name. The name is only a label: a commit +// looks the choice back up and writes the id, because a profile made during +// this editor session has a name the Sim cannot resolve. +function GuiEditorInspectorPane::fillProfileRow(%this, %row, %field) +{ + %ids = %this.profileOptions(%this.target, %field); + %items = ""; + %count = getWordCount(%ids); + for(%i = 0; %i < %count; %i++) + { + %profile = getWord(%ids, %i); + %name = %profile.getName(); + if(%name $= "") + { + continue; + } + %items = (%items $= "") ? %name : (%items TAB %name); + %this.profileByName[%name] = %profile; + } + + // Forget what the row was showing before refilling it. fillItems deliberately + // preserves the current selection and re-inserts it when the new list does + // not contain it -- right for the Profile Editor's font list, where a face + // outside the directory must not be silently dropped, but wrong here: the + // candidates are recomputed from scratch every bind, so a name that is no + // longer one of them is a ghost of the last fill. It is how a profile from + // the previous theme survived a Set Theme. + %row.currentItem = ""; + %row.fillItems(%items); + + %current = GuiEditor.themeApplier.fieldProfile(%this.target, %field); + %row.setValue(isObject(%current) ? %current.getName() : ""); +} + +//----------------------------------------------------------------------------- +// Commits. Every write to the control goes through here. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::onProfileRowCommit(%this, %row) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + %field = %row.fieldName; + + // The font color swatch is two fields, and the second of them is why the + // changed test cannot come first: picking the color the profile already + // draws in is a real edit when the override was off. + if(%field $= "fontColor") + { + %this.commitFontColor(%row); + return; + } + + // The text box has been writing to the control on every keystroke, so the + // changed test cannot come first here either -- by now the control already + // says what the row says. + if(%field $= "text") + { + %this.commitText(%row); + return; + } + + // A text box commits on blur, so most commits arrive from a field the user + // only tabbed through. Writing one anyway would put an edit in the undo + // record -- and mark the Gui dirty -- for something that never happened. + if(!%row.hasChanged()) + { + return; + } + + // Category names no field on the control. It picks the control's Profile, + // and has to be intercepted here or writeField would put a dynamic field + // called "category" on it. + if(%field $= "category") + { + %row.markClean(); + %this.setCategory(%row.getValue()); + return; + } + + // A profile slot is chosen by name in the list but written by id: a profile + // created this session carries a name the Sim cannot resolve, because the + // editor runs with assignName stashing names rather than registering them. + // GuiEditorThemeApplier::applyToControl writes ids for exactly this reason. + if(%this.isProfileSlot(%field)) + { + %profile = %this.profileByName[%row.getValue()]; + if(isObject(%profile)) + { + %this.writeField(%field, %profile.getId()); + } + } + else if(%this.isCursorSlot(%field)) + { + %cursor = %this.cursorByName[%row.getValue()]; + if(isObject(%cursor)) + { + %this.writeField(%field, %cursor.getId()); + } + } + else + { + %this.writeField(%field, %row.getValue()); + } + + %row.markClean(); + + // Changing a sprite's source swaps which fields the header shows, which + // means deleting the row this commit arrived from. Deferred to the next + // tick so the engine is not mid-dispatch on a control being freed. + if(%this.boundClass $= "GuiSpriteCtrl" && %this.isSpriteSourceField(%field)) + { + %this.schedule(0, "rebindDeferred"); + } + + %this.afterCommit(); +} + +function GuiEditorInspectorPane::isProfileSlot(%this, %field) +{ + return isObject(%this.target) && + %this.target.getFieldType(%field) $= "GuiProfile"; +} + +function GuiEditorInspectorPane::isCursorSlot(%this, %field) +{ + return isObject(%this.target) && + %this.target.getFieldType(%field) $= "GuiCursor"; +} + +function GuiEditorInspectorPane::isSpriteSourceField(%this, %field) +{ + return %this.spec.listHas("Image Animation Bitmap", %field); +} + +function GuiEditorInspectorPane::rebindDeferred(%this) +{ + if(isObject(%this.target)) + { + %this.bind(%this.target); + } +} + +//----------------------------------------------------------------------------- +// Text, which reaches the control twice: once per keystroke so the canvas keeps +// up, and once here so the change is recorded as the single edit it was. +// +// The pair matters because a field write is the editor's unit of change -- +// inspectPreApply / inspectPostApply, and whatever an undo record is eventually +// hung on. Eleven keystrokes are one edit, not eleven, so the control is put +// back to what it held when the first key landed and the new value written over +// it once. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::commitText(%this, %row) +{ + %block = %this.activeTextBlock(); + + // Nobody typed: an ordinary commit, from a box the user tabbed through or a + // value that arrived from script. Or the selection moved while an edit was + // open, in which case the stash belongs to a control this row is no longer + // showing -- the typed text is already on it, so there is nothing to save. + if(!isObject(%block) || !%block.typing || %block.typingTarget != %this.target) + { + if(%row.hasChanged()) + { + %this.writeField("text", %row.getValue()); + %row.markClean(); + %this.afterCommit(); + } + if(isObject(%block)) + { + %block.endTyping(); + } + return; + } + + %value = %row.getValue(); + %before = %block.textBeforeEdit; + %block.endTyping(); + %row.markClean(); + + // strcmp, not $=: $= runs dStricmp, so retyping a caption in a different + // case would read as no change at all. + if(strcmp(%value, %before) == 0) + { + return; + } + + %this.target.text = %before; + %this.writeField("text", %value); + %this.afterCommit(); +} + +//----------------------------------------------------------------------------- +// Font color, which is two fields wearing one widget. overrideFontColor is +// what decides whether fontColor is used at all (guiControl.cc renderText), and +// on its own it is a checkbox that does nothing visible -- so the swatch is +// both: picking a color turns the override on, and the row's reset button +// turns it off again. With it off the swatch shows the profile's own color, +// so the row always says what the control will actually draw in. +//----------------------------------------------------------------------------- + +function GuiEditorInspectorPane::commitFontColor(%this, %row) +{ + // Nothing to do only when the color is unchanged AND the override was + // already on. Picking the profile's exact color while it was off is the + // user asking to pin that color down, which is a change to the control + // even though the swatch looks the same. + if(!%row.hasChanged() && %this.target.overrideFontColor) + { + return; + } + + // Two fields, one edit. + GuiEditor.undoRecorder.begin("Change Font Color", ""); + %this.writeField("fontColor", %row.getValue()); + %this.writeField("overrideFontColor", true); + GuiEditor.undoRecorder.end(); + + %row.markClean(); + %row.setOverridden(true); + %this.afterCommit(); +} + +// The row widget's reset means "back to the theme's stamped value" everywhere +// 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) +{ + if(%row.fieldName !$= "fontColor" || !isObject(%this.target)) + { + return; + } + + %this.writeField("overrideFontColor", false); + + %block = %this.activeTextBlock(); + if(isObject(%block)) + { + %this.populating = true; + %block.loadFontColor(%this.target); + %this.populating = false; + } + + %this.afterCommit(); +} + +// The two flags in the text block's caption row. They change the control's size +// rather than only its look -- textExtend grows the width when wrap is off and +// the height when it is on -- so the geometry rows are stale the moment either +// is written. +function GuiEditorInspectorPane::onTextFlagChanged(%this, %field, %value) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + %this.writeField(%field, %value); + %this.refreshGeometry(); + %this.afterCommit(); +} + +// Everything a write has to tell the rest of the editor. The canvas has to +// redraw and the explorer tree may be showing the name that just changed. +function GuiEditorInspectorPane::afterCommit(%this) +{ + if(isObject(%this.window)) + { + %this.window.onPaneCommit(%this.target); + } +} diff --git a/editor/GuiEditor/scripts/GuiEditorInspectorWindow.cs b/editor/GuiEditor/scripts/GuiEditorInspectorWindow.cs index fc26876f5..56a9c4d96 100644 --- a/editor/GuiEditor/scripts/GuiEditorInspectorWindow.cs +++ b/editor/GuiEditor/scripts/GuiEditorInspectorWindow.cs @@ -2,10 +2,12 @@ function GuiEditorInspectorWindow::onAdd(%this) { + // Fill rather than width/height -- the window's only child wants its whole + // content rect. See GuiEditorControlListWindow for why. %this.scroller = new GuiScrollCtrl() { - HorizSizing="width"; - VertSizing="height"; + HorizSizing="fill"; + VertSizing="fill"; Position="0 0"; Extent="352 354"; hScrollBar="alwaysOff"; @@ -20,45 +22,25 @@ ThemeManager.setProfile(%this.scroller, "scrollArrowProfile", "ArrowProfile"); %this.add(%this.scroller); - %this.inspector = new GuiInspector() + // The custom pane that replaced the native GuiInspector. The inspector is + // still a live C++ class -- the Asset Admin uses one -- but it has no place + // here: it reflected every registered field, which for a Gui control means + // offering fields the class provably never reads. + %this.pane = new GuiChainCtrl() { - Class = "GuiEditorInspector"; - HorizSizing="width"; - VertSizing="height"; - Position="0 0"; - Extent="338 354"; - FieldCellSize="288 40"; - ControlOffset="10 18"; - ConstantThumbHeight=false; - ScrollBarThickness=12; - ShowArrowButtons=true; + class = "GuiEditorInspectorPane"; + HorizSizing = "width"; + Position = "0 0"; + Extent = "338 354"; + IsVertical = true; + ChildSpacing = 6; + paneWidth = 338; + window = %this; }; - ThemeManager.setProfile(%this.inspector, "emptyProfile"); - ThemeManager.setProfile(%this.inspector, "panelProfile", "GroupPanelProfile"); - ThemeManager.setProfile(%this.inspector, "emptyProfile", "GroupGridProfile"); - ThemeManager.setProfile(%this.inspector, "labelProfile", "LabelProfile"); - ThemeManager.setProfile(%this.inspector, "overrideLabelProfile", "OverrideLabelProfile"); - ThemeManager.setProfile(%this.inspector, "textEditProfile", "textEditProfile"); - ThemeManager.setProfile(%this.inspector, "dropDownProfile", "dropDownProfile"); - ThemeManager.setProfile(%this.inspector, "dropDownItemProfile", "dropDownItemProfile"); - ThemeManager.setProfile(%this.inspector, "emptyProfile", "backgroundProfile"); - ThemeManager.setProfile(%this.inspector, "scrollingPanelProfile", "ScrollProfile"); - ThemeManager.setProfile(%this.inspector, "scrollingPanelThumbProfile", "ThumbProfile"); - ThemeManager.setProfile(%this.inspector, "scrollingPanelTrackProfile", "TrackProfile"); - ThemeManager.setProfile(%this.inspector, "scrollingPanelArrowProfile", "ArrowProfile"); - ThemeManager.setProfile(%this.inspector, "checkboxProfile", "checkboxProfile"); - ThemeManager.setProfile(%this.inspector, "buttonProfile", "buttonProfile"); - ThemeManager.setProfile(%this.inspector, "tipProfile", "tooltipProfile"); - ThemeManager.setProfile(%this.inspector, "colorPickerProfile", "colorPopupProfile"); - ThemeManager.setProfile(%this.inspector, "colorPopupProfile", "colorPopupPanelProfile"); - ThemeManager.setProfile(%this.inspector, "emptyProfile", "colorPopupPickerProfile"); - ThemeManager.setProfile(%this.inspector, "colorPickerSelectorProfile", "colorPopupSelectorProfile"); - %this.scroller.add(%this.inspector); + %this.scroller.add(%this.pane); + %this.pane.build(); %this.inspectList = new SimSet(); - - //%this.inspector.addHiddenField("isContainer"); - %this.inspector.addHiddenField("BindToGuiEditor"); } function GuiEditorInspectorWindow::onRemove(%this) @@ -70,9 +52,60 @@ } } +// Edit arrives after every drag and every resize on the canvas, not only on a +// fresh selection (guiEditCtrl.cc calls it from the mouse-up that ends both). +// Rebinding on each of those would rebuild the class sections while the user is +// still dragging, so an Edit for the control already bound reloads geometry and +// nothing else. function GuiEditorInspectorWindow::onEdit(%this, %object) { - %this.inspector.inspect(%object); + if(isObject(%object) && %object == %this.pane.target) + { + %this.pane.refreshGeometry(); + return; + } + %this.pane.bind(%object); +} + +// Something re-profiled the control under the pane: a fresh drop being themed +// on arrival, or Set Theme sweeping the whole document. The pane caches what it +// found -- which slots were worth a Variants row, and what each drop-down +// offers -- so a re-profile behind its back leaves it describing the control +// the way it used to be. +// +// This is what a dropped control needs, because it is announced (and inspected) +// wearing its constructor's profiles and only themed afterwards. +function GuiEditorInspectorWindow::onRethemed(%this, %object) +{ + if(isObject(%this.pane.target)) + { + %this.pane.bind(%this.pane.target); + } +} + +// An undo or a redo rewrote the control the pane is already showing. Every row +// is stale, not just the geometry ones -- a replayed step can put back a +// caption, a toggle or a profile -- so this re-reads them all. bind() would do +// that too, but by rebuilding the pane from scratch, and the control has not +// changed: only its values have. +function GuiEditorInspectorWindow::onReplayed(%this) +{ + if(isObject(%this.pane.target)) + { + %this.pane.refresh(); + } +} + +// Reparenting changes which geometry fields the control is allowed to edit -- +// a chain, grid, frame set or tab book writes its children's bounds itself -- +// so the pane has to re-evaluate against the new parent. The brain has always +// posted this event; nothing listened to it until now. +function GuiEditorInspectorWindow::onParentChange(%this, %parent) +{ + if(isObject(%this.pane.target)) + { + %this.pane.bind(%this.pane.target); + } } function GuiEditorInspectorWindow::onClearInspect(%this, %object) @@ -83,7 +116,7 @@ %count = %this.inspectList.getCount(); if(%count > 0) { - %this.inspector.inspect(%this.inspectList.getObject(%count - 1)); + %this.pane.bind(%this.inspectList.getObject(%count - 1)); } } } @@ -91,13 +124,20 @@ function GuiEditorInspectorWindow::onClearInspectAll(%this) { %this.inspectList.clear(); - %this.inspector.clear(); + %this.pane.unbind(); } function GuiEditorInspectorWindow::onAlsoInspect(%this, %object) { %this.inspectList.add(%object); - %this.inspector.inspect(%object); + %this.pane.bind(%object); +} + +// A write landed on the selected control. The canvas has to redraw, and the +// explorer tree may be showing a name that just changed. +function GuiEditorInspectorWindow::onPaneCommit(%this, %object) +{ + %this.postEvent("PostApply", %object); } function GuiEditorInspectorWindow::onObjectRemoved(%this) diff --git a/editor/GuiEditor/scripts/GuiEditorItemRow.cs b/editor/GuiEditor/scripts/GuiEditorItemRow.cs new file mode 100644 index 000000000..5dc24f08c --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorItemRow.cs @@ -0,0 +1,349 @@ + +//----------------------------------------------------------------------------- +// One static row of a list box or a drop down, on one line of the Items +// section. +// +// [ Easy ][ 1 ][c][#][o][*][^][v][X] +// +// caption, ID, "show color", the color, active, starts selected, move up, move +// down, remove. +// +// Show color, not "own color": hasColor does not tint anything. The list draws a +// small colored bullet in front of the caption and indents the text past it +// (GuiListBoxCtrl::onRenderItem, renderColorBullet) - the caption itself is +// drawn in the profile's font color either way. So the switch is about whether +// the dot is there at all. +// +// Nine controls rather than the eight the row looks like it needs, because that +// dot is two values: hasColor and color. Nothing can be read off a swatch alone +// -- a swatch always holds SOME color -- so the toggle says whether there is a +// dot and the swatch says what color it is, dead until the toggle is on. The +// alternative was to read a transparent swatch as "no dot", which would have +// meant picking a hue did nothing until the alpha was raised as well. +// +// The row owns its widgets and no values. It speaks in the same TAB-separated +// records GuiListBoxCtrl::getItemList writes, so the block above it can join +// what its rows say and hand the lot back without translating anything. +// +// The creator sets owner inline and calls build() once AFTER adding the row to +// its cell, because the cell is what decides how wide it is. Everything it does +// reports to owner: +// +// onItemRowTyped a caption keystroke - live, and not yet an undo step +// onItemRowCommit a box lost focus or took Enter +// onItemRowToggled a switch flipped +// onItemRowMove the up or down arrow, with -1 or 1 +// onItemRowRemove the bin +//----------------------------------------------------------------------------- + +function GuiEditorItemRow::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +function GuiEditorItemRow::build(%this) +{ + // The width the row actually has, not the one it was created with: the grid + // sizes a cell the moment it is added, which is before build() runs, so + // laying out against the nominal width would place every widget for a + // 338-wide row inside a cell the grid had already made narrower - and the + // icons at the right-hand end would sit off the edge of the pane. Sizing + // flags take it from here. + %w = getWord(%this.getExtent(), 0); + %pad = 4; + %gap = 2; + %iconW = 24; + %swatchW = 26; + %idW = 34; + %boxH = 22; + + // Everything but the caption is a fixed size, so the group is measured once + // and the caption takes what is left. They all carry "left" sizing, which + // keeps them against the right edge as the Properties frame is dragged wider + // and gives the caption the slack. + %groupW = %idW + %swatchW + (%iconW * 6) + (%gap * 7); + %groupX = %w - %pad - %groupW; + %captionW = %groupX - %pad - %gap; + + %this.setExtent(%w, 26); + + %this.captionBox = new GuiTextEditCtrl() + { + HorizSizing = "width"; + Position = %pad SPC 2; + Extent = %captionW SPC %boxH; + Tooltip = "What this row says."; + }; + ThemeManager.setProfile(%this.captionBox, "textEditProfile"); + ThemeManager.setProfile(%this.captionBox, "tipProfile", "TooltipProfile"); + // Command is per keystroke - GuiTextEditCtrl runs it on every edit to its + // buffer - which is what makes the row appear on the canvas as it is typed. + // AltCommand is the blur and ReturnCommand the Enter, and those are the two + // that make an undo step. + %this.captionBox.Command = %this.getID() @ ".onCaptionTyped();"; + %this.captionBox.AltCommand = %this.getID() @ ".onCommit();"; + %this.captionBox.ReturnCommand = %this.getID() @ ".onCommit();"; + %this.add(%this.captionBox); + + %x = %groupX; + + %this.idBox = new GuiTextEditCtrl() + { + HorizSizing = "left"; + Position = %x SPC 2; + Extent = %idW SPC %boxH; + align = "center"; + inputMode = "Number"; + Tooltip = "A number script can find this row by, with findItemID. Rows that nothing looks up can all be left at zero."; + }; + ThemeManager.setProfile(%this.idBox, "textEditProfile"); + ThemeManager.setProfile(%this.idBox, "tipProfile", "TooltipProfile"); + %this.idBox.AltCommand = %this.getID() @ ".onCommit();"; + %this.idBox.ReturnCommand = %this.getID() @ ".onCommit();"; + %this.add(%this.idBox); + %x += %idW + %gap; + + %this.colorToggle = %this.makeToggle(%x, "color", "Show color", + "", $EditorIcon::brush, + "Draws a colored dot in front of the caption, and moves the caption over to make room for it.", + "No dot. The caption starts at the edge of the row."); + %x += %iconW + %gap; + + %this.swatch = new GuiColorPopupCtrl() + { + class = "GuiProfileEditorColorPopup"; + HorizSizing = "left"; + Position = %x SPC 2; + Extent = %swatchW SPC %boxH; + showColorValues = true; + Tooltip = "The color of the dot."; + }; + ThemeManager.setProfile(%this.swatch, "colorPickerProfile"); + ThemeManager.setProfile(%this.swatch, "emptyProfile", "backgroundProfile"); + ThemeManager.setProfile(%this.swatch, "colorPopupProfile", "popupProfile"); + ThemeManager.setProfile(%this.swatch, "emptyProfile", "pickerProfile"); + ThemeManager.setProfile(%this.swatch, "colorPickerSelectorProfile", "selectorProfile"); + ThemeManager.setProfile(%this.swatch, "textEditProfile", "valueProfile"); + ThemeManager.setProfile(%this.swatch, "tipProfile", "TooltipProfile"); + %this.swatch.Command = %this.getID() @ ".onCommit();"; + %this.add(%this.swatch); + %x += %swatchW + %gap; + + %this.activeToggle = %this.makeToggle(%x, "active", "Active", + $EditorIcon::on, $EditorIcon::off, + "Can be picked when the game runs.", + "Drawn greyed out and cannot be picked. Use it for a choice that is there but not available yet."); + %x += %iconW + %gap; + + %this.selectedToggle = %this.makeToggle(%x, "selected", "Starts selected", + $EditorIcon::round_checkmark, $EditorIcon::round, + "Already picked when the Gui loads. A drop down shows this row instead of its placeholder text.", + "Not picked when the Gui loads."); + %x += %iconW + %gap; + + %this.upButton = %this.makeIconButton(%x, $EditorIcon::sq_up, + "Move this row up.", ".onMoveUp();"); + %x += %iconW + %gap; + + %this.downButton = %this.makeIconButton(%x, $EditorIcon::sq_down, + "Move this row down.", ".onMoveDown();"); + %x += %iconW + %gap; + + %this.removeButton = %this.makeIconButton(%x, $EditorIcon::trash, + "Remove this row.", ".onRemoveClicked();"); +} + +// A checkbox wearing an icon, the same GuiEditorToggleIcon 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"; + HorizSizing = "left"; + Position = %x SPC 1; + Extent = "24 24"; + frameOn = %frameOn; + frameOff = %frameOff; + tipOn = %tipOn; + tipOff = %tipOff; + toggleName = %name; + toggleLabel = %label; + owner = %this; + }; + ThemeManager.setProfile(%toggle, "iconButtonProfile"); + ThemeManager.setProfile(%toggle, "tipProfile", "TooltipProfile"); + %this.add(%toggle); + + return %toggle; +} + +function GuiEditorItemRow::makeIconButton(%this, %x, %frame, %tip, %command) +{ + %button = new GuiButtonCtrl() + { + class = "EditorIconButton"; + HorizSizing = "left"; + Frame = %frame; + Position = %x SPC 1; + Extent = "24 24"; + Tooltip = %tip; + Command = %this.getID() @ %command; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + %this.add(%button); + + return %button; +} + +//----------------------------------------------------------------------------- +// Values, in the records GuiListBoxCtrl::getItemList speaks: +// +// text ID active selected hasColor "r g b a" +//----------------------------------------------------------------------------- + +function GuiEditorItemRow::setRecord(%this, %record) +{ + %this.populating = true; + + %this.captionBox.setText(getField(%record, 0)); + %this.idBox.setText(getField(%record, 1) + 0); + %this.activeToggle.setValue(getField(%record, 2)); + %this.selectedToggle.setValue(getField(%record, 3)); + + %hasColor = getField(%record, 4); + %this.colorToggle.setValue(%hasColor); + + // A row showing no dot still needs something in the swatch, or turning the + // toggle on would give it whatever was there before. The profile's font color + // is the useful default: it is the one color the list is already known to be + // legible in, so the first dot lands visible rather than black on black. + %color = getField(%record, 5); + if(!%hasColor || getWordCount(%color) < 4) + { + %color = %this.defaultBulletColor(); + } + %this.swatch.setColorF(%color); + + %this.populating = false; + %this.refreshColorState(); + + %this.lastRecord = %this.getRecord(); +} + +function GuiEditorItemRow::getRecord(%this) +{ + %hasColor = %this.colorToggle.getValue() ? 1 : 0; + + return %this.captionBox.getText() TAB + (%this.idBox.getText() + 0) TAB + (%this.activeToggle.getValue() ? 1 : 0) TAB + (%this.selectedToggle.getValue() ? 1 : 0) TAB + %hasColor TAB + %this.swatch.getColorF(); +} + +function GuiEditorItemRow::hasChanged(%this) +{ + return strcmp(%this.getRecord(), %this.lastRecord) != 0; +} + +function GuiEditorItemRow::markClean(%this) +{ + %this.lastRecord = %this.getRecord(); +} + +// What a dot starts as before anyone picks a color for it: the color the list +// draws its captions in, which is the one color the profile guarantees reads +// against its own background. +function GuiEditorItemRow::defaultBulletColor(%this) +{ + %ctrl = isObject(%this.owner) ? %this.owner.target : ""; + if(isObject(%ctrl)) + { + %profile = %ctrl.getFieldValue("Profile"); + if(isObject(%profile)) + { + return %profile.fontColor; + } + } + + return "1 1 1 1"; +} + +// The swatch means nothing while there is no dot to color, so it says so rather +// than sitting there looking editable. +function GuiEditorItemRow::refreshColorState(%this) +{ + %this.swatch.setActive(%this.colorToggle.getValue()); +} + +function GuiEditorItemRow::setCaretHere(%this) +{ + %this.captionBox.setFirstResponder(); +} + +//----------------------------------------------------------------------------- +// Reporting. Nothing here writes to the control; the block owns every write, so +// it stays the only thing that knows the list as a whole. +//----------------------------------------------------------------------------- + +function GuiEditorItemRow::onCaptionTyped(%this) +{ + if(%this.populating || !isObject(%this.owner)) + { + return; + } + + %this.owner.onItemRowTyped(%this); +} + +function GuiEditorItemRow::onCommit(%this) +{ + if(%this.populating || !isObject(%this.owner)) + { + return; + } + + %this.owner.onItemRowCommit(%this); +} + +function GuiEditorItemRow::onToggleIconChanged(%this, %toggle) +{ + if(%this.populating || !isObject(%this.owner)) + { + return; + } + + if(%toggle.toggleName $= "color") + { + %this.refreshColorState(); + } + + %this.owner.onItemRowToggled(%this, %toggle.toggleName); +} + +function GuiEditorItemRow::onMoveUp(%this) +{ + if(isObject(%this.owner)) + { + %this.owner.onItemRowMove(%this, -1); + } +} + +function GuiEditorItemRow::onMoveDown(%this) +{ + if(isObject(%this.owner)) + { + %this.owner.onItemRowMove(%this, 1); + } +} + +function GuiEditorItemRow::onRemoveClicked(%this) +{ + if(isObject(%this.owner)) + { + %this.owner.onItemRowRemove(%this); + } +} diff --git a/editor/GuiEditor/scripts/GuiEditorItemsBlock.cs b/editor/GuiEditor/scripts/GuiEditorItemsBlock.cs new file mode 100644 index 000000000..13258174e --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorItemsBlock.cs @@ -0,0 +1,509 @@ + +//----------------------------------------------------------------------------- +// The Items section of the Gui Editor's properties pane: the static rows a list +// box or a drop down is authored with. +// +// Until this existed the rows were script's alone - addItem, from an onWake +// somewhere away from the Gui that shows them - and a list laid out in the +// editor was an empty rectangle. A row typed here appears on the canvas as it is +// typed and is saved with the Gui. +// +// The block edits the list as a WHOLE. It reads it in one getItemList and writes +// it back in one setItemList, and its rows are widgets over the records that +// call returns. That is not laziness: every gesture on offer - add, remove, move +// up, move down, retype a caption - shifts what is around the row it touched, so +// a per-row write would have to know which of them it was, and an undo of one +// would have to know what the others became. A list is short. +// +// It is built ONCE, in GuiEditorInspectorPane::build, and shown or hidden per +// class - the arrangement GuiEditorDynamicFields uses, and for the reason the +// pane's header comment gives: a block rebuilt on a selection change can delete +// a control the engine is mid-dispatch on. The rows inside it are rebuilt, but +// always from a schedule(0), because the click that removes or moves one arrives +// from a button inside the row that is about to be freed. +// +// Not GuiTreeViewCtrl, though it derives from GuiListBoxCtrl: a tree generates +// its rows from a root object. See GuiEditorControlSpec::hasItemList. +// +// The creator sets pane and blockWidth inline, then calls build() once after +// adding it. +//----------------------------------------------------------------------------- + +function GuiEditorItemsBlock::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +function GuiEditorItemsBlock::build(%this) +{ + %this.typing = false; + %this.listBeforeEdit = ""; + %this.pendingFocus = false; + + // The rows in a grid rather than a chain of their own, which is what every + // other section in this pane uses and the only thing that lays out correctly + // inside a collapsible panel: a chain sizes itself from its children, so a + // chain of full-width rows inside a chain inside a panel had each level + // widening the one above it, a bit per layout pass, until the row ran off the + // edge of the pane. A grid sizes its children from ITSELF. + // + // One column, because a row is a line. MaxColCount is the only thing that + // says so: left to fit as many columns as it can, the grid would put two + // short rows side by side the moment the Properties frame was dragged wide. + %this.grid = %this.pane.makeCellGrid(0); + %this.grid.MaxColCount = 1; + %this.grid.CellSizeY = 26; + %this.add(%this.grid); + + %this.buildAddRow(); +} + +// A caption box and an Add button, the shape GuiEditorDynamicFields uses. Unlike +// a dynamic field, a row with an empty caption is a legal row - a separator, or +// one whose text a script fills in later - so Add appends whatever the box holds +// and puts the caret in the new row. +function GuiEditorItemsBlock::buildAddRow(%this) +{ + %w = %this.blockWidth; + %buttonW = 56; + + %row = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 30; + }; + ThemeManager.setProfile(%row, "emptyProfile"); + %this.add(%row); + %this.addRow = %row; + + %this.nameBox = new GuiTextEditCtrl() + { + HorizSizing = "width"; + Position = "4 4"; + Extent = (%w - %buttonW - 16) SPC 22; + Tooltip = "What a new row should say. It can be left empty and typed in afterwards."; + }; + ThemeManager.setProfile(%this.nameBox, "textEditProfile"); + ThemeManager.setProfile(%this.nameBox, "tipProfile", "TooltipProfile"); + %this.nameBox.ReturnCommand = %this.getID() @ ".onAddClicked();"; + %row.add(%this.nameBox); + + %this.addButton = new GuiButtonCtrl() + { + HorizSizing = "left"; + Position = (%w - %buttonW - 4) SPC 4; + Extent = %buttonW SPC 22; + Text = "Add"; + Command = %this.getID() @ ".onAddClicked();"; + }; + ThemeManager.setProfile(%this.addButton, "buttonProfile"); + %row.add(%this.addButton); +} + +//----------------------------------------------------------------------------- +// Binding. +//----------------------------------------------------------------------------- + +function GuiEditorItemsBlock::bind(%this, %ctrl) +{ + // A half-typed caption belongs to the control it was typed on. Moving the + // selection abandons it; the control already holds every keystroke. + %this.typing = false; + %this.listBeforeEdit = ""; + + %this.target = %ctrl; + %this.rebuildRows(); +} + +// Re-read what the control holds without rebuilding, where the rows still line +// up with it. This is the undo and redo path (GuiEditorInspectorWindow:: +// onReplayed): a replay can put back a caption, a switch or the whole order, and +// only the last of those changes how many rows there are. +// +// Rebuilding from here would be re-entrant - refresh() is reached from inside a +// commit - so a count that no longer matches goes through the same schedule(0) +// everything else does. +function GuiEditorItemsBlock::refresh(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + %list = %this.target.getItemList(); + %count = (%list $= "") ? 0 : getRecordCount(%list); + + if(%count != %this.grid.getCount()) + { + %this.rebuildDeferred(); + return; + } + + for(%i = 0; %i < %count; %i++) + { + %this.grid.getObject(%i).setRecord(getRecord(%list, %i)); + } + + %this.refreshArrows(); +} + +function GuiEditorItemsBlock::rebuildRows(%this) +{ + %this.grid.deleteObjects(); + + if(isObject(%this.target)) + { + %list = %this.target.getItemList(); + %count = (%list $= "") ? 0 : getRecordCount(%list); + for(%i = 0; %i < %count; %i++) + { + %this.makeRow(getRecord(%list, %i)); + } + } + + %this.refreshArrows(); +} + +function GuiEditorItemsBlock::makeRow(%this, %record) +{ + %row = new GuiControl() + { + class = "GuiEditorItemRow"; + HorizSizing = "width"; + Position = "0 0"; + Extent = %this.blockWidth SPC 26; + owner = %this; + }; + + // Added before build(), because the grid sizes a cell the moment it takes + // one and the row lays itself out against the width it actually has. The + // nominal blockWidth above is only what it starts at. + %this.grid.add(%row); + %row.build(); + %row.setRecord(%record); + + return %row; +} + +// The first row cannot go up and the last cannot go down, so those two arrows +// say so rather than being clickable and doing nothing. +function GuiEditorItemsBlock::refreshArrows(%this) +{ + %count = %this.grid.getCount(); + for(%i = 0; %i < %count; %i++) + { + %row = %this.grid.getObject(%i); + %row.upButton.setActive(%i > 0); + %row.downButton.setActive(%i < (%count - 1)); + } +} + +// Deferred, always: a remove or a move arrives from a button inside the row that +// is about to be freed, and an add arrives from a button whose own Command is +// still running. +function GuiEditorItemsBlock::rebuildDeferred(%this) +{ + %this.schedule(0, "rebuildNow"); +} + +// Only the deferred path re-measures the section, never bind(): a bind ends with +// the pane's own forceLayout, and forceLayout nudges the pane's width by a pixel +// and back. An expanded GuiPanelCtrl takes the nudge up and does not give it +// back, so a second one in the same bind left the section a pixel wider than the +// pane every time a control was selected -- and the row's right-hand icons were +// what fell off the edge. +function GuiEditorItemsBlock::rebuildNow(%this) +{ + %this.rebuildRows(); + %this.notifyResized(); + + // A row added by the Add button is one the user is about to type into. + if(%this.pendingFocus) + { + %this.pendingFocus = false; + %count = %this.grid.getCount(); + if(%count > 0) + { + %this.grid.getObject(%count - 1).setCaretHere(); + } + } +} + +//----------------------------------------------------------------------------- +// The list, as the rows currently spell it. +//----------------------------------------------------------------------------- + +// What the ROWS say, for the three edits whose new value exists only in a widget: +// a retyped caption, an ID, a flipped switch. +// +// Read off the chain rather than an index of its own, so the order the rows are +// in and the order they are written in cannot disagree. +function GuiEditorItemsBlock::collect(%this) +{ + %list = ""; + %count = %this.grid.getCount(); + for(%i = 0; %i < %count; %i++) + { + %record = %this.grid.getObject(%i).getRecord(); + %list = (%i == 0) ? %record : (%list NL %record); + } + + return %list; +} + +// What the CONTROL says, which is what adding, removing and moving work from. +// +// Not collect(), because the rows lag: every one of those three ends in a +// rebuild that has to be deferred, so between the write and the next tick the +// chain still shows the list as it was. Two clicks on Add inside one frame read +// the same stale chain and the first row added was lost. The control is never +// stale - it was written before the rebuild was even scheduled - and a box the +// user was typing in has already committed on the way out of it, because taking +// focus is what AltCommand fires on. +function GuiEditorItemsBlock::currentList(%this) +{ + return isObject(%this.target) ? %this.target.getItemList() : ""; +} + +// Whether the chain can still be trusted to say which row is which. False only +// inside the window a deferred rebuild has not closed yet, where an index into +// the chain would name the wrong record. +function GuiEditorItemsBlock::rowsAreCurrent(%this) +{ + %list = %this.currentList(); + %count = (%list $= "") ? 0 : getRecordCount(%list); + + return %count == %this.grid.getCount(); +} + +function GuiEditorItemsBlock::indexOf(%this, %row) +{ + %count = %this.grid.getCount(); + for(%i = 0; %i < %count; %i++) + { + if(%this.grid.getObject(%i) == %row) + { + return %i; + } + } + + return -1; +} + +// One write, one undo step. The recorder does the writing, so a write that was +// not recorded is a write that did not happen. +function GuiEditorItemsBlock::writeList(%this, %list, %name) +{ + if(!isObject(%this.target)) + { + return; + } + + GuiEditor.undoRecorder.begin(%name, ""); + GuiEditor.undoRecorder.writeItems(%this.target, %list); + GuiEditor.undoRecorder.end(); + + %this.pane.afterCommit(); +} + +//----------------------------------------------------------------------------- +// Editing. +//----------------------------------------------------------------------------- + +// A caption keystroke. Straight onto the control rather than through the +// recorder, because this runs per character and an edit is one change however +// many keys it took; the undo step is written once, on commit, from the list +// stashed here. +function GuiEditorItemsBlock::onItemRowTyped(%this, %row) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + // Taken on the first keystroke, because that is the last moment the control + // still holds what the edit started from. + if(!%this.typing) + { + %this.typing = true; + %this.listBeforeEdit = %this.target.getItemList(); + } + + %this.target.setItemList(%this.collect()); + %this.pane.afterCommit(); +} + +function GuiEditorItemsBlock::onItemRowCommit(%this, %row) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + %wasTyping = %this.typing; + %before = %this.listBeforeEdit; + %this.typing = false; + %this.listBeforeEdit = ""; + + if(!%row.hasChanged()) + { + return; + } + %row.markClean(); + + // Put back what the edit started from, so the one write below is the whole + // of it. Without this the recorder would compare the control against itself + // and find nothing to record. + if(%wasTyping) + { + %this.target.setItemList(%before); + } + + %this.writeList(%this.collect(), "Edit Item"); +} + +function GuiEditorItemsBlock::onItemRowToggled(%this, %row, %name) +{ + if(%this.populating || !isObject(%this.target) || !%this.rowsAreCurrent()) + { + return; + } + + %row.markClean(); + + %list = %this.collect(); + + // Only one row can start selected on a list that only allows one selection. + // The engine's setItemList restores exactly what it is given - it has to, or + // an undo could not put a multi-selection back - so the rule belongs here. + if(%name $= "selected" && %row.selectedToggle.getValue() && + !%this.target.getFieldValue("AllowMultipleSelections")) + { + %list = %this.clearOtherSelections(%list, %this.indexOf(%row)); + } + + %this.writeList(%list, "Change Item"); + + // The rows have to catch up with a selection that was taken off them. + if(%name $= "selected") + { + %this.rebuildDeferred(); + } +} + +function GuiEditorItemsBlock::clearOtherSelections(%this, %list, %keepIndex) +{ + %out = ""; + %count = getRecordCount(%list); + for(%i = 0; %i < %count; %i++) + { + %record = getRecord(%list, %i); + if(%i != %keepIndex) + { + %record = setField(%record, 3, 0); + } + %out = (%i == 0) ? %record : (%out NL %record); + } + + return %out; +} + +function GuiEditorItemsBlock::onItemRowMove(%this, %row, %delta) +{ + if(!isObject(%this.target) || !%this.rowsAreCurrent()) + { + return; + } + + %index = %this.indexOf(%row); + %swapWith = %index + %delta; + if(%index == -1 || %swapWith < 0 || %swapWith >= %this.grid.getCount()) + { + return; + } + + %list = %this.currentList(); + %out = ""; + %count = getRecordCount(%list); + for(%i = 0; %i < %count; %i++) + { + %pick = %i; + if(%i == %index) + { + %pick = %swapWith; + } + else if(%i == %swapWith) + { + %pick = %index; + } + + %record = getRecord(%list, %pick); + %out = (%i == 0) ? %record : (%out NL %record); + } + + %this.writeList(%out, "Move Item"); + %this.rebuildDeferred(); +} + +function GuiEditorItemsBlock::onItemRowRemove(%this, %row) +{ + if(!isObject(%this.target) || !%this.rowsAreCurrent()) + { + return; + } + + %index = %this.indexOf(%row); + if(%index == -1) + { + return; + } + + %list = %this.currentList(); + %out = ""; + %count = getRecordCount(%list); + for(%i = 0; %i < %count; %i++) + { + if(%i == %index) + { + continue; + } + + %record = getRecord(%list, %i); + %out = (%out $= "") ? %record : (%out NL %record); + } + + %this.writeList(%out, "Remove Item"); + %this.rebuildDeferred(); +} + +function GuiEditorItemsBlock::onAddClicked(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + // The defaults an LBItem is born with: no ID, active, unselected, and no + // color dot. + %record = trim(%this.nameBox.getText()) TAB "0" TAB "1" TAB "0" TAB "0" TAB "1 1 1 1"; + + %list = %this.currentList(); + %list = (%list $= "") ? %record : (%list NL %record); + + %this.nameBox.setText(""); + %this.writeList(%list, "Add Item"); + + %this.pendingFocus = true; + %this.rebuildDeferred(); +} + +// A row was added or taken away, so the section's height changed under the +// chain. Nothing above it moved, but the panel has to be told to re-measure. +function GuiEditorItemsBlock::notifyResized(%this) +{ + if(isObject(%this.pane)) + { + %this.pane.onItemsChanged(); + } +} diff --git a/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs b/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs new file mode 100644 index 000000000..6ed54aae6 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorMenuItemBlock.cs @@ -0,0 +1,315 @@ + +//----------------------------------------------------------------------------- +// Everything about a menu item, in the header. +// +// A GuiMenuItemCtrl is unlike anything else in the palette: it calls +// SimObject::initPersistFields rather than GuiControl's, so it has no profile, +// no geometry, no tooltip and no sizing - and then registers a handful of +// fields of its own. What is left is a header with a caption in it and nothing +// underneath, which is why those fields live up here rather than in a section +// of their own. There is nothing to scroll past to reach them. +// +// It owns its own caption box for the same reason. The shared GuiEditorTextBlock +// is a multi-line box with wrap, extend, alignment and font rows attached - all +// of which a menu item hides - and a menu caption is one short line that decides +// how wide the menu is. A box three lines tall for a word like "File" says the +// wrong thing about what belongs there. +// +// The block owns its widgets and no values: every row reports here and this +// forwards to the pane, so the pane stays the only thing that writes to the +// control. The creator sets pane, spec and blockWidth inline, then calls build() +// once after adding it. +//----------------------------------------------------------------------------- + +function GuiEditorMenuItemBlock::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +function GuiEditorMenuItemBlock::build(%this) +{ + %this.typing = false; + + %this.grid = %this.pane.makeCellGrid(0, %this.pane.rowWidth); + %this.add(%this.grid); + + %this.buildCaption(); + %this.buildKind(); + %this.buildCommands(); + + // Visible and Active are not here. They are GuiControl's names, and a menu + // item registers them again for itself, so the header's own toggle row can + // show them - see GuiEditorControlSpec::stateToggles. A second pair of + // switches a few pixels below the first would be worse than no switches. +} + +//----------------------------------------------------------------------------- +// The caption. One line, because that is what a menu label is - and because for +// a top-level menu it is also the width of the menu, which the bar recomputes +// from the text on every keystroke. +//----------------------------------------------------------------------------- + +function GuiEditorMenuItemBlock::buildCaption(%this) +{ + %row = %this.pane.makeFieldRow(%this.grid, "text", "Caption", "text", ""); + %this.textRow = %row; + + // Command is free on a field row -- it commits on AltCommand (blur) and + // ReturnCommand -- and GuiTextEditCtrl runs Command on every edit to its + // buffer. So this is the per-keystroke hook, and it is what makes the menu on + // the canvas grow and shrink under the cursor as the caption is typed. + %row.editor.Command = %this.getID() @ ".onCaptionTyped();"; +} + +// Straight onto the control rather than through the pane's writeField: this runs +// per character, and an edit is one change however many keys it took. The undo +// step is written once, on commit, from the value stashed here. +function GuiEditorMenuItemBlock::onCaptionTyped(%this) +{ + if(%this.populating || !isObject(%this.pane.target)) + { + return; + } + + %ctrl = %this.pane.target; + + // Taken on the first keystroke, because that is the last moment the control + // still holds what the edit started from. + if(!%this.typing) + { + %this.typing = true; + %this.typingTarget = %ctrl; + %this.textBeforeEdit = %ctrl.getFieldValue("text"); + } + + %ctrl.text = %this.textRow.getValue(); + + // Cheap -- the explorer tree refreshes the one row's label -- and it keeps + // the tree reading the same as the canvas while you type. + %this.pane.afterCommit(); +} + +function GuiEditorMenuItemBlock::endTyping(%this) +{ + %this.typing = false; + %this.typingTarget = ""; + %this.textBeforeEdit = ""; +} + +//----------------------------------------------------------------------------- +// What picking the item does. Toggle and Radio are two bool fields but one +// decision -- a menu item is a plain command, a checkable one, or one of a set +// -- so they are one control here, and each writes both fields. +//----------------------------------------------------------------------------- + +function GuiEditorMenuItemBlock::buildKind(%this) +{ + %row = new GuiControl() + { + class = "GuiEditorChoiceRow"; + labelText = "Kind"; + labelWidth = 76; + fieldName = "kind"; + owner = %this; + }; + + %row.addChoice("command", $EditorIcon::list_bullets, + "Runs its command and closes the menu. The ordinary kind."); + %row.addChoice("toggle", $EditorIcon::checkbox_checked, + "Carries a tick that turns on and off, like a Show Grid switch."); + %row.addChoice("radio", $EditorIcon::round, + "One of a run of items where only one is on at a time. The run is the items either side of it, so keep them together."); + %row.addChoice("spacer", $EditorIcon::round_minus, + "A rule across the menu, to group what is above it apart from what is below. It is not pickable and runs no command. Written as a single dash in the caption, which is what makes one in a .gui file too."); + + %this.grid.add(%row); + %row.build(); + %this.kindRow = %row; + + %this.onRow = %this.pane.makeFieldRow(%this.grid, "IsOn", "Starts On", "bool", ""); +} + +// The chooser changed. Two fields, so one transaction: the C++ setters each +// rewrite mDisplayType, and the one being turned OFF has to go first or it +// undoes the one being turned on. +function GuiEditorMenuItemBlock::onChoiceRowChanged(%this, %row) +{ + if(%this.populating || !isObject(%this.pane.target)) + { + return; + } + + %kind = %row.getValue(); + %wasSpacer = (%this.pane.target.getFieldValue("text") $= "-"); + + GuiEditor.undoRecorder.begin("Menu Item Kind", ""); + if(%kind $= "toggle") + { + %this.pane.writeField("Radio", false); + %this.pane.writeField("Toggle", true); + } + else if(%kind $= "radio") + { + %this.pane.writeField("Toggle", false); + %this.pane.writeField("Radio", true); + } + else + { + %this.pane.writeField("Toggle", false); + %this.pane.writeField("Radio", false); + } + + // A separator has no field of its own: a single dash in the caption is the + // whole of it, in a .gui file and here (GuiMenuItemCtrl::setText). So the + // kind is written by writing the caption, and leaving it means clearing the + // dash - otherwise picking Command off a separator would leave a separator. + if(%kind $= "spacer") + { + %this.pane.writeField("text", "-"); + } + else if(%wasSpacer) + { + %this.pane.writeField("text", ""); + } + GuiEditor.undoRecorder.end(); + + %this.refreshKind(); + %this.pane.afterCommit(); +} + +// What the rest of the block has to say depends on the kind. "Starts On" only +// means something where there is a mark to start on, and a separator is not +// pickable at all - so it runs nothing, answers to nothing, and is not named. +function GuiEditorMenuItemBlock::refreshKind(%this) +{ + %kind = %this.kindRow.getValue(); + %spacer = (%kind $= "spacer"); + + %this.onRow.setVisible(!%spacer && %kind !$= "command"); + + %this.textRow.setVisible(!%spacer); + %this.commandRow.setVisible(!%spacer); + %this.altCommandRow.setVisible(!%spacer); + %this.acceleratorRow.setVisible(!%spacer); + %this.variableRow.setVisible(!%spacer); + + %this.resizeToFit(); +} + +//----------------------------------------------------------------------------- +// What picking it runs. +//----------------------------------------------------------------------------- + +function GuiEditorMenuItemBlock::buildCommands(%this) +{ + %this.commandRow = %this.pane.makeFieldRow(%this.grid, "Command", "Command", "text", ""); + %this.altCommandRow = %this.pane.makeFieldRow(%this.grid, "AltCommand", "Alt Command", "text", ""); + %this.acceleratorRow = %this.pane.makeFieldRow(%this.grid, "Accelerator", "Shortcut", "text", ""); + %this.variableRow = %this.pane.makeFieldRow(%this.grid, "Variable", "Variable", "text", ""); +} + +//----------------------------------------------------------------------------- +// Loading and writing. +//----------------------------------------------------------------------------- + +function GuiEditorMenuItemBlock::bind(%this, %ctrl) +{ + %this.populating = true; + + %this.textRow.setValue(%ctrl.getFieldValue("text")); + %this.commandRow.setValue(%ctrl.getFieldValue("Command")); + %this.altCommandRow.setValue(%ctrl.getFieldValue("AltCommand")); + %this.acceleratorRow.setValue(%ctrl.getFieldValue("Accelerator")); + %this.variableRow.setValue(%ctrl.getFieldValue("Variable")); + %this.onRow.setValue(%ctrl.getFieldValue("IsOn")); + + // Only inside a menu. A rule across the menu BAR would have nothing either + // side of it to separate, so the engine does not make one there + // (GuiMenuItemCtrl::setText) and this must not offer it either. + %parent = %ctrl.getParent(); + %inMenu = isObject(%parent) && %parent.isMemberOfClass("GuiMenuItemCtrl"); + %this.kindRow.setChoiceVisible("spacer", %inMenu); + + // The caption is asked first, because a dash outranks the other two: an item + // carrying one is a separator whatever Toggle and Radio happen to hold. + %kind = "command"; + if(%inMenu && %ctrl.getFieldValue("text") $= "-") + { + %kind = "spacer"; + } + else if(%ctrl.getFieldValue("Toggle")) + { + %kind = "toggle"; + } + else if(%ctrl.getFieldValue("Radio")) + { + %kind = "radio"; + } + %this.kindRow.setValue(%kind); + + %this.populating = false; + + %this.refreshKind(); +} + +// Every row in this block reports here. The caption is the one that has already +// 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) +{ + if(%this.populating || !isObject(%this.pane.target)) + { + return; + } + + %field = %row.fieldName; + + if(%field $= "text") + { + %value = %row.getValue(); + %before = %this.textBeforeEdit; + %wasTyping = %this.typing; + %this.endTyping(); + %row.markClean(); + + if(!%wasTyping) + { + return; + } + + %this.pane.target.text = %before; + %this.pane.writeField("text", %value); + + // Typing a dash is the documented way to make a separator, so the chooser + // has to notice one arriving that way as well as being picked. + %this.bind(%this.pane.target); + %this.pane.afterCommit(); + return; + } + + if(!%row.hasChanged()) + { + return; + } + + %row.markClean(); + %this.pane.writeField(%field, %row.getValue()); + %this.pane.afterCommit(); +} + +// Nothing here has a reset button, so this is only ever the row asking politely. +function GuiEditorMenuItemBlock::onProfileRowReset(%this, %row) +{ +} + +// Nudge the width and put it back, which is one parentResized through every +// child -- the same trick the header block and the pane use to re-measure a +// chain after something in it was shown or hidden. +function GuiEditorMenuItemBlock::resizeToFit(%this) +{ + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + %this.resize(0, 0, %w + 1, %h); + %this.resize(0, 0, %w, %h); +} diff --git a/editor/GuiEditor/scripts/GuiEditorSaveGuiDialog.cs b/editor/GuiEditor/scripts/GuiEditorSaveGuiDialog.cs index 456ea95e7..bc19ba38a 100644 --- a/editor/GuiEditor/scripts/GuiEditorSaveGuiDialog.cs +++ b/editor/GuiEditor/scripts/GuiEditorSaveGuiDialog.cs @@ -100,9 +100,11 @@ class = "EditorForm"; %this.validate(); } +// Grey the Save button until the form can be saved, and say why in the feedback +// line either way. Every return below is a reason not to. function GuiEditorSaveGuiDialog::Validate(%this) { - %this.createButton.active = false; + %this.saveButton.setActive(false); %folderPath = %this.getFolderPath(); %guiName = %this.guiNameBox.getText(); @@ -156,16 +158,36 @@ class = "EditorForm"; } if(isFile(%filePath)) { - %this.createButton.active = true; - %this.feedback.setText("A file by this name already exists. It will be overwritten."); + %this.saveButton.setActive(true); + %this.feedback.setText(%this.withFormatWarning( + "A file by this name already exists. It will be overwritten.")); return true; } - %this.createButton.active = true; - %this.feedback.setText("A new Gui file will be created!"); + %this.saveButton.setActive(true); + %this.feedback.setText(%this.withFormatWarning("A new Gui file will be created!")); return true; } +// The .gui script format writes fields and child objects and nothing else, so +// anything a control keeps as TAML custom nodes goes missing. A warning rather +// than a refusal: the format is still the right answer for a Gui that holds none +// of it, and which of the two to save in is the user's call. Saying which +// controls are affected is what makes it actionable. +function GuiEditorSaveGuiDialog::withFormatWarning(%this, %text) +{ + if(%this.guiFormatDropDown.getSelectedItem() != 0) + { + return %text; + } + + %warning = GuiEditor.tamlOnlyStateSummary(); + + // "\n\n" rather than NL NL: NL is a binary operator, so two of them in a row + // have nothing between them and will not parse. + return (%warning $= "") ? %text : (%text @ "\n\n" @ %warning); +} + function GuiEditorSaveGuiDialog::onSave(%this) { if(%this.validate()) @@ -178,6 +200,23 @@ class = "EditorForm"; } } +// The Cancel button and the window X both land here, and so does onSave once the +// file is written. Only the first two mean anything was called off, and by the +// time the third arrives SaveCore has already released what it was holding - so +// dropping it here is a no-op on that path and the answer on the other two. +// +// This is the far end of the unsaved-changes prompt's Save button: the user said +// save-then-carry-on, changed their mind about the file name, and must not find +// the editor carrying on anyway with nothing written. +function GuiEditorSaveGuiDialog::onClose(%this) +{ + GuiEditor.dropPendingCommand(); + + Canvas.popDialog(%this); + EditorCore.dialog = %this; + EditorCore.schedule(100, "deleteDialog"); +} + function GuiEditorSaveGuiDialog::onFolderOpened(%this, %textBox) { %this.Validate(); diff --git a/editor/GuiEditor/scripts/GuiEditorTextBlock.cs b/editor/GuiEditor/scripts/GuiEditorTextBlock.cs new file mode 100644 index 000000000..799e3a973 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorTextBlock.cs @@ -0,0 +1,456 @@ + +//----------------------------------------------------------------------------- +// Everything GuiControl::renderText reads, in one component. +// +// These seven fields used to be nine rows in a shared "Text" section that the +// pane hid wholesale whenever the header carried the text box -- and the header +// carried three of them. The other five had nowhere to go, which is how a +// GuiControl ended up with a caption it could not resize. Putting them in one +// component makes that impossible to repeat: the block is either shown or it is +// not, and it is the only place any of these fields live. +// +// caption row the block's heading, and the two flags that change what the +// text does to the control it sits in. Wrap first, extend +// second; extend stays live either way, because +// guiControl.cc grows the width when wrap is off and the +// height when it is on. +// text box a GuiTextEditCtrl with textWrap on, which is what makes it +// multi-line: it wraps, scrolls and hit-tests in line space. +// Three lines tall, because one line of a heading is a poor +// view of a paragraph. +// align grid the two alignments, as segmented rows labelled "H:" and +// "V:" -- one decision each, and the icons say the rest. +// font grid size and color, the pair anyone reaching for "this text is +// too small" wants. +// +// Both grids are cell grids, so they sit side by side in a wide Properties +// frame and stack in a narrow one. +// +// Two of these exist. The header holds one, for the classes whose text is a +// principal property; the Text section holds the other, for the classes that +// can draw text but are not asked to (a grid) and the one that draws none but +// still sizes it (a slider). Two cheap instances rather than reparenting a live +// control out from under a selection event -- see +// GuiEditorControlSpec::textBlockHome. +// +// Who owns what: the block owns its layout and decides which of its parts a +// class can use. It owns no values. Its three field rows report straight to the +// pane, and its choice rows and toggles are forwarded there, so the pane stays +// the only thing that writes to the control. +// +// The creator sets pane, spec and blockWidth inline, then calls build() once +// after adding it. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); +} + +function GuiEditorTextBlock::build(%this) +{ + %this.buildCaption(); + %this.buildText(); + %this.buildAlign(); + %this.buildFont(); +} + +//----------------------------------------------------------------------------- +// The caption line: what this block is about, and the two flags that belong +// with it rather than with the alignments. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::buildCaption(%this) +{ + %w = %this.blockWidth; + %size = 24; + + %row = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC (%size + 2); + }; + ThemeManager.setProfile(%row, "emptyProfile"); + %this.add(%row); + %this.captionRow = %row; + + // The label stops short of the two icons and keeps that gap as the pane + // widens; the icons keep their distance from the right edge. + %this.label = new GuiControl() + { + HorizSizing = "width"; + Position = "4 4"; + Extent = (%w - 8 - ((%size + 2) * 2)) SPC 16; + Text = "Text"; + align = "left"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%this.label, "labelProfile"); + %row.add(%this.label); + + %this.wrapButton = %this.makeIcon(%row, %w - ((%size + 2) * 2), %size, "textWrap", + "Wrap Text", $EditorIcon::align_just, + "Wraps onto as many lines as it needs, breaking between words. Return puts a line break in while you are typing here.", + "Draws on one line whatever the control's width, and anything past the edge is clipped. Return finishes the edit."); + + %this.extendButton = %this.makeIcon(%row, %w - (%size + 2), %size, "textExtend", + "Extend To Fit Text", $EditorIcon::expand, "", ""); + %this.applyExtendTip(false); +} + +// One icon in the caption row, pinned to the right edge as the pane widens. +function GuiEditorTextBlock::makeIcon(%this, %row, %x, %size, %field, %label, %frame, %tipOn, %tipOff) +{ + %button = new GuiCheckBoxCtrl() + { + class = "GuiEditorToggleIcon"; + HorizSizing = "left"; + Position = %x SPC 1; + Extent = %size SPC %size; + frameOn = %frame; + frameOff = %frame; + tipOn = %tipOn; + tipOff = %tipOff; + toggleName = %field; + toggleLabel = %label; + owner = %this; + }; + ThemeManager.setProfile(%button, "iconButtonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %row.add(%button); + return %button; +} + +// Extend does something different depending on wrap, so its tooltip has to say +// which -- it is the only way to tell from the pane that the same flag grows +// two different axes (guiControl.cc renderText: extent.y when wrapping, extent.x +// when not). +function GuiEditorTextBlock::applyExtendTip(%this, %wrapping) +{ + %tip = %wrapping + ? "Grows taller to fit however many lines the text wraps onto, so nothing is clipped." + : "Grows wider to fit the text on its one line, so nothing is clipped."; + + %this.extendButton.tipOn = %tip; + %this.extendButton.tipOff = %tip; + %this.extendButton.refresh(); +} + +//----------------------------------------------------------------------------- +// The text box itself, as a field row of the pane's own kind so that loading, +// committing and the changed check are the ones every other field gets. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::buildText(%this) +{ + // No caption of its own: the caption row above is its label, which is what + // leaves room for the two icons beside it. An empty labelText is how a field + // row is told to keep no space for one. + %row = %this.pane.makeFieldRow(%this, "text", "", "multiline", ""); + %this.row["text"] = %row; + %this.textRow = %row; + + // Command is free on a field row -- it commits on AltCommand (blur) and + // ReturnCommand -- and GuiTextEditCtrl runs Command on every edit to its + // buffer: a character, a backspace, a delete, a paste, an undo. So this is + // the per-keystroke hook, and it is what lets the control on the canvas fill + // in as you type. + %row.editor.Command = %this.getID() @ ".onTextTyped();"; +} + +//----------------------------------------------------------------------------- +// Typing. The control is updated on every keystroke, but the edit is still one +// edit: the text the control held when the first key landed is kept here, and +// the pane writes the change once, properly, when the box loses focus. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::onTextTyped(%this) +{ + if(%this.isPopulating() || !isObject(%this.pane.target)) + { + return; + } + + %ctrl = %this.pane.target; + + // Taken on the first keystroke, because that is the last moment the control + // still holds what the edit started from. + if(!%this.typing) + { + %this.typing = true; + %this.typingTarget = %ctrl; + %this.textBeforeEdit = %ctrl.getFieldValue("text"); + } + + // Straight onto the control rather than through the pane's writeField: this + // runs per character, and an edit is one change however many keys it took. + %ctrl.text = %this.textRow.getValue(); + + // Cheap -- the explorer tree refreshes the one row's label -- and it keeps + // the tree reading the same as the canvas while you type. + %this.pane.afterCommit(); +} + +function GuiEditorTextBlock::endTyping(%this) +{ + %this.typing = false; + %this.typingTarget = ""; + %this.textBeforeEdit = ""; +} + +//----------------------------------------------------------------------------- +// Alignment. Two segmented rows with the narrowest labels the row supports -- +// eight buttons and two captions have to fit a Properties frame someone has +// dragged narrow. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::buildAlign(%this) +{ + %grid = %this.makeGrid(128, 30); + %this.add(%grid); + %this.alignGrid = %grid; + + %this.alignRow = %this.makeChoiceRow(%grid, "align", "H:", + "left" TAB $EditorIcon::align_left TAB "Align text to the left" NL + "center" TAB $EditorIcon::align_center TAB "Centre text" NL + "right" TAB $EditorIcon::align_right TAB "Align text to the right"); + + %this.vAlignRow = %this.makeChoiceRow(%grid, "vAlign", "V:", + "top" TAB $EditorIcon::align_top TAB "Align text to the top" NL + "middle" TAB $EditorIcon::align_middle TAB "Centre text vertically" NL + "bottom" TAB $EditorIcon::align_bottom TAB "Align text to the bottom"); +} + +// %choices is one record per value: value TAB icon TAB tooltip. "default" leads +// every row and wears no icon -- it is not an absence but the value a control +// starts on, which getAlignmentType resolves to the profile's own alignment. +function GuiEditorTextBlock::makeChoiceRow(%this, %grid, %field, %label, %choices) +{ + %row = new GuiControl() + { + class = "GuiEditorChoiceRow"; + Position = "0 0"; + labelText = %label; + labelWidth = 24; + fieldName = %field; + owner = %this; + }; + %grid.add(%row); + + %row.addChoice("default", "", "Use the profile's alignment"); + %count = getRecordCount(%choices); + for(%i = 0; %i < %count; %i++) + { + %rec = getRecord(%choices, %i); + %row.addChoice(getField(%rec, 0), getField(%rec, 1), getField(%rec, 2)); + } + + %row.build(); + return %row; +} + +//----------------------------------------------------------------------------- +// Size and color. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::buildFont(%this) +{ + %grid = %this.makeGrid(128, 48); + %this.add(%grid); + %this.fontGrid = %grid; + + // The kind comes from the pane's table rather than a literal here, so this + // row cannot end up spelling a field's type differently from everywhere + // else -- fontSizeAdjust is a multiplier, and a whole-number row would round + // every useful value of it away. + %this.row["fontSizeAdjust"] = %this.pane.makeFieldRow(%grid, "fontSizeAdjust", + "Font Size", %this.pane.sharedKindFor("fontSizeAdjust"), ""); + %this.fontSizeRow = %this.row["fontSizeAdjust"]; + + // One widget for two fields. overrideFontColor has no row: picking a color + // is what turns it on, and the reset button -- which every field row already + // carries -- is what turns it off again. With it off the swatch shows the + // profile's own color, so the row always says what the control will draw in. + %this.row["fontColor"] = %this.pane.makeFieldRow(%grid, "fontColor", + "Font Color", %this.pane.sharedKindFor("fontColor"), ""); + %this.fontColorRow = %this.row["fontColor"]; + %this.fontColorRow.resetButton.Tooltip = "Go back to the profile's font color"; +} + +// The block's own cell grid. Narrower cells than the pane's rows use: two of +// these fit where one 220-wide field row does, which is what lets the alignment +// pair and the font pair each sit on one line. +function GuiEditorTextBlock::makeGrid(%this, %cellW, %cellH) +{ + %grid = new GuiGridCtrl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %this.blockWidth SPC 4; + CellModeX = "variable"; + CellModeY = "variable"; + CellSizeX = %cellW; + CellSizeY = %cellH; + CellSpacingX = 4; + CellSpacingY = 4; + MaxColCount = 0; + MaxRowCount = 0; + OrderMode = "lrtb"; + IsExtentDynamic = true; + }; + ThemeManager.setProfile(%grid, "emptyProfile"); + return %grid; +} + +//----------------------------------------------------------------------------- +// Binding. Which parts apply is the spec's answer, asked per field rather than +// per role so that the per-class exceptions (a text edit has no vAlign, a tab +// book no wrap) are honoured here too. +// +// The three field rows are the pane's to show or hide -- they are in its shared +// registry and its filter walks them. What is left is this block's own. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::bindClass(%this, %ctrl, %class) +{ + %spec = %this.spec; + + %this.label.setText(%spec.textLabelFor(%class)); + + %wrap = %spec.isFieldVisible(%class, "textWrap"); + %extend = %spec.isFieldVisible(%class, "textExtend"); + %this.wrapButton.setVisible(%wrap); + %this.extendButton.setVisible(%extend); + + // The caption row is the heading for a text box and the home of the two + // flags. With none of the three on show it is a title over nothing -- which + // is what a slider would get, since it reads fontSizeAdjust and nothing else + // in this block. + %this.captionRow.setVisible(%spec.isFieldVisible(%class, "text") || %wrap || %extend); + + %this.alignRow.setVisible(%spec.isFieldVisible(%class, "align")); + %this.vAlignRow.setVisible(%spec.isFieldVisible(%class, "vAlign")); + + // A container that lays out only the children it can see needs telling when + // one of them comes or goes; nothing re-runs the layout on setVisible. + %this.alignGrid.setVisible(%this.alignRow.isVisible() || %this.vAlignRow.isVisible()); + %this.fontGrid.setVisible(%spec.isFieldVisible(%class, "fontSizeAdjust") || + %spec.isFieldVisible(%class, "fontColor")); + + %this.resizeGrid(%this.alignGrid); + %this.resizeGrid(%this.fontGrid); + %this.resizeToFit(); +} + +// The block's own width rather than the one it was built at: blockWidth is what +// the pane authored, and a grid re-measured against it would snap back to that +// after the Properties frame had been dragged wider. +function GuiEditorTextBlock::resizeGrid(%this, %grid) +{ + %grid.resize(0, 0, getWord(%this.getExtent(), 0), getWord(%grid.getExtent(), 1)); +} + +// A GuiChainCtrl positions its children without resizing them, and a grid only +// learns its height once something lays it out, so nudge the width by a pixel +// and back to force exactly one parentResized through every child. +// +// Through its own position, not through zero: resize() sets the position as +// well as the extent. The header's copy lives in a chain, which puts its +// children back wherever it likes, but the Text section's copy sits at y=24 +// under the panel's title bar -- and moving it to zero drew its caption and its +// two icons on top of that title, which read as a second "Text" printed over +// the first. +function GuiEditorTextBlock::resizeToFit(%this) +{ + %x = getWord(%this.getPosition(), 0); + %y = getWord(%this.getPosition(), 1); + %w = getWord(%this.getExtent(), 0); + %h = getWord(%this.getExtent(), 1); + + %this.resize(%x, %y, %w + 1, %h); + %this.resize(%x, %y, %w, %h); +} + +//----------------------------------------------------------------------------- +// Loading. The pane loads the field rows it registered; what is left is the two +// choice rows, the two toggles, and the one row whose value is not simply the +// field it names. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::load(%this, %ctrl) +{ + %this.populating = true; + + // A pending edit belongs to whatever was selected when it started. Loading + // a control abandons it -- the typed text is already on that control, so + // there is nothing to lose, and keeping the stash would let a blur that + // arrives after the selection moved write one control's caption onto + // another. + %this.endTyping(); + + %this.alignRow.setValue(%ctrl.getFieldValue("align")); + %this.vAlignRow.setValue(%ctrl.getFieldValue("vAlign")); + + %this.wrapButton.setValue(%ctrl.textWrap); + %this.extendButton.setValue(%ctrl.textExtend); + %this.applyExtendTip(%ctrl.textWrap); + + %this.loadFontColor(%ctrl); + + %this.populating = false; +} + +// The swatch shows what the control will actually draw in: its own color while +// it is overriding, and the profile's while it is not. Anything else would have +// the row claim a color the control does not use. +function GuiEditorTextBlock::loadFontColor(%this, %ctrl) +{ + %override = %ctrl.overrideFontColor; + %color = %ctrl.getFieldValue("fontColor"); + + if(!%override) + { + %profile = GuiEditor.themeApplier.fieldProfile(%ctrl, "Profile"); + if(isObject(%profile) && %profile.fontColor !$= "") + { + %color = %profile.fontColor; + } + } + + %this.fontColorRow.setValue(%color); + %this.fontColorRow.setOverridden(%override); +} + +//----------------------------------------------------------------------------- +// Forwarding. Every write still goes through the pane. +//----------------------------------------------------------------------------- + +function GuiEditorTextBlock::isPopulating(%this) +{ + return %this.populating || %this.pane.populating; +} + +function GuiEditorTextBlock::onChoiceRowChanged(%this, %row) +{ + if(%this.isPopulating()) + { + return; + } + %this.pane.onHeaderChoiceChanged(%row.fieldName, %row.getValue()); +} + +function GuiEditorTextBlock::onToggleIconChanged(%this, %toggle) +{ + if(%this.isPopulating()) + { + return; + } + + if(%toggle.toggleName $= "textWrap") + { + %this.applyExtendTip(%toggle.getValue()); + } + + %this.pane.onTextFlagChanged(%toggle.toggleName, %toggle.getValue()); +} diff --git a/editor/GuiEditor/scripts/GuiEditorThemeApplier.cs b/editor/GuiEditor/scripts/GuiEditorThemeApplier.cs index 29d89c512..29f9eabf4 100644 --- a/editor/GuiEditor/scripts/GuiEditorThemeApplier.cs +++ b/editor/GuiEditor/scripts/GuiEditorThemeApplier.cs @@ -16,6 +16,29 @@ { %this.buildFieldTable(); %this.buildClassTable(); + %this.buildCursorFieldTable(); +} + +// The engine has exactly six cursor slots across three classes, and unlike a +// profile slot each one means the same thing wherever it appears: a window's +// upDownCursor and a frame set's are both "the pointer for a horizontal edge". +// So this needs no per-class exceptions, only the field name. +function GuiEditorThemeApplier::buildCursorFieldTable(%this) +{ + %this.setCursorFieldCategory("editCursor", "Edit"); + %this.setCursorFieldCategory("leftRightCursor", "LeftRight"); + %this.setCursorFieldCategory("upDownCursor", "UpDown"); + %this.setCursorFieldCategory("nWSECursor", "NWSE"); +} + +function GuiEditorThemeApplier::setCursorFieldCategory(%this, %field, %category) +{ + %this.cursorFieldCategory[strlwr(%field)] = %category; +} + +function GuiEditorThemeApplier::cursorCategoryForField(%this, %field) +{ + return %this.cursorFieldCategory[strlwr(%field)]; } // What each named slot is for, independent of the control wearing it. "Profile" @@ -154,6 +177,13 @@ %changed = %this.walk(%ctrl, %theme, %overrideStandalone); %this.endApply(); + // Consume the palette's request. It exists to answer one question -- which + // of the four faces a bare GuiControl was dropped as -- and that question is + // asked once, on arrival. What the control ends up WEARING is the lasting + // record of what it was told to be, so leaving the field set would let a drop + // silently overrule a later change made in the properties pane. + %ctrl.paletteCategory = ""; + return %changed; } @@ -240,6 +270,11 @@ continue; } + // Through the Gui Editor's undo recorder, which does the write. Set Theme + // fills a slot on every control in the document and that is one Ctrl+Z, so + // GuiEditor::setTheme opens a transaction around the whole sweep; the + // recorder is what collects the writes into it. + // // setEditFieldValue, not a plain assignment: a profile field is a raw // field offset, so writing it does not touch reference counts. The // inspect-apply pair sleeps the control first and wakes it after, which @@ -253,13 +288,112 @@ // added to a theme, a new stand-alone - therefore cannot be found by name // until it has been saved and reloaded. An id always resolves, and the // field still writes its name when the Gui is saved. - %ctrl.setEditFieldValue(%field, %target.getId()); + GuiEditor.undoRecorder.writeField(%ctrl, %field, %target.getId()); + %changed++; + } + + %changed += %this.applyCursorsToControl(%ctrl, %theme); + + return %changed; +} + +// The cursor slots, filled the same way and for the same reason: a Gui should +// wear ITS theme's cursors, not whichever set a project happened to install +// globally. Done silently -- the properties pane only shows a cursor row when +// the theme offers a choice, so most controls are themed here and never +// mention it. +// +// A cursor already belonging to this theme is left alone, which is what lets a +// deliberate second choice survive a re-apply. +function GuiEditorThemeApplier::applyCursorsToControl(%this, %ctrl, %theme) +{ + %changed = 0; + + %count = %ctrl.getFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %field = %ctrl.getField(%i); + if(%ctrl.getFieldType(%field) !$= "GuiCursor") + { + continue; + } + + %category = %this.cursorCategoryForField(%field); + if(%category $= "") + { + continue; + } + + %current = %this.fieldCursor(%ctrl, %field); + if(isObject(%current) && %this.themeOfCursor(%current) == %theme) + { + continue; + } + + // From another theme: carry the category across rather than this one's + // guess, exactly as a profile slot does. + if(isObject(%current) && %current.category !$= "") + { + %category = %current.category; + } + + %target = %theme.getCursor(%category); + if(!isObject(%target) || %current == %target) + { + continue; + } + + GuiEditor.undoRecorder.writeField(%ctrl, %field, %target.getId()); %changed++; } return %changed; } +// The object in a cursor field, or 0. Same reasoning as fieldProfile: the field +// reads back as a name, and a name the Sim cannot resolve is not necessarily a +// dead reference while the editor is running in editor mode. +function GuiEditorThemeApplier::fieldCursor(%this, %ctrl, %field) +{ + %value = %ctrl.getFieldValue(%field); + if(%value $= "") + { + return 0; + } + if(isObject(%value)) + { + return %value.getId(); + } + return %this.library.findCursorByName(%value); +} + +// The theme a cursor belongs to, or 0. A cursor carries its category, which +// narrows the search to one list per theme. +function GuiEditorThemeApplier::themeOfCursor(%this, %cursor) +{ + %category = %cursor.category; + if(%category $= "") + { + return 0; + } + + %themeCount = getWordCount(%this.themeList); + for(%i = 0; %i < %themeCount; %i++) + { + %theme = getWord(%this.themeList, %i); + %members = %theme.getCursors(%category); + for(%m = 0; %m < getWordCount(%members); %m++) + { + if(getWord(%members, %m) == %cursor) + { + return %theme; + } + } + } + + return 0; +} + // The object in a profile field, or 0. The field reads back as a name (or an id // string for an unnamed profile). A name the Sim cannot resolve is not // necessarily a dead reference: in editor mode a profile created this session @@ -372,6 +506,8 @@ %changed++; } + %changed += %this.detachCursors(%ctrl, %theme); + for(%i = 0; %i < %ctrl.getCount(); %i++) { %changed += %this.detachWalk(%ctrl.getObject(%i), %theme, %profile); @@ -380,6 +516,50 @@ return %changed; } +// The same rescue for cursor slots. A GuiCursor* is as raw a pointer as a +// profile's, so a control still naming one of a doomed theme's cursors would be +// left pointing at freed memory. +// +// The replacement is the canonical name for the slot ("LeftRightCursor" and the +// rest) rather than an empty string: writing "" through TypeGuiCursor does not +// clear the field, it falls back to DefaultCursor (guiTypes.cc), which would +// put an arrow on a window's resize edge. +function GuiEditorThemeApplier::detachCursors(%this, %ctrl, %theme) +{ + if(!isObject(%theme)) + { + return 0; + } + + %changed = 0; + %count = %ctrl.getFieldCount(); + for(%i = 0; %i < %count; %i++) + { + %field = %ctrl.getField(%i); + if(%ctrl.getFieldType(%field) !$= "GuiCursor") + { + continue; + } + + %current = %this.fieldCursor(%ctrl, %field); + if(!isObject(%current) || %this.themeOfCursor(%current) != %theme) + { + continue; + } + + %category = %this.cursorCategoryForField(%field); + if(%category $= "") + { + continue; + } + + %ctrl.setEditFieldValue(%field, %theme.getCursorCanonicalName(%category)); + %changed++; + } + + return %changed; +} + //----------------------------------------------------------------------------- // Category lookup. //----------------------------------------------------------------------------- @@ -414,6 +594,15 @@ { if(%ctrl.getClassName() $= "GuiControl") { + // The palette can say outright which of the four it dropped, and when it + // does there is nothing to guess. Two of the four are unreachable by the + // guess below at all: a Panel that is not the root, and Overlay ever. + // Cleared by applyToBranch once the walk is done. + if(%ctrl.paletteCategory !$= "") + { + return %ctrl.paletteCategory; + } + if(%isRoot) { return "Panel"; diff --git a/editor/GuiEditor/scripts/GuiEditorToggleIcon.cs b/editor/GuiEditor/scripts/GuiEditorToggleIcon.cs new file mode 100644 index 000000000..47b83c7f0 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorToggleIcon.cs @@ -0,0 +1,174 @@ + +//----------------------------------------------------------------------------- +// An icon that stays pressed: a toggle button, built from a GuiCheckBoxCtrl. +// +// A checkbox already IS a toggle. GuiCheckBoxCtrl::onAction flips mStateOn and +// then runs the Command, and it refuses to do either while the control is +// inactive -- which is the whole contract, and more than GuiButtonCtrl offers. +// What makes it look like a checkbox rather than a button is only its layout: +// a small box beside a caption. Take the caption away and let the box have the +// whole control and the same class renders as a button that holds its state: +// +// boxOffset "0 0" the box starts at the content rect's corner +// boxExtent the extent GuiCheckBoxCtrl clamps it to the content rect +// text "" and textExtent "0 0", so no caption is drawn +// +// GuiCheckBoxCtrl::renderInnerControl then draws a universal rect for the whole +// control in the current state, and an icon sitting on top (UseInput off, so it +// never eats the click) says what the button is for. +// +// The icon carries the on/off reading rather than the background, because a +// profile cannot express it without art: GuiControlProfile::getFillColor maps +// NormalStateOn to mFillColor, the same color as NormalState, so the four "On" +// states differ only when the profile renders from an image or bitmap. Bright +// icon means on, dim means off, and a second frame can say it again where there +// is art for one (a closed and an open padlock). +// +// There is deliberately no hover animation. EditorIconButton has one and it is +// where its disabled state went: the engine delivers touch events to inactive +// controls (findHitControl checks mVisible and mUseInput, never mActive), so +// the hover handler repainted over the disabled color. Here the state is +// computed in one place, refresh(), and nothing else writes the icon. +// +// The creator sets frameOn, frameOff, tipOn, tipOff, owner and toggleName +// inline. Clicks arrive at owner.onToggleIconChanged(%this). +//----------------------------------------------------------------------------- + +function GuiEditorToggleIcon::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 + // leaves whatever happened to be in the second slot. The fields themselves + // are TypePoint2I and parse the pair correctly. + %extent = %this.getExtent(); + + %this.setText(""); + %this.boxOffset = "0 0"; + %this.boxExtent = %extent; + %this.textOffset = "0 0"; + %this.textExtent = "0 0"; + + %this.icon = new GuiSpriteCtrl() + { + HorizSizing = "center"; + VertSizing = "center"; + Extent = "16 16"; + MinExtent = "16 16"; + Position = "0 0"; + Image = "EditorCore:EditorIcons16"; + ImageSize = "16 16"; + constrainProportions = "1"; + fullSize = "0"; + Frame = %this.frameOff; + UseInput = false; + }; + ThemeManager.setProfile(%this.icon, "spriteProfile"); + %this.add(%this.icon); + + // ThemeManager repaints what it was given a profile for, and the icon's tint + // is not that: refresh() COPIES a color off the profile onto the sprite, so + // swapping the profile underneath leaves the copy behind. Without this the + // button's background changed theme and the picture on it did not, until + // something happened to call refresh() again -- which for a segmented row + // meant the icons corrected themselves the moment you clicked one. + %this.startListening(ThemeManager); + + %this.Command = %this.getID() @ ".onToggled();"; + %this.refresh(); +} + +function GuiEditorToggleIcon::onThemeChange(%this, %theme) +{ + %this.refresh(); +} + +// 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) +{ + %this.refresh(); + + if(isObject(%this.owner)) + { + %this.owner.onToggleIconChanged(%this); + } +} + +// Set the state without telling the owner, for loading a value in. +function GuiEditorToggleIcon::setValue(%this, %on) +{ + %this.setStateOn(%on); + %this.refresh(); +} + +function GuiEditorToggleIcon::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) +{ + %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. + %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, + // where the absence of a picture is the meaning. + %this.icon.setVisible(%frame !$= ""); + if(%frame !$= "") + { + %this.icon.setImageFrame(%frame); + } + + if(!%this.isActive()) + { + %this.icon.setImageColor(%profile.fontColorNA); + } + else + { + %this.icon.setImageColor(%on ? %profile.fontColorHL : %profile.fontColor); + } + + %this.Tooltip = %this.buildTip(%on); +} + +// Two lines, now that a control's text can hold a line break: what this is and +// which way it is set, then what that means. +// +// Visible - On +// Draws when the game runs... +// +// The first line only appears for a toggle that names itself. A segmented row's +// 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) +{ + %tip = %on ? %this.tipOn : %this.tipOff; + + if(%this.toggleLabel $= "") + { + return %tip; + } + + %heading = %this.toggleLabel @ " - " @ (%on ? "On" : "Off"); + return (%tip $= "") ? %heading : (%heading NL %tip); +} + +// setActive does not repaint on its own, and the disabled tint is ours to draw. +function GuiEditorToggleIcon::onActive(%this) +{ + %this.refresh(); +} + +function GuiEditorToggleIcon::onInactive(%this) +{ + %this.refresh(); +} diff --git a/editor/GuiEditor/scripts/GuiEditorToolsWindow.cs b/editor/GuiEditor/scripts/GuiEditorToolsWindow.cs index 7189db861..4f77fade0 100644 --- a/editor/GuiEditor/scripts/GuiEditorToolsWindow.cs +++ b/editor/GuiEditor/scripts/GuiEditorToolsWindow.cs @@ -13,8 +13,19 @@ ThemeManager.setProfile(%this.buttonBar, "emptyProfile"); %this.add(%this.buttonBar); - %this.buttonBar.addButton("onProfileEditor", 49, "Open the Gui Profile Editor", ""); - %this.buttonBar.addButton("onSetTheme", 46, "Set this Gui's theme", ""); + %this.buttonBar.addButton("onProfileEditor", $EditorIcon::doc_edit, "Open the Gui Profile Editor", ""); + %this.buttonBar.addButton("onSetTheme", $EditorIcon::brush, "Set this Gui's theme", ""); +} + +// The window's own title carries the document being edited, and a trailing star +// when it has changes that are not on disk. This window rather than anywhere +// else because it is the top-left panel and already holds the theme buttons, so +// it is where the eye goes first -- and because the editor TAB cannot do it: +// EditorCoreTabBook::onTabSelected looks an editor up by its tab text, so +// retitling the tab loses the lookup. +function GuiEditorToolsWindow::showDocument(%this, %name, %modified) +{ + %this.setText(%modified ? (%name @ " *") : %name); } function GuiEditorToolsWindow::onRemove(%this) diff --git a/editor/GuiEditor/scripts/GuiEditorUndoAction.cs b/editor/GuiEditor/scripts/GuiEditorUndoAction.cs new file mode 100644 index 000000000..dd0ccb8fe --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorUndoAction.cs @@ -0,0 +1,510 @@ + +//----------------------------------------------------------------------------- +// One undoable step. Built by GuiEditorUndoRecorder, which is the only thing +// that creates one, and handed to the UndoManager the Gui Editor's GuiEditCtrl +// already owns (guiEditCtrl.cc, getUndoManager). UndoScriptAction is the engine +// class that exists for exactly this - its undo() and redo() call straight back +// into script (collection/undo.h). +// +// An action holds an ordered list of ops rather than one change, because a +// single gesture is several changes: dropping a control moves it into the add +// set, then the theme applier writes a profile into every slot it has, then its +// position is set. That is one Ctrl+Z, so it is one action. +// +// field a persist field write. setEditFieldValue, not assignment, so the +// control sleeps and wakes around it and protected fields run their +// setters - Position and Extent are protected (guiControl.h), so a +// geometry op really does resize the control rather than poke +// mBounds behind its back. +// dynamic a dynamic field write, which has no edit-field path. +// move a change of parent, of index within the parent, or both. The +// trash is an ordinary parent here: it is a real SimGroup the edit +// control owns and never empties, so a deleted control is still a +// live object with a home, and undoing a delete is the same +// operation as undoing anything else that moved. +// items a list box or drop down's whole set of static rows, before and +// after. Whole for the same reason an order op is: every gesture the +// Items pane offers can shift the rows around the one it touched. +// order one parent's whole list of children, before and after. Restoring +// a list is order-independent and cannot be got subtly wrong, which +// a pile of per-control indices can: every index a move op restores +// shifts the siblings around it, so a rearrangement that touched +// several of them at once depends on replay order. A drag in the +// Explorer tree can move any number of controls between any number +// of parents, so it records lists. +// +// undo() replays the list backwards writing the before values, redo() forwards +// writing the after values. Every op re-checks its objects: an action can +// outlive the controls it names. +//----------------------------------------------------------------------------- + +function GuiEditorUndoAction::onAdd(%this) +{ + %this.opCount = 0; + %this.fixCount = 0; + %this.frameCount = 0; + %this.touchList = ""; +} + +// Where a control's layout has to end up once the ops have run. +// +// A container that places its own children takes their layout from them when +// they arrive, and every one of them takes something different: a GuiChainCtrl +// zeroes the position outright while the editor is open, a GuiGridCtrl forces +// the sizing off center and fill, a GuiFrameSetCtrl forces it off center and +// then resizes the control to its frame (guiChainCtrl.cc / guiGridCtrl.cc / +// guiFrameSetCtrl.cc, onChildAdded). All of it is right for a control being +// dropped in and wrong for one being put back, which knows what it was. +// +// So a structural op remembers all four of the fields a container can take - +// position, extent, and the two sizing modes - at each end, and writes them +// again afterwards. Afterwards is the point: recording them as ordinary field +// ops would not do, because ops replay forwards for a redo and backwards for an +// undo, and the layout has to be written after the move in both directions. +// +// %before and %after are TAB-delimited, from GuiEditorUndoRecorder::layoutOf. +function GuiEditorUndoAction::addLayoutFix(%this, %ctrl, %before, %after) +{ + %i = %this.fixCount; + %this.fixCtrl[%i] = %ctrl; + %this.fixBefore[%i] = %before; + %this.fixAfter[%i] = %after; + %this.fixCount = %i + 1; +} + +// The same idea one level up, for the one container that keeps a layout of its +// own beside the child list. A GuiFrameSetCtrl destroys a frame when the +// control in it is removed and merges the split into its twin, so putting the +// control back is not enough - the frame it stood in has to be rebuilt too, and +// the sibling that swallowed the space given it back. +function GuiEditorUndoAction::addFrameFix(%this, %frameSet, %before, %after) +{ + %i = %this.frameCount; + %this.frameSet[%i] = %frameSet; + %this.frameBefore[%i] = %before; + %this.frameAfter[%i] = %after; + %this.frameCount = %i + 1; +} + +//----------------------------------------------------------------------------- +// Recording. +//----------------------------------------------------------------------------- + +function GuiEditorUndoAction::addFieldOp(%this, %ctrl, %field, %before, %after, %isDynamic) +{ + %i = %this.opCount; + %this.opType[%i] = %isDynamic ? "dynamic" : "field"; + %this.opCtrl[%i] = %ctrl; + %this.opField[%i] = %field; + %this.opBefore[%i] = %before; + %this.opAfter[%i] = %after; + %this.opCount = %i + 1; +} + +function GuiEditorUndoAction::addMoveOp(%this, %ctrl, %oldParent, %oldIndex, %newParent, %newIndex) +{ + %i = %this.opCount; + %this.opType[%i] = "move"; + %this.opCtrl[%i] = %ctrl; + %this.opOldParent[%i] = %oldParent; + %this.opOldIndex[%i] = %oldIndex; + %this.opNewParent[%i] = %newParent; + %this.opNewIndex[%i] = %newIndex; + %this.opCount = %i + 1; +} + +// %before and %after are lists of child ids. The parent is the op's object. +function GuiEditorUndoAction::addOrderOp(%this, %parent, %before, %after) +{ + %i = %this.opCount; + %this.opType[%i] = "order"; + %this.opCtrl[%i] = %parent; + %this.opBefore[%i] = %before; + %this.opAfter[%i] = %after; + %this.opCount = %i + 1; +} + +// %before and %after are whole item lists, from GuiListBoxCtrl::getItemList. +// Like an order op it names no field, so it holds the same two slots a field op +// does and findOp matches it on the object alone. +function GuiEditorUndoAction::addItemsOp(%this, %ctrl, %before, %after) +{ + %i = %this.opCount; + %this.opType[%i] = "items"; + %this.opCtrl[%i] = %ctrl; + %this.opBefore[%i] = %before; + %this.opAfter[%i] = %after; + %this.opCount = %i + 1; +} + +function GuiEditorUndoAction::isEmpty(%this) +{ + return %this.opCount <= 0; +} + +// Name a control the user should be looking at after this action replays, for +// the cases where the ops do not say it themselves - an order op names the +// parent whose children were rearranged, not the control that was dragged. +function GuiEditorUndoAction::noteTouched(%this, %ctrl) +{ + if(!isObject(%ctrl) || %this.listHas(%this.touchList, %ctrl)) + { + return; + } + + %this.touchList = (%this.touchList $= "") ? %ctrl : (%this.touchList SPC %ctrl); +} + +// Fold another action's ops into this one, for coalescing a run of nudges into +// the single move the user thinks they made. An op that touches something this +// action already touched updates that op's after value and keeps its before - +// so ten nudges read as one move from where the control started to where it +// ended up. Anything new is appended. +function GuiEditorUndoAction::mergeFrom(%this, %other) +{ + for(%i = 0; %i < %other.opCount; %i++) + { + %found = %this.findOp(%other.opType[%i], %other.opCtrl[%i], %other.opField[%i]); + if(%found == -1) + { + if(%other.opType[%i] $= "move") + { + %this.addMoveOp(%other.opCtrl[%i], %other.opOldParent[%i], %other.opOldIndex[%i], + %other.opNewParent[%i], %other.opNewIndex[%i]); + } + else if(%other.opType[%i] $= "order") + { + %this.addOrderOp(%other.opCtrl[%i], %other.opBefore[%i], %other.opAfter[%i]); + } + else if(%other.opType[%i] $= "items") + { + %this.addItemsOp(%other.opCtrl[%i], %other.opBefore[%i], %other.opAfter[%i]); + } + else + { + %this.addFieldOp(%other.opCtrl[%i], %other.opField[%i], %other.opBefore[%i], + %other.opAfter[%i], %other.opType[%i] $= "dynamic"); + } + continue; + } + + if(%other.opType[%i] $= "move") + { + %this.opNewParent[%found] = %other.opNewParent[%i]; + %this.opNewIndex[%found] = %other.opNewIndex[%i]; + } + else if(%other.opType[%i] $= "order") + { + %this.opAfter[%found] = %other.opAfter[%i]; + } + else + { + %this.opAfter[%found] = %other.opAfter[%i]; + } + } +} + +function GuiEditorUndoAction::findOp(%this, %type, %ctrl, %field) +{ + for(%i = 0; %i < %this.opCount; %i++) + { + if(%this.opType[%i] !$= %type || %this.opCtrl[%i] != %ctrl) + { + continue; + } + + // A move, an order or an items op names no field, so matching the object + // - the control for one, the parent for the other - is the whole test. + if(%type $= "move" || %type $= "order" || %type $= "items" || + %this.opField[%i] $= %field) + { + return %i; + } + } + + return -1; +} + +// Every control this action names, once each, in the order they were recorded. +// The recorder re-selects these after a replay so the user can see what a +// Ctrl+Z did. +function GuiEditorUndoAction::touched(%this) +{ + if(%this.touchList !$= "") + { + return %this.touchList; + } + + %list = ""; + for(%i = 0; %i < %this.opCount; %i++) + { + %ctrl = %this.opCtrl[%i]; + if(%ctrl $= "" || %this.listHas(%list, %ctrl)) + { + continue; + } + %list = (%list $= "") ? %ctrl : (%list SPC %ctrl); + } + + return %list; +} + +function GuiEditorUndoAction::listHas(%this, %list, %item) +{ + for(%i = 0; %i < getWordCount(%list); %i++) + { + if(getWord(%list, %i) == %item) + { + return true; + } + } + + return false; +} + +//----------------------------------------------------------------------------- +// Replaying. Called by the UndoManager (collection/undo.h, UndoScriptAction). +//----------------------------------------------------------------------------- + +function GuiEditorUndoAction::undo(%this) +{ + %this.replay(false); +} + +function GuiEditorUndoAction::redo(%this) +{ + %this.replay(true); +} + +// Backwards for an undo: the ops were recorded in the order they happened, and +// unwinding them in that same order would replay a control's second write +// before its first. +function GuiEditorUndoAction::replay(%this, %forward) +{ + // The recorder that built this action, handed over when it did. It outlives + // the stack it filled, but check anyway: an action can still be replayed + // while the editor is being torn down around it. + // With the direction, because the recorder tracks which state the document is + // in and that is the only thing that says which way it just moved. + if(isObject(%this.recorder)) + { + %this.recorder.noteReplay(%this, %forward); + } + + if(%forward) + { + for(%i = 0; %i < %this.opCount; %i++) + { + %this.applyOp(%i, true); + } + } + else + { + for(%i = %this.opCount - 1; %i >= 0; %i--) + { + %this.applyOp(%i, false); + } + } + + %this.applyLayoutFixes(%forward); + %this.applyFrameFixes(%forward); + %this.notifyParents(%forward); +} + +// After the layout fixes, because rebuilding the tree lays the children out +// from their frames -- which is the frame set's answer, and it outranks the +// control's own. +function GuiEditorUndoAction::applyFrameFixes(%this, %forward) +{ + for(%i = 0; %i < %this.frameCount; %i++) + { + %frameSet = %this.frameSet[%i]; + if(!isObject(%frameSet)) + { + continue; + } + + %frameSet.setFrameLayout(%forward ? %this.frameAfter[%i] : %this.frameBefore[%i]); + } +} + +function GuiEditorUndoAction::applyLayoutFixes(%this, %forward) +{ + for(%i = 0; %i < %this.fixCount; %i++) + { + %ctrl = %this.fixCtrl[%i]; + if(!isObject(%ctrl)) + { + continue; + } + + %layout = %forward ? %this.fixAfter[%i] : %this.fixBefore[%i]; + + // The sizing modes first: a control still set to center or fill + // overrides any position or extent written to it, so writing the + // geometry while the old mode is still on would not stick. + %ctrl.setEditFieldValue("HorizSizing", getField(%layout, 2)); + %ctrl.setEditFieldValue("VertSizing", getField(%layout, 3)); + %ctrl.setEditFieldValue("Position", getField(%layout, 0)); + %ctrl.setEditFieldValue("Extent", getField(%layout, 1)); + } +} + +// A container lays its children out in list order, and the SimSet ordering +// methods change that order without telling it - which is why a control put +// back into a chain kept the slot it briefly held at the end of the list. +// Adding and removing a child notify on their own; being rearranged does not. +// +// Only the parent the control ends up in needs telling: the one it came from +// hears about it through onChildRemoved. +function GuiEditorUndoAction::notifyParents(%this, %forward) +{ + %told = ""; + + for(%i = 0; %i < %this.opCount; %i++) + { + %type = %this.opType[%i]; + if(%type $= "order") + { + %parent = %this.opCtrl[%i]; + } + else if(%type $= "move") + { + %parent = %forward ? %this.opNewParent[%i] : %this.opOldParent[%i]; + } + else + { + continue; + } + + // The trash is a plain SimGroup, and lays nothing out. + if(!isObject(%parent) || !%parent.isMemberOfClass("GuiControl") || + %this.listHas(%told, %parent)) + { + continue; + } + + %told = (%told $= "") ? %parent : (%told SPC %parent); + %parent.childrenReordered(); + } +} + +function GuiEditorUndoAction::applyOp(%this, %i, %forward) +{ + %ctrl = %this.opCtrl[%i]; + if(!isObject(%ctrl)) + { + return; + } + + %type = %this.opType[%i]; + + // Rebuilding a list: adding each child in turn and pushing it to the back + // leaves the parent holding exactly the recorded order. add() is what makes + // this cover reparenting too - a control listed by one parent is taken from + // whichever other one currently holds it - and it means the order the ops + // replay in cannot matter, since every control appears in exactly one list. + if(%type $= "order") + { + %list = %forward ? %this.opAfter[%i] : %this.opBefore[%i]; + for(%w = 0; %w < getWordCount(%list); %w++) + { + %child = getWord(%list, %w); + if(!isObject(%child)) + { + continue; + } + %ctrl.add(%child); + %ctrl.pushToBack(%child); + } + return; + } + + // A list box or drop down's static rows, whole. setItemList replaces the lot + // and resizes the control, which is the same thing the pane's every gesture + // does, so a replay needs nothing else. + if(%type $= "items") + { + %ctrl.setItemList(%forward ? %this.opAfter[%i] : %this.opBefore[%i]); + return; + } + + if(%type $= "move") + { + %parent = %forward ? %this.opNewParent[%i] : %this.opOldParent[%i]; + %index = %forward ? %this.opNewIndex[%i] : %this.opOldIndex[%i]; + if(isObject(%parent)) + { + %this.placeAt(%parent, %ctrl, %index); + } + return; + } + + %value = %forward ? %this.opAfter[%i] : %this.opBefore[%i]; + + // A profile field holds an id, and a Profile Editor revert frees and reloads + // the theme's profiles - so an id recorded before that no longer resolves. + // The recorder clears the stack on those paths; this is the belt to its + // braces, because writing a dead id would put the control on nothing. + if(%ctrl.getFieldType(%this.opField[%i]) $= "GuiProfile" && !isObject(%value)) + { + warn("Gui Editor: skipping undo of " @ %this.opField[%i] @ " - the profile it named is gone."); + return; + } + + if(%type $= "dynamic") + { + %ctrl.setFieldValue(%this.opField[%i], %value); + return; + } + + %ctrl.setEditFieldValue(%this.opField[%i], %value); +} + +// Put %ctrl in %parent at %index, which is the whole of what a move op does. +// An index of -1 means "wherever" - used for the trash, where order is not a +// thing anyone can see. +// +// add() is a no-op when the control is already a child (SimGroup::addObject +// returns early on obj->mGroup == this), so the same call covers a reparent and +// a reorder within one parent. reorderChild inserts before its target, which is +// why moving down the list has to aim one past the index: erasing the control +// first shifts everything below it up by one. +function GuiEditorUndoAction::placeAt(%this, %parent, %ctrl, %index) +{ + %parent.add(%ctrl); + + if(%index < 0) + { + return; + } + + %count = %parent.getCount(); + if(%index >= %count - 1) + { + %parent.pushToBack(%ctrl); + return; + } + + %current = %this.indexOf(%parent, %ctrl); + if(%current == %index || %current == -1) + { + return; + } + + %target = (%current > %index) ? %parent.getObject(%index) : %parent.getObject(%index + 1); + %parent.reorderChild(%ctrl, %target); +} + +function GuiEditorUndoAction::indexOf(%this, %parent, %ctrl) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + if(%parent.getObject(%i) == %ctrl) + { + return %i; + } + } + + return -1; +} diff --git a/editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs b/editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs new file mode 100644 index 000000000..8241c8734 --- /dev/null +++ b/editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs @@ -0,0 +1,1052 @@ + +//----------------------------------------------------------------------------- +// The Gui Editor's undo recorder. Owned by GuiEditor; the only thing in the +// editor that touches the UndoManager, and the only thing that builds a +// GuiEditorUndoAction. +// +// Everything that changes the Gui being authored comes through here, by one of +// three routes: +// +// writeField / writeDynamicField a field write. The recorder does the +// writing, so a write that is not recorded +// is a write that did not happen. +// beginGesture / endGesture a gesture with the mouse on the canvas: a +// drag-move or a handle-resize, which the +// C++ brackets with onPreEdit/onPostEdit +// and which can reparent as well as move. +// snapshot + commitGeometry everything else whose result is only +// known once it is over - a run of arrow +// key nudges (bracketed in turn by +// onPreSelectionNudged/...), an align, a +// resize from the Layout menu. +// +// The C++ edit control has always made all of those callbacks; they are what +// they were always for. +// +// A transaction groups whatever happens between begin() and end() into one +// action, so that a gesture the user made once is one Ctrl+Z. Transactions +// nest: the outermost owns the action, and a write outside any transaction +// gets one of its own. +// +// Records name objects by id, so the stack has to be dropped whenever the +// document or the profiles under it are replaced. See GuiEditor::NewGui, +// DisplayGuiContent and detachTheme. +//----------------------------------------------------------------------------- + +function GuiEditorUndoRecorder::onAdd(%this) +{ + %this.depth = 0; + %this.pending = 0; + %this.pendingKind = ""; + %this.suspended = false; + %this.lastAction = 0; + %this.lastKind = ""; + %this.snapCount = 0; + %this.gestCount = 0; + %this.inGesture = false; + + // Both zero: a recorder that has recorded nothing is sitting on a document + // nobody has changed. See the serials section below. + %this.serialCounter = 0; + %this.editSerial = 0; + %this.savedSerial = 0; + + %this.hierCount = 0; + %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) +{ + // A transaction still open at teardown owns an action nobody will ever + // push, and an action that never reached a manager is nobody else's to + // free. + if(isObject(%this.pending)) + { + %this.pending.delete(); + %this.pending = 0; + } +} + +// The manager is a member of the C++ edit control (guiEditCtrl.cc), registered +// with it and freed with it, so it is asked for rather than held. +function GuiEditorUndoRecorder::manager(%this) +{ + if(!isObject(%this.owner) || !isObject(%this.owner.brain)) + { + return 0; + } + + return %this.owner.brain.getUndoManager(); +} + +//----------------------------------------------------------------------------- +// Transactions. +//----------------------------------------------------------------------------- + +// %kind is only read by the coalescing test; "" means "never merge this with +// anything". +function GuiEditorUndoRecorder::begin(%this, %name, %kind) +{ + if(%this.suspended) + { + return; + } + + %this.depth++; + if(%this.depth > 1) + { + return; + } + + %this.pending = new UndoScriptAction() + { + class = "GuiEditorUndoAction"; + actionName = (%name $= "") ? "Edit" : %name; + recorder = %this; + }; + %this.pendingKind = %kind; +} + +function GuiEditorUndoRecorder::end(%this) +{ + if(%this.suspended || %this.depth <= 0) + { + return; + } + + %this.depth--; + if(%this.depth > 0) + { + return; + } + + %action = %this.pending; + %this.pending = 0; + if(!isObject(%action)) + { + return; + } + + // Nothing happened between the begin and the end: a click that selected + // without moving, a text box the user only tabbed through, a commit whose + // value was already what it is. Not an undo step. + if(%action.isEmpty()) + { + %action.delete(); + return; + } + + if(%this.canCoalesce(%action)) + { + %this.lastAction.mergeFrom(%action); + %action.delete(); + + // The surviving action now ends somewhere new, so it needs a serial that + // has never been seen. Its priorSerial is left alone: undoing it still + // takes the document back to before the whole run. + %this.lastAction.serial = %this.nextSerial(); + %this.moveTo(%this.lastAction.serial); + return; + } + + // Now that the operation is over, what any frame set involved in it ended up + // looking like. + %this.emitFrameFixes(%action); + + // Both ends recorded before the document is said to have moved: priorSerial + // is where it was standing, serial is where this action puts it. + %action.priorSerial = %this.editSerial; + %action.serial = %this.nextSerial(); + + %action.addToManager(%this.manager()); + %this.lastAction = %action; + %this.lastKind = %this.pendingKind; + %this.moveTo(%action.serial); + %this.refreshMenu(); +} + +// Holding an arrow key down is one move, not thirty. Only a like-for-like +// repeat merges: the same kind of gesture, touching exactly the same controls, +// with nothing in between. lastAction is dropped by any replay and any clear, +// so a merge can never reach an action that is no longer on top of the stack. +function GuiEditorUndoRecorder::canCoalesce(%this, %action) +{ + if(%this.pendingKind $= "" || %this.pendingKind !$= %this.lastKind) + { + return false; + } + + if(!isObject(%this.lastAction)) + { + return false; + } + + return %this.lastAction.touched() $= %action.touched(); +} + +//----------------------------------------------------------------------------- +// Serials: whether the document has changed since it was last saved. +// +// The recorder answers this because it is already the one thing every change +// goes through, so an edit that is not recorded is an edit that did not happen. +// +// It cannot be answered by counting. UndoManager offers getUndoCount, +// getRedoCount and the two name lookups and nothing else -- there is no way to +// ask which action is on top -- and a count is not an identity anyway: save at a +// depth of five, undo once, make a DIFFERENT edit, and the depth is five again +// with a different document underneath it. A flag built on depth reads clean +// there, which is the one direction it must never be wrong in. +// +// So every action carries two numbers instead. serial is where the document +// stands once that action has been applied; priorSerial is where it stood +// before. Both come from a counter that never repeats, so a value identifies a +// state rather than a position: +// +// undo of A -> the document is at A.priorSerial +// redo of A -> the document is at A.serial +// +// and editSerial is wherever the document is now. markClean records it, +// isModified compares against it. Undo back to the state that was saved and the +// two match again; branch off differently and they cannot, because the new +// action's serial has never existed before. +// +// Nothing here mirrors the C++ stack, so nothing can drift out of step with it. +// An action trimmed off the bottom of the manager's stack simply never replays, +// so its serial never comes back -- the safe direction. +// +// clear() taking a fresh serial is what keeps GuiEditor::detachTheme honest: it +// empties the stack because every record names a profile about to be freed, and +// the controls are exactly as edited afterwards as they were before. +//----------------------------------------------------------------------------- + +function GuiEditorUndoRecorder::nextSerial(%this) +{ + %this.serialCounter++; + return %this.serialCounter; +} + +// Say where the document now stands, and tell whoever is showing that. +function GuiEditorUndoRecorder::moveTo(%this, %serial) +{ + %this.editSerial = %serial; + + if(isObject(%this.owner)) + { + %this.owner.refreshDocumentTitle(); + } +} + +// This is the document as it now stands on disk. Called after a save, and after +// a new or freshly opened document, which are clean by definition. +function GuiEditorUndoRecorder::markClean(%this) +{ + %this.savedSerial = %this.editSerial; + + if(isObject(%this.owner)) + { + %this.owner.refreshDocumentTitle(); + } +} + +function GuiEditorUndoRecorder::isModified(%this) +{ + return %this.editSerial != %this.savedSerial; +} + +//----------------------------------------------------------------------------- +// Writes. The recorder does the writing so that recording cannot be forgotten. +//----------------------------------------------------------------------------- + +// setEditFieldValue rather than a plain assignment: it brackets the write with +// inspectPreApply / inspectPostApply, which sleeps and wakes the control (so a +// re-profile releases the old profile and takes a count on the new one) and +// runs the protected-field setters - Position and Extent are protected fields +// whose setters call setPosition/setExtent. +function GuiEditorUndoRecorder::writeField(%this, %ctrl, %field, %value) +{ + if(!isObject(%ctrl)) + { + return; + } + + %before = %this.fieldSnapshot(%ctrl, %field); + %ctrl.setEditFieldValue(%field, %value); + %this.recordField(%ctrl, %field, %before, %this.fieldSnapshot(%ctrl, %field), false); +} + +function GuiEditorUndoRecorder::writeDynamicField(%this, %ctrl, %field, %value) +{ + if(!isObject(%ctrl)) + { + return; + } + + %before = %ctrl.getFieldValue(%field); + %ctrl.setFieldValue(%field, %value); + %this.recordField(%ctrl, %field, %before, %ctrl.getFieldValue(%field), true); +} + +// A list box or drop down's static rows. Not a field write, because the rows are +// not a field: they are TAML custom nodes, and getItemList/setItemList is the +// only handle script has on all of them at once - the same arrangement a frame +// set's layout has. +// +// The whole list at each end rather than the one row that changed. Every gesture +// the Items pane offers - add, remove, move up, move down, retype a caption - +// can shift what is around it, so a per-row record would have to know which of +// them it was. A list is short, and restoring one cannot be got subtly wrong. +function GuiEditorUndoRecorder::writeItems(%this, %ctrl, %list) +{ + if(!isObject(%ctrl)) + { + return; + } + + %before = %ctrl.getItemList(); + %ctrl.setItemList(%list); + %after = %ctrl.getItemList(); + + if(%this.suspended || strcmp(%before, %after) == 0) + { + return; + } + + %owned = %this.autoBegin("Change Items"); + if(isObject(%this.pending)) + { + %this.pending.addItemsOp(%ctrl, %before, %after); + } + %this.autoEnd(%owned); +} + +// What the field holds, in the form an undo should write back. +// +// A profile slot is read as an id, never the name the field reads back: the +// editor runs in editor mode, where SimObject::assignName stashes a new +// object's name instead of registering it, so a profile made this session +// cannot be found by name. GuiEditorThemeApplier::fieldProfile already knows +// how to resolve one either way. +// +// A control's own name is asked of the object for the same reason - +// getFieldValue answers "" for it in editor mode. +function GuiEditorUndoRecorder::fieldSnapshot(%this, %ctrl, %field) +{ + if(%ctrl.getFieldType(%field) $= "GuiProfile") + { + %profile = %this.owner.themeApplier.fieldProfile(%ctrl, %field); + return isObject(%profile) ? %profile.getId() : ""; + } + + if(%field $= "name") + { + return %ctrl.getName(); + } + + return %ctrl.getFieldValue(%field); +} + +// Reading the value back after the write, rather than trusting what was asked +// for, is what keeps redo faithful: the engine normalises plenty of them - a +// bool, a point, a profile written by id and read back by name. +function GuiEditorUndoRecorder::recordField(%this, %ctrl, %field, %before, %after, %isDynamic) +{ + if(%this.suspended || !isObject(%ctrl)) + { + return; + } + + // strcmp, not $=: $= runs dStricmp, so recasing a caption would read as no + // change at all and vanish from the stack. + if(strcmp(%before, %after) == 0) + { + return; + } + + %owned = %this.autoBegin("Change " @ %field); + if(isObject(%this.pending)) + { + %this.pending.addFieldOp(%ctrl, %field, %before, %after, %isDynamic); + } + %this.autoEnd(%owned); +} + +//----------------------------------------------------------------------------- +// Structure: a control changing parent, index, or both. +//----------------------------------------------------------------------------- + +// Called after the control has arrived. Undoing an add moves the control to +// the trash rather than deleting it, which is what leaves redo something to +// put back. +function GuiEditorUndoRecorder::recordAdd(%this, %ctrl, %name) +{ + if(%this.suspended || !isObject(%ctrl)) + { + return; + } + + %parent = %ctrl.getParent(); + if(!isObject(%parent)) + { + return; + } + + %this.watchFrameSet(%parent); + + %owned = %this.autoBegin((%name $= "") ? "Add Control" : %name); + if(isObject(%this.pending)) + { + %this.pending.addMoveOp(%ctrl, %this.trash(), -1, %parent, %this.indexOf(%parent, %ctrl)); + + // The layout it arrived with, which is what a redo has to put back: a + // container that places its own children will have taken it once + // already. Both ends are the same value - undo sends the control to the + // trash, where its layout is nobody's business. + %layout = %this.layoutOf(%ctrl); + %this.pending.addLayoutFix(%ctrl, %layout, %layout); + } + %this.autoEnd(%owned); +} + +// Called before the control is trashed - the C++ fires onTrashSelection ahead +// of the move for this reason - because where it came from is exactly what is +// about to be lost. +function GuiEditorUndoRecorder::recordDelete(%this, %ctrl, %name) +{ + if(%this.suspended || !isObject(%ctrl)) + { + return; + } + + %parent = %ctrl.getParent(); + if(!isObject(%parent)) + { + return; + } + + // Before the trash move, which is the last moment the frame it stands in + // still exists. + %this.watchFrameSet(%parent); + + %owned = %this.autoBegin((%name $= "") ? "Delete Control" : %name); + if(isObject(%this.pending)) + { + %this.pending.addMoveOp(%ctrl, %parent, %this.indexOf(%parent, %ctrl), %this.trash(), -1); + + // How it was laid out, which the parent will take from it the moment it + // is put back, if the parent is one that places its own children. + %layout = %this.layoutOf(%ctrl); + %this.pending.addLayoutFix(%ctrl, %layout, %layout); + } + %this.autoEnd(%owned); +} + +// A whole selection on its way to the trash, recorded highest index first. +// +// Order is load-bearing. An undo replays the ops backwards, so recording them +// in descending index order restores them in ascending order - and restoring +// low to high is the only order that lands them all where they were, because +// putting a control back at index 3 shifts everything from 3 down one place. +function GuiEditorUndoRecorder::recordDeleteSelection(%this, %selection) +{ + if(%this.suspended || !isObject(%selection)) + { + return; + } + + %count = %selection.getCount(); + for(%i = 0; %i < %count; %i++) + { + %list[%i] = %selection.getObject(%i); + } + + %this.begin("Delete Control", ""); + for(%out = 0; %out < %count; %out++) + { + %pick = -1; + for(%i = 0; %i < %count; %i++) + { + if(%list[%i] $= "" || !isObject(%list[%i])) + { + continue; + } + if(%pick == -1 || %this.parentIndexOf(%list[%i]) > %this.parentIndexOf(%list[%pick])) + { + %pick = %i; + } + } + + if(%pick == -1) + { + break; + } + + %this.recordDelete(%list[%pick], ""); + %list[%pick] = ""; + } + %this.end(); +} + +function GuiEditorUndoRecorder::parentIndexOf(%this, %ctrl) +{ + %parent = %ctrl.getParent(); + return isObject(%parent) ? %this.indexOf(%parent, %ctrl) : -1; +} + +// Called after the move, with where the control used to be. +function GuiEditorUndoRecorder::recordMove(%this, %ctrl, %oldParent, %oldIndex, %name) +{ + if(%this.suspended || !isObject(%ctrl) || !isObject(%oldParent)) + { + return; + } + + %parent = %ctrl.getParent(); + if(!isObject(%parent)) + { + return; + } + + %index = %this.indexOf(%parent, %ctrl); + if(%parent == %oldParent && %index == %oldIndex) + { + return; + } + + %owned = %this.autoBegin((%name $= "") ? "Move Control" : %name); + if(isObject(%this.pending)) + { + %this.pending.addMoveOp(%ctrl, %oldParent, %oldIndex, %parent, %index); + } + %this.autoEnd(%owned); +} + +//----------------------------------------------------------------------------- +// Frame sets, the one container that keeps a layout of its own beside the child +// list. Removing a control destroys the frame it stood in and merges the split +// into its twin, so the tree has to be kept before the removal and put back +// after -- getFrameLayout / setFrameLayout, which exist for this. +// +// The tree afterwards is only knowable once the operation is over, so what is +// taken here is the before, and the after is read when the transaction closes. +//----------------------------------------------------------------------------- + +function GuiEditorUndoRecorder::watchFrameSet(%this, %parent) +{ + if(%this.suspended || !isObject(%parent) || !%parent.isMemberOfClass("GuiFrameSetCtrl")) + { + return; + } + + for(%i = 0; %i < %this.watchCount; %i++) + { + if(%this.watchSet[%i] == %parent) + { + return; + } + } + + %i = %this.watchCount; + %this.watchSet[%i] = %parent; + %this.watchBefore[%i] = %parent.getFrameLayout(); + %this.watchCount = %i + 1; +} + +function GuiEditorUndoRecorder::emitFrameFixes(%this, %action) +{ + for(%i = 0; %i < %this.watchCount; %i++) + { + %frameSet = %this.watchSet[%i]; + if(isObject(%frameSet)) + { + %action.addFrameFix(%frameSet, %this.watchBefore[%i], %frameSet.getFrameLayout()); + } + } + + %this.watchCount = 0; +} + +// Everything a container can take from a child when it arrives, in the order +// GuiEditorUndoAction::applyLayoutFixes reads it back out. TAB-delimited +// because a position has a space in it. +function GuiEditorUndoRecorder::layoutOf(%this, %ctrl) +{ + return %ctrl.getPosition() TAB %ctrl.getExtent() TAB + %ctrl.getFieldValue("HorizSizing") TAB %ctrl.getFieldValue("VertSizing"); +} + +// The edit control's trash: a SimGroup it owns and never empties, which is +// where deleteSelection puts controls instead of deleting them. +function GuiEditorUndoRecorder::trash(%this) +{ + return %this.owner.brain.getTrash(); +} + +function GuiEditorUndoRecorder::indexOf(%this, %parent, %ctrl) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + if(%parent.getObject(%i) == %ctrl) + { + return %i; + } + } + + return -1; +} + +// A record made outside any transaction is its own undo step. Returns whether +// it opened one, so the matching end only fires for the one that did. +function GuiEditorUndoRecorder::autoBegin(%this, %name) +{ + if(%this.depth > 0) + { + return false; + } + + %this.begin(%name, ""); + return true; +} + +function GuiEditorUndoRecorder::autoEnd(%this, %owned) +{ + if(%owned) + { + %this.end(); + } +} + +//----------------------------------------------------------------------------- +// Geometry, which is only known to have changed once the gesture is over. +//----------------------------------------------------------------------------- + +// %selection is the edit control's selected set, which is one reused SimSet +// (guiEditCtrl.cc, updateSelectedSet) - so its contents are copied out here +// rather than the set being held on to. +function GuiEditorUndoRecorder::snapshot(%this, %selection) +{ + %this.snapCount = 0; + + // Nothing to do while a canvas gesture is running: the moves inside one are + // the engine's way of dragging, not edits of their own, and the gesture keeps + // its own snapshot of where everything started. See beginGesture. + if(%this.suspended || %this.inGesture || !isObject(%selection)) + { + return; + } + + for(%i = 0; %i < %selection.getCount(); %i++) + { + %ctrl = %selection.getObject(%i); + %this.snapCtrl[%i] = %ctrl; + %this.snapPos[%i] = %ctrl.getPosition(); + %this.snapExt[%i] = %ctrl.getExtent(); + } + + %this.snapCount = %selection.getCount(); +} + +// Diff against the snapshot and push whatever actually moved. A mouse-down +// that selected without dragging, or a nudge into a wall, records nothing. +function GuiEditorUndoRecorder::commitGeometry(%this, %name, %kind) +{ + if(%this.suspended || %this.inGesture || %this.snapCount <= 0) + { + return; + } + + // An empty name means "say what happened". The C++ knows whether the gesture + // was a drag or a resize (mMouseDownMode) but does not pass it, and the + // snapshot can answer it anyway. + if(%name $= "") + { + %name = "Move Control"; + for(%i = 0; %i < %this.snapCount; %i++) + { + %ctrl = %this.snapCtrl[%i]; + if(isObject(%ctrl) && strcmp(%this.snapExt[%i], %ctrl.getExtent()) != 0) + { + %name = "Resize Control"; + break; + } + } + } + + %this.begin(%name, %kind); + for(%i = 0; %i < %this.snapCount; %i++) + { + %ctrl = %this.snapCtrl[%i]; + if(!isObject(%ctrl)) + { + continue; + } + + %this.recordField(%ctrl, "Position", %this.snapPos[%i], %ctrl.getPosition(), false); + %this.recordField(%ctrl, "Extent", %this.snapExt[%i], %ctrl.getExtent(), false); + } + %this.end(); + + %this.snapCount = 0; +} + +//----------------------------------------------------------------------------- +// A canvas gesture: a drag-move or a handle-resize, which the C++ brackets with +// onPreEdit and onPostEdit. +// +// The whole gesture is one action taken from one snapshot -- where each selected +// control stood when the mouse went down, and where it stands when the mouse +// comes up. The nudge callbacks that arrive in between are ignored for the +// duration: guiEditCtrl.cc drags by calling moveSelection once per mouse-move +// event, and each of those brackets itself with the nudge pair, so left to +// themselves they record a hundred small moves that then have to coalesce back +// into the one move the user made. +// +// Ignoring them is also what makes a drag across a container boundary come out +// right. Halfway through the gesture the C++ reparents the selection into +// whatever container is under the cursor and rewrites each control's position to +// keep it there (onTouchDragged -> moveSelectionToCtrl), announcing neither -- so +// a record built frame by frame ends up holding a position that means something +// only inside the parent the control has just left, and no record of the parent +// at all. Undo then put the control somewhere it had never been. +//----------------------------------------------------------------------------- + +function GuiEditorUndoRecorder::beginGesture(%this, %selection) +{ + %this.gestCount = 0; + %this.inGesture = true; + + if(%this.suspended || !isObject(%selection)) + { + return; + } + + for(%i = 0; %i < %selection.getCount(); %i++) + { + %ctrl = %selection.getObject(%i); + %this.gestCtrl[%i] = %ctrl; + %this.gestPos[%i] = %ctrl.getPosition(); + %this.gestExt[%i] = %ctrl.getExtent(); + %this.gestLayout[%i] = %this.layoutOf(%ctrl); + %this.gestParent[%i] = %ctrl.getParent(); + %this.gestIndex[%i] = %this.parentIndexOf(%ctrl); + } + + %this.gestCount = %selection.getCount(); +} + +function GuiEditorUndoRecorder::endGesture(%this) +{ + %this.inGesture = false; + + if(%this.suspended || %this.gestCount <= 0) + { + return; + } + + %this.begin(%this.gestureName(), ""); + for(%i = 0; %i < %this.gestCount; %i++) + { + %ctrl = %this.gestCtrl[%i]; + if(!isObject(%ctrl)) + { + continue; + } + + // A control that changed parent has its geometry put back by the layout + // fix rather than by a field op. The fix carries the sizing modes as well + // as the bounds and is applied after every op has run, so it is the one + // that wins where the container places its own children -- and one writer + // per control is one less pair of records that can disagree. + if(%ctrl.getParent() != %this.gestParent[%i]) + { + %this.watchFrameSet(%this.gestParent[%i]); + %this.watchFrameSet(%ctrl.getParent()); + + %this.recordMove(%ctrl, %this.gestParent[%i], %this.gestIndex[%i], ""); + if(isObject(%this.pending)) + { + %this.pending.addLayoutFix(%ctrl, %this.gestLayout[%i], %this.layoutOf(%ctrl)); + } + continue; + } + + %this.recordField(%ctrl, "Position", %this.gestPos[%i], %ctrl.getPosition(), false); + %this.recordField(%ctrl, "Extent", %this.gestExt[%i], %ctrl.getExtent(), false); + } + %this.end(); + + %this.gestCount = 0; +} + +// What the gesture turned out to be, which is only knowable now it is over. The +// C++ knows whether the mouse took a handle or the body (mMouseDownMode) but +// does not pass it, and the snapshot can answer anyway. +function GuiEditorUndoRecorder::gestureName(%this) +{ + %name = "Move Control"; + + for(%i = 0; %i < %this.gestCount; %i++) + { + %ctrl = %this.gestCtrl[%i]; + if(!isObject(%ctrl)) + { + continue; + } + + if(%ctrl.getParent() != %this.gestParent[%i]) + { + return "Reparent Control"; + } + + if(strcmp(%this.gestExt[%i], %ctrl.getExtent()) != 0) + { + %name = "Resize Control"; + } + } + + return %name; +} + +//----------------------------------------------------------------------------- +// Hierarchy, for a rearrangement whose shape is not known until it is over. +// +// A drag in the Explorer tree can move any number of selected controls into any +// number of parents at once (GuiTreeViewCtrl::reorderFromDrag), so rather than +// try to follow it, the whole document's shape is remembered before and read +// again after. What changed is then whichever parents' child lists differ. +//----------------------------------------------------------------------------- + +function GuiEditorUndoRecorder::snapshotHierarchy(%this, %root) +{ + %this.hierCount = 0; + %this.hierCtrlCount = 0; + + // Taken here rather than in commitHierarchy: a drag that pulls a control out + // of a frame set destroys the frame on the way, so by the time the move is + // over the tree it came from is already gone. + %this.watchCount = 0; + + if(%this.suspended || !isObject(%root)) + { + return; + } + + %this.hierWalk(%root); +} + +function GuiEditorUndoRecorder::hierWalk(%this, %parent) +{ + %n = %this.hierCount; + %this.hierParent[%n] = %parent; + %this.hierList[%n] = %this.childList(%parent); + %this.hierCount = %n + 1; + + for(%i = 0; %i < %parent.getCount(); %i++) + { + %ctrl = %parent.getObject(%i); + + // Per control as well as per parent: the lists are what gets restored, + // but they cannot say which control the user actually dragged, and that + // is what the selection should land on afterwards. + %c = %this.hierCtrlCount; + %this.hierCtrl[%c] = %ctrl; + %this.hierCtrlParent[%c] = %parent; + %this.hierCtrlIndex[%c] = %i; + %this.hierCtrlLayout[%c] = %this.layoutOf(%ctrl); + %this.hierCtrlCount = %c + 1; + + // Every frame set in the document, whether the drag turns out to touch + // it or not: which ones it touches is not known until it is over, and by + // then their trees have already changed. + %this.watchFrameSet(%ctrl); + + %this.hierWalk(%ctrl); + } +} + +function GuiEditorUndoRecorder::childList(%this, %parent) +{ + %list = ""; + for(%i = 0; %i < %parent.getCount(); %i++) + { + %child = %parent.getObject(%i); + %list = (%list $= "") ? %child : (%list SPC %child); + } + + return %list; +} + +function GuiEditorUndoRecorder::commitHierarchy(%this, %name) +{ + if(%this.suspended || %this.hierCount <= 0) + { + return; + } + + %this.begin(%name, ""); + + for(%i = 0; %i < %this.hierCount; %i++) + { + %parent = %this.hierParent[%i]; + if(!isObject(%parent)) + { + continue; + } + + %now = %this.childList(%parent); + if(%this.hierList[%i] $= %now) + { + continue; + } + + %this.pending.addOrderOp(%parent, %this.hierList[%i], %now); + } + + // Which controls moved, for the selection after a replay. + if(isObject(%this.pending) && !%this.pending.isEmpty()) + { + for(%i = 0; %i < %this.hierCtrlCount; %i++) + { + %ctrl = %this.hierCtrl[%i]; + if(!isObject(%ctrl)) + { + continue; + } + + %parent = %ctrl.getParent(); + if(%parent != %this.hierCtrlParent[%i] || + %this.indexOf(%parent, %ctrl) != %this.hierCtrlIndex[%i]) + { + %this.pending.noteTouched(%ctrl); + + // Dragged into a container that places its own children, its + // layout is no longer its own - so both ends are kept and put + // back after the lists are. + %this.pending.addLayoutFix(%ctrl, %this.hierCtrlLayout[%i], + %this.layoutOf(%ctrl)); + } + } + } + + %this.end(); + + %this.hierCount = 0; + %this.hierCtrlCount = 0; +} + +//----------------------------------------------------------------------------- +// Replaying, and the state that has to be dropped when one happens. +//----------------------------------------------------------------------------- + +function GuiEditorUndoRecorder::suspend(%this) +{ + %this.suspended = true; +} + +function GuiEditorUndoRecorder::resume(%this) +{ + %this.suspended = false; +} + +// Called by the action as it replays, both ways. Three jobs: hand GuiEditor the +// controls to re-select afterwards, drop the coalescing state - the action on +// top of the undo stack is no longer the one a merge would be aiming at - and +// move the document to whichever end of this action the replay is heading for. +function GuiEditorUndoRecorder::noteReplay(%this, %action, %forward) +{ + %this.replayTouched = %action.touched(); + %this.lastAction = 0; + %this.lastKind = ""; + + %this.moveTo(%forward ? %action.serial : %action.priorSerial); +} + +function GuiEditorUndoRecorder::clear(%this) +{ + if(isObject(%this.pending)) + { + %this.pending.delete(); + %this.pending = 0; + } + %this.depth = 0; + + %manager = %this.manager(); + if(isObject(%manager)) + { + %manager.clearAll(); + } + + %this.lastAction = 0; + %this.lastKind = ""; + %this.snapCount = 0; + + // Including a gesture left open. Nothing clears the stack in the middle of a + // drag, but a gesture whose mouse-up never arrived would otherwise go on + // swallowing every move made after it. + %this.gestCount = 0; + %this.inGesture = false; + + // A state nothing can replay its way back to. Losing the records does not + // un-edit the controls, and the actions that could have carried the document + // home have just been freed - so from here the only route back to clean is a + // save. The callers that DO leave a clean document (a new one, a freshly + // opened one) say so themselves by calling markClean afterwards. + %this.moveTo(%this.nextSerial()); + + %this.replayTouched = ""; + %this.refreshMenu(); +} + +//----------------------------------------------------------------------------- +// The Edit menu. +//----------------------------------------------------------------------------- + +function GuiEditorUndoRecorder::undoCount(%this) +{ + %manager = %this.manager(); + return isObject(%manager) ? %manager.getUndoCount() : 0; +} + +function GuiEditorUndoRecorder::redoCount(%this) +{ + %manager = %this.manager(); + 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. +function GuiEditorUndoRecorder::refreshMenu(%this) +{ + %undo = %this.undoCount(); + %redo = %this.redoCount(); + + if(%undo == %this.menuUndo && %redo == %this.menuRedo) + { + return; + } + + %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/editor/GuiEditor/scripts/GuiProfileEditorCursorForm.cs b/editor/GuiEditor/scripts/GuiProfileEditorCursorForm.cs new file mode 100644 index 000000000..223fc574c --- /dev/null +++ b/editor/GuiEditor/scripts/GuiProfileEditorCursorForm.cs @@ -0,0 +1,524 @@ + +//----------------------------------------------------------------------------- +// The cursor pane: the fourth form sharing the Gui Profile Editor's Properties +// window, shown when a cursor node is selected. +// +// Most of it is the ordinary field-row machinery the profile pane uses. What is +// not is the hot spot, which cannot be set by typing numbers: it is one pixel +// in a 13x17 image, and whether it is the right pixel is a question about what +// the art looks like. So the pane is built around a GuiEditorCursorCtrl showing +// the art magnified with the hot spot marked and draggable, and the fields +// underneath report what the dragging did. +// +// Two fields decide where the pointer lands and both are shown, because they do +// different jobs: +// +// Anchor (renderOffset) a fraction of the art's own size, so "0.5 0.5" is +// the middle whatever the art measures. This is what +// stops a small pointer and a large sizer from +// appearing to leap when one replaces the other, and +// it is why the anchor is set from a 3x3 of presets +// rather than typed. +// Nudge (hotSpot) pixels on top of that. This is what dragging writes. +// +// The creator sets the dialog back-pointer and an initial Extent inline, then +// calls build() once after adding it to its scroller; bind()/unbind() attach a +// cursor. +//----------------------------------------------------------------------------- + +// The Marker and Tint buttons are the same size as each other on purpose: they +// do the same kind of job, one on the cursor and one on the editor's own +// marker, and matching them is what says so. +$CursorForm::SwatchWidth = 66; +$CursorForm::SwatchExtent = "66 22"; + +function GuiProfileEditorCursorForm::onAdd(%this) +{ + ThemeManager.setProfile(%this, "emptyProfile"); + %this.rowWidth = 200; +} + +function GuiProfileEditorCursorForm::build(%this) +{ + %w = %this.formWidth; + + %this.nameLabel = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 24; + Text = "Cursor:"; + align = "left"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%this.nameLabel, "labelProfile"); + %this.add(%this.nameLabel); + + %this.buildEditor(); + %this.buildAnchor(); + + %grid = %this.makeCellGrid(); + %this.add(%grid); + %this.addFieldRow(%grid, "bitmapName", "Art", "file"); + %this.addFieldRow(%grid, "color", "Tint", "color", $CursorForm::SwatchWidth); + %this.addFieldRow(%grid, "hotSpot", "Nudge (pixels)", "point"); + %this.addFieldRow(%grid, "renderOffset", "Anchor (fraction)", "pointf"); + + // Same forced layout pass the profile form needs: a GuiChainCtrl positions + // its children without resizing them, so nothing would otherwise tell the + // grid how wide it is. + %h = getWord(%this.getExtent(), 1); + %this.resize(0, 0, %w + 1, %h); + %this.resize(0, 0, %w, %h); +} + +// The magnifier, and the zoom controls that belong to it. +function GuiProfileEditorCursorForm::buildEditor(%this) +{ + %w = %this.formWidth; + + // Tall enough that the small cursors reach the full 16x: the stock pointer is + // 17 pixels high, so it needs 272 for the art plus what the profile's border + // and padding take off the content rect. The big ones (a 32x32 move cursor + // would want 512) still cap lower, which is what the greyed "+" is for. + %viewHeight = 304; + + %this.editorBox = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC (%viewHeight + 60); + }; + ThemeManager.setProfile(%this.editorBox, "emptyProfile"); + %this.add(%this.editorBox); + + %this.editor = new GuiEditorCursorCtrl() + { + class = "GuiProfileEditorCursorView"; + HorizSizing = "width"; + VertSizing = "height"; + Position = "0 0"; + Extent = %w SPC %viewHeight; + zoom = 8; + owner = %this; + }; + ThemeManager.setProfile(%this.editor, "displayBoxProfile"); + %this.editorBox.add(%this.editor); + + // The readout names the pixel the pointer really lands on, which is neither + // field on its own -- so without it the two numbers below would look like + // they disagreed with the dot. + %rowY = %viewHeight + 6; + + %this.readout = new GuiControl() + { + HorizSizing = "width"; + Position = "0" SPC %rowY; + Extent = (%w - 96) SPC 22; + Text = ""; + align = "left"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%this.readout, "labelProfile"); + %this.editorBox.add(%this.readout); + + %this.zoomOut = %this.makeZoomButton(%w - 92, %rowY, "-", "Zoom out"); + %this.zoomLabel = new GuiControl() + { + HorizSizing = "left"; + Position = (%w - 62) SPC %rowY; + Extent = "34 22"; + Text = "8x"; + align = "center"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%this.zoomLabel, "labelProfile"); + %this.editorBox.add(%this.zoomLabel); + %this.zoomIn = %this.makeZoomButton(%w - 26, %rowY, "+", "Zoom in"); + + %this.dotRow = new GuiControl() + { + HorizSizing = "width"; + Position = "0" SPC (%rowY + 26); + Extent = %w SPC 24; + }; + ThemeManager.setProfile(%this.dotRow, "emptyProfile"); + %this.editorBox.add(%this.dotRow); + + %dotLabel = new GuiControl() + { + Position = "0 0"; + Extent = "80 22"; + Text = "Marker"; + align = "left"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%dotLabel, "labelProfile"); + %this.dotRow.add(%dotLabel); + + // The hot-spot marker's own color, because a dark dot vanishes on dark art + // and a light one on light art. It belongs to the editor, not the cursor, so + // it is never written to the theme. + // + // A button, not a bar: left-aligned at a fixed width rather than filling the + // row, because a full-width band of flat color reads as a progress bar. + %this.dotSwatch = new GuiColorPopupCtrl() + { + class = "GuiProfileEditorColorPopup"; + Position = "84 0"; + Extent = $CursorForm::SwatchExtent; + showColorValues = false; + }; + ThemeManager.setProfile(%this.dotSwatch, "colorPickerProfile"); + ThemeManager.setProfile(%this.dotSwatch, "emptyProfile", "backgroundProfile"); + ThemeManager.setProfile(%this.dotSwatch, "colorPopupProfile", "popupProfile"); + ThemeManager.setProfile(%this.dotSwatch, "emptyProfile", "pickerProfile"); + ThemeManager.setProfile(%this.dotSwatch, "colorPickerSelectorProfile", "selectorProfile"); + ThemeManager.setProfile(%this.dotSwatch, "textEditProfile", "valueProfile"); + %this.dotSwatch.Command = %this.getID() @ ".onDotColorChanged();"; + %this.dotRow.add(%this.dotSwatch); + %this.dotSwatch.setColorI(%this.editor.dotColor); +} + +function GuiProfileEditorCursorForm::makeZoomButton(%this, %x, %y, %text, %tip) +{ + %button = new GuiButtonCtrl() + { + HorizSizing = "left"; + Position = %x SPC %y; + Extent = "26 22"; + Text = %text; + tooltip = %tip; + Command = %this.getID() @ ".onZoom(" @ (%text $= "+" ? 1 : -1) @ ");"; + }; + ThemeManager.setProfile(%button, "buttonProfile"); + ThemeManager.setProfile(%button, "tipProfile", "TooltipProfile"); + %this.editorBox.add(%button); + return %button; +} + +// Nine presets for the anchor, laid out as the thing they mean: where in the +// art the pointer sits. Typing "0.5 0.5" is the same edit, but nobody reads a +// pair of decimals as "the middle" at a glance. +function GuiProfileEditorCursorForm::buildAnchor(%this) +{ + %w = %this.formWidth; + + // Tall enough for four wrapped lines beside the pin cluster. The cluster + // itself only needs 82, but a hint that clips mid-sentence is worse than + // none, and the pane scrolls. + %this.anchorBox = new GuiControl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %w SPC 104; + }; + ThemeManager.setProfile(%this.anchorBox, "emptyProfile"); + %this.add(%this.anchorBox); + + %label = new GuiControl() + { + Position = "0 0"; + Extent = "200 20"; + Text = "Anchor"; + align = "left"; + vAlign = "middle"; + }; + ThemeManager.setProfile(%label, "labelProfile"); + %this.anchorBox.add(%label); + + %fractions = "0" TAB "0.5" TAB "1"; + for(%row = 0; %row < 3; %row++) + { + for(%col = 0; %col < 3; %col++) + { + %x = getField(%fractions, %col); + %y = getField(%fractions, %row); + + %pin = new GuiButtonCtrl() + { + Position = (%col * 20) SPC (22 + (%row * 20)); + Extent = "18 18"; + Text = ""; + tooltip = "Anchor at" SPC %x SPC %y; + Command = %this.getID() @ ".onAnchorPreset(" @ %x @ "," SPC %y @ ");"; + }; + ThemeManager.setProfile(%pin, "buttonProfile"); + ThemeManager.setProfile(%pin, "tipProfile", "TooltipProfile"); + %this.anchorBox.add(%pin); + } + } + + %hint = new GuiControl() + { + HorizSizing = "width"; + Position = "68 22"; + Extent = (%w - 68) SPC 78; + Text = "A fraction of the art's size, so one anchor suits art of any size - which is what stops a cursor appearing to jump when another replaces it."; + align = "left"; + vAlign = "top"; + textWrap = true; + }; + ThemeManager.setProfile(%hint, "labelProfile"); + %this.anchorBox.add(%hint); +} + +function GuiProfileEditorCursorForm::makeCellGrid(%this) +{ + %grid = new GuiGridCtrl() + { + HorizSizing = "width"; + Position = "0 0"; + Extent = %this.formWidth SPC 4; + CellModeX = "variable"; + CellModeY = "variable"; + CellSizeX = %this.rowWidth; + CellSizeY = 48; + CellSpacingX = 4; + CellSpacingY = 4; + MaxColCount = 0; + MaxRowCount = 0; + OrderMode = "lrtb"; + IsExtentDynamic = true; + }; + ThemeManager.setProfile(%grid, "emptyProfile"); + return %grid; +} + +function GuiProfileEditorCursorForm::addFieldRow(%this, %container, %field, %label, %kind, %swatchWidth) +{ + %row = new GuiControl() + { + class = "GuiProfileEditorFieldRow"; + Position = "0 0"; + fieldName = %field; + labelText = %label; + kind = %kind; + swatchWidth = %swatchWidth; + owner = %this; + }; + %container.add(%row); + %row.build(); + + %this.row[%field] = %row; + %this.rowFields = (%this.rowFields $= "") ? %field : (%this.rowFields SPC %field); + return %row; +} + +//----------------------------------------------------------------------------- +// Binding. +//----------------------------------------------------------------------------- + +function GuiProfileEditorCursorForm::bind(%this, %cursor, %label) +{ + if(!isObject(%cursor)) + { + %this.unbind(); + return; + } + + %this.target = %cursor; + %this.nameLabel.setText("Cursor: " @ %label); + %this.editor.cursor = %cursor; + %this.refresh(); +} + +function GuiProfileEditorCursorForm::unbind(%this) +{ + %this.target = ""; + if(isObject(%this.editor)) + { + %this.editor.cursor = ""; + } +} + +function GuiProfileEditorCursorForm::refresh(%this) +{ + if(!isObject(%this.target)) + { + return; + } + + // populating stops a row that is only being filled in from reporting itself + // as an edit, which would record a theme override nobody asked for. + %this.populating = true; + + %count = getWordCount(%this.rowFields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%this.rowFields, %i); + %this.row[%field].setValue(%this.target.getFieldValue(%field)); + } + + %this.populating = false; + + %this.refreshOverrides(); + %this.refreshReadout(); +} + +// Only the tint is derived from the theme, so it is the only row that can be +// overridden and the only one with anything to reset. The art fields are the +// user's outright -- there is no theme value behind them to go back to. +function GuiProfileEditorCursorForm::refreshOverrides(%this) +{ + %theme = %this.currentTheme(); + %hasTheme = isObject(%theme) && isObject(%this.target); + + %count = getWordCount(%this.rowFields); + for(%i = 0; %i < %count; %i++) + { + %field = getWord(%this.rowFields, %i); + %this.row[%field].setOverridden(%hasTheme && %theme.isFieldOverridden(%this.target, %field)); + } +} + +function GuiProfileEditorCursorForm::refreshReadout(%this) +{ + if(!isObject(%this.target) || !isObject(%this.editor)) + { + return; + } + + %extent = %this.editor.getImageExtent(); + if(getWord(%extent, 0) <= 0) + { + %this.readout.setText("No art - choose a file."); + %this.zoomIn.setActive(false); + %this.zoomOut.setActive(false); + return; + } + + %hot = %this.editor.getEffectiveHotSpot(); + %this.readout.setText("Points at pixel" SPC getWord(%hot, 0) @ "," SPC getWord(%hot, 1) SPC + "of" SPC getWord(%extent, 0) @ "x" @ getWord(%extent, 1)); + + // The zoom the control is really drawing at, which is not always the one + // that was asked for: art too big for the pane is clamped to what fits. The + // buttons grey out at both ends rather than letting a click do nothing and + // leaving the number to explain why. + %zoom = %this.editor.getZoom(); + %this.zoomLabel.setText(%zoom @ "x"); + %this.zoomIn.setActive(%zoom < %this.editor.getMaxZoom()); + %this.zoomOut.setActive(%zoom > 1); +} + +function GuiProfileEditorCursorForm::currentTheme(%this) +{ + %root = %this.dialog.currentRoot; + if(isObject(%root) && %root.getClassName() $= "GuiProfileTheme") + { + return %root; + } + return ""; +} + +//----------------------------------------------------------------------------- +// Edits. +//----------------------------------------------------------------------------- + +function GuiProfileEditorCursorForm::onProfileRowCommit(%this, %row) +{ + if(%this.populating || !isObject(%this.target)) + { + return; + } + + // A text box commits on blur, so most commits arrive from a field the user + // only tabbed through; writing one would record an override for an edit that + // never happened. + if(!%row.hasChanged()) + { + return; + } + + %this.target.setFieldValue(%row.fieldName, %row.getValue()); + %row.markClean(); + %this.afterCommit(); +} + +function GuiProfileEditorCursorForm::onProfileRowReset(%this, %row) +{ + %theme = %this.currentTheme(); + if(!isObject(%theme) || !isObject(%this.target)) + { + return; + } + + %theme.resetField(%this.target, %row.fieldName); + %this.refresh(); + %this.afterCommit(); +} + +// The magnifier reports a drag here. The value is already on the cursor -- the +// control writes it as the drag happens, which is what makes the dot follow the +// mouse -- so this only has to catch the rows up and mark the theme dirty. +function GuiProfileEditorCursorForm::onHotSpotChanged(%this, %x, %y) +{ + if(!isObject(%this.target)) + { + return; + } + + %this.populating = true; + %this.row["hotSpot"].setValue(%x SPC %y); + %this.populating = false; + + %this.refreshReadout(); + %this.afterCommit(); +} + +function GuiProfileEditorCursorForm::onAnchorPreset(%this, %x, %y) +{ + if(!isObject(%this.target)) + { + return; + } + + %this.target.renderOffset = %x SPC %y; + %this.refresh(); + %this.afterCommit(); +} + +function GuiProfileEditorCursorForm::onZoom(%this, %step) +{ + %this.editor.setZoom(%this.editor.getZoom() + %step); + %this.refreshReadout(); +} + +function GuiProfileEditorCursorForm::onDotColorChanged(%this) +{ + %this.editor.dotColor = %this.dotSwatch.getColorI(); +} + +function GuiProfileEditorCursorForm::afterCommit(%this) +{ + %this.refreshOverrides(); + + // The readout is a function of BOTH placement fields, so typing into either + // one moves it. Without this it goes on reporting the pixel the dot used to + // be on while the dot itself has already moved -- which reads as the + // magnifier and the numbers disagreeing. + %this.refreshReadout(); + + %this.dialog.onProfileChanged(%this.target); +} + +//----------------------------------------------------------------------------- +// The magnifier forwards its callbacks to the pane that owns it. +//----------------------------------------------------------------------------- + +function GuiProfileEditorCursorView::onHotSpotChanged(%this, %x, %y) +{ + if(isObject(%this.owner)) + { + %this.owner.onHotSpotChanged(%x, %y); + } +} + +function GuiProfileEditorCursorView::onZoomChanged(%this, %zoom) +{ + if(isObject(%this.owner)) + { + %this.owner.refreshReadout(); + } +} diff --git a/editor/GuiEditor/scripts/GuiProfileEditorDialog.cs b/editor/GuiEditor/scripts/GuiProfileEditorDialog.cs index d1fe5e7de..192743494 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorDialog.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorDialog.cs @@ -35,15 +35,17 @@ ThemeManager.setProfile(%this.toolbar, "emptyProfile"); %content.add(%this.toolbar); - %this.toolbar.addButton("onNewTheme", 11, "New Theme", ""); - %this.toolbar.addButton("onRename", 49, "Rename Theme or Stand Alone Profile", "getRootSelected"); - %this.toolbar.addButton("onDelete", 23, "Delete Theme or Stand Alone Profile", "getRootSelected"); - %this.toolbar.addButton("onNewProfile", 25, "New Profile in Category", "getCategorySelected"); - %this.toolbar.addButton("onRemoveProfile", 23, "Remove Extra Profile", "getExtraSelected"); - %this.toolbar.addButton("onNewStandalone", 25, "New Stand Alone Profile", ""); - // Frame 22 is the circular revert arrow; frame 11 (a plus) was reading as - // another "new" button next to the three that really are. - %this.toolbar.addButton("onResetMember", 22, "Reset All Overrides on Member", "getMemberSelected"); + %this.toolbar.addButton("onNewTheme", $EditorIcon::doc_plus, "New Theme", ""); + %this.toolbar.addButton("onRename", $EditorIcon::doc_edit, "Rename Theme or Stand Alone Profile", "getRootSelected"); + %this.toolbar.addButton("onDelete", $EditorIcon::round_delete, "Delete Theme or Stand Alone Profile", "getRootSelected"); + // These two serve profiles and cursors alike, so their tips are answered per + // selection rather than fixed - see newInCategoryTip. + %this.toolbar.addButton("onNewProfile", $EditorIcon::round_plus, "New Profile in Category", "getCategorySelected", "newInCategoryTip"); + %this.toolbar.addButton("onRemoveProfile", $EditorIcon::round_delete, "Remove Extra Profile", "getExtraSelected", "removeExtraTip"); + %this.toolbar.addButton("onNewStandalone", $EditorIcon::round_plus, "New Stand Alone Profile", ""); + // The circular revert arrow; a plain plus was reading as another "new" + // button next to the three that really are. + %this.toolbar.addButton("onResetMember", $EditorIcon::playback_reload, "Reset All Overrides on Member", "getMemberSelected"); %paneTop = 40; %paneHeight = %height - 116; @@ -273,6 +275,42 @@ class = "GuiProfileEditorBorderForm"; %this.borderForm.build(); %this.borderFormScroller.add(%this.borderForm); + // The cursor pane, fourth of the forms sharing this window. It is the one + // that is not mostly field rows: a hot spot has to be placed by eye against + // magnified art, so the pane is built around the magnifier. + %this.cursorFormScroller = new GuiScrollCtrl() + { + HorizSizing = "fill"; + VertSizing = "fill"; + Position = "0 0"; + Extent = "400" SPC %paneHeight; + hScrollBar = "alwaysOff"; + vScrollBar = "dynamic"; + constantThumbHeight = "0"; + showArrowButtons = "1"; + scrollBarThickness = "14"; + Visible = false; + }; + ThemeManager.setProfile(%this.cursorFormScroller, "emptyProfile"); + ThemeManager.setProfile(%this.cursorFormScroller, "thumbProfile", "ThumbProfile"); + ThemeManager.setProfile(%this.cursorFormScroller, "trackProfile", "TrackProfile"); + ThemeManager.setProfile(%this.cursorFormScroller, "scrollArrowProfile", "ArrowProfile"); + %this.memberWindow.add(%this.cursorFormScroller); + + %this.cursorForm = new GuiChainCtrl() + { + class = "GuiProfileEditorCursorForm"; + HorizSizing = "width"; + Position = "0 0"; + Extent = "386" SPC %paneHeight; + IsVertical = true; + ChildSpacing = 6; + formWidth = 386; + dialog = %this; + }; + %this.cursorFormScroller.add(%this.cursorForm); + %this.cursorForm.build(); + //--- Frame 3: the Borders pane -- a movable window of five border setters, // shown only while a profile is selected (onTreeSelect toggles it). Added // before the preview so it docks into the Borders frame. @@ -399,6 +437,10 @@ class = "GuiProfileEditorPreview"; { %this.borderForm.unbind(); } + if(isObject(%this.cursorForm)) + { + %this.cursorForm.unbind(); + } if(isObject(%this.borderChain)) { %this.unbindBorderSetters(); @@ -439,6 +481,16 @@ class = "GuiProfileEditorPreview"; %this.currentRoot = %proxy.theme; %this.currentMember = %proxy.theme.getBorder(%proxy.category); } + else if(%kind $= "cursorCategory") + { + %this.currentRoot = %proxy.theme; + %this.currentMember = %proxy.theme.getCursor(%proxy.category); + } + else if(%kind $= "cursorExtra") + { + %this.currentRoot = %proxy.theme; + %this.currentMember = %proxy.target; + } else if(%kind $= "extra") { %this.currentRoot = %proxy.theme; @@ -473,6 +525,12 @@ class = "GuiProfileEditorPreview"; %this.borderFormScroller.setVisible(true); %this.borderForm.bind(%this.currentMember, %proxy.treeLabel); } + else if(%this.isCursorKind(%kind)) + { + %this.hideMemberPanes(); + %this.cursorFormScroller.setVisible(true); + %this.cursorForm.bind(%this.currentMember, %proxy.treeLabel); + } else { %this.hideMemberPanes(); @@ -520,6 +578,10 @@ class = "GuiProfileEditorPreview"; { %this.preview.showBorder(%proxy.theme, %this.currentMember); } + else if(%this.isCursorKind(%kind)) + { + %this.preview.showCursor(%proxy.theme, %this.currentMember); + } else if(%kind $= "standalone") { %this.preview.showCategory("", %proxy.target.category, %proxy.target); @@ -578,6 +640,11 @@ class = "GuiProfileEditorPreview"; return %kind $= "category" || %kind $= "extra" || %kind $= "standalone"; } +function GuiProfileEditorDialog::isCursorKind(%this, %kind) +{ + return %kind $= "cursorCategory" || %kind $= "cursorExtra"; +} + // The tree's grouping rows: the "Gui Themes" root and the "Stand Alone", // "Profiles" and "Borders" folders. They carry no editable target. function GuiProfileEditorDialog::isHeaderKind(%this, %kind) @@ -585,7 +652,7 @@ class = "GuiProfileEditorPreview"; return %kind $= "root" || %kind $= "folder"; } -// Drops all three member panes out of the Properties window. Each pane is also +// Drops all four member panes out of the Properties window. Each pane is also // unbound so it stops tracking whatever it last showed. function GuiProfileEditorDialog::hideMemberPanes(%this) { @@ -595,6 +662,8 @@ class = "GuiProfileEditorPreview"; %this.themeForm.unbind(); %this.borderFormScroller.setVisible(false); %this.borderForm.unbind(); + %this.cursorFormScroller.setVisible(false); + %this.cursorForm.unbind(); } // Build the five setters into the pane chain: the default (no checkbox, full @@ -782,14 +851,28 @@ class = "GuiProfileEditorBorderSetter"; return (%this.currentProxy.kind $= "theme") ? %this.currentProxy.target : %this.currentProxy.root; } +// The two "new in a category" buttons serve profiles and cursors alike: both +// families are a category row that can hold extras, so one pair of buttons that +// asks the selection what it is beats a second pair that would sit greyed out +// nine tenths of the time. function GuiProfileEditorDialog::getCategorySelected(%this) { - return isObject(%this.currentProxy) && %this.currentProxy.kind $= "category"; + if(!isObject(%this.currentProxy)) + { + return false; + } + %kind = %this.currentProxy.kind; + return %kind $= "category" || %kind $= "cursorCategory"; } function GuiProfileEditorDialog::getExtraSelected(%this) { - return isObject(%this.currentProxy) && %this.currentProxy.kind $= "extra"; + if(!isObject(%this.currentProxy)) + { + return false; + } + %kind = %this.currentProxy.kind; + return %kind $= "extra" || %kind $= "cursorExtra"; } function GuiProfileEditorDialog::getMemberSelected(%this) @@ -799,7 +882,23 @@ class = "GuiProfileEditorBorderSetter"; return false; } %kind = %this.currentProxy.kind; - return %kind $= "category" || %kind $= "border" || %kind $= "extra"; + return %kind $= "category" || %kind $= "border" || %kind $= "extra" || %this.isCursorKind(%kind); +} + +// What the two shared "in a category" buttons are about to do. A cursor +// category and a profile category are both a row that can hold extras, so one +// pair of buttons serves both - but a tip reading "Profile" while a cursor is +// selected describes the wrong thing, which is worse than no tip. +function GuiProfileEditorDialog::newInCategoryTip(%this) +{ + %kind = isObject(%this.currentProxy) ? %this.currentProxy.kind : ""; + return (%kind $= "cursorCategory") ? "New Cursor in Category" : "New Profile in Category"; +} + +function GuiProfileEditorDialog::removeExtraTip(%this) +{ + %kind = isObject(%this.currentProxy) ? %this.currentProxy.kind : ""; + return (%kind $= "cursorExtra") ? "Remove Extra Cursor" : "Remove Extra Profile"; } //----------------------------------------------------------------------------- @@ -929,8 +1028,20 @@ class = "GuiProfileEditorBorderSetter"; { return; } - %profile = %this.library.createExtraProfile(%this.currentProxy.theme, %this.currentProxy.category); - if(isObject(%profile)) + + %theme = %this.currentProxy.theme; + %category = %this.currentProxy.category; + + if(%this.currentProxy.kind $= "cursorCategory") + { + %member = %this.library.createExtraCursor(%theme, %category); + } + else + { + %member = %this.library.createExtraProfile(%theme, %category); + } + + if(isObject(%member)) { %this.tree.refresh(); } @@ -943,17 +1054,53 @@ class = "GuiProfileEditorBorderSetter"; return; } %theme = %this.currentProxy.theme; - %profile = %this.currentProxy.target; + %member = %this.currentProxy.target; + %isCursor = %this.currentProxy.kind $= "cursorExtra"; %this.preview.clearSamples(); - %this.profileForm.unbind(); + %this.hideMemberPanes(); %this.currentProxy = ""; %this.currentRoot = ""; %this.currentMember = ""; - %this.library.removeExtraProfile(%theme, %profile); + %orphanedArt = ""; + if(%isCursor) + { + %orphanedArt = %this.library.removeExtraCursor(%theme, %member); + } + else + { + %this.library.removeExtraProfile(%theme, %member); + } + %this.tree.refresh(); %this.toolbar.refreshEnabled(); + + // Removing a cursor leaves its picture behind. Deleting that as a side + // effect would be a file thrown away without being asked about, and keeping + // it always would litter the folder with the art of every cursor ever tried. + // So ask -- but only when the answer is not already obvious: the library + // hands back a path only for art it made, that nothing else is using, and + // that is really on disk. + if(%orphanedArt !$= "") + { + %this.doomedCursorArt = %orphanedArt; + %this.openConfirmDialog("Delete Image", + "The cursor is gone. Its image file, " @ fileName(%orphanedArt) @ + ", is not used by any other cursor. Delete it as well?", + "Delete Image", "doDeleteCursorArt"); + } +} + +// The confirm arm of the question above. Cancel is the other, and it does +// nothing at all: the file stays where it is. +function GuiProfileEditorDialog::doDeleteCursorArt(%this) +{ + if(%this.doomedCursorArt !$= "") + { + %this.library.deleteCursorArt(%this.doomedCursorArt); + %this.doomedCursorArt = ""; + } } function GuiProfileEditorDialog::onNewStandalone(%this) @@ -984,6 +1131,10 @@ class = "GuiProfileEditorBorderSetter"; { %this.borderForm.bind(%this.currentMember, %this.currentProxy.treeLabel); } + else if(%this.isCursorKind(%this.currentProxy.kind)) + { + %this.cursorForm.refresh(); + } else { %this.profileForm.refresh(); diff --git a/editor/GuiEditor/scripts/GuiProfileEditorFieldRow.cs b/editor/GuiEditor/scripts/GuiProfileEditorFieldRow.cs index 2a70cf8cb..2976759ea 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorFieldRow.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorFieldRow.cs @@ -42,8 +42,17 @@ %pad = 4; %resetW = 24; %labelH = 16; - %editorY = %labelH + 4; - %editorH = 24; + + // No caption means no room kept for one. The text block asks for this: its + // text box is captioned by the row above, which is what leaves space beside + // the caption for the wrap and extend icons. The label control is still + // built, so setLabelText can give it one later. + %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; %h = %editorY + %editorH + 4; // The editor stops short of the reset button so the two never overlap once @@ -61,6 +70,7 @@ Text = %this.labelText; align = "left"; vAlign = "middle"; + Visible = %captioned; }; ThemeManager.setProfile(%this.label, "labelProfile"); %this.add(%this.label); @@ -86,7 +96,13 @@ } else if(%kind $= "color") { - %this.editor = %this.makeSwatch(%pad, %editorY, %editorW, 22); + // swatchWidth opts a row out of filling its cell. A swatch that spans the + // whole width reads as a bar of flat color -- a progress bar, a divider -- + // rather than as the button it is. Rows that show a state's colors keep + // the full width, because there four of them share it and the widths are + // how you tell which is which. + %this.editor = %this.makeSwatch(%pad, %editorY, + (%this.swatchWidth > 0) ? %this.swatchWidth : %editorW, 22); } else if(%kind $= "enum" || %kind $= "dropdown") { @@ -116,9 +132,9 @@ class = "GuiProfileEditorRowDropDown"; %this.fillItems(%this.enumItems); } } - else if(%kind $= "point") + else if(%kind $= "point" || %kind $= "pointf") { - // A Point2I field ("x y") gets one box per axis; either one commits both. + // A two-part field ("x y") gets one box per axis; either one commits both. // Relative sizing splits the widened cell evenly between them. %boxW = (%editorW - 6) / 2; %this.editor = %this.makeInput(%pad, %editorY, %boxW, 22, true, "relative"); @@ -136,18 +152,32 @@ class = "GuiProfileEditorRowDropDown"; // file dialog -- an asset id is no more typeable from memory than a path. %this.makeFindRow(%pad, %editorY, %editorW, ".onFindAssetClicked();"); } + else if(%kind $= "multiline") + { + // The full cell width: this row's reset button is never shown, and the + // caption line above it is where its owner puts anything else. + // + // mTextWrap is what makes GuiTextEditCtrl multi-line -- it wraps, scrolls + // and hit-tests in line space (guiTextEditCtrl.cc). It still cannot take a + // typed newline: handleEnterKey has no insert path, so Enter goes on + // meaning commit and a long string simply wraps. + %this.editor = %this.makeInput(%pad, %editorY, %w - (%pad * 2), %editorH, false, "width"); + %this.editor.textWrap = true; + %this.editor.vAlign = "top"; + } else { - %this.editor = %this.makeInput(%pad, %editorY, %editorW, 22, %kind $= "number", "width"); + %this.editor = %this.makeInput(%pad, %editorY, %editorW, 22, + %kind $= "number" || %kind $= "decimal", "width"); } - // The per-field reset, shown only while the field is overridden. Frame 22 of - // EditorCore:editorIcons16 is the circular revert arrow. "left" sizing keeps - // the button pinned to the cell's right edge as the grid widens. + // The per-field reset, shown only while the field is overridden. The icon is + // the circular revert arrow. "left" sizing keeps the button pinned to the + // cell's right edge as the grid widens. %this.resetButton = new GuiButtonCtrl() { class = "EditorIconButton"; - Frame = 22; + Frame = $EditorIcon::playback_reload; HorizSizing = "left"; Position = (%w - %resetW - %pad) SPC (%editorY - 1); Tooltip = "Reset this field to the theme's value"; @@ -178,23 +208,46 @@ class = "EditorIconButton"; %this.add(%this.findButton); } +// 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) +{ + 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) { + %decimal = %this.isDecimalKind(); + %box = new GuiTextEditCtrl() { - class = "GuiProfileEditorRowInput"; + // The class only goes on a box that wants the arrow keys to step its + // value. GuiTextEditCtrl gives a script onUpArrow the key before its own + // caret movement (guiTextEditCtrl.cc handleKeyDownWithNoModifier), and + // isMethod answers for the CLASS, not the instance -- so wearing this on + // 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" : ""; HorizSizing = %sizing; Position = %x SPC %y; Extent = %w SPC %h; align = %numeric ? "center" : "left"; row = %this; numeric = %numeric; + + // What an arrow key is worth. A font size multiplier lives between 0.5 + // and 3, so stepping it by one is the same as not offering the key. + step = %decimal ? 0.1 : 1; }; if(%numeric) { - %box.inputMode = "Number"; + // Number mode refuses the decimal point, which would make a float field + // impossible to type into. + %box.inputMode = %decimal ? "Decimal" : "Number"; } ThemeManager.setProfile(%box, "textEditProfile"); %box.AltCommand = %this.getID() @ ".commit();"; @@ -208,7 +261,9 @@ class = "GuiProfileEditorRowInput"; %swatch = new GuiColorPopupCtrl() { class = "GuiProfileEditorColorPopup"; - HorizSizing = "width"; + // A fixed-width swatch stays put as the cell widens; a full-width one + // follows it. + HorizSizing = (%this.swatchWidth > 0) ? "anchorLeft" : "width"; Position = %x SPC %y; Extent = %w SPC %h; showColorValues = true; @@ -278,7 +333,7 @@ class = "GuiProfileEditorColorPopup"; { %this.selectItem(%value); } - else if(%kind $= "point") + else if(%kind $= "point" || %kind $= "pointf") { %this.editor.setText(getWord(%value, 0)); %this.editorY.setText(getWord(%value, 1)); @@ -304,17 +359,26 @@ class = "GuiProfileEditorColorPopup"; { return %this.editor.getText(); } - if(%kind $= "point") + if(%kind $= "point" || %kind $= "pointf") { - return mFloor(%this.editor.getText()) SPC mFloor(%this.editorY.getText()); + return %this.numberIn(%this.editor) SPC %this.numberIn(%this.editorY); } - if(%kind $= "number") + if(%kind $= "number" || %kind $= "decimal") { - return mFloor(%this.editor.getText()); + return %this.numberIn(%this.editor); } return %this.editor.getText(); } +// A whole-number field is floored, so a box left holding "12.0" does not write +// "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) +{ + return %this.isDecimalKind() ? %box.getText() : mFloor(%box.getText()); +} + //----------------------------------------------------------------------------- // Drop-down contents. //----------------------------------------------------------------------------- @@ -499,7 +563,11 @@ class = "GuiProfileEditorColorPopup"; { return; } - %this.setText(%this.getText() + %delta); + + // Rounded, or repeated tenths accumulate into 1.2000000476837158. + %value = %this.getText() + (%delta * %this.step); + %this.setText(%this.step < 1 ? mFloatLength(%value, 2) : %value); + %this.selectAllText(); %this.row.commit(); } diff --git a/editor/GuiEditor/scripts/GuiProfileEditorLibrary.cs b/editor/GuiEditor/scripts/GuiProfileEditorLibrary.cs index 4ebf53f70..90f288d17 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorLibrary.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorLibrary.cs @@ -106,6 +106,169 @@ return makeRelativePath(%this.getFontsPath(), getMainDotCsDir()); } +//----------------------------------------------------------------------------- +// Cursor art. +// +// Unlike fonts, which are keyed by face and size and so can share one folder, +// every theme gets a folder of its own: two themes in a project may want +// cursors that look nothing alike - a menu pointer and a combat reticle - and a +// shared folder would mean one theme overwriting the other's files. +// +// The art is copied rather than referenced so a theme and its cursors travel +// together, and it is copied from the stock grayscale set so a new theme's +// cursors are tinted to its palette from the moment it exists. +//----------------------------------------------------------------------------- + +function GuiProfileEditorLibrary::getThemeCursorsPath(%this, %theme) +{ + if(!isObject(%theme) || %theme.getName() $= "") + { + return ""; + } + return pathConcat(%this.getThemesPath(), "cursors", %theme.getName()); +} + +// The stock set to copy from: the project's own AppCore if it has one, and +// otherwise the editor's, which is always loaded. The two hold the same seven +// files under the same names. +function GuiProfileEditorLibrary::getStockCursorsPath(%this) +{ + %appCore = ModuleDatabase.findModule("AppCore", 1); + if(isObject(%appCore)) + { + %path = pathConcat(makeFullPath(%appCore.getModulePath(), getMainDotCsDir()), "gui/images/cursors"); + if(isDirectory(%path)) + { + return %path; + } + } + + %editorCore = EditorManager.findModule("EditorCore", 1); + if(isObject(%editorCore)) + { + return pathConcat(makeFullPath(%editorCore.getModulePath(), getMainDotCsDir()), "Themes/BaseTheme/images/cursors"); + } + + return ""; +} + +// Give %theme its own copy of the stock art and point it at the folder. Safe to +// call on every load: pathCopy is asked not to overwrite, so art the user has +// replaced or edited is never clobbered. +function GuiProfileEditorLibrary::seedThemeCursors(%this, %theme) +{ + %target = %this.getThemeCursorsPath(%theme); + if(%target $= "") + { + return false; + } + + %source = %this.getStockCursorsPath(); + if(%source $= "" || !isDirectory(%source)) + { + warn("GuiProfileEditorLibrary::seedThemeCursors: no stock cursor art to copy from."); + return false; + } + + createPath(%target @ "/"); + + %categories = %theme.getCursorCategoryNames(); + for(%i = 0; %i < getWordCount(%categories); %i++) + { + %file = %theme.getCursorStockFile(getWord(%categories, %i)); + if(%file !$= "") + { + pathCopy(pathConcat(%source, %file), pathConcat(%target, %file)); + } + } + + // Assigning the folder restamps, which fills in the bitmap of any cursor + // that has none yet. One already pointing at art keeps it. + %directory = makeRelativePath(%target, getMainDotCsDir()); + if(%theme.cursorDirectory !$= %directory) + { + %theme.cursorDirectory = %directory; + } + + return true; +} + +// Follow a theme rename with its art. The files are named for nothing but their +// category, so this is a folder copy plus a rewrite of every member still +// pointing into the old folder; art the user pointed somewhere else entirely is +// left alone. The old folder is dropped on save, like any other doomed file. +function GuiProfileEditorLibrary::moveThemeCursors(%this, %theme, %oldName) +{ + %target = %this.getThemeCursorsPath(%theme); + %source = pathConcat(%this.getThemesPath(), "cursors", %oldName); + if(%target $= "" || %oldName $= "" || %source $= %target || !isDirectory(%source)) + { + // Nothing to move: seeding gives the renamed theme a folder of its own. + return %this.seedThemeCursors(%theme); + } + + createPath(%target @ "/"); + %newPrefix = makeRelativePath(%target, getMainDotCsDir()); + + %categories = %theme.getCursorCategoryNames(); + for(%i = 0; %i < getWordCount(%categories); %i++) + { + %category = getWord(%categories, %i); + + // The stock file for the category, whether or not anything points at it + // yet: a member with no art is filled from it at the next restamp, and + // that has to find it in the new folder. + %this.moveCursorFile(%theme.getCursorStockFile(%category), %source, %target); + + // Then every member's own file. Driven by what the members actually + // name rather than by the stock list, because an extra's art is named + // after the member -- a rename that copied only the stock files left + // every extra pointing into the new folder at a file still sitting in + // the old one. + %members = %theme.getCursors(%category); + for(%m = 0; %m < getWordCount(%members); %m++) + { + %this.moveCursorArt(getWord(%members, %m), %source, %target, %newPrefix); + } + } + + %theme.cursorDirectory = %newPrefix; + return true; +} + +// Copy one file between the two folders and drop the original at the next save. +// Silent about a file that is not there: the stock art of a category whose +// member was pointed elsewhere never existed in the old folder either. +function GuiProfileEditorLibrary::moveCursorFile(%this, %name, %source, %target) +{ + if(%name $= "") + { + return; + } + + %from = pathConcat(%source, %name); + if(pathCopy(%from, pathConcat(%target, %name))) + { + %this.doomFile(%from); + } +} + +// Follow one cursor's art into the new folder, if that is where it lives. Art +// the user chose from somewhere else is left exactly where it is - it is not +// the theme's to move. +function GuiProfileEditorLibrary::moveCursorArt(%this, %cursor, %source, %target, %newPrefix) +{ + %current = %cursor.bitmapName; + if(%current $= "" || filePath(makeFullPath(%current, getMainDotCsDir())) !$= %source) + { + return; + } + + %name = fileName(%current); + %this.moveCursorFile(%name, %source, %target); + %cursor.bitmapName = pathConcat(%newPrefix, %name); +} + // Point a loaded or new root at the project's font folder. A theme restamps its // members on assignment, so its profiles follow along (an overridden field is // left alone, as with any other stamp). @@ -204,6 +367,39 @@ return 0; } +// The cursor counterpart of findProfileByName, and needed for the same reason: +// a cursor made during an editor session may carry a name the Sim dictionary +// never registered, and reading its slot as empty would let a re-theme quietly +// overwrite a deliberate choice. +function GuiProfileEditorLibrary::findCursorByName(%this, %name) +{ + if(%name $= "") + { + return 0; + } + + %themes = %this.getThemes(); + for(%i = 0; %i < getWordCount(%themes); %i++) + { + %theme = getWord(%themes, %i); + %categories = %theme.getCursorCategoryNames(); + for(%c = 0; %c < getWordCount(%categories); %c++) + { + %members = %theme.getCursors(getWord(%categories, %c)); + for(%m = 0; %m < getWordCount(%members); %m++) + { + %member = getWord(%members, %m); + if(%member.getName() $= %name) + { + return %member; + } + } + } + } + + return 0; +} + // Is this one of the stand-alone profiles the editor manages? Asked before an // apply overwrites a slot: a stand-alone profile is the supported alternative to // theming and is left alone unless the user says otherwise. A script profile - @@ -221,6 +417,30 @@ return false; } +// The standalone profiles stamped for one category, as a space-separated list +// of ids. Asked by the Gui Editor's properties pane, which offers a control's +// profile slot the members of the slot's category and nothing else. +// +// %category is matched exactly, and "" is a real answer rather than a wildcard: +// a standalone starts with no category (the profile form shows that as "Any") +// and the pane treats those differently from a stamped one -- offering them +// wherever a slot is already on show, but never letting one be the reason a +// slot appears. +function GuiProfileEditorLibrary::getStandaloneProfiles(%this, %category) +{ + %list = ""; + for(%i = 0; %i < %this.standaloneFolder.getCount(); %i++) + { + %profile = %this.standaloneFolder.getObject(%i).target; + if(!isObject(%profile) || %profile.category !$= %category) + { + continue; + } + %list = (%list $= "") ? %profile.getId() : (%list SPC %profile.getId()); + } + return %list; +} + // Load any theme files not already loaded. Safe to call on every dialog // open: files belonging to live objects are skipped. function GuiProfileEditorLibrary::scanThemes(%this) @@ -280,6 +500,10 @@ // keeps its caches. Repair it on load, but don't mark the theme dirty // over it: the corrected path is written the next time it is saved. %this.applyFontsPath(%object); + // Likewise the cursor art: a theme that arrived from another project, or + // one written before cursors existed, gets its own folder here rather + // than having none. + %this.seedThemeCursors(%object); %this.addThemeProxies(%object); return %object; } @@ -390,6 +614,7 @@ %this.sourceFile[%theme.getId()] = %file; %this.loadedFile[%file] = true; %this.applyFontsPath(%theme); + %this.seedThemeCursors(%theme); %this.addThemeProxies(%theme); } } @@ -459,6 +684,38 @@ %borderFolder.add(%borderProxy); } + // Cursors follow the profile shape rather than the border one: a category + // row that holds the default member, with any extras beneath it. A theme + // offering two cursors for the same job is what makes the Gui Editor show a + // choice on a control's cursor slot. + %cursorFolder = new SimGroup() + { + kind = "folder"; + treeLabel = "Cursors"; + }; + %proxy.add(%cursorFolder); + + %cursorNames = %theme.getCursorCategoryNames(); + for(%i = 0; %i < getWordCount(%cursorNames); %i++) + { + %category = getWord(%cursorNames, %i); + %cursorProxy = new SimGroup() + { + kind = "cursorCategory"; + theme = %theme; + category = %category; + treeLabel = %category; + }; + %this.cursorCategoryProxy[%theme.getId() @ "_" @ %category] = %cursorProxy; + %cursorFolder.add(%cursorProxy); + + %cursors = %theme.getCursors(%category); + for(%c = 1; %c < getWordCount(%cursors); %c++) + { + %this.addCursorExtraProxy(%theme, %category, getWord(%cursors, %c)); + } + } + %this.proxyRoot.add(%proxy); // The Stand Alone folder always stays at the bottom of the tree. @@ -491,6 +748,32 @@ %categoryProxy.add(%leaf); } +function GuiProfileEditorLibrary::addCursorExtraProxy(%this, %theme, %category, %cursor) +{ + %categoryProxy = %this.cursorCategoryProxy[%theme.getId() @ "_" @ %category]; + if(!isObject(%categoryProxy)) + { + return; + } + + %label = %cursor.getName(); + if(%label $= "") + { + %label = "(unnamed)"; + } + + %leaf = new ScriptObject() + { + kind = "cursorExtra"; + theme = %theme; + target = %cursor; + category = %category; + treeLabel = %label; + }; + %this.cursorExtraProxy[%cursor.getId()] = %leaf; + %categoryProxy.add(%leaf); +} + function GuiProfileEditorLibrary::addStandaloneProxy(%this, %profile, %bundle) { %label = %profile.getName(); @@ -792,13 +1075,24 @@ // Operations. //----------------------------------------------------------------------------- +// Mark a file for deletion at the next save. Nothing is removed from disk until +// then, so Cancel keeps it. +function GuiProfileEditorLibrary::doomFile(%this, %file) +{ + if(%file $= "") + { + return; + } + %this.doomedFile[%this.doomedFileCount] = %file; + %this.doomedFileCount++; +} + function GuiProfileEditorLibrary::doomSourceFile(%this, %root) { %file = %this.sourceFile[%root.getId()]; if(%file !$= "") { - %this.doomedFile[%this.doomedFileCount] = %file; - %this.doomedFileCount++; + %this.doomFile(%file); %this.sourceFile[%root.getId()] = ""; %this.loadedFile[%file] = ""; } @@ -831,6 +1125,7 @@ %theme.borderSize = 1; %theme.fontSize = 16; %this.applyFontsPath(%theme); + %this.seedThemeCursors(%theme); %this.sourceFile[%theme.getId()] = ""; %this.addThemeProxies(%theme); @@ -852,6 +1147,8 @@ function GuiProfileEditorLibrary::renameThemeTo(%this, %theme, %name) { + %oldName = %theme.getName(); + %this.beginNaming(); %renamed = %theme.renameTheme(%name); %this.endNaming(); @@ -861,6 +1158,11 @@ return false; } + // The art folder is named for the theme, so it follows the rename. Members + // still pointing into the old folder are repointed; art the user chose from + // somewhere else is left where it is. + %this.moveThemeCursors(%theme, %oldName); + // The old file no longer matches the theme; replace it on save. %this.doomSourceFile(%theme); @@ -888,6 +1190,21 @@ } } + %cursorNames = %theme.getCursorCategoryNames(); + for(%i = 0; %i < getWordCount(%cursorNames); %i++) + { + %cursors = %theme.getCursors(getWord(%cursorNames, %i)); + for(%c = 1; %c < getWordCount(%cursors); %c++) + { + %cursor = getWord(%cursors, %c); + %leaf = %this.cursorExtraProxy[%cursor.getId()]; + if(isObject(%leaf)) + { + %leaf.treeLabel = %cursor.getName(); + } + } + } + return true; } @@ -907,6 +1224,126 @@ return %profile; } +// A second (or third) cursor in a category, with art of its own so editing it +// cannot change what the category's default looks like. The copy is named for +// the member, which is what keeps two extras in one category apart. +function GuiProfileEditorLibrary::createExtraCursor(%this, %theme, %category) +{ + %this.beginNaming(); + %cursor = %theme.createCursor(%category); + %this.endNaming(); + + if(!isObject(%cursor)) + { + return 0; + } + + %folder = %this.getThemeCursorsPath(%theme); + %stock = %theme.getCursorStockFile(%category); + if(%folder !$= "" && %stock !$= "") + { + %file = pathConcat(%folder, %cursor.getName() @ fileExt(%stock)); + if(pathCopy(pathConcat(%folder, %stock), %file)) + { + %cursor.bitmapName = makeRelativePath(%file, getMainDotCsDir()); + } + } + + %this.addCursorExtraProxy(%theme, %category, %cursor); + %this.markDirty(%theme); + return %cursor; +} + +// Removes the cursor and answers with the art file it leaves behind, or "" if +// there is nothing worth asking about. The file is NOT deleted here: the art +// may be the user's own, it may still be in use, and either way throwing away +// a picture is not something to do as a side effect of removing the thing that +// happened to be pointing at it. The caller asks; deleteCursorArt does it. +function GuiProfileEditorLibrary::removeExtraCursor(%this, %theme, %cursor) +{ + %leaf = %this.cursorExtraProxy[%cursor.getId()]; + %bitmap = %cursor.bitmapName; + + %removed = %theme.removeCursor(%cursor); + if(!%removed) + { + return ""; + } + + if(isObject(%leaf)) + { + %leaf.delete(); + } + %this.cursorExtraProxy[%cursor.getId()] = ""; + %this.markDirty(%theme); + + return %this.orphanedCursorArt(%theme, %bitmap); +} + +// Is this art now unused, ours to remove, and actually there? Only then is +// there a question to ask. Called after the cursor is gone, so "unused" is a +// plain search of what remains. +// +// Three ways to answer no, and each is a file that must survive: +// - a path outside the theme's own cursor folder is the user's own picture, +// chosen with the Find button; this editor did not put it there. +// - a path another cursor still names is in use, and the obvious case is an +// extra pointed at the category's stock art, which the default member uses. +// - a path with no file behind it has nothing to delete. +function GuiProfileEditorLibrary::orphanedCursorArt(%this, %theme, %bitmap) +{ + if(%bitmap $= "") + { + return ""; + } + + %full = makeFullPath(%bitmap, getMainDotCsDir()); + %folder = %this.getThemeCursorsPath(%theme); + if(%folder $= "" || filePath(%full) !$= %folder) + { + return ""; + } + + if(%this.isCursorArtInUse(%bitmap) || !isFile(%full)) + { + return ""; + } + + return %full; +} + +// Does any cursor in any loaded theme still name this art? Compared as the +// relative paths a theme stores, which is the one form they all agree on. +function GuiProfileEditorLibrary::isCursorArtInUse(%this, %bitmap) +{ + %themes = %this.getThemes(); + for(%i = 0; %i < getWordCount(%themes); %i++) + { + %theme = getWord(%themes, %i); + %categories = %theme.getCursorCategoryNames(); + for(%c = 0; %c < getWordCount(%categories); %c++) + { + %members = %theme.getCursors(getWord(%categories, %c)); + for(%m = 0; %m < getWordCount(%members); %m++) + { + if(getWord(%members, %m).bitmapName $= %bitmap) + { + return true; + } + } + } + } + + return false; +} + +// Drop the file at the next save, like every other file this editor removes - +// so Cancel keeps it, exactly as Cancel keeps a deleted theme. +function GuiProfileEditorLibrary::deleteCursorArt(%this, %file) +{ + %this.doomFile(%file); +} + function GuiProfileEditorLibrary::removeExtraProfile(%this, %theme, %profile) { // Only an extra has a leaf, and only an extra can be removed - so this is also diff --git a/editor/GuiEditor/scripts/GuiProfileEditorPreview.cs b/editor/GuiEditor/scripts/GuiProfileEditorPreview.cs index 9bfc094c8..56324b78c 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorPreview.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorPreview.cs @@ -102,6 +102,10 @@ { %this.showBorder(%theme, %member); } + else if(%kind $= "cursor") + { + %this.showCursor(%theme, %member); + } } // Size the stage to the bounding box of the current samples and center it @@ -242,6 +246,14 @@ %this.reskinSlot(%ctrl, %theme, "listBoxProfile", "DropDownItem"); %this.reskinSlot(%ctrl, %theme, "backgroundProfile", "Overlay"); + // The four cursor slots the engine has, so hovering the sample's text box or + // a window edge shows this theme's own cursors rather than whichever set is + // installed globally. + %this.reskinCursorSlot(%ctrl, %theme, "editCursor", "Edit"); + %this.reskinCursorSlot(%ctrl, %theme, "leftRightCursor", "LeftRight"); + %this.reskinCursorSlot(%ctrl, %theme, "upDownCursor", "UpDown"); + %this.reskinCursorSlot(%ctrl, %theme, "nWSECursor", "NWSE"); + %this.fillSampleContent(%ctrl); for(%i = 0; %i < %ctrl.getCount(); %i++) @@ -286,6 +298,23 @@ } } +// The cursor equivalent, and it cannot use the same test: an unset cursor field +// reads back as "" exactly like a field the control does not have, and sample +// controls never set one. So ask the type system whether the field exists +// rather than asking whether it holds anything. +function GuiProfileEditorPreview::reskinCursorSlot(%this, %ctrl, %theme, %field, %category) +{ + if(%ctrl.getFieldType(%field) !$= "GuiCursor") + { + return; + } + %cursor = %theme.getCursor(%category); + if(isObject(%cursor)) + { + %ctrl.setEditFieldValue(%field, %cursor); + } +} + function GuiProfileEditorPreview::categoryForClass(%this, %class) { switch$(%class) @@ -641,6 +670,128 @@ %this.layoutStage(); } +//----------------------------------------------------------------------------- +// Cursors. The only preview in this pane that is not something to look at: a +// cursor is judged by using it, so this is a range to move the pointer through +// with targets small enough that a hot spot a few pixels out is obvious. +// +// The canvas cursor is swapped on entry and put back on exit, which is why the +// buttons inside show it too -- a button does not override getCursor, so what +// the canvas holds is what it displays. +//----------------------------------------------------------------------------- + +function GuiProfileEditorPreview::showCursor(%this, %theme, %cursor) +{ + %this.clearSamples(); + if(!isObject(%cursor)) + { + return; + } + %this.lastKind = "cursor"; + %this.lastTheme = %theme; + %this.lastMember = %cursor; + + // Dressed in the theme being edited rather than the editor's own chrome, like + // every other sample in this pane. That is also what gives the targets a + // hover state: the editor's button profile has no highlight fill, so targets + // wearing it sat dead under the pointer -- and a target you cannot see + // yourself hit is no test of a hot spot. + %range = new GuiControl() + { + class = "GuiProfileEditorCursorRange"; + Position = "0 0"; + Extent = "280 220"; + preview = %this; + cursor = %cursor; + }; + %panel = isObject(%theme) ? %theme.getProfile("Panel") : 0; + if(isObject(%panel)) + { + %range.setEditFieldValue("Profile", %panel); + } + else + { + ThemeManager.setProfile(%range, "displayBoxProfile"); + } + %this.addSample(%range); + + // Laid out by sizing flags rather than by numbers, because the range wears a + // profile from the theme being edited and a theme is free to give its panels + // however much padding it likes -- PlanetX does, and hard-coded positions + // slid out from under it. GuiControl::onChildAdded parentResizes each child + // against the parent's INNER rect, so "fill" and "center" resolve against + // the padded area the moment the child is added. + %hint = new GuiControl() + { + HorizSizing = "fill"; + VertSizing = "anchorTop"; + Position = "0 0"; + Extent = "280 40"; + Text = "Move in here to try the cursor. The target is small on purpose."; + align = "center"; + vAlign = "top"; + textWrap = true; + textExtend = true; + }; + ThemeManager.setProfile(%hint, "labelProfile"); + %range.add(%hint); + + // One target, centred. Six scattered ones needed absolute coordinates to be + // scattered *within*, which is exactly what a padded panel takes away; a + // single centred button says the same thing and cannot drift. + %target = new GuiButtonCtrl() + { + HorizSizing = "center"; + VertSizing = "center"; + Position = "128 98"; + Extent = "24 24"; + Text = "+"; + }; + %button = isObject(%theme) ? %theme.getProfile("Button") : 0; + if(isObject(%button)) + { + %target.setEditFieldValue("Profile", %button); + } + else + { + ThemeManager.setProfile(%target, "buttonProfile"); + } + %range.add(%target); + + %this.layoutStage(); +} + +// Entering swaps the canvas cursor for the one being edited; leaving puts back +// the editor's own. Both are needed: the canvas cursor is global, so a preview +// that only set it would leave the whole editor wearing a half-finished cursor. +function GuiProfileEditorCursorRange::onMouseEnter(%this) +{ + if(isObject(%this.cursor)) + { + Canvas.setCursor(%this.cursor); + } +} + +function GuiProfileEditorCursorRange::onMouseLeave(%this) +{ + %this.restoreCursor(); +} + +function GuiProfileEditorCursorRange::onRemove(%this) +{ + // Deleted while the pointer is inside it -- selecting another node rebuilds + // the stage -- and onMouseLeave never arrives. + %this.restoreCursor(); +} + +function GuiProfileEditorCursorRange::restoreCursor(%this) +{ + if(isObject(ThemeManager.activeTheme.defaultCursor)) + { + Canvas.setCursor(ThemeManager.activeTheme.defaultCursor); + } +} + //----------------------------------------------------------------------------- // Sample builders. //----------------------------------------------------------------------------- diff --git a/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs b/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs index e915bb7ee..18ff118df 100644 --- a/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs +++ b/editor/GuiEditor/scripts/GuiProfileEditorStateColorRow.cs @@ -112,12 +112,12 @@ class = "GuiProfileEditorColorPopup"; %this.caption[%i] = %caption; } - // Frame 22 of EditorCore:editorIcons16 is the circular revert arrow. "left" - // sizing pins the button to the cell's right edge as the grid widens. + // The circular revert arrow. "left" sizing pins the button to the cell's + // right edge as the grid widens. %this.resetButton = new GuiButtonCtrl() { class = "EditorIconButton"; - Frame = 22; + Frame = $EditorIcon::playback_reload; HorizSizing = "left"; Position = (%w - %resetW - %pad) SPC (%swatchY - 1); Tooltip = "Reset this row's overridden states to the theme's values"; diff --git a/editor/ProjectManager/scripts/ProjectGamePanel.cs b/editor/ProjectManager/scripts/ProjectGamePanel.cs index 6df552123..020db772d 100644 --- a/editor/ProjectManager/scripts/ProjectGamePanel.cs +++ b/editor/ProjectManager/scripts/ProjectGamePanel.cs @@ -3,8 +3,8 @@ { %this.init("Project"); - %this.buttonBar.addButton("createNewModule", 11, "Create Module", ""); - %this.buttonBar.addButton("editModule", 49, "Edit Module", "editModuleAvailable"); + %this.buttonBar.addButton("createNewModule", $EditorIcon::doc_plus, "Create Module", ""); + %this.buttonBar.addButton("editModule", $EditorIcon::doc_edit, "Edit Module", "editModuleAvailable"); } function ProjectGamePanel::onOpen(%this, %allModules) diff --git a/engine/source/2d/gui/guiImageButtonCtrl.cc b/engine/source/2d/gui/guiImageButtonCtrl.cc deleted file mode 100755 index 9ffd158a9..000000000 --- a/engine/source/2d/gui/guiImageButtonCtrl.cc +++ /dev/null @@ -1,292 +0,0 @@ -//----------------------------------------------------------------------------- -// 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 _GUIIMAGEBUTTON_H_ -#include "2d/gui/guiImageButtonCtrl.h" -#endif - -#ifndef _RENDER_PROXY_H_ -#include "2d/core/RenderProxy.h" -#endif - -#ifndef _DGL_H_ -#include "graphics/dgl.h" -#endif - -#ifndef _CONSOLE_H_ -#include "console/console.h" -#endif - -#ifndef _CONSOLETYPES_H_ -#include "console/consoleTypes.h" -#endif - -#ifndef _GUICANVAS_H_ -#include "gui/guiCanvas.h" -#endif - -#ifndef _H_GUIDEFAULTCONTROLRENDER_ -#include "gui/guiDefaultControlRender.h" -#endif - -/// Script bindings. -#include "guiImageButtonCtrl_ScriptBindings.h" - -//----------------------------------------------------------------------------- - -IMPLEMENT_CONOBJECT(GuiImageButtonCtrl); - -//----------------------------------------------------------------------------- - -GuiImageButtonCtrl::GuiImageButtonCtrl() : - mNormalAssetId( StringTable->EmptyString ), - mHoverAssetId( StringTable->EmptyString ), - mDownAssetId( StringTable->EmptyString ), - mInactiveAssetId( StringTable->EmptyString ) -{ - mBounds.extent.set(140, 30); -} - -//----------------------------------------------------------------------------- - -void GuiImageButtonCtrl::initPersistFields() -{ - // Call parent. - Parent::initPersistFields(); - - addProtectedField("NormalImage", TypeAssetId, Offset(mNormalAssetId, GuiImageButtonCtrl), &setNormalImage, &getNormalImage, "The image asset Id used for the normal button state."); - addProtectedField("HoverImage", TypeAssetId, Offset(mHoverAssetId, GuiImageButtonCtrl), &setHoverImage, &getHoverImage, "The image asset Id used for the hover button state."); - addProtectedField("DownImage", TypeAssetId, Offset(mDownAssetId, GuiImageButtonCtrl), &setDownImage, &getDownImage, "The image asset Id used for the Down button state."); - addProtectedField("InactiveImage", TypeAssetId, Offset(mInactiveAssetId, GuiImageButtonCtrl), &setInactiveImage, &getInactiveImage, "The image asset Id used for the inactive button state."); -} - -//----------------------------------------------------------------------------- - -bool GuiImageButtonCtrl::onWake() -{ - // Call parent. - if (!Parent::onWake()) - return false; - - // Is only the "normal" image specified? - if ( mNormalAssetId != StringTable->EmptyString && - mHoverAssetId == StringTable->EmptyString && - mDownAssetId == StringTable->EmptyString && - mInactiveAssetId == StringTable->EmptyString ) - { - // Yes, so use it for all states. - mImageNormalAsset = mNormalAssetId; - mImageHoverAsset = mNormalAssetId; - mImageDownAsset = mNormalAssetId; - mImageInactiveAsset = mNormalAssetId; - } - else - { - // No, so assign individual states. - mImageNormalAsset = mNormalAssetId; - mImageHoverAsset = mHoverAssetId; - mImageDownAsset = mDownAssetId; - mImageInactiveAsset = mInactiveAssetId; - } - - return true; -} - -//----------------------------------------------------------------------------- - -void GuiImageButtonCtrl::onSleep() -{ - // Clear assets. - mImageNormalAsset.clear(); - mImageHoverAsset.clear(); - mImageDownAsset.clear(); - mImageInactiveAsset.clear(); - - // Call parent. - Parent::onSleep(); -} - -//----------------------------------------------------------------------------- - -void GuiImageButtonCtrl::setNormalImage( const char* pImageAssetId ) -{ - // Sanity! - AssertFatal( pImageAssetId != NULL, "Cannot use a NULL asset Id." ); - - // Fetch the asset Id. - mNormalAssetId = StringTable->insert(pImageAssetId); - - // Assign asset if awake. - if ( isAwake() ) - mImageNormalAsset = mNormalAssetId; - - // Update control. - setUpdate(); -} - -//----------------------------------------------------------------------------- - -void GuiImageButtonCtrl::setHoverImage( const char* pImageAssetId ) -{ - // Sanity! - AssertFatal( pImageAssetId != NULL, "Cannot use a NULL asset Id." ); - - // Fetch the asset Id. - mHoverAssetId = StringTable->insert(pImageAssetId); - - // Assign asset if awake. - if ( isAwake() ) - mImageHoverAsset = mHoverAssetId; - - // Update control. - setUpdate(); -} - -//----------------------------------------------------------------------------- - -void GuiImageButtonCtrl::setDownImage( const char* pImageAssetId ) -{ - // Sanity! - AssertFatal( pImageAssetId != NULL, "Cannot use a NULL asset Id." ); - - // Fetch the asset Id. - mDownAssetId = StringTable->insert(pImageAssetId); - - // Assign asset if awake. - if ( isAwake() ) - mImageDownAsset = mDownAssetId; - - // Update control. - setUpdate(); -} - -//----------------------------------------------------------------------------- - -void GuiImageButtonCtrl::setInactiveImage( const char* pImageAssetId ) -{ - // Sanity! - AssertFatal( pImageAssetId != NULL, "Cannot use a NULL asset Id." ); - - // Fetch the asset Id. - mInactiveAssetId = StringTable->insert(pImageAssetId); - - // Assign asset if awake. - if ( isAwake() ) - mImageInactiveAsset = mInactiveAssetId; - - // Update control. - setUpdate(); -} - -//----------------------------------------------------------------------------- - -void GuiImageButtonCtrl::onRender(Point2I offset, const RectI& updateRect) -{ - // Reset button state. - ButtonState state = NORMAL; - - // Calculate button state. - if ( mActive ) - { - if ( mMouseOver ) - state = HOVER; - - if ( mDepressed ) - state = DOWN; - } - else - { - state = INACTIVE; - } - - switch (state) - { - case NORMAL: - { - // Render the "normal" asset. - renderButton(mImageNormalAsset, 0, offset, updateRect); - - } break; - - case HOVER: - { - // Render the "hover" asset. - renderButton(mImageHoverAsset, 0, offset, updateRect); - - } break; - - case DOWN: - { - // Render the "down" asset. - renderButton(mImageDownAsset, 0, offset, updateRect); - - } break; - - case INACTIVE: - { - // Render the "inactive" asset. - renderButton(mImageInactiveAsset, 0, offset, updateRect); - - } break; - } -} - -//------------------------------------------------------------------------------ - -void GuiImageButtonCtrl::renderButton( ImageAsset* pImageAsset, const U32 frame, Point2I &offset, const RectI& updateRect ) -{ - // Ignore an invalid datablock. - if ( pImageAsset == NULL ) - return; - - // Is the asset valid and has the specified frame? - if ( pImageAsset->isAssetValid() && frame < pImageAsset->getFrameCount() ) - { - // Yes, so calculate the source region. - const ImageAsset::FrameArea::PixelArea& pixelArea = pImageAsset->getImageFrameArea( frame ).mPixelArea; - RectI sourceRegion( pixelArea.mPixelOffset, Point2I(pixelArea.mPixelWidth, pixelArea.mPixelHeight) ); - - // Calculate destination region. - RectI destinationRegion(offset, mBounds.extent); - - // Render image. - dglSetBitmapModulation( mProfile->mFillColor ); - dglDrawBitmapStretchSR( pImageAsset->getImageTexture(), destinationRegion, sourceRegion ); - dglClearBitmapModulation(); - renderChildControls( offset, mBounds, updateRect); - } - else - { - // No, so fetch the 'cannot render' proxy. - RenderProxy* pNoImageRenderProxy = Sim::findObject( CANNOT_RENDER_PROXY_NAME ); - - // Finish if no render proxy available or it can't render. - if ( pNoImageRenderProxy == NULL || !pNoImageRenderProxy->validRender() ) - return; - - // Render using render-proxy.. - pNoImageRenderProxy->renderGui( *this, offset, updateRect ); - } - - // Update the control. - setUpdate(); -} diff --git a/engine/source/2d/gui/guiImageButtonCtrl.h b/engine/source/2d/gui/guiImageButtonCtrl.h deleted file mode 100755 index 9d45f073b..000000000 --- a/engine/source/2d/gui/guiImageButtonCtrl.h +++ /dev/null @@ -1,101 +0,0 @@ -//----------------------------------------------------------------------------- -// 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 _GUIIMAGEBUTTON_H_ -#define _GUIIMAGEBUTTON_H_ - -#ifndef _GUIBUTTONCTRL_H_ -#include "gui/buttons/guiButtonCtrl.h" -#endif -#ifndef _TEXTURE_MANAGER_H_ -#include "graphics/TextureManager.h" -#endif - -#ifndef _IMAGE_ASSET_H_ -#include "2d/assets/ImageAsset.h" -#endif - -#ifndef _ASSET_PTR_H_ -#include "assets/assetPtr.h" -#endif - -//----------------------------------------------------------------------------- - -class GuiImageButtonCtrl : public GuiButtonCtrl -{ -private: - typedef GuiButtonCtrl Parent; - -protected: - StringTableEntry mNormalAssetId; - StringTableEntry mHoverAssetId; - StringTableEntry mDownAssetId; - StringTableEntry mInactiveAssetId; - - AssetPtr mImageNormalAsset; - AssetPtr mImageHoverAsset; - AssetPtr mImageDownAsset; - AssetPtr mImageInactiveAsset; - - void renderButton( ImageAsset* pImageAsset, const U32 frame, Point2I &offset, const RectI& updateRect); - -protected: - enum ButtonState - { - NORMAL, - HOVER, - DOWN, - INACTIVE - }; - -public: - GuiImageButtonCtrl(); - bool onWake(); - void onSleep(); - void onRender(Point2I offset, const RectI &updateRect); - - static void initPersistFields(); - - void setNormalImage( const char* pImageAssetId ); - inline StringTableEntry getNormalImage( void ) const { return mNormalAssetId; } - void setHoverImage( const char* pImageAssetId ); - inline StringTableEntry getHoverImage( void ) const { return mHoverAssetId; } - void setDownImage( const char* pImageAssetId ); - inline StringTableEntry getDownImage( void ) const { return mDownAssetId; } - void setInactiveImage( const char* pImageAssetId ); - inline StringTableEntry getInactiveImage( void ) const { return mInactiveAssetId; } - - // Declare type. - DECLARE_CONOBJECT(GuiImageButtonCtrl); - -protected: - static bool setNormalImage(void* obj, const char* data) { static_cast(obj)->setNormalImage( data ); return false; } - static const char* getNormalImage(void* obj, const char* data) { return static_cast(obj)->getNormalImage(); } - static bool setHoverImage(void* obj, const char* data) { static_cast(obj)->setHoverImage( data ); return false; } - static const char* getHoverImage(void* obj, const char* data) { return static_cast(obj)->getHoverImage(); } - static bool setDownImage(void* obj, const char* data) { static_cast(obj)->setDownImage( data ); return false; } - static const char* getDownImage(void* obj, const char* data) { return static_cast(obj)->getDownImage(); } - static bool setInactiveImage(void* obj, const char* data) { static_cast(obj)->setInactiveImage( data ); return false; } - static const char* getInactiveImage(void* obj, const char* data) { return static_cast(obj)->getInactiveImage(); } -}; - -#endif //_GUIIMAGEBUTTON_H_ diff --git a/engine/source/2d/gui/guiImageButtonCtrl_ScriptBindings.h b/engine/source/2d/gui/guiImageButtonCtrl_ScriptBindings.h deleted file mode 100755 index ffe938018..000000000 --- a/engine/source/2d/gui/guiImageButtonCtrl_ScriptBindings.h +++ /dev/null @@ -1,74 +0,0 @@ -//----------------------------------------------------------------------------- -// 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(GuiImageButtonCtrl, GuiButtonCtrl) - -/*! Sets the asset Id the button \up\ state. - @return No return value. -*/ -ConsoleMethodWithDocs( GuiImageButtonCtrl, setNormalImage, ConsoleVoid, 3, 3, (imageAssetId)) -{ - object->setNormalImage( argv[2] ); -} - -//----------------------------------------------------------------------------- - -/*! Sets the asset Id the button \hover\ state. - @return No return value. -*/ -ConsoleMethodWithDocs( GuiImageButtonCtrl, setHoverImage, ConsoleVoid, 3, 3, (imageAssetId)) -{ - object->setHoverImage( argv[2] ); -} - -//----------------------------------------------------------------------------- - -/*! Sets the asset Id the button \down\ state. - @return No return value. -*/ -ConsoleMethodWithDocs( GuiImageButtonCtrl, setDownImage, ConsoleVoid, 3, 3, (imageAssetId)) -{ - object->setDownImage( argv[2] ); -} - -//----------------------------------------------------------------------------- - -/*! Sets the asset Id the button \inactive\ state. - @return No return value. -*/ -ConsoleMethodWithDocs( GuiImageButtonCtrl, setInactiveImage, ConsoleVoid, 3, 3, (imageAssetId)) -{ - object->setInactiveImage( argv[2] ); -} - -//----------------------------------------------------------------------------- - -/*! Sets the asset Id the button \inactive\ state. - @return No return value. -*/ -ConsoleMethodWithDocs( GuiImageButtonCtrl, setActive, ConsoleVoid, 3, 3, (imageAssetId)) -{ - bool flag = dAtob( argv[2] ); - object->setActive( flag ); -} - -ConsoleMethodGroupEndWithDocs(GuiImageButtonCtrl) diff --git a/engine/source/console/consoleNamespace.cc b/engine/source/console/consoleNamespace.cc index 325ef62e6..396c99fe4 100755 --- a/engine/source/console/consoleNamespace.cc +++ b/engine/source/console/consoleNamespace.cc @@ -150,6 +150,11 @@ bool Namespace::canTabComplete(const char *prevText, const char *bestMatch, cons bool Namespace::unlinkClass(Namespace* parent) { + // Giving back a self-link (see classLinkTo). Nothing was linked and nothing + // was counted, so there is nothing here to undo. + if(parent == this) + return true; + Namespace* walk = this; while(walk->mParent && walk->mParent->mName == mName) @@ -180,6 +185,23 @@ bool Namespace::unlinkClass(Namespace* parent) bool Namespace::classLinkTo(Namespace* parent) { + // A namespace already is itself, so linking it to itself cannot change what + // it inherits: it is a no-op, not the parent change it looks like. Objects + // ask for it whenever they are named after their own class - the singleton + // "new ScriptObject(Foo) { class = "Foo"; }" links Foo to the C++ class and + // then asks to link its name, Foo, to Foo. Counting it here would need an + // unlink that never comes, so it is not counted either. + // + // It is still worth saying, because the object said one thing twice and the + // second one is doing nothing. Once, here, where the repeat is made - not + // again from unlinkClass when it is given back. + if(parent == this) + { + Con::warnf(ConsoleLogEntry::General, "Namespace::classLinkTo - %s cannot be its own parent, and has no need to be: a name is already a namespace. An object whose name and class are both '%s' wants only the name.", + mName, mName); + return true; + } + Namespace* walk = this; while(walk->mParent && walk->mParent->mName == mName) diff --git a/engine/source/graphics/gFont.h b/engine/source/graphics/gFont.h index 873e2981e..0cfc450e4 100755 --- a/engine/source/graphics/gFont.h +++ b/engine/source/graphics/gFont.h @@ -220,6 +220,19 @@ inline U32 GFont::getCharHeight(const UTF16 in_charIndex) inline bool GFont::isValidChar(const UTF16 in_charIndex) const { + // A line break is not a glyph. Every caller reads "valid" as "draw this and + // count its width", and a platform font may well claim it is drawable -- + // WinFont::isValidChar answers true for everything but NUL, its range check + // commented out -- so a newline inside a string rendered as the font's + // missing-glyph box AND took up space, which in a text edit moved the caret + // as well. Answered here rather than in each back-end so every platform + // agrees. + // + // Before the remap table, not after: a font cached to a .uft with the box + // already in it would otherwise go on saying yes. + if(in_charIndex == '\n' || in_charIndex == '\r') + return false; + if(mRemapTable[in_charIndex] != -1) return true; diff --git a/engine/source/gui/buttons/guiButtonCtrl.cc b/engine/source/gui/buttons/guiButtonCtrl.cc index 2fd9501fe..d4c051508 100755 --- a/engine/source/gui/buttons/guiButtonCtrl.cc +++ b/engine/source/gui/buttons/guiButtonCtrl.cc @@ -38,7 +38,12 @@ GuiButtonCtrl::GuiButtonCtrl() mMouseOver = false; mActive = true; mBounds.extent.set(140, 30); - mText = StringTable->insert("Button"); + // No caption. A button whose text is deliberately blank - an image button + // wearing its whole face in the profile's imageAsset, say - must be able to + // say so: SimObject::writeField drops every empty value, so a blank caption + // is written as an absent one, and a default here would stand back up on + // read. The Gui Editor captions the buttons it places instead. + mText = StringTable->EmptyString; mTextID = StringTable->EmptyString; mProfile = NULL; mIsContainer = false; diff --git a/engine/source/gui/buttons/guiDropDownCtrl.cc b/engine/source/gui/buttons/guiDropDownCtrl.cc index 3af32adac..6606126c5 100644 --- a/engine/source/gui/buttons/guiDropDownCtrl.cc +++ b/engine/source/gui/buttons/guiDropDownCtrl.cc @@ -142,6 +142,64 @@ void GuiDropDownCtrl::initPersistFields() endGroup("Drop Down"); } +//----------------------------------------------------------------------------- +// Static rows. All four of these belong to the list box; a drop down only has to +// find it, because mListBox is not a child of anything a writer or a clone +// walks. It writes rather than +// with nothing extra to do: TamlXmlWriter::compileCustomElements names the +// section after the element it is writing. +//----------------------------------------------------------------------------- + +void GuiDropDownCtrl::onTamlCustomWrite(TamlCustomNodes& customNodes) +{ + Parent::onTamlCustomWrite(customNodes); + + if (mListBox != NULL) + { + mListBox->onTamlCustomWrite(customNodes); + } +} + +void GuiDropDownCtrl::onTamlCustomRead(const TamlCustomNodes& customNodes) +{ + Parent::onTamlCustomRead(customNodes); + + if (mListBox != NULL) + { + mListBox->onTamlCustomRead(customNodes); + } +} + +const char* GuiDropDownCtrl::getItemList() +{ + return (mListBox != NULL) ? mListBox->getItemList() : ""; +} + +void GuiDropDownCtrl::setItemList(const char* itemList) +{ + if (mListBox != NULL) + { + mListBox->setItemList(itemList); + } + + // The button draws the selected row's caption, so a list arriving with one + // already on changes what the drop down itself reads. + setUpdate(); +} + +void GuiDropDownCtrl::deepCloneChildren(SimObject* clone) +{ + Parent::deepCloneChildren(clone); + + GuiDropDownCtrl* pDropDown = dynamic_cast(clone); + if (pDropDown == NULL) + { + return; + } + + pDropDown->setItemList(getItemList()); +} + void GuiDropDownCtrl::onTouchUp(const GuiEvent &event) { if (!mActive) diff --git a/engine/source/gui/buttons/guiDropDownCtrl.h b/engine/source/gui/buttons/guiDropDownCtrl.h index eb6dcf8af..b036205e9 100644 --- a/engine/source/gui/buttons/guiDropDownCtrl.h +++ b/engine/source/gui/buttons/guiDropDownCtrl.h @@ -91,6 +91,19 @@ class GuiDropDownCtrl : public GuiButtonCtrl GuiDropDownCtrl(); static void initPersistFields(); + /// @name Static rows + /// + /// A drop down's rows live in mListBox, which is built in the constructor and + /// added to no set anything walks - so none of GuiListBoxCtrl's persistence + /// reaches it on its own. Each of these is the list box's, forwarded, the way + /// the ~30 item methods in guiDropDownCtrl_ScriptBinding.h already are. + /// @{ + virtual void onTamlCustomWrite(TamlCustomNodes& customNodes); + virtual void onTamlCustomRead(const TamlCustomNodes& customNodes); + const char* getItemList(); + void setItemList(const char* itemList); + /// @} + virtual void onTouchUp(const GuiEvent &event); GuiControlState getCurrentState(); void onRender(Point2I offset, const RectI &updateRect); @@ -121,6 +134,11 @@ class GuiDropDownCtrl : public GuiButtonCtrl static bool writeScrollBarThicknessFn(void* obj, StringTableEntry pFieldName) { return static_cast(obj)->mScrollBarThickness != DEFAULT_THICKNESS; } DECLARE_CONOBJECT(GuiDropDownCtrl); + +protected: + /// The rows again: a deep clone copies fields and children, and the list box + /// holding them is neither. + virtual void deepCloneChildren(SimObject* clone); }; #endif \ No newline at end of file diff --git a/engine/source/gui/buttons/guiDropDownCtrl_ScriptBinding.h b/engine/source/gui/buttons/guiDropDownCtrl_ScriptBinding.h index d73cc9e14..57c8807fe 100644 --- a/engine/source/gui/buttons/guiDropDownCtrl_ScriptBinding.h +++ b/engine/source/gui/buttons/guiDropDownCtrl_ScriptBinding.h @@ -461,4 +461,26 @@ ConsoleMethodWithDocs(GuiDropDownCtrl, sortByID, ConsoleVoid, 2, 3, "([bool asce object->getList()->sortByID(direction); } +/*! Gets the whole list as text: every row, with its ID, color and state. Opaque + - hand it back to setItemList unchanged. + @return The list, as a string. +*/ +ConsoleMethodWithDocs(GuiDropDownCtrl, getItemList, ConsoleString, 2, 2, ()) +{ + return object->getItemList(); +} + +/*! Replaces every row in the list with the ones described by text taken earlier + by getItemList. + + A record may stop short: a caption on its own is a row, and every field left + off keeps its default. So "Easy\nNormal\nHard" is three plain rows. + @param itemList A string from getItemList. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiDropDownCtrl, setItemList, ConsoleVoid, 3, 3, (itemList)) +{ + object->setItemList(argv[2]); +} + ConsoleMethodGroupEndWithDocs(GuiDropDownCtrl) \ No newline at end of file diff --git a/engine/source/gui/containers/guiFrameSetCtrl.cc b/engine/source/gui/containers/guiFrameSetCtrl.cc index d748a001d..1c91f5f8e 100644 --- a/engine/source/gui/containers/guiFrameSetCtrl.cc +++ b/engine/source/gui/containers/guiFrameSetCtrl.cc @@ -513,6 +513,253 @@ void GuiFrameSetCtrl::loadFrame(GuiFrameSetCtrl::Frame* frame, const U32 frameID } } +//----------------------------------------------------------------------------- +// The frame tree as text. +// +// A frame set keeps its layout in a tree beside the child list, and destroys a +// frame outright when the control in it is removed (onChildRemoved below), so +// deleting a control collapses the split it was in. Nothing could put that +// back: the tree is built once in onAdd from dynamic fields, which are then +// cleared, and there was no way to read it or write it afterwards. That made a +// frame set the one container the Gui Editor's undo could not fully restore. +// +// One record per frame, a parent always before its children, eight numbers +// each: +// +// id child1 child2 isVertical extentX extentY isAnchored controlID +// +// The control is named by object id rather than by its index among the +// children, so that restoring a tree recorded before a delete - one naming a +// control that is now sitting in the editor's trash - simply leaves that frame +// empty rather than putting the wrong control in it. +//----------------------------------------------------------------------------- + +const char* GuiFrameSetCtrl::getFrameLayout() +{ + char* buffer = Con::getReturnBuffer(4096); + buffer[0] = '\0'; + + appendFrameLayout(&mRootFrame, buffer, 4096); + + return buffer; +} + +void GuiFrameSetCtrl::appendFrameLayout(GuiFrameSetCtrl::Frame* frame, char* buffer, const U32 size) +{ + char record[128]; + dSprintf(record, sizeof(record), "%d %d %d %d %d %d %d %d ", + frame->id, + frame->child1 ? frame->child1->id : 0, + frame->child2 ? frame->child2->id : 0, + frame->isVertical ? 1 : 0, + frame->extent.x, + frame->extent.y, + frame->isAnchored ? 1 : 0, + frame->control ? frame->control->getId() : 0); + + if ((dStrlen(buffer) + dStrlen(record)) >= size) + { + Con::warnf("GuiFrameSetCtrl::getFrameLayout - frame tree is too large to write out"); + return; + } + dStrcat(buffer, record); + + if (frame->child1) + { + appendFrameLayout(frame->child1, buffer, size); + } + if (frame->child2) + { + appendFrameLayout(frame->child2, buffer, size); + } +} + +void GuiFrameSetCtrl::setFrameLayout(const char* layout) +{ + Vector values; + for (const char* at = layout; *at; ) + { + while (*at == ' ' || *at == '\t') + at++; + if (!*at) + break; + + values.push_back((U32)dAtoi(at)); + + while (*at && *at != ' ' && *at != '\t') + at++; + } + + if (values.size() < 8 || (values.size() % 8) != 0) + { + Con::warnf("GuiFrameSetCtrl::setFrameLayout - expected a multiple of eight values, got %d", values.size()); + return; + } + + // Back to a single frame. deleteChildren frees the subtree but leaves the + // pointers as they were, so they have to be cleared here or the rebuild + // would walk into freed frames. + mRootFrame.deleteChildren(); + mRootFrame.child1 = nullptr; + mRootFrame.child2 = nullptr; + mRootFrame.control = nullptr; + + buildFrameLayout(&mRootFrame, values[0], values); + + // Frames created from here on must not reuse an id the tree already holds. + for (U32 i = 0; i < (U32)values.size(); i += 8) + { + if (values[i] > mNextFrameID) + { + mNextFrameID = values[i]; + } + } + + resize(getPosition(), getExtent()); +} + +//----------------------------------------------------------------------------- +// Copying. A frame set is the one control in the engine that keeps a layout of +// its own beside the child list, and none of it is a persist field - it is +// written as TAML custom nodes and nothing else. So a copy of a frame set that +// only copied fields and children would come back with four children and no +// frames to put them in. +// +// This runs after the children have been copied, because a frame only takes a +// control that is already a child (onChildAdded/assignChildToFrame fills empty +// frames, it never makes one). +//----------------------------------------------------------------------------- + +void GuiFrameSetCtrl::deepCloneChildren(SimObject* clone) +{ + Parent::deepCloneChildren(clone); + + GuiFrameSetCtrl* pCloneFrameSet = dynamic_cast(clone); + if (pCloneFrameSet) + { + pCloneFrameSet->copyFrameTreeFrom(this); + } +} + +void GuiFrameSetCtrl::copyFrameTreeFrom(GuiFrameSetCtrl* source) +{ + if (!source) + { + return; + } + + // Back to a single frame first, exactly as setFrameLayout does: the children + // arriving have already been handed whichever frames were free, and those + // assignments are about to be made again properly. + mRootFrame.deleteChildren(); + mRootFrame.child1 = nullptr; + mRootFrame.child2 = nullptr; + mRootFrame.control = nullptr; + + copyFrame(&mRootFrame, &source->mRootFrame, source); + + resize(getPosition(), getExtent()); +} + +// The tree is copied structurally rather than through getFrameLayout, because +// the layout text names each frame's control by id - and the copy's children are +// new objects with new ids. What pairs them instead is position in the child +// list: deepCloneChildren copies children in order, so the source's nth child +// and this control's nth child are the same control. +void GuiFrameSetCtrl::copyFrame(GuiFrameSetCtrl::Frame* frame, const GuiFrameSetCtrl::Frame* sourceFrame, GuiFrameSetCtrl* source) +{ + frame->id = sourceFrame->id; + frame->isVertical = sourceFrame->isVertical; + frame->extent = sourceFrame->extent; + frame->isAnchored = sourceFrame->isAnchored; + frame->control = nullptr; + + // Frames split from here on must not reuse an id the copied tree holds. + if (sourceFrame->id > mNextFrameID) + { + mNextFrameID = sourceFrame->id; + } + + if (sourceFrame->child1 && sourceFrame->child2) + { + // The same call buildFrameLayout makes, and for the same reason: it is + // what builds a pair of frames under this one. The ids it assigns are + // overwritten by the recursion below with the source's own. + splitFrame(frame, frame->isVertical ? GuiDirection::Up : GuiDirection::Left); + + copyFrame(frame->child1, sourceFrame->child1, source); + copyFrame(frame->child2, sourceFrame->child2, source); + return; + } + + if (!sourceFrame->control) + { + return; + } + + for (U32 i = 0; i < (U32)source->size(); i++) + { + if ((*source)[i] == sourceFrame->control) + { + if (i < (U32)size()) + { + frame->control = dynamic_cast((*this)[i]); + } + break; + } + } +} + +void GuiFrameSetCtrl::buildFrameLayout(GuiFrameSetCtrl::Frame* frame, const U32 frameID, const Vector& values) +{ + S32 at = -1; + for (U32 i = 0; i < (U32)values.size(); i += 8) + { + if (values[i] == frameID) + { + at = (S32)i; + break; + } + } + + if (at < 0) + { + return; + } + + frame->id = frameID; + frame->isVertical = values[at + 3] != 0; + frame->extent.set(values[at + 4], values[at + 5]); + frame->isAnchored = values[at + 6] != 0; + frame->control = nullptr; + + const U32 child1ID = values[at + 1]; + const U32 child2ID = values[at + 2]; + + if (child1ID && child2ID) + { + // The same call loadFrame makes, and for the same reason: it is what + // builds a pair of frames under this one. + splitFrame(frame, frame->isVertical ? GuiDirection::Up : GuiDirection::Left); + + buildFrameLayout(frame->child1, child1ID, values); + buildFrameLayout(frame->child2, child2ID, values); + return; + } + + // A leaf, so it may hold a control - but only one that is still a child of + // this frame set. + const U32 controlID = values[at + 7]; + if (controlID) + { + GuiControl* ctrl; + if (Sim::findObject(controlID, ctrl) && ctrl->getGroup() == this) + { + frame->control = ctrl; + } + } +} + void GuiFrameSetCtrl::onChildAdded(GuiControl* child) { //Ensure the child isn't positioned to the center @@ -1343,16 +1590,30 @@ const char* GuiFrameSetCtrl::getDataField(const char* tag, const U32 id) return SimObject::getDataField(tagFieldName, idBuffer); } -static StringTableEntry frameNodeSectionName = StringTable->insert("Frames", true); -static StringTableEntry frameNodeName = StringTable->insert("Frame", true); -static StringTableEntry frameIDName = StringTable->insert("ID", true); -static StringTableEntry frameChild1Name = StringTable->insert("Child1ID", true); -static StringTableEntry frameChild2Name = StringTable->insert("Child2ID", true); -static StringTableEntry frameIsVerticalName = StringTable->insert("IsVertical", true); -static StringTableEntry frameExtentXName = StringTable->insert("ExtentX", true); -static StringTableEntry frameExtentYName = StringTable->insert("ExtentY", true); -static StringTableEntry frameIsAnchoredName = StringTable->insert("IsAnchored", true); -static StringTableEntry frameChildMapName = StringTable->insert("ChildMap", true); +// Interned case-INSENSITIVELY, which is what all of these are compared against: +// TamlCustomNodes::findNode and the XML parser both intern with the default, so +// a name interned the case-sensitive way is a different pointer and matches +// nothing. +// +// It is not theoretical. StringTable hands back the first spelling of a name it +// was ever given, so a case-sensitive "ID" here could write itself out as "Id" - +// whichever spelling reached the table first, from anywhere in the engine - and +// then fail to recognise its own output on the way back in, dropping the field +// with nothing but a warnf. Which spelling wins depends on static-initialisation +// order across translation units, so it can change from one build to the next. +// GuiListBoxCtrl's Items nodes had exactly that happen to them. +// +// It also means a hand-edited Gui may spell these however it likes. +static StringTableEntry frameNodeSectionName = StringTable->insert("Frames"); +static StringTableEntry frameNodeName = StringTable->insert("Frame"); +static StringTableEntry frameIDName = StringTable->insert("ID"); +static StringTableEntry frameChild1Name = StringTable->insert("Child1ID"); +static StringTableEntry frameChild2Name = StringTable->insert("Child2ID"); +static StringTableEntry frameIsVerticalName = StringTable->insert("IsVertical"); +static StringTableEntry frameExtentXName = StringTable->insert("ExtentX"); +static StringTableEntry frameExtentYName = StringTable->insert("ExtentY"); +static StringTableEntry frameIsAnchoredName = StringTable->insert("IsAnchored"); +static StringTableEntry frameChildMapName = StringTable->insert("ChildMap"); void GuiFrameSetCtrl::onTamlCustomWrite(TamlCustomNodes& customNodes) { diff --git a/engine/source/gui/containers/guiFrameSetCtrl.h b/engine/source/gui/containers/guiFrameSetCtrl.h index 7b605e5f0..a8500f508 100644 --- a/engine/source/gui/containers/guiFrameSetCtrl.h +++ b/engine/source/gui/containers/guiFrameSetCtrl.h @@ -105,6 +105,13 @@ class GuiFrameSetCtrl : public GuiEasingSupport bool onAdd(); virtual void parentResized(const Point2I& oldParentExtent, const Point2I& newParentExtent); void loadFrame(GuiFrameSetCtrl::Frame* frame, const U32 frameID); + + // The frame tree as text, readable and writable at runtime. See the note on + // getFrameLayout in the .cc for why this exists. + const char* getFrameLayout(); + void setFrameLayout(const char* layout); + void appendFrameLayout(GuiFrameSetCtrl::Frame* frame, char* buffer, const U32 size); + void buildFrameLayout(GuiFrameSetCtrl::Frame* frame, const U32 frameID, const Vector& values); void resize(const Point2I& newPosition, const Point2I& newExtent); void inspectPostApply(); bool onWake(); @@ -157,6 +164,16 @@ class GuiFrameSetCtrl : public GuiEasingSupport void setDataField(const char* tag, const U32 id, const U32 value); const char* getDataField(const char* tag, const U32 id); + // Rebuild %source's frame tree here, with this control's own children in it. + // Public for the sake of deepCloneChildren, which is called on the source and + // has to reach the copy. + void copyFrameTreeFrom(GuiFrameSetCtrl* source); + +protected: + virtual void deepCloneChildren(SimObject* clone); + void copyFrame(Frame* frame, const Frame* sourceFrame, GuiFrameSetCtrl* source); + +public: DECLARE_CONOBJECT(GuiFrameSetCtrl); }; diff --git a/engine/source/gui/containers/guiFrameSetCtrl_ScriptBinding.h b/engine/source/gui/containers/guiFrameSetCtrl_ScriptBinding.h index 4c3b883bc..9866d45df 100644 --- a/engine/source/gui/containers/guiFrameSetCtrl_ScriptBinding.h +++ b/engine/source/gui/containers/guiFrameSetCtrl_ScriptBinding.h @@ -67,4 +67,31 @@ ConsoleMethodWithDocs(GuiFrameSetCtrl, setFrameSize, ConsoleVoid, 4, 4, (int fra object->setFrameSize(dAtoi(argv[2]), dAtoi(argv[3])); } +/*! Gets the frame tree as text: the splits, their sizes, and which control sits + in each frame. Opaque - hand it back to setFrameLayout unchanged. + + A frame set destroys a frame when the control in it is removed, so anything + that needs to put a removed control back where it was - the Gui Editor's + undo - has to keep the layout first. + @return The frame tree, as a string. +*/ +ConsoleMethodWithDocs(GuiFrameSetCtrl, getFrameLayout, ConsoleString, 2, 2, ()) +{ + return object->getFrameLayout(); +} + +/*! Rebuilds the frame tree from text taken earlier by getFrameLayout, and lays + the children out again. + + A frame naming a control that is no longer a child of this frame set comes + back empty, so a layout recorded before a delete can be restored before the + control is. + @param layout A string from getFrameLayout. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiFrameSetCtrl, setFrameLayout, ConsoleVoid, 3, 3, (layout)) +{ + object->setFrameLayout(argv[2]); +} + ConsoleMethodGroupEndWithDocs(GuiFrameSetCtrl) \ No newline at end of file diff --git a/engine/source/gui/containers/guiScrollCtrl.cc b/engine/source/gui/containers/guiScrollCtrl.cc index e4679bda1..b17adf1d5 100755 --- a/engine/source/gui/containers/guiScrollCtrl.cc +++ b/engine/source/gui/containers/guiScrollCtrl.cc @@ -80,6 +80,7 @@ GuiScrollCtrl::GuiScrollCtrl() mEventBubbled = false; mCalcGuard = false; mResizeGuard = false; + mNotifyGuard = false; } void GuiScrollCtrl::initPersistFields() @@ -110,21 +111,18 @@ void GuiScrollCtrl::resize(const Point2I &newPos, const Point2I &newExt) mCalcGuard = false; computeSizes(); + // The bar appearing or going away is now announced by computeSizes, which + // is where it is actually decided -- and which catches the far more + // common case of a bar arriving because a child grew, nowhere near a + // resize of this control. All that is left here is to settle the + // rectangles against the size the children have just been given. + // + // What stood here instead adjusted each child's mRenderInsetRB and then + // called parentResized with the same extent for old AND new, so every + // sizing mode that works from a delta computed zero and did nothing; the + // inset it wrote was overwritten by the next renderChild anyway. if (hasH != mHasHScrollBar || hasV != mHasVScrollBar) { - S32 deltaY = hasH != mHasHScrollBar ? (mHasHScrollBar ? mScrollBarThickness : -mScrollBarThickness) : 0; - S32 deltaX = hasV != mHasVScrollBar ? (mHasVScrollBar ? mScrollBarThickness : -mScrollBarThickness) : 0; - - iterator i; - for (i = begin(); i != end(); i++) - { - GuiControl* ctrl = static_cast(*i); - ctrl->mRenderInsetRB = Point2I(ctrl->mRenderInsetRB.x + deltaX, ctrl->mRenderInsetRB.y + deltaY); - ctrl->preventResizeModeFill(); - ctrl->preventResizeModeCenter(); - ctrl->parentResized(mBounds.extent - (ctrl->mRenderInsetLT + ctrl->mRenderInsetRB), mBounds.extent - (ctrl->mRenderInsetLT + ctrl->mRenderInsetRB)); - } - mCalcGuard = true; Parent::resize(newPos, newExt); mCalcGuard = false; @@ -140,17 +138,57 @@ void GuiScrollCtrl::childResized(GuiControl *child) computeSizes(); } +RectI GuiScrollCtrl::getInnerRect(Point2I &offset, Point2I &extent, GuiControlState currentState, GuiControlProfile *profile) +{ + // The margins, borders and padding first, then whatever the bars are using. + // A child asking this control how big it is has to be told what it can + // actually see, or it lays its last column out underneath the bar. + RectI inner = Parent::getInnerRect(offset, extent, currentState, profile); + inner.extent = subtractScrollBars(inner.extent, mHasHScrollBar, mHasVScrollBar, mScrollBarThickness); + + return inner; +} + +void GuiScrollCtrl::preventUnsizedModes(GuiControl *child) +{ + // Fill and center both mean "put me where the parent's size says", and in an + // axis this control can scroll there is no such size: the content is as long + // as it wants to be and this is a window onto it. Filling there would clamp + // the content to what is visible and leave nothing to scroll. + // + // An axis whose bar is alwaysOff is a different thing entirely. Nothing + // scrolls across it, the room is exactly the inner rect less any bar on the + // other axis, and fill is the honest way for a child to ask for it. Refusing + // it there is what forced callers to compute widths in script. + if (canScrollHorizontally()) + { + child->preventHorizResizeModeFill(); + child->preventHorizResizeModeCenter(); + } + + if (canScrollVertically()) + { + child->preventVertResizeModeFill(); + child->preventVertResizeModeCenter(); + } +} + void GuiScrollCtrl::addObject(SimObject* object) { - //Fill is not supported inside a scroll control GuiControl* child = dynamic_cast(object); if (child) { - child->preventResizeModeFill(); - child->preventResizeModeCenter(); + preventUnsizedModes(child); } Parent::addObject(object); computeSizes(); + + // A child that fills wants its size now rather than at whatever resize + // happens to come next -- the same reason GuiControl::applySizing exists. + if (child) + { + child->parentResized(mContentExt, mContentExt); + } } bool GuiScrollCtrl::onWake() @@ -293,12 +331,61 @@ GuiScrollCtrl::Region GuiScrollCtrl::findHitRegion(const Point2I& pt) } #pragma region CalculationFunctions +Point2I GuiScrollCtrl::subtractScrollBars(const Point2I &extent, const bool hasHBar, const bool hasVBar, const S32 barThickness) +{ + // A vertical bar stands down the side and so costs WIDTH; a horizontal one + // costs height. Getting that pair the wrong way round is the easiest + // mistake here, which is most of why this is one function and not four. + return Point2I(extent.x - (hasVBar ? barThickness : 0), + extent.y - (hasHBar ? barThickness : 0)); +} + +void GuiScrollCtrl::calcBarPresence(const S32 forceHBar, const S32 forceVBar, const Point2I &childExtent, + const Point2I &contentExtent, const S32 barThickness, bool &outHasHBar, bool &outHasVBar) +{ + outHasHBar = (forceHBar == ScrollBarAlwaysOn); + outHasVBar = (forceVBar == ScrollBarAlwaysOn); + + // Every test below is against the room that actually REMAINS, which is what + // lets one bar call the other into being. The old code compared both against + // the un-narrowed extent, so its second look at the horizontal bar asked a + // question it had already answered and could never say yes. + Point2I room = subtractScrollBars(contentExtent, outHasHBar, outHasVBar, barThickness); + + if (!outHasHBar && forceHBar == ScrollBarDynamic && childExtent.x > room.x) + { + outHasHBar = true; + room.y -= barThickness; + } + + if (!outHasVBar && forceVBar == ScrollBarDynamic && childExtent.y > room.y) + { + outHasVBar = true; + room.x -= barThickness; + + // The vertical bar just narrowed the content, and that can be what + // pushes it wide enough to need a horizontal one after all. + if (!outHasHBar && forceHBar == ScrollBarDynamic && childExtent.x > room.x) + { + outHasHBar = true; + } + } +} + void GuiScrollCtrl::computeSizes() { if (!mCalcGuard)//Prevent needless calcuations { + const bool hadHBar = mHasHScrollBar; + const bool hadVBar = mHasVScrollBar; + calcContentExtents(); + // What there would be with no bars at all. Kept because the bars are + // decided from it and then taken off it, and because a child that has to + // be told the room changed needs both answers. + const Point2I barFreeExtent = mContentExt; + mHBarEnabled = false; mVBarEnabled = false; mHasVScrollBar = (mForceVScrollBar == ScrollBarAlwaysOn); @@ -306,28 +393,21 @@ void GuiScrollCtrl::computeSizes() setUpdate(); - if (calcChildExtents()) + const bool hasChildren = calcChildExtents(); + if (hasChildren) { - if (mChildExt.x > mContentExt.x && (mForceHScrollBar == ScrollBarDynamic)) - { - mHasHScrollBar = true; - } - if (mChildExt.y > mContentExt.y && (mForceVScrollBar == ScrollBarDynamic)) - { - mHasVScrollBar = true; - - // If Extent X Changed, check Horiz Scrollbar. - if (mChildExt.x > mContentExt.x && !mHasHScrollBar && (mForceHScrollBar == ScrollBarDynamic)) - { - mHasHScrollBar = true; - } - } + calcBarPresence(mForceHScrollBar, mForceVScrollBar, mChildExt, barFreeExtent, + mScrollBarThickness, mHasHScrollBar, mHasVScrollBar); + } - if (mHasVScrollBar) - mContentExt.x -= mScrollBarThickness; - if (mHasHScrollBar) - mContentExt.y -= mScrollBarThickness; + // Outside the children test, unlike before: a bar forced alwaysOn takes + // its space whether or not anything has been put in the control yet, and + // a scroller that reported its whole width until its first child arrived + // would hand that width to the child. + mContentExt = subtractScrollBars(barFreeExtent, mHasHScrollBar, mHasVScrollBar, mScrollBarThickness); + if (hasChildren) + { // enable needed scroll bars if (mChildExt.x > mContentExt.x) mHBarEnabled = true; @@ -337,6 +417,15 @@ void GuiScrollCtrl::computeSizes() //Are we now over-scrolled? calcScrollOffset(); } + + // The bars appearing is a change in how much room the children have, and + // this is the only place that knows it happened -- most of the time the + // bar arrives from childResized, nowhere near a resize of this control. + if (hadHBar != mHasHScrollBar || hadVBar != mHasVScrollBar) + { + notifyChildrenOfBarChange(barFreeExtent, hadHBar, hadVBar); + } + // build all the rectangles and such... Point2I zero = mBounds.point.Zero; RectI ctrlRect = applyMargins(zero, mBounds.extent, NormalState, mProfile); @@ -346,6 +435,32 @@ void GuiScrollCtrl::computeSizes() } } +void GuiScrollCtrl::notifyChildrenOfBarChange(const Point2I &barFreeExtent, const bool hadHBar, const bool hadVBar) +{ + // Resizing a child calls back into childResized and so into here again. The + // re-entered pass does its own arithmetic and settles; what it must not do + // is announce this same change a second time. + if (mNotifyGuard) + { + return; + } + mNotifyGuard = true; + + // Both extents measured from the SAME bar-free rect, so the difference + // between them is the bars and nothing else. Any change to the control's own + // size has already been passed down by GuiControl::resize. + const Point2I before = subtractScrollBars(barFreeExtent, hadHBar, hadVBar, mScrollBarThickness); + const Point2I after = subtractScrollBars(barFreeExtent, mHasHScrollBar, mHasVScrollBar, mScrollBarThickness); + + for (iterator i = begin(); i != end(); i++) + { + GuiControl *ctrl = static_cast(*i); + ctrl->parentResized(before, after); + } + + mNotifyGuard = false; +} + void GuiScrollCtrl::calcContentExtents() { RectI ctrlRect = applyMargins(mBounds.point, mBounds.extent, NormalState, mProfile); @@ -931,8 +1046,10 @@ void GuiScrollCtrl::onRender(Point2I offset, const RectI &updateRect) renderUniversalRect(ctrlRect, mProfile, NormalState); - RectI fillRect = applyBorders(ctrlRect.point, ctrlRect.extent, NormalState, mProfile); - RectI contentRect = applyScrollBarSpacing(fillRect.point, fillRect.extent); + // The same rect the children were SIZED against -- getInnerRect is now the + // one definition of it. Two separate subtractions were how the visible area + // and the laid-out area came to disagree in the first place. + RectI contentRect = getInnerRect(offset, mBounds.extent, NormalState, mProfile); mChildArea.set(contentRect.point, contentRect.extent); renderVScrollBar(offset); @@ -946,23 +1063,10 @@ void GuiScrollCtrl::onRender(Point2I offset, const RectI &updateRect) RectI GuiScrollCtrl::applyScrollBarSpacing(Point2I offset, Point2I extent) { - RectI contentRect = RectI(offset, extent); - - if (mHasVScrollBar && mHasHScrollBar) - { - contentRect.extent.x -= mScrollBarThickness; - contentRect.extent.y -= mScrollBarThickness; - } - else if (mHasVScrollBar) - { - contentRect.extent.x -= mScrollBarThickness; - } - else if (mHasHScrollBar) - { - contentRect.extent.y -= mScrollBarThickness; - } - - return contentRect; + // Kept for anything overriding or calling it, but no longer a second copy of + // the arithmetic: getInnerRect and this both go through subtractScrollBars, + // so they cannot come to disagree about what a bar costs. + return RectI(offset, subtractScrollBars(extent, mHasHScrollBar, mHasVScrollBar, mScrollBarThickness)); } GuiControlState GuiScrollCtrl::getRegionCurrentState(GuiScrollCtrl::Region region) diff --git a/engine/source/gui/containers/guiScrollCtrl.h b/engine/source/gui/containers/guiScrollCtrl.h index 060405dd7..29774550c 100755 --- a/engine/source/gui/containers/guiScrollCtrl.h +++ b/engine/source/gui/containers/guiScrollCtrl.h @@ -34,6 +34,7 @@ class GuiScrollCtrl : public GuiControl bool mEventBubbled; bool mCalcGuard; bool mResizeGuard; + bool mNotifyGuard; protected: @@ -138,6 +139,50 @@ class GuiScrollCtrl : public GuiControl virtual void computeSizes(); + /// @name The size a child is given + /// + /// A scroll control is the one container that does not always have a size to + /// offer a child. In an axis it can scroll, the content is as long as it + /// wants to be and the control merely shows a window onto it -- there is no + /// fixed size to hand down. In an axis whose bar is alwaysOff there is no + /// scrolling, so the space IS bounded, and a child may be sized to it. + /// + /// These say which case an axis is in, and what is left over once whatever + /// bars are showing have taken their share. + /// @{ + + /// True when this axis scrolls, and so has no fixed size to offer a child. + bool canScrollHorizontally() const { return mForceHScrollBar != ScrollBarAlwaysOff; } + bool canScrollVertically() const { return mForceVScrollBar != ScrollBarAlwaysOff; } + + /// What is left of an extent once the showing bars have taken their space. + /// Pure, and the single definition of that arithmetic: the visible rect, the + /// rect children are rendered into and the rect fill resolves against are all + /// this same subtraction, and used to be three separate ones. + static Point2I subtractScrollBars(const Point2I &extent, const bool hasHBar, const bool hasVBar, const S32 barThickness); + + /// Which bars a scroller of this configuration shows. + /// + /// Pure, so that the one genuinely circular part of the layout is testable on + /// its own: a vertical bar narrows the content, which can be what pushes the + /// content wide enough to need a horizontal one. + static void calcBarPresence(const S32 forceHBar, const S32 forceVBar, const Point2I &childExtent, + const Point2I &contentExtent, const S32 barThickness, bool &outHasHBar, bool &outHasVBar); + + /// The visible content rect: the inner rect less the bars that are showing. + virtual RectI getInnerRect(Point2I &offset, Point2I &extent, GuiControlState currentState, GuiControlProfile *profile); + + /// Strips the sizing modes a child may not use in an axis that scrolls. + void preventUnsizedModes(GuiControl *child); + + /// Tells the children that the bars took, or gave back, their share. + /// + /// Only the bars' share: an outer resize has already been passed down by + /// GuiControl::resize, and counting it twice would move every child that + /// sizes on a delta. + void notifyChildrenOfBarChange(const Point2I &barFreeExtent, const bool hadHBar, const bool hadVBar); + /// @} + virtual void addObject(SimObject *obj); virtual void resize(const Point2I &newPosition, const Point2I &newExtent); virtual void childResized(GuiControl *child); diff --git a/engine/source/gui/containers/guiTabBookCtrl.cc b/engine/source/gui/containers/guiTabBookCtrl.cc index ec1c42487..ee0cf508d 100755 --- a/engine/source/gui/containers/guiTabBookCtrl.cc +++ b/engine/source/gui/containers/guiTabBookCtrl.cc @@ -60,6 +60,11 @@ GuiTabBookCtrl::GuiTabBookCtrl() mBounds.extent.set( 400, 300 ); mPageRect = RectI(0,0,0,0); mTabRect = RectI(0,0,0,0); + + // Empty until a layout pass in edit mode fills it in, and read by + // getAddTabGlobalRect - which script can ask about a book that has never laid + // itself out at all. + mAddTabRect = RectI(0,0,0,0); mTabDownPosition = Point2I(); mDepressed = false; @@ -82,17 +87,6 @@ void GuiTabBookCtrl::initPersistFields() addField("TabProfile", TypeGuiProfile, Offset(mTabProfile, GuiTabBookCtrl)); } -// Empty for now, will implement for handling design time context menu for manipulating pages -ConsoleMethod( GuiTabBookCtrl, addPage, void, 2, 2, "() Empty") -{ - object->addNewPage(); -} - -//ConsoleMethod( GuiTabBookCtrl, removePage, void, 2, 2, "()") -//{ -//} - - bool GuiTabBookCtrl::onAdd() { Parent::onAdd(); @@ -125,6 +119,73 @@ void GuiTabBookCtrl::onChildRemoved( GuiControl* child ) else if (mActivePage == NULL ) mActivePage = static_cast(mPages[0].Page); + // The strip has one fewer tab in it, and nothing else re-runs the layout on a + // removal: solveDirty watches the tab position, the font height and the first + // tab's width, and a delete changes none of them. Without this the surviving + // tabs keep the rectangles they were given and leave a hole where the deleted + // one used to be. + calculatePageTabs(); + + // Whichever page was promoted above was hidden the moment some other tab was + // chosen, and nothing has told it otherwise. + syncPageVisibility(); +} + +void GuiTabBookCtrl::syncPageVisibility() +{ + for( S32 i = 0; i < mPages.size(); i++ ) + { + GuiTabPageCtrl* page = mPages[i].Page; + if( page != NULL ) + page->setVisible( page == mActivePage ); + } +} + +// The tab strip is drawn from mPages, which is filled in the order pages are +// added and is otherwise independent of the child list. Anything that +// rearranges the children - a drag in the Gui Editor's tree, or an undo putting +// a deleted page back where it came from - therefore leaves a page sitting in +// the middle of the children and at the end of the tab strip. The children are +// the truth, so rebuild the order from them. +void GuiTabBookCtrl::childrenReordered() +{ + Vector ordered; + + for (iterator i = begin(); i != end(); i++) + { + GuiTabPageCtrl* page = dynamic_cast(*i); + if (!page) + continue; + + for (S32 p = 0; p < mPages.size(); p++) + { + if (mPages[p].Page == page) + { + ordered.push_back(mPages[p]); + break; + } + } + } + + // Anything the children did not account for is a page the book believes in + // and the child list does not; dropping it here would leak it out of the + // strip, so only take the rebuild when the two agree on the count. + if (ordered.size() != mPages.size()) + return; + + mPages.clear(); + for (S32 p = 0; p < ordered.size(); p++) + mPages.push_back(ordered[p]); + + calculatePageTabs(); + + // Undo puts a deleted page back by moving it, and the recorder's layout fix + // restores position, extent and sizing - not visibility. A page that was + // showing when it was deleted comes back still showing, on top of whichever + // page took over from it. + syncPageVisibility(); + + Parent::childrenReordered(); } void GuiTabBookCtrl::onChildAdded( GuiControl *child ) @@ -133,19 +194,26 @@ void GuiTabBookCtrl::onChildAdded( GuiControl *child ) if( !page ) { Con::warnf("GuiTabBookCtrl::onChildAdded - attempting to add NON GuiTabPageCtrl as child page"); - SimObject *simObj = reinterpret_cast(child); - removeObject( simObj ); - if( mActivePage ) + + // Work out where it is going BEFORE taking it out of the book. A book with + // no active page and no parent has nowhere to send it, and removing it + // first left it registered with no group at all - which a book emptied of + // its pages in the editor makes an ordinary thing to run into. + GuiControl *destination = mActivePage; + if( destination == NULL ) { - mActivePage->addObject( simObj ); + Con::warnf("GuiTabBookCtrl::onChildAdded - unable to find active page to reassign ownership of new child control to, placing on parent"); + destination = getParent(); } - else + + if( destination == NULL ) { - Con::warnf("GuiTabBookCtrl::onChildAdded - unable to find active page to reassign ownership of new child control to, placing on parent"); - GuiControl *rent = getParent(); - if( rent ) - rent->addObject( simObj ); + Con::warnf("GuiTabBookCtrl::onChildAdded - no parent to place it on either; leaving it where it is"); + return; } + + removeObject( child ); + destination->addObject( child ); return; } @@ -158,9 +226,24 @@ void GuiTabBookCtrl::onChildAdded( GuiControl *child ) mPages.push_back( newPage ); + // A book with pages always has an active one. Without this a book holding a + // single page draws that page's tab unselected and shows nothing inside it, + // and onMouseDownEditor's "select the page behind the tab" has nothing to + // select. + // + // Deliberately not selectPage(), which ends in an onTabSelected script + // callback: this runs from inside addObject, and EditorCore adds one page per + // editor as each editor's module loads. Its handler opens the editor that + // page belongs to, so during load the first one would open before the rest of + // them exist. + if( mActivePage == NULL ) + mActivePage = page; + // Calculate Page Information calculatePageTabs(); + syncPageVisibility(); + child->resize( Point2I(0, 0), mPageRect.extent ); } @@ -218,6 +301,19 @@ void GuiTabBookCtrl::addNewPage() this->addObject( page ); } +void GuiTabBookCtrl::requestNewPage() +{ + // The GuiEditCtrl wears the GuiEditorBrain namespace, so this arrives at + // GuiEditorBrain::onAddTabPage. A book being edited by anything that has no + // handler for it simply gets no page, which is the right way for this to + // fail. + GuiEditCtrl* edit = GuiControl::smEditorHandle; + if( edit != NULL && edit->isMethod("onAddTabPage") ) + { + Con::executef( edit, 2, "onAddTabPage", getIdString() ); + } +} + void GuiTabBookCtrl::resize(const Point2I &newPosition, const Point2I &newExtent) { Parent::resize( newPosition, newExtent ); @@ -257,6 +353,27 @@ Point2I GuiTabBookCtrl::getTabLocalCoord(const Point2I &src) return ret; } +RectI GuiTabBookCtrl::getAddTabGlobalRect() +{ + // isEditMode as well as the rectangle, because closing the editor does not + // re-run the layout: the book would go on reporting the "+" it had until + // something else happened to resize it. Nothing DRAWS one - renderAddTab has + // no editor to ask for a colour - so this is the only place it could show. + if (!mAddTabRect.isValidRect() || !isEditMode()) + { + return RectI(0, 0, 0, 0); + } + + // The same walk onRender makes to reach the point it hands renderTabs, which + // is the origin mAddTabRect is measured from. + Point2I totalOffset = localToGlobalCoord(Point2I(0, 0)) + mTabRect.point; + RectI ctrlRect = applyMargins(totalOffset, mTabRect.extent, NormalState, mProfile); + RectI fillRect = applyBorders(ctrlRect.point, ctrlRect.extent, NormalState, mProfile); + RectI contentRect = applyPadding(fillRect.point, fillRect.extent, NormalState, mProfile); + + return RectI(contentRect.point + mAddTabRect.point, mAddTabRect.extent); +} + void GuiTabBookCtrl::onTouchDown(const GuiEvent &event) { Point2I localMouse = globalToLocalCoord( event.mousePoint ); @@ -342,7 +459,25 @@ bool GuiTabBookCtrl::onMouseDownEditor(const GuiEvent &event, const Point2I& off if( mTabRect.pointInRect( localMouse ) ) { - GuiTabPageCtrl *tab = findHitTab( localMouse ); + // Tab rectangles are measured from the strip's CONTENT, not from the + // control. onTouchDown has always converted before asking and this has + // always not, which put every editor tab hit out by the book's margin, + // border and padding - and, for a bottom or right strip, by the whole + // width of the page area as well. + Point2I tabLocalMouse = getTabLocalCoord( localMouse ); + + // Before the real tabs: the "+" sits inside the strip, so a stale tab + // rectangle must not get first refusal on it. + if( mAddTabRect.isValidRect() && mAddTabRect.pointInRect( tabLocalMouse ) ) + { + requestNewPage(); + + // Nothing else happens on this click. Selection follows the page the + // editor is about to make, not the page that happened to be showing. + return true; + } + + GuiTabPageCtrl *tab = findHitTab( tabLocalMouse ); if( tab != NULL ) { selectPage( tab ); @@ -402,11 +537,6 @@ void GuiTabBookCtrl::onRender(Point2I offset, const RectI &updateRect) void GuiTabBookCtrl::renderTabs( const Point2I &offset ) { - // If the tab size is zero, don't render tabs, - // and assume it's a tab-less tab-book - JDD - if( mPages.empty()) - return; - for( S32 i = 0; i < mPages.size(); i++ ) { RectI tabBounds = mPages[i].TabRect; @@ -415,6 +545,42 @@ void GuiTabBookCtrl::renderTabs( const Point2I &offset ) if( tab != NULL ) renderTab( tabBounds, tab ); } + + // After the real tabs, so it always reads as the end of the strip. Empty + // unless calculatePageTabs found itself in edit mode, which is the whole test + // - a book with no pages still gets one, and that is the only thing standing + // between an emptied book and being unrecoverable. + if( mAddTabRect.isValidRect() ) + { + RectI addBounds = mAddTabRect; + addBounds.point += offset; + renderAddTab( addBounds ); + } +} + +void GuiTabBookCtrl::renderAddTab( RectI tabRect ) +{ + GuiEditCtrl* edit = GuiControl::smEditorHandle; + if( edit == NULL ) + return; + + // Ghosted, so it never passes for a page. Brighter under the cursor, so it + // reads as something to click rather than a gap the tabs did not fill - the + // same idea as the frame set's split handles. + ColorI fill = edit->getEditorColor(); + fill.alpha = 100; + + GuiCanvas* root = getRoot(); + if( root != NULL && tabRect.pointInRect( root->getCursorPos() ) ) + fill.alpha = 200; + + dglDrawRectFill( tabRect, fill ); + + dglSetBitmapModulation( getFontColor( edit->mProfile, NormalState ) ); + F32 tempAdjust = mFontSizeAdjust; + mFontSizeAdjust = 1.5f; + renderText( tabRect.point, tabRect.extent, "+", edit->mProfile ); + mFontSizeAdjust = tempAdjust; } void GuiTabBookCtrl::renderTab( RectI tabRect, GuiTabPageCtrl *tab ) @@ -526,11 +692,26 @@ S32 GuiTabBookCtrl::calculatePageTabWidth( GuiTabPageCtrl *page ) void GuiTabBookCtrl::calculatePageTabs() { + // Ahead of every return below: a book that leaves edit mode must not be left + // holding a "+" tab that is no longer drawn, or getAddPageTabRect reports a + // rectangle nothing will answer a click in. + mAddTabRect.set(Point2I(0, 0), Point2I(0, 0)); + + // The "+" tab is the only reason to lay out a book with no pages. Without it + // an empty book short-circuits here exactly as it always has: mTabRect keeps + // the zero it was constructed with, and onRender returns on the invalid rect + // before drawing anything at all. + // + // mTabProfile is the other half of what that short circuit has been quietly + // protecting - the font lookup below dereferences it, and it is only set from + // a profile the constructor names, which a bare engine need not have. + const bool wantAddTab = isEditMode() && mTabProfile != NULL; + // Short Circuit. // // If the tab size is zero, don't render tabs, // and assume it's a tab-less tab-book - JDD - if( mPages.empty()) + if( mPages.empty() && !wantAddTab ) return; S32 currRow = 0; @@ -622,20 +803,74 @@ void GuiTabBookCtrl::calculatePageTabs() }; } + // The "+" tab goes after the last real one, laid out by the same rules but + // square, so it reads as an affordance rather than a page with no name. + // + // It wraps like a tab too. That grows mTabRect and shrinks mPageRect, which + // re-sizes every page - but only while the Gui is being authored, and not + // durably: the book sizes each page from mPageRect whenever one is added, so + // a Gui saved with the "+" on a row of its own loads back unchanged. + // + // Only the counter that survives the loop needs bumping. currRow feeds the + // strip's height for a top or bottom book, currColumn its width for a left or + // right one; the other is written and never read. + if( wantAddTab ) + { + const S32 addSize = tabHeight; + + switch( mTabPosition ) + { + case AlignTop: + case AlignBottom: + // currX > 0 so a strip too narrow for even one square does not push the + // "+" onto an empty row it still cannot fit on. + if( currX + addSize > innerRect.extent.x && currX > 0 ) + { + balanceRow( currRow, currX ); + currRow++; + currX = 0; + } + + mAddTabRect.point.x = currX; + mAddTabRect.point.y = currRow * tabHeight; + mAddTabRect.extent.x = addSize; + mAddTabRect.extent.y = tabHeight; + break; + case AlignLeft: + case AlignRight: + if( currY + addSize > innerRect.extent.y && currY > 0 ) + { + balanceColumn( currColumn, currY ); + currColumn++; + currY = 0; + } + + mAddTabRect.point.x = currColumn * tabHeight; + mAddTabRect.point.y = currY; + mAddTabRect.extent.x = tabHeight; + mAddTabRect.extent.y = addSize; + break; + }; + } + currRow++; currColumn++; Point2I colExtent = Point2I(currColumn * tabHeight, currRow * tabHeight); Point2I outerExtent = getOuterExtent(colExtent, NormalState, mProfile); - // Calculate + // Extent before point, in every case. A bottom or right strip places itself + // by measuring back from the far edge, so reading mTabRect.extent before this + // pass has written it takes the size the strip was LAST time - zero on the + // first pass after construction, which is the only pass a book gets when it + // has no pages to add and so nothing to trigger a second one. switch( mTabPosition ) { case AlignTop: - mTabRect.point.x = 0; - mTabRect.point.y = 0; mTabRect.extent.x = mBounds.extent.x; mTabRect.extent.y = outerExtent.y; + mTabRect.point.x = 0; + mTabRect.point.y = 0; mPageRect.point.x = 0; mPageRect.point.y = mTabRect.extent.y; @@ -644,10 +879,10 @@ void GuiTabBookCtrl::calculatePageTabs() break; case AlignBottom: - mTabRect.point.x = 0; - mTabRect.point.y = mBounds.extent.y - mTabRect.extent.y; mTabRect.extent.x = mBounds.extent.x; mTabRect.extent.y = outerExtent.y; + mTabRect.point.x = 0; + mTabRect.point.y = mBounds.extent.y - mTabRect.extent.y; mPageRect.point.x = 0; mPageRect.point.y = 0; @@ -656,26 +891,26 @@ void GuiTabBookCtrl::calculatePageTabs() break; case AlignLeft: - mTabRect.point.x = 0; - mTabRect.point.y = 0; - mTabRect.extent.x = outerExtent.x; + mTabRect.extent.x = outerExtent.x; mTabRect.extent.y = mBounds.extent.y; + mTabRect.point.x = 0; + mTabRect.point.y = 0; - mPageRect.point.x = mTabRect.extent.x; + mPageRect.point.x = mTabRect.extent.x; mPageRect.point.y = 0; - mPageRect.extent.x = mBounds.extent.x - mTabRect.extent.x; + mPageRect.extent.x = mBounds.extent.x - mTabRect.extent.x; mPageRect.extent.y = mBounds.extent.y; break; case AlignRight: - mTabRect.point.x = mBounds.extent.x - mTabRect.extent.x; - mTabRect.point.y = 0; - mTabRect.extent.x = outerExtent.x; + mTabRect.extent.x = outerExtent.x; mTabRect.extent.y = mBounds.extent.y; + mTabRect.point.x = mBounds.extent.x - mTabRect.extent.x; + mTabRect.point.y = 0; - mPageRect.point.x = 0; + mPageRect.point.x = 0; mPageRect.point.y = 0; - mPageRect.extent.x = mBounds.extent.x - mTabRect.extent.x; + mPageRect.extent.x = mBounds.extent.x - mTabRect.extent.x; mPageRect.extent.y = mTabRect.extent.y; break; diff --git a/engine/source/gui/containers/guiTabBookCtrl.h b/engine/source/gui/containers/guiTabBookCtrl.h index 60a308304..7a2c1a3ed 100755 --- a/engine/source/gui/containers/guiTabBookCtrl.h +++ b/engine/source/gui/containers/guiTabBookCtrl.h @@ -85,6 +85,12 @@ class GuiTabBookCtrl : public GuiControl RectI mPageRect; ///< Rectangle of the tab page portion of the control RectI mTabRect; ///< Rectangle of the tab portion of the control + + /// The editor-only "+" tab that follows the last real one, in the same + /// coordinates as TabHeaderInfo::TabRect - local to the tab strip's content, + /// which is what getTabLocalCoord converts a mouse point into. Empty whenever + /// the book is not being authored, which is how everything tests for it. + RectI mAddTabRect; Vector mPages; ///< Vector of pages contained by the control GuiTabPageCtrl* mActivePage; ///< Pointer to the active (selected) tab page child control GuiTabPageCtrl* mHoverTab; ///< Pointer to the tab page that currently has the mouse positioned ontop of its tab @@ -134,6 +140,7 @@ class GuiTabBookCtrl : public GuiControl /// @{ void onChildRemoved( GuiControl* child ); void onChildAdded( GuiControl *child ); + void childrenReordered(); /// @} /// @name Rendering methods @@ -147,6 +154,12 @@ class GuiTabBookCtrl : public GuiControl /// @param tabRect the rectangle to render the tab into /// @param tab pointer to the tab page control for which to render the tab void renderTab( RectI tabRect, GuiTabPageCtrl* tab ); + + /// Draw the editor-only "+" tab. Ghosted rather than drawn as a real tab, + /// because it is an affordance and not a page - the same treatment + /// GuiChainCtrl gives the space it keeps at the end of itself. + /// @param tabRect the rectangle to render into, in global coordinates + void renderAddTab( RectI tabRect ); /// @} /// @name Page Management @@ -158,6 +171,13 @@ class GuiTabBookCtrl : public GuiControl /// This may change in the future. void addNewPage(); + /// Ask the Gui Editor for a page, because the "+" tab was clicked. + /// + /// The book draws the affordance; it does not make the page. A page created + /// while authoring has to be themed, recorded for undo and announced to the + /// Explorer tree, none of which a control knows how to do. + void requestNewPage(); + U32 getSelectedPage(); /// Select a tab page based on an index @@ -183,6 +203,14 @@ class GuiTabBookCtrl : public GuiControl /// @name Internal Utility Functions /// @{ + /// Show the active page and hide the rest. + /// + /// The quiet half of selectPage: no script callback, so it is safe to call + /// while a child is arriving or leaving. selectPage ends in onTabSelected, + /// and EditorCore's handler opens the editor a page belongs to - which is not + /// something a book can afford to trigger from inside addObject. + void syncPageVisibility(); + /// Update ourselves by hooking common GuiControl functionality. void setUpdate(); @@ -212,6 +240,13 @@ class GuiTabBookCtrl : public GuiControl /// Changes a local point to a point in the inner rect of the tab section. Point2I getTabLocalCoord(const Point2I &src); + /// The "+" tab in global coordinates, or an empty rect when there is none. + /// + /// The explicit inverse of getTabLocalCoord, worked out on demand rather than + /// remembered from the last render: script asks about a book the moment it is + /// dropped, which is before the book has drawn a frame. + RectI getAddTabGlobalRect(); + /// @} /// @name Sizing diff --git a/engine/source/gui/containers/guiTabBookCtrl_ScriptBinding.h b/engine/source/gui/containers/guiTabBookCtrl_ScriptBinding.h index c82b681c1..28b941e83 100644 --- a/engine/source/gui/containers/guiTabBookCtrl_ScriptBinding.h +++ b/engine/source/gui/containers/guiTabBookCtrl_ScriptBinding.h @@ -59,4 +59,39 @@ ConsoleMethodWithDocs(GuiTabBookCtrl, setTabProfile, ConsoleVoid, 3, 3, (GuiCont if (Sim::findObject(argv[2], profile)) object->setControlTabProfile(profile); -} \ No newline at end of file +} + +/*! Appends an untitled page to the book. + + The raw form: it names the page itself, puts it on GuiTabPageProfile and + tells nobody. The Gui Editor does not use this - a page made there has to be + themed, recorded for undo and announced to the Explorer tree, so the editor + builds its own from GuiEditorBrain::newTabPage. This is for building a book + from script or from C++, which is what GuiFrameSetCtrl does when it docks a + window. + @return No return value +*/ +ConsoleMethodWithDocs(GuiTabBookCtrl, addPage, ConsoleVoid, 2, 2, ()) +{ + object->addNewPage(); +} + +/*! Returns the bounds of the editor-only "+" tab as "x y width height", in + global coordinates. + + Empty - "0 0 0 0" - unless the book is inside the Gui being authored, which + is the only time the "+" is drawn. A book with no pages at all still reports + one; that is what stops an emptied book from being unrecoverable. + @return The rectangle the "+" occupies on screen. +*/ +ConsoleMethodWithDocs(GuiTabBookCtrl, getAddPageTabRect, ConsoleString, 2, 2, ()) +{ + RectI rect = object->getAddTabGlobalRect(); + + char* buffer = Con::getReturnBuffer(64); + dSprintf(buffer, 64, "%d %d %d %d", rect.point.x, rect.point.y, rect.extent.x, rect.extent.y); + + return buffer; +} + +ConsoleMethodGroupEndWithDocs(GuiTabBookCtrl) \ No newline at end of file diff --git a/engine/source/gui/containers/guiTabPageCtrl.cc b/engine/source/gui/containers/guiTabPageCtrl.cc index 188810583..248bb5d10 100644 --- a/engine/source/gui/containers/guiTabPageCtrl.cc +++ b/engine/source/gui/containers/guiTabPageCtrl.cc @@ -28,6 +28,11 @@ #include "gui/guiDefaultControlRender.h" #include "gui/editor/guiEditCtrl.h" +// Only for canBeChildOf below. The include belongs here rather than in the +// header: guiTabBookCtrl.h includes guiTabPageCtrl.h, so the two headers cannot +// include each other. +#include "gui/containers/guiTabBookCtrl.h" + IMPLEMENT_CONOBJECT(GuiTabPageCtrl); GuiTabPageCtrl::GuiTabPageCtrl(void) @@ -105,6 +110,16 @@ GuiControl *GuiTabPageCtrl::findPrevTabable(GuiControl *curResponder, bool first return tabCtrl; } +// A page is a tab's worth of a book and nothing else: its geometry is dictated +// by the book, its caption is what the book draws on the tab, and outside one it +// renders as a bare panel that nothing can select a tab for. So it refuses every +// other parent, and the Gui Editor leaves it where it is. Book to book is still +// allowed - that is a page moving between two things that can both hold it. +bool GuiTabPageCtrl::canBeChildOf(GuiControl* parent) +{ + return dynamic_cast(parent) != NULL; +} + void GuiTabPageCtrl::setText(const char *txt) { Parent::setText( txt ); diff --git a/engine/source/gui/containers/guiTabPageCtrl.h b/engine/source/gui/containers/guiTabPageCtrl.h index 24936fa7b..f4565108e 100644 --- a/engine/source/gui/containers/guiTabPageCtrl.h +++ b/engine/source/gui/containers/guiTabPageCtrl.h @@ -42,6 +42,15 @@ class GuiTabPageCtrl : public GuiControl void selectWindow(void); ///< Select this window + /// A page only means anything inside a GuiTabBookCtrl, so it refuses every + /// other parent. See GuiControl::canBeChildOf. + bool canBeChildOf(GuiControl* parent); + + /// Its book forces it to the page rect on every layout pass, so there is + /// nothing for the editor's sizing handles to change. See + /// GuiControl::isGeometryEditable. + bool isGeometryEditable() { return false; }; + virtual void setText(const char *txt = NULL); ///< Override setText function to signal parent we need to update. void onRender(Point2I offset, const RectI &updateRect); void parentResized(const Point2I& oldParentExtent, const Point2I& newParentExtent); diff --git a/engine/source/gui/editor/guiEditCtrl.cc b/engine/source/gui/editor/guiEditCtrl.cc index a5a2b9549..7e45c1279 100755 --- a/engine/source/gui/editor/guiEditCtrl.cc +++ b/engine/source/gui/editor/guiEditCtrl.cc @@ -30,6 +30,17 @@ #include "io/fileStream.h" #include "gui/containers/guiScrollCtrl.h" +// Undo lives in script (editor/GuiEditor/scripts/GuiEditorUndoRecorder.cs), on +// the UndoManager this class owns. What is here is the announcements it needs: +// every edit made below is bracketed by a callback, and the paired ones - +// onPreEdit/onPostEdit for a drag or a handle-resize, onPreSelectionNudged and +// its post for a run of arrow keys - are pairs because what a gesture did is +// only known once it ends. onTrashSelection deliberately fires before the +// controls reach the trash, while each one still knows where it came from. +// +// The three edits with no callback - justifySelection, bringToFront and +// pushToBack - are reached only from the Layout menu, and the Gui Editor's +// script wraps those commands to record either side of the call. IMPLEMENT_CONOBJECT(GuiEditCtrl); GuiEditCtrl::GuiEditCtrl() : mCurrentAddSet(NULL), mEditorRoot(NULL), mGridSnap(10, 10), mDragBeginPoint(-1, -1) @@ -132,6 +143,22 @@ ConsoleMethod(GuiEditCtrl, setCurrentAddSet, void, 3, 3, "(GuiControl ctrl) Set object->setCurrentAddSet(addSet); } +ConsoleMethod(GuiEditCtrl, moveSelectionToCtrl, void, 3, 3, "(GuiControl parent) Reparent every selected control into the given container.\n" + "What a drag across the canvas onto a container does. Controls that refuse the\n" + "parent - see GuiControl::canBeChildOf - and locked ones are left where they are.\n" + "@param parent The container to move the selection into.\n" + "@return No return value.") +{ + GuiControl* newParent; + + if (!Sim::findObject(argv[2], newParent)) + { + Con::printf("%s(): Invalid control: %s", argv[0], argv[2]); + return; + } + object->moveSelectionToCtrl(newParent); +} + ConsoleMethod(GuiEditCtrl, getCurrentAddSet, S32, 2, 2, "()\n @return Returns the set to which new controls will be added") { const GuiControl* add = object->getCurrentAddSet(); @@ -182,18 +209,6 @@ ConsoleMethod(GuiEditCtrl, moveSelection, void, 4, 4, "(int deltax, int deltay) } } -ConsoleMethod(GuiEditCtrl, saveSelection, void, 3, 3, "(string fileName) Saves the current selection to given filename\n" - "@return No return value.") -{ - object->saveSelection(argv[2]); -} - -ConsoleMethod(GuiEditCtrl, loadSelection, void, 3, 3, "(string fileName) Loads from given filename\n" - "@return No return value.") -{ - object->loadSelection(argv[2]); -} - ConsoleMethod(GuiEditCtrl, selectAll, void, 2, 2, "() Selects all controls\n" "@return No return value.") { @@ -292,6 +307,46 @@ void GuiEditCtrl::setEditMode(bool value) mCurrentAddSet = mEditorRoot; } +// The eye was just turned off for a control. If the container being worked in is +// that control or something inside it, come back out to the nearest one that is +// still drawn. +// +// A drag heals itself, because onControlDragged picks its target with +// findHitControl and that no longer answers anything hidden. Click-to-place from +// the palette does not: it adds into the add set, so without this the next +// control placed would land inside a hidden parent and appear nowhere at all. +// +// The selection is deliberately left alone. Clicking the eye must not move it - +// that is the promise the Explorer's gutter is built around. +void GuiEditCtrl::controlHidden(GuiControl* ctrl) +{ + if (ctrl == NULL || !ctrl->isHidden() || mCurrentAddSet == NULL) + return; + + bool inside = false; + for (GuiControl* walk = mCurrentAddSet; walk != NULL; walk = walk->getParent()) + { + if (walk == ctrl) + { + inside = true; + break; + } + if (walk == mEditorRoot) + break; + } + + if (!inside) + return; + + // Up past anything else that is hidden. One hidden branch inside another is + // the case this loop is for. + GuiControl* newAddSet = ctrl->getParent(); + while (newAddSet != NULL && newAddSet != mEditorRoot && newAddSet->isHidden()) + newAddSet = newAddSet->getParent(); + + setCurrentAddSet(newAddSet != NULL ? newAddSet : mEditorRoot, false); +} + void GuiEditCtrl::setCurrentAddSet(GuiControl* ctrl, bool clearSelection) { if (ctrl != mCurrentAddSet) @@ -360,7 +415,6 @@ void GuiEditCtrl::addNewControl(GuiControl* ctrl) mSelectedControls.push_back(ctrl); Con::executef(this, 2, "onAddSelected", Con::getIntArg(ctrl->getId())); //} - // undo Con::executef(this, 2, "onAddNewCtrl", Con::getIntArg(ctrl->getId())); } @@ -412,6 +466,21 @@ S32 GuiEditCtrl::getSizingHitKnobs(const Point2I& pt, const RectI& box) return sizingNone; } +// Whether the editor must leave this control's position and extent alone. +// +// Three different reasons that come to the same thing. isLocked is the padlock +// the user turned on. isGeometryEditable is the control saying its parent +// dictates its geometry - a tab page, a menu item - so that dragging it would +// change a number something else overwrites on the next layout pass. And +// isHidden is the eye: a control the editor does not draw offers nothing to take +// hold of, so the eight sizing knobs it would otherwise keep - hit tested before +// findHitControl ever runs, and drawn by nothing - would be invisible traps +// straddling the edges of whatever the user was trying to reach behind it. +static inline bool editGeometryFrozen(GuiControl* ctrl) +{ + return ctrl == NULL || ctrl->isHidden() || ctrl->isLocked() || !ctrl->isGeometryEditable(); +} + void GuiEditCtrl::drawControlDecoration(GuiControl* ctrl, RectI& box, ColorI& outlineColor, ColorI& nutColor) { S32 lx = box.point.x, rx = box.point.x + box.extent.x - 1; @@ -438,8 +507,10 @@ void GuiEditCtrl::drawControlDecoration(GuiControl* ctrl, RectI& box, ColorI& ou dglDrawRect(box, outlineColor); } } - else if (ctrl->isLocked()) + else if (editGeometryFrozen(ctrl)) { + // An outline rather than eight handles, because there is nothing here to + // take hold of - see editGeometryFrozen. box.inset(-1, -1); dglDrawRect(box, strongestColor); box.inset(-1,-1); @@ -700,7 +771,10 @@ void GuiEditCtrl::getCursor(GuiCursor*& cursor, bool& showCursor, const GuiEvent Point2I mousePos = globalToLocalCoord(lastGuiEvent.mousePoint); // first see if we hit a sizing knob on the currently selected control... - if (mSelectedControls.size() == 1 && initCursors() == true) + // (a control whose geometry is not the editor's to change draws no knobs, so + // it must not promise a resize cursor over one either) + if (mSelectedControls.size() == 1 && initCursors() == true && + !editGeometryFrozen(mSelectedControls.first())) { ctrl = mSelectedControls.first(); cext = ctrl->getExtent(); @@ -765,7 +839,10 @@ void GuiEditCtrl::onTouchDown(const GuiEvent& event) mLastMousePos = globalToLocalCoord(event.mousePoint); // first see if we hit a sizing knob on the currently selected control... - if (mSelectedControls.size() == 1) + // (none are drawn for a control whose geometry is not the editor's to change, + // so none can be hit either - otherwise the gesture starts, fires onPreEdit + // and records an undo step for a resize that never happens) + if (mSelectedControls.size() == 1 && !editGeometryFrozen(mSelectedControls.first())) { ctrl = mSelectedControls.first(); cext = ctrl->getExtent(); @@ -775,7 +852,6 @@ void GuiEditCtrl::onTouchDown(const GuiEvent& event) if ((mSizingMode = (GuiEditCtrl::sizingModes)getSizingHitKnobs(mLastMousePos, box)) != 0) { mMouseDownMode = SizingSelection; - // undo Con::executef(this, 2, "onPreEdit", Con::getIntArg(getSelectedSet().getId())); return; } @@ -836,7 +912,6 @@ void GuiEditCtrl::onTouchDown(const GuiEvent& event) // Set Mouse Mode mMouseDownMode = MovingSelection; - // undo Con::executef(this, 2, "onPreEdit", Con::getIntArg(getSelectedSet().getId())); } } @@ -951,6 +1026,13 @@ void GuiEditCtrl::onTouchUp(const GuiEvent& event) for (i = mCurrentAddSet->begin(); i != mCurrentAddSet->end(); i++) { GuiControl* ctrl = dynamic_cast(*i); + + // A band is drawn across the canvas, and a hidden control is not on + // the canvas. This walks the children rather than hit testing them, + // so it is the one selection path findHitControl does not answer for. + if (ctrl == NULL || ctrl->isHidden()) + continue; + Point2I upperL = globalToLocalCoord(ctrl->localToGlobalCoord(Point2I(0, 0))); Point2I lowerR = upperL + ctrl->mBounds.extent - Point2I(1, 1); @@ -968,7 +1050,6 @@ void GuiEditCtrl::onTouchUp(const GuiEvent& event) // deliver post edit event if we've been editing // note: paxorr: this may need to be moved earlier, if the selection has changed. - // undo if (mMouseDownMode == SizingSelection || mMouseDownMode == MovingSelection) Con::executef(this, 2, "onPostEdit", Con::getIntArg(getSelectedSet().getId())); @@ -1018,8 +1099,8 @@ void GuiEditCtrl::onTouchDragged(const GuiEvent& event) GuiControl* ctrl = mSelectedControls.first(); - // can't resize a locked control - if (ctrl && ctrl->isLocked()) + // can't resize a locked control, nor one whose parent owns its geometry + if (editGeometryFrozen(ctrl)) return; Point2I ctrlPoint = mCurrentAddSet->globalToLocalCoord(event.mousePoint); @@ -1091,8 +1172,8 @@ void GuiEditCtrl::onTouchDragged(const GuiEvent& event) for (; i != mSelectedControls.end(); i++) { - // skip locked controls - if ((*i)->isLocked()) + // skip controls the editor may not move + if (editGeometryFrozen(*i)) continue; if ((*i)->mBounds.point.x < minPos.x) @@ -1118,8 +1199,8 @@ void GuiEditCtrl::onTouchDragged(const GuiEvent& event) { for (S32 i = 0; i < mSelectedControls.size(); i++) { - // skip locked controls - if (mSelectedControls[i]->isLocked()) + // skip controls the editor may not move + if (editGeometryFrozen(mSelectedControls[i])) continue; Point2I snapBackPoint(mSelectedControls[i]->mBounds.point.x, mDragBeginPoints[i].y); @@ -1133,8 +1214,8 @@ void GuiEditCtrl::onTouchDragged(const GuiEvent& event) { for (S32 i = 0; i < mSelectedControls.size(); i++) { - // skip locked controls - if (mSelectedControls[i]->isLocked()) + // skip controls the editor may not move + if (editGeometryFrozen(mSelectedControls[i])) continue; Point2I snapBackPoint(mDragBeginPoints[i].x, mSelectedControls[i]->mBounds.point.y); @@ -1187,14 +1268,39 @@ void GuiEditCtrl::moveSelectionToCtrl(GuiControl* newParent) if (ctrl->getParent() == newParent) continue; - // skip locked controls - if (ctrl->isLocked()) + // skip locked controls, and hidden ones: this runs off a drag that + // moveSelection has already refused to move them with, so reparenting + // them here would change the one number the drag left alone + if (ctrl->isLocked() || ctrl->isHidden()) + continue; + + // skip controls that will not live there - a tab page outside a tab book + // is the case this exists for + if (!ctrl->canBeChildOf(newParent)) continue; Point2I globalpos = ctrl->localToGlobalCoord(Point2I(0, 0)); newParent->addObject(ctrl); Point2I newpos = ctrl->globalToLocalCoord(globalpos) + ctrl->mBounds.point; - ctrl->mBounds.set(newpos, ctrl->mBounds.extent); + + // resize rather than a direct write to mBounds, which is what this was. + // + // Keeping the control under the pointer is right for the modes that own + // their position, and wrong for the two that do not: addObject has just + // centred or filled the control against its new parent, and writing + // mBounds threw that away. resize puts it back -- it forces center and + // fill itself, for exactly this reason -- so the control is correct the + // moment it arrives rather than on the next mouse move that happens to + // call resize for some other reason. + ctrl->resize(newpos, ctrl->mBounds.extent); + + // addObject cleared the cached proportion of a scaled control and + // onChildAdded recaptured it -- but against the position the control + // arrived at, which the line above has just replaced with the one under + // the pointer. Clear it again so the recapture happens from where the + // control actually is; otherwise the next time this parent is resized the + // control jumps back to where the drag happened to enter it. + ctrl->resetStoredRelPos(); } Con::executef(this, 2, "onSelectionParentChange", Con::getIntArg(newParent->getId())); @@ -1224,7 +1330,6 @@ void GuiEditCtrl::moveAndSnapSelection(const Point2I& delta) { // move / nudge gets a special callback so that multiple small moves can be // coalesced into one large undo action. - // undo Con::executef(this, 2, "onPreSelectionNudged", Con::getIntArg(getSelectedSet().getId())); Vector::iterator i; @@ -1236,7 +1341,6 @@ void GuiEditCtrl::moveAndSnapSelection(const Point2I& delta) (*i)->resize(newPos, (*i)->mBounds.extent); } - // undo Con::executef(this, 2, "onPostSelectionNudged", Con::getIntArg(getSelectedSet().getId())); // allow script to update the inspector @@ -1247,21 +1351,19 @@ void GuiEditCtrl::moveAndSnapSelection(const Point2I& delta) void GuiEditCtrl::moveSelection(const Point2I& delta) { // move / nudge gets a special callback so that multiple small moves can be - // coalesced into one large undo action. - // undo + // coalesced into one large undo action. Con::executef(this, 2, "onPreSelectionNudged", Con::getIntArg(getSelectedSet().getId())); Vector::iterator i; for (i = mSelectedControls.begin(); i != mSelectedControls.end(); i++) { - // skip locked controls - if ((*i)->isLocked()) + // skip controls the editor may not move + if (editGeometryFrozen(*i)) continue; (*i)->resize((*i)->mBounds.point + delta, (*i)->mBounds.extent); } - // undo Con::executef(this, 2, "onPostSelectionNudged", Con::getIntArg(getSelectedSet().getId())); // allow script to update the inspector @@ -1369,7 +1471,8 @@ void GuiEditCtrl::justifySelection(Justification j) void GuiEditCtrl::deleteSelection(void) { - // undo + // Before the move below, while each control still knows the parent and the + // index it would have to go back to. Con::executef(this, 2, "onTrashSelection", Con::getIntArg(getSelectedSet().getId())); Vector::iterator i; @@ -1380,57 +1483,6 @@ void GuiEditCtrl::deleteSelection(void) mSelectedControls.clear(); } -void GuiEditCtrl::loadSelection(const char* filename) -{ - if (!mCurrentAddSet) - mCurrentAddSet = mEditorRoot; - - Con::executef(2, "exec", filename); - SimSet* set; - if (!Sim::findObject("guiClipboard", set)) - return; - - if (set->size()) - { - Con::executef(this, 1, "onClearSelected"); - mSelectedControls.clear(); - for (U32 i = 0; i < (U32)set->size(); i++) - { - GuiControl* ctrl = dynamic_cast((*set)[i]); - if (ctrl) - { - mCurrentAddSet->addObject(ctrl); - mSelectedControls.push_back(ctrl); - Con::executef(this, 2, "onAddSelected", Con::getIntArg(ctrl->getId())); - } - } - // Undo - Con::executef(this, 2, "onAddNewCtrlSet", Con::getIntArg(getSelectedSet().getId())); - } - set->deleteObject(); -} - -void GuiEditCtrl::saveSelection(const char* filename) -{ - // if there are no selected objects, then don't save - if (mSelectedControls.size() == 0) - return; - - FileStream stream; - if (!ResourceManager->openFileForWrite(stream, filename)) - return; - SimSet* clipboardSet = new SimSet; - clipboardSet->registerObject(); - Sim::getRootGroup()->addObject(clipboardSet, "guiClipboard"); - - Vector::iterator i; - for (i = mSelectedControls.begin(); i != mSelectedControls.end(); i++) - clipboardSet->addObject(*i); - - clipboardSet->write(stream, 0); - clipboardSet->deleteObject(); -} - void GuiEditCtrl::selectAll() { if (!mCurrentAddSet) @@ -1456,9 +1508,10 @@ void GuiEditCtrl::selectAll() } } +// No callback: the Layout menu is the only way in, and the Gui Editor's script +// records around its own command (GuiEditor::BringToFront). void GuiEditCtrl::bringToFront() { - // undo if (mSelectedControls.size() != 1) return; @@ -1466,9 +1519,9 @@ void GuiEditCtrl::bringToFront() mCurrentAddSet->pushObjectToBack(ctrl); } +// As above: recorded by GuiEditor::PushToBack. void GuiEditCtrl::pushToBack() { - // undo if (mSelectedControls.size() != 1) return; @@ -1544,13 +1597,11 @@ void GuiEditCtrl::setSnapToGrid(U32 gridsize) void GuiEditCtrl::controlInspectPreApply(GuiControl* object) { - // undo Con::executef(this, 2, "onControlInspectPreApply", Con::getIntArg(object->getId())); } void GuiEditCtrl::controlInspectPostApply(GuiControl* object) { - // undo Con::executef(this, 2, "onControlInspectPostApply", Con::getIntArg(object->getId())); } diff --git a/engine/source/gui/editor/guiEditCtrl.h b/engine/source/gui/editor/guiEditCtrl.h index 1e9173d45..3ab2c77b6 100755 --- a/engine/source/gui/editor/guiEditCtrl.h +++ b/engine/source/gui/editor/guiEditCtrl.h @@ -88,6 +88,7 @@ class GuiEditCtrl : public GuiControl void setCurrentAddSet(GuiControl *ctrl, bool clearSelection = true); const GuiControl* getCurrentAddSet() const; void setSelection(GuiControl *ctrl); + void controlHidden(GuiControl *ctrl); // Undo Access void undo(); @@ -130,8 +131,6 @@ class GuiEditCtrl : public GuiControl void justifySelection( Justification j); void moveSelection(const Point2I &delta); void moveAndSnapSelection(const Point2I &delta); - void saveSelection(const char *filename); - void loadSelection(const char *filename); void addSelection(S32 id); void removeSelection(S32 id); void deleteSelection(); diff --git a/engine/source/gui/editor/guiEditorCursorCtrl.cc b/engine/source/gui/editor/guiEditorCursorCtrl.cc new file mode 100644 index 000000000..4f49a40f4 --- /dev/null +++ b/engine/source/gui/editor/guiEditorCursorCtrl.cc @@ -0,0 +1,482 @@ +//----------------------------------------------------------------------------- +// 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/guiEditorCursorCtrl.h" +#include "gui/guiTypes.h" +#include "gui/guiDefaultControlRender.h" +#include "graphics/dgl.h" +#include "graphics/gBitmap.h" +#include "console/consoleTypes.h" +#include "io/resource/resourceManager.h" + +#include "guiEditorCursorCtrl_ScriptBinding.h" + +IMPLEMENT_CONOBJECT(GuiEditorCursorCtrl); + +GuiEditorCursorCtrl::GuiEditorCursorCtrl() +{ + mCursor = NULL; + mNotifiedCursor = NULL; + mBitmap = NULL; + mBitmapKey = StringTable->EmptyString; + mZoom = 8; + mDragging = false; + + // Defaults chosen to read against the stock art, which is a white body with + // a black outline: a saturated red dot is visible on both, and the checker + // greys are far enough from either to never be mistaken for the art. + mDotColor.set(220, 40, 40, 255); + mAnchorColor.set(90, 170, 255, 160); + mCheckerLight.set(110, 110, 110, 255); + mCheckerDark.set(80, 80, 80, 255); + mGridColor.set(0, 0, 0, 60); +} + +GuiEditorCursorCtrl::~GuiEditorCursorCtrl() +{ + releaseBitmap(); +} + +void GuiEditorCursorCtrl::initPersistFields() +{ + Parent::initPersistFields(); + + addField("cursor", TypeGuiCursor, Offset(mCursor, GuiEditorCursorCtrl)); + addField("zoom", TypeS32, Offset(mZoom, GuiEditorCursorCtrl)); + addField("dotColor", TypeColorI, Offset(mDotColor, GuiEditorCursorCtrl)); + addField("anchorColor", TypeColorI, Offset(mAnchorColor, GuiEditorCursorCtrl)); + addField("checkerLight", TypeColorI, Offset(mCheckerLight, GuiEditorCursorCtrl)); + addField("checkerDark", TypeColorI, Offset(mCheckerDark, GuiEditorCursorCtrl)); + addField("gridColor", TypeColorI, Offset(mGridColor, GuiEditorCursorCtrl)); +} + +//----------------------------------------------------------------------------- +// The hot-spot arithmetic. Both directions are static and take everything they +// use: guiCanvas.cc positions a cursor at cursorPos - hotSpot and then +// GuiCursor::render subtracts (S32)(extent * renderOffset), so the pointer ends +// up at hotSpot + trunc(extent * renderOffset) within the image. The truncation +// is the engine's, reproduced rather than rounded, so the dot marks the pixel +// that will really be under the mouse. +//----------------------------------------------------------------------------- + +Point2I GuiEditorCursorCtrl::getEffectiveHotSpot(const Point2I& hotSpot, const Point2F& renderOffset, const Point2I& imageExtent) +{ + return Point2I(hotSpot.x + (S32)(imageExtent.x * renderOffset.x), + hotSpot.y + (S32)(imageExtent.y * renderOffset.y)); +} + +Point2I GuiEditorCursorCtrl::getHotSpotForPixel(const Point2I& pixel, const Point2F& renderOffset, const Point2I& imageExtent) +{ + return Point2I(pixel.x - (S32)(imageExtent.x * renderOffset.x), + pixel.y - (S32)(imageExtent.y * renderOffset.y)); +} + +//----------------------------------------------------------------------------- + +void GuiEditorCursorCtrl::setCursorObject(GuiCursor* cursor) +{ + mCursor = cursor; + mDragging = false; + bindCursorNotify(); +} + +// Both halves of the notification are unwound by SimObject itself when either +// end is deleted, so this only has to keep the registration pointed at the +// current cursor. +void GuiEditorCursorCtrl::bindCursorNotify() +{ + if (mCursor == mNotifiedCursor) + return; + + if (mNotifiedCursor != NULL) + clearNotify(mNotifiedCursor); + + if (mCursor != NULL) + deleteNotify(mCursor); + + mNotifiedCursor = mCursor; + + // Different cursor, so the art decoded for the last one is stale. resolveBitmap + // would notice a changed file name on its own, but not two cursors naming the + // same file with different hot spots. + releaseBitmap(); +} + +void GuiEditorCursorCtrl::onStaticModified(const char* slotName, const char* newValue) +{ + Parent::onStaticModified(slotName, newValue); + + if (dStricmp(slotName, "cursor") == 0) + { + mDragging = false; + bindCursorNotify(); + } +} + +void GuiEditorCursorCtrl::onDeleteNotify(SimObject* object) +{ + if (object == mNotifiedCursor) + { + // The notification is the cursor's last act -- SimObject has already taken + // this end of the registration apart -- so drop the pointer without + // clearing anything, and let the pane draw empty until it is given another. + mCursor = NULL; + mNotifiedCursor = NULL; + mDragging = false; + releaseBitmap(); + } + + Parent::onDeleteNotify(object); +} + +void GuiEditorCursorCtrl::releaseBitmap() +{ + if (mBitmap != NULL) + { + delete mBitmap; + mBitmap = NULL; + } + mBitmapKey = StringTable->EmptyString; +} + +const GBitmap* GuiEditorCursorCtrl::resolveBitmap() +{ + if (mCursor == NULL) + { + releaseBitmap(); + return NULL; + } + + StringTableEntry key = mCursor->getBitmapName(); + if (key == NULL || *key == '\0') + { + releaseBitmap(); + return NULL; + } + + // Reload when the cursor is pointed at different art - which is exactly what + // happens when the user picks a file in the pane beside this. + if (key != mBitmapKey) + { + releaseBitmap(); + mBitmapKey = key; + + // Straight from the resource manager rather than through a + // TextureHandle: a cursor's handle is a BitmapTexture, whose CPU-side + // bitmap the texture manager frees once it has uploaded it. The same + // extension probe TextureManager::loadBitmap does, so a bitmapName + // written without one (as AppCore's hand-made cursors always were) still + // finds its file. + static const char* const extensions[] = { "", ".png", ".jpg" }; + char pathBuffer[1024]; + for (S32 i = 0; i < 3 && mBitmap == NULL; ++i) + { + dSprintf(pathBuffer, sizeof(pathBuffer), "%s%s", key, extensions[i]); + mBitmap = (GBitmap*)ResourceManager->loadInstance(pathBuffer); + } + + if (mBitmap == NULL) + Con::warnf("GuiEditorCursorCtrl - could not read the cursor art '%s'.", key); + } + + return mBitmap; +} + +void GuiEditorCursorCtrl::setZoom(S32 zoom) +{ + mZoom = mClamp(zoom, 1, getMaxZoom()); +} + +// The largest magnification whose art still fits the pane. Asking the control +// rather than assuming smMaxZoom is what keeps the zoom readout honest: a 32x32 +// cursor in a 370x200 pane tops out at 6x, and a control that reported 16x while +// drawing 6x would be lying about the one number the user is steering by. +S32 GuiEditorCursorCtrl::getMaxZoom() +{ + const Point2I imageExtent = getImageExtent(); + if (imageExtent.x <= 0 || imageExtent.y <= 0) + return smMaxZoom; + + Point2I globalOffset = localToGlobalCoord(Point2I(0, 0)); + Point2I extent = mBounds.extent; + const RectI content = getInnerRect(globalOffset, extent, NormalState, mProfile); + + S32 zoom = smMaxZoom; + while (zoom > 1 && (imageExtent.x * zoom > content.extent.x || imageExtent.y * zoom > content.extent.y)) + --zoom; + + return zoom; +} + +Point2I GuiEditorCursorCtrl::getImageExtent() +{ + const GBitmap* bitmap = resolveBitmap(); + if (bitmap == NULL) + return Point2I(0, 0); + + return Point2I((S32)bitmap->getWidth(), (S32)bitmap->getHeight()); +} + +bool GuiEditorCursorCtrl::getArtRect(RectI& artRect, Point2I& imageExtent) +{ + imageExtent = getImageExtent(); + if (imageExtent.x <= 0 || imageExtent.y <= 0) + return false; + + // Global, because the hit test asks this question with a global mouse point + // and the renderer draws in global coordinates too. + Point2I globalOffset = localToGlobalCoord(Point2I(0, 0)); + Point2I extent = mBounds.extent; + const RectI content = getInnerRect(globalOffset, extent, NormalState, mProfile); + + // Never magnify past what fits: a 32x32 cursor at 16x wants 512 pixels, and + // a pane that narrow would otherwise clip the art the user is aiming at. + // Written back rather than used locally, so what mZoom says and what is drawn + // can never differ -- a readout showing a zoom nothing is drawn at is worse + // than no readout. A pane that grows lets the user zoom in again. + S32 zoom = mClamp(mZoom, 1, smMaxZoom); + while (zoom > 1 && (imageExtent.x * zoom > content.extent.x || imageExtent.y * zoom > content.extent.y)) + --zoom; + mZoom = zoom; + + const Point2I size(imageExtent.x * zoom, imageExtent.y * zoom); + artRect.point.set(content.point.x + ((content.extent.x - size.x) / 2), + content.point.y + ((content.extent.y - size.y) / 2)); + artRect.extent = size; + + return true; +} + +Point2I GuiEditorCursorCtrl::pixelAt(const Point2I& globalPoint, const RectI& artRect, const Point2I& imageExtent) +{ + const S32 zoom = artRect.extent.x / imageExtent.x; + if (zoom <= 0) + return Point2I(0, 0); + + return Point2I(mClamp((globalPoint.x - artRect.point.x) / zoom, 0, imageExtent.x - 1), + mClamp((globalPoint.y - artRect.point.y) / zoom, 0, imageExtent.y - 1)); +} + +void GuiEditorCursorCtrl::setHotSpotPixel(const Point2I& pixel) +{ + if (mCursor == NULL) + return; + + const Point2I imageExtent = getImageExtent(); + if (imageExtent.x <= 0 || imageExtent.y <= 0) + return; + + const Point2I hotSpot = getHotSpotForPixel(pixel, mCursor->getRenderOffset(), imageExtent); + if (hotSpot == mCursor->getHotSpot()) + return; + + mCursor->setHotSpot(hotSpot); + + // The pane writes the field and marks the theme dirty; this only reports + // that the value moved, so the control never has to know about themes. + Con::executef(this, 3, "onHotSpotChanged", Con::getIntArg(hotSpot.x), Con::getIntArg(hotSpot.y)); +} + +//----------------------------------------------------------------------------- +// Rendering. +//----------------------------------------------------------------------------- + +void GuiEditorCursorCtrl::onRender(Point2I offset, const RectI& updateRect) +{ + RectI ctrlRect = applyMargins(offset, mBounds.extent, NormalState, mProfile); + renderUniversalRect(ctrlRect, mProfile, NormalState); + + RectI artRect; + Point2I imageExtent; + if (getArtRect(artRect, imageExtent)) + { + renderChecker(artRect); + renderArt(artRect, imageExtent); + renderGrid(artRect, imageExtent); + renderMarks(artRect, imageExtent); + } + + Point2I contentOffset = offset; + Point2I contentExtent = mBounds.extent; + renderChildControls(offset, getInnerRect(contentOffset, contentExtent, NormalState, mProfile), updateRect); +} + +void GuiEditorCursorCtrl::renderChecker(const RectI& artRect) +{ + // Squares in screen space rather than image space, so the backdrop stays the + // same size as the zoom changes and never reads as part of the art. + const S32 square = 8; + + dglDrawRectFill(artRect, mCheckerDark); + + for (S32 y = 0; y < artRect.extent.y; y += square) + { + for (S32 x = ((y / square) % 2) * square; x < artRect.extent.x; x += square * 2) + { + RectI cell(artRect.point.x + x, artRect.point.y + y, + getMin(square, artRect.extent.x - x), getMin(square, artRect.extent.y - y)); + dglDrawRectFill(cell, mCheckerLight); + } + } +} + +void GuiEditorCursorCtrl::renderArt(const RectI& artRect, const Point2I& imageExtent) +{ + const GBitmap* bitmap = resolveBitmap(); + if (bitmap == NULL) + return; + + const S32 zoom = artRect.extent.x / imageExtent.x; + const ColorI& tint = mCursor->mColor; + + for (S32 y = 0; y < imageExtent.y; ++y) + { + for (S32 x = 0; x < imageExtent.x; ++x) + { + ColorI pixel; + if (!bitmap->getColor((U32)x, (U32)y, pixel) || pixel.alpha == 0) + continue; + + // The same multiply the canvas applies through bitmap modulation, so + // what is magnified here is what will be drawn on screen. + pixel.red = (U8)((pixel.red * tint.red) / 255); + pixel.green = (U8)((pixel.green * tint.green) / 255); + pixel.blue = (U8)((pixel.blue * tint.blue) / 255); + pixel.alpha = (U8)((pixel.alpha * tint.alpha) / 255); + + dglDrawRectFill(RectI(artRect.point.x + (x * zoom), artRect.point.y + (y * zoom), zoom, zoom), pixel); + } + } +} + +void GuiEditorCursorCtrl::renderGrid(const RectI& artRect, const Point2I& imageExtent) +{ + const S32 zoom = artRect.extent.x / imageExtent.x; + + // Below this the lines cost more than the pixels they separate. + if (zoom < 4) + return; + + for (S32 x = 1; x < imageExtent.x; ++x) + { + const S32 lineX = artRect.point.x + (x * zoom); + dglDrawLine(lineX, artRect.point.y, lineX, artRect.point.y + artRect.extent.y, mGridColor); + } + + for (S32 y = 1; y < imageExtent.y; ++y) + { + const S32 lineY = artRect.point.y + (y * zoom); + dglDrawLine(artRect.point.x, lineY, artRect.point.x + artRect.extent.x, lineY, mGridColor); + } + + dglDrawRect(artRect, mGridColor); +} + +void GuiEditorCursorCtrl::renderMarks(const RectI& artRect, const Point2I& imageExtent) +{ + const S32 zoom = artRect.extent.x / imageExtent.x; + const Point2F renderOffset = mCursor->getRenderOffset(); + + // The anchor: where renderOffset alone puts the pointer. Drawn first and + // faintly, as crosshairs across the whole art, so the distance between it + // and the dot IS the hotSpot nudge -- which is the one thing a reader needs + // to understand why two fields exist. + const Point2I anchor((S32)(imageExtent.x * renderOffset.x), (S32)(imageExtent.y * renderOffset.y)); + const S32 anchorX = artRect.point.x + (anchor.x * zoom); + const S32 anchorY = artRect.point.y + (anchor.y * zoom); + dglDrawLine(anchorX, artRect.point.y, anchorX, artRect.point.y + artRect.extent.y, mAnchorColor); + dglDrawLine(artRect.point.x, anchorY, artRect.point.x + artRect.extent.x, anchorY, mAnchorColor); + + // The hot spot itself: the pixel that will sit under the mouse, boxed so the + // pixel is identifiable, with a dot at its center for low zooms where the + // box is only a few pixels across. + const Point2I hot = getEffectiveHotSpot(mCursor->getHotSpot(), renderOffset, imageExtent); + const RectI hotPixel(artRect.point.x + (hot.x * zoom), artRect.point.y + (hot.y * zoom), zoom, zoom); + + dglDrawRect(hotPixel, mDotColor); + if (zoom >= 3) + { + const RectI inner(hotPixel.point.x + 1, hotPixel.point.y + 1, hotPixel.extent.x - 2, hotPixel.extent.y - 2); + dglDrawRectFill(inner, mDotColor); + } + else + { + dglDrawRectFill(hotPixel, mDotColor); + } +} + +//----------------------------------------------------------------------------- +// Input. +//----------------------------------------------------------------------------- + +void GuiEditorCursorCtrl::onTouchDown(const GuiEvent& event) +{ + RectI artRect; + Point2I imageExtent; + if (!getArtRect(artRect, imageExtent)) + return; + + // A press outside the art is not a hot spot: it would clamp to an edge pixel + // nobody aimed at. + if (!artRect.pointInRect(event.mousePoint)) + return; + + mDragging = true; + mouseLock(); + setHotSpotPixel(pixelAt(event.mousePoint, artRect, imageExtent)); +} + +void GuiEditorCursorCtrl::onTouchDragged(const GuiEvent& event) +{ + if (!mDragging) + return; + + RectI artRect; + Point2I imageExtent; + if (!getArtRect(artRect, imageExtent)) + return; + + // Clamped rather than ignored: a drag that wanders off the art should pin the + // hot spot to the edge it left by, not stop responding. + setHotSpotPixel(pixelAt(event.mousePoint, artRect, imageExtent)); +} + +void GuiEditorCursorCtrl::onTouchUp(const GuiEvent& event) +{ + if (!mDragging) + return; + + mDragging = false; + mouseUnlock(); +} + +void GuiEditorCursorCtrl::onMouseWheelUp(const GuiEvent& event) +{ + setZoom(mZoom + 1); + Con::executef(this, 2, "onZoomChanged", Con::getIntArg(mZoom)); +} + +void GuiEditorCursorCtrl::onMouseWheelDown(const GuiEvent& event) +{ + setZoom(mZoom - 1); + Con::executef(this, 2, "onZoomChanged", Con::getIntArg(mZoom)); +} diff --git a/engine/source/gui/editor/guiEditorCursorCtrl.h b/engine/source/gui/editor/guiEditorCursorCtrl.h new file mode 100644 index 000000000..aff7e6151 --- /dev/null +++ b/engine/source/gui/editor/guiEditorCursorCtrl.h @@ -0,0 +1,172 @@ +//----------------------------------------------------------------------------- +// 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_EDITOR_CURSOR_CTRL_H_ +#define _GUI_EDITOR_CURSOR_CTRL_H_ + +#ifndef _GUICONTROL_H_ +#include "gui/guiControl.h" +#endif + +//----------------------------------------------------------------------------- +// The Gui Profile Editor's hot-spot editor: one cursor's art, magnified, with +// its hot spot marked and draggable. +// +// This exists because a hot spot cannot be set by typing numbers. It is a +// single pixel in a 13x17 image, and whether it is the right pixel is a +// question about what the art looks like -- so the only way to answer it is to +// see the two together at a size where individual pixels are visible. +// +// Two fields decide where the pointer lands, and the display keeps them +// distinct because they do different jobs: +// +// renderOffset a FRACTION of the bitmap's own size, so "0.5 0.5" means +// "centered" whatever the art measures. This is what stops a +// 13x17 arrow and a 32x32 sizer from appearing to leap when one +// replaces the other, and it is drawn as the faint anchor mark. +// hotSpot a pixel nudge applied on top of it, drawn as the bright dot. +// +// The pointer ends up at hotSpot + trunc(extent * renderOffset), which is the +// dot; dragging moves the dot and writes hotSpot, leaving the anchor alone. +// +// Art is drawn a pixel at a time rather than as a stretched bitmap: magnifying +// through the texture filter would blur exactly the pixel boundaries the user +// is trying to aim at. Transparent pixels are skipped, which is most of a +// cursor. +// +// Editor-only, like guiEditCtrl and the explorer tree, and never offered in the +// palette (every class whose name begins "GuiEdit" is refused there -- this one +// is excluded by having no entry in the placeable list at all). +//----------------------------------------------------------------------------- + +class GuiCursor; +class GBitmap; + +class GuiEditorCursorCtrl : public GuiControl +{ +private: + typedef GuiControl Parent; + +protected: + GuiCursor* mCursor; ///< The cursor being edited. Nothing is drawn without one. + + /// The cursor we hold a delete notification from, which is not always the one + /// in mCursor: the "cursor" field is written straight through TypeGuiCursor, + /// which overwrites the pointer without telling us what used to be there. So + /// the registration is tracked separately and reconciled after every write. + GuiCursor* mNotifiedCursor; + + /// Point the delete notification at whatever mCursor now holds. Cheap and + /// idempotent, so it is safe to call after any write to the field. + void bindCursorNotify(); + + // Its own decoded copy of the art, because the pixels are the point. A + // cursor's TextureHandle is a BitmapTexture, and TextureManager deletes the + // CPU-side bitmap once it has uploaded one of those -- so asking the cursor + // for its pixels gets NULL. Reloaded whenever the cursor, or the file it + // names, changes. + GBitmap* mBitmap; + StringTableEntry mBitmapKey; + S32 mZoom; ///< Magnification, 1 to smMaxZoom. + ColorI mDotColor; ///< The hot-spot mark. User-settable because a dark dot vanishes on dark art. + ColorI mAnchorColor; ///< The renderOffset anchor mark. + ColorI mCheckerLight; ///< The two squares of the transparency backdrop. + ColorI mCheckerDark; + ColorI mGridColor; ///< Pixel grid, drawn only when zoomed enough to be useful. + bool mDragging; + + /// The decoded art, loading or reloading it if the cursor now names a + /// different file. NULL when there is no cursor or the file is missing. + const GBitmap* resolveBitmap(); + void releaseBitmap(); + + /// The rect the magnified art occupies, in global coordinates, and the + /// image size it was computed from. Returns false when there is nothing to + /// draw (no cursor, or art that failed to load). + bool getArtRect(RectI& artRect, Point2I& imageExtent); + + /// Which image pixel a global point falls on, clamped into the image. + Point2I pixelAt(const Point2I& globalPoint, const RectI& artRect, const Point2I& imageExtent); + + /// Move the hot spot to an image pixel and tell script. hotSpot absorbs the + /// change; renderOffset is deliberately untouched. + void setHotSpotPixel(const Point2I& pixel); + + void renderChecker(const RectI& artRect); + void renderArt(const RectI& artRect, const Point2I& imageExtent); + void renderGrid(const RectI& artRect, const Point2I& imageExtent); + void renderMarks(const RectI& artRect, const Point2I& imageExtent); + +public: + static const S32 smMaxZoom = 16; + + /// Where the pointer sits within the image: the anchor, floored to a pixel, + /// plus the nudge. Static and taking everything it uses, so the arithmetic + /// that the renderer, the hit test and the script readout all depend on can + /// be tested without a canvas -- rendering a cursor needs a GL context, and + /// a unit test has none. + static Point2I getEffectiveHotSpot(const Point2I& hotSpot, const Point2F& renderOffset, const Point2I& imageExtent); + + /// The inverse: the hotSpot that puts the pointer on a given pixel. + static Point2I getHotSpotForPixel(const Point2I& pixel, const Point2F& renderOffset, const Point2I& imageExtent); + + GuiEditorCursorCtrl(); + ~GuiEditorCursorCtrl(); + static void initPersistFields(); + + void onRender(Point2I offset, const RectI& updateRect); + + /// A cursor can be deleted while this pane still points at it -- removing a + /// theme's extra cursor does exactly that -- and the next frame would draw + /// from freed memory. Forget it instead. + virtual void onDeleteNotify(SimObject* object); + + /// The "cursor" field bypasses setCursorObject entirely (script assigns it + /// directly, and so does a .gui.taml load), so this is the only place such a + /// write can be noticed. + virtual void onStaticModified(const char* slotName, const char* newValue = NULL); + + void onTouchDown(const GuiEvent& event); + void onTouchDragged(const GuiEvent& event); + void onTouchUp(const GuiEvent& event); + + /// Zoom in and out over the art, which is what a magnifier should do. + void onMouseWheelUp(const GuiEvent& event); + void onMouseWheelDown(const GuiEvent& event); + + inline GuiCursor* getCursor() const { return mCursor; } + void setCursorObject(GuiCursor* cursor); + void setZoom(S32 zoom); + inline S32 getZoom() const { return mZoom; } + + /// The largest magnification the pane can actually show this art at. The + /// zoom is clamped to it, so getZoom() never reports one that is not being + /// drawn -- and the pane's "+" can go grey when there is no more to give. + S32 getMaxZoom(); + + /// The image's real size, or (0,0) when there is no art. + Point2I getImageExtent(); + + DECLARE_CONOBJECT(GuiEditorCursorCtrl); +}; + +#endif //_GUI_EDITOR_CURSOR_CTRL_H_ diff --git a/engine/source/gui/editor/guiEditorCursorCtrl_ScriptBinding.h b/engine/source/gui/editor/guiEditorCursorCtrl_ScriptBinding.h new file mode 100644 index 000000000..6bc20c643 --- /dev/null +++ b/engine/source/gui/editor/guiEditorCursorCtrl_ScriptBinding.h @@ -0,0 +1,87 @@ +//----------------------------------------------------------------------------- +// 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(GuiEditorCursorCtrl, GuiControl) + +/*! Gets the size of the cursor art, or "0 0" when there is none to show. + @return The image size as "width height". +*/ +ConsoleMethodWithDocs(GuiEditorCursorCtrl, getImageExtent, ConsoleString, 2, 2, ()) +{ + char* buffer = Con::getReturnBuffer(32); + const Point2I extent = object->getImageExtent(); + dSprintf(buffer, 32, "%d %d", extent.x, extent.y); + return buffer; +} + +/*! Gets the pixel of the art the pointer actually lands on: the renderOffset + anchor floored to a pixel, plus the hotSpot nudge. This is the number a + readout should show, because neither field alone answers "where does it + point". + @return The pixel as "x y", or "0 0" when there is no art. +*/ +ConsoleMethodWithDocs(GuiEditorCursorCtrl, getEffectiveHotSpot, ConsoleString, 2, 2, ()) +{ + char* buffer = Con::getReturnBuffer(32); + + GuiCursor* cursor = object->getCursor(); + const Point2I extent = object->getImageExtent(); + if (cursor == NULL || extent.x <= 0 || extent.y <= 0) + { + dStrcpy(buffer, "0 0"); + return buffer; + } + + const Point2I hotSpot = GuiEditorCursorCtrl::getEffectiveHotSpot(cursor->getHotSpot(), cursor->getRenderOffset(), extent); + dSprintf(buffer, 32, "%d %d", hotSpot.x, hotSpot.y); + return buffer; +} + +/*! Sets the magnification, clamped to 1..16. + @param zoom The magnification factor. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiEditorCursorCtrl, setZoom, ConsoleVoid, 3, 3, (zoom)) +{ + object->setZoom(dAtoi(argv[2])); +} + +/*! Gets the current magnification, which is always the one being drawn: a zoom + too big for the pane is clamped rather than pretended at. + @return The magnification factor. +*/ +ConsoleMethodWithDocs(GuiEditorCursorCtrl, getZoom, ConsoleInt, 2, 2, ()) +{ + return object->getZoom(); +} + +/*! Gets the largest magnification this art fits the pane at. Zooming in stops + here, so a pane can grey out its "+" instead of offering a zoom that would + do nothing. + @return The maximum magnification factor. +*/ +ConsoleMethodWithDocs(GuiEditorCursorCtrl, getMaxZoom, ConsoleInt, 2, 2, ()) +{ + return object->getMaxZoom(); +} + +ConsoleMethodGroupEndWithDocs(GuiEditorCursorCtrl) diff --git a/engine/source/gui/editor/guiEditorExplorerTree.cc b/engine/source/gui/editor/guiEditorExplorerTree.cc new file mode 100644 index 000000000..4b46282ed --- /dev/null +++ b/engine/source/gui/editor/guiEditorExplorerTree.cc @@ -0,0 +1,442 @@ +//----------------------------------------------------------------------------- +// 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/guiEditorExplorerTree.h" +#include "graphics/dgl.h" +#include "gui/guiCanvas.h" +#include "gui/editor/guiEditCtrl.h" + +#include "guiEditorExplorerTree_ScriptBinding.h" + +IMPLEMENT_CONOBJECT(GuiEditorExplorerTree); + +GuiEditorExplorerTree::GuiEditorExplorerTree() +{ + mStateImageAssetID = StringTable->EmptyString; + mStateImageAsset = NULL; + // -1 is "no art", so a tree given no sheet draws empty boxes rather than + // whatever frame 0 happens to be. + mEyeFrame = -1; + mLockFrame = -1; + mShownTip = StringTable->EmptyString; + mHiddenTip = StringTable->EmptyString; + mLockedTip = StringTable->EmptyString; + mUnlockedTip = StringTable->EmptyString; + mGutterPress = false; +} + +void GuiEditorExplorerTree::initPersistFields() +{ + Parent::initPersistFields(); + + // The frame numbers come from script, never from here: EditorIcons.cs is + // generated and alphabetical, so an index baked into the engine would + // silently become a different picture the next time the sheet is rebuilt. + addProtectedField("StateIcons", TypeAssetId, Offset(mStateImageAssetID, GuiEditorExplorerTree), &setStateImage, &getStateImage, "The image asset the eye and padlock are frames of."); + addField("EyeFrame", TypeS32, Offset(mEyeFrame, GuiEditorExplorerTree), "Frame of StateIcons drawn when a control is not hidden."); + addField("LockFrame", TypeS32, Offset(mLockFrame, GuiEditorExplorerTree), "Frame of StateIcons drawn when a control is locked."); + + addField("ShownTip", TypeString, Offset(mShownTip, GuiEditorExplorerTree), "Tooltip for the eye column when the control is not hidden."); + addField("HiddenTip", TypeString, Offset(mHiddenTip, GuiEditorExplorerTree), "Tooltip for the eye column when the control is hidden."); + addField("LockedTip", TypeString, Offset(mLockedTip, GuiEditorExplorerTree), "Tooltip for the padlock column when the control is locked."); + addField("UnlockedTip", TypeString, Offset(mUnlockedTip, GuiEditorExplorerTree), "Tooltip for the padlock column when the control is not locked."); +} + +void GuiEditorExplorerTree::setStateImageAsset(const char* pImageAssetID) +{ + // Sanity! + AssertFatal(pImageAssetID != NULL, "Cannot use a NULL asset ID."); + + mStateImageAssetID = StringTable->insert(pImageAssetID); + + if (mStateImageAssetID != StringTable->EmptyString) + { + mStateImageAsset = pImageAssetID; + } + else + { + mStateImageAsset.clear(); + } +} + +//----------------------------------------------------------------------------- +// Geometry. All of it static and taking everything it uses, so it can be tested +// away from a canvas, a Sim and a GL context -- adding a row to a tree loads a +// font, which registers a texture, which is the one thing a unit test here +// cannot do. +//----------------------------------------------------------------------------- + +S32 GuiEditorExplorerTree::getGutterWidth() +{ + return 2 * smColumnWidth; +} + +void GuiEditorExplorerTree::getGutterCells(S32 left, S32 top, S32 height, RectI& hiddenCell, RectI& lockedCell) +{ + hiddenCell.set(Point2I(left, top), Point2I(smColumnWidth, height)); + lockedCell.set(Point2I(left + smColumnWidth, top), Point2I(smColumnWidth, height)); +} + +GuiEditorExplorerTree::GutterColumn GuiEditorExplorerTree::columnAt(S32 x, S32 left) +{ + const S32 offset = x - left; + if (offset < 0) + { + return GutterNone; + } + if (offset < smColumnWidth) + { + return GutterHidden; + } + if (offset < (2 * smColumnWidth)) + { + return GutterLocked; + } + return GutterNone; +} + +RectI GuiEditorExplorerTree::getBoxRect(const RectI& cell) +{ + // Square, and never taller than the row: the art is square and stretching it + // to a short row would be the one thing that makes 16px work look wrong. + const S32 size = getMin(smBoxSize, cell.extent.y); + // The divider owns the cell's last pixel, so center in what is left of it. + const S32 usable = cell.extent.x - 1; + return RectI(cell.point.x + ((usable - size) / 2), + cell.point.y + ((cell.extent.y - size) / 2), + size, size); +} + +S32 GuiEditorExplorerTree::gutterInset() +{ + if (!mProfile) + { + return 0; + } + + GuiBorderProfile* leftProfile = mProfile->getLeftBorder(); + if (!leftProfile) + { + return 0; + } + + return leftProfile->getMargin(NormalState) + leftProfile->getBorder(NormalState) + leftProfile->getPadding(NormalState); +} + +//----------------------------------------------------------------------------- +// Drawing +//----------------------------------------------------------------------------- + +void GuiEditorExplorerTree::renderItemGutter(const RectI& itemRect, RectI& contentRect, TreeItem* treeItem, GuiControlState currentState) +{ + if (!mProfile || !treeItem) + { + return; + } + + const S32 left = itemRect.point.x + gutterInset(); + const S32 want = (left + getGutterWidth()) - contentRect.point.x; + if (want <= 0 || contentRect.extent.x <= want) + { + // No room for the rail and something after it. Draw nothing and carve + // nothing: a cramped tree falls back to plain rows, which is legible, + // rather than to text written over the columns, which is not. + return; + } + + // Reserve before anything below can return early, so a row that draws no + // boxes still keeps its text off the strip and lined up with every other row. + contentRect.point.x += want; + contentRect.extent.x -= want; + + RectI hiddenCell, lockedCell; + getGutterCells(left, itemRect.point.y, itemRect.extent.y, hiddenCell, lockedCell); + + // Hover is polled, not evented: there is no per-row enter or leave to hook, + // and the row above has already read the cursor once to pick its own state. + Point2I cursorPt = Point2I(0, 0); + GuiCanvas* root = getRoot(); + if (root) + { + cursorPt = root->getCursorPos(); + } + + // The root row is the simulated canvas: stage furniture, not a control in the + // document. Hiding it would blank the canvas and locking it would do nothing, + // so it gets no boxes -- but it still draws its dividers, or the rail would + // start one row down and read as a column that had failed to paint. + SimObject* obj = getItemObject(treeItem); + const bool hasBoxes = (obj != NULL && treeItem->trunk != NULL); + + // The rail is drawn in ONE ink, on every row, whatever state the row is in. + // + // Inheriting the row's font colour is what the icons did at first, and it read + // correctly nowhere: the box behind them does not change with selection, so a + // selected row put selected-TEXT ink on an unselected background and the eye + // all but vanished. Checked against all four themes. The boxes and dividers + // are already state-independent -- the icons have to match them, not the text. + dglSetBitmapModulation(getFontColor(mProfile, NormalState)); + + // Photoshop's order and Photoshop's reading: the eye is shown when the + // control is NOT hidden, the padlock when it IS locked. So a tidy Gui is a + // column of eyes and a column of nothing. + renderGutterCell(hiddenCell, cursorPt, hasBoxes, hasBoxes && !obj->isHidden(), mEyeFrame); + renderGutterCell(lockedCell, cursorPt, hasBoxes, hasBoxes && obj->isLocked(), mLockFrame); + + // Put back what the row was drawing with. onRenderItem sets the modulation + // once, before the gutter, and everything after this -- the triangle, the + // class icon, renderText -- is still relying on it. + dglSetBitmapModulation(getFontColor(mProfile, currentState)); +} + +void GuiEditorExplorerTree::renderGutterCell(const RectI& cell, const Point2I& cursorPt, bool showBox, bool showIcon, S32 frame) +{ + const ColorI& quiet = mProfile->getFillColor(HighlightState); + + // One pixel down the cell's right, over the row's WHOLE height rather than + // its content's, so the two columns read as continuous rules down the tree + // even under a profile that puts air between rows. + RectI divider(cell.point.x + cell.extent.x - 1, cell.point.y, 1, cell.extent.y); + dglDrawRectFill(divider, quiet); + + RectI box = getBoxRect(cell); + if (!showBox || !box.isValidRect()) + { + return; + } + + // The hover fill is deliberately barely there against the row fill. The cell + // under the pointer steps up to the selected fill, which is the only thing + // saying the rail can be clicked at all -- on an unlocked, visible control + // both boxes are empty and there is otherwise nothing to find. + dglDrawRectFill(box, cell.pointInRect(cursorPt) ? mProfile->getFillColor(SelectedState) : quiet); + + // The caller has set the modulation to the normal-state font colour and will + // 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); + } +} + +//----------------------------------------------------------------------------- +// Hit testing +//----------------------------------------------------------------------------- + +S32 GuiEditorExplorerTree::visibleRowAt(const Point2I& globalPoint) +{ + const Point2I local = globalToLocalCoord(globalPoint); + if (local.y < 0 || mItemSize.y <= 0) + { + return -1; + } + + const S32 slot = (S32)mFloor((F32)local.y / (F32)mItemSize.y); + for (S32 i = 0, j = 0; i < mItems.size(); i++) + { + TreeItem* treeItem = dynamic_cast(mItems[i]); + if (treeItem && treeItem->isVisible) + { + if (j == slot) + { + return i; + } + j++; + } + } + return -1; +} + +GuiEditorExplorerTree::GutterColumn GuiEditorExplorerTree::hitGutter(const Point2I& globalPoint, SimObject** objOut) +{ + const GutterColumn column = columnAt(globalToLocalCoord(globalPoint).x, gutterInset()); + if (column == GutterNone) + { + return GutterNone; + } + + const S32 index = visibleRowAt(globalPoint); + if (index < 0) + { + return GutterNone; + } + + TreeItem* treeItem = dynamic_cast(mItems[index]); + // The root keeps its columns' width but has no boxes, so it has none to hit. + if (!treeItem || !treeItem->isActive || treeItem->trunk == NULL) + { + return GutterNone; + } + + SimObject* obj = getItemObject(treeItem); + if (!obj) + { + return GutterNone; + } + + if (objOut) + { + *objOut = obj; + } + return column; +} + +Point2I GuiEditorExplorerTree::getGutterPoint(S32 index, GutterColumn column) +{ + // Visible-row space, which is what the rows are drawn and hit tested in. + S32 visible = 0; + for (S32 i = 0; i < mItems.size() && i < index; i++) + { + TreeItem* treeItem = dynamic_cast(mItems[i]); + if (treeItem && treeItem->isVisible) + { + visible++; + } + } + + RectI hiddenCell, lockedCell; + getGutterCells(gutterInset(), visible * mItemSize.y, mItemSize.y, hiddenCell, lockedCell); + + const RectI& cell = (column == GutterLocked) ? lockedCell : hiddenCell; + const RectI box = getBoxRect(cell); + Point2I local(box.point.x + (box.extent.x / 2), box.point.y + (box.extent.y / 2)); + return localToGlobalCoord(local); +} + +void GuiEditorExplorerTree::onTouchDown(const GuiEvent& event) +{ + mGutterPress = false; + + // Above everything the base does, including its row-0 case -- which never + // calls Parent::onTouchDown at all, so the root row would otherwise be the + // one row whose columns did nothing. + // + // Returning before Parent::onTouchDown is what buys every requirement at + // once. GuiListBoxCtrl::onTouchDown never runs, so there is no + // setFirstResponder and no handleItemClick; no handleItemClick means no + // selection change and no onClick or onDoubleClick; and mLastClickItem is + // left alone, so a press in a column followed by a press on the row is not + // read as a double click. + SimObject* obj = NULL; + const GutterColumn column = hitGutter(event.mousePoint, &obj); + if (column != GutterNone && obj) + { + // Written straight, with no undo record, and that is deliberate. Neither + // flag is ever saved -- SimObject::_writeHidden and _writeLocked both + // refuse, see the comment there -- so an undo step would restore + // something that can never reach a file, and would leave Ctrl+Z after + // "hide three things, then move a button" un-hiding something instead of + // putting the button back. + // + // It acts on the row that was clicked and not on the selection, which is + // what a layers panel does and what someone reaching for one row's eye + // means. If this ever should be undoable, it is this branch that changes + // and nothing else. + if (column == GutterHidden) + { + obj->setHidden(!obj->isHidden()); + + // A hidden control is not a target on the canvas any more, and that + // includes being the container the next control is placed into. + if (GuiControl::smEditorHandle != NULL) + { + GuiControl::smEditorHandle->controlHidden(dynamic_cast(obj)); + } + } + else + { + obj->setLocked(!obj->isLocked()); + } + + mGutterPress = true; + setUpdate(); + return; + } + + Parent::onTouchDown(event); +} + +void GuiEditorExplorerTree::onTouchDragged(const GuiEvent& event) +{ + // A press that landed in a column never becomes a reorder. Returning here + // also skips getHitIndex, which matters: its side effects are the drag state + // the drop indicator and reorderFromDrag read, and letting them update during + // a gutter press would leave the tree half-primed to move something. + if (mGutterPress) + { + return; + } + + Parent::onTouchDragged(event); +} + +void GuiEditorExplorerTree::onTouchUp(const GuiEvent& event) +{ + if (mGutterPress) + { + mGutterPress = false; + mDragActive = false; + // Swallowed rather than chained: GuiControl::onTouchUp hands an event the + // script did not consume to the parent, and the toggle already happened. + return; + } + + Parent::onTouchUp(event); +} + +//----------------------------------------------------------------------------- +// Tooltips +//----------------------------------------------------------------------------- + +const char* GuiEditorExplorerTree::tipForPoint(const Point2I& globalPoint) +{ + SimObject* obj = NULL; + const GutterColumn column = hitGutter(globalPoint, &obj); + if (column == GutterNone || !obj) + { + return NULL; + } + + StringTableEntry tip = (column == GutterHidden) + ? (obj->isHidden() ? mHiddenTip : mShownTip) + : (obj->isLocked() ? mLockedTip : mUnlockedTip); + + return (tip != StringTable->EmptyString) ? tip : NULL; +} + +bool GuiEditorExplorerTree::renderTooltip(Point2I& cursorPos, const char* tipText) +{ + // The canvas re-calls this every frame once the pointer has settled, and it + // decides whether to at all by comparing whole controls rather than points. + // So one control can serve different text per region, and the text follows + // the cursor from column to column without anything having to invalidate it. + if (tipText == NULL) + { + const char* gutterTip = tipForPoint(cursorPos); + if (gutterTip != NULL) + { + tipText = gutterTip; + } + } + + return Parent::renderTooltip(cursorPos, tipText); +} diff --git a/engine/source/gui/editor/guiEditorExplorerTree.h b/engine/source/gui/editor/guiEditorExplorerTree.h new file mode 100644 index 000000000..15a118aa3 --- /dev/null +++ b/engine/source/gui/editor/guiEditorExplorerTree.h @@ -0,0 +1,167 @@ +//----------------------------------------------------------------------------- +// 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_EDITOR_EXPLORERTREE_H_ +#define _GUI_EDITOR_EXPLORERTREE_H_ + +#ifndef _GUI_TREEVIEWCTRL_H +#include "gui/guiTreeViewCtrl.h" +#endif + +//----------------------------------------------------------------------------- +// The Gui Editor's Explorer tree: a tree with two columns of editor state down +// its left edge. +// +// hidden and locked are not properties of the Gui being authored. Neither is +// ever written to a file -- SimObject::_writeHidden and _writeLocked both refuse +// -- because saving them would put a working state into the document. They lived +// in the properties pane beside Visible, Active and useInput, which are the real +// thing, and being next to them read as a promise that they were too. +// +// So they moved here, as the eye and the padlock of a layers panel. The columns +// do NOT indent with the tree: they are a fixed rail at the left, in row with +// each item, which is what makes a whole branch's state scannable at a glance. +// +// 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 GuiEditorExplorerTree : public GuiTreeViewCtrl +{ +private: + typedef GuiTreeViewCtrl Parent; + +public: + enum GutterColumn + { + GutterNone = 0, + GutterHidden, + GutterLocked + }; + + // The box is the icon, drawn at its own size: 16px art in a 16px box is the + // only scale that is sharp. Fixed rather than derived from the row height, + // which moves with the theme's font size -- a rail that changes width when + // someone bumps the font has stopped being a rail. The last pixel of a + // column is its divider. + // + // 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 smBoxSize = 16; + static constexpr S32 smBoxPad = 2; + static constexpr S32 smColumnWidth = smBoxSize + (2 * smBoxPad) + 1; + + /// What the two columns cost a row, together. + static S32 getGutterWidth(); + + /// The two cells, given the row's left content edge and its vertical span. + /// Deliberately a pure function of three numbers: the renderer and the hit + /// test call it with the same numbers, so the two cannot drift apart. The + /// eye is leftmost, as it is in every layers panel anyone has used. + static void getGutterCells(S32 left, S32 top, S32 height, RectI& hiddenCell, RectI& lockedCell); + + /// Which column an x falls in, measured from the same left edge. The row was + /// already settled by y, so this is the whole hit test. + static GutterColumn columnAt(S32 x, S32 left); + + /// The square inside a cell that the icon draws in and the eye reads as a + /// box. Centered in what the divider leaves. + static RectI getBoxRect(const RectI& cell); + + GuiEditorExplorerTree(); + static void initPersistFields(); + + void onTouchDown(const GuiEvent& event); + void onTouchDragged(const GuiEvent& event); + void onTouchUp(const GuiEvent& event); + bool renderTooltip(Point2I& cursorPos, const char* tipText = NULL); + + /// The row a global point lands on, as a raw mItems index, or -1. + /// + /// getHitIndex would answer this, but it also recomputes the drag state as a + /// side effect -- mDragIndex, mReorderMethod, mIsDragLegal -- and the tooltip + /// asks this question every frame the pointer sits still. Asking must not arm + /// anything. + S32 visibleRowAt(const Point2I& globalPoint); + + /// The row's left content edge, in the control's own coordinates. + /// + /// Measured with NORMAL-state insets in both the paint and the hit test, so a + /// profile that pads a highlighted row differently cannot make the columns + /// jitter under the pointer or make a click miss by the width of the change. + S32 gutterInset(); + + /// The center of one column's box on one row, in global coordinates. For + /// tests, which must not hard-code a point: a stale coordinate reports a + /// missing item, which is exactly what a broken hit test reports, and the + /// test would be lying either way. + Point2I getGutterPoint(S32 index, GutterColumn column); + +protected: + void renderItemGutter(const RectI& itemRect, RectI& contentRect, TreeItem* treeItem, GuiControlState currentState); + /// One cell: its divider always, then its box if the row has one, then the + /// icon if the flag says so. The root row draws dividers and no box, so the + /// rail runs the whole height of the tree. + void renderGutterCell(const RectI& cell, const Point2I& cursorPt, bool showBox, bool showIcon, S32 frame); + + /// Which column a press landed in, and on what. GutterNone for the root row, + /// for an inactive row, and for anything with no object behind it. + GutterColumn hitGutter(const Point2I& globalPoint, SimObject** objOut); + + /// The tip for whatever the cursor is over, or NULL to leave the control's + /// own tooltip alone. + const char* tipForPoint(const Point2I& globalPoint); + + StringTableEntry mStateImageAssetID; + AssetPtr mStateImageAsset; + S32 mEyeFrame; + S32 mLockFrame; + + // Four strings rather than a format, and named for the state each describes + // rather than for an on and an off. The eye reads inverted -- it is present + // when the control is NOT hidden -- so "the tip for the on state" would have + // to be read twice by everyone who ever touched it. + // + // The text itself is script's: the "Locked - On" heading style belongs with + // the other toggles that use it, and nothing in the engine should be + // inventing user-facing prose. + StringTableEntry mShownTip; + StringTableEntry mHiddenTip; + StringTableEntry mLockedTip; + StringTableEntry mUnlockedTip; + + /// Latched by a press that landed in a column, so the drag and the release + /// that follow it know to do nothing. + bool mGutterPress; + + void setStateImageAsset(const char* pImageAssetID); + inline StringTableEntry getStateImageAsset(void) const { return mStateImageAssetID; } + static bool setStateImage(void* obj, const char* data) { static_cast(obj)->setStateImageAsset(data); return false; } + static const char* getStateImage(void* obj, const char* data) { return static_cast(obj)->getStateImageAsset(); } + +public: + DECLARE_CONOBJECT(GuiEditorExplorerTree); +}; + +#endif //_GUI_EDITOR_EXPLORERTREE_H_ diff --git a/engine/source/gui/editor/guiEditorExplorerTree_ScriptBinding.h b/engine/source/gui/editor/guiEditorExplorerTree_ScriptBinding.h new file mode 100644 index 000000000..73035f530 --- /dev/null +++ b/engine/source/gui/editor/guiEditorExplorerTree_ScriptBinding.h @@ -0,0 +1,79 @@ +//----------------------------------------------------------------------------- +// 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(GuiEditorExplorerTree, GuiTreeViewCtrl) + +/*! The width of one gutter column, in pixels. + @return The column width, dividers included. +*/ +ConsoleMethodWithDocs(GuiEditorExplorerTree, getGutterColumnWidth, ConsoleInt, 2, 2, ()) +{ + return GuiEditorExplorerTree::smColumnWidth; +} + +/*! Which gutter column a point in the tree's own coordinates falls in. + @param localX The x offset into the control. + @return "hidden", "locked", or "" for neither. +*/ +ConsoleMethodWithDocs(GuiEditorExplorerTree, gutterColumnAt, ConsoleString, 3, 3, "(S32 localX)") +{ + switch (GuiEditorExplorerTree::columnAt(dAtoi(argv[2]), object->gutterInset())) + { + case GuiEditorExplorerTree::GutterHidden: + return "hidden"; + case GuiEditorExplorerTree::GutterLocked: + return "locked"; + default: + return ""; + } +} + +/*! The center of one column's box on one row, in canvas coordinates. + Exists so a test can find its own target rather than carry a hard-coded + point: a coordinate that drifted off the box would report a missing item, + which is exactly what a broken hit test reports, and the test would be lying + either way. + @param index The zero-based raw item index. + @param column "hidden" or "locked". + @return The point, as "x y". +*/ +ConsoleMethodWithDocs(GuiEditorExplorerTree, getGutterPoint, ConsoleString, 4, 4, "(S32 index, string column)") +{ + S32 index = dAtoi(argv[2]); + if (index < 0 || index >= object->mItems.size()) + { + Con::warnf("GuiEditorExplorerTree::getGutterPoint() - Invalid index given."); + return "0 0"; + } + + GuiEditorExplorerTree::GutterColumn column = (dStricmp(argv[3], "locked") == 0) + ? GuiEditorExplorerTree::GutterLocked + : GuiEditorExplorerTree::GutterHidden; + + Point2I point = object->getGutterPoint(index, column); + + char* buffer = Con::getReturnBuffer(32); + dSprintf(buffer, 32, "%d %d", point.x, point.y); + return buffer; +} + +ConsoleMethodGroupEndWithDocs(GuiEditorExplorerTree) diff --git a/engine/source/gui/editor/guiMenuBarCtrl.cc b/engine/source/gui/editor/guiMenuBarCtrl.cc index 689dda487..792910907 100644 --- a/engine/source/gui/editor/guiMenuBarCtrl.cc +++ b/engine/source/gui/editor/guiMenuBarCtrl.cc @@ -29,6 +29,9 @@ #include "gui/editor/guiMenuBarCtrl.h" +// For the editor-only "+": the colour it borrows and the handle it calls back on. +#include "gui/editor/guiEditCtrl.h" + #include "gui/editor/guiMenuBarCtrl_ScriptBinding.h" #pragma region GuiMenuBarCtrl @@ -64,6 +67,16 @@ GuiMenuBarCtrl::GuiMenuBarCtrl() mHoverTarget = NULL; mOpenMenu = NULL; + // Empty until a layout pass in edit mode fills it in, and read by + // getAddItemGlobalRect - which script can ask about a bar that has never laid + // itself out at all. + mAddItemRect = RectI(0, 0, 0, 0); + + VECTOR_SET_ASSOCIATION(mEditRowRects); + mEditOpenMenu = NULL; + mEditBoxRect = RectI(0, 0, 0, 0); + mEditAddRowRect = RectI(0, 0, 0, 0); + mUseConstantHeightThumb = false; mScrollBarThickness = 12; mShowArrowButtons = false; @@ -113,8 +126,20 @@ void GuiMenuBarCtrl::onChildAdded(GuiControl *child) if (!menu) { Con::warnf("GuiMenuBarCtrl::onChildAdded - Only a GuiMenuItemCtrl can be added to a GuiMenuBarCtrl! Child will not be added."); - SimObject *simObj = reinterpret_cast(child); - removeObject(simObj); + + // Somewhere to put it, decided BEFORE taking it out of the bar. Removing + // it first and then finding nowhere for it - which is what this did - left + // the control registered with no group at all, so it was neither in the + // document nor deleted. A bar with no parent is the case that reaches it. + GuiControl *destination = getParent(); + if (destination == NULL) + { + Con::warnf("GuiMenuBarCtrl::onChildAdded - no parent to place it on; leaving it where it is"); + return; + } + + removeObject(child); + destination->addObject(child); return; } menu->mMenuBar = this; @@ -132,11 +157,53 @@ void GuiMenuBarCtrl::onChildAdded(GuiControl *child) void GuiMenuBarCtrl::onChildRemoved(SimObject *child) { + // Everything the bar still remembers about the departing item. None of this + // mattered while a menu bar was something you built once in script and never + // edited; deleting an item is an ordinary action now, and each of these is a + // pointer something dereferences later - onKeyDown reads mOpenMenu, + // setHoverTarget reads mHoverTarget, and the key walk follows the sibling + // links. + GuiMenuItemCtrl *item = dynamic_cast(child); + if (item != NULL) + { + if (item->mPrevItem != NULL) + { + item->mPrevItem->mNextItem = item->mNextItem; + } + if (item->mNextItem != NULL) + { + item->mNextItem->mPrevItem = item->mPrevItem; + } + item->mPrevItem = NULL; + item->mNextItem = NULL; + + if (mHoverTarget == item) + { + mHoverTarget = NULL; + } + if (mOpenMenu == item) + { + mOpenMenu = NULL; + } + if (mEditOpenMenu == item) + { + mEditOpenMenu = NULL; + mEditBoxRect.set(Point2I(0, 0), Point2I(0, 0)); + mEditRowRects.clear(); + mEditAddRowRect.set(Point2I(0, 0), Point2I(0, 0)); + } + } + calculateMenus(); } void GuiMenuBarCtrl::calculateMenus() { + // Ahead of everything else: a bar that leaves edit mode must not be left + // holding a "+" that is no longer drawn, or getAddItemRect reports a + // rectangle nothing will answer a click in. + mAddItemRect.set(Point2I(0, 0), Point2I(0, 0)); + RectI innerRect = getInnerRect(); iterator i; S32 length = 0; @@ -152,13 +219,488 @@ void GuiMenuBarCtrl::calculateMenus() length += ctrl->getExtent().x; } } + + // The "+" follows the last menu, square on the strip's height so it reads as + // an affordance rather than a menu with a one-character name. Nothing to wrap + // and nothing to run out of: the bar is a single row, and onRender keeps it + // as wide as the clip rect it is drawn into. + if (isEditMode()) + { + mAddItemRect.set(Point2I(length, 0), Point2I(innerRect.extent.y, innerRect.extent.y)); + } +} + +Point2I GuiMenuBarCtrl::getMenuLocalCoord(const Point2I &src) +{ + Point2I offset = Point2I(0, 0); + RectI contentRect = getInnerRect(offset); + + return src - contentRect.point; +} + +RectI GuiMenuBarCtrl::getAddItemGlobalRect() +{ + // isEditMode as well as the rectangle, because closing the editor does not + // re-run the layout: the bar would go on reporting the "+" it had until + // something else happened to resize it. + if (!mAddItemRect.isValidRect() || !isEditMode()) + { + return RectI(0, 0, 0, 0); + } + + Point2I offset = Point2I(0, 0); + RectI contentRect = getInnerRect(offset); + + return RectI(localToGlobalCoord(contentRect.point) + mAddItemRect.point, mAddItemRect.extent); +} + +GuiMenuItemCtrl* GuiMenuBarCtrl::findSelectedMenu() +{ + GuiEditCtrl* edit = GuiControl::smEditorHandle; + if (edit == NULL || !isEditMode()) + return NULL; + + // Walk up from each selected control. Whichever one reaches a direct child of + // this bar names the menu being worked in - so selecting a command keeps its + // menu open, and selecting the bar or anything else closes it. + SimSet& selected = edit->getSelectedSet(); + for (SimSet::iterator i = selected.begin(); i != selected.end(); i++) + { + GuiControl* ctrl = dynamic_cast(*i); + while (ctrl != NULL) + { + if (ctrl->getParent() == this) + { + return dynamic_cast(ctrl); + } + ctrl = ctrl->getParent(); + } + } + + return NULL; +} + +void GuiMenuBarCtrl::refreshEditMenu() +{ + GuiMenuItemCtrl* open = findSelectedMenu(); + if (open != mEditOpenMenu) + { + mEditOpenMenu = open; + setUpdate(); + } + + // Re-laid every time rather than only on a change, so renaming a command in + // the properties pane widens the box straight away. + layoutEditMenu(); +} + +void GuiMenuBarCtrl::onPreRender() +{ + refreshEditMenu(); + + Parent::onPreRender(); +} + +void GuiMenuBarCtrl::layoutEditMenu() +{ + mEditBoxRect.set(Point2I(0, 0), Point2I(0, 0)); + mEditRowRects.clear(); + mEditAddRowRect.set(Point2I(0, 0), Point2I(0, 0)); + + if (mEditOpenMenu == NULL || mMenuItemProfile == NULL || mMenuContentProfile == NULL) + return; + + GFont* font = mMenuItemProfile->getFont(mFontSizeAdjust); + if (font == NULL) + return; + + Point2I rowInner = Point2I(0, font->getHeight()); + const S32 rowHeight = getOuterExtent(rowInner, NormalState, mMenuItemProfile).y; + if (rowHeight <= 0) + return; + + // Wide enough for the longest label, plus the two gutters the marks live in - + // a bullet on the left, a submenu arrow on the right, each a row tall. Adding + // them rather than taking them out of the middle is what the runtime list + // does, and without it every label is truncated to make room for marks most + // of the rows do not even have. + S32 widest = 0; + for (iterator i = mEditOpenMenu->begin(); i != mEditOpenMenu->end(); i++) + { + GuiMenuItemCtrl* child = dynamic_cast(*i); + if (child == NULL) + continue; + + Point2I textInner = Point2I(font->getStrWidth((const UTF8*)child->getText()), 0); + widest = getMax(widest, getOuterExtent(textInner, NormalState, mMenuItemProfile).x); + } + widest += 2 * rowHeight; + + // And never narrower than the menu it hangs from, so it reads as belonging to + // that menu rather than floating under it. + widest = getMax(widest, mEditOpenMenu->getExtent().x); + + // A separator is the profile's chrome with no line of text in it, which is + // what makes it read as a rule between two groups rather than a blank row. + // + // In the SELECTED state, which is not a detail. Nothing else in a menu uses + // that state - hover is Highlight, greyed is Disabled - so a theme shapes its + // separators entirely through the SL fields of the three border profiles, and + // measuring in any other state prices the row from padding the separator does + // not use. GuiMenuListCtrl::updateSize measures the real thing the same way. + Point2I emptyInner = Point2I(0, 0); + const S32 spacerHeight = getOuterExtent(emptyInner, SelectedState, mMenuItemProfile).y; + + // One row per command plus the "+" row - which is why an empty menu still + // gets a box. A menu with no children has no GuiMenuListCtrl at all (it is + // built lazily when the first child arrives), so the runtime machinery could + // not draw this case even if it were open, and it is the case that matters: + // a menu the "+" just made has nothing in it yet. + S32 rowsHeight = rowHeight; + for (iterator i = mEditOpenMenu->begin(); i != mEditOpenMenu->end(); i++) + { + rowsHeight += editRowHeight(dynamic_cast(*i), rowHeight, spacerHeight); + } + + Point2I boxInner = Point2I(widest, rowsHeight); + Point2I boxOuter = getOuterExtent(boxInner, NormalState, mMenuContentProfile); + + Point2I boxPoint = Point2I(mEditOpenMenu->mBounds.point.x, + mEditOpenMenu->mBounds.point.y + mEditOpenMenu->mBounds.extent.y); + mEditBoxRect.set(boxPoint, boxOuter); + + // Rows start at the box's content, not its corner. + Point2I boxOrigin = boxPoint; + RectI boxContent = getInnerRect(boxOrigin, boxOuter, NormalState, mMenuContentProfile); + + // What localToGlobalCoord will add on the way up from a command: the strip's + // content offset, and the open menu's own contribution. Measured rather than + // assumed - the inset renderChild stamps on a menu is not reliably there when + // script asks, and being one border out is exactly the kind of wrong that + // looks like nothing at all until you see the outline beside the row. + Point2I barOrigin = Point2I(0, 0); + Point2I barContentInset = getInnerRect(barOrigin).point; + Point2I fromMenu = mEditOpenMenu->mBounds.point + mEditOpenMenu->mRenderInsetLT; + + S32 y = boxContent.point.y; + for (iterator i = mEditOpenMenu->begin(); i != mEditOpenMenu->end(); i++) + { + S32 h = editRowHeight(dynamic_cast(*i), rowHeight, spacerHeight); + RectI rowRect = RectI(boxContent.point.x, y, widest, h); + mEditRowRects.push_back(rowRect); + + // Give the command the rectangle it is being drawn in, so that everything + // which asks a control where it is gets the answer the user can see. + // + // It is never rendered as a child, so nothing else maintains its bounds: + // they are either the 64x64 a GuiControl is constructed with, or a + // screen-space rectangle the RUNTIME dropdown stamped on it the last time + // one was opened. The Gui Editor draws its selection from + // localToGlobalCoord, so either of those puts the outline somewhere with + // no visible relationship to the row that was clicked. + // + // localToGlobalCoord walks mBounds.point + mRenderInsetLT and then every + // ancestor's, so the bounds it needs are the row less everything the walk + // is going to add for it. This control contributes none of its own. + GuiMenuItemCtrl* command = dynamic_cast(*i); + if (command != NULL) + { + command->mRenderInsetLT = Point2I(0, 0); + command->mRenderInsetRB = Point2I(0, 0); + command->mBounds.set(rowRect.point + barContentInset - fromMenu, rowRect.extent); + } + + y += h; + } + + mEditAddRowRect.set(Point2I(boxContent.point.x, y), Point2I(widest, rowHeight)); +} + +S32 GuiMenuBarCtrl::editRowHeight(GuiMenuItemCtrl* item, S32 rowHeight, S32 spacerHeight) +{ + if (item != NULL && item->mDisplayType == GuiMenuItemCtrl::DisplayType::Spacer) + { + return spacerHeight; + } + return rowHeight; +} + +void GuiMenuBarCtrl::renderEditMenu(const Point2I &contentOffset) +{ + if (!mEditBoxRect.isValidRect()) + return; + + // The clip in force here is this control's PARENT's content rect, not the + // bar's own bounds (GuiControl::renderChild), so the box is free to hang + // below the strip and is clipped to the container the bar sits in - which is + // exactly as far as it should reach. + RectI box = mEditBoxRect; + box.point += contentOffset; + renderUniversalRect(box, mMenuContentProfile, NormalState); + + GuiCanvas* root = getRoot(); + Point2I cursor = (root != NULL) ? root->getCursorPos() : Point2I(-1, -1); + + S32 row = 0; + for (iterator i = mEditOpenMenu->begin(); i != mEditOpenMenu->end() && row < mEditRowRects.size(); i++, row++) + { + GuiMenuItemCtrl* child = dynamic_cast(*i); + if (child == NULL) + continue; + + RectI rowRect = mEditRowRects[row]; + rowRect.point += contentOffset; + + // A separator draws as the runtime draws one - the profile in its SELECTED + // state, no text and no marks - so a rule looks here like it will look in + // the game. + if (child->mDisplayType == GuiMenuItemCtrl::DisplayType::Spacer) + { + RectI spacerRect = applyMargins(rowRect.point, rowRect.extent, SelectedState, mMenuItemProfile); + if (spacerRect.isValidRect()) + { + renderUniversalRect(spacerRect, mMenuItemProfile, SelectedState); + } + continue; + } + + GuiControlState state = child->isActive() ? NormalState : DisabledState; + if (rowRect.pointInRect(cursor)) + state = HighlightState; + + // Margins first, as GuiMenuListCtrl::onRenderItem does. A theme that puts + // a margin on a menu item - which is how a separator is given the room + // that makes its two border lines read as a groove rather than as the + // edges of a band - would otherwise draw differently here than in the game. + RectI ctrlRect = applyMargins(rowRect.point, rowRect.extent, state, mMenuItemProfile); + if (!ctrlRect.isValidRect()) + continue; + + renderUniversalRect(ctrlRect, mMenuItemProfile, state); + + // The same marks GuiMenuListCtrl::onRenderItem draws, so what a kind + // looks like is what it will look like: a square bullet for a toggle, a + // round one for a radio item, lit when it starts on, and an arrow on the + // right for an item that opens a submenu of its own. + RectI leftIcon = RectI(rowRect.point, Point2I(rowRect.extent.y, rowRect.extent.y)); + if (child->mDisplayType == GuiMenuItemCtrl::DisplayType::Toggle || + child->mDisplayType == GuiMenuItemCtrl::DisplayType::Radio) + { + ColorI markColor = child->mIsOn ? mMenuItemProfile->getFillColor(HighlightState) + : mMenuItemProfile->getFillColor(NormalState); + S32 fontHeight = mMenuItemProfile->getFont(mFontSizeAdjust)->getHeight(); + renderColorBullet(leftIcon, markColor, getMin(fontHeight, 16), + child->mDisplayType == GuiMenuItemCtrl::DisplayType::Radio); + } + + if (child->mDisplayType == GuiMenuItemCtrl::DisplayType::Menu) + { + RectI rightIcon = RectI( + Point2I(rowRect.point.x + rowRect.extent.x - rowRect.extent.y, rowRect.point.y), + leftIcon.extent); + rightIcon.inset(2, 0); + ColorI arrowColor = ColorI(getFontColor(mMenuItemProfile, state)); + renderTriangleIcon(rightIcon, arrowColor, GuiDirection::Right, + mMenuItemProfile->getFont(mFontSizeAdjust)->getHeight() / 2); + } + + // Indented past the mark column, the way the runtime rows are, so a menu + // of mixed kinds still reads as one column of labels. + dglSetBitmapModulation(getFontColor(mMenuItemProfile, state)); + RectI textRect = RectI(rowRect.point.x + rowRect.extent.y, rowRect.point.y, + rowRect.extent.x - (2 * rowRect.extent.y), rowRect.extent.y); + renderText(textRect.point, textRect.extent, child->getText(), mMenuItemProfile); + } + + if (mEditAddRowRect.isValidRect()) + { + RectI addRow = mEditAddRowRect; + addRow.point += contentOffset; + renderAddItem(addRow); + } +} + +RectI GuiMenuBarCtrl::getAddSubItemGlobalRect() +{ + // Worked out on demand rather than read back from the last frame. Which menu + // is open follows the selection, and the selection changes without anything + // being drawn - script selects a menu and asks about it in the same breath. + refreshEditMenu(); + + if (!mEditAddRowRect.isValidRect() || !isEditMode()) + { + return RectI(0, 0, 0, 0); + } + + Point2I offset = Point2I(0, 0); + RectI contentRect = getInnerRect(offset); + + return RectI(localToGlobalCoord(contentRect.point) + mEditAddRowRect.point, mEditAddRowRect.extent); +} + +GuiMenuItemCtrl* GuiMenuBarCtrl::findMenuAt(const Point2I &menuLocalPt) +{ + // Deliberately not findHitMenu: that one refuses an inactive menu, and a + // greyed-out menu is exactly the kind you open the editor to fix. Only + // visibility is honoured, because calculateMenus does not place a hidden item + // and its rectangle is therefore whatever it was last time. + iterator i; + for (i = begin(); i != end(); i++) + { + GuiMenuItemCtrl *ctrl = dynamic_cast(*i); + if (ctrl != NULL && ctrl->isVisible() && ctrl->mBounds.pointInRect(menuLocalPt)) + { + return ctrl; + } + } + return NULL; +} + +void GuiMenuBarCtrl::renderAddItem(RectI itemRect) +{ + GuiEditCtrl* edit = GuiControl::smEditorHandle; + if (edit == NULL) + return; + + // Ghosted, so it never passes for a menu. Brighter under the cursor, so it + // reads as something to click rather than the empty tail of the bar - the + // same treatment the tab book's "+" tab gets. + ColorI fill = edit->getEditorColor(); + fill.alpha = 100; + + GuiCanvas* root = getRoot(); + if (root != NULL && itemRect.pointInRect(root->getCursorPos())) + fill.alpha = 200; + + dglDrawRectFill(itemRect, fill); + + dglSetBitmapModulation(getFontColor(edit->mProfile, NormalState)); + F32 tempAdjust = mFontSizeAdjust; + mFontSizeAdjust = 1.5f; + renderText(itemRect.point, itemRect.extent, "+", edit->mProfile); + mFontSizeAdjust = tempAdjust; +} + +void GuiMenuBarCtrl::requestNewMenuItem(GuiMenuItemCtrl *parent) +{ + // The GuiEditCtrl wears the GuiEditorBrain namespace, so this arrives at + // GuiEditorBrain::onAddMenuItem. A bar being edited by anything with no + // handler for it simply gets no item, which is the right way for this to + // fail. + GuiEditCtrl* edit = GuiControl::smEditorHandle; + if (edit != NULL && edit->isMethod("onAddMenuItem")) + { + Con::executef(edit, 3, "onAddMenuItem", getIdString(), + (parent != NULL) ? parent->getIdString() : ""); + } +} + +bool GuiMenuBarCtrl::pointInControl(const Point2I& parentCoordPoint) +{ + if (Parent::pointInControl(parentCoordPoint)) + return true; + + // The dropdown drawn while authoring hangs BELOW the strip, outside the bar's + // own bounds - and both findHitControl and the canvas walk bounds, so without + // this nobody ever offers the bar a click on its own "+" row. Empty outside + // edit mode, so this says nothing about a bar in a running game. + if (mEditBoxRect.isValidRect()) + { + Point2I origin = Point2I(0, 0); + RectI contentRect = getInnerRect(origin); + + RectI box = mEditBoxRect; + box.point += mBounds.point + contentRect.point; + + if (box.pointInRect(parentCoordPoint)) + return true; + } + + return false; +} + +bool GuiMenuBarCtrl::onMouseDownEditor(const GuiEvent &event, const Point2I& offset) +{ + Point2I menuLocalMouse = getMenuLocalCoord(globalToLocalCoord(event.mousePoint)); + GuiEditCtrl* edit = GuiControl::smEditorHandle; + + if (mAddItemRect.isValidRect() && mAddItemRect.pointInRect(menuLocalMouse)) + { + requestNewMenuItem(NULL); + + // Nothing else happens on this click. Selection follows the item the + // editor is about to make. + return true; + } + + // The open dropdown, which is drawn over whatever is beneath it and so gets + // asked before it. + if (mEditAddRowRect.isValidRect() && mEditAddRowRect.pointInRect(menuLocalMouse)) + { + requestNewMenuItem(mEditOpenMenu); + return true; + } + + if (mEditOpenMenu != NULL && edit != NULL) + { + for (S32 row = 0; row < mEditRowRects.size(); row++) + { + if (mEditRowRects[row].pointInRect(menuLocalMouse) && row < mEditOpenMenu->size()) + { + edit->select(dynamic_cast((*mEditOpenMenu)[row])); + return true; + } + } + } + + // The bar is opaque to findHitControl, so nothing else is going to offer the + // menu itself as the thing that was clicked. + GuiMenuItemCtrl *item = findMenuAt(menuLocalMouse); + if (item != NULL && edit != NULL) + { + edit->select(item); + return true; + } + + return Parent::onMouseDownEditor(event, offset); } -GuiControl* GuiMenuBarCtrl::findHitControl(const Point2I &pt, S32 initialLayer) +// The bar is opaque to hit testing: it places its items itself and answers for +// them by hand, through findHitMenu. +// +// This used to take two parameters where GuiControl's takes four, so it hid the +// base rather than overriding it and never ran - every caller reaches this +// through a GuiControl*. The base then descended into the items, and on into +// THEIR children, whose mBounds are either the default 64x64 or a canvas-global +// rectangle GuiMenuListCtrl stamped on them the last time a dropdown was drawn. +// Clicking a menu on an authored bar therefore handed the Gui Editor the last +// command inside it. +GuiControl* GuiMenuBarCtrl::findHitControl(const Point2I &pt, S32 initialLayer, const bool ignoreUseInput, const bool ignoreEditSelected) { return this; } +void GuiMenuBarCtrl::setUpdate() +{ + Parent::setUpdate(); + + // A top-level menu is exactly as wide as its caption, so the strip's layout + // is a function of the text and has to be redone whenever the text might + // have moved. The properties pane writes a caption on every keystroke, and + // this is what makes the bar reflow under the cursor as it is typed. + calculateMenus(); +} + +void GuiMenuBarCtrl::childrenReordered() +{ + // The strip is packed left to right in child order, and nothing else re-runs + // it on a reorder - so dragging an item in the Gui Editor's tree would leave + // every item wearing the rectangle of whichever item used to be there. + calculateMenus(); + + Parent::childrenReordered(); +} + GuiMenuItemCtrl* GuiMenuBarCtrl::findHitMenu(const Point2I &pt) { iterator i; @@ -259,6 +801,20 @@ void GuiMenuBarCtrl::onRender(Point2I offset, const RectI &updateRect) { //Render the childen renderChildControls(offset, contentRect, updateRect); + + // After the menus, so it always reads as the end of the strip. Empty + // unless calculateMenus found itself in edit mode, which is the whole + // test - and on a bar with no menus at all it is the only way to get one. + if (mAddItemRect.isValidRect()) + { + RectI addBounds = mAddItemRect; + addBounds.point += contentRect.point; + renderAddItem(addBounds); + } + + // Last, so the open menu's box covers the strip's own "+" if the two ever + // overlap. + renderEditMenu(contentRect.point); } } @@ -617,6 +1173,10 @@ GuiMenuItemCtrl::GuiMenuItemCtrl() mNextItem = NULL; mOpenSubMenu = NULL; + // Only ever assigned once this item is inside a bar, or inside an item that + // is - and onChildAdded reads it before either has necessarily happened. + mMenuBar = NULL; + mScroll = NULL; mList = NULL; } @@ -634,7 +1194,11 @@ void GuiMenuItemCtrl::initPersistFields() addField("Visible", TypeBool, Offset(mVisible, GuiMenuItemCtrl)); addProtectedField("IsOn", TypeBool, Offset(mIsOn, GuiMenuItemCtrl), &defaultProtectedSetFn, &defaultProtectedGetFn, &writeIsOn, ""); addProtectedField("Toggle", TypeBool, Offset(mToggle, GuiMenuItemCtrl), &setToggle, &defaultProtectedGetFn, &writeToggle, ""); - addProtectedField("Radio", TypeS32, Offset(mRadio, GuiMenuItemCtrl), &setRadio, &defaultProtectedGetFn, &writeRadio, ""); + // TypeBool, not TypeS32: mRadio is a bool, and setRadio has always written it + // with dAtob. Declared as an S32 the default getter read four bytes off a + // one-byte member and answered with whatever was next to it, so a plain + // command could read back as a radio item. + addProtectedField("Radio", TypeBool, Offset(mRadio, GuiMenuItemCtrl), &setRadio, &defaultProtectedGetFn, &writeRadio, ""); endGroup("MenuItem"); addGroup("Localization"); @@ -684,16 +1248,36 @@ void GuiMenuItemCtrl::onChildAdded(GuiControl *child) if (!subMenu) { Con::warnf("GuiMenuItemCtrl::onChildAdded - Only a GuiMenuItemCtrl can be added to a GuiMenuItemCtrl! Child will not be added."); - SimObject *simObj = reinterpret_cast(child); - removeObject(simObj); + + // As in GuiMenuBarCtrl::onChildAdded above: find the destination first, so + // a refusal cannot orphan the control it refused. + GuiControl *destination = getParent(); + if (destination == NULL) + { + Con::warnf("GuiMenuItemCtrl::onChildAdded - no parent to place it on; leaving it where it is"); + return; + } + + removeObject(child); + destination->addObject(child); return; } subMenu->mMenuBar = this->mMenuBar; - subMenu->setControlProfile(mMenuBar->mMenuItemProfile); + + // mMenuBar is NULL until this item is itself inside a bar, and an item built + // in script can be given children before it is added to one. + if (mMenuBar != NULL) + { + subMenu->setControlProfile(mMenuBar->mMenuItemProfile); + } if (dStrcmp(subMenu->getText(), "-") == 0) { + // The text is left as "-" rather than cleared. It is the only record that + // this is a separator - there is no field for it - so clearing it meant a + // separator did not survive being saved and read back: the reloaded item + // had no dash left to recognise it by. Keeping it also lets the properties + // pane show what it is, and lets someone type their way back out of it. subMenu->mDisplayType = Spacer; - subMenu->setText(StringTable->EmptyString); } else if (subMenu->mToggle) { @@ -740,21 +1324,112 @@ void GuiMenuItemCtrl::onChildAdded(GuiControl *child) void GuiMenuItemCtrl::onChildRemoved(SimObject *child) { + // The same repair GuiMenuBarCtrl::onChildRemoved makes, for a submenu's own + // children: the sibling links the keyboard walk follows, and the two pointers + // that would otherwise name a control this item no longer holds. + GuiMenuItemCtrl *item = dynamic_cast(child); + if (item != NULL) + { + if (item->mPrevItem != NULL) + { + item->mPrevItem->mNextItem = item->mNextItem; + } + if (item->mNextItem != NULL) + { + item->mNextItem->mPrevItem = item->mPrevItem; + } + item->mPrevItem = NULL; + item->mNextItem = NULL; + + if (mOpenSubMenu == item) + { + mOpenSubMenu = NULL; + } + if (mList != NULL && mList->mHoveredItem == item) + { + mList->mHoveredItem = NULL; + } + } + if (size() <= 0) { if (mDisplayType == Menu) { + // Back to a plain command, which is what an item that never had + // children is - so an emptied menu draws the same as a fresh one. mDisplayType = TextCommand; - mList->deleteObject(); - mList = NULL; - mScroll->deleteObject(); - mScroll = NULL; + + // The scroller and the list are KEPT, not freed. They are built with a + // bare new and never registered - as is every hidden helper control in + // this file - so deleteObject asserts on them ("Object not + // registered"), and registering them instead moves them into the + // canvas's ownership and hangs the engine on the way down if a menu is + // open when it quits. onChildAdded above reuses them if a child ever + // comes back, which in the Gui Editor it routinely does. + // + // Nothing reached any of this until a menu could be emptied one + // command at a time while authoring. + if (mIsOpen && mMenuBar != NULL) + { + closeMenu(); + } } } checkForGoodChildren(); } +// A menu item is a row in a bar or a row in a menu and nothing else: it exposes +// none of GuiControl's geometry (initPersistFields calls SimObject's), its +// rectangle is dictated by whatever is laying it out, and anywhere else it is a +// control nothing will ever draw a row for. So it refuses every other parent and +// the Gui Editor leaves it where it is. +// +// Two legal kinds of parent rather than the one a tab page has - moving a +// command from one menu to another, or a menu from one bar to another, are both +// ordinary things to want. +bool GuiMenuItemCtrl::canBeChildOf(GuiControl* parent) +{ + return dynamic_cast(parent) != NULL || + dynamic_cast(parent) != NULL; +} + +void GuiMenuItemCtrl::setText(const char *txt) +{ + Parent::setText(txt); + + // A single dash is how the documentation says you write a separator, and the + // text is the only record of one. Derived here rather than only when the item + // is added, so typing a dash into the properties pane turns the row into a + // separator as it is typed - and typing over the dash turns it back. + // + // Only inside a menu: a separator on the bar itself would be a gap in the + // strip with nothing either side of it to separate. And never over a Menu, + // which is what having children makes an item. + if (mDisplayType != Menu && dynamic_cast(getParent()) != NULL) + { + if (dStrcmp(getText(), "-") == 0) + { + mDisplayType = Spacer; + } + else if (mDisplayType == Spacer) + { + mDisplayType = mToggle ? Toggle : (mRadio ? Radio : TextCommand); + } + } + + // Whoever is laying this item out sized it from the text that just changed. + // For a top-level menu that is the bar, whose setUpdate re-runs + // calculateMenus; for a command inside a menu the parent is another item, + // whose base setUpdate does nothing - and it does not need to, because the + // authored dropdown is re-measured every frame in refreshEditMenu. + GuiControl *parent = getParent(); + if (parent != NULL) + { + parent->setUpdate(); + } +} + void GuiMenuItemCtrl::checkForGoodChildren() { if (mDisplayType != Menu) @@ -909,7 +1584,7 @@ bool GuiMenuItemCtrl::onKeyDown(const GuiEvent &event) { if (mOpenSubMenu != NULL && (mOpenSubMenu->mList->mHoveredItem != NULL || mOpenSubMenu->mOpenSubMenu != NULL)) { - mOpenSubMenu->onKeyDown(event); + return mOpenSubMenu->onKeyDown(event); } else if (event.keyCode == KEY_DOWN && event.modifier == 0) { @@ -1019,6 +1694,12 @@ bool GuiMenuItemCtrl::onKeyDown(const GuiEvent &event) } return true; } + + // Any other key belongs to whoever asked next - matching GuiMenuBarCtrl's own + // onKeyDown. Falling off the end here returned whatever happened to be in the + // return register, for every ordinary letter and for any of the five keys + // above arriving with a modifier. + return false; } void GuiMenuItemCtrl::setMenuActive(StringTableEntry name, bool isActive) diff --git a/engine/source/gui/editor/guiMenuBarCtrl.h b/engine/source/gui/editor/guiMenuBarCtrl.h index d4aec4ec5..a88d99bf1 100644 --- a/engine/source/gui/editor/guiMenuBarCtrl.h +++ b/engine/source/gui/editor/guiMenuBarCtrl.h @@ -56,11 +56,94 @@ class GuiMenuBarCtrl : public GuiControl virtual void inspectPostApply(); virtual void onChildAdded(GuiControl *child); virtual void onChildRemoved(SimObject *child); + virtual void childrenReordered(); + /// Re-runs calculateMenus, because a top-level menu is as wide as its text + /// and so anything that changes the text changes the layout. + virtual void setUpdate(); virtual void calculateMenus(); - virtual GuiControl* findHitControl(const Point2I &pt, S32 initialLayer); + /// GuiControl's signature, so this overrides rather than hides it. The bar + /// answers for its own items; nothing outside may descend into them. + virtual GuiControl* findHitControl(const Point2I &pt, S32 initialLayer = -1, const bool ignoreUseInput = false, const bool ignoreEditSelected = true); virtual GuiMenuItemCtrl* findHitMenu(const Point2I &pt); virtual void onRender(Point2I offset, const RectI &updateRect); + /// @name Authoring + /// + /// A GuiMenuItemCtrl is not something the control palette offers - it means + /// nothing outside a bar - so the bar makes its own, from a "+" it draws after + /// the last menu while the Gui is being authored. + /// @{ + + /// The editor-only "+", in the same coordinates as an item's mBounds: local to + /// the bar's CONTENT, which is the space calculateMenus lays items out in. + /// Empty whenever the bar is not being authored, which is how everything + /// tests for it. + RectI mAddItemRect; + + /// The menu whose dropdown is showing while authoring, and the rectangles of + /// what is in it - one row per command, then the "+" row, inside a box. All + /// in the same content-local space as mAddItemRect. + /// + /// Drawn by the bar rather than opened for real: the runtime dropdown is a + /// full-canvas dialog pushed at layer 99, and a dialog at that layer takes + /// every click, so the Gui Editor would never see one. Drawing it here costs + /// nothing at runtime and leaves the editor holding the mouse. + GuiMenuItemCtrl *mEditOpenMenu; + RectI mEditBoxRect; + Vector mEditRowRects; + RectI mEditAddRowRect; + + /// The menu the selection is in - that item, or anything inside it. Derived + /// rather than toggled, so the dropdown follows the Explorer tree as readily + /// as the canvas and cannot disagree with what is selected. + GuiMenuItemCtrl* findSelectedMenu(); + /// Re-derive which menu is open and re-lay its rows. Called every frame from + /// onPreRender, and on demand by the geometry accessors - the selection can + /// change without anything drawing. + void refreshEditMenu(); + void layoutEditMenu(); + /// A separator is a rule between two groups, so it gets the profile's chrome + /// and none of the height a line of text would need. + S32 editRowHeight(GuiMenuItemCtrl* item, S32 rowHeight, S32 spacerHeight); + void renderEditMenu(const Point2I &contentOffset); + + /// The dropdown's "+" row in global coordinates, or an empty rect. + RectI getAddSubItemGlobalRect(); + + virtual void onPreRender(); + + /// Control-local to content-local. findHitMenu reaches the same space through + /// a child's mRenderInsetLT, which only says anything once the bar has drawn + /// and only if there is a child to ask. + Point2I getMenuLocalCoord(const Point2I &src); + + /// The "+" in global coordinates, or an empty rect when there is none. + RectI getAddItemGlobalRect(); + + /// The menu under a content-local point, drawn or not, active or not - which + /// is what authoring needs and findHitMenu deliberately does not give. + GuiMenuItemCtrl* findMenuAt(const Point2I &menuLocalPt); + + /// Draw the "+". Ghosted rather than drawn as a menu, because it is an + /// affordance and not an item. + void renderAddItem(RectI itemRect); + + /// Ask the Gui Editor for an item. The bar draws the affordance; it does not + /// make the item, because an item made while authoring has to be themed, + /// recorded for undo and announced to the Explorer tree. + /// @param parent The menu to put it in, or NULL for a top-level one. + void requestNewMenuItem(GuiMenuItemCtrl *parent); + + virtual bool onMouseDownEditor(const GuiEvent &event, const Point2I& offset); + + /// The bar's own bounds, plus the dropdown it draws below them while + /// authoring. Hit testing walks bounds, and the dropdown hangs outside the + /// bar's - so without this the click never reaches the bar at all and the + /// "+" row is unreachable. + virtual bool pointInControl(const Point2I& parentCoordPoint); + + /// @} + virtual void processHover(const GuiEvent &event); virtual void setHoverTarget(GuiMenuItemCtrl *ctrl); virtual void onTouchMove(const GuiEvent &event); @@ -163,6 +246,19 @@ class GuiMenuItemCtrl : public GuiControl virtual void inspectPostApply(); virtual void onChildAdded(GuiControl *child); virtual void onChildRemoved(SimObject *child); + + /// A menu item only means anything inside a bar or inside another item, so it + /// refuses every other parent. See GuiControl::canBeChildOf. + bool canBeChildOf(GuiControl* parent); + + /// Its bar decides where it sits and how wide it is, from the text. See + /// GuiControl::isGeometryEditable. + bool isGeometryEditable() { return false; }; + + /// Tell whoever is laying this out that its text - and so its width - + /// changed. The properties pane writes the text on every keystroke, so this + /// is what makes the bar reflow as you type. + virtual void setText(const char *txt = NULL); void checkForGoodChildren(); virtual void closeMenu(); void ApplyMenuSettings(); diff --git a/engine/source/gui/editor/guiMenuBarCtrl_ScriptBinding.h b/engine/source/gui/editor/guiMenuBarCtrl_ScriptBinding.h index d5cc77392..5fe70d41e 100644 --- a/engine/source/gui/editor/guiMenuBarCtrl_ScriptBinding.h +++ b/engine/source/gui/editor/guiMenuBarCtrl_ScriptBinding.h @@ -122,4 +122,41 @@ ConsoleMethodWithDocs(GuiMenuBarCtrl, setMenuActive, ConsoleVoid, 4, 4, ("string object->setMenuActive(argv[2], dAtob(argv[3])); } +/*! Returns the bounds of the editor-only "+" as "x y width height", in global + coordinates. + + Empty - "0 0 0 0" - unless the bar is inside the Gui being authored, which is + the only time the "+" is drawn. A bar with no menus at all still reports one; + that is what stops an emptied bar from being unrecoverable, now that the + control palette does not offer a GuiMenuItemCtrl. + @return The rectangle the "+" occupies on screen. +*/ +ConsoleMethodWithDocs(GuiMenuBarCtrl, getAddItemRect, ConsoleString, 2, 2, ()) +{ + RectI rect = object->getAddItemGlobalRect(); + + char* buffer = Con::getReturnBuffer(64); + dSprintf(buffer, 64, "%d %d %d %d", rect.point.x, rect.point.y, rect.extent.x, rect.extent.y); + + return buffer; +} + +/*! Returns the bounds of the "+" row at the foot of the open dropdown, as + "x y width height" in global coordinates. + + Empty unless the bar is being authored AND one of its menus is selected - + that is what decides which dropdown is showing. A menu with no commands in it + at all still reports one; that row is the only way a command ever gets made. + @return The rectangle the dropdown's "+" occupies on screen. +*/ +ConsoleMethodWithDocs(GuiMenuBarCtrl, getAddSubItemRect, ConsoleString, 2, 2, ()) +{ + RectI rect = object->getAddSubItemGlobalRect(); + + char* buffer = Con::getReturnBuffer(64); + dSprintf(buffer, 64, "%d %d %d %d", rect.point.x, rect.point.y, rect.extent.x, rect.extent.y); + + return buffer; +} + ConsoleMethodGroupEndWithDocs(GuiMenuBarCtrl) \ No newline at end of file diff --git a/engine/source/gui/guiControl.cc b/engine/source/gui/guiControl.cc index dab4a98ba..e521e13b0 100755 --- a/engine/source/gui/guiControl.cc +++ b/engine/source/gui/guiControl.cc @@ -53,23 +53,32 @@ IMPLEMENT_CONOBJECT_CHILDREN(GuiControl); +/// Counts are 4, not 3: "default" is a real, reachable value. +/// +/// It used to be excluded, and that was a bug rather than a choice. A control +/// starts on DefaultAlign, which getAlignmentType resolves to the PROFILE's +/// alignment -- so it is the setting that means "inherit", and every control +/// has it until someone picks otherwise. Hiding it from the table meant +/// ConsoleGetType could not name the value it found and answered with an empty +/// string, so an untouched control's align read back as nothing, and there was +/// no way to put a control back to inheriting once you had chosen a side. static EnumTable::Enums alignCtrlEnums[] = { { AlignmentType::LeftAlign, "left" }, { AlignmentType::CenterAlign, "center" }, { AlignmentType::RightAlign, "right" }, - { AlignmentType::DefaultAlign, "default" } + { AlignmentType::DefaultAlign, "default" } ///< Inherit the profile's alignment. }; -static EnumTable gAlignCtrlTable(3, &alignCtrlEnums[0]); +static EnumTable gAlignCtrlTable(4, &alignCtrlEnums[0]); static EnumTable::Enums vAlignCtrlEnums[] = { { VertAlignmentType::TopVAlign, "top" }, { VertAlignmentType::MiddleVAlign, "middle" }, { VertAlignmentType::BottomVAlign, "bottom" }, - { VertAlignmentType::DefaultVAlign, "default" } + { VertAlignmentType::DefaultVAlign, "default" } ///< Inherit the profile's alignment. }; -static EnumTable gVAlignCtrlTable(3, &vAlignCtrlEnums[0]); +static EnumTable gVAlignCtrlTable(4, &vAlignCtrlEnums[0]); //used to locate the next/prev responder when tab is pressed S32 GuiControl::smCursorChanged = -1; @@ -148,6 +157,22 @@ bool GuiControl::onAdd() mBounds.extent.x = getMax( mMinExtent.x, mBounds.extent.x ); mBounds.extent.y = getMax( mMinExtent.y, mBounds.extent.y ); + // Nothing below this class may be left without a profile. Most constructors + // setField one themselves, but several never did -- GuiChainCtrl, + // GuiTabPageCtrl, GuiSliderCtrl, GuiTextEditCtrl, GuiInputCtrl, + // GuiSpriteCtrl and SceneWindow among them -- and a control with a null + // mProfile is a crash waiting for the first thing that reads it. A chain + // does not even need to be rendered: adding a child runs calculateExtent, + // which asks the profile for its borders. + // + // Doing it here rather than in each constructor is the point: it is one + // place, it covers every class that already exists, and a class added later + // cannot forget. GuiDefaultProfile is created during engine start-up + // (defaultGame.cc) and is the same fallback the TypeGuiProfile setter uses, + // so this is the behaviour every named profile already had on a miss. + if( mProfile == NULL ) + setField( "profile", "GuiDefaultProfile" ); + // Add to root group. Sim::getGuiGroup()->addObject(this); @@ -179,27 +204,63 @@ void GuiControl::onChildRemoved(GuiControl* child) } } +/// The sizing names, and why there are two sets of them. +/// +/// The original names describe the edge that MOVES; the field controls the edge +/// that STAYS. So "right" pins the LEFT edge (parentResized has no branch for +/// it, so nothing moves) and "left" pins the RIGHT one (newPosition.x += delta). +/// Reading a Gui file meant inverting every one of them in your head, and +/// picking one from a list was a memory test. +/// +/// The anchor names say what actually happens. Ordering is load-bearing in two +/// directions: +/// +/// ConsoleGetType returns the FIRST label whose value matches, so whichever +/// name is listed first is what a field reads back as and what TAML writes. +/// The preferred names are therefore at the top. +/// +/// ConsoleSetType accepts ANY label in the table, case-insensitively, so the +/// deprecated names below still load. Every .gui.taml already on disk, and +/// every script that spells a sizing flag the old way, keeps working. +/// +/// Note that a Gui saved by this build writes the new names, which an older +/// build cannot read -- its table has no "anchorLeft", and ConsoleSetType +/// silently falls back to index 0 on a miss. +/// +/// "width"/"height" (both edges pinned) and "center"/"fill" were never +/// misleading and keep their names. "relative" gains "scale", which is what it +/// does. static EnumTable::Enums horzEnums[] = { - { GuiControl::horizResizeRight, "right" }, - { GuiControl::horizResizeWidth, "width" }, - { GuiControl::horizResizeLeft, "left" }, - { GuiControl::horizResizeCenter, "center" }, - { GuiControl::horizResizeRelative, "relative" }, - { GuiControl::horizResizeFill, "fill" } + { GuiControl::horizResizeRight, "anchorLeft" }, ///< Left edge stays put. + { GuiControl::horizResizeLeft, "anchorRight" }, ///< Right edge stays put. + { GuiControl::horizResizeWidth, "width" }, ///< Both edges stay; the width follows the parent. + { GuiControl::horizResizeCenter, "center" }, ///< Neither edge; stays centred. + { GuiControl::horizResizeRelative, "scale" }, ///< Both edges scale with the parent. + { GuiControl::horizResizeFill, "fill" }, ///< Fills the parent's inner rect. + + // Deprecated. Accepted for reading; never written. + { GuiControl::horizResizeRight, "right" }, ///< \deprecated Use anchorLeft. + { GuiControl::horizResizeLeft, "left" }, ///< \deprecated Use anchorRight. + { GuiControl::horizResizeRelative, "relative" } ///< \deprecated Use scale. }; -static EnumTable gHorizSizingTable(6, &horzEnums[0]); +static EnumTable gHorizSizingTable(9, &horzEnums[0]); static EnumTable::Enums vertEnums[] = { - { GuiControl::vertResizeBottom, "bottom" }, - { GuiControl::vertResizeHeight, "height" }, - { GuiControl::vertResizeTop, "top" }, - { GuiControl::vertResizeCenter, "center" }, - { GuiControl::vertResizeRelative, "relative" }, - { GuiControl::vertResizeFill, "fill" } + { GuiControl::vertResizeBottom, "anchorTop" }, ///< Top edge stays put. + { GuiControl::vertResizeTop, "anchorBottom" }, ///< Bottom edge stays put. + { GuiControl::vertResizeHeight, "height" }, ///< Both edges stay; the height follows the parent. + { GuiControl::vertResizeCenter, "center" }, ///< Neither edge; stays centred. + { GuiControl::vertResizeRelative, "scale" }, ///< Both edges scale with the parent. + { GuiControl::vertResizeFill, "fill" }, ///< Fills the parent's inner rect. + + // Deprecated. Accepted for reading; never written. + { GuiControl::vertResizeBottom, "bottom" }, ///< \deprecated Use anchorTop. + { GuiControl::vertResizeTop, "top" }, ///< \deprecated Use anchorBottom. + { GuiControl::vertResizeRelative, "relative" } ///< \deprecated Use scale. }; -static EnumTable gVertSizingTable(6, &vertEnums[0]); +static EnumTable gVertSizingTable(9, &vertEnums[0]); void GuiControl::initPersistFields() { @@ -306,6 +367,30 @@ void GuiControl::addObject(SimObject *object) if(mAwake) ctrl->awaken(); + // Two pieces of cached sizing state describe the parent the control has just + // left, and neither means anything here. Both are cleared before + // onChildAdded, which is the first thing to read them. + // + // mStoredRelativePos is the proportion of its parent a SCALED control + // occupies, cached so that a run of layout passes cannot round its edges away + // a pixel at a time. Writing a position is the moment it stops describing the + // control, which is why the Position and Extent field setters clear it -- and + // changing parent is that moment too. Left alone, onChildAdded applied the OLD + // parent's proportion to the NEW parent's extent: a button 200 wide at x=100 + // in an 800-wide container arrived 50 wide at x=25 in a 200-wide one. Reset, + // the proportion is recaptured against the parent it has now, old and new + // extents are the same value in that call, and the arithmetic is the identity + // -- so a move keeps the size it moved, which is what every other sizing mode + // already did. + // + // mStoredExtent is extent given up to minExtent and owed back when there is + // room again. A debt run up under one parent is not the next one's to pay. + // + // Note this cannot fire on a re-add: addObject returns above when the object + // is already a child of this control. + ctrl->resetStoredRelPos(); + ctrl->resetStoredExtent(); + onChildAdded( ctrl ); } @@ -606,24 +691,87 @@ void GuiControl::parentResized(const Point2I &oldParentExtent, const Point2I &ne resize(newPosition, newExtent); } +// One axis of the rescue. Nothing of the control is visible when its far edge is +// at or before the parent's near edge, or its near edge is at or past the +// parent's far edge -- and a parent with no room at all shows nothing wherever +// the control is put, which the second test catches on its own (pos >= 0). +static S32 rescuedAxis(S32 pos, S32 extent, S32 parentInnerExtent) +{ + const bool offTheNearSide = (pos + extent) <= 0; + const bool offTheFarSide = pos >= parentInnerExtent; + + return (offTheNearSide || offTheFarSide) ? 0 : pos; +} + +Point2I GuiControl::rescuedPosition(const Point2I &pos, const Point2I &extent, const Point2I &parentInnerExtent) +{ + return Point2I(rescuedAxis(pos.x, extent.x, parentInnerExtent.x), + rescuedAxis(pos.y, extent.y, parentInnerExtent.y)); +} + +bool GuiControl::pullIntoView() +{ + GuiControl* parent = getParent(); + if (parent == NULL) + { + return false; + } + + const Point2I rescued = rescuedPosition(mBounds.point, mBounds.extent, parent->getInnerRect().extent); + if (rescued == mBounds.point) + { + return false; + } + + // Through resize rather than a direct write to mBounds, so a container that + // places its own children hears about it -- and then reset the cached + // proportion, because a scaled control that has just been moved must measure + // from where it landed. resize() does not do that for its caller: it is what + // parentResized itself calls, and clearing the cache there would defeat it. + resize(rescued, mBounds.extent); + resetStoredRelPos(); + + return true; +} + void GuiControl::preventResizeModeFill() +{ + preventHorizResizeModeFill(); + preventVertResizeModeFill(); +} + +void GuiControl::preventResizeModeCenter() +{ + preventHorizResizeModeCenter(); + preventVertResizeModeCenter(); +} + +void GuiControl::preventHorizResizeModeFill() { if (getHorizSizing() == horizResizeFill) { setHorizSizing(horizResizeRight); } +} + +void GuiControl::preventVertResizeModeFill() +{ if (getVertSizing() == vertResizeFill) { setVertSizing(vertResizeBottom); } } -void GuiControl::preventResizeModeCenter() +void GuiControl::preventHorizResizeModeCenter() { if (getHorizSizing() == horizResizeCenter) { setHorizSizing(horizResizeRight); } +} + +void GuiControl::preventVertResizeModeCenter() +{ if (getVertSizing() == vertResizeCenter) { setVertSizing(vertResizeBottom); @@ -849,6 +997,90 @@ GuiControlProfile* GuiControl::resolveDefaultTooltipProfile() return dynamic_cast(Sim::findObject("GuiDefaultProfile")); } +// Wrap one paragraph of tooltip text to maxWidth, appending to lines and +// returning the new line count. This is the word wrapping renderTooltip has +// always done, lifted out so it can run once per paragraph -- a tooltip splits +// on line breaks first now, which is what lets one carry a heading and an +// explanation of what it means on separate lines. +// +// Not GuiControl::getLineList: that wraps only when the CONTROL wraps, and a +// tooltip has to wrap whatever the control it belongs to does. +static S32 wrapTooltipParagraph(const char* paragraph, GFont* font, const S32 maxWidth, + const S32 spaceWidth, FrameTemp& lines, S32 lineCount, + const S32 maxLines, S32& widestLine) +{ + const S32 wordCount = StringUnit::getUnitCount( paragraph, " " ); + S32 lineWidth = 0; + S32 wordStartIndex = 0; + S32 wordEndIndex = 0; + + while( true ) + { + // Do we have any words left? + if ( wordEndIndex < wordCount ) + { + // Yes, so fetch the word. + const char* pWord = StringUnit::getUnit( paragraph, wordEndIndex, " " ); + + // Add word length. + const S32 wordLength = (S32)font->getStrWidth( pWord ) + spaceWidth; + + // Do we still have room? + if ( (lineWidth + wordLength) < maxWidth ) + { + // Yes, so add word length. + lineWidth += wordLength; + + // Next word. + wordEndIndex++; + + continue; + } + + // Do we have any lines left? + if ( lineCount < maxLines ) + { + // Yes, so insert line. + lines[lineCount++] = StringUnit::getUnits( paragraph, wordStartIndex, wordEndIndex-1, " " ); + + // Update horizontal text bounds. + if ( lineWidth > widestLine ) + widestLine = lineWidth; + } + + // Set new line length. + lineWidth = wordLength; + + // Set word start. + wordStartIndex = wordEndIndex; + + // Next word. + wordEndIndex++; + + continue; + } + + // Do we have any words left? + if ( wordStartIndex < wordCount ) + { + // Yes, so do we have any lines left? + if ( lineCount < maxLines ) + { + // Yes, so insert line. + lines[lineCount++] = StringUnit::getUnits( paragraph, wordStartIndex, wordCount-1, " " ); + + // Update horizontal text bounds. + if ( lineWidth > widestLine ) + widestLine = lineWidth; + } + } + + break; + } + + return lineCount; +} + bool GuiControl::renderTooltip(Point2I &cursorPos, const char* tipText ) { #if !defined(TORQUE_OS_IOS) && !defined(TORQUE_OS_ANDROID) && !defined(TORQUE_OS_EMSCRIPTEN) @@ -915,83 +1147,31 @@ bool GuiControl::renderTooltip(Point2I &cursorPos, const char* tipText ) // Fetch the maximum allowed tooltip extent. const S32 maxTooltipWidth = mTooltipWidth; - // Fetch word count. - const S32 wordCount = StringUnit::getUnitCount( renderTip, " " ); - // Reset line storage. const S32 tooltipLineStride = (S32)font->getHeight() + 4; const S32 maxTooltipLines = 20; S32 tooltipLineCount = 0; - S32 tooltipLineWidth = 0; FrameTemp tooltipLines( maxTooltipLines ); - // Reset word indexing. - S32 wordStartIndex = 0; - S32 wordEndIndex = 0; - - // Search for end word. - while( true ) + // Paragraph by paragraph, wrapping each to the tooltip width. Breaking on + // newlines first is what lets a tip say what a thing is on one line and + // what it does on the next. + const string tip(renderTip); + string::size_type paragraphStart = 0; + while ( paragraphStart <= tip.length() ) { - // Do we have any words left? - if ( wordEndIndex < wordCount ) - { - // Yes, so fetch the word. - const char* pWord = StringUnit::getUnit( renderTip, wordEndIndex, " " ); + const string::size_type breakAt = tip.find('\n', paragraphStart); + const string paragraph = (breakAt == string::npos) + ? tip.substr(paragraphStart) + : tip.substr(paragraphStart, breakAt - paragraphStart); - // Add word length. - const S32 wordLength = (S32)font->getStrWidth( pWord ) + spaceWidth; + tooltipLineCount = wrapTooltipParagraph( paragraph.c_str(), font, maxTooltipWidth, + spaceWidth, tooltipLines, tooltipLineCount, maxTooltipLines, textBounds.x ); - // Do we still have room? - if ( (tooltipLineWidth + wordLength) < maxTooltipWidth ) - { - // Yes, so add word length. - tooltipLineWidth += wordLength; + if ( breakAt == string::npos ) + break; - // Next word. - wordEndIndex++; - - continue; - } - - // Do we have any lines left? - if ( tooltipLineCount < maxTooltipLines ) - { - // Yes, so insert line. - tooltipLines[tooltipLineCount++] = StringUnit::getUnits( renderTip, wordStartIndex, wordEndIndex-1, " " ); - - // Update horizontal text bounds. - if ( tooltipLineWidth > textBounds.x ) - textBounds.x = tooltipLineWidth; - } - - // Set new line length. - tooltipLineWidth = wordLength; - - // Set word start. - wordStartIndex = wordEndIndex; - - // Next word. - wordEndIndex++; - - continue; - } - - // Do we have any words left? - if ( wordStartIndex < wordCount ) - { - // Yes, so do we have any lines left? - if ( tooltipLineCount < maxTooltipLines ) - { - // Yes, so insert line. - tooltipLines[tooltipLineCount++] = StringUnit::getUnits( renderTip, wordStartIndex, wordCount-1, " " ); - - // Update horizontal text bounds. - if ( tooltipLineWidth > textBounds.x ) - textBounds.x = tooltipLineWidth; - } - } - - break; + paragraphStart = breakAt + 1; } // Controls the size of the inside (gutter) tooltip region. @@ -1053,7 +1233,7 @@ void GuiControl::renderChildControls(const Point2I& offset, const RectI& content Con::errorf( "GuiControl::renderChildControls() object %i is NULL", count ); continue; } - if (ctrl->mVisible && (!isEditMode() || !ctrl->isHidden())) + if (ctrl->mVisible && !isHiddenInEditor(ctrl)) { renderChild(ctrl, offset, content, clipRect); } @@ -1492,6 +1672,10 @@ bool GuiControl::pointInControl(const Point2I& parentCoordPoint) } +// A hidden child is skipped here and not descended into, which is what takes its +// whole subtree with it - the same thing renderChildControls does, and it has to +// be the same thing, or the Gui Editor's eye would take a control out of sight +// while leaving it in the way of every click aimed at what is behind it. GuiControl* GuiControl::findHitControl(const Point2I &pt, S32 initialLayer, const bool ignoreUseInput, const bool ignoreEditSelected) { iterator i = end(); // find in z order (last to first) @@ -1503,9 +1687,10 @@ GuiControl* GuiControl::findHitControl(const Point2I &pt, S32 initialLayer, cons { continue; } - else if (ctrl->pointInControl(pt - ctrl->mRenderInsetLT) && - ctrl->mVisible && - (ignoreUseInput || ctrl->mUseInput) && + else if (ctrl->pointInControl(pt - ctrl->mRenderInsetLT) && + ctrl->mVisible && + !isHiddenInEditor(ctrl) && + (ignoreUseInput || ctrl->mUseInput) && (ignoreEditSelected || (isEditMode() && !ctrl->isEditSelected()))) { Point2I ptemp = pt - (ctrl->mBounds.point + ctrl->mRenderInsetLT); @@ -2355,6 +2540,41 @@ void GuiControl::renderLineList(const Point2I& offset, const Point2I& extent, co } } +// Split on newlines by hand rather than with getline, which cannot tell an +// empty string from no string: it fails immediately on "", so an empty control +// produced NO lines at all. A line block is what draws a GuiTextEditCtrl's +// caret, which is why an empty multi-line box had no cursor in it. getline also +// drops the empty paragraph a trailing newline creates, so pressing return at +// the end of the text left the caret with no line to sit on. +// +// The newline stays on the end of its paragraph, for the same reason the +// wrapping in getLineList re-appends the space it consumed: GuiTextEditCtrl +// finds the character offset of a line by summing the lengths of the lines +// above it (renderLineList), so a character dropped here moves the caret. +// Nothing draws or measures it -- dglDrawText and GFont::getStrWidth both skip +// a line break, which GFont::isValidChar answers false for. +// +// Kept apart from the wrapping below because it needs no font: measuring text +// loads one, and a font registers a texture, which cannot be done in the C++ +// unit tests -- they run with no canvas. See guiTextEditTests.cc. +vector GuiControl::splitParagraphs(const char* text) +{ + vector paragraphList = vector(); + string paragraphBuffer; + for (const char* c = text; *c != '\0'; c++) + { + paragraphBuffer += *c; + if (*c == '\n') + { + paragraphList.push_back(paragraphBuffer); + paragraphBuffer.clear(); + } + } + paragraphList.push_back(paragraphBuffer); + + return paragraphList; +} + vector GuiControl::getLineList(const char* text, GuiControlProfile* profile, S32 totalWidth) { GFont* font = profile->getFont(mFontSizeAdjust); @@ -2366,12 +2586,7 @@ vector GuiControl::getLineList(const char* text, GuiControlProfile* prof } else { - vector paragraphList = vector(); - istringstream f(text); - string s; - while (getline(f, s)) { - paragraphList.push_back(s); - } + vector paragraphList = splitParagraphs(text); for (string& paragraph : paragraphList) { @@ -2409,7 +2624,10 @@ vector GuiControl::getLineList(const char* text, GuiControlProfile* prof line = word; } } - if (paragraph.back() == ' ') + // 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 += " "; } diff --git a/engine/source/gui/guiControl.h b/engine/source/gui/guiControl.h index 2233ba8a6..c6f26e99d 100755 --- a/engine/source/gui/guiControl.h +++ b/engine/source/gui/guiControl.h @@ -175,6 +175,19 @@ class GuiControl : public SimGroup, public virtual Tickable virtual bool isEditMode(); virtual bool isEditSelected(); + /// Whether a child of this control must be treated as though it were not + /// there: the eye in the Gui Editor's Explorer is off for it. + /// + /// The paint and the hit test both ask this, and they have to agree. A + /// control the editor does not draw must not be a target either, or the eye + /// takes it out of sight while leaving it in the way - which is the one + /// thing hiding is for. + /// + /// Cheap test first: isHidden is a bit, isEditMode walks the parent chain, + /// and the answer for almost every control on almost every mouse move is no. + inline bool isHiddenInEditor(GuiControl* child) + { return child->isHidden() && isEditMode(); } + /// @name Keyboard Input /// @{ GuiControl* mFirstResponder; @@ -378,6 +391,7 @@ class GuiControl : public SimGroup, public virtual Tickable /// @param value True if object should be visible virtual void setVisible(bool value); inline bool isVisible() { return mVisible; } ///< Returns true if the object is visible + inline bool rendersChildren() { return mRendersChildren; } ///< True if the control draws its children, which is what makes it able to be a container. static bool writeVisibleFn(void* obj, const char* data) { GuiControl* ctrl = static_cast(obj); return !ctrl->isVisible(); } static bool writeUseInputFn(void* obj, const char* data) { GuiControl* ctrl = static_cast(obj); return !ctrl->mUseInput; } static bool writeIsContainerFn(void* obj, const char* data) { GuiControl* ctrl = static_cast(obj); return ctrl->mRendersChildren && !ctrl->mIsContainer; } @@ -485,11 +499,47 @@ class GuiControl : public SimGroup, public virtual Tickable /// @param newParentExtent The new size of the parent object virtual void parentResized(const Point2I &oldParentExtent, const Point2I &newParentExtent); + /// Where a control goes when a move has stranded it outside its parent. + /// + /// Reparenting in the Explorer tree is a gesture with no pointer in it, so + /// nothing supplies a position and the control keeps the local one it held + /// in its old parent. Dropped into something smaller that can put it + /// entirely outside: not clipped, not partly visible, gone. + /// + /// Per axis, because a placement that is still valid should be kept -- a + /// control that was 20 pixels down and 400 across is still 20 pixels down. + /// Only when it is ENTIRELY outside on that axis, because a control the user + /// can see is a control the user can drag, and moving one that merely + /// overhangs the edge would be undoing a placement rather than rescuing it. + /// + /// Static and taking everything it uses, so the arithmetic can be tested + /// without a canvas -- as GuiScrollCtrl::subtractScrollBars is. + static Point2I rescuedPosition(const Point2I &pos, const Point2I &extent, const Point2I &parentInnerExtent); + + /// The same against the parent this control actually has, resizing it if it + /// had to move. Answers whether it did. + /// + /// A no-op for a control that is even partly visible, and for one with no + /// parent at all, so a caller can ask about a whole selection without + /// working out which members need it. + bool pullIntoView(); + /// Removes the resize mode of fill and changes it to right or bottom void preventResizeModeFill(); /// Removes the resize mode of center and changes it to right or bottom void preventResizeModeCenter(); + + /// The same, one axis at a time. + /// + /// A container that can only offer a child a fixed size in ONE direction -- + /// a scroll control, whose other axis is as long as its content wants to be + /// -- has to be able to refuse fill across without refusing it down. See + /// GuiScrollCtrl::preventUnsizedModes. + void preventHorizResizeModeFill(); + void preventVertResizeModeFill(); + void preventHorizResizeModeCenter(); + void preventVertResizeModeCenter(); /// @} /// @name Rendering @@ -683,6 +733,30 @@ class GuiControl : public SimGroup, public virtual Tickable /// @param offset the offset which is representative of the units x and y that the editor takes up on screen virtual bool onMouseDraggedEditor(const GuiEvent &event, const Point2I& offset) { return false; }; + /// Whether this control is allowed to become a child of the given container. + /// + /// The Gui Editor asks before it reparents anything - a drag in the Explorer + /// tree, a drag across the canvas, a paste - and leaves the control where it + /// is when the answer is no. Nothing else in the engine asks: SimGroup::addObject + /// cannot refuse, so this is advice the editor takes, not an invariant the + /// object model enforces. + /// + /// A control that only means something inside one particular parent overrides + /// this. GuiTabPageCtrl is the one that does. + virtual bool canBeChildOf(GuiControl* parent) { return true; }; + + /// Whether the Gui Editor may move or resize this control. + /// + /// False where the PARENT dictates the geometry and the control's own + /// Position and Extent are written over the moment anything re-lays it out - + /// a tab page, a menu item. The editor draws such a control the way it draws + /// a locked one: an outline rather than eight sizing handles, which would + /// otherwise be handles you can drag to no effect at all. + /// + /// This is the control's own nature, not the user's padlock; isLocked is + /// that, and the editor honours both. + virtual bool isGeometryEditable() { return true; }; + /// @} /// @name Tabs @@ -817,6 +891,10 @@ class GuiControl : public SimGroup, public virtual Tickable /// @note This should move into the graphics library at some point void renderText(const Point2I &offset, const Point2I &extent, const char *text, GuiControlProfile *profile, TextRotationOptions rot = tRotateNone); virtual void renderLineList(const Point2I& offset, const Point2I& extent, const S32 startOffsetY, const vector lineList, GuiControlProfile* profile, const TextRotationOptions rot = tRotateNone); + /// Splits text into paragraphs on its line breaks. The measuring half of + /// getLineList needs a font; this half does not, which is what lets it be + /// tested on its own. + static vector splitParagraphs(const char* text); 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); diff --git a/engine/source/gui/guiControl_ScriptBinding.h b/engine/source/gui/guiControl_ScriptBinding.h index a6c5d544d..d630af911 100644 --- a/engine/source/gui/guiControl_ScriptBinding.h +++ b/engine/source/gui/guiControl_ScriptBinding.h @@ -75,6 +75,56 @@ ConsoleMethodWithDocs(GuiControl, reorderChild, ConsoleVoid, 4,4, (child1, chil } } +/*! Tell this control that the order of its children has changed, so that one + which lays its children out - a chain, a grid, a frame set - places them + again. + + reorderChild and the SimSet ordering methods change the list and announce + nothing, so anything that rearranges children from script has to say so + afterwards. Adding and removing a child already notify on their own; this is + for the case where the same children are simply in a different order. + @return No return value +*/ +ConsoleMethodWithDocs(GuiControl, childrenReordered, ConsoleVoid, 2, 2, ()) +{ + object->childrenReordered(); +} + +/*! Whether this control is allowed to become a child of the given container. + + The Gui Editor asks before every reparent it performs - a drag in the Explorer + tree, a drag across the canvas, a paste - and leaves the control where it is + when the answer is false. Almost everything answers true; a GuiTabPageCtrl + answers true only for a GuiTabBookCtrl. + + Nothing enforces this below the editor: add() will still put a control + anywhere. This is the question, not the gate. + @param parent The container being proposed. + @return True when the control would be at home there. +*/ +ConsoleMethodWithDocs(GuiControl, canBeChildOf, ConsoleBool, 3, 3, (GuiControl parent)) +{ + GuiControl* pParent = dynamic_cast(Sim::findObject(argv[2])); + + return object->canBeChildOf(pParent); +} + +/*! Whether the Gui Editor may move or resize this control. + + False where the PARENT dictates the geometry and the control's own Position + and Extent are written over the moment anything re-lays it out - a tab page, + a menu item. The editor draws such a control the way it draws a locked one: + an outline rather than eight sizing handles. + + This is the control's own nature, not the user's padlock; isLocked is that, + and the editor honours both. + @return True when the editor may change this control's geometry. +*/ +ConsoleMethodWithDocs(GuiControl, isGeometryEditable, ConsoleBool, 2, 2, ()) +{ + return object->isGeometryEditable(); +} + /*! @return Returns the Id of the parent control */ ConsoleMethodWithDocs( GuiControl, getParent, ConsoleInt, 2, 2, ()) @@ -373,4 +423,64 @@ ConsoleMethodWithDocs(GuiControl, getTextExtend, ConsoleBool, 2, 2, ()) return object->getTextExtend(); } +/*! Re-applies this control's sizing flags against its parent's current size. + + HorizSizing and VertSizing are only ever consulted from parentResized, so + setting one leaves the control exactly where it was until something else + resizes the parent. That is fine for the modes that describe what happens to + a size CHANGE, but "center" and "fill" describe a position the control + should always be in, and those look broken until the next layout pass. + + Calling this runs the layout with a zero delta, so the modes that need a + delta do nothing -- which is correct, they have nothing to respond to -- and + center and fill take effect at once. + + Does nothing if the control has no parent. + @return No return value +*/ +ConsoleMethodWithDocs(GuiControl, applySizing, ConsoleVoid, 2, 2, ()) +{ + GuiControl* parent = object->getParent(); + if( parent == NULL ) + return; + + // Both extents the same: parentResized derives the inner rect itself for + // the two modes that need it, so the outer extent is all it wants here. + const Point2I extent = parent->getExtent(); + object->parentResized( extent, extent ); +} + +/*! Moves this control back inside its parent if a move has left it entirely + outside. + + Reparenting in the Explorer tree is a gesture with no pointer in it, so + nothing supplies a position and the control keeps the local one it held in + its old parent. Dropped into something smaller that can put it entirely + outside: not clipped, not partly visible, gone. + + An axis on which nothing of the control is visible is set to 0. The other axis + is left alone -- a control that was 20 pixels down and 400 across is still 20 + pixels down -- and so is a control that merely overhangs an edge, because one + the user can see is one the user can drag. + + Does nothing to a control with no parent, so a whole selection can be offered + without checking each member. + @return True if the control had to be moved. +*/ +ConsoleMethodWithDocs(GuiControl, pullIntoView, ConsoleBool, 2, 2, ()) +{ + return object->pullIntoView(); +} + +/*! Returns true if this control draws its children. + A control that does not render children can never be a container: the + isContainer field is forced to false for it and editing that field is + meaningless. This is fixed by the class and cannot be changed. + @return Returns true if the control renders its children. +*/ +ConsoleMethodWithDocs(GuiControl, rendersChildren, ConsoleBool, 2, 2, ()) +{ + return object->rendersChildren(); +} + ConsoleMethodGroupEndWithDocs(GuiControl) diff --git a/engine/source/gui/guiListBoxCtrl.cc b/engine/source/gui/guiListBoxCtrl.cc index bc0bb89f9..649046a4e 100755 --- a/engine/source/gui/guiListBoxCtrl.cc +++ b/engine/source/gui/guiListBoxCtrl.cc @@ -22,6 +22,7 @@ #include "platform/platform.h" #include "gui/guiListBoxCtrl.h" #include "gui/guiCanvas.h" +#include "persistence/taml/tamlCustom.h" #include #include "guiListBoxCtrl_ScriptBinding.h" @@ -610,6 +611,380 @@ void GuiListBoxCtrl::setItemText( S32 index, StringTableEntry text ) } #pragma endregion +#pragma region Persistence +//----------------------------------------------------------------------------- +// Static rows: the ones a list is authored with rather than filled with at +// runtime. +// +// An item is not a field and not a child object - GuiListBoxCtrl::addObject +// refuses children outright - so neither of the two things a writer walks can +// see one. They go out as TAML custom nodes instead, which is what +// GuiFrameSetCtrl does with its frame tree for exactly the same reason: +// +// +// +// +// +// +// +// +// +// TamlXmlWriter::compileCustomElements prefixes the section with the element +// name it is writing, so a subclass gets with nothing +// extra to do, and the reader's findNode() sees the unprefixed name either way. +// +// Everything but the caption is written only when it differs from what LBItem's +// constructor sets, so an ordinary list of captions stays one attribute a row. +// +// The two limitations are the frame set's, and worth naming: the legacy .gui +// script writer knows nothing of custom nodes, so a Gui saved in that format +// loses its rows (the Gui Editor's save dialog says so), and a deep clone has +// to copy them by hand - see deepCloneChildren below. +//----------------------------------------------------------------------------- + +// Interned case-INSENSITIVELY, which is what every one of these is compared +// against: TamlCustomNodes::findNode and the XML parser both intern with the +// default, so a name interned the case-sensitive way is a different pointer and +// silently matches nothing. +// +// It is not theoretical. StringTable hands back the first spelling of a name it +// was ever given, so a case-sensitive "ID" here wrote itself out as whatever +// spelling reached the table first - "Id", from somewhere else in the engine - +// and then failed to recognise it on the way back in, dropping every ID in the +// file with a warning. Which spelling wins depends on static initialisation +// order across translation units, so it can change from one build to the next. +// +// It also means a hand-edited file may spell these however it likes. +static StringTableEntry itemsNodeName = StringTable->insert("Items"); +static StringTableEntry itemNodeName = StringTable->insert("Item"); +static StringTableEntry itemTextName = StringTable->insert("Text"); +static StringTableEntry itemIDName = StringTable->insert("ID"); +static StringTableEntry itemActiveName = StringTable->insert("Active"); +static StringTableEntry itemSelectedName = StringTable->insert("Selected"); +static StringTableEntry itemColorName = StringTable->insert("Color"); + +void GuiListBoxCtrl::onTamlCustomWrite(TamlCustomNodes& customNodes) +{ + // Debug Profiling. + PROFILE_SCOPE(GuiListBoxCtrl_OnTamlCustomWrite); + + // Call parent. + Parent::onTamlCustomWrite(customNodes); + + if (!writesItems() || mItems.size() == 0) + { + return; + } + + TamlCustomNode* pItemsNode = customNodes.addNode(itemsNodeName); + + for (S32 i = 0; i < mItems.size(); i++) + { + LBItem* item = mItems[i]; + TamlCustomNode* pItemNode = pItemsNode->addNode(itemNodeName); + + pItemNode->addField(itemTextName, item->itemText); + + if (item->ID != 0) + { + pItemNode->addField(itemIDName, item->ID); + } + if (!item->isActive) + { + pItemNode->addField(itemActiveName, item->isActive); + } + if (item->isSelected) + { + pItemNode->addField(itemSelectedName, item->isSelected); + } + if (item->hasColor) + { + pItemNode->addField(itemColorName, item->color); + } + } +} + +void GuiListBoxCtrl::onTamlCustomRead(const TamlCustomNodes& customNodes) +{ + // Debug Profiling. + PROFILE_SCOPE(GuiListBoxCtrl_OnTamlCustomRead); + + // Call parent. + Parent::onTamlCustomRead(customNodes); + + const TamlCustomNode* pItemsNode = customNodes.findNode(itemsNodeName); + + if (pItemsNode == NULL) + { + return; + } + + // Whatever the control was built holding. A list read into an object that + // already has rows is a replacement, not an append. + clearItems(); + + const TamlCustomNodeVector& itemNodes = pItemsNode->getChildren(); + for (TamlCustomNodeVector::const_iterator nodeItr = itemNodes.begin(); nodeItr != itemNodes.end(); ++nodeItr) + { + const TamlCustomNode* pItemNode = *nodeItr; + + if (pItemNode->getNodeName() != itemNodeName) + { + Con::warnf("GuiListBoxCtrl::onTamlCustomRead() - Unknown tag name of '%s'. Only '%s' is valid.", + pItemNode->getNodeName(), itemNodeName); + continue; + } + + // Made before the fields are read, because every field below writes into + // it. A row with no Text at all is still a row - an empty caption is a + // legal item, and the editor can make one. + LBItem* item = appendItemInternal(StringTable->EmptyString); + if (!item) + { + continue; + } + + const TamlCustomFieldVector& fields = pItemNode->getFields(); + for (TamlCustomFieldVector::const_iterator fieldItr = fields.begin(); fieldItr != fields.end(); ++fieldItr) + { + const TamlCustomField* pField = *fieldItr; + StringTableEntry fieldName = pField->getFieldName(); + + if (fieldName == itemTextName) + { + item->itemText = StringTable->insert(pField->getFieldValue(), true); + } + else if (fieldName == itemIDName) + { + pField->getFieldValue(item->ID); + } + else if (fieldName == itemActiveName) + { + pField->getFieldValue(item->isActive); + } + else if (fieldName == itemSelectedName) + { + pField->getFieldValue(item->isSelected); + } + else if (fieldName == itemColorName) + { + pField->getFieldValue(item->color); + item->hasColor = true; + } + else + { + Con::warnf("GuiListBoxCtrl::onTamlCustomRead() - Encountered an unknown field name of '%s'.", fieldName); + } + } + + // The selection list is kept beside the items and has to agree with them. + if (item->isSelected) + { + mSelectedItems.push_front(item); + } + } + + updateSize(); +} + +//----------------------------------------------------------------------------- +// The list as one string, for the two callers that want all of it at once: the +// Gui Editor's Items section, which edits the list as a whole rather than a row +// at a time, and its undo stack, which records what the list was and what it +// became. The same job getFrameLayout/setFrameLayout do for a frame set, and +// like those it is opaque - the file format is the custom nodes above. +// +// One record per item, newline separated; TAB between fields, in a fixed order: +// +// text ID active selected hasColor "r g b a" +// +// A caption may hold neither character. A list row is one line, and a +// GuiTextEditCtrl can produce neither - Enter commits (handleEnterKey has no +// insert path) and Tab moves the focus - but setItemList strips them anyway, +// because a caption arriving from script has been through neither. +//----------------------------------------------------------------------------- + +const char* GuiListBoxCtrl::getItemList() +{ + // Measured rather than guessed: a list is any length, and the return buffer + // has to be asked for at the size it will actually need. 64 covers the five + // numbers, the four color components and the separators. + U32 size = 1; + for (S32 i = 0; i < mItems.size(); i++) + { + size += dStrlen(mItems[i]->itemText) + 64; + } + + char* buffer = Con::getReturnBuffer(size); + buffer[0] = '\0'; + + U32 used = 0; + for (S32 i = 0; i < mItems.size(); i++) + { + LBItem* item = mItems[i]; + + // A row without a color has no color to write: ColorF's default + // constructor is empty (gColor.h), so item->color is whatever was in + // that memory until something sets it. Printing it would make two + // identical lists read as different strings, which is the one thing an + // opaque round-trippable form may not do. + ColorF color = item->hasColor ? item->color : ColorF(0.0f, 0.0f, 0.0f, 0.0f); + + used += dSprintf(buffer + used, size - used, "%s%s\t%d\t%d\t%d\t%d\t%g %g %g %g", + (i == 0) ? "" : "\n", + item->itemText, + item->ID, + item->isActive ? 1 : 0, + item->isSelected ? 1 : 0, + item->hasColor ? 1 : 0, + color.red, color.green, color.blue, color.alpha); + } + + return buffer; +} + +// One field of a record, copied out of the middle of the string it sits in. +// Returns the buffer, so it reads as an argument to dAtoi and friends. +static const char* copyItemField(char* buffer, const U32 size, const char* start, S32 length) +{ + if (length < 0) + { + length = 0; + } + if ((U32)length >= size) + { + length = size - 1; + } + + dMemcpy(buffer, start, length); + buffer[length] = '\0'; + + return buffer; +} + +void GuiListBoxCtrl::setItemList(const char* itemList) +{ + // The order the fields are written in above. A record may stop short of all + // six: a script handing over a bare list of captions is a legal list, and + // every field left off keeps whatever LBItem's constructor gave it. + const S32 fieldMax = 6; + + clearItems(); + + for (const char* at = itemList; at != NULL && *at; ) + { + const char* recordEnd = dStrchr(at, '\n'); + if (recordEnd == NULL) + { + recordEnd = at + dStrlen(at); + } + + // Split on TAB by pointer rather than through getWord or getUnit: the + // first field is a caption, which may be empty and may hold spaces, and + // neither of those survives a word split. + const char* field[fieldMax]; + S32 fieldLength[fieldMax]; + S32 fieldCount = 0; + + const char* fieldStart = at; + for (const char* scan = at; scan <= recordEnd; scan++) + { + if (scan != recordEnd && *scan != '\t') + { + continue; + } + + if (fieldCount < fieldMax) + { + field[fieldCount] = fieldStart; + fieldLength[fieldCount] = (S32)(scan - fieldStart); + fieldCount++; + } + fieldStart = scan + 1; + } + + char scratch[1024]; + + LBItem* item = appendItemInternal(StringTable->insert( + copyItemField(scratch, sizeof(scratch), field[0], fieldLength[0]), true)); + + if (item != NULL) + { + if (fieldCount > 1) + { + item->ID = dAtoi(copyItemField(scratch, sizeof(scratch), field[1], fieldLength[1])); + } + if (fieldCount > 2) + { + item->isActive = dAtob(copyItemField(scratch, sizeof(scratch), field[2], fieldLength[2])); + } + if (fieldCount > 3) + { + item->isSelected = dAtob(copyItemField(scratch, sizeof(scratch), field[3], fieldLength[3])); + } + if (fieldCount > 4) + { + item->hasColor = dAtob(copyItemField(scratch, sizeof(scratch), field[4], fieldLength[4])); + } + if (fieldCount > 5 && item->hasColor) + { + const char* colorText = copyItemField(scratch, sizeof(scratch), field[5], fieldLength[5]); + Con::setData(TypeColorF, &item->color, 0, 1, &colorText); + } + + // The selection list is kept beside the items and has to agree with + // them, or getSelectedItem and the render disagree about what is on. + if (item->isSelected) + { + mSelectedItems.push_front(item); + } + } + + at = (*recordEnd == '\0') ? recordEnd : (recordEnd + 1); + } + + // The list is the control's size, so the canvas has to be told. This is what + // makes a row typed in the Gui Editor appear under the cursor as it is typed. + updateSize(); + setUpdate(); +} + +// A row added with none of the noise the public paths make: insertItem resizes +// the control once per item and addSelection scrolls and fires onSelect, and +// neither belongs in the middle of loading a list. +GuiListBoxCtrl::LBItem* GuiListBoxCtrl::appendItemInternal(StringTableEntry text) +{ + LBItem* newItem = createItem(); + if (!newItem) + { + Con::warnf("GuiListBoxCtrl::appendItemInternal - error allocating item memory!"); + return NULL; + } + + newItem->itemText = text; + mItems.push_back(newItem); + + return newItem; +} + +// Items are neither fields nor children, so a clone made by copying both comes +// back with an empty list. GuiFrameSetCtrl overrides the same phase for the same +// reason, and it is what makes the Gui Editor's copy, cut and paste carry the +// rows without knowing anything about them. +void GuiListBoxCtrl::deepCloneChildren(SimObject* clone) +{ + Parent::deepCloneChildren(clone); + + GuiListBoxCtrl* pListBox = dynamic_cast(clone); + if (pListBox == NULL) + { + return; + } + + pListBox->setItemList(getItemList()); +} +#pragma endregion + #pragma region Sizing void GuiListBoxCtrl::updateSize() { diff --git a/engine/source/gui/guiListBoxCtrl.h b/engine/source/gui/guiListBoxCtrl.h index 950c63561..47754876e 100755 --- a/engine/source/gui/guiListBoxCtrl.h +++ b/engine/source/gui/guiListBoxCtrl.h @@ -101,7 +101,32 @@ class GuiListBoxCtrl : public GuiControl // Persistence - static void initPersistFields(); + static void initPersistFields(); + + /// @name Static rows + /// + /// The rows a list is authored with, as opposed to the ones a script fills in + /// at runtime. An item is neither a field nor a child object, so it is + /// written as TAML custom nodes - the arrangement GuiFrameSetCtrl already + /// uses for its frame tree, with the same two consequences: the legacy .gui + /// script writer cannot carry them, and a deep clone has to copy them itself. + /// @{ + + virtual void onTamlCustomWrite( TamlCustomNodes& customNodes ); + virtual void onTamlCustomRead( const TamlCustomNodes& customNodes ); + + /// The whole list as one opaque string: one record per item, TAB-separated + /// fields in a fixed order. Not the file format - it is what the Gui Editor + /// reads and writes in a single call, and what its undo stack records, in the + /// same way getFrameLayout/setFrameLayout serve a frame set. + const char* getItemList(); + void setItemList( const char* itemList ); + + /// Whether the rows this control holds are its own to save. A GuiTreeViewCtrl + /// generates its items from a root object, so a written-out set of them would + /// be stale the moment the tree next builds itself. + virtual bool writesItems() { return true; } + /// @} // Item Accessors S32 getItemCount(); @@ -175,6 +200,15 @@ class GuiListBoxCtrl : public GuiControl protected: GuiControl *caller; + + /// Items are not children and not fields, so a deep clone would otherwise + /// come back with an empty list. This is the phase GuiFrameSetCtrl uses for + /// the same reason. + virtual void deepCloneChildren(SimObject* clone); + + /// Add an item without any of the noise addSelection/insertItem make: no + /// onSelect callback, no scroll, no resize per row. For loading a list in. + LBItem* appendItemInternal(StringTableEntry text); }; #endif \ No newline at end of file diff --git a/engine/source/gui/guiListBoxCtrl_ScriptBinding.h b/engine/source/gui/guiListBoxCtrl_ScriptBinding.h index 26dc53353..204f35996 100644 --- a/engine/source/gui/guiListBoxCtrl_ScriptBinding.h +++ b/engine/source/gui/guiListBoxCtrl_ScriptBinding.h @@ -428,4 +428,30 @@ ConsoleMethodWithDocs(GuiListBoxCtrl, sortByID, ConsoleVoid, 2, 3, "([bool ascen object->sortByID(direction); } +/*! Gets the whole list as text: every row, with its ID, color and state. Opaque + - hand it back to setItemList unchanged. + + A list is saved as TAML custom nodes rather than as fields, so anything that + needs to keep one and put it back - the Gui Editor's Items pane, and its undo + - reads it through here rather than a row at a time. + @return The list, as a string. +*/ +ConsoleMethodWithDocs(GuiListBoxCtrl, getItemList, ConsoleString, 2, 2, ()) +{ + return object->getItemList(); +} + +/*! Replaces every row in the list with the ones described by text taken earlier + by getItemList. + + A record may stop short: a caption on its own is a row, and every field left + off keeps its default. So "Easy\nNormal\nHard" is three plain rows. + @param itemList A string from getItemList. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiListBoxCtrl, setItemList, ConsoleVoid, 3, 3, (itemList)) +{ + object->setItemList(argv[2]); +} + ConsoleMethodGroupEndWithDocs(GuiListBoxCtrl) \ No newline at end of file diff --git a/engine/source/gui/guiProfileTheme.cc b/engine/source/gui/guiProfileTheme.cc index 0eb241a26..6570887f6 100644 --- a/engine/source/gui/guiProfileTheme.cc +++ b/engine/source/gui/guiProfileTheme.cc @@ -325,6 +325,37 @@ static void stampCondenserDarkBorder(GuiProfileTheme* theme, GuiBorderProfile* b applyBorderRecipe(border, recipe); } +static void stampSelectedInsetBorder(GuiProfileTheme* theme, GuiBorderProfile* border) +{ + // A padded inset in three states and a rule in the fourth. The odd one out is + // SELECTED, and it is deliberate: a menu separator is drawn as the menu item + // profile in its selected state and nothing else in a menu ever uses that + // state (hover is Highlight, greyed is Disabled), so the SL fields of a menu + // item's borders belong to separators alone. One border therefore gives a + // theme both the room around a label and the groove between two groups of + // them, which is why this is what a generated MenuItem wears. + // + // The margin is what makes it a groove rather than a band. A separator's + // height is nothing but this border's margin, rim and padding + // (GuiMenuListCtrl::updateSize measures it with a zero-height interior), so + // dropping the padding to 0 and giving it 4 of margin buys 4px of the menu's + // own fill above and below a 2px rule. + BorderRecipe recipe = baseBorderRecipe(theme); + set4(recipe.margin, 0, 0, 4, 0); + set4(recipe.border, 0, 0, 1, 0); + set4(recipe.borderColor, theme->getColorSurface()); + set4(recipe.padding, 10, 10, 0, 10); + recipe.underfill = true; + + // Alone among the recipes, not scaled by the theme's border size. This rim is + // a rule between two groups of commands rather than the edge of a control: at + // borderSize 0 it would vanish and the menu would lose its grouping, and at 2 + // or 3 it would thicken into a band. + recipe.borderScale = 1; + + applyBorderRecipe(border, recipe); +} + //----------------------------------------------------------------------------- // Profile recipes. Every profile starts from the Default recipe and overrides // what its category needs; borders are wired to the theme's border members. @@ -793,7 +824,12 @@ static void stampMenuItemProfile(GuiProfileTheme* theme, GuiControlProfile* prof STAMP_FIELD(profile, "fontColorHL", mFontColorHL, adj(text, 10)); STAMP_FIELD(profile, "fontColorNA", mFontColorNA, alphaOf(text, 150)); STAMP_FIELD(profile, "align", mAlignment, AlignmentType::LeftAlign); - stampProfileBorders(theme, profile, "Padded", NULL, NULL, NULL, NULL); + // Top and bottom take SelectedInset, so a new theme's menus arrive with + // separators that read as separators. The sides are held back from it and + // given the same inset with no state to it: were they to fall through, a + // separator would pick up the 4px margin and the rim at each end too, capping + // the rule with a tick rather than running it the width of the menu. + stampProfileBorders(theme, profile, "SelectedInset", "Padded", "Padded", NULL, NULL); } static void stampMenuContentProfile(GuiProfileTheme* theme, GuiControlProfile* profile) @@ -896,6 +932,22 @@ static void stampDragAndDropProfile(GuiProfileTheme* theme, GuiControlProfile* p STAMP_FIELD(profile, "fontColor", mFontColor, theme->getColorHighlight()); } +//----------------------------------------------------------------------------- +// Cursor recipe. There is only the one: a pointer is the mouse's equivalent of +// text and wants the same contrast against the background, so every cursor in a +// theme takes the foreground color and the set reads as a set. Anything else is +// a per-cursor override, which costs one click in the editor. +// +// This tints art that is deliberately grayscale - black outline, white body - +// so the body takes the color and the outline stays put. A theme that brings +// its own colored art sets the tint to white and this stops mattering. +//----------------------------------------------------------------------------- + +static void stampCursor(GuiProfileTheme* theme, GuiCursor* cursor) +{ + STAMP_FIELD(cursor, "color", mColor, theme->getColorForeground()); +} + //----------------------------------------------------------------------------- // The engine-defined category tables: the canonical set of profiles a // complete theme provides, one entry per profile slot the stock GuiControls @@ -963,9 +1015,33 @@ const GuiProfileTheme::BorderCategory GuiProfileTheme::smBorderCategories[] = { "RimmedExpander", "RimmedExpanderBorder", stampRimmedExpanderBorder }, { "CondenserLight", "CondenserLightBorder", stampCondenserLightBorder }, { "CondenserDark", "CondenserDarkBorder", stampCondenserDarkBorder }, + { "SelectedInset", "SelectedInsetBorder", stampSelectedInsetBorder }, }; const S32 GuiProfileTheme::smBorderCategoryCount = sizeof(smBorderCategories) / sizeof(smBorderCategories[0]); +// The seven cursors the engine can ask for by name. +// +// The placement values started as the ones AppCore's hand-written cursors had +// always used and are now what came back from actually aiming them in the +// hot-spot editor -- which is the point of having built it. Five of the seven +// moved: the resize cursors want their crosshair centred on the pointer rather +// than a pixel down and right of it, and the two bars want one pixel of lift so +// the gap between their arrowheads straddles the edge being dragged. +// +// A "0" hot spot with a centred anchor is not a missing value: the anchor does +// the placing, and the nudge is only what the anchor cannot express. +const GuiProfileTheme::CursorCategory GuiProfileTheme::smCursorCategories[] = +{ + { "Default", "DefaultCursor", "defaultCursor.png", 1, 1, 0.0f, 0.0f, stampCursor }, + { "Edit", "EditCursor", "ibeam.png", 0, 0, 0.5f, 0.5f, stampCursor }, + { "Move", "MoveCursor", "move.png", 0, 0, 0.5f, 0.5f, stampCursor }, + { "LeftRight", "LeftRightCursor", "leftRight.png", 0, -1, 0.5f, 0.5f, stampCursor }, + { "UpDown", "UpDownCursor", "upDown.png", 0, -1, 0.5f, 0.5f, stampCursor }, + { "NWSE", "NWSECursor", "NWSE.png", 0, 0, 0.5f, 0.5f, stampCursor }, + { "NESW", "NESWCursor", "NESW.png", 0, 0, 0.5f, 0.5f, stampCursor }, +}; +const S32 GuiProfileTheme::smCursorCategoryCount = sizeof(smCursorCategories) / sizeof(smCursorCategories[0]); + //----------------------------------------------------------------------------- GuiProfileTheme::GuiProfileTheme() @@ -976,6 +1052,7 @@ GuiProfileTheme::GuiProfileTheme() mFontTitle = StringTable->insert("Arial"); mFontCode = StringTable->insert("Courier New"); mFontDirectory = StringTable->EmptyString; + mCursorDirectory = StringTable->EmptyString; mFontSize = 12; // Semantic dark palette. Every profile fill/border is derived from these six @@ -998,6 +1075,10 @@ GuiProfileTheme::GuiProfileTheme() mDefaultBorders.setSize(smBorderCategoryCount); for (S32 i = 0; i < smBorderCategoryCount; ++i) mDefaultBorders[i] = NULL; + + mDefaultCursors.setSize(smCursorCategoryCount); + for (S32 i = 0; i < smCursorCategoryCount; ++i) + mDefaultCursors[i] = NULL; } void GuiProfileTheme::initPersistFields() @@ -1022,6 +1103,13 @@ void GuiProfileTheme::initPersistFields() endGroup("Colors"); addField("borderSize", TypeS32, Offset(mBorderSize, GuiProfileTheme)); + + // Where this theme's own cursor art lives, relative to the game root. Each + // theme gets its own folder so two themes can carry dramatically different + // cursors without one overwriting the other's files. Filled by whoever + // seeds the art (the Profile Editor, or AppCore for the stock theme); a + // theme that names none simply has cursors with no bitmap yet. + addField("cursorDirectory", TypeString, Offset(mCursorDirectory, GuiProfileTheme)); } bool GuiProfileTheme::onAdd() @@ -1046,6 +1134,19 @@ void GuiProfileTheme::onRemove() while (mExtraBorders.size() > 0) mExtraBorders.last()->deleteObject(); + while (mExtraCursors.size() > 0) + mExtraCursors.last()->deleteObject(); + + for (S32 i = 0; i < smCursorCategoryCount; ++i) + { + if (mDefaultCursors[i] != NULL) + { + GuiCursor* cursor = mDefaultCursors[i]; + mDefaultCursors[i] = NULL; + cursor->deleteObject(); + } + } + for (S32 i = 0; i < smProfileCategoryCount; ++i) { if (mDefaultProfiles[i] != NULL) @@ -1112,6 +1213,21 @@ void GuiProfileTheme::onDeleteNotify(SimObject* object) mDefaultBorders[i] = NULL; } + for (S32 i = 0; i < mExtraCursors.size(); ++i) + { + if (mExtraCursors[i] == object) + { + mExtraCursors.erase(i); + break; + } + } + + for (S32 i = 0; i < smCursorCategoryCount; ++i) + { + if (mDefaultCursors[i] == object) + mDefaultCursors[i] = NULL; + } + Parent::onDeleteNotify(object); } @@ -1155,6 +1271,40 @@ S32 GuiProfileTheme::findBorderCategoryIndex(StringTableEntry categoryName) return -1; } +StringTableEntry GuiProfileTheme::getCursorCategoryName(S32 index) +{ + if (index < 0 || index >= smCursorCategoryCount) + return NULL; + + return StringTable->insert(smCursorCategories[index].name); +} + +S32 GuiProfileTheme::findCursorCategoryIndex(StringTableEntry categoryName) +{ + for (S32 i = 0; i < smCursorCategoryCount; ++i) + { + if (StringTable->insert(smCursorCategories[i].name) == categoryName) + return i; + } + return -1; +} + +const char* GuiProfileTheme::getCursorStockFile(S32 index) +{ + if (index < 0 || index >= smCursorCategoryCount) + return ""; + + return smCursorCategories[index].stockFile; +} + +const char* GuiProfileTheme::getCursorCanonicalName(S32 index) +{ + if (index < 0 || index >= smCursorCategoryCount) + return ""; + + return smCursorCategories[index].suffix; +} + //----------------------------------------------------------------------------- // Members. //----------------------------------------------------------------------------- @@ -1182,6 +1332,12 @@ GuiBorderProfile* GuiProfileTheme::getBorder(StringTableEntry categoryName) cons return (index >= 0) ? mDefaultBorders[index] : NULL; } +GuiCursor* GuiProfileTheme::getCursor(StringTableEntry categoryName) const +{ + const S32 index = findCursorCategoryIndex(categoryName); + return (index >= 0) ? mDefaultCursors[index] : NULL; +} + GuiControlProfile* GuiProfileTheme::createMemberProfile(S32 categoryIndex, const char* objectName) { const ProfileCategory& category = smProfileCategories[categoryIndex]; @@ -1236,6 +1392,63 @@ GuiBorderProfile* GuiProfileTheme::createMemberBorder(S32 categoryIndex) return border; } +GuiCursor* GuiProfileTheme::createMemberCursor(S32 categoryIndex, const char* objectName) +{ + const CursorCategory& category = smCursorCategories[categoryIndex]; + + GuiCursor* cursor = new GuiCursor(); + + char nameBuffer[256]; + if (objectName == NULL && getName() != NULL && *getName() != '\0') + { + dSprintf(nameBuffer, sizeof(nameBuffer), "%s%s", getName(), category.suffix); + objectName = nameBuffer; + } + if (objectName != NULL && *objectName != '\0') + cursor->assignName(objectName); + + if (!cursor->registerObject()) + { + delete cursor; + return NULL; + } + + cursor->mCategory = StringTable->insert(category.name); + + // Placement comes from the table and belongs to the art, so it is set here + // rather than stamped -- a restamp must never move a hot spot the user + // tuned. setTheme comes after, so these writes are not seen as overrides. + cursor->setHotSpot(Point2I(category.hotSpotX, category.hotSpotY)); + cursor->setRenderOffset(Point2F(category.renderOffsetX, category.renderOffsetY)); + + cursor->setTheme(this); + fillCursorArt(cursor, categoryIndex); + deleteNotify(cursor); + + return cursor; +} + +void GuiProfileTheme::fillCursorArt(GuiCursor* cursor, S32 categoryIndex) +{ + if (cursor == NULL || mCursorDirectory == NULL || *mCursorDirectory == '\0') + return; + + // Only ever fills a blank. A cursor pointed at the user's own art keeps it + // through every restamp, which is the whole difference between art and the + // derived fields around it. + const StringTableEntry current = cursor->getBitmapName(); + if (current != NULL && *current != '\0') + return; + + char pathBuffer[1024]; + dSprintf(pathBuffer, sizeof(pathBuffer), "%s/%s", mCursorDirectory, smCursorCategories[categoryIndex].stockFile); + + // Through setDataField so TypeFilename expands it: the directory is stored + // relative to the game root, and the texture manager wants a real path. + // GuiCursor treats bitmapName as art, so this does not mark an override. + cursor->setDataField(StringTable->insert("bitmapName"), NULL, pathBuffer); +} + GuiControlProfile* GuiProfileTheme::createProfile(const char* categoryName, const char* objectName) { const S32 categoryIndex = findCategoryIndex(StringTable->insert(categoryName)); @@ -1323,6 +1536,58 @@ bool GuiProfileTheme::removeBorder(GuiBorderProfile* border) return false; } +// An extra cursor belongs to a category, exactly as an extra profile does: a +// theme with two "Default" cursors is offering a choice between two pointers, +// which is the case the Gui Editor shows a cursor slot for. It starts on the +// category's stock art; the editor gives it a copy of its own to edit. +GuiCursor* GuiProfileTheme::createCursor(const char* categoryName, const char* objectName) +{ + const S32 categoryIndex = findCursorCategoryIndex(StringTable->insert(categoryName)); + if (categoryIndex < 0) + { + Con::warnf("GuiProfileTheme::createCursor() - unknown category '%s'.", categoryName); + return NULL; + } + + // Generate when no name is given. + char nameBuffer[256]; + if ((objectName == NULL || *objectName == '\0') && getName() != NULL && *getName() != '\0') + { + for (S32 n = 2; n < 1000000; ++n) + { + dSprintf(nameBuffer, sizeof(nameBuffer), "%s%s%d", getName(), smCursorCategories[categoryIndex].suffix, n); + if (Sim::findObject(nameBuffer) == NULL) + break; + } + objectName = nameBuffer; + } + + GuiCursor* cursor = createMemberCursor(categoryIndex, objectName); + if (cursor == NULL) + return NULL; + + mExtraCursors.push_back(cursor); + smCursorCategories[categoryIndex].stamp(this, cursor); + + return cursor; +} + +bool GuiProfileTheme::removeCursor(GuiCursor* cursor) +{ + for (S32 i = 0; i < mExtraCursors.size(); ++i) + { + if (mExtraCursors[i] == cursor) + { + // Deletion notifies us back and erases the list entry. + cursor->deleteObject(); + return true; + } + } + + // Default members are never removed: a theme is always complete. + return false; +} + bool GuiProfileTheme::renameTheme(const char* newName) { if (!isProperlyAdded()) @@ -1375,6 +1640,15 @@ bool GuiProfileTheme::renameTheme(const char* newName) renames.push_back(rename); } + for (S32 i = 0; i < smCursorCategoryCount; ++i) + { + if (mDefaultCursors[i] == NULL) + continue; + dSprintf(nameBuffer, sizeof(nameBuffer), "%s%s", newName, smCursorCategories[i].suffix); + PendingRename rename = { mDefaultCursors[i], StringTable->insert(nameBuffer) }; + renames.push_back(rename); + } + // Extras rename only when they follow the ... pattern. for (S32 i = 0; i < mExtraProfiles.size(); ++i) { @@ -1386,6 +1660,16 @@ bool GuiProfileTheme::renameTheme(const char* newName) renames.push_back(rename); } + for (S32 i = 0; i < mExtraCursors.size(); ++i) + { + const char* extraName = mExtraCursors[i]->getName(); + if (!hasOldName || extraName == NULL || dStrncmp(extraName, oldName, oldNameLength) != 0) + continue; + dSprintf(nameBuffer, sizeof(nameBuffer), "%s%s", newName, extraName + oldNameLength); + PendingRename rename = { mExtraCursors[i], StringTable->insert(nameBuffer) }; + renames.push_back(rename); + } + // Collision pre-check: every target must be free or already belong to the // object being renamed to it. for (S32 i = 0; i < renames.size(); ++i) @@ -1487,6 +1771,31 @@ void GuiProfileTheme::restamp() if (categoryIndex >= 0) smProfileCategories[categoryIndex].stamp(this, mExtraProfiles[i]); } + + // Cursors. fillCursorArt runs on every pass rather than only at creation: + // a theme usually learns where its cursor folder is after its members + // already exist (the editor names the folder once the theme has a name), + // and it is also what re-points a member whose art went missing. + for (S32 i = 0; i < smCursorCategoryCount; ++i) + { + if (mDefaultCursors[i] == NULL) + mDefaultCursors[i] = createMemberCursor(i, NULL); + if (mDefaultCursors[i] != NULL) + { + fillCursorArt(mDefaultCursors[i], i); + smCursorCategories[i].stamp(this, mDefaultCursors[i]); + } + } + + for (S32 i = 0; i < mExtraCursors.size(); ++i) + { + const S32 categoryIndex = findCursorCategoryIndex(mExtraCursors[i]->mCategory); + if (categoryIndex >= 0) + { + fillCursorArt(mExtraCursors[i], categoryIndex); + smCursorCategories[categoryIndex].stamp(this, mExtraCursors[i]); + } + } } //----------------------------------------------------------------------------- @@ -1499,7 +1808,7 @@ void GuiProfileTheme::restamp() U32 GuiProfileTheme::getTamlChildCount(void) const { - U32 count = (U32)mExtraProfiles.size() + (U32)mExtraBorders.size(); + U32 count = (U32)mExtraProfiles.size() + (U32)mExtraBorders.size() + (U32)mExtraCursors.size(); for (S32 i = 0; i < smBorderCategoryCount; ++i) { @@ -1507,6 +1816,12 @@ U32 GuiProfileTheme::getTamlChildCount(void) const ++count; } + for (S32 i = 0; i < smCursorCategoryCount; ++i) + { + if (mDefaultCursors[i] != NULL) + ++count; + } + for (S32 i = 0; i < smProfileCategoryCount; ++i) { if (mDefaultProfiles[i] != NULL) @@ -1535,6 +1850,22 @@ SimObject* GuiProfileTheme::getTamlChild(const U32 childIndex) const return mExtraBorders[index]; index -= (U32)mExtraBorders.size(); + // Cursors reference nothing and are referenced by nothing inside the file, + // so their position is free; they sit between the borders and the profiles + // to keep the written order stable and readable. + for (S32 i = 0; i < smCursorCategoryCount; ++i) + { + if (mDefaultCursors[i] == NULL) + continue; + if (index == 0) + return mDefaultCursors[i]; + --index; + } + + if (index < (U32)mExtraCursors.size()) + return mExtraCursors[index]; + index -= (U32)mExtraCursors.size(); + for (S32 i = 0; i < smProfileCategoryCount; ++i) { if (mDefaultProfiles[i] == NULL) @@ -1577,6 +1908,28 @@ void GuiProfileTheme::addTamlChild(SimObject* pSimObject) return; } + GuiCursor* cursor = dynamic_cast(pSimObject); + if (cursor != NULL) + { + const S32 categoryIndex = findCursorCategoryIndex(cursor->mCategory); + if (categoryIndex < 0) + { + Con::warnf("GuiProfileTheme::addTamlChild() - cursor child with unknown category '%s' left unattached.", cursor->mCategory); + return; + } + + // First one in a category is that category's default; the rest are the + // extras the user added. Same rule as profiles. + if (mDefaultCursors[categoryIndex] == NULL) + mDefaultCursors[categoryIndex] = cursor; + else + mExtraCursors.push_back(cursor); + + cursor->setTheme(this, true); + deleteNotify(cursor); + return; + } + GuiControlProfile* profile = dynamic_cast(pSimObject); if (profile != NULL) { diff --git a/engine/source/gui/guiProfileTheme.h b/engine/source/gui/guiProfileTheme.h index ba0171a94..4d90b943e 100644 --- a/engine/source/gui/guiProfileTheme.h +++ b/engine/source/gui/guiProfileTheme.h @@ -94,6 +94,7 @@ struct GuiThemeMembership class GuiControlProfile; class GuiBorderProfile; +class GuiCursor; //----------------------------------------------------------------------------- /// A set of theme-wide values (fonts, palette colors, border size) from which @@ -117,6 +118,7 @@ class GuiProfileTheme : public SimObject, public TamlChildren public: typedef void (*StampProfileFn)(GuiProfileTheme* theme, GuiControlProfile* profile); typedef void (*StampBorderFn)(GuiProfileTheme* theme, GuiBorderProfile* border); + typedef void (*StampCursorFn)(GuiProfileTheme* theme, GuiCursor* cursor); /// One entry of the engine-defined category table: the mCategory value, /// the member-name suffix, and the recipe deriving the member's fields @@ -135,12 +137,35 @@ class GuiProfileTheme : public SimObject, public TamlChildren StampBorderFn stamp; }; + /// One entry of the cursor table. Unlike a profile or a border, a cursor is + /// a bitmap, which no recipe can derive from a palette - so the table also + /// carries the stock art a new member starts from and the placement values + /// that art wants. Those three are written once when the member is created + /// and never stamped again; the recipe derives only the tint. + /// + /// The suffixes deliberately match the canonical cursor names the engine + /// looks up when a control names none ("EditCursor", "LeftRightCursor" and + /// the rest), so installing a theme's cursors under those names is a matter + /// of dropping the theme's own name from the front. + struct CursorCategory + { + const char* name; + const char* suffix; + const char* stockFile; ///< Art seeded into the theme's cursor folder. + S32 hotSpotX; + S32 hotSpotY; + F32 renderOffsetX; ///< A fraction of the bitmap's own size, so it + F32 renderOffsetY; ///< anchors the same way whatever the art measures. + StampCursorFn stamp; + }; + private: // Theme-wide values, the inputs to every category recipe. StringTableEntry mFontBody; ///< Font for body text; the most common font. StringTableEntry mFontTitle; ///< Font for titles and headers. StringTableEntry mFontCode; ///< Monospace font for code and console text. StringTableEntry mFontDirectory; ///< Directory searched for the fonts. + StringTableEntry mCursorDirectory; ///< Directory holding this theme's own cursor art. S32 mFontSize; ///< Base font size; recipes may offset it. ColorI mColorBackground; ///< Deepest background color. ColorI mColorSurface; ///< Raised surface/control background color. @@ -156,16 +181,27 @@ class GuiProfileTheme : public SimObject, public TamlChildren Vector mExtraProfiles; Vector mDefaultBorders; Vector mExtraBorders; ///< User-authored single-use "custom" borders owned by the theme (not category members). + Vector mDefaultCursors; + Vector mExtraCursors; ///< Extra cursors, each one within a category (the profile pattern, not the border one). static const ProfileCategory smProfileCategories[]; static const BorderCategory smBorderCategories[]; + static const CursorCategory smCursorCategories[]; static const S32 smProfileCategoryCount; static const S32 smBorderCategoryCount; + static const S32 smCursorCategoryCount; bool mTamlReading; ///< Suppresses auto-creation/stamping while Taml populates the theme. GuiControlProfile* createMemberProfile(S32 categoryIndex, const char* objectName); GuiBorderProfile* createMemberBorder(S32 categoryIndex); + GuiCursor* createMemberCursor(S32 categoryIndex, const char* objectName); + + /// Point a cursor at this theme's copy of its category's stock art. Only + /// ever fills an EMPTY bitmapName: art is the user's, and a restamp must + /// never overwrite what they chose. Does nothing until the theme knows a + /// cursor directory, which is why it is retried on every restamp. + void fillCursorArt(GuiCursor* cursor, S32 categoryIndex); public: DECLARE_CONOBJECT(GuiProfileTheme); @@ -192,17 +228,33 @@ class GuiProfileTheme : public SimObject, public TamlChildren static S32 getBorderCategoryCount() { return smBorderCategoryCount; } static StringTableEntry getBorderCategoryName(S32 index); static S32 findBorderCategoryIndex(StringTableEntry categoryName); + static S32 getCursorCategoryCount() { return smCursorCategoryCount; } + static StringTableEntry getCursorCategoryName(S32 index); + static S32 findCursorCategoryIndex(StringTableEntry categoryName); + + /// The stock art file name for a cursor category ("defaultCursor.png"). The + /// editor asks so it can seed a theme's cursor folder; the theme itself + /// never copies files. + static const char* getCursorStockFile(S32 index); + + /// The name the engine falls back to for a cursor category ("EditCursor"). + /// Also the member-name suffix, which is why a member is findable both ways. + static const char* getCursorCanonicalName(S32 index); // Members. S32 getProfileCount() const; inline const Vector& getExtraProfiles() const { return mExtraProfiles; } inline const Vector& getExtraBorders() const { return mExtraBorders; } + inline const Vector& getExtraCursors() const { return mExtraCursors; } GuiControlProfile* getProfile(StringTableEntry categoryName) const; ///< The category's default member. GuiBorderProfile* getBorder(StringTableEntry categoryName) const; ///< The border category's default member. + GuiCursor* getCursor(StringTableEntry categoryName) const; ///< The cursor category's default member. GuiControlProfile* createProfile(const char* categoryName, const char* objectName); ///< Create an extra profile in a category. bool removeProfile(GuiControlProfile* profile); ///< Delete an extra; defaults are refused. GuiBorderProfile* createBorder(const char* objectName); ///< Create a single-use custom border owned by the theme. bool removeBorder(GuiBorderProfile* border); ///< Delete a custom border. + GuiCursor* createCursor(const char* categoryName, const char* objectName); ///< Create an extra cursor in a category. + bool removeCursor(GuiCursor* cursor); ///< Delete an extra; defaults are refused. /// Create any missing default members and re-derive every non-overridden /// field of every member from the current theme values. @@ -220,6 +272,7 @@ class GuiProfileTheme : public SimObject, public TamlChildren inline StringTableEntry getFontTitle() const { return mFontTitle; } inline StringTableEntry getFontCode() const { return mFontCode; } inline StringTableEntry getFontDirectory() const { return mFontDirectory; } + inline StringTableEntry getCursorDirectory() const { return mCursorDirectory; } inline S32 getFontSize() const { return mFontSize; } inline const ColorI& getColorBackground() const { return mColorBackground; } inline const ColorI& getColorSurface() const { return mColorSurface; } diff --git a/engine/source/gui/guiProfileTheme_ScriptBinding.h b/engine/source/gui/guiProfileTheme_ScriptBinding.h index 784cc2144..107abb872 100644 --- a/engine/source/gui/guiProfileTheme_ScriptBinding.h +++ b/engine/source/gui/guiProfileTheme_ScriptBinding.h @@ -164,8 +164,116 @@ ConsoleMethodWithDocs(GuiProfileTheme, removeBorder, ConsoleBool, 3, 3, (border) return object->removeBorder(border); } +/*! Gets the default member cursor for a cursor category. + @param category The cursor category name (see getCursorCategoryNames). + @return The GuiCursor id, or 0 if the category is unknown. +*/ +ConsoleMethodWithDocs(GuiProfileTheme, getCursor, ConsoleInt, 3, 3, (category)) +{ + GuiCursor* cursor = object->getCursor(StringTable->insert(argv[2])); + return cursor != NULL ? cursor->getId() : 0; +} + +/*! Gets all member cursors for a category: the default first, then extras. + This is how a game picks between several cursors a theme offers for the same + job - Canvas.setCursor(getWord(%theme.getCursors("Default"), 1)). + @param category The cursor category name (see getCursorCategoryNames). + @return A space-separated list of GuiCursor ids. +*/ +ConsoleMethodWithDocs(GuiProfileTheme, getCursors, ConsoleString, 3, 3, (category)) +{ + StringTableEntry category = StringTable->insert(argv[2]); + + char* buffer = Con::getReturnBuffer(1024); + S32 offset = 0; + buffer[0] = '\0'; + + GuiCursor* cursor = object->getCursor(category); + if (cursor != NULL) + offset += dSprintf(buffer + offset, 1024 - offset, "%d", cursor->getId()); + + const Vector& extras = object->getExtraCursors(); + for (S32 i = 0; i < extras.size(); ++i) + { + if (extras[i]->mCategory != category) + continue; + offset += dSprintf(buffer + offset, 1024 - offset, "%s%d", offset > 0 ? " " : "", extras[i]->getId()); + } + + return buffer; +} + +/*! Gets the engine-defined cursor category names. The suffixes these produce + are also the canonical cursor names the engine falls back to when a control + names no cursor of its own. + @return A space-separated list of cursor category names. +*/ +ConsoleMethodWithDocs(GuiProfileTheme, getCursorCategoryNames, ConsoleString, 2, 2, ()) +{ + char* buffer = Con::getReturnBuffer(1024); + S32 offset = 0; + buffer[0] = '\0'; + + for (S32 i = 0; i < GuiProfileTheme::getCursorCategoryCount(); ++i) + offset += dSprintf(buffer + offset, 1024 - offset, "%s%s", i > 0 ? " " : "", GuiProfileTheme::getCursorCategoryName(i)); + + return buffer; +} + +/*! Gets the stock art file name a cursor category starts from + ("defaultCursor.png"). The editor asks so it can seed a theme's own cursor + folder; a theme never copies files itself. + @param category The cursor category name (see getCursorCategoryNames). + @return The stock file name, or "" if the category is unknown. +*/ +ConsoleMethodWithDocs(GuiProfileTheme, getCursorStockFile, ConsoleString, 3, 3, (category)) +{ + return GuiProfileTheme::getCursorStockFile(GuiProfileTheme::findCursorCategoryIndex(StringTable->insert(argv[2]))); +} + +/*! Gets the name the engine falls back to for a cursor category + ("EditCursor"). Installing a theme's cursors means registering copies of + them under these names; see AppCore::installThemeCursors. + @param category The cursor category name (see getCursorCategoryNames). + @return The canonical cursor name, or "" if the category is unknown. +*/ +ConsoleMethodWithDocs(GuiProfileTheme, getCursorCanonicalName, ConsoleString, 3, 3, (category)) +{ + return GuiProfileTheme::getCursorCanonicalName(GuiProfileTheme::findCursorCategoryIndex(StringTable->insert(argv[2]))); +} + +/*! Creates an extra member cursor in a category, alongside the default. A + category holding more than one cursor is what makes the Gui Editor offer a + choice on a control's cursor slot. + @param category The cursor category name (see getCursorCategoryNames). + @param name Optional object name; defaults to . + @return The new GuiCursor id, or 0 on failure. +*/ +ConsoleMethodWithDocs(GuiProfileTheme, createCursor, ConsoleInt, 3, 4, (category, [name])) +{ + GuiCursor* cursor = object->createCursor(argv[2], argc > 3 ? argv[3] : NULL); + return cursor != NULL ? cursor->getId() : 0; +} + +/*! Removes an extra member cursor. Default members are refused: a theme always + provides one cursor per category. + @param cursor The extra cursor to remove. + @return True if the cursor was an extra and was removed. +*/ +ConsoleMethodWithDocs(GuiProfileTheme, removeCursor, ConsoleBool, 3, 3, (cursor)) +{ + GuiCursor* cursor = dynamic_cast(Sim::findObject(argv[2])); + if (cursor == NULL) + { + Con::warnf("GuiProfileTheme::removeCursor() - could not find cursor '%s'.", argv[2]); + return false; + } + + return object->removeCursor(cursor); +} + /*! Clears a member's override on one field, so the field re-derives from the - theme. Accepts a member profile or border profile. + theme. Accepts a member profile, border profile or cursor. @param member The member profile or border profile. @param fieldName The field to reset. @return No return value. @@ -191,6 +299,14 @@ ConsoleMethodWithDocs(GuiProfileTheme, resetField, ConsoleVoid, 4, 4, (member, f return; } + GuiCursor* cursor = dynamic_cast(target); + if (cursor != NULL && cursor->getTheme() == object) + { + cursor->clearThemeFieldOverride(field); + object->restamp(); + return; + } + Con::warnf("GuiProfileTheme::resetField() - '%s' is not a member of this theme.", argv[2]); } @@ -219,6 +335,14 @@ ConsoleMethodWithDocs(GuiProfileTheme, resetProfile, ConsoleVoid, 3, 3, (member) return; } + GuiCursor* cursor = dynamic_cast(target); + if (cursor != NULL && cursor->getTheme() == object) + { + cursor->clearAllThemeOverrides(); + object->restamp(); + return; + } + Con::warnf("GuiProfileTheme::resetProfile() - '%s' is not a member of this theme.", argv[2]); } @@ -240,6 +364,10 @@ ConsoleMethodWithDocs(GuiProfileTheme, isFieldOverridden, ConsoleBool, 4, 4, (me if (border != NULL) return border->isThemeFieldOverridden(field); + GuiCursor* cursor = dynamic_cast(target); + if (cursor != NULL) + return cursor->isThemeFieldOverridden(field); + return false; } diff --git a/engine/source/gui/guiTextEditCtrl.cc b/engine/source/gui/guiTextEditCtrl.cc index e553d4aba..1fe9bd11c 100755 --- a/engine/source/gui/guiTextEditCtrl.cc +++ b/engine/source/gui/guiTextEditCtrl.cc @@ -45,7 +45,7 @@ GuiTextEditTextBlock::GuiTextEditTextBlock() mLineStartIbeamValue = 0; } -void GuiTextEditTextBlock::render(const RectI& bounds, string line, U32 ibeamStartValue, GuiControlProfile* profile, GuiControlState currentState, GuiTextEditSelection& selector, AlignmentType align, GFont* font, bool overrideFontColor) +void GuiTextEditTextBlock::render(const RectI& bounds, string line, U32 ibeamStartValue, GuiControlProfile* profile, GuiControlState currentState, GuiTextEditSelection& selector, AlignmentType align, GFont* font, bool isLastLine, bool overrideFontColor) { mGlobalBounds.set(bounds.point, bounds.extent); mText.assign(line); @@ -69,7 +69,7 @@ void GuiTextEditTextBlock::render(const RectI& bounds, string line, U32 ibeamSta } Point2I textStartPoint = getGlobalTextStart(); - if (selector.renderIbeam(textStartPoint, mGlobalBounds.extent, line, mLineStartIbeamValue, mLineStartIbeamValue + line.length(), profile, font)) + if (selector.renderIbeam(textStartPoint, mGlobalBounds.extent, line, mLineStartIbeamValue, mLineStartIbeamValue + line.length(), isLastLine, profile, font)) { Point2I cursorCenter = selector.getCursorCenter(); performScrollJumpX(cursorCenter.x, clipRect.point.x, clipRect.point.x + clipRect.extent.x); @@ -173,33 +173,60 @@ void GuiTextEditTextBlock::processTextAlignment(const string line, GFont* font, #pragma endregion #pragma region GuiTextEditSelection +// In declaration order, and every member once, so that a missing one shows. +// mBlockAnchor and mTextLength used to be missing, and mTextLength is the one +// every caret move clamps against: a caret could be placed anywhere at all in a +// box whose text had never been typed or clicked into, because the bound it was +// checked against was whatever had last used the memory. GuiTextEditSelection::GuiTextEditSelection() { + mBlockAnchor = 0; mBlockStart = 0; mBlockEnd = 0; mCursorPos = 0; - mCursorOn = false; - mNumFramesElapsed = 0; mCursorAtEOL = false; mIsFirstResponder = false; mGlobalUnadjustedCursorRect.set(0, 0, 0, 0); mCursorRendered = false; + mTextLength = 0; mNumFramesElapsed = 0; mTimeLastCursorFlipped = 0; mCursorOn = false; } -bool GuiTextEditSelection::renderIbeam(const Point2I& startPoint, const Point2I& extent, const string line, const U32 start, const U32 end, GuiControlProfile* profile, GFont* font) +// Whether this line draws the caret. The caret is a position BETWEEN two +// characters, so the seam between two lines is one position with two homes -- +// the end of the line above and the start of the line below -- and mCursorAtEOL +// says which of them it is. Exactly one line may answer yes, or the box blinks +// two carets at once. +// +// A seam needs both its lines to exist. The first line is recognizable on its +// own -- only it starts at 0 -- but the last one is not, because a line can end +// at the end of the text without being last: that is precisely what a trailing +// return makes, a line ending at the text's end followed by the empty line the +// caret has just moved to. So the caller says which line is last. Reading it +// off mCursorPos instead is what drew the second caret. +bool GuiTextEditSelection::isIbeamOnLine(const U32 start, const U32 end, const bool isLastLine) const { if (!mIsFirstResponder || !mCursorOn || - (mCursorAtEOL && mCursorPos == start && mCursorPos != 0) || - (!mCursorAtEOL && mCursorPos == end && mCursorPos != mTextLength) || + (mCursorAtEOL && mCursorPos == start && start != 0) || + (!mCursorAtEOL && mCursorPos == end && !isLastLine) || (mCursorPos < start || mCursorPos > end)) { return false; } + return true; +} + +bool GuiTextEditSelection::renderIbeam(const Point2I& startPoint, const Point2I& extent, const string line, const U32 start, const U32 end, const bool isLastLine, GuiControlProfile* profile, GFont* font) +{ + if (!isIbeamOnLine(start, end, isLastLine)) + { + return false; + } + string blockText = line.substr(0, mCursorPos - start); U32 blockStrWidth = font->getStrWidth(blockText.c_str()); RectI ibeamRect = RectI(startPoint.x + blockStrWidth - 1, startPoint.y, 2, extent.y); @@ -529,6 +556,13 @@ void GuiTextEditCtrl::setText( const UTF8 *txt ) else mTextBuffer.clear(); + // Every caret move clamps to the length the selection was last told, and + // every other path that changes the text is a keystroke or a click that + // says so itself. This is the one that isn't -- it is how a control loaded + // from TAML gets its text -- so without this a box could answer setCursorPos + // with a position outside the text it is holding. + mSelector.setTextLength(mTextBuffer.length()); + setVariable(mTextBuffer.c_str()); } @@ -867,6 +901,10 @@ void GuiTextEditCtrl::onUndo() mUndoText = tempText; mUndoSelector = tempSelector; + + // Every other path that changes the buffer reports it, so a Command + // watching the box live would have gone quiet on ctrl+Z alone. + execConsoleCallback(); } bool GuiTextEditCtrl::onKeyDown(const GuiEvent &event) @@ -1082,7 +1120,7 @@ void GuiTextEditCtrl::renderLineList(const Point2I& offset, const Point2I& exten { dglSetBitmapModulation(getFontColor(profile, NormalState)); } - mTextBlockList[i].render(blockBounds, lineList[i], ibeamPos, mProfile, getCurrentState(), mSelector, getAlignmentType(), font, mOverrideFontColor); + mTextBlockList[i].render(blockBounds, lineList[i], ibeamPos, mProfile, getCurrentState(), mSelector, getAlignmentType(), font, (i == (lineList.size() - 1)), mOverrideFontColor); offsetY += textHeight; ibeamPos += lineList[i].length(); @@ -1614,6 +1652,17 @@ bool GuiTextEditCtrl::handleEscapeKey() bool GuiTextEditCtrl::handleEnterKey() { + // Wrapping is what makes this control multi-line -- it is the flag that + // decides whether the text is one line or a paragraph -- so in a wrapped + // box return is a line break rather than "I am done with this field". + // It takes precedence over returnCommand and returnCausesTab, which are + // how a single-line box ends an edit; a box with a paragraph in it ends + // its edit by losing focus. + if (mTextWrap) + { + return insertNewLine(); + } + if (isMethod("onReturn")) Con::executef(this, 1, "onReturn"); @@ -1639,6 +1688,43 @@ bool GuiTextEditCtrl::handleEnterKey() return true; } +// A newline goes in the buffer directly rather than through +// handleCharacterInput: the font has no glyph for it, so isValidChar would +// refuse it, and no InputMode has an opinion about it worth honouring -- a +// Number-only box is single-line and never reaches this. +bool GuiTextEditCtrl::insertNewLine() +{ + saveUndoState(); + + if (mSelector.hasSelection()) + { + mSelector.eraseSelection(mTextBuffer); + } + + if (mTextBuffer.length() >= mMaxStrLen) + { + keyDenied(); + return true; + } + + // Always an insert, never an overwrite: there is no character in a line + // break for insert-off mode to replace. + mTextBuffer.insert(mSelector.getCursorPos(), "\n"); + mSelector.setTextLength(mTextBuffer.length()); + mSelector.stepCursorForward(); + + // The caret has just moved to the start of the new line, which is the far + // side of a seam it may have been sitting on: a click at the end of a line + // leaves the end-of-line flag set, and left set it would draw the caret on + // the line the user has just left. + mSelector.setCursorAtEOL(false); + + setText(mTextBuffer); + + execConsoleCallback(); + return true; +} + bool GuiTextEditCtrl::handleArrowKey(GuiDirection direction) { if (direction == GuiDirection::Left) diff --git a/engine/source/gui/guiTextEditCtrl.h b/engine/source/gui/guiTextEditCtrl.h index e9e5c60c8..4f2d482d3 100755 --- a/engine/source/gui/guiTextEditCtrl.h +++ b/engine/source/gui/guiTextEditCtrl.h @@ -62,7 +62,8 @@ class GuiTextEditSelection void selectTo(const U32 target); inline bool hasSelection() { return mBlockEnd > mBlockStart; } void onPreRender(const U32 time); - bool renderIbeam(const Point2I& startPoint, const Point2I& extent, const string line, const U32 start, const U32 end, GuiControlProfile* profile, GFont* font); + bool isIbeamOnLine(const U32 start, const U32 end, const bool isLastLine) const; + bool renderIbeam(const Point2I& startPoint, const Point2I& extent, const string line, const U32 start, const U32 end, const bool isLastLine, GuiControlProfile* profile, GFont* font); inline string getSelection(const string& fullText) { return hasSelection() ? fullText.substr(mBlockStart, mBlockEnd - mBlockStart) : string(); } void eraseSelection(string& fullText); void stepCursorForward(); @@ -85,7 +86,7 @@ class GuiTextEditTextBlock inline const U32 getStartValue() const { return mLineStartIbeamValue; } inline RectI getGlobalBounds() const { return mGlobalBounds; } inline Point2I getGlobalTextStart() { return Point2I(mGlobalBounds.point.x + mTextOffsetX - mTextScrollX, mGlobalBounds.point.y); } - void render(const RectI& bounds, string line, U32 ibeamStartValue, GuiControlProfile* profile, GuiControlState currentState, GuiTextEditSelection& selector, AlignmentType align, GFont* font, bool overrideFontColor = false); + void render(const RectI& bounds, string line, U32 ibeamStartValue, GuiControlProfile* profile, GuiControlState currentState, GuiTextEditSelection& selector, AlignmentType align, GFont* font, bool isLastLine, bool overrideFontColor = false); U32 renderTextSection(const Point2I& startPoint, const U32 subStrStart, const U32 subStrLen, GuiControlProfile* profile, const GuiControlState currentState, GFont* font, bool isSelectedText = false, bool overrideFontColor = false); void performScrollJumpX(const S32 targetX, const S32 areaStart, const S32 areaEnd); U32 calculateIbeamPositionInLine(const S32 targetX, GFont* font); @@ -166,6 +167,7 @@ class GuiTextEditCtrl : public GuiControl virtual bool handleCharacterInput(const GuiEvent& event); virtual bool handleEscapeKey(); virtual bool handleEnterKey(); + virtual bool insertNewLine(); virtual bool handleArrowKey(GuiDirection direction); virtual bool handleShiftArrowKey(GuiDirection direction); virtual bool handleBackSpace(); diff --git a/engine/source/gui/guiTreeViewCtrl.cc b/engine/source/gui/guiTreeViewCtrl.cc index c0abddbad..08f36ef4f 100755 --- a/engine/source/gui/guiTreeViewCtrl.cc +++ b/engine/source/gui/guiTreeViewCtrl.cc @@ -33,7 +33,13 @@ IMPLEMENT_CONOBJECT(GuiTreeViewCtrl); GuiTreeViewCtrl::GuiTreeViewCtrl() { mActive = true; - mIndentSize = 10; + // Zero is "one row height", which is the step the tree has always used. The + // field sat here unread for years holding 10; wiring it up without resetting + // it would have re-indented every tree in the engine as a side effect. + mIndentSize = 0; + mIconImageAssetID = StringTable->EmptyString; + mIconImageAsset = NULL; + mIconSize = 16; mMultipleSelections = true; mTouchPoint = Point2I::Zero; mDragActive = false; @@ -48,6 +54,118 @@ GuiTreeViewCtrl::~GuiTreeViewCtrl() { } +S32 GuiTreeViewCtrl::resolveIndent(S32 indentSize, S32 rowInnerHeight) +{ + // A row height is the historical step and stays the default, so a tree that + // says nothing indents exactly as it always did. Neither answer may go + // negative: a row too short to have an inside would otherwise walk the tree + // backwards, one level at a time. + const S32 indent = (indentSize > 0) ? indentSize : rowInnerHeight; + return (indent > 0) ? indent : 0; +} + +S32 GuiTreeViewCtrl::focusLineOffset(S32 rowInnerHeight) +{ + const S32 offset = (rowInnerHeight - smFocusLineWidth) / 2; + return (offset > 0) ? offset : 0; +} + +bool GuiTreeViewCtrl::iconSlot(const RectI& contentRect, S32 iconSize, RectI& dstOut, S32& advanceOut) +{ + advanceOut = 0; + if (iconSize <= 0 || contentRect.extent.y <= 0) + { + return false; + } + + // Never enlarge. The art is drawn for one size and blowing it up is what + // looks soft, so a row shorter than the icon gets the icon shrunk to it. + const S32 size = getMin(iconSize, contentRect.extent.y); + const S32 advance = size + smIconGap; + if (size <= 0 || contentRect.extent.x < advance) + { + // No room for the icon and a space after it. Consume nothing, so the row + // falls back to plain text rather than to text drawn over an icon. + return false; + } + + dstOut.set(Point2I(contentRect.point.x, contentRect.point.y + ((contentRect.extent.y - size) / 2)), + Point2I(size, size)); + advanceOut = advance; + return true; +} + +void GuiTreeViewCtrl::setIconImageAsset(const char* pImageAssetID) +{ + // Sanity! + AssertFatal(pImageAssetID != NULL, "Cannot use a NULL asset ID."); + + mIconImageAssetID = StringTable->insert(pImageAssetID); + + // Unlike a profile's sheet there is no refcount to wait on: a tree draws its + // own icons rather than lending them to whatever wears it, so resolve now. + // An empty id clears, which is how a tree turns icons back off. + if (mIconImageAssetID != StringTable->EmptyString) + { + mIconImageAsset = pImageAssetID; + } + else + { + mIconImageAsset.clear(); + } +} + +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()) + { + return; + } + + RectI dst; + S32 advance = 0; + if (!iconSlot(contentRect, mIconSize, dst, advance)) + { + return; + } + + // The modulation is still the row's font color, set before any of this drew. + drawIconFrame(dst, mIconImageAsset, (U32)treeItem->iconFrame); + + contentRect.point.x += advance; + contentRect.extent.x -= advance; +} + +S32 GuiTreeViewCtrl::getObjectIconFrame(SimObject* obj) +{ + // No sheet means no icons at all, so do not trouble script for an answer we + // would only throw away. + if (!obj || mIconImageAsset.isNull() || !isMethod("onGetItemIcon")) + { + return -1; + } + + const char* frame = Con::executef(this, 2, "onGetItemIcon", Con::getIntArg(obj->getId())); + if (!frame || !frame[0]) + { + return -1; + } + return dAtoi(frame); +} + GuiTreeViewCtrl::TreeItem* GuiTreeViewCtrl::grabItemPtr(S32 index) { // Range Check @@ -70,6 +188,15 @@ void GuiTreeViewCtrl::initPersistFields() // It defaults off so trees holding non-GuiControl items (e.g. the Profile // Editor's proxy tree) can never enter the reorder path; opt in explicitly. addField("AllowReorder", TypeBool, Offset(mAllowReorder, GuiTreeViewCtrl)); + // How far one level of depth steps a row in. Zero - the default - is one row + // height, which is what the tree has always done; a narrow tree carrying + // other things in the row can buy the width back by naming a smaller step. + addField("IndentSize", TypeS32, Offset(mIndentSize, GuiTreeViewCtrl), "Pixels per level of depth. 0 uses the row height."); + // A sheet of small pictures, one per row, drawn between the triangle and the + // text. Which frame a row wears is script's answer to onGetItemIcon; with no + // sheet set the question is never asked and no width is spent. + addProtectedField("IconImage", TypeAssetId, Offset(mIconImageAssetID, GuiTreeViewCtrl), &setIconImage, &getIconImage, "The image asset a row's icon is a frame of."); + addField("IconSize", TypeS32, Offset(mIconSize, GuiTreeViewCtrl), "How big to draw a row's icon. The art is never enlarged past this."); } S32 GuiTreeViewCtrl::getAdjacentVisibleIndex(S32 fromIndex, S32 direction) @@ -257,11 +384,10 @@ SimObject* GuiTreeViewCtrl::getItemObject(TreeItem* item) return item ? static_cast(item->itemData) : nullptr; } -void GuiTreeViewCtrl::reorderFromDrag() +SimGroup* GuiTreeViewCtrl::resolveDropTarget(TreeItem* dragItem) { - TreeItem* dragItem = grabItemPtr(mDragIndex); if (!dragItem) - return; + return NULL; if (mReorderMethod == ReorderMethod::Below && dragItem->isOpen) { @@ -270,20 +396,63 @@ void GuiTreeViewCtrl::reorderFromDrag() // The container the dragged items land in: the drag item itself when // inserting into it, otherwise the drag item's parent branch. For a - // non-Insert drop at the root there is no parent branch, so bail. + // non-Insert drop at the root there is no parent branch. TreeItem* targetItem = (mReorderMethod == ReorderMethod::Insert) ? dragItem : dragItem->trunk; if (!targetItem) - return; + return NULL; // The drop target must be a real SimGroup (a GuiControl hierarchy). If the // item's object isn't one - or was deleted - there is nothing to reorder. - SimGroup* target = dynamic_cast(getItemObject(targetItem)); + return dynamic_cast(getItemObject(targetItem)); +} + +bool GuiTreeViewCtrl::selectionAcceptsTarget(SimGroup* target) +{ + // A non-GuiControl target answers NULL here, which is the right question to + // ask: a control that insists on a particular kind of parent is not at home + // in a bare SimGroup either. + GuiControl* parent = dynamic_cast(target); + + for (S32 i = 0; i < mItems.size(); i++) + { + TreeItem* treeItem = dynamic_cast(mItems[i]); + if (treeItem && treeItem->isSelected) + { + GuiControl* ctrl = dynamic_cast(getItemObject(treeItem)); + if (ctrl && !ctrl->canBeChildOf(parent)) + return false; + } + } + + return true; +} + +void GuiTreeViewCtrl::reorderFromDrag() +{ + TreeItem* dragItem = grabItemPtr(mDragIndex); + if (!dragItem) + return; + + SimGroup* target = resolveDropTarget(dragItem); if (!target) { Con::warnf("GuiTreeViewCtrl::reorderFromDrag - drop target is not a SimGroup; ignoring reorder"); return; } + // The drag indicator has already refused this, so reaching it means the + // hover and the drop disagreed. Bail rather than move a control somewhere it + // said it does not belong. + if (!selectionAcceptsTarget(target)) + return; + + // Past every bail, so the rearrangement below is certain to happen. A drop + // can move any number of selected items into any number of containers, and + // the only record of where they came from is the hierarchy itself - hence a + // pair, rather than one callback afterwards. The Gui Editor's tree listens + // to both and turns the difference into an undo step. + Con::executef(this, 1, "onPreReorder"); + vector objectAboveTargetList; if (mReorderMethod != ReorderMethod::Insert) { @@ -316,7 +485,15 @@ void GuiTreeViewCtrl::reorderFromDrag() if (obj) { target->addObject(obj); - target->bringObjectToFront(obj); + + // A container is allowed to turn a child away inside addObject - + // a tab book re-homes anything that is not a page - so the object + // may not be in the target at all. bringObjectToFront reads + // front() to build its argument, before reOrder gets the chance + // to notice the object is not a member, and front() on an empty + // list dereferences nothing at all. + if (target->isMember(obj)) + target->bringObjectToFront(obj); } } } @@ -328,13 +505,15 @@ void GuiTreeViewCtrl::reorderFromDrag() group->bringObjectToFront(obj); } - // target is the same object as the container above; reorder its children. - GuiControl* control = dynamic_cast(getItemObject(targetItem)); + // The container the items landed in; tell it its children moved. + GuiControl* control = dynamic_cast(target); if (control) { control->childrenReordered(); } + Con::executef(this, 1, "onPostReorder"); + refreshTree(); } @@ -462,26 +641,37 @@ void GuiTreeViewCtrl::onRenderItem(RectI& itemRect, LBItem* item) RectI fillRect = applyBorders(ctrlRect.point, ctrlRect.extent, currentState, mProfile); RectI contentRect = applyPadding(fillRect.point, fillRect.extent, currentState, mProfile); + // Anything pinned to the row's left edge goes in here: before the focus line + // and before the depth indent, so it stays put instead of travelling with the + // tree. The base draws nothing and carves nothing. + renderItemGutter(itemRect, contentRect, treeItem, currentState); + if (contentRect.extent.x <= 0) + { + return; + } + + const S32 indent = resolveIndent(mIndentSize, contentRect.extent.y); + //indent to the focus level if(mFocusLevel >= 0) { - contentRect.point.x += (mFocusLevel * contentRect.extent.y); - contentRect.extent.x -= (mFocusLevel * contentRect.extent.y); + contentRect.point.x += (mFocusLevel * indent); + contentRect.extent.x -= (mFocusLevel * indent); - //convert this space to a line by crushing down the sides - S32 crush = mRound((contentRect.extent.y - 2) / 2); - RectI line = RectI(contentRect.point.x + crush, contentRect.point.y, 2, contentRect.extent.y); + // Crushed down to a line, hanging from the triangle's point. + S32 crush = focusLineOffset(contentRect.extent.y); + RectI line = RectI(contentRect.point.x + crush, contentRect.point.y, smFocusLineWidth, contentRect.extent.y); ColorI lineColor = currentState == SelectedState ? mProfile->getFillColor(NormalState) : mProfile->getFillColor(SelectedState); dglDrawRectFill(line, lineColor); //Remove indent - contentRect.point.x -= (mFocusLevel * contentRect.extent.y); - contentRect.extent.x += (mFocusLevel * contentRect.extent.y); + contentRect.point.x -= (mFocusLevel * indent); + contentRect.extent.x += (mFocusLevel * indent); } // Indent by level - contentRect.point.x += (treeItem->level * contentRect.extent.y); - contentRect.extent.x -= (treeItem->level * contentRect.extent.y); + contentRect.point.x += (treeItem->level * indent); + contentRect.extent.x -= (treeItem->level * indent); // Render open/close triangle if(obj) @@ -504,6 +694,10 @@ void GuiTreeViewCtrl::onRenderItem(RectI& itemRect, LBItem* item) contentRect.point.x += contentRect.extent.y; contentRect.extent.x -= contentRect.extent.y; + // The row's own picture, between the triangle and the words. The base draws + // nothing and carves nothing. + renderItemIcon(contentRect, treeItem, currentState); + renderText(contentRect.point, contentRect.extent, item->itemText, mProfile); } @@ -582,6 +776,15 @@ S32 GuiTreeViewCtrl::getHitIndex(const GuiEvent& event) } } + // And the controls get a say in where they are put. A drop the + // tree would refuse must not draw an indicator promising it - + // nothing happening is a great deal harder to read than no line + // appearing in the first place. + if (mIsDragLegal && !selectionAcceptsTarget(resolveDropTarget(treeItem))) + { + mIsDragLegal = false; + } + mDragIndex = j; return i; } @@ -640,6 +843,7 @@ void GuiTreeViewCtrl::inspectObject(SimObject* obj) S32 id = addItemWithID(text, obj->getId(), obj); TreeItem* treeItem = grabItemPtr(id); treeItem->level = 0; + treeItem->iconFrame = getObjectIconFrame(obj); addBranches(treeItem, obj, 1); } @@ -661,6 +865,7 @@ void GuiTreeViewCtrl::addBranches(TreeItem* treeItem, SimObject* obj, U16 level) TreeItem* branch = grabItemPtr(index); branch->level = level; branch->trunk = treeItem; + branch->iconFrame = getObjectIconFrame(sub); treeItem->branchList.push_back(branch); addBranches(branch, sub, level + 1); @@ -739,7 +944,10 @@ void GuiTreeViewCtrl::refreshTree() StringTableEntry GuiTreeViewCtrl::getObjectText(SimObject* obj) { - char buffer[1024]; + // Empty rather than uninitialised: the fall-through at the bottom returns + // this buffer whether or not the block below filled it, and a null object + // used to hand the string table whatever was on the stack. + char buffer[1024] = { 0 }; if (obj) { if (isMethod("onGetObjectText")) @@ -779,23 +987,17 @@ StringTableEntry GuiTreeViewCtrl::getObjectText(SimObject* obj) return StringTable->insert(buffer, true); } -void GuiTreeViewCtrl::calculateHeaderExtent() +void GuiTreeViewCtrl::refreshItem(S32 index) { - if(mProfile) + TreeItem* treeItem = grabItemPtr(index); + if (!treeItem) { - GuiBorderProfile* topProfile = mProfile->getTopBorder(); - GuiBorderProfile* bottomProfile = mProfile->getBottomBorder(); - - S32 topSize = (topProfile) ? topProfile->getMargin(NormalState) + topProfile->getBorder(NormalState) + topProfile->getPadding(NormalState) : 0; - S32 bottomSize = (bottomProfile) ? bottomProfile->getMargin(NormalState) + bottomProfile->getBorder(NormalState) + bottomProfile->getPadding(NormalState) : 0; - - GFont* font = mProfile->getFont(); - S32 fontSize = (font) ? font->getHeight() : 0; - - S32 height = topSize + bottomSize + fontSize; - S32 width = mBounds.extent.x; - + return; } + + SimObject* obj = getItemObject(treeItem); + setItemText(index, getObjectText(obj)); + treeItem->iconFrame = getObjectIconFrame(obj); } void GuiTreeViewCtrl::updateSize() diff --git a/engine/source/gui/guiTreeViewCtrl.h b/engine/source/gui/guiTreeViewCtrl.h index 8a0d64923..ca6c48194 100755 --- a/engine/source/gui/guiTreeViewCtrl.h +++ b/engine/source/gui/guiTreeViewCtrl.h @@ -39,6 +39,12 @@ class GuiTreeViewCtrl : public GuiListBoxCtrl protected: SimObjectPtr mRootObject; S32 mIndentSize; + /// The sheet a row's icon is a frame of, and how big to draw it. Empty by + /// default: with no sheet a row asks script for nothing, draws nothing and + /// costs nothing, so a tree that wants no icons is exactly as it was. + StringTableEntry mIconImageAssetID; + AssetPtr mIconImageAsset; + S32 mIconSize; Point2I mTouchPoint; bool mDragActive; S32 mDragIndex; @@ -56,7 +62,7 @@ class GuiTreeViewCtrl : public GuiListBoxCtrl class TreeItem : public GuiListBoxCtrl::LBItem { public: - TreeItem() : isOpen(1), level(0), triangleArea(RectI()), isVisible(1), branchList(vector()), trunk(nullptr) { } + TreeItem() : isOpen(1), level(0), triangleArea(RectI()), isVisible(1), branchList(vector()), trunk(nullptr), iconFrame(-1) { } virtual ~TreeItem() { } bool isOpen; @@ -65,17 +71,102 @@ class GuiTreeViewCtrl : public GuiListBoxCtrl bool isVisible; vector branchList; TreeItem* trunk; + /// Which frame of the tree's icon sheet this row draws, or -1 for none. + /// Asked of script once when the row is built rather than every frame - + /// onRenderItem runs for every visible row of every frame, so a callback + /// here would be a console call per row per frame. + S32 iconFrame; }; -private: + /// The per-level indent step. Zero means "one row height", which is what the + /// tree has always done and so is the default; a positive value is used as + /// given. Static, and taking both numbers, so it can be tested away from a + /// canvas, a Sim and a GL context - the same reason GuiScrollCtrl's bar + /// arithmetic is two statics. + static S32 resolveIndent(S32 indentSize, S32 rowInnerHeight); + + /// Where an icon of iconSize draws inside contentRect, and what it costs in + /// width. Never enlarges: a row shorter than the art shrinks the art rather + /// than promising a slot it cannot hold. Answers false - consuming nothing - + /// when there is no room at all, so a cramped tree degrades to plain rows + /// instead of to text drawn over an icon. + static bool iconSlot(const RectI& contentRect, S32 iconSize, RectI& dstOut, S32& advanceOut); + + /// Where the focus line's 2px rule sits, measured from the left of the focus + /// level's slot. + /// + /// Centred on the TRIANGLE's square rather than on the indent step, because + /// the line hangs down from a container's triangle and should line up with + /// its point. The two were the same number until IndentSize became settable, + /// which is exactly how they silently stopped agreeing. + static S32 focusLineOffset(S32 rowInnerHeight); + + /// Width of the focus line itself. + static constexpr S32 smFocusLineWidth = 2; + + /// Air between the icon and the text it labels. Four rather than two: the art + /// on these sheets bleeds to the tile edge on purpose, so two pixels of gap + /// reads as none and the picture runs into the first letter. + /// + /// constexpr rather than const: a test asserting against it binds it to a + /// const reference, which would odr-use a plain static const and fail to link + /// for want of a definition. + static constexpr S32 smIconGap = 4; + +protected: + // Reachable by a subclass: a render hook is handed a TreeItem and needs the + // SimObject behind it. Neither is something a caller outside the hierarchy + // should be doing, so neither is public. TreeItem* grabItemPtr(S32 index); // The tree always stores a SimObject* in LBItem::itemData; recover it // safely (item may be null) so callers can dynamic_cast to the real type. SimObject* getItemObject(TreeItem* item); + + /// Two seams in a row, so a subclass can add to one without copying the whole + /// of onRenderItem - which could not be copied faithfully anyway, the focus + /// line being driven by private state. Both are handed contentRect by + /// reference and may carve space off its left; every step after them uses + /// what is left. + /// + /// renderItemGutter runs BEFORE the focus line and the depth indent, so what + /// it draws stays pinned to the row's left edge instead of travelling with + /// the tree. renderItemIcon runs between the triangle and the text. + 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. + S32 getObjectIconFrame(SimObject* obj); + + void setIconImageAsset(const char* pImageAssetID); + inline StringTableEntry getIconImageAsset(void) const { return mIconImageAssetID; } + static bool setIconImage(void* obj, const char* data) { static_cast(obj)->setIconImageAsset(data); return false; } + static const char* getIconImage(void* obj, const char* data) { return static_cast(obj)->getIconImageAsset(); } + +private: // Commits a completed drag-reorder. Fully guarded: bails on any missing or // wrong-typed item rather than trusting itemData is a GuiControl/SimGroup. void reorderFromDrag(); + // The container a drop on the given row would land in: the row itself when + // inserting into it, otherwise its parent branch. Both the hover, which draws + // the drop indicator, and the drop itself resolve it through here, so the + // indicator cannot promise a target the drop then refuses. + SimGroup* resolveDropTarget(TreeItem* dragItem); + // Whether every selected control would accept that container as a parent. + // GuiControl::canBeChildOf is the question; a tab page is what says no. + bool selectionAcceptsTarget(SimGroup* target); + // Keyboard navigation has to work in visible-row space. Collapsed branches // stay in mItems with isVisible false and never render, so stepping raw // indices - which is what the list box base class does - walks the selection @@ -91,6 +182,13 @@ class GuiTreeViewCtrl : public GuiListBoxCtrl bool itemHasBranches(S32 index); public: + /// A tree's rows are not its own to save. Every TreeItem is generated from + /// mRootObject - inspectObject builds the lot and rebuilds them whenever the + /// object under them changes - so a set written into the .gui.taml would be + /// stale the moment the tree next built itself, and would then be thrown away + /// unread. The list box's static rows stop here. + virtual bool writesItems() { return false; } + // GuiControl //bool onWake(); //void onSleep(); @@ -124,7 +222,11 @@ class GuiTreeViewCtrl : public GuiListBoxCtrl void addBranches(TreeItem* treeItem, SimObject* obj, U16 level); void refreshTree(); StringTableEntry getObjectText(SimObject* obj); - void calculateHeaderExtent(); + /// Re-asks the inspected object for one row's text and icon. A row's picture + /// can change without the row moving - a bare GuiControl re-profiled from a + /// panel to a label is still the same object in the same place - so the two + /// have to be refreshable together. + void refreshItem(S32 index); virtual GuiListBoxCtrl::LBItem* createItem(); void setBranchesVisible(TreeItem* treeItem, bool isVisible); void setItemOpen(S32 index, bool isOpen); diff --git a/engine/source/gui/guiTreeViewCtrl_ScriptBinding.h b/engine/source/gui/guiTreeViewCtrl_ScriptBinding.h index 54d1a198c..5001d0bdc 100644 --- a/engine/source/gui/guiTreeViewCtrl_ScriptBinding.h +++ b/engine/source/gui/guiTreeViewCtrl_ScriptBinding.h @@ -115,4 +115,38 @@ ConsoleMethodWithDocs(GuiTreeViewCtrl, refreshItemText, ConsoleVoid, 3, 3, "(S32 object->setItemText(index, object->getObjectText(sub)); } +/*! Refreshes both the text and the icon of the item based on the inspected + object. A control's picture can change without the row moving - re-profiling + a bare GuiControl from a panel to a label is still the same object in the + same place - so prefer this over refreshItemText. + @param index The zero-based index of the item that will be updated. + @return No return value. +*/ +ConsoleMethodWithDocs(GuiTreeViewCtrl, refreshItem, ConsoleVoid, 3, 3, "(S32 index)") +{ + S32 index = dAtoi(argv[2]); + if (index < 0 || index >= object->mItems.size()) + { + Con::warnf("GuiTreeViewCtrl::refreshItem() - Invalid index given."); + return; + } + object->refreshItem(index); +} + +/*! Gets the icon frame the given item is wearing, or -1 if it has none. + @param index The zero-based index of the item. + @return The frame index into the tree's IconImage sheet. +*/ +ConsoleMethodWithDocs(GuiTreeViewCtrl, getItemIcon, ConsoleInt, 3, 3, "(S32 index)") +{ + S32 index = dAtoi(argv[2]); + if (index < 0 || index >= object->mItems.size()) + { + Con::warnf("GuiTreeViewCtrl::getItemIcon() - Invalid index given."); + return -1; + } + GuiTreeViewCtrl::TreeItem* treeItem = dynamic_cast(object->mItems[index]); + return treeItem ? treeItem->iconFrame : -1; +} + ConsoleMethodGroupEndWithDocs(GuiTreeViewCtrl) \ No newline at end of file diff --git a/engine/source/gui/guiTypes.cc b/engine/source/gui/guiTypes.cc index 1e461e340..b315e7d62 100755 --- a/engine/source/gui/guiTypes.cc +++ b/engine/source/gui/guiTypes.cc @@ -33,6 +33,21 @@ #include "graphics/TextureManager.h" // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- // +//------------------------------------------------------------------------------ +// The theme-managed field names, shared by the membership glue on GuiCursor, +// GuiBorderProfile and GuiControlProfile. +static StringTableEntry themeCategoryField() +{ + static StringTableEntry categoryField = StringTable->insert("category"); + return categoryField; +} + +static StringTableEntry themeOverridesField() +{ + static StringTableEntry overridesField = StringTable->insert("themeOverrides"); + return overridesField; +} + IMPLEMENT_CONOBJECT(GuiCursor); GuiCursor::GuiCursor() @@ -40,6 +55,12 @@ GuiCursor::GuiCursor() mHotSpot.set(0,0); mRenderOffset.set(0.0f,0.0f); mExtent.set(1,1); + mBitmapName = StringTable->EmptyString; + + // White is the identity for bitmap modulation, so an untinted cursor draws + // exactly as it did before this field existed. + mColor.set(255, 255, 255, 255); + mCategory = StringTable->EmptyString; } GuiCursor::~GuiCursor() @@ -49,9 +70,22 @@ GuiCursor::~GuiCursor() void GuiCursor::initPersistFields() { Parent::initPersistFields(); + + // hotSpot and renderOffset both shift where the art lands, and they are not + // redundant. hotSpot is a pixel nudge; renderOffset is a fraction of the + // bitmap's own size, so "0.5 0.5" means "centered on the pointer" whatever + // the art measures. That is what stops a 13x17 arrow and a 32x32 sizer from + // appearing to leap when one replaces the other. The pointer ends up at + // hotSpot + (extent * renderOffset) within the image; see render(). addField("hotSpot", TypePoint2I, Offset(mHotSpot, GuiCursor)); addField("renderOffset",TypePoint2F, Offset(mRenderOffset, GuiCursor)); - addField("bitmapName", TypeFilename, Offset(mBitmapName, GuiCursor)); + // Written relative to the game root; see getRelativeBitmapName. + addProtectedField("bitmapName", TypeFilename, Offset(mBitmapName, GuiCursor), &defaultProtectedSetFn, &getBitmapName, "Bitmap drawn for this cursor"); + addField("color", TypeColorI, Offset(mColor, GuiCursor)); + + addField("category", TypeString, Offset(mCategory, GuiCursor)); + // The offset is unused: both accessors are supplied and the setter returns false. + addProtectedField("themeOverrides", TypeString, Offset(mCategory, GuiCursor), &setThemeOverrides, &getThemeOverrides, "Theme-overridden field names"); } bool GuiCursor::onAdd() @@ -69,16 +103,24 @@ void GuiCursor::onRemove() Parent::onRemove(); } -void GuiCursor::render(const Point2I &pos) +const Point2I& GuiCursor::resolve() { if (!mTextureHandle && mBitmapName && mBitmapName[0]) { mTextureHandle = TextureHandle(mBitmapName, TextureHandle::BitmapTexture); - if(!mTextureHandle) - return; - mExtent.set(mTextureHandle.getWidth(), mTextureHandle.getHeight()); + if (mTextureHandle) + mExtent.set(mTextureHandle.getWidth(), mTextureHandle.getHeight()); } + return mExtent; +} + +void GuiCursor::render(const Point2I &pos) +{ + resolve(); + if(!mTextureHandle) + return; + // Render the cursor centered according to dimensions of texture S32 texWidth = mTextureHandle.getWidth(); S32 texHeight = mTextureHandle.getHeight(); @@ -86,9 +128,115 @@ void GuiCursor::render(const Point2I &pos) Point2I renderPos = pos; renderPos.x -= (S32)( texWidth * mRenderOffset.x ); renderPos.y -= (S32)( texHeight * mRenderOffset.y ); - - dglClearBitmapModulation(); + + // The stock cursors are grayscale - black outline, white body - so this + // colors the body and leaves the outline. mColor starts white, which is what + // dglClearBitmapModulation sets, so an untinted cursor is unaffected. + dglSetBitmapModulation(mColor); dglDrawBitmap(mTextureHandle, renderPos); + dglClearBitmapModulation(); +} + +StringTableEntry GuiCursor::getRelativeBitmapName( void ) const +{ + if (mBitmapName == NULL || mBitmapName == StringTable->EmptyString) + return mBitmapName; + + // Only a path inside the game is made relative. Art somewhere else - another + // drive, a folder beside the repository - is left alone: a "../.." chain + // climbing out of the game folder is no more portable than the absolute path + // it came from. Same rule as GuiControlProfile's bitmap. + StringTableEntry gameRoot = Platform::getMainDotCsDir(); + if (!Con::isBasePath(mBitmapName, gameRoot)) + return mBitmapName; + + return Platform::makeRelativePathName(mBitmapName, gameRoot); +} + +void GuiCursor::setTheme(GuiProfileTheme* theme, bool preserveOverrides) +{ + if (mThemeMembership.mTheme == theme) + return; + + if (mThemeMembership.mTheme != NULL) + clearNotify(mThemeMembership.mTheme); + + mThemeMembership.mTheme = theme; + if (!preserveOverrides) + mThemeMembership.clearAll(); + + if (theme != NULL) + deleteNotify(theme); +} + +// A cursor's art - which bitmap, and where the pointer sits within it - is the +// one thing a theme cannot derive from a palette, so a theme sets these once +// when it creates the member and never stamps them again. That makes them +// unlike every other member field: there is no theme value behind them to +// override or to reset to, and they must persist whether or not anything marked +// them. Only "color" is stamped, and only it takes part in override tracking. +static bool isCursorArtField(StringTableEntry field) +{ + static StringTableEntry bitmapField = StringTable->insert("bitmapName"); + static StringTableEntry hotSpotField = StringTable->insert("hotSpot"); + static StringTableEntry renderOffsetField = StringTable->insert("renderOffset"); + + return field == bitmapField || field == hotSpotField || field == renderOffsetField; +} + +void GuiCursor::onStaticModified(const char* slotName, const char* newValue) +{ + Parent::onStaticModified(slotName, newValue); + + StringTableEntry slot = StringTable->insert(slotName); + + // The texture is loaded once and cached, so pointing a live cursor at new + // art has to drop it - otherwise the old bitmap draws forever and the + // extent, which the hot spot is measured against, stays wrong. + static StringTableEntry bitmapField = StringTable->insert("bitmapName"); + if (slot == bitmapField) + { + mTextureHandle = TextureHandle(); + mExtent.set(1, 1); + } + + // On a themed cursor, an external write to a stamped field becomes an + // override that stamping will preserve. Category, the override list and the + // art fields are not stamped, so they are not overridable. + if (mThemeMembership.mTheme != NULL) + { + if (slot != themeCategoryField() && slot != themeOverridesField() && !isCursorArtField(slot)) + mThemeMembership.markOverride(slot); + } +} + +bool GuiCursor::writeField(StringTableEntry fieldname, const char* value) +{ + if (!Parent::writeField(fieldname, value)) + return false; + + // Themed cursors persist their art, their category and the override list + // unconditionally; everything else is derived from the theme and persists + // only when explicitly overridden. + if (mThemeMembership.mTheme != NULL) + { + if (fieldname != themeCategoryField() && + fieldname != themeOverridesField() && + !isCursorArtField(fieldname) && + findField(fieldname) != NULL && + !mThemeMembership.isOverridden(fieldname)) + return false; + } + + return true; +} + +void GuiCursor::onDeleteNotify(SimObject* object) +{ + if (object == (SimObject*)mThemeMembership.mTheme) + mThemeMembership.mTheme = NULL; + + Parent::onDeleteNotify(object); } // Setup the type, this will keep Border profiles from being listed with normal profiles. @@ -131,21 +279,6 @@ ConsoleGetType(TypeGuiCursor) return returnBuffer; } -//------------------------------------------------------------------------------ -// The theme-managed field names, shared by the membership glue on -// GuiBorderProfile and GuiControlProfile. -static StringTableEntry themeCategoryField() -{ - static StringTableEntry categoryField = StringTable->insert("category"); - return categoryField; -} - -static StringTableEntry themeOverridesField() -{ - static StringTableEntry overridesField = StringTable->insert("themeOverrides"); - return overridesField; -} - IMPLEMENT_CONOBJECT(GuiBorderProfile); GuiBorderProfile::GuiBorderProfile() diff --git a/engine/source/gui/guiTypes.h b/engine/source/gui/guiTypes.h index bb804f0a7..2540f991b 100755 --- a/engine/source/gui/guiTypes.h +++ b/engine/source/gui/guiTypes.h @@ -129,6 +129,11 @@ enum VertAlignmentType DefaultVAlign }; +/// The pointer the canvas draws. A control names one through a TypeGuiCursor +/// field (a text edit's editCursor, a window's resize cursors); anything that +/// names none falls back to the canonical name for its kind - "EditCursor", +/// "LeftRightCursor" and the rest - which is what a GuiProfileTheme's cursor +/// members are installed under. See GuiProfileTheme::smCursorCategories. class GuiCursor : public SimObject { private: @@ -140,9 +145,28 @@ class GuiCursor : public SimObject Point2I mExtent; TextureHandle mTextureHandle; + GuiThemeMembership mThemeMembership; ///< Theme membership and per-field override tracking. + public: + /// Multiplied into the bitmap as it is drawn. The stock art is grayscale - + /// black outline, white body - so a tint colors the body and leaves the + /// outline alone, which is what lets a theme skin the stock cursors without + /// anyone drawing new ones. White is the identity (dglClearBitmapModulation + /// is exactly white), so a cursor that never sets this renders as it always did. + ColorI mColor; + + StringTableEntry mCategory; ///< The theme category this cursor belongs to. See GuiProfileTheme. + Point2I getHotSpot() { return mHotSpot; } Point2I getExtent() { return mExtent; } + Point2F getRenderOffset() { return mRenderOffset; } + + // Used by GuiProfileTheme when it creates a member from the cursor table, + // and by the hot-spot editor. Neither is derived from the theme's values, so + // neither goes through the stamping path. + void setHotSpot(const Point2I& hotSpot) { mHotSpot = hotSpot; } + void setRenderOffset(const Point2F& renderOffset) { mRenderOffset = renderOffset; } + inline StringTableEntry getBitmapName() const { return mBitmapName; } DECLARE_CONOBJECT(GuiCursor); GuiCursor(void); @@ -152,6 +176,39 @@ class GuiCursor : public SimObject bool onAdd(void); void onRemove(); void render(const Point2I &pos); + + /// Load the bitmap now and answer its real size. render() does this on its + /// first pass, so until a cursor has been drawn once its extent is (1,1) - + /// no use to an editor that has to lay out and measure before drawing. + const Point2I& resolve(); + + /// The bitmap path as it should be written down: relative to the game root + /// when it points inside the game, and unchanged when it does not. As with + /// GuiControlProfile's bitmap, mBitmapName itself is always absolute - + /// TypeFilename expands whatever it is given the moment it is set - and an + /// absolute path in a saved theme names a folder on one machine only. + StringTableEntry getRelativeBitmapName( void ) const; + + // Theme membership. A cursor stamped by a GuiProfileTheme tracks which + // fields were explicitly overridden; standalone cursors are unaffected. + // preserveOverrides keeps an override set loaded before attachment (Taml). + void setTheme(GuiProfileTheme* theme, bool preserveOverrides = false); + inline GuiProfileTheme* getTheme() const { return mThemeMembership.mTheme; } + bool isThemeFieldOverridden(StringTableEntry field) const { return mThemeMembership.isOverridden(field); } + void clearThemeFieldOverride(StringTableEntry field) { mThemeMembership.clearOverride(field); } + void clearAllThemeOverrides() { mThemeMembership.clearAll(); } + + virtual void onStaticModified(const char* slotName, const char* newValue = NULL); + virtual bool writeField(StringTableEntry fieldname, const char* value); + virtual void onDeleteNotify(SimObject* object); + +protected: + static bool setThemeOverrides(void* obj, const char* data) { static_cast(obj)->mThemeMembership.parseOverrideList(data); return false; } + static const char* getThemeOverrides(void* obj, const char* data) { return static_cast(obj)->mThemeMembership.formatOverrideList(); } + + // Set is left to TypeFilename, which expands the path; only the read-back is + // ours, so that what gets written stays portable. + static const char* getBitmapName(void* obj, const char* data) { return static_cast(obj)->getRelativeBitmapName(); } }; DefineConsoleType(TypeGuiCursor) diff --git a/engine/source/platformOSX/osxFileIO.mm b/engine/source/platformOSX/osxFileIO.mm index 2d7571cdc..050212fa4 100755 --- a/engine/source/platformOSX/osxFileIO.mm +++ b/engine/source/platformOSX/osxFileIO.mm @@ -389,7 +389,17 @@ static void recurseDumpPath(const char* curPath, Vector& fil if ( Ok == currentStatus || EOS == currentStatus ) { struct stat statData; - + + // The handle is buffered stdio, so bytes just written may still be in + // that buffer and not yet in the inode fstat reports. Windows and Linux + // both hold an unbuffered handle and so never see a stale size; push the + // buffer out first so this answers with the same authority they do. A + // read-only file has nothing to push, and the one hot caller + // (setPosition) has already flushed by way of its own fseek, so this + // costs nothing on the paths that ask most often. + if ( hasCapability(FileWrite) ) + fflush((FILE*)handle); + if(fstat(fileno((FILE*)handle), &statData) != 0) return 0; @@ -687,6 +697,12 @@ static void recurseDumpPath(const char* curPath, Vector& fil return false; } char* pFinalSlash = dStrrchr(pathBuffer, '/'); + if ( pFinalSlash == NULL ) + { + // A bare file name in the working directory names no directory, so + // there is nothing to create. Windows and Linux both no-op here. + return true; + } if ( pFinalSlash != pathBuffer+pathLength-1 ) { pFinalSlash[1] = 0; diff --git a/engine/source/platformiOS/iOSFileio.mm b/engine/source/platformiOS/iOSFileio.mm index 96223a97a..ac9b44a6e 100755 --- a/engine/source/platformiOS/iOSFileio.mm +++ b/engine/source/platformiOS/iOSFileio.mm @@ -244,7 +244,17 @@ bool dFileTouch(const char *path) if (Ok == currentStatus || EOS == currentStatus) { struct stat statData; - + + // The handle is buffered stdio, so bytes just written may still be in + // that buffer and not yet in the inode fstat reports. Windows and Linux + // both hold an unbuffered handle and so never see a stale size; push the + // buffer out first so this answers with the same authority they do. A + // read-only file has nothing to push, and the one hot caller + // (setPosition) has already flushed by way of its own fseek, so this + // costs nothing on the paths that ask most often. + if ( hasCapability(FileWrite) ) + fflush((FILE*)handle); + if(fstat(fileno((FILE*)handle), &statData) != 0) return 0; diff --git a/engine/source/sim/simObject.cc b/engine/source/sim/simObject.cc index 58ecbbb86..820b5f99c 100755 --- a/engine/source/sim/simObject.cc +++ b/engine/source/sim/simObject.cc @@ -286,6 +286,30 @@ void SimObject::assignDynamicFieldsFrom(SimObject* parent) void SimObject::assignFieldsFrom(SimObject *parent) { + copyFieldsFrom(parent, 0); +} + +void SimObject::copyFieldsFrom(SimObject *parent, const U32 flags) +{ + // What the caller asked to be left out. Every one of these is an ordinary + // persist field, so without this they would be copied like any other - and + // each does something no copy wants: + // + // name two objects answering to one name, which is the same bug + // whether or not the Sim is currently registering them. + // parentGroup setParentGroup() calls parent->addObject(), so copying the + // field does not record where the object lives, it MOVES it + // there - into the group the original is in. + // class setClass()/setSuperClass() link the object's namespaces, so + // superclass writing either one makes the object's script class live from + // that moment. A deep clone leaves both to copyTo, at the very + // end, so that nothing it does on the way can find a script + // callback to run. + static StringTableEntry nameField = StringTable->insert("name"); + static StringTableEntry parentGroupField = StringTable->insert("parentGroup"); + static StringTableEntry classField = StringTable->insert("class"); + static StringTableEntry superClassField = StringTable->insert("superclass"); + // only allow field assigns from objects of the same class: if(getClassRep() == parent->getClassRep()) { @@ -295,10 +319,28 @@ void SimObject::assignFieldsFrom(SimObject *parent) for(U32 i = 0; i < (U32)list.size(); i++) { const AbstractClassRep::Field* f = &list[i]; + + if((flags & CopyFields_SkipName) && f->pFieldname == nameField) + continue; + + if((flags & CopyFields_SkipParentGroup) && f->pFieldname == parentGroupField) + continue; + + if((flags & CopyFields_SkipScriptClass) && + (f->pFieldname == classField || f->pFieldname == superClassField)) + continue; + S32 lastField = f->elementCount - 1; for(S32 j = 0; j <= lastField; j++) { - const char* fieldVal = (*f->getDataFn)( this, Con::getData(f->type, (void *) (((const char *)parent) + f->offset), j, f->table, f->flag)); + // Read through the SOURCE, not through this object. A protected + // field's get function is free to ignore the raw data it is handed + // and answer from the object instead - GuiControl's "text" does + // exactly that (getTextProperty returns obj->getText()) - so asking + // this object for the value read back what the copy already held and + // wrote it straight back: nothing was ever copied. Every such field + // silently did not copy until now, a caption among them. + const char* fieldVal = (*f->getDataFn)( parent, Con::getData(f->type, (void *) (((const char *)parent) + f->offset), j, f->table, f->flag)); //if(fieldVal) // Con::setData(f->type, (void *) (((const char *)this) + f->offset), j, 1, &fieldVal, f->table); if(fieldVal) @@ -1186,6 +1228,70 @@ SimObject* SimObject::clone( const bool copyDynamicFields ) return pCloneObject; } +//----------------------------------------------------------------------------- +// A copy of an object, everything in it, and everything below it. +// +// The contract is that a deep clone is data: it holds what the original held, +// and no script lifecycle callback fires while it is being built. Two orderings +// are what deliver that, and both are load-bearing - see cloneInto below. +//----------------------------------------------------------------------------- + +SimObject* SimObject::deepClone() +{ + SimObject* pCloneObject = allocClone(); + + if ( pCloneObject == NULL ) + return NULL; + + cloneInto( pCloneObject ); + + return pCloneObject; +} + +// A registered but empty object of this object's class. getClassName() is the +// class rep's name - the C++ class - so this works for an object carrying a +// script class too; the script class itself is copied by copyTo, later. +SimObject* SimObject::allocClone() +{ + SimObject* pCloneObject = dynamic_cast( ConsoleObject::create(getClassName()) ); + if (!pCloneObject) + { + Con::errorf("SimObject::deepClone() - Unable to create cloned object of class '%s'.", getClassName()); + return NULL; + } + + if ( !pCloneObject->registerObject() ) + { + Con::warnf("SimObject::deepClone() - Unable to register cloned object."); + delete pCloneObject; + return NULL; + } + + return pCloneObject; +} + +void SimObject::cloneInto(SimObject* pCloneObject) +{ + // Not the name and not the parent group: the clone is nameless and belongs to + // nothing until whoever asked for it puts it somewhere. And not the script + // class, which is left to copyTo below. + pCloneObject->copyFieldsFrom( this, + CopyFields_SkipName | CopyFields_SkipParentGroup | CopyFields_SkipScriptClass ); + + deepCloneChildren( pCloneObject ); + + // Last, and this is the point. copyTo is what sets the script class and links + // the namespaces, so until now the clone had no script class for anything to + // find a callback on: registerObject fires script onAdd, and adding a child + // fires the parent's script onChildAdded (guiControl.cc). A class whose onAdd + // or onChildAdded builds children would otherwise build a second set of them + // on top of the ones being copied - which is exactly what a copy must not do. + // + // Skipping the class in the field copy above is half of the same rule: class + // and superclass are ordinary persist fields, and their setters link the + // namespaces too, so copying them early would defeat this entirely. + copyTo( pCloneObject ); +} //----------------------------------------------------------------------------- diff --git a/engine/source/sim/simObject.h b/engine/source/sim/simObject.h index b7d3bb4ca..3a47b2248 100755 --- a/engine/source/sim/simObject.h +++ b/engine/source/sim/simObject.h @@ -272,13 +272,24 @@ class SimObject: public ConsoleObject, public TamlCallbacks { static_cast(object)->setLocked(dAtob(data)); return false; } + /// Neither flag is ever written. + /// + /// Both are editor scaffolding: Hidden is read only from inside an edit + /// root (guiControl.cc renderChildControls guards it with isEditMode, so a + /// running game never consults it at all) and Locked only stops the Gui + /// Editor from selecting something. Saving them put a working state into + /// the document -- hide a control to reach what was behind it, save, and + /// the next person to open the file gets an invisible control whose only + /// clue is a dashed outline. + /// + /// They still read back and set normally; they simply do not persist. static bool _writeHidden(void* object, const char* data) { - return static_cast(object)->isHidden(); + return false; } static bool _writeLocked(void* object, const char* data) { - return static_cast(object)->isLocked(); + return false; } public: @@ -707,6 +718,24 @@ class SimObject: public ConsoleObject, public TamlCallbacks /// @param obj Object to copy from. void assignFieldsFrom(SimObject *obj); + /// What copyFieldsFrom may be told to leave alone. + enum CopyFieldsFlags + { + CopyFields_SkipName = BIT(0), ///< Two objects answering to one name is a bug. + CopyFields_SkipParentGroup = BIT(1), ///< Writing it ADDS the object to that group. + CopyFields_SkipScriptClass = BIT(2) ///< class and superclass, which link namespaces. + }; + + /// assignFieldsFrom, with the option of leaving some fields out. + /// + /// Every persist field is a field, including the two that decide where the + /// object lives and what it is called - so a copy that wants to be a copy + /// rather than a second reference to the same place has to say so. + /// + /// @param obj Object to copy from; must be of the same class. + /// @param flags CopyFieldsFlags, or 0 for everything. + void copyFieldsFrom(SimObject *obj, const U32 flags); + /// Copy dynamic fields from another object onto this one. /// /// Everything from obj will overwrite what's in this @@ -765,6 +794,44 @@ class SimObject: public ConsoleObject, public TamlCallbacks SimObject* clone( const bool copyDynamicFields ); virtual void copyTo(SimObject* object); + /// Copy this object, everything in it, and everything below it. + /// + /// Unlike clone(), which makes a shell of the right class and leaves the + /// fields to the caller, a deep clone is finished when it returns: fields, + /// dynamic fields and the whole child tree, with each child a new object of + /// its own. The name and the parent group are deliberately not copied - the + /// clone is nameless and belongs to nothing until someone adds it. + /// + /// No script lifecycle callback fires on a deep clone. See cloneInto. + SimObject* deepClone(); + +protected: + /// The three phases of a deep clone, separated because a child has to be + /// added to its new parent BEFORE its fields are written: a container that + /// places its own children takes what it wants from a child as it arrives + /// (a GuiChainCtrl zeroes the position, a GuiGridCtrl forces the sizing off + /// center), and doing that after the values were copied would undo them. + + /// A registered but empty object of this object's class. + virtual SimObject* allocClone(); + + /// Fill in a shell from allocClone: fields, then children, then the class. + /// + /// copyTo runs LAST, and that is the whole reason a deep clone is inert. + /// It is what sets mClassName/mSuperClassName and links the namespaces, and + /// until it has run the clone's script class is invisible: registerObject + /// fires script onAdd (simObject.cc) and GuiControl::onChildAdded fires + /// script onChildAdded (guiControl.cc), and neither can find a method on a + /// namespace that is not linked yet. So a class whose onAdd builds children + /// cannot build a second set of them on top of the ones being copied. + void cloneInto(SimObject* clone); + + /// Copy this object's children into %clone. Nothing to do here; SimGroup, + /// which is where owned children live, does the work. + virtual void deepCloneChildren(SimObject* clone) {} + +public: + template bool isType(void) { return dynamic_cast(this) != NULL; } // Component Console Overrides diff --git a/engine/source/sim/simObject_ScriptBinding.h b/engine/source/sim/simObject_ScriptBinding.h index 335add6d3..ffa04f9e1 100644 --- a/engine/source/sim/simObject_ScriptBinding.h +++ b/engine/source/sim/simObject_ScriptBinding.h @@ -906,6 +906,26 @@ ConsoleMethodWithDocs(SimObject, clone, ConsoleInt, 2, 3, ([copyDynamicFields = return pClonedObject->getId(); } +/*! Copies the object, everything in it, and everything below it. + Unlike clone(), a deep clone comes back finished: every field, every dynamic + field, and a new copy of every child, recursively. The copy has no name and + belongs to no group until you add it somewhere. + + No script callback runs on the copy while it is being made - not onAdd, not + onChildAdded - so a class that builds children of its own cannot end up with + two sets of them. + @return (newObjectID) The new object's id if successful, otherwise a 0. +*/ +ConsoleMethodWithDocs(SimObject, deepClone, ConsoleInt, 2, 2, ()) +{ + SimObject* pClonedObject = object->deepClone(); + + if ( pClonedObject == NULL ) + return 0; + + return pClonedObject->getId(); +} + /*! Takes all values from one object and puts them into anther object of the same class. This includes dynamic fields. @return No return value. */ diff --git a/engine/source/sim/simSet.cc b/engine/source/sim/simSet.cc index a9028247c..5b221729c 100755 --- a/engine/source/sim/simSet.cc +++ b/engine/source/sim/simSet.cc @@ -404,6 +404,37 @@ void SimGroup::onChildRemoved(SimObject* obj) ////////////////////////////////////////////////////////////////////////// +void SimGroup::deepCloneChildren(SimObject* clone) +{ + SimGroup* pCloneGroup = dynamic_cast( clone ); + if ( pCloneGroup == NULL ) + return; + + // The child is added while it is still empty, and only then filled in. That + // order is the point: a container that places its own children takes what it + // wants from a child as the child arrives - a GuiChainCtrl zeroes its + // position, a GuiGridCtrl forces its sizing off center, a GuiFrameSetCtrl + // puts it in a frame - and every one of those would overwrite the values a + // copy is trying to carry if they were written first. + // + // Order within the list is the arrival order, so the copy holds its children + // in the same order the original does. Anything that lays out by list order + // (which is all of them) therefore lays the copy out the same way. + for (iterator itr = begin(); itr != end(); itr++) + { + SimObject* pChild = *itr; + + SimObject* pChildClone = pChild->allocClone(); + if ( pChildClone == NULL ) + continue; + + pCloneGroup->addObject( pChildClone ); + pChild->cloneInto( pChildClone ); + } +} + +////////////////////////////////////////////////////////////////////////// + void SimGroup::onRemove() { lock(); diff --git a/engine/source/sim/simSet.h b/engine/source/sim/simSet.h index 625618ab1..54e61188a 100755 --- a/engine/source/sim/simSet.h +++ b/engine/source/sim/simSet.h @@ -318,6 +318,17 @@ class SimGroup: public SimSet bool processArguments(S32 argc, const char **argv); +protected: + /// Deep-clone every child into %clone. + /// + /// A group's children are its own - it is the only container in the engine + /// that owns what it holds - so a copy of a group is a copy of the tree under + /// it. A SimSet does not override this: its members belong to whatever group + /// holds them, and duplicating those would be inventing objects nobody asked + /// for. + virtual void deepCloneChildren(SimObject* clone); + +public: DECLARE_CONOBJECT(SimGroup); }; diff --git a/engine/source/testing/tests/guiControlReparentTests.cc b/engine/source/testing/tests/guiControlReparentTests.cc new file mode 100644 index 000000000..a0a795b70 --- /dev/null +++ b/engine/source/testing/tests/guiControlReparentTests.cc @@ -0,0 +1,576 @@ +//----------------------------------------------------------------------------- +// 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 _SIMBASE_H_ +#include "sim/simBase.h" +#endif + +//----------------------------------------------------------------------------- +// What happens to a control when it changes parent. +// +// In the Gui Editor a control can change parent two ways: dragged across the +// canvas until the pointer is over a different container, or dragged in the +// Explorer tree from one branch to another. Either way the control is handed to +// GuiControl::addObject, which ends in onChildAdded, which calls parentResized +// with the new parent's inner extent for BOTH the old and the new value -- a +// zero delta, so that a centred or filled child settles into its new home at +// once and everything else is left alone. +// +// "Left alone" is the promise these tests hold it to. A move is a move: the +// control keeps the size it had, in every mode but the two that compute a size +// from the parent every layout. +// +// None of this needs a canvas. Nothing here is woken, nothing measures text, and +// no font is ever asked for, so there is no texture assert waiting -- see the +// note about that in guiScrollLayoutTests.cc. The controls are real ones with +// real parents because the thing under test IS the reparent, not arithmetic that +// could be lifted out of it. The one piece that could be lifted out -- the rescue +// at the bottom of this file -- was. +//----------------------------------------------------------------------------- + +static StringTableEntry reparentField( const char* name ) +{ + return StringTable->insert( name ); +} + +// Through the fields rather than resize(), because a control with no parent yet +// is exactly the case resize() has nothing to lay out against. The field setters +// also clear the sizing batteries, which is the state a freshly built control is +// supposed to be in. +static GuiControl* makeControl( const char* position, const char* extent ) +{ + GuiControl* ctrl = new GuiControl(); + ctrl->registerObject(); + ctrl->setDataField( reparentField( "Position" ), NULL, position ); + ctrl->setDataField( reparentField( "Extent" ), NULL, extent ); + return ctrl; +} + +static void setSizing( GuiControl* ctrl, const char* horiz, const char* vert ) +{ + ctrl->setDataField( reparentField( "HorizSizing" ), NULL, horiz ); + ctrl->setDataField( reparentField( "VertSizing" ), NULL, vert ); +} + +// GuiDefaultProfile's border profile is all zeroes -- GuiBorderProfile's +// constructor sets margin, border and padding to 0 and GuiDefaultBorderProfile +// never changes them -- so a control wearing it has an inner rect the size of +// its bounds. Asserted rather than assumed: every expected number below is +// written as though inner and outer are the same, and if that ever stops being +// true this is the test that says so rather than six confusing failures. +TEST( GuiControlReparentTests, TheDefaultProfileCostsAChildNothing ) +{ + GuiControl* parent = makeControl( "0 0", "800 600" ); + + const RectI inner = parent->getInnerRect(); + ASSERT_EQ( inner.point.x, 0 ); + ASSERT_EQ( inner.point.y, 0 ); + ASSERT_EQ( inner.extent.x, 800 ); + ASSERT_EQ( inner.extent.y, 600 ); + + parent->deleteObject(); +} + +//----------------------------------------------------------------------------- +// scale -- the mode that was wrong. +// +// A scaled control caches the proportion of its parent it occupies, so that a +// run of layout passes cannot round its edges away a pixel at a time +// (relPosBatteryH, behind the mUseRelPosH flag). resetStoredRelPos clears the +// cache, and the Position and Extent field setters call it, because writing a +// position is the moment the cached proportion stops describing the control. +// +// Changing parent is that moment too. Before the fix nothing called it there, so +// onChildAdded applied the OLD parent's proportion to the NEW parent's extent: a +// button 200 wide at x=100 in an 800-wide container came out 50 wide at x=25 in +// a 200-wide one. The drop then put the position back under the pointer, which +// hid half the damage -- the button landed where it was dropped, at a quarter of +// its size. +//----------------------------------------------------------------------------- + +TEST( GuiControlReparentTests, ScaleKeepsItsExtentInASmallerParent ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "200 150" ); + + GuiControl* child = makeControl( "100 100", "200 40" ); + setSizing( child, "scale", "scale" ); + + big->addObject( child ); + ASSERT_EQ( child->getExtent().x, 200 ) << "Arriving anywhere must not resize it."; + ASSERT_EQ( child->getExtent().y, 40 ); + + small->addObject( child ); + + ASSERT_EQ( child->getExtent().x, 200 ) + << "A move is a move. Scale describes what a control does when its " + "parent is RESIZED, not what it does when it is handed to a " + "different one -- 0.125 to 0.375 of 200 is 50, which is the bug."; + ASSERT_EQ( child->getExtent().y, 40 ); + ASSERT_EQ( child->getPosition().x, 100 ) + << "And the position is the caller's to set afterwards, not the " + "layout's to guess at."; + ASSERT_EQ( child->getPosition().y, 100 ); + + small->deleteObject(); + big->deleteObject(); +} + +// The other direction, which fails differently: a stale proportion applied to a +// bigger parent inflates the control instead of shrinking it. Worth its own case +// because the fix could plausibly have been a clamp, and a clamp would pass the +// test above and fail this one. +TEST( GuiControlReparentTests, ScaleKeepsItsExtentInALargerParent ) +{ + GuiControl* small = makeControl( "0 0", "200 150" ); + GuiControl* big = makeControl( "0 0", "800 600" ); + + GuiControl* child = makeControl( "20 20", "100 30" ); + setSizing( child, "scale", "scale" ); + + small->addObject( child ); + big->addObject( child ); + + ASSERT_EQ( child->getExtent().x, 100 ) + << "0.1 to 0.6 of 800 is 400 -- four times the size it was dropped at."; + ASSERT_EQ( child->getExtent().y, 30 ); + + big->deleteObject(); + small->deleteObject(); +} + +// The feature the cache exists for, which the fix must not have switched off. +// Resetting the proportion on a move recharges it against the new parent; it +// does not stop it being used. +TEST( GuiControlReparentTests, ScaleStillScalesWhenItsOwnParentResizes ) +{ + GuiControl* parent = makeControl( "0 0", "800 600" ); + + GuiControl* child = makeControl( "100 100", "200 40" ); + setSizing( child, "scale", "scale" ); + parent->addObject( child ); + + parent->resize( Point2I( 0, 0 ), Point2I( 400, 300 ) ); + + ASSERT_EQ( child->getPosition().x, 50 ) << "Half the parent, half the offset."; + ASSERT_EQ( child->getExtent().x, 100 ) << "And half the width."; + ASSERT_EQ( child->getPosition().y, 50 ); + ASSERT_EQ( child->getExtent().y, 20 ); + + parent->deleteObject(); +} + +// The whole point of the cache, and the reason the fix had to be a reset rather +// than a removal. Recomputing the proportion from integer bounds every pass +// loses a little each time; the cached one is exact, so a round trip comes back +// where it started. +TEST( GuiControlReparentTests, ScaleSurvivesARoundTripWithoutDrift ) +{ + GuiControl* parent = makeControl( "0 0", "800 600" ); + + GuiControl* child = makeControl( "101 101", "199 41" ); + setSizing( child, "scale", "scale" ); + parent->addObject( child ); + + parent->resize( Point2I( 0, 0 ), Point2I( 333, 251 ) ); + parent->resize( Point2I( 0, 0 ), Point2I( 97, 63 ) ); + parent->resize( Point2I( 0, 0 ), Point2I( 800, 600 ) ); + + ASSERT_EQ( child->getPosition().x, 101 ) << "Three resizes, no drift."; + ASSERT_EQ( child->getExtent().x, 199 ); + ASSERT_EQ( child->getPosition().y, 101 ); + ASSERT_EQ( child->getExtent().y, 41 ); + + parent->deleteObject(); +} + +// A scaled control that has been moved scales against the parent it is in now. +// This is the pair to the test above: the cache must be recharged by the move, +// not merely ignored during it. +TEST( GuiControlReparentTests, ScaleScalesAgainstTheNewParentAfterAMove ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "200 150" ); + + GuiControl* child = makeControl( "20 20", "100 30" ); + setSizing( child, "scale", "scale" ); + + big->addObject( child ); + small->addObject( child ); + + small->resize( Point2I( 0, 0 ), Point2I( 100, 75 ) ); + + ASSERT_EQ( child->getPosition().x, 10 ) + << "Halving the parent it lives in now halves it. Against the 800-wide " + "parent it came from, 20 of 800 would round to 0."; + ASSERT_EQ( child->getExtent().x, 50 ); + ASSERT_EQ( child->getPosition().y, 10 ); + ASSERT_EQ( child->getExtent().y, 15 ); + + small->deleteObject(); + big->deleteObject(); +} + +//----------------------------------------------------------------------------- +// The modes that were already right, locked down so that the fix above cannot +// quietly change them. Each one is a different branch of parentResized. +//----------------------------------------------------------------------------- + +TEST( GuiControlReparentTests, AnchoredKeepsBothPositionAndExtent ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "200 150" ); + + GuiControl* child = makeControl( "100 100", "200 40" ); + setSizing( child, "anchorLeft", "anchorTop" ); + + big->addObject( child ); + small->addObject( child ); + + ASSERT_EQ( child->getPosition().x, 100 ) + << "A zero delta moves an anchored control not at all -- which is what " + "lets it end up outside a smaller parent. See the rescue below."; + ASSERT_EQ( child->getPosition().y, 100 ); + ASSERT_EQ( child->getExtent().x, 200 ); + ASSERT_EQ( child->getExtent().y, 40 ); + + small->deleteObject(); + big->deleteObject(); +} + +// width and height pin both edges, so they respond to a delta by changing size. +// A move has no delta, so they behave like the anchors here. +TEST( GuiControlReparentTests, WidthAndHeightKeepBothPositionAndExtent ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "200 150" ); + + GuiControl* child = makeControl( "100 100", "200 40" ); + setSizing( child, "width", "height" ); + + big->addObject( child ); + small->addObject( child ); + + ASSERT_EQ( child->getPosition().x, 100 ); + ASSERT_EQ( child->getPosition().y, 100 ); + ASSERT_EQ( child->getExtent().x, 200 ); + ASSERT_EQ( child->getExtent().y, 40 ); + + small->deleteObject(); + big->deleteObject(); +} + +TEST( GuiControlReparentTests, CenterRecentersInTheNewParent ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "200 150" ); + + GuiControl* child = makeControl( "0 0", "100 40" ); + setSizing( child, "center", "center" ); + + big->addObject( child ); + ASSERT_EQ( child->getPosition().x, 350 ) << "(800 - 100) / 2"; + ASSERT_EQ( child->getPosition().y, 280 ) << "(600 - 40) / 2"; + + small->addObject( child ); + ASSERT_EQ( child->getPosition().x, 50 ) << "(200 - 100) / 2"; + ASSERT_EQ( child->getPosition().y, 55 ) << "(150 - 40) / 2"; + ASSERT_EQ( child->getExtent().x, 100 ) << "Centering moves a control; it does not resize one."; + ASSERT_EQ( child->getExtent().y, 40 ); + + small->deleteObject(); + big->deleteObject(); +} + +TEST( GuiControlReparentTests, FillFillsTheNewParent ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "200 150" ); + + GuiControl* child = makeControl( "10 10", "100 40" ); + setSizing( child, "fill", "fill" ); + + big->addObject( child ); + ASSERT_EQ( child->getPosition().x, 0 ); + ASSERT_EQ( child->getPosition().y, 0 ); + ASSERT_EQ( child->getExtent().x, 800 ); + ASSERT_EQ( child->getExtent().y, 600 ); + + small->addObject( child ); + ASSERT_EQ( child->getPosition().x, 0 ); + ASSERT_EQ( child->getPosition().y, 0 ); + ASSERT_EQ( child->getExtent().x, 200 ); + ASSERT_EQ( child->getExtent().y, 150 ); + + small->deleteObject(); + big->deleteObject(); +} + +// The axes are independent, and mixing them is the case a fix that resets "the +// battery" as one thing would get wrong. +TEST( GuiControlReparentTests, TheTwoAxesAreIndependent ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "200 150" ); + + GuiControl* child = makeControl( "100 100", "200 40" ); + setSizing( child, "scale", "center" ); + + big->addObject( child ); + small->addObject( child ); + + ASSERT_EQ( child->getExtent().x, 200 ) << "Scale across: the size is kept."; + ASSERT_EQ( child->getPosition().x, 100 ); + ASSERT_EQ( child->getPosition().y, 55 ) << "Center down: (150 - 40) / 2."; + ASSERT_EQ( child->getExtent().y, 40 ); + + small->deleteObject(); + big->deleteObject(); +} + +//----------------------------------------------------------------------------- +// The minExtent battery, which is the same stale-state bug wearing different +// clothes. mStoredExtent records extent a control gave up to its minExtent and +// is owed back when there is room again. A debt run up under one parent means +// nothing under the next, so a move clears it. +//----------------------------------------------------------------------------- + +TEST( GuiControlReparentTests, AMinExtentDebtDoesNotFollowTheControl ) +{ + GuiControl* squeezer = makeControl( "0 0", "800 600" ); + + GuiControl* child = makeControl( "0 0", "400 300" ); + child->setDataField( reparentField( "MinExtent" ), NULL, "100 80" ); + setSizing( child, "width", "height" ); + squeezer->addObject( child ); + + // Squeeze it past its minimum: it stops at 100 wide and remembers it is owed + // the rest. + squeezer->resize( Point2I( 0, 0 ), Point2I( 400, 380 ) ); + ASSERT_EQ( child->getExtent().x, 100 ) << "Clamped at the minimum."; + + GuiControl* roomy = makeControl( "0 0", "800 600" ); + roomy->addObject( child ); + ASSERT_EQ( child->getExtent().x, 100 ) << "The move itself changes nothing."; + + // Growing the new parent by 50 should give the control 50, not pay back a + // debt it ran up somewhere else. + roomy->resize( Point2I( 0, 0 ), Point2I( 850, 600 ) ); + ASSERT_EQ( child->getExtent().x, 150 ) + << "A control that arrives at 100 wide is 100 wide. Carrying the debt " + "over would swallow the 50 and leave it at 100."; + + roomy->deleteObject(); + squeezer->deleteObject(); +} + +//----------------------------------------------------------------------------- +// rescuedPosition -- where a control goes when a move has stranded it. +// +// A tree drag has no pointer, so nothing supplies a position and the control +// keeps the local one it held in its old parent. Dropped into something smaller +// that can put it entirely outside: not clipped, not partly visible, gone. +// +// Per axis, because a placement that is still valid should be kept -- a button +// that was 20 pixels down and 400 across is still 20 pixels down. Only when the +// control is ENTIRELY outside, because a control the user can see is a control +// the user can drag, and moving one that merely overhangs the edge would be +// undoing a placement rather than rescuing it. +// +// Static, so the arithmetic can be read on its own: the same shape as +// GuiScrollCtrl::subtractScrollBars and GuiTreeViewCtrl::resolveIndent. +//----------------------------------------------------------------------------- + +static const Point2I RescueInner( 100, 300 ); +static const Point2I RescueSize( 80, 24 ); + +TEST( GuiControlReparentTests, RescueLeavesAControlThatFitsAlone ) +{ + const Point2I at = GuiControl::rescuedPosition( Point2I( 10, 40 ), RescueSize, RescueInner ); + + ASSERT_EQ( at.x, 10 ); + ASSERT_EQ( at.y, 40 ); +} + +TEST( GuiControlReparentTests, RescueZerosTheAxisThatIsPastTheRightEdge ) +{ + const Point2I at = GuiControl::rescuedPosition( Point2I( 400, 20 ), RescueSize, RescueInner ); + + ASSERT_EQ( at.x, 0 ); + ASSERT_EQ( at.y, 20 ) << "20 down is still 20 down."; +} + +TEST( GuiControlReparentTests, RescueZerosTheAxisThatIsPastTheBottomEdge ) +{ + const Point2I at = GuiControl::rescuedPosition( Point2I( 20, 500 ), RescueSize, RescueInner ); + + ASSERT_EQ( at.x, 20 ); + ASSERT_EQ( at.y, 0 ); +} + +TEST( GuiControlReparentTests, RescueZerosBothWhenBothAreOut ) +{ + const Point2I at = GuiControl::rescuedPosition( Point2I( 400, 500 ), RescueSize, RescueInner ); + + ASSERT_EQ( at.x, 0 ); + ASSERT_EQ( at.y, 0 ); +} + +// Off the left and off the top are just as invisible, and cost nothing to catch. +TEST( GuiControlReparentTests, RescueCatchesOffTheLeftAndOffTheTop ) +{ + const Point2I left = GuiControl::rescuedPosition( Point2I( -90, 20 ), RescueSize, RescueInner ); + ASSERT_EQ( left.x, 0 ) << "-90 + 80 is -10: the right edge is off the left side."; + ASSERT_EQ( left.y, 20 ); + + const Point2I above = GuiControl::rescuedPosition( Point2I( 20, -30 ), RescueSize, RescueInner ); + ASSERT_EQ( above.x, 20 ); + ASSERT_EQ( above.y, 0 ) << "-30 + 24 is -6."; +} + +TEST( GuiControlReparentTests, RescueLeavesAControlThatIsOnlyPartlyOutside ) +{ + const Point2I over = GuiControl::rescuedPosition( Point2I( 90, 20 ), RescueSize, RescueInner ); + ASSERT_EQ( over.x, 90 ) << "10 pixels of it are visible, so it can be dragged."; + ASSERT_EQ( over.y, 20 ); + + const Point2I under = GuiControl::rescuedPosition( Point2I( -10, 20 ), RescueSize, RescueInner ); + ASSERT_EQ( under.x, -10 ) << "And 70 pixels here."; + ASSERT_EQ( under.y, 20 ); +} + +// The boundaries, where "entirely outside" is decided. A control whose left edge +// sits exactly on the parent's right edge shows nothing; one pixel back shows one +// pixel. +TEST( GuiControlReparentTests, RescueIsExactAtTheEdges ) +{ + ASSERT_EQ( GuiControl::rescuedPosition( Point2I( 100, 0 ), RescueSize, RescueInner ).x, 0 ) + << "Left edge on the parent's right edge: nothing is visible."; + ASSERT_EQ( GuiControl::rescuedPosition( Point2I( 99, 0 ), RescueSize, RescueInner ).x, 99 ) + << "One pixel visible is visible."; + ASSERT_EQ( GuiControl::rescuedPosition( Point2I( -80, 0 ), RescueSize, RescueInner ).x, 0 ) + << "Right edge on the parent's left edge: nothing is visible."; + ASSERT_EQ( GuiControl::rescuedPosition( Point2I( -79, 0 ), RescueSize, RescueInner ).x, -79 ); +} + +// A container with no room at all cannot show anything wherever the control is +// put, and 0 is the least surprising answer. +TEST( GuiControlReparentTests, RescueHandlesAParentWithNoRoom ) +{ + const Point2I at = GuiControl::rescuedPosition( Point2I( 40, 40 ), RescueSize, Point2I( 0, 0 ) ); + + ASSERT_EQ( at.x, 0 ); + ASSERT_EQ( at.y, 0 ); +} + +//----------------------------------------------------------------------------- +// pullIntoView -- the same thing against the parent a control actually has. +//----------------------------------------------------------------------------- + +TEST( GuiControlReparentTests, PullIntoViewRescuesAStrandedControl ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "100 300" ); + + GuiControl* child = makeControl( "400 20", "80 24" ); + setSizing( child, "anchorLeft", "anchorTop" ); + + big->addObject( child ); + small->addObject( child ); + ASSERT_EQ( child->getPosition().x, 400 ) << "Stranded by the move itself."; + + ASSERT_TRUE( child->pullIntoView() ) << "It moved, so it says so."; + ASSERT_EQ( child->getPosition().x, 0 ); + ASSERT_EQ( child->getPosition().y, 20 ); + ASSERT_EQ( child->getExtent().x, 80 ) << "A rescue moves a control; it does not resize one."; + ASSERT_EQ( child->getExtent().y, 24 ); + + ASSERT_FALSE( child->pullIntoView() ) << "And a second call has nothing to do."; + + small->deleteObject(); + big->deleteObject(); +} + +TEST( GuiControlReparentTests, PullIntoViewLeavesAVisibleControlAlone ) +{ + GuiControl* parent = makeControl( "0 0", "800 600" ); + + GuiControl* child = makeControl( "100 100", "200 40" ); + parent->addObject( child ); + + ASSERT_FALSE( child->pullIntoView() ); + ASSERT_EQ( child->getPosition().x, 100 ); + ASSERT_EQ( child->getPosition().y, 100 ); + + parent->deleteObject(); +} + +// A control with no parent has no view to be pulled into, and asking must not +// crash -- the Explorer tree asks about a whole selection without checking each +// one, and the root of the document is in that selection. +TEST( GuiControlReparentTests, PullIntoViewIsSafeWithNoParent ) +{ + GuiControl* orphan = makeControl( "400 400", "80 24" ); + + ASSERT_FALSE( orphan->pullIntoView() ); + ASSERT_EQ( orphan->getPosition().x, 400 ) << "Left exactly as it was."; + + orphan->deleteObject(); +} + +// A rescue has to leave the sizing cache honest, or the next time the parent +// resizes the control jumps back to where it was rescued from. +TEST( GuiControlReparentTests, PullIntoViewLeavesScaleMeasuringFromWhereItLanded ) +{ + GuiControl* big = makeControl( "0 0", "800 600" ); + GuiControl* small = makeControl( "0 0", "100 300" ); + + GuiControl* child = makeControl( "400 20", "80 24" ); + setSizing( child, "scale", "scale" ); + + big->addObject( child ); + small->addObject( child ); + ASSERT_TRUE( child->pullIntoView() ); + ASSERT_EQ( child->getPosition().x, 0 ); + + small->resize( Point2I( 0, 0 ), Point2I( 200, 300 ) ); + + ASSERT_EQ( child->getPosition().x, 0 ) + << "Doubling the parent doubles an offset of 0, which is 0."; + ASSERT_EQ( child->getExtent().x, 160 ) << "And doubles the width."; + + small->deleteObject(); + big->deleteObject(); +} + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/guiCursorHotSpotTests.cc b/engine/source/testing/tests/guiCursorHotSpotTests.cc new file mode 100644 index 000000000..959632eda --- /dev/null +++ b/engine/source/testing/tests/guiCursorHotSpotTests.cc @@ -0,0 +1,163 @@ +//----------------------------------------------------------------------------- +// 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_EDITOR_CURSOR_CTRL_H_ +#include "gui/editor/guiEditorCursorCtrl.h" +#endif + +//----------------------------------------------------------------------------- +// Where a cursor actually points. +// +// Two fields decide it, and the split is not redundant: guiCanvas.cc positions +// the art at cursorPos - hotSpot, then GuiCursor::render subtracts +// (S32)(extent * renderOffset). So the pixel under the mouse is +// hotSpot + trunc(extent * renderOffset). +// +// renderOffset is a FRACTION of the art's own size, which is what makes it +// size-independent: "0.5 0.5" is the middle of a 13x17 pointer and the middle +// of a 32x32 sizer alike, so swapping one for the other does not make the shape +// appear to jump. hotSpot is the pixel nudge on top of that. +// +// The hot-spot editor draws both and drags the second, so this arithmetic is +// what its dot, its hit test and its readout all agree on. It lives in statics +// precisely so it can be checked here: rendering a cursor needs a GL context +// and a texture, and a unit test has neither. +//----------------------------------------------------------------------------- + +TEST( GuiCursorHotSpotTests, HotSpotAloneIsTheAnswerWithNoRenderOffset ) +{ + // The stock Default cursor: 13x17 art, hot spot one pixel in, no offset. + const Point2I hot = GuiEditorCursorCtrl::getEffectiveHotSpot( + Point2I( 1, 1 ), Point2F( 0.0f, 0.0f ), Point2I( 13, 17 ) ); + + ASSERT_EQ( hot.x, 1 ); + ASSERT_EQ( hot.y, 1 ); + + SUCCEED(); +} + +TEST( GuiCursorHotSpotTests, RenderOffsetIsAFractionOfTheArtSize ) +{ + // The stock Move cursor: 32x32 art centred on the pointer by the anchor + // alone, so the pointer lands in the middle and the nudge is zero. + const Point2I move = GuiEditorCursorCtrl::getEffectiveHotSpot( + Point2I( 0, 0 ), Point2F( 0.5f, 0.5f ), Point2I( 32, 32 ) ); + ASSERT_EQ( move.x, 16 ); + ASSERT_EQ( move.y, 16 ); + + // The stock Edit cursor: 8x20 art, centered, no nudge. + const Point2I edit = GuiEditorCursorCtrl::getEffectiveHotSpot( + Point2I( 0, 0 ), Point2F( 0.5f, 0.5f ), Point2I( 8, 20 ) ); + ASSERT_EQ( edit.x, 4 ); + ASSERT_EQ( edit.y, 10 ); + + SUCCEED(); +} + +// The point of a fractional offset: the same value centers art of any size, so +// a pointer and a sizer of different dimensions still sit under the same spot. +TEST( GuiCursorHotSpotTests, TheSameRenderOffsetCentersArtOfAnySize ) +{ + const Point2F centered( 0.5f, 0.5f ); + + const Point2I small = GuiEditorCursorCtrl::getEffectiveHotSpot( Point2I( 0, 0 ), centered, Point2I( 16, 16 ) ); + const Point2I large = GuiEditorCursorCtrl::getEffectiveHotSpot( Point2I( 0, 0 ), centered, Point2I( 32, 32 ) ); + + ASSERT_EQ( small.x, 8 ); + ASSERT_EQ( large.x, 16 ); + ASSERT_EQ( small.x * 2, large.x ) << "A fraction must scale with the art, not sit at a fixed pixel."; + + SUCCEED(); +} + +// The engine truncates rather than rounds, and the editor must mark the pixel +// that will really be under the mouse - not the one that ought to be. +TEST( GuiCursorHotSpotTests, TheFractionTruncatesJustAsTheEngineDoes ) +{ + // 32x16 art at an anchor of 0.5, 0.4: 16 * 0.4 is 6.4, and (S32)6.4 is 6 -- + // guiTypes.cc casts, it does not round. Any anchor other than a half or a + // whole can land between pixels, so the editor has to reproduce the cast + // rather than round, or its dot would sit a pixel off what the canvas draws. + const Point2I hot = GuiEditorCursorCtrl::getEffectiveHotSpot( + Point2I( 0, 0 ), Point2F( 0.5f, 0.4f ), Point2I( 32, 16 ) ); + + ASSERT_EQ( hot.x, 16 ); + ASSERT_EQ( hot.y, 6 ) << "6.4 truncates to 6; rounding here would put the dot a pixel off."; + + // An odd extent halved does the same: 17 * 0.5 is 8.5 -> 8. + const Point2I odd = GuiEditorCursorCtrl::getEffectiveHotSpot( + Point2I( 0, 0 ), Point2F( 0.5f, 0.5f ), Point2I( 13, 17 ) ); + + ASSERT_EQ( odd.x, 6 ); + ASSERT_EQ( odd.y, 8 ); + + SUCCEED(); +} + +// Dragging the dot asks the inverse question: which hotSpot puts the pointer on +// the pixel I clicked? It has to invert exactly, or a drag would drift. +TEST( GuiCursorHotSpotTests, HotSpotForPixelInvertsEffectiveHotSpot ) +{ + const Point2F renderOffset( 0.5f, 0.4f ); + const Point2I imageExtent( 32, 16 ); + + for ( S32 x = 0; x < imageExtent.x; ++x ) + { + for ( S32 y = 0; y < imageExtent.y; ++y ) + { + const Point2I pixel( x, y ); + const Point2I hotSpot = GuiEditorCursorCtrl::getHotSpotForPixel( pixel, renderOffset, imageExtent ); + const Point2I roundTrip = GuiEditorCursorCtrl::getEffectiveHotSpot( hotSpot, renderOffset, imageExtent ); + + ASSERT_EQ( roundTrip.x, pixel.x ); + ASSERT_EQ( roundTrip.y, pixel.y ); + } + } + + SUCCEED(); +} + +// A drag writes hotSpot and leaves renderOffset alone, so aiming at the same +// pixel under a different anchor must produce a different nudge. This is what +// keeps the two fields meaningfully separate rather than one number in disguise. +TEST( GuiCursorHotSpotTests, TheNudgeAbsorbsTheAnchorChoice ) +{ + const Point2I imageExtent( 16, 16 ); + const Point2I target( 4, 4 ); + + const Point2I fromCorner = GuiEditorCursorCtrl::getHotSpotForPixel( target, Point2F( 0.0f, 0.0f ), imageExtent ); + const Point2I fromCenter = GuiEditorCursorCtrl::getHotSpotForPixel( target, Point2F( 0.5f, 0.5f ), imageExtent ); + + ASSERT_EQ( fromCorner.x, 4 ) << "With no anchor the nudge is the pixel itself."; + ASSERT_EQ( fromCenter.x, -4 ) << "Anchored at the middle, reaching pixel 4 means nudging back 4."; + + SUCCEED(); +} + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/guiHitTestTests.cc b/engine/source/testing/tests/guiHitTestTests.cc new file mode 100644 index 000000000..445d0b35d --- /dev/null +++ b/engine/source/testing/tests/guiHitTestTests.cc @@ -0,0 +1,220 @@ +//----------------------------------------------------------------------------- +// 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 _GUIEDITCTRL_H_ +#include "gui/editor/guiEditCtrl.h" +#endif + +#ifndef _SIMBASE_H_ +#include "sim/simBase.h" +#endif + +//----------------------------------------------------------------------------- +// What the mouse can hit, and what the Gui Editor's eye takes away. +// +// Clicking the eye in the Explorer sets SimObject's Hidden flag, and +// renderChildControls stops drawing that control and everything under it. The +// hit test has to agree, or the eye takes a control out of sight while leaving +// it in the way of every click aimed at what is behind it -- which is the one +// thing hiding is for. Both paths now ask isHiddenInEditor, and these are the +// tests that hold them to the same answer. +// +// The rule is scoped to the editor, twice over: isEditMode walks up the parent +// chain looking for the edit root, and gives up at once if smDesignTime is +// false. The last two tests are the ones that pin that down -- a shipped game +// must not pay for this and must not obey it. +// +// None of it needs a canvas. findHitControl reads mBounds and mRenderInsetLT and +// nothing else; nothing here is woken, nothing measures text, and no font is +// ever asked for, so there is no texture assert waiting (see the note in +// guiScrollLayoutTests.cc). The GuiEditCtrl is real because isEditMode reads the +// real static and asks it for its real root -- stubbing that would test the stub. +//----------------------------------------------------------------------------- + +static StringTableEntry hitTestField( const char* name ) +{ + return StringTable->insert( name ); +} + +// Through the fields rather than resize(), for the reason given in +// guiControlReparentTests.cc: a control with no parent yet is exactly the case +// resize() has nothing to lay out against. +static GuiControl* makeHitControl( const char* position, const char* extent ) +{ + GuiControl* ctrl = new GuiControl(); + ctrl->registerObject(); + ctrl->setDataField( hitTestField( "Position" ), NULL, position ); + ctrl->setDataField( hitTestField( "Extent" ), NULL, extent ); + return ctrl; +} + +// One back panel with a smaller front panel sitting on top of it, and a control +// deeper still inside the front one. Later children are drawn last and hit +// first, so "front" really is in front. +// +// root 0,0 800x600 +// back 100,100 400x300 -> 100..499, 100..399 +// front 200,150 200x100 -> 200..399, 150..249 +// deep 20,20 60x40 -> 220..279, 170..209 in root's coordinates +class GuiHitTestTests : public ::testing::Test +{ +protected: + virtual void SetUp() + { + mWasDesignTime = GuiControl::smDesignTime; + mWasEditorHandle = GuiControl::smEditorHandle; + + mRoot = makeHitControl( "0 0", "800 600" ); + mBack = makeHitControl( "100 100", "400 300" ); + mFront = makeHitControl( "200 150", "200 100" ); + mDeep = makeHitControl( "20 20", "60 40" ); + + mRoot->addObject( mBack ); + mRoot->addObject( mFront ); + mFront->addObject( mDeep ); + + // Exactly what GuiEditCtrl::onWake does when the editor opens. + mEdit = new GuiEditCtrl(); + mEdit->registerObject(); + mEdit->setRoot( mRoot ); + GuiControl::smDesignTime = true; + GuiControl::smEditorHandle = mEdit; + } + + virtual void TearDown() + { + // Put the statics back before anything else can run: leaving the editor + // switched on would follow this suite into the next one. + GuiControl::smDesignTime = mWasDesignTime; + GuiControl::smEditorHandle = mWasEditorHandle; + + mEdit->deleteObject(); + mRoot->deleteObject(); // and the three controls it holds + } + + // A point over deep, and so over front and back as well. + Point2I overDeep() const { return Point2I( 250, 190 ); } + + // Over front and back, but outside deep. + Point2I overFront() const { return Point2I( 380, 240 ); } + + GuiControl* mRoot; + GuiControl* mBack; + GuiControl* mFront; + GuiControl* mDeep; + GuiEditCtrl* mEdit; + bool mWasDesignTime; + GuiEditCtrl* mWasEditorHandle; +}; + +TEST_F( GuiHitTestTests, TheEditRootIsInEditMode ) +{ + ASSERT_TRUE( mRoot->isEditMode() ) << "Everything below depends on this."; + ASSERT_TRUE( mFront->isEditMode() ) << "isEditMode walks up to the edit root."; +} + +TEST_F( GuiHitTestTests, TheFrontOneIsHit ) +{ + ASSERT_EQ( mRoot->findHitControl( overFront() ), mFront ); + ASSERT_EQ( mRoot->findHitControl( overDeep() ), mDeep ); +} + +TEST_F( GuiHitTestTests, HidingTheFrontOneLetsTheClickThrough ) +{ + mFront->setHidden( true ); + + ASSERT_EQ( mRoot->findHitControl( overFront() ), mBack ) + << "The whole point of the eye: a control that is not drawn is not a " + "target, so the click reaches what is behind it."; +} + +TEST_F( GuiHitTestTests, AHiddenControlTakesItsChildrenWithIt ) +{ + mFront->setHidden( true ); + + ASSERT_EQ( mRoot->findHitControl( overDeep() ), mBack ) + << "Hiding a container stops the whole branch being drawn, so the whole " + "branch has to stop being hit -- otherwise a hidden panel's children " + "still eat every click aimed through it."; +} + +TEST_F( GuiHitTestTests, HidingAChildLeavesItsParentAlone ) +{ + mDeep->setHidden( true ); + + ASSERT_EQ( mRoot->findHitControl( overDeep() ), mFront ) + << "One control, not the branch it sits in."; + ASSERT_EQ( mRoot->findHitControl( overFront() ), mFront ); +} + +TEST_F( GuiHitTestTests, ShowingItAgainPutsItBack ) +{ + mFront->setHidden( true ); + mFront->setHidden( false ); + + ASSERT_EQ( mRoot->findHitControl( overFront() ), mFront ); + ASSERT_EQ( mRoot->findHitControl( overDeep() ), mDeep ); +} + +// The flag is editor scaffolding and is never written to a file, so the only +// thing standing between it and a shipped game is isEditMode. These two tests +// are that guarantee, from both ends. + +TEST_F( GuiHitTestTests, OutsideTheEditRootTheFlagMeansNothing ) +{ + GuiControl* stray = makeHitControl( "0 0", "800 600" ); + GuiControl* strayBack = makeHitControl( "100 100", "400 300" ); + GuiControl* strayFront = makeHitControl( "200 150", "200 100" ); + stray->addObject( strayBack ); + stray->addObject( strayFront ); + strayFront->setHidden( true ); + + ASSERT_FALSE( stray->isEditMode() ) << "Never parented into the edit root."; + ASSERT_EQ( stray->findHitControl( overFront() ), strayFront ) + << "A Gui that is not the one being edited must behave exactly as it " + "did before, flag or no flag."; + + stray->deleteObject(); +} + +TEST_F( GuiHitTestTests, WithTheEditorShutTheFlagMeansNothing ) +{ + mFront->setHidden( true ); + GuiControl::smDesignTime = false; + + ASSERT_EQ( mRoot->findHitControl( overFront() ), mFront ) + << "smDesignTime is off the moment the editor sleeps, and a running " + "game must not consult the flag at all."; +} + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/guiProfileThemeTests.cc b/engine/source/testing/tests/guiProfileThemeTests.cc index 288c83600..92de42958 100644 --- a/engine/source/testing/tests/guiProfileThemeTests.cc +++ b/engine/source/testing/tests/guiProfileThemeTests.cc @@ -913,7 +913,8 @@ TEST( GuiProfileThemeTests, ThemeGeneratesTheNamedBorderPalette ) const char* names[] = { "Empty", "Rimmed", "Thick", "Light", "Dark", "Padded", "Highlight", "PaddedRim", "BevelLight", "BevelDark", "PaddedLight", - "PaddedDark", "RimmedExpander", "CondenserLight", "CondenserDark" }; + "PaddedDark", "RimmedExpander", "CondenserLight", "CondenserDark", + "SelectedInset" }; const S32 count = sizeof( names ) / sizeof( names[0] ); ASSERT_EQ( GuiProfileTheme::getBorderCategoryCount(), count ); for ( S32 i = 0; i < count; ++i ) @@ -998,6 +999,81 @@ TEST( GuiProfileThemeTests, SixBorderRecipesUseExpectedValues ) SUCCEED(); } +//----------------------------------------------------------------------------- +// SelectedInset: the border that gives a generated theme its menu separators. +// Three states pad a label; the selected state - the only state a menu ever +// draws a separator in - is a rule with room around it. +//----------------------------------------------------------------------------- + +TEST( GuiProfileThemeTests, SelectedInsetBorderIsARuleInTheSelectedState ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); // constructor borderSize == 1 + + GuiBorderProfile* inset = theme->getBorder( StringTable->insert( "SelectedInset" ) ); + ASSERT_TRUE( inset != NULL ); + + // Normal, highlight and disabled: a plain 10px inset, no rim. + const S32 padStates[] = { 0, 1, 3 }; + for ( S32 s = 0; s < 3; ++s ) + { + const S32 i = padStates[s]; + ASSERT_EQ( inset->mMargin[i], 0 ); + ASSERT_EQ( inset->mBorder[i], 0 ); + ASSERT_EQ( inset->mPadding[i], 10 ); + } + + // Selected: margin, rim, no padding - so a separator is exactly as tall as + // its own chrome, which is how GuiMenuListCtrl::updateSize measures one. + ASSERT_EQ( inset->mMargin[2], 4 ); + ASSERT_EQ( inset->mBorder[2], 1 ); + ASSERT_EQ( inset->mPadding[2], 0 ); + ASSERT_TRUE( inset->mUnderfill ); + + for ( S32 i = 0; i < 4; ++i ) + ASSERT_TRUE( inset->mBorderColor[i] == theme->getColorSurface() ); + + // A rule, not an edge: unlike every other recipe this one ignores borderSize, + // so a theme with no borders at all still separates its menus, and a heavy + // one does not turn the rule into a band. + theme->setDataField( StringTable->insert( "borderSize" ), NULL, "0" ); + ASSERT_EQ( inset->mBorder[2], 1 ); + theme->setDataField( StringTable->insert( "borderSize" ), NULL, "3" ); + ASSERT_EQ( inset->mBorder[2], 1 ); + + theme->deleteObject(); + + SUCCEED(); +} + +TEST( GuiProfileThemeTests, MenuItemProfileWearsSelectedInsetWithPaddedSides ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + + GuiControlProfile* item = theme->getProfile( StringTable->insert( "MenuItem" ) ); + ASSERT_TRUE( item != NULL ); + ASSERT_EQ( item->mBorderDefault, theme->getBorder( StringTable->insert( "SelectedInset" ) ) ); + + // Top and bottom fall back to the default - which is what shapes a separator - + // while the sides carry a plain inset instead, so a rule runs the width of the + // menu rather than being capped at both ends. + GuiBorderProfile* padded = theme->getBorder( StringTable->insert( "Padded" ) ); + ASSERT_EQ( item->getLeftProfile(), padded ); + ASSERT_EQ( item->getRightProfile(), padded ); + ASSERT_EQ( item->getTopProfile(), item->mBorderDefault ); + ASSERT_EQ( item->getBottomProfile(), item->mBorderDefault ); + + // Resolved eagerly: render reads these cached pointers, never the lazy + // resolver, so a freshly stamped profile must already know its sides. + ASSERT_TRUE( item->getLeftBorder() != NULL ); + ASSERT_TRUE( item->getTopBorder() != NULL ); + + theme->deleteObject(); + + SUCCEED(); +} + //----------------------------------------------------------------------------- // Custom borders: single-use, user-authored borders owned by the theme as // extras. They are not category members, keep their own field values, are @@ -1161,4 +1237,249 @@ TEST( GuiProfileThemeTests, FreshlyStampedProfileResolvesFallbackSidesToDefaultB SUCCEED(); } +//----------------------------------------------------------------------------- +// Cursors: the third member family. They follow the profile pattern - a +// category table, one guaranteed default per category, extras within a +// category - with one difference that drives most of these tests: a cursor's +// art cannot be derived from a palette, so bitmapName, hotSpot and renderOffset +// are set once at creation and never stamped, while only the tint is. +// +// Nothing here may draw or resolve a cursor: a unit test has no GL context, and +// loading a texture trips a modal assert that arrives as a hang. +//----------------------------------------------------------------------------- + +TEST( GuiProfileThemeTests, ThemeProvidesOneCursorPerCategory ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + + ASSERT_EQ( GuiProfileTheme::getCursorCategoryCount(), 7 ); + + for ( S32 i = 0; i < GuiProfileTheme::getCursorCategoryCount(); ++i ) + { + StringTableEntry category = GuiProfileTheme::getCursorCategoryName( i ); + GuiCursor* cursor = theme->getCursor( category ); + + ASSERT_TRUE( cursor != NULL ) << "Every cursor category must have a default member."; + ASSERT_STREQ( cursor->mCategory, category ); + ASSERT_EQ( cursor->getTheme(), theme ); + } + + theme->deleteObject(); + + SUCCEED(); +} + +// The load-bearing invariant behind installing a theme's cursors: a member is +// named , and the suffix IS the canonical name the engine +// falls back to when a control names no cursor (guiTextEditCtrl.cc, +// guiWindowCtrl.cc, guiFrameSetCtrl.cc, guiEditCtrl.cc). Installing is then +// just dropping the theme's name from the front - no lookup table to drift. +TEST( GuiProfileThemeTests, CursorMemberNamesAreThemeNamePlusCanonicalName ) +{ + static const char* const canonicalNames[] = + { + "DefaultCursor", "EditCursor", "MoveCursor", + "LeftRightCursor", "UpDownCursor", "NWSECursor", "NESWCursor" + }; + + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + + for ( S32 i = 0; i < GuiProfileTheme::getCursorCategoryCount(); ++i ) + { + char expected[256]; + dSprintf( expected, sizeof( expected ), "UnitTestTheme%s", canonicalNames[i] ); + + GuiCursor* cursor = theme->getCursor( GuiProfileTheme::getCursorCategoryName( i ) ); + ASSERT_EQ( (SimObject*)cursor, Sim::findObject( expected ) ) + << "Member name must be the theme name followed by the canonical cursor name."; + } + + theme->deleteObject(); + + SUCCEED(); +} + +TEST( GuiProfileThemeTests, CursorColorIsStampedFromForegroundAndOverridable ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + + GuiCursor* cursor = theme->getCursor( StringTable->insert( "Default" ) ); + ASSERT_TRUE( cursor != NULL ); + + theme->setDataField( StringTable->insert( "colorForeground" ), NULL, "11 22 33 255" ); + ASSERT_TRUE( cursor->mColor == ColorI( 11, 22, 33, 255 ) ); + + // An explicit tint survives later theme changes... + cursor->setDataField( StringTable->insert( "color" ), NULL, "9 8 7 6" ); + ASSERT_TRUE( cursor->isThemeFieldOverridden( StringTable->insert( "color" ) ) ); + theme->setDataField( StringTable->insert( "colorForeground" ), NULL, "44 55 66 255" ); + ASSERT_TRUE( cursor->mColor == ColorI( 9, 8, 7, 6 ) ); + + // ...and clearing it re-derives. + cursor->clearThemeFieldOverride( StringTable->insert( "color" ) ); + theme->restamp(); + ASSERT_TRUE( cursor->mColor == ColorI( 44, 55, 66, 255 ) ); + + theme->deleteObject(); + + SUCCEED(); +} + +TEST( GuiProfileThemeTests, CursorArtIsNeverStampedAndNeverOverrideTracked ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + + GuiCursor* cursor = theme->getCursor( StringTable->insert( "Move" ) ); + ASSERT_TRUE( cursor != NULL ); + + // The table's placement values for Move: centred on the pointer by the + // anchor alone, so the nudge has nothing left to say. + ASSERT_EQ( cursor->getHotSpot().x, 0 ); + ASSERT_EQ( cursor->getHotSpot().y, 0 ); + ASSERT_EQ( cursor->getRenderOffset().x, 0.5f ); + + cursor->setDataField( StringTable->insert( "hotSpot" ), NULL, "4 9" ); + cursor->setDataField( StringTable->insert( "bitmapName" ), NULL, "unitTestArt/pointer.png" ); + + // Art is the user's, not the theme's: it is not an "override" of anything, + // because no recipe would ever write it. + ASSERT_FALSE( cursor->isThemeFieldOverridden( StringTable->insert( "hotSpot" ) ) ); + ASSERT_FALSE( cursor->isThemeFieldOverridden( StringTable->insert( "bitmapName" ) ) ); + + // And a restamp leaves all of it alone. + theme->setDataField( StringTable->insert( "colorForeground" ), NULL, "1 2 3 255" ); + theme->restamp(); + ASSERT_EQ( cursor->getHotSpot().x, 4 ); + ASSERT_EQ( cursor->getHotSpot().y, 9 ); + ASSERT_TRUE( dStrstr( cursor->getBitmapName(), "pointer.png" ) != NULL ); + + theme->deleteObject(); + + SUCCEED(); +} + +// A theme usually learns where its cursor folder is after its members already +// exist, so filling the art is retried on every restamp - but only ever into a +// blank, so it can never overwrite art the user chose. +TEST( GuiProfileThemeTests, CursorArtFillsFromCursorDirectoryButOnlyWhenEmpty ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + + GuiCursor* cursor = theme->getCursor( StringTable->insert( "Default" ) ); + ASSERT_TRUE( cursor != NULL ); + ASSERT_STREQ( cursor->getBitmapName(), "" ) << "A theme with no cursor directory has no art to point at."; + + theme->setDataField( StringTable->insert( "cursorDirectory" ), NULL, "unitTestThemes/cursors/UnitTestTheme" ); + ASSERT_TRUE( dStrstr( cursor->getBitmapName(), "defaultCursor.png" ) != NULL ) + << "Naming the directory fills the category's stock art."; + + // Pointing the directory somewhere else does not move a cursor that already + // has art - including art that was filled from the old directory. + theme->setDataField( StringTable->insert( "cursorDirectory" ), NULL, "unitTestThemes/cursors/Other" ); + ASSERT_TRUE( dStrstr( cursor->getBitmapName(), "UnitTestTheme" ) != NULL ) + << "Filled art must survive a directory change; only a blank is filled."; + + theme->deleteObject(); + + SUCCEED(); +} + +TEST( GuiProfileThemeTests, ExtraCursorsShareCategoryAndOnlyExtrasAreRemovable ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + + GuiCursor* extra = theme->createCursor( "Default", NULL ); + ASSERT_TRUE( extra != NULL ); + ASSERT_STREQ( extra->getName(), "UnitTestThemeDefaultCursor2" ); + ASSERT_STREQ( extra->mCategory, "Default" ); + ASSERT_EQ( extra->getTheme(), theme ); + + // Both members of the category are offered; the default comes first. + ASSERT_EQ( theme->getExtraCursors().size(), 1 ); + ASSERT_EQ( theme->getExtraCursors()[0], extra ); + + GuiCursor* defaultCursor = theme->getCursor( StringTable->insert( "Default" ) ); + ASSERT_FALSE( theme->removeCursor( defaultCursor ) ) << "Default members must not be removable."; + ASSERT_TRUE( theme->removeCursor( extra ) ); + ASSERT_EQ( theme->getExtraCursors().size(), 0 ); + + theme->deleteObject(); + + SUCCEED(); +} + +TEST( GuiProfileThemeTests, RenameThemeRenamesCursorMembers ) +{ + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestThemeA" ); + + GuiCursor* extra = theme->createCursor( "Edit", NULL ); + ASSERT_STREQ( extra->getName(), "UnitTestThemeAEditCursor2" ); + + ASSERT_TRUE( theme->renameTheme( "UnitTestThemeB" ) ); + + ASSERT_EQ( (SimObject*)theme->getCursor( StringTable->insert( "Edit" ) ), + Sim::findObject( "UnitTestThemeBEditCursor" ) ); + ASSERT_STREQ( extra->getName(), "UnitTestThemeBEditCursor2" ); + ASSERT_TRUE( Sim::findObject( "UnitTestThemeAEditCursor" ) == NULL ); + + theme->deleteObject(); + + SUCCEED(); +} + +TEST( GuiProfileThemeTests, CursorTamlRoundTripPreservesArtAndOverrides ) +{ + const char* fileName = "unitTestGuiProfileThemeCursors.taml"; + + GuiProfileTheme* theme = new GuiProfileTheme(); + theme->registerObject( "UnitTestTheme" ); + theme->setDataField( StringTable->insert( "cursorDirectory" ), NULL, "unitTestThemes/cursors/UnitTestTheme" ); + + GuiCursor* edit = theme->getCursor( StringTable->insert( "Edit" ) ); + edit->setDataField( StringTable->insert( "hotSpot" ), NULL, "3 7" ); + edit->setDataField( StringTable->insert( "color" ), NULL, "9 8 7 6" ); + ASSERT_TRUE( theme->createCursor( "Default", NULL ) != NULL ); + + Taml taml; + ASSERT_TRUE( taml.write( theme, fileName ) ); + theme->deleteObject(); + ASSERT_TRUE( Sim::findObject( "UnitTestThemeEditCursor" ) == NULL ); + + GuiProfileTheme* loaded = taml.read( fileName ); + ASSERT_TRUE( loaded != NULL ); + + GuiCursor* loadedEdit = loaded->getCursor( StringTable->insert( "Edit" ) ); + ASSERT_TRUE( loadedEdit != NULL ); + ASSERT_EQ( (SimObject*)loadedEdit, Sim::findObject( "UnitTestThemeEditCursor" ) ); + ASSERT_EQ( loadedEdit->getTheme(), loaded ); + + // Art persists although nothing marked it overridden - that is the whole + // point of exempting it, since no recipe could rebuild it on load. + ASSERT_EQ( loadedEdit->getHotSpot().x, 3 ); + ASSERT_EQ( loadedEdit->getHotSpot().y, 7 ); + ASSERT_TRUE( dStrstr( loadedEdit->getBitmapName(), "ibeam.png" ) != NULL ); + + // The tint round-trips as an override. + ASSERT_TRUE( loadedEdit->isThemeFieldOverridden( StringTable->insert( "color" ) ) ); + ASSERT_TRUE( loadedEdit->mColor == ColorI( 9, 8, 7, 6 ) ); + + // The extra survives alongside a complete default set. + ASSERT_EQ( loaded->getExtraCursors().size(), 1 ); + GuiCursor* loadedExtra = dynamic_cast( Sim::findObject( "UnitTestThemeDefaultCursor2" ) ); + ASSERT_TRUE( loadedExtra != NULL ); + ASSERT_STREQ( loadedExtra->mCategory, "Default" ); + + loaded->deleteObject(); + Platform::fileDelete( fileName ); + + SUCCEED(); +} + #endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/guiScrollLayoutTests.cc b/engine/source/testing/tests/guiScrollLayoutTests.cc new file mode 100644 index 000000000..53501324c --- /dev/null +++ b/engine/source/testing/tests/guiScrollLayoutTests.cc @@ -0,0 +1,244 @@ +//----------------------------------------------------------------------------- +// 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 _GUISCROLLCTRL_H_ +#include "gui/containers/guiScrollCtrl.h" +#endif + +//----------------------------------------------------------------------------- +// How much room a scroll control has to offer a child. +// +// A scroll control is the one container that does not always have a size to +// give. In an axis it can scroll, the content is as long as it wants to be and +// the control is a window onto it -- there is no fixed size to hand down. In an +// axis whose bar is alwaysOff nothing scrolls, the room is bounded, and a child +// may be sized to it. +// +// Getting that wrong is not cosmetic. The palette in the Gui Editor laid its +// tiles out across a width that included the vertical scroll bar, so the last +// column of every row was drawn underneath it, and the names in that column read +// "Number Bo:". It was fixed in script three times -- by narrowing the container, +// by asking for fill, by measuring and subtracting -- and none of them held, +// because none of them were the thing that was wrong. +// +// These tests are the arithmetic, on its own. They construct nothing and need no +// canvas: the two functions are static and take everything they use, which is +// why they were pulled out of computeSizes in the first place. +//----------------------------------------------------------------------------- + +static const S32 BarThickness = 14; + +//----------------------------------------------------------------------------- +// subtractScrollBars -- what is left once the bars have taken their share. +// +// A vertical bar stands down the side and costs WIDTH; a horizontal one costs +// height. Transposing that pair is the easiest mistake in this file. +//----------------------------------------------------------------------------- + +TEST( GuiScrollLayoutTests, NoBarsTakeNothing ) +{ + const Point2I room = GuiScrollCtrl::subtractScrollBars( Point2I( 300, 200 ), false, false, BarThickness ); + + ASSERT_EQ( room.x, 300 ); + ASSERT_EQ( room.y, 200 ); + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, AVerticalBarCostsWidth ) +{ + const Point2I room = GuiScrollCtrl::subtractScrollBars( Point2I( 300, 200 ), false, true, BarThickness ); + + ASSERT_EQ( room.x, 286 ) << "A vertical bar stands down the side; it takes width."; + ASSERT_EQ( room.y, 200 ) << "It must not take height."; + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, AHorizontalBarCostsHeight ) +{ + const Point2I room = GuiScrollCtrl::subtractScrollBars( Point2I( 300, 200 ), true, false, BarThickness ); + + ASSERT_EQ( room.x, 300 ) << "A horizontal bar must not take width."; + ASSERT_EQ( room.y, 186 ); + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, BothBarsTakeBoth ) +{ + const Point2I room = GuiScrollCtrl::subtractScrollBars( Point2I( 300, 200 ), true, true, BarThickness ); + + ASSERT_EQ( room.x, 286 ); + ASSERT_EQ( room.y, 186 ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// calcBarPresence -- which bars a scroller of this configuration shows. +// +// The content extent passed in is the bar-free one: margins, borders and padding +// already taken off, nothing else. +//----------------------------------------------------------------------------- + +// A helper, because every test below wants both answers and C++ has no tuples +// worth the trouble here. +struct BarPresence +{ + bool horizontal; + bool vertical; + + BarPresence( const S32 forceH, const S32 forceV, const Point2I &child, const Point2I &content ) + { + GuiScrollCtrl::calcBarPresence( forceH, forceV, child, content, BarThickness, horizontal, vertical ); + } +}; + +TEST( GuiScrollLayoutTests, AlwaysOffShowsNoBarHoweverBigTheContent ) +{ + const BarPresence bars( GuiScrollCtrl::ScrollBarAlwaysOff, GuiScrollCtrl::ScrollBarAlwaysOff, + Point2I( 9000, 9000 ), Point2I( 300, 200 ) ); + + ASSERT_FALSE( bars.horizontal ); + ASSERT_FALSE( bars.vertical ) << "alwaysOff is a promise that the axis does not scroll."; + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, AlwaysOnShowsTheBarWithNoContentAtAll ) +{ + const BarPresence bars( GuiScrollCtrl::ScrollBarAlwaysOn, GuiScrollCtrl::ScrollBarAlwaysOn, + Point2I( 0, 0 ), Point2I( 300, 200 ) ); + + ASSERT_TRUE( bars.horizontal ); + ASSERT_TRUE( bars.vertical ) << "An always-on bar takes its space whether or not it is needed."; + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, DynamicStaysHiddenWhileTheContentFits ) +{ + const BarPresence bars( GuiScrollCtrl::ScrollBarDynamic, GuiScrollCtrl::ScrollBarDynamic, + Point2I( 300, 200 ), Point2I( 300, 200 ) ); + + ASSERT_FALSE( bars.horizontal ) << "Exactly filling the room is not overflowing it."; + ASSERT_FALSE( bars.vertical ); + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, DynamicShowsTheVerticalBarWhenTheContentIsTooTall ) +{ + const BarPresence bars( GuiScrollCtrl::ScrollBarAlwaysOff, GuiScrollCtrl::ScrollBarDynamic, + Point2I( 300, 5000 ), Point2I( 300, 200 ) ); + + ASSERT_TRUE( bars.vertical ); + ASSERT_FALSE( bars.horizontal ) << "The width still fits, and across is alwaysOff regardless."; + + SUCCEED(); +} + +// The palette's shape: tall content, no horizontal scrolling, a dynamic vertical +// bar. This is the case the whole bug lived in. +TEST( GuiScrollLayoutTests, TheRoomAcrossIsNarrowedByTheVerticalBar ) +{ + const Point2I content( 334, 321 ); + const BarPresence bars( GuiScrollCtrl::ScrollBarAlwaysOff, GuiScrollCtrl::ScrollBarDynamic, + Point2I( 334, 1500 ), content ); + + ASSERT_TRUE( bars.vertical ); + + const Point2I room = GuiScrollCtrl::subtractScrollBars( content, bars.horizontal, bars.vertical, BarThickness ); + + ASSERT_EQ( room.x, 320 ) << "This is the width the palette's groups must lay out in."; + ASSERT_EQ( room.y, 321 ) << "Nothing takes height here; across is alwaysOff."; + + SUCCEED(); +} + +// The circular part, and the reason calcBarPresence exists as its own function. +// The old code compared both axes against the un-narrowed extent, so its second +// look at the horizontal bar asked a question it had already answered. +TEST( GuiScrollLayoutTests, AVerticalBarCanCallAHorizontalOneIntoBeing ) +{ + // 295 fits across 300, but not across the 286 left once the vertical bar + // has taken its share. + const BarPresence bars( GuiScrollCtrl::ScrollBarDynamic, GuiScrollCtrl::ScrollBarDynamic, + Point2I( 295, 5000 ), Point2I( 300, 200 ) ); + + ASSERT_TRUE( bars.vertical ); + ASSERT_TRUE( bars.horizontal ) + << "The vertical bar narrowed the content past what the width could hold."; + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, AHorizontalBarCanCallAVerticalOneIntoBeing ) +{ + // 195 fits down 200, but not down the 186 left once the horizontal bar has. + const BarPresence bars( GuiScrollCtrl::ScrollBarDynamic, GuiScrollCtrl::ScrollBarDynamic, + Point2I( 5000, 195 ), Point2I( 300, 200 ) ); + + ASSERT_TRUE( bars.horizontal ); + ASSERT_TRUE( bars.vertical ); + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, AnAlwaysOnVerticalBarNarrowsTheRoomTheHorizontalOneJudges ) +{ + // Nothing overflows 300 across, but the always-on vertical bar leaves 286. + const BarPresence bars( GuiScrollCtrl::ScrollBarDynamic, GuiScrollCtrl::ScrollBarAlwaysOn, + Point2I( 295, 50 ), Point2I( 300, 200 ) ); + + ASSERT_TRUE( bars.vertical ); + ASSERT_TRUE( bars.horizontal ) + << "An always-on bar takes its space before the other axis is judged."; + + SUCCEED(); +} + +TEST( GuiScrollLayoutTests, AnAlwaysOffAxisIsNeverCalledIntoBeing ) +{ + // Wide enough to overflow twice over, but across cannot scroll. + const BarPresence bars( GuiScrollCtrl::ScrollBarAlwaysOff, GuiScrollCtrl::ScrollBarDynamic, + Point2I( 5000, 5000 ), Point2I( 300, 200 ) ); + + ASSERT_TRUE( bars.vertical ); + ASSERT_FALSE( bars.horizontal ) << "alwaysOff holds even when the content overflows it."; + + SUCCEED(); +} + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/guiTextEditTests.cc b/engine/source/testing/tests/guiTextEditTests.cc new file mode 100644 index 000000000..1a4e54857 --- /dev/null +++ b/engine/source/testing/tests/guiTextEditTests.cc @@ -0,0 +1,516 @@ +//----------------------------------------------------------------------------- +// 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 + +// guiTextEditCtrl.h names GuiControl as a base without including it, so the +// order here matters. +#ifndef _GUICONTROL_H_ +#include "gui/guiControl.h" +#endif + +#ifndef _GUITEXTEDITCTRL_H_ +#include "gui/guiTextEditCtrl.h" +#endif + +#ifndef _GUITYPES_H_ +#include "gui/guiTypes.h" +#endif + +//----------------------------------------------------------------------------- +// The caret in a multi-line text box, and the line list it stands on. +// +// A text box draws itself one line block at a time, and each block is asked +// whether the caret belongs to it. A caret is a position BETWEEN two +// characters, so the seam between two lines is a single position with two +// homes: the end of the line above and the start of the line below. Exactly one +// of them may draw it, or the box blinks two carets at once. +// +// That decision is GuiTextEditSelection::isIbeamOnLine, and these tests are the +// whole of it. They walk a line list the way GuiTextEditCtrl::renderLineList +// does -- a line's first caret position is the sum of the lengths of the lines +// above it -- and count how many lines claim the caret. The answer is one. +// +// The line lists below are what GuiControl::getLineList returns for the text +// each test names. The caret arithmetic here is only right for as long as the +// line list keeps that shape, and both halves have broken in turn -- see the +// note at the foot of this file for what holds the two together. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +// A caret that is showing: the box holds the first responder and the blink is +// in its "on" half-second. Text length first -- setCursorPosition clamps to it. +static void placeCaret( GuiTextEditSelection& selector, const U32 textLength, const U32 cursorPos, const bool atEOL ) +{ + selector.setTextLength( textLength ); + selector.setCursorPosition( cursorPos ); + selector.setCursorAtEOL( atEOL ); + selector.setFirstResponder( true ); + selector.resetCursorBlink(); +} + +// Every line of the list that would draw the caret, by index. One entry means a +// working text box; two means the bug this file exists for. +static vector caretLines( const GuiTextEditSelection& selector, const vector& lineList ) +{ + vector claimed = vector(); + U32 ibeamPos = 0; + for ( U32 i = 0; i < lineList.size(); i++ ) + { + const bool isLastLine = ( i == ( lineList.size() - 1 ) ); + if ( selector.isIbeamOnLine( ibeamPos, ibeamPos + lineList[i].length(), isLastLine ) ) + { + claimed.push_back( i ); + } + ibeamPos += lineList[i].length(); + } + return claimed; +} + +static vector lines( const char* a, const char* b = NULL, const char* c = NULL ) +{ + vector lineList = vector(); + lineList.push_back( string( a ) ); + if ( b != NULL ) lineList.push_back( string( b ) ); + if ( c != NULL ) lineList.push_back( string( c ) ); + return lineList; +} + +// The whole text back out of a line list, so a test states its text once. +static U32 textLengthOf( const vector& lineList ) +{ + U32 length = 0; + for ( U32 i = 0; i < lineList.size(); i++ ) + { + length += lineList[i].length(); + } + return length; +} + +//----------------------------------------------------------------------------- +// One caret, wherever it is +//----------------------------------------------------------------------------- + +TEST( GuiTextEditTests, CaretShowsOnceAtEveryPositionInASingleLine ) +{ + const vector lineList = lines( "abc" ); + + for ( U32 pos = 0; pos <= 3; pos++ ) + { + GuiTextEditSelection selector; + placeCaret( selector, 3, pos, false ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ) << "One line, so one caret, at position " << pos << "."; + ASSERT_EQ( claimed[0], 0 ); + } + + SUCCEED(); +} + +TEST( GuiTextEditTests, CaretShowsOnceInAnEmptyBox ) +{ + // Empty text is one blank line, and the caret sits at the start of it. + const vector lineList = lines( "" ); + + GuiTextEditSelection selector; + placeCaret( selector, 0, 0, false ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ) << "An empty box still shows where typing would go."; + ASSERT_EQ( claimed[0], 0 ); + + SUCCEED(); +} + +TEST( GuiTextEditTests, WrapSeamGivesTheCaretToTheLineBelow ) +{ + // "hello world" wrapped: the space is kept on the line it ended. + const vector lineList = lines( "hello ", "world" ); + + GuiTextEditSelection selector; + placeCaret( selector, 11, 6, false ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ) << "Position 6 is the end of one line and the start of the next."; + ASSERT_EQ( claimed[0], 1 ) << "Not at end of line, so the caret is on the lower line."; + + SUCCEED(); +} + +TEST( GuiTextEditTests, WrapSeamGivesTheCaretToTheLineAboveWhenAtEOL ) +{ + const vector lineList = lines( "hello ", "world" ); + + GuiTextEditSelection selector; + placeCaret( selector, 11, 6, true ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ); + ASSERT_EQ( claimed[0], 0 ) << "At end of line, so the caret stays on the upper line."; + + SUCCEED(); +} + +TEST( GuiTextEditTests, CaretShowsOnceAtTheEndOfTheText ) +{ + const vector lineList = lines( "hello ", "world" ); + + GuiTextEditSelection selector; + placeCaret( selector, 11, 11, false ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ) << "There is no line after the last one to hand it to."; + ASSERT_EQ( claimed[0], 1 ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// The line a return makes +//----------------------------------------------------------------------------- + +TEST( GuiTextEditTests, CaretShowsOnceOnTheEmptyLineAReturnMakes ) +{ + // "abc" and a return: the newline stays on the end of its paragraph and the + // empty paragraph after it is a line of its own. The caret is at position 4, + // which is both the end of the first line and the start of the second -- + // and the end of the whole text, which is what used to make both of them + // draw it. + const vector lineList = lines( "abc\n", "" ); + + GuiTextEditSelection selector; + placeCaret( selector, 4, 4, false ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ) << "Two carets: one at the end of the old line, one on the new line."; + ASSERT_EQ( claimed[0], 1 ) << "Return moves the caret to the new line, not the end of the old one."; + + SUCCEED(); +} + +TEST( GuiTextEditTests, CaretShowsOnceAfterTwoReturns ) +{ + // The blank line in the middle is a paragraph holding nothing but its own + // newline, so it is a line with length, unlike the empty one at the end. + const vector lineList = lines( "abc\n", "\n", "" ); + + GuiTextEditSelection selector; + placeCaret( selector, 5, 5, false ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ); + ASSERT_EQ( claimed[0], 2 ) << "The caret is on the last of the empty lines."; + + SUCCEED(); +} + +TEST( GuiTextEditTests, CaretShowsOnceAtEveryPositionOfTextEndingInAReturn ) +{ + // The sweep the two tests above are samples of: every caret position of + // "ab\ncd\n", either side of the end-of-line flag, is owned by one line. + const vector lineList = lines( "ab\n", "cd\n", "" ); + const U32 textLength = textLengthOf( lineList ); + + for ( U32 pos = 0; pos <= textLength; pos++ ) + { + for ( U32 eol = 0; eol <= 1; eol++ ) + { + GuiTextEditSelection selector; + placeCaret( selector, textLength, pos, ( eol == 1 ) ); + + const vector claimed = caretLines( selector, lineList ); + ASSERT_EQ( claimed.size(), 1 ) + << "Position " << pos << ( eol == 1 ? " at end of line" : "" ) << " drew " << claimed.size() << " carets."; + } + } + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// No caret at all +//----------------------------------------------------------------------------- + +TEST( GuiTextEditTests, CaretIsHiddenWhenTheBoxIsNotTheFirstResponder ) +{ + const vector lineList = lines( "abc\n", "" ); + + GuiTextEditSelection selector; + placeCaret( selector, 4, 4, false ); + selector.setFirstResponder( false ); + + ASSERT_EQ( caretLines( selector, lineList ).size(), 0 ) << "A box that is not being edited has no caret."; + + SUCCEED(); +} + +TEST( GuiTextEditTests, CaretIsHiddenWhileTheBlinkIsOff ) +{ + const vector lineList = lines( "abc\n", "" ); + + // A fresh selection has not blinked on yet, so this is the dark half of the + // blink without waiting half a second for it. + GuiTextEditSelection selector; + selector.setTextLength( 4 ); + selector.setCursorPosition( 4 ); + selector.setFirstResponder( true ); + + ASSERT_EQ( caretLines( selector, lineList ).size(), 0 ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// Return, from the control +//----------------------------------------------------------------------------- + +// Reaches the key handler and the caret state through the class itself rather +// than through new public methods: nothing here exists for the test's benefit. +class TestTextEditCtrl : public GuiTextEditCtrl +{ +public: + using GuiTextEditCtrl::mSelector; + using GuiTextEditCtrl::insertNewLine; +}; + +TEST( GuiTextEditTests, ReturnPutsTheCaretOnTheNewLine ) +{ + TestTextEditCtrl* box = new TestTextEditCtrl(); + box->registerObject(); + box->setTextWrap( true ); + box->setText( "hello world" ); + + // What a click at the end of a wrapped line leaves behind: the box is being + // edited, and the caret is at the seam, belonging to the line above. + box->mSelector.setTextLength( 11 ); + box->mSelector.setCursorPosition( 6 ); + box->mSelector.setCursorAtEOL( true ); + box->mSelector.setFirstResponder( true ); + box->mSelector.resetCursorBlink(); + + box->insertNewLine(); + + ASSERT_EQ( box->mSelector.getCursorPos(), 7 ) << "The caret steps over the line break it just made."; + + // "hello \n" is now a paragraph of its own, so position 7 is the seam + // between it and "world". A caret still flagged end-of-line draws at the end + // of the line above -- the line the user just left. + const vector lineList = lines( "hello \n", "world" ); + const vector claimed = caretLines( box->mSelector, lineList ); + + ASSERT_EQ( claimed.size(), 1 ); + ASSERT_EQ( claimed[0], 1 ) << "Return moves the caret onto the new line."; + + box->deleteObject(); + + SUCCEED(); +} + +TEST( GuiTextEditTests, ReturnInsertsALineBreakAtTheCaret ) +{ + TestTextEditCtrl* box = new TestTextEditCtrl(); + box->registerObject(); + box->setTextWrap( true ); + box->setText( "abcdef" ); + + box->mSelector.setTextLength( 6 ); + box->mSelector.setCursorPosition( 3 ); + + box->insertNewLine(); + + ASSERT_STREQ( box->getText(), "abc\ndef" ); + ASSERT_EQ( box->mSelector.getCursorPos(), 4 ); + + box->deleteObject(); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// The caret and the length of the text it is in +// +// Every caret move clamps to the length of the text, so the selection has to be +// told that length by whoever changes the text. Both halves of that have been +// missing: the length started as uninitialized memory, and setText -- the one +// path that changes the text with no keystroke behind it -- never passed it on. +//----------------------------------------------------------------------------- + +TEST( GuiTextEditTests, AFreshSelectionHoldsNoText ) +{ + GuiTextEditSelection selector; + + ASSERT_EQ( selector.getCursorPos(), 0 ); + + // setCursorPosition clamps to the text length. Left uninitialized, that + // length is whatever was in the memory, so the caret lands anywhere. + selector.setCursorPosition( 5 ); + + ASSERT_EQ( selector.getCursorPos(), 0 ) << "No text, so there is nowhere for the caret to go."; + + SUCCEED(); +} + +TEST( GuiTextEditTests, TheCaretReachesTextThatWasSetRatherThanTyped ) +{ + TestTextEditCtrl* box = new TestTextEditCtrl(); + box->registerObject(); + box->setText( "abc" ); + + // What a control loaded from TAML has: text, and no keystroke or click + // behind it to have told the selection how long that text is. + box->setIbeamPosition( 2 ); + + ASSERT_EQ( box->getIbeamPosition(), 2 ) << "The caret can go anywhere inside text the box was given."; + + box->deleteObject(); + + SUCCEED(); +} + +TEST( GuiTextEditTests, TheCaretCannotBePlacedPastTheEndOfTheText ) +{ + TestTextEditCtrl* box = new TestTextEditCtrl(); + box->registerObject(); + box->setText( "abc" ); + + box->setIbeamPosition( 99 ); + + ASSERT_EQ( box->getIbeamPosition(), 3 ) << "Past the end is the end."; + + box->deleteObject(); + + SUCCEED(); +} + +TEST( GuiTextEditTests, ClearingTheTextBringsTheCaretBack ) +{ + TestTextEditCtrl* box = new TestTextEditCtrl(); + box->registerObject(); + box->setText( "abc" ); + box->setIbeamPosition( 3 ); + + box->setText( "" ); + + ASSERT_EQ( box->getIbeamPosition(), 0 ) << "The text it pointed into is gone."; + + box->deleteObject(); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +//----------------------------------------------------------------------------- +// The line list the caret arithmetic rests on +// +// GuiControl::getLineList is two jobs: splitting text into paragraphs on its +// line breaks, and wrapping each of those to a width. Only the second needs a +// font, so only the second is out of reach here -- measuring text loads a font, +// a font registers a texture, and TextureManager::refresh asserts because this +// suite runs with no canvas and so no GL context to make one in. +// +// The split is the half the caret cares about, and the half that has broken: +// every line list written out by hand above is what it returns. +//----------------------------------------------------------------------------- + +TEST( GuiTextEditTests, EmptyTextIsOneParagraph ) +{ + const vector paragraphs = GuiControl::splitParagraphs( "" ); + + ASSERT_EQ( paragraphs.size(), 1 ) << "No paragraph means no line block, and a line block is what draws the caret."; + ASSERT_EQ( paragraphs[0].length(), 0 ); + + SUCCEED(); +} + +TEST( GuiTextEditTests, ALineBreakStaysOnTheEndOfItsParagraph ) +{ + const vector paragraphs = GuiControl::splitParagraphs( "ab\ncd" ); + + ASSERT_EQ( paragraphs.size(), 2 ); + ASSERT_STREQ( paragraphs[0].c_str(), "ab\n" ) << "Dropping the break here moves the caret on every line below it."; + ASSERT_STREQ( paragraphs[1].c_str(), "cd" ); + + SUCCEED(); +} + +TEST( GuiTextEditTests, ATrailingReturnMakesAnEmptyLastParagraph ) +{ + const vector paragraphs = GuiControl::splitParagraphs( "abc\n" ); + + ASSERT_EQ( paragraphs.size(), 2 ) << "The caret has to have a line to sit on after a return."; + ASSERT_STREQ( paragraphs[0].c_str(), "abc\n" ); + ASSERT_EQ( paragraphs[1].length(), 0 ); + + SUCCEED(); +} + +TEST( GuiTextEditTests, ABlankLineBetweenTwoParagraphsIsKept ) +{ + const vector paragraphs = GuiControl::splitParagraphs( "a\n\nb" ); + + ASSERT_EQ( paragraphs.size(), 3 ); + ASSERT_STREQ( paragraphs[1].c_str(), "\n" ) << "A blank line is a paragraph holding nothing but its own break."; + + SUCCEED(); +} + +TEST( GuiTextEditTests, ParagraphLengthsSumToTheTextLength ) +{ + // The invariant the caret stands on: renderLineList finds a line's first + // caret position by summing the lengths of the lines above it, so a + // character dropped or invented here moves the caret. + const char* texts[] = { "", "abc", "abc\n", "abc\n\n", "\n", "ab\ncd\n", "hello world" }; + + for ( U32 i = 0; i < ( sizeof( texts ) / sizeof( texts[0] ) ); i++ ) + { + const vector paragraphs = GuiControl::splitParagraphs( texts[i] ); + + U32 sum = 0; + for ( U32 p = 0; p < paragraphs.size(); p++ ) + { + sum += paragraphs[p].length(); + } + + ASSERT_EQ( sum, dStrlen( texts[i] ) ) + << "The paragraphs of '" << texts[i] << "' hold " << sum << " characters, not " << dStrlen( texts[i] ) << "."; + } + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// The wrapping half is covered by tests/smoke/textEdit.cs, which runs in a real +// engine with a real canvas: it reads the line count off a control that sizes +// itself to its text. +//----------------------------------------------------------------------------- +//----------------------------------------------------------------------------- + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/guiTreeRowLayoutTests.cc b/engine/source/testing/tests/guiTreeRowLayoutTests.cc new file mode 100644 index 000000000..d3a93265e --- /dev/null +++ b/engine/source/testing/tests/guiTreeRowLayoutTests.cc @@ -0,0 +1,354 @@ +//----------------------------------------------------------------------------- +// 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 _GUI_TREEVIEWCTRL_H +#include "gui/guiTreeViewCtrl.h" +#endif + +#ifndef _GUI_EDITOR_EXPLORERTREE_H_ +#include "gui/editor/guiEditorExplorerTree.h" +#endif + +//----------------------------------------------------------------------------- +// How a tree row spends its width, left to right. +// +// A row is a budget: two gutter columns, an indent per level, a triangle, an +// icon, and whatever is left is the text. Every one of those is arithmetic, and +// all of it used to be inline in onRenderItem where nothing could reach it. +// +// It cannot be tested there. Adding a row to a tree calls updateSize, which asks +// the 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 pulled out into statics that take +// everything they use, exactly as GuiScrollCtrl's bar arithmetic was, and the +// statics are what these tests call. They construct nothing. +//----------------------------------------------------------------------------- + +//----------------------------------------------------------------------------- +// resolveIndent -- how far one level of depth steps the row in. +// +// The step was always the row's inner height, hard-coded at three sites. It is +// now a field, and the danger in that is the default: mIndentSize sat unread for +// years holding 10, so wiring it up without resetting it would have silently +// re-indented every tree in the engine. Zero means "as before". +//----------------------------------------------------------------------------- + +TEST( GuiTreeRowLayoutTests, ZeroIndentMeansOneRowHeight ) +{ + ASSERT_EQ( GuiTreeViewCtrl::resolveIndent( 0, 22 ), 22 ) + << "Zero is the default, and the default must be the step the tree always used."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, APositiveIndentWins ) +{ + ASSERT_EQ( GuiTreeViewCtrl::resolveIndent( 12, 22 ), 12 ) + << "A tree that asks for a narrower step gets it, whatever the row height is."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, ANegativeIndentFallsBackToTheRowHeight ) +{ + ASSERT_EQ( GuiTreeViewCtrl::resolveIndent( -6, 22 ), 22 ) + << "Only a positive value is an instruction; anything else means 'as before'."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, IndentIsNeverNegative ) +{ + ASSERT_EQ( GuiTreeViewCtrl::resolveIndent( 0, 0 ), 0 ) + << "A row with no inside indents by nothing."; + ASSERT_EQ( GuiTreeViewCtrl::resolveIndent( 0, -4 ), 0 ) + << "A negative step would walk the tree backwards, one level at a time."; + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// focusLineOffset -- the rule that hangs from a container's triangle. +// +// It marks the branch the editor will drop into, and it runs down from that +// container's triangle, so it has to line up with the triangle's point. It did +// for free while the indent step and the row height were the same number: the +// line was centred in the indent slot and the triangle was drawn in a square of +// the row height, and nobody had to know which one they were centring on. +// +// Making IndentSize settable broke that silently. The line moved four pixels +// left of the triangle it belonged to and everything still "worked". Hence a +// static, and hence this: the invariant is that the line covers the centre of +// the triangle's square, whatever the indent happens to be. +//----------------------------------------------------------------------------- + +// The triangle is drawn in a square of the row's inner height, so its point is +// at half that. The line must cover it. +static bool lineCoversTriangleTip( const S32 rowInnerHeight ) +{ + const S32 start = GuiTreeViewCtrl::focusLineOffset( rowInnerHeight ); + const S32 end = start + GuiTreeViewCtrl::smFocusLineWidth; + const S32 tip = rowInnerHeight / 2; + return tip >= start && tip < end; +} + +TEST( GuiTreeRowLayoutTests, TheFocusLineHangsFromTheTrianglesPoint ) +{ + for( S32 rowInnerHeight = 4; rowInnerHeight <= 64; rowInnerHeight++ ) + { + ASSERT_TRUE( lineCoversTriangleTip( rowInnerHeight ) ) + << "row height " << rowInnerHeight << ": the line missed the triangle it hangs from."; + } + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, TheFocusLineDoesNotDependOnTheIndent ) +{ + // The point of the static. focusLineOffset takes the row height and nothing + // else, so no value of IndentSize can move the line off its triangle -- which + // is precisely what happened when the offset was computed from the indent. + ASSERT_EQ( GuiTreeViewCtrl::focusLineOffset( 20 ), 9 ); + ASSERT_EQ( GuiTreeViewCtrl::resolveIndent( 12, 20 ), 12 ) + << "A narrow indent is still honoured..."; + ASSERT_EQ( GuiTreeViewCtrl::focusLineOffset( 20 ), 9 ) + << "...and the line does not follow it."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, TheFocusLineNeverStartsLeftOfItsSlot ) +{ + ASSERT_EQ( GuiTreeViewCtrl::focusLineOffset( 0 ), 0 ); + ASSERT_EQ( GuiTreeViewCtrl::focusLineOffset( 1 ), 0 ) + << "A row too short to centre a 2px rule in must not push it into the slot before."; + ASSERT_EQ( GuiTreeViewCtrl::focusLineOffset( -8 ), 0 ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// iconSlot -- where the row's picture goes, and what it costs. +// +// Two promises: it never enlarges the art, and when it cannot fit it consumes +// nothing at all. The second is what stops a cramped tree drawing its text on +// top of its icons -- degrading to a plain row is legible, overlapping is not. +//----------------------------------------------------------------------------- + +TEST( GuiTreeRowLayoutTests, TheIconCentersInATallerRow ) +{ + RectI dst; + S32 advance = 0; + const bool fits = GuiTreeViewCtrl::iconSlot( RectI( 100, 50, 200, 22 ), 16, dst, advance ); + + ASSERT_TRUE( fits ); + ASSERT_EQ( dst.point.x, 100 ) << "It draws at the left edge of what is left."; + ASSERT_EQ( dst.point.y, 53 ) << "Six spare pixels, three above and three below."; + ASSERT_EQ( dst.extent.x, 16 ); + ASSERT_EQ( dst.extent.y, 16 ) << "A square: the art is square and must not be stretched."; + ASSERT_EQ( advance, 16 + GuiTreeViewCtrl::smIconGap ) << "The icon, plus one breath before the text."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, TheIconNeverEnlarges ) +{ + RectI dst; + S32 advance = 0; + const bool fits = GuiTreeViewCtrl::iconSlot( RectI( 0, 0, 200, 12 ), 16, dst, advance ); + + ASSERT_TRUE( fits ); + ASSERT_EQ( dst.extent.y, 12 ) << "A row shorter than the art shrinks the art, rather than blowing it up."; + ASSERT_EQ( dst.extent.x, 12 ) << "Still square."; + ASSERT_EQ( dst.point.y, 0 ) << "Nothing spare, so nothing to center."; + ASSERT_EQ( advance, 12 + GuiTreeViewCtrl::smIconGap ); + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, ANarrowRowGetsNoSlotAndPaysNothing ) +{ + RectI dst; + S32 advance = 0; + const bool fits = GuiTreeViewCtrl::iconSlot( RectI( 0, 0, 10, 22 ), 16, dst, advance ); + + ASSERT_FALSE( fits ) << "There is no room for the icon and a space after it."; + ASSERT_EQ( advance, 0 ) << "Consuming width it did not draw in would put the text under nothing."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, TheSlotFitsInExactlyItsOwnWidth ) +{ + RectI dst; + S32 advance = 0; + + ASSERT_TRUE( GuiTreeViewCtrl::iconSlot( RectI( 0, 0, 16 + GuiTreeViewCtrl::smIconGap, 22 ), 16, dst, advance ) ) + << "Exactly enough is enough."; + + ASSERT_FALSE( GuiTreeViewCtrl::iconSlot( RectI( 0, 0, 15 + GuiTreeViewCtrl::smIconGap, 22 ), 16, dst, advance ) ) + << "One pixel short is short. The gap is part of the cost, not a nicety to drop."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, NoIconSizeIsNoSlot ) +{ + RectI dst; + S32 advance = 0; + + ASSERT_FALSE( GuiTreeViewCtrl::iconSlot( RectI( 0, 0, 200, 22 ), 0, dst, advance ) ) + << "A tree with no sheet set asks for nothing and must be charged nothing."; + ASSERT_EQ( advance, 0 ); + + ASSERT_FALSE( GuiTreeViewCtrl::iconSlot( RectI( 0, 0, 200, 0 ), 16, dst, advance ) ) + << "A row with no height has nowhere to put it."; + ASSERT_EQ( advance, 0 ); + + SUCCEED(); +} + +//----------------------------------------------------------------------------- +// The Explorer's two gutter columns. +// +// The eye and the padlock are a fixed rail at the row's left edge, and the only +// thing keeping the picture and the click in agreement is that both ask the same +// function the same question. Nothing on screen would report them disagreeing: +// the boxes would draw where they always did and the clicks would land a few +// pixels off, toggling the wrong control or nothing at all. +// +// So the geometry is checked here, from both sides of every boundary. An +// off-by-one in columnAt reads as "the eye is fussy about where you click", +// which is the kind of thing that gets lived with rather than reported. +//----------------------------------------------------------------------------- + +// Where the rail starts. Nonzero on purpose: with the editor's treeViewProfile +// the inset is 0, so testing only at 0 would pass on arithmetic that had dropped +// the left edge entirely. +static const S32 GutterLeft = 7; + +TEST( GuiTreeRowLayoutTests, TheEyeColumnIsLeftmost ) +{ + RectI eye, lock; + GuiEditorExplorerTree::getGutterCells( GutterLeft, 40, 22, eye, lock ); + + ASSERT_EQ( eye.point.x, GutterLeft ) + << "Every layers panel anyone has used puts visibility first. This is that order."; + ASSERT_LT( eye.point.x, lock.point.x ); + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, TheTwoColumnsAbutAndSpanTheRow ) +{ + RectI eye, lock; + GuiEditorExplorerTree::getGutterCells( GutterLeft, 40, 22, eye, lock ); + + ASSERT_EQ( eye.point.x + eye.extent.x, lock.point.x ) << "No seam between them."; + ASSERT_EQ( eye.extent.x, GuiEditorExplorerTree::smColumnWidth ); + ASSERT_EQ( lock.extent.x, GuiEditorExplorerTree::smColumnWidth ); + ASSERT_EQ( eye.extent.x + lock.extent.x, GuiEditorExplorerTree::getGutterWidth() ) + << "What the renderer carves off the row must be what the two cells actually occupy."; + + ASSERT_EQ( eye.point.y, 40 ); + ASSERT_EQ( eye.extent.y, 22 ) << "The full row height, so the dividers read as continuous rules."; + ASSERT_EQ( lock.point.y, 40 ); + ASSERT_EQ( lock.extent.y, 22 ); + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, EveryColumnBoundaryIsWhereItLooks ) +{ + const S32 w = GuiEditorExplorerTree::smColumnWidth; + + ASSERT_EQ( GuiEditorExplorerTree::columnAt( GutterLeft - 1, GutterLeft ), + GuiEditorExplorerTree::GutterNone ) << "One pixel left of the rail is not the rail."; + ASSERT_EQ( GuiEditorExplorerTree::columnAt( GutterLeft, GutterLeft ), + GuiEditorExplorerTree::GutterHidden ) << "Its first pixel is the eye."; + ASSERT_EQ( GuiEditorExplorerTree::columnAt( GutterLeft + w - 1, GutterLeft ), + GuiEditorExplorerTree::GutterHidden ) + << "Including its divider: the whole cell is clickable, not just the box."; + ASSERT_EQ( GuiEditorExplorerTree::columnAt( GutterLeft + w, GutterLeft ), + GuiEditorExplorerTree::GutterLocked ) << "And the next pixel is the padlock."; + ASSERT_EQ( GuiEditorExplorerTree::columnAt( GutterLeft + ( 2 * w ) - 1, GutterLeft ), + GuiEditorExplorerTree::GutterLocked ); + ASSERT_EQ( GuiEditorExplorerTree::columnAt( GutterLeft + ( 2 * w ), GutterLeft ), + GuiEditorExplorerTree::GutterNone ) + << "Past the rail is the tree, where a click selects rather than toggles."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, AFarawayPointIsInNoColumn ) +{ + ASSERT_EQ( GuiEditorExplorerTree::columnAt( -400, GutterLeft ), + GuiEditorExplorerTree::GutterNone ); + ASSERT_EQ( GuiEditorExplorerTree::columnAt( 4000, GutterLeft ), + GuiEditorExplorerTree::GutterNone ); + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, TheBoxSitsInsideItsCellClearOfTheDivider ) +{ + RectI eye, lock; + GuiEditorExplorerTree::getGutterCells( GutterLeft, 40, 22, eye, lock ); + const RectI box = GuiEditorExplorerTree::getBoxRect( eye ); + + ASSERT_EQ( box.extent.x, GuiEditorExplorerTree::smBoxSize ); + ASSERT_EQ( box.extent.y, GuiEditorExplorerTree::smBoxSize ) << "Square: the art is square."; + ASSERT_GE( box.point.x, eye.point.x ) << "Inside its own cell."; + ASSERT_LT( box.point.x + box.extent.x, eye.point.x + eye.extent.x ) + << "Clear of the divider, which owns the cell's last pixel."; + ASSERT_EQ( box.point.y, 40 + 3 ) << "Six spare pixels of row, three above and three below."; + + SUCCEED(); +} + +TEST( GuiTreeRowLayoutTests, TheBoxShrinksIntoAShortRowRatherThanOverflowing ) +{ + RectI eye, lock; + GuiEditorExplorerTree::getGutterCells( GutterLeft, 0, 10, eye, lock ); + const RectI box = GuiEditorExplorerTree::getBoxRect( eye ); + + ASSERT_EQ( box.extent.y, 10 ) << "A row too short for the art gets the art shrunk to it."; + ASSERT_EQ( box.extent.x, 10 ) << "Still square, so the icon is not distorted."; + ASSERT_GE( box.point.y, 0 ); + ASSERT_LE( box.point.y + box.extent.y, 10 ) << "And it stays within the row it is in."; + + SUCCEED(); +} + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/namespaceLinkTests.cc b/engine/source/testing/tests/namespaceLinkTests.cc new file mode 100644 index 000000000..556d0815d --- /dev/null +++ b/engine/source/testing/tests/namespaceLinkTests.cc @@ -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. +//----------------------------------------------------------------------------- + +// We don't want tests in a shipping version. +#ifndef TORQUE_SHIPPING + +#ifndef _UNIT_TESTING_H_ +#include "testing/unitTesting.h" +#endif + +#ifndef _SIMBASE_H_ +#include "sim/simBase.h" +#endif + +#ifndef _CONSOLE_H_ +#include "console/console.h" +#endif + +#ifndef _CONSOLENAMESPACE_H_ +#include "console/consoleNamespace.h" +#endif + +//----------------------------------------------------------------------------- +// Namespace linkage for an object named after its own class. +// +// A script singleton is written as +// +// new ScriptObject(PlanetXUpgrades) { class = "PlanetXUpgrades"; }; +// +// so that every file can reach it by name and its methods can be written as +// PlanetXUpgrades::foo. SimObject::linkNamespaces then links the class to the +// C++ class, and a moment later links the OBJECT NAME to the class - but those +// are one namespace, so it asks that namespace to become its own parent. +// +// Self-parenting is a no-op: the namespace is already the one the object wears. +// It used to be an error instead ("cannot change namespace parent linkage of +// PlanetXUpgrades from ScriptObject to PlanetXUpgrades"), with the mirror image +// at teardown - a failure reported for something that neither failed nor did +// anything. It is a warning now, because the object did say one word twice and +// the second one buys nothing. +// +// These tests hold that warning to what it is worth - said once, where the +// repeat is written, naming the namespace - hold the parent reference count +// balanced across the no-op, which is what a mismatched link/unlink pair would +// quietly break, and hold onto the error the guard really exists for: one +// namespace being given two different parents. +//----------------------------------------------------------------------------- + +static bool sCapturing = false; +static U32 sErrorCount = 0; +static U32 sWarningCount = 0; +static char sFirstError[1024]; +static char sFirstWarning[1024]; + +static void remember( char* buffer, U32 size, const char* line ) +{ + dStrncpy( buffer, line, size - 1 ); + buffer[size - 1] = '\0'; +} + +static void captureOutput( ConsoleLogEntry::Level level, const char* line ) +{ + if ( !sCapturing ) + return; + + if ( level == ConsoleLogEntry::Error ) + { + if ( sErrorCount == 0 ) + remember( sFirstError, sizeof(sFirstError), line ); + + sErrorCount++; + } + else if ( level == ConsoleLogEntry::Warning ) + { + if ( sWarningCount == 0 ) + remember( sFirstWarning, sizeof(sFirstWarning), line ); + + sWarningCount++; + } +} + +static void beginCapture() +{ + sErrorCount = 0; + sWarningCount = 0; + sFirstError[0] = '\0'; + sFirstWarning[0] = '\0'; + sCapturing = true; + Con::addConsumer( captureOutput ); +} + +static void endCapture() +{ + Con::removeConsumer( captureOutput ); + sCapturing = false; +} + +//----------------------------------------------------------------------------- +// The singleton named after its class. +//----------------------------------------------------------------------------- + +TEST( NamespaceLinkTests, ObjectNamedAfterItsClassIsWarnedNotFailed ) +{ + SimObject* singleton = new SimObject(); + singleton->setClassNamespace( "NsLinkTestSingleton" ); + + beginCapture(); + singleton->registerObject( "NsLinkTestSingleton" ); + endCapture(); + + ASSERT_EQ( sErrorCount, 0u ) + << "Nothing failed, so nothing should say it did: " << sFirstError; + ASSERT_EQ( sWarningCount, 1u ) + << "The redundant class is worth one warning - no more, and not none."; + ASSERT_TRUE( dStrstr( sFirstWarning, "NsLinkTestSingleton" ) != NULL ) + << "It has to name the namespace to be actionable, and said: " << sFirstWarning; + + ASSERT_TRUE( singleton->getNamespace() != NULL ); + ASSERT_STREQ( singleton->getNamespace()->mName, "NsLinkTestSingleton" ) + << "It still wears the namespace its script methods are written in."; + + beginCapture(); + singleton->deleteObject(); + endCapture(); + + ASSERT_EQ( sErrorCount, 0u ) + << "Deleting it said: " << sFirstError; + ASSERT_EQ( sWarningCount, 0u ) + << "Teardown repeats nothing - the warning belongs where the repeat is written."; +} + +// Link and unlink have to agree about whether the self-link counted, or the +// count drifts: too many unlinks and the class loses its parent while another +// object is still wearing it, too few and it never comes back at all. Two full +// rounds catch either one. +TEST( NamespaceLinkTests, SelfLinkLeavesTheParentReferenceCountBalanced ) +{ + StringTableEntry name = StringTable->insert( "NsLinkTestBalanced" ); + + for ( U32 round = 0; round < 2; round++ ) + { + SimObject* singleton = new SimObject(); + singleton->setClassNamespace( name ); + singleton->registerObject( name ); + + Namespace* linked = Namespace::find( name ); + ASSERT_TRUE( linked->mParent != NULL ) + << "Round " << round << ": the class namespace must inherit the C++ class."; + ASSERT_STREQ( linked->mParent->mName, "SimObject" ) + << "Round " << round << ": the self-link must not have displaced that parent."; + + singleton->deleteObject(); + } + + Namespace* ns = Namespace::find( name ); + ASSERT_EQ( ns->mRefCountToParent, 0u ) + << "Every link was given back."; + ASSERT_TRUE( ns->mParent == NULL ) + << "So the namespace is unlinked again."; +} + +//----------------------------------------------------------------------------- +// What the guard is really for. +//----------------------------------------------------------------------------- + +// Two objects giving one class namespace two different superclasses is a real +// ambiguity - whichever registered first silently decides what the other +// inherits - so it must still be reported. This test therefore writes one +// genuine error line to the console log; the NsLinkTest names in it say so. +TEST( NamespaceLinkTests, TwoDifferentParentsForOneClassIsStillAnError ) +{ + SimObject* first = new SimObject(); + first->setSuperClassNamespace( "NsLinkTestParentOne" ); + first->setClassNamespace( "NsLinkTestShared" ); + first->registerObject(); + + SimObject* second = new SimObject(); + second->setSuperClassNamespace( "NsLinkTestParentTwo" ); + second->setClassNamespace( "NsLinkTestShared" ); + + beginCapture(); + second->registerObject(); + endCapture(); + + ASSERT_EQ( sErrorCount, 1u ) + << "One namespace cannot have two parents, and saying so is the point of the guard."; + + second->deleteObject(); + first->deleteObject(); +} + +#endif // TORQUE_SHIPPING diff --git a/engine/source/testing/tests/simObjectCloneTests.cc b/engine/source/testing/tests/simObjectCloneTests.cc new file mode 100644 index 000000000..d45b5bcd0 --- /dev/null +++ b/engine/source/testing/tests/simObjectCloneTests.cc @@ -0,0 +1,392 @@ +//----------------------------------------------------------------------------- +// 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 _SIMBASE_H_ +#include "sim/simBase.h" +#endif + +#ifndef _SIMSET_H_ +#include "sim/simSet.h" +#endif + +#ifndef _GUICONTROL_H_ +#include "gui/guiControl.h" +#endif + +#ifndef _GUIFRAMESETCTRL_H_ +#include "gui/containers/guiFrameSetCtrl.h" +#endif + +#ifndef _CONSOLE_H_ +#include "console/console.h" +#endif + +//----------------------------------------------------------------------------- +// SimObject::deepClone and the copyFieldsFrom it is built on. +// +// A deep clone exists for copy and paste in the Gui Editor, and the two things +// it promises are the two things a clipboard needs: the copy holds everything +// the original held, and nothing of the original's script lifecycle runs while +// it is being made. Both are asserted here, because both are orderings inside +// the implementation that nothing else would catch if they were reversed. +//----------------------------------------------------------------------------- + +static StringTableEntry fieldName( const char* name ) +{ + return StringTable->insert( name ); +} + +static const char* readField( SimObject* object, const char* name ) +{ + return object->getDataField( fieldName( name ), NULL ); +} + +static void writeField( SimObject* object, const char* name, const char* value ) +{ + object->setDataField( fieldName( name ), NULL, value ); +} + +//----------------------------------------------------------------------------- +// copyFieldsFrom: the two fields a copy must not take. +//----------------------------------------------------------------------------- + +TEST( SimObjectCloneTests, CopyFieldsFromCarriesOrdinaryFields ) +{ + SimObject* source = new SimObject(); + source->registerObject(); + writeField( source, "internalName", "carried" ); + writeField( source, "aDynamicField", "also carried" ); + + SimObject* target = new SimObject(); + target->registerObject(); + target->copyFieldsFrom( source, 0 ); + + ASSERT_STREQ( readField( target, "internalName" ), "carried" ); + ASSERT_STREQ( readField( target, "aDynamicField" ), "also carried" ) + << "Dynamic fields come across too, as assignFieldsFrom always did."; + + source->deleteObject(); + target->deleteObject(); +} + +// A protected field whose get function answers from the object rather than from +// the raw data it is handed - GuiControl's "text" is one (getTextProperty returns +// obj->getText()) - has to be read through the source. Reading it through the +// destination gives back what the destination already held, so the field silently +// does not copy at all. +TEST( SimObjectCloneTests, CopyFieldsFromCarriesProtectedFieldsWithCustomGetters ) +{ + GuiControl* source = new GuiControl(); + source->registerObject(); + writeField( source, "text", "Save As..." ); + writeField( source, "Extent", "123 45" ); + + GuiControl* clone = dynamic_cast( source->deepClone() ); + ASSERT_TRUE( clone != NULL ); + + ASSERT_STREQ( readField( clone, "text" ), "Save As..." ) + << "text is protected with a getter that ignores the data pointer."; + ASSERT_STREQ( readField( clone, "Extent" ), "123 45" ) + << "Extent is protected too, but with the default getter."; + + clone->deleteObject(); + source->deleteObject(); +} + +TEST( SimObjectCloneTests, CopyFieldsFromCanSkipTheName ) +{ + SimObject* source = new SimObject(); + source->registerObject( "CloneTestNamedSource" ); + + SimObject* withName = new SimObject(); + withName->registerObject(); + withName->copyFieldsFrom( source, 0 ); + ASSERT_STREQ( withName->getName(), "CloneTestNamedSource" ) + << "name is an ordinary persist field, so it copies unless asked not to."; + + SimObject* withoutName = new SimObject(); + withoutName->registerObject(); + withoutName->copyFieldsFrom( source, SimObject::CopyFields_SkipName ); + ASSERT_TRUE( withoutName->getName() == NULL || withoutName->getName()[0] == '\0' ) + << "Two objects answering to one name is the bug this flag exists for."; + + source->deleteObject(); + withName->deleteObject(); + withoutName->deleteObject(); +} + +TEST( SimObjectCloneTests, CopyFieldsFromCanSkipTheParentGroup ) +{ + SimGroup* group = new SimGroup(); + group->registerObject(); + + SimObject* source = new SimObject(); + source->registerObject(); + group->addObject( source ); + + // Copying parentGroup does not record where the object lives, it moves the + // object there: the field's setter calls parent->addObject(). + SimObject* moved = new SimObject(); + moved->registerObject(); + moved->copyFieldsFrom( source, 0 ); + ASSERT_TRUE( moved->getGroup() == group ) + << "parentGroup is a persist field whose setter adds the object to the group."; + + SimObject* homeless = new SimObject(); + homeless->registerObject(); + homeless->copyFieldsFrom( source, SimObject::CopyFields_SkipParentGroup ); + ASSERT_TRUE( homeless->getGroup() == NULL ) + << "A copy belongs nowhere until someone puts it somewhere."; + + homeless->deleteObject(); + group->deleteObject(); // takes source and moved with it +} + +//----------------------------------------------------------------------------- +// deepClone. +//----------------------------------------------------------------------------- + +TEST( SimObjectCloneTests, DeepCloneCopiesFieldsButNotIdentity ) +{ + SimGroup* group = new SimGroup(); + group->registerObject(); + + SimObject* source = new SimObject(); + source->registerObject( "CloneTestDeepSource" ); + group->addObject( source ); + writeField( source, "internalName", "inner" ); + writeField( source, "aDynamicField", "dynamic" ); + + SimObject* clone = source->deepClone(); + ASSERT_TRUE( clone != NULL ); + ASSERT_TRUE( clone != source ); + + ASSERT_STREQ( readField( clone, "internalName" ), "inner" ); + ASSERT_STREQ( readField( clone, "aDynamicField" ), "dynamic" ); + ASSERT_TRUE( clone->getName() == NULL || clone->getName()[0] == '\0' ); + ASSERT_TRUE( clone->getGroup() == NULL ); + + clone->deleteObject(); + group->deleteObject(); +} + +TEST( SimObjectCloneTests, DeepCloneCopiesTheWholeTree ) +{ + SimGroup* source = new SimGroup(); + source->registerObject(); + + SimObject* child = new SimObject(); + child->registerObject(); + source->addObject( child ); + writeField( child, "internalName", "child" ); + + SimGroup* grandChildHolder = new SimGroup(); + grandChildHolder->registerObject(); + source->addObject( grandChildHolder ); + + SimObject* grandChild = new SimObject(); + grandChild->registerObject(); + grandChildHolder->addObject( grandChild ); + writeField( grandChild, "internalName", "grandChild" ); + + SimGroup* clone = dynamic_cast( source->deepClone() ); + ASSERT_TRUE( clone != NULL ); + ASSERT_EQ( clone->size(), 2 ); + + // Deep, not shared: every object below the clone is a new object. + ASSERT_TRUE( (*clone)[0] != child ); + ASSERT_STREQ( readField( (*clone)[0], "internalName" ), "child" ); + + SimGroup* clonedHolder = dynamic_cast( (*clone)[1] ); + ASSERT_TRUE( clonedHolder != NULL ); + ASSERT_EQ( clonedHolder->size(), 1 ); + ASSERT_TRUE( (*clonedHolder)[0] != grandChild ); + ASSERT_STREQ( readField( (*clonedHolder)[0], "internalName" ), "grandChild" ); + + clone->deleteObject(); + source->deleteObject(); +} + +TEST( SimObjectCloneTests, DeepCloneOfASimSetDoesNotDuplicateItsMembers ) +{ + // A SimSet references objects some group owns; duplicating those would be + // inventing objects nobody asked for. Only SimGroup recurses. + SimSet* set = new SimSet(); + set->registerObject(); + + SimObject* member = new SimObject(); + member->registerObject(); + set->addObject( member ); + + SimSet* clone = dynamic_cast( set->deepClone() ); + ASSERT_TRUE( clone != NULL ); + ASSERT_EQ( clone->size(), 0 ); + + clone->deleteObject(); + set->deleteObject(); + member->deleteObject(); +} + +//----------------------------------------------------------------------------- +// The promise that makes a deep clone usable as a clipboard: no script +// lifecycle callback fires on it. +// +// copyTo runs last in cloneInto, which is what makes this true - it is what +// links the namespaces, so while the clone is being filled in there is no +// script class on it for registerObject or onChildAdded to find a method on. +// A class whose onAdd builds children would otherwise build a second set on top +// of the ones being copied. +//----------------------------------------------------------------------------- + +TEST( SimObjectCloneTests, DeepCloneRunsNoScriptLifecycle ) +{ + Con::evaluate( + "function CloneTestProbe::onAdd(%this) { $CloneTestProbeAdds = $CloneTestProbeAdds + 1; }\n" + "function CloneTestProbe::onChildAdded(%this, %child) { $CloneTestProbeChildAdds = $CloneTestProbeChildAdds + 1; }\n", + false, NULL ); + + Con::setIntVariable( "$CloneTestProbeAdds", 0 ); + Con::setIntVariable( "$CloneTestProbeChildAdds", 0 ); + + GuiControl* source = new GuiControl(); + writeField( source, "class", "CloneTestProbe" ); + source->registerObject(); + + ASSERT_EQ( Con::getIntVariable( "$CloneTestProbeAdds" ), 1 ) + << "The original really does have a class whose onAdd runs."; + + GuiControl* child = new GuiControl(); + child->registerObject(); + source->addObject( child ); + + ASSERT_EQ( Con::getIntVariable( "$CloneTestProbeChildAdds" ), 1 ) + << "And a class whose onChildAdded runs."; + + Con::setIntVariable( "$CloneTestProbeAdds", 0 ); + Con::setIntVariable( "$CloneTestProbeChildAdds", 0 ); + + GuiControl* clone = dynamic_cast( source->deepClone() ); + ASSERT_TRUE( clone != NULL ); + + ASSERT_EQ( Con::getIntVariable( "$CloneTestProbeAdds" ), 0 ) + << "A deep clone is data: onAdd must not run on it."; + ASSERT_EQ( Con::getIntVariable( "$CloneTestProbeChildAdds" ), 0 ) + << "Nor onChildAdded, or a class that builds children would double them."; + + ASSERT_EQ( clone->size(), 1 ) + << "Exactly the children the original had, and no more."; + ASSERT_STREQ( readField( clone, "class" ), "CloneTestProbe" ) + << "The class itself does come across - copyTo is what copies it."; + + clone->deleteObject(); + source->deleteObject(); +} + +//----------------------------------------------------------------------------- +// GuiFrameSetCtrl, the one control whose layout is not in its field list. +//----------------------------------------------------------------------------- + +static U32 countFrames( const GuiFrameSetCtrl::Frame* frame ) +{ + if ( frame == NULL ) + return 0; + + return 1 + countFrames( frame->child1 ) + countFrames( frame->child2 ); +} + +static bool framesMatch( const GuiFrameSetCtrl::Frame* a, const GuiFrameSetCtrl::Frame* b ) +{ + if ( a == NULL || b == NULL ) + return a == b; + + if ( a->id != b->id || a->isVertical != b->isVertical || + a->isAnchored != b->isAnchored || a->extent != b->extent ) + return false; + + if ( (a->control == NULL) != (b->control == NULL) ) + return false; + + return framesMatch( a->child1, b->child1 ) && framesMatch( a->child2, b->child2 ); +} + +// Every control the tree holds is one of %owner's own children. +static bool framesHoldOwnChildren( const GuiFrameSetCtrl::Frame* frame, GuiFrameSetCtrl* owner ) +{ + if ( frame == NULL ) + return true; + + if ( frame->control != NULL && frame->control->getGroup() != owner ) + return false; + + return framesHoldOwnChildren( frame->child1, owner ) && + framesHoldOwnChildren( frame->child2, owner ); +} + +TEST( SimObjectCloneTests, DeepCloneRebuildsAFrameSetsTree ) +{ + GuiFrameSetCtrl* source = new GuiFrameSetCtrl(); + source->registerObject(); + source->resize( Point2I( 0, 0 ), Point2I( 400, 200 ) ); + + // Root frame is 1. Split it, then split the right half, for three leaves. + const Point2I halves = source->splitFrame( 1, false ); + source->splitFrame( halves.y, true ); + + for ( U32 i = 0; i < 3; i++ ) + { + GuiControl* panel = new GuiControl(); + panel->registerObject(); + source->addObject( panel ); + } + + // Settle the tree before anything is measured. A split leaves its new frames + // at their constructed extent until the control resizes, and the copy ends + // with a resize of its own (as setFrameLayout does) - so an unsettled source + // would differ from its copy over extents neither of them had been told yet. + source->resize( Point2I( 0, 0 ), Point2I( 400, 200 ) ); + + ASSERT_EQ( countFrames( &source->mRootFrame ), 5u ) + << "Two splits make five frames: a root, two halves, and two under one of them."; + ASSERT_TRUE( framesHoldOwnChildren( &source->mRootFrame, source ) ); + + GuiFrameSetCtrl* clone = dynamic_cast( source->deepClone() ); + ASSERT_TRUE( clone != NULL ); + ASSERT_EQ( clone->size(), 3 ); + + ASSERT_EQ( countFrames( &clone->mRootFrame ), countFrames( &source->mRootFrame ) ); + ASSERT_TRUE( framesMatch( &clone->mRootFrame, &source->mRootFrame ) ) + << "Same shape, same ids, same extents, same anchoring, same frames filled."; + ASSERT_TRUE( framesHoldOwnChildren( &clone->mRootFrame, clone ) ) + << "And filled with the CLONE's children, not the original's."; + + clone->deleteObject(); + source->deleteObject(); +} + +#endif // TORQUE_SHIPPING diff --git a/library/AppCore/appCore.cs b/library/AppCore/appCore.cs index ac0557488..856d192ed 100644 --- a/library/AppCore/appCore.cs +++ b/library/AppCore/appCore.cs @@ -26,9 +26,11 @@ exec("./scripts/constants.cs"); exec("./scripts/defaultPreferences.cs"); exec("./gui/guiCursors.cs"); - %this.createGuiCursors(); exec("./scripts/themes.cs"); %this.loadThemes(); + // After the themes, not before: the cursors a project uses are its theme's, + // and this installs them under the names the engine looks up. + %this.installThemeCursors(%this.cursorTheme()); exec("./scripts/canvas.cs"); // Initialize the canvas diff --git a/library/AppCore/gui/guiCursors.cs b/library/AppCore/gui/guiCursors.cs index bd3331565..7eb14ab73 100644 --- a/library/AppCore/gui/guiCursors.cs +++ b/library/AppCore/gui/guiCursors.cs @@ -20,15 +20,24 @@ // IN THE SOFTWARE. //----------------------------------------------------------------------------- -/// The mouse cursors a GUI names by convention: a text field asks for EditCursor, -/// a window's edges for LeftRightCursor and friends, and a control with none of -/// its own gets DefaultCursor. +/// The mouse cursors a GUI names by convention: a text field asks for +/// EditCursor, a window's edges for LeftRightCursor and friends, and a control +/// with none of its own gets DefaultCursor. Those names are hard-coded in the +/// engine (guiTextEditCtrl.cc, guiWindowCtrl.cc, guiFrameSetCtrl.cc, +/// guiEditCtrl.cc), so something has to answer to them. /// -/// This file used to build a project's ~70 GUI profiles as well. Those are now a -/// GuiProfileTheme (see scripts/themes.cs), which derives the whole set from six -/// colors and is editable in the GUI Profile Editor - so a project skins itself -/// by editing a theme rather than by forking a thousand lines of script. Cursors -/// have not moved into the theme yet, so they stay here. +/// This file used to answer by building seven cursors out of literals, the last +/// of the hand-written GUI furniture after the ~70 profiles became a +/// GuiProfileTheme. Now the theme owns cursors too - each one its own art, +/// tinted from the theme's palette - and this installs a chosen theme's set +/// under the canonical names. A control that names a cursor outright still wins; +/// this is only what everything else falls back to, including the canvas arrow. +/// +/// It is also callable at any time, which is how a game swaps between themes +/// that look nothing alike: +/// +/// AppCore.installThemeCursors(Combat); +/// Canvas.setCursor(DefaultCursor); /// Registers %object under %name, or - if something already holds the name - /// copies the new object's fields onto the existing one and throws the new one @@ -55,54 +64,106 @@ } } -function AppCore::createGuiCursors(%this) +/// Every theme the project loaded, as a space-separated list of ids. They live +/// in the Gui data group, which is where GuiProfileTheme::onAdd puts them. +function AppCore::getThemes(%this) { - %this.SafeCreateNamedObject("DefaultCursor", new GuiCursor() + %themes = ""; + if(!isObject(GuiDataGroup)) { - hotSpot = "1 1"; - renderOffset = "0 0"; - bitmapName = "^AppCore/gui/images/cursors/defaultCursor"; - }); + return %themes; + } - %this.SafeCreateNamedObject("LeftRightCursor", new GuiCursor() + for(%i = 0; %i < GuiDataGroup.getCount(); %i++) { - hotSpot = "0.5 0"; - renderOffset = "0.5 0.4"; - bitmapName = "^AppCore/gui/images/cursors/leftRight"; - }); + %object = GuiDataGroup.getObject(%i); + if(%object.getClassName() $= "GuiProfileTheme") + { + %themes = (%themes $= "") ? %object.getId() : (%themes SPC %object.getId()); + } + } + + return %themes; +} - %this.SafeCreateNamedObject("UpDownCursor", new GuiCursor() +/// Which theme's cursors become the canonical ones. A project with one theme +/// never has to think about this; a project with several says so by setting +/// $pref::AppCore::cursorTheme, and gets told when it hasn't. +function AppCore::cursorTheme(%this) +{ + %themes = %this.getThemes(); + %count = getWordCount(%themes); + if(%count == 0) { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/upDown"; - }); + return 0; + } - %this.SafeCreateNamedObject("NWSECursor", new GuiCursor() + if($pref::AppCore::cursorTheme !$= "") { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/NWSE"; - }); + for(%i = 0; %i < %count; %i++) + { + %theme = getWord(%themes, %i); + if(%theme.getName() $= $pref::AppCore::cursorTheme) + { + return %theme; + } + } + warn("AppCore::cursorTheme: $pref::AppCore::cursorTheme names '" @ $pref::AppCore::cursorTheme @ "', which is not a loaded theme."); + } - %this.SafeCreateNamedObject("NESWCursor", new GuiCursor() + if(%count == 1) { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/NESW"; - }); + return getWord(%themes, 0); + } - %this.SafeCreateNamedObject("MoveCursor", new GuiCursor() + for(%i = 0; %i < %count; %i++) { - hotSpot = "1 1"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/move"; - }); + %theme = getWord(%themes, %i); + if(%theme.getName() $= "Base") + { + return %theme; + } + } + + %first = getWord(%themes, 0); + warn("AppCore::cursorTheme: " @ %count @ " themes are loaded and none is named 'Base', so the cursors come from '" @ + %first.getName() @ "'. Set $pref::AppCore::cursorTheme to choose."); + return %first; +} + +/// Point the canonical cursor names at %theme's cursors. The names are copies +/// rather than the members themselves: a name can only belong to one object, +/// and a theme's members have to keep their own names for the Guis that +/// reference them. +function AppCore::installThemeCursors(%this, %theme) +{ + if(!isObject(%theme)) + { + warn("AppCore::installThemeCursors: no theme to install cursors from."); + return false; + } - %this.SafeCreateNamedObject("EditCursor", new GuiCursor() + %categories = %theme.getCursorCategoryNames(); + %count = getWordCount(%categories); + for(%i = 0; %i < %count; %i++) { - hotSpot = "0 0"; - renderOffset = "0.5 0.5"; - bitmapName = "^AppCore/gui/images/cursors/ibeam"; - }); + %category = getWord(%categories, %i); + %cursor = %theme.getCursor(%category); + if(!isObject(%cursor)) + { + continue; + } + + // The category's canonical name comes from the engine's own table, so + // this never has to take a theme name apart to find it. + %this.SafeCreateNamedObject(%theme.getCursorCanonicalName(%category), new GuiCursor() + { + bitmapName = %cursor.bitmapName; + hotSpot = %cursor.hotSpot; + renderOffset = %cursor.renderOffset; + color = %cursor.color; + }); + } + + return true; } diff --git a/library/AppCore/scripts/defaultPreferences.cs b/library/AppCore/scripts/defaultPreferences.cs index 37543dd9d..c4a015b5e 100644 --- a/library/AppCore/scripts/defaultPreferences.cs +++ b/library/AppCore/scripts/defaultPreferences.cs @@ -35,6 +35,12 @@ $pref::iOS::EnableOtherOrientationRotation = 1; $pref::iOS::StatusBarType = 0; +/// AppCore. Which theme's cursors are installed under the names the engine +/// looks up when a control names none of its own - DefaultCursor, EditCursor +/// and the rest (see gui/guiCursors.cs). Empty is the usual answer: a project +/// with one theme uses it, and one with several is asked to say which. +$pref::AppCore::cursorTheme = ""; + /// T2D $pref::T2D::ParticlePlayerEmissionRateScale = 1.0; $pref::T2D::ParticlePlayerSizeScale = 1.0; diff --git a/library/AppCore/scripts/themes.cs b/library/AppCore/scripts/themes.cs index 2b59420b3..1a61994d0 100644 --- a/library/AppCore/scripts/themes.cs +++ b/library/AppCore/scripts/themes.cs @@ -65,6 +65,83 @@ return pathConcat(filePath(filePath(makeFullPath(%module.getModulePath(), getMainDotCsDir()))), "themes"); } +/// Where one theme keeps its cursor art. A folder per theme, because two themes +/// in the same project may want cursors that look nothing alike - a menu +/// pointer and a combat reticle - and sharing one folder would mean one +/// overwriting the other. +function AppCore::getThemeCursorsPath(%this, %theme) +{ + %path = %this.getThemesPath(); + if(%path $= "" || !isObject(%theme) || %theme.getName() $= "") + { + return ""; + } + return pathConcat(%path, "cursors", %theme.getName()); +} + +/// The stock cursor art every theme starts from, inside AppCore itself. It is +/// grayscale on purpose so a theme's tint colors it. +function AppCore::getStockCursorsPath(%this) +{ + %module = ModuleDatabase.findModule("AppCore", 1); + if(!isObject(%module)) + { + return ""; + } + return pathConcat(makeFullPath(%module.getModulePath(), getMainDotCsDir()), "gui/images/cursors"); +} + +/// Give %theme its own copy of the stock cursor art and point it at the folder. +/// Idempotent: pathCopy is asked not to overwrite, so a theme whose art is +/// already there (or has been edited) is left exactly as it is. +/// +/// Only the folder being absent triggers the copy, which keeps boot down to one +/// directory test per theme - and means Android, where pathCopy is unsupported, +/// never reaches it in a project that shipped its art. +function AppCore::seedThemeCursors(%this, %theme) +{ + %target = %this.getThemeCursorsPath(%theme); + if(%target $= "") + { + return false; + } + + // isDirectory rather than isFile: isFile answers out of the resource + // manager, which knows nothing about files written after the last scan. + if(!isDirectory(%target)) + { + %source = %this.getStockCursorsPath(); + if(%source $= "" || !isDirectory(%source)) + { + warn("AppCore::seedThemeCursors: no stock cursor art at " @ %source @ "."); + return false; + } + + createPath(%target @ "/"); + + %categories = %theme.getCursorCategoryNames(); + for(%i = 0; %i < getWordCount(%categories); %i++) + { + %file = %theme.getCursorStockFile(getWord(%categories, %i)); + if(%file $= "") + { + continue; + } + pathCopy(pathConcat(%source, %file), pathConcat(%target, %file)); + } + } + + // Assigning the folder restamps the theme, which fills in the bitmap of any + // cursor that has none yet. A cursor already pointing at art keeps it. + %directory = makeRelativePath(%target, getMainDotCsDir()); + if(%theme.cursorDirectory !$= %directory) + { + %theme.cursorDirectory = %directory; + } + + return true; +} + function AppCore::loadThemes(%this) { %path = %this.getThemesPath(); @@ -119,6 +196,7 @@ } %this.repairFontDirectory(%object); + %this.seedThemeCursors(%object); return true; } @@ -173,6 +251,9 @@ borderSize = 1; }; + // Before the write, so the file records where the art went. + %this.seedThemeCursors(%theme); + %file = pathConcat(%path, "Base.taml"); TAMLWrite(%theme, %file); diff --git a/tests/README.md b/tests/README.md index 21bc11850..aaefdb951 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,19 +2,30 @@ These drive the **real engine** — a real canvas, the real editor, the real input path — and check that it behaves. They are the counterpart to the GoogleTest C++ -unit tests (`main.runAllUnitTests.cs`, see the repo README): those test functions, -these test the thing a person actually uses. +unit tests: those test functions, these test the thing a person actually uses. ``` tests\run.ps1 every pass/fail suite tests\run.ps1 colorPopup one of them (wildcards allowed) tests\run.ps1 -Shots the screenshot harnesses instead tests\run.ps1 -List what would run + +tests\run-unit.ps1 the C++ unit tests -- seconds, not minutes ``` The runner exits non-zero if anything came out other than expected. Build first: `cmake --build build --config Debug --target Torque2D`. +**Reach for `run-unit.ps1` first.** A suite here costs its own process and gets a +90 second timeout; the whole unit run takes seconds. What belongs here is what +genuinely needs a canvas: rendering, real input, first responder, tooltips, +anything that measures text, and anything involving the rows of a list box or a +tree — adding one calls `updateSize()`, which loads a font, which registers a +texture, which asserts with no GL context. Arithmetic does not belong here. The +established move is to pull it out into a `static` that takes everything it uses +and test that: `GuiScrollCtrl::subtractScrollBars`, `GuiTreeViewCtrl::resolveIndent`, +`GuiEditorExplorerTree::columnAt`. + ## Layout | | | @@ -110,6 +121,12 @@ sequence; otherwise it is picked up automatically and run last, alphabetically. exit code is 0 — so a test that "does nothing" is usually a test that did not compile. TorqueScript has no comma operator and no method chaining on a call result (`Canvas.getContent().add(%x)` is a parse error; take the two steps). +- **`screenShot` does not create its folder, and it fails by logging.** `shots/` is + gitignored, so a tree that has never run a shot does not have one — a fresh clone, + or a git worktree. The harness then runs green all the way to `SHOTS DONE` and + writes nothing, and the runner reports `0 shots` with no reason given. Every test + that screenshots calls `createPath(testRoot("shots/"))` before its first + `schedule` for this reason; keep doing it in new ones. - **A debug-build `AssertFatal` is a modal message box.** A test that trips one hangs rather than crashing, which is why the runner kills on a timeout. If a test "hangs", suspect an undismissed assert before suspecting a loop. diff --git a/tests/lib/input.ps1 b/tests/lib/input.ps1 index 31a9f1437..1a2266d5a 100644 --- a/tests/lib/input.ps1 +++ b/tests/lib/input.ps1 @@ -98,6 +98,35 @@ function Send-EngineClick { [TorqueInput]::PostMessage($Hwnd, $script:WM_LBUTTONUP, [IntPtr]0, $lp) | Out-Null } +# A press, a run of moves and a release, for a gesture a single click cannot +# reach: a rubber band, a control dragged across the canvas. +# +# The moves carry MK_LBUTTON in wParam, which is what the window proc sees from a +# real drag, and they are stepped rather than jumped: a control that acts on the +# distance covered rather than on the end point would otherwise see one enormous +# move and clamp it. +function Send-EngineDrag { + param([IntPtr]$Hwnd, [int]$FromX, [int]$FromY, [int]$ToX, [int]$ToY, [int]$Steps = 8) + + $from = [IntPtr](($FromY -shl 16) -bor $FromX) + [TorqueInput]::PostMessage($Hwnd, $script:WM_MOUSEMOVE, [IntPtr]0, $from) | Out-Null + Start-Sleep -Milliseconds 200 + [TorqueInput]::PostMessage($Hwnd, $script:WM_LBUTTONDOWN, [IntPtr]$script:MK_LBUTTON, $from) | Out-Null + Start-Sleep -Milliseconds 150 + + for ($i = 1; $i -le $Steps; $i++) { + $x = $FromX + [int]((($ToX - $FromX) * $i) / $Steps) + $y = $FromY + [int]((($ToY - $FromY) * $i) / $Steps) + $lp = [IntPtr](($y -shl 16) -bor $x) + [TorqueInput]::PostMessage($Hwnd, $script:WM_MOUSEMOVE, [IntPtr]$script:MK_LBUTTON, $lp) | Out-Null + Start-Sleep -Milliseconds 60 + } + + Start-Sleep -Milliseconds 150 + $to = [IntPtr](($ToY -shl 16) -bor $ToX) + [TorqueInput]::PostMessage($Hwnd, $script:WM_LBUTTONUP, [IntPtr]0, $to) | Out-Null +} + function Send-EngineKey { param([IntPtr]$Hwnd, [string]$Key) diff --git a/tests/lib/prelude.cs b/tests/lib/prelude.cs index 1c5e6e02c..9a098b5fc 100644 --- a/tests/lib/prelude.cs +++ b/tests/lib/prelude.cs @@ -24,3 +24,32 @@ function testExec(%relativePath) { exec(testRoot(%relativePath)); } + +//----------------------------------------------------------------------------- +// Answer the Gui Editor's unsaved-changes prompt with Discard, if it is up. +// +// New Gui, Open Gui and Revert ask before throwing a modified document away, so +// a test that uses one of them to get to a clean canvas has to answer. Returns +// whether there was anything to answer -- a document with no edits in it is +// taken away without a word, and callers that arrive clean are not doing +// anything wrong. +// +// Here rather than copied into each suite because three of them need it, and +// because a harness that does NOT answer does not fail: it screenshots the +// dialog, or carries on against the document it thought it had cleared. +//----------------------------------------------------------------------------- + +function discardUnsavedPrompt() +{ + for(%i = Canvas.getCount() - 1; %i >= 0; %i--) + { + %dialog = Canvas.getObject(%i); + if(%dialog.class $= "GuiEditorConfirmSaveDialog") + { + %dialog.onDiscard(); + return true; + } + } + + return false; +} diff --git a/tests/run-unit.ps1 b/tests/run-unit.ps1 new file mode 100644 index 000000000..2141df8db --- /dev/null +++ b/tests/run-unit.ps1 @@ -0,0 +1,124 @@ +<# +.SYNOPSIS + Runs the C++ GoogleTest unit tests. + +.DESCRIPTION + The counterpart to run.ps1. Those drive the real engine, one process per + suite, with a 90 second timeout each; these are one process for the lot and + take seconds, because they touch no canvas, no GL context and no font. + + That is also their limit. The engine boots far enough to have Con, Sim, the + string table, the resource manager and GuiDefaultProfile -- so a test can + new and registerObject a control, read and write its fields, run script + through Con::evaluate, and round-trip TAML. What it cannot do is wake a + control or measure text: a font registers a texture, and with no GL context + TextureManager asserts. In a debug build an assert is a modal box, so that + failure arrives as a hang rather than as a red line. + + There is no filter ARGUMENT -- runAllUnitTests takes none, and hands + InitGoogleTest an empty argv. GoogleTest reads GTEST_FILTER from the + environment instead, which is what -Filter sets here. + +.PARAMETER Filter + A GoogleTest filter, e.g. 'GuiTreeRowLayoutTests.*' or 'Gui*'. Omit to run + everything. + +.PARAMETER Release + Use Torque2D.exe rather than Torque2D_DEBUG.exe. + +.PARAMETER Timeout + Seconds to let the run take before killing it. A debug AssertFatal is a modal + message box, so a test that trips one hangs. + +.EXAMPLE + tests\run-unit.ps1 + tests\run-unit.ps1 GuiTreeRowLayoutTests.* + tests\run-unit.ps1 Gui* +#> +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$Filter = '', + [switch]$Release, + [int]$Timeout = 120 +) + +$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' + +if (-not (Test-Path $exe)) { + Write-Host "No $([IO.Path]::GetFileName($exe)) at the repo root. Build first:" -ForegroundColor Red + Write-Host " cmake --build build --config $(if ($Release) { 'Release' } else { 'Debug' }) --target Torque2D" + exit 1 +} +if (-not (Test-Path $boot)) { + Write-Host "No main.runAllUnitTests.cs at the repo root." -ForegroundColor Red + exit 1 +} + +# The boot script sets logMode 2, which truncates console.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. +if (Test-Path $log) { Remove-Item $log -Force } + +$previousFilter = $env:GTEST_FILTER +if ($Filter) { + $env:GTEST_FILTER = $Filter + Write-Host "Running unit tests matching '$Filter'" +} else { + Remove-Item Env:\GTEST_FILTER -ErrorAction SilentlyContinue + Write-Host 'Running all unit tests' +} + +$hung = $false +try { + $proc = Start-Process -FilePath $exe -ArgumentList $boot -WorkingDirectory $repo -PassThru -WindowStyle Minimized + if (-not $proc.WaitForExit($Timeout * 1000)) { + $hung = $true + Write-Host " timed out after $Timeout s -- almost always a modal AssertFatal" -ForegroundColor Red + try { $proc.Kill() } catch { } + $proc.WaitForExit(5000) | Out-Null + } +} +finally { + if ($null -ne $previousFilter) { $env:GTEST_FILTER = $previousFilter } + else { Remove-Item Env:\GTEST_FILTER -ErrorAction SilentlyContinue } +} + +if (-not (Test-Path $log)) { + Write-Host ' the run wrote no console.log' -ForegroundColor Red + exit 1 +} + +$lines = Get-Content $log +$ran = @($lines | Select-String -SimpleMatch '> Starting Test').Count +$failLines = @($lines | Select-String -SimpleMatch '>> Failed with') + +foreach ($line in $failLines) { + Write-Host " $($line.Line.Trim())" -ForegroundColor Red +} + +Write-Host '' +if ($hung) { + Write-Host "$ran ran, then it hung." -ForegroundColor Red + if ($lines.Count) { Write-Host " last line: $($lines[-1])" } + exit 1 +} +if ($failLines.Count -gt 0) { + Write-Host "$ran tests, $($failLines.Count) failed." -ForegroundColor Red + exit 1 +} +if ($ran -eq 0) { + # An over-narrow filter matches nothing and GoogleTest reports success, which + # would otherwise read as "everything passed". + Write-Host 'No tests ran. Check the filter.' -ForegroundColor Yellow + exit 1 +} + +Write-Host "All $ran passed." -ForegroundColor Green +exit 0 diff --git a/tests/run.ps1 b/tests/run.ps1 index 9e58f5fcd..6c8b5c199 100644 --- a/tests/run.ps1 +++ b/tests/run.ps1 @@ -73,8 +73,8 @@ $KeepProject = @('bitmapPathRead') $Order = @( 'profileEditor', 'profileForm', 'border', 'borderPane', 'standalone', 'headerPane', 'colorPopup', 'themeApply', 'font', 'assetPicker', - 'tooltipProfile', 'textClick', 'bitmapPathWrite', 'bitmapPathRead', - 'toybox', 'planetX' + 'tooltipProfile', 'textClick', 'undo', 'clipboard', 'bitmapPathWrite', + 'bitmapPathRead', 'toybox', 'planetX' ) if (-not (Test-Path $Exe)) { @@ -184,8 +184,8 @@ foreach ($test in $tests) { } $summary = if ($Shots) { "$wrote shot$(if ($wrote -ne 1) { 's' })" } else { "$pass passed" } - $colour = if ($ok) { 'Green' } else { 'Red' } - Write-Host ("{0,-14} {1}" -f $summary, $note) -ForegroundColor $colour + $color = if ($ok) { 'Green' } else { 'Red' } + Write-Host ("{0,-14} {1}" -f $summary, $note) -ForegroundColor $color if (-not $ok) { $lines | Select-String 'FAIL:' | Select-Object -First 6 | ForEach-Object { diff --git a/tests/run.sh b/tests/run.sh index 5b7ef3b8c..caee27495 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -9,10 +9,10 @@ # # A test passes when it logs no FAIL lines and exits on its own. # -# The two input-driven suites (tooltipProfile, textClick) have Win32-only -# companion scripts (*.input.ps1) that post real mouse/keyboard; there is no -# equivalent here, so those are skipped by default. Pass --with-input to run -# them anyway (they will get no synthetic input and may under-count). +# The input-driven suites -- the ones with a Win32-only *.input.ps1 companion +# posting real mouse/keyboard -- have no equivalent here, so they are skipped by +# default. Pass --with-input to run them anyway (they will get no synthetic input +# and will report their clicks as failures). # # Usage: # tests/run.sh every pass/fail suite @@ -63,14 +63,22 @@ fi KEEP_PROJECT=(bitmapPathRead) # Order matters for one pair only: bitmapPathWrite before bitmapPathRead. The -# input-driven pair is listed so ordering holds when --with-input is passed. +# input-driven suites are listed so ordering holds when --with-input is passed. ORDER=(profileEditor profileForm border borderPane standalone \ headerPane colorPopup themeApply font assetPicker \ tooltipProfile textClick bitmapPathWrite bitmapPathRead \ toybox planetX) -# Tests whose input path is Win32-only; skipped unless --with-input. -INPUT_ONLY=(tooltipProfile textClick) +# Tests whose input path is Win32-only; skipped unless --with-input. Derived from +# the companion scripts actually on disk rather than named here, because a +# hardcoded list goes stale the moment a suite gains one -- which it had: six +# input-driven suites were running without a driver and reporting their clicks as +# engine failures. +INPUT_ONLY=() +for f in "$SCRIPT_DIR/smoke"/*.input.ps1; do + [[ -e "$f" ]] || continue + INPUT_ONLY+=("$(basename "$f" .input.ps1)") +done contains() { local n="$1"; shift; for e in "$@"; do [[ "$e" == "$n" ]] && return 0; done; return 1; } diff --git a/tests/shots/controlIcons.cs b/tests/shots/controlIcons.cs new file mode 100644 index 000000000..63003fe3b --- /dev/null +++ b/tests/shots/controlIcons.cs @@ -0,0 +1,132 @@ +// Renders the control palette's icon sheets, tinted the way the editor will tint +// them, with each entry's key beside its picture. +// +// Two jobs. The obvious one is looking at the art at the size it will actually be +// drawn -- the review sheets the build script writes are on a dark ground of +// their own choosing, and this is the real theme. +// +// The load-bearing one is proving the assets load at all. GuiEditor's module.taml +// carried no block until these sheets arrived; without it the +// PNGs sit on disk and are never registered, with no error anywhere. A missing +// image renders as nothing rather than throwing, so the failure is a page of +// labels with blank space where the icons should be -- which is exactly what this +// shot shows, and nothing else would. +// +// Page 0 is the 128 sheet at 96px, which is what the grid view will draw. Page 1 +// is the 64 sheet at 48px, which is the row view. Page 2 is the 16 sheet, which +// the Explorer tree draws. +// +// The tiles keep constrainProportions, so page 2 draws at 16px rather than being +// stretched to fill -- which is the point of it. Whether an icon still reads once +// one pixel stands for four design units is the question, and the answer has to +// be looked at at the size it will really be. Seeing all 31 at once is also the +// only thing that catches the new asset failing to register: a missing image +// renders as nothing rather than throwing, so that failure is a page of labels +// with blank space above them. +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; + +testExec("editor/main.cs"); +schedule(2500, 0, "controlIconsShot"); + +$controlIcons::page = 0; +$controlIcons::pages = 3; + +function controlIconsShot() +{ + if($controlIcons::page >= $controlIcons::pages) + { + echo("SHOTS DONE"); + quit(); + return; + } + + // The table the Gui Editor built at create time. Asking it rather than + // hard-coding 31 means this page cannot drift from the sheet. + %icons = GuiEditor.controlIcons; + %keys = %icons.keys(); + %count = getFieldCount(%keys); + + %big = ($controlIcons::page == 0); + %tile = %big ? 96 : 48; + // Page 2 asks sheetFor for the 16 sheet and then draws it four times up. + %sheet = %icons.sheetFor(($controlIcons::page == 2) ? 16 : %tile); + %cell = %big ? 128 : (($controlIcons::page == 2) ? 16 : 64); + + %page = new GuiControl() { Position = "0 0"; Extent = "1024 768"; }; + ThemeManager.setProfile(%page, "panelProfile"); + $controlIcons::gui = %page; + + %cols = %big ? 6 : 8; + %stepX = %big ? 168 : 126; + %stepY = %big ? 128 : 76; + + // The fallback is not in keys() -- it is what the palette falls back TO, not + // something anyone drags -- so it is drawn first and by hand. It is the one + // frame a wrong answer from frameFor would land on, so seeing it is the point. + controlIconsTile(%page, 12, 8, %tile, %sheet, %cell, 0, "unknown"); + + for(%i = 0; %i < %count; %i++) + { + %key = getField(%keys, %i); + %slot = %i + 1; + %x = 12 + ((%slot % %cols) * %stepX); + %y = 8 + (mFloor(%slot / %cols) * %stepY); + controlIconsTile(%page, %x, %y, %tile, %sheet, %cell, %icons.frameFor(%key), %key); + } + + Canvas.pushDialog(%page); + schedule(1000, 0, "controlIconsGrab"); +} + +// One tile: the sprite, tinted from the theme exactly as EditorIconButton tints +// its icon, with the entry's key under it. +function controlIconsTile(%page, %x, %y, %tile, %sheet, %cell, %frame, %label) +{ + %icon = new GuiSpriteCtrl() + { + Position = %x SPC %y; + Extent = %tile SPC %tile; + Image = %sheet; + ImageSize = %cell SPC %cell; + Frame = %frame; + ImageColor = ThemeManager.activeTheme.iconButtonProfile.FontColor; + constrainProportions = "1"; + fullSize = "0"; + }; + ThemeManager.setProfile(%icon, "spriteProfile"); + %page.add(%icon); + + %text = new GuiControl() + { + Position = %x SPC (%y + %tile + 2); + Extent = (%tile + 60) SPC 16; + Text = %label; + }; + ThemeManager.setProfile(%text, "labelProfile"); + %page.add(%text); +} + +function controlIconsGrab() +{ + // screenShot does not create the folder, and it fails by logging rather than + // throwing -- so in a tree that has never run one (a fresh clone, or a git + // worktree, where shots/ is gitignored and therefore absent) the harness + // otherwise runs green all the way to SHOTS DONE and writes nothing. + createPath(testRoot("shots/")); + screenShot(testRoot("shots/controlIcons" @ $controlIcons::page @ ".png"), "PNG"); + schedule(500, 0, "controlIconsNext"); +} + +function controlIconsNext() +{ + Canvas.popDialog($controlIcons::gui); + $controlIcons::gui.delete(); + $controlIcons::page++; + schedule(200, 0, "controlIconsShot"); +} diff --git a/tests/shots/controlPalette.cs b/tests/shots/controlPalette.cs new file mode 100644 index 000000000..7d33d9435 --- /dev/null +++ b/tests/shots/controlPalette.cs @@ -0,0 +1,115 @@ +// Visual harness for the Gui Editor's control palette. Four shots: +// +// 0 grid mode, every group open -- a picture over its name, the default view +// 1 row mode, every group open -- a small picture with the name beside it +// 2 grid mode with two groups collapsed +// 3 grid mode scrolled to the two-line names +// +// Neither of the last two is decoration. GuiExpandCtrl force-writes mVisible on +// every direct child of a panel whenever it expands or collapses, which is why +// the tiles live in an inner grid; collapsing and reopening is what proves the +// tiles survived it. And every name in Basics fits on one line, so only the +// fourth shot shows what the caption band is sized for -- a name that wraps, +// stacking upward off the floor of the tile without reaching the picture. +// +// Run: tests/run.ps1 -Shots controlPalette ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "pOpenProject"); + +function pOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "pOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function pOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "pGridShot"); +} + +function pGrab(%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/controlPalette" @ %name @ ".png"), "PNG"); +} + +function pGridShot() +{ + pGrab(0); + GuiEditor.ctrlListWindow.setMode("rows"); + GuiEditor.ctrlListWindow.modeRow.setValue("rows"); + schedule(800, 0, "pRowShot"); +} + +function pRowShot() +{ + pGrab(1); + GuiEditor.ctrlListWindow.setMode("grid"); + GuiEditor.ctrlListWindow.modeRow.setValue("grid"); + schedule(800, 0, "pCollapseShot"); +} + +// Collapse the FIRST group, not a later one: the palette column is short enough +// that everything past Basics is below the fold, so closing group 3 would look +// identical to not closing anything. +function pCollapseShot() +{ + %window = GuiEditor.ctrlListWindow; + %window.group[0].setExpanded(false); + %window.group[2].setExpanded(false); + %window.relayout(); + schedule(800, 0, "pFinish"); +} + +function pFinish() +{ + pGrab(2); + + // Reopen what was closed, so the shot has proved that a tile survives a + // collapse rather than being left hidden by the expand control. + %window = GuiEditor.ctrlListWindow; + %window.group[0].setExpanded(true); + %window.group[2].setExpanded(true); + %window.relayout(); + + schedule(800, 0, "pWrapShot"); +} + +// The long names live in Input & Data -- "Radio Button", "Image Button" -- which +// is the third group and so below the fold. Shutting the two above it is what +// brings it to the top; scrolling would depend on how tall the window happens to +// be on the machine running this. +function pWrapShot() +{ + %window = GuiEditor.ctrlListWindow; + %window.group[0].setExpanded(false); + %window.group[1].setExpanded(false); + %window.relayout(); + schedule(800, 0, "pWrapGrab"); +} + +function pWrapGrab() +{ + pGrab(3); + + %window = GuiEditor.ctrlListWindow; + %window.group[0].setExpanded(true); + %window.group[1].setExpanded(true); + %window.relayout(); + + echo("SHOTS DONE"); + schedule(500, 0, "quit"); +} diff --git a/tests/shots/cursorPane.cs b/tests/shots/cursorPane.cs new file mode 100644 index 000000000..81e274460 --- /dev/null +++ b/tests/shots/cursorPane.cs @@ -0,0 +1,107 @@ +// Visual harness for the cursor pane. The hot-spot editor is the part of this +// feature that only exists to be looked at, so these are the shots that say +// whether it works: the magnifier at a few zooms, the anchor mark and the hot +// spot mark distinguishable from each other, the tint following the theme, and +// the try-it range in the preview frame. +// Run: tests/run.ps1 -Shots cursorPane ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "sStep1"); + +function sStep1() +{ + ProjectManager.setProjectFolder("cursorPaneShotProject"); + GuiEditor.open(); + GuiEditor.openProfileEditor(); + + %d = GuiEditor.profileEditorDialog; + $sTheme = %d.library.createTheme("CursorShot"); + %d.tree.refresh(); + + createPath(testRoot("shots/")); + + // category TAB zoom TAB name + $sCases = + "Default" TAB "8" TAB "default" NL + "Default" TAB "16" TAB "close" NL + "Move" TAB "6" TAB "move" NL + "Edit" TAB "10" TAB "ibeam" NL + "LeftRight" TAB "8" TAB "leftRight" NL + "NWSE" TAB "8" TAB "corner"; + $sIndex = 0; + schedule(600, 0, "sShoot"); +} + +function sShoot() +{ + %rec = getRecord($sCases, $sIndex); + %category = getField(%rec, 0); + %zoom = getField(%rec, 1); + %name = getField(%rec, 2); + + %d = GuiEditor.profileEditorDialog; + %d.onTreeSelect(%d.library.cursorCategoryProxy[$sTheme.getId() @ "_" @ %category]); + %d.cursorForm.editor.setZoom(%zoom); + %d.cursorForm.refreshReadout(); + + echo("SHOT: " @ %name @ " - " @ %category @ " at " @ %zoom @ "x"); + schedule(500, 0, "sGrab", %name); +} + +function sGrab(%name) +{ + screenShot(testRoot("shots/cursorPane_" @ %name @ ".png"), "PNG"); + echo("SHOT: wrote cursorPane_" @ %name @ ".png"); + + $sIndex++; + if($sIndex < getRecordCount($sCases)) + { + schedule(400, 0, "sShoot"); + return; + } + + schedule(300, 0, "sTinted"); +} + +// The tint is what makes a grayscale stock set look like it belongs to the +// theme, so it needs a shot of its own against a colour nobody could mistake +// for the art. +function sTinted() +{ + %d = GuiEditor.profileEditorDialog; + $sTheme.colorForeground = "60 200 255 255"; + %d.onTreeSelect(%d.library.cursorCategoryProxy[$sTheme.getId() @ "_Move"]); + %d.cursorForm.editor.setZoom(8); + + schedule(500, 0, "sGrabTinted"); +} + +function sGrabTinted() +{ + screenShot(testRoot("shots/cursorPane_tinted.png"), "PNG"); + echo("SHOT: wrote cursorPane_tinted.png"); + + // An anchored cursor: the faint crosshair (renderOffset) and the marked + // pixel (hotSpot) should be visibly apart, which is the whole reason the + // pane draws both. + %d = GuiEditor.profileEditorDialog; + %d.cursorForm.onAnchorPreset(0.5, 0.5); + %d.cursorForm.row["hotSpot"].applyValue("6 -4"); + %d.cursorForm.onProfileRowCommit(%d.cursorForm.row["hotSpot"]); + + schedule(500, 0, "sGrabAnchored"); +} + +function sGrabAnchored() +{ + screenShot(testRoot("shots/cursorPane_anchored.png"), "PNG"); + echo("SHOT: wrote cursorPane_anchored.png"); + + echo("SHOT DONE"); + schedule(400, 0, "quit"); +} diff --git a/tests/shots/deleteConfirm.cs b/tests/shots/deleteConfirm.cs index 1df65f53e..20bad5d8f 100644 --- a/tests/shots/deleteConfirm.cs +++ b/tests/shots/deleteConfirm.cs @@ -12,6 +12,7 @@ testExec("editor/main.cs"); +createPath(testRoot("shots/")); schedule(2500, 0, "shotStep1"); function shotStep1() diff --git a/tests/shots/explorerTree.cs b/tests/shots/explorerTree.cs new file mode 100644 index 000000000..2ffec8685 --- /dev/null +++ b/tests/shots/explorerTree.cs @@ -0,0 +1,192 @@ +// The Gui Editor's Explorer tree, with a Gui deep enough to show what the two +// gutter columns cost and what the rows look like once they carry a picture. +// +// This is the proof no assertion can give. The boxes are drawn in the profile's +// HOVER fill, which is deliberately almost the row fill -- the whole point is +// that the rail is quiet -- and "quiet but findable" is a judgement a person has +// to make by looking. Same for the 16px control icons at their real size on the +// real theme, and for how much of a 228px tree is left for text at depth. +// +// Two controls are locked and two are hidden, so both columns show a mix rather +// than a run of one thing, and one row is selected -- which is the case that has +// already been wrong once. +// +// The eye and the padlock are drawn on a box whose colour does NOT change with +// selection, so an icon that took the row's font colour like the text does put +// selected-text ink on an unselected background and all but disappeared. One +// shot per editor theme, because the amount by which that is wrong depends +// entirely on how far apart a theme's selected and normal colours are: it is +// nearly invisible under some and merely odd under others, and a single shot +// would not have shown it. +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; + +testExec("editor/main.cs"); + +// screenShot does not create its folder, and fails by logging rather than +// throwing -- so a missing folder is a silent no-shot. +createPath(testRoot("shots/")); + +schedule(2500, 0, "expTreeOpenProject"); + +function expTreeOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "expTreeOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function expTreeOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "expTreeClear"); +} + +// Start empty, and in a step of its own. PlanetX opens with a Gui already +// loaded and finishes displaying it after the editor page has come up, so +// clearing in the same frame as the build leaves that Gui's controls in the +// tree -- and the shot becomes a picture of PlanetX rather than of a shape +// chosen to show the columns. +function expTreeClear() +{ + GuiEditor.NewGui(); + + // As in tests/smoke/explorerGutter.cs: usually nothing to answer, but a + // prompt left standing would be what the shot was of. + discardUnsavedPrompt(); + + schedule(500, 0, "expTreeSetup"); +} + +function expTreeSetup() +{ + %root = GuiEditor.rootGui; + + // A backdrop with a panel inside it, and rows inside that: three levels, so + // the indent budget is visible rather than theoretical. + // A spread of classes, so the row icons are not all the same picture. Names + // come from GuiEditorControlIcons.cs -- there is no GuiTextCtrl in this + // engine, and a class that does not exist fails silently: eval hands back 0, + // add() takes it, and the row simply never appears. + %backdrop = expAdd("GuiControl", %root, "8 8", "420 300", "backdrop"); + %panel = expAdd("GuiPanelCtrl", %backdrop, "12 12", "300 240", "panel"); + %title = expAdd("GuiControl", %panel, "8 8", "200 24", "title"); + %edit = expAdd("GuiTextEditCtrl", %panel, "8 40", "200 28", "nameBox"); + %list = expAdd("GuiListBoxCtrl", %panel, "8 76", "200 90", "choices"); + %check = expAdd("GuiCheckBoxCtrl", %panel, "8 176", "90 24", "agree"); + %ok = expAdd("GuiButtonCtrl", %panel, "8 206", "90 30", "okButton"); + %slider = expAdd("GuiSliderCtrl", %backdrop, "12 260", "300 24", "volume"); + + // A mix down both columns, so neither is a run of one thing. + %title.locked = true; + %list.locked = true; + %edit.hidden = true; + %ok.hidden = true; + + %tree = GuiEditor.explorerWindow.tree; + %tree.refresh(); + + // Open every branch, so the shot shows the whole shape rather than whatever + // happened to be expanded. + %count = %tree.getItemCount(); + for(%i = 0; %i < %count; %i++) + { + %tree.setItemOpen(%i, true); + } + %tree.refresh(); + + // Remembered rather than selected once: switching the editor theme drops the + // tree's selection, and a selected row is the whole point of these shots. + $expTree::selectId = %panel.getId(); + expTreeSelect(); + + echo("EXPTREE: " @ %tree.getItemCount() @ " rows, column width " @ + %tree.getGutterColumnWidth()); + + schedule(600, 0, "expTreeShot"); +} + +// Themed the way a dropped control would be, because the theme is what decides +// a bare GuiControl's category -- and its category is what decides which of the +// four GuiControl faces its row icon shows. +function expAdd(%class, %parent, %pos, %extent, %name) +{ + %ctrl = eval("return new " @ %class @ "();"); + if(!isObject(%ctrl)) + { + // Loud, because the failure is otherwise a row that quietly is not there + // and a shot that looks like a smaller Gui than the one asked for. + error("EXPTREE: could not make a " @ %class); + return 0; + } + %ctrl.Position = %pos; + %ctrl.Extent = %extent; + %parent.add(%ctrl); + %ctrl.setInternalName(%name); + + %theme = GuiEditor.themeByName(GuiEditor.themeName); + if(isObject(%theme)) + { + GuiEditor.themeApplier.applyToBranch(%ctrl, %theme, false); + } + return %ctrl; +} + +// One shot per registered editor theme. ThemeManager owns the editor's own +// chrome -- the tree wears its treeViewProfile -- which is a different thing +// from GuiEditor.setTheme, that being the theme of the Gui being edited. +$expTree::theme = 0; + +function expTreeShot() +{ + if($expTree::theme >= ThemeManager.themeList.getCount()) + { + echo("SHOTS DONE"); + quit(); + return; + } + + // The .class field, not getName or getClassName: the themes are registered + // as unnamed ScriptObjects, so the first is empty and the second says + // "ScriptObject" for all four. + ThemeManager.setTheme($expTree::theme); + echo("EXPTREE: theme " @ $expTree::theme @ " is " @ ThemeManager.activeTheme.class); + + // Selecting and grabbing in one step captures the frame BEFORE the + // selection: screenShot reads the framebuffer, which still holds the last + // frame drawn. The row has to be selected a frame ahead of the shot. + expTreeSelect(); + schedule(600, 0, "expTreeGrab"); +} + +// A selected row, every time. The rail's colours are deliberately independent of +// selection, so a shot without a selected row cannot show whether that is true. +function expTreeSelect() +{ + %tree = GuiEditor.explorerWindow.tree; + %index = %tree.findItemID($expTree::selectId); + if(%index >= 0) + { + %tree.clearSelection(); + %tree.setSelected(%index, true); + } + else + { + error("EXPTREE: lost the row that was meant to be selected"); + } +} + +function expTreeGrab() +{ + screenShot(testRoot("shots/explorerTree" @ $expTree::theme @ ".png"), "PNG"); + $expTree::theme++; + schedule(500, 0, "expTreeShot"); +} diff --git a/tests/shots/iconSheet.cs b/tests/shots/iconSheet.cs index 76697061a..ee7dc11d2 100644 --- a/tests/shots/iconSheet.cs +++ b/tests/shots/iconSheet.cs @@ -1,5 +1,11 @@ -// Temporary: renders every frame of EditorCore:editorIcons16 at 4x with its index -// so a button can be given an icon that actually means something. +// Renders the editor icon sheets so a button can be given an icon that actually +// means something -- and so a regenerated sheet can be checked against the +// constants that name it. +// +// Page 0-3 walk EditorCore:editorIcons16/24/32/48 a screenful at a time, each +// icon drawn at its frame index. Every sheet is the same 32x10 grid of the same +// 304 icons, so an index names the same picture on all four pages; the pages +// differ only in which source art is being magnified. setLogMode(2); setScriptExecEcho(false); trace(false); @@ -9,35 +15,67 @@ AssetDatabase.EchoInfo = false; testExec("editor/main.cs"); +createPath(testRoot("shots/")); schedule(2500, 0, "sheetShot"); +$iconSheet::perPage = 60; +$iconSheet::count = 304; +$iconSheet::page = 0; + function sheetShot() { + %first = $iconSheet::page * $iconSheet::perPage; + if(%first >= $iconSheet::count) + { + echo("SHOTS DONE"); + quit(); + return; + } + %page = new GuiControl() { Position = "0 0"; Extent = "1024 768"; }; ThemeManager.setProfile(%page, "panelProfile"); + $iconSheet::gui = %page; - for(%i = 0; %i < 64; %i++) + // One column per icon size, so the same index can be compared across sheets + // in a single glance. + %sizes = "16 24 32 48"; + for(%i = 0; %i < $iconSheet::perPage; %i++) { - %col = %i % 8; - %row = mFloor(%i / 8); - %x = 20 + (%col * 124); - %y = 20 + (%row * 92); + %index = %first + %i; + if(%index >= $iconSheet::count) + { + break; + } - %icon = new GuiSpriteCtrl() + %col = %i % 5; + %row = mFloor(%i / 5); + %x = 12 + (%col * 202); + %y = 12 + (%row * 62); + + for(%s = 0; %s < 4; %s++) { - Position = %x SPC %y; - Extent = "64 64"; - Image = "EditorCore:EditorIcons16"; - ImageSize = "16 16"; - Frame = %i; - ImageColor = "255 255 255 255"; - constrainProportions = "1"; - fullSize = "0"; - }; - ThemeManager.setProfile(%icon, "spriteProfile"); - %page.add(%icon); + %size = getWord(%sizes, %s); + %icon = new GuiSpriteCtrl() + { + Position = (%x + (%s * 44)) SPC %y; + Extent = "40 40"; + Image = "EditorCore:editorIcons" @ %size; + ImageSize = %size SPC %size; + Frame = %index; + ImageColor = "255 255 255 255"; + constrainProportions = "1"; + fullSize = "0"; + }; + ThemeManager.setProfile(%icon, "spriteProfile"); + %page.add(%icon); + } - %label = new GuiControl() { Position = (%x + 66) SPC (%y + 22); Extent = "40 20"; Text = %i; }; + %label = new GuiControl() + { + Position = %x SPC (%y + 42); + Extent = "180 16"; + Text = %index; + }; ThemeManager.setProfile(%label, "labelProfile"); %page.add(%label); } @@ -48,12 +86,14 @@ function sheetShot() function sheetGrab() { - screenShot(testRoot("shots/editorIconSheet.png"), "PNG"); - schedule(500, 0, "sheetDone"); + screenShot(testRoot("shots/editorIconSheet" @ $iconSheet::page @ ".png"), "PNG"); + schedule(500, 0, "sheetNext"); } -function sheetDone() +function sheetNext() { - echo("SHOTS DONE"); - quit(); + Canvas.popDialog($iconSheet::gui); + $iconSheet::gui.delete(); + $iconSheet::page++; + schedule(200, 0, "sheetShot"); } diff --git a/tests/shots/inspectorPane.cs b/tests/shots/inspectorPane.cs new file mode 100644 index 000000000..56bcc5bc2 --- /dev/null +++ b/tests/shots/inspectorPane.cs @@ -0,0 +1,151 @@ +// Visual harness for the Gui Editor's properties pane. Screenshots the header +// archetypes side by side, which is the thing the design turns on: one shell, +// with the geometry, text and value blocks swapped per control. +// +// The four cases are the ones that look different from each other: +// button the ordinary shape -- full geometry, a text block, easing +// window the most secondary fields of anything, plus a title +// tabpage no geometry at all; the book owns every bit of it +// chainkid one axis owned, the other still the control's +// +// Run: tests/run.ps1 -Shots inspectorPane ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "sOpenProject"); + +// A real project, loaded the way the project selector does it, because the pane +// has to be drawn wearing a project's theme to be worth looking at. +function sOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "sOpenEditor"); +} + +// Loading AppCore starts the project's game over the canvas; the editor comes +// back the way Ctrl+~ does it. Pages register in load order: EditorConsole, +// ProjectManager, AssetAdmin, GuiEditor. +function sOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "sStep1"); +} + +function sStep1() +{ + // A theme, so the pane is drawn wearing what a real project would. + $sTheme = GuiEditor.themeLibrary.createTheme("PaneShotTheme"); + GuiEditor.themeName = $sTheme.getName(); + + // One of each interesting shape, parked in the Gui being edited. + $sButton = sPlace("GuiButtonCtrl"); + $sWindow = sPlace("GuiWindowCtrl"); + + %book = sPlace("GuiTabBookCtrl"); + $sPage = new GuiTabPageCtrl(); + %book.add($sPage); + GuiEditor.themeApplier.applyToBranch($sPage, $sTheme, true); + + %chain = sPlace("GuiChainCtrl"); + %chain.IsVertical = true; + $sChainKid = new GuiButtonCtrl(); + %chain.add($sChainKid); + GuiEditor.themeApplier.applyToBranch($sChainKid, $sTheme, true); + + // A dynamic field and a second WindowContent, so the two sections that only + // appear when they have something to say are in the window's shot. + $sWindow.myTag = "example"; + $sTheme.createProfile("WindowContent"); + + createPath(testRoot("shots/")); + + // An inherited-role control, whose text block lives in the Text section + // rather than the header, and the one control that owns none of its own + // position. + $sInput = sPlace("GuiInputCtrl"); + $sMenuBar = sPlace("GuiMenuBarCtrl"); + + // case TAB object global [TAB "bottom" to scroll down first] + $sCases = + "pane-button" TAB "$sButton" NL + "pane-sections" TAB "$sButton" TAB "bottom" NL + "pane-window" TAB "$sWindow" NL + "pane-tabpage" TAB "$sPage" NL + "pane-chainkid" TAB "$sChainKid" NL + "pane-input" TAB "$sInput" NL + "pane-menubar" TAB "$sMenuBar"; + $sIndex = 0; + schedule(1000, 0, "sShoot"); +} + +function sPlace(%class) +{ + %ctrl = eval("return new " @ %class @ "();"); + GuiEditor.rootGui.add(%ctrl); + GuiEditor.themeApplier.applyToBranch(%ctrl, $sTheme, true); + return %ctrl; +} + +function sShoot() +{ + %rec = getRecord($sCases, $sIndex); + %name = getField(%rec, 0); + %ctrl = eval("return " @ getField(%rec, 1) @ ";"); + + %pane = GuiEditor.inspectorWindow.pane; + %pane.bind(%ctrl); + + // Every section open, so the shot shows what each control actually offers + // rather than a column of collapsed headers. + // textPanel is not in panelList: it holds one component rather than a list + // of rows, so the pane decides its visibility outright. + %pane.textPanel.setExpanded(true); + + %panels = %pane.panelList SPC %pane.classPanels; + for(%i = 0; %i < getWordCount(%panels); %i++) + { + %panel = %pane.panel[getWord(%panels, %i)]; + if(isObject(%panel) && %panel.isVisible()) + { + %panel.setExpanded(true); + } + } + %pane.dynamicPanel.setExpanded(true); + + // The pane is taller than its frame, so the sections that come after the + // header are only visible from the bottom of the scroller. + %scroller = GuiEditor.inspectorWindow.scroller; + if(getField(%rec, 2) $= "bottom") + { + %scroller.scrollToBottom(); + } + else + { + %scroller.scrollToTop(); + } + + // Let the chain, the grids and the panels settle before grabbing. + schedule(500, 0, "sGrab", %name); +} + +function sGrab(%name) +{ + screenShot(testRoot("shots/" @ %name @ ".png"), "PNG"); + echo("SHOT: wrote " @ %name @ ".png"); + + $sIndex++; + if($sIndex < getRecordCount($sCases)) + { + schedule(300, 0, "sShoot"); + return; + } + + echo("SHOT DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/shots/inspectorText.cs b/tests/shots/inspectorText.cs new file mode 100644 index 000000000..259ebe6f4 --- /dev/null +++ b/tests/shots/inspectorText.cs @@ -0,0 +1,117 @@ +// Visual harness for the Category picker and the text block: the high-score +// heading from the bug report, before and after. +// +// text-empty a GuiControl inside a panel, which is what you get when you +// drop one and type a caption into it afterwards -- the guess ran +// when it was dropped, saw no text, and made it an Empty. +// text-label the same control after its Category is set to Label and its +// font size raised, which is the whole of the fix from the +// outside. +// +// Run: tests/run.ps1 -Shots inspectorText ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "sOpenProject"); + +function sOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "sOpenEditor"); +} + +function sOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "sStep1"); +} + +function sStep1() +{ + $sTheme = GuiEditor.themeLibrary.createTheme("TextShotTheme"); + GuiEditor.themeName = $sTheme.getName(); + + // The dialog: a backdrop with a heading in it. The heading is themed while + // it is still empty, which is the whole of the problem. + $sPanel = new GuiControl() + { + Position = "40 40"; + Extent = "260 180"; + }; + GuiEditor.rootGui.add($sPanel); + + $sHeading = new GuiControl() + { + Position = "20 16"; + Extent = "220 32"; + }; + $sPanel.add($sHeading); + GuiEditor.themeApplier.applyToBranch($sPanel, $sTheme, true); + + $sHeading.text = "High Scores"; + + createPath(testRoot("shots/")); + schedule(500, 0, "sShootEmpty"); +} + +function sShootEmpty() +{ + sBind(); + schedule(500, 0, "sGrab", "text-empty", "sFix"); +} + +// What the fix is, in two edits: say what the control is, then set the size the +// caption wants. +function sFix() +{ + %pane = GuiEditor.inspectorWindow.pane; + + %row = %pane.header.categoryRow; + %row.applyValue("Label"); + %row.commit(); + + %row = %pane.row["fontSizeAdjust"]; + %row.applyValue("1.6"); + %row.commit(); + + sBind(); + schedule(500, 0, "sGrab", "text-label", ""); +} + +function sBind() +{ + %pane = GuiEditor.inspectorWindow.pane; + %pane.bind($sHeading); + + %pane.textPanel.setExpanded(true); + %panels = %pane.panelList SPC %pane.classPanels; + for(%i = 0; %i < getWordCount(%panels); %i++) + { + %panel = %pane.panel[getWord(%panels, %i)]; + if(isObject(%panel) && %panel.isVisible()) + { + %panel.setExpanded(true); + } + } +} + +function sGrab(%name, %next) +{ + screenShot(testRoot("shots/" @ %name @ ".png"), "PNG"); + echo("SHOT: wrote " @ %name @ ".png"); + + if(%next !$= "") + { + schedule(300, 0, %next); + return; + } + + echo("SHOT DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/shots/listItems.cs b/tests/shots/listItems.cs new file mode 100644 index 000000000..b4f557922 --- /dev/null +++ b/tests/shots/listItems.cs @@ -0,0 +1,124 @@ +// Visual harness for the Items section of the Gui Editor's properties pane: the +// static rows a list box or a drop down is authored with. +// +// Worth looking at rather than only asserting on, because the row is dense -- +// nine controls on one line -- and whether all nine still fit at the pane's +// width is the whole question. The canvas is in the shot too: a row typed here +// is drawn on the list immediately, and that is the point of the feature. +// +// Every section but Items is shut, so the rows are in frame rather than eight +// screens below it. +// +// Run: tests/run.ps1 -Shots listItems ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "sOpenProject"); + +// A real project, loaded the way the project selector does it, so the pane and +// the list on the canvas are both drawn wearing a project's theme. +function sOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "sOpenEditor"); +} + +function sOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "sStep1"); +} + +function sStep1() +{ + $sTheme = GuiEditor.themeLibrary.createTheme("ItemsShotTheme"); + GuiEditor.themeName = $sTheme.getName(); + + $sList = sPlace("GuiListBoxCtrl"); + $sList.resize(30, 30, 200, 140); + + // Five rows that between them use every field the section offers: an ID, a + // color dot, an inactive row and one that starts selected. + $sList.setItemList( + "Easy" TAB "1" TAB "1" TAB "0" TAB "0" TAB "1 1 1 1" NL + "Normal" TAB "2" TAB "1" TAB "1" TAB "0" TAB "1 1 1 1" NL + "Hard" TAB "3" TAB "1" TAB "0" TAB "1" TAB "1 0.4 0.2 1" NL + "Nightmare" TAB "4" TAB "0" TAB "0" TAB "1" TAB "0.8 0.2 0.2 1" NL + "Impossible" TAB "5" TAB "0" TAB "0" TAB "0" TAB "1 1 1 1"); + + $sDrop = sPlace("GuiDropDownCtrl"); + $sDrop.resize(30, 190, 200, 26); + $sDrop.setItemList( + "Windowed" TAB "1" NL + "Fullscreen" TAB "2" TAB "1" TAB "1" NL + "Borderless window" TAB "3"); + + createPath(testRoot("shots/")); + + // case TAB object global + $sCases = + "items-list" TAB "$sList" NL + "items-dropdown" TAB "$sDrop"; + $sIndex = 0; + schedule(1000, 0, "sShoot"); +} + +function sPlace(%class) +{ + %ctrl = eval("return new " @ %class @ "();"); + GuiEditor.rootGui.add(%ctrl); + GuiEditor.themeApplier.applyToBranch(%ctrl, $sTheme, true); + return %ctrl; +} + +function sShoot() +{ + %rec = getRecord($sCases, $sIndex); + %name = getField(%rec, 0); + %ctrl = eval("return " @ getField(%rec, 1) @ ";"); + + %pane = GuiEditor.inspectorWindow.pane; + %pane.bind(%ctrl); + + // Everything but Items shut, so the rows are in the shot rather than eight + // screens below it. The header cannot be collapsed and does not need to be. + %panels = %pane.panelList SPC %pane.classPanels; + for(%i = 0; %i < getWordCount(%panels); %i++) + { + %panel = %pane.panel[getWord(%panels, %i)]; + if(isObject(%panel)) + { + %panel.setExpanded(false); + } + } + %pane.textPanel.setExpanded(false); + %pane.dynamicPanel.setExpanded(true); + %pane.itemsPanel.setExpanded(true); + + GuiEditor.inspectorWindow.scroller.scrollToBottom(); + + // Let the chain, the rows and the panels settle before grabbing. + schedule(500, 0, "sGrab", %name); +} + +function sGrab(%name) +{ + screenShot(testRoot("shots/" @ %name @ ".png"), "PNG"); + echo("SHOT: wrote " @ %name @ ".png"); + + $sIndex++; + if($sIndex < getRecordCount($sCases)) + { + schedule(300, 0, "sShoot"); + return; + } + + echo("SHOT DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/shots/menuBar.cs b/tests/shots/menuBar.cs new file mode 100644 index 000000000..baf591590 --- /dev/null +++ b/tests/shots/menuBar.cs @@ -0,0 +1,141 @@ +// Visual harness for the menu bar's editor-only affordances. +// +// ONE bar, shot in three states, because a Gui can only really show one: a menu +// bar owns nothing but its height. GuiMenuBarCtrl::resize throws away the +// position it is handed and passes (0,0), and onRender resizes the bar to the +// clip rect's width on every frame it draws - so two bars in a Gui sit exactly +// on top of each other. +// +// empty no menus at all -- which the control palette cannot fix, since it +// does not offer a GuiMenuItemCtrl, so the "+" is the only way +// anything ever gets into a bar +// menus three of them, so the "+" has to land after the last +// nested a menu with commands inside it +// +// The editor's own menu bar is in every shot, along the top, and must never grow +// a "+": it is not inside the Gui being authored, so isEditMode() is false for +// it. +// +// Run: tests/run.ps1 -Shots menuBar ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "mbOpenProject"); + +// A real project, loaded the way the project selector does it, so the bars are +// drawn wearing a project's menu profiles rather than the engine's fallbacks. +function mbOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "mbOpenEditor"); +} + +// Loading AppCore starts the project's game over the canvas; the editor comes +// back the way Ctrl+~ does it. Pages register in load order: EditorConsole, +// ProjectManager, AssetAdmin, GuiEditor. +function mbOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "mbStep1"); +} + +function mbStep1() +{ + $mbTheme = GuiEditor.themeLibrary.createTheme("MenuBarShotTheme"); + GuiEditor.themeName = $mbTheme.getName(); + + // Position is not ours to choose; the bar pins itself to its parent's origin. + $mbBar = new GuiMenuBarCtrl() { Extent = "300 30"; }; + GuiEditor.rootGui.add($mbBar); + GuiEditor.themeApplier.applyToBranch($mbBar, $mbTheme, true); + + createPath(testRoot("shots/")); + schedule(900, 0, "mbShootEmpty"); +} + +function mbShootEmpty() +{ + mbGrab("menubar-empty"); + + for(%i = 1; %i <= 3; %i++) + { + mbItem($mbBar, "Menu " @ %i); + } + schedule(600, 0, "mbShootMenus"); +} + +function mbShootMenus() +{ + mbGrab("menubar-menus"); + + // One of each kind, so the shot says what each looks like: a plain command, a + // separator written as a dash, a toggle that starts on, a radio that does + // not, and one that opens a submenu of its own. + %menu = $mbBar.getObject(0); + mbItem(%menu, "New"); + mbItem(%menu, "-"); + + %toggle = mbItem(%menu, "Show Grid"); + %toggle.Toggle = true; + %toggle.IsOn = true; + + %radio = mbItem(%menu, "Snap to Grid"); + %radio.Radio = true; + + %sub = mbItem(%menu, "Recent"); + mbItem(%sub, "titleGui.gui.taml"); + + schedule(600, 0, "mbShootNested"); +} + +// Nothing selected, so no dropdown: the commands exist but the box does not. +function mbShootNested() +{ + mbGrab("menubar-nested"); + + // Selecting the menu is what opens it. Through the brain, so this is the same + // route a click on the canvas or a row in the Explorer tree takes. + GuiEditor.brain.selectList($mbBar.getObject(0)); + schedule(600, 0, "mbShootOpen"); +} + +function mbShootOpen() +{ + mbGrab("menubar-open"); + + // And a menu with nothing in it, which is the state every menu starts in and + // the one the runtime dropdown could not draw at all - its list is not built + // until a first child arrives. + GuiEditor.brain.selectList($mbBar.getObject(1)); + schedule(600, 0, "mbShootOpenEmpty"); +} + +function mbShootOpenEmpty() +{ + mbGrab("menubar-open-empty"); + + echo("SHOT DONE"); + schedule(300, 0, "quit"); +} + +// Added to its parent BEFORE it is given any children of its own: a menu item +// reads its bar out of its parent when a child arrives. +function mbItem(%parent, %text) +{ + %item = new GuiMenuItemCtrl() { Text = %text; }; + %parent.add(%item); + GuiEditor.themeApplier.applyToBranch(%item, $mbTheme, true); + return %item; +} + +function mbGrab(%name) +{ + screenShot(testRoot("shots/" @ %name @ ".png"), "PNG"); + echo("SHOT: wrote " @ %name @ ".png"); +} diff --git a/tests/shots/tabBook.cs b/tests/shots/tabBook.cs new file mode 100644 index 000000000..1f83c48eb --- /dev/null +++ b/tests/shots/tabBook.cs @@ -0,0 +1,107 @@ +// Visual harness for the tab book's editor-only "+" tab. +// +// Two books in every shot, because the two cases fail differently: +// populated three pages, so the "+" has to land after the last real tab +// empty no pages at all -- which before this drew NOTHING, tab strip +// included, because calculatePageTabs short-circuited and left +// mTabRect zero for onRender to bail on +// +// All four tab positions, not just the two obvious ones: a bottom or right strip +// places itself by measuring back from the far edge, and used to read its own +// extent before this pass had written it. A book with pages hid that behind a +// second layout pass; a book with none gets only the first. +// +// Run: tests/run.ps1 -Shots tabBook ; look in shots/. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +testExec("editor/main.cs"); +schedule(2500, 0, "tbOpenProject"); + +// A real project, loaded the way the project selector does it, so the books are +// drawn wearing a project's theme rather than the engine's fallbacks. +function tbOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "tbOpenEditor"); +} + +// Loading AppCore starts the project's game over the canvas; the editor comes +// back the way Ctrl+~ does it. Pages register in load order: EditorConsole, +// ProjectManager, AssetAdmin, GuiEditor. +function tbOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "tbStep1"); +} + +function tbStep1() +{ + $tbTheme = GuiEditor.themeLibrary.createTheme("TabBookShotTheme"); + GuiEditor.themeName = $tbTheme.getName(); + + // Where the canvas can actually show something. A Gui is authored at its own + // size and the editor's frame is a few hundred pixels wide, so the middle of + // the root is off screen more often than not; the brain already works out + // which part of a container is visible for click placement. + %room = GuiEditor.brain.visiblePartOf(GuiEditor.rootGui); + %left = getWord(%room, 0) + 20; + %top = getWord(%room, 1) + 20; + + $tbBook = tbPlace(%left, %top); + for(%i = 1; %i <= 3; %i++) + { + %page = new GuiTabPageCtrl() { Text = "Page " @ %i; }; + $tbBook.add(%page); + GuiEditor.themeApplier.applyToBranch(%page, $tbTheme, true); + } + + $tbEmpty = tbPlace(%left, %top + 160); + + createPath(testRoot("shots/")); + + $tbCases = "Top" TAB "Bottom" TAB "Left" TAB "Right"; + $tbIndex = 0; + schedule(800, 0, "tbShoot"); +} + +function tbPlace(%x, %y) +{ + %book = new GuiTabBookCtrl() { Extent = "300 130"; }; + GuiEditor.rootGui.add(%book); + %book.setPositionGlobal(%x, %y); + GuiEditor.themeApplier.applyToBranch(%book, $tbTheme, true); + return %book; +} + +function tbShoot() +{ + %pos = getField($tbCases, $tbIndex); + $tbBook.TabPosition = %pos; + $tbEmpty.TabPosition = %pos; + + // solveDirty notices the changed field on the next onPreRender and resizes, + // which is what re-runs calculatePageTabs. Nothing to call by hand. + schedule(600, 0, "tbGrab", %pos); +} + +function tbGrab(%pos) +{ + screenShot(testRoot("shots/tabbook-" @ %pos @ ".png"), "PNG"); + echo("SHOT: wrote tabbook-" @ %pos @ ".png"); + + $tbIndex++; + if($tbIndex < getFieldCount($tbCases)) + { + schedule(300, 0, "tbShoot"); + return; + } + + echo("SHOT DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/shots/theme.cs b/tests/shots/theme.cs index 1f1049068..128be0859 100644 --- a/tests/shots/theme.cs +++ b/tests/shots/theme.cs @@ -10,6 +10,7 @@ testExec("editor/main.cs"); // The splash animates for ~2.8s before the project selector appears; give it room. +createPath(testRoot("shots/")); schedule(7000, 0, "shot1"); function shot1() diff --git a/tests/shots/unsavedPrompt.cs b/tests/shots/unsavedPrompt.cs new file mode 100644 index 000000000..1fb7c5570 --- /dev/null +++ b/tests/shots/unsavedPrompt.cs @@ -0,0 +1,90 @@ +//----------------------------------------------------------------------------- +// The unsaved-changes prompt, for looking at. Three buttons and a message whose +// length depends on the document's name, in a box of a fixed size -- which is +// the shape of the bug that made the Profile Editor's confirm dialog grow to +// fit its message. +// +// Two shots: a Gui that has never been saved (the longest name it can have is +// the default one) and a saved one. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; + +testExec("editor/main.cs"); + +createPath(testRoot("shots/")); +schedule(2500, 0, "upOpenProject"); + +// The long way round rather than GuiEditor.open(), because the point of a shot +// is the picture: opening the editor the way a person does is what puts the +// editor's own chrome behind the dialog instead of a black canvas. +function upOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "upOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function upOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "upStep1"); +} + +function upStep1() +{ + %ctrl = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "A"; }; + GuiEditor.rootGui.add(%ctrl); + + GuiEditor.inspectorWindow.pane.bind(%ctrl); + GuiEditor.inspectorWindow.pane.writeField("Text", "unsaved work"); + + GuiEditor.NewGui(); + schedule(600, 0, "upStep2"); +} + +function upStep2() +{ + screenShot(testRoot("shots/unsavedPromptUntitled.png"), "PNG"); + schedule(400, 0, "upStep3"); +} + +function upStep3() +{ + discardUnsavedPrompt(); + + // And again with a name that came from a file, which is the longer message. + %ctrl = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "A"; }; + GuiEditor.rootGui.add(%ctrl); + + GuiEditor.fileName = "titleScreen.gui"; + GuiEditor.refreshDocumentTitle(); + + GuiEditor.inspectorWindow.pane.bind(%ctrl); + GuiEditor.inspectorWindow.pane.writeField("Text", "unsaved work"); + + GuiEditor.NewGui(); + schedule(600, 0, "upStep4"); +} + +function upStep4() +{ + screenShot(testRoot("shots/unsavedPromptNamed.png"), "PNG"); + schedule(400, 0, "upDone"); +} + +function upDone() +{ + discardUnsavedPrompt(); + echo("PROMPT SHOTS DONE"); + quit(); +} diff --git a/tests/smoke/assetPicker.cs b/tests/smoke/assetPicker.cs index 5cbe5e13d..aef0c8a30 100644 --- a/tests/smoke/assetPicker.cs +++ b/tests/smoke/assetPicker.cs @@ -340,53 +340,44 @@ function fStep6() } //----------------------------------------------------------------------------- -// The other caller: the native inspector's browse button. This is the path the -// engine change opened up -- GuiInspectorTypeAsset builds a "..." button and -// bakes a call to EditorCore.openAssetPicker into its Command. +// The other caller: the Find button on an asset field in the Gui Editor's +// properties pane. +// +// 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 +// "asset" kind, which routes the click through onFindAssetClicked instead of a +// baked-in command string. Same promise, one indirection later. +// +// GuiInspectorTypeAsset itself is still live: editor/AssetAdmin/AssetInspector.cs +// builds a GuiInspector. Nothing covers that one, which is a gap this change +// created rather than closed. //----------------------------------------------------------------------------- -function fFindBrowseButton(%ctrl) -{ - if(strstr(%ctrl.Command, "openAssetPicker") != -1) - { - return %ctrl; - } - for(%i = 0; %i < %ctrl.getCount(); %i++) - { - %found = fFindBrowseButton(%ctrl.getObject(%i)); - if(isObject(%found)) - { - return %found; - } - } - return 0; -} - function fStep7() { - // A Sprite's Image field is a TypeImageAssetPtr, which is what gets the - // browse button. Driven through the Gui Editor's own inspector rather than a - // fresh one: a detached inspector never wakes, and an unwoken control does - // not build its field editors, so there would be no button to find. - $fSprite = new Sprite(); - $fInspector = GuiEditor.inspectorWindow.inspector; - fCheck("the editor's inspector is available", isObject($fInspector)); - $fInspector.inspect($fSprite); - - %browse = fFindBrowseButton($fInspector); - fCheck("inspector built a browse button for the asset field", isObject(%browse)); - fCheck("browse button calls the editor's picker", - strstr(%browse.Command, "EditorCore.openAssetPicker(") != -1); - fCheck("browse button passes the asset type", - strstr(%browse.Command, "ImageAsset") != -1); - fCheck("browse button passes apply as the callback method", - strstr(%browse.Command, "\"apply\"") != -1); + // A GuiSpriteCtrl's Image field is a TypeAssetId, which is what gets the + // Find button. It has to be a Gui control now rather than a Sprite: the + // pane binds controls, and a scene object was only ever usable here because + // the old inspector took any SimObject. + $fSprite = new GuiSpriteCtrl(); + GuiEditor.rootGui.add($fSprite); + + $fPane = GuiEditor.inspectorWindow.pane; + fCheck("the editor's properties pane is available", isObject($fPane)); + $fPane.bind($fSprite); + + %row = $fPane.row["Image"]; + fCheck("pane built a row for the asset field", isObject(%row)); + fCheck("the asset field got an asset row", isObject(%row) && %row.kind $= "asset"); + fCheck("asset row has a Find button", isObject(%row) && isObject(%row.findButton)); // Run exactly what a click would run. - eval(%browse.Command); + eval(%row.findButton.Command); %picker = fPicker(); - fCheck("the browse button opened the picker", isObject(%picker)); + fCheck("the Find button opened the picker", isObject(%picker)); fCheck("the picker opened on the right asset type", %picker.assetType $= "ImageAsset"); %item = %picker.grid.getObject(0); @@ -399,11 +390,11 @@ function fStep7() function fStep8() { - fCheck("choosing wrote the asset onto the inspected object", + fCheck("choosing wrote the asset onto the bound control", $fSprite.Image $= $fInspectorChoice); - fCheck("the inspector's picker closed", !isObject(fPicker())); + fCheck("the pane's picker closed", !isObject(fPicker())); - $fInspector.clear(); + $fPane.unbind(); $fSprite.delete(); // Land on a border node before quitting. Quitting with a profile node diff --git a/tests/smoke/bitmapPathRead.cs b/tests/smoke/bitmapPathRead.cs index f5c683c94..fb4b95a53 100644 --- a/tests/smoke/bitmapPathRead.cs +++ b/tests/smoke/bitmapPathRead.cs @@ -28,6 +28,7 @@ function smokeCheck(%label, %condition) } } +createPath(testRoot("shots/")); schedule(2500, 0, "readStep1"); function readStep1() diff --git a/tests/smoke/buttonText.cs b/tests/smoke/buttonText.cs new file mode 100644 index 000000000..59e4aea64 --- /dev/null +++ b/tests/smoke/buttonText.cs @@ -0,0 +1,108 @@ +//----------------------------------------------------------------------------- +// A caption the author cleared has to come back cleared. +// +// SimObject::writeField drops every empty value, so a blank caption is written +// as an absent one. Anything the constructor seeds therefore stands back up on +// read and silently replaces the author's blank - which is what put "Button" on +// all thirty seven keys of the VirtualKeyboard the first time it went through +// the Gui Editor. A button carries no caption of its own now; the Gui Editor +// captions the ones it places. +// +// This is a smoke suite rather than a C++ unit test because building a button +// assigns it a profile, and a profile loads its font, which registers a texture +// - and a unit test has no GL context, so that asserts. +//----------------------------------------------------------------------------- + +setRandomSeed(); +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; + +ModuleDatabase.scanModules(testRoot("toybox")); +ModuleDatabase.LoadExplicit("AppCore"); + +function smokeCheck(%label, %condition) +{ + echo(%condition ? ("SMOKE PASS: " @ %label) : ("SMOKE FAIL: " @ %label)); +} + +createPath(testRoot("shots/")); +schedule(4000, 0, "btDefaults"); + +function btDefaults() +{ + // What a freshly built control arrives with. Every one of these is checked + // for existence first: getText() on something that was never made answers + // the empty string too, so "has no caption" would pass on a control that + // does not exist. + %button = new GuiButtonCtrl(); + smokeCheck("a button was made", isObject(%button)); + smokeCheck("a new button has no caption (" @ %button.getText() @ ")", + %button.getText() $= ""); + %button.delete(); + + // A check box and a radio take their text from GuiButtonCtrl, so the seeded + // caption used to reach them too -- a check box read "Button". + %check = new GuiCheckBoxCtrl(); + smokeCheck("a check box was made", isObject(%check)); + smokeCheck("a new check box has no caption (" @ %check.getText() @ ")", + %check.getText() $= ""); + %check.delete(); + + %radio = new GuiRadioCtrl(); + smokeCheck("a radio was made", isObject(%radio)); + smokeCheck("a new radio has no caption (" @ %radio.getText() @ ")", + %radio.getText() $= ""); + %radio.delete(); + + // The drop down is deliberately left alone: it draws its text only while + // nothing is selected, so "none" is an empty state, not a caption. + // + // Read through the text FIELD, not getText(). GuiDropDownCtrl binds its own + // getText, which answers the selected item's text and an empty string when + // nothing is selected -- it never reads mText at all. Asserting on getText + // here would report a placeholder that is perfectly intact as missing. + %drop = new GuiDropDownCtrl(); + smokeCheck("a drop down was made", isObject(%drop)); + smokeCheck("a new drop down still reads none (" @ %drop.text @ ")", + %drop.text $= "none"); + %drop.delete(); + + schedule(200, 0, "btRoundTrip"); +} + +function btRoundTrip() +{ + %path = testRoot("shots/buttonTextRoundTrip.taml"); + + // The bug itself: blank is written as absent, and absent used to read back + // as "Button". + %blank = new GuiButtonCtrl(); + %blank.setText(""); + TamlWrite(%blank, %path); + %blank.delete(); + + %loaded = TamlRead(%path); + smokeCheck("a blank caption survives the round trip (" @ %loaded.getText() @ ")", + isObject(%loaded) && %loaded.getText() $= ""); + %loaded.delete(); + + // And the ordinary case still works. + %captioned = new GuiButtonCtrl(); + %captioned.setText("Change username"); + TamlWrite(%captioned, %path); + %captioned.delete(); + + %reloaded = TamlRead(%path); + smokeCheck("a set caption survives the round trip (" @ %reloaded.getText() @ ")", + isObject(%reloaded) && %reloaded.getText() $= "Change username"); + %reloaded.delete(); + + fileDelete(%path); + echo("SMOKE DONE"); + quit(); +} diff --git a/tests/smoke/canvasDrop.cs b/tests/smoke/canvasDrop.cs new file mode 100644 index 000000000..99a999b9e --- /dev/null +++ b/tests/smoke/canvasDrop.cs @@ -0,0 +1,326 @@ +//----------------------------------------------------------------------------- +// Where a control lands when it arrives in the document. +// +// Two gestures put one there, and both used to be able to put it somewhere +// nobody can see: +// +// a drag GuiDragAndDropCtrl hit-tests from the drag control's PARENT, which +// is the brain, and GuiControl::findHitControl answers "me" when no +// child is hit -- whatever the point. So the brain heard +// onControlDropped for every point on screen, and placed the control +// at the cursor: drag one back onto the palette to change your mind +// and it was added behind the palette. +// +// a click places the control in the middle of the container being worked in. +// A real Gui is designed at 1024x768 and the canvas frame is a few +// hundred pixels wide, so the middle of a container is regularly off +// the side of the canvas -- behind the palette or the explorer. +// +// The drags here are built rather than posted. A real drag would need +// startDragging, which mouse-locks the canvas, and the point of the test is +// what onControlDropped does with the payload it is handed -- so the object +// graph a drag makes (payload inside a GuiDragAndDropCtrl inside the brain) is +// assembled directly, and the drag control is deleted afterwards exactly as +// GuiDragAndDropCtrl::onTouchUp deletes it. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function cdCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("CDROP PASS: " @ %label); + } + else + { + $Fail++; + echo("CDROP FAIL: " @ %label); + } +} + +schedule(2000, 0, "cdSetup"); + +// A project, so there is a theme: an unthemed drop skips a whole branch of +// acceptControl and this suite is about the placing, not the theming. +function cdSetup() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + GuiEditor.open(); + schedule(500, 0, "cdDropChecks"); +} + +//----------------------------------------------------------------------------- +// Helpers. Global coordinates throughout -- the position these callbacks are +// handed is local to the brain, which is why neither the code nor the test +// measures anything with it. +//----------------------------------------------------------------------------- + +// The rect of a control, as "x y width height" in global coordinates. +function cdInside(%point, %ctrl) +{ + %x = getWord(%point, 0); + %y = getWord(%point, 1); + %at = %ctrl.getGlobalPosition(); + %ext = %ctrl.getExtent(); + + return (%x >= getWord(%at, 0) && %y >= getWord(%at, 1) && + %x < getWord(%at, 0) + getWord(%ext, 0) && + %y < getWord(%at, 1) + getWord(%ext, 1)); +} + +// The whole control, not just its corner. +function cdWhollyInside(%ctrl, %container) +{ + %at = %ctrl.getGlobalPosition(); + %ext = %ctrl.getExtent(); + %far = (getWord(%at, 0) + getWord(%ext, 0) - 1) SPC + (getWord(%at, 1) + getWord(%ext, 1) - 1); + + return cdInside(%at, %container) && cdInside(%far, %container); +} + +// The object graph a drag has when the mouse comes up, without the mouse lock. +// beginDrag grabs the payload by the middle, so the cursor is its centre. +function cdBuildDrag(%payload, %cursor) +{ + %brainAt = GuiEditor.brain.getGlobalPosition(); + %size = %payload.getExtent(); + + %x = getWord(%cursor, 0) - getWord(%brainAt, 0) - (getWord(%size, 0) / 2); + %y = getWord(%cursor, 1) - getWord(%brainAt, 1) - (getWord(%size, 1) / 2); + + %drag = new GuiDragAndDropCtrl() + { + Profile = "GuiDragAndDropProfile"; + HorizSizing = "anchorLeft"; + VertSizing = "anchorTop"; + Position = mFloor(%x) SPC mFloor(%y); + Extent = %size; + deleteOnMouseUp = true; + }; + %drag.add(%payload); + GuiEditor.brain.add(%drag); + + return %drag; +} + +// Press, drag to %cursor, release. Answers the payload, which may be dead. +function cdDrop(%class, %cursor) +{ + %payload = eval("return new " @ %class @ "();"); + %drag = cdBuildDrag(%payload, %cursor); + + GuiEditor.brain.onControlDropped(%payload, %drag.getPosition()); + %drag.delete(); + + return %payload; +} + +//----------------------------------------------------------------------------- +// A drop has to be over the canvas. +//----------------------------------------------------------------------------- + +function cdDropChecks() +{ + %root = GuiEditor.rootGui; + %palette = GuiEditor.ctrlListWindow; + + // Aimed at the palette, which is where a hand goes to change its mind. + %at = %palette.getGlobalPosition(); + %cursor = (getWord(%at, 0) + 60) SPC (getWord(%at, 1) + 140); + cdCheck("the test is aiming off the canvas", !cdInside(%cursor, %root)); + + %before = %root.getCount(); + %depth = GuiEditor.undoRecorder.undoCount(); + %payload = cdDrop("GuiButtonCtrl", %cursor); + + cdCheck("a drop over the palette adds nothing (" @ %root.getCount() @ ")", + %root.getCount() == %before); + cdCheck("the control it was carrying goes with the drag", !isObject(%payload)); + cdCheck("and it is not an undo step (" @ + (GuiEditor.undoRecorder.undoCount() - %depth) @ ")", + GuiEditor.undoRecorder.undoCount() == %depth); + + // The same gesture, ended over the canvas. + %rootAt = %root.getGlobalPosition(); + %cursor = (getWord(%rootAt, 0) + 80) SPC (getWord(%rootAt, 1) + 90); + %payload = cdDrop("GuiButtonCtrl", %cursor); + + cdCheck("a drop over the canvas is added (" @ %root.getCount() @ ")", + %root.getCount() == %before + 1); + cdCheck("it is in the add set", %payload.getGroup() == GuiEditor.brain.getCurrentAddSet()); + cdCheck("it landed under the cursor", %payload.getGlobalCenter() $= %cursor); + + // A drop lands twice -- once now, once 40ms later, because the container it + // arrived in may have moved it. Let the second one happen before going on. + schedule(200, 0, "cdAddSetChecks", %payload); +} + +//----------------------------------------------------------------------------- +// A drag that wanders off the canvas must not change the container being worked +// in: the click gesture places into it, so losing it silently moves where the +// next click puts a control. +//----------------------------------------------------------------------------- + +function cdAddSetChecks(%dropped) +{ + cdCheck("the drop stayed under the cursor after its second placing", + cdWhollyInside(%dropped, GuiEditor.rootGui)); + + // A container to be working in, sized so it is wholly on the canvas. + %panel = new GuiControl() + { + Position = "20 20"; + Extent = "200 200"; + }; + ThemeManager.setProfile(%panel, "panelProfile"); + GuiEditor.rootGui.add(%panel); + GuiEditor.brain.setCurrentAddSet(%panel); + cdCheck("working in the panel", GuiEditor.brain.getCurrentAddSet() == %panel); + + %palette = GuiEditor.ctrlListWindow; + %at = %palette.getGlobalPosition(); + %cursor = (getWord(%at, 0) + 60) SPC (getWord(%at, 1) + 140); + + %payload = new GuiButtonCtrl(); + %drag = cdBuildDrag(%payload, %cursor); + GuiEditor.brain.onControlDragged(%payload, %drag.getPosition()); + %drag.delete(); + + cdCheck("dragging off the canvas keeps the container (" @ + GuiEditor.brain.getCurrentAddSet() @ ")", + GuiEditor.brain.getCurrentAddSet() == %panel); + + schedule(100, 0, "cdClickChecks", %panel); +} + +//----------------------------------------------------------------------------- +// A click places in the middle of the container being worked in. +//----------------------------------------------------------------------------- + +function cdClickChecks(%panel) +{ + %tile = cdFindTile("GuiButtonCtrl"); + cdCheck("found the button tile", isObject(%tile)); + + // In the panel: dead centre of it. + %probe = new GuiButtonCtrl(); + %size = %probe.getExtent(); + %at = %panel.getGlobalPosition(); + %room = %panel.getExtent(); + %want = mFloor(getWord(%at, 0) + ((getWord(%room, 0) - getWord(%size, 0)) / 2)) SPC + mFloor(getWord(%at, 1) + ((getWord(%room, 1) - getWord(%size, 1)) / 2)); + %probe.delete(); + + %tile.onClick(); + %ctrl = %panel.getObject(%panel.getCount() - 1); + cdCheck("clicking placed it in the panel", %ctrl.getGroup() == %panel); + cdCheck("in the middle of it (" @ %ctrl.getGlobalPosition() @ " wanted " @ %want @ ")", + %ctrl.getGlobalPosition() $= %want); + cdCheck("which is on the canvas", cdWhollyInside(%ctrl, GuiEditor.rootGui)); + + // And with nothing selected, the middle of the canvas itself. + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + %tile.onClick(); + %ctrl = GuiEditor.rootGui.getObject(GuiEditor.rootGui.getCount() - 1); + cdCheck("with no container chosen it goes on the canvas", + cdWhollyInside(%ctrl, GuiEditor.rootGui)); + + schedule(100, 0, "cdBigGuiChecks"); +} + +//----------------------------------------------------------------------------- +// The case the canvas frame cannot show: a Gui designed at 1024x768 in a canvas +// a few hundred pixels wide. The middle of a container that runs off the side +// of the canvas is behind the palette, so the click has to place into the part +// of the container that can actually be seen. +//----------------------------------------------------------------------------- + +function cdBigGuiChecks() +{ + %content = TAMLRead(testRoot("PlanetX/PlanetXGame/gui/titleGui.gui.taml")); + GuiEditor.DisplayGuiContent(%content, false); + + %root = GuiEditor.rootGui; + %inner = %content.getObject(0); + GuiEditor.brain.setCurrentAddSet(%inner); + + cdCheck("the loaded Gui is wider than the canvas (" @ + getWord(%content.getExtent(), 0) @ " in " @ getWord(%root.getExtent(), 0) @ ")", + getWord(%content.getExtent(), 0) > getWord(%root.getExtent(), 0)); + cdCheck("its container runs off the canvas", !cdWhollyInside(%inner, %root)); + + %tile = cdFindTile("GuiButtonCtrl"); + %tile.onClick(); + %ctrl = %inner.getObject(%inner.getCount() - 1); + + cdCheck("clicking placed it in the container", %ctrl.getGroup() == %inner); + cdCheck("somewhere that can be seen (" @ %ctrl.getGlobalPosition() @ ")", + cdWhollyInside(%ctrl, %root)); + + // Specifically the middle of the visible part: the container starts at the + // canvas's left edge and runs 1024 wide, so that is the middle of the + // canvas across, and the middle of the overlap down. + %overlap = cdOverlapRect(%inner, %root); + %size = %ctrl.getExtent(); + %want = mFloor(getWord(%overlap, 0) + ((getWord(%overlap, 2) - getWord(%size, 0)) / 2)) SPC + mFloor(getWord(%overlap, 1) + ((getWord(%overlap, 3) - getWord(%size, 1)) / 2)); + cdCheck("in the middle of the part on the canvas (" @ + %ctrl.getGlobalPosition() @ " wanted " @ %want @ ")", + %ctrl.getGlobalPosition() $= %want); + + echo("CDROP DONE " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} + +// "x y width height" of what two controls share, in global coordinates. +function cdOverlapRect(%a, %b) +{ + %aAt = %a.getGlobalPosition(); + %aExt = %a.getExtent(); + %bAt = %b.getGlobalPosition(); + %bExt = %b.getExtent(); + + %left = mFloor(mGetMax(getWord(%aAt, 0), getWord(%bAt, 0))); + %top = mFloor(mGetMax(getWord(%aAt, 1), getWord(%bAt, 1))); + %right = mFloor(mGetMin(getWord(%aAt, 0) + getWord(%aExt, 0), + getWord(%bAt, 0) + getWord(%bExt, 0))); + %bottom = mFloor(mGetMin(getWord(%aAt, 1) + getWord(%aExt, 1), + getWord(%bAt, 1) + getWord(%bExt, 1))); + + return %left SPC %top SPC (%right - %left) SPC (%bottom - %top); +} + +function cdFindTile(%key) +{ + %window = GuiEditor.ctrlListWindow; + for(%g = 0; %g < %window.groupCount; %g++) + { + %group = %window.group[%g]; + for(%i = 0; %i < %group.tileCount; %i++) + { + if(%group.tile[%i].key $= %key) + { + return %group.tile[%i]; + } + } + } + return 0; +} diff --git a/tests/smoke/clipboard.cs b/tests/smoke/clipboard.cs new file mode 100644 index 000000000..2333bd78f --- /dev/null +++ b/tests/smoke/clipboard.cs @@ -0,0 +1,836 @@ +//----------------------------------------------------------------------------- +// Copy, cut and paste in the Gui Editor. Boots the editor, opens the PlanetX +// project so there is a real theme to work with, and puts the clipboard through +// what a person does with it: copy a control and paste it, paste it again, paste +// it somewhere else, copy a whole panel, cut something. +// +// The three things that are easy to get wrong and are checked hardest: a paste +// 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. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function cCheck(%label, %condition) +{ + echo(%condition ? ("CLIP PASS: " @ %label) : ("CLIP FAIL: " @ %label)); +} + +function cUndoCount() +{ + return GuiEditor.undoRecorder.undoCount(); +} + +function cRedoCount() +{ + return GuiEditor.undoRecorder.redoCount(); +} + +function cSelect(%ctrl) +{ + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select(%ctrl); +} + +function cIndexOf(%parent, %ctrl) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + if(%parent.getObject(%i) == %ctrl) + { + return %i; + } + } + return -1; +} + +// What a paste selected, which is what it pasted. +function cPasted(%index) +{ + %set = GuiEditor.brain.getSelected(); + return %set.getObject(%index); +} + +function cPastedCount() +{ + %set = GuiEditor.brain.getSelected(); + return %set.getCount(); +} + +// Comparing against "" proves nothing: an absent dynamic field and an empty one +// read back the same, because an empty one is exactly what the engine deletes. +function cHasDynamicField(%ctrl, %name) +{ + for(%i = 0; %i < %ctrl.getDynamicFieldCount(); %i++) + { + if(getWord(%ctrl.getDynamicField(%i), 0) $= %name) + { + return true; + } + } + return false; +} + +// Menu items are nested controls, so this walks rather than indexes. +function cMenuItem(%parent, %text) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + %item = %parent.getObject(%i); + if(%item.Text $= %text) + { + return %item; + } + + %found = cMenuItem(%item, %text); + if(isObject(%found)) + { + return %found; + } + } + + return 0; +} + +// A frame set's tree with the control ids taken out: those are object ids, so a +// copy's are necessarily different. What has to match is the shape - every +// frame's id, split direction, extent and anchoring - and which frames hold a +// control at all. +function cFrameShape(%layout) +{ + %shape = ""; + %count = getWordCount(%layout); + + for(%i = 0; (%i + 7) < %count; %i += 8) + { + for(%j = 0; %j < 7; %j++) + { + %shape = %shape @ getWord(%layout, %i + %j) @ " "; + } + %shape = %shape @ ((getWord(%layout, %i + 7) != 0) ? "1" : "0") @ " "; + } + + return %shape; +} + +// A control class that builds a child of its own the moment it is created, which +// is the house pattern (TORQUE_SCRIPT.md) and the thing a copy must not do twice. +function ClipProbe::onAdd(%this) +{ + %kid = new GuiControl() + { + Position = "4 4"; + Extent = "20 20"; + }; + %this.add(%kid); +} + +schedule(2000, 0, "cStep1"); + +function cStep1() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + $cTheme = nameToID("PlanetX"); + cCheck("PlanetX theme loaded", isObject($cTheme)); + + GuiEditor.open(); + cCheck("the editor built a clipboard", isObject(GuiEditor.clipboard)); + cCheck("which starts empty", GuiEditor.clipboard.isEmpty()); + + // Before anything has been copied, so this is the only moment it can be + // checked. + %pasteItem = cMenuItem(EditorCore.menuBar, "Paste"); + cCheck("Paste is greyed with nothing on the clipboard", !%pasteItem.Active); + + // The stage: a panel with three named children, and a second container to + // paste into. + $cPanel = new GuiControl() { Position = "10 10"; Extent = "400 300"; }; + GuiEditor.rootGui.add($cPanel); + + $cA = new GuiButtonCtrl(clipA) { Position = "10 10"; Extent = "80 30"; Text = "A"; }; + $cPanel.add($cA); + $cB = new GuiButtonCtrl(clipB) { Position = "10 50"; Extent = "80 30"; Text = "B"; }; + $cPanel.add($cB); + $cC = new GuiButtonCtrl(clipC) { Position = "10 90"; Extent = "80 30"; Text = "C"; }; + $cPanel.add($cC); + + $cOther = new GuiControl() { Position = "10 320"; Extent = "300 120"; }; + GuiEditor.rootGui.add($cOther); + + GuiEditor.setTheme($cTheme, false); + GuiEditor.undoRecorder.clear(); + + cCheck("the stage's controls are named", $cA.getName() $= "clipA"); + cCheck("the stack starts empty", cUndoCount() == 0 && cRedoCount() == 0); + + schedule(300, 0, "cStepCopyPaste"); +} + +//----------------------------------------------------------------------------- +// One control: copy, paste, and what the paste is worth on the undo stack. +//----------------------------------------------------------------------------- + +function cStepCopyPaste() +{ + GuiEditor.undoRecorder.clear(); + %grid = GuiEditor.brain.getGridSize(); + + cSelect($cA); + GuiEditor.Copy(); + + cCheck("a copy fills the clipboard", !GuiEditor.clipboard.isEmpty()); + cCheck("and records nothing on the undo stack", cUndoCount() == 0); + cCheck("and does not touch the original", $cA.getParent() == $cPanel); + + %pasteItem = cMenuItem(EditorCore.menuBar, "Paste"); + cCheck("Paste is offered once something is copied", %pasteItem.Active); + + %before = $cPanel.getCount(); + GuiEditor.Paste(); + + %copy = cPasted(0); + cCheck("a paste is one step", cUndoCount() == 1); + cCheck("it added one control", $cPanel.getCount() == (%before + 1)); + cCheck("into the container the original was in", %copy.getParent() == $cPanel); + cCheck("and it is a different object", %copy != $cA); + cCheck("of the same class", %copy.getClassName() $= $cA.getClassName()); + cCheck("with the same extent", %copy.getExtent() $= $cA.getExtent()); + cCheck("the same caption", %copy.Text $= $cA.Text); + cCheck("and the same profile (" @ %copy.getFieldValue("Profile") @ ")", + %copy.getFieldValue("Profile") $= $cA.getFieldValue("Profile")); + cCheck("the paste is selected", cPastedCount() == 1); + + // Stepped one grid line so it is not hidden exactly behind the original. + %wanted = (getWord($cA.getPosition(), 0) + %grid) SPC + (getWord($cA.getPosition(), 1) + %grid); + cCheck("stepped off the original (" @ %copy.getPosition() @ " wanted " @ %wanted @ ")", + %copy.getPosition() $= %wanted); + + // The name is the original's, made unique - not shared with it. + cCheck("the copy was renamed (" @ %copy.getName() @ ")", %copy.getName() $= "clipA2"); + cCheck("and the original kept its own name", $cA.getName() $= "clipA"); + cCheck("with no marker left behind", !cHasDynamicField(%copy, "clipName")); + + // Undo does not delete: the control lives in the trash, which is what leaves + // redo something to put back. + %trash = GuiEditor.brain.getTrash(); + GuiEditor.Undo(); + cCheck("undo took the paste out of the Gui", %copy.getGroup() == %trash); + cCheck("but did not delete it", isObject(%copy)); + cCheck("and the container is back to what it held", $cPanel.getCount() == %before); + + GuiEditor.Redo(); + cCheck("redo pasted it again", %copy.getParent() == $cPanel); + cCheck("still named for the original", %copy.getName() $= "clipA2"); + + // A second paste while the first copy is still in the Gui: it has to step past + // it and be named around it, rather than colliding with what it just made. + GuiEditor.Paste(); + %twice = cPasted(0); + + %wanted = (getWord($cA.getPosition(), 0) + (2 * %grid)) SPC + (getWord($cA.getPosition(), 1) + (2 * %grid)); + cCheck("a second paste steps past the first (" @ %twice.getPosition() @ ")", + %twice.getPosition() $= %wanted); + cCheck("and is named around it (" @ %twice.getName() @ ")", %twice.getName() $= "clipA3"); + + // Put the stage back the way the next step expects to find it. + GuiEditor.Undo(); + GuiEditor.Undo(); + cCheck("the stage is back to three children", $cPanel.getCount() == 3); + + schedule(300, 0, "cStepRepeat"); +} + +//----------------------------------------------------------------------------- +// Pasting again, and pasting elsewhere. Nothing is ever laid exactly on top of +// something already pasted. +//----------------------------------------------------------------------------- + +function cStepRepeat() +{ + GuiEditor.undoRecorder.clear(); + %grid = GuiEditor.brain.getGridSize(); + %from = $cA.getPosition(); + + // A fresh copy, which starts the stepping over. + GuiEditor.brain.setCurrentAddSet($cPanel); + cSelect($cA); + GuiEditor.Copy(); + + GuiEditor.Paste(); + %first = cPasted(0); + cCheck("the first paste steps once (" @ %first.getPosition() @ ")", + %first.getPosition() $= ((getWord(%from, 0) + %grid) SPC (getWord(%from, 1) + %grid))); + + // A different container: the position it had, unstepped, because there is + // nothing there to hide behind. + cSelect($cOther); + GuiEditor.brain.setCurrentAddSet($cOther); + GuiEditor.Paste(); + %second = cPasted(0); + + cCheck("pasting elsewhere puts it in that container", %second.getParent() == $cOther); + cCheck("at the position it had (" @ %second.getPosition() @ ")", + %second.getPosition() $= %from); + + // Back to the panel, where a paste has already been. The step carries on from + // where that container left off rather than starting again, or this paste + // would land exactly on the copy already sitting there. + GuiEditor.brain.setCurrentAddSet($cPanel); + GuiEditor.Paste(); + %third = cPasted(0); + + cCheck("coming back to a container carries on stepping (" @ %third.getPosition() @ ")", + %third.getPosition() $= ((getWord(%from, 0) + (2 * %grid)) SPC + (getWord(%from, 1) + (2 * %grid)))); + cCheck("so it does not land on the copy already there", + %third.getPosition() !$= %first.getPosition()); + cCheck("and all three are different objects", + %first != %second && %second != %third && %first != %third); + + // Tidy up: three pastes, three steps back. + GuiEditor.Undo(); + GuiEditor.Undo(); + GuiEditor.Undo(); + cCheck("all three pastes came back off the stack", cUndoCount() == 0); + cCheck("and the panel holds what it started with", $cPanel.getCount() == 3); + cCheck("as does the other container", $cOther.getCount() == 0); + + schedule(300, 0, "cStepMultiple"); +} + +//----------------------------------------------------------------------------- +// More than one control at once, and the reduction that stops a control being +// pasted twice. +//----------------------------------------------------------------------------- + +function cStepMultiple() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet($cPanel); + + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select($cA); + GuiEditor.brain.addSelection($cC); + GuiEditor.Copy(); + + %before = $cPanel.getCount(); + GuiEditor.Paste(); + + cCheck("pasting two controls is still one step", cUndoCount() == 1); + cCheck("and both arrived", $cPanel.getCount() == (%before + 2)); + cCheck("both are selected", cPastedCount() == 2); + + // Document order, not selection order: the copies sit in the same z-order as + // the originals. + %firstCopy = cPasted(0); + %secondCopy = cPasted(1); + cCheck("in the order they were in the document", + %firstCopy.Text $= "A" && %secondCopy.Text $= "C"); + + GuiEditor.Undo(); + cCheck("one undo took both back", $cPanel.getCount() == %before); + cCheck("and left nothing on the undo stack", cUndoCount() == 0); + + // A control inside another selected control is already coming along. + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select($cPanel); + GuiEditor.brain.addSelection($cB); + GuiEditor.Copy(); + + %rootBefore = GuiEditor.rootGui.getCount(); + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + GuiEditor.Paste(); + + cCheck("selecting a container and something inside it pastes one control", + GuiEditor.rootGui.getCount() == (%rootBefore + 1)); + cCheck("which is the container", cPastedCount() == 1); + + %panelCopy = cPasted(0); + cCheck("and it brought its three children (" @ %panelCopy.getCount() @ ")", + %panelCopy.getCount() == 3); + + GuiEditor.Undo(); + cCheck("and that was one step too", GuiEditor.rootGui.getCount() == %rootBefore); + + schedule(300, 0, "cStepNested"); +} + +//----------------------------------------------------------------------------- +// A copy of a whole panel: everything below it comes across, and none of it +// shares a name with the original. +//----------------------------------------------------------------------------- + +function cStepNested() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + + cSelect($cPanel); + GuiEditor.Copy(); + GuiEditor.Paste(); + + %copy = cPasted(0); + cCheck("the panel copy holds three children", %copy.getCount() == 3); + + %childA = %copy.getObject(0); + %childB = %copy.getObject(1); + %childC = %copy.getObject(2); + + cCheck("the children are new objects", + %childA != $cA && %childB != $cB && %childC != $cC); + cCheck("in the same order", %childA.Text $= "A" && %childC.Text $= "C"); + cCheck("with the positions they had", + %childA.getPosition() $= $cA.getPosition() && + %childC.getPosition() $= $cC.getPosition()); + cCheck("and the extents they had", %childB.getExtent() $= $cB.getExtent()); + cCheck("wearing the same profiles", + %childB.getFieldValue("Profile") $= $cB.getFieldValue("Profile")); + + cCheck("every child was renamed (" @ %childA.getName() SPC %childB.getName() SPC + %childC.getName() @ ")", + %childA.getName() !$= "clipA" && %childB.getName() !$= "clipB" && + %childC.getName() !$= "clipC"); + cCheck("and named for what it was", %childA.getName() $= "clipA2"); + cCheck("with no markers left behind", + !cHasDynamicField(%childA, "clipName") && !cHasDynamicField(%childC, "clipName")); + + GuiEditor.Undo(); + cCheck("undoing the panel paste takes the whole branch", cUndoCount() == 0); + + schedule(300, 0, "cStepDynamic"); +} + +//----------------------------------------------------------------------------- +// Dynamic fields, which the .cs writer would have dropped - the reason the +// clipboard clones rather than serialises. +//----------------------------------------------------------------------------- + +function cStepDynamic() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet($cPanel); + + GuiEditor.undoRecorder.writeDynamicField($cB, "cSmokeTag", "hello"); + cCheck("the original carries a dynamic field", cHasDynamicField($cB, "cSmokeTag")); + + cSelect($cB); + GuiEditor.Copy(); + GuiEditor.Paste(); + + %copy = cPasted(0); + cCheck("the copy carries it too", cHasDynamicField(%copy, "cSmokeTag")); + cCheck("with the value it had", %copy.cSmokeTag $= "hello"); + + GuiEditor.Undo(); + GuiEditor.undoRecorder.clear(); + $cB.cSmokeTag = ""; + + schedule(300, 0, "cStepClass"); +} + +//----------------------------------------------------------------------------- +// The one a clipboard built on serialising, or on plain construction, gets +// wrong: a control whose class builds a child in onAdd. The copy must hold the +// children the original has, and not a second set of them. +//----------------------------------------------------------------------------- + +function cStepClass() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + + $cProbe = new GuiControl() + { + class = "ClipProbe"; + Position = "420 10"; + Extent = "120 60"; + }; + GuiEditor.rootGui.add($cProbe); + + cCheck("the probe's class built it a child", $cProbe.getCount() == 1); + + cSelect($cProbe); + GuiEditor.Copy(); + GuiEditor.Paste(); + + %copy = cPasted(0); + cCheck("the copy has exactly one child (" @ %copy.getCount() @ ")", %copy.getCount() == 1); + cCheck("and it is not the original's child", %copy.getObject(0) != $cProbe.getObject(0)); + cCheck("the copy still has the class", %copy.class $= "ClipProbe"); + + // Two steps, not one expression: TorqueScript cannot call a method on the + // result of a call. + %copyKid = %copy.getObject(0); + %sourceKid = $cProbe.getObject(0); + cCheck("and the child kept its geometry", + %copyKid.getPosition() $= %sourceKid.getPosition()); + + GuiEditor.Undo(); + + schedule(300, 0, "cStepCut"); +} + +//----------------------------------------------------------------------------- +// Cut, which is a copy plus the delete the Delete key already does. +//----------------------------------------------------------------------------- + +function cStepCut() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet($cPanel); + %trash = GuiEditor.brain.getTrash(); + + %index = cIndexOf($cPanel, $cB); + cSelect($cB); + GuiEditor.Cut(); + + cCheck("a cut is one step", cUndoCount() == 1); + cCheck("the control went to the trash", $cB.getGroup() == %trash); + cCheck("it was not deleted", isObject($cB)); + cCheck("and the clipboard has it", !GuiEditor.clipboard.isEmpty()); + + GuiEditor.Paste(); + %copy = cPasted(0); + + cCheck("the paste after a cut is a second step", cUndoCount() == 2); + cCheck("and it arrived", %copy.getParent() == $cPanel); + + // The original is in the trash, which is not the document - so its name is + // free and the pasted control can have it back. + cCheck("a cut and paste keeps the name (" @ %copy.getName() @ ")", + %copy.getName() $= "clipB"); + + GuiEditor.Undo(); + cCheck("undoing the paste leaves the cut", cUndoCount() == 1); + GuiEditor.Undo(); + cCheck("undoing the cut puts the original back", $cB.getParent() == $cPanel); + cCheck("at the index it came from", cIndexOf($cPanel, $cB) == %index); + + schedule(300, 0, "cStepFrames"); +} + +//----------------------------------------------------------------------------- +// A frame set, whose layout is not in its field list at all: the frame tree is +// written as TAML custom nodes and nothing else, so copying one is the case that +// proves the copy is a deep clone rather than a field copy. +//----------------------------------------------------------------------------- + +function cStepFrames() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + + $cFrames = new GuiFrameSetCtrl() + { + Position = "420 100"; + Extent = "400 200"; + DividerThickness = 4; + }; + GuiEditor.rootGui.add($cFrames); + + %ids = $cFrames.createHorizontalSplit(1); + %left = getWord(%ids, 0); + %right = getWord(%ids, 1); + $cFrames.createVerticalSplit(%left); + $cFrames.createVerticalSplit(%right); + + for(%i = 0; %i < 4; %i++) + { + %panel = new GuiControl() { Extent = "40 20"; }; + $cFrames.add(%panel); + } + + // Settle the layout before anything is measured: a frame set places its + // children when it resizes, and until then they sit at the bounds they were + // built with. + $cFrames.childrenReordered(); + + %sourceShape = cFrameShape($cFrames.getFrameLayout()); + cCheck("the frame set has a tree", %sourceShape !$= ""); + + cSelect($cFrames); + GuiEditor.Copy(); + GuiEditor.Paste(); + + %copy = cPasted(0); + cCheck("the copy holds four children", %copy.getCount() == 4); + + %copyShape = cFrameShape(%copy.getFrameLayout()); + cCheck("and the frame tree came with it" NL + " source: " @ %sourceShape NL + " copy: " @ %copyShape, %copyShape $= %sourceShape); + + // Each frame's control must be one of the COPY's children. The layout's + // eighth value per frame is the control id, so this reads them back out. + %layout = %copy.getFrameLayout(); + %ownChildren = true; + for(%i = 0; (%i + 7) < getWordCount(%layout); %i += 8) + { + %ctrl = getWord(%layout, %i + 7); + if(%ctrl != 0 && %ctrl.getParent() != %copy) + { + %ownChildren = false; + } + } + cCheck("holding the copy's own children, not the original's", %ownChildren); + + GuiEditor.Undo(); + cCheck("and the paste was one step", cUndoCount() == 0); + + schedule(300, 0, "cStepMenu"); +} + +//----------------------------------------------------------------------------- +// The Edit menu, which is what says whether any of this is available. +//----------------------------------------------------------------------------- + +function cStepMenu() +{ + %cutItem = cMenuItem(EditorCore.menuBar, "Cut"); + %copyItem = cMenuItem(EditorCore.menuBar, "Copy"); + %pasteItem = cMenuItem(EditorCore.menuBar, "Paste"); + + cCheck("the Edit menu has all three items", + isObject(%cutItem) && isObject(%copyItem) && isObject(%pasteItem)); + + cSelect($cA); + cCheck("Cut is offered with a selection", %cutItem.Active); + cCheck("so is Copy", %copyItem.Active); + + GuiEditor.brain.clearSelection(); + cCheck("Cut is greyed with nothing selected", !%cutItem.Active); + cCheck("so is Copy", !%copyItem.Active); + + cCheck("Paste is still offered - the clipboard is not empty", %pasteItem.Active); + + schedule(300, 0, "cStepStale"); +} + +//----------------------------------------------------------------------------- +// The clipboard holds live controls wearing live profiles, so it goes stale in +// the one place the undo stack does: when the theme library frees a profile. +//----------------------------------------------------------------------------- + +function cStepStale() +{ + %library = GuiEditor.getThemeLibrary(); + $cThemeB = %library.createTheme("ClipThemeB"); + cCheck("second theme created", isObject($cThemeB)); + + GuiEditor.setTheme($cThemeB, false); + + cSelect($cA); + GuiEditor.Copy(); + cCheck("something is on the clipboard", !GuiEditor.clipboard.isEmpty()); + + // Deleting the theme detaches the document from it first, and the copies in + // the clipboard hold the same profiles by raw pointer. + %library.deleteTheme($cThemeB); + cCheck("deleting a theme empties the clipboard", GuiEditor.clipboard.isEmpty()); + + %pasteItem = cMenuItem(EditorCore.menuBar, "Paste"); + cCheck("and greys Paste again", !%pasteItem.Active); + + GuiEditor.setTheme($cTheme, false); + GuiEditor.undoRecorder.clear(); + + schedule(300, 0, "cStepChain"); +} + +//----------------------------------------------------------------------------- +// Pasting into a container that places its own children. +// +// A GuiChainCtrl lays its children out in list order and takes a child's +// position when it arrives, which it is entitled to do: a control pasted into a +// chain belongs where the chain puts it. What must still hold is that the copy +// arrived, in the right container, as one undo step. +// +// Last, and on the real editor UI, because a chain only claims its children +// while the canvas is in edit mode - which needs the editor pushed onto the +// canvas rather than merely registered. +//----------------------------------------------------------------------------- + +function cStepChain() +{ + EditorCore.open(); + EditorCore.tabBook.selectPageName("Gui Editor"); + + schedule(500, 0, "cStepChainRun"); +} + +function cStepChainRun() +{ + GuiEditor.undoRecorder.clear(); + + $cChain = new GuiChainCtrl() + { + Position = "420 320"; + Extent = "200 120"; + IsVertical = true; + ChildSpacing = 4; + }; + GuiEditor.rootGui.add($cChain); + + for(%i = 0; %i < 3; %i++) + { + %button = new GuiButtonCtrl() { Extent = "80 24"; Text = "chain" @ %i; }; + $cChain.add(%button); + } + + %probe = $cChain.getObject(1); + cCheck("the editor is in edit mode (a chain lays out what it is given)", + getWord(%probe.getPosition(), 0) == 0); + + cSelect(%probe); + GuiEditor.Copy(); + + GuiEditor.brain.setCurrentAddSet($cChain); + GuiEditor.Paste(); + + %copy = cPasted(0); + cCheck("the paste is one step", cUndoCount() == 1); + cCheck("it arrived in the chain", %copy.getParent() == $cChain); + cCheck("the chain holds four now", $cChain.getCount() == 4); + cCheck("and the copy kept its caption", %copy.Text $= %probe.Text); + + GuiEditor.Undo(); + cCheck("undo took it back out", $cChain.getCount() == 3); + + schedule(300, 0, "cStepDuplicate"); +} + +//----------------------------------------------------------------------------- +// Duplicate, which is a copy that never touches the clipboard: it lands in the +// control's OWN parent, one grid step off, whatever container is currently being +// worked in and whatever is on the clipboard at the time. +//----------------------------------------------------------------------------- + +function cStepDuplicate() +{ + GuiEditor.undoRecorder.clear(); + %grid = GuiEditor.brain.getGridSize(); + + %before = $cPanel.getCount(); + %at = $cA.getPosition(); + + cSelect($cA); + GuiEditor.Duplicate(); + + %copy = cPasted(0); + cCheck("duplicate made one control", cPastedCount() == 1); + cCheck("in the same parent as the original", %copy.getParent() == $cPanel); + cCheck("which now holds one more", $cPanel.getCount() == %before + 1); + cCheck("the original is still there", $cA.getParent() == $cPanel); + + cCheck("the copy is one grid step across", + getWord(%copy.getPosition(), 0) == getWord(%at, 0) + %grid); + cCheck("and one down", + getWord(%copy.getPosition(), 1) == getWord(%at, 1) + %grid); + + cCheck("the copy counted on from the original's name", + %copy.getName() $= "clipA2"); + cCheck("and kept its caption", %copy.Text $= $cA.Text); + + cCheck("duplicate is one step", cUndoCount() == 1); + GuiEditor.Undo(); + cCheck("undo took the copy back out", $cPanel.getCount() == %before); + + schedule(300, 0, "cStepDuplicateClipboard"); +} + +// The whole reason it is not Ctrl+C, Ctrl+V: what is on the clipboard is still +// on the clipboard afterwards. +function cStepDuplicateClipboard() +{ + GuiEditor.undoRecorder.clear(); + + cSelect($cB); + GuiEditor.Copy(); + + cSelect($cA); + GuiEditor.Duplicate(); + + cCheck("the clipboard still holds something", !GuiEditor.clipboard.isEmpty()); + + GuiEditor.brain.setCurrentAddSet($cOther); + GuiEditor.Paste(); + + %pasted = cPasted(0); + cCheck("and what it holds is what was copied, not what was duplicated", + %pasted.Text $= $cB.Text); + + schedule(300, 0, "cStepDuplicateNested"); +} + +// A panel and a button inside it: the reduction that stops the button being +// duplicated twice is the same one copy uses. +function cStepDuplicateNested() +{ + GuiEditor.undoRecorder.clear(); + + %before = GuiEditor.rootGui.getCount(); + + GuiEditor.brain.clearSelection(); + GuiEditor.brain.addSelection($cPanel); + GuiEditor.brain.addSelection($cA); + + GuiEditor.Duplicate(); + + cCheck("the panel was duplicated once", cPastedCount() == 1); + cCheck("into the root", GuiEditor.rootGui.getCount() == %before + 1); + + %copy = cPasted(0); + cCheck("and the button came with it rather than separately", + %copy.getCount() == $cPanel.getCount()); + + cCheck("still one step", cUndoCount() == 1); + GuiEditor.Undo(); + cCheck("undo removed the whole thing", GuiEditor.rootGui.getCount() == %before); + + schedule(300, 0, "cStepMenuGreying"); +} + +// Both new items follow the selection, the way Cut and Copy beside them do. +// There is nothing to duplicate or delete when nothing is selected, and an item +// that stays lit is an item that lies about it. +function cStepMenuGreying() +{ + %duplicate = cMenuItem(EditorCore.menuBar, "Duplicate"); + %delete = cMenuItem(EditorCore.menuBar, "Delete"); + + cCheck("the Duplicate item exists", isObject(%duplicate)); + cCheck("the Delete item exists", isObject(%delete)); + + GuiEditor.brain.clearSelection(); + cCheck("Duplicate is greyed with nothing selected", !%duplicate.Active); + cCheck("Delete is greyed with nothing selected", !%delete.Active); + + cSelect($cA); + cCheck("Duplicate is offered once something is", %duplicate.Active); + cCheck("Delete is offered once something is", %delete.Active); + + schedule(300, 0, "cDone"); +} + +function cDone() +{ + echo("CLIP DONE"); + quit(); +} diff --git a/tests/smoke/cursorPane.cs b/tests/smoke/cursorPane.cs new file mode 100644 index 000000000..0a66eb533 --- /dev/null +++ b/tests/smoke/cursorPane.cs @@ -0,0 +1,260 @@ +// Cursor-pane smoke test. Drives the Gui Profile Editor's cursor support: the +// Cursors folder in the tree, the pane that replaces the other three when a +// 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. + +setLogMode(2); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function cCheck(%label, %cond) +{ + if(%cond) echo("CURSMOKE PASS: " @ %label); + else echo("CURSMOKE FAIL: " @ %label); +} + +testExec("editor/main.cs"); +schedule(2000, 0, "cStep1"); + +function cStep1() +{ + ProjectManager.setProjectFolder("cursorPaneSmokeProject"); + GuiEditor.open(); + GuiEditor.openProfileEditor(); + %d = GuiEditor.profileEditorDialog; + cCheck("dialog opened", isObject(%d)); + + %theme = %d.library.createTheme("CurSmoke"); + %d.tree.refresh(); + $curSmokeTheme = %theme; + + // --- The theme provides a cursor per category, with art of its own. --- + %categories = %theme.getCursorCategoryNames(); + cCheck("theme reports seven cursor categories", getWordCount(%categories) == 7); + + %cursor = %theme.getCursor("Default"); + cCheck("default cursor member exists", isObject(%cursor)); + cCheck("member is named for the theme", %cursor.getName() $= "CurSmokeDefaultCursor"); + cCheck("member carries its category", %cursor.category $= "Default"); + cCheck("art was seeded into the theme's own folder", + strstr(%cursor.bitmapName, "cursors/CurSmoke") >= 0); + cCheck("the art file is really there", + isFile(makeFullPath(%cursor.bitmapName, getMainDotCsDir()))); + cCheck("the tint came from the palette", %cursor.color $= %theme.colorForeground); + + // --- Selecting a cursor node brings the cursor pane, and only that. --- + %proxy = %d.library.cursorCategoryProxy[%theme.getId() @ "_Default"]; + cCheck("cursor category proxy built", isObject(%proxy)); + %d.onTreeSelect(%proxy); + + cCheck("cursor form shown", %d.cursorFormScroller.isVisible()); + cCheck("profile form hidden for a cursor", !%d.profileFormScroller.isVisible()); + cCheck("border form hidden for a cursor", !%d.borderFormScroller.isVisible()); + cCheck("theme form hidden for a cursor", !%d.formScroller.isVisible()); + cCheck("borders pane hidden for a cursor", !%d.bordersWindow.isVisible()); + cCheck("current member is the cursor", %d.currentMember == %cursor.getId()); + cCheck("pane bound to the cursor", %d.cursorForm.target == %cursor.getId()); + cCheck("magnifier bound to the cursor", %d.cursorForm.editor.cursor $= %cursor.getName()); + + // The two color buttons are buttons, not bars: a swatch spanning the whole + // row reads as a progress bar. Both are the same size as each other, which + // is what says they do the same kind of job. + cCheck("the tint swatch is button-shaped", + getWord(%d.cursorForm.row["color"].editor.getExtent(), 0) == $CursorForm::SwatchWidth); + cCheck("the marker swatch matches it", + getWord(%d.cursorForm.dotSwatch.getExtent(), 0) == $CursorForm::SwatchWidth); + // ...while a profile's state-color rows still fill their cell, where four + // swatches share the width and that is how you tell them apart. + %d.onTreeSelect(%d.library.categoryProxy[%theme.getId() @ "_Button"]); + cCheck("profile color rows still fill their cell", + getWord(%d.profileForm.fillRow.swatch[0].getExtent(), 0) > 40); + %d.onTreeSelect(%proxy); + + schedule(400, 0, "cStep2"); +} + +function cStep2() +{ + %d = GuiEditor.profileEditorDialog; + %theme = $curSmokeTheme; + %cursor = %theme.getCursor("Default"); + %editor = %d.cursorForm.editor; + + // --- The magnifier read the art, so it knows its real size. --- + %extent = %editor.getImageExtent(); + cCheck("magnifier measured the art", getWord(%extent, 0) == 13 && getWord(%extent, 1) == 17); + + // --- The try-it range lays out against the padded content, not the box. --- + // It wears a profile from the theme being edited, and a theme may give its + // panels any padding it likes, so nothing here may be positioned by number. + // These two hold whatever the padding is: the hint fills the content width, + // and the target sits in the middle of that same width. + %range = %d.preview.stage.getObject(0); + cCheck("the try-it range is on the stage", isObject(%range)); + %hint = %range.getObject(0); + %target = %range.getObject(1); + + %hintWidth = getWord(%hint.getExtent(), 0); + cCheck("the hint filled the content width", + %hintWidth > 0 && %hintWidth <= getWord(%range.getExtent(), 0)); + cCheck("the hint starts at the content's left edge", getWord(%hint.getPosition(), 0) == 0); + cCheck("the target is centred in that same width", + getWord(%target.getPosition(), 0) == ((%hintWidth - getWord(%target.getExtent(), 0)) / 2)); + + // --- The zoom never claims a magnification it is not drawing. --- + // A stock-sized cursor must be able to reach the full 16x: the pane is sized + // for it. Anything less means the view shrank and the ceiling came with it. + %max = %editor.getMaxZoom(); + cCheck("a 13x17 cursor reaches the full 16x", %max == 16); + %editor.setZoom(16); + cCheck("asking past the ceiling reports the ceiling, not the wish", + %editor.getZoom() == %max); + %d.cursorForm.refreshReadout(); + cCheck("the zoom label agrees with what is drawn", + %d.cursorForm.zoomLabel.getText() $= (%max @ "x")); + cCheck("zoom in is greyed at the ceiling", !%d.cursorForm.zoomIn.isActive()); + cCheck("zoom out is live at the ceiling", %d.cursorForm.zoomOut.isActive()); + + %editor.setZoom(1); + %d.cursorForm.refreshReadout(); + cCheck("zoom out is greyed at 1x", !%d.cursorForm.zoomOut.isActive()); + cCheck("zoom in is live at 1x", %d.cursorForm.zoomIn.isActive()); + %editor.setZoom(8); + + // The stock Default cursor: hot spot 1,1 with no anchor, so the pointer + // lands on pixel 1,1. + cCheck("effective hot spot combines both fields", %editor.getEffectiveHotSpot() $= "1 1"); + + // --- Anchoring is a fraction of the art, and the nudge absorbs it. --- + // Compared numerically: a Point2F reads back in the console's float format, + // not as the string it was written with. + %d.cursorForm.onAnchorPreset(0.5, 0.5); + cCheck("anchor preset wrote renderOffset", + getWord(%cursor.renderOffset, 0) == 0.5 && getWord(%cursor.renderOffset, 1) == 0.5); + // 13 * 0.5 truncates to 6, 17 * 0.5 to 8, and the nudge is still 1,1. + cCheck("anchor moved where the cursor points", %editor.getEffectiveHotSpot() $= "7 9"); + + %d.cursorForm.onAnchorPreset(0, 0); + cCheck("anchor cleared again", %editor.getEffectiveHotSpot() $= "1 1"); + + // The readout is a function of both placement fields, so a typed edit to + // 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"]); + 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"]); + + // --- 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"]); + 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")); + cCheck("hot spot is never an override", !%theme.isFieldOverridden(%cursor, "hotSpot")); + cCheck("art is never an override", !%theme.isFieldOverridden(%cursor, "bitmapName")); + cCheck("editing marked the theme dirty", %d.library.isDirty()); + + // A restamp must not undo the art, only re-derive the tint. + %theme.resetField(%cursor, "color"); + cCheck("resetting the tint re-derives it", %cursor.color $= %theme.colorForeground); + cCheck("the art survived the restamp", strstr(%cursor.bitmapName, "cursors/CurSmoke") >= 0); + + schedule(400, 0, "cStep3"); +} + +function cStep3() +{ + %d = GuiEditor.profileEditorDialog; + %theme = $curSmokeTheme; + + // --- An extra in a category: what makes the Gui Editor offer a choice. --- + %extra = %d.library.createExtraCursor(%theme, "Default"); + cCheck("extra cursor created", isObject(%extra)); + cCheck("extra sits in the same category", %extra.category $= "Default"); + cCheck("category now offers two cursors", getWordCount(%theme.getCursors("Default")) == 2); + cCheck("extra got art of its own", + %extra.bitmapName !$= %theme.getCursor("Default").bitmapName); + cCheck("the extra's art file exists", + isFile(makeFullPath(%extra.bitmapName, getMainDotCsDir()))); + + %leaf = %d.library.cursorExtraProxy[%extra.getId()]; + cCheck("extra has a tree leaf", isObject(%leaf)); + %d.onTreeSelect(%leaf); + cCheck("cursor pane binds an extra too", %d.cursorForm.target == %extra.getId()); + + // The two shared toolbar buttons describe what they are about to act on. + cCheck("remove tip names a cursor while one is selected", + %d.removeExtraTip() $= "Remove Extra Cursor"); + %d.onTreeSelect(%d.library.cursorCategoryProxy[%theme.getId() @ "_Default"]); + cCheck("new tip names a cursor in a cursor category", + %d.newInCategoryTip() $= "New Cursor in Category"); + %d.onTreeSelect(%d.library.categoryProxy[%theme.getId() @ "_Button"]); + cCheck("new tip still names a profile in a profile category", + %d.newInCategoryTip() $= "New Profile in Category"); + %d.onTreeSelect(%leaf); + + // --- Renaming the theme takes the members and the art folder with it. --- + cCheck("theme renamed", %d.library.renameThemeTo(%theme, "CurSmokeTwo")); + cCheck("members followed the rename", + %theme.getCursor("Default").getName() $= "CurSmokeTwoDefaultCursor"); + cCheck("art folder followed the rename", + strstr(%theme.getCursor("Default").bitmapName, "cursors/CurSmokeTwo") >= 0); + cCheck("the moved art file exists", + isFile(makeFullPath(%theme.getCursor("Default").bitmapName, getMainDotCsDir()))); + + // An extra's art is named after the member rather than the category, so a + // rename that moved only the stock files left it behind while its cursor + // pointed hopefully into the new folder. + cCheck("an extra's art followed the rename too", + strstr(%extra.bitmapName, "cursors/CurSmokeTwo") >= 0); + cCheck("and the extra's file is really there", + isFile(makeFullPath(%extra.bitmapName, getMainDotCsDir()))); + + // --- Removing the extra puts the category back to one, and offers to take + // its picture with it rather than doing so behind the user's back. --- + %art = %extra.bitmapName; + %artFile = makeFullPath(%art, getMainDotCsDir()); + %orphaned = %d.library.removeExtraCursor(%theme, %extra); + cCheck("extra removed", getWordCount(%theme.getCursors("Default")) == 1); + cCheck("its now-unused art is offered up, not deleted", %orphaned $= %artFile); + cCheck("the file is still there until someone says otherwise", isFile(%artFile)); + + // Saying yes only dooms it -- Cancel would still keep it, like every other + // file this editor removes. + %d.doomedCursorArt = %orphaned; + %d.doDeleteCursorArt(); + cCheck("confirming dooms the file", %d.library.isDirty()); + + // --- Art that is still in use is never offered. This is the case that + // would really have hurt: an extra pointed at the category's stock art, + // which the default member is also using. --- + %shared = %d.library.createExtraCursor(%theme, "Edit"); + %shared.bitmapName = %theme.getCursor("Edit").bitmapName; + %sharedFile = makeFullPath(%shared.bitmapName, getMainDotCsDir()); + %orphaned = %d.library.removeExtraCursor(%theme, %shared); + cCheck("shared art is not offered for deletion", %orphaned $= ""); + cCheck("and the file the default still uses survives", isFile(%sharedFile)); + + // --- Nor is art the user chose from somewhere else. --- + %outside = %d.library.createExtraCursor(%theme, "Move"); + %outside.bitmapName = "editor/EditorCore/Themes/BaseTheme/images/cursors/move.png"; + cCheck("art from outside the theme's folder is left alone", + %d.library.removeExtraCursor(%theme, %outside) $= ""); + + cCheck("a default cursor cannot be removed", + !%theme.removeCursor(%theme.getCursor("Default"))); + + echo("CURSMOKE DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/cursorSlots.cs b/tests/smoke/cursorSlots.cs new file mode 100644 index 000000000..3698b7bc9 --- /dev/null +++ b/tests/smoke/cursorSlots.cs @@ -0,0 +1,178 @@ +// Cursor-slot smoke test: the Gui Editor half of cursor support. +// +// A control's cursor fields follow the same rule as its secondary profile +// slots. Set Theme fills them without being asked, so a window is on its own +// theme's cursors from the moment it is dropped -- but a row only appears once +// 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. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function sCheck(%label, %cond) +{ + if(%cond) echo("CSSMOKE PASS: " @ %label); + else echo("CSSMOKE FAIL: " @ %label); +} + +function sPane() +{ + return GuiEditor.inspectorWindow.pane; +} + +function sRebind(%ctrl) +{ + %pane = sPane(); + %pane.bind(%ctrl); + return %pane; +} + +function sHasRow(%pane, %field) +{ + return isObject(%pane.row[%field]); +} + +testExec("editor/main.cs"); +schedule(2000, 0, "sStep1"); + +//----------------------------------------------------------------------------- +// Set Theme fills the cursor slots silently, and shows nothing for them. +//----------------------------------------------------------------------------- + +function sStep1() +{ + ProjectManager.setProjectFolder("cursorSlotsSmokeProject"); + GuiEditor.open(); + + // A window has three cursor slots; a frame set and a text edit have the + // others between them. + $sWindow = new GuiWindowCtrl(); + GuiEditor.rootGui.add($sWindow); + $sEdit = new GuiTextEditCtrl(); + GuiEditor.rootGui.add($sEdit); + + $sTheme = GuiEditor.themeLibrary.createTheme("CSSmoke"); + sCheck("theme created", isObject($sTheme)); + + GuiEditor.themeApplier.applyToBranch($sWindow, $sTheme, true); + GuiEditor.themeApplier.applyToBranch($sEdit, $sTheme, true); + GuiEditor.themeName = $sTheme.getName(); + + // Filled without being asked: the point is that a Gui wears ITS theme's + // cursors rather than whichever set the project installed globally. + sCheck("window took the theme's corner cursor", + $sWindow.nWSECursor $= $sTheme.getCursor("NWSE").getName()); + sCheck("window took the theme's horizontal cursor", + $sWindow.leftRightCursor $= $sTheme.getCursor("LeftRight").getName()); + sCheck("text edit took the theme's text cursor", + $sEdit.editCursor $= $sTheme.getCursor("Edit").getName()); + + // ...and says nothing about it, because there is nothing to choose. + %pane = sRebind($sWindow); + sCheck("no Variants section with one cursor per category", + !isObject(%pane.panel["Variants"])); + sCheck("no corner cursor row", !sHasRow(%pane, "nWSECursor")); + sCheck("no horizontal cursor row", !sHasRow(%pane, "leftRightCursor")); + + schedule(200, 0, "sStep2"); +} + +//----------------------------------------------------------------------------- +// A second cursor in one category: that slot, and only that slot, appears. +//----------------------------------------------------------------------------- + +function sStep2() +{ + $sExtra = GuiEditor.themeLibrary.createExtraCursor($sTheme, "NWSE"); + sCheck("extra NWSE cursor created", isObject($sExtra)); + sCheck("theme reports two NWSE cursors", + getWordCount($sTheme.getCursors("NWSE")) == 2); + + %pane = sRebind($sWindow); + sCheck("Variants section appeared", isObject(%pane.panel["Variants"])); + sCheck("corner cursor got a row", sHasRow(%pane, "nWSECursor")); + + // Only the category with a choice in it. + sCheck("horizontal cursor row still hidden", !sHasRow(%pane, "leftRightCursor")); + sCheck("vertical cursor row still hidden", !sHasRow(%pane, "upDownCursor")); + + %row = %pane.row["nWSECursor"]; + sCheck("row offers the default member", + %row.editor.findItemText($sTheme.getCursor("NWSE").getName(), false) >= 0); + sCheck("row offers the extra member", + %row.editor.findItemText($sExtra.getName(), false) >= 0); + sCheck("row shows what the control wears", + %row.getValue() $= $sTheme.getCursor("NWSE").getName()); + + // A text edit has no NWSE slot at all, so its pane is unaffected. + %editPane = sRebind($sEdit); + sCheck("text edit shows no cursor row", !sHasRow(%editPane, "editCursor")); + + schedule(200, 0, "sStep3"); +} + +//----------------------------------------------------------------------------- +// Choosing the extra writes it, and detaching the theme leaves nothing dangling. +//----------------------------------------------------------------------------- + +function sStep3() +{ + %pane = sRebind($sWindow); + %row = %pane.row["nWSECursor"]; + + %row.applyValue($sExtra.getName()); + %pane.onProfileRowCommit(%row); + sCheck("choosing the extra wrote it to the control", + $sWindow.nWSECursor $= $sExtra.getName()); + + // Re-applying the theme leaves a deliberate second choice alone: it already + // belongs to this theme, which is the whole test for "someone chose this". + GuiEditor.themeApplier.applyToBranch($sWindow, $sTheme, true); + sCheck("re-applying the theme keeps the chosen cursor", + $sWindow.nWSECursor $= $sExtra.getName()); + + // Detach moves every slot off the doomed theme, onto the canonical name for + // that slot -- not an empty string, which through TypeGuiCursor would land + // on DefaultCursor and put an arrow on a resize edge. + // + // Give two of the three names something to resolve to first, the way a + // project's AppCore does at boot. The editor on its own registers no + // cursors under them (its theme builds them anonymously), and the third is + // deliberately left unregistered to cover the other branch. + // + // editorMode off while naming: in editor mode assignName stashes a name + // instead of registering it, which is right for a control being authored + // and wrong for these. + editorMode(false); + $sCorner = new GuiCursor(); + $sCorner.setName("NWSECursor"); + $sHorizontal = new GuiCursor(); + $sHorizontal.setName("LeftRightCursor"); + editorMode(true); + + GuiEditor.detachTheme($sTheme, 0); + + sCheck("corner cursor detached to its canonical name", + $sWindow.nWSECursor $= "NWSECursor"); + sCheck("horizontal cursor detached to its canonical name", + $sWindow.leftRightCursor $= "LeftRightCursor"); + sCheck("nothing was left pointing at the theme", + $sWindow.nWSECursor !$= $sExtra.getName()); + + // The third had no canonical cursor to land on, so the field cleared. That + // is safe rather than broken: an empty cursor slot is what every untouched + // control has, and the engine re-resolves it by name the next time the + // pointer is over the control (guiTextEditCtrl.cc getCursor). + sCheck("a slot with no canonical cursor cleared instead of dangling", + $sEdit.editCursor $= ""); + + $sCorner.delete(); + $sHorizontal.delete(); + + echo("CSSMOKE DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/explorerDelete.cs b/tests/smoke/explorerDelete.cs new file mode 100644 index 000000000..00d00c452 --- /dev/null +++ b/tests/smoke/explorerDelete.cs @@ -0,0 +1,188 @@ +//----------------------------------------------------------------------------- +// Deleting a control, from each of the three places a person can ask for it, +// and the one thing that has to be true afterwards: the Explorer tree shows the +// document as it now is. +// +// The routes are not symmetrical, which is how the tree came to go stale. Two of +// them (the Delete key on the canvas, and Cut) delete and then announce it, and +// the Explorer window is listening. The third is the Delete key while the tree +// itself holds first responder: the TREE announces, the brain does the deleting +// -- and postEvent does not deliver to the object that posted, so the window +// that owns the tree never heard that anything had gone. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function edCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("EDEL PASS: " @ %label); + } + else + { + $Fail++; + echo("EDEL FAIL: " @ %label); + } +} + +schedule(2000, 0, "edSetup"); + +function edSetup() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + GuiEditor.open(); + schedule(500, 0, "edCanvasKey"); +} + +//----------------------------------------------------------------------------- +// Helpers. +//----------------------------------------------------------------------------- + +// One control on the canvas, selected, with the tree showing it. +function edPlace() +{ + %tile = edFindTile("GuiButtonCtrl"); + %tile.onClick(); + + %ctrl = GuiEditor.rootGui.getObject(GuiEditor.rootGui.getCount() - 1); + GuiEditor.brain.onInspect(%ctrl); + + return %ctrl; +} + +// What every route has to leave behind: the control gone from the document, and +// gone from the tree as well. +function edGone(%label, %ctrl, %items) +{ + %tree = GuiEditor.explorerWindow.tree; + + edCheck(%label @ ": the control left the document", + %ctrl.getGroup() != GuiEditor.rootGui); + edCheck(%label @ ": the tree no longer lists it", + %tree.findItemID(%ctrl) == -1); + edCheck(%label @ ": the tree lost a row (" @ %items @ " -> " @ + %tree.getItemCount() @ ")", %tree.getItemCount() == %items - 1); +} + +//----------------------------------------------------------------------------- +// The Delete key with the canvas in charge. The C++ deletes and then calls +// onDelete, which is the announcement. +//----------------------------------------------------------------------------- + +function edCanvasKey() +{ + %tree = GuiEditor.explorerWindow.tree; + %ctrl = edPlace(); + + edCheck("the tree lists a control that was just placed", %tree.findItemID(%ctrl) != -1); + %items = %tree.getItemCount(); + + GuiEditor.brain.deleteSelection(); + GuiEditor.brain.onDelete(); + edGone("canvas Delete", %ctrl, %items); + + schedule(100, 0, "edCut"); +} + +//----------------------------------------------------------------------------- +// Cut, which is a copy and then that same delete. +//----------------------------------------------------------------------------- + +function edCut() +{ + %tree = GuiEditor.explorerWindow.tree; + %ctrl = edPlace(); + %items = %tree.getItemCount(); + + GuiEditor.Cut(); + edGone("Cut", %ctrl, %items); + + schedule(100, 0, "edTreeKey"); +} + +//----------------------------------------------------------------------------- +// The Delete key while the tree holds first responder, which is what happens +// after clicking a row. GuiListBoxCtrl::onKeyDown calls onDeleteKey. +//----------------------------------------------------------------------------- + +function edTreeKey() +{ + %tree = GuiEditor.explorerWindow.tree; + %ctrl = edPlace(); + %items = %tree.getItemCount(); + + %index = %tree.findItemID(%ctrl); + edCheck("the tree can find the row to delete", %index != -1); + + %tree.onDeleteKey(%index, %tree.getItemText(%index), %ctrl); + edGone("tree Delete", %ctrl, %items); + + // A delete is one undo step whichever route asked for it, and undoing it has + // to put the row back -- the same refresh, the other way round. + GuiEditor.Undo(); + edCheck("undo brought the control back", %ctrl.getGroup() == GuiEditor.rootGui); + edCheck("and the tree lists it again", %tree.findItemID(%ctrl) != -1); + + schedule(100, 0, "edMenu"); +} + +//----------------------------------------------------------------------------- +// The Edit menu's Delete, which is the route that does not need the right thing +// to hold first responder first -- and the only one that says out loud that the +// command exists. +// +// DeleteSelection, NOT Delete: delete is a console method on every SimObject, so +// GuiEditor.Delete() destroys the editor. It does it quietly, too -- the object +// goes, and the next line to touch GuiEditor is the one that reports an error. +//----------------------------------------------------------------------------- + +function edMenu() +{ + %tree = GuiEditor.explorerWindow.tree; + %ctrl = edPlace(); + %items = %tree.getItemCount(); + + GuiEditor.DeleteSelection(); + edGone("menu Delete", %ctrl, %items); + + // Cut is this with a copy in front of it, and says so by calling it. + GuiEditor.Undo(); + edCheck("undo brought it back once more", %ctrl.getGroup() == GuiEditor.rootGui); + + echo("EDEL DONE " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} + +function edFindTile(%key) +{ + %window = GuiEditor.ctrlListWindow; + for(%g = 0; %g < %window.groupCount; %g++) + { + %group = %window.group[%g]; + for(%i = 0; %i < %group.tileCount; %i++) + { + if(%group.tile[%i].key $= %key) + { + return %group.tile[%i]; + } + } + } + return 0; +} diff --git a/tests/smoke/explorerGutter.cs b/tests/smoke/explorerGutter.cs new file mode 100644 index 000000000..4029af721 --- /dev/null +++ b/tests/smoke/explorerGutter.cs @@ -0,0 +1,255 @@ +//----------------------------------------------------------------------------- +// The Explorer tree's two columns of editor state -- the eye and the padlock. +// +// The geometry is unit tested (guiTreeRowLayoutTests.cc): where the columns are, +// which one an x falls in, where the box sits in a cell. None of that needs a +// canvas and none of it is repeated here. +// +// What IS here is the part no unit test can reach. Adding a row to a tree loads +// a font, which registers a texture, which asserts with no GL context -- so +// anything involving real rows has to run in a real engine. And the central +// promise of the feature is a negative: clicking a box toggles the flag and does +// NOT change the selection. Calling the toggle directly would skip the very code +// that could break that, so the click itself is posted for real, by +// explorerGutter.input.ps1. +//----------------------------------------------------------------------------- + +setLogMode(1); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function gutCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("EGUT PASS: " @ %label); + } + else + { + $Fail++; + echo("EGUT FAIL: " @ %label); + } +} + +// The engine works out where a box actually landed and leaves the point for the +// PowerShell side to click. A hard-coded coordinate that drifted off the box +// would report a missing item, which is exactly what a broken hit test reports. +function gutPostTarget(%point) +{ + createPath(testRoot("shots/")); + %file = new FileObject(); + %file.openForWrite(testRoot("shots/explorerGutterTarget.txt")); + %file.writeLine(%point); + %file.close(); + %file.delete(); +} + +schedule(2500, 0, "gutOpenProject"); + +// The long way in, and it has to be. GuiEditor.open() is enough for a suite that +// only calls methods, but it does not put the editor on the canvas -- and a +// posted click goes to whatever is actually on screen. Clicks against an editor +// opened the short way land on the project selector and do nothing at all, which +// reads exactly like a broken hit test. +function gutOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "gutOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function gutOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "gutClear"); +} + +// Start from an empty Gui, in a step of its own: PlanetX finishes displaying +// its own Gui after the editor page comes up, so clearing in the same frame as +// the build leaves its controls in the tree and moves every row. +function gutClear() +{ + GuiEditor.NewGui(); + + // In case PlanetX's Gui arrived by a route that counts as an edit. It does + // not today, and a clean document is taken away without asking, so this is + // usually a no-op -- but an unanswered prompt would leave the tree showing + // the Gui this step exists to clear. + discardUnsavedPrompt(); + + schedule(500, 0, "gutRun"); +} + +function gutRun() +{ + %tree = GuiEditor.explorerWindow.tree; + + // --- The class swap itself. Both halves matter: GuiEditor.cs and the theme + // applier both branch on isMemberOfClass("GuiTreeViewCtrl"), so a tree that + // stopped being one would break things nowhere near here. + gutCheck("the Explorer wears the editor tree class", + %tree.getClassName() $= "GuiEditorExplorerTree"); + gutCheck("and is still a tree view", %tree.isMemberOfClass("GuiTreeViewCtrl")); + + // --- Editor-only: the palette must refuse it, and BOTH copies of that rule + // have to agree. They are separately maintained -- one generated, one typed + // -- and this is the assertion that catches them drifting apart. + gutCheck("the palette refuses the editor tree", + !GuiEditor.controlIcons.isPlaceableClass("GuiEditorExplorerTree")); + gutCheck("and the spec's copy of the rule agrees", + !GuiEditor.inspectorWindow.pane.spec.isPlaceableClass("GuiEditorExplorerTree")); + + // --- The columns answer where they say they do. gutterColumnAt is the same + // function the paint uses, so this is the contract between them. + %w = %tree.getGutterColumnWidth(); + gutCheck("a column is a sensible width", %w > 0 && %w < 64); + gutCheck("the first column is the eye", %tree.gutterColumnAt(0) $= "hidden"); + gutCheck("the second is the padlock", %tree.gutterColumnAt(%w) $= "locked"); + gutCheck("past both is the tree itself", %tree.gutterColumnAt(2 * %w) $= ""); + + // --- Now a real control to toggle. + %ctrl = new GuiButtonCtrl(); + GuiEditor.rootGui.add(%ctrl); + %ctrl.setInternalName("gutTarget"); + %theme = GuiEditor.themeByName(GuiEditor.themeName); + if(isObject(%theme)) + { + GuiEditor.themeApplier.applyToBranch(%ctrl, %theme, false); + } + %tree.refresh(); + + $gutCtrl = %ctrl; + $gutIndex = %tree.findItemID(%ctrl.getId()); + gutCheck("the control has a row", $gutIndex > 0); + + gutCheck("it starts shown", !%ctrl.hidden); + gutCheck("and unlocked", !%ctrl.locked); + + // --- Select something ELSE, so "the click did not change the selection" is + // a claim with something to lose. Selecting the target itself would pass + // whether the click preserved the selection or set it. + %tree.clearSelection(); + %tree.setSelected(0, true); + $gutSelected = %tree.getSelectedItem(); + gutCheck("something else is selected to start with", $gutSelected != $gutIndex); + + echo("EGUT: tree extent " @ %tree.getExtent() @ ", " @ %tree.getItemCount() @ " rows"); + + // --- The row icons. The frame is cached on the row when the tree builds, so + // what is checked is that the cache holds the right answer and that it is + // re-asked when the answer changes. + %icons = GuiEditor.controlIcons; + gutCheck("the tree draws from the 16px sheet", + %tree.IconImage $= "GuiEditor:controlIcons16"); + gutCheck("the root row wears no picture", %tree.getItemIcon(0) == -1); + gutCheck("a button row wears the button", + %tree.getItemIcon($gutIndex) == %icons.frameFor("GuiButtonCtrl")); + + // A bare GuiControl is four palette entries sharing one class, told apart by + // the category it wears -- so the class alone cannot pick its picture. This + // is the case keyFor exists for. + %bare = new GuiControl(); + GuiEditor.rootGui.add(%bare); + %bare.setInternalName("gutBare"); + if(isObject(%theme)) + { + GuiEditor.themeApplier.applyToBranch(%bare, %theme, false); + } + %tree.refresh(); + + %bareIndex = %tree.findItemID(%bare.getId()); + %pane = GuiEditor.inspectorWindow.pane; + %wasCategory = %pane.currentCategory(%bare); + gutCheck("a bare GuiControl wears the face for its category", + %tree.getItemIcon(%bareIndex) == + %icons.frameFor("GuiControl:" @ %wasCategory)); + + // Change the face, and the row must follow. This is the invalidation path: + // the Category picker rewrites the Profile, which commits, which posts + // PostApply, which is what re-asks this one row. + %newCategory = (%wasCategory $= "Label") ? "Panel" : "Label"; + %pane.bind(%bare); + %pane.setCategory(%newCategory); + gutCheck("changing the category changed the row's picture", + %tree.getItemIcon(%tree.findItemID(%bare.getId())) == + %icons.frameFor("GuiControl:" @ %newCategory)); + gutCheck("and it really is a different picture", + %icons.frameFor("GuiControl:" @ %newCategory) != + %icons.frameFor("GuiControl:" @ %wasCategory)); + + %bare.delete(); + %tree.refresh(); + $gutIndex = %tree.findItemID($gutCtrl.getId()); + %tree.clearSelection(); + %tree.setSelected(0, true); + $gutSelected = %tree.getSelectedItem(); + + // Hand the eye box's center to the PowerShell side. + gutPostTarget(%tree.getGutterPoint($gutIndex, "hidden")); + echo("EGUT: eye box of row " @ $gutIndex @ " at " @ + %tree.getGutterPoint($gutIndex, "hidden")); + + schedule(9000, 0, "gutAfterEyeClick"); +} + +function gutAfterEyeClick() +{ + %tree = GuiEditor.explorerWindow.tree; + + // --- The whole point of the feature. + gutCheck("clicking the eye hid the control", $gutCtrl.hidden); + gutCheck("and did not move the selection", %tree.getSelectedItem() == $gutSelected); + gutCheck("and did not select the row it was on", %tree.getSelCount() == 1); + + // Now the padlock on the same row, which also proves the two columns are + // told apart by a real click and not just by gutterColumnAt. + gutPostTarget(%tree.getGutterPoint($gutIndex, "locked")); + echo("EGUT: padlock box of row " @ $gutIndex @ " at " @ + %tree.getGutterPoint($gutIndex, "locked")); + + schedule(9000, 0, "gutAfterLockClick"); +} + +function gutAfterLockClick() +{ + %tree = GuiEditor.explorerWindow.tree; + + gutCheck("clicking the padlock locked the control", $gutCtrl.locked); + gutCheck("and left the eye alone", $gutCtrl.hidden); + gutCheck("and still did not move the selection", + %tree.getSelectedItem() == $gutSelected); + + // And a click on the row's TEXT must still select, or the columns have + // swallowed the tree. + %point = %tree.getGutterPoint($gutIndex, "hidden"); + %textPoint = (getWord(%point, 0) + (3 * %tree.getGutterColumnWidth())) SPC getWord(%point, 1); + gutPostTarget(%textPoint); + echo("EGUT: row text at " @ %textPoint); + + schedule(9000, 0, "gutAfterTextClick"); +} + +function gutAfterTextClick() +{ + %tree = GuiEditor.explorerWindow.tree; + + gutCheck("clicking the row still selects it", %tree.getSelectedItem() == $gutIndex); + gutCheck("and selecting did not toggle anything", $gutCtrl.hidden && $gutCtrl.locked); + + echo("EGUT RESULT: " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} diff --git a/tests/smoke/explorerGutter.input.ps1 b/tests/smoke/explorerGutter.input.ps1 new file mode 100644 index 000000000..a4b5c5bf1 --- /dev/null +++ b/tests/smoke/explorerGutter.input.ps1 @@ -0,0 +1,52 @@ +# Input for explorerGutter.cs. Posts three real clicks: the eye box of a row, +# then the padlock box of the same row, then the row's text. +# +# None of the three points is written here. Where a box lands depends on the +# theme's borders and on how tall the rows turned out, so the engine works it out +# with getGutterPoint and leaves it in a file for this script to pick up. A +# hard-coded point that drifted off the box would report a control that never +# toggled - which is precisely 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 that a press in a column +# toggles a flag WITHOUT selecting the row, and everything that could break that +# hangs off GuiControl's touch path: first responder, handleItemClick, the click +# callbacks, the reorder drag. Calling the toggle from script would skip all of +# it and prove nothing. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +$target = Join-Path $PSScriptRoot "..\..\shots\explorerGutterTarget.txt" + +# Anything left by an earlier run would be clicked before this run has even +# built its tree. +if (Test-Path $target) { Remove-Item $target -Force } + +function Wait-ForTarget { + param([string]$Path, [int]$Seconds = 20) + + $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 = @('the eye box', 'the padlock box', 'the row text') +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/frameSet.cs b/tests/smoke/frameSet.cs new file mode 100644 index 000000000..249d98d77 --- /dev/null +++ b/tests/smoke/frameSet.cs @@ -0,0 +1,170 @@ +//----------------------------------------------------------------------------- +// GuiFrameSetCtrl persistence. +// +// A frame set is the one container that keeps a layout of its own beside its +// child list, and none of it is a persist field: the split tree is written as +// TAML custom nodes and nothing else. So it is the one control whose file can +// look complete and load back as something else entirely - every frame merged +// into one, with the children piled into it - and until now nothing tested that. +// +// The failure it is shaped around: TAML matches custom-node field names by +// StringTable pointer, so a name that stops matching takes every value in a +// frame node with it - loadTamlFrame gives up on a frame of extent zero and the +// tree comes back flat, with nothing but a warnf to say so. That is what the +// value-count checks below are for. +// +// It cannot be provoked on demand. Whether a name matches depends on which +// spelling of it reached the string table first, which depends on static +// initialisation order across translation units - so the same source can pass +// one build and fail the next. GuiListBoxCtrl's Items nodes did exactly that; +// the frame set has so far been lucky. Both now intern case-insensitively, which +// is what removes the dependence. This test does not prove that fix on any given +// 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. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +function fsCheck(%label, %condition) +{ + echo(%condition ? ("FRAMESET PASS: " @ %label) : ("FRAMESET FAIL: " @ %label)); +} + +function fsScratch() +{ + return testRoot("shots/frameSetScratch"); +} + +function fsReadFile(%file) +{ + %fo = new FileObject(); + if(!%fo.openForRead(%file)) + { + %fo.delete(); + return ""; + } + + %text = ""; + while(!%fo.isEOF()) + { + %text = %text @ %fo.readLine() @ " "; + } + %fo.close(); + %fo.delete(); + + return %text; +} + +// A frame layout with the control ids taken out. Every eighth value is the id of +// the control standing in that frame, which is a different number in a Gui that +// has been read back off disk - so it is the only part of the layout that cannot +// be compared, and the only part that says nothing about the tree's shape. +function fsShape(%layout) +{ + %out = ""; + %count = getWordCount(%layout); + for(%i = 0; %i < %count; %i++) + { + if((%i % 8) == 7) + { + continue; + } + %out = (%out $= "") ? getWord(%layout, %i) : (%out SPC getWord(%layout, %i)); + } + + return %out; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "fsStep1"); + +function fsStep1() +{ + createPath(fsScratch() @ "/"); + + %frames = new GuiFrameSetCtrl() + { + Extent = "400 200"; + }; + + // One split, and a control in each half. A frame takes the control as it + // arrives (onChildAdded fills an empty frame; it never makes one), so the + // split has to come first. + %ids = %frames.createHorizontalSplit(1); + %frames.setFrameSize(getWord(%ids, 0), 150); + + %left = new GuiButtonCtrl() { Text = "Left"; }; + %frames.add(%left); + %right = new GuiButtonCtrl() { Text = "Right"; }; + %frames.add(%right); + + %before = %frames.getFrameLayout(); + fsCheck("a split frame set is three frames (" @ getWordCount(%before) @ " values)", + getWordCount(%before) == 24); + fsCheck("both controls are in it", %frames.getCount() == 2); + + %file = pathConcat(fsScratch(), "frames.gui.taml"); + TAMLWrite(%frames, %file); + + // Lower-cased, because which capitalisation an attribute is written in is not + // the writer's to decide - StringTable hands back the first spelling of a name + // it was ever given. + %text = strlwr(fsReadFile(%file)); + fsCheck("the file carries a Frames section", strstr(%text, "guiframesetctrl.frames") != -1); + fsCheck("with frame nodes in it", strstr(%text, "= 0; %i--) + { + %obj = Canvas.getObject(%i); + if(%obj.class $= %class) + { + return %obj; + } + } + + return 0; +} + +function gsNudgeBy(%ctrl) +{ + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select(%ctrl); + + %before = getWord(%ctrl.getPosition(), 0); + GuiEditor.brain.moveSelection(1, 0); + + return getWord(%ctrl.getPosition(), 0) - %before; +} + +schedule(2000, 0, "gsSetup"); + +function gsSetup() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + GuiEditor.open(); + + // Off a grid line to start with, so a snapped nudge and an unsnapped one + // cannot land on the same number by luck. + $gsCtrl = new GuiButtonCtrl() { Position = "3 3"; Extent = "80 30"; Text = "A"; }; + GuiEditor.rootGui.add($gsCtrl); + + schedule(300, 0, "gsStepDialog"); +} + +//----------------------------------------------------------------------------- +// Setting the size, through the dialog the menu opens. +//----------------------------------------------------------------------------- + +function gsStepDialog() +{ + GuiEditor.SetGridSize(); + + %dialog = gsDialog("GuiEditorGridSizeDialog"); + gsCheck("the Set Grid Size dialog opened", isObject(%dialog)); + gsCheck("and it starts on the current size", + %dialog.gridSizeBox.getText() == GuiEditor.brain.getGridSize()); + + %dialog.gridSizeBox.setText("25"); + %dialog.onDone(); + + gsCheck("the dialog set the grid size", GuiEditor.brain.getGridSize() == 25); + gsCheck("and a nudge snaps to it", gsNudgeBy($gsCtrl) == 22); + + schedule(300, 0, "gsStepToggle"); +} + +//----------------------------------------------------------------------------- +// Toggling it off and on, which is the Layout menu's Snap to Grid item. +//----------------------------------------------------------------------------- + +function gsStepToggle() +{ + $gsCtrl.setPosition(3, 3); + + GuiEditor.SnapToGrid(false); + gsCheck("snap off moves by the pixel", gsNudgeBy($gsCtrl) == 1); + gsCheck("and the size is remembered while it is off", + GuiEditor.brain.getGridSize() == 25); + + $gsCtrl.setPosition(3, 3); + + GuiEditor.SnapToGrid(true); + gsCheck("the size survived the round trip", GuiEditor.brain.getGridSize() == 25); + gsCheck("and snap is on again", gsNudgeBy($gsCtrl) == 22); + + schedule(300, 0, "gsDone"); +} + +function gsDone() +{ + echo("GRID DONE"); + quit(); +} diff --git a/tests/smoke/hiddenClickThrough.cs b/tests/smoke/hiddenClickThrough.cs new file mode 100644 index 000000000..b26b6638d --- /dev/null +++ b/tests/smoke/hiddenClickThrough.cs @@ -0,0 +1,186 @@ +//----------------------------------------------------------------------------- +// Clicking through a control the Gui Editor's eye has hidden. +// +// The rule itself is unit tested (guiHitTestTests.cc) against a GuiEditCtrl the +// test builds and points the statics at. What that cannot check is the wiring: +// that the editor as it really boots has the Gui under edit inside its edit +// root, so isEditMode is true for the controls on the canvas and the eye means +// anything at all. Get that wrong and every unit test still passes while the +// feature does nothing. +// +// So this asks the same question of the real editor, through the same entry +// point the mouse uses -- GuiControl::findHitControl, via its script binding. +// The unhidden answers are asserted first: they are what make the hidden ones +// mean something rather than just meaning the coordinates were wrong. +//----------------------------------------------------------------------------- + +setLogMode(1); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function hctCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("HCT PASS: " @ %label); + } + else + { + $Fail++; + echo("HCT FAIL: " @ %label); + } +} + +schedule(2500, 0, "hctOpenProject"); + +// The long way in. GuiEditor.open() would be enough for a suite that only calls +// methods on it, but the thing under test is whether the Gui being edited is +// really inside the editor's edit root -- so the editor has to be brought up the +// way it comes up for a person. +function hctOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "hctOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function hctOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "hctClear"); +} + +// Start from an empty Gui, in a step of its own: PlanetX finishes displaying its +// own Gui after the editor page comes up, so clearing in the same frame as the +// build leaves its controls on the canvas and in the way of every point below. +function hctClear() +{ + GuiEditor.NewGui(); + discardUnsavedPrompt(); + schedule(500, 0, "hctBuild"); +} + +// One back panel, a smaller front panel on top of it, and a control deeper still +// inside the front one. Later children are drawn last and hit first, so "front" +// really is in front. +// +// rootGui 0,0 the simulated canvas +// back 100,100 400x300 -> 100..499, 100..399 +// front 200,150 200x100 -> 200..399, 150..249 +// deep 20,20 60x40 -> 220..279, 170..209 in rootGui's space +// +// Sizing is nailed down rather than left to the default, because a mode that +// computes its position from the parent would move these out from under the +// points below the moment they were added. +function hctBuild() +{ + %root = GuiEditor.rootGui; + + $hctBack = new GuiControl() + { + Position = "100 100"; + Extent = "400 300"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + %root.add($hctBack); + + $hctFront = new GuiControl() + { + Position = "200 150"; + Extent = "200 100"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + %root.add($hctFront); + + $hctDeep = new GuiControl() + { + Position = "20 20"; + Extent = "60 40"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + $hctFront.add($hctDeep); + + // A frame for the canvas to lay everything out and render once, so the + // render insets findHitControl subtracts are the real ones. + schedule(500, 0, "hctRun"); +} + +// Over front, clear of deep. +function hctOverFront() +{ + return "380 240"; +} + +// Over deep, and so over front and back as well. +function hctOverDeep() +{ + return "250 190"; +} + +function hctHit(%point) +{ + return GuiEditor.rootGui.findHitControl(getWord(%point, 0), getWord(%point, 1)); +} + +function hctRun() +{ + // --- The baseline. If these fail, nothing below is about hiding. + hctCheck("the front panel is hit where it covers the back one", + hctHit(hctOverFront()) == $hctFront.getId()); + hctCheck("and its child is hit where the child is", + hctHit(hctOverDeep()) == $hctDeep.getId()); + hctCheck("the back panel is hit where nothing covers it", + hctHit("120 120") == $hctBack.getId()); + + // --- The whole point. + $hctFront.hidden = true; + + hctCheck("hiding the front panel lets the click reach the back one", + hctHit(hctOverFront()) == $hctBack.getId()); + hctCheck("and takes its children with it", + hctHit(hctOverDeep()) == $hctBack.getId()); + + // --- It is out of the way, not gone: the Explorer is the only way back. + %tree = GuiEditor.explorerWindow.tree; + %tree.refresh(); + hctCheck("a hidden control still has a row in the Explorer", + %tree.findItemID($hctFront.getId()) > 0); + hctCheck("and still answers as hidden", + $hctFront.hidden); + + // --- And back. + $hctFront.hidden = false; + + hctCheck("showing it again makes it a target again", + hctHit(hctOverFront()) == $hctFront.getId()); + hctCheck("and its child with it", + hctHit(hctOverDeep()) == $hctDeep.getId()); + + // --- One control, not the branch it sits in. + $hctDeep.hidden = true; + + hctCheck("hiding a child leaves its parent hit-testable", + hctHit(hctOverDeep()) == $hctFront.getId()); + hctCheck("and the parent is still hit where it always was", + hctHit(hctOverFront()) == $hctFront.getId()); + + echo("HCT RESULT: " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} diff --git a/tests/smoke/hiddenNotATarget.cs b/tests/smoke/hiddenNotATarget.cs new file mode 100644 index 000000000..79b358f51 --- /dev/null +++ b/tests/smoke/hiddenNotATarget.cs @@ -0,0 +1,261 @@ +//----------------------------------------------------------------------------- +// The three ways a hidden control could still be picked up, none of which goes +// through findHitControl. +// +// hiddenClickThrough.cs asks findHitControl directly, which is the rule itself. +// These two get in front of it: +// +// 1. The sizing knobs. GuiEditCtrl::onTouchDown tests them BEFORE it hit tests +// anything, and clicking the eye deliberately does not move the selection -- +// so a control hidden while selected would keep eight invisible grab zones +// straddling its edges, which is exactly where someone aiming at what is +// behind it would click. editGeometryFrozen is what closes that. +// +// 2. The container being worked in. It is where a palette click places the next +// control, and it is not chosen by hit testing at all, so hiding it would +// leave the next control placed inside a hidden parent, appearing nowhere. +// GuiEditCtrl::controlHidden is what closes that. +// +// 3. The rubber band. It walks the children of the container being worked in +// rather than hit testing them, so it would sweep up a control that is not +// drawn along with the ones that are. +// +// All three need real input: the knob test lives in the middle of the touch +// path, only the Explorer's gutter click calls controlHidden, and a band needs a +// press, a run of moves and a release. Driving any of them from script would +// skip the code under test. hiddenNotATarget.input.ps1 posts them. +//----------------------------------------------------------------------------- + +setLogMode(1); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function hntCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("HNT PASS: " @ %label); + } + else + { + $Fail++; + echo("HNT FAIL: " @ %label); + } +} + +// The engine works out where to click and leaves the point for the PowerShell +// side, exactly as explorerGutter.cs does: a hard-coded coordinate that drifted +// off its target would report a control that never reacted, which is what a +// broken hit test reports too. +function hntPostTarget(%point) +{ + createPath(testRoot("shots/")); + %file = new FileObject(); + %file.openForWrite(testRoot("shots/hiddenNotATargetTarget.txt")); + %file.writeLine(%point); + %file.close(); + %file.delete(); +} + +schedule(2500, 0, "hntOpenProject"); + +function hntOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "hntOpenEditor"); +} + +// Pages register in load order: EditorConsole, ProjectManager, AssetAdmin, +// GuiEditor. +function hntOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "hntClear"); +} + +function hntClear() +{ + GuiEditor.NewGui(); + discardUnsavedPrompt(); + schedule(500, 0, "hntBuild"); +} + +// rootGui the simulated canvas +// back 100,100 400x300 +// front 200,150 200x100 -- selected, then hidden +// +// front's top left corner sits well inside back, so the click below is both on +// front's top-left sizing knob and over back. Before the fix it resized front. +function hntBuild() +{ + %root = GuiEditor.rootGui; + + $hntBack = new GuiControl() + { + Position = "100 100"; + Extent = "400 300"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + %root.add($hntBack); + + $hntFront = new GuiControl() + { + Position = "200 150"; + Extent = "200 100"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + %root.add($hntFront); + + // A frame to lay out and render, so the global positions read below are the + // ones the mouse would land on. + schedule(500, 0, "hntKnobs"); +} + +function hntKnobs() +{ + GuiEditor.brain.select($hntFront); + $hntFront.hidden = true; + + %sel = GuiEditor.brain.getSelected(); + hntCheck("the hidden control is the one selected", + %sel.getCount() == 1 && %sel.getObject(0) == $hntFront.getId()); + + echo("HNT: canvas at " @ GuiEditor.rootGui.getGlobalPosition() @ + " extent " @ GuiEditor.rootGui.getExtent()); + echo("HNT: front's top-left knob at " @ $hntFront.getGlobalPosition()); + + // Its top-left knob: dead centre of the eight, and the one furthest from + // anything else that could claim the press. + hntPostTarget($hntFront.getGlobalPosition()); + + schedule(9000, 0, "hntAfterKnobClick"); +} + +function hntAfterKnobClick() +{ + %sel = GuiEditor.brain.getSelected(); + + hntCheck("clicking a hidden control's sizing knob selects what is behind it", + %sel.getCount() == 1 && %sel.getObject(0) == $hntBack.getId()); + hntCheck("and did not resize the hidden control", + $hntFront.getExtent() $= "200 100"); + hntCheck("nor move it", $hntFront.getPosition() $= "200 150"); + + schedule(200, 0, "hntAddSet"); +} + +// Now the container being worked in. Hiding it has to put the add set back +// somewhere that is still drawn, or the next control placed lands inside it. +function hntAddSet() +{ + $hntBox = new GuiControl() + { + Position = "520 100"; + Extent = "150 150"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + GuiEditor.rootGui.add($hntBox); + + %tree = GuiEditor.explorerWindow.tree; + %tree.refresh(); + + GuiEditor.brain.setCurrentAddSet($hntBox); + hntCheck("the container being worked in is the one about to be hidden", + GuiEditor.brain.getCurrentAddSet() == $hntBox.getId()); + + $hntBoxIndex = %tree.findItemID($hntBox.getId()); + hntCheck("it has a row in the Explorer", $hntBoxIndex > 0); + + echo("HNT: its eye box at " @ %tree.getGutterPoint($hntBoxIndex, "hidden")); + hntPostTarget(%tree.getGutterPoint($hntBoxIndex, "hidden")); + + schedule(9000, 0, "hntAfterEyeClick"); +} + +function hntAfterEyeClick() +{ + hntCheck("clicking the eye hid the container", $hntBox.hidden); + hntCheck("and came back out of it, to what still draws", + GuiEditor.brain.getCurrentAddSet() == GuiEditor.rootGui.getId()); + + schedule(200, 0, "hntBand"); +} + +// The third path that does not hit test: a rubber band walks the children of the +// container being worked in and takes whatever it encloses. +// +// Both of these have to sit inside the canvas frame, which is a good deal +// smaller than the Gui it is showing -- the band is posted in window +// coordinates, and a point past the frame's edge would land on a tool window +// instead. The band starts clear of every control, or the press would be read as +// selecting one rather than as beginning a band. +function hntBand() +{ + // Said outright rather than inherited from the step above. A band only ever + // looks at the children of the container being worked in, so a phase that + // took the add set on trust would test nothing at all the moment the step + // before it went wrong -- which is precisely how it would go wrong. + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + GuiEditor.brain.clearSelection(); + + $hntBandShown = new GuiControl() + { + Position = "20 400"; + Extent = "100 60"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + GuiEditor.rootGui.add($hntBandShown); + + $hntBandHidden = new GuiControl() + { + Position = "140 400"; + Extent = "100 60"; + HorizSizing = "right"; + VertSizing = "bottom"; + }; + GuiEditor.rootGui.add($hntBandHidden); + $hntBandHidden.hidden = true; + + %origin = GuiEditor.rootGui.getGlobalPosition(); + %x = getWord(%origin, 0); + %y = getWord(%origin, 1); + + %band = (%x + 10) SPC (%y + 380) SPC (%x + 280) SPC (%y + 480); + echo("HNT: band " @ %band); + hntPostTarget(%band); + + schedule(12000, 0, "hntAfterBand"); +} + +function hntAfterBand() +{ + %sel = GuiEditor.brain.getSelected(); + + // The band has to have done something, or the two checks below would pass on + // a gesture that never happened. + hntCheck("the band selected what it enclosed", %sel.getCount() > 0); + hntCheck("it took the control that is drawn", + %sel.isMember($hntBandShown)); + hntCheck("and left the hidden one where a band cannot reach it", + !%sel.isMember($hntBandHidden)); + + echo("HNT RESULT: " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} diff --git a/tests/smoke/hiddenNotATarget.input.ps1 b/tests/smoke/hiddenNotATarget.input.ps1 new file mode 100644 index 000000000..53f47f1ac --- /dev/null +++ b/tests/smoke/hiddenNotATarget.input.ps1 @@ -0,0 +1,59 @@ +# Input for hiddenNotATarget.cs. Posts two real clicks: the top-left sizing knob +# of a control that is hidden while selected, then the eye box of the container +# being worked in. +# +# Neither point is written here. Where the knob lands depends on where the +# frameset put the canvas, and where the eye box lands depends on the theme's +# borders and the row height - so the engine works both out and leaves them for +# this script to pick up, one at a time. +# +# The clicks have to be real. The knob test sits in the middle of +# GuiEditCtrl::onTouchDown, ahead of the hit test, and only the Explorer's gutter +# click calls controlHidden; script could reach neither. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +$target = Join-Path $PSScriptRoot "..\..\shots\hiddenNotATargetTarget.txt" + +# Anything left by an earlier run would be clicked before this run has built +# anything to click on. +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 +} + +# Two words is a point to click, four is a band to drag across. +$labels = @("the hidden control's sizing knob", + "the eye box of the add set", + "a band across both controls") +foreach ($label in $labels) { + $point = Wait-ForTarget -Path $target + if (-not $point) { + Write-Host " the engine never reported $label" + return + } + + Remove-Item $target -Force + + if ($point.Count -ge 4) { + Send-EngineDrag -Hwnd $Hwnd -FromX ([int]$point[0]) -FromY ([int]$point[1]) ` + -ToX ([int]$point[2]) -ToY ([int]$point[3]) + Write-Host " dragged $label from ($($point[0]),$($point[1])) to ($($point[2]),$($point[3]))" + } + else { + 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/inspectorPane.cs b/tests/smoke/inspectorPane.cs new file mode 100644 index 000000000..fe09c99ba --- /dev/null +++ b/tests/smoke/inspectorPane.cs @@ -0,0 +1,628 @@ +// Properties-pane smoke test. Drives GuiEditorInspectorPane -- the custom pane +// that replaced the native GuiInspector in the Gui Editor -- through a control +// 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. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function pCheck(%label, %cond) +{ + if(%cond) echo("IPSMOKE PASS: " @ %label); + else echo("IPSMOKE FAIL: " @ %label); +} + +// Drop a control into the Gui being edited and select it, which is the path a +// real selection takes -- the brain themes it on arrival and posts Inspect. +function pAdd(%class) +{ + %ctrl = eval("return new " @ %class @ "();"); + GuiEditor.rootGui.add(%ctrl); + + %theme = GuiEditor.themeByName(GuiEditor.themeName); + if(isObject(%theme)) + { + GuiEditor.themeApplier.applyToBranch(%ctrl, %theme, false); + } + return %ctrl; +} + +function pBind(%ctrl) +{ + GuiEditor.inspectorWindow.pane.bind(%ctrl); + return GuiEditor.inspectorWindow.pane; +} + +// Is a row on show? A row that was never built is not visible either, and +// isVisible() on nothing is false -- so ask both, or a missing row would read +// as a deliberate hide (which is exactly how a stale check once passed for +// years; see tests/README.md). +function pRowShown(%pane, %field) +{ + %row = %pane.row[%field]; + return isObject(%row) && %row.isVisible(); +} + +function pRowBuilt(%pane, %field) +{ + return isObject(%pane.row[%field]); +} + +// Does the control really carry this dynamic field? Comparing the value against +// "" proves nothing: an absent field and an empty one read back identically, +// because an empty one is exactly what the engine deletes. +function pHasDynamicField(%ctrl, %name) +{ + for(%i = 0; %i < %ctrl.getDynamicFieldCount(); %i++) + { + if(getWord(%ctrl.getDynamicField(%i), 0) $= %name) + { + return true; + } + } + return false; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "pStep1"); + +//----------------------------------------------------------------------------- +// The pane exists and replaced the inspector. +//----------------------------------------------------------------------------- + +function pStep1() +{ + ProjectManager.setProjectFolder("inspectorPaneSmokeProject"); + GuiEditor.open(); + + %w = GuiEditor.inspectorWindow; + pCheck("inspector window exists", isObject(%w)); + pCheck("pane built", isObject(%w.pane)); + pCheck("native inspector gone", !isObject(%w.inspector)); + pCheck("pane owns a spec", isObject(%w.pane.spec)); + pCheck("header built", isObject(%w.pane.header)); + + schedule(200, 0, "pStep2"); +} + +//----------------------------------------------------------------------------- +// A button: text in the header, easing shown, no class sections beyond that. +//----------------------------------------------------------------------------- + +function pStep2() +{ + $pButton = pAdd("GuiButtonCtrl"); + %pane = pBind($pButton); + + pCheck("bound to the button", %pane.target == $pButton); + pCheck("pane visible once bound", %pane.isVisible()); + pCheck("button text is in the header", %pane.header.textGrid.isVisible()); + + // --- Alignment is a segmented row per axis, "default" first and blank. --- + // "default" is not an absence: getAlignmentType resolves it to the profile's + // own alignment, and the engine's tables only started exposing it with this + // change (the count said 3 while the array held 4). + %align = %pane.header.alignRow; + pCheck("align row has four choices", %align.choiceCount == 4); + pCheck("align row leads with default", %align.choiceValue[0] $= "default"); + pCheck("the default choice has no icon", %align.choiceIcon[0] $= ""); + pCheck("the default button draws no icon", !%align.choiceButton[0].icon.isVisible()); + pCheck("the other three do", %align.choiceButton[1].icon.isVisible()); + pCheck("v-align row has four choices", %pane.header.vAlignRow.choiceCount == 4); + + $pButton.align = "default"; + %pane.refresh(); + pCheck("default reads back by name now the table exposes it", + $pButton.align $= "default"); + pCheck("row shows default chosen", %align.getValue() $= "default"); + pCheck("only the default button is down", + %align.choiceButton[0].getValue() && !%align.choiceButton[2].getValue()); + + // Picking one writes it and moves the pressed button. + %align.choiceButton[2].performClick(); + pCheck("choosing centre reached the control", $pButton.align $= "center"); + pCheck("the chosen button is down", %align.choiceButton[2].getValue()); + pCheck("and the previous one is up", !%align.choiceButton[0].getValue()); + + // A radio cannot be un-picked, only replaced. + %align.choiceButton[2].performClick(); + pCheck("clicking the chosen one again keeps it", $pButton.align $= "center"); + pCheck("and leaves it looking chosen", %align.choiceButton[2].getValue()); + + %align.choiceButton[0].performClick(); + pCheck("going back to default writes default", $pButton.align $= "default"); + pCheck("button shows easing", pRowShown(%pane, "easeFillColorHL")); + pCheck("button shows tooltip", pRowShown(%pane, "tooltip")); + pCheck("button can be a container", %pane.header.containerButton.isVisible()); + // One text block, in one place. The row the pane filters and loads IS the + // header block's -- there is no second copy of it to disagree with. + pCheck("the text row on show is the header block's", + pRowShown(%pane, "text") && %pane.row["text"] == %pane.header.textBlock.row["text"]); + pCheck("the text section is not also showing", !%pane.textPanel.isVisible()); + + // The header loaded the control's actual values. + pCheck("name row loaded", %pane.header.nameRow.getValue() $= $pButton.getName()); + pCheck("extent row loaded", %pane.header.extentRow.getValue() $= $pButton.getExtent()); + + // --- A commit reaches the control. --- + // applyValue, not setValue: setValue also records the value as the row's + // baseline, which is what tells a later commit that nothing was edited. A + // user typing into the box changes the widget without touching the + // baseline, and applyValue is the half that does that. + %pane.header.textRow.applyValue("Smoke"); + %pane.header.textRow.commit(); + pCheck("text commit reached the control", $pButton.getText() $= "Smoke"); + + %pane.header.extentRow.applyValue("123 45"); + %pane.header.extentRow.commit(); + pCheck("extent commit reached the control", $pButton.getExtent() $= "123 45"); + + // A row that lost focus without being edited must not write. + $pButton.setText("Untouched"); + %pane.refresh(); + %pane.header.textRow.commit(); + pCheck("unchanged row does not write", $pButton.getText() $= "Untouched"); + + schedule(200, 0, "pStep3"); +} + +//----------------------------------------------------------------------------- +// A chain: no text at all, and its own two fields promoted to the header. +//----------------------------------------------------------------------------- + +function pStep3() +{ + $pChain = pAdd("GuiChainCtrl"); + %pane = pBind($pChain); + + pCheck("chain hides the header text block", !%pane.header.textGrid.isVisible()); + pCheck("chain hides the shared text row", !pRowShown(%pane, "text")); + // A chain draws no text at all, so the block is in neither of its homes -- + // checked at both ends, because "no row" and "a hidden row" read the same + // through pRowShown and only one of them is the answer here. + pCheck("chain hides the text section", !%pane.textPanel.isVisible()); + pCheck("chain has no text row at all", !pRowBuilt(%pane, "text")); + pCheck("chain hides easing", !pRowShown(%pane, "easeFillColorHL")); + pCheck("chain promotes IsVertical", pRowBuilt(%pane, "IsVertical")); + pCheck("chain promotes ChildSpacing", pRowBuilt(%pane, "ChildSpacing")); + pCheck("chain can be a container", %pane.header.containerButton.isVisible()); + + // A grid draws text through the base onRender, so it keeps the fields -- + // but in the collapsed section rather than the header. + $pGrid = pAdd("GuiGridCtrl"); + %pane = pBind($pGrid); + pCheck("grid hides the header text block", !%pane.header.textGrid.isVisible()); + pCheck("grid keeps the shared text row", pRowShown(%pane, "text")); + pCheck("grid text section shown", %pane.textPanel.isVisible()); + pCheck("and it is the section block's row", + %pane.row["text"] == %pane.sectionText.row["text"]); + pCheck("grid promotes CellModeX", pRowBuilt(%pane, "CellModeX")); + pCheck("grid has its Grid section", pRowBuilt(%pane, "MaxColCount")); + + schedule(200, 0, "pStep4"); +} + +//----------------------------------------------------------------------------- +// Classes whose sections have to be rebuilt as the selection moves between +// them -- the one part of the pane that is not filtered but replaced. +//----------------------------------------------------------------------------- + +function pStep4() +{ + $pWindow = pAdd("GuiWindowCtrl"); + %pane = pBind($pWindow); + // The six switches are icons in the header's value block now, sharing a line + // with Title Height rather than costing a section of six checkboxes. + pCheck("window has no Window section", !pRowBuilt(%pane, "canClose")); + pCheck("its switches are in the header", + isObject(%pane.header.windowToggleRow) && + isObject(%pane.header.windowButton["canClose"])); + pCheck("window has its Grips section", pRowBuilt(%pane, "resizeRightWidth")); + pCheck("window keeps its title text", %pane.header.textGrid.isVisible()); + pCheck("window promotes titleHeight", pRowBuilt(%pane, "titleHeight")); + + // Moving to a slider must take the window's fields away again. + $pSlider = pAdd("GuiSliderCtrl"); + %pane = pBind($pSlider); + pCheck("window fields gone after reselect", !pRowBuilt(%pane, "resizeRightWidth")); + pCheck("and its switch row went with them", !isObject(%pane.header.windowToggleRow)); + pCheck("slider has its ticks", pRowBuilt(%pane, "ticks")); + pCheck("slider promotes range", pRowBuilt(%pane, "range")); + pCheck("slider hides the header text block", !%pane.header.textGrid.isVisible()); + pCheck("slider keeps fontSizeAdjust", pRowShown(%pane, "fontSizeAdjust")); + pCheck("slider cannot be a container", !%pane.header.containerButton.isVisible()); + + // And back again, to prove the rebuild is not one-way. + %pane = pBind($pWindow); + pCheck("window fields returned", pRowBuilt(%pane, "resizeRightWidth")); + pCheck("and so did its switch row", isObject(%pane.header.windowToggleRow)); + pCheck("slider fields gone", !pRowBuilt(%pane, "ticks")); + + schedule(200, 0, "pStep5"); +} + +//----------------------------------------------------------------------------- +// Geometry, which belongs to the parent. This is the check that would have +// caught the fields the old inspector let you edit into a silent revert. +//----------------------------------------------------------------------------- + +function pStep5() +{ + %pane = pBind($pButton); + %anchor = %pane.header.anchorPicker; + pCheck("free control edits its position", + $pButton.getParent() == GuiEditor.rootGui && + %pane.header.positionRow.editor.isActive()); + pCheck("free control edits its extent", %pane.header.extentRow.editor.isActive()); + pCheck("free control edits horizontal sizing", %anchor.leftPin.isActive()); + + // --- The anchor picker and the sizing names it speaks. --- + // The deprecated set still loads: "right" is the old name for anchorLeft, + // and it has to keep working because every Gui already on disk uses it. + $pButton.HorizSizing = "right"; + $pButton.VertSizing = "height"; + pCheck("a deprecated name still sets the field", + $pButton.HorizSizing $= "anchorLeft"); + pCheck("and reads back as the anchor name, which is what TAML will write", + $pButton.HorizSizing !$= "right"); + + %pane.refresh(); + pCheck("anchorLeft reads back as a left-edge pin", + %anchor.pinLeft && !%anchor.pinRight); + pCheck("\"height\" reads back as both vertical pins", + %anchor.pinTop && %anchor.pinBottom); + pCheck("readout names the resolved pair", + %anchor.readout.getText() $= "anchorLeft / height"); + + // The other three deprecated spellings. + $pButton.HorizSizing = "left"; + pCheck("\"left\" still maps to anchorRight", $pButton.HorizSizing $= "anchorRight"); + $pButton.VertSizing = "top"; + pCheck("\"top\" still maps to anchorBottom", $pButton.VertSizing $= "anchorBottom"); + $pButton.VertSizing = "bottom"; + pCheck("\"bottom\" still maps to anchorTop", $pButton.VertSizing $= "anchorTop"); + $pButton.HorizSizing = "relative"; + pCheck("\"relative\" still maps to scale", $pButton.HorizSizing $= "scale"); + + // And the picker reads a control that was set the old way. + $pButton.HorizSizing = "right"; + $pButton.VertSizing = "height"; + %pane.refresh(); + + // The pins are toggle icons, so performClick drives the real path: the + // checkbox flips itself and reports what it became. + // Pinning the right edge as well must produce "width", not "left". + %anchor.rightPin.performClick(); + pCheck("both horizontal pins resolve to width", $pButton.HorizSizing $= "width"); + %anchor.leftPin.performClick(); + pCheck("right pin alone resolves to anchorRight", $pButton.HorizSizing $= "anchorRight"); + %anchor.rightPin.performClick(); + pCheck("no horizontal pin resolves to center", $pButton.HorizSizing $= "center"); + + // Fill supersedes the pins and clearing it hands the axis back to them. + %anchor.leftPin.performClick(); + %anchor.onChipClicked("h", "fill"); + pCheck("fill chip supersedes the pins", $pButton.HorizSizing $= "fill"); + pCheck("fill chip shows as on", %anchor.hFill.getStateOn()); + %anchor.onChipClicked("h", "fill"); + pCheck("clearing fill returns to the pins", $pButton.HorizSizing $= "anchorLeft"); + + // Scale and fill are exclusive on an axis. + %anchor.onChipClicked("h", "scale"); + pCheck("scale chip applies", $pButton.HorizSizing $= "scale"); + %anchor.onChipClicked("h", "fill"); + pCheck("fill replaces scale", $pButton.HorizSizing $= "fill"); + pCheck("scale turned off", !%anchor.hRel.getStateOn()); + + // Touching a pin drops the special. + %anchor.leftPin.performClick(); + pCheck("a pin click clears the special", $pButton.HorizSizing !$= "fill"); + + // The vertical axis is written even when only the horizontal one moved. + // Checked here, while everything above has touched H alone. + pCheck("vertical sizing survived horizontal edits", $pButton.VertSizing $= "height"); + + // --- Center and fill take effect at once, and are reversible. --- + // Both describe a position the control should always be in rather than a + // reaction to a size change, so waiting for the next parent resize would + // make picking one look like it did nothing. + $pButton.HorizSizing = "anchorLeft"; + $pButton.VertSizing = "anchorTop"; + $pButton.setPosition(37, 41); + $pButton.setExtent(120, 26); + %pane.refresh(); + + %parentW = getWord($pButton.getParent().getExtent(), 0); + %anchor.onChipClicked("h", "fill"); + pCheck("fill moved the control to the left edge immediately", + getWord($pButton.getPosition(), 0) == 0); + pCheck("fill widened the control immediately", + getWord($pButton.getExtent(), 0) > 120); + pCheck("fill left the other axis alone", + getWord($pButton.getPosition(), 1) == 41 && + getWord($pButton.getExtent(), 1) == 26); + + pCheck("fill stashed what it overwrote", + %pane.stashPos["h"] $= "37 41" && %pane.stashExtent["h"] $= "120 26"); + + // Clicking away from fill gives back exactly what it overwrote. + %anchor.onChipClicked("h", "scale"); + pCheck("leaving fill cleared the stash", %pane.stashed["h"] $= ""); + pCheck("leaving fill restored the x position", + getWord($pButton.getPosition(), 0) == 37); + pCheck("leaving fill restored the width", + getWord($pButton.getExtent(), 0) == 120); + + // Center owns the position but not the extent. Clearing scale hands the + // axis back to its pins, which is anchorLeft, so one click empties them. + %anchor.onChipClicked("h", "scale"); + %anchor.leftPin.performClick(); + pCheck("no pins resolves to center", $pButton.HorizSizing $= "center"); + pCheck("center recentred the control immediately", + getWord($pButton.getPosition(), 0) != 37); + pCheck("center left the width alone", getWord($pButton.getExtent(), 0) == 120); + + %anchor.leftPin.performClick(); + pCheck("leaving center restored the x position", + getWord($pButton.getPosition(), 0) == 37); + + // The stash belongs to the control it came from: selecting something else + // throws it away rather than carrying a stale position across. + %anchor.onChipClicked("h", "fill"); + pBind($pWindow); + %pane = pBind($pButton); + pCheck("the stash does not survive a selection change", + %pane.stashed["h"] $= ""); + $pButton.HorizSizing = "anchorLeft"; + $pButton.setPosition(37, 41); + $pButton.setExtent(120, 26); + %pane.refresh(); + + // With Fill in effect the pins are not what is happening, so they read off + // -- and a disabled axis must refuse the click entirely. + %anchor.setAxisEnabled(false, true); + %before = $pButton.HorizSizing; + %anchor.leftPin.performClick(); + pCheck("a disabled axis ignores its pins", $pButton.HorizSizing $= %before); + %anchor.setAxisEnabled(true, true); + + $pButton.HorizSizing = "right"; + $pButton.VertSizing = "bottom"; + %pane.refresh(); + + // Into a vertical chain: it takes the Y position and the vertical sizing, + // and leaves the X position and the extent alone. + $pChain.IsVertical = true; + $pChain.add($pButton); + %pane = pBind($pButton); + pCheck("chain child keeps X position", %pane.header.positionRow.editor.isActive()); + pCheck("chain child loses Y position", !%pane.header.positionRow.editorY.isActive()); + pCheck("chain child keeps horizontal anchoring", + %pane.header.anchorPicker.leftPin.isActive()); + pCheck("chain child loses vertical anchoring", + !%pane.header.anchorPicker.topPin.isActive()); + pCheck("chain child keeps its extent", %pane.header.extentRow.editor.isActive()); + + // Into a grid: the cell owns everything. + $pGrid.add($pButton); + %pane = pBind($pButton); + pCheck("grid child loses X position", !%pane.header.positionRow.editor.isActive()); + pCheck("grid child loses Y position", !%pane.header.positionRow.editorY.isActive()); + pCheck("grid child loses its extent", !%pane.header.extentRow.editor.isActive()); + pCheck("grid child loses both sizings", + !%pane.header.anchorPicker.leftPin.isActive() && + !%pane.header.anchorPicker.topPin.isActive()); + + // A scroller is the container that looks like it owns its children and does + // not -- it only scrolls them. + $pScroll = pAdd("GuiScrollCtrl"); + $pScroll.add($pButton); + %pane = pBind($pButton); + pCheck("scroll child keeps its position", %pane.header.positionRow.editor.isActive()); + pCheck("scroll child keeps its extent", %pane.header.extentRow.editor.isActive()); + + schedule(200, 0, "pStep6"); +} + +//----------------------------------------------------------------------------- +// Toggles, the profile picker, and clearing the selection. +//----------------------------------------------------------------------------- + +function pStep6() +{ + %pane = pBind($pButton); + %header = %pane.header; + + // The toggle's box has to cover the whole control for it to read as a + // button rather than a checkbox. It got this wrong once: setBoxOffset and + // setBoxExtent document one argument and read two, so a single "0 0" left + // the box at (0,32) -- drawn entirely below the control. + pCheck("toggle box covers the whole control", + %header.visibleButton.getBoxOffset() $= "0 0" && + %header.visibleButton.getBoxExtent() $= %header.visibleButton.getExtent()); + + // The four runtime state flags are icon toggles now that the sheet has art + // for them. hidden and locked are not among them any more -- they are editor + // working state and moved out to the Explorer tree's columns. + pCheck("the pane no longer offers hidden", !isObject(%header.hiddenButton)); + pCheck("the pane no longer offers locked", !isObject(%header.lockedButton)); + + // And they must not come back through the side door. Both are real persist + // fields on SimObject, and buildOtherSection sweeps up every field no section + // claimed -- so unless editorToggles() keeps naming them, removing the two + // buttons does not remove the two controls from the pane. It turns them into + // a pair of generic checkboxes in "Other", which is worse than where they + // started: same working state, now filed under the leftovers. + pCheck("hidden did not reappear as a generic row", !pRowBuilt(%pane, "hidden")); + pCheck("locked did not reappear as a generic row", !pRowBuilt(%pane, "locked")); + pCheck("the spec still claims both", + %pane.spec.editorToggles() $= "hidden locked"); + + pCheck("visible toggle reflects the control", + %header.visibleButton.getValue() == $pButton.Visible); + + %header.visibleButton.performClick(); + pCheck("visible toggle reached the control", !$pButton.Visible); + %header.visibleButton.performClick(); + pCheck("visible toggle restored", $pButton.Visible); + + %header.inputButton.performClick(); + pCheck("accepts-input toggle reached the control", !$pButton.useInput); + %header.inputButton.performClick(); + + %header.containerButton.performClick(); + pCheck("accepts-children toggle reached the control", $pButton.isContainer); + %header.containerButton.performClick(); + + // A control that cannot draw children has the field forced false, so the + // button goes rather than sitting there wired to nothing. + %listPane = pBind(pAdd("GuiListBoxCtrl")); + pCheck("accepts-children hidden where it is dead", + !%listPane.header.containerButton.isVisible()); + %pane = pBind($pButton); + %header = %pane.header; + pCheck("accepts-children shown where it is live", + %header.containerButton.isVisible()); + + // A toggle icon is a checkbox wearing an icon, so it holds its own state. + // performClick drives the real path: the checkbox flips itself and its + // Command tells the pane what it became. activeButton has a true on/off pair, + // so it is the one that can prove the icon follows the value. + %header.activeButton.performClick(); + pCheck("active toggle reached the control", !$pButton.Active); + pCheck("active icon shows the off frame", + %header.activeButton.icon.getImageFrame() == %header.activeButton.frameOff); + %header.activeButton.performClick(); + pCheck("active toggle flips back", $pButton.Active); + pCheck("active icon shows the on frame", + %header.activeButton.icon.getImageFrame() == %header.activeButton.frameOn); + + // A disabled toggle must not act. The engine hands touch events to inactive + // controls -- findHitControl checks mVisible and mUseInput, never mActive -- + // so this is the checkbox's own guard doing the work. + %header.activeButton.setActive(false); + %header.activeButton.performClick(); + pCheck("a disabled toggle does not change the control", $pButton.Active); + pCheck("a disabled toggle keeps its own state on", %header.activeButton.getValue()); + %header.activeButton.setActive(true); + + // The profile picker offers the theme's members for the control's category, + // not every profile in the sim. + %items = %header.profileRow.editor.getItemCount(); + pCheck("profile picker has candidates (" @ %items @ ")", %items > 0); + %current = GuiEditor.themeApplier.fieldProfile($pButton, "Profile"); + pCheck("profile picker shows what the control wears", + isObject(%current) && %header.profileRow.getValue() $= %current.getName()); + + // A menu item has no GuiControl fields at all, so the header sheds everything + // generic and shows a block of its own instead - caption included, on one + // line, because a menu label is one line and it is also how wide the menu is. + $pMenuItem = new GuiMenuItemCtrl(); + %pane = pBind($pMenuItem); + %block = %pane.header.menuItemBlock; + pCheck("menu item hides the profile row", !%pane.header.profileRow.isVisible()); + pCheck("menu item hides geometry", !%pane.header.geometryGrid.isVisible()); + pCheck("menu item shows its own block", %block.isVisible()); + pCheck("which stands the shared text block down", !%pane.header.textBlock.isVisible()); + pCheck("its caption is single line", %block.textRow.kind $= "text"); + pCheck("it carries the command fields", %block.commandRow.isVisible() && + %block.acceleratorRow.isVisible()); + + // Visible and Active are GuiControl's names, but a menu item registers them + // again for itself, so those two switches really do work here. + pCheck("menu item keeps Visible and Active", %pane.header.visibleButton.isVisible() && + %pane.header.activeButton.isVisible()); + pCheck("but not Accepts Input", !%pane.header.inputButton.isVisible()); + + // The section those fields used to be in is gone, so nothing is left showing + // a header that cannot open. + pCheck("no dead Menu Item section", !isObject(%pane.panel["Item"]) || + !%pane.panel["Item"].isVisible()); + + // An ordinary control is untouched by any of it. + %pane = pBind($pButton); + pCheck("an ordinary control shows no menu item block", + !%pane.header.menuItemBlock.isVisible()); + pCheck("and keeps the shared text block", %pane.header.textBlock.isVisible()); + + schedule(200, 0, "pStep7"); +} + +//----------------------------------------------------------------------------- +// Dynamic fields: built fresh rather than ported, and filtered so a frame set's +// serialized layout tree cannot be hand-edited into an unloadable Gui. +//----------------------------------------------------------------------------- + +function pStep7() +{ + %pane = pBind($pButton); + %dyn = %pane.dynamicFields; + + pCheck("dynamic section built", isObject(%dyn)); + pCheck("dynamic section shown for a bound control", %pane.dynamicPanel.isVisible()); + pCheck("a plain control starts with no dynamic fields", !%dyn.hasFields()); + + // Add through the widget, exactly as a click would. A dynamic field cannot + // hold an empty value -- SimFieldDictionary::setFieldValue frees the entry + // when the value is empty -- so Add produces a row, and giving that row a + // value is what puts the field on the control. + %dyn.nameBox.setText("smokeTag"); + %dyn.onAddClicked(); + pCheck("add built a row for the new name", isObject(%dyn.row["smokeTag"])); + pCheck("name box cleared after adding", %dyn.nameBox.getText() $= ""); + pCheck("naming alone does not create the field", + !pHasDynamicField($pButton, "smokeTag")); + + // Edit its value: this is what creates it. + %dyn.row["smokeTag"].applyValue("hello"); + %dyn.row["smokeTag"].commit(); + pCheck("dynamic value commit reached the control", $pButton.smokeTag $= "hello"); + pCheck("the field now really exists", pHasDynamicField($pButton, "smokeTag")); + + // A name that is already a registered field must not be accepted -- writing + // it would set the real field and quietly do something else entirely. + %dyn.nameBox.setText("Extent"); + %dyn.onAddClicked(); + pCheck("a built-in field name is refused", !isObject(%dyn.row["Extent"])); + %dyn.nameBox.setText(""); + + // A frame set hides the fields it serializes its own layout into. + $pFrameSet = pAdd("GuiFrameSetCtrl"); + $pFrameSet.frameID0 = "1"; + $pFrameSet.myOwnField = "keep"; + %pane = pBind($pFrameSet); + %dyn = %pane.dynamicFields; + pCheck("frame set hides its serialized layout field", !isObject(%dyn.row["frameID0"])); + pCheck("frame set still shows a user field", isObject(%dyn.row["myOwnField"])); + + schedule(200, 0, "pStep8"); +} + +function pStep8() +{ + %pane = pBind($pButton); + %dyn = %pane.dynamicFields; + pCheck("dynamic field survived reselect", isObject(%dyn.row["smokeTag"])); + + // Remove, which is the row's reset button repurposed. + %dyn.onProfileRowReset(%dyn.row["smokeTag"]); + schedule(100, 0, "pStep9"); +} + +function pStep9() +{ + %pane = GuiEditor.inspectorWindow.pane; + pCheck("remove took the field off the control", !pHasDynamicField($pButton, "smokeTag")); + pCheck("remove took the row with it", !isObject(%pane.dynamicFields.row["smokeTag"])); + + // Nothing selected: the pane stops drawing rather than showing stale values. + %pane.unbind(); + pCheck("pane hides when nothing is selected", !%pane.isVisible()); + + echo("IPSMOKE DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/inspectorSpec.cs b/tests/smoke/inspectorSpec.cs new file mode 100644 index 000000000..5d078055a --- /dev/null +++ b/tests/smoke/inspectorSpec.cs @@ -0,0 +1,337 @@ +// Control-spec smoke test. Exercises GuiEditorControlSpec on its own -- no +// editor, no canvas, no UI -- because the spec is pure data and every rule it +// encodes is a claim about what the engine reads. If one of these fails, either +// the engine changed or the table was wrong. +// +// 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. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function sCheck(%label, %cond) +{ + if(%cond) echo("ISSMOKE PASS: " @ %label); + else echo("ISSMOKE FAIL: " @ %label); +} + +// Make a control, and deliberately do NOT give it a profile. +// +// This used to have to set one. Several constructors never did -- GuiChainCtrl, +// GuiTabPageCtrl, GuiSliderCtrl, GuiTextEditCtrl, GuiInputCtrl, GuiSpriteCtrl +// and SceneWindow among them -- so a control built from script carried a null +// mProfile, and GuiChainCtrl::onChildAdded runs calculateExtent, which asks the +// profile for its borders. Adding a child to a chain was a hard crash. +// +// GuiControl::onAdd now falls back to GuiDefaultProfile for anything that +// reaches registration without one, so every line below is also a test of that. +function sMake(%class) +{ + return eval("return new " @ %class @ "();"); +} + +// The spec itself needs nothing but the class list, but the controls step 4 +// builds to ask about geometry are real ones, and profiling a real control +// pulls in fonts and textures -- which need a live canvas. So this boots the +// editor like every other suite rather than running the spec in a vacuum. +testExec("editor/main.cs"); +schedule(2000, 0, "sStep1"); + +//----------------------------------------------------------------------------- +// The table itself. +//----------------------------------------------------------------------------- + +function sStep1() +{ + testExec("editor/GuiEditor/scripts/GuiEditorControlSpec.cs"); + $sSpec = new ScriptObject() { class = "GuiEditorControlSpec"; }; + sCheck("spec object created", isObject($sSpec)); + + // --- Drift guard: a control class added to the engine must not fall + // through to the unknown-class fallback unnoticed. --- + %missing = $sSpec.findMissingClasses(enumerateConsoleClasses("GuiControl")); + sCheck("spec covers every placeable class (missing: " @ %missing @ ")", %missing $= ""); + + sCheck("known class recognised", $sSpec.isKnownClass("GuiButtonCtrl")); + sCheck("unknown class not recognised", !$sSpec.isKnownClass("GuiNotARealCtrl")); + + schedule(50, 0, "sStep2"); +} + +//----------------------------------------------------------------------------- +// Text. Every one of these is a case where the field list and the render path +// disagree. +//----------------------------------------------------------------------------- + +function sStep2() +{ + %s = $sSpec; + + // A chain draws only a "+" in edit mode, so the nine text fields it + // inherits are all dead. + sCheck("chain hides text", !%s.isFieldVisible("GuiChainCtrl", "text")); + sCheck("chain hides align", !%s.isFieldVisible("GuiChainCtrl", "align")); + sCheck("chain hides fontColor", !%s.isFieldVisible("GuiChainCtrl", "fontColor")); + + // A slider never calls renderText, but it does size its dglDrawText with + // getFont(mFontSizeAdjust). + sCheck("slider hides text", !%s.isFieldVisible("GuiSliderCtrl", "text")); + sCheck("slider hides align", !%s.isFieldVisible("GuiSliderCtrl", "align")); + sCheck("slider keeps fontSizeAdjust", %s.isFieldVisible("GuiSliderCtrl", "fontSizeAdjust")); + + // The multi-line text control: wrap is real, vAlign and textExtend are not. + sCheck("text edit keeps textWrap", %s.isFieldVisible("GuiTextEditCtrl", "textWrap")); + sCheck("text edit keeps align", %s.isFieldVisible("GuiTextEditCtrl", "align")); + sCheck("text edit hides vAlign", !%s.isFieldVisible("GuiTextEditCtrl", "vAlign")); + sCheck("text edit hides textExtend", !%s.isFieldVisible("GuiTextEditCtrl", "textExtend")); + + // A list renders item text with its own profile: layout lives, the control's + // own string does not. + sCheck("list box hides text", !%s.isFieldVisible("GuiListBoxCtrl", "text")); + sCheck("list box hides textID", !%s.isFieldVisible("GuiListBoxCtrl", "textID")); + sCheck("list box keeps align", %s.isFieldVisible("GuiListBoxCtrl", "align")); + + // The book draws the page's caption, so the string belongs to the page and + // the layout belongs to the book. + sCheck("tab page keeps text", %s.isFieldVisible("GuiTabPageCtrl", "text")); + sCheck("tab page hides align", !%s.isFieldVisible("GuiTabPageCtrl", "align")); + sCheck("tab book hides text", !%s.isFieldVisible("GuiTabBookCtrl", "text")); + sCheck("tab book keeps align", %s.isFieldVisible("GuiTabBookCtrl", "align")); + + // A panel hands its text to the header button it owns. + sCheck("panel keeps text", %s.isFieldVisible("GuiPanelCtrl", "text")); + sCheck("panel hides textWrap", !%s.isFieldVisible("GuiPanelCtrl", "textWrap")); + + // Where the text box belongs, and what it is called there. + // Where the text block goes, which is one answer per class rather than the + // two overlapping ones it used to be. Proxy is the interesting case: a list + // has no string of its own but draws its items with this control's font, so + // the block is worth having open. + sCheck("button text in header", %s.textBlockHome("GuiButtonCtrl") $= "header"); + sCheck("panel caption in header", %s.textBlockHome("GuiPanelCtrl") $= "header"); + sCheck("tree item font in header", %s.textBlockHome("GuiTreeViewCtrl") $= "header"); + sCheck("grid text in the section", %s.textBlockHome("GuiGridCtrl") $= "section"); + sCheck("slider keeps only its font size", %s.textBlockHome("GuiSliderCtrl") $= "section"); + sCheck("a chain gets no text block", %s.textBlockHome("GuiChainCtrl") $= "none"); + sCheck("nor does a sprite", %s.textBlockHome("GuiSpriteCtrl") $= "none"); + sCheck("grid text still shown", %s.isFieldVisible("GuiGridCtrl", "text")); + sCheck("drop down text labelled Placeholder", + %s.textLabelFor("GuiDropDownCtrl") $= "Placeholder"); + sCheck("tab page text labelled Tab Caption", + %s.textLabelFor("GuiTabPageCtrl") $= "Tab Caption"); + + schedule(50, 0, "sStep3"); +} + +//----------------------------------------------------------------------------- +// Easing. Narrower than the class tree suggests: inheriting GuiEasingSupport is +// not the same as calling getFillColor. +//----------------------------------------------------------------------------- + +function sStep3() +{ + %s = $sSpec; + + sCheck("button keeps easing", %s.isFieldVisible("GuiButtonCtrl", "easeFillColorHL")); + sCheck("drop down keeps easing", %s.isFieldVisible("GuiDropDownCtrl", "easeTimeFillColorSL")); + sCheck("frame set keeps easing", %s.isFieldVisible("GuiFrameSetCtrl", "easeFillColorSL")); + + // GuiCheckBoxCtrl::onRender draws its box through renderInnerControl and + // never renders a universal rect of its own, so it inherits a dead set. + sCheck("check box hides easing", !%s.isFieldVisible("GuiCheckBoxCtrl", "easeFillColorHL")); + sCheck("radio hides easing", !%s.isFieldVisible("GuiRadioCtrl", "easeFillColorHL")); + + // A menu item calls SimObject::initPersistFields, not GuiControl's. + sCheck("menu item is bare", %s.hasFlag("GuiMenuItemCtrl", "bare")); + sCheck("menu item hides Profile", !%s.isFieldVisible("GuiMenuItemCtrl", "Profile")); + sCheck("menu item hides tooltip", !%s.isFieldVisible("GuiMenuItemCtrl", "tooltip")); + sCheck("menu item keeps text", %s.isFieldVisible("GuiMenuItemCtrl", "text")); + + // Never shown for anyone. + sCheck("canSave never shown", !%s.isFieldVisible("GuiControl", "canSave")); + sCheck("parentGroup never shown", !%s.isFieldVisible("GuiControl", "parentGroup")); + + schedule(50, 0, "sStep4"); +} + +//----------------------------------------------------------------------------- +// Geometry, which is the parent's answer rather than the class's, and +// isContainer, which is the engine's. +//----------------------------------------------------------------------------- + +function sStep4() +{ + %s = $sSpec; + + // --- Every control gets a profile, whether its constructor set one or not. + // The chain is the one that used to crash outright on the next line. --- + %bare = sMake("GuiChainCtrl"); + sCheck("a chain gets a fallback profile", isObject(%bare.Profile)); + sCheck("a tab page gets a fallback profile", isObject(sMake("GuiTabPageCtrl").Profile)); + sCheck("a slider gets a fallback profile", isObject(sMake("GuiSliderCtrl").Profile)); + sCheck("a text edit gets a fallback profile", isObject(sMake("GuiTextEditCtrl").Profile)); + sCheck("an input control gets a fallback profile", isObject(sMake("GuiInputCtrl").Profile)); + sCheck("a sprite gets a fallback profile", isObject(sMake("GuiSpriteCtrl").Profile)); + sCheck("a scene window gets a fallback profile", isObject(sMake("SceneWindow").Profile)); + + // A control whose constructor names its own profile keeps that one rather + // than being overwritten by the fallback. + sCheck("a button keeps its constructor's profile choice", + isObject(sMake("GuiButtonCtrl").Profile)); + + $sRoot = sMake("GuiControl"); + $sRoot.setExtent(400, 400); + + %vChain = sMake("GuiChainCtrl"); + %vChain.IsVertical = true; + $sRoot.add(%vChain); + %vKid = sMake("GuiButtonCtrl"); + %vChain.add(%vKid); + + %hChain = sMake("GuiChainCtrl"); + %hChain.IsVertical = false; + $sRoot.add(%hChain); + %hKid = sMake("GuiButtonCtrl"); + %hChain.add(%hKid); + + %grid = sMake("GuiGridCtrl"); + $sRoot.add(%grid); + %gridKid = sMake("GuiButtonCtrl"); + %grid.add(%gridKid); + + // The container people expect to own its children and does not. + %scroll = sMake("GuiScrollCtrl"); + $sRoot.add(%scroll); + %scrollKid = sMake("GuiButtonCtrl"); + %scroll.add(%scrollKid); + + %book = sMake("GuiTabBookCtrl"); + $sRoot.add(%book); + %page = sMake("GuiTabPageCtrl"); + %book.add(%page); + + sCheck("plain child owns its geometry", %s.geometryModeOf(%vChain) $= "full"); + sCheck("vertical chain child is chainV", %s.geometryModeOf(%vKid) $= "chainV"); + sCheck("horizontal chain child is chainH", %s.geometryModeOf(%hKid) $= "chainH"); + sCheck("grid child owns nothing", %s.geometryModeOf(%gridKid) $= "none"); + sCheck("tab page owns nothing", %s.geometryModeOf(%page) $= "none"); + sCheck("scroll child still owns its geometry", %s.geometryModeOf(%scrollKid) $= "full"); + + // A vertical chain takes the Y position and the vertical sizing; the cross + // axis is left alone, and the extent is the child's own on both axes. + sCheck("chainV keeps HorizSizing", %s.isGeometryFieldLive("chainV", "HorizSizing")); + sCheck("chainV drops VertSizing", !%s.isGeometryFieldLive("chainV", "VertSizing")); + sCheck("chainV keeps Extent", %s.isGeometryFieldLive("chainV", "Extent")); + sCheck("chainV position is x only", %s.livePositionAxes("chainV") $= "x"); + sCheck("chainH position is y only", %s.livePositionAxes("chainH") $= "y"); + sCheck("none has no live position axis", %s.livePositionAxes("none") $= ""); + sCheck("full keeps both position axes", %s.livePositionAxes("full") $= "xy"); + sCheck("none drops Extent", !%s.isGeometryFieldLive("none", "Extent")); + + // isContainer is only meaningful where the control draws children, which is + // the engine's answer via the new rendersChildren() accessor. + %list = sMake("GuiListBoxCtrl"); + %slider = sMake("GuiSliderCtrl"); + %sprite = sMake("GuiSpriteCtrl"); + sCheck("plain control can be a container", %s.isContainerFieldVisible($sRoot)); + sCheck("chain can be a container", %s.isContainerFieldVisible(%vChain)); + sCheck("list box cannot be a container", !%s.isContainerFieldVisible(%list)); + sCheck("slider cannot be a container", !%s.isContainerFieldVisible(%slider)); + sCheck("sprite can be a container", %s.isContainerFieldVisible(%sprite)); + + schedule(50, 0, "sStep5"); +} + +//----------------------------------------------------------------------------- +// Dynamic fields, type mapping, and the unknown-class fallback. +//----------------------------------------------------------------------------- + +function sStep5() +{ + %s = $sSpec; + + // A frame set serializes its whole frame tree into dynamic fields; editing + // those by hand can leave a Gui that will not load. + sCheck("frame set hides frameID0", %s.hidesDynamicField("GuiFrameSetCtrl", "frameID0")); + sCheck("frame set hides frameExtentX2", %s.hidesDynamicField("GuiFrameSetCtrl", "frameExtentX2")); + sCheck("frame set keeps a user field", !%s.hidesDynamicField("GuiFrameSetCtrl", "myThing")); + sCheck("button hides no dynamic fields", !%s.hidesDynamicField("GuiButtonCtrl", "frameID0")); + + // getFieldType answers with a console type's class name, not its TypeXxx + // constant, so the mapping is keyed on the former. + sCheck("bool maps to bool", %s.kindForType("bool") $= "bool"); + sCheck("int maps to number", %s.kindForType("int") $= "number"); + sCheck("char maps to number", %s.kindForType("char") $= "number"); + // The two real-numbered kinds are separate from the whole-numbered ones + // because the row rounds on the way out, which turned a font size multiplier + // of 1.5 into a 1 and a slider value of 0.5 into a 0. + sCheck("float maps to decimal", %s.kindForType("float") $= "decimal"); + sCheck("enumval maps to enum", %s.kindForType("enumval") $= "enum"); + sCheck("Point2I maps to point", %s.kindForType("Point2I") $= "point"); + sCheck("Point2F maps to pointf", %s.kindForType("Point2F") $= "pointf"); + sCheck("Vector2 maps to pointf", %s.kindForType("Vector2") $= "pointf"); + sCheck("ColorI maps to color", %s.kindForType("ColorI") $= "color"); + sCheck("FluidColorI maps to color", %s.kindForType("FluidColorI") $= "color"); + sCheck("filename maps to file", %s.kindForType("filename") $= "file"); + sCheck("assetIdString maps to asset", %s.kindForType("assetIdString") $= "asset"); + sCheck("GuiProfile maps to profile", %s.kindForType("GuiProfile") $= "profile"); + sCheck("GuiCursor maps to hidden", %s.kindForType("GuiCursor") $= "hidden"); + sCheck("string maps to text", %s.kindForType("string") $= "text"); + + // The type names above have to be what the engine actually answers. + %btn = sMake("GuiButtonCtrl"); + sCheck("engine spells Point2I as Point2I", %btn.getFieldType("Extent") $= "Point2I"); + sCheck("engine spells TypeBool as bool", %btn.getFieldType("Visible") $= "bool"); + sCheck("engine spells TypeEnum as enumval", %btn.getFieldType("HorizSizing") $= "enumval"); + sCheck("engine spells TypeGuiProfile as GuiProfile", %btn.getFieldType("Profile") $= "GuiProfile"); + sCheck("engine spells TypeS32 as int", %btn.getFieldType("tooltipWidth") $= "int"); + sCheck("engine spells TypeColorI as ColorI", %btn.getFieldType("fontColor") $= "ColorI"); + + %sprite = sMake("GuiSpriteCtrl"); + sCheck("engine spells TypeAssetId as assetIdString", + %sprite.getFieldType("Image") $= "assetIdString"); + sCheck("engine spells TypeFluidColorI as FluidColorI", + %sprite.getFieldType("imageColor") $= "FluidColorI"); + + // A sprite names its picture three mutually exclusive ways. + sCheck("sprite defaults to Image source", %s.spriteSourceModeOf(%sprite) $= "Image"); + sCheck("Image source offers Frame", + %s.listHas(%s.spriteSourceFields("Image"), "Frame")); + sCheck("Bitmap source drops Frame", + !%s.listHas(%s.spriteSourceFields("Bitmap"), "Frame")); + + // An unknown class shows everything rather than less. + sCheck("unknown class shows text", %s.isFieldVisible("GuiNotARealCtrl", "text")); + sCheck("unknown class shows tooltip", %s.isFieldVisible("GuiNotARealCtrl", "tooltip")); + sCheck("unknown class is not bare", !%s.hasFlag("GuiNotARealCtrl", "bare")); + sCheck("unknown class still hides canSave", !%s.isFieldVisible("GuiNotARealCtrl", "canSave")); + + // Labels fall back to the field name when nothing better is registered. + sCheck("registered label used", %s.labelFor("HorizSizing") $= "Horizontal Sizing"); + sCheck("unregistered label falls back", %s.labelFor("someOddField") $= "someOddField"); + + // Sections are declared per class rather than inherited, so a subclass that + // wants its parent's section has to say so. + sCheck("tree view has both its sections", + %s.listHas(%s.sectionKeys("GuiTreeViewCtrl"), "List") && + %s.listHas(%s.sectionKeys("GuiTreeViewCtrl"), "Tree")); + // The window's six switches are an icon row in the header beside Title + // Height, not a section of six checkboxes, so Grips is all it has left. + sCheck("window has its Grips section", + %s.listHas(%s.sectionKeys("GuiWindowCtrl"), "Grips")); + sCheck("and no Window section", !%s.listHas(%s.sectionKeys("GuiWindowCtrl"), "Window")); + sCheck("its switches are named for the header instead", + getWordCount(%s.windowToggles()) == 6 && + %s.listHas(%s.windowToggles(), "canClose")); + sCheck("plain control has no sections", %s.sectionKeys("GuiControl") $= ""); + sCheck("window section titled", + %s.sectionTitle("GuiWindowCtrl", "Grips") $= "Resize Grips"); + sCheck("window grips fields", + %s.listHas(%s.sectionFields("GuiWindowCtrl", "Grips"), "resizeRightWidth")); + + echo("ISSMOKE DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/inspectorText.cs b/tests/smoke/inspectorText.cs new file mode 100644 index 000000000..edea5de69 --- /dev/null +++ b/tests/smoke/inspectorText.cs @@ -0,0 +1,396 @@ +// Category picker and text block smoke test. +// +// Both halves of one bug report: a GuiControl dropped into a panel, given the +// text "High Scores", could not be made to look like a heading. Its category +// was guessed once when it was dropped -- no text yet, so Empty -- and nothing +// re-ran the guess or let it be corrected, so the Profile drop-down had one +// entry in it. Reaching for the font size instead did not work either, because +// the pane hid all nine text fields whenever the header carried the text box +// and the header carried three of them. +// +// 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. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function tCheck(%label, %cond) +{ + if(%cond) echo("ITSMOKE PASS: " @ %label); + else echo("ITSMOKE FAIL: " @ %label); +} + +function tPane() +{ + return GuiEditor.inspectorWindow.pane; +} + +function tBind(%ctrl) +{ + %pane = tPane(); + %pane.bind(%ctrl); + return %pane; +} + +function tRowShown(%pane, %field) +{ + %row = %pane.row[%field]; + return isObject(%row) && %row.isVisible(); +} + +function tItemCount(%row) +{ + return %row.editor.getItemCount(); +} + +function tOffers(%row, %name) +{ + return %row.editor.findItemText(%name, false) >= 0; +} + +// A profile field reads back as a NAME, and in editor mode that name was never +// registered with the Sim -- so comparing what the field holds against a +// profile id is comparing a string to a number and always answering no. The +// applier already knows how to resolve one; ask it. +function tProfileOf(%ctrl) +{ + return GuiEditor.themeApplier.fieldProfile(%ctrl, "Profile"); +} + +testExec("editor/main.cs"); +schedule(2000, 0, "tStep1"); + +//----------------------------------------------------------------------------- +// The report, reproduced: a panel with a GuiControl inside it that wants to be +// a heading. +//----------------------------------------------------------------------------- + +function tStep1() +{ + ProjectManager.setProjectFolder("inspectorTextSmokeProject"); + GuiEditor.open(); + + $tTheme = GuiEditor.themeLibrary.createTheme("ITSmoke"); + tCheck("theme created", isObject($tTheme)); + GuiEditor.themeName = $tTheme.getName(); + + // The panel is a child of the simulated canvas, so it is the Gui's root and + // takes Panel. The heading is a child of it with no text yet, which is + // exactly the state the guess reads as Empty. + $tPanel = new GuiControl(); + GuiEditor.rootGui.add($tPanel); + $tHeading = new GuiControl(); + $tPanel.add($tHeading); + GuiEditor.themeApplier.applyToBranch($tPanel, $tTheme, true); + + echo("ITSMOKE: panel wears " @ tProfileOf($tPanel).getName() @ + ", heading wears " @ tProfileOf($tHeading).getName() @ + " (category '" @ tProfileOf($tHeading).category @ "')"); + + tCheck("root GuiControl took Panel", + tProfileOf($tPanel) == $tTheme.getProfile("Panel")); + tCheck("the child took Empty", + tProfileOf($tHeading) == $tTheme.getProfile("Empty")); + + // Typing the caption is what the user did next. It changes nothing about the + // profile -- the guess ran when the control was dropped. + $tHeading.text = "High Scores"; + + %pane = tBind($tHeading); + %row = %pane.header.categoryRow; + tCheck("the category row is on show", %row.isVisible()); + tCheck("and reads the category the control is on", %row.getValue() $= "Empty"); + tCheck("it offers all four", tOffers(%row, "Empty") && tOffers(%row, "Panel") && + tOffers(%row, "Label") && tOffers(%row, "Overlay")); + + // The bug: one entry, and no way to reach a Label profile. + tCheck("the profile row offers only the Empty member", + tItemCount(%pane.header.profileRow) == 1); + + schedule(200, 0, "tStep2"); +} + +//----------------------------------------------------------------------------- +// Correcting the guess. +//----------------------------------------------------------------------------- + +function tStep2() +{ + %pane = tPane(); + %row = %pane.header.categoryRow; + + %row.applyValue("Label"); + %row.commit(); + + tCheck("picking Label moved the control onto the Label profile", + tProfileOf($tHeading) == $tTheme.getProfile("Label")); + tCheck("the category row now reads Label", %row.getValue() $= "Label"); + tCheck("and the profile row offers the theme's Label member", + tOffers(%pane.header.profileRow, $tTheme.getProfile("Label").getName())); + + // The choice is recorded by the profile the control wears, so it survives a + // reselect with no state of its own. + %pane = tBind($tPanel); + %pane = tBind($tHeading); + tCheck("the category survives a reselect", + %pane.header.categoryRow.getValue() $= "Label"); + + // And back, to prove it is not one-way. + %row = %pane.header.categoryRow; + %row.applyValue("Overlay"); + %row.commit(); + tCheck("and moves again to Overlay", + tProfileOf($tHeading) == $tTheme.getProfile("Overlay")); + + %row.applyValue("Label"); + %row.commit(); + + schedule(200, 0, "tStep3"); +} + +//----------------------------------------------------------------------------- +// Only the ambiguous class gets one. Everything else is pinned by its class, +// where a picker would be a way to make a check box look like a scrollbar. +//----------------------------------------------------------------------------- + +function tStep3() +{ + $tButton = new GuiButtonCtrl(); + GuiEditor.rootGui.add($tButton); + GuiEditor.themeApplier.applyToBranch($tButton, $tTheme, true); + + %pane = tBind($tButton); + tCheck("a button has no category row", !%pane.header.categoryRow.isVisible()); + tCheck("and still offers its own profile", + tOffers(%pane.header.profileRow, $tTheme.getProfile("Button").getName())); + + schedule(200, 0, "tStep4"); +} + +//----------------------------------------------------------------------------- +// The five fields that had nowhere to go. This is the half of the report that +// was never about categories at all. +//----------------------------------------------------------------------------- + +function tStep4() +{ + %pane = tBind($tHeading); + + tCheck("font size is reachable", tRowShown(%pane, "fontSizeAdjust")); + tCheck("font color is reachable", tRowShown(%pane, "fontColor")); + tCheck("the text box is reachable", tRowShown(%pane, "text")); + + %block = %pane.activeTextBlock(); + tCheck("the block is the header's", %block == %pane.header.textBlock); + tCheck("wrap is reachable", %block.wrapButton.isVisible()); + tCheck("extend is reachable", %block.extendButton.isVisible()); + tCheck("both alignments are reachable", + %block.alignRow.isVisible() && %block.vAlignRow.isVisible()); + + // textID went back to Localization, which is the only place that builds it + // now. It used to be built twice and hidden by the text filter with the rest. + tCheck("text id is reachable", tRowShown(%pane, "textID")); + + // The size the user could not reach. A multiplier, so the value that matters + // is a fractional one: the row used to round on the way out, which would + // have made reaching the field no better than not reaching it. + %row = %pane.row["fontSizeAdjust"]; + tCheck("the font size row takes decimals", %row.kind $= "decimal"); + + %row.applyValue("1.5"); + %row.commit(); + tCheck("setting the font size reaches the control", $tHeading.fontSizeAdjust == 1.5); + tCheck("and the row reads it back whole", %row.getValue() == 1.5); + + // The arrow keys step a multiplier by a tenth rather than by one. + %row.editor.onUpArrow(); + tCheck("an arrow key steps it by a tenth", $tHeading.fontSizeAdjust == 1.6); + + // Only a box that wants the arrows to step a value may claim them. + // GuiTextEditCtrl hands a script onUpArrow the key before its own caret + // 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"); + 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")); + + %row.applyValue("2"); + %row.commit(); + + schedule(200, 0, "tStepTyping"); +} + +//----------------------------------------------------------------------------- +// Typing. The control fills in as you type, but the edit still reaches it as +// one change: the text box writes the control directly per keystroke and the +// pane writes it properly once, when the box loses focus. +//----------------------------------------------------------------------------- + +function tStepTyping() +{ + %pane = tBind($tHeading); + %block = %pane.activeTextBlock(); + %row = %pane.row["text"]; + + $tTextBefore = $tHeading.text; + tCheck("nothing is being typed yet", !%block.typing); + + // What a keystroke does: the box's buffer changes and the engine runs its + // Command. Set the buffer and call the same handler the Command names. + %row.editor.setText("High Scores!"); + %block.onTextTyped(); + + tCheck("the control filled in as we typed", $tHeading.text $= "High Scores!"); + tCheck("the edit is open", %block.typing); + tCheck("and it remembers what the control said before", + %block.textBeforeEdit $= $tTextBefore); + tCheck("and which control it belongs to", %block.typingTarget == $tHeading); + + // Another keystroke must not move the stash: the whole edit is one change. + %row.editor.setText("High Scores!!"); + %block.onTextTyped(); + tCheck("a second keystroke keeps the original text stashed", + %block.textBeforeEdit $= $tTextBefore); + + // Blur. The row commits, the pane puts back what the control held when the + // edit began, and writes the new value once. + %row.commit(); + tCheck("the commit closed the edit", !%block.typing); + tCheck("and the control kept the typed text", $tHeading.text $= "High Scores!!"); + + // A commit with nothing typed must not write anything. + %row.commit(); + tCheck("committing again is a no-op", $tHeading.text $= "High Scores!!"); + + $tHeading.text = "High Scores"; + %pane.refresh(); + + schedule(200, 0, "tStep5"); +} + +//----------------------------------------------------------------------------- +// Wrap and extend. Extend does something in both wrap states -- guiControl.cc +// grows the width when wrap is off and the height when it is on -- so it must +// not be disabled with wrap off. +//----------------------------------------------------------------------------- + +function tStep5() +{ + %pane = tPane(); + %block = %pane.activeTextBlock(); + + tCheck("extend is live while wrap is off", + !$tHeading.textWrap && %block.extendButton.isActive()); + + %block.extendButton.performClick(); + tCheck("extend reached the control", $tHeading.textExtend); + // The tip is two lines: what the switch is and how it is set, then what that + // means. The second line is the one that has to follow the wrap state, since + // extend grows a different axis depending on it. + tCheck("its tooltip names the switch and its state", + getRecord(%block.extendButton.Tooltip, 0) $= "Extend To Fit Text - On"); + tCheck("and says which way it grows", + strstr(getRecord(%block.extendButton.Tooltip, 1), "wider") >= 0); + + %block.wrapButton.performClick(); + tCheck("wrap reached the control", $tHeading.textWrap); + tCheck("and the tooltip changed with it", + strstr(getRecord(%block.extendButton.Tooltip, 1), "taller") >= 0); + + // Off again, and loaded back the same way on a rebind. + %block.wrapButton.performClick(); + %block.extendButton.performClick(); + tCheck("both cleared", !$tHeading.textWrap && !$tHeading.textExtend); + + %pane = tBind($tHeading); + %block = %pane.activeTextBlock(); + tCheck("the toggles reload from the control", + !%block.wrapButton.getValue() && !%block.extendButton.getValue()); + + schedule(200, 0, "tStep6"); +} + +//----------------------------------------------------------------------------- +// Font color, which is two fields wearing one swatch. +//----------------------------------------------------------------------------- + +function tStep6() +{ + %pane = tPane(); + %row = %pane.row["fontColor"]; + + tCheck("nothing is overridden to begin with", !$tHeading.overrideFontColor); + tCheck("so the swatch shows the profile's color", + %row.getValue() $= tProfileOf($tHeading).fontColor); + tCheck("and there is nothing to revert", !%row.resetButton.isVisible()); + + %row.editor.setColorI("10 20 30 255"); + %row.commit(); + tCheck("picking a color wrote it", $tHeading.fontColor $= "10 20 30 255"); + tCheck("and turned the override on", $tHeading.overrideFontColor); + tCheck("the revert appeared", %row.resetButton.isVisible()); + + // The revert is the only way back to the profile's color. + %pane.onProfileRowReset(%row); + tCheck("revert turned the override off", !$tHeading.overrideFontColor); + tCheck("the swatch fell back to the profile's color", + %row.getValue() $= tProfileOf($tHeading).fontColor); + tCheck("and the revert went away", !%row.resetButton.isVisible()); + + schedule(200, 0, "tStep7"); +} + +//----------------------------------------------------------------------------- +// Where the block lives, class by class. There are two of it and only ever one +// on show. +//----------------------------------------------------------------------------- + +function tStep7() +{ + $tDrop = new GuiDropDownCtrl(); + GuiEditor.rootGui.add($tDrop); + $tTree = new GuiTreeViewCtrl(); + GuiEditor.rootGui.add($tTree); + $tPage = new GuiPanelCtrl(); + GuiEditor.rootGui.add($tPage); + $tGrid = new GuiGridCtrl(); + GuiEditor.rootGui.add($tGrid); + $tSprite = new GuiSpriteCtrl(); + GuiEditor.rootGui.add($tSprite); + + %pane = tBind($tDrop); + tCheck("a drop-down's placeholder is in the header", + %pane.header.textBlock.isVisible() && !%pane.textPanel.isVisible()); + + %pane = tBind($tPage); + tCheck("a panel's header text is in the header", + %pane.header.textBlock.isVisible() && !%pane.textPanel.isVisible()); + tCheck("but its dead layout fields are not offered", + !tRowShown(%pane, "fontSizeAdjust") && + !%pane.header.textBlock.alignRow.isVisible()); + + // A list draws its items with this control's font, so the layout half is + // live even though the text field itself is never drawn. + %pane = tBind($tTree); + tCheck("a tree's item font is in the header", + %pane.header.textBlock.isVisible() && !%pane.textPanel.isVisible()); + tCheck("with no string of its own to edit", !tRowShown(%pane, "text")); + tCheck("but the font size it draws them at", tRowShown(%pane, "fontSizeAdjust")); + + %pane = tBind($tGrid); + tCheck("a grid's text is in the section", + !%pane.header.textBlock.isVisible() && %pane.textPanel.isVisible()); + + %pane = tBind($tSprite); + tCheck("a sprite gets no text block at all", + !%pane.header.textBlock.isVisible() && !%pane.textPanel.isVisible()); + + echo("ITSMOKE DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/inspectorVariants.cs b/tests/smoke/inspectorVariants.cs new file mode 100644 index 000000000..b9d4f50fd --- /dev/null +++ b/tests/smoke/inspectorVariants.cs @@ -0,0 +1,331 @@ +// Variants smoke test. The properties pane hides a control's secondary profile +// slots -- contentProfile, thumbProfile and the rest -- until there is actually +// something to choose between, and the set that decides whether a row appears +// is deliberately narrower than the set the row then offers. +// +// The rule, from the design: +// +// anchors = theme members of the slot's category +// + standalones stamped for that category +// + whatever the slot currently holds +// row appears <=> count(anchors) > 1 +// options = anchors + uncategorised ("Any") standalones +// +// 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. + +setLogMode(1); +$Scripts::ignoreDSOs = true; +setScriptExecEcho(false); +trace(false); + +function vCheck(%label, %cond) +{ + if(%cond) echo("IVSMOKE PASS: " @ %label); + else echo("IVSMOKE FAIL: " @ %label); +} + +function vPane() +{ + return GuiEditor.inspectorWindow.pane; +} + +// Rebind so the pane re-evaluates the slots against the theme as it is now. +function vRebind(%ctrl) +{ + %pane = vPane(); + %pane.bind(%ctrl); + return %pane; +} + +function vHasRow(%pane, %field) +{ + return isObject(%pane.row[%field]); +} + +function vOffers(%pane, %field, %name) +{ + %row = %pane.row[%field]; + return isObject(%row) && %row.editor.findItemText(%name, false) >= 0; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "vStep1"); + +//----------------------------------------------------------------------------- +// One member per category: no Variants rows at all. +//----------------------------------------------------------------------------- + +function vStep1() +{ + ProjectManager.setProjectFolder("inspectorVariantsSmokeProject"); + GuiEditor.open(); + + // A window is the control with the most secondary slots -- content, close, + // minimise and maximise -- so it is the one that would be noisiest if the + // threshold were wrong. + $vWindow = new GuiWindowCtrl(); + GuiEditor.rootGui.add($vWindow); + + $vTheme = GuiEditor.themeLibrary.createTheme("IVSmoke"); + vCheck("theme created", isObject($vTheme)); + + GuiEditor.themeApplier.applyToBranch($vWindow, $vTheme, true); + GuiEditor.themeName = $vTheme.getName(); + + %pane = vRebind($vWindow); + vCheck("window bound", %pane.target == $vWindow); + vCheck("no Variants section with one member per category", + !isObject(%pane.panel["Variants"])); + vCheck("no content slot row", !vHasRow(%pane, "contentProfile")); + vCheck("no close button slot row", !vHasRow(%pane, "closeButtonProfile")); + + // The header's own Profile picker exists regardless -- it is not a Variants + // row and has no threshold. + vCheck("header profile picker still there", %pane.header.profileRow.isVisible()); + + schedule(200, 0, "vStep2"); +} + +//----------------------------------------------------------------------------- +// A second WindowContent in the theme: that slot, and only that slot, appears. +//----------------------------------------------------------------------------- + +function vStep2() +{ + $vExtra = $vTheme.createProfile("WindowContent"); + vCheck("extra WindowContent created", isObject($vExtra)); + vCheck("theme reports two WindowContent members", + getWordCount($vTheme.getProfiles("WindowContent")) == 2); + + %pane = vRebind($vWindow); + vCheck("Variants section appeared", isObject(%pane.panel["Variants"])); + vCheck("content slot got a row", vHasRow(%pane, "contentProfile")); + vCheck("Variants section is visible", %pane.panel["Variants"].isVisible()); + + // Only the slot with a choice. The other three categories still have one + // member each. + vCheck("close button slot still hidden", !vHasRow(%pane, "closeButtonProfile")); + vCheck("min button slot still hidden", !vHasRow(%pane, "minButtonProfile")); + vCheck("tooltip slot still hidden", !vHasRow(%pane, "tooltipProfile")); + + // The row offers both members and starts on the one the control wears. + vCheck("row offers the default member", + vOffers(%pane, "contentProfile", $vTheme.getProfile("WindowContent").getName())); + vCheck("row offers the extra member", + vOffers(%pane, "contentProfile", $vExtra.getName())); + + %current = GuiEditor.themeApplier.fieldProfile($vWindow, "contentProfile"); + vCheck("row shows what the control wears", + isObject(%current) && %pane.row["contentProfile"].getValue() $= %current.getName()); + + schedule(200, 0, "vStep3"); +} + +//----------------------------------------------------------------------------- +// Choosing the extra, and the write going in by id rather than by name. +//----------------------------------------------------------------------------- + +function vStep3() +{ + %pane = vRebind($vWindow); + %row = %pane.row["contentProfile"]; + + // applyValue rather than setValue: setValue also records the baseline that + // tells a later commit nothing was edited. + %row.applyValue($vExtra.getName()); + %row.commit(); + + %now = GuiEditor.themeApplier.fieldProfile($vWindow, "contentProfile"); + vCheck("choosing a variant reached the control", %now == $vExtra.getId()); + + // A theme member chosen deliberately survives a re-apply: applyToControl + // skips any slot already wearing a profile from this theme. + GuiEditor.themeApplier.applyToBranch($vWindow, $vTheme, false); + %after = GuiEditor.themeApplier.fieldProfile($vWindow, "contentProfile"); + vCheck("the choice survives Set Theme", %after == $vExtra.getId()); + + schedule(200, 0, "vStep4"); +} + +//----------------------------------------------------------------------------- +// Standalones. A categorised one is an anchor; an "Any" one is only an option. +//----------------------------------------------------------------------------- + +function vStep4() +{ + %library = GuiEditor.themeLibrary; + + // Uncategorised -- what createStandalone makes, and what the Profile + // Editor shows as "Any". + $vAny = %library.createStandalone("IVAnyProfile"); + vCheck("standalone created", isObject($vAny)); + vCheck("standalone starts uncategorised", $vAny.category $= ""); + + %pane = vRebind($vWindow); + + // THE RULE: it must not have made any new row appear. + vCheck("Any standalone adds no close button row", !vHasRow(%pane, "closeButtonProfile")); + vCheck("Any standalone adds no tooltip row", !vHasRow(%pane, "tooltipProfile")); + vCheck("Any standalone adds no min button row", !vHasRow(%pane, "minButtonProfile")); + + // But it is offered where a row already exists, and in the header picker, + // because those slots are on show regardless. + vCheck("Any standalone offered in an existing Variants row", + vOffers(%pane, "contentProfile", "IVAnyProfile")); + vCheck("Any standalone offered as the control's own profile", + %pane.header.profileRow.editor.findItemText("IVAnyProfile", false) >= 0); + + // Stamp it for a category and the matching slot must appear. + $vAny.category = "WindowCloseButton"; + %pane = vRebind($vWindow); + vCheck("categorised standalone makes its slot appear", + vHasRow(%pane, "closeButtonProfile")); + vCheck("the new row offers the standalone", + vOffers(%pane, "closeButtonProfile", "IVAnyProfile")); + vCheck("other slots still hidden", !vHasRow(%pane, "minButtonProfile")); + + schedule(200, 0, "vStep5"); +} + +//----------------------------------------------------------------------------- +// What is never offered, and what the "currently assigned" term is for. +//----------------------------------------------------------------------------- + +function vStep5() +{ + %pane = vRebind($vWindow); + + // A script profile is neither a theme member nor a standalone the editor + // manages. The old inspector listed every named profile in the sim; that is + // the dropdown this pane exists to replace. + vCheck("script profiles are not offered", + !vOffers(%pane, "contentProfile", "GuiDefaultProfile")); + + // A second theme's members are not offered either, even at the right + // category -- the Gui wears one theme. + $vOther = GuiEditor.themeLibrary.createTheme("IVOther"); + %pane = vRebind($vWindow); + vCheck("another theme's member is not offered", + !vOffers(%pane, "contentProfile", $vOther.getProfile("WindowContent").getName())); + + // Unless the control is actually wearing it. The currently-assigned term + // exists so a slot holding something the theme does not offer is visible + // and changeable rather than silently stuck -- hand-edit a saved Gui to + // point one slot at another theme and that slot, and only that slot, gets a + // picker offering both. + $vWindow.setEditFieldValue("minButtonProfile", $vOther.getProfile("WindowButton").getId()); + %pane = vRebind($vWindow); + vCheck("an off-theme assignment makes its row appear", + vHasRow(%pane, "minButtonProfile")); + vCheck("and the row offers what is actually worn", + vOffers(%pane, "minButtonProfile", $vOther.getProfile("WindowButton").getName())); + + // Only that slot. maxButtonProfile shares minButtonProfile's category and + // is still on the current theme, so it stays quiet -- the current value is + // counted per slot, not per category. + vCheck("a sibling slot on the current theme stays hidden", + !vHasRow(%pane, "maxButtonProfile")); + + $vWindow.setEditFieldValue("Profile", $vOther.getProfile("Window").getId()); + %pane = vRebind($vWindow); + vCheck("the header always shows the control's own profile", + %pane.header.profileRow.getValue() $= $vOther.getProfile("Window").getName()); + + // A control with no secondary slots at all never gets the section. + $vButton = new GuiButtonCtrl(); + GuiEditor.rootGui.add($vButton); + GuiEditor.themeApplier.applyToBranch($vButton, $vTheme, true); + %pane = vRebind($vButton); + vCheck("a button has no Variants section", !isObject(%pane.panel["Variants"])); + + schedule(200, 0, "vStep6"); +} + +//----------------------------------------------------------------------------- +// The real drop order, which is where this all went wrong. +// +// GuiEditorBrain::onControlDropped adds the control -- which announces it, and +// so inspects it -- and only THEN applies the theme. A GuiWindowCtrl's +// constructor names five profiles (GuiWindowProfile, GuiWindowContentProfile +// and the rest), so the pane used to read all five as the control's current +// choice: four slots crossed the threshold, every drop-down offered a +// Gui*Profile, and the boxes showed one as selected. None of it was true a +// moment later. +//----------------------------------------------------------------------------- + +function vStep6() +{ + GuiEditor.themeName = $vTheme.getName(); + + // Exactly what the brain does, in its order. + $vDropped = new GuiWindowCtrl(); + vCheck("a fresh window wears its constructor's profile", + $vDropped.Profile $= "GuiWindowProfile"); + + GuiEditor.brain.addNewCtrl($vDropped); + GuiEditor.themeApplier.applyToBranch($vDropped, $vTheme, false); + GuiEditor.brain.postEvent("Rethemed", $vDropped); + + %pane = GuiEditor.inspectorWindow.pane; + vCheck("the drop left the pane on the dropped control", %pane.target == $vDropped); + vCheck("the control ended up on the theme", + $vDropped.Profile $= $vTheme.getProfile("Window").getName()); + + // The symptom. Not "no Variants section at all" -- by now this theme has a + // second WindowContent and there is a standalone stamped WindowCloseButton, + // so those two slots have a genuine choice and should show. The bug was the + // slots with NO choice showing, because the constructor's profile counted + // as one. WindowButton has a single member and no standalone, so its two + // slots are the clean test. + vCheck("a slot with no real choice stays hidden after a drop", + !vHasRow(%pane, "minButtonProfile") && !vHasRow(%pane, "maxButtonProfile")); + + // And no constructor default may appear anywhere, including in the rows + // that are legitimately on show. + %slots = %pane.panelFields["Variants"]; + %leaked = ""; + for(%i = 0; %i < getWordCount(%slots); %i++) + { + %field = getWord(%slots, %i); + if(%pane.row[%field].editor.findItemText("Gui" @ %field, false) >= 0) + { + %leaked = %leaked SPC %field; + } + } + vCheck("no constructor default leaked into a Variants row (" @ %leaked @ ")", + %leaked $= ""); + vCheck("content row offers the theme's member", + !vHasRow(%pane, "contentProfile") || + vOffers(%pane, "contentProfile", $vTheme.getProfile("WindowContent").getName())); + + // And the header must show what the control actually wears, not the + // constructor default it was announced with. + vCheck("the header shows the themed profile", + %pane.header.profileRow.getValue() $= $vTheme.getProfile("Window").getName()); + vCheck("the constructor default is not offered", + %pane.header.profileRow.editor.findItemText("GuiWindowProfile", false) < 0); + + schedule(200, 0, "vStep7"); +} + +// Set Theme sweeps the document; the pane has to hear about that too, and the +// list it rebuilds must not keep a ghost of the theme it just left. +function vStep7() +{ + %pane = GuiEditor.inspectorWindow.pane; + %before = $vDropped.Profile; + + GuiEditor.setTheme($vOther, false); + + vCheck("Set Theme moved the control", $vDropped.Profile !$= %before); + vCheck("the header followed Set Theme without a reselect", + %pane.header.profileRow.getValue() $= $vOther.getProfile("Window").getName()); + vCheck("the old theme's profile is not left in the list", + %pane.header.profileRow.editor.findItemText(%before, false) < 0); + + echo("IVSMOKE DONE"); + schedule(300, 0, "quit"); +} diff --git a/tests/smoke/keyboardToy.cs b/tests/smoke/keyboardToy.cs new file mode 100644 index 000000000..0d930fab0 --- /dev/null +++ b/tests/smoke/keyboardToy.cs @@ -0,0 +1,90 @@ +//----------------------------------------------------------------------------- +// The KeyboardToy comes up, and the VirtualKeyboard it exists to show comes up +// with it. +// +// The toy had rotted quietly: it called reset() on a bare "KeyboardToy" name +// that resolves to nothing, so the dialog was never pushed and the toy opened +// to an empty screen. Its label was a GuiTextCtrl, deleted from the engine in +// 2021, and two of the profiles it named went with AppCore's. None of that is +// visible until something loads the toy, which is what this does. +//----------------------------------------------------------------------------- + +setRandomSeed(); +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; + +ModuleDatabase.scanModules(testRoot("toybox")); +ModuleDatabase.LoadExplicit("AppCore"); + +function smokeCheck(%label, %condition) +{ + echo(%condition ? ("SMOKE PASS: " @ %label) : ("SMOKE FAIL: " @ %label)); +} + +createPath(testRoot("shots/")); +schedule(5000, 0, "kbStepLoad"); + +function kbStepLoad() +{ + %toy = ModuleDatabase.findModule("KeyboardToy", 1); + smokeCheck("the KeyboardToy module is there", isObject(%toy)); + + loadToy(%toy); + schedule(1000, 0, "kbStepDialog"); +} + +function kbStepDialog() +{ + // create() makes both dialogs and reset() pushes the first. Before the fix + // reset() was never reached, so this is the check that matters. + smokeCheck("the toy made its dialog", isObject(MainGameDlg)); + smokeCheck("the toy made its second dialog", isObject(ChangeUsernameDlg)); + smokeCheck("the dialog was pushed to the canvas", isObject(MainGameDlg) && MainGameDlg.isAwake()); + + // The label was a GuiTextCtrl; a plain GuiControl carries text now. + smokeCheck("the label survived losing GuiTextCtrl", isObject(UserNameTxt)); + smokeCheck("the label kept its text", UserNameTxt.getText() $= "NONAME"); + + // Every profile the two dialogs name must actually exist, or the controls + // silently fall back and the toy looks wrong rather than failing. + smokeCheck("the dialog profile exists", isObject(GuiSpriteProfile)); + smokeCheck("the label profile exists", isObject(GuiTextProfile)); + smokeCheck("the entry profile exists", isObject(GuiTextEditProfile)); + smokeCheck("the button profile exists", isObject(BlueButtonProfile)); + + schedule(500, 0, "kbStepKeyboard"); +} + +function kbStepKeyboard() +{ + // What the toy is for: the keyboard, raised the way its button raises it. + VirtualKeyboard.push(ChangeUsernameDlg, ChangeUsernameEntry); + schedule(1000, 0, "kbStepKeys"); +} + +function kbStepKeys() +{ + smokeCheck("the keyboard came up", isObject(KeyboardGui)); + smokeCheck("its keys are in the tree", isObject(KeyboardSet)); + + // The four state strips that replaced GuiImageButtonCtrl. A profile's + // imageAsset is indexed by control state, so one strip is a whole button. + smokeCheck("the key profile exists", isObject(GuiKeyboardKeyProfile)); + smokeCheck("the space bar profile exists", isObject(GuiKeyboardSpaceProfile)); + smokeCheck("the close profile exists", isObject(GuiKeyboardCloseProfile)); + smokeCheck("the caps lock profile exists", isObject(GuiKeyboardLatchedProfile)); + + screenShot(testRoot("shots/keyboardToy.png"), "PNG"); + schedule(1000, 0, "kbSmokeDone"); +} + +function kbSmokeDone() +{ + echo("SMOKE DONE"); + quit(); +} diff --git a/tests/smoke/listItems.cs b/tests/smoke/listItems.cs new file mode 100644 index 000000000..6899ff2e3 --- /dev/null +++ b/tests/smoke/listItems.cs @@ -0,0 +1,507 @@ +//----------------------------------------------------------------------------- +// Static list rows: the ones a list box or a drop down is authored with, rather +// than the ones a script fills in at runtime. +// +// An item is neither a field nor a child object, so it takes a route of its own +// to disk - TAML custom nodes - and a route of its own into a clone. This checks +// 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. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +function liCheck(%label, %condition) +{ + echo(%condition ? ("LISTITEMS PASS: " @ %label) : ("LISTITEMS FAIL: " @ %label)); +} + +function liScratch() +{ + return testRoot("shots/listItemsScratch"); +} + +function liReadFile(%file) +{ + %fo = new FileObject(); + if(!%fo.openForRead(%file)) + { + %fo.delete(); + return ""; + } + + %text = ""; + while(!%fo.isEOF()) + { + %text = %text @ %fo.readLine() @ " "; + } + %fo.close(); + %fo.delete(); + + return %text; +} + +testExec("editor/main.cs"); +schedule(2000, 0, "liStep1"); + +//----------------------------------------------------------------------------- +// The list as a string, which is what the editor and its undo stack both use. +//----------------------------------------------------------------------------- + +function liStep1() +{ + createPath(liScratch() @ "/"); + + %list = new GuiListBoxCtrl(); + + liCheck("an empty list reads as an empty string", %list.getItemList() $= ""); + + // A bare list of captions: every field after the first is left off, so each + // row keeps what LBItem's constructor gave it. + %list.setItemList("Easy" NL "Normal" NL "Hard"); + liCheck("three captions make three rows", %list.getItemCount() == 3); + liCheck("caption 0 read back", %list.getItemText(0) $= "Easy"); + liCheck("caption 2 read back", %list.getItemText(2) $= "Hard"); + liCheck("a row with no fields is active", %list.getItemActive(1)); + liCheck("a row with no fields is unselected", %list.getSelectedItem() == -1); + + // A caption with spaces in it, which is why the encoding splits on TAB and + // the parse does not go near getWord. + %list.setItemList("Two words" TAB "7" TAB "1" TAB "0" TAB "0" TAB "1 1 1 1"); + liCheck("a caption may hold spaces", %list.getItemText(0) $= "Two words"); + liCheck("ID survives the round trip", %list.getItemID(0) == 7); + + // An empty caption is a legal row - the editor's Add button makes one. + %list.setItemList("" NL "after"); + liCheck("an empty caption is still a row", %list.getItemCount() == 2); + liCheck("the row after an empty one is intact", %list.getItemText(1) $= "after"); + + %list.delete(); + + schedule(100, 0, "liStep2"); +} + +//----------------------------------------------------------------------------- +// To disk and back. Custom nodes, so this is the TAML path only - the .gui +// script writer cannot carry them, which is what the save dialog warns about. +//----------------------------------------------------------------------------- + +function liStep2() +{ + %list = new GuiListBoxCtrl() + { + Extent = "180 120"; + AllowMultipleSelections = false; + }; + %list.setItemList( + "Easy" TAB "1" TAB "1" TAB "0" TAB "0" TAB "1 1 1 1" NL + "Normal" TAB "2" TAB "1" TAB "1" TAB "0" TAB "1 1 1 1" NL + "Hard" TAB "3" TAB "0" TAB "1" TAB "1" TAB "1 0 0 1"); + + %before = %list.getItemList(); + + %file = pathConcat(liScratch(), "list.gui.taml"); + TAMLWrite(%list, %file); + + // Lower-cased before every search below. StringTable hands back the first + // spelling of a name it was ever given, so which capitalisation an attribute + // is written in is not ours to decide - "ID" comes out as "Id" - and a test + // that pinned the case would fail on a spelling that works perfectly. + %text = strlwr(liReadFile(%file)); + liCheck("the file carries an Items section", strstr(%text, "guilistboxctrl.items") != -1); + liCheck("the file carries a row", strstr(%text, "text=\"normal\"") != -1); + + // Only what differs from an LBItem's defaults is written, so an ordinary row + // is one attribute. + liCheck("a default ID is not written", strstr(%text, "id=\"0\"") == -1); + liCheck("a default Active is not written", strstr(%text, "active=\"1\"") == -1); + liCheck("a color is written where there is one", strstr(%text, "color=") != -1); + + %read = TAMLRead(%file); + liCheck("the file reads back as a list box", isObject(%read) && %read.getClassName() $= "GuiListBoxCtrl"); + liCheck("every row came back", isObject(%read) && %read.getItemCount() == 3); + liCheck("the list round trips exactly", isObject(%read) && strcmp(%read.getItemList(), %before) == 0); + liCheck("the selection came back with it", isObject(%read) && %read.getSelectedItem() != -1); + + // Named separately, because a field silently failing to come back is what a + // whole-list comparison reports least clearly. Every one of these is matched + // by a StringTable pointer on the way in, and an attribute name interned the + // case-sensitive way stops matching the parser's without saying so. + liCheck("an ID survives the file", isObject(%read) && %read.getItemID(1) == 2); + liCheck("an inactive row survives the file", isObject(%read) && !%read.getItemActive(2)); + + // The color through the record, because getItemColor is C++ only - the + // bindings expose setItemColor and clearItemColor but never a getter. + %hard = getRecord(%read.getItemList(), 2); + liCheck("a color survives the file", + getField(%hard, 4) == 1 && getField(%hard, 5) $= "1 0 0 1"); + + if(isObject(%read)) + { + %read.delete(); + } + + // A deep clone copies fields and children, and an item is neither. This is + // the path the Gui Editor's copy, cut and paste take. + %clone = %list.deepClone(); + liCheck("a deep clone carries the rows", isObject(%clone) && strcmp(%clone.getItemList(), %before) == 0); + if(isObject(%clone)) + { + %clone.delete(); + } + + %list.delete(); + + schedule(100, 0, "liStep3"); +} + +//----------------------------------------------------------------------------- +// A drop down keeps its rows in a list box that is nobody's child, so none of +// the above reaches it on its own. +//----------------------------------------------------------------------------- + +function liStep3() +{ + %drop = new GuiDropDownCtrl() + { + Extent = "140 24"; + }; + %drop.setItemList("Red" TAB "10" NL "Green" TAB "20" NL "Blue" TAB "30"); + + liCheck("a drop down takes a list", %drop.getItemCount() == 3); + liCheck("a drop down reads its rows back", %drop.getItemText(1) $= "Green"); + liCheck("a drop down keeps IDs", %drop.getItemID(2) == 30); + + %before = %drop.getItemList(); + + %file = pathConcat(liScratch(), "drop.gui.taml"); + TAMLWrite(%drop, %file); + + %text = liReadFile(%file); + liCheck("the drop down's section is named for its own class", + strstr(%text, "GuiDropDownCtrl.Items") != -1); + + %read = TAMLRead(%file); + liCheck("a drop down round trips", isObject(%read) && strcmp(%read.getItemList(), %before) == 0); + if(isObject(%read)) + { + %read.delete(); + } + + %clone = %drop.deepClone(); + liCheck("a cloned drop down carries its rows", + isObject(%clone) && strcmp(%clone.getItemList(), %before) == 0); + if(isObject(%clone)) + { + %clone.delete(); + } + + %drop.delete(); + + schedule(100, 0, "liStep4"); +} + +//----------------------------------------------------------------------------- +// The tree, which must not join in. +//----------------------------------------------------------------------------- + +function liStep4() +{ + %tree = new GuiTreeViewCtrl() + { + Extent = "180 120"; + }; + + // Reached through the base class, since nothing else would put rows in one. + %tree.setItemList("ghost" NL "rows"); + liCheck("a tree can still hold items in memory", %tree.getItemCount() == 2); + + %file = pathConcat(liScratch(), "tree.gui.taml"); + TAMLWrite(%tree, %file); + + %text = liReadFile(%file); + liCheck("a tree writes no Items section", strstr(%text, ".Items") == -1); + + %tree.delete(); + + schedule(100, 0, "liStep5"); +} + +//----------------------------------------------------------------------------- +// The Items section of the properties pane, on the real editor UI. +//----------------------------------------------------------------------------- + +function liStep5() +{ + ProjectManager.setProjectFolder("listItemsSmokeProject"); + GuiEditor.open(); + + EditorCore.open(); + EditorCore.tabBook.selectPageName("Gui Editor"); + + schedule(800, 0, "liStep6"); +} + +function liBind(%ctrl) +{ + GuiEditor.inspectorWindow.pane.bind(%ctrl); + return GuiEditor.inspectorWindow.pane; +} + +function liStep6() +{ + %pane = GuiEditor.inspectorWindow.pane; + liCheck("the pane built an Items section", isObject(%pane.itemsPanel) && isObject(%pane.itemsBlock)); + + // Which classes get one. A tree derives from a list box and must not. + liCheck("a list box has an item list", %pane.spec.hasItemList("GuiListBoxCtrl")); + liCheck("a drop down has an item list", %pane.spec.hasItemList("GuiDropDownCtrl")); + liCheck("a tree does not", !%pane.spec.hasItemList("GuiTreeViewCtrl")); + liCheck("a button does not", !%pane.spec.hasItemList("GuiButtonCtrl")); + + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + %room = GuiEditor.brain.visiblePartOf(GuiEditor.rootGui); + $liAt = (getWord(%room, 0) + 30) SPC (getWord(%room, 1) + 30); + + $liList = new GuiListBoxCtrl() { Extent = "180 120"; Position = $liAt; }; + GuiEditor.brain.onControlDropped($liList, "50 50"); + liCheck("the list box arrived on the canvas", $liList.getParent() == GuiEditor.rootGui); + + GuiEditor.undoRecorder.clear(); + + %pane = liBind($liList); + liCheck("the section shows for a list box", %pane.itemsPanel.isVisible()); + liCheck("the block bound to the list box", %pane.itemsBlock.target == $liList); + liCheck("an empty list has no rows", %pane.itemsBlock.grid.getCount() == 0); + + $liBlock = %pane.itemsBlock; + + // Add, through the Add row exactly as a click on it would. + $liBlock.nameBox.setText("Easy"); + $liBlock.onAddClicked(); + liCheck("the control took the row", $liList.getItemCount() == 1); + liCheck("with the caption typed", $liList.getItemText(0) $= "Easy"); + liCheck("adding a row is one undo step", GuiEditor.undoRecorder.undoCount() == 1); + + // The rows are rebuilt from a schedule(0), because a click that changes them + // arrives from inside one. + schedule(50, 0, "liStep7"); +} + +function liStep7() +{ + liCheck("the row appeared in the pane", $liBlock.grid.getCount() == 1); + + $liBlock.nameBox.setText("Normal"); + $liBlock.onAddClicked(); + $liBlock.nameBox.setText("Hard"); + $liBlock.onAddClicked(); + + schedule(50, 0, "liStep8"); +} + +function liStep8() +{ + liCheck("three rows", $liBlock.grid.getCount() == 3 && $liList.getItemCount() == 3); + + // The first row cannot go up and the last cannot go down. + liCheck("the first row's up arrow is off", !$liBlock.grid.getObject(0).upButton.isActive()); + liCheck("the last row's down arrow is off", !$liBlock.grid.getObject(2).downButton.isActive()); + liCheck("a middle row can go either way", + $liBlock.grid.getObject(1).upButton.isActive() && + $liBlock.grid.getObject(1).downButton.isActive()); + + // Retype a caption the way the box does: per keystroke, then a commit. + %row = $liBlock.grid.getObject(0); + %row.captionBox.setText("Simple"); + %row.onCaptionTyped(); + liCheck("typing reaches the control at once", $liList.getItemText(0) $= "Simple"); + + %steps = GuiEditor.undoRecorder.undoCount(); + %row.onCommit(); + liCheck("a retyped caption is one more undo step", + GuiEditor.undoRecorder.undoCount() == (%steps + 1)); + liCheck("and the control kept it", $liList.getItemText(0) $= "Simple"); + + // An ID, and the two switches. + %row.idBox.setText("7"); + %row.onCommit(); + liCheck("the ID was written", $liList.getItemID(0) == 7); + + %row.activeToggle.setValue(false); + $liBlock.onItemRowToggled(%row, "active"); + liCheck("a row can be turned inactive", !$liList.getItemActive(0)); + + schedule(50, 0, "liStep9"); +} + +function liStep9() +{ + // Move the top row down, which the pane does by rewriting the whole list. + $liBlock.onItemRowMove($liBlock.grid.getObject(0), 1); + + schedule(50, 0, "liStep10"); +} + +function liStep10() +{ + liCheck("the moved row swapped with the one below", + $liList.getItemText(0) $= "Normal" && $liList.getItemText(1) $= "Simple"); + liCheck("and carried its ID with it", $liList.getItemID(1) == 7); + + // Remove the middle row. + $liBlock.onItemRowRemove($liBlock.grid.getObject(1)); + + schedule(50, 0, "liStep11"); +} + +function liStep11() +{ + liCheck("the row went", $liList.getItemCount() == 2); + liCheck("the pane agrees", $liBlock.grid.getCount() == 2); + liCheck("and it was the right one", + $liList.getItemText(0) $= "Normal" && $liList.getItemText(1) $= "Hard"); + + // Every step back, then every step forward again. + $liFinal = $liList.getItemList(); + $liSteps = GuiEditor.undoRecorder.undoCount(); + + for(%i = 0; %i < $liSteps; %i++) + { + GuiEditor.Undo(); + } + liCheck("undoing everything empties the list (" @ $liList.getItemCount() @ ")", + $liList.getItemCount() == 0); + + for(%i = 0; %i < $liSteps; %i++) + { + GuiEditor.Redo(); + } + liCheck("redoing everything puts it back exactly", + strcmp($liList.getItemList(), $liFinal) == 0); + + schedule(50, 0, "liStep12"); +} + +//----------------------------------------------------------------------------- +// Only one row can start selected on a single-selection list. +//----------------------------------------------------------------------------- + +function liStep12() +{ + liCheck("the pane caught up with the replay", $liBlock.grid.getCount() == 2); + + $liList.AllowMultipleSelections = false; + + %first = $liBlock.grid.getObject(0); + %first.selectedToggle.setValue(true); + $liBlock.onItemRowToggled(%first, "selected"); + + schedule(50, 0, "liStep13"); +} + +function liStep13() +{ + %second = $liBlock.grid.getObject(1); + %second.selectedToggle.setValue(true); + $liBlock.onItemRowToggled(%second, "selected"); + + schedule(50, 0, "liStep14"); +} + +function liStep14() +{ + liCheck("the second row is the selected one", $liList.getSelectedItem() == 1); + liCheck("and the first was turned off", + !$liBlock.grid.getObject(0).selectedToggle.getValue()); + + // A drop down gets the same section, over rows that live in a list box the + // drop down owns rather than in the control the pane is bound to. + %room = GuiEditor.brain.visiblePartOf(GuiEditor.rootGui); + $liDrop = new GuiDropDownCtrl() + { + Extent = "140 24"; + Position = (getWord(%room, 0) + 30) SPC (getWord(%room, 1) + 200); + }; + GuiEditor.brain.onControlDropped($liDrop, "50 20"); + + %pane = liBind($liDrop); + liCheck("the section shows for a drop down", %pane.itemsPanel.isVisible()); + + %pane.itemsBlock.nameBox.setText("Fullscreen"); + %pane.itemsBlock.onAddClicked(); + liCheck("the drop down took the row", $liDrop.getItemCount() == 1); + liCheck("with its caption", $liDrop.getItemText(0) $= "Fullscreen"); + + // And a class that has no rows at all keeps the section out of the way. + $liButton = new GuiButtonCtrl() + { + Extent = "100 30"; + Position = (getWord(%room, 0) + 30) SPC (getWord(%room, 1) + 240); + }; + GuiEditor.brain.onControlDropped($liButton, "50 20"); + + %pane = liBind($liButton); + liCheck("the section hides for a button", !%pane.itemsPanel.isVisible()); + + schedule(100, 0, "liStep15"); +} + +//----------------------------------------------------------------------------- +// What the legacy .gui format would drop. Custom nodes are a TAML feature, and +// the script writer walks fields and children only. +//----------------------------------------------------------------------------- + +function liStep15() +{ + %summary = GuiEditor.tamlOnlyStateSummary(); + liCheck("the summary names the rows on the canvas (" @ %summary @ ")", + strstr(%summary, "rows on 2 lists") != -1); + liCheck("and says what to do about it", strstr(%summary, "Save as TAML") != -1); + + // A frame set counts too, but only once it has been split - an unsplit one + // would be rebuilt as itself. + %room = GuiEditor.brain.visiblePartOf(GuiEditor.rootGui); + // The drop point is the middle of the payload, and it has to land ON the Gui + // being edited - findHitControl answers "me" for any point at all, so the + // brain polices the boundary itself and simply refuses one that misses. + $liFrames = new GuiFrameSetCtrl() + { + Extent = "200 120"; + Position = (getWord(%room, 0) + 30) SPC (getWord(%room, 1) + 30); + }; + GuiEditor.brain.onControlDropped($liFrames, "100 60"); + liCheck("the frame set arrived", $liFrames.getParent() == GuiEditor.rootGui); + liCheck("an unsplit frame set is not counted", + strstr(GuiEditor.tamlOnlyStateSummary(), "frame") == -1); + + $liFrames.createHorizontalSplit(1); + %summary = GuiEditor.tamlOnlyStateSummary(); + liCheck("a split one is (" @ %summary @ ")", strstr(%summary, "1 frame layout") != -1); + liCheck("and the heading names both kinds", + strstr(%summary, "list rows or frame layouts") != -1); + + // And nothing to say about a document that holds none of it. + %empty = new GuiControl(); + %saved = GuiEditor.rootGui; + GuiEditor.rootGui = %empty; + liCheck("an ordinary Gui gets no warning", GuiEditor.tamlOnlyStateSummary() $= ""); + GuiEditor.rootGui = %saved; + %empty.delete(); + + schedule(100, 0, "liDone"); +} + +function liDone() +{ + echo("LISTITEMS DONE"); + quit(); +} diff --git a/tests/smoke/menuBar.cs b/tests/smoke/menuBar.cs new file mode 100644 index 000000000..aca88cbdb --- /dev/null +++ b/tests/smoke/menuBar.cs @@ -0,0 +1,516 @@ +//----------------------------------------------------------------------------- +// Authoring a GuiMenuBarCtrl in the Gui Editor. +// +// A menu item is not something the control palette offers - it means nothing +// outside a bar - so before the "+" there was no way whatsoever to put anything +// into a menu bar you dropped. Now the bar makes its own: one when it is +// dropped, one for every click on the "+" after the last menu, and one for every +// click on the "+" at the foot of an open menu. +// +// Menus nest, which is the whole difficulty. This checks both levels, that the +// dropdown follows the SELECTION rather than a toggle of its own, that a menu +// with nothing in it still offers the row that fills it, and that an item cannot +// be dragged, dropped or pasted anywhere but into a bar or another item. +// +// 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. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function mbCheck(%label, %condition) +{ + echo(%condition ? ("MENUBAR PASS: " @ %label) : ("MENUBAR FAIL: " @ %label)); +} + +function mbUndoCount() +{ + return GuiEditor.undoRecorder.undoCount(); +} + +function mbSelect(%ctrl) +{ + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select(%ctrl); +} + +// Is the inner rect wholly inside the outer one? Both are "x y width height". +function mbInside(%inner, %outer) +{ + return getWord(%inner, 0) >= getWord(%outer, 0) && + getWord(%inner, 1) >= getWord(%outer, 1) && + (getWord(%inner, 0) + getWord(%inner, 2)) <= (getWord(%outer, 0) + getWord(%outer, 2)) && + (getWord(%inner, 1) + getWord(%inner, 3)) <= (getWord(%outer, 1) + getWord(%outer, 3)); +} + +schedule(2000, 0, "mbStep1"); + +function mbStep1() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + // By id: a bare identifier in TorqueScript is a string, and everything here + // compares object handles. + $mbTheme = nameToID("PlanetX"); + mbCheck("PlanetX theme loaded", isObject($mbTheme)); + + GuiEditor.open(); + GuiEditor.setTheme($mbTheme, false); + + EditorCore.open(); + EditorCore.tabBook.selectPageName("Gui Editor"); + + schedule(800, 0, "mbStepPalette"); +} + +//----------------------------------------------------------------------------- +// The palette, which has refused menu items all along. +//----------------------------------------------------------------------------- + +function mbStepPalette() +{ + %icons = GuiEditor.controlIcons; + + mbCheck("the palette will not place a menu item", !%icons.isPlaceableClass("GuiMenuItemCtrl")); + mbCheck("but a menu bar is still offered", + strstr(%icons.keysInGroup("Advanced"), "GuiMenuBarCtrl") != -1); + + // Unlike GuiTabPageCtrl, which keeps its icon row and only loses its tile, a + // menu item has no row in the table at all - so refusedNames is the only + // thing standing between it and the sweep over the class registry. + mbCheck("a menu item has no icon row to lose", !%icons.isKnown("GuiMenuItemCtrl")); + mbCheck("and is not covered by any entry", !%icons.coversClass("GuiMenuItemCtrl")); + + schedule(300, 0, "mbStepDrop"); +} + +//----------------------------------------------------------------------------- +// Dropping a bar, which has to arrive with a menu in it. +//----------------------------------------------------------------------------- + +function mbStepDrop() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + + // Through placeControl, which is what clicking a palette tile does. NOT + // onControlDropped: that opens with a cursor test a menu bar can never pass. + // A bar owns nothing but its height - GuiMenuBarCtrl::resize throws away the + // position it is handed - so its payload sits at 0,0 whatever it is told, and + // the middle of a 300x30 rectangle at 0,0 is up in the editor's own chrome. + $mbBar = new GuiMenuBarCtrl() { Extent = "300 30"; }; + $mbBar.Position = GuiEditor.brain.centredPlacement($mbBar); + GuiEditor.brain.placeControl($mbBar); + + mbCheck("the bar arrived", $mbBar.getParent() == GuiEditor.rootGui); + mbCheck("a bar refuses the position it was given (" @ $mbBar.getPosition() @ ")", + $mbBar.getPosition() $= "0 0"); + mbCheck("carrying exactly one menu (" @ $mbBar.getCount() @ ")", $mbBar.getCount() == 1); + + $mbMenu1 = $mbBar.getObject(0); + mbCheck("which is a menu item", $mbMenu1.getClassName() $= "GuiMenuItemCtrl"); + mbCheck("captioned \"" @ $mbMenu1.getText() @ "\"", $mbMenu1.getText() $= "Menu 1"); + + // The bar's own profile, and the two slots it dresses its items out of. + // + // Not the item's own Profile: GuiMenuItemCtrl calls SimObject's + // initPersistFields rather than GuiControl's, so it has no Profile FIELD at + // all - which is the same reason the properties pane calls it "bare". Its + // profile is assigned in C++ from these slots (onChildAdded does + // setControlProfile(mMenuProfile)), so these are the thing worth checking. + mbCheck("the bar was themed on arrival (" @ $mbBar.Profile.category @ ")", + $mbBar.Profile.category $= "MenuBar"); + mbCheck("its menu slot was themed (" @ $mbBar.MenuProfile.category @ ")", + $mbBar.MenuProfile.category $= "Menu"); + mbCheck("and its menu-item slot (" @ $mbBar.MenuItemProfile.category @ ")", + $mbBar.MenuItemProfile.category $= "MenuItem"); + + mbCheck("the whole thing is one undo step (" @ mbUndoCount() @ ")", mbUndoCount() == 1); + + // After the drop's own schedule(40), so the undo is not racing it. + schedule(200, 0, "mbStepDropUndo"); +} + +function mbStepDropUndo() +{ + %trash = GuiEditor.brain.getTrash(); + + GuiEditor.Undo(); + + // getGroup, not getParent: a control sitting in the trash - a plain SimGroup + // - reads as having no parent at all. + mbCheck("undo took the bar out of the Gui", $mbBar.getGroup() == %trash); + mbCheck("with its menu still inside it", $mbMenu1.getParent() == $mbBar); + + GuiEditor.Redo(); + mbCheck("redo put the bar back", $mbBar.getParent() == GuiEditor.rootGui); + mbCheck("still holding its menu (" @ $mbBar.getCount() @ ")", $mbBar.getCount() == 1); + + schedule(300, 0, "mbStepTopLevel"); +} + +//----------------------------------------------------------------------------- +// The bar's "+", which asks GuiEditorBrain::onAddMenuItem for a top-level menu. +//----------------------------------------------------------------------------- + +function mbStepTopLevel() +{ + GuiEditor.undoRecorder.clear(); + + GuiEditor.brain.onAddMenuItem($mbBar, ""); + + mbCheck("the bar grew a menu (" @ $mbBar.getCount() @ ")", $mbBar.getCount() == 2); + + $mbMenu2 = $mbBar.getObject(1); + mbCheck("numbered on from the last (" @ $mbMenu2.getText() @ ")", $mbMenu2.getText() $= "Menu 2"); + mbCheck("the new menu is selected", GuiEditor.brain.selectionList() $= $mbMenu2); + mbCheck("and is where the next control would land", + GuiEditor.brain.getCurrentAddSet() == $mbMenu2); + + mbCheck("adding a menu is one step (" @ mbUndoCount() @ ")", mbUndoCount() == 1); + + GuiEditor.Undo(); + mbCheck("undo took it back off (" @ $mbBar.getCount() @ ")", $mbBar.getCount() == 1); + GuiEditor.Redo(); + mbCheck("redo put it back (" @ $mbBar.getCount() @ ")", $mbBar.getCount() == 2); + + schedule(300, 0, "mbStepNested"); +} + +//----------------------------------------------------------------------------- +// The dropdown's "+", which is the same call with a parent named. +//----------------------------------------------------------------------------- + +function mbStepNested() +{ + GuiEditor.undoRecorder.clear(); + + GuiEditor.brain.onAddMenuItem($mbBar, $mbMenu1); + + mbCheck("the menu grew a command (" @ $mbMenu1.getCount() @ ")", $mbMenu1.getCount() == 1); + mbCheck("the bar did not (" @ $mbBar.getCount() @ ")", $mbBar.getCount() == 2); + + %command = $mbMenu1.getObject(0); + + // Numbering is per parent, so a menu's commands start from 1 rather than + // carrying on from the bar's count. + mbCheck("numbered from 1 inside its own menu (" @ %command.getText() @ ")", + %command.getText() $= "Menu 1"); + mbCheck("adding a command is one step (" @ mbUndoCount() @ ")", mbUndoCount() == 1); + + GuiEditor.brain.onAddMenuItem($mbBar, $mbMenu1); + mbCheck("a second command counts on (" @ $mbMenu1.getObject(1).getText() @ ")", + $mbMenu1.getObject(1).getText() $= "Menu 2"); + + schedule(300, 0, "mbStepDropdown"); +} + +//----------------------------------------------------------------------------- +// Which dropdown is showing, and where its "+" row is. Derived from the +// selection rather than toggled, so the Explorer tree opens it too. +//----------------------------------------------------------------------------- + +function mbStepDropdown() +{ + %bar = mbGlobalRect($mbBar); + + mbCheck("the bar reports a \"+\" (" @ $mbBar.getAddItemRect() @ ")", + getWord($mbBar.getAddItemRect(), 2) > 0); + mbCheck("square, as an affordance rather than a menu", + getWord($mbBar.getAddItemRect(), 2) == getWord($mbBar.getAddItemRect(), 3)); + mbCheck("inside the bar it belongs to", mbInside($mbBar.getAddItemRect(), %bar)); + mbCheck("after the menus rather than before them", + getWord($mbBar.getAddItemRect(), 0) > getWord(%bar, 0)); + + // Nothing selected, no dropdown. + GuiEditor.brain.clearSelection(); + mbCheck("nothing selected means no dropdown (" @ $mbBar.getAddSubItemRect() @ ")", + getWord($mbBar.getAddSubItemRect(), 2) == 0); + + // The menu itself. + mbSelect($mbMenu1); + %row = $mbBar.getAddSubItemRect(); + mbCheck("selecting a menu opens it (" @ %row @ ")", getWord(%row, 2) > 0); + mbCheck("and its \"+\" row sits below the bar", + getWord(%row, 1) >= (getWord(%bar, 1) + getWord(%bar, 3))); + + // A command inside it keeps it open, which is what makes the dropdown usable + // at all: selecting the row you just made must not close the menu. + mbSelect($mbMenu1.getObject(0)); + mbCheck("selecting a command keeps it open (" @ $mbBar.getAddSubItemRect() @ ")", + $mbBar.getAddSubItemRect() $= %row); + + // Another menu switches it. + mbSelect($mbMenu2); + mbCheck("selecting another menu switches the dropdown", + $mbBar.getAddSubItemRect() !$= %row && + getWord($mbBar.getAddSubItemRect(), 2) > 0); + + // Menu 2 has nothing in it, and that is the case that matters: a menu the + // "+" just made has no GuiMenuListCtrl at all, so the runtime machinery could + // not draw this even if it were open. + mbCheck("an empty menu still offers a \"+\" row (" @ $mbMenu2.getCount() @ " children)", + $mbMenu2.getCount() == 0 && getWord($mbBar.getAddSubItemRect(), 2) > 0); + + // A command's own bounds are the row it is drawn in, so everything that asks + // a control where it is - the editor's selection outline most of all - gets + // the answer the user can see. Before this they were the 64x64 a GuiControl + // is constructed with, and the outline appeared nowhere near the row. + mbSelect($mbMenu1); + %command = $mbMenu1.getObject(0); + + // The dropdown is laid out in onPreRender, or on demand by either of the two + // geometry accessors. Asking for the "+" row first is what makes the rows + // current for a selection that has not been drawn yet - without it these read + // whatever the previously open menu left behind. + %addRow = $mbBar.getAddSubItemRect(); + %row = %command.getGlobalPosition() SPC %command.getExtent(); + + mbCheck("a command is not still 64x64 (" @ %command.getExtent() @ ")", + %command.getExtent() !$= "64 64"); + mbCheck("it lines up with the \"+\" row below it (" @ %row @ " vs " @ %addRow @ ")", + getWord(%row, 0) == getWord(%addRow, 0) && getWord(%row, 2) == getWord(%addRow, 2)); + mbCheck("and sits inside the bar's dropdown", + getWord(%row, 1) < getWord(%addRow, 1)); + + // The bar itself is not one of its own menus. + mbSelect($mbBar); + mbCheck("selecting the bar closes the dropdown (" @ $mbBar.getAddSubItemRect() @ ")", + getWord($mbBar.getAddSubItemRect(), 2) == 0); + + schedule(300, 0, "mbStepCaption"); +} + +//----------------------------------------------------------------------------- +// A top-level menu is exactly as wide as its caption, so the strip has to be +// re-laid whenever the text moves - including on every keystroke, which is what +// the properties pane does while a caption is being typed. +//----------------------------------------------------------------------------- + +function mbStepCaption() +{ + %menu = $mbBar.getObject(0); + %wide = getWord(%menu.getExtent(), 0); + + // A plain field assignment, which is exactly what the pane's per-keystroke + // path does - not setEditFieldValue, so nothing here is going through + // inspectPostApply. + %menu.text = "A Considerably Longer Caption"; + mbCheck("a longer caption widens the menu (" @ %wide @ " -> " @ + getWord(%menu.getExtent(), 0) @ ")", getWord(%menu.getExtent(), 0) > %wide); + + %menu.text = "M"; + mbCheck("and a shorter one narrows it again (" @ getWord(%menu.getExtent(), 0) @ ")", + getWord(%menu.getExtent(), 0) < %wide); + + // The menu after it moves too, because the strip packs left to right. + %second = $mbBar.getObject(1); + mbCheck("the menu after it moved up (" @ %second.getPosition() @ ")", + getWord(%second.getPosition(), 0) == getWord(%menu.getExtent(), 0)); + + %menu.text = "Menu 1"; + + schedule(300, 0, "mbStepSpacer"); +} + +//----------------------------------------------------------------------------- +// Separators. A single dash in the caption is the whole of one - there is no +// field for it - which is what the documentation says and what a .gui file +// carries, so it has to survive being read back as well as being typed. +//----------------------------------------------------------------------------- + +function mbStepSpacer() +{ + %menu = $mbBar.getObject(0); + %command = %menu.getObject(0); + + mbSelect(%menu); + + // Asking for the "+" row is what lays the dropdown out, and the row height is + // how a separator shows itself: it is the profile's chrome with no line of + // text in it. + $mbBar.getAddSubItemRect(); + %tall = getWord(%command.getExtent(), 1); + + %command.text = "-"; + $mbBar.getAddSubItemRect(); + + mbCheck("a dash keeps its dash (" @ %command.getText() @ ")", %command.getText() $= "-"); + mbCheck("and makes the row a thin rule (" @ %tall @ " -> " @ + getWord(%command.getExtent(), 1) @ ")", getWord(%command.getExtent(), 1) < %tall); + + // The pane has to notice it, because typing the dash is how you make one. + %pane = GuiEditor.inspectorWindow.pane; + %pane.bind(%command); + mbCheck("the pane calls it a spacer (" @ %pane.header.menuItemBlock.kindRow.getValue() @ ")", + %pane.header.menuItemBlock.kindRow.getValue() $= "spacer"); + mbCheck("and drops the fields a rule cannot use", + !%pane.header.menuItemBlock.commandRow.isVisible()); + + // And back out of it again. + %command.text = "Open"; + $mbBar.getAddSubItemRect(); + mbCheck("typing over the dash makes it a command again (" @ + getWord(%command.getExtent(), 1) @ ")", getWord(%command.getExtent(), 1) == %tall); + + %pane.bind(%command); + mbCheck("and the pane agrees (" @ %pane.header.menuItemBlock.kindRow.getValue() @ ")", + %pane.header.menuItemBlock.kindRow.getValue() $= "command"); + + // A dash on the BAR is just a caption: a rule across a menu bar would have + // nothing either side of it to separate. + %menu.text = "-"; + $mbBar.getAddSubItemRect(); + %pane.bind(%menu); + mbCheck("a dash on the bar is not a separator (" @ + %pane.header.menuItemBlock.kindRow.getValue() @ ")", + %pane.header.menuItemBlock.kindRow.getValue() $= "command"); + mbCheck("and the bar is not offered the choice", + !%pane.header.menuItemBlock.kindRow.choiceButton[3].isVisible()); + %menu.text = "Menu 1"; + + // A command inside a menu is. + %pane.bind(%command); + mbCheck("a command inside a menu is offered it", + %pane.header.menuItemBlock.kindRow.choiceButton[3].isVisible()); + + schedule(300, 0, "mbStepDelete"); +} + +function mbGlobalRect(%ctrl) +{ + return %ctrl.getGlobalPosition() SPC %ctrl.getExtent(); +} + +//----------------------------------------------------------------------------- +// Emptying a bar, which used to be permanent. +//----------------------------------------------------------------------------- + +function mbStepDelete() +{ + GuiEditor.undoRecorder.clear(); + + while($mbBar.getCount() > 0) + { + %menu = $mbBar.getObject(0); + mbSelect(%menu); + GuiEditor.brain.onObjectRemoved(%menu); + } + + mbCheck("the bar can be emptied (" @ $mbBar.getCount() @ ")", $mbBar.getCount() == 0); + mbCheck("and is still recoverable (" @ $mbBar.getAddItemRect() @ ")", + getWord($mbBar.getAddItemRect(), 2) > 0); + + GuiEditor.brain.onAddMenuItem($mbBar, ""); + mbCheck("the \"+\" refills an emptied bar (" @ $mbBar.getCount() @ ")", + $mbBar.getCount() == 1); + mbCheck("numbering from the start again (" @ $mbBar.getObject(0).getText() @ ")", + $mbBar.getObject(0).getText() $= "Menu 1"); + + schedule(300, 0, "mbStepRules"); +} + +//----------------------------------------------------------------------------- +// Where a menu item is allowed to live. Two legal kinds of parent, unlike a tab +// page, because moving a command between menus is an ordinary thing to want. +//----------------------------------------------------------------------------- + +function mbStepRules() +{ + $mbPanel = new GuiControl() { Position = "10 400"; Extent = "300 200"; }; + GuiEditor.rootGui.add($mbPanel); + + $mbButton = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "B"; }; + $mbPanel.add($mbButton); + + %menu = $mbBar.getObject(0); + GuiEditor.brain.onAddMenuItem($mbBar, %menu); + %command = %menu.getObject(0); + + mbCheck("a menu belongs in its bar", %menu.canBeChildOf($mbBar)); + mbCheck("a command belongs in its menu", %command.canBeChildOf(%menu)); + mbCheck("and in a bar too", %command.canBeChildOf($mbBar)); + mbCheck("but not in a panel", !%command.canBeChildOf($mbPanel)); + mbCheck("nor on the root", !%command.canBeChildOf(GuiEditor.rootGui)); + mbCheck("an ordinary control still goes anywhere", $mbButton.canBeChildOf($mbPanel)); + + // And what the editor may do to it once it is there. A menu item's position + // and extent are the bar's to write, so the canvas draws it an outline rather + // than eight handles you could drag to no effect. + mbCheck("a menu's geometry is not the editor's", !%menu.isGeometryEditable()); + mbCheck("nor a command's", !%command.isGeometryEditable()); + mbCheck("an ordinary control's is", $mbButton.isGeometryEditable()); + + // The canvas drag. Both halves, because a rule that refused everything would + // pass the first check on its own. + mbSelect(%command); + GuiEditor.brain.moveSelectionToCtrl($mbPanel); + mbCheck("dragging a command onto a panel leaves it alone", %command.getParent() == %menu); + + mbSelect(%command); + GuiEditor.brain.moveSelectionToCtrl($mbBar); + mbCheck("dragging it onto the bar moves it", %command.getParent() == $mbBar); + + mbSelect($mbButton); + GuiEditor.brain.moveSelectionToCtrl($mbPanel); + mbCheck("an ordinary control is not caught by the rule", + $mbButton.getParent() == $mbPanel); + + schedule(300, 0, "mbStepPaste"); +} + +function mbStepPaste() +{ + %menu = $mbBar.getObject(0); + + mbSelect(%menu); + GuiEditor.Copy(); + + // Into a panel: refused, and the clipboard is left holding it. + %before = $mbPanel.getCount(); + GuiEditor.brain.setCurrentAddSet($mbPanel); + GuiEditor.Paste(); + mbCheck("pasting a menu into a panel puts nothing there (" @ $mbPanel.getCount() @ ")", + $mbPanel.getCount() == %before); + + %before = $mbBar.getCount(); + GuiEditor.brain.setCurrentAddSet($mbBar); + GuiEditor.Paste(); + mbCheck("pasting it into a bar does (" @ $mbBar.getCount() @ ")", + $mbBar.getCount() == (%before + 1)); + + schedule(300, 0, "mbStepChrome"); +} + +//----------------------------------------------------------------------------- +// The editor's own menu bar, which is a real GuiMenuBarCtrl full of real menu +// items and must be untouched by any of this. It is not inside the Gui being +// authored, so isEditMode() is false for it. +//----------------------------------------------------------------------------- + +function mbStepChrome() +{ + mbCheck("the editor's own bar exists", isObject(EditorCore.menuBar)); + mbCheck("and has its menus (" @ EditorCore.menuBar.getCount() @ ")", + EditorCore.menuBar.getCount() > 0); + mbCheck("but no \"+\" of its own (" @ EditorCore.menuBar.getAddItemRect() @ ")", + getWord(EditorCore.menuBar.getAddItemRect(), 2) == 0); + mbCheck("and no dropdown of its own (" @ EditorCore.menuBar.getAddSubItemRect() @ ")", + getWord(EditorCore.menuBar.getAddSubItemRect(), 2) == 0); + + echo("MENUBAR DONE"); + quit(); +} diff --git a/tests/smoke/menuBarClick.cs b/tests/smoke/menuBarClick.cs new file mode 100644 index 000000000..06bdbb49d --- /dev/null +++ b/tests/smoke/menuBarClick.cs @@ -0,0 +1,239 @@ +//----------------------------------------------------------------------------- +// Clicking the menu bar's two "+" affordances, for real. +// +// menuBar.cs calls GuiEditorBrain::onAddMenuItem directly, which is the right +// test for what the editor does once asked. This is the half that asks, and all +// of it is C++ the script cannot reach: GuiMenuBarCtrl::onMouseDownEditor +// converts the mouse point into the bar's content coordinates and tests it +// against the strip's "+", then the open dropdown's "+" row, then the rows, then +// the menus. Four rectangles in a space nothing else in the file uses - the +// runtime findHitMenu reaches it a third way, through a child's render inset. +// +// The sequence is the real one: click the bar's "+", get a menu, and because the +// editor selects what it just made, that menu's dropdown is already open - so +// the second click puts the first command in it. +// +// Driven by menuBarClick.input.ps1. Neither point is hard-coded there: the +// engine works out where each "+" actually landed and hands it over in a file. A +// hard-coded click that drifted off the target would report a missing item, +// which is exactly what a broken hit test reports. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function mcCheck(%label, %condition) +{ + echo(%condition ? ("MENUCLICK PASS: " @ %label) : ("MENUCLICK FAIL: " @ %label)); +} + +// Where the engine leaves the next point for the driver to click. +function mcTargetFile() +{ + return testRoot("shots/menuBarClickTarget.txt"); +} + +function mcAimAt(%rect, %what) +{ + %x = getWord(%rect, 0) + mFloor(getWord(%rect, 2) / 2); + %y = getWord(%rect, 1) + mFloor(getWord(%rect, 3) / 2); + + %file = new FileObject(); + %file.openForWrite(mcTargetFile()); + %file.writeLine(%x SPC %y); + %file.close(); + %file.delete(); + + echo("MENUCLICK: " @ %what @ " at " @ %x SPC %y); +} + +schedule(2500, 0, "mcStep1"); + +// Through the project selector, the way a person opens a project. Calling +// GuiEditor.open() directly leaves the selector sitting on top, and a posted +// click lands on whatever is actually in front. +function mcStep1() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + schedule(2500, 0, "mcStep2"); +} + +function mcStep2() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + + createPath(testRoot("shots/")); + + schedule(1500, 0, "mcStep3"); +} + +function mcStep3() +{ + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + + // placeControl, which is what clicking a palette tile does. A menu bar pins + // itself to its parent's origin, so there is no position to choose. + $mcBar = new GuiMenuBarCtrl() { Extent = "300 30"; }; + $mcBar.Position = GuiEditor.brain.centredPlacement($mcBar); + GuiEditor.brain.placeControl($mcBar); + + // Nothing selected: a drop selects what it dropped, and the edit control + // tests its sizing knobs before it hands the click to the control. + GuiEditor.brain.clearSelection(); + + schedule(300, 0, "mcStep4"); +} + +function mcStep4() +{ + mcCheck("the bar arrived with a menu (" @ $mcBar.getCount() @ ")", $mcBar.getCount() == 1); + + %rect = $mcBar.getAddItemRect(); + mcCheck("and reports a \"+\" to aim at (" @ %rect @ ")", getWord(%rect, 2) > 0); + mcAimAt(%rect, "the bar's +"); + + GuiEditor.undoRecorder.clear(); + + // The driver posts its first click during this window. + schedule(8000, 0, "mcStep5"); +} + +function mcStep5() +{ + mcCheck("clicking the bar's \"+\" added a menu (" @ $mcBar.getCount() @ ")", + $mcBar.getCount() == 2); + + if($mcBar.getCount() != 2) + { + echo("MENUCLICK DONE"); + schedule(400, 0, "quit"); + return; + } + + $mcMenu = $mcBar.getObject(1); + mcCheck("made by the editor, not the raw C++ (" @ $mcMenu.getText() @ ")", + $mcMenu.getText() $= "Menu 2"); + mcCheck("one undo step for the click (" @ GuiEditor.undoRecorder.undoCount() @ ")", + GuiEditor.undoRecorder.undoCount() == 1); + + // The editor selected what it made, and the dropdown follows the selection - + // so the new menu is already open, with nothing in it but its "+" row. That + // is the whole point of deriving it from the selection rather than a toggle. + mcCheck("the new menu is selected", GuiEditor.brain.selectionList() $= $mcMenu); + + %rect = $mcBar.getAddSubItemRect(); + mcCheck("and its dropdown is already open (" @ %rect @ ")", getWord(%rect, 2) > 0); + mcAimAt(%rect, "the dropdown's +"); + + GuiEditor.undoRecorder.clear(); + + // And the second click. + schedule(8000, 0, "mcStep6"); +} + +function mcStep6() +{ + mcCheck("clicking the dropdown's \"+\" added a command (" @ $mcMenu.getCount() @ ")", + $mcMenu.getCount() == 1); + mcCheck("inside the menu, not on the bar (" @ $mcBar.getCount() @ ")", + $mcBar.getCount() == 2); + + if($mcMenu.getCount() == 1) + { + mcCheck("numbered from 1 in its own menu (" @ $mcMenu.getObject(0).getText() @ ")", + $mcMenu.getObject(0).getText() $= "Menu 1"); + } + + mcCheck("one undo step for that click too (" @ GuiEditor.undoRecorder.undoCount() @ ")", + GuiEditor.undoRecorder.undoCount() == 1); + + screenShot(testRoot("shots/menuBarClick.png"), "PNG"); + + schedule(300, 0, "mcStep7"); +} + +//----------------------------------------------------------------------------- +// And the editor's OWN menu bar, which is a real GuiMenuBarCtrl full of real +// menu items sitting a few pixels above everything this suite just did. +// +// It must still open the ordinary way. Nothing here touched openMenu or the +// full-canvas dialog it pushes, but the bar's findHitControl was given +// GuiControl's signature so that it overrides rather than hides - and that +// changes which control the canvas resolves a point to at runtime, not just in +// the editor. This is the check for that. +//----------------------------------------------------------------------------- + +function mcStep7() +{ + %file = mcFindItem(EditorCore.menuBar, "File"); + mcCheck("the editor has a File menu", isObject(%file)); + mcCheck("which has commands in it (" @ %file.getCount() @ ")", %file.getCount() > 0); + mcCheck("and no \"+\" of its own (" @ EditorCore.menuBar.getAddItemRect() @ ")", + getWord(EditorCore.menuBar.getAddItemRect(), 2) == 0); + + // An open menu is a dialog pushed on the canvas, so the canvas child count is + // what says whether it opened. + $mcDialogs = Canvas.getCount(); + + mcAimAt(%file.getGlobalPosition() SPC %file.getExtent(), "the editor's own File menu"); + + schedule(8000, 0, "mcStep8"); +} + +function mcStep8() +{ + mcCheck("clicking a real menu still opens it (" @ $mcDialogs @ " -> " @ + Canvas.getCount() @ ")", Canvas.getCount() == ($mcDialogs + 1)); + + screenShot(testRoot("shots/menuBarRuntime.png"), "PNG"); + + // And shut it again before leaving. An open menu holds its scroller inside a + // dialog pushed on the canvas, and quitting out from under that hangs the + // engine on the way down - which is nothing to do with this feature, but is + // a state no test should leave behind. The background catcher is full-canvas, + // so a click anywhere closes it. + mcAimAt("500 600 4 4", "somewhere else, to close it"); + + schedule(8000, 0, "mcStep9"); +} + +function mcStep9() +{ + mcCheck("clicking away closes it again (" @ Canvas.getCount() @ ")", + Canvas.getCount() == $mcDialogs); + + echo("MENUCLICK DONE"); + schedule(400, 0, "quit"); +} + +// Menu items are nested controls, so this walks rather than indexes. +function mcFindItem(%parent, %text) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + %item = %parent.getObject(%i); + if(%item.Text $= %text) + { + return %item; + } + + %found = mcFindItem(%item, %text); + if(isObject(%found)) + { + return %found; + } + } + + return 0; +} diff --git a/tests/smoke/menuBarClick.input.ps1 b/tests/smoke/menuBarClick.input.ps1 new file mode 100644 index 000000000..b0d8b4219 --- /dev/null +++ b/tests/smoke/menuBarClick.input.ps1 @@ -0,0 +1,50 @@ +# Input for menuBarClick.cs. Posts two real clicks: one on the menu bar's "+", +# and then one on the "+" row at the foot of the dropdown the first click opened. +# +# Neither point is written here. The engine works out where each "+" actually +# landed - which depends on the theme's font, the profile's borders and how wide +# the menus turned out - and leaves it in a file for this script to pick up. A +# hard-coded point that drifted off the target would report a missing item, which +# is precisely what a broken hit test reports, and the test would be lying either +# way. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +$target = Join-Path $PSScriptRoot "..\..\shots\menuBarClickTarget.txt" + +# Anything left by an earlier run would be clicked before this run has even +# placed its bar. +if (Test-Path $target) { Remove-Item $target -Force } + +function Wait-ForTarget { + param([string]$Path, [int]$Seconds = 20) + + $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 +} + +# Three rounds, each written by the engine about eight seconds before it is read +# back: the bar's "+", then the dropdown's "+" row, then the editor's own File +# menu - which is not part of the feature at all, but is the check that a real +# menu still opens the ordinary way. +$labels = @('the bar''s +', 'the dropdown''s +', 'the editor''s File menu', 'away, to close it') +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/palette.cs b/tests/smoke/palette.cs new file mode 100644 index 000000000..0f98dc007 --- /dev/null +++ b/tests/smoke/palette.cs @@ -0,0 +1,555 @@ +//----------------------------------------------------------------------------- +// The Gui Editor's control palette: the generated entry table it is built from, +// its two view modes, and the two things a tile can do. +// +// Driven through script rather than posted input on purpose. A tile's screen +// position depends on the scroll offset, which group is collapsed and how many +// columns the grid decided on, and none of that is exposed to script -- so a +// coordinate-based test would be asserting arithmetic it cannot check. The +// input-driven suites are also the flaky ones. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function palCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("PAL PASS: " @ %label); + } + else + { + $Fail++; + echo("PAL FAIL: " @ %label); + } +} + +schedule(2000, 0, "palSetup"); + +// A project, so there is a theme. Every control the editor places is themed on +// arrival, and the four faces of a bare GuiControl are told apart by the profile +// they end up wearing -- with no theme loaded, onControlDropped skips theming +// altogether and there is nothing to check. +function palSetup() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + GuiEditor.open(); + palCheck("the editor adopted a theme", GuiEditor.themeName !$= ""); + + schedule(500, 0, "palTableChecks"); +} + +//----------------------------------------------------------------------------- +// The generated table. Checked before anything is built on it, because every +// failure here shows up much later and much less legibly -- a group whose keys +// come back empty renders as a collapsible section with nothing in it. +//----------------------------------------------------------------------------- + +function palTableChecks() +{ + %icons = GuiEditor.controlIcons; + palCheck("the icon table exists", isObject(%icons)); + + %groups = %icons.groups(); + palCheck("four groups (" @ getFieldCount(%groups) @ ")", getFieldCount(%groups) == 4); + + // The one that could quietly fail: "Input & Data" carries a space and an + // ampersand, and it is used as a dynamic-field subscript. If TorqueScript + // cannot hold that, keysInGroup answers "" and the group renders empty. + %awkward = getField(%groups, 2); + palCheck("third group is named \"" @ %awkward @ "\"", %awkward $= "Input & Data"); + palCheck("a group name with a space and an ampersand still keys its entries (" @ + getFieldCount(%icons.keysInGroup(%awkward)) @ ")", + getFieldCount(%icons.keysInGroup(%awkward)) == 6); + + // Every entry lands in exactly one group, and the groups account for all of + // them -- no entry silently dropped, none listed twice. + %all = %icons.keys(); + %total = getFieldCount(%all); + palCheck("29 entries outside the fallback (" @ %total @ ")", %total == 29); + + %sum = 0; + for(%g = 0; %g < getFieldCount(%groups); %g++) + { + %group = getField(%groups, %g); + %keys = %icons.keysInGroup(%group); + %count = getFieldCount(%keys); + palCheck("group \"" @ %group @ "\" has entries (" @ %count @ ")", %count > 0); + %sum += %count; + + for(%i = 0; %i < %count; %i++) + { + %key = getField(%keys, %i); + palCheck(%key @ " reports the group it was listed under", + %icons.groupFor(%key) $= %group); + } + } + + // Every entry is either in a group or refused -- none silently dropped. A + // refused entry keeps its row, and so its frame and its label, because the + // frame IS the row index; it just isn't offered. GuiTabPageCtrl is the one + // that is: only a tab book makes a page, from the + tab it draws in the + // editor. + %refused = 0; + for(%i = 0; %i < %total; %i++) + { + %key = getField(%all, %i); + if(!%icons.isPlaceableClass(%icons.classFor(%key))) + { + %refused++; + } + } + palCheck("the table refuses one entry (" @ %refused @ ")", %refused == 1); + palCheck("Tab Page is the refused one", !%icons.isPlaceableClass("GuiTabPageCtrl")); + palCheck("a refused entry keeps its icon", %icons.isKnown("GuiTabPageCtrl")); + palCheck("a refused entry still counts as covered", %icons.coversClass("GuiTabPageCtrl")); + palCheck("every entry is either grouped or refused (" @ %sum @ " + " @ %refused @ + " of " @ %total @ ")", (%sum + %refused) == %total); + + // The fallback is reachable but never offered. + palCheck("the fallback is not in any group", %icons.groupFor("unknown") $= ""); + palCheck("the fallback is not in the key list", strstr(%all, "unknown") == -1); + palCheck("an unknown key resolves to frame 0", %icons.frameFor("NoSuchCtrl") == 0); + palCheck("an unknown key is not known", !%icons.isKnown("NoSuchCtrl")); + palCheck("a real key is known", %icons.isKnown("GuiButtonCtrl")); + + // The four faces of a bare GuiControl: same class, different category. + %faces = "Empty" TAB "Panel" TAB "Label" TAB "Overlay"; + for(%i = 0; %i < 4; %i++) + { + %face = getField(%faces, %i); + %key = "GuiControl:" @ %face; + palCheck(%key @ " builds a GuiControl", %icons.classFor(%key) $= "GuiControl"); + palCheck(%key @ " carries category " @ %face, %icons.categoryFor(%key) $= %face); + } + + // Anything else pins its own category, so it asks for none. + palCheck("an ordinary class asks for no category", %icons.categoryFor("GuiButtonCtrl") $= ""); + palCheck("a class key builds its own class", %icons.classFor("GuiSliderCtrl") $= "GuiSliderCtrl"); + + // Each threshold is that sheet's own resolution: past it the next sheet up is + // shrunk rather than this one enlarged. Both boundaries are checked from + // either side, because an off-by-one here reads as "the icons went soft" + // rather than as a failure. + palCheck("64px art takes the 64 sheet", %icons.sheetFor(64) $= "GuiEditor:controlIcons64"); + palCheck("80px art takes the 128 sheet", %icons.sheetFor(80) $= "GuiEditor:controlIcons128"); + palCheck("32px art takes the 64 sheet", %icons.sheetFor(32) $= "GuiEditor:controlIcons64"); + palCheck("17px art takes the 64 sheet", %icons.sheetFor(17) $= "GuiEditor:controlIcons64"); + palCheck("16px art takes the 16 sheet", %icons.sheetFor(16) $= "GuiEditor:controlIcons16"); + + // And every sheet it can name has to exist. A missing image renders as + // nothing rather than throwing, so an unregistered asset would show up as + // blank space in the tree and nowhere else at all. + for(%i = 0; %i < 3; %i++) + { + %size = getWord("16 64 128", %i); + %sheet = %icons.sheetFor(%size); + %asset = AssetDatabase.acquireAsset(%sheet); + palCheck(%sheet @ " is a declared asset", isObject(%asset)); + if(isObject(%asset)) + { + palCheck(%sheet @ " carries all 32 cells", %asset.getFrameCount() == 32); + AssetDatabase.releaseAsset(%sheet); + } + } + + palPaletteChecks(); +} + +//----------------------------------------------------------------------------- +// The palette the window actually built. +//----------------------------------------------------------------------------- + +function palPaletteChecks() +{ + %window = GuiEditor.ctrlListWindow; + %icons = GuiEditor.controlIcons; + + palCheck("the mode row was built", isObject(%window.modeRow)); + palCheck("the mode row offers two views", %window.modeRow.choiceCount == 2); + palCheck("it starts in grid mode", %window.mode $= "grid"); + + // A stock engine should have an icon for everything the palette can place, + // so the sweep finds nothing and there is no fifth group. When that is not + // true, say which class -- "5 groups" on its own sends the next person + // hunting. + %undrawn = ""; + %classes = enumerateConsoleClasses("GuiControl"); + for(%i = 0; %i < getFieldCount(%classes); %i++) + { + %name = trim(getField(%classes, %i)); + if(%icons.isPlaceableClass(%name) && !%icons.coversClass(%name) && + strstr(%undrawn, %name) == -1) + { + %undrawn = %undrawn SPC %name; + } + } + palCheck("every placeable class has an icon (" @ trim(%undrawn) @ ")", %undrawn $= ""); + palCheck("four groups were built (" @ %window.groupCount @ ")", %window.groupCount == 4); + + // Every group open, with its tiles in the inner grid rather than on the + // panel -- GuiExpandCtrl rewrites mVisible on direct children only. + %tiles = 0; + for(%g = 0; %g < %window.groupCount; %g++) + { + %group = %window.group[%g]; + palCheck("group " @ %g @ " is open", %group.getExpanded()); + palCheck("group " @ %g @ " has tiles", %group.tileCount > 0); + palCheck("group " @ %g @ " taller than its header (" @ + getWord(%group.getExtent(), 1) @ ")", + getWord(%group.getExtent(), 1) > $GuiEditorControlGroup::headerHeight); + %tiles += %group.tileCount; + } + // One fewer than the 29 table entries: Tab Page is refused, so it has a row + // and an icon but no tile. + palCheck("28 tiles across the groups (" @ %tiles @ ")", %tiles == 28); + palCheck("no Tab Page tile", palFindTile("GuiTabPageCtrl") == 0); + + palWidthChecks(); + palResizeChecks(); + palModeChecks(); +} + +//----------------------------------------------------------------------------- +// Dragging the frame. +// +// The static case passing means nothing on its own: a width handed out once is +// right once. The palette was fixed in script that way and it held only until +// the frame was next dragged, because "width" sizing adds the parent's CHANGE to +// a child's own width and never re-reads anything. +// +// So sweep the window across a range of widths, the way a person dragging its +// edge does, and check the invariant at every stop. Nothing calls relayout here +// on purpose -- dragging a frame does not, and if the layout only survives a +// nudge from script then it has not survived. +//----------------------------------------------------------------------------- + +function palResizeChecks() +{ + %window = GuiEditor.ctrlListWindow; + %bar = %window.scroller.scrollBarThickness; + %pos = %window.getPosition(); + %was = %window.getExtent(); + %height = getWord(%was, 1); + + %worst = ""; + %worstOver = 0; + + for(%w = 250; %w <= 430; %w += 6) + { + %window.resize(getWord(%pos, 0), getWord(%pos, 1), %w, %height); + + %scrollerW = getWord(%window.scroller.getExtent(), 0); + for(%g = 0; %g < %window.groupCount; %g++) + { + %over = (getWord(%window.group[%g].getExtent(), 0) + %bar) - %scrollerW; + if(%over > %worstOver) + { + %worstOver = %over; + %worst = "window " @ %w @ ", group " @ %g @ ": " @ + getWord(%window.group[%g].getExtent(), 0) @ " + " @ %bar @ + " is " @ %over @ " past " @ %scrollerW; + } + } + } + + %window.resize(getWord(%pos, 0), getWord(%pos, 1), getWord(%was, 0), %height); + + palCheck("no width leaves a group under the scroll bar (" @ + (%worstOver > 0 ? %worst : "31 widths clear") @ ")", %worstOver <= 0); +} + +//----------------------------------------------------------------------------- +// Room for the scroll bar. +// +// A GuiScrollCtrl subtracts its bar when it CLIPS -- applyScrollBarSpacing feeds +// renderChildControls a narrowed rect -- but never when it lays out: the content +// child keeps its full extent and is simply drawn cut off. A group that took the +// scroller's whole width therefore ran one column under the bar, and the grid, +// sizing itself from that width, fitted a column that could not be seen. +// +// The palette takes the bar off itself. These checks are what says so. +//----------------------------------------------------------------------------- + +function palWidthChecks() +{ + %window = GuiEditor.ctrlListWindow; + %bar = %window.scroller.scrollBarThickness; + %scroller = getWord(%window.scroller.getExtent(), 0); + + for(%g = 0; %g < %window.groupCount; %g++) + { + %group = %window.group[%g]; + %width = getWord(%group.getExtent(), 0); + + // The scroller's own borders are on top of this, so a group that merely + // fits inside the outer extent is still too wide. Leaving a whole bar + // spare is the part that cannot happen by accident. + palCheck("group " @ %g @ " leaves room for the bar (" @ %width @ " + " @ + %bar @ " within " @ %scroller @ ")", (%width + %bar) <= %scroller); + } + + // The grid is the thing that actually reflows, and it is sized from the + // group, so this is the assertion that the columns are countable. + %group = %window.group[0]; + palCheck("the grid is no wider than its group", + getWord(%group.grid.getExtent(), 0) <= getWord(%group.getExtent(), 0)); +} + +//----------------------------------------------------------------------------- +// Switching views, and surviving a collapse. +//----------------------------------------------------------------------------- + +function palModeChecks() +{ + %window = GuiEditor.ctrlListWindow; + %group = %window.group[0]; + %tile = %group.tile[0]; + + palCheck("grid mode shows the caption", %tile.caption.isVisible()); + palCheck("grid mode names the control, not the class", + %tile.caption.getText() $= GuiEditor.controlIcons.labelFor(%tile.key)); + palCheck("grid mode centers the caption and sits it on the floor", + %tile.caption.align $= "center" && %tile.caption.vAlign $= "bottom"); + palCheck("grid mode wraps a long name onto a second line", %tile.caption.textWrap); + + // The caption fills the tile's INNER rect -- that is both how the text finds + // the floor and how the tile measures a border it cannot ask the theme for. + // So it starts at the origin and is strictly smaller than the outer extent. + palCheck("the grid caption fills the tile", %tile.caption.getPosition() $= "0 0"); + %innerH = getWord(%tile.caption.getExtent(), 1); + palCheck("the caption measures the inner rect, not the outer (" @ %innerH @ + " inside " @ getWord(%tile.getExtent(), 1) @ ")", + %innerH < getWord(%tile.getExtent(), 1)); + + // The picture has to leave the band clear, or the second line of a long name + // draws over it. This is the assertion the cell height exists to satisfy. + %iconBottom = getWord(%tile.icon.getPosition(), 1) + getWord(%tile.icon.getExtent(), 1); + palCheck("the icon clears the caption band (" @ %iconBottom @ " vs " @ + (%innerH - $GuiEditorControlTile::gridCaption) @ ")", + %iconBottom <= (%innerH - $GuiEditorControlTile::gridCaption)); + palCheck("the icon is not pushed off the top of the tile", + getWord(%tile.icon.getPosition(), 1) >= 0); + palCheck("grid mode draws 56px art (" @ getWord(%tile.icon.getExtent(), 0) @ ")", + getWord(%tile.icon.getExtent(), 0) == $GuiEditorControlTile::gridArt); + palCheck("grid mode takes the 64 sheet", %tile.icon.Image $= "GuiEditor:controlIcons64"); + palCheck("the grid cell is tall enough for the picture and the band", + %group.grid.CellSizeY == $GuiEditorControlGroup::gridCellHeight); + + %window.setMode("rows"); + palCheck("row mode shows the caption", %tile.caption.isVisible()); + palCheck("row mode names the control, not the class", + %tile.caption.getText() $= GuiEditor.controlIcons.labelFor(%tile.key)); + palCheck("the tooltip still names the class", + %tile.tooltip $= GuiEditor.controlIcons.classFor(%tile.key)); + + // The two modes share one caption, so each has to put back what the other + // wrote. This is the half that would rot silently. + palCheck("row mode puts the caption back beside the icon", + %tile.caption.align $= "left" && %tile.caption.vAlign $= "middle"); + palCheck("row mode stops wrapping", !%tile.caption.textWrap); + palCheck("row mode draws 32px art (" @ getWord(%tile.icon.getExtent(), 0) @ ")", + getWord(%tile.icon.getExtent(), 0) == $GuiEditorControlTile::rowArt); + palCheck("row mode takes the 64 sheet", %tile.icon.Image $= "GuiEditor:controlIcons64"); + palCheck("row mode is one tile per row", + %group.grid.CellSizeX >= getWord(%group.getExtent(), 0)); + + %window.setMode("grid"); + palCheck("switching back restores the grid cell", + %group.grid.CellSizeX == $GuiEditorControlGroup::gridCell); + palCheck("switching back centers the caption again", + %tile.caption.isVisible() && %tile.caption.align $= "center" && + %tile.caption.vAlign $= "bottom" && %tile.caption.textWrap); + + // Collapsing force-writes mVisible on the panel's direct children. The tiles + // are grandchildren, so they must come back. + %group.setExpanded(false); + %window.relayout(); + %group.setExpanded(true); + %window.relayout(); + palCheck("a tile survives its group being collapsed", %tile.isVisible()); + palCheck("the group reopened", %group.getExpanded()); + + palThemeChecks(); +} + +//----------------------------------------------------------------------------- +// The editor theme. +// +// Every picture in this window is greyscale art that means nothing until it is +// modulated: on a light theme an untinted icon is a white smear on a pale +// panel. So the tint has to be set when the control is built AND set again when +// the theme changes -- and the second half is the one that goes missing, +// because it looks as though ThemeManager already does it. It does not: it +// swaps the profile OBJECT on every control that registered one, which repaints +// backgrounds and text on its own, and cannot reach a color that script has +// already copied onto a sprite. +// +// Checked against the default theme and the light one, because the default's +// text color is white -- the same color an untinted sprite draws in -- so a +// tile that is never tinted at all still looks right there and only breaks when +// someone switches. +//----------------------------------------------------------------------------- + +function palThemeChecks() +{ + %was = ThemeManager.curTheme; + + palThemeTints("as built"); + + // Lab Coat is the light theme: its text color is near-black where the + // default's is white. Assert they differ, or every check below could pass + // without a single sprite being touched. + %before = ThemeManager.activeTheme.itemSelectProfile.fontColor; + ThemeManager.setTheme(1); + %after = ThemeManager.activeTheme.itemSelectProfile.fontColor; + palCheck("the two themes really do differ (" @ %before @ " / " @ %after @ ")", + !palSameColor(%before, %after)); + + palThemeTints("after a theme change"); + + ThemeManager.setTheme(%was); + palThemeTints("after changing back"); + + palDropChecks(); +} + +function palThemeTints(%when) +{ + %window = GuiEditor.ctrlListWindow; + %theme = ThemeManager.activeTheme; + %tile = %window.group[0].tile[0]; + + // The tile is drawn on its own profile, so that is where its picture takes + // its color from -- not from the caption's label profile, which happens to + // name the same color in every theme that ships. + palCheck("a tile tints its icon " @ %when @ " (" @ %tile.icon.getImageColor() @ ")", + palSameColor(%tile.icon.getImageColor(), %theme.itemSelectProfile.fontColor)); + + // The mode row is a radio group: one button is down and one is up, and the + // two take different colors out of the same profile. Checking both is what + // catches a refresh that only ever runs for one state. + %on = %window.modeRow.choiceButton[0]; + %off = %window.modeRow.choiceButton[1]; + palCheck("the chosen mode button tints its icon " @ %when @ " (" @ + %on.icon.getImageColor() @ ")", + palSameColor(%on.icon.getImageColor(), %theme.iconButtonProfile.fontColorHL)); + palCheck("the other mode button tints its icon " @ %when @ " (" @ + %off.icon.getImageColor() @ ")", + palSameColor(%off.icon.getImageColor(), %theme.iconButtonProfile.fontColor)); +} + +// TypeColorI reads back as a stock color NAME whenever the components match one, +// so a white profile color answers "White" while the sprite that was set from it +// answers "255 255 255 255". Comparing the two as strings fails on exactly the +// colors a theme is most likely to use. +function palSameColor(%a, %b) +{ + return palColorI(%a) $= palColorI(%b); +} + +function palColorI(%color) +{ + return isStockColor(%color) ? getStockColorI(%color) : %color; +} + +//----------------------------------------------------------------------------- +// The two gestures. A click is a drop that never moved, and it has to reach the +// document by the same path a drag does -- that is where theming, selection and +// undo recording live. +//----------------------------------------------------------------------------- + +function palDropChecks() +{ + %window = GuiEditor.ctrlListWindow; + %root = GuiEditor.rootGui; + %before = %root.getCount(); + + %tile = palFindTile("GuiButtonCtrl"); + palCheck("found the button tile", isObject(%tile)); + + %depth = GuiEditor.undoRecorder.undoCount(); + %tile.onClick(); + + palCheck("clicking a tile added a control (" @ %root.getCount() @ ")", + %root.getCount() == %before + 1); + %added = %root.getObject(%root.getCount() - 1); + palCheck("it built the class the tile names", %added.getClassName() $= "GuiButtonCtrl"); + + // The reason a click is routed through onControlDropped rather than adding + // the control itself: that path reaches GuiEditCtrl::addNewControl, which + // fires onAddNewCtrl, which is where the recorder hooks in. If a click ever + // grows its own way into the document, this is the check that notices. + palCheck("clicking a tile is one undo step (" @ + (GuiEditor.undoRecorder.undoCount() - %depth) @ ")", + GuiEditor.undoRecorder.undoCount() == %depth + 1); + + GuiEditor.Undo(); + palCheck("undoing a click takes the control back out (" @ %root.getCount() @ ")", + %root.getCount() == %before); + GuiEditor.Redo(); + palCheck("and redo puts it back", %root.getCount() == %before + 1); + %added = %root.getObject(%root.getCount() - 1); + palCheck("redo restores the same class", %added.getClassName() $= "GuiButtonCtrl"); + + // A drag ends with a mouse-up over the tile, which fires onClick as well -- + // a button keeps mDepressed through a drag. One gesture, one control. + %count = %root.getCount(); + %tile.dragged = true; + %tile.onClick(); + palCheck("a click that ended a drag adds nothing", %root.getCount() == %count); + palCheck("and the drag flag is cleared afterwards", !%tile.dragged); + + // The four faces of a bare GuiControl: the palette says which, so the + // applier must not fall back to guessing. + %faces = "Empty" TAB "Panel" TAB "Label" TAB "Overlay"; + for(%i = 0; %i < 4; %i++) + { + %face = getField(%faces, %i); + %tile = palFindTile("GuiControl:" @ %face); + palCheck("found the " @ %face @ " tile", isObject(%tile)); + + %tile.onClick(); + %ctrl = %root.getObject(%root.getCount() - 1); + palCheck(%face @ " dropped a bare GuiControl", %ctrl.getClassName() $= "GuiControl"); + palCheck(%face @ " wears a " @ %face @ " profile (" @ %ctrl.Profile.category @ ")", + %ctrl.Profile.category $= %face); + palCheck(%face @ " consumed its request", %ctrl.paletteCategory $= ""); + } + + echo("PAL DONE " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} + +function palFindTile(%key) +{ + %window = GuiEditor.ctrlListWindow; + for(%g = 0; %g < %window.groupCount; %g++) + { + %group = %window.group[%g]; + for(%i = 0; %i < %group.tileCount; %i++) + { + if(%group.tile[%i].key $= %key) + { + return %group.tile[%i]; + } + } + } + return 0; +} diff --git a/tests/smoke/planetX.cs b/tests/smoke/planetX.cs index e9eefcff2..a93e3d3a5 100644 --- a/tests/smoke/planetX.cs +++ b/tests/smoke/planetX.cs @@ -20,6 +20,7 @@ function smokeCheck(%label, %condition) echo(%condition ? ("SMOKE PASS: " @ %label) : ("SMOKE FAIL: " @ %label)); } +createPath(testRoot("shots/")); schedule(4000, 0, "planetXSmoke"); function planetXSmoke() @@ -29,6 +30,37 @@ function planetXSmoke() smokeCheck("AppCore no longer creates GuiWindowProfile", !isObject(GuiWindowProfile)); smokeCheck("AppCore still creates its cursors", isObject(DefaultCursor) && isObject(EditCursor)); + // Cursors come from the theme now. The canonical names still answer -- the + // engine hard-codes them for any control that names no cursor of its own -- + // but what they hold is a copy of the theme's member, art and tint included. + smokeCheck("the theme owns a cursor per category", + isObject(PlanetXDefaultCursor) && isObject(PlanetXEditCursor) && isObject(PlanetXNWSECursor)); + // Either the recipe's tint or one the project chose. Asserting it always + // equals colorForeground would forbid the override the pane exists to make; + // that the tint tracks the palette when NOT overridden is covered by + // GuiProfileThemeTests and smoke/cursorPane. + smokeCheck("cursor tint is the palette's, or a deliberate override", + PlanetX.isFieldOverridden(PlanetXDefaultCursor, "color") || + PlanetXDefaultCursor.color $= PlanetX.colorForeground); + smokeCheck("theme cursors have their own art", + strstr(PlanetXDefaultCursor.bitmapName, "themes/cursors/PlanetX") >= 0); + smokeCheck("the art was seeded beside the theme", + isDirectory(testRoot("PlanetX/themes/cursors/PlanetX"))); + smokeCheck("the canonical names carry the theme's cursors", + DefaultCursor.bitmapName $= PlanetXDefaultCursor.bitmapName && + DefaultCursor.hotSpot $= PlanetXDefaultCursor.hotSpot && + DefaultCursor.color $= PlanetXDefaultCursor.color); + smokeCheck("an installed copy is not mistaken for a theme member", DefaultCursor.category $= ""); + + // Counted relative to what the project's own theme file holds, which is the + // user's to change: PlanetX already ships an extra Default cursor of its own. + %before = getWordCount(PlanetX.getCursors("Default")); + %extra = PlanetX.createCursor("Default"); + %extra.bitmapName = "unitTestArt/other.png"; + smokeCheck("a category can hold another cursor", getWordCount(PlanetX.getCursors("Default")) == (%before + 1)); + PlanetX.removeCursor(%extra); + smokeCheck("an extra cursor can be removed again", getWordCount(PlanetX.getCursors("Default")) == %before); + smokeCheck("the PlanetX theme loaded", isObject(PlanetX)); smokeCheck("theme button profile", isObject(PlanetXButtonProfile)); smokeCheck("theme window profile", isObject(PlanetXWindowProfile)); @@ -51,6 +83,21 @@ function planetXSmoke() smokeCheck("the title screen is up", isObject(PlanetXTitle)); smokeCheck("title wears a theme profile", PlanetXTitle.getFieldValue("Profile") $= "PlanetXEmptyProfile"); + // The upgrade catalog is named, not classed: a name IS a namespace, so onAdd + // and every method reach upgrades.cs through it. Saying the same word again as + // a class would only ask the namespace to become its own parent. + smokeCheck("the upgrade catalog is up", isObject(PlanetXUpgrades)); + smokeCheck("its onAdd ran through the name", getWordCount(PlanetXUpgrades.keys) > 0); + smokeCheck("its methods resolve by name", PlanetXUpgrades.isEligible("damage", 1)); + + // One module namespace holds every asset family, so a sound and a particle + // cannot both be "playerDeath" - whichever is scanned second is dropped, and + // the id then answers with the wrong kind of asset entirely. + smokeCheck("the death sound is an audio asset", + AssetDatabase.getAssetType("PlanetXGame:playerDeathBurst") $= "AudioAsset"); + smokeCheck("the death effect is still a particle asset", + AssetDatabase.getAssetType("PlanetXGame:playerDeath") $= "ParticleAsset"); + screenShot(testRoot("shots/planetXThemeSmoke.png"), "PNG"); schedule(1000, 0, "planetXSmokeDone"); } diff --git a/tests/smoke/reparent.cs b/tests/smoke/reparent.cs new file mode 100644 index 000000000..2f99776b4 --- /dev/null +++ b/tests/smoke/reparent.cs @@ -0,0 +1,360 @@ +//----------------------------------------------------------------------------- +// Moving a control from a large container into a small one, by each of the two +// gestures that can do it, in each of the sizing modes. +// +// the canvas drag the control until the pointer is over another container. +// GuiEditCtrl::moveSelectionToCtrl reparents it and then puts it +// back under the pointer, so where it ends up is settled and the +// only question is what happened to its SIZE. +// +// the tree drag its row onto another branch. There is no pointer in that +// gesture, so nothing supplies a position: the control keeps the +// local one it had in its old parent, and a small enough new +// parent can leave it entirely outside. +// +// Both were wrong for "scale". A scaled control caches the proportion of its +// parent it occupies, and nothing cleared that cache when it changed parent, so +// the old parent's proportion was applied to the new parent's extent -- a button +// 200 wide arriving in a container a quarter the width came out 50 wide. That +// half is engine-side and is pinned down properly in +// engine/source/testing/tests/guiControlReparentTests.cc, which needs no canvas. +// It is checked again here because this is the real editor doing it, through the +// real gesture code, to a real themed control whose profile has borders. +// +// The rescue is the half that only exists here: GuiEditorExplorerTree's +// onPostReorder pulls a stranded control back into view before the undo step is +// committed, so one Ctrl+Z puts it back in its old parent at its old position. +// +// Neither drag is posted. A real one needs startDragging, which mouse-locks the +// canvas, and the tree's needs mDragIndex state that only a genuine touch +// sequence sets up -- so each gesture is driven at the seam its own code uses: +// moveSelectionToCtrl for the canvas, and onPreReorder / add / onPostReorder for +// the tree, which is exactly what GuiTreeViewCtrl::reorderFromDrag does around +// its own move. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function rpCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("RPAR PASS: " @ %label); + } + else + { + $Fail++; + echo("RPAR FAIL: " @ %label); + } +} + +function rpSame(%label, %got, %want) +{ + rpCheck(%label @ " (" @ %got @ ")", %got $= %want); +} + +schedule(2000, 0, "rpSetup"); + +// A project, so there is a theme. An unthemed control wears its constructor's +// profiles, and the whole point of running this in the editor rather than in a +// unit test is that the containers here have real borders and so an inner rect +// that is smaller than their extent. +function rpSetup() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + GuiEditor.open(); + schedule(500, 0, "rpBuild"); +} + +//----------------------------------------------------------------------------- +// The document: one large container and one small one, side by side on the root. +//----------------------------------------------------------------------------- + +function rpBuild() +{ + $rpBig = rpContainer("20 20", "600 400"); + $rpSmall = rpContainer("650 20", "100 80"); + + rpCheck("the large container is on the root", $rpBig.getGroup() == GuiEditor.rootGui); + rpCheck("the small container is on the root", $rpSmall.getGroup() == GuiEditor.rootGui); + + rpCanvasDrag(); +} + +function rpContainer(%pos, %ext) +{ + %ctrl = new GuiControl() + { + Position = %pos; + Extent = %ext; + isContainer = true; + }; + + // Through the brain, so the control arrives the way a dropped one does: added + // to the current add set, themed, announced, and listed in the tree. + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + GuiEditor.brain.acceptControl(%ctrl); + + return %ctrl; +} + +// A fresh button in the large container, in the sizing mode under test. Fresh +// each time: a control that has already been moved once has a recharged +// proportion cache, and the bug is about the FIRST move. +function rpButton(%pos, %horiz, %vert) +{ + %button = new GuiButtonCtrl() + { + Position = %pos; + Extent = "200 40"; + Text = "Button"; + }; + + GuiEditor.brain.setCurrentAddSet($rpBig); + GuiEditor.brain.acceptControl(%button); + + %button.HorizSizing = %horiz; + %button.VertSizing = %vert; + + return %button; +} + +// Done with a control, between cases. Through the editor rather than a bare +// delete(): a control that is still the selection when it goes leaves the brain +// and every pane holding an id that no longer answers. This is the route the +// Delete key takes, and it puts the control in the trash rather than destroying +// it, which is also what keeps undo coherent. +function rpDiscard(%button) +{ + GuiEditor.brain.onInspect(%button); + GuiEditor.brain.deleteSelection(); + GuiEditor.brain.onDelete(); +} + +//----------------------------------------------------------------------------- +// The canvas drag. What has to survive it is the SIZE: the position is the +// gesture's to decide and it has already decided, by putting the control back +// under the pointer it was dragged with. +//----------------------------------------------------------------------------- + +function rpCanvasDrag() +{ + rpCanvasCase("anchored", "anchorLeft", "anchorTop"); + rpCanvasCase("width/height", "width", "height"); + rpCanvasCase("scale", "scale", "scale"); + + rpCanvasOwned("center", "center", "center", true); + rpCanvasOwned("fill", "fill", "fill", false); + + schedule(100, 0, "rpTreeDrag"); +} + +// The modes that keep their own geometry. A move must change neither the extent +// nor where the control appears on screen. +function rpCanvasCase(%mode, %horiz, %vert) +{ + %button = rpButton("100 100", %horiz, %vert); + + %extent = %button.getExtent(); + %where = %button.getGlobalPosition(); + + GuiEditor.brain.onInspect(%button); + GuiEditor.brain.moveSelectionToCtrl($rpSmall); + + rpCheck("canvas " @ %mode @ ": the control changed parent", + %button.getGroup() == $rpSmall); + rpSame("canvas " @ %mode @ ": the extent is the one it was dragged at", + %button.getExtent(), %extent); + rpSame("canvas " @ %mode @ ": and it is still where it was dropped", + %button.getGlobalPosition(), %where); + + rpDiscard(%button); +} + +// The two modes that compute their geometry from the parent every layout, and +// so are expected to move rather than to stay. +// +// What they are held to is that they settled against the container they are in +// NOW: running the layout again must change nothing. A control still carrying +// the old parent's answer fails that immediately, and unlike a hard-coded +// coordinate it does not need this test to know what the container's borders +// cost -- which is the whole reason these two are checked in the real themed +// editor rather than only in the unit suite. +// +// center is in this group for its POSITION only. It centers a control; it does +// not resize one, so a 200-wide button centered in a container half that wide +// stays 200 wide and hangs out of both sides. That is correct, and it is why +// this cannot simply assert that the control fits. +function rpCanvasOwned(%mode, %horiz, %vert, %keepsExtent) +{ + %button = rpButton("100 100", %horiz, %vert); + %extent = %button.getExtent(); + + GuiEditor.brain.onInspect(%button); + GuiEditor.brain.moveSelectionToCtrl($rpSmall); + + rpCheck("canvas " @ %mode @ ": the control changed parent", + %button.getGroup() == $rpSmall); + rpSettled("canvas " @ %mode, %button); + + if(%keepsExtent) + { + rpSame("canvas " @ %mode @ ": centering moves a control, it does not resize one", + %button.getExtent(), %extent); + } + else + { + rpCheck("canvas " @ %mode @ ": it took the new container's width, not the old one's", + getWord(%button.getExtent(), 0) < getWord($rpBig.getExtent(), 0)); + rpCheck("canvas " @ %mode @ ": and its height", + getWord(%button.getExtent(), 1) < getWord($rpBig.getExtent(), 1)); + } + + rpDiscard(%button); +} + +// Re-running the layout against the parent the control has now must be a no-op. +// applySizing is parentResized with a zero delta, so the modes that respond to a +// change have nothing to respond to and the two that describe a position simply +// reassert it -- which is exactly the question being asked. +function rpSettled(%label, %ctrl) +{ + %was = %ctrl.getPosition() SPC %ctrl.getExtent(); + %ctrl.applySizing(); + + rpSame(%label @ ": the move left it where a fresh layout would put it", + %ctrl.getPosition() SPC %ctrl.getExtent(), %was); +} + +//----------------------------------------------------------------------------- +// The tree drag. Here the position is nobody's to decide, so it is the position +// that is at stake -- and the extent has to hold as well. +//----------------------------------------------------------------------------- + +function rpTreeDrag() +{ + // Both axes outside the small container: 300 across is past a 100-wide one, + // and 100 down is past an 80-tall one. + rpTreeCase("anchored", "anchorLeft", "anchorTop", "300 100", "0 0"); + rpTreeCase("width/height", "width", "height", "300 100", "0 0"); + rpTreeCase("scale", "scale", "scale", "300 100", "0 0"); + + // One axis outside. 20 down is inside 80 even once the container's borders + // have taken their share, so it is kept: a control that was 20 pixels down is + // still 20 pixels down. + rpTreeCase("one axis", "anchorLeft", "anchorTop", "300 20", "0 20"); + + // Inside already, and not to be touched. + rpTreeCase("already visible", "anchorLeft", "anchorTop", "10 10", "10 10"); + + // The two that place themselves need no rescue, and must not get one: a + // control the layout has already put somewhere is not stranded, whatever its + // coordinates read. fill in particular sits at 0,0 with the parent's whole + // inner extent, which no rescue would touch, and center can legitimately be + // at a NEGATIVE position when the control is wider than the container. + rpTreeOwned("center", "center", "center"); + rpTreeOwned("fill", "fill", "fill"); + + schedule(100, 0, "rpUndo"); +} + +function rpTreeOwned(%mode, %horiz, %vert) +{ + %button = rpButton("300 100", %horiz, %vert); + + rpTreeMove(%button, $rpSmall); + + rpCheck("tree " @ %mode @ ": the control changed parent", + %button.getGroup() == $rpSmall); + rpSettled("tree " @ %mode, %button); + + rpDiscard(%button); +} + +function rpTreeCase(%mode, %horiz, %vert, %at, %expect) +{ + %button = rpButton(%at, %horiz, %vert); + %extent = %button.getExtent(); + + rpTreeMove(%button, $rpSmall); + + rpCheck("tree " @ %mode @ ": the control changed parent", + %button.getGroup() == $rpSmall); + rpSame("tree " @ %mode @ ": the extent survived the move", + %button.getExtent(), %extent); + rpSame("tree " @ %mode @ ": it is somewhere the user can see", + %button.getPosition(), %expect); + + rpDiscard(%button); +} + +// What GuiTreeViewCtrl::reorderFromDrag does around its own move, with the +// selection set the way a drag would have left it. +function rpTreeMove(%ctrl, %target) +{ + %tree = GuiEditor.explorerWindow.tree; + + %index = %tree.findItemID(%ctrl); + rpCheck("the tree has a row for the control", %index != -1); + + %tree.clearSelection(); + %tree.setSelected(%index, true); + + %tree.onPreReorder(); + %target.add(%ctrl); + %tree.onPostReorder(); + + %tree.refresh(); +} + +//----------------------------------------------------------------------------- +// Undo. The rescue runs before commitHierarchy, so the corrected position is +// what the undo step records as the "after" -- which means one step puts the +// control back in its old parent AT ITS OLD POSITION, rather than leaving it +// rescued somewhere it was never placed. +//----------------------------------------------------------------------------- + +function rpUndo() +{ + %button = rpButton("300 100", "anchorLeft", "anchorTop"); + %extent = %button.getExtent(); + + rpTreeMove(%button, $rpSmall); + rpSame("undo: the move stranded it and the rescue caught it", + %button.getPosition(), "0 0"); + + GuiEditor.Undo(); + + rpCheck("undo put the control back in the container it came from", + %button.getGroup() == $rpBig); + rpSame("undo put it back where it was, not where it was rescued to", + %button.getPosition(), "300 100"); + rpSame("undo left the extent alone", %button.getExtent(), %extent); + + // And forward again, because a rescue that only survives one direction is a + // rescue the user loses by pressing Ctrl+Y. + GuiEditor.Redo(); + + rpCheck("redo moved it back into the small container", + %button.getGroup() == $rpSmall); + rpSame("redo restored the rescued position", %button.getPosition(), "0 0"); + + echo("RPAR DONE " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} diff --git a/tests/smoke/saveDialog.cs b/tests/smoke/saveDialog.cs new file mode 100644 index 000000000..4abb486d0 --- /dev/null +++ b/tests/smoke/saveDialog.cs @@ -0,0 +1,93 @@ +//----------------------------------------------------------------------------- +// Save Gui As, and the one thing its form owes the person filling it in: the +// Save button says whether what they have typed can be saved. +// +// Nothing here saves. Validate is the whole subject, and the folder it is +// pointed at is real content in the PlanetX project -- writing a file into that +// to prove a button is grey would be a poor trade. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function sdCheck(%label, %condition) +{ + echo(%condition ? ("SAVED PASS: " @ %label) : ("SAVED FAIL: " @ %label)); +} + +// A dialog is pushed onto the Canvas and nothing keeps a handle to it, so it is +// found the way it is displayed: as the Canvas's newest child. +function sdDialog(%class) +{ + for(%i = Canvas.getCount() - 1; %i >= 0; %i--) + { + %obj = Canvas.getObject(%i); + if(%obj.class $= %class) + { + return %obj; + } + } + + return 0; +} + +schedule(2000, 0, "sdSetup"); + +function sdSetup() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + GuiEditor.open(); + + schedule(300, 0, "sdStepValidate"); +} + +function sdStepValidate() +{ + GuiEditor.SaveGuiAs(); + + $sdDialog = sdDialog("GuiEditorSaveGuiDialog"); + sdCheck("the Save Gui dialog opened", isObject($sdDialog)); + + // A Gui that has never been saved opens the form with no target folder, which + // is the first thing it asks for. + sdCheck("an unfilled form does not validate", !$sdDialog.validate()); + sdCheck("and Save is not offered", !$sdDialog.saveButton.isActive()); + + // A real module folder, which is what the form is holding out for: it refuses + // anything outside a module, and anything inside a library one. + $sdDialog.folderBox.setText("PlanetX/PlanetXGame/gui"); + $sdDialog.guiNameBox.setText("smokeSaveDialog.gui"); + + sdCheck("a filled form validates", $sdDialog.validate()); + sdCheck("and Save is offered", $sdDialog.saveButton.isActive()); + + // And back, because a form that only ever enables is a form that never told + // the truth in the first place. + $sdDialog.guiNameBox.setText(""); + + sdCheck("clearing the name invalidates it again", !$sdDialog.validate()); + sdCheck("and Save is withdrawn", !$sdDialog.saveButton.isActive()); + + schedule(300, 0, "sdDone"); +} + +function sdDone() +{ + // Closed rather than left standing, so the process exits through the same + // path a person's Cancel would take. + $sdDialog.onClose(); + + echo("SAVED DONE"); + quit(); +} diff --git a/tests/smoke/standalone.cs b/tests/smoke/standalone.cs index e4869f27d..237a25aa6 100644 --- a/tests/smoke/standalone.cs +++ b/tests/smoke/standalone.cs @@ -63,6 +63,18 @@ function dropdownOffers(%ctrl, %text) return false; } +// Bind the Gui Editor's properties pane to a control and ask what its Profile +// picker offers. This used to read the native GuiInspector's dropdown, which +// listed every named profile in the sim; the pane offers the candidates for the +// control's category instead, so the question is the same but the place to ask +// it moved. +function paneOffers(%ctrl, %text) +{ + %pane = GuiEditor.inspectorWindow.pane; + %pane.bind(%ctrl); + return dropdownOffers(%pane.header.profileRow.editor, %text); +} + function previewSampleClass(%dialog, %index) { %stage = %dialog.preview.stage; @@ -277,16 +289,27 @@ function reproStep5() Extent = "100 30"; Text = "Probe"; }; - GuiEditor.inspectorWindow.inspector.inspect(%button); - - smokeCheck("inspector offers a known engine profile", - dropdownOffers(GuiEditor.inspectorWindow.inspector, "GuiDefaultProfile")); - smokeCheck("inspector offers the standalone profile", - dropdownOffers(GuiEditor.inspectorWindow.inspector, "RubySwitch")); - smokeCheck("inspector offers the migrated legacy profile", - dropdownOffers(GuiEditor.inspectorWindow.inspector, "LegacyProfile")); - - GuiEditor.inspectorWindow.inspector.clear(); + // RubySwitch is stamped for the Button category, which is what a + // GuiButtonCtrl's own Profile slot asks for, so it is a candidate. + smokeCheck("pane offers the standalone profile", + paneOffers(%button, "RubySwitch")); + + // LegacyProfile carries no category at all -- what the Profile Editor shows + // as "Any". Those are offered as a control's main profile (the slot exists + // regardless, so listing one costs nothing) but never make a secondary + // Variants slot appear, which is the rule that keeps one uncategorised + // profile from sprouting a row on every slot of every control. + smokeCheck("pane offers the uncategorised legacy profile", + paneOffers(%button, "LegacyProfile")); + + // Deliberately NOT offered any more. GuiDefaultProfile is a script profile, + // neither a theme member nor a standalone the editor manages; the old + // inspector listed every named profile in the sim, which is the flat + // several-hundred-entry dropdown the pane exists to replace. + smokeCheck("pane does not offer a bare script profile", + !paneOffers(%button, "GuiDefaultProfile")); + + GuiEditor.inspectorWindow.pane.unbind(); %button.delete(); // Reopen for the delete pass, and give the profile a custom border first: @@ -351,12 +374,11 @@ function reproStep7() Extent = "100 30"; Text = "Probe"; }; - GuiEditor.inspectorWindow.inspector.inspect(%button); - smokeCheck("inspector no longer offers the deleted profile", - !dropdownOffers(GuiEditor.inspectorWindow.inspector, "RubySwitch")); - smokeCheck("inspector still offers the surviving profile", - dropdownOffers(GuiEditor.inspectorWindow.inspector, "LegacyProfile")); - GuiEditor.inspectorWindow.inspector.clear(); + smokeCheck("pane no longer offers the deleted profile", + !paneOffers(%button, "RubySwitch")); + smokeCheck("pane still offers the surviving profile", + paneOffers(%button, "LegacyProfile")); + GuiEditor.inspectorWindow.pane.unbind(); %button.delete(); %names = "RubyButton" TAB "RubySwitch" TAB "LegacyProfile"; diff --git a/tests/smoke/tabBook.cs b/tests/smoke/tabBook.cs new file mode 100644 index 000000000..beaa0735e --- /dev/null +++ b/tests/smoke/tabBook.cs @@ -0,0 +1,405 @@ +//----------------------------------------------------------------------------- +// Authoring a GuiTabBookCtrl in the Gui Editor. +// +// A tab page is the one control the palette will not offer, because it is the +// one control that means nothing outside its container. So the book makes its +// own: one when it is dropped, and one for every click on the "+" tab it draws +// at the end of its strip while the Gui is being authored. This checks that the +// palette really has stopped offering it, that both routes to a page produce the +// same object, that a book can be emptied and still be recoverable, and that a +// page cannot be dragged, dropped or pasted anywhere but into a book. +// +// Runs on the real editor UI throughout, because the "+" only exists where +// 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. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function tbCheck(%label, %condition) +{ + echo(%condition ? ("TABBOOK PASS: " @ %label) : ("TABBOOK FAIL: " @ %label)); +} + +function tbUndoCount() +{ + return GuiEditor.undoRecorder.undoCount(); +} + +function tbSelect(%ctrl) +{ + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select(%ctrl); +} + +// Is the inner rect wholly inside the outer one? Both are "x y width height". +function tbInside(%inner, %outer) +{ + return getWord(%inner, 0) >= getWord(%outer, 0) && + getWord(%inner, 1) >= getWord(%outer, 1) && + (getWord(%inner, 0) + getWord(%inner, 2)) <= (getWord(%outer, 0) + getWord(%outer, 2)) && + (getWord(%inner, 1) + getWord(%inner, 3)) <= (getWord(%outer, 1) + getWord(%outer, 3)); +} + +function tbGlobalRect(%ctrl) +{ + return %ctrl.getGlobalPosition() SPC %ctrl.getExtent(); +} + +schedule(2000, 0, "tbStep1"); + +function tbStep1() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + // By id: a bare identifier in TorqueScript is a string, and everything here + // compares object handles. + $tbTheme = nameToID("PlanetX"); + tbCheck("PlanetX theme loaded", isObject($tbTheme)); + + GuiEditor.open(); + GuiEditor.setTheme($tbTheme, false); + + // The real editor UI, on the canvas, from the very start. Everything about + // the "+" is gated on isEditMode(), which answers false until the Gui being + // edited is actually on screen under the editor. + EditorCore.open(); + EditorCore.tabBook.selectPageName("Gui Editor"); + + schedule(800, 0, "tbStepPalette"); +} + +//----------------------------------------------------------------------------- +// The palette, which has to refuse the class without losing it. +// +// The row stays in the icon table: the frame number IS the row index, so +// dropping the row would repoint every icon below it onto the wrong art, and +// dropping the class from covered[] would have the sweep over the class registry +// offer it straight back with a question mark for an icon. +//----------------------------------------------------------------------------- + +function tbStepPalette() +{ + %icons = GuiEditor.controlIcons; + + tbCheck("the palette will not place a tab page", !%icons.isPlaceableClass("GuiTabPageCtrl")); + tbCheck("no Tab Page tile in Layout", + strstr(%icons.keysInGroup("Layout"), "GuiTabPageCtrl") == -1); + tbCheck("but Tab Book is still there", + strstr(%icons.keysInGroup("Layout"), "GuiTabBookCtrl") != -1); + + tbCheck("the entry survives in the table", %icons.isKnown("GuiTabPageCtrl")); + tbCheck("and still counts as covered", %icons.coversClass("GuiTabPageCtrl")); + tbCheck("so it keeps its label (" @ %icons.labelFor("GuiTabPageCtrl") @ ")", + %icons.labelFor("GuiTabPageCtrl") $= "Tab Page"); + + // The two frames either side of the seam. If the row had been deleted rather + // than refused, frameFor would answer 0 for Tab Page and Window would have + // slid down onto its art. Asserted as adjacency rather than two literals: + // the frame IS the row index, so removing any earlier entry renumbers both + // without saying anything about the seam these two are here to guard. + %tabPage = %icons.frameFor("GuiTabPageCtrl"); + tbCheck("Tab Page keeps a frame of its own (" @ %tabPage @ ")", %tabPage > 0); + tbCheck("Window sits right after it (" @ %icons.frameFor("GuiWindowCtrl") @ ")", + %icons.frameFor("GuiWindowCtrl") == (%tabPage + 1)); + + schedule(300, 0, "tbStepDrop"); +} + +//----------------------------------------------------------------------------- +// Dropping a book, which has to arrive with a page in it. +//----------------------------------------------------------------------------- + +function tbStepDrop() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + + // Where the canvas can actually show something: a drop is only a drop when + // the middle of the payload is over the Gui being edited. + %room = GuiEditor.brain.visiblePartOf(GuiEditor.rootGui); + $tbAt = (getWord(%room, 0) + 30) SPC (getWord(%room, 1) + 30); + + $tbBook = new GuiTabBookCtrl() { Extent = "300 130"; Position = $tbAt; }; + GuiEditor.brain.onControlDropped($tbBook, "50 50"); + + tbCheck("the book arrived", $tbBook.getParent() == GuiEditor.rootGui); + tbCheck("carrying exactly one page (" @ $tbBook.getCount() @ ")", $tbBook.getCount() == 1); + + $tbPage = $tbBook.getObject(0); + tbCheck("which is a tab page", $tbPage.getClassName() $= "GuiTabPageCtrl"); + tbCheck("captioned \"" @ $tbPage.getText() @ "\"", $tbPage.getText() $= "Page 1"); + tbCheck("the only page is the active one", $tbBook.getSelectedPage() == 0); + tbCheck("and it is showing", $tbPage.isVisible()); + + // Themed by category rather than by profile name, which is what the theme + // applier actually decides. + tbCheck("the book was themed on arrival (" @ $tbBook.Profile.category @ ")", + $tbBook.Profile.category $= "TabBook"); + tbCheck("and the page it brought with it (" @ $tbPage.Profile.category @ ")", + $tbPage.Profile.category $= "TabPage"); + + // The page came with the book, so it is part of the book arriving rather + // than a second thing the user did. + tbCheck("the whole thing is one undo step (" @ tbUndoCount() @ ")", tbUndoCount() == 1); + + // After the drop's own schedule(40), so the undo is not racing it. + schedule(200, 0, "tbStepDropUndo"); +} + +function tbStepDropUndo() +{ + %trash = GuiEditor.brain.getTrash(); + + GuiEditor.Undo(); + + // getGroup, not getParent: a control sitting in the trash - a plain SimGroup + // - reads as having no parent at all. + tbCheck("undo took the book out of the Gui", $tbBook.getGroup() == %trash); + tbCheck("with its page still inside it", $tbPage.getParent() == $tbBook); + + GuiEditor.Redo(); + tbCheck("redo put the book back", $tbBook.getParent() == GuiEditor.rootGui); + tbCheck("still holding its page (" @ $tbBook.getCount() @ ")", $tbBook.getCount() == 1); + tbCheck("which is showing again", $tbPage.isVisible()); + + schedule(300, 0, "tbStepPlus"); +} + +//----------------------------------------------------------------------------- +// The "+" tab, which is what GuiTabBookCtrl::requestNewPage asks for. Called +// directly here: the click that reaches it is C++, and what this suite is about +// is what the editor does once asked. +//----------------------------------------------------------------------------- + +function tbStepPlus() +{ + GuiEditor.undoRecorder.clear(); + + GuiEditor.brain.onAddTabPage($tbBook); + + tbCheck("the book grew a page (" @ $tbBook.getCount() @ ")", $tbBook.getCount() == 2); + + $tbPage2 = $tbBook.getObject(1); + tbCheck("numbered on from the last (" @ $tbPage2.getText() @ ")", $tbPage2.getText() $= "Page 2"); + tbCheck("themed like any arrival (" @ $tbPage2.Profile.category @ ")", + $tbPage2.Profile.category $= "TabPage"); + + tbCheck("the new page is the active tab", $tbBook.getSelectedPage() == 1); + tbCheck("so it is the one showing", $tbPage2.isVisible()); + tbCheck("and the first one is not", !$tbPage.isVisible()); + + tbCheck("the new page is selected", GuiEditor.brain.selectionList() $= $tbPage2); + tbCheck("and is where the next control would land", + GuiEditor.brain.getCurrentAddSet() == $tbPage2); + + tbCheck("adding a page is one step (" @ tbUndoCount() @ ")", tbUndoCount() == 1); + + GuiEditor.Undo(); + tbCheck("undo took the page back off (" @ $tbBook.getCount() @ ")", $tbBook.getCount() == 1); + tbCheck("leaving the book alone", $tbBook.getParent() == GuiEditor.rootGui); + tbCheck("and the first page showing again", $tbPage.isVisible()); + + GuiEditor.Redo(); + tbCheck("redo put it back (" @ $tbBook.getCount() @ ")", $tbBook.getCount() == 2); + + // A third, to prove the numbering keeps counting rather than restarting. + GuiEditor.brain.onAddTabPage($tbBook); + tbCheck("a third page carries on the numbering (" @ $tbBook.getObject(2).getText() @ ")", + $tbBook.getObject(2).getText() $= "Page 3"); + + schedule(300, 0, "tbStepGeometry"); +} + +//----------------------------------------------------------------------------- +// Where the "+" is, which is the half of it a click depends on. +//----------------------------------------------------------------------------- + +function tbStepGeometry() +{ + %rect = $tbBook.getAddPageTabRect(); + %book = tbGlobalRect($tbBook); + + tbCheck("the book reports a \"+\" tab (" @ %rect @ ")", getWord(%rect, 2) > 0); + tbCheck("square, as an affordance rather than a tab (" @ + getWord(%rect, 2) @ "x" @ getWord(%rect, 3) @ ")", + getWord(%rect, 2) == getWord(%rect, 3)); + tbCheck("inside the book it belongs to", tbInside(%rect, %book)); + + // Three tabs at the default minimum width sit to its left, so a "+" at the + // very start of the strip would mean it had been laid out before them. + tbCheck("after the tabs rather than before them", + getWord(%rect, 0) > getWord(%book, 0)); + + // A book with no pages is an ordinary state now that the palette cannot + // supply one, and the "+" is the only way back out of it. + $tbEmpty = new GuiTabBookCtrl() { Extent = "300 130"; Position = $tbAt; }; + GuiEditor.rootGui.add($tbEmpty); + tbCheck("a book with no pages still offers a \"+\" (" @ $tbEmpty.getAddPageTabRect() @ ")", + getWord($tbEmpty.getAddPageTabRect(), 2) > 0); + + // Outside the Gui being authored there is no "+" at all - which is what + // keeps it off the editor's own chrome, every window of which is a real + // GuiControl on the same canvas. + %loose = new GuiTabBookCtrl() { Extent = "300 130"; }; + tbCheck("a book outside the edited Gui has none (" @ %loose.getAddPageTabRect() @ ")", + getWord(%loose.getAddPageTabRect(), 2) == 0); + %loose.delete(); + + schedule(300, 0, "tbStepDelete"); +} + +//----------------------------------------------------------------------------- +// Removing pages, which is the ordinary Delete and nothing new. +//----------------------------------------------------------------------------- + +function tbStepDelete() +{ + GuiEditor.undoRecorder.clear(); + + // The ACTIVE page first, which is the case with something to get wrong: the + // book has to promote another page AND show it, where before it promoted one + // and left it hidden from whenever some other tab was last chosen. + %active = $tbBook.getObject($tbBook.getSelectedPage()); + tbSelect(%active); + GuiEditor.brain.onObjectRemoved(%active); + + tbCheck("the page went (" @ $tbBook.getCount() @ ")", $tbBook.getCount() == 2); + tbCheck("and the page that took over is showing", + $tbBook.getObject($tbBook.getSelectedPage()).isVisible()); + + // Then down to nothing, because emptying a book is what used to leave it + // drawing nothing at all. + + while($tbBook.getCount() > 0) + { + %page = $tbBook.getObject(0); + tbSelect(%page); + GuiEditor.brain.onObjectRemoved(%page); + } + + tbCheck("the book can be emptied (" @ $tbBook.getCount() @ ")", $tbBook.getCount() == 0); + tbCheck("and is still recoverable (" @ $tbBook.getAddPageTabRect() @ ")", + getWord($tbBook.getAddPageTabRect(), 2) > 0); + + // Straight back out of it, through the same door the "+" uses. + GuiEditor.brain.onAddTabPage($tbBook); + tbCheck("the \"+\" refills an emptied book (" @ $tbBook.getCount() @ ")", + $tbBook.getCount() == 1); + tbCheck("numbering from the start again (" @ $tbBook.getObject(0).getText() @ ")", + $tbBook.getObject(0).getText() $= "Page 1"); + tbCheck("with the new page showing", $tbBook.getObject(0).isVisible()); + + schedule(300, 0, "tbStepUndoDelete"); +} + +function tbStepUndoDelete() +{ + // Right back to three pages, and exactly one of them visible at every point + // along the way. Restoring the page that was active when it was deleted is + // the case that used to draw two pages on top of each other: the recorder + // puts back position, extent and sizing, and visibility is not among them. + while(tbUndoCount() > 0) + { + GuiEditor.Undo(); + } + + tbCheck("undo walked back to three pages (" @ $tbBook.getCount() @ ")", + $tbBook.getCount() == 3); + + %showing = 0; + for(%i = 0; %i < $tbBook.getCount(); %i++) + { + if($tbBook.getObject(%i).isVisible()) + { + %showing++; + } + } + tbCheck("with exactly one of them showing (" @ %showing @ ")", %showing == 1); + + schedule(300, 0, "tbStepRules"); +} + +//----------------------------------------------------------------------------- +// Where a page is allowed to live. GuiControl::canBeChildOf is the rule; the +// Explorer tree drag, the canvas drag and paste are the three doors that ask it. +//----------------------------------------------------------------------------- + +function tbStepRules() +{ + $tbPanel = new GuiControl() { Position = "10 400"; Extent = "300 200"; }; + GuiEditor.rootGui.add($tbPanel); + + $tbButton = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "B"; }; + $tbPanel.add($tbButton); + + %page = $tbBook.getObject(0); + + tbCheck("a page belongs in its book", %page.canBeChildOf($tbBook)); + tbCheck("and in any other book", %page.canBeChildOf($tbEmpty)); + tbCheck("but not in a panel", !%page.canBeChildOf($tbPanel)); + tbCheck("nor on the root", !%page.canBeChildOf(GuiEditor.rootGui)); + tbCheck("an ordinary control still goes anywhere", $tbButton.canBeChildOf($tbPanel)); + tbCheck("including at a book, which re-homes it itself", + $tbButton.canBeChildOf($tbBook)); + + // The canvas drag. Both halves, because a rule that refuses everything would + // pass the first check on its own. + tbSelect(%page); + GuiEditor.brain.moveSelectionToCtrl($tbPanel); + tbCheck("dragging a page onto a panel leaves it alone", %page.getParent() == $tbBook); + + tbSelect(%page); + GuiEditor.brain.moveSelectionToCtrl($tbEmpty); + tbCheck("dragging it onto another book moves it", %page.getParent() == $tbEmpty); + + tbSelect($tbButton); + GuiEditor.brain.moveSelectionToCtrl($tbPanel); + tbCheck("an ordinary control is not caught by the rule", + $tbButton.getParent() == $tbPanel); + + schedule(300, 0, "tbStepPaste"); +} + +function tbStepPaste() +{ + %page = $tbEmpty.getObject(0); + + tbSelect(%page); + GuiEditor.Copy(); + + // Into a panel: refused, and the clipboard is left holding it so that + // selecting a book and pasting again does what was meant. + %before = $tbPanel.getCount(); + GuiEditor.brain.setCurrentAddSet($tbPanel); + GuiEditor.Paste(); + tbCheck("pasting a page into a panel puts nothing there (" @ $tbPanel.getCount() @ ")", + $tbPanel.getCount() == %before); + + %before = $tbBook.getCount(); + GuiEditor.brain.setCurrentAddSet($tbBook); + GuiEditor.Paste(); + tbCheck("pasting it into a book does (" @ $tbBook.getCount() @ ")", + $tbBook.getCount() == (%before + 1)); + + schedule(300, 0, "tbDone"); +} + +function tbDone() +{ + echo("TABBOOK DONE"); + quit(); +} diff --git a/tests/smoke/tabBookClick.cs b/tests/smoke/tabBookClick.cs new file mode 100644 index 000000000..a80717621 --- /dev/null +++ b/tests/smoke/tabBookClick.cs @@ -0,0 +1,141 @@ +//----------------------------------------------------------------------------- +// Clicking the "+" tab, for real. +// +// tabBook.cs calls GuiEditorBrain::onAddTabPage directly, which is the right +// test for what the editor does once asked - but it says nothing about the half +// that asks. That half is C++: GuiTabBookCtrl::onMouseDownEditor converts the +// mouse point into the tab strip's own coordinates, tests it against the "+", +// and calls back into script. Nothing about it can be reached from script, and +// its predecessor got the conversion wrong for years (onTouchDown converted and +// this did not), so a click that is a whole border out still looks like a hit. +// +// Driven by tabBookClick.input.ps1, which posts a real WM_LBUTTONDOWN/UP. The +// point is NOT hard-coded there: the engine works out where the "+" actually is +// and hands it over in a file. A hard-coded click landing an inch to the left of +// the "+" would report exactly what a broken hit test reports. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function tcCheck(%label, %condition) +{ + echo(%condition ? ("TABCLICK PASS: " @ %label) : ("TABCLICK FAIL: " @ %label)); +} + +// Where the engine leaves the point for the driver to click. +function tcTargetFile() +{ + return testRoot("shots/tabBookClickTarget.txt"); +} + +schedule(2500, 0, "tcStep1"); + +// Through the project selector, the way a person opens a project. The suites +// that call GuiEditor.open() directly get an editor that is awake - enough for +// isEditMode(), and so enough to call the brain's methods - but leaves the +// project selector sitting on top of it. A posted click lands on whatever is +// actually in front, so this one has to dismiss it properly. +function tcStep1() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + + schedule(2500, 0, "tcStep2"); +} + +// Loading AppCore starts the project's game over the canvas; the editor comes +// back the way Ctrl+~ does it. Pages register in load order: EditorConsole, +// ProjectManager, AssetAdmin, GuiEditor. +function tcStep2() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + + createPath(testRoot("shots/")); + + schedule(1500, 0, "tcStep3"); +} + +function tcStep3() +{ + GuiEditor.brain.setCurrentAddSet(GuiEditor.rootGui); + + // Dropped rather than added, so it arrives themed and with the page the drop + // seeds it with - the state a book is actually in when someone reaches for + // the "+". + %room = GuiEditor.brain.visiblePartOf(GuiEditor.rootGui); + $tcBook = new GuiTabBookCtrl() + { + Extent = "260 120"; + Position = (getWord(%room, 0) + 40) SPC (getWord(%room, 1) + 40); + }; + GuiEditor.brain.onControlDropped($tcBook, "50 50"); + + // After the drop's own schedule(40) has finished placing it. + schedule(300, 0, "tcStep4"); +} + +function tcStep4() +{ + // A drop selects what it dropped, and the edit control tests its sizing + // knobs before it hands the click to the control. Nothing selected means + // nothing between the cursor and the book. + GuiEditor.brain.clearSelection(); + + tcCheck("the book arrived with a page (" @ $tcBook.getCount() @ ")", $tcBook.getCount() == 1); + + %rect = $tcBook.getAddPageTabRect(); + tcCheck("and reports a \"+\" to aim at (" @ %rect @ ")", getWord(%rect, 2) > 0); + + %x = getWord(%rect, 0) + mFloor(getWord(%rect, 2) / 2); + %y = getWord(%rect, 1) + mFloor(getWord(%rect, 3) / 2); + + %file = new FileObject(); + %file.openForWrite(tcTargetFile()); + %file.writeLine(%x SPC %y); + %file.close(); + %file.delete(); + + echo("TABCLICK: + tab centre at " @ %x SPC %y); + + GuiEditor.undoRecorder.clear(); + + // The driver posts its click during this window. + schedule(8000, 0, "tcStep5"); +} + +function tcStep5() +{ + tcCheck("clicking the \"+\" added a page (" @ $tcBook.getCount() @ ")", $tcBook.getCount() == 2); + + if($tcBook.getCount() == 2) + { + %page = $tcBook.getObject(1); + tcCheck("made by the editor, not the raw C++ (" @ %page.getText() @ ")", + %page.getText() $= "Page 2"); + tcCheck("themed like any arrival (" @ %page.Profile.category @ ")", + %page.Profile.category $= "TabPage"); + tcCheck("and showing, as the active tab", %page.isVisible()); + } + + tcCheck("one undo step for the click (" @ GuiEditor.undoRecorder.undoCount() @ ")", + GuiEditor.undoRecorder.undoCount() == 1); + + // The click landed on the "+" and not on the book behind it, which would + // have selected the book instead of adding anything. + tcCheck("the click was not taken as a selection", + GuiEditor.brain.selectionList() !$= $tcBook); + + screenShot(testRoot("shots/tabBookClick.png"), "PNG"); + echo("TABCLICK DONE"); + schedule(400, 0, "quit"); +} diff --git a/tests/smoke/tabBookClick.input.ps1 b/tests/smoke/tabBookClick.input.ps1 new file mode 100644 index 000000000..84e782e70 --- /dev/null +++ b/tests/smoke/tabBookClick.input.ps1 @@ -0,0 +1,39 @@ +# Input for tabBookClick.cs. Posts a real click onto the tab book's "+" tab, so +# the hit test in GuiTabBookCtrl::onMouseDownEditor is exercised rather than +# stepped over. +# +# The point is not written here. The engine works out where the "+" actually +# landed - which depends on the theme's font, the profile's borders and the tab +# position - and leaves it in a file for this script to pick up. A hard-coded +# point that drifted off the "+" would report a missing page, which is precisely +# what a broken hit test reports, and the test would be lying either way. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +$target = Join-Path $PSScriptRoot "..\..\shots\tabBookClickTarget.txt" + +# Anything left by an earlier run would be clicked before this run has even +# placed its book. +if (Test-Path $target) { Remove-Item $target -Force } + +# The book is dropped at about t=3.5s and read again at t=11.5s. +$deadline = (Get-Date).AddSeconds(20) +$point = $null +while ((Get-Date) -lt $deadline) { + if (Test-Path $target) { + $line = (Get-Content $target -TotalCount 1) + if ($line -and $line.Trim()) { $point = $line.Trim().Split(' '); break } + } + Start-Sleep -Milliseconds 250 +} + +if (-not $point) { + Write-Host " the engine never reported a + tab position" + return +} + +Remove-Item $target -Force + +Send-EngineClick -Hwnd $Hwnd -X ([int]$point[0]) -Y ([int]$point[1]) +Write-Host " clicked the + tab at ($($point[0]),$($point[1]))" diff --git a/tests/smoke/textClick.cs b/tests/smoke/textClick.cs index 247ba26d7..552c284e9 100644 --- a/tests/smoke/textClick.cs +++ b/tests/smoke/textClick.cs @@ -32,6 +32,7 @@ function smokeCheck(%label, %condition) $clickText = "toybox/themes/image/ironWindow.png and then some more text"; +createPath(testRoot("shots/")); schedule(2500, 0, "clickStep1"); function clickStep1() diff --git a/tests/smoke/textEdit.cs b/tests/smoke/textEdit.cs new file mode 100644 index 000000000..ef75c4b6d --- /dev/null +++ b/tests/smoke/textEdit.cs @@ -0,0 +1,185 @@ +//----------------------------------------------------------------------------- +// The multi-line text box, at the engine level. Driven by textEdit.input.ps1, +// which posts a real click and real Return/Up keys, so the whole input path +// runs rather than the methods behind it. +// +// Two behaviours, both of them GuiControl::getLineList's doing: +// +// A. How many lines a piece of text is. getLineList built its paragraph list +// with getline, which cannot tell an empty string from no string -- it +// fails immediately on "" -- so empty text produced NO lines at all, and a +// line block is what draws a GuiTextEditCtrl's caret. An empty multi-line +// box therefore had no cursor in it. getline also dropped the empty +// paragraph that a trailing newline makes, so pressing return at the end +// of the text left the caret with no line to sit on. +// +// Measured through textExtend, which sizes a control from +// textHeight * lineList.size() -- the only place the line count is visible +// from script. +// +// B. Return puts a line break in a wrapped box rather than ending the edit, +// and the up arrow then moves the caret to the line above. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; + +testExec("editor/main.cs"); + +function teCheck(%label, %condition) +{ + if(%condition) echo("TESMOKE PASS: " @ %label); + else echo("TESMOKE FAIL: " @ %label); +} + +// A wrapped, extending control holding %text. Its height after a frame is +// one line per line of text, which is what makes the line count observable. +function teProbe(%stage, %x, %text) +{ + %probe = new GuiControl() + { + Position = %x SPC 400; + Extent = "180 20"; + textWrap = true; + textExtend = true; + Text = %text; + }; + ThemeManager.setProfile(%probe, "labelProfile"); + %stage.add(%probe); + return %probe; +} + +$teText = "abc def"; + +createPath(testRoot("shots/")); +schedule(2500, 0, "teStep1"); + +function teStep1() +{ + // The Gui Editor owns the profiles these wear. + GuiEditor.open(); + + %stage = new GuiControl() + { + Position = "0 0"; + Extent = "1024 768"; + }; + ThemeManager.setProfile(%stage, "overlayProfile"); + + // vAlign top so the first line sits at the top of the content rect. Left to + // the profile it is centred, and a click above the line reads as position 0 + // (calculateIbeamPosition returns 0 for a point above the first block) -- + // which looks exactly like a click that missed. + $teBox = new GuiTextEditCtrl() + { + Position = "200 200"; + Extent = "500 90"; + textWrap = true; + vAlign = "top"; + Text = $teText; + }; + ThemeManager.setProfile($teBox, "textEditProfile"); + %stage.add($teBox); + + // One line each, two lines, and two lines where the second is empty. + $teEmpty = teProbe(%stage, 100, ""); + $teOneLine = teProbe(%stage, 300, "x"); + $teTwoLines = teProbe(%stage, 500, "x" NL "y"); + $teTrailing = teProbe(%stage, 700, "x" NL ""); + + Canvas.pushDialog(%stage); + + // Pushing a dialog hands the first responder to the box, and taking focus + // selects the whole text -- which leaves the caret at the end, exactly where + // a successful click would leave it. Give it back so that a caret at the end + // later can only be the click's doing. + $teBox.makeFirstResponder(false); + $teBox.setCursorPos(0); + + // The driver clicks into the box during this window. The windows are wide + // on purpose: the driver sleeps against the wall clock while these run + // against a boot that takes longer when the whole suite is running, and a + // key that arrives before its step has been mistaken for a broken engine + // once already. + schedule(4500, 0, "teStep2"); +} + +//----------------------------------------------------------------------------- +// A. The line count. +//----------------------------------------------------------------------------- + +function teStep2() +{ + %empty = getWord($teEmpty.getExtent(), 1); + %one = getWord($teOneLine.getExtent(), 1); + %two = getWord($teTwoLines.getExtent(), 1); + %trailing = getWord($teTrailing.getExtent(), 1); + + echo("TESMOKE: heights empty=" @ %empty @ " one=" @ %one @ + " two=" @ %two @ " trailing=" @ %trailing); + + // The one that was broken: empty text is one (blank) line, not none. + teCheck("empty text is one line, like a one-word line", %empty == %one); + teCheck("two paragraphs are taller than one", %two > %one); + teCheck("a trailing newline keeps its empty line", %trailing == %two); + + // The driver clicks past the end of the text, so the engine's own hit test + // parks the caret at the end -- which is where Return has to be for it to + // make a new empty line rather than split the text in two. Placed by the + // click rather than by setCursorPos here, so that nothing depends on this + // step running before the key arrives. + echo("TESMOKE: caret at " @ $teBox.getCursorPos() @ " of " @ strlen($teText)); + teCheck("the box took the click and the caret is at the end", + $teBox.getCursorPos() == strlen($teText)); + + // The driver presses Return during this window. + schedule(4000, 0, "teStep3"); +} + +//----------------------------------------------------------------------------- +// B. Return, and then the up arrow. +//----------------------------------------------------------------------------- + +function teStep3() +{ + %text = $teBox.getText(); + echo("TESMOKE: after return, length " @ strlen(%text) @ + " cursor " @ $teBox.getCursorPos()); + + teCheck("return added a character", strlen(%text) == (strlen($teText) + 1)); + teCheck("and the character is a line break", %text $= ($teText NL "")); + teCheck("the caret moved onto the new line", + $teBox.getCursorPos() == strlen($teText) + 1); + + // Whether the box kept the focus is answered in teStep4: a control that + // ended its edit on return would not be there to receive the up arrow. + $teCursorAfterReturn = $teBox.getCursorPos(); + + // The driver presses Up during this window. + schedule(4000, 0, "teStep4"); +} + +function teStep4() +{ + %pos = $teBox.getCursorPos(); + echo("TESMOKE: after up arrow, cursor " @ %pos @ + " (was " @ $teCursorAfterReturn @ ")"); + + // Up moves the caret to the line above. Without this the key was swallowed + // by a script onUpArrow that had nothing to do with a text box. + teCheck("the up arrow moved the caret off the new line", + %pos < $teCursorAfterReturn); + + // The same fact, read the other way: the key could only arrive because + // return left the box holding the focus instead of ending the edit. + teCheck("so return did not end the edit", %pos != $teCursorAfterReturn); + + screenShot(testRoot("shots/textEdit.png"), "PNG"); + echo("TESMOKE DONE"); + schedule(400, 0, "quit"); +} diff --git a/tests/smoke/textEdit.input.ps1 b/tests/smoke/textEdit.input.ps1 new file mode 100644 index 000000000..338e2552d --- /dev/null +++ b/tests/smoke/textEdit.input.ps1 @@ -0,0 +1,29 @@ +# Input for textEdit.cs. Posts a real click to focus the multi-line box, then a +# real Return and a real Up, so the engine's own key path decides what they do. +# +# The gaps are wide because these sleeps run against the wall clock while the +# suite's steps run against a boot that is slower when every test is running. +# With half-second margins the Return once landed before the step that reads the +# caret, while focus still had the whole text selected -- so it replaced the text +# instead of appending to it, and read as a broken engine rather than a racing +# test. Each key now lands two seconds clear of the steps either side of it. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +# The stage is built at t=2.5s and read at t=7s. The box is at 200,200 with +# extent 500x90; the click is well past the end of "abc def", so the engine's +# hit test parks the caret at the end of the text. +Start-Sleep -Seconds 5 +Send-EngineClick -Hwnd $Hwnd -X 600 -Y 212 +Write-Host " clicked past the end of the text at (600,212)" + +# Read at t=7s, next read at t=11s. +Start-Sleep -Seconds 4 +Send-EngineKey -Hwnd $Hwnd -Key 'RETURN' +Write-Host " pressed Return" + +# Read at t=11s, next read at t=15s. +Start-Sleep -Seconds 4 +Send-EngineKey -Hwnd $Hwnd -Key 'UP' +Write-Host " pressed Up" diff --git a/tests/smoke/toggleTip.cs b/tests/smoke/toggleTip.cs new file mode 100644 index 000000000..b2944ca76 --- /dev/null +++ b/tests/smoke/toggleTip.cs @@ -0,0 +1,106 @@ +//----------------------------------------------------------------------------- +// Two-line tooltips on the properties pane's toggle icons. Driven by +// toggleTip.input.ps1, which hovers a real mouse over one of them long enough +// for the tip to appear, so what is screenshotted is what a user would see. +// +// A toggle says what it is and which way it is set on the first line, and what +// that means on the second: +// +// Visible - On +// Draws when the game runs... +// +// Which needed the tooltip renderer taught about line breaks: it does its own +// word wrapping, splitting on spaces alone, so a newline used to be part of a +// word -- and once GFont stopped drawing a glyph for it, the two lines would +// have run together into one. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; + +testExec("editor/main.cs"); + +function ttCheck(%label, %condition) +{ + if(%condition) echo("TTSMOKE PASS: " @ %label); + else echo("TTSMOKE FAIL: " @ %label); +} + +createPath(testRoot("shots/")); +schedule(2500, 0, "ttOpenProject"); + +// A tip has to be hovered to be seen, so unlike the other pane suites this one +// needs the editor actually on screen: a real project, loaded the way the +// selector loads it, and then the editor brought up the way ctrl+~ does. +function ttOpenProject() +{ + ProjectManager.setProjectFolder("PlanetX"); + EditorCore.projectSelector.onProjectSelected(pathConcat(getMainDotCsDir(), "PlanetX")); + schedule(2500, 0, "ttOpenEditor"); +} + +function ttOpenEditor() +{ + EditorCore.toggleEditor(); + EditorCore.tabBook.selectPage(3); + schedule(1500, 0, "ttStep1"); +} + +function ttStep1() +{ + $ttTheme = GuiEditor.themeLibrary.createTheme("TTSmoke"); + GuiEditor.themeName = $ttTheme.getName(); + + $ttCtrl = new GuiControl() + { + Position = "40 40"; + Extent = "200 60"; + }; + GuiEditor.rootGui.add($ttCtrl); + GuiEditor.themeApplier.applyToBranch($ttCtrl, $ttTheme, true); + + %pane = GuiEditor.inspectorWindow.pane; + %pane.bind($ttCtrl); + + // --- The format, which needs no mouse. --- + %visible = %pane.header.visibleButton; + %tip = %visible.Tooltip; + echo("TTSMOKE: visible tip = [" @ %tip @ "]"); + + ttCheck("the tip leads with the name and the state", + getRecord(%tip, 0) $= "Visible - On"); + ttCheck("and explains that state underneath", + strstr(getRecord(%tip, 1), "Draws when the game runs") >= 0); + ttCheck("it is two lines, not one", getRecordCount(%tip) == 2); + + // Turning it off rewrites both lines. + %visible.setValue(false); + echo("TTSMOKE: off tip = [" @ %visible.Tooltip @ "]"); + ttCheck("off says off", getRecord(%visible.Tooltip, 0) $= "Visible - Off"); + ttCheck("with the other explanation", + strstr(getRecord(%visible.Tooltip, 1), "does not draw") >= 0 || + strstr(getRecord(%visible.Tooltip, 1), "Does not draw") >= 0); + %visible.setValue(true); + + // A segmented row's buttons are choices rather than switches, so they keep + // the one line they had -- "Centre text - On" would be a worse caption. + %align = %pane.header.alignRow; + ttCheck("a choice button has no On/Off heading", + getRecordCount(%align.choiceButton[2].Tooltip) == 1); + + // The driver hovers the Visible toggle during this window; the tip is + // screenshotted so the two lines can be seen to actually render. + schedule(6000, 0, "ttStep2"); +} + +function ttStep2() +{ + screenShot(testRoot("shots/toggleTip.png"), "PNG"); + echo("TTSMOKE DONE"); + schedule(400, 0, "quit"); +} diff --git a/tests/smoke/toggleTip.input.ps1 b/tests/smoke/toggleTip.input.ps1 new file mode 100644 index 000000000..969ab583f --- /dev/null +++ b/tests/smoke/toggleTip.input.ps1 @@ -0,0 +1,17 @@ +# Input for toggleTip.cs. A tooltip only draws while the pointer has been still +# over a control for longer than its hover time, so this parks the mouse on the +# Visible toggle and leaves it there while the shot is taken. +param([IntPtr]$Hwnd) + +. "$PSScriptRoot\..\lib\input.ps1" + +# The project loads at t=2.5s, the editor comes up at t=5s and the pane is bound +# at t=6.5s. The shot is taken at t=12.5s, so hover well inside that. +Start-Sleep -Seconds 8 + +# The state toggles sit in one row in the header: hidden, locked, then Visible, +# Active, Accepts Input and Accepts Children at 28px intervals. The row is at +# y~364 for a bare GuiControl, which is the one class that also carries a +# Category row above it -- 52px lower than it sits for anything else. +Send-EngineMouseMove -Hwnd $Hwnd -X 83 -Y 364 +Write-Host " hovering the Visible toggle at (83,364)" diff --git a/tests/smoke/toybox.cs b/tests/smoke/toybox.cs index a5f43f0f9..a9889eb35 100644 --- a/tests/smoke/toybox.cs +++ b/tests/smoke/toybox.cs @@ -17,6 +17,7 @@ function smokeCheck(%label, %condition) echo(%condition ? ("SMOKE PASS: " @ %label) : ("SMOKE FAIL: " @ %label)); } +createPath(testRoot("shots/")); schedule(5000, 0, "toySmoke"); function toySmoke() diff --git a/tests/smoke/treeIcons.cs b/tests/smoke/treeIcons.cs new file mode 100644 index 000000000..46ee62abf --- /dev/null +++ b/tests/smoke/treeIcons.cs @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------------- +// A tree row can wear a small picture, drawn between the triangle and the text. +// +// The frame is pulled from script ONCE, while the tree is building itself, and +// cached on the row -- onRenderItem runs for every visible row of every frame, +// so asking per draw would be a console call per row per frame. That caching is +// the whole risk in the feature: a row whose picture should have changed but +// whose cache was never invalidated is wrong in a way nothing else reports. +// +// This has to run with a canvas. Adding a row calls updateSize, which asks the +// profile for a font, which loads one, which registers a texture -- so the row +// arithmetic is unit tested (guiTreeRowLayoutTests.cc) and the plumbing is here. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +$Pass = 0; +$Fail = 0; + +function iconCheck(%label, %condition) +{ + if(%condition) + { + $Pass++; + echo("TICO PASS: " @ %label); + } + else + { + $Fail++; + echo("TICO FAIL: " @ %label); + } +} + +// The tree under test answers from here. Returning "" is how a handler declines +// a row, which must read the same as having no handler at all. +function IconTestTree::onGetItemIcon(%this, %obj) +{ + if(%obj.iconDeclines) + { + return ""; + } + return %obj.wantIcon; +} + +schedule(2500, 0, "icoRun"); + +function icoRun() +{ + // A little tree of our own rather than the editor's, so this tests the base + // class and not the Gui Editor's use of it. + %root = new SimGroup(); + %a = new SimObject(); %a.wantIcon = 7; + %b = new SimObject(); %b.wantIcon = 3; + %c = new SimObject(); %c.iconDeclines = true; + %root.add(%a); + %root.add(%b); + %root.add(%c); + + %tree = new GuiTreeViewCtrl() + { + class = "IconTestTree"; + Position = "0 0"; + Extent = "200 200"; + }; + ThemeManager.setProfile(%tree, "treeViewProfile"); + Canvas.getContent().add(%tree); + + // --- With no sheet set, the question is never asked. + %tree.inspect(%root); + iconCheck("no sheet means no icon on the root", %tree.getItemIcon(0) == -1); + iconCheck("no sheet means no icon on a branch", %tree.getItemIcon(1) == -1); + + // --- Set one, and the rows pick their frames up on the next build. + %tree.IconImage = "EditorCore:editorIcons16"; + iconCheck("the sheet round-trips through the field", + %tree.IconImage $= "EditorCore:editorIcons16"); + + %tree.refresh(); + iconCheck("a branch wears the frame script named", %tree.getItemIcon(1) == 7); + iconCheck("each branch answers for itself", %tree.getItemIcon(2) == 3); + iconCheck("a declined row wears none", %tree.getItemIcon(3) == -1); + + // --- The cache is the risk. A row whose answer changes must be refreshable + // without rebuilding the whole tree, because that is what the properties + // pane does when a control is re-profiled under a selection. + %b.wantIcon = 21; + iconCheck("the cache does not follow the object on its own", %tree.getItemIcon(2) == 3); + %tree.refreshItem(2); + iconCheck("refreshItem re-asks", %tree.getItemIcon(2) == 21); + iconCheck("refreshItem left its neighbours alone", %tree.getItemIcon(1) == 7); + + // --- And clearing the sheet turns the feature back off. + %tree.IconImage = ""; + %tree.refresh(); + iconCheck("clearing the sheet stops the asking", %tree.getItemIcon(1) == -1); + + // --- Out-of-range indices report rather than crash. + iconCheck("an index past the end answers -1", %tree.getItemIcon(999) == -1); + + %tree.deleteObject(); + %root.deleteObject(); + + echo("TICO RESULT: " @ $Pass @ " passed, " @ $Fail @ " failed"); + quit(); +} diff --git a/tests/smoke/undo.cs b/tests/smoke/undo.cs new file mode 100644 index 000000000..9934e46f0 --- /dev/null +++ b/tests/smoke/undo.cs @@ -0,0 +1,1060 @@ +//----------------------------------------------------------------------------- +// Undo / redo in the Gui Editor. Boots the editor, opens the PlanetX project so +// there is a real theme to work with, and puts every kind of edit through a +// round trip: does undo put it back, does redo do it again, and - the part that +// is easy to get wrong - is one thing the user did one step on the stack. +// +// 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. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function uCheck(%label, %condition) +{ + echo(%condition ? ("UNDO PASS: " @ %label) : ("UNDO FAIL: " @ %label)); +} + +function uUndoCount() +{ + return GuiEditor.undoRecorder.undoCount(); +} + +function uRedoCount() +{ + return GuiEditor.undoRecorder.redoCount(); +} + +// Every pane write needs a bound target, and a replay moves the selection - so +// the pane is re-bound before each case rather than once. +function uBind(%ctrl) +{ + GuiEditor.inspectorWindow.pane.bind(%ctrl); + return GuiEditor.inspectorWindow.pane; +} + +function uIndexOf(%parent, %ctrl) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + if(%parent.getObject(%i) == %ctrl) + { + return %i; + } + } + return -1; +} + +function uSelect(%ctrl) +{ + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select(%ctrl); +} + +// Does the control really carry this dynamic field? Comparing against "" proves +// nothing: an absent field and an empty one read back the same, because an empty +// one is exactly what the engine deletes. +function uHasDynamicField(%ctrl, %name) +{ + for(%i = 0; %i < %ctrl.getDynamicFieldCount(); %i++) + { + if(getWord(%ctrl.getDynamicField(%i), 0) $= %name) + { + return true; + } + } + return false; +} + +schedule(2000, 0, "uStep1"); + +function uStep1() +{ + ProjectManager.setProjectFolder("PlanetX"); + ModuleDatabase.ScanModules("PlanetX"); + ModuleDatabase.LoadExplicit("AppCore", 1); + + // By id: a bare identifier in TorqueScript is a string, and everything here + // compares object handles. + $uTheme = nameToID("PlanetX"); + uCheck("PlanetX theme loaded", isObject($uTheme)); + + GuiEditor.open(); + uCheck("the editor built a recorder", isObject(GuiEditor.undoRecorder)); + uCheck("which found the brain's UndoManager", isObject(GuiEditor.undoRecorder.manager())); + + // The stage: a panel with three children, which is enough to test index + // restoration without being hard to read. + $uPanel = new GuiControl() { Position = "10 10"; Extent = "400 300"; }; + GuiEditor.rootGui.add($uPanel); + + $uA = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "A"; }; + $uPanel.add($uA); + $uB = new GuiButtonCtrl() { Position = "10 50"; Extent = "80 30"; Text = "B"; }; + $uPanel.add($uB); + $uC = new GuiButtonCtrl() { Position = "10 90"; Extent = "80 30"; Text = "C"; }; + $uPanel.add($uC); + + $uOther = new GuiControl() { Position = "10 320"; Extent = "200 100"; }; + GuiEditor.rootGui.add($uOther); + + GuiEditor.setTheme($uTheme, false); + + GuiEditor.undoRecorder.clear(); + uCheck("the stack starts empty", uUndoCount() == 0 && uRedoCount() == 0); + + schedule(300, 0, "uStepFields"); +} + +//----------------------------------------------------------------------------- +// Field edits, which all arrive through GuiEditorInspectorPane::writeField. +//----------------------------------------------------------------------------- + +function uStepFields() +{ + GuiEditor.undoRecorder.clear(); + + // Geometry. Position and Extent are protected fields, so writing them runs + // setPosition/setExtent rather than poking mBounds - which is what makes an + // undone resize a real resize. + %pane = uBind($uA); + %before = $uA.getExtent(); + %pane.writeField("Extent", "120 40"); + + uCheck("a field write is one step", uUndoCount() == 1); + uCheck("and it reached the control", $uA.getExtent() $= "120 40"); + + GuiEditor.Undo(); + uCheck("undo put the extent back", $uA.getExtent() $= %before); + uCheck("and moved the step to the redo stack", uUndoCount() == 0 && uRedoCount() == 1); + + GuiEditor.Redo(); + uCheck("redo did it again", $uA.getExtent() $= "120 40"); + uCheck("and moved it back", uUndoCount() == 1 && uRedoCount() == 0); + + // A profile slot, which is recorded by id because a profile made this + // session carries a name the Sim never registered. + %pane = uBind($uA); + %pane.writeField("Profile", PlanetXPanelProfile.getId()); + uCheck("a profile write is one step", uUndoCount() == 2); + uCheck("and it landed", $uA.getFieldValue("Profile") $= "PlanetXPanelProfile"); + + GuiEditor.Undo(); + uCheck("undo put the old profile back", + $uA.getFieldValue("Profile") $= "PlanetXButtonProfile"); + GuiEditor.Redo(); + uCheck("and redo re-applied it", $uA.getFieldValue("Profile") $= "PlanetXPanelProfile"); + GuiEditor.Undo(); + + // A toggle. + %pane = uBind($uB); + %pane.writeField("Visible", false); + uCheck("a toggle is one step", uUndoCount() == 2); + uCheck("and it took", !$uB.Visible); + GuiEditor.Undo(); + uCheck("undo turned it back on", $uB.Visible); + + // A write that changes nothing is not a step. + %depth = uUndoCount(); + %pane = uBind($uB); + %pane.writeField("Visible", true); + uCheck("writing the value it already had records nothing", uUndoCount() == %depth); + + // A dynamic field, which has no setEditFieldValue path of its own. + GuiEditor.undoRecorder.writeDynamicField($uB, "uSmokeTag", "hello"); + uCheck("a dynamic field write is one step", uUndoCount() == %depth + 1); + uCheck("and the field exists", uHasDynamicField($uB, "uSmokeTag")); + + GuiEditor.Undo(); + uCheck("undo removed the dynamic field", !uHasDynamicField($uB, "uSmokeTag")); + GuiEditor.Redo(); + uCheck("redo put it back", $uB.uSmokeTag $= "hello"); + GuiEditor.Undo(); + + schedule(300, 0, "uStepSelection"); +} + +//----------------------------------------------------------------------------- +// What the user is looking at after a replay. +// +// Changing a setting and pressing Ctrl+Z is the commonest undo there is, and the +// control whose setting it was is the one already selected -- so the properties +// pane has to still be on it, with the old value back in the row. Losing the +// selection there empties the whole panel, which reads as though the undo did +// something much larger than it did. +//----------------------------------------------------------------------------- + +function uStepSelection() +{ + GuiEditor.undoRecorder.clear(); + + uSelect($uC); + %pane = GuiEditor.inspectorWindow.pane; + uCheck("selecting a control put it in the pane", %pane.target == $uC); + + %before = $uC.getExtent(); + %pane.writeField("Extent", "150 60"); + + GuiEditor.Undo(); + + %selection = GuiEditor.brain.getSelected(); + uCheck("undo kept the selection", %selection.getCount() == 1); + uCheck("and kept it on the control that changed", %selection.getObject(0) == $uC); + uCheck("the pane is still showing that control", %pane.target == $uC); + uCheck("and the Explorer row is still selected", + GuiEditor.explorerWindow.tree.getSelCount() == 1); + uCheck("the control's value went back", $uC.getExtent() $= %before); + uCheck("and the row it is shown in went back with it", + %pane.header.extentRow.getValue() $= %before); + + GuiEditor.Redo(); + uCheck("redo leaves it selected too", %pane.target == $uC); + uCheck("with the row showing the new value", + %pane.header.extentRow.getValue() $= "150 60"); + GuiEditor.Undo(); + + // An undo whose controls are not the ones selected moves the selection to + // them, which is the other half of the same promise: you see what changed. + GuiEditor.undoRecorder.clear(); + uSelect($uA); + %pane.writeField("Visible", false); + + uSelect($uC); + uCheck("the selection moved away", %pane.target == $uC); + + GuiEditor.Undo(); + %selection = GuiEditor.brain.getSelected(); + uCheck("undo selected what it actually changed", %selection.getObject(0) == $uA); + uCheck("and the pane followed", %pane.target == $uA); + + // Two controls, from one step that moved both. A nudge rather than an align: + // aligning left never moves the leftmost control, so only one of the two + // would have changed - and the selection lands on what changed. + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select($uA); + GuiEditor.brain.addSelection($uC); + GuiEditor.brain.moveSelection(1, 0); + + GuiEditor.Undo(); + %selection = GuiEditor.brain.getSelected(); + uCheck("undoing a two-control step selects both", %selection.getCount() == 2); + + schedule(300, 0, "uStepSizing"); +} + +//----------------------------------------------------------------------------- +// Sizing, which is a field write that moves the control. +// +// Setting an axis to center or fill hands that axis's geometry to the engine, +// which lays the control out there and then. The enum and the position it took +// are one change, so undo has to give back both -- restoring the enum alone +// leaves the control sitting where centring put it. +//----------------------------------------------------------------------------- + +function uStepSizing() +{ + GuiEditor.undoRecorder.clear(); + + %pane = uBind($uA); + $uA.setPosition(20, 40); + %posBefore = $uA.getPosition(); + %horizBefore = $uA.getFieldValue("HorizSizing"); + + %pane.onSizingChanged("center", $uA.getFieldValue("VertSizing")); + %centred = $uA.getPosition(); + + uCheck("centring moved the control", %centred !$= %posBefore); + uCheck("and the enum and the move it caused are one step", uUndoCount() == 1); + + GuiEditor.Undo(); + uCheck("undo restored the sizing", $uA.getFieldValue("HorizSizing") $= %horizBefore); + uCheck("and the position centring took", $uA.getPosition() $= %posBefore); + + GuiEditor.Redo(); + uCheck("redo centred it again", $uA.getPosition() $= %centred); + uCheck("with the sizing back on center", $uA.getFieldValue("HorizSizing") $= "center"); + GuiEditor.Undo(); + + // Fill takes the extent as well as the position. + GuiEditor.undoRecorder.clear(); + %pane = uBind($uA); + %posBefore = $uA.getPosition(); + %extentBefore = $uA.getExtent(); + + %pane.onSizingChanged("fill", $uA.getFieldValue("VertSizing")); + uCheck("filling resized the control", $uA.getExtent() !$= %extentBefore); + + GuiEditor.Undo(); + uCheck("undo restored the extent fill took", $uA.getExtent() $= %extentBefore); + uCheck("and the position with it", $uA.getPosition() $= %posBefore); + + schedule(300, 0, "uStepText"); +} + +//----------------------------------------------------------------------------- +// Typing, which reaches the control once per keystroke so the canvas keeps up, +// and reaches the stack once. +//----------------------------------------------------------------------------- + +function uStepText() +{ + GuiEditor.undoRecorder.clear(); + + %pane = uBind($uC); + %block = %pane.activeTextBlock(); + %row = %pane.row["text"]; + %before = $uC.text; + + // What a keystroke does: the box's buffer changes and the engine runs its + // Command. Set the buffer and call the handler that Command names. + %row.editor.setText("Ca"); + %block.onTextTyped(); + %row.editor.setText("Cat"); + %block.onTextTyped(); + + uCheck("the control filled in as we typed", $uC.text $= "Cat"); + uCheck("and nothing was recorded yet", uUndoCount() == 0); + + %row.commit(); + uCheck("three keystrokes are one step", uUndoCount() == 1); + uCheck("and the control kept the typed text", $uC.text $= "Cat"); + + GuiEditor.Undo(); + uCheck("undo restored the whole caption at once", $uC.text $= %before); + GuiEditor.Redo(); + uCheck("redo typed it again", $uC.text $= "Cat"); + + schedule(300, 0, "uStepAdd"); +} + +//----------------------------------------------------------------------------- +// Adding a control, which is a move op with the trash on one end. Undo does not +// delete: the control keeps living in the trash, which is the whole reason redo +// can put it back wearing everything it had. +//----------------------------------------------------------------------------- + +function uStepAdd() +{ + GuiEditor.undoRecorder.clear(); + + GuiEditor.brain.setCurrentAddSet($uPanel); + + // Position, not the point handed to the callback, is what a drop is placed + // from: the payload is not owned yet, so its own global position is where the + // cursor let go. It has to be over the canvas -- a drop that is not is a drag + // taken back to the palette and abandoned, and adds nothing at all. The panel + // this lands in sits at 10,10 inside a canvas that starts at 366,26. + $uDropped = new GuiCheckBoxCtrl() { Position = "400 60"; Extent = "120 30"; Text = "Dropped"; }; + GuiEditor.brain.onControlDropped($uDropped, "50 50"); + + uCheck("a drop is one step, not one per thing it did", uUndoCount() == 1); + uCheck("the control arrived", $uDropped.getParent() == $uPanel); + uCheck("and was themed on arrival", + $uDropped.getFieldValue("Profile") $= "PlanetXCheckBoxProfile"); + + // After the drop's own schedule(40) has run, so the undo is not racing it. + schedule(200, 0, "uStepAddUndo"); +} + +function uStepAddUndo() +{ + %trash = GuiEditor.brain.getTrash(); + + GuiEditor.Undo(); + + // getGroup, not getParent: getParent is a GuiControl's view of the hierarchy + // and dynamic_casts the group it is in, so a control sitting in the trash -- + // a plain SimGroup -- reads as having no parent at all. + uCheck("undo took the control out of the Gui", $uDropped.getGroup() == %trash); + uCheck("but did not delete it", isObject($uDropped)); + + GuiEditor.Redo(); + uCheck("redo put it back", $uDropped.getParent() == $uPanel); + uCheck("still wearing its theme profile", + $uDropped.getFieldValue("Profile") $= "PlanetXCheckBoxProfile"); + + GuiEditor.Undo(); + + schedule(300, 0, "uStepDelete"); +} + +//----------------------------------------------------------------------------- +// Deleting, which has to restore the index as well as the parent - a control +// put back at the end of its parent is a control that changed z-order. +//----------------------------------------------------------------------------- + +function uStepDelete() +{ + GuiEditor.undoRecorder.clear(); + %trash = GuiEditor.brain.getTrash(); + + // One control, from the middle. + uCheck("B starts at index 1", uIndexOf($uPanel, $uB) == 1); + uSelect($uB); + GuiEditor.brain.deleteSelection(); + + uCheck("a delete is one step", uUndoCount() == 1); + uCheck("and the control went to the trash", $uB.getGroup() == %trash); + + GuiEditor.Undo(); + uCheck("undo put it back in its parent", $uB.getParent() == $uPanel); + uCheck("at the index it came from", uIndexOf($uPanel, $uB) == 1); + + GuiEditor.Redo(); + uCheck("redo trashed it again", $uB.getGroup() == %trash); + GuiEditor.Undo(); + + // Two at once, from either side of a third. + GuiEditor.undoRecorder.clear(); + %indexA = uIndexOf($uPanel, $uA); + %indexC = uIndexOf($uPanel, $uC); + + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select($uA); + GuiEditor.brain.addSelection($uC); + GuiEditor.brain.deleteSelection(); + + uCheck("deleting two controls is still one step", uUndoCount() == 1); + uCheck("both went to the trash", + $uA.getGroup() == %trash && $uC.getGroup() == %trash); + + GuiEditor.Undo(); + uCheck("undo restored both", $uA.getParent() == $uPanel && $uC.getParent() == $uPanel); + uCheck("A at its old index", uIndexOf($uPanel, $uA) == %indexA); + uCheck("C at its old index", uIndexOf($uPanel, $uC) == %indexC); + + schedule(300, 0, "uStepNudge"); +} + +//----------------------------------------------------------------------------- +// Nudging. Holding an arrow key down is one move, which is what the C++'s +// separate onPreSelectionNudged callback was always for. +//----------------------------------------------------------------------------- + +function uStepNudge() +{ + GuiEditor.undoRecorder.clear(); + + %before = $uA.getPosition(); + uSelect($uA); + GuiEditor.brain.moveSelection(1, 0); + GuiEditor.brain.moveSelection(1, 0); + GuiEditor.brain.moveSelection(1, 0); + + // Not "moved by three": snap to grid is on by default (the brain turns it on + // in onAdd), so a nudge of 1 is a nudge to the next grid line. What the run + // landed on is what undo has to take back. + %after = $uA.getPosition(); + uCheck("three nudges are one step", uUndoCount() == 1); + uCheck("and the control moved", %after !$= %before); + + GuiEditor.Undo(); + uCheck("undo takes back the whole run", $uA.getPosition() $= %before); + GuiEditor.Redo(); + uCheck("redo replays the whole run", $uA.getPosition() $= %after); + + // Anything in between breaks the run, or a nudge made after a different edit + // would be folded into the move before it. + GuiEditor.undoRecorder.clear(); + uSelect($uA); + GuiEditor.brain.moveSelection(0, 1); + + %pane = uBind($uA); + %pane.writeField("Visible", false); + + uSelect($uA); + GuiEditor.brain.moveSelection(0, 1); + + uCheck("an edit between two nudges keeps them apart", uUndoCount() == 3); + + %pane = uBind($uA); + %pane.writeField("Visible", true); + + // A mouse-down that selects without dragging is not an edit. + GuiEditor.undoRecorder.clear(); + %selection = GuiEditor.brain.getSelected(); + GuiEditor.undoRecorder.snapshot(%selection); + GuiEditor.undoRecorder.commitGeometry("", ""); + uCheck("a gesture that moved nothing records nothing", uUndoCount() == 0); + + schedule(300, 0, "uStepLayout"); +} + +//----------------------------------------------------------------------------- +// The Layout menu, which records in script because the C++ says nothing when it +// aligns or restacks. +//----------------------------------------------------------------------------- + +function uStepLayout() +{ + GuiEditor.undoRecorder.clear(); + + $uA.setPosition(10, 10); + $uC.setPosition(60, 90); + %beforeA = $uA.getPosition(); + %beforeC = $uC.getPosition(); + + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select($uA); + GuiEditor.brain.addSelection($uC); + GuiEditor.Justify(0); + + uCheck("aligning is one step", uUndoCount() == 1); + uCheck("and it moved something", $uC.getPosition() !$= %beforeC); + + GuiEditor.Undo(); + uCheck("undo put both back", + $uA.getPosition() $= %beforeA && $uC.getPosition() $= %beforeC); + GuiEditor.Redo(); + uCheck("redo aligned them again", $uC.getPosition() !$= %beforeC); + + // Restacking. The C++ works on the current add set, so that has to be the + // control's parent - and setting it clears the selection, so it goes first. + GuiEditor.undoRecorder.clear(); + GuiEditor.brain.setCurrentAddSet($uPanel); + uSelect($uA); + + %before = uIndexOf($uPanel, $uA); + GuiEditor.BringToFront(); + + uCheck("bring to front is one step", uUndoCount() == 1); + uCheck("and it moved the control up the list", uIndexOf($uPanel, $uA) != %before); + + GuiEditor.Undo(); + uCheck("undo restored the index", uIndexOf($uPanel, $uA) == %before); + GuiEditor.Redo(); + uCheck("redo restacked it again", uIndexOf($uPanel, $uA) != %before); + GuiEditor.Undo(); + + schedule(300, 0, "uStepTheme"); +} + +//----------------------------------------------------------------------------- +// Set Theme, which writes a profile into every slot of every control in the +// document and is one Ctrl+Z. +//----------------------------------------------------------------------------- + +function uStepTheme() +{ + GuiEditor.undoRecorder.clear(); + + %library = GuiEditor.getThemeLibrary(); + $uThemeB = %library.createTheme("UndoThemeB"); + uCheck("second theme created", isObject($uThemeB)); + + GuiEditor.setTheme($uThemeB, false); + uCheck("a whole-document re-theme is one step", uUndoCount() == 1); + uCheck("and it re-profiled the controls", + $uA.getFieldValue("Profile") $= "UndoThemeBButtonProfile"); + + GuiEditor.Undo(); + uCheck("undo put the old theme's profiles back", + $uA.getFieldValue("Profile") $= "PlanetXButtonProfile"); + uCheck("all of them", $uPanel.getFieldValue("Profile") $= "PlanetXPanelProfile"); + + GuiEditor.Redo(); + uCheck("redo re-applied the theme", + $uA.getFieldValue("Profile") $= "UndoThemeBButtonProfile"); + + GuiEditor.Undo(); + GuiEditor.setTheme($uTheme, false); + + // Deleting the theme detaches the document from it first, which is one of the + // paths that has to empty the stack: every record holds profile ids that stop + // resolving. + %library.deleteTheme($uThemeB); + uCheck("deleting a theme emptied the stack", uUndoCount() == 0 && uRedoCount() == 0); + + schedule(300, 0, "uStepReparent"); +} + +//----------------------------------------------------------------------------- +// Dragging in the Explorer tree, which rearranges the hierarchy in C++ and can +// move any number of controls into any number of parents at once. The engine +// now brackets it with onPreReorder / onPostReorder; those are called directly +// here, around the same hierarchy change the drag makes. +//----------------------------------------------------------------------------- + +function uStepReparent() +{ + GuiEditor.undoRecorder.clear(); + %tree = GuiEditor.explorerWindow.tree; + + %index = uIndexOf($uPanel, $uA); + + %tree.onPreReorder(); + $uOther.add($uA); + %tree.onPostReorder(); + + uCheck("a reparent is one step", uUndoCount() == 1); + uCheck("and the control changed parent", $uA.getParent() == $uOther); + + GuiEditor.Undo(); + uCheck("undo returned it to its old parent", $uA.getParent() == $uPanel); + uCheck("at its old index", uIndexOf($uPanel, $uA) == %index); + + GuiEditor.Redo(); + uCheck("redo reparented it again", $uA.getParent() == $uOther); + GuiEditor.Undo(); + + // A reorder inside one parent, which is the same op restoring a list rather + // than a parent. + GuiEditor.undoRecorder.clear(); + %first = $uPanel.getObject(0); + %last = $uPanel.getObject($uPanel.getCount() - 1); + + %tree.onPreReorder(); + $uPanel.reorderChild(%last, %first); + %tree.onPostReorder(); + + uCheck("a reorder is one step", uUndoCount() == 1); + uCheck("and the order changed", $uPanel.getObject(0) == %last); + + GuiEditor.Undo(); + uCheck("undo restored the order", $uPanel.getObject(0) == %first); + + schedule(300, 0, "uStepCanvasDrag"); +} + +//----------------------------------------------------------------------------- +// Dragging on the canvas, which the C++ brackets with onPreEdit and onPostEdit. +// +// The gesture is performed here the way guiEditCtrl.cc performs it, because two +// things happen inside it that no callback announces. onTouchDragged moves the +// selection once per mouse-move event - each of those brackets itself with the +// nudge pair - and then looks at what is under the cursor and reparents into it +// (moveSelectionToCtrl), rewriting the control's position so it stays under the +// pointer. So a drag that crosses a container boundary changes parent AND +// changes position a second time, silently, in the middle of the gesture. +//----------------------------------------------------------------------------- + +function uStepCanvasDrag() +{ + // A drag that stays where it is. The ordinary case, and the one the reparent + // record must not disturb. + GuiEditor.undoRecorder.clear(); + uSelect($uA); + + %wasPos = $uA.getPosition(); + + GuiEditor.brain.onPreEdit(GuiEditor.brain.getSelected()); + GuiEditor.brain.moveSelection(0, 20); + GuiEditor.brain.moveSelection(0, 20); + GuiEditor.brain.onPostEdit(GuiEditor.brain.getSelected()); + + %droppedPos = $uA.getPosition(); + uCheck("a canvas drag is one step", uUndoCount() == 1); + uCheck("and the control moved", %droppedPos !$= %wasPos); + + GuiEditor.Undo(); + uCheck("undo takes the whole drag back", $uA.getPosition() $= %wasPos); + GuiEditor.Redo(); + uCheck("redo replays the whole drag", $uA.getPosition() $= %droppedPos); + GuiEditor.Undo(); + + schedule(300, 0, "uStepCanvasDragReparent"); +} + +function uStepCanvasDragReparent() +{ + GuiEditor.undoRecorder.clear(); + uSelect($uA); + + %wasParent = $uA.getParent(); + %wasIndex = uIndexOf($uPanel, $uA); + %wasPos = $uA.getPosition(); + + GuiEditor.brain.onPreEdit(GuiEditor.brain.getSelected()); + GuiEditor.brain.moveSelection(0, 20); + GuiEditor.brain.moveSelection(0, 20); + GuiEditor.brain.moveSelectionToCtrl($uOther); + GuiEditor.brain.onPostEdit(GuiEditor.brain.getSelected()); + + %droppedPos = $uA.getPosition(); + uCheck("a drag into another container is one step", uUndoCount() == 1); + uCheck("and the control changed parent", $uA.getParent() == $uOther); + + GuiEditor.Undo(); + uCheck("undo returned it to its old parent", $uA.getParent() == %wasParent); + uCheck("at its old index", uIndexOf($uPanel, $uA) == %wasIndex); + + // The position matters as much as the parent: the reparent rewrote it to a + // number that means something only inside the container it moved to, so a + // replay that puts one back without the other lands the control somewhere it + // has never been. + uCheck("and its old position", $uA.getPosition() $= %wasPos); + + GuiEditor.Redo(); + uCheck("redo dragged it across again", $uA.getParent() == $uOther); + uCheck("to where the drag left it", $uA.getPosition() $= %droppedPos); + + GuiEditor.Undo(); + uCheck("and it came home", $uA.getParent() == %wasParent); + + schedule(300, 0, "uStepMenu"); +} + +//----------------------------------------------------------------------------- +// The Edit menu, which is what tells the user whether there is anything to take +// back. It was inactive entirely until now. +//----------------------------------------------------------------------------- + +// Menu items are nested controls, so this walks rather than indexes. +function uMenuItem(%parent, %text) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + %item = %parent.getObject(%i); + if(%item.Text $= %text) + { + return %item; + } + + %found = uMenuItem(%item, %text); + if(isObject(%found)) + { + return %found; + } + } + + return 0; +} + +function uStepMenu() +{ + %editMenu = uMenuItem(EditorCore.menuBar, "Edit"); + %undoItem = uMenuItem(EditorCore.menuBar, "Undo"); + %redoItem = uMenuItem(EditorCore.menuBar, "Redo"); + %cutItem = uMenuItem(EditorCore.menuBar, "Cut"); + + uCheck("the Edit menu exists", isObject(%editMenu)); + uCheck("and is offered at last", %editMenu.Active); + + // Cut belongs to the clipboard now, and it follows the selection rather than + // the undo stack - tests/smoke/clipboard.cs is where that is checked. Here it + // is only worth knowing that this suite left a selection behind, since undo + // moves one. + uCheck("Cut is offered while something is selected", %cutItem.Active); + + GuiEditor.undoRecorder.clear(); + uCheck("Undo is greyed with an empty stack", !%undoItem.Active); + uCheck("Redo is greyed with an empty stack", !%redoItem.Active); + + %pane = uBind($uC); + %pane.writeField("Visible", false); + uCheck("an edit lights Undo", %undoItem.Active); + uCheck("and leaves Redo greyed", !%redoItem.Active); + + GuiEditor.Undo(); + uCheck("undoing the last step greys Undo", !%undoItem.Active); + uCheck("and lights Redo", %redoItem.Active); + + GuiEditor.Redo(); + uCheck("redoing lights Undo again", %undoItem.Active); + uCheck("and greys Redo", !%redoItem.Active); + + GuiEditor.Undo(); + + schedule(300, 0, "uStepStale"); +} + +//----------------------------------------------------------------------------- +// The two ways the stack goes stale. +//----------------------------------------------------------------------------- + +function uStepStale() +{ + GuiEditor.undoRecorder.clear(); + + // A record naming a control that has since been deleted for real. Nothing + // clears the stack for this - a control the editor did not delete can still + // go away - so the replay has to survive it. + %doomed = new GuiButtonCtrl() { Position = "10 10"; Extent = "60 20"; Text = "X"; }; + $uOther.add(%doomed); + + %pane = uBind(%doomed); + %pane.writeField("Extent", "90 40"); + uCheck("the doomed control's edit was recorded", uUndoCount() == 1); + + %doomed.delete(); + GuiEditor.Undo(); + uCheck("undoing an edit on a deleted control is a no-op, not a crash", + uUndoCount() == 0 && uRedoCount() == 1); + + // A new document. Every id on the stack has just been freed. + // + // Through the prompt, because a document with edits in it is no longer thrown + // away without a word - and this one is nothing but edits. What the prompt + // itself does is tests/smoke/unsaved.cs; here it is only the way to the New. + GuiEditor.NewGui(); + uCheck("New Gui on a modified document asks first", discardUnsavedPrompt()); + uCheck("New Gui empties both stacks", uUndoCount() == 0 && uRedoCount() == 0); + + schedule(300, 0, "uStepChain"); +} + +//----------------------------------------------------------------------------- +// Deleting out of a layout container. +// +// A GuiChainCtrl lays its children out in list order and zeroes the position of +// any child added to it while the editor is open (guiChainCtrl.cc, +// onChildAdded) -- both right for a control being dropped in, both wrong for +// one being put back, which knows where it was and which slot it held. +// +// Last, and on the real editor UI, because that second half only happens when +// isEditMode() is true: that needs the brain awake, which needs the editor +// pushed onto the canvas rather than merely registered. Everything above runs +// without it, so this is where the canvas gets taken over. +//----------------------------------------------------------------------------- + +function uStepChain() +{ + EditorCore.open(); + EditorCore.tabBook.selectPageName("Gui Editor"); + + schedule(500, 0, "uStepChainRun"); +} + +function uStepChainRun() +{ + GuiEditor.undoRecorder.clear(); + + $uChain = new GuiChainCtrl() + { + Position = "10 10"; + Extent = "200 40"; + IsVertical = true; + ChildSpacing = 4; + }; + GuiEditor.rootGui.add($uChain); + + // Proof by behaviour that the canvas really is in edit mode, since that is + // what this whole step turns on: zeroing an added child's position is + // something a chain only does then. + %probe = new GuiButtonCtrl() { Extent = "80 24"; }; + %probe.setPosition(33, 33); + $uChain.add(%probe); + uCheck("the editor is really in edit mode now (a chain zeroes what it is given)", + getWord(%probe.getPosition(), 0) == 0); + %probe.delete(); + + // The x is set after the add, because the add is what zeroes it. + for(%i = 0; %i < 4; %i++) + { + %button = new GuiButtonCtrl() { Extent = "80 24"; Text = "chain" @ %i; }; + $uChain.add(%button); + %button.setPosition(%i * 5, 0); + $uButton[%i] = %button; + } + + uCheck("the chain holds four buttons", $uChain.getCount() == 4); + %xBefore = getWord($uButton[1].getPosition(), 0); + uCheck("and the second one has an x of its own", %xBefore == 5); + + uSelect($uButton[1]); + GuiEditor.brain.deleteSelection(); + uCheck("deleting left three", $uChain.getCount() == 3); + + GuiEditor.Undo(); + + uCheck("undo put it back in the chain", $uButton[1].getParent() == $uChain); + uCheck("at the index it came from", uIndexOf($uChain, $uButton[1]) == 1); + uCheck("keeping the x the chain does not own (" @ + getWord($uButton[1].getPosition(), 0) @ " was " @ %xBefore @ ")", + getWord($uButton[1].getPosition(), 0) == %xBefore); + + // The chain owns y, so the proof it laid out again is that the four are back + // in flow order rather than the restored one sitting where the list briefly + // had it -- last. + %y0 = getWord($uButton[0].getPosition(), 1); + %y1 = getWord($uButton[1].getPosition(), 1); + %y2 = getWord($uButton[2].getPosition(), 1); + %y3 = getWord($uButton[3].getPosition(), 1); + uCheck("and the chain laid them out in order again (" @ + %y0 SPC %y1 SPC %y2 SPC %y3 @ ")", %y0 < %y1 && %y1 < %y2 && %y2 < %y3); + + schedule(300, 0, "uStepContainers"); +} + +//----------------------------------------------------------------------------- +// Every other container that places its own children. +// +// A chain is not a special case: a grid, a tab book and a frame set all take +// something from a child when it arrives and all lay their children out by list +// order, so all of them can hand back the wrong thing after an undo. The test +// each one gets is the same, and it is the strongest one available: the whole +// container must look exactly as it did before the delete. +//----------------------------------------------------------------------------- + +// Every child's identity, position and extent, in list order. TAB-delimited +// because a position has a space in it. +function uLayoutOf(%parent) +{ + %text = ""; + for(%i = 0; %i < %parent.getCount(); %i++) + { + %child = %parent.getObject(%i); + %entry = %child @ "[" @ %child.getPosition() @ "][" @ %child.getExtent() @ "]"; + %text = (%text $= "") ? %entry : (%text TAB %entry); + } + + return %text; +} + +// Delete the second child of %parent and undo it. Everything has to come back: +// the list order, and every child's geometry, which is the container's to give. +function uRoundTrip(%label, %parent) +{ + GuiEditor.undoRecorder.clear(); + + %before = uLayoutOf(%parent); + %child = %parent.getObject(1); + + uSelect(%child); + GuiEditor.brain.deleteSelection(); + uCheck(%label @ ": the delete took", %parent.getCount() == 3); + + GuiEditor.Undo(); + + uCheck(%label @ ": undo put it back at index 1", uIndexOf(%parent, %child) == 1); + + %after = uLayoutOf(%parent); + uCheck(%label @ ": and the container looks as it did" NL + " before: " @ %before NL + " after: " @ %after, %after $= %before); +} + +function uStepContainers() +{ + // A grid, which places children into cells by list order and forces a + // child's sizing off center and fill when it arrives. + $uGrid = new GuiGridCtrl() + { + Position = "10 200"; + Extent = "300 120"; + CellSizeX = 60; + CellSizeY = 30; + MaxColCount = 2; + }; + GuiEditor.rootGui.add($uGrid); + + for(%i = 0; %i < 4; %i++) + { + %cell = new GuiButtonCtrl() { Extent = "50 20"; Text = "cell" @ %i; }; + $uGrid.add(%cell); + } + uRoundTrip("grid", $uGrid); + + // A container takes a child's sizing as well as its geometry: a grid forces + // center and fill off on arrival. A child put back on center afterwards + // would lose it again on the way in. + %cell = $uGrid.getObject(1); + %cell.setEditFieldValue("HorizSizing", "center"); + uCheck("grid: the cell is on center to start", + %cell.getFieldValue("HorizSizing") $= "center"); + + GuiEditor.undoRecorder.clear(); + uSelect(%cell); + GuiEditor.brain.deleteSelection(); + GuiEditor.Undo(); + uCheck("grid: undo restored the sizing the grid takes on arrival (" @ + %cell.getFieldValue("HorizSizing") @ ")", + %cell.getFieldValue("HorizSizing") $= "center"); + + // A tab book, whose tab strip is drawn from a page list of its own rather + // than from the children. + $uBook = new GuiTabBookCtrl() + { + Position = "330 200"; + Extent = "300 120"; + }; + GuiEditor.rootGui.add($uBook); + + for(%i = 0; %i < 4; %i++) + { + %page = new GuiTabPageCtrl() { Text = "page" @ %i; }; + $uBook.add(%page); + $uPage[%i] = %page; + } + uRoundTrip("tab book", $uBook); + + // The pages all share the book's page rect, so their geometry cannot say + // whether the tab strip came back in the right order. Selecting by index + // can: it selects the page list's second entry, which has to be the child + // that is second. + $uBook.selectPage(1); + uCheck("tab book: the second tab is the second page", + $uPage[1].isVisible()); + + schedule(300, 0, "uStepFrameSet"); +} + +function uStepFrameSet() +{ + // A frame set, which places each child in a frame of a tree it keeps beside + // the child list. + $uFrames = new GuiFrameSetCtrl() + { + Position = "10 340"; + Extent = "400 200"; + DividerThickness = 4; + }; + GuiEditor.rootGui.add($uFrames); + + %ids = $uFrames.createHorizontalSplit(1); + %left = getWord(%ids, 0); + %right = getWord(%ids, 1); + %ids = $uFrames.createVerticalSplit(%left); + %ids = $uFrames.createVerticalSplit(%right); + + for(%i = 0; %i < 4; %i++) + { + %panel = new GuiControl() { Extent = "40 20"; }; + $uFrames.add(%panel); + } + + // Settle the layout before anything is measured. A frame set places its + // children when it resizes, and until then they are still sitting at the + // bounds they were built with -- which would make the before-and-after + // comparison meaningless. + $uFrames.childrenReordered(); + + // The frame tree itself has to come back, not just the child list: removing + // a control destroys the frame it stood in and merges the split into its + // twin, so without that the sibling keeps the space it swallowed. + $uFrameLayout = $uFrames.getFrameLayout(); + uCheck("frame set: its layout can be read", $uFrameLayout !$= ""); + + uRoundTrip("frame set", $uFrames); + + uCheck("frame set: and the frame tree came back with it", + $uFrames.getFrameLayout() $= $uFrameLayout); + + // Redo has to take it apart again, or a second undo would be restoring + // against a tree that no longer matches. + GuiEditor.Redo(); + uCheck("frame set: redo collapsed the frame again", + $uFrames.getFrameLayout() !$= $uFrameLayout); + GuiEditor.Undo(); + uCheck("frame set: and undoing again rebuilt it", + $uFrames.getFrameLayout() $= $uFrameLayout); + + schedule(300, 0, "uDone"); +} + +function uDone() +{ + echo("UNDO DONE"); + quit(); +} diff --git a/tests/smoke/unsaved.cs b/tests/smoke/unsaved.cs new file mode 100644 index 000000000..0777014cf --- /dev/null +++ b/tests/smoke/unsaved.cs @@ -0,0 +1,417 @@ +//----------------------------------------------------------------------------- +// Unsaved-changes protection: the modified flag, the name on screen, the prompt +// that stands between a modified Gui and the four commands that would discard +// it, and Revert. +// +// The flag is derived from the undo recorder, which is already the one funnel +// every change goes through. What makes that worth testing rather than assuming +// is that depth is not identity: save, undo, then edit differently and the stack +// is exactly as deep as it was with a different document underneath it. A flag +// 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. +//----------------------------------------------------------------------------- + +setLogMode(2); +setScriptExecEcho(false); +trace(false); +$Scripts::ignoreDSOs = true; +setCompanyAndProduct("Torque Game Engines", "Torque2D"); +ModuleDatabase.EchoInfo = false; +AssetDatabase.EchoInfo = false; +AssetDatabase.IgnoreAutoUnload = true; + +testExec("editor/main.cs"); + +function usCheck(%label, %condition) +{ + echo(%condition ? ("SAVE PASS: " @ %label) : ("SAVE FAIL: " @ %label)); +} + +function usModified() +{ + return GuiEditor.undoRecorder.isModified(); +} + +// A dialog is pushed onto the Canvas and nothing keeps a handle to it, so it is +// found the way it is displayed: as the Canvas's newest child. +function usDialog(%class) +{ + for(%i = Canvas.getCount() - 1; %i >= 0; %i--) + { + %obj = Canvas.getObject(%i); + if(%obj.class $= %class) + { + return %obj; + } + } + + return 0; +} + +// One edit, through the properties pane, which is the route every field change +// the user makes takes. +function usEdit(%ctrl, %field, %value) +{ + GuiEditor.brain.clearSelection(); + GuiEditor.brain.select(%ctrl); + + %pane = GuiEditor.inspectorWindow.pane; + %pane.bind(%ctrl); + %pane.writeField(%field, %value); +} + +schedule(2000, 0, "usSetup"); + +// A throwaway project rather than PlanetX, because this suite writes a Gui file +// and PlanetX is real content. run.ps1 clears any folder a test names ending in +// SmokeProject before each run, so the save below has somewhere of its own to +// land. Nothing here needs a theme. +function usSetup() +{ + ProjectManager.setProjectFolder("unsavedSmokeProject"); + createPath(testRoot("unsavedSmokeProject/")); + + GuiEditor.open(); + + $usCtrl = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "A"; }; + GuiEditor.rootGui.add($usCtrl); + + // The add itself is an edit, so start the measurements from a clean slate. + GuiEditor.undoRecorder.markClean(); + + schedule(300, 0, "usStepFlag"); +} + +//----------------------------------------------------------------------------- +// The flag. +//----------------------------------------------------------------------------- + +function usStepFlag() +{ + usCheck("a document nobody has touched is not modified", !usModified()); + + usEdit($usCtrl, "Text", "B"); + usCheck("an edit marks it modified", usModified()); + + GuiEditor.undoRecorder.markClean(); + usCheck("saving clears it", !usModified()); + + // Back to where the save was taken. The document is byte for byte what was + // written, so it is not modified, and a flag that only ever latches would say + // otherwise. + usEdit($usCtrl, "Text", "C"); + GuiEditor.Undo(); + usCheck("undoing back to the save point clears it", !usModified()); + + GuiEditor.Redo(); + usCheck("and redoing away from it marks it again", usModified()); + + schedule(300, 0, "usStepBranch"); +} + +// The case a depth counter gets wrong: undo past the save point, then make a +// different edit. The stack is the same height it was at the save, and the +// document is not the document that was saved. +function usStepBranch() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.undoRecorder.markClean(); + + usEdit($usCtrl, "Text", "one"); + %depth = GuiEditor.undoRecorder.undoCount(); + GuiEditor.undoRecorder.markClean(); + + GuiEditor.Undo(); + usCheck("undo past the save point marks it", usModified()); + + usEdit($usCtrl, "Text", "two"); + usCheck("the stack is back to the depth it was saved at", + GuiEditor.undoRecorder.undoCount() == %depth); + usCheck("but a different edit is still modified", usModified()); + + schedule(300, 0, "usStepWipe"); +} + +// Clearing the stack does not clean the document. detachTheme does exactly this +// -- a profile the document is wearing is about to be freed, so every record +// naming it has to go -- and the controls are just as edited afterwards. +function usStepWipe() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.undoRecorder.markClean(); + + usEdit($usCtrl, "Text", "edited"); + usCheck("modified before the wipe", usModified()); + + GuiEditor.undoRecorder.clear(); + usCheck("and still modified after it", usModified()); + + schedule(300, 0, "usStepTitle"); +} + +//----------------------------------------------------------------------------- +// The name on screen. The Gui Tools window's own title carries it: the window is +// the top-left panel and already holds the theme buttons, so it becomes the +// strip that says what is being worked on. +//----------------------------------------------------------------------------- + +function usTitle() +{ + return GuiEditor.guiToolsWindow.getText(); +} + +function usStepTitle() +{ + GuiEditor.undoRecorder.clear(); + GuiEditor.undoRecorder.markClean(); + + usCheck("a Gui with no file of its own is untitled", usTitle() $= "untitled.gui"); + + usEdit($usCtrl, "Text", "titled"); + usCheck("and wears a marker once edited", usTitle() $= "untitled.gui *"); + + // A real save rather than writing the field, because the point is that + // SaveCore both clears the flag and puts the new name up. + $usFile = testRoot("unsavedSmokeProject/unsavedTitle.gui"); + GuiEditor.SaveCore($usFile, 0, "unsavedSmokeProject", ""); + + usCheck("saving puts the file name up", usTitle() $= "unsavedTitle.gui"); + usCheck("and takes the marker off", !usModified()); + + usEdit($usCtrl, "Text", "changed since"); + usCheck("editing a saved Gui marks it again", usTitle() $= "unsavedTitle.gui *"); + + usCheck("and the file really was written", isFile($usFile)); + + schedule(300, 0, "usStepNoAsk"); +} + +//----------------------------------------------------------------------------- +// The guard. Four commands throw the document away; each one asks first, and +// only when there is something to lose. +//----------------------------------------------------------------------------- + +function usGuardDialog() +{ + return usDialog("GuiEditorConfirmSaveDialog"); +} + +// A control on the canvas with an edit against it, so there is work to lose. +function usDirtyDocument() +{ + $usCtrl = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "A"; }; + GuiEditor.rootGui.add($usCtrl); + usEdit($usCtrl, "Text", "worth keeping"); +} + +function usStepNoAsk() +{ + GuiEditor.undoRecorder.markClean(); + GuiEditor.NewGui(); + + usCheck("New on a clean document does not ask", !isObject(usGuardDialog())); + usCheck("it empties the document", GuiEditor.rootGui.getCount() == 0); + usCheck("the new document is clean", !usModified()); + usCheck("and untitled again", usTitle() $= "untitled.gui"); + + schedule(300, 0, "usStepCancel"); +} + +function usStepCancel() +{ + usDirtyDocument(); + %count = GuiEditor.rootGui.getCount(); + + GuiEditor.NewGui(); + %dialog = usGuardDialog(); + usCheck("New on a modified document asks", isObject(%dialog)); + + %dialog.onCancel(); + usCheck("Cancel leaves the document alone", GuiEditor.rootGui.getCount() == %count); + usCheck("and leaves it modified", usModified()); + + schedule(300, 0, "usStepDiscard"); +} + +function usStepDiscard() +{ + GuiEditor.NewGui(); + %dialog = usGuardDialog(); + usCheck("still asking", isObject(%dialog)); + + %dialog.onDiscard(); + usCheck("Discard goes through with the New", GuiEditor.rootGui.getCount() == 0); + usCheck("and what it left behind is clean", !usModified()); + + schedule(300, 0, "usStepSave"); +} + +// Save from the prompt, on a Gui that already has a file: it writes and carries +// on without another question. +function usStepSave() +{ + usDirtyDocument(); + GuiEditor.SaveCore($usFile, 0, "unsavedSmokeProject", ""); + usEdit($usCtrl, "Text", "changed after the save"); + + GuiEditor.NewGui(); + %dialog = usGuardDialog(); + usCheck("a saved-but-changed Gui asks too", isObject(%dialog)); + + %dialog.onSave(); + usCheck("Save did not need the Save As dialog", + !isObject(usDialog("GuiEditorSaveGuiDialog"))); + usCheck("and then went through with the New", GuiEditor.rootGui.getCount() == 0); + usCheck("leaving a clean document", !usModified()); + + schedule(300, 0, "usStepSaveAsCancelled"); +} + +// The trap. On a Gui with no file, Save opens Save As -- which has its own +// Cancel, and taking it must abandon the New rather than quietly going ahead +// with it having saved nothing. +function usStepSaveAsCancelled() +{ + usDirtyDocument(); + %count = GuiEditor.rootGui.getCount(); + + GuiEditor.NewGui(); + usGuardDialog().onSave(); + + %saveDialog = usDialog("GuiEditorSaveGuiDialog"); + usCheck("Save on a never-saved Gui opens Save As", isObject(%saveDialog)); + + %saveDialog.onClose(); + usCheck("cancelling Save As leaves the document in place", + GuiEditor.rootGui.getCount() == %count); + usCheck("and leaves it modified", usModified()); + + schedule(300, 0, "usStepMenuCommands"); +} + +// The two exits that cannot be run from a suite that has to survive to report. +// Menu items are nested controls, so this walks rather than indexes. +function usMenuItem(%parent, %text) +{ + for(%i = 0; %i < %parent.getCount(); %i++) + { + %item = %parent.getObject(%i); + if(%item.Text $= %text) + { + return %item; + } + + %found = usMenuItem(%item, %text); + if(isObject(%found)) + { + return %found; + } + } + + return 0; +} + +function usStepMenuCommands() +{ + %exit = usMenuItem(EditorCore.menuBar, "Exit"); + %closeProject = usMenuItem(EditorCore.menuBar, "Close Project"); + + usCheck("the Exit item exists", isObject(%exit)); + usCheck("the Close Project item exists", isObject(%closeProject)); + + // Both are EditorCore.guardedCommand with the real command inside, and + // running either one for real would take the process with it. So the route + // itself is exercised with a harmless command instead, and the two items are + // checked for naming that route. + usCheck("Exit goes through the guard", + strstr(%exit.Command, "guardedCommand") >= 0); + usCheck("Close Project goes through the guard", + strstr(%closeProject.Command, "guardedCommand") >= 0); + + schedule(300, 0, "usStepGuardedCommand"); +} + +// The route those two items take, with something safe at the end of it. +function usStepGuardedCommand() +{ + $usRan = false; + + // Nothing to lose: straight through. + GuiEditor.undoRecorder.markClean(); + EditorCore.guardedCommand("$usRan = true;"); + usCheck("a guarded command on a clean document just runs", $usRan); + + // Something to lose: held until answered for. + $usRan = false; + usDirtyDocument(); + EditorCore.guardedCommand("$usRan = true;"); + + usCheck("on a modified one it asks first", isObject(usGuardDialog())); + usCheck("and holds the command back", !$usRan); + + usGuardDialog().onCancel(); + usCheck("Cancel drops it for good", !$usRan); + + EditorCore.guardedCommand("$usRan = true;"); + usGuardDialog().onDiscard(); + usCheck("Discard lets it through", $usRan); + + schedule(300, 0, "usStepRevertGreyed"); +} + +//----------------------------------------------------------------------------- +// Revert, which is the only command here that puts something back rather than +// taking it away - and which still asks first, because putting the file back is +// discarding everything done since. +//----------------------------------------------------------------------------- + +function usStepRevertGreyed() +{ + // A document with no file of its own. There is nothing to revert TO. + GuiEditor.NewGui(); + usGuardDialog().onDiscard(); + + $usRevert = usMenuItem(EditorCore.menuBar, "Revert"); + usCheck("the Revert item exists", isObject($usRevert)); + usCheck("and is not offered before the first save", !$usRevert.Active); + + schedule(300, 0, "usStepRevert"); +} + +function usStepRevert() +{ + $usCtrl = new GuiButtonCtrl() { Position = "10 10"; Extent = "80 30"; Text = "A"; }; + GuiEditor.rootGui.add($usCtrl); + usEdit($usCtrl, "Text", "on disk"); + + GuiEditor.SaveCore($usFile, 0, "unsavedSmokeProject", ""); + usCheck("Revert is offered once the Gui has a file", $usRevert.Active); + + usEdit($usCtrl, "Text", "not on disk"); + usCheck("and the change is showing", $usCtrl.getText() $= "not on disk"); + + GuiEditor.Revert(); + usCheck("Revert asks before discarding", isObject(usGuardDialog())); + usGuardDialog().onDiscard(); + + schedule(300, 0, "usStepRevertCheck"); +} + +function usStepRevertCheck() +{ + usCheck("the document was re-read", GuiEditor.rootGui.getCount() == 1); + usCheck("with what was on disk in it", + GuiEditor.rootGui.getObject(0).getText() $= "on disk"); + usCheck("and it is clean again", !usModified()); + usCheck("still under its own name", usTitle() $= "unsavedTitle.gui"); + + schedule(300, 0, "usDone"); +} + +function usDone() +{ + echo("SAVE DONE"); + quit(); +} diff --git a/toybox/KeyboardToy/1/ChangeUsernameDlg.gui.taml b/toybox/KeyboardToy/1/ChangeUsernameDlg.gui.taml index c4e911521..d8e8539b2 100644 --- a/toybox/KeyboardToy/1/ChangeUsernameDlg.gui.taml +++ b/toybox/KeyboardToy/1/ChangeUsernameDlg.gui.taml @@ -1,66 +1,82 @@ - + Active="true" + align="default" + vAlign="default" + Image="@asset=Sandbox:blueGradient" + constrainProportions="false" + HelpTag="0"> - - - - - + Name="ChangeUsernameEntry" + canSaveDynamicFields="false" + isContainer="false" + Profile="GuiTextEditProfile" + HorizSizing="center" + VertSizing="anchorTop" + Position="389 320" + Extent="430 50" + MinExtent="8 8" + AltCommand="VirtualKeyboard.pop();UserNameTxt.setText(ChangeUsernameEntry.getText());" + Active="true" + Text="" + align="default" + vAlign="default" + fontSizeAdjust="3" + sinkAllKeyEvents="true" + MaxLength="255" /> + + + + diff --git a/toybox/KeyboardToy/1/MainGameDlg.gui.taml b/toybox/KeyboardToy/1/MainGameDlg.gui.taml index db56a8a4e..93e6fb8db 100644 --- a/toybox/KeyboardToy/1/MainGameDlg.gui.taml +++ b/toybox/KeyboardToy/1/MainGameDlg.gui.taml @@ -1,70 +1,80 @@ - - - - - - - + Active="true" + align="default" + vAlign="default" + Image="@asset=Sandbox:blueGradient" + constrainProportions="false" + HelpTag="0"> + + + + + diff --git a/toybox/KeyboardToy/1/main.cs b/toybox/KeyboardToy/1/main.cs index f664245a1..8c9e9ef9c 100644 --- a/toybox/KeyboardToy/1/main.cs +++ b/toybox/KeyboardToy/1/main.cs @@ -21,11 +21,18 @@ //----------------------------------------------------------------------------- function KeyboardToy::create( %this ) -{ - Sandbox.add( TamlRead("./MainGameDlg.gui.taml") ); - Sandbox.add( TamlRead("./ChangeUsernameDlg.gui.taml") ); +{ + // Keep hold of what we make. The Sandbox owns the controls once they are + // added, so the toy cannot assume they are still there when it is torn + // down, and a held id does not depend on the name being registered. + %this.mainDlg = TamlRead("./MainGameDlg.gui.taml"); + Sandbox.add( %this.mainDlg ); + + %this.changeUsernameDlg = TamlRead("./ChangeUsernameDlg.gui.taml"); + Sandbox.add( %this.changeUsernameDlg ); + // Reset the toy. - KeyboardToy.reset(); + %this.reset(); } @@ -33,8 +40,13 @@ function KeyboardToy::destroy( %this ) { - MainGameDlg.delete(); - ChangeUsernameDlg.delete(); + // The Sandbox may have taken these down with it already, so delete only + // what is still standing. + if ( isObject(%this.mainDlg) ) + %this.mainDlg.delete(); + + if ( isObject(%this.changeUsernameDlg) ) + %this.changeUsernameDlg.delete(); } //----------------------------------------------------------------------------- @@ -43,8 +55,7 @@ { // Clear the scene. SandboxScene.clear(); - - Canvas.pushDialog(MainGameDlg); - + + Canvas.pushDialog(%this.mainDlg); } //----------------------------------------------------------------------------- diff --git a/toybox/Sandbox/1/gui/guiProfiles.cs b/toybox/Sandbox/1/gui/guiProfiles.cs index bb1c6a7bc..bdebd675d 100644 --- a/toybox/Sandbox/1/gui/guiProfiles.cs +++ b/toybox/Sandbox/1/gui/guiProfiles.cs @@ -565,6 +565,7 @@ function SafeCreateNamedObject(%name, %object) { fillColor = "232 240 248 255"; fillColorHL = "242 250 255 255"; + fillColorSL = "242 250 255 255"; fillColorNA = "127 127 127 52"; fillColorTextSL = "251 170 0 255"; fontColor = "27 59 95 255"; diff --git a/toybox/VirtualKeyboard/1/assets/images/closeBtn.png b/toybox/VirtualKeyboard/1/assets/images/closeBtn.png deleted file mode 100644 index ef3fb84a1..000000000 Binary files a/toybox/VirtualKeyboard/1/assets/images/closeBtn.png and /dev/null differ diff --git a/toybox/VirtualKeyboard/1/assets/images/closeBtn.taml b/toybox/VirtualKeyboard/1/assets/images/closeBtn.taml deleted file mode 100644 index 572ec94d2..000000000 --- a/toybox/VirtualKeyboard/1/assets/images/closeBtn.taml +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/toybox/VirtualKeyboard/1/assets/images/closeStates.png b/toybox/VirtualKeyboard/1/assets/images/closeStates.png new file mode 100644 index 000000000..695c5b197 Binary files /dev/null and b/toybox/VirtualKeyboard/1/assets/images/closeStates.png differ diff --git a/toybox/VirtualKeyboard/1/assets/images/closeStates.taml b/toybox/VirtualKeyboard/1/assets/images/closeStates.taml new file mode 100644 index 000000000..12df715a8 --- /dev/null +++ b/toybox/VirtualKeyboard/1/assets/images/closeStates.taml @@ -0,0 +1,7 @@ + diff --git a/toybox/VirtualKeyboard/1/assets/images/kbbD.png b/toybox/VirtualKeyboard/1/assets/images/kbbD.png deleted file mode 100644 index 66999b2f8..000000000 Binary files a/toybox/VirtualKeyboard/1/assets/images/kbbD.png and /dev/null differ diff --git a/toybox/VirtualKeyboard/1/assets/images/kbbD.taml b/toybox/VirtualKeyboard/1/assets/images/kbbD.taml deleted file mode 100644 index 747758e08..000000000 --- a/toybox/VirtualKeyboard/1/assets/images/kbbD.taml +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/toybox/VirtualKeyboard/1/assets/images/kbbN.png b/toybox/VirtualKeyboard/1/assets/images/kbbN.png deleted file mode 100644 index 922ab5670..000000000 Binary files a/toybox/VirtualKeyboard/1/assets/images/kbbN.png and /dev/null differ diff --git a/toybox/VirtualKeyboard/1/assets/images/kbbN.taml b/toybox/VirtualKeyboard/1/assets/images/kbbN.taml deleted file mode 100644 index 6fb28665c..000000000 --- a/toybox/VirtualKeyboard/1/assets/images/kbbN.taml +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/toybox/VirtualKeyboard/1/assets/images/keyLatched.png b/toybox/VirtualKeyboard/1/assets/images/keyLatched.png new file mode 100644 index 000000000..da8b08dac Binary files /dev/null and b/toybox/VirtualKeyboard/1/assets/images/keyLatched.png differ diff --git a/toybox/VirtualKeyboard/1/assets/images/keyLatched.taml b/toybox/VirtualKeyboard/1/assets/images/keyLatched.taml new file mode 100644 index 000000000..698ed8fba --- /dev/null +++ b/toybox/VirtualKeyboard/1/assets/images/keyLatched.taml @@ -0,0 +1,7 @@ + diff --git a/toybox/VirtualKeyboard/1/assets/images/keyStates.png b/toybox/VirtualKeyboard/1/assets/images/keyStates.png new file mode 100644 index 000000000..15d7672a5 Binary files /dev/null and b/toybox/VirtualKeyboard/1/assets/images/keyStates.png differ diff --git a/toybox/VirtualKeyboard/1/assets/images/keyStates.taml b/toybox/VirtualKeyboard/1/assets/images/keyStates.taml new file mode 100644 index 000000000..019da0588 --- /dev/null +++ b/toybox/VirtualKeyboard/1/assets/images/keyStates.taml @@ -0,0 +1,7 @@ + diff --git a/toybox/VirtualKeyboard/1/assets/images/spaceBar.png b/toybox/VirtualKeyboard/1/assets/images/spaceBar.png deleted file mode 100644 index f4312f3e3..000000000 Binary files a/toybox/VirtualKeyboard/1/assets/images/spaceBar.png and /dev/null differ diff --git a/toybox/VirtualKeyboard/1/assets/images/spaceBar.taml b/toybox/VirtualKeyboard/1/assets/images/spaceBar.taml deleted file mode 100644 index 1fa9fe1c4..000000000 --- a/toybox/VirtualKeyboard/1/assets/images/spaceBar.taml +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/toybox/VirtualKeyboard/1/assets/images/spaceBarD.png b/toybox/VirtualKeyboard/1/assets/images/spaceBarD.png deleted file mode 100644 index 887478727..000000000 Binary files a/toybox/VirtualKeyboard/1/assets/images/spaceBarD.png and /dev/null differ diff --git a/toybox/VirtualKeyboard/1/assets/images/spaceBarD.taml b/toybox/VirtualKeyboard/1/assets/images/spaceBarD.taml deleted file mode 100644 index ef8cba9c6..000000000 --- a/toybox/VirtualKeyboard/1/assets/images/spaceBarD.taml +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/toybox/VirtualKeyboard/1/assets/images/spaceBarStates.png b/toybox/VirtualKeyboard/1/assets/images/spaceBarStates.png new file mode 100644 index 000000000..cc3a9eaf4 Binary files /dev/null and b/toybox/VirtualKeyboard/1/assets/images/spaceBarStates.png differ diff --git a/toybox/VirtualKeyboard/1/assets/images/spaceBarStates.taml b/toybox/VirtualKeyboard/1/assets/images/spaceBarStates.taml new file mode 100644 index 000000000..fa1f77555 --- /dev/null +++ b/toybox/VirtualKeyboard/1/assets/images/spaceBarStates.taml @@ -0,0 +1,7 @@ + diff --git a/toybox/VirtualKeyboard/1/gui/keyboardGui.gui.taml b/toybox/VirtualKeyboard/1/gui/keyboardGui.gui.taml new file mode 100644 index 000000000..74a319211 --- /dev/null +++ b/toybox/VirtualKeyboard/1/gui/keyboardGui.gui.taml @@ -0,0 +1,664 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toybox/VirtualKeyboard/1/gui/keyboardGui.taml b/toybox/VirtualKeyboard/1/gui/keyboardGui.taml deleted file mode 100644 index 82fc41f59..000000000 --- a/toybox/VirtualKeyboard/1/gui/keyboardGui.taml +++ /dev/null @@ -1,749 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/toybox/VirtualKeyboard/1/main.cs b/toybox/VirtualKeyboard/1/main.cs index 4761c3a0e..847394da1 100644 --- a/toybox/VirtualKeyboard/1/main.cs +++ b/toybox/VirtualKeyboard/1/main.cs @@ -18,7 +18,7 @@ } function VirtualKeyboard::push(%this, %targetGui, %textBox, %showClose) { - Sandbox.add( TamlRead("./gui/keyboardGui.taml") ); + Sandbox.add( TamlRead("./gui/keyboardGui.gui.taml") ); %textBox.setText(""); %this.targetGui = %targetGui; @@ -182,42 +182,58 @@ %this.textBox.setText(getSubStr(%this.textBox.getText(), 0, %len - 1)); } -if(!isObject(GuiKeyboardProfile)) new GuiControlProfile (GuiKeyboardProfile) +//----------------------------------------------------------------------------- +// The key caps. +// +// A profile's imageAsset is indexed by control state, so one four frame strip +// carries a whole button: 0 normal, 1 hover, 2 pressed, 3 disabled. That is the +// whole reason these are profiles rather than fields on the buttons - the keys +// share four looks between thirty seven of them. +// +// No colors here. renderUniversalRect only reaches fillColor when there is no +// image to draw, and these buttons carry no text, so the font fields would go +// unread as well. The click comes from soundButtonDown, which GuiButtonCtrl +// plays for us. +//----------------------------------------------------------------------------- + +if(!isObject(GuiKeyboardKeyProfile)) new GuiControlProfile (GuiKeyboardKeyProfile) { tab = false; canKeyFocus = false; - hasBitmapArray = false; mouseOverSelected = false; - // fill color - fillColor = "211 211 211 255"; - fillColorHL = "244 244 244 255"; - fillColorSL = "244 244 244 255"; - fillColorNA = "244 244 244 255"; - - // border color - border = 0; - borderColor = "100 100 100 255"; - borderColorHL = "128 128 128 255"; - borderColorSL = "128 128 128 255"; - borderColorNA = "64 64 64 255"; - - // font - fontType = $platformFontType; - fontSize = $platformFontSize; - - fontColor = "0 0 0"; - fontColorHL = "32 100 100"; - fontColorSL= "10 10 10"; - fontColorNA = "0 0 0"; - - // used by guiTextControl - align = "left"; - returnTab = false; - numbersOnly = false; - cursorColor = "0 0 0 255"; - - // sounds + imageAsset = "VirtualKeyboard:keyStates"; + soundButtonDown = "VirtualKeyboard:keypress"; +}; + +if(!isObject(GuiKeyboardSpaceProfile)) new GuiControlProfile (GuiKeyboardSpaceProfile) +{ + tab = false; + canKeyFocus = false; + mouseOverSelected = false; + + imageAsset = "VirtualKeyboard:spaceBarStates"; + soundButtonDown = "VirtualKeyboard:keypress"; +}; + +if(!isObject(GuiKeyboardCloseProfile)) new GuiControlProfile (GuiKeyboardCloseProfile) +{ + tab = false; + canKeyFocus = false; + mouseOverSelected = false; + + imageAsset = "VirtualKeyboard:closeStates"; + soundButtonDown = "VirtualKeyboard:keypress"; +}; + +// Caps lock is shown by a button that wears the pressed cap in every state, so +// it reads as held down for as long as the lock is on. +if(!isObject(GuiKeyboardLatchedProfile)) new GuiControlProfile (GuiKeyboardLatchedProfile) +{ + tab = false; + canKeyFocus = false; + mouseOverSelected = false; + + imageAsset = "VirtualKeyboard:keyLatched"; soundButtonDown = "VirtualKeyboard:keypress"; - //soundButtonOver = "Sandbox:mouseOver"; };