From df27ae7f50a5f1ab0f5a8450541f52185ca24ee9 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 8 Sep 2026 21:34:48 +0300 Subject: [PATCH 01/52] Vendor RmlUi, FreeType and Dear ImGui for the UI system --- .github/workflows/engine-build.yml | 7 +++ .gitmodules | 9 +++ THIRD_PARTY_NOTICES.md | 7 +++ docs/BUILDING.md | 8 +++ docs/UI_DESIGN.md | 49 ++++++++------- tests/CMakeLists.txt | 1 + tests/uishell/CMakeLists.txt | 23 +++++++ tests/uishell/uishell.cpp | 90 +++++++++++++++++++++++++++ thirdparty/CMakeLists.txt | 79 +++++++++++++++++++++++ thirdparty/RmlUi | 1 + thirdparty/freetype | 1 + thirdparty/imgui | 1 + thirdparty/licenses/freetype-zlib.txt | 25 ++++++++ thirdparty/licenses/stb-imgui.txt | 40 ++++++++++++ 14 files changed, 319 insertions(+), 22 deletions(-) create mode 100644 tests/uishell/CMakeLists.txt create mode 100644 tests/uishell/uishell.cpp create mode 160000 thirdparty/RmlUi create mode 160000 thirdparty/freetype create mode 160000 thirdparty/imgui create mode 100644 thirdparty/licenses/freetype-zlib.txt create mode 100644 thirdparty/licenses/stb-imgui.txt diff --git a/.github/workflows/engine-build.yml b/.github/workflows/engine-build.yml index 75102b394..a8f062e59 100644 --- a/.github/workflows/engine-build.yml +++ b/.github/workflows/engine-build.yml @@ -88,6 +88,13 @@ jobs: cat thirdparty/licenses/khronos-vulkan-notice.txt \ thirdparty/bgfx.cmake/bimg/3rdparty/astc-encoder/LICENSE.txt \ > artifact/OpenTS_THIRD_PARTY_LICENSES/khronos-vulkan.txt + cp thirdparty/RmlUi/LICENSE.txt artifact/OpenTS_THIRD_PARTY_LICENSES/rmlui.txt + cp thirdparty/RmlUi/Include/RmlUi/Core/Containers/LICENSE.txt artifact/OpenTS_THIRD_PARTY_LICENSES/rmlui-containers.txt + cat thirdparty/freetype/LICENSE.TXT thirdparty/freetype/docs/FTL.TXT \ + > artifact/OpenTS_THIRD_PARTY_LICENSES/freetype.txt + cp thirdparty/licenses/freetype-zlib.txt artifact/OpenTS_THIRD_PARTY_LICENSES/zlib.txt + cp thirdparty/imgui/LICENSE.txt artifact/OpenTS_THIRD_PARTY_LICENSES/dear-imgui.txt + cp thirdparty/licenses/stb-imgui.txt artifact/OpenTS_THIRD_PARTY_LICENSES/stb-imgui.txt - name: Upload runtime files uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.gitmodules b/.gitmodules index 0af59cec6..7805f8147 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,12 @@ [submodule "thirdparty/miniaudio"] path = thirdparty/miniaudio url = https://github.com/mackron/miniaudio.git +[submodule "thirdparty/RmlUi"] + path = thirdparty/RmlUi + url = https://github.com/mikke89/RmlUi.git +[submodule "thirdparty/freetype"] + path = thirdparty/freetype + url = https://github.com/freetype/freetype.git +[submodule "thirdparty/imgui"] + path = thirdparty/imgui + url = https://github.com/ocornut/imgui.git diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 13cdcc4d1..d68cef927 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -15,6 +15,13 @@ remains under its own license and copyright notices. | [Vulkan Headers](https://github.com/KhronosGroup/Vulkan-Headers) | Vulkan API headers used by bgfx | Apache-2.0 | | [miniaudio](https://github.com/mackron/miniaudio) | Audio device output, resampling, and WAV, FLAC, and MP3 decoding | MIT-0 or Unlicense | | [stb_vorbis](https://github.com/nothings/stb) | Ogg Vorbis decoding, bundled with miniaudio | MIT or Unlicense | +| [RmlUi](https://github.com/mikke89/RmlUi) | User interface documents, styling, and layout | MIT | +| [robin_hood](https://github.com/martinus/robin-hood-hashing) | Hash map bundled with RmlUi | MIT | +| [itlib](https://github.com/iboB/itlib) | Containers bundled with RmlUi | MIT | +| [FreeType](https://freetype.org) | Font rasterization used by RmlUi | FTL | +| [zlib](https://zlib.net) | Compressed font support, bundled with FreeType | zlib | +| [Dear ImGui](https://github.com/ocornut/imgui) | Developer overlays | MIT | +| [stb](https://github.com/nothings/stb) | Rectangle packing, text editing, and TrueType headers bundled with Dear ImGui | MIT or Unlicense | The source checkout keeps the license texts under `thirdparty/`. Binary packages reproduce the license texts for the components used by OpenTS under diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 25c61988c..ba4adb609 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -39,6 +39,14 @@ The audio layer uses [miniaudio](https://github.com/mackron/miniaudio), vendored through `thirdparty/miniaudio` at a tested tag and compiled as one translation unit from `thirdparty/miniaudio-impl.c`. +The user interface toolkits are [RmlUi](https://github.com/mikke89/RmlUi), +with [FreeType](https://freetype.org) rasterizing its fonts, and +[Dear ImGui](https://github.com/ocornut/imgui) for developer overlays. They are +vendored through `thirdparty/RmlUi`, `thirdparty/freetype`, and +`thirdparty/imgui` at tested tags. FreeType builds with its bundled zlib copy +and without bzip2, PNG, HarfBuzz, or Brotli; Dear ImGui is compiled from its +core sources without any of its bundled backends. + For a fresh clone, use `git clone --recurse-submodules`. Configuration stops with instructions if a submodule is missing. Update a pinned tag in a separate change. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index edebbfcb8..b19a5d465 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,9 +1,11 @@ # UI system design -Status: proposal. Nothing here is implemented, built, or measured. Source -inspection and upstream documentation inform it. This page owns the proposed -UI architecture and migration; [Building OpenTS](BUILDING.md) owns build -support and [Project direction](DIRECTION.md) the wider architecture. +Status: proposal under implementation. Step 1 of the +[migration plan](#migration-plan), the dependencies, has landed; nothing else +is implemented, built, or measured. Source inspection and upstream +documentation inform the rest. This page owns the proposed UI architecture and +migration; [Building OpenTS](BUILDING.md) owns build support and +[Project direction](DIRECTION.md) the wider architecture. ## Where the UI stands today @@ -531,13 +533,12 @@ commit every document to bitmap faces. That choice waits for that view. ### Strings -Engine strings become UTF-8 through the process active code page declared -in `sun.manifest`, a separate change that is a prerequisite for every RmlUi -screen that shows text. With it, every narrow Win32 API, including the -`LoadString` behind `Fetch_String`, yields UTF-8 bytes, and the shell copies -a string out of the `Fetch_String` cache and hands it to RmlUi unchanged. -Text typed into a field goes into engine buffers unchanged. What the -transition does not remove: fixed-size engine buffers, packet fields, and +Engine strings are UTF-8 through the process active code page declared in +`sun.manifest`; that transition has landed. Every narrow Win32 API, including +the `LoadString` behind `Fetch_String`, yields UTF-8 bytes, and the shell +copies a string out of the `Fetch_String` cache and hands it to RmlUi +unchanged. Text typed into a field goes into engine buffers unchanged. What +the transition does not remove: fixed-size engine buffers, packet fields, and file names are sized in bytes, so a field's character limit is a byte limit and truncation never splits a sequence; and `WWFontClass` indexes glyphs by byte, which bounds in-game text to the range the transition supports. @@ -661,14 +662,15 @@ built static with the static CRT that `thirdparty/CMakeLists.txt` forces: | Project | License | Notes | | --- | --- | --- | | RmlUi 6.x | MIT | `RMLUI_FONT_ENGINE=freetype`, no samples, no backends, static | -| FreeType 2.13 | FTL | bzip2, PNG, HarfBuzz, and Brotli disabled; aliased as `Freetype::Freetype` for RmlUi's find | +| FreeType 2.14 | FTL | zlib, bzip2, PNG, HarfBuzz, and Brotli disabled, so the gzip module uses the bundled zlib copy; aliased as `Freetype::Freetype` for RmlUi's dependency check | | Dear ImGui | MIT | core sources compiled into a small target; no bundled backends | `THIRD_PARTY_NOTICES.md`, `thirdparty/licenses/`, and the packaging license -copy grow by the same three entries. CI already checks out submodules -recursively. The build stamp step gains the string-name generator, and -`bimg_decode` loses `EXCLUDE_FROM_ALL` and is linked. Dependency upgrades are -separate changes. +copy grow by the three projects and the components they bundle: robin_hood +and itlib in RmlUi, zlib in FreeType, and the stb headers in Dear ImGui. CI +already checks out submodules recursively. The build stamp step gains the +string-name generator, and `bimg_decode` loses `EXCLUDE_FROM_ALL` and is +linked. Dependency upgrades are separate changes. ## Migration plan @@ -680,10 +682,11 @@ takes one. Sizes are rough: S under a day of focused work, M a few days, L a week or more. The order is bottom-up because of the coexistence rule: a screen migrates only after every screen it can open has migrated. -Prerequisite: the UTF-8 transition lands before step 3. Steps 1 and 2 need no -text beyond an ASCII test document. +The UTF-8 transition that step 3 needs has landed. Steps 1 and 2 need no text +beyond an ASCII test document. -1. **Dependencies** (S). Submodules, CMake, notices, `BUILDING.md`. No engine +1. **Dependencies** (S, landed). Submodules, CMake, notices, `BUILDING.md`, + and a `tests/uishell` smoke test that links the three libraries. No engine code uses them. Evidence: Debug and Release build. 2. **Shell** (M). Everything in the code-layout table except screens, the backend split, the input hook, resize handling, the `ui/` copy step, the @@ -731,9 +734,11 @@ credits are unscheduled. ## Validation and evidence -A `tests/uishell` CTest target links RmlUi core, FreeType, `uiscreen.h`, the -string table, and the screen presenters with a recording render interface -and a null system interface. It runs without game assets: +The `tests/uishell` CTest target begins as a smoke test that brings RmlUi +core, FreeType, and Dear ImGui up and down under the engine's link settings. +As screens land it links `uiscreen.h`, the string table, and the screen +presenters with a recording render interface and a null system interface. It +runs without game assets: - Load every shipped document and fail on a parse error or a property outside the declared profile. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 69f46e4cc..817289cf2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -32,3 +32,4 @@ add_subdirectory(deploymentconfig) add_subdirectory(tutorial) add_subdirectory(utf8) add_subdirectory(shapefacing) +add_subdirectory(uishell) diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt new file mode 100644 index 000000000..d8f48e2d0 --- /dev/null +++ b/tests/uishell/CMakeLists.txt @@ -0,0 +1,23 @@ +# The harness links the vendored UI toolkits the way the engine will, so a runtime library +# or structure layout mismatch fails here before any screen exists. It lives outside code/ +# so that the recursive glob building OpenTS cannot pick this target's entry point up. +add_executable(UIShell + "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" +) + +target_compile_features(UIShell PRIVATE cxx_std_20) + +target_compile_definitions(UIShell PRIVATE WIN32 _WINDOWS NOMINMAX) + +target_compile_options(UIShell PRIVATE + $<$:/MTd /EHsc /Zc:__cplusplus> + $<$:/MT /EHsc /Zc:__cplusplus> +) + +target_link_libraries(UIShell PRIVATE RmlUi::Core freetype imgui kernel32 user32 shell32) + +set_target_properties(UIShell PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +add_test(NAME uishell COMMAND UIShell) diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp new file mode 100644 index 000000000..d2c62e315 --- /dev/null +++ b/tests/uishell/uishell.cpp @@ -0,0 +1,90 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Starts each vendored UI toolkit up and down once with no window, renderer, or game +// data, linked the way the engine links them. A runtime library or structure layout +// mismatch shows here as a link error or a failed check. + +#include + +#include +#include +#include FT_FREETYPE_H +#include + +namespace { + +int Failures = 0; + + +void Check(bool condition, char const * what) +{ + std::printf("%-76s %s\n", what, condition ? "ok" : "FAILED"); + + if (!condition) { + Failures++; + } +} + + +void Test_RmlUi(void) +{ + Check(Rml::Initialise(), "RmlUi initialises without a render interface"); + + Rml::String version = Rml::GetVersion(); + std::printf(" RmlUi %s\n", version.c_str()); + Check(!version.empty(), "RmlUi reports a version"); + + Rml::Shutdown(); +} + + +void Test_FreeType(void) +{ + FT_Library library = nullptr; + Check(FT_Init_FreeType(&library) == 0 && library != nullptr, "FreeType initialises a library"); + + if (library != nullptr) { + FT_Int major = 0; + FT_Int minor = 0; + FT_Int patch = 0; + FT_Library_Version(library, &major, &minor, &patch); + std::printf(" FreeType %d.%d.%d\n", major, minor, patch); + Check(major == 2, "FreeType reports the 2.x API"); + + FT_Done_FreeType(library); + } +} + + +void Test_ImGui(void) +{ + Check(IMGUI_CHECKVERSION(), "ImGui header and library agree on structure layouts"); + + ImGuiContext * context = ImGui::CreateContext(); + Check(context != nullptr, "ImGui creates a context"); + std::printf(" Dear ImGui %s\n", ImGui::GetVersion()); + + if (context != nullptr) { + ImGui::DestroyContext(context); + } +} + +} + + +int main(void) +{ + Test_RmlUi(); + Test_FreeType(); + Test_ImGui(); + + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); + return(Failures == 0 ? 0 : 1); +} diff --git a/thirdparty/CMakeLists.txt b/thirdparty/CMakeLists.txt index aab8357fd..985c2bb3c 100644 --- a/thirdparty/CMakeLists.txt +++ b/thirdparty/CMakeLists.txt @@ -79,3 +79,82 @@ if(MSVC) # Match the engine's floating-point model so the resampler behaves the same in both. target_compile_options(miniaudio PRIVATE /arch:SSE2 /fp:precise) endif() + +# +# --------------------------------------------------------- +# FreeType (font rasterization for RmlUi) +# --------------------------------------------------------- +# +if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/freetype/CMakeLists.txt") + message(FATAL_ERROR + "thirdparty/freetype is empty. Fetch the submodules with:\n" + " git submodule update --init --recursive") +endif() + +# RmlUi defaults to shared libraries; the engine links every dependency statically. +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +# Only TrueType rasterization is needed. The gzip module keeps FreeType's bundled zlib copy +# rather than a system one; every other optional decoder and shaper stays out. +set(FT_DISABLE_ZLIB ON CACHE BOOL "" FORCE) +set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE) +set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE) +set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE) +set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE) +set(SKIP_INSTALL_ALL ON CACHE BOOL "" FORCE) + +add_subdirectory(freetype) + +# RmlUi looks for the target that FindFreetype would create. FreeType's own tree exports +# that name for installed consumers but never defines it inside a build tree. +add_library(Freetype::Freetype ALIAS freetype) + +# +# --------------------------------------------------------- +# RmlUi (user interface documents) +# --------------------------------------------------------- +# +if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/RmlUi/CMakeLists.txt") + message(FATAL_ERROR + "thirdparty/RmlUi is empty. Fetch the submodules with:\n" + " git submodule update --init --recursive") +endif() + +set(RMLUI_FONT_ENGINE "freetype" CACHE STRING "" FORCE) +set(RMLUI_SAMPLES OFF CACHE BOOL "" FORCE) +set(RMLUI_LUA_BINDINGS OFF CACHE BOOL "" FORCE) +set(RMLUI_LOTTIE_PLUGIN OFF CACHE BOOL "" FORCE) +set(RMLUI_SVG_PLUGIN OFF CACHE BOOL "" FORCE) +set(RMLUI_TRACY_PROFILING OFF CACHE BOOL "" FORCE) + +# The vendored FreeType above satisfies RmlUi's dependency check. A copy installed on the +# build machine must never be picked up in its place. +set(CMAKE_DISABLE_FIND_PACKAGE_Freetype ON) + +add_subdirectory(RmlUi) + +# +# --------------------------------------------------------- +# Dear ImGui (developer overlays) +# --------------------------------------------------------- +# +if(NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/imgui/imgui.h") + message(FATAL_ERROR + "thirdparty/imgui is empty. Fetch the submodules with:\n" + " git submodule update --init --recursive") +endif() + +# ImGui ships no build files. Only the core is compiled; the platform and renderer +# adapters are the engine's own. +add_library(imgui STATIC + "${CMAKE_CURRENT_SOURCE_DIR}/imgui/imgui.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/imgui/imgui_demo.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/imgui/imgui_draw.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/imgui/imgui_tables.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/imgui/imgui_widgets.cpp" +) +target_include_directories(imgui PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/imgui") + +# Configuration macros change the layout of ImGui structures, so consumers must see the +# same set as the implementation. +target_compile_definitions(imgui PUBLIC IMGUI_DISABLE_OBSOLETE_FUNCTIONS) diff --git a/thirdparty/RmlUi b/thirdparty/RmlUi new file mode 160000 index 000000000..ba95ffe8b --- /dev/null +++ b/thirdparty/RmlUi @@ -0,0 +1 @@ +Subproject commit ba95ffe8bfb6370efb2cdcca927eaad4710c5413 diff --git a/thirdparty/freetype b/thirdparty/freetype new file mode 160000 index 000000000..0a0221a13 --- /dev/null +++ b/thirdparty/freetype @@ -0,0 +1 @@ +Subproject commit 0a0221a1347e2f1e07c395263540026e9a0aa7c7 diff --git a/thirdparty/imgui b/thirdparty/imgui new file mode 160000 index 000000000..f1cc2ae15 --- /dev/null +++ b/thirdparty/imgui @@ -0,0 +1 @@ +Subproject commit f1cc2ae15e53a861a874c3034aae6798fde194ab diff --git a/thirdparty/licenses/freetype-zlib.txt b/thirdparty/licenses/freetype-zlib.txt new file mode 100644 index 000000000..ac795d42b --- /dev/null +++ b/thirdparty/licenses/freetype-zlib.txt @@ -0,0 +1,25 @@ +zlib (thirdparty/freetype/src/gzip, the copy bundled with FreeType's gzip module) + +zlib.h -- interface of the 'zlib' general purpose compression library +version 1.3.1, January 22nd, 2024 + +Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + +Jean-loup Gailly Mark Adler +jloup@gzip.org madler@alumni.caltech.edu diff --git a/thirdparty/licenses/stb-imgui.txt b/thirdparty/licenses/stb-imgui.txt new file mode 100644 index 000000000..4c3be10bd --- /dev/null +++ b/thirdparty/licenses/stb-imgui.txt @@ -0,0 +1,40 @@ +stb (thirdparty/imgui/imstb_rectpack.h, imstb_textedit.h, and imstb_truetype.h) + +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2017 Sean Barrett +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. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +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 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. +------------------------------------------------------------------------------ From ab8849a38f10aa39788579895c26136354b1f317 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 8 Sep 2026 23:12:39 +0300 Subject: [PATCH 02/52] Add the RmlUi shell and its Debug test document --- .github/workflows/engine-build.yml | 2 + .github/workflows/engine-release.yml | 2 +- THIRD_PARTY_NOTICES.md | 2 + code/CMakeLists.txt | 32 +- code/bgfxbackend.cpp | 74 +- code/bgfxbackend.h | 20 +- code/bgfxviews.hh | 27 + code/mainloop.cpp | 2 + code/msgloop.cpp | 6 + code/ownrdraw.cpp | 3 + code/startup.cpp | 14 + code/ui/uicoord.h | 32 + code/ui/uifile.cpp | 111 +++ code/ui/uifile.h | 26 + code/ui/uirender.cpp | 351 +++++++++ code/ui/uirender.h | 59 ++ code/ui/uishell.cpp | 707 ++++++++++++++++++ code/ui/uishell.h | 39 + code/ui/uisystem.cpp | 65 ++ code/ui/uisystem.h | 27 + code/ui/uitexture.cpp | 82 ++ code/ui/uitexture.h | 18 + code/video.cpp | 54 +- code/video.h | 1 + code/winstub.cpp | 9 + docs/BUILDING.md | 4 +- docs/UI_DESIGN.md | 145 ++-- manual/changes/ui-shell.md | 14 + .../commands/fixed-debug-ui-test-document.md | 11 + manual/content/systems/developer-mode.md | 4 + manual/content/using/build-and-run.md | 2 +- manual/data/command-adapters.yaml | 16 + manual/data/commands.yaml | 13 + tests/uishell/CMakeLists.txt | 13 +- tests/uishell/uishell.cpp | 303 +++++++- thirdparty/licenses/stb-image.txt | 40 + ui/OFL.txt | 92 +++ ui/OpenSans.ttf | Bin 0 -> 532636 bytes ui/test.rcss | 61 ++ ui/test.rml | 13 + 40 files changed, 2373 insertions(+), 123 deletions(-) create mode 100644 code/bgfxviews.hh create mode 100644 code/ui/uicoord.h create mode 100644 code/ui/uifile.cpp create mode 100644 code/ui/uifile.h create mode 100644 code/ui/uirender.cpp create mode 100644 code/ui/uirender.h create mode 100644 code/ui/uishell.cpp create mode 100644 code/ui/uishell.h create mode 100644 code/ui/uisystem.cpp create mode 100644 code/ui/uisystem.h create mode 100644 code/ui/uitexture.cpp create mode 100644 code/ui/uitexture.h create mode 100644 manual/changes/ui-shell.md create mode 100644 manual/content/commands/fixed-debug-ui-test-document.md create mode 100644 thirdparty/licenses/stb-image.txt create mode 100644 ui/OFL.txt create mode 100644 ui/OpenSans.ttf create mode 100644 ui/test.rcss create mode 100644 ui/test.rml diff --git a/.github/workflows/engine-build.yml b/.github/workflows/engine-build.yml index a8f062e59..5f55c59a2 100644 --- a/.github/workflows/engine-build.yml +++ b/.github/workflows/engine-build.yml @@ -95,6 +95,8 @@ jobs: cp thirdparty/licenses/freetype-zlib.txt artifact/OpenTS_THIRD_PARTY_LICENSES/zlib.txt cp thirdparty/imgui/LICENSE.txt artifact/OpenTS_THIRD_PARTY_LICENSES/dear-imgui.txt cp thirdparty/licenses/stb-imgui.txt artifact/OpenTS_THIRD_PARTY_LICENSES/stb-imgui.txt + cp thirdparty/licenses/stb-image.txt artifact/OpenTS_THIRD_PARTY_LICENSES/stb-image.txt + cp ui/OFL.txt artifact/OpenTS_THIRD_PARTY_LICENSES/open-sans.txt - name: Upload runtime files uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/engine-release.yml b/.github/workflows/engine-release.yml index db7f5173c..0e9958d52 100644 --- a/.github/workflows/engine-release.yml +++ b/.github/workflows/engine-release.yml @@ -39,7 +39,7 @@ jobs: working-directory: artifact run: > zip -r "../OpenTS-${TAG}.zip" - Game.exe Game.pdb Language.dll + Game.exe Game.pdb Language.dll ui LICENSE.md THIRD_PARTY_NOTICES.md OpenTS_THIRD_PARTY_LICENSES - name: Attach the zip to the release diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d68cef927..1e57c8cef 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -22,6 +22,8 @@ remains under its own license and copyright notices. | [zlib](https://zlib.net) | Compressed font support, bundled with FreeType | zlib | | [Dear ImGui](https://github.com/ocornut/imgui) | Developer overlays | MIT | | [stb](https://github.com/nothings/stb) | Rectangle packing, text editing, and TrueType headers bundled with Dear ImGui | MIT or Unlicense | +| [stb_image](https://github.com/nothings/stb) | PNG and TGA decoding for the UI, bundled with bimg | MIT or Unlicense | +| [Open Sans](https://github.com/googlefonts/opensans) | The UI font | OFL-1.1 | The source checkout keeps the license texts under `thirdparty/`. Binary packages reproduce the license texts for the components used by OpenTS under diff --git a/code/CMakeLists.txt b/code/CMakeLists.txt index fa2d5ea20..94bc58009 100644 --- a/code/CMakeLists.txt +++ b/code/CMakeLists.txt @@ -148,26 +148,35 @@ target_include_directories(OpenTS PRIVATE # The generated build stamp has to exist before anything compiles. add_dependencies(OpenTS OpenTSBuildStamp) -# Only the renderer's own translation unit sees bgfx, so its headers and the shaders it -# embeds are put on that file rather than on the whole target. +# Only the renderer's own translation unit and the UI overlay renderer see bgfx, so its +# headers and the shaders they embed are put on those files rather than on the whole target. set(BGFX_ROOT "${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bgfx") -set_source_files_properties("${CMAKE_CURRENT_SOURCE_DIR}/bgfxbackend.cpp" PROPERTIES +set(OPENTS_BGFX_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/bgfxbackend.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/uirender.cpp" +) +set_source_files_properties(${OPENTS_BGFX_SOURCES} PROPERTIES INCLUDE_DIRECTORIES - "${BGFX_ROOT}/include;${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bx/include;${BGFX_ROOT}/examples/common/imgui" + "${BGFX_ROOT}/include;${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bx/include;${BGFX_ROOT}/examples/common/imgui;${BGFX_ROOT}/examples/common/debugdraw" COMPILE_DEFINITIONS "BX_CONFIG_DEBUG=$,1,$>" COMPILE_OPTIONS "/Zc:preprocessor" ) # bx rewrites __stdcall while its headers are being parsed by clang-cl. Force the -# compatibility header into the renderer translation unit as well as bx/bgfx so the +# compatibility header into the renderer translation units as well as bx/bgfx so the # MSVC standard-library headers that follow still see the Win32 calling convention. if(OPENTS_EXPERIMENTAL_CLANG_CL AND CMAKE_SIZEOF_VOID_P EQUAL 4) - set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/bgfxbackend.cpp" APPEND PROPERTY + set_property(SOURCE ${OPENTS_BGFX_SOURCES} APPEND PROPERTY COMPILE_OPTIONS "/FI${CMAKE_SOURCE_DIR}/thirdparty/bx-clang-compat.h" ) endif() +# The image decoder is a header bimg carries; only the texture loader compiles it. +set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/ui/uitexture.cpp" APPEND PROPERTY + INCLUDE_DIRECTORIES "${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bimg/3rdparty/stb" +) + message(STATUS "${PROJECT_NAME}: Adding compilier definitions...") target_compile_definitions(OpenTS PRIVATE WIN32 @@ -204,6 +213,7 @@ target_link_libraries(OpenTS PRIVATE bx bimg miniaudio + RmlUi::Core comctl32 dbghelp iphlpapi @@ -332,6 +342,16 @@ add_custom_command(TARGET OpenTS POST_BUILD "${TS_RUN_DIR}" ) +# The UI documents, styles and font ship beside the executable like Language.dll. Their own +# target copies them, so an edited document reaches the run directory without a relink. +add_custom_target(OpenTSUIFiles ALL + COMMAND ${CMAKE_COMMAND} -E make_directory "${TS_RUN_DIR}/ui" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/ui" "${TS_RUN_DIR}/ui" + COMMENT "Copying the UI files to the run directory" + VERBATIM +) +add_dependencies(OpenTS OpenTSUIFiles) + # # --------------------------------------------------------- # Visual Studio startup project diff --git a/code/bgfxbackend.cpp b/code/bgfxbackend.cpp index af72c26a9..3285bfcca 100644 --- a/code/bgfxbackend.cpp +++ b/code/bgfxbackend.cpp @@ -12,6 +12,7 @@ #include "bgfxbackend.h" +#include "bgfxviews.hh" #include "dbgprint.h" #include "except.h" @@ -36,14 +37,6 @@ static const bgfx::EmbeddedShader _EmbeddedShaders[] = { }; -// The view that magnifies the frame when the pixel art filter needs an intermediate -// target, and the one that draws onto the window. Views render in ascending order, so -// the magnify pass must carry the lower id for the present pass to sample its output -// from this frame rather than the last one. -static const bgfx::ViewId VIEW_PRESCALE = 0; -static const bgfx::ViewId VIEW_PRESENT = 1; - - static bool _Initialized = false; static bgfx::TextureHandle _FrameTexture = BGFX_INVALID_HANDLE; @@ -54,6 +47,10 @@ static bgfx::VertexLayout _VertexLayout; static int _FrameWidth = 0; static int _FrameHeight = 0; + +// A recreated frame texture holds nothing until the first upload reaches it. +static bool _FrameUploaded = false; + static int _PrescaleWidth = 0; static int _PrescaleHeight = 0; static int _DrawableWidth = 0; @@ -198,7 +195,7 @@ static void Submit_Quad(bgfx::ViewId view, bgfx::TextureHandle texture, float x, /// Builds an orthographic projection over a target measured in pixels, with the origin in /// its top left corner. /// -static void Build_Ortho_Projection(float * result, int width, int height) +void Backend_Build_Ortho_Projection(float * result, int width, int height) { const float depthnear = 0.0f; const float depthfar = 1000.0f; @@ -223,7 +220,7 @@ static void Set_View_Transform(bgfx::ViewId view, int width, int height) { float projection[16]; bgfx::setViewRect(view, 0, 0, (uint16_t)width, (uint16_t)height); - Build_Ortho_Projection(projection, width, height); + Backend_Build_Ortho_Projection(projection, width, height); bgfx::setViewTransform(view, NULL, projection); } @@ -391,6 +388,7 @@ void Backend_Shutdown(void) _FrameWidth = 0; _FrameHeight = 0; + _FrameUploaded = false; _Initialized = false; } @@ -421,6 +419,7 @@ bool Backend_Set_Frame_Size(int width, int height) _FrameIs565 = (caps->formats[bgfx::TextureFormat::B5G6R5] & BGFX_CAPS_FORMAT_TEXTURE_2D) != 0; _FrameTexture = bgfx::createTexture2D((uint16_t)width, (uint16_t)height, false, 1, _FrameIs565 ? bgfx::TextureFormat::B5G6R5 : bgfx::TextureFormat::BGRA8); + _FrameUploaded = false; if (!bgfx::isValid(_FrameTexture)) { return(false); } @@ -461,37 +460,49 @@ void Backend_On_Resize(int drawablewidth, int drawableheight) /// -/// Uploads the frame and puts it on the screen. +/// Submits the frame to the window, uploading new pixels first when given any. +/// The frame reaches the screen when Backend_End_Frame runs; whatever is submitted in +/// between draws over it. /// -/// The frame's top left pixel, in 16 bit 565. +/// The frame's top left pixel, in 16 bit 565, or NULL to present the +/// frame uploaded last. /// The bytes between one row of that frame and the next. /// Where the left edge of the frame lands in the window. /// Where the top edge of the frame lands in the window. /// How wide the frame is drawn. /// How tall the frame is drawn. /// How the frame is filtered when it is drawn larger than it is. -void Backend_Present(void const * pixels, int pitch, int destx, int desty, int destwidth, int destheight, BackendScaleMode mode) +/// bool; Was a frame submitted? When not, the frame must not be ended. +bool Backend_Present(void const * pixels, int pitch, int destx, int desty, int destwidth, int destheight, BackendScaleMode mode) { - if (!_Initialized || pixels == NULL || !bgfx::isValid(_FrameTexture)) { - return; + if (!_Initialized || !bgfx::isValid(_FrameTexture)) { + return(false); + } + + if (pixels == NULL && !_FrameUploaded) { + return(false); } // A minimized window has no client area to present into. if (_DrawableWidth <= 0 || _DrawableHeight <= 0) { - return; + return(false); } - if (_FrameIs565) { - bgfx::updateTexture2D(_FrameTexture, 0, 0, 0, 0, (uint16_t)_FrameWidth, (uint16_t)_FrameHeight, bgfx::copy(pixels, (uint32_t)(_FrameHeight * pitch)), (uint16_t)pitch); - } else if (_ConvertBuffer != NULL) { - for (int y = 0; y < _FrameHeight; y++) { - unsigned short const * source = (unsigned short const *)((char const *)pixels + y * pitch); - unsigned int * dest = _ConvertBuffer + y * _FrameWidth; - for (int x = 0; x < _FrameWidth; x++) { - dest[x] = _ConvertTable[source[x]]; + if (pixels != NULL) { + if (_FrameIs565) { + bgfx::updateTexture2D(_FrameTexture, 0, 0, 0, 0, (uint16_t)_FrameWidth, (uint16_t)_FrameHeight, bgfx::copy(pixels, (uint32_t)(_FrameHeight * pitch)), (uint16_t)pitch); + _FrameUploaded = true; + } else if (_ConvertBuffer != NULL) { + for (int y = 0; y < _FrameHeight; y++) { + unsigned short const * source = (unsigned short const *)((char const *)pixels + y * pitch); + unsigned int * dest = _ConvertBuffer + y * _FrameWidth; + for (int x = 0; x < _FrameWidth; x++) { + dest[x] = _ConvertTable[source[x]]; + } } + bgfx::updateTexture2D(_FrameTexture, 0, 0, 0, 0, (uint16_t)_FrameWidth, (uint16_t)_FrameHeight, bgfx::copy(_ConvertBuffer, (uint32_t)(_FrameWidth * _FrameHeight * 4)), (uint16_t)(_FrameWidth * 4)); + _FrameUploaded = true; } - bgfx::updateTexture2D(_FrameTexture, 0, 0, 0, 0, (uint16_t)_FrameWidth, (uint16_t)_FrameHeight, bgfx::copy(_ConvertBuffer, (uint32_t)(_FrameWidth * _FrameHeight * 4)), (uint16_t)(_FrameWidth * 4)); } bgfx::TextureHandle source = _FrameTexture; @@ -536,6 +547,19 @@ void Backend_Present(void const * pixels, int pitch, int destx, int desty, int d bool flipv = from_prescale && bgfx::getCaps()->originBottomLeft; Submit_Quad(VIEW_PRESENT, source, (float)destx, (float)desty, (float)destwidth, (float)destheight, samplerflags, flipv); + return(true); +} + + +/// +/// Ends the frame Backend_Present began, putting everything submitted since on the screen. +/// +void Backend_End_Frame(void) +{ + if (!_Initialized) { + return; + } + bgfx::frame(); } diff --git a/code/bgfxbackend.h b/code/bgfxbackend.h index 8f3cb4184..4e89742df 100644 --- a/code/bgfxbackend.h +++ b/code/bgfxbackend.h @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -// The renderer's private interface. Only bgfxbackend.cpp includes bgfx, so no bgfx type -// appears here and no other translation unit needs the library's headers or its build -// settings. video.cpp is the only caller. +// The renderer's private interface. Only bgfxbackend.cpp and the UI overlay renderer +// include bgfx, so no bgfx type appears here and no other translation unit needs the +// library's headers or its build settings. video.cpp is the only caller. #pragma once @@ -39,8 +39,16 @@ void Backend_Shutdown(void); bool Backend_Set_Frame_Size(int width, int height); void Backend_On_Resize(int drawablewidth, int drawableheight); -// Uploads the frame and presents it. The pixels are 16 bit 565 and stay owned by the -// caller; they are consumed before this returns. -void Backend_Present(void const * pixels, int pitch, int destx, int desty, int destwidth, int destheight, BackendScaleMode mode); +// Submits the frame, uploading new pixels first when given any. The pixels are 16 bit 565 +// and stay owned by the caller; they are consumed before this returns. NULL presents the +// frame uploaded last. Nothing reaches the screen until Backend_End_Frame, and what is +// submitted between the two calls draws over the frame. A false return means no frame was +// submitted and the frame must not be ended. +bool Backend_Present(void const * pixels, int pitch, int destx, int desty, int destwidth, int destheight, BackendScaleMode mode); +void Backend_End_Frame(void); + +// Fills a 4x4 matrix with an orthographic projection over a target measured in pixels, +// with the origin in its top left corner, shaped for the renderer bgfx settled on. +void Backend_Build_Ortho_Projection(float * result, int width, int height); char const * Backend_Renderer_Name(void); diff --git a/code/bgfxviews.hh b/code/bgfxviews.hh new file mode 100644 index 000000000..7d423c49f --- /dev/null +++ b/code/bgfxviews.hh @@ -0,0 +1,27 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The bgfx views the presenter and the UI overlays draw into. Views render in ascending +// order, so the magnify pass must carry the lower id for the present pass to sample its +// output from this frame, and the overlays must follow the frame they sit on. + +#pragma once + + +// Magnifies the frame when the pixel art filter needs an intermediate target. +const unsigned short VIEW_PRESCALE = 0; + +// Draws the frame onto the window. +const unsigned short VIEW_PRESENT = 1; + +// RmlUi documents. +const unsigned short VIEW_UI = 2; + +// Developer overlays. +const unsigned short VIEW_DEV = 3; diff --git a/code/mainloop.cpp b/code/mainloop.cpp index c3e70e9f3..7425670dd 100644 --- a/code/mainloop.cpp +++ b/code/mainloop.cpp @@ -60,6 +60,7 @@ #include "theme.h" #include "timer.h" #include "tracker.h" +#include "ui/uishell.h" #include "bench.hh" #include "special.hh" @@ -300,6 +301,7 @@ bool Main_Loop(void) */ if (!Session.Play) { if (SpecialDialog == SDLG_NONE && GameInFocus) { + UI_Tick(); Map.Input(input, x, y); if (input) { Keyboard_Process(input); diff --git a/code/msgloop.cpp b/code/msgloop.cpp index dcbe981cb..8f5e721ed 100644 --- a/code/msgloop.cpp +++ b/code/msgloop.cpp @@ -40,6 +40,7 @@ #include "_tooltip.h" #include "cctooltip.h" +#include "ui/uishell.h" #include "vector.h" #include "video.h" @@ -112,6 +113,11 @@ void Windows_Message_Handler(void) ToolTips->Message_Handler(&msg); } + // Ahead of the dialogs, so that a developer key works whichever window has focus. + if (UI_Intercept_Pumped_Message(msg)) { + continue; + } + /* ** Pass the windows message through any modeless dialogs that may ** be active. If one of the dialogs processes the message, then diff --git a/code/ownrdraw.cpp b/code/ownrdraw.cpp index 7d7592e10..ac4f1eefa 100644 --- a/code/ownrdraw.cpp +++ b/code/ownrdraw.cpp @@ -41,6 +41,7 @@ #include "session.h" #include "srfcache.h" #include "theme.h" +#include "ui/uishell.h" #include "utf8.h" #include "voc.h" #include "vox.h" @@ -6904,6 +6905,8 @@ bool OwnerDraw::Dialog_Message_Handler(void) Call_Back(); } + UI_Tick(); + return(false); } diff --git a/code/startup.cpp b/code/startup.cpp index d028eaa66..8cd8750db 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -147,6 +147,7 @@ #include "vanimtype.h" #include "vector.h" #include "video.h" +#include "ui/uishell.h" #include "walk.h" #include "warhead.h" #include "wave.h" @@ -225,6 +226,7 @@ void Reset_Surfaces(void) VisibleSurface = NULL; } + UI_Shutdown(); Video_Shutdown(); surfaces_reset = true; @@ -555,6 +557,15 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho DeploymentConfig.Read_File(Data_Directory().c_str()); Init_Search_Folders(DeploymentConfig.SearchPaths.c_str()); + // The UI files ship beside the executable, whichever data directory the deployment + // names, so they are found through the executable's own directory. + std::string uidirectory = path; + if (!uidirectory.empty() && uidirectory.back() != '\\' && uidirectory.back() != '/') { + uidirectory += '\\'; + } + uidirectory += "ui\\"; + CDFileClass::Add_Search_Drive(uidirectory.c_str()); + // The recording's name was settled during static initialization, before there was // anywhere for a player's files to go. Naming it again settles it where it belongs. Session.RecordFile.Set_Name("RECORD.BIN"); @@ -632,6 +643,9 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho exit(EXIT_FAILURE); } + // The game runs without the UI shell; its own log says why it stayed off. + UI_Init(); + do { Windows_Message_Handler(); } diff --git a/code/ui/uicoord.h b/code/ui/uicoord.h new file mode 100644 index 000000000..246013b1b --- /dev/null +++ b/code/ui/uicoord.h @@ -0,0 +1,32 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + + +// A pointer position in the overlay's own pixels. +struct UIPointerPosition +{ + int X; + int Y; + bool Inside; +}; + + +// Converts a client area position into the overlay's coordinates. The overlay covers the +// frame's destination rectangle, whose right and bottom edges are exclusive. A position +// outside keeps its offset, so a captured pointer can still be followed there. +inline UIPointerPosition UI_Client_To_Overlay(int destx, int desty, int destwidth, int destheight, int clientx, int clienty) +{ + UIPointerPosition position; + position.X = clientx - destx; + position.Y = clienty - desty; + position.Inside = position.X >= 0 && position.Y >= 0 && position.X < destwidth && position.Y < destheight; + return(position); +} diff --git a/code/ui/uifile.cpp b/code/ui/uifile.cpp new file mode 100644 index 000000000..902b7ff00 --- /dev/null +++ b/code/ui/uifile.cpp @@ -0,0 +1,111 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uifile.h" + +#include "ccfile.h" + +#include + + +static Rml::String Base_Name(Rml::String const & path) +{ + size_t start = path.find_last_of("/\\"); + return((start == Rml::String::npos) ? path : path.substr(start + 1)); +} + + +Rml::FileHandle UIFileInterfaceClass::Open(Rml::String const & path) +{ + Rml::String name = Base_Name(path); + if (name.empty()) { + return(0); + } + + CCFileClass * file = new CCFileClass(name.c_str()); + if (!file->Is_Available() || !file->Open(FileClass::READ)) { + delete file; + return(0); + } + + return((Rml::FileHandle)file); +} + + +void UIFileInterfaceClass::Close(Rml::FileHandle file) +{ + CCFileClass * ccfile = (CCFileClass *)file; + if (ccfile != NULL) { + ccfile->Close(); + delete ccfile; + } +} + + +size_t UIFileInterfaceClass::Read(void * buffer, size_t size, Rml::FileHandle file) +{ + CCFileClass * ccfile = (CCFileClass *)file; + if (ccfile == NULL || size == 0) { + return(0); + } + + int count = ccfile->Read(buffer, size > INT_MAX ? INT_MAX : (int)size); + return(count > 0 ? (size_t)count : 0); +} + + +// The engine reports the position it reached rather than success, so a clamped seek is +// recognized by comparing the two. +bool UIFileInterfaceClass::Seek(Rml::FileHandle file, long offset, int origin) +{ + CCFileClass * ccfile = (CCFileClass *)file; + if (ccfile == NULL) { + return(false); + } + + int target = (int)offset; + switch (origin) { + case SEEK_CUR: + target += ccfile->Seek(0, SEEK_CUR); + break; + + case SEEK_END: + target += ccfile->Size(); + break; + + default: + break; + } + + return(ccfile->Seek((int)offset, origin) == target); +} + + +size_t UIFileInterfaceClass::Tell(Rml::FileHandle file) +{ + CCFileClass * ccfile = (CCFileClass *)file; + if (ccfile == NULL) { + return(0); + } + + int position = ccfile->Seek(0, SEEK_CUR); + return(position > 0 ? (size_t)position : 0); +} + + +size_t UIFileInterfaceClass::Length(Rml::FileHandle file) +{ + CCFileClass * ccfile = (CCFileClass *)file; + if (ccfile == NULL) { + return(0); + } + + int size = ccfile->Size(); + return(size > 0 ? (size_t)size : 0); +} diff --git a/code/ui/uifile.h b/code/ui/uifile.h new file mode 100644 index 000000000..f56b40f02 --- /dev/null +++ b/code/ui/uifile.h @@ -0,0 +1,26 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include + + +// RmlUi's files come through the engine's search chain: a bare name is looked for in the +// user path, the run directory, the search drives and then the mix files. +class UIFileInterfaceClass : public Rml::FileInterface +{ + public: + virtual Rml::FileHandle Open(Rml::String const & path) override; + virtual void Close(Rml::FileHandle file) override; + virtual size_t Read(void * buffer, size_t size, Rml::FileHandle file) override; + virtual bool Seek(Rml::FileHandle file, long offset, int origin) override; + virtual size_t Tell(Rml::FileHandle file) override; + virtual size_t Length(Rml::FileHandle file) override; +}; diff --git a/code/ui/uirender.cpp b/code/ui/uirender.cpp new file mode 100644 index 000000000..a3e97135c --- /dev/null +++ b/code/ui/uirender.cpp @@ -0,0 +1,351 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The bgfx side of the UI overlay. With bgfxbackend.cpp it is one of the two translation +// units that include bgfx. + +#include "ui/uirender.h" + +#include "bgfxbackend.h" +#include "bgfxviews.hh" +#include "dbgprint.h" +#include "ui/uitexture.h" + +#include +#include + +#include +#include + +#include +#include +#include + + +// The imgui shader the frame quad uses ignores the model matrix, which is where each +// document fragment's translation has to travel; this pair honors it. +static const bgfx::EmbeddedShader _EmbeddedShaders[] = { + BGFX_EMBEDDED_SHADER(vs_debugdraw_fill_texture), + BGFX_EMBEDDED_SHADER(fs_debugdraw_fill_texture), + BGFX_EMBEDDED_SHADER_END() +}; + +static bgfx::VertexLayout _VertexLayout; + + +// A compiled document fragment, submitted many times with different translations. +struct UIGeometry +{ + bgfx::VertexBufferHandle Vertices; + bgfx::IndexBufferHandle Indices; +}; + + +// RmlUi reads a zero handle as no texture, and bgfx hands out index zero, so texture +// handles cross the boundary biased by one. +static bgfx::TextureHandle Texture_Handle(Rml::TextureHandle handle) +{ + bgfx::TextureHandle texture = { (uint16_t)(handle - 1) }; + return(texture); +} + + +UIRenderInterfaceClass::UIRenderInterfaceClass(void) : + IsReady(false), + Program(bgfx::kInvalidHandle), + Sampler(bgfx::kInvalidHandle), + WhiteTexture(bgfx::kInvalidHandle), + ViewX(0), + ViewY(0), + ViewWidth(0), + ViewHeight(0), + ScissorEnabled(false), + Scissor(Rml::Rectanglei::MakeInvalid()) +{ +} + + +bool UIRenderInterfaceClass::Init(void) +{ + if (IsReady) { + return(true); + } + + _VertexLayout.begin() + .add(bgfx::Attrib::Position, 2, bgfx::AttribType::Float) + .add(bgfx::Attrib::Color0, 4, bgfx::AttribType::Uint8, true) + .add(bgfx::Attrib::TexCoord0, 2, bgfx::AttribType::Float) + .end(); + + bgfx::RendererType::Enum type = bgfx::getRendererType(); + bgfx::ShaderHandle vertexshader = bgfx::createEmbeddedShader(_EmbeddedShaders, type, "vs_debugdraw_fill_texture"); + bgfx::ShaderHandle fragmentshader = bgfx::createEmbeddedShader(_EmbeddedShaders, type, "fs_debugdraw_fill_texture"); + + if (!bgfx::isValid(vertexshader) || !bgfx::isValid(fragmentshader)) { + if (bgfx::isValid(vertexshader)) { + bgfx::destroy(vertexshader); + } + if (bgfx::isValid(fragmentshader)) { + bgfx::destroy(fragmentshader); + } + DebugString("UI: the overlay shaders are unavailable for %s\n", Backend_Renderer_Name()); + return(false); + } + + bgfx::ProgramHandle program = bgfx::createProgram(vertexshader, fragmentshader, true); + bgfx::UniformHandle sampler = bgfx::createUniform("s_texColor", bgfx::UniformType::Sampler); + + const unsigned int white = 0xFFFFFFFF; + bgfx::TextureHandle whitetexture = bgfx::createTexture2D(1, 1, false, 1, bgfx::TextureFormat::RGBA8, 0, bgfx::copy(&white, sizeof(white))); + + if (!bgfx::isValid(program) || !bgfx::isValid(sampler) || !bgfx::isValid(whitetexture)) { + if (bgfx::isValid(program)) { + bgfx::destroy(program); + } + if (bgfx::isValid(sampler)) { + bgfx::destroy(sampler); + } + if (bgfx::isValid(whitetexture)) { + bgfx::destroy(whitetexture); + } + DebugString("UI: the overlay renderer could not be created\n"); + return(false); + } + + Program = program.idx; + Sampler = sampler.idx; + WhiteTexture = whitetexture.idx; + IsReady = true; + return(true); +} + + +void UIRenderInterfaceClass::Shutdown(void) +{ + if (!IsReady) { + return; + } + + bgfx::TextureHandle whitetexture = { WhiteTexture }; + bgfx::UniformHandle sampler = { Sampler }; + bgfx::ProgramHandle program = { Program }; + + bgfx::destroy(whitetexture); + bgfx::destroy(sampler); + bgfx::destroy(program); + + WhiteTexture = bgfx::kInvalidHandle; + Sampler = bgfx::kInvalidHandle; + Program = bgfx::kInvalidHandle; + IsReady = false; +} + + +// View state persists across frames and resets, and the prescale pass binds a framebuffer +// to a lower view, so everything the overlay relies on is set again each frame. +void UIRenderInterfaceClass::Begin_Frame(int x, int y, int width, int height) +{ + ViewX = x; + ViewY = y; + ViewWidth = width; + ViewHeight = height; + + float projection[16]; + Backend_Build_Ortho_Projection(projection, width, height); + + bgfx::setViewFrameBuffer(VIEW_UI, BGFX_INVALID_HANDLE); + bgfx::setViewMode(VIEW_UI, bgfx::ViewMode::Sequential); + bgfx::setViewRect(VIEW_UI, (uint16_t)x, (uint16_t)y, (uint16_t)width, (uint16_t)height); + bgfx::setViewTransform(VIEW_UI, NULL, projection); +} + + +void UIRenderInterfaceClass::Log_Resource_Counts(char const * when) const +{ + bgfx::Stats const * stats = bgfx::getStats(); + if (stats == NULL) { + return; + } + + DebugString("UI: %s; renderer holds %u textures, %u vertex buffers, %u index buffers\n", + when, (unsigned)stats->numTextures, (unsigned)stats->numVertexBuffers, (unsigned)stats->numIndexBuffers); +} + + +Rml::CompiledGeometryHandle UIRenderInterfaceClass::CompileGeometry(Rml::Span vertices, Rml::Span indices) +{ + if (!IsReady || vertices.empty() || indices.empty()) { + return(0); + } + + bgfx::VertexBufferHandle vertexbuffer = bgfx::createVertexBuffer(bgfx::copy(vertices.data(), (uint32_t)(vertices.size() * sizeof(Rml::Vertex))), _VertexLayout); + + bgfx::IndexBufferHandle indexbuffer; + if ((bgfx::getCaps()->supported & BGFX_CAPS_INDEX32) != 0) { + indexbuffer = bgfx::createIndexBuffer(bgfx::copy(indices.data(), (uint32_t)(indices.size() * sizeof(int))), BGFX_BUFFER_INDEX32); + } else { + assert(vertices.size() <= 65536); + std::vector narrow(indices.size()); + for (size_t index = 0; index < indices.size(); index++) { + narrow[index] = (uint16_t)indices[index]; + } + indexbuffer = bgfx::createIndexBuffer(bgfx::copy(narrow.data(), (uint32_t)(narrow.size() * sizeof(uint16_t)))); + } + + if (!bgfx::isValid(vertexbuffer) || !bgfx::isValid(indexbuffer)) { + if (bgfx::isValid(vertexbuffer)) { + bgfx::destroy(vertexbuffer); + } + if (bgfx::isValid(indexbuffer)) { + bgfx::destroy(indexbuffer); + } + return(0); + } + + UIGeometry * geometry = new UIGeometry; + geometry->Vertices = vertexbuffer; + geometry->Indices = indexbuffer; + return((Rml::CompiledGeometryHandle)geometry); +} + + +void UIRenderInterfaceClass::RenderGeometry(Rml::CompiledGeometryHandle handle, Rml::Vector2f translation, Rml::TextureHandle texture) +{ + if (!IsReady || handle == 0) { + return; + } + + if (ScissorEnabled && !Apply_Scissor()) { + return; + } + + UIGeometry const * geometry = (UIGeometry const *)handle; + + float transform[16]; + memset(transform, 0, sizeof(transform)); + transform[0] = 1.0f; + transform[5] = 1.0f; + transform[10] = 1.0f; + transform[12] = translation.x; + transform[13] = translation.y; + transform[15] = 1.0f; + bgfx::setTransform(transform); + + bgfx::TextureHandle sampled = { WhiteTexture }; + if (texture != 0) { + sampled = Texture_Handle(texture); + } + + bgfx::UniformHandle sampler = { Sampler }; + bgfx::ProgramHandle program = { Program }; + + bgfx::setVertexBuffer(0, geometry->Vertices); + bgfx::setIndexBuffer(geometry->Indices); + bgfx::setTexture(0, sampler, sampled, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); + bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A | BGFX_STATE_BLEND_FUNC(BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA)); + bgfx::submit(VIEW_UI, program); +} + + +void UIRenderInterfaceClass::ReleaseGeometry(Rml::CompiledGeometryHandle handle) +{ + if (handle == 0) { + return; + } + + UIGeometry * geometry = (UIGeometry *)handle; + bgfx::destroy(geometry->Vertices); + bgfx::destroy(geometry->Indices); + delete geometry; +} + + +Rml::TextureHandle UIRenderInterfaceClass::LoadTexture(Rml::Vector2i & dimensions, Rml::String const & source) +{ + std::vector rgba; + int width = 0; + int height = 0; + + if (!UI_Load_Image(source.c_str(), rgba, width, height)) { + return(0); + } + + dimensions.x = width; + dimensions.y = height; + return(GenerateTexture(Rml::Span(rgba.data(), rgba.size()), dimensions)); +} + + +Rml::TextureHandle UIRenderInterfaceClass::GenerateTexture(Rml::Span source, Rml::Vector2i dimensions) +{ + if (!IsReady || dimensions.x <= 0 || dimensions.y <= 0) { + return(0); + } + + uint32_t size = (uint32_t)dimensions.x * (uint32_t)dimensions.y * 4; + if (source.size() < size) { + return(0); + } + + bgfx::TextureHandle texture = bgfx::createTexture2D((uint16_t)dimensions.x, (uint16_t)dimensions.y, false, 1, bgfx::TextureFormat::RGBA8, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP, bgfx::copy(source.data(), size)); + if (!bgfx::isValid(texture)) { + return(0); + } + + return((Rml::TextureHandle)texture.idx + 1); +} + + +void UIRenderInterfaceClass::ReleaseTexture(Rml::TextureHandle texture) +{ + if (texture == 0) { + return; + } + + bgfx::destroy(Texture_Handle(texture)); +} + + +void UIRenderInterfaceClass::EnableScissorRegion(bool enable) +{ + ScissorEnabled = enable; +} + + +void UIRenderInterfaceClass::SetScissorRegion(Rml::Rectanglei region) +{ + Scissor = region; +} + + +// bgfx scissors are window pixels, while RmlUi clips in the overlay's own; a region that +// clips everything away reports false so the draw can be skipped. +bool UIRenderInterfaceClass::Apply_Scissor(void) const +{ + if (!Scissor.Valid()) { + return(false); + } + + int left = ViewX + Scissor.Left(); + int top = ViewY + Scissor.Top(); + int right = left + Scissor.Width(); + int bottom = top + Scissor.Height(); + + if (left < ViewX) left = ViewX; + if (top < ViewY) top = ViewY; + if (right > ViewX + ViewWidth) right = ViewX + ViewWidth; + if (bottom > ViewY + ViewHeight) bottom = ViewY + ViewHeight; + + if (right <= left || bottom <= top) { + return(false); + } + + bgfx::setScissor((uint16_t)left, (uint16_t)top, (uint16_t)(right - left), (uint16_t)(bottom - top)); + return(true); +} diff --git a/code/ui/uirender.h b/code/ui/uirender.h new file mode 100644 index 000000000..7f9c59752 --- /dev/null +++ b/code/ui/uirender.h @@ -0,0 +1,59 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include + + +// Draws RmlUi geometry through bgfx into the overlay view. Init needs the renderer running; +// Shutdown comes after Rml::Shutdown, which releases every texture and geometry through +// this object, and before the renderer stops. bgfx handles are kept as their indices so +// that no bgfx type appears here. +class UIRenderInterfaceClass : public Rml::RenderInterface +{ + public: + UIRenderInterfaceClass(void); + + bool Init(void); + void Shutdown(void); + + // Points the overlay view at the frame's destination rectangle, in window pixels. + void Begin_Frame(int x, int y, int width, int height); + + // Writes the renderer's live texture and buffer counts to the debug log. + void Log_Resource_Counts(char const * when) const; + + virtual Rml::CompiledGeometryHandle CompileGeometry(Rml::Span vertices, Rml::Span indices) override; + virtual void RenderGeometry(Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation, Rml::TextureHandle texture) override; + virtual void ReleaseGeometry(Rml::CompiledGeometryHandle geometry) override; + + virtual Rml::TextureHandle LoadTexture(Rml::Vector2i & dimensions, Rml::String const & source) override; + virtual Rml::TextureHandle GenerateTexture(Rml::Span source, Rml::Vector2i dimensions) override; + virtual void ReleaseTexture(Rml::TextureHandle texture) override; + + virtual void EnableScissorRegion(bool enable) override; + virtual void SetScissorRegion(Rml::Rectanglei region) override; + + private: + bool Apply_Scissor(void) const; + + bool IsReady; + unsigned short Program; + unsigned short Sampler; + unsigned short WhiteTexture; + + int ViewX; + int ViewY; + int ViewWidth; + int ViewHeight; + + bool ScissorEnabled; + Rml::Rectanglei Scissor; +}; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp new file mode 100644 index 000000000..0bb42e9e5 --- /dev/null +++ b/code/ui/uishell.cpp @@ -0,0 +1,707 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uishell.h" + +#include "dbgprint.h" +#include "globals.h" +#include "movies.h" +#include "ui/uicoord.h" +#include "ui/uifile.h" +#include "ui/uirender.h" +#include "ui/uisystem.h" +#include "video.h" + +// windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its +// element walkers. +#undef GetFirstChild +#undef GetNextSibling + +#include + + +// The interfaces outlive Rml::Shutdown, which releases every resource through them. +static UISystemInterfaceClass _System; +static UIFileInterfaceClass _File; +static UIRenderInterfaceClass _Render; + +static Rml::Context * _Context = NULL; +static bool _Ready = false; +static bool _FontLoaded = false; + +// Set while the context updates or renders. Work that arrives then waits for the next tick. +static bool _InContext = false; +static bool _InHook = false; +static bool _InTick = false; + +static bool _PendingResize = false; +static bool _PendingLeave = false; +static bool _PendingRelease = false; + +// The presses the shell consumed, as a mask over the mouse button indices, and whether it +// took the window's capture for them. Their releases belong to the shell wherever they land. +static unsigned int _OwnedButtons = 0; +static bool _TookCapture = false; +static bool _MouseInside = false; + +static wchar_t _HighSurrogate = 0; + +static Rml::Input::KeyIdentifier _KeyMap[256]; + + +struct UIKeyMapping +{ + int VirtualKey; + Rml::Input::KeyIdentifier Key; +}; + +static const UIKeyMapping _KeyMappings[] = { + { VK_BACK, Rml::Input::KI_BACK }, + { VK_TAB, Rml::Input::KI_TAB }, + { VK_CLEAR, Rml::Input::KI_CLEAR }, + { VK_RETURN, Rml::Input::KI_RETURN }, + { VK_PAUSE, Rml::Input::KI_PAUSE }, + { VK_CAPITAL, Rml::Input::KI_CAPITAL }, + { VK_ESCAPE, Rml::Input::KI_ESCAPE }, + { VK_SPACE, Rml::Input::KI_SPACE }, + { VK_PRIOR, Rml::Input::KI_PRIOR }, + { VK_NEXT, Rml::Input::KI_NEXT }, + { VK_END, Rml::Input::KI_END }, + { VK_HOME, Rml::Input::KI_HOME }, + { VK_LEFT, Rml::Input::KI_LEFT }, + { VK_UP, Rml::Input::KI_UP }, + { VK_RIGHT, Rml::Input::KI_RIGHT }, + { VK_DOWN, Rml::Input::KI_DOWN }, + { VK_SNAPSHOT, Rml::Input::KI_SNAPSHOT }, + { VK_INSERT, Rml::Input::KI_INSERT }, + { VK_DELETE, Rml::Input::KI_DELETE }, + { VK_LWIN, Rml::Input::KI_LWIN }, + { VK_RWIN, Rml::Input::KI_RWIN }, + { VK_APPS, Rml::Input::KI_APPS }, + { VK_MULTIPLY, Rml::Input::KI_MULTIPLY }, + { VK_ADD, Rml::Input::KI_ADD }, + { VK_SEPARATOR, Rml::Input::KI_SEPARATOR }, + { VK_SUBTRACT, Rml::Input::KI_SUBTRACT }, + { VK_DECIMAL, Rml::Input::KI_DECIMAL }, + { VK_DIVIDE, Rml::Input::KI_DIVIDE }, + { VK_NUMLOCK, Rml::Input::KI_NUMLOCK }, + { VK_SCROLL, Rml::Input::KI_SCROLL }, + { VK_SHIFT, Rml::Input::KI_LSHIFT }, + { VK_CONTROL, Rml::Input::KI_LCONTROL }, + { VK_MENU, Rml::Input::KI_LMENU }, + { VK_LSHIFT, Rml::Input::KI_LSHIFT }, + { VK_RSHIFT, Rml::Input::KI_RSHIFT }, + { VK_LCONTROL, Rml::Input::KI_LCONTROL }, + { VK_RCONTROL, Rml::Input::KI_RCONTROL }, + { VK_LMENU, Rml::Input::KI_LMENU }, + { VK_RMENU, Rml::Input::KI_RMENU }, + { VK_OEM_1, Rml::Input::KI_OEM_1 }, + { VK_OEM_PLUS, Rml::Input::KI_OEM_PLUS }, + { VK_OEM_COMMA, Rml::Input::KI_OEM_COMMA }, + { VK_OEM_MINUS, Rml::Input::KI_OEM_MINUS }, + { VK_OEM_PERIOD, Rml::Input::KI_OEM_PERIOD }, + { VK_OEM_2, Rml::Input::KI_OEM_2 }, + { VK_OEM_3, Rml::Input::KI_OEM_3 }, + { VK_OEM_4, Rml::Input::KI_OEM_4 }, + { VK_OEM_5, Rml::Input::KI_OEM_5 }, + { VK_OEM_6, Rml::Input::KI_OEM_6 }, + { VK_OEM_7, Rml::Input::KI_OEM_7 }, + { VK_OEM_8, Rml::Input::KI_OEM_8 }, + { VK_OEM_102, Rml::Input::KI_OEM_102 }, +}; + + +#ifdef _DEBUG + +// The test document is a developer's check of the shell; F9 shows and hides it. +static Rml::ElementDocument * _TestDocument = NULL; +static bool _PendingToggle = false; +static bool _CloseRequested = false; + +class UITestListenerClass : public Rml::EventListener +{ + public: + virtual void ProcessEvent(Rml::Event &) override + { + _CloseRequested = true; + } +}; + +static UITestListenerClass _TestListener; + +#endif + + +// Letters, digits, the keypad digits and the function keys are contiguous in both codings. +static void Build_Key_Map(void) +{ + for (int code = 0; code < 256; code++) { + _KeyMap[code] = Rml::Input::KI_UNKNOWN; + } + + for (UIKeyMapping const & mapping : _KeyMappings) { + _KeyMap[mapping.VirtualKey] = mapping.Key; + } + + for (int letter = 0; letter < 26; letter++) { + _KeyMap['A' + letter] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_A + letter); + } + for (int digit = 0; digit < 10; digit++) { + _KeyMap['0' + digit] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_0 + digit); + _KeyMap[VK_NUMPAD0 + digit] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_NUMPAD0 + digit); + } + for (int function = 0; function < 12; function++) { + _KeyMap[VK_F1 + function] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_F1 + function); + } +} + + +static int Key_Modifiers(void) +{ + int modifiers = 0; + + if (GetKeyState(VK_SHIFT) & 0x8000) { + modifiers |= Rml::Input::KM_SHIFT; + } + if (GetKeyState(VK_CONTROL) & 0x8000) { + modifiers |= Rml::Input::KM_CTRL; + } + if (GetKeyState(VK_MENU) & 0x8000) { + modifiers |= Rml::Input::KM_ALT; + } + if (GetKeyState(VK_CAPITAL) & 1) { + modifiers |= Rml::Input::KM_CAPSLOCK; + } + if (GetKeyState(VK_NUMLOCK) & 1) { + modifiers |= Rml::Input::KM_NUMLOCK; + } + + return(modifiers); +} + + +static bool Documents_Visible(void) +{ + if (_Context == NULL) { + return(false); + } + + for (int index = 0; index < _Context->GetNumDocuments(); index++) { + Rml::ElementDocument * document = _Context->GetDocument(index); + if (document != NULL && document->IsVisible()) { + return(true); + } + } + + return(false); +} + + +static bool Text_Input_Focused(void) +{ + Rml::Element * focus = _Context->GetFocusElement(); + if (focus == NULL) { + return(false); + } + + Rml::String const & tag = focus->GetTagName(); + return(tag == "input" || tag == "textarea"); +} + + +static void Apply_Dimensions(void) +{ + VideoScaleInfo const & scale = Video_Get_Scale_Info(); + + float ratio = scale.ScaleX < scale.ScaleY ? scale.ScaleX : scale.ScaleY; + if (ratio <= 0.0f) { + ratio = 1.0f; + } + + _Context->SetDimensions(Rml::Vector2i(scale.DestWidth, scale.DestHeight)); + _Context->SetDensityIndependentPixelRatio(ratio); +} + + +static UIPointerPosition Pointer_Position(LPARAM clientlparam) +{ + VideoScaleInfo const & scale = Video_Get_Scale_Info(); + return(UI_Client_To_Overlay(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight, GET_X_LPARAM(clientlparam), GET_Y_LPARAM(clientlparam))); +} + + +// Forgets the presses the shell owns, telling the documents they ended, and gives the +// capture back when the shell took it. +static void Drop_Presses(void) +{ + unsigned int owned = _OwnedButtons; + _OwnedButtons = 0; + + for (int button = 0; button < 3; button++) { + if (owned & (1u << button)) { + _Context->ProcessMouseButtonUp(button, Key_Modifiers()); + } + } + + if (_TookCapture) { + _TookCapture = false; + if (GetCapture() == MainWindow) { + ReleaseCapture(); + } + } +} + + +#ifdef _DEBUG + +static void Toggle_Test_Document(void) +{ + if (!_FontLoaded) { + DebugString("UI: the test document needs the font, which did not load\n"); + return; + } + + if (_TestDocument == NULL) { + _TestDocument = _Context->LoadDocument("test.rml"); + if (_TestDocument == NULL) { + DebugString("UI: test.rml did not load\n"); + return; + } + + Rml::Element * close = _TestDocument->GetElementById("close"); + if (close != NULL) { + close->AddEventListener(Rml::EventId::Click, &_TestListener); + } + + _TestDocument->Show(); + _Render.Log_Resource_Counts("test document loaded and shown"); + } else if (_TestDocument->IsVisible()) { + _TestDocument->Hide(); + _Render.Log_Resource_Counts("test document hidden"); + } else { + _TestDocument->Show(); + _Render.Log_Resource_Counts("test document shown"); + } + + Video_Mark_Overlay_Dirty(); +} + +#endif + + +bool UI_Init(void) +{ + if (_Ready) { + return(true); + } + + Build_Key_Map(); + + if (!_Render.Init()) { + return(false); + } + + Rml::SetSystemInterface(&_System); + Rml::SetFileInterface(&_File); + Rml::SetRenderInterface(&_Render); + + if (!Rml::Initialise()) { + DebugString("UI: RmlUi did not initialise\n"); + _Render.Shutdown(); + return(false); + } + + VideoScaleInfo const & scale = Video_Get_Scale_Info(); + _Context = Rml::CreateContext("main", Rml::Vector2i(scale.DestWidth, scale.DestHeight)); + if (_Context == NULL) { + DebugString("UI: the context could not be created\n"); + Rml::Shutdown(); + _Render.Shutdown(); + return(false); + } + + Apply_Dimensions(); + + _FontLoaded = Rml::LoadFontFace("OpenSans.ttf"); + if (!_FontLoaded) { + DebugString("UI: OpenSans.ttf did not load, so no document can be shown\n"); + } + + _Ready = true; + DebugString("UI: RmlUi %s ready over a %dx%d frame at %.2f pixels per dp\n", + Rml::GetVersion().c_str(), scale.DestWidth, scale.DestHeight, _Context->GetDensityIndependentPixelRatio()); + return(true); +} + + +void UI_Shutdown(void) +{ + if (!_Ready) { + return; + } + + _Ready = false; + + if (_OwnedButtons != 0) { + Drop_Presses(); + } + +#ifdef _DEBUG + _TestDocument = NULL; + _PendingToggle = false; + _CloseRequested = false; +#endif + + Rml::RemoveContext("main"); + _Context = NULL; + + Rml::Shutdown(); + _Render.Shutdown(); + _FontLoaded = false; +} + + +void UI_On_Video_Change(void) +{ + if (!_Ready) { + return; + } + + if (_InContext) { + _PendingResize = true; + } else { + Apply_Dimensions(); + } + + Video_Mark_Overlay_Dirty(); +} + + +void UI_Tick(void) +{ + if (!_Ready || _InTick || _InContext) { + return; + } + + _InTick = true; + +#ifdef _DEBUG + if (_PendingToggle) { + _PendingToggle = false; + Toggle_Test_Document(); + } + if (_CloseRequested) { + _CloseRequested = false; + if (_TestDocument != NULL && _TestDocument->IsVisible()) { + _TestDocument->Hide(); + _Render.Log_Resource_Counts("test document closed"); + Video_Mark_Overlay_Dirty(); + } + } +#endif + + if (_PendingResize) { + _PendingResize = false; + Apply_Dimensions(); + } + if (_PendingRelease) { + _PendingRelease = false; + Drop_Presses(); + } + if (_PendingLeave) { + _PendingLeave = false; + _Context->ProcessMouseLeave(); + _MouseInside = false; + } + + _InContext = true; + _Context->Update(); + _InContext = false; + + if (Documents_Visible()) { + Video_Mark_Overlay_Dirty(); + } + + _InTick = false; +} + + +void UI_Render_Overlay(void) +{ + if (!_Ready || _InContext || Movie_Is_Playing() || !Documents_Visible()) { + return; + } + + VideoScaleInfo const & scale = Video_Get_Scale_Info(); + if (scale.DestWidth <= 0 || scale.DestHeight <= 0) { + return; + } + + _Render.Begin_Frame(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight); + + _InContext = true; + _Context->Render(); + _InContext = false; +} + + +static bool Handle_Mouse_Move(LPARAM clientlparam) +{ + UIPointerPosition position = Pointer_Position(clientlparam); + + if (_OwnedButtons != 0 || position.Inside) { + _Context->ProcessMouseMove(position.X, position.Y, Key_Modifiers()); + _MouseInside = position.Inside; + Video_Mark_Overlay_Dirty(); + } else if (_MouseInside) { + _Context->ProcessMouseLeave(); + _MouseInside = false; + Video_Mark_Overlay_Dirty(); + } + + return(false); +} + + +static bool Handle_Button_Down(int button, LPARAM clientlparam) +{ + UIPointerPosition position = Pointer_Position(clientlparam); + + if (!position.Inside && _OwnedButtons == 0) { + return(false); + } + + int modifiers = Key_Modifiers(); + _Context->ProcessMouseMove(position.X, position.Y, modifiers); + _MouseInside = position.Inside; + + bool interacting = !_Context->ProcessMouseButtonDown(button, modifiers); + Video_Mark_Overlay_Dirty(); + + if (!interacting) { + return(false); + } + + if (_OwnedButtons == 0) { + _TookCapture = (GetCapture() != MainWindow); + if (_TookCapture) { + SetCapture(MainWindow); + } + } + _OwnedButtons |= (1u << button); + return(true); +} + + +static bool Handle_Button_Up(int button, LPARAM clientlparam) +{ + if ((_OwnedButtons & (1u << button)) == 0) { + return(false); + } + + UIPointerPosition position = Pointer_Position(clientlparam); + int modifiers = Key_Modifiers(); + + _Context->ProcessMouseMove(position.X, position.Y, modifiers); + _Context->ProcessMouseButtonUp(button, modifiers); + _MouseInside = position.Inside; + Video_Mark_Overlay_Dirty(); + + _OwnedButtons &= ~(1u << button); + if (_OwnedButtons == 0 && _TookCapture) { + _TookCapture = false; + if (GetCapture() == MainWindow) { + ReleaseCapture(); + } + } + + return(true); +} + + +static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) +{ + POINT point; + point.x = GET_X_LPARAM(screenlparam); + point.y = GET_Y_LPARAM(screenlparam); + ScreenToClient(MainWindow, &point); + + VideoScaleInfo const & scale = Video_Get_Scale_Info(); + UIPointerPosition position = UI_Client_To_Overlay(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight, point.x, point.y); + if (!position.Inside) { + return(false); + } + + // Windows counts wheel movement away from the user as positive; RmlUi scrolls down for it. + float delta = (float)(short)HIWORD(wparam) / (float)WHEEL_DELTA; + bool consumed = !_Context->ProcessMouseWheel(Rml::Vector2f(0.0f, -delta), Key_Modifiers()); + Video_Mark_Overlay_Dirty(); + return(consumed); +} + + +static bool Handle_Key(UINT message, WPARAM wparam) +{ + Rml::Input::KeyIdentifier key = _KeyMap[wparam & 0xFF]; + if (key == Rml::Input::KI_UNKNOWN) { + return(false); + } + + bool propagated; + if (message == WM_KEYDOWN) { + propagated = _Context->ProcessKeyDown(key, Key_Modifiers()); + } else { + propagated = _Context->ProcessKeyUp(key, Key_Modifiers()); + } + + Video_Mark_Overlay_Dirty(); + return(!propagated || Text_Input_Focused()); +} + + +// Windows delivers a character beyond the basic plane as two messages; the first half +// waits for the second. Carriage returns become newlines and control characters stay out. +static bool Handle_Char(WPARAM wparam) +{ + wchar_t unit = (wchar_t)wparam; + + if (unit >= 0xD800 && unit < 0xDC00) { + _HighSurrogate = unit; + return(false); + } + + char32_t code = unit; + if (unit >= 0xDC00 && unit < 0xE000 && _HighSurrogate != 0) { + code = 0x10000 + (((char32_t)_HighSurrogate - 0xD800) << 10) + ((char32_t)unit - 0xDC00); + } + _HighSurrogate = 0; + + if (code == '\r') { + code = '\n'; + } + if ((code < 32 && code != '\n') || code == 127) { + return(false); + } + + bool consumed = !_Context->ProcessTextInput((Rml::Character)code); + Video_Mark_Overlay_Dirty(); + return(consumed); +} + + +bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) +{ + if (!_Ready || _InHook || hwnd != MainWindow) { + return(false); + } + + // Another window taking the capture ends the presses the shell owns. + if (message == WM_CAPTURECHANGED) { + if (_OwnedButtons != 0 && (HWND)clientlparam != MainWindow) { + _TookCapture = false; + if (_InContext) { + _PendingRelease = true; + } else { + _InHook = true; + Drop_Presses(); + _InHook = false; + } + } + return(false); + } + + if (message == WM_ACTIVATEAPP) { + if (wparam == 0 && _MouseInside) { + if (_InContext) { + _PendingLeave = true; + } else { + _InHook = true; + _Context->ProcessMouseLeave(); + _MouseInside = false; + _InHook = false; + } + } + return(false); + } + + if (_InContext || (_OwnedButtons == 0 && !Documents_Visible())) { + return(false); + } + + _InHook = true; + bool consumed = false; + + switch (message) { + case WM_MOUSEMOVE: + consumed = Handle_Mouse_Move(clientlparam); + break; + + case WM_LBUTTONDOWN: + case WM_LBUTTONDBLCLK: + consumed = Handle_Button_Down(0, clientlparam); + break; + + case WM_RBUTTONDOWN: + case WM_RBUTTONDBLCLK: + consumed = Handle_Button_Down(1, clientlparam); + break; + + case WM_MBUTTONDOWN: + case WM_MBUTTONDBLCLK: + consumed = Handle_Button_Down(2, clientlparam); + break; + + case WM_LBUTTONUP: + consumed = Handle_Button_Up(0, clientlparam); + break; + + case WM_RBUTTONUP: + consumed = Handle_Button_Up(1, clientlparam); + break; + + case WM_MBUTTONUP: + consumed = Handle_Button_Up(2, clientlparam); + break; + + case WM_MOUSEWHEEL: + consumed = Handle_Wheel(wparam, clientlparam); + break; + + case WM_KEYDOWN: + case WM_KEYUP: + consumed = Handle_Key(message, wparam); + break; + + case WM_CHAR: + consumed = Handle_Char(wparam); + break; + + default: + break; + } + + _InHook = false; + return(consumed); +} + + +bool UI_Intercept_Pumped_Message(MSG const & msg) +{ +#ifdef _DEBUG + if (_Ready && Debug_Flag && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F9) { + if (msg.message == WM_KEYDOWN && (msg.lParam & (1 << 30)) == 0) { + _PendingToggle = true; + } + return(true); + } +#else + (void)msg; +#endif + return(false); +} diff --git a/code/ui/uishell.h b/code/ui/uishell.h new file mode 100644 index 000000000..459d267ae --- /dev/null +++ b/code/ui/uishell.h @@ -0,0 +1,39 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The UI shell owns the RmlUi context, the overlay pass and the input hook. The rest of +// the engine reaches it through these functions alone; no toolkit type appears here. + +#pragma once + +#include "win.h" + + +// Needs the window, the renderer and the file search chain. A false return leaves every +// other entry point inert. +bool UI_Init(void); +void UI_Shutdown(void); + +// The frame moved or changed size inside the window. +void UI_On_Video_Change(void); + +// Advances the documents and executes the intents their events queued. Called at the +// game's service points, never from a paint handler or the message pump. +void UI_Tick(void); + +// Draws the visible documents over the frame the renderer has just submitted. +void UI_Render_Overlay(void); + +// Offers a main window message to the shell before the game sees it. The position is the +// raw client one, taken before the router translated it. True means the message is consumed. +bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam); + +// Offers a pumped message to the shell before dispatch, whichever window it is for. True +// means the message is consumed. +bool UI_Intercept_Pumped_Message(MSG const & msg); diff --git a/code/ui/uisystem.cpp b/code/ui/uisystem.cpp new file mode 100644 index 000000000..ae0729bf0 --- /dev/null +++ b/code/ui/uisystem.cpp @@ -0,0 +1,65 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uisystem.h" + +#include "dbgprint.h" +#include "win.h" + + +UISystemInterfaceClass::UISystemInterfaceClass(void) : + StartTime(timeGetTime()) +{ +} + + +// UI animation follows the wall clock, never the game's deterministic timers. +double UISystemInterfaceClass::GetElapsedTime(void) +{ + return((double)(timeGetTime() - StartTime) / 1000.0); +} + + +bool UISystemInterfaceClass::LogMessage(Rml::Log::Type type, Rml::String const & message) +{ + char const * level = "info"; + + switch (type) { + case Rml::Log::LT_ERROR: + level = "error"; + break; + + case Rml::Log::LT_ASSERT: + level = "assert"; + break; + + case Rml::Log::LT_WARNING: + level = "warning"; + break; + + case Rml::Log::LT_DEBUG: + level = "debug"; + break; + + default: + break; + } + + DebugString("UI %s: %s\n", level, message.c_str()); + return(true); +} + + +// Documents name their resources by bare file name, so one name resolves the same way from +// the ui directory, a loose override or a mix. +void UISystemInterfaceClass::JoinPath(Rml::String & translated, Rml::String const &, Rml::String const & path) +{ + size_t start = path.find_last_of("/\\"); + translated = (start == Rml::String::npos) ? path : path.substr(start + 1); +} diff --git a/code/ui/uisystem.h b/code/ui/uisystem.h new file mode 100644 index 000000000..0d36fc2cb --- /dev/null +++ b/code/ui/uisystem.h @@ -0,0 +1,27 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include + + +// RmlUi's view of the engine's clock, debug log and resource naming. +class UISystemInterfaceClass : public Rml::SystemInterface +{ + public: + UISystemInterfaceClass(void); + + virtual double GetElapsedTime(void) override; + virtual bool LogMessage(Rml::Log::Type type, Rml::String const & message) override; + virtual void JoinPath(Rml::String & translated, Rml::String const & documentpath, Rml::String const & path) override; + + private: + unsigned int StartTime; +}; diff --git a/code/ui/uitexture.cpp b/code/ui/uitexture.cpp new file mode 100644 index 000000000..b8d1744a6 --- /dev/null +++ b/code/ui/uitexture.cpp @@ -0,0 +1,82 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uitexture.h" + +#include "ccfile.h" +#include "dbgprint.h" + +#define STB_IMAGE_IMPLEMENTATION +#define STBI_ONLY_PNG +#define STBI_ONLY_TGA +#define STBI_NO_STDIO +#include + +#include + + +static bool Has_Extension(char const * name, char const * extension) +{ + size_t length = strlen(name); + size_t extensionlength = strlen(extension); + + return(length >= extensionlength && _stricmp(name + length - extensionlength, extension) == 0); +} + + +bool UI_Load_Image(char const * name, std::vector & rgba, int & width, int & height) +{ + rgba.clear(); + width = 0; + height = 0; + + if (name == NULL || (!Has_Extension(name, ".png") && !Has_Extension(name, ".tga"))) { + return(false); + } + + CCFileClass file(name); + if (!file.Is_Available()) { + return(false); + } + + int size = file.Size(); + if (size <= 0) { + return(false); + } + + std::vector encoded((size_t)size); + if (!file.Open(FileClass::READ) || file.Read(encoded.data(), size) != size) { + return(false); + } + file.Close(); + + int channels = 0; + unsigned char * pixels = stbi_load_from_memory(encoded.data(), size, &width, &height, &channels, 4); + if (pixels == NULL) { + DebugString("UI: %s did not decode: %s\n", name, stbi_failure_reason()); + width = 0; + height = 0; + return(false); + } + + rgba.assign(pixels, pixels + (size_t)width * (size_t)height * 4); + stbi_image_free(pixels); + + // RmlUi composes premultiplied colour. + for (size_t index = 0; index < rgba.size(); index += 4) { + unsigned int alpha = rgba[index + 3]; + if (alpha != 255) { + rgba[index] = (unsigned char)(rgba[index] * alpha / 255); + rgba[index + 1] = (unsigned char)(rgba[index + 1] * alpha / 255); + rgba[index + 2] = (unsigned char)(rgba[index + 2] * alpha / 255); + } + } + + return(true); +} diff --git a/code/ui/uitexture.h b/code/ui/uitexture.h new file mode 100644 index 000000000..1f6e822df --- /dev/null +++ b/code/ui/uitexture.h @@ -0,0 +1,18 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include + + +// Decodes a PNG or TGA image, named the way documents name their resources, into +// premultiplied RGBA8 rows from the top down. False when the file is missing, unreadable +// or of another kind; the output is then empty. +bool UI_Load_Image(char const * name, std::vector & rgba, int & width, int & height); diff --git a/code/video.cpp b/code/video.cpp index f262d5b5d..6f4cf8e49 100644 --- a/code/video.cpp +++ b/code/video.cpp @@ -23,6 +23,7 @@ #include "goptions.h" #include "misc.h" #include "surface.h" +#include "ui/uishell.h" #include "wincursor.h" #include @@ -48,6 +49,11 @@ static VideoScaleInfo _ScaleInfo; // presented. A frame that is skipped for pacing stays marked, so the next present shows // the newest content rather than a stale one. static bool _FrameIsDirty = false; + +// Set when the UI overlay changed, so a present is due even while the frame is not. The +// frame is then presented again without being uploaded again. +static bool _OverlayIsDirty = false; + static unsigned int _LastPresentTime = 0; static unsigned int _PresentInterval = 16; @@ -185,6 +191,7 @@ void Video_Shutdown(void) Backend_Shutdown(); _Initialized = false; _FrameIsDirty = false; + _OverlayIsDirty = false; } @@ -211,6 +218,7 @@ bool Video_Set_Mode(int width, int height) Update_Scale_Info(); Win_Cursor_Refresh(); + UI_On_Video_Change(); _FrameIsDirty = true; return(true); } @@ -230,6 +238,7 @@ void Video_On_Resize(int drawablewidth, int drawableheight) Backend_On_Resize(drawablewidth, drawableheight); Update_Scale_Info(); Win_Cursor_Refresh(); + UI_On_Video_Change(); Video_Mark_Dirty(); } @@ -258,9 +267,21 @@ void Video_Mark_Dirty(void) /// -/// Puts the visible surface on the screen whatever its state. +/// Records that the UI overlay has changed since the last present. /// -void Video_Present(void) +void Video_Mark_Overlay_Dirty(void) +{ + _OverlayIsDirty = true; +} + + +/// +/// Puts the frame on the screen with the UI overlay over it. +/// Both marks are cleared before presenting, so anything invalidated while the present is +/// under way is kept for the next one rather than lost with this one. +/// +/// Does the visible surface hold newer pixels than the renderer? +static void Present(bool uploadframe) { if (!_Initialized || _Presenting || VisibleSurface == NULL) { return; @@ -273,24 +294,37 @@ void Video_Present(void) return; } + _FrameIsDirty = false; + _OverlayIsDirty = false; + _LastPresentTime = timeGetTime(); + _Presenting = true; - Backend_Present(pixels, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode()); + if (Backend_Present(uploadframe ? pixels : NULL, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode())) { + UI_Render_Overlay(); + Backend_End_Frame(); + } _Presenting = false; +} - _FrameIsDirty = false; - _LastPresentTime = timeGetTime(); + +/// +/// Puts the visible surface on the screen whatever its state. +/// +void Video_Present(void) +{ + Present(true); } /// -/// Puts the visible surface on the screen if it has changed and the display is ready for -/// another frame. -/// A skipped present leaves the frame marked, so the next one shows the newest content. +/// Puts the visible surface on the screen if it or the UI overlay has changed and the +/// display is ready for another frame. +/// A skipped present leaves the marks in place, so the next one shows the newest content. /// This never waits: the game loop is not paced by presentation. /// void Video_Present_If_Dirty(void) { - if (!_FrameIsDirty) { + if (!_FrameIsDirty && !_OverlayIsDirty) { return; } @@ -299,7 +333,7 @@ void Video_Present_If_Dirty(void) return; } - Video_Present(); + Present(_FrameIsDirty); } diff --git a/code/video.h b/code/video.h index d3ec1b094..946f8ea8f 100644 --- a/code/video.h +++ b/code/video.h @@ -46,6 +46,7 @@ void Video_On_Resize(int drawablewidth, int drawableheight); void Video_Set_Refresh_Rate(int refreshrate); void Video_Mark_Dirty(void); +void Video_Mark_Overlay_Dirty(void); void Video_Present(void); void Video_Present_If_Dirty(void); diff --git a/code/winstub.cpp b/code/winstub.cpp index e11e6fc62..690a033c5 100644 --- a/code/winstub.cpp +++ b/code/winstub.cpp @@ -68,6 +68,7 @@ #include "resource.h" #include "session.h" #include "theme.h" +#include "ui/uishell.h" #include "video.h" #include "win.h" #include "wincursor.h" @@ -175,6 +176,10 @@ extern bool InMovie; LRESULT CALLBACK /*_export*/ Windows_Procedure(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + // The router below rewrites a position into the frame's own pixels; the UI overlay lays + // itself out in the window's and wants the position as Windows delivered it. + LPARAM client_lparam = lParam; + /* * The frame may be drawn scaled, so a click has to be matched against where the * player sees the controls rather than where Windows finds them. @@ -187,6 +192,10 @@ LRESULT CALLBACK /*_export*/ Windows_Procedure(HWND hwnd, UINT message, WPARAM w lParam = translated_lparam; } + if (UI_Handle_Window_Message(hwnd, message, wParam, client_lparam)) { + return(0); + } + int low_param = LOWORD(wParam); Map.Message_Handler(hwnd, message, wParam, lParam); diff --git a/docs/BUILDING.md b/docs/BUILDING.md index ba4adb609..be980c0f9 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -75,7 +75,9 @@ files to `TS_RUN_DIR`, which defaults to `Run/`: | Release | `Game.exe`, `Game.pdb`, `Game.map`, `Language.dll` | `Language.dll` has the same name in both configurations, so the most recently -built configuration replaces the previous copy in `Run/`. Compiler and linker +built configuration replaces the previous copy in `Run/`. Both configurations +also copy the repository's `ui/` directory, which holds the UI documents, +styles, and font, to `ui/` beside the executable. Compiler and linker intermediates stay in the selected build directory. ## Experimental clang-cl cross-build diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b19a5d465..fcd6d3885 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,10 +1,11 @@ # UI system design -Status: proposal under implementation. Step 1 of the -[migration plan](#migration-plan), the dependencies, has landed; nothing else -is implemented, built, or measured. Source inspection and upstream -documentation inform the rest. This page owns the proposed UI architecture and -migration; [Building OpenTS](BUILDING.md) owns build support and +Status: proposal under implementation. Steps 1 and 2 of the +[migration plan](#migration-plan), the dependencies and the RmlUi shell, have +landed; the Dear ImGui half of step 2 and everything after it are not yet +implemented, built, or measured. Source inspection and upstream documentation +inform the rest. This page owns the proposed UI architecture and migration; +[Building OpenTS](BUILDING.md) owns build support and [Project direction](DIRECTION.md) the wider architecture. ## Where the UI stands today @@ -174,20 +175,24 @@ actions is added only where a screen has real state transitions. ### Code layout New files live in `code/ui/`. The recursive glob in `code/CMakeLists.txt` -picks them up, and the directory lets the RmlUi, ImGui, and bgfx include -paths be scoped to the files that need them, as `bgfxbackend.cpp` is scoped -today. +picks them up. The library headers reach the whole target through the linked +targets, as bgfx's already do; the per-file properties carry only the shader +headers, the image decoder header, and the bgfx debug define, as +`bgfxbackend.cpp`'s do today. Files without a status column entry are not yet +written. -| File | Holds | -| --- | --- | -| `uishell.h`, `uishell.cpp` | init and shutdown, resize, input hook, tick, overlay render entry, modal runner, selector | -| `uirender.cpp` | RmlUi render interface and the ImGui renderer on bgfx; the only UI file that includes bgfx | -| `uisystem.cpp` | RmlUi system interface: time, logging to `DebugString`, cursor, clipboard, string translation | -| `uifile.cpp` | RmlUi file interface over `CCFileClass` | -| `uitexture.cpp` | image decoding, SHP and PCX conversion, surface-backed textures | -| `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | -| `uidev.cpp` | ImGui context and developer overlays | -| one file per screen | presenter, view-model binding, and the RmlUi view glue | +| File | Holds | Status | +| --- | --- | --- | +| `bgfxviews.hh` (in `code/`) | the view ids the presenter and the overlays share | landed | +| `uishell.h`, `uishell.cpp` | init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector | landed without the modal runner and selector | +| `uirender.h`, `uirender.cpp` | RmlUi render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx | landed without ImGui | +| `uisystem.h`, `uisystem.cpp` | RmlUi system interface: time, logging to `DebugString`, cursor, clipboard, string translation | landed with time, logging, and resource naming | +| `uifile.h`, `uifile.cpp` | RmlUi file interface over `CCFileClass` | landed | +| `uitexture.h`, `uitexture.cpp` | image decoding, SHP and PCX conversion, surface-backed textures | landed for PNG and TGA | +| `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | +| `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | | +| `uidev.cpp` | ImGui context and developer overlays | | +| one file per screen | presenter, view-model binding, and the RmlUi view glue | | Shipped UI files (documents, styles, images, the font) live in `ui/` at the repository root. The build copies the tree beside the executable as it copies @@ -235,12 +240,16 @@ methods: | Projection | The overlay view's orthographic transform; no game-image filter state inherited. | | Reset and resize | Target-dependent resources recreated, viewport and scissor refreshed, a full redraw requested; existing documents redraw without reload. | -The program is the embedded imgui vertex and fragment shader that -`bgfxbackend.cpp` already carries. Its attributes (position, texture -coordinate, color) match RmlUi's vertex and ImGui's vertex, each with its own -layout. Clip masks, transforms, layers, filters, and shaders are deferred; -shipped documents stay within a declared profile (text, images, ordinary -layout, borders, basic decorators), and a document check enforces it. +The program is bgfx's embedded debug-draw texture shader pair +(`vs_debugdraw_fill_texture`, `fs_debugdraw_fill_texture`). The imgui pair the +frame quad uses multiplies by the view projection alone and drops the model +matrix, which is where each compiled fragment's per-draw translation travels; +the debug-draw pair multiplies by the model, view, and projection product. Its +attributes (position, texture coordinate, color) match RmlUi's vertex and +ImGui's vertex, each with its own layout. Clip masks, transforms, layers, +filters, and shaders are deferred; shipped documents stay within a declared +profile (text, images, ordinary layout, borders, basic decorators), and a +document check enforces it. ### Invalidation @@ -307,19 +316,24 @@ migrates as one family. ### Hook and priority The shell gets a hook in `Windows_Procedure` after `Route_Mouse_Message` and -before `Map.Message_Handler`: +before `Map.Message_Handler`. The router rewrites a position into the frame's +own pixels, so the hook receives the position as Windows delivered it: ```cpp -if (UI_Handle_Window_Message(hwnd, message, wParam, lParam)) { +if (UI_Handle_Window_Message(hwnd, message, wParam, client_lparam)) { return(0); } ``` Placing it after the routing keeps legacy child windows working under video scaling; placing it before the keyboard handler keeps consumed input out of -the `KN_` queue. The hook covers mouse, wheel, key, and text messages only. -Activation, size, paint, transport, and system messages continue on their -paths. Forwarded or re-targeted messages are delivered to a toolkit once. +the `KN_` queue. The hook covers mouse, wheel, key, and text messages, and +watches capture and activation changes to end the presses it owns without +consuming them. Size, paint, transport, and system messages continue on their +paths. Forwarded or re-targeted messages are delivered to a toolkit once. A +developer key is intercepted earlier still, in `Windows_Message_Handler` +ahead of the dialog loop, so it works whichever window has focus; it only +records a request that the next tick executes. Priority follows scope and capture, not toolkit: @@ -479,7 +493,9 @@ update the context; a nested update or present request is recorded and served at the next safe point. Non-modal documents are updated by a `UI_Tick` call in `Main_Loop` next to -`Map.Input` and rendered by every present. +`Map.Input`, and by one at the end of each pass of the legacy dialog driver +so that a document stays alive under a menu, and are rendered by every +present. Teardown order: mark the screen closing and invalidate its token, then drop focus and capture and discard its intents, then detach listeners and data @@ -497,19 +513,23 @@ pointer. The RmlUi file interface is a thin wrapper over `CCFileClass`. Documents, styles, images, and fonts use flat basenames, and the interface resolves every relative reference by basename, so the same files load from a loose -`ui/` directory or from a mix. The run directory's `ui/` is added to the -`CDFileClass` search paths; the existing order then applies: user path, +`ui/` directory or from a mix. The `ui/` directory beside the executable is +added to the `CDFileClass` search paths as an absolute path, whichever data +directory the deployment names; the existing order then applies: user path, current directory, search paths, mix files. A mod overrides a document by -placing a file earlier in that order or by shipping it in a mix. The `ui/` -directory on disk is a packaging convenience, not part of the lookup key. +placing a file earlier in that order; a copy in a mix is used only when no +loose file exists. The `ui/` directory on disk is a packaging convenience, +not part of the lookup key. The adapter validates sizes, reads, and seeks; RmlUi uses `size_t` where the engine uses `int`, and a clamped seek must not look like success. A missing required document, style, or font fails preparation with the name reported. ### Images -Images resolve by extension. PNG and TGA decode through `bimg_decode`, which -is already vendored and needs only linking. PCX goes through `Read_PCX_File` +Images resolve by extension. PNG and TGA decode through `stb_image.h`, which +bimg vendors and the texture loader compiles with only those two formats +enabled; `bimg_decode` itself stays out because it would bring the AVIF codecs +and three more decoders along. PCX goes through `Read_PCX_File` with the palette named in the source string. SHP frames use a `name.shp#frame` form with an optional palette, decoded to RGBA with index zero transparent. Surfaces the engine draws at runtime (the map preview, the @@ -521,9 +541,11 @@ pointers. ### Fonts -Fonts use RmlUi's FreeType engine with an OFL sans-serif shipped in `ui/`. -The legacy dialogs already draw with a system TrueType face, so this changes -nothing about their look. RmlUi uses one font engine per process, installed +Fonts use RmlUi's FreeType engine with the variable Open Sans (OFL 1.1) from +Google Fonts shipped in `ui/` as `OpenSans.ttf` beside its license text; the +engine registers each of its named weights from the one file. The legacy +dialogs already draw with a system TrueType face, so this changes nothing +about their look. RmlUi uses one font engine per process, installed with `SetFontEngineInterface` before `Rml::Initialise`, and the built-in engine is not reachable from a custom one. In-game text that must match the bitmap fonts, needed only by the post-migration sidebar view, has two routes: @@ -669,8 +691,7 @@ built static with the static CRT that `thirdparty/CMakeLists.txt` forces: copy grow by the three projects and the components they bundle: robin_hood and itlib in RmlUi, zlib in FreeType, and the stb headers in Dear ImGui. CI already checks out submodules recursively. The build stamp step gains the -string-name generator, and `bimg_decode` loses `EXCLUDE_FROM_ALL` and is -linked. Dependency upgrades are separate changes. +string-name generator. Dependency upgrades are separate changes. ## Migration plan @@ -688,13 +709,15 @@ beyond an ASCII test document. 1. **Dependencies** (S, landed). Submodules, CMake, notices, `BUILDING.md`, and a `tests/uishell` smoke test that links the three libraries. No engine code uses them. Evidence: Debug and Release build. -2. **Shell** (M). Everything in the code-layout table except screens, the - backend split, the input hook, resize handling, the `ui/` copy step, the - file interface with mix resolution, and a Debug-only test document toggled - by a developer key. Evidence: the test document renders over the main menu - and in game at several resolutions and scale modes; clicks on it are - consumed; clicks beside it reach the game; legacy dialogs still open and - close; repeated open and close leaks nothing. +2. **Shell** (M, RmlUi half landed). Everything in the code-layout table + except screens, the backend split, the input hook, resize handling, the + `ui/` copy step, the file interface with mix resolution, and a Debug-only + test document toggled by F9. The Dear ImGui context, its renderer, and the + first overlay follow as their own change. Evidence: the test document + renders over the main menu and in game at several resolutions and scale + modes; clicks on it, beside any legacy dialog, are consumed; clicks beside + it reach the game; legacy dialogs still open and close; repeated open and + close leaks nothing. 3. **Version dialog** (S, leaf). The integration pilot: fonts, clipping, mapping, dismissal by mouse and keyboard, focus return, UI-only redraw, resize, preparation failure. The main menu keeps hiding around it. @@ -734,14 +757,18 @@ credits are unscheduled. ## Validation and evidence -The `tests/uishell` CTest target begins as a smoke test that brings RmlUi -core, FreeType, and Dear ImGui up and down under the engine's link settings. -As screens land it links `uiscreen.h`, the string table, and the screen -presenters with a recording render interface and a null system interface. It -runs without game assets: - -- Load every shipped document and fail on a parse error or a property - outside the declared profile. +The `tests/uishell` CTest target brings FreeType and Dear ImGui up and down +under the engine's link settings and drives RmlUi core through a recording +render interface and a counting system interface. As screens land it links +`uiscreen.h`, the string table, and the screen presenters. It runs without +game assets: + +- Load every shipped document from the source tree with the shipped font, + show, update, and render it, and fail on a parse error, an RmlUi warning + or error, a call to a render method the shell leaves at its default, a + scissor outside the context, or a resource named by anything but a bare + file name; after shutdown, every compiled geometry and texture has been + released. - Bind a presenter, drive it with `Context::ProcessMouseButtonDown` on a known element, and assert the queued intent and result; drive the same actions through the legacy adapter and assert the same ordered service @@ -750,8 +777,9 @@ runs without game assets: and catalog removal. - Scan shipped documents for `[[TXT_*]]` names and check each exists in the generated table. -- Round-trip the coordinate mapping at integer and fractional scales, with - letterboxing, resize, outside input, and captured release. +- Map client positions into the overlay at integer and fractional scales, + with letterboxing, exclusive edges, outside input, and the offset a captured + pointer keeps outside. Runtime evidence stays per pull request, as `CONTRIBUTING.md` requires: the screen exercised in single player, skirmish, and a two-instance LAN game @@ -777,7 +805,6 @@ geometry memory are recorded on an agreed baseline before defaults change. ## Open decisions -- The shipped font. - The kill-switch key name, fixed by the change that introduces it. - The in-game text route for the sidebar view: TrueType conversions of the game fonts or a bitmap font engine for every document. diff --git a/manual/changes/ui-shell.md b/manual/changes/ui-shell.md new file mode 100644 index 000000000..68c11cba7 --- /dev/null +++ b/manual/changes/ui-shell.md @@ -0,0 +1,14 @@ +--- +title: Add the RmlUi shell and its test document +category: internal +release: 0.2.0 +targets: + - type: command + id: fixed:debug-ui-test-document + effect: added +credit: [ZivDero] +--- + +The engine gains a UI shell that draws RmlUi documents through the renderer over the presented frame, reads them through the game's own file search, and takes their input ahead of the game. Nothing player-facing uses it yet: the first migrated screens follow in later changes. + +A `ui` directory of documents, styles, and the Open Sans font now ships beside the executable. A Debug build with the debug keys armed shows a test document on F9; a Release build carries the shell but shows nothing. diff --git a/manual/content/commands/fixed-debug-ui-test-document.md b/manual/content/commands/fixed-debug-ui-test-document.md new file mode 100644 index 000000000..c46d00153 --- /dev/null +++ b/manual/content/commands/fixed-debug-ui-test-document.md @@ -0,0 +1,11 @@ +--- +command_id: fixed:debug-ui-test-document +--- + +The key is read from the message pump before any dialog sees it, so it works while a menu dialog or one of its controls has focus as well as in play, and the key release is swallowed with the press so the game never sees either. The first press loads `test.rml` from the `ui` directory beside the executable and shows it; each later press hides or shows the same document. The document is a panel in the top left corner of the frame with a button that hides it, drawn by the renderer over the presented frame at the window's resolution and following the frame's position and scale. + +A click on the panel is consumed before it reaches the game; a click on the frame beside it reaches the game as before. The panel is positioned away from the centered menu dialogs because a visible dialog takes the clicks over its own area first. + +The document needs the shipped `OpenSans.ttf`. When the font failed to load at startup, the key writes a line to the debug log and shows nothing. Each load, show, and hide also logs the renderer's live texture and buffer counts. + +[Developer mode and diagnostics](/systems/developer-mode/) covers the flag that arms the keys handled directly in code. diff --git a/manual/content/systems/developer-mode.md b/manual/content/systems/developer-mode.md index 6f2b32430..b4f82bd83 100644 --- a/manual/content/systems/developer-mode.md +++ b/manual/content/systems/developer-mode.md @@ -45,6 +45,10 @@ Assertions are live wherever `NDEBUG` is undefined, which is the Debug configura `ASSERT.TXT` is opened for writing at its start rather than appended, and the new record is written from the beginning of the file. A shorter report therefore leaves the tail of the previous one in place behind it, and the file never accumulates a history. ::: +## The UI test document + +A Debug build with the debug keys armed shows an RmlUi test document over the game and its menus on [F9](/commands/fixed-debug-ui-test-document/) and hides it again on the next press. The document is a panel in the top left corner of the frame with a button that closes it. It is drawn by the renderer over the presented frame, so it appears over the main menu as well as in play, and it follows the frame's position and scale in the window. A click on the panel never reaches the game; a click beside it does. The document, its style sheet, and the font come from the `ui` directory beside the executable, and each load, show, and hide writes a line to the debug log with the renderer's texture and buffer counts, so a leak across repeated toggles shows there. + ## The monochrome pages The monochrome display is a four-page text surface driven through a monochrome display device rather than drawn on screen. Enabling it only raises a flag; nothing verifies that the device is there. The first screen clear the device refuses lowers the flag again, so on a machine without that device the pages switch themselves off on the first diagnostic pass and stay off until something enables them again. diff --git a/manual/content/using/build-and-run.md b/manual/content/using/build-and-run.md index 5ec42db5a..39c678ac3 100644 --- a/manual/content/using/build-and-run.md +++ b/manual/content/using/build-and-run.md @@ -23,7 +23,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A Win32 cmake --build build --config Debug ``` -The Debug build copies `GameD.exe`, its symbols, map file, and the matching `Language.dll` into `Run/`. Use `--config Release` to produce `Game.exe` instead. +The Debug build copies `GameD.exe`, its symbols, map file, and the matching `Language.dll` into `Run/`, and the repository's `ui/` directory of UI documents, styles, and font into `Run/ui/`. Use `--config Release` to produce `Game.exe` instead. After supplying the required game data in `Run/`, launch the selected executable from that directory: diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 3496023ef..e48d86426 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -280,6 +280,15 @@ fixed_controls: availability: *all sites: - { file: code/wdtsel.cpp, function: Selection::Check_For_Breakout, expression: VK_ESCAPE } + - id: fixed:debug-ui-test-document + title: Toggle the UI test document + description: Shows or hides the RmlUi test document drawn over the game and its menus. + audience: debug + bindings: [F9] + context: Any game window focused, with Debug_Flag enabled + availability: *debug + sites: + - { file: code/ui/uishell.cpp, function: UI_Intercept_Pumped_Message, expression: VK_F9, guard: _DEBUG } fixed_exclusions: - site: { file: code/debug.cpp, function: Debug_Key, expression: KN_BUTTON, guard: _DEBUG } @@ -428,6 +437,13 @@ fixed_exclusions: reason: World Domination Tour territory-screen polling and hit-testing. - site: { file: code/winstub.cpp, function: Create_Main_Window, expression: VK_M } reason: Registers a Windows hotkey that has no WM_HOTKEY handler in the current source tree. + - sites: + - { file: code/ui/uishell.cpp, function: Key_Modifiers, expression: VK_SHIFT } + - { file: code/ui/uishell.cpp, function: Key_Modifiers, expression: VK_CONTROL } + - { file: code/ui/uishell.cpp, function: Key_Modifiers, expression: VK_MENU } + - { file: code/ui/uishell.cpp, function: Key_Modifiers, expression: VK_CAPITAL } + - { file: code/ui/uishell.cpp, function: Key_Modifiers, expression: VK_NUMLOCK } + reason: UI shell modifier-state encoding for RmlUi, not separate controls. launch_options: - id: launch:help diff --git a/manual/data/commands.yaml b/manual/data/commands.yaml index d286e7be0..0570fee4f 100644 --- a/manual/data/commands.yaml +++ b/manual/data/commands.yaml @@ -2197,6 +2197,19 @@ fixed_controls: _provenance: source: code/wdtsel.cpp guard: null +- id: fixed:debug-ui-test-document + route_id: fixed-debug-ui-test-document + kind: fixed + title: Toggle the UI test document + description: Shows or hides the RmlUi test document drawn over the game and its menus. + audience: debug + availability: *id002 + bindings: + - F9 + context: Any game window focused, with Debug_Flag enabled + _provenance: + source: code/ui/uishell.cpp + guard: _DEBUG launch_options: - id: launch:help route_id: help diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index d8f48e2d0..9c7ea69bc 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -1,13 +1,18 @@ -# The harness links the vendored UI toolkits the way the engine will, so a runtime library -# or structure layout mismatch fails here before any screen exists. It lives outside code/ -# so that the recursive glob building OpenTS cannot pick this target's entry point up. +# The harness links the vendored UI toolkits the way the engine does and loads the shipped +# documents through a recording render interface, so a runtime library mismatch, a document +# that fails to parse or a style outside the implemented render methods fails here. It lives +# outside code/ so that the recursive glob building OpenTS cannot pick this target's entry +# point up. add_executable(UIShell "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" ) target_compile_features(UIShell PRIVATE cxx_std_20) -target_compile_definitions(UIShell PRIVATE WIN32 _WINDOWS NOMINMAX) +target_include_directories(UIShell PRIVATE "${CMAKE_SOURCE_DIR}/code") + +# The documents are read from the source tree, so the test needs no run directory. +target_compile_definitions(UIShell PRIVATE WIN32 _WINDOWS NOMINMAX "OPENTS_UI_DIR=\"${CMAKE_SOURCE_DIR}/ui\"") target_compile_options(UIShell PRIVATE $<$:/MTd /EHsc /Zc:__cplusplus> diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index d2c62e315..e21d53984 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -7,17 +7,26 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -// Starts each vendored UI toolkit up and down once with no window, renderer, or game -// data, linked the way the engine links them. A runtime library or structure layout -// mismatch shows here as a link error or a failed check. +// Pins what the UI shell relies on without a window, a renderer or game data: the vendored +// toolkits start and stop under the engine's link settings, every shipped document loads +// and draws through the render interface methods the shell implements and none it does +// not, the documents name their resources the way the shell resolves them, and the +// pointer mapping into the overlay behaves at the frame's edges. #include +#include +#include +#include +#include +#include #include #include #include FT_FREETYPE_H #include +#include "ui/uicoord.h" + namespace { int Failures = 0; @@ -33,15 +42,185 @@ void Check(bool condition, char const * what) } -void Test_RmlUi(void) +// Counts every call RmlUi makes while a document is laid out and drawn. The methods the +// shell leaves at their defaults count as violations of the styling profile the shipped +// documents must stay within. +class RecordingRenderInterfaceClass : public Rml::RenderInterface { - Check(Rml::Initialise(), "RmlUi initialises without a render interface"); + public: + int Compiled = 0; + int Rendered = 0; + int ReleasedGeometry = 0; + int Loaded = 0; + int Generated = 0; + int ReleasedTextures = 0; + int Unsupported = 0; + std::vector Scissors; - Rml::String version = Rml::GetVersion(); - std::printf(" RmlUi %s\n", version.c_str()); - Check(!version.empty(), "RmlUi reports a version"); + virtual Rml::CompiledGeometryHandle CompileGeometry(Rml::Span, Rml::Span) override + { + Compiled++; + return((Rml::CompiledGeometryHandle)Compiled); + } - Rml::Shutdown(); + virtual void RenderGeometry(Rml::CompiledGeometryHandle, Rml::Vector2f, Rml::TextureHandle) override + { + Rendered++; + } + + virtual void ReleaseGeometry(Rml::CompiledGeometryHandle) override + { + ReleasedGeometry++; + } + + virtual Rml::TextureHandle LoadTexture(Rml::Vector2i & dimensions, Rml::String const &) override + { + Loaded++; + dimensions = Rml::Vector2i(1, 1); + return((Rml::TextureHandle)(Loaded + Generated)); + } + + virtual Rml::TextureHandle GenerateTexture(Rml::Span, Rml::Vector2i) override + { + Generated++; + return((Rml::TextureHandle)(Loaded + Generated)); + } + + virtual void ReleaseTexture(Rml::TextureHandle) override + { + ReleasedTextures++; + } + + virtual void EnableScissorRegion(bool) override + { + } + + virtual void SetScissorRegion(Rml::Rectanglei region) override + { + Scissors.push_back(region); + } + + virtual void EnableClipMask(bool enable) override + { + if (enable) { + Unsupported++; + } + } + + virtual void RenderToClipMask(Rml::ClipMaskOperation, Rml::CompiledGeometryHandle, Rml::Vector2f) override + { + Unsupported++; + } + + virtual void SetTransform(Rml::Matrix4f const * transform) override + { + if (transform != nullptr) { + Unsupported++; + } + } + + virtual Rml::LayerHandle PushLayer(void) override + { + Unsupported++; + return(0); + } + + virtual void CompositeLayers(Rml::LayerHandle, Rml::LayerHandle, Rml::BlendMode, Rml::Span) override + { + Unsupported++; + } + + virtual void PopLayer(void) override + { + Unsupported++; + } + + virtual Rml::TextureHandle SaveLayerAsTexture(void) override + { + Unsupported++; + return(0); + } + + virtual Rml::CompiledFilterHandle SaveLayerAsMaskImage(void) override + { + Unsupported++; + return(0); + } + + virtual Rml::CompiledFilterHandle CompileFilter(Rml::String const &, Rml::Dictionary const &) override + { + Unsupported++; + return(0); + } + + virtual void ReleaseFilter(Rml::CompiledFilterHandle) override + { + } + + virtual Rml::CompiledShaderHandle CompileShader(Rml::String const &, Rml::Dictionary const &) override + { + Unsupported++; + return(0); + } + + virtual void RenderShader(Rml::CompiledShaderHandle, Rml::CompiledGeometryHandle, Rml::Vector2f, Rml::TextureHandle) override + { + Unsupported++; + } + + virtual void ReleaseShader(Rml::CompiledShaderHandle) override + { + } +}; + + +class CountingSystemInterfaceClass : public Rml::SystemInterface +{ + public: + int Problems = 0; + + virtual bool LogMessage(Rml::Log::Type type, Rml::String const & message) override + { + if (type == Rml::Log::LT_ERROR || type == Rml::Log::LT_ASSERT || type == Rml::Log::LT_WARNING) { + Problems++; + std::printf(" RmlUi: %s\n", message.c_str()); + } + return(true); + } +}; + + +std::string Read_Text(std::filesystem::path const & path) +{ + std::ifstream stream(path, std::ios::binary); + return(std::string((std::istreambuf_iterator(stream)), std::istreambuf_iterator())); +} + + +// A resource reference is what follows one of the attribute or function openers up to its +// closing quote or parenthesis. +bool References_Are_Bare(std::string const & text) +{ + static char const * const openers[] = { "href=\"", "src=\"", "url(" }; + + for (char const * opener : openers) { + size_t at = text.find(opener); + while (at != std::string::npos) { + size_t start = at + std::strlen(opener); + size_t end = text.find_first_of("\")", start); + if (end == std::string::npos) { + return(false); + } + std::string reference = text.substr(start, end - start); + if (reference.find_first_of("/\\") != std::string::npos) { + std::printf(" %s is not a bare name\n", reference.c_str()); + return(false); + } + at = text.find(opener, end); + } + } + + return(true); } @@ -76,14 +255,118 @@ void Test_ImGui(void) } } + +void Test_Coordinates(void) +{ + UIPointerPosition position = UI_Client_To_Overlay(0, 0, 640, 480, 100, 200); + Check(position.Inside && position.X == 100 && position.Y == 200, "an unscaled frame maps client pixels to itself"); + + // A 640x480 frame doubled into a 1280x720 window sits at x 160 with bars either side. + position = UI_Client_To_Overlay(160, 0, 960, 720, 160, 0); + Check(position.Inside && position.X == 0 && position.Y == 0, "the top left corner of the frame is inside at the origin"); + position = UI_Client_To_Overlay(160, 0, 960, 720, 159, 10); + Check(!position.Inside && position.X == -1, "a point on the left bar is outside and keeps its offset"); + position = UI_Client_To_Overlay(160, 0, 960, 720, 1119, 719); + Check(position.Inside && position.X == 959 && position.Y == 719, "the last pixel of the frame is inside"); + position = UI_Client_To_Overlay(160, 0, 960, 720, 1120, 719); + Check(!position.Inside && position.X == 960, "the right edge is exclusive"); + + // A 640x480 frame at one and a half times fills a 960x720 window exactly. + position = UI_Client_To_Overlay(0, 0, 960, 720, 959, 719); + Check(position.Inside, "a fractional scale keeps its last pixel inside"); + position = UI_Client_To_Overlay(0, 0, 960, 720, 960, 0); + Check(!position.Inside, "a fractional scale keeps the edge exclusive"); + + position = UI_Client_To_Overlay(0, 0, 960, 720, -5, 3); + Check(!position.Inside && position.X == -5, "a negative client position is outside with its offset kept"); +} + + +void Test_Documents(void) +{ + std::filesystem::path directory(OPENTS_UI_DIR); + + RecordingRenderInterfaceClass render; + CountingSystemInterfaceClass system; + Rml::SetRenderInterface(&render); + Rml::SetSystemInterface(&system); + + Check(Rml::Initialise(), "RmlUi initialises with the recording interfaces"); + std::printf(" RmlUi %s\n", Rml::GetVersion().c_str()); + + Check(Rml::LoadFontFace((directory / "OpenSans.ttf").string()), "the shipped font loads"); + + Rml::Context * context = Rml::CreateContext("test", Rml::Vector2i(1280, 800)); + Check(context != nullptr, "a context is created"); + + int documents = 0; + for (std::filesystem::directory_entry const & entry : std::filesystem::directory_iterator(directory)) { + std::filesystem::path path = entry.path(); + std::string extension = path.extension().string(); + + if (extension == ".rml" || extension == ".rcss") { + std::string what = path.filename().string() + " names its resources by bare file name"; + Check(References_Are_Bare(Read_Text(path)), what.c_str()); + } + + if (extension != ".rml" || context == nullptr) { + continue; + } + + documents++; + int rendered = render.Rendered; + int unsupported = render.Unsupported; + int problems = system.Problems; + render.Scissors.clear(); + + Rml::ElementDocument * document = context->LoadDocument(path.string()); + std::string name = path.filename().string(); + Check(document != nullptr, (name + " loads").c_str()); + if (document == nullptr) { + continue; + } + + document->Show(); + context->Update(); + context->Render(); + + Check(render.Rendered > rendered, (name + " draws geometry").c_str()); + Check(render.Unsupported == unsupported, (name + " stays within the implemented render methods").c_str()); + Check(system.Problems == problems, (name + " raises no RmlUi warning or error").c_str()); + + bool clipped = true; + for (Rml::Rectanglei const & scissor : render.Scissors) { + if (!scissor.Valid() || scissor.Left() < 0 || scissor.Top() < 0 || scissor.Right() > 1280 || scissor.Bottom() > 800) { + clipped = false; + } + } + Check(clipped, (name + " scissors within the context").c_str()); + + document->Close(); + context->Update(); + } + + Check(documents > 0, "the ui directory holds at least one document"); + + if (context != nullptr) { + Rml::RemoveContext("test"); + } + Rml::Shutdown(); + + Check(render.ReleasedGeometry == render.Compiled, "every compiled geometry is released by shutdown"); + Check(render.ReleasedTextures == render.Loaded + render.Generated, "every texture is released by shutdown"); + std::printf(" %d geometries, %d generated textures, %d loaded textures\n", render.Compiled, render.Generated, render.Loaded); +} + } int main(void) { - Test_RmlUi(); Test_FreeType(); Test_ImGui(); + Test_Coordinates(); + Test_Documents(); std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); return(Failures == 0 ? 0 : 1); diff --git a/thirdparty/licenses/stb-image.txt b/thirdparty/licenses/stb-image.txt new file mode 100644 index 000000000..917d94121 --- /dev/null +++ b/thirdparty/licenses/stb-image.txt @@ -0,0 +1,40 @@ +stb_image (thirdparty/bgfx.cmake/bimg/3rdparty/stb/stb_image.h) + +This software is available under 2 licenses -- choose whichever you prefer. +------------------------------------------------------------------------------ +ALTERNATIVE A - MIT License +Copyright (c) 2017 Sean Barrett +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. +------------------------------------------------------------------------------ +ALTERNATIVE B - Public Domain (www.unlicense.org) +This is free and unencumbered software released into the public domain. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. +In jurisdictions that recognize copyright laws, the author or authors of this +software dedicate any and all copyright interest in the software to the public +domain. We make this dedication for the benefit of the public at large and to +the detriment of our heirs and successors. We intend this dedication to be an +overt act of relinquishment in perpetuity of all present and future rights to +this software under copyright law. +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 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. +------------------------------------------------------------------------------ diff --git a/ui/OFL.txt b/ui/OFL.txt new file mode 100644 index 000000000..d762c3c46 --- /dev/null +++ b/ui/OFL.txt @@ -0,0 +1,92 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/OpenSans.ttf b/ui/OpenSans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9db85693b027f3b05f6d77471d215f20707127c1 GIT binary patch literal 532636 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 IF`oGQKT7FbbN~PV literal 0 HcmV?d00001 diff --git a/ui/test.rcss b/ui/test.rcss new file mode 100644 index 000000000..a6e0a6026 --- /dev/null +++ b/ui/test.rcss @@ -0,0 +1,61 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 15dp; + color: #e6e6e6; + pointer-events: none; +} + +body > * +{ + pointer-events: auto; +} + +div, h1, p +{ + display: block; +} + +#panel +{ + position: absolute; + left: 16dp; + top: 16dp; + width: 300dp; + padding: 12dp 14dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +h1 +{ + font-size: 20dp; + font-weight: bold; + margin-bottom: 8dp; +} + +p +{ + margin-bottom: 12dp; + line-height: 1.35em; +} + +button +{ + display: inline-block; + padding: 6dp 16dp; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} diff --git a/ui/test.rml b/ui/test.rml new file mode 100644 index 000000000..e20bd4c92 --- /dev/null +++ b/ui/test.rml @@ -0,0 +1,13 @@ + + + UI shell test + + + +
+

OpenTS UI shell

+

An RmlUi document drawn by bgfx over the game. Hover the button, then click it to close this panel. F9 brings it back.

+ +
+ +
From a66a29df577b248ba0c353bdd2687e709c166413 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 03:57:48 +0300 Subject: [PATCH 03/52] Add the Dear ImGui developer overlays --- code/CMakeLists.txt | 13 +- code/ui/uidev.cpp | 574 ++++++++++++++++++ code/ui/uidev.h | 33 + code/ui/uirender.cpp | 197 +++++- code/ui/uirender.h | 30 +- code/ui/uishell.cpp | 168 ++++- code/video.cpp | 34 ++ code/video.h | 3 + docs/UI_DESIGN.md | 53 +- manual/changes/imgui-overlays.md | 14 + .../commands/fixed-debug-benchmark-overlay.md | 11 + manual/content/systems/developer-mode.md | 10 +- manual/data/command-adapters.yaml | 9 + manual/data/commands.yaml | 13 + tests/uishell/uishell.cpp | 87 ++- 15 files changed, 1179 insertions(+), 70 deletions(-) create mode 100644 code/ui/uidev.cpp create mode 100644 code/ui/uidev.h create mode 100644 manual/changes/imgui-overlays.md create mode 100644 manual/content/commands/fixed-debug-benchmark-overlay.md diff --git a/code/CMakeLists.txt b/code/CMakeLists.txt index 94bc58009..38128c247 100644 --- a/code/CMakeLists.txt +++ b/code/CMakeLists.txt @@ -157,12 +157,22 @@ set(OPENTS_BGFX_SOURCES ) set_source_files_properties(${OPENTS_BGFX_SOURCES} PROPERTIES INCLUDE_DIRECTORIES - "${BGFX_ROOT}/include;${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bx/include;${BGFX_ROOT}/examples/common/imgui;${BGFX_ROOT}/examples/common/debugdraw" + "${BGFX_ROOT}/include;${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bx/include" COMPILE_DEFINITIONS "BX_CONFIG_DEBUG=$,1,$>" COMPILE_OPTIONS "/Zc:preprocessor" ) +# The frame quad's shaders come from the imgui example and the overlay's from the debug +# draw example. The imgui example directory also carries an imgui.h that would shadow Dear +# ImGui's, so only the file that needs those shaders sees it. +set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/bgfxbackend.cpp" APPEND PROPERTY + INCLUDE_DIRECTORIES "${BGFX_ROOT}/examples/common/imgui" +) +set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/ui/uirender.cpp" APPEND PROPERTY + INCLUDE_DIRECTORIES "${BGFX_ROOT}/examples/common/debugdraw" +) + # bx rewrites __stdcall while its headers are being parsed by clang-cl. Force the # compatibility header into the renderer translation units as well as bx/bgfx so the # MSVC standard-library headers that follow still see the Win32 calling convention. @@ -214,6 +224,7 @@ target_link_libraries(OpenTS PRIVATE bimg miniaudio RmlUi::Core + imgui comctl32 dbghelp iphlpapi diff --git a/code/ui/uidev.cpp b/code/ui/uidev.cpp new file mode 100644 index 000000000..14ffd1375 --- /dev/null +++ b/code/ui/uidev.cpp @@ -0,0 +1,574 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uidev.h" + +#include "_bench.h" +#include "bench.h" +#include "bench.hh" +#include "dbgprint.h" +#include "globals.h" +#include "logic.h" +#include "mono.h" +#include "mpu.h" +#include "ui/uirender.h" +#include "video.h" + +#include + +#include + + +static ImGuiContext * _Context = NULL; +static bool _Shown = false; +static bool _ShowDemo = false; +static long long _LastFrameTicks = 0; + +// The processor speed in megahertz, measured once when the window first opens; zero when +// the machine could not say. +static int _CPUSpeed = 0; + +// The benchmarks as they stood when they were last reset, so the table reports the second +// just gone the way the monochrome page does rather than a second half counted. +struct UIBenchmarkSample +{ + unsigned int Value; + unsigned int Count; +}; + +static UIBenchmarkSample _Samples[BENCH_COUNT]; +static bool _SamplesValid = false; +static bool _ResetEachSecond = true; +static unsigned int _LastSampleTime = 0; + +static ImGuiKey _KeyMap[256]; + + +struct UIDevKeyMapping +{ + int VirtualKey; + ImGuiKey Key; +}; + +static const UIDevKeyMapping _KeyMappings[] = { + { VK_TAB, ImGuiKey_Tab }, + { VK_LEFT, ImGuiKey_LeftArrow }, + { VK_RIGHT, ImGuiKey_RightArrow }, + { VK_UP, ImGuiKey_UpArrow }, + { VK_DOWN, ImGuiKey_DownArrow }, + { VK_PRIOR, ImGuiKey_PageUp }, + { VK_NEXT, ImGuiKey_PageDown }, + { VK_HOME, ImGuiKey_Home }, + { VK_END, ImGuiKey_End }, + { VK_INSERT, ImGuiKey_Insert }, + { VK_DELETE, ImGuiKey_Delete }, + { VK_BACK, ImGuiKey_Backspace }, + { VK_SPACE, ImGuiKey_Space }, + { VK_RETURN, ImGuiKey_Enter }, + { VK_ESCAPE, ImGuiKey_Escape }, + { VK_OEM_7, ImGuiKey_Apostrophe }, + { VK_OEM_COMMA, ImGuiKey_Comma }, + { VK_OEM_MINUS, ImGuiKey_Minus }, + { VK_OEM_PERIOD, ImGuiKey_Period }, + { VK_OEM_2, ImGuiKey_Slash }, + { VK_OEM_1, ImGuiKey_Semicolon }, + { VK_OEM_PLUS, ImGuiKey_Equal }, + { VK_OEM_4, ImGuiKey_LeftBracket }, + { VK_OEM_5, ImGuiKey_Backslash }, + { VK_OEM_6, ImGuiKey_RightBracket }, + { VK_OEM_3, ImGuiKey_GraveAccent }, + { VK_CAPITAL, ImGuiKey_CapsLock }, + { VK_SCROLL, ImGuiKey_ScrollLock }, + { VK_NUMLOCK, ImGuiKey_NumLock }, + { VK_SNAPSHOT, ImGuiKey_PrintScreen }, + { VK_PAUSE, ImGuiKey_Pause }, + { VK_NUMPAD0, ImGuiKey_Keypad0 }, + { VK_NUMPAD1, ImGuiKey_Keypad1 }, + { VK_NUMPAD2, ImGuiKey_Keypad2 }, + { VK_NUMPAD3, ImGuiKey_Keypad3 }, + { VK_NUMPAD4, ImGuiKey_Keypad4 }, + { VK_NUMPAD5, ImGuiKey_Keypad5 }, + { VK_NUMPAD6, ImGuiKey_Keypad6 }, + { VK_NUMPAD7, ImGuiKey_Keypad7 }, + { VK_NUMPAD8, ImGuiKey_Keypad8 }, + { VK_NUMPAD9, ImGuiKey_Keypad9 }, + { VK_DECIMAL, ImGuiKey_KeypadDecimal }, + { VK_DIVIDE, ImGuiKey_KeypadDivide }, + { VK_MULTIPLY, ImGuiKey_KeypadMultiply }, + { VK_SUBTRACT, ImGuiKey_KeypadSubtract }, + { VK_ADD, ImGuiKey_KeypadAdd }, + { VK_SHIFT, ImGuiKey_LeftShift }, + { VK_LSHIFT, ImGuiKey_LeftShift }, + { VK_RSHIFT, ImGuiKey_RightShift }, + { VK_CONTROL, ImGuiKey_LeftCtrl }, + { VK_LCONTROL, ImGuiKey_LeftCtrl }, + { VK_RCONTROL, ImGuiKey_RightCtrl }, + { VK_MENU, ImGuiKey_LeftAlt }, + { VK_LMENU, ImGuiKey_LeftAlt }, + { VK_RMENU, ImGuiKey_RightAlt }, + { VK_LWIN, ImGuiKey_LeftSuper }, + { VK_RWIN, ImGuiKey_RightSuper }, + { VK_APPS, ImGuiKey_Menu }, + { '0', ImGuiKey_0 }, + { '1', ImGuiKey_1 }, + { '2', ImGuiKey_2 }, + { '3', ImGuiKey_3 }, + { '4', ImGuiKey_4 }, + { '5', ImGuiKey_5 }, + { '6', ImGuiKey_6 }, + { '7', ImGuiKey_7 }, + { '8', ImGuiKey_8 }, + { '9', ImGuiKey_9 }, + { 'A', ImGuiKey_A }, + { 'B', ImGuiKey_B }, + { 'C', ImGuiKey_C }, + { 'D', ImGuiKey_D }, + { 'E', ImGuiKey_E }, + { 'F', ImGuiKey_F }, + { 'G', ImGuiKey_G }, + { 'H', ImGuiKey_H }, + { 'I', ImGuiKey_I }, + { 'J', ImGuiKey_J }, + { 'K', ImGuiKey_K }, + { 'L', ImGuiKey_L }, + { 'M', ImGuiKey_M }, + { 'N', ImGuiKey_N }, + { 'O', ImGuiKey_O }, + { 'P', ImGuiKey_P }, + { 'Q', ImGuiKey_Q }, + { 'R', ImGuiKey_R }, + { 'S', ImGuiKey_S }, + { 'T', ImGuiKey_T }, + { 'U', ImGuiKey_U }, + { 'V', ImGuiKey_V }, + { 'W', ImGuiKey_W }, + { 'X', ImGuiKey_X }, + { 'Y', ImGuiKey_Y }, + { 'Z', ImGuiKey_Z }, + { VK_F1, ImGuiKey_F1 }, + { VK_F2, ImGuiKey_F2 }, + { VK_F3, ImGuiKey_F3 }, + { VK_F4, ImGuiKey_F4 }, + { VK_F5, ImGuiKey_F5 }, + { VK_F6, ImGuiKey_F6 }, + { VK_F7, ImGuiKey_F7 }, + { VK_F8, ImGuiKey_F8 }, + { VK_F9, ImGuiKey_F9 }, + { VK_F10, ImGuiKey_F10 }, + { VK_F11, ImGuiKey_F11 }, + { VK_F12, ImGuiKey_F12 }, +}; + + +// The rows of the benchmark table, in the groups the monochrome Events page uses. Five of +// the counters are declared but never started anywhere in the engine. +struct UIBenchmarkRow +{ + BenchType Type; + char const * Group; + char const * Name; + bool Instrumented; +}; + +static const UIBenchmarkRow _Rows[] = { + { BENCH_FINDPATH, "Logic", "Find path", true }, + { BENCH_GREATEST_THREAT, "Logic", "Greatest threat", true }, + { BENCH_AI, "Logic", "Object AI", true }, + { BENCH_PCP, "Logic", "Per cell process", true }, + { BENCH_EVAL_OBJECT, "Logic", "Evaluate object", true }, + { BENCH_EVAL_CELL, "Logic", "Evaluate cell", true }, + { BENCH_EVAL_WALL, "Logic", "Evaluate wall", true }, + { BENCH_MISSION, "Logic", "Mission list", true }, + { BENCH_CELL, "Map objects", "Cell drawing", true }, + { BENCH_OBJECTS, "Map objects", "Object drawing", false }, + { BENCH_ANIMS, "Map objects", "Animations", true }, + { BENCH_PALETTE, "Palette", "Color cycling", false }, + { BENCH_GSCREEN_RENDER, "Presentation", "Screen render", true }, + { BENCH_SIDEBAR, "Presentation", "Sidebar cameos", true }, + { BENCH_RADAR, "Presentation", "Radar", false }, + { BENCH_TACTICAL, "Presentation", "Tactical map", false }, + { BENCH_POWER, "Presentation", "Power bar", true }, + { BENCH_SHROUD, "Presentation", "Shroud", false }, + { BENCH_TABS, "Presentation", "Tabs", true }, + { BENCH_BLIT_DISPLAY, "Presentation", "Blit to display", true }, +}; + + +static void Build_Key_Map(void) +{ + for (int code = 0; code < 256; code++) { + _KeyMap[code] = ImGuiKey_None; + } + + for (UIDevKeyMapping const & mapping : _KeyMappings) { + _KeyMap[mapping.VirtualKey] = mapping.Key; + } +} + + +// The aggregate modifier a sided key contributes to; ImGui wants both submitted. +static ImGuiKey Modifier_Of(ImGuiKey key) +{ + switch (key) { + case ImGuiKey_LeftShift: + case ImGuiKey_RightShift: + return(ImGuiMod_Shift); + + case ImGuiKey_LeftCtrl: + case ImGuiKey_RightCtrl: + return(ImGuiMod_Ctrl); + + case ImGuiKey_LeftAlt: + case ImGuiKey_RightAlt: + return(ImGuiMod_Alt); + + case ImGuiKey_LeftSuper: + case ImGuiKey_RightSuper: + return(ImGuiMod_Super); + + default: + return(ImGuiKey_None); + } +} + + +static bool Snapshots_In_Use(void) +{ + return(_ResetEachSecond && !MonoClass::Is_Enabled()); +} + + +// Copies every counter aside and starts the next second, leaving the load timings alone. +static void Sample_Benchmarks(void) +{ + for (int index = BENCH_FIRST; index < BENCH_COUNT; index++) { + _Samples[index].Value = Benches[index].Value(); + _Samples[index].Count = Benches[index].Count(); + if (index != BENCH_RULES && index != BENCH_SCENARIO) { + Benches[index].Reset(); + } + } + + _SamplesValid = true; + _LastSampleTime = timeGetTime(); +} + + +static UIBenchmarkSample Read_Benchmark(BenchType type) +{ + if (Snapshots_In_Use() && _SamplesValid) { + return(_Samples[type]); + } + + UIBenchmarkSample sample; + sample.Value = Benches[type].Value(); + sample.Count = Benches[type].Count(); + return(sample); +} + + +// An average in microseconds when the processor speed is known; the raw RDTSC/16 ticks +// otherwise. +static void Format_Average(char * buffer, size_t size, unsigned int ticks) +{ + if (_CPUSpeed > 0) { + std::snprintf(buffer, size, "%.1f", (double)ticks * 16.0 / (double)_CPUSpeed); + } else { + std::snprintf(buffer, size, "%u", ticks); + } +} + + +static void Draw_Benchmark_Table(void) +{ + UIBenchmarkSample frame = Read_Benchmark(BENCH_GAME_FRAME); + double frametotal = (double)frame.Value * (double)frame.Count; + + if (!ImGui::BeginTable("benchmarks", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + return; + } + + ImGui::TableSetupColumn("Process"); + ImGui::TableSetupColumn("Frame %"); + ImGui::TableSetupColumn(_CPUSpeed > 0 ? "Average (us)" : "Average (ticks)"); + ImGui::TableSetupColumn("Samples"); + ImGui::TableHeadersRow(); + + char const * group = NULL; + char average[32]; + + for (UIBenchmarkRow const & row : _Rows) { + if (group == NULL || strcmp(group, row.Group) != 0) { + group = row.Group; + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextDisabled("%s", group); + } + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::TextUnformatted(row.Name); + + if (!row.Instrumented) { + ImGui::TableNextColumn(); + ImGui::TextDisabled("not instrumented"); + continue; + } + + UIBenchmarkSample sample = Read_Benchmark(row.Type); + double own = (double)sample.Value * (double)sample.Count; + double percent = frametotal > 0.0 ? own * 100.0 / frametotal : 0.0; + if (percent > 100.0) { + percent = 100.0; + } + + ImGui::TableNextColumn(); + ImGui::Text("%.1f", percent); + ImGui::TableNextColumn(); + Format_Average(average, sizeof(average), sample.Value); + ImGui::TextUnformatted(average); + ImGui::TableNextColumn(); + ImGui::Text("%u", sample.Count); + } + + ImGui::EndTable(); +} + + +static void Draw_Benchmark_Window(void) +{ + ImGui::SetNextWindowPos(ImVec2(16.0f, 16.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(560.0f, 620.0f), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Frame benchmarks", &_Shown)) { + ImGui::End(); + return; + } + + ImGui::Text("Logic frames per second %u, frame %d", LastFramesPerSecond, Frame); + ImGui::Text("Presents per second %u, present interval %u ms", Video_Presents_Per_Second(), Video_Present_Interval()); + ImGui::Text("Overlay ticks per second %.0f", ImGui::GetIO().Framerate); + ImGui::Checkbox("Show the Dear ImGui demo window", &_ShowDemo); + ImGui::Separator(); + + if (Benches == NULL) { + ImGui::TextUnformatted("The benchmarks are compiled into Debug builds only."); + ImGui::End(); + return; + } + + if (ImGui::Button("Reset")) { + Sample_Benchmarks(); + } + ImGui::SameLine(); + ImGui::Checkbox("Reset every second", &_ResetEachSecond); + if (_ResetEachSecond && MonoClass::Is_Enabled()) { + ImGui::SameLine(); + ImGui::TextDisabled("(the monochrome display owns the reset while it is on)"); + } + + if (Snapshots_In_Use() && (!_SamplesValid || timeGetTime() - _LastSampleTime >= 1000)) { + Sample_Benchmarks(); + } + + Draw_Benchmark_Table(); + + char rules[32]; + char scenario[32]; + Format_Average(rules, sizeof(rules), Benches[BENCH_RULES].Value()); + Format_Average(scenario, sizeof(scenario), Benches[BENCH_SCENARIO].Value()); + ImGui::Text("Load times: rules %s, scenario %s", rules, scenario); + + ImGui::End(); +} + + +bool UIDev_Active(void) +{ + return(_Context != NULL && _Shown); +} + + +void UIDev_Toggle(UIRenderInterfaceClass const & render) +{ + if (_Context == NULL) { + IMGUI_CHECKVERSION(); + _Context = ImGui::CreateContext(); + + ImGuiIO & io = ImGui::GetIO(); + io.IniFilename = NULL; + io.LogFilename = NULL; + io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures | ImGuiBackendFlags_RendererHasVtxOffset; + io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; + io.BackendPlatformName = "OpenTS shell"; + io.BackendRendererName = "OpenTS bgfx"; + + ImGuiPlatformIO & platform = ImGui::GetPlatformIO(); + platform.Renderer_TextureMaxWidth = render.Texture_Limit(); + platform.Renderer_TextureMaxHeight = render.Texture_Limit(); + + Build_Key_Map(); + _CPUSpeed = Get_RDTSC_CPU_Speed(); + _LastFrameTicks = 0; + _SamplesValid = false; + + DebugString("UI: Dear ImGui %s context created, processor %d MHz\n", ImGui::GetVersion(), _CPUSpeed); + } + + _Shown = !_Shown; + render.Log_Resource_Counts(_Shown ? "developer overlay shown" : "developer overlay hidden"); +} + + +// One tick is one ImGui frame. The scale can change between frames, so the display size +// and the font scale follow the frame's destination every time. +void UIDev_Tick(void) +{ + if (!UIDev_Active()) { + return; + } + + VideoScaleInfo const & scale = Video_Get_Scale_Info(); + if (scale.DestWidth <= 0 || scale.DestHeight <= 0) { + return; + } + + float ratio = scale.ScaleX < scale.ScaleY ? scale.ScaleX : scale.ScaleY; + if (ratio <= 0.0f) { + ratio = 1.0f; + } + + LARGE_INTEGER now; + LARGE_INTEGER frequency; + QueryPerformanceCounter(&now); + QueryPerformanceFrequency(&frequency); + float delta = (_LastFrameTicks == 0 || frequency.QuadPart == 0) ? (1.0f / 60.0f) : (float)(now.QuadPart - _LastFrameTicks) / (float)frequency.QuadPart; + if (delta < 0.0001f) { + delta = 0.0001f; + } + _LastFrameTicks = now.QuadPart; + + ImGuiIO & io = ImGui::GetIO(); + io.DisplaySize = ImVec2((float)scale.DestWidth, (float)scale.DestHeight); + io.DeltaTime = delta; + ImGui::GetStyle().FontScaleDpi = ratio; + + ImGui::NewFrame(); + Draw_Benchmark_Window(); + if (_ShowDemo) { + ImGui::ShowDemoWindow(&_ShowDemo); + } + ImGui::Render(); +} + + +void UIDev_Render(UIRenderInterfaceClass & render) +{ + if (!UIDev_Active()) { + return; + } + + render.Render_ImGui(ImGui::GetDrawData()); +} + + +void UIDev_Shutdown(UIRenderInterfaceClass & render) +{ + if (_Context == NULL) { + return; + } + + render.Destroy_ImGui_Textures(); + ImGui::DestroyContext(_Context); + _Context = NULL; + _Shown = false; + _ShowDemo = false; +} + + +void UIDev_Mouse_Position(int x, int y) +{ + if (!UIDev_Active()) { + return; + } + + ImGui::GetIO().AddMousePosEvent((float)x, (float)y); +} + + +bool UIDev_Mouse_Button(int button, bool down) +{ + if (!UIDev_Active()) { + return(false); + } + + ImGuiIO & io = ImGui::GetIO(); + io.AddMouseButtonEvent(button, down); + return(io.WantCaptureMouse); +} + + +bool UIDev_Mouse_Wheel(float delta) +{ + if (!UIDev_Active()) { + return(false); + } + + ImGuiIO & io = ImGui::GetIO(); + io.AddMouseWheelEvent(0.0f, delta); + return(io.WantCaptureMouse); +} + + +bool UIDev_Key(WPARAM virtualkey, bool down) +{ + if (!UIDev_Active()) { + return(false); + } + + ImGuiIO & io = ImGui::GetIO(); + ImGuiKey key = _KeyMap[virtualkey & 0xFF]; + if (key != ImGuiKey_None) { + ImGuiKey modifier = Modifier_Of(key); + if (modifier != ImGuiKey_None) { + io.AddKeyEvent(modifier, down); + } + io.AddKeyEvent(key, down); + } + + return(io.WantCaptureKeyboard); +} + + +bool UIDev_Character(wchar_t unit) +{ + if (!UIDev_Active()) { + return(false); + } + + ImGuiIO & io = ImGui::GetIO(); + io.AddInputCharacterUTF16((ImWchar16)unit); + return(io.WantTextInput); +} + + +void UIDev_Focus(bool focused) +{ + if (_Context == NULL) { + return; + } + + ImGui::GetIO().AddFocusEvent(focused); +} + + +bool UIDev_Wants_Mouse(void) +{ + return(UIDev_Active() && ImGui::GetIO().WantCaptureMouse); +} diff --git a/code/ui/uidev.h b/code/ui/uidev.h new file mode 100644 index 000000000..994e4c3bc --- /dev/null +++ b/code/ui/uidev.h @@ -0,0 +1,33 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "win.h" + +class UIRenderInterfaceClass; + + +// The Dear ImGui developer overlays. The context is created on the first toggle, so a +// build whose developer keys never arm allocates nothing here. +bool UIDev_Active(void); +void UIDev_Toggle(UIRenderInterfaceClass const & render); +void UIDev_Tick(void); +void UIDev_Render(UIRenderInterfaceClass & render); +void UIDev_Shutdown(UIRenderInterfaceClass & render); + +// Input reaches the overlays before the documents and the game. A true return means the +// overlays want that message kept from both. +void UIDev_Mouse_Position(int x, int y); +bool UIDev_Mouse_Button(int button, bool down); +bool UIDev_Mouse_Wheel(float delta); +bool UIDev_Key(WPARAM virtualkey, bool down); +bool UIDev_Character(wchar_t unit); +void UIDev_Focus(bool focused); +bool UIDev_Wants_Mouse(void); diff --git a/code/ui/uirender.cpp b/code/ui/uirender.cpp index a3e97135c..6b2bf5145 100644 --- a/code/ui/uirender.cpp +++ b/code/ui/uirender.cpp @@ -7,7 +7,7 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -// The bgfx side of the UI overlay. With bgfxbackend.cpp it is one of the two translation +// The bgfx side of the UI overlays. With bgfxbackend.cpp it is one of the two translation // units that include bgfx. #include "ui/uirender.h" @@ -23,6 +23,8 @@ #include #include +#include + #include #include #include @@ -36,7 +38,9 @@ static const bgfx::EmbeddedShader _EmbeddedShaders[] = { BGFX_EMBEDDED_SHADER_END() }; +// RmlUi and Dear ImGui order their vertex members differently. static bgfx::VertexLayout _VertexLayout; +static bgfx::VertexLayout _DevVertexLayout; // A compiled document fragment, submitted many times with different translations. @@ -47,8 +51,8 @@ struct UIGeometry }; -// RmlUi reads a zero handle as no texture, and bgfx hands out index zero, so texture -// handles cross the boundary biased by one. +// RmlUi and Dear ImGui both read a zero handle as no texture, and bgfx hands out index +// zero, so texture handles cross either boundary biased by one. static bgfx::TextureHandle Texture_Handle(Rml::TextureHandle handle) { bgfx::TextureHandle texture = { (uint16_t)(handle - 1) }; @@ -66,7 +70,8 @@ UIRenderInterfaceClass::UIRenderInterfaceClass(void) : ViewWidth(0), ViewHeight(0), ScissorEnabled(false), - Scissor(Rml::Rectanglei::MakeInvalid()) + Scissor(Rml::Rectanglei::MakeInvalid()), + DevShortageLogged(false) { } @@ -83,6 +88,12 @@ bool UIRenderInterfaceClass::Init(void) .add(bgfx::Attrib::TexCoord0, 2, bgfx::AttribType::Float) .end(); + _DevVertexLayout.begin() + .add(bgfx::Attrib::Position, 2, bgfx::AttribType::Float) + .add(bgfx::Attrib::TexCoord0, 2, bgfx::AttribType::Float) + .add(bgfx::Attrib::Color0, 4, bgfx::AttribType::Uint8, true) + .end(); + bgfx::RendererType::Enum type = bgfx::getRendererType(); bgfx::ShaderHandle vertexshader = bgfx::createEmbeddedShader(_EmbeddedShaders, type, "vs_debugdraw_fill_texture"); bgfx::ShaderHandle fragmentshader = bgfx::createEmbeddedShader(_EmbeddedShaders, type, "fs_debugdraw_fill_texture"); @@ -148,8 +159,8 @@ void UIRenderInterfaceClass::Shutdown(void) // View state persists across frames and resets, and the prescale pass binds a framebuffer -// to a lower view, so everything the overlay relies on is set again each frame. -void UIRenderInterfaceClass::Begin_Frame(int x, int y, int width, int height) +// to a lower view, so everything the overlays rely on is set again each frame. +void UIRenderInterfaceClass::Set_View(unsigned short view, int x, int y, int width, int height) { ViewX = x; ViewY = y; @@ -159,10 +170,32 @@ void UIRenderInterfaceClass::Begin_Frame(int x, int y, int width, int height) float projection[16]; Backend_Build_Ortho_Projection(projection, width, height); - bgfx::setViewFrameBuffer(VIEW_UI, BGFX_INVALID_HANDLE); - bgfx::setViewMode(VIEW_UI, bgfx::ViewMode::Sequential); - bgfx::setViewRect(VIEW_UI, (uint16_t)x, (uint16_t)y, (uint16_t)width, (uint16_t)height); - bgfx::setViewTransform(VIEW_UI, NULL, projection); + bgfx::setViewFrameBuffer(view, BGFX_INVALID_HANDLE); + bgfx::setViewMode(view, bgfx::ViewMode::Sequential); + bgfx::setViewRect(view, (uint16_t)x, (uint16_t)y, (uint16_t)width, (uint16_t)height); + bgfx::setViewTransform(view, NULL, projection); +} + + +void UIRenderInterfaceClass::Begin_Frame(int x, int y, int width, int height) +{ + Set_View(VIEW_UI, x, y, width, height); +} + + +void UIRenderInterfaceClass::Begin_Dev_Frame(int x, int y, int width, int height) +{ + Set_View(VIEW_DEV, x, y, width, height); +} + + +int UIRenderInterfaceClass::Texture_Limit(void) const +{ + if (!IsReady) { + return(0); + } + + return((int)bgfx::getCaps()->limits.maxTextureSize); } @@ -349,3 +382,147 @@ bool UIRenderInterfaceClass::Apply_Scissor(void) const bgfx::setScissor((uint16_t)left, (uint16_t)top, (uint16_t)(right - left), (uint16_t)(bottom - top)); return(true); } + + +// Dear ImGui asks for its textures through status requests; each is answered here and +// acknowledged, and a destroyed texture keeps its pixels so that ImGui can ask again. +void UIRenderInterfaceClass::Update_ImGui_Texture(ImTextureData * texture) +{ + if (texture->Status == ImTextureStatus_WantCreate) { + assert(texture->Format == ImTextureFormat_RGBA32); + + // A texture created with its pixels is immutable in bgfx, and the atlas keeps growing. + bgfx::TextureHandle handle = bgfx::createTexture2D((uint16_t)texture->Width, (uint16_t)texture->Height, false, 1, bgfx::TextureFormat::RGBA8, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); + if (!bgfx::isValid(handle)) { + DebugString("UI: a %dx%d overlay texture could not be created\n", texture->Width, texture->Height); + return; + } + + bgfx::updateTexture2D(handle, 0, 0, 0, 0, (uint16_t)texture->Width, (uint16_t)texture->Height, bgfx::copy(texture->GetPixels(), (uint32_t)texture->GetSizeInBytes())); + texture->SetTexID((ImTextureID)handle.idx + 1); + texture->SetStatus(ImTextureStatus_OK); + } else if (texture->Status == ImTextureStatus_WantUpdates) { + bgfx::TextureHandle handle = { (uint16_t)(texture->TexID - 1) }; + int pitch = texture->GetPitch(); + + for (ImTextureRect const & rect : texture->Updates) { + uint32_t size = (uint32_t)((rect.h - 1) * pitch + rect.w * texture->BytesPerPixel); + bgfx::updateTexture2D(handle, 0, 0, rect.x, rect.y, rect.w, rect.h, bgfx::copy(texture->GetPixelsAt(rect.x, rect.y), size), (uint16_t)pitch); + } + + texture->SetStatus(ImTextureStatus_OK); + } + + if (texture->Status == ImTextureStatus_WantDestroy && texture->UnusedFrames > 0) { + if (texture->TexID != ImTextureID_Invalid) { + bgfx::TextureHandle handle = { (uint16_t)(texture->TexID - 1) }; + bgfx::destroy(handle); + texture->SetTexID(ImTextureID_Invalid); + } + texture->SetStatus(ImTextureStatus_Destroyed); + } +} + + +void UIRenderInterfaceClass::Destroy_ImGui_Textures(void) +{ + for (ImTextureData * texture : ImGui::GetPlatformIO().Textures) { + if (texture->TexID != ImTextureID_Invalid) { + bgfx::TextureHandle handle = { (uint16_t)(texture->TexID - 1) }; + bgfx::destroy(handle); + texture->SetTexID(ImTextureID_Invalid); + } + texture->SetStatus(ImTextureStatus_Destroyed); + } +} + + +// ImGui rebuilds its geometry every frame, so it travels in transient buffers; its colours +// carry straight alpha, unlike the premultiplied documents. +void UIRenderInterfaceClass::Render_ImGui(ImDrawData * data) +{ + if (!IsReady || data == NULL || !data->Valid || data->DisplaySize.x <= 0.0f || data->DisplaySize.y <= 0.0f) { + return; + } + + if (data->Textures != NULL) { + for (ImTextureData * texture : *data->Textures) { + if (texture->Status != ImTextureStatus_OK) { + Update_ImGui_Texture(texture); + } + } + } + + float identity[16]; + memset(identity, 0, sizeof(identity)); + identity[0] = 1.0f; + identity[5] = 1.0f; + identity[10] = 1.0f; + identity[15] = 1.0f; + + bgfx::UniformHandle sampler = { Sampler }; + bgfx::ProgramHandle program = { Program }; + ImDrawCallback resetstate = ImGui::GetPlatformIO().DrawCallback_ResetRenderState; + + for (ImDrawList const * list : data->CmdLists) { + uint32_t vertexcount = (uint32_t)list->VtxBuffer.Size; + uint32_t indexcount = (uint32_t)list->IdxBuffer.Size; + if (vertexcount == 0 || indexcount == 0) { + continue; + } + + if (bgfx::getAvailTransientVertexBuffer(vertexcount, _DevVertexLayout) < vertexcount || bgfx::getAvailTransientIndexBuffer(indexcount) < indexcount) { + if (!DevShortageLogged) { + DebugString("UI: an overlay draw list did not fit the transient buffers and was skipped\n"); + DevShortageLogged = true; + } + continue; + } + + bgfx::TransientVertexBuffer vertices; + bgfx::TransientIndexBuffer indices; + bgfx::allocTransientVertexBuffer(&vertices, vertexcount, _DevVertexLayout); + bgfx::allocTransientIndexBuffer(&indices, indexcount); + memcpy(vertices.data, list->VtxBuffer.Data, vertexcount * sizeof(ImDrawVert)); + memcpy(indices.data, list->IdxBuffer.Data, indexcount * sizeof(ImDrawIdx)); + + for (ImDrawCmd const & command : list->CmdBuffer) { + if (command.UserCallback != NULL) { + if (command.UserCallback != resetstate) { + command.UserCallback(list, &command); + } + continue; + } + if (command.ElemCount == 0) { + continue; + } + + int left = ViewX + (int)(command.ClipRect.x - data->DisplayPos.x); + int top = ViewY + (int)(command.ClipRect.y - data->DisplayPos.y); + int right = ViewX + (int)(command.ClipRect.z - data->DisplayPos.x); + int bottom = ViewY + (int)(command.ClipRect.w - data->DisplayPos.y); + + if (left < ViewX) left = ViewX; + if (top < ViewY) top = ViewY; + if (right > ViewX + ViewWidth) right = ViewX + ViewWidth; + if (bottom > ViewY + ViewHeight) bottom = ViewY + ViewHeight; + if (right <= left || bottom <= top) { + continue; + } + + bgfx::TextureHandle sampled = { WhiteTexture }; + ImTextureID id = command.GetTexID(); + if (id != ImTextureID_Invalid) { + sampled.idx = (uint16_t)(id - 1); + } + + bgfx::setScissor((uint16_t)left, (uint16_t)top, (uint16_t)(right - left), (uint16_t)(bottom - top)); + bgfx::setTransform(identity); + bgfx::setVertexBuffer(0, &vertices, command.VtxOffset, vertexcount - command.VtxOffset); + bgfx::setIndexBuffer(&indices, command.IdxOffset, command.ElemCount); + bgfx::setTexture(0, sampler, sampled, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); + bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A | BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_SRC_ALPHA, BGFX_STATE_BLEND_INV_SRC_ALPHA, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA)); + bgfx::submit(VIEW_DEV, program); + } + } +} diff --git a/code/ui/uirender.h b/code/ui/uirender.h index 7f9c59752..2c90823d4 100644 --- a/code/ui/uirender.h +++ b/code/ui/uirender.h @@ -11,11 +11,14 @@ #include +struct ImDrawData; +struct ImTextureData; -// Draws RmlUi geometry through bgfx into the overlay view. Init needs the renderer running; -// Shutdown comes after Rml::Shutdown, which releases every texture and geometry through -// this object, and before the renderer stops. bgfx handles are kept as their indices so -// that no bgfx type appears here. + +// Draws RmlUi geometry and Dear ImGui frames through bgfx into the overlay views. Init +// needs the renderer running; Shutdown comes after Rml::Shutdown, which releases every +// texture and geometry through this object, and before the renderer stops. bgfx handles +// are kept as their indices so that no bgfx type appears here. class UIRenderInterfaceClass : public Rml::RenderInterface { public: @@ -24,9 +27,22 @@ class UIRenderInterfaceClass : public Rml::RenderInterface bool Init(void); void Shutdown(void); - // Points the overlay view at the frame's destination rectangle, in window pixels. + // Points the document view at the frame's destination rectangle, in window pixels. void Begin_Frame(int x, int y, int width, int height); + // Points the developer view at the same rectangle. + void Begin_Dev_Frame(int x, int y, int width, int height); + + // Draws one Dear ImGui frame into the developer view, creating, updating and + // destroying its textures as it asks. + void Render_ImGui(ImDrawData * data); + + // Destroys every texture Dear ImGui still holds. Called before its context goes. + void Destroy_ImGui_Textures(void); + + // The largest texture edge the renderer accepts. + int Texture_Limit(void) const; + // Writes the renderer's live texture and buffer counts to the debug log. void Log_Resource_Counts(char const * when) const; @@ -42,7 +58,9 @@ class UIRenderInterfaceClass : public Rml::RenderInterface virtual void SetScissorRegion(Rml::Rectanglei region) override; private: + void Set_View(unsigned short view, int x, int y, int width, int height); bool Apply_Scissor(void) const; + void Update_ImGui_Texture(ImTextureData * texture); bool IsReady; unsigned short Program; @@ -56,4 +74,6 @@ class UIRenderInterfaceClass : public Rml::RenderInterface bool ScissorEnabled; Rml::Rectanglei Scissor; + + bool DevShortageLogged; }; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 0bb42e9e5..4f8f66c3f 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -13,6 +13,7 @@ #include "globals.h" #include "movies.h" #include "ui/uicoord.h" +#include "ui/uidev.h" #include "ui/uifile.h" #include "ui/uirender.h" #include "ui/uisystem.h" @@ -43,10 +44,13 @@ static bool _InTick = false; static bool _PendingResize = false; static bool _PendingLeave = false; static bool _PendingRelease = false; +static int _PendingDevFocus = -1; // The presses the shell consumed, as a mask over the mouse button indices, and whether it // took the window's capture for them. Their releases belong to the shell wherever they land. +// The developer overlays' own presses are a subset that their release goes back to. static unsigned int _OwnedButtons = 0; +static unsigned int _DevOwnedButtons = 0; static bool _TookCapture = false; static bool _MouseInside = false; @@ -119,9 +123,11 @@ static const UIKeyMapping _KeyMappings[] = { #ifdef _DEBUG -// The test document is a developer's check of the shell; F9 shows and hides it. +// The test document is a developer's check of the shell; F9 shows and hides it, and F6 the +// developer overlays. static Rml::ElementDocument * _TestDocument = NULL; static bool _PendingToggle = false; +static bool _PendingDevToggle = false; static bool _CloseRequested = false; class UITestListenerClass : public Rml::EventListener @@ -241,10 +247,14 @@ static UIPointerPosition Pointer_Position(LPARAM clientlparam) static void Drop_Presses(void) { unsigned int owned = _OwnedButtons; + unsigned int devowned = _DevOwnedButtons; _OwnedButtons = 0; + _DevOwnedButtons = 0; for (int button = 0; button < 3; button++) { - if (owned & (1u << button)) { + if (devowned & (1u << button)) { + UIDev_Mouse_Button(button, false); + } else if (owned & (1u << button)) { _Context->ProcessMouseButtonUp(button, Key_Modifiers()); } } @@ -352,9 +362,12 @@ void UI_Shutdown(void) Drop_Presses(); } + UIDev_Shutdown(_Render); + #ifdef _DEBUG _TestDocument = NULL; _PendingToggle = false; + _PendingDevToggle = false; _CloseRequested = false; #endif @@ -396,6 +409,11 @@ void UI_Tick(void) _PendingToggle = false; Toggle_Test_Document(); } + if (_PendingDevToggle) { + _PendingDevToggle = false; + UIDev_Toggle(_Render); + Video_Mark_Overlay_Dirty(); + } if (_CloseRequested) { _CloseRequested = false; if (_TestDocument != NULL && _TestDocument->IsVisible()) { @@ -419,14 +437,23 @@ void UI_Tick(void) _Context->ProcessMouseLeave(); _MouseInside = false; } + if (_PendingDevFocus >= 0) { + UIDev_Focus(_PendingDevFocus != 0); + _PendingDevFocus = -1; + } _InContext = true; _Context->Update(); + UIDev_Tick(); _InContext = false; - if (Documents_Visible()) { + // An overlay closed from inside its own frame still needs one present to clear. + static bool devwasactive = false; + bool devactive = UIDev_Active(); + if (Documents_Visible() || devactive || devwasactive) { Video_Mark_Overlay_Dirty(); } + devwasactive = devactive; _InTick = false; } @@ -434,7 +461,13 @@ void UI_Tick(void) void UI_Render_Overlay(void) { - if (!_Ready || _InContext || Movie_Is_Playing() || !Documents_Visible()) { + if (!_Ready || _InContext || Movie_Is_Playing()) { + return; + } + + bool documents = Documents_Visible(); + bool overlays = UIDev_Active(); + if (!documents && !overlays) { return; } @@ -443,11 +476,18 @@ void UI_Render_Overlay(void) return; } - _Render.Begin_Frame(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight); + if (documents) { + _Render.Begin_Frame(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight); - _InContext = true; - _Context->Render(); - _InContext = false; + _InContext = true; + _Context->Render(); + _InContext = false; + } + + if (overlays) { + _Render.Begin_Dev_Frame(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight); + UIDev_Render(_Render); + } } @@ -455,6 +495,12 @@ static bool Handle_Mouse_Move(LPARAM clientlparam) { UIPointerPosition position = Pointer_Position(clientlparam); + UIDev_Mouse_Position(position.X, position.Y); + if (UIDev_Wants_Mouse()) { + Video_Mark_Overlay_Dirty(); + return(false); + } + if (_OwnedButtons != 0 || position.Inside) { _Context->ProcessMouseMove(position.X, position.Y, Key_Modifiers()); _MouseInside = position.Inside; @@ -469,10 +515,45 @@ static bool Handle_Mouse_Move(LPARAM clientlparam) } +static void Own_Press(int button) +{ + if (_OwnedButtons == 0) { + _TookCapture = (GetCapture() != MainWindow); + if (_TookCapture) { + SetCapture(MainWindow); + } + } + _OwnedButtons |= (1u << button); +} + + +static void Release_Press(int button) +{ + _OwnedButtons &= ~(1u << button); + _DevOwnedButtons &= ~(1u << button); + if (_OwnedButtons == 0 && _TookCapture) { + _TookCapture = false; + if (GetCapture() == MainWindow) { + ReleaseCapture(); + } + } +} + + static bool Handle_Button_Down(int button, LPARAM clientlparam) { UIPointerPosition position = Pointer_Position(clientlparam); + if (UIDev_Active()) { + UIDev_Mouse_Position(position.X, position.Y); + if (UIDev_Mouse_Button(button, true)) { + Own_Press(button); + _DevOwnedButtons |= (1u << button); + Video_Mark_Overlay_Dirty(); + return(true); + } + } + if (!position.Inside && _OwnedButtons == 0) { return(false); } @@ -488,24 +569,32 @@ static bool Handle_Button_Down(int button, LPARAM clientlparam) return(false); } - if (_OwnedButtons == 0) { - _TookCapture = (GetCapture() != MainWindow); - if (_TookCapture) { - SetCapture(MainWindow); - } - } - _OwnedButtons |= (1u << button); + Own_Press(button); return(true); } static bool Handle_Button_Up(int button, LPARAM clientlparam) { + UIPointerPosition position = Pointer_Position(clientlparam); + + if (_DevOwnedButtons & (1u << button)) { + UIDev_Mouse_Position(position.X, position.Y); + UIDev_Mouse_Button(button, false); + Release_Press(button); + Video_Mark_Overlay_Dirty(); + return(true); + } + + // A release the overlays did not own still ends the press they saw begin. + if (UIDev_Active()) { + UIDev_Mouse_Button(button, false); + } + if ((_OwnedButtons & (1u << button)) == 0) { return(false); } - UIPointerPosition position = Pointer_Position(clientlparam); int modifiers = Key_Modifiers(); _Context->ProcessMouseMove(position.X, position.Y, modifiers); @@ -513,14 +602,7 @@ static bool Handle_Button_Up(int button, LPARAM clientlparam) _MouseInside = position.Inside; Video_Mark_Overlay_Dirty(); - _OwnedButtons &= ~(1u << button); - if (_OwnedButtons == 0 && _TookCapture) { - _TookCapture = false; - if (GetCapture() == MainWindow) { - ReleaseCapture(); - } - } - + Release_Press(button); return(true); } @@ -534,12 +616,23 @@ static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) VideoScaleInfo const & scale = Video_Get_Scale_Info(); UIPointerPosition position = UI_Client_To_Overlay(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight, point.x, point.y); + + // Windows counts wheel movement away from the user as positive; ImGui scrolls up for it + // and RmlUi scrolls down. + float delta = (float)(short)HIWORD(wparam) / (float)WHEEL_DELTA; + + if (UIDev_Active()) { + UIDev_Mouse_Position(position.X, position.Y); + if (UIDev_Mouse_Wheel(delta)) { + Video_Mark_Overlay_Dirty(); + return(true); + } + } + if (!position.Inside) { return(false); } - // Windows counts wheel movement away from the user as positive; RmlUi scrolls down for it. - float delta = (float)(short)HIWORD(wparam) / (float)WHEEL_DELTA; bool consumed = !_Context->ProcessMouseWheel(Rml::Vector2f(0.0f, -delta), Key_Modifiers()); Video_Mark_Overlay_Dirty(); return(consumed); @@ -548,6 +641,11 @@ static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) static bool Handle_Key(UINT message, WPARAM wparam) { + if (UIDev_Key(wparam, message == WM_KEYDOWN)) { + Video_Mark_Overlay_Dirty(); + return(true); + } + Rml::Input::KeyIdentifier key = _KeyMap[wparam & 0xFF]; if (key == Rml::Input::KI_UNKNOWN) { return(false); @@ -571,6 +669,11 @@ static bool Handle_Char(WPARAM wparam) { wchar_t unit = (wchar_t)wparam; + if (UIDev_Character(unit)) { + Video_Mark_Overlay_Dirty(); + return(true); + } + if (unit >= 0xD800 && unit < 0xDC00) { _HighSurrogate = unit; return(false); @@ -617,6 +720,11 @@ bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM cli } if (message == WM_ACTIVATEAPP) { + if (_InContext) { + _PendingDevFocus = (wparam != 0) ? 1 : 0; + } else { + UIDev_Focus(wparam != 0); + } if (wparam == 0 && _MouseInside) { if (_InContext) { _PendingLeave = true; @@ -630,7 +738,7 @@ bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM cli return(false); } - if (_InContext || (_OwnedButtons == 0 && !Documents_Visible())) { + if (_InContext || (_OwnedButtons == 0 && !Documents_Visible() && !UIDev_Active())) { return(false); } @@ -700,6 +808,12 @@ bool UI_Intercept_Pumped_Message(MSG const & msg) } return(true); } + if (_Ready && Debug_Flag && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F6) { + if (msg.message == WM_KEYDOWN && (msg.lParam & (1 << 30)) == 0) { + _PendingDevToggle = true; + } + return(true); + } #else (void)msg; #endif diff --git a/code/video.cpp b/code/video.cpp index 6f4cf8e49..179c8454c 100644 --- a/code/video.cpp +++ b/code/video.cpp @@ -57,6 +57,12 @@ static bool _OverlayIsDirty = false; static unsigned int _LastPresentTime = 0; static unsigned int _PresentInterval = 16; +// Presents in the second under way and in the whole second before it, for the developer +// overlays. +static unsigned int _PresentsThisSecond = 0; +static unsigned int _PresentsLastSecond = 0; +static unsigned int _PresentSecondStart = 0; + // Presents can nest, because a dialog repainting itself presents from inside the paint // that the engine's own present provoked. static bool _Presenting = false; @@ -192,6 +198,9 @@ void Video_Shutdown(void) _Initialized = false; _FrameIsDirty = false; _OverlayIsDirty = false; + _PresentsThisSecond = 0; + _PresentsLastSecond = 0; + _PresentSecondStart = 0; } @@ -298,6 +307,13 @@ static void Present(bool uploadframe) _OverlayIsDirty = false; _LastPresentTime = timeGetTime(); + if (_LastPresentTime - _PresentSecondStart >= 1000) { + _PresentsLastSecond = _PresentsThisSecond; + _PresentsThisSecond = 0; + _PresentSecondStart = _LastPresentTime; + } + _PresentsThisSecond++; + _Presenting = true; if (Backend_Present(uploadframe ? pixels : NULL, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode())) { UI_Render_Overlay(); @@ -346,6 +362,24 @@ VideoScaleInfo const & Video_Get_Scale_Info(void) } +/// +/// Reports how many frames reached the screen in the last whole second. +/// +unsigned int Video_Presents_Per_Second(void) +{ + return(_PresentsLastSecond); +} + + +/// +/// Reports the shortest gap the presenter allows between two frames, in milliseconds. +/// +unsigned int Video_Present_Interval(void) +{ + return(_PresentInterval); +} + + /// /// Compares two display modes by width and then height. /// diff --git a/code/video.h b/code/video.h index 946f8ea8f..a3e624c59 100644 --- a/code/video.h +++ b/code/video.h @@ -52,4 +52,7 @@ void Video_Present_If_Dirty(void); VideoScaleInfo const & Video_Get_Scale_Info(void); +unsigned int Video_Presents_Per_Second(void); +unsigned int Video_Present_Interval(void); + int * EnumDisplayModes(int minwidth, int minheight, int maxwidth, int maxheight); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index fcd6d3885..6d34ad852 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,10 +1,10 @@ # UI system design Status: proposal under implementation. Steps 1 and 2 of the -[migration plan](#migration-plan), the dependencies and the RmlUi shell, have -landed; the Dear ImGui half of step 2 and everything after it are not yet -implemented, built, or measured. Source inspection and upstream documentation -inform the rest. This page owns the proposed UI architecture and migration; +[migration plan](#migration-plan), the dependencies, the RmlUi shell, and the +Dear ImGui overlays, have landed; everything after them is not yet implemented, +built, or measured. Source inspection and upstream documentation inform the +rest. This page owns the proposed UI architecture and migration; [Building OpenTS](BUILDING.md) owns build support and [Project direction](DIRECTION.md) the wider architecture. @@ -185,13 +185,13 @@ written. | --- | --- | --- | | `bgfxviews.hh` (in `code/`) | the view ids the presenter and the overlays share | landed | | `uishell.h`, `uishell.cpp` | init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector | landed without the modal runner and selector | -| `uirender.h`, `uirender.cpp` | RmlUi render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx | landed without ImGui | +| `uirender.h`, `uirender.cpp` | RmlUi render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx | landed | | `uisystem.h`, `uisystem.cpp` | RmlUi system interface: time, logging to `DebugString`, cursor, clipboard, string translation | landed with time, logging, and resource naming | | `uifile.h`, `uifile.cpp` | RmlUi file interface over `CCFileClass` | landed | | `uitexture.h`, `uitexture.cpp` | image decoding, SHP and PCX conversion, surface-backed textures | landed for PNG and TGA | | `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | | `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | | -| `uidev.cpp` | ImGui context and developer overlays | | +| `uidev.h`, `uidev.cpp` | ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | | one file per screen | presenter, view-model binding, and the RmlUi view glue | | Shipped UI files (documents, styles, images, the font) live in `ui/` at the @@ -586,17 +586,24 @@ key gets a manual page. A sidebar view key follows the sidebar view. ## Dear ImGui ImGui is vendored as a submodule, compiled into Debug and Release, and -rendered by a small bgfx adapter in `uirender.cpp` that reuses the same -program and view setup as the RmlUi renderer, on `VIEW_DEV`. Its platform -adapter feeds it input through the shell hook and follows the pinned -version's backend contract for texture creation and destruction. Overlays are -armed by the developer-mode flags the manual documents; tool visibility and -frame rate never touch deterministic state. The first uses are single-window -diagnostics such as frame benchmarks, network statistics, and object and -house inspectors. A player-facing feature may choose an ImGui view through -the same screen contract; it then meets the same coexistence, input, and -evidence rules as an RmlUi view. Docking, extra native viewports, and editor -architecture are separate work. +rendered by a small bgfx adapter in `uirender.cpp` that reuses the RmlUi +renderer's program and view setup, on `VIEW_DEV`, with its own vertex layout +and straight-alpha blending, since ImGui's colours are not premultiplied. Its +geometry travels in transient buffers every frame, and its textures follow the +pinned version's contract: the renderer answers each create, update, and +destroy request and acknowledges it. The glyph atlas is created empty and +filled by updates, because bgfx makes a texture created with pixels immutable +and the atlas grows as glyphs are first drawn. Its platform adapter in +`uidev.cpp` feeds it input through the shell hook ahead of the documents; the +default font is scaled by the frame's dp ratio. The context is created on the +first toggle, so a build whose developer keys never arm allocates nothing. +Overlays are armed by the developer-mode flags the manual documents; tool +visibility and frame rate never touch deterministic state. The first overlay +is the frame benchmark window on F6; network statistics and object and house +inspectors follow the same shape. A player-facing feature may choose an ImGui +view through the same screen contract; it then meets the same coexistence, +input, and evidence rules as an RmlUi view. Docking, extra native viewports, +and editor architecture are separate work. ## Sidebar @@ -709,11 +716,11 @@ beyond an ASCII test document. 1. **Dependencies** (S, landed). Submodules, CMake, notices, `BUILDING.md`, and a `tests/uishell` smoke test that links the three libraries. No engine code uses them. Evidence: Debug and Release build. -2. **Shell** (M, RmlUi half landed). Everything in the code-layout table +2. **Shell** (M, landed in two changes). Everything in the code-layout table except screens, the backend split, the input hook, resize handling, the `ui/` copy step, the file interface with mix resolution, and a Debug-only - test document toggled by F9. The Dear ImGui context, its renderer, and the - first overlay follow as their own change. Evidence: the test document + test document toggled by F9; then the Dear ImGui context, its renderer, and + the frame benchmark window toggled by F6. Evidence: the test document renders over the main menu and in game at several resolutions and scale modes; clicks on it, beside any legacy dialog, are consumed; clicks beside it reach the game; legacy dialogs still open and close; repeated open and @@ -751,9 +758,9 @@ beyond an ASCII test document. 14. **Sidebar** (M, then L). The model and view split with the gadget view; later the RmlUi view over the whole column and its selection key. -ImGui overlays (S each) can follow step 2: frame benchmarks first, then what -a developer needs next. GadgetClass screens, MSEngine screens, and the -credits are unscheduled. +ImGui overlays (S each) follow step 2: the frame benchmarks landed with it, +then what a developer needs next. GadgetClass screens, MSEngine screens, and +the credits are unscheduled. ## Validation and evidence diff --git a/manual/changes/imgui-overlays.md b/manual/changes/imgui-overlays.md new file mode 100644 index 000000000..83a289c8a --- /dev/null +++ b/manual/changes/imgui-overlays.md @@ -0,0 +1,14 @@ +--- +title: Add the Dear ImGui developer overlays +category: internal +release: 0.2.0 +targets: + - type: command + id: fixed:debug-benchmark-overlay + effect: added +credit: [ZivDero] +--- + +A Debug build with the debug keys armed shows a frame benchmark window on F6, drawn by Dear ImGui over the game and its menus. It reports the logic frames and presents of the last second and the engine's frame benchmarks, and takes the mouse and keyboard only while the pointer is over it or one of its fields has focus. + +The benchmarks it reads are the ones the monochrome Events page shows; the window resets them each second only while that display is off, so the two never take samples from each other. A Release build carries none of this. diff --git a/manual/content/commands/fixed-debug-benchmark-overlay.md b/manual/content/commands/fixed-debug-benchmark-overlay.md new file mode 100644 index 000000000..73b01ddf4 --- /dev/null +++ b/manual/content/commands/fixed-debug-benchmark-overlay.md @@ -0,0 +1,11 @@ +--- +command_id: fixed:debug-benchmark-overlay +--- + +The key is read from the message pump before any dialog sees it, so it works while a menu dialog or one of its controls has focus as well as in play, and the key release is swallowed with the press so the game never sees either. The first press creates the Dear ImGui context and shows the frame benchmark window; each later press hides or shows it, and the window's own close button hides it too. + +The window reports the logic frames and the presents of the last second, the frame number, the present interval, and the frame benchmarks the monochrome Events page shows, as a share of the frame and as an average in microseconds when the processor speed could be measured, in ticks otherwise. The five counters the engine never starts are marked. While the monochrome display is off, the window resets the benchmarks once a second and shows the second just gone; while that display is on, the Events page keeps its reset and the window shows the live running averages. A button resets on demand and a switch opens the Dear ImGui demo window. + +The window takes the mouse only while the pointer is over it and the keyboard only while one of its fields has focus; everything beside it reaches the game and the test document. A visible menu dialog takes the mouse over its own area before the shell sees it, so over a dialog the window answers the pointer only where it covers the frame beside the dialog. Each toggle writes the renderer's live texture and buffer counts to the debug log. + +[Developer mode and diagnostics](/systems/developer-mode/) covers the flag that arms the keys handled directly in code. diff --git a/manual/content/systems/developer-mode.md b/manual/content/systems/developer-mode.md index b4f82bd83..89148d777 100644 --- a/manual/content/systems/developer-mode.md +++ b/manual/content/systems/developer-mode.md @@ -29,7 +29,7 @@ A Debug build started with a windowed-mode option, a resolution, or a map name h ## What a Debug build allocates -A Debug build creates two extra objects during startup. The first is the scenario editor, which is built whether or not anything ever enters editor state. The second is the set of frame benchmarks, and only where the processor test passes; where it does not, the benchmark page draws nothing and the rest of the diagnostic surface is unaffected. +A Debug build creates two extra objects during startup. The first is the scenario editor, which is built whether or not anything ever enters editor state. The second is the set of frame benchmarks, which count in processor time-stamp ticks of sixteen cycles each. A Release build creates neither. ## The debug log @@ -47,7 +47,13 @@ Assertions are live wherever `NDEBUG` is undefined, which is the Debug configura ## The UI test document -A Debug build with the debug keys armed shows an RmlUi test document over the game and its menus on [F9](/commands/fixed-debug-ui-test-document/) and hides it again on the next press. The document is a panel in the top left corner of the frame with a button that closes it. It is drawn by the renderer over the presented frame, so it appears over the main menu as well as in play, and it follows the frame's position and scale in the window. A click on the panel never reaches the game; a click beside it does. The document, its style sheet, and the font come from the `ui` directory beside the executable, and each load, show, and hide writes a line to the debug log with the renderer's texture and buffer counts, so a leak across repeated toggles shows there. +A Debug build with the debug keys armed shows an RmlUi test document over the game and its menus on [F9](/commands/fixed-debug-ui-test-document/) and hides it again on the next press. The document is a panel in the top left corner of the frame with a button that closes it. It is drawn by the renderer over the presented frame, so it appears over the main menu as well as in play, and it follows the frame's position and scale in the window. A click on the panel never reaches the game; a click beside it does, and a visible menu dialog takes the clicks over its own area first. The document, its style sheet, and the font come from the `ui` directory beside the executable, and each load, show, and hide writes a line to the debug log with the renderer's texture and buffer counts, so a leak across repeated toggles shows there. + +## The benchmark overlay + +A Debug build with the debug keys armed shows a frame benchmark window on [F6](/commands/fixed-debug-benchmark-overlay/) and hides it on the next press or through the window's own close button. The window is drawn by Dear ImGui over the presented frame, like the test document, and follows the frame's position and scale. It reports the logic frames and the presents of the last second, the frame number, the present interval, and the frame benchmarks the Events page shows, as a share of the frame and an average in microseconds when the processor speed could be measured, in ticks otherwise. The five counters the engine never starts are marked as such. + +While the monochrome display is off, the window resets the benchmarks once a second and shows the second just gone; while that display is on, the Events page keeps its reset and the window shows the live running averages, so the two never take samples from each other. A button resets on demand. The window takes the mouse only while the pointer is over it and the keyboard only while one of its fields has focus; everything beside it reaches the game. A visible menu dialog takes the mouse over its own area before the shell sees it, so over a dialog the window answers the pointer only where it covers the frame beside the dialog. A switch opens the Dear ImGui demo window, which exercises the renderer. ## The monochrome pages diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index e48d86426..2b1e6deca 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -289,6 +289,15 @@ fixed_controls: availability: *debug sites: - { file: code/ui/uishell.cpp, function: UI_Intercept_Pumped_Message, expression: VK_F9, guard: _DEBUG } + - id: fixed:debug-benchmark-overlay + title: Toggle the benchmark overlay + description: Shows or hides the Dear ImGui frame benchmark window drawn over the game and its menus. + audience: debug + bindings: [F6] + context: Any game window focused, with Debug_Flag enabled + availability: *debug + sites: + - { file: code/ui/uishell.cpp, function: UI_Intercept_Pumped_Message, expression: VK_F6, guard: _DEBUG } fixed_exclusions: - site: { file: code/debug.cpp, function: Debug_Key, expression: KN_BUTTON, guard: _DEBUG } diff --git a/manual/data/commands.yaml b/manual/data/commands.yaml index 0570fee4f..cdffab980 100644 --- a/manual/data/commands.yaml +++ b/manual/data/commands.yaml @@ -2210,6 +2210,19 @@ fixed_controls: _provenance: source: code/ui/uishell.cpp guard: _DEBUG +- id: fixed:debug-benchmark-overlay + route_id: fixed-debug-benchmark-overlay + kind: fixed + title: Toggle the benchmark overlay + description: Shows or hides the Dear ImGui frame benchmark window drawn over the game and its menus. + audience: debug + availability: *id002 + bindings: + - F6 + context: Any game window focused, with Debug_Flag enabled + _provenance: + source: code/ui/uishell.cpp + guard: _DEBUG launch_options: - id: launch:help route_id: help diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index e21d53984..42bcb67e3 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -242,6 +242,33 @@ void Test_FreeType(void) } +void Draw_ImGui_Test_Window(void) +{ + ImGui::SetNextWindowSize(ImVec2(400.0f, 300.0f), ImGuiCond_Always); + ImGui::Begin("Test window"); + ImGui::TextUnformatted("A frame drawn without a renderer."); + if (ImGui::BeginTable("rows", 3, ImGuiTableFlags_Borders)) { + ImGui::TableSetupColumn("Process"); + ImGui::TableSetupColumn("Frame %"); + ImGui::TableSetupColumn("Average"); + ImGui::TableHeadersRow(); + for (int row = 0; row < 3; row++) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("Row %d", row); + ImGui::TableNextColumn(); + ImGui::Text("%.1f", row * 10.0f); + ImGui::TableNextColumn(); + ImGui::Text("%d", row * 100); + } + ImGui::EndTable(); + } + ImGui::End(); +} + + +// The shell drives Dear ImGui through the 1.92 texture contract; this walks that contract +// with the harness standing in for the renderer. void Test_ImGui(void) { Check(IMGUI_CHECKVERSION(), "ImGui header and library agree on structure layouts"); @@ -249,10 +276,66 @@ void Test_ImGui(void) ImGuiContext * context = ImGui::CreateContext(); Check(context != nullptr, "ImGui creates a context"); std::printf(" Dear ImGui %s\n", ImGui::GetVersion()); + if (context == nullptr) { + return; + } - if (context != nullptr) { - ImGui::DestroyContext(context); + ImGuiIO & io = ImGui::GetIO(); + io.IniFilename = nullptr; + io.LogFilename = nullptr; + io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures | ImGuiBackendFlags_RendererHasVtxOffset; + io.DisplaySize = ImVec2(1280.0f, 800.0f); + io.DeltaTime = 1.0f / 60.0f; + + ImGui::NewFrame(); + Draw_ImGui_Test_Window(); + ImGui::Render(); + + ImDrawData * data = ImGui::GetDrawData(); + Check(data != nullptr && data->Valid, "the first ImGui frame produces draw data"); + Check(data != nullptr && data->CmdLists.Size > 0 && data->TotalVtxCount > 0, "the first ImGui frame draws geometry"); + + ImTextureData * atlas = nullptr; + if (data != nullptr && data->Textures != nullptr && data->Textures->Size == 1) { + atlas = (*data->Textures)[0]; } + Check(atlas != nullptr, "the first ImGui frame lists one texture, the font atlas"); + Check(atlas != nullptr && atlas->Status == ImTextureStatus_WantCreate, "the font atlas asks to be created"); + Check(atlas != nullptr && atlas->Format == ImTextureFormat_RGBA32 && atlas->Width > 0 && atlas->Height > 0, "the font atlas is RGBA32 with a size"); + + if (atlas != nullptr) { + atlas->SetTexID((ImTextureID)1); + atlas->SetStatus(ImTextureStatus_OK); + } + + ImGui::NewFrame(); + Draw_ImGui_Test_Window(); + ImGui::Render(); + data = ImGui::GetDrawData(); + + bool acknowledged = data != nullptr && data->Textures != nullptr; + if (acknowledged) { + for (ImTextureData * texture : *data->Textures) { + if (texture->Status != ImTextureStatus_OK) { + acknowledged = false; + } + } + } + Check(acknowledged, "the second ImGui frame leaves every texture acknowledged"); + + bool textured = data != nullptr; + if (textured) { + for (ImDrawList const * list : data->CmdLists) { + for (ImDrawCmd const & command : list->CmdBuffer) { + if (command.UserCallback == nullptr && command.ElemCount > 0 && command.GetTexID() == ImTextureID_Invalid) { + textured = false; + } + } + } + } + Check(textured, "every ImGui draw command carries a texture id"); + + ImGui::DestroyContext(context); } From 0f58e7d58c13d8cd341fc3b54930be2a19b2416c Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 07:03:49 +0300 Subject: [PATCH 04/52] Add the string table and the LegacyDialogs kill switch --- CMakeLists.txt | 14 +++++- cmake/StringTable.cmake | 64 +++++++++++++++++++++++++ code/msgloop.cpp | 15 +++++- code/msgloop.h | 1 + code/options.cpp | 5 ++ code/options.h | 1 + code/ownrdraw.cpp | 6 +++ code/ui/uishell.cpp | 14 ++++++ code/ui/uishell.h | 7 +++ code/ui/uisystem.cpp | 57 ++++++++++++++++++++++ code/ui/uisystem.h | 7 ++- code/windlg.cpp | 5 ++ docs/UI_DESIGN.md | 31 ++++++------ manual/changes/rmlui-version-dialog.md | 13 ++++++ manual/content/keys/legacydialogs.md | 11 +++++ manual/data/ini-keys.yaml | 18 +++++++ tests/uishell/CMakeLists.txt | 5 +- tests/uishell/uishell.cpp | 65 +++++++++++++++++++++++++- 18 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 cmake/StringTable.cmake create mode 100644 manual/changes/rmlui-version-dialog.md create mode 100644 manual/content/keys/legacydialogs.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 391723c94..385df30f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,10 +46,12 @@ set(TS_RUN_DIR "${CMAKE_SOURCE_DIR}/Run" CACHE PATH "Tiberian Sun run directory" # The version resources, the version displays, and the debug log's opening banner all report # the version and commit that produced the binary. Stamping runs once here so the headers exist # before the first compile, and again per build through OpenTSBuildStamp so that committing does -# not require a reconfigure to be reflected. +# not require a reconfigure to be reflected. The UI shell's string-name table is generated from +# language.h on the same schedule. set(OPENTS_GENERATED_DIR "${CMAKE_BINARY_DIR}/generated") set(OPENTS_VERSION_HEADER "${OPENTS_GENERATED_DIR}/opents_version.h") set(OPENTS_STAMP_HEADER "${OPENTS_GENERATED_DIR}/opents_build.h") +set(OPENTS_STRINGS_HEADER "${OPENTS_GENERATED_DIR}/opents_strings.h") file(MAKE_DIRECTORY "${OPENTS_GENERATED_DIR}") set(OPENTS_STAMP_ARGS @@ -64,11 +66,19 @@ set(OPENTS_STAMP_ARGS -P "${CMAKE_SOURCE_DIR}/cmake/GitStamp.cmake" ) +set(OPENTS_STRINGS_ARGS + "-DOPENTS_LANGUAGE_HEADER=${CMAKE_SOURCE_DIR}/code/language/language.h" + "-DOPENTS_STRINGS_HEADER=${OPENTS_STRINGS_HEADER}" + -P "${CMAKE_SOURCE_DIR}/cmake/StringTable.cmake" +) + execute_process(COMMAND ${CMAKE_COMMAND} ${OPENTS_STAMP_ARGS}) +execute_process(COMMAND ${CMAKE_COMMAND} ${OPENTS_STRINGS_ARGS}) add_custom_target(OpenTSBuildStamp ALL COMMAND ${CMAKE_COMMAND} ${OPENTS_STAMP_ARGS} - COMMENT "Stamping the build identity" + COMMAND ${CMAKE_COMMAND} ${OPENTS_STRINGS_ARGS} + COMMENT "Stamping the build identity and the string-name table" VERBATIM ) diff --git a/cmake/StringTable.cmake b/cmake/StringTable.cmake new file mode 100644 index 000000000..e0dd95b16 --- /dev/null +++ b/cmake/StringTable.cmake @@ -0,0 +1,64 @@ +# Writes the string-name table the UI shell uses to resolve [[TXT_NAME]] references in its +# documents. language.h defines the string identifiers as macros and keeps no name table, so +# every `#define TXT_NAME id` line becomes one entry here. +# +# Run at configure time and once per build, so an edit to language.h reaches the table without +# a reconfigure. The header is only rewritten when its contents change, so an ordinary rebuild +# does not force a recompile. +# +# Expects OPENTS_LANGUAGE_HEADER and OPENTS_STRINGS_HEADER to be set. + +if(NOT EXISTS "${OPENTS_LANGUAGE_HEADER}") + message(FATAL_ERROR "StringTable.cmake: ${OPENTS_LANGUAGE_HEADER} does not exist.") +endif() + +file(STRINGS "${OPENTS_LANGUAGE_HEADER}" lines REGEX "^#define TXT_[A-Za-z0-9_]+[ \t]+[0-9]+") + +set(entries "") +set(count 0) +foreach(line IN LISTS lines) + string(REGEX REPLACE "^#define (TXT_[A-Za-z0-9_]+)[ \t]+([0-9]+).*$" "\\1;\\2" parts "${line}") + list(GET parts 0 name) + list(GET parts 1 id) + string(APPEND entries "\t{ \"${name}\", ${id} },\n") + math(EXPR count "${count} + 1") +endforeach() + +if(count EQUAL 0) + message(FATAL_ERROR "StringTable.cmake: ${OPENTS_LANGUAGE_HEADER} defines no TXT_ identifiers.") +endif() + +set(content +"/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +/* Generated by cmake/StringTable.cmake from language.h. Edits are overwritten by the next build. */ + +#pragma once + +struct OpenTSStringName +{ + char const * Name; + int Id; +}; + +inline constexpr OpenTSStringName OpenTSStringNames[] = { +${entries}}; + +inline constexpr int OpenTSStringNameCount = ${count}; +") + +set(existing "") +if(EXISTS "${OPENTS_STRINGS_HEADER}") + file(READ "${OPENTS_STRINGS_HEADER}" existing) +endif() + +if(NOT existing STREQUAL content) + file(WRITE "${OPENTS_STRINGS_HEADER}" "${content}") +endif() diff --git a/code/msgloop.cpp b/code/msgloop.cpp index 8f5e721ed..f96a93eb3 100644 --- a/code/msgloop.cpp +++ b/code/msgloop.cpp @@ -125,7 +125,9 @@ void Windows_Message_Handler(void) */ bool processed = false; for (int index = 0; index < _ModelessDialogs.Count(); index++) { - if (IsDialogMessage(_ModelessDialogs[index], &msg)) { + // A driver parks a hidden parent around a child screen; it must not take the keys. + HWND dialog = _ModelessDialogs[index]; + if (IsWindowVisible(dialog) && IsDialogMessage(dialog, &msg)) { processed = true; break; } @@ -220,6 +222,17 @@ void Remove_Modeless_Dialog(HWND dialog) } +bool Any_Modeless_Dialog_Visible(void) +{ + for (int index = 0; index < _ModelessDialogs.Count(); index++) { + if (IsWindowVisible(_ModelessDialogs[index])) { + return(true); + } + } + return(false); +} + + /// /// Fetches a tracked modeless dialog by its window title. /// This routine searches the dialogs submitted by Add_Modeless_Dialog for one whose caption diff --git a/code/msgloop.h b/code/msgloop.h index af1c7d712..dc7538b0a 100644 --- a/code/msgloop.h +++ b/code/msgloop.h @@ -40,6 +40,7 @@ void Windows_Message_Handler(void); void Remove_Modeless_Dialog(HWND dialog); void Add_Modeless_Dialog(HWND dialog); HWND Get_Modeless_Dialog_From_Name(const char *name); +bool Any_Modeless_Dialog_Visible(void); // Accelerator keys support routines. void Add_Accelerator(HWND window, HACCEL accelerator); diff --git a/code/options.cpp b/code/options.cpp index 74ce00f6a..a1bcf503a 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -115,6 +115,7 @@ OptionsClass::OptionsClass(void) : SoundVolume(.7f), VoiceVolume(1.0f), ScoreVolume(.5f), + LegacyDialogs(false), AutoScroll(true), IsScoreRepeat(false), IsScoreShuffle(false), @@ -378,6 +379,9 @@ void OptionsClass::Load_Settings(void) AutoScroll = ConfigINI.Get_Bool("Options", "AutoScroll", AutoScroll); DebugString("AutoScroll is %s\n", AutoScroll == true ? "ON" : "OFF"); + LegacyDialogs = ConfigINI.Get_Bool("Options", "LegacyDialogs", LegacyDialogs); + DebugString("LegacyDialogs is %s\n", LegacyDialogs == true ? "ON" : "OFF"); + DetailLevel = ConfigINI.Get_Int("Options", "DetailLevel", DetailLevel); DetailLevel = std::min(DetailLevel, 2); DetailLevel = std::max(DetailLevel, 0); @@ -468,6 +472,7 @@ void OptionsClass::Save_Settings (void) ConfigINI.Put_Int("Options", "ScrollMethod", ScrollMethod); ConfigINI.Put_Int("Options", "ScrollRate", ScrollRate); ConfigINI.Put_Bool("Options", "AutoScroll", AutoScroll); + ConfigINI.Put_Bool("Options", "LegacyDialogs", LegacyDialogs); ConfigINI.Put_Int("Options", "DetailLevel", DetailLevel); ConfigINI.Put_Bool("Options", "SidebarCameoText", SidebarCameoText); ConfigINI.Put_Bool("Options", "SidebarSorting", SidebarSorting); diff --git a/code/options.h b/code/options.h index 5910b1a37..fb232041c 100644 --- a/code/options.h +++ b/code/options.h @@ -89,6 +89,7 @@ class OptionsClass { */ int ScrollMethod; int ScrollRate; // Distance to scroll. + bool LegacyDialogs; // Win32 dialogs for the screens that have an RmlUi view? bool AutoScroll; // Does map autoscroll? /* diff --git a/code/ownrdraw.cpp b/code/ownrdraw.cpp index ac4f1eefa..f0fbe6fb4 100644 --- a/code/ownrdraw.cpp +++ b/code/ownrdraw.cpp @@ -47,6 +47,7 @@ #include "vox.h" #include "windlg.h" +#include #include #include #include @@ -6739,6 +6740,9 @@ int OwnerDraw::Release_Mouse(void) /// Every dialog begun with this routine must be finished with End_Dialog. HWND OwnerDraw::Begin_Dialog(int id, DLGPROC proc) { + // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. + assert(!UI_Screen_Shown()); + LPCDLGTEMPLATE templ = (LPCDLGTEMPLATE)Fetch_Resource(MAKEINTRESOURCE(id), (LPCSTR)RT_DIALOG); if (templ == NULL) { return(NULL); @@ -6818,6 +6822,8 @@ void OwnerDraw::End_Dialog(HWND window) /// void OwnerDraw::Display_Dialog(HWND window) { + assert(!UI_Screen_Shown()); + ShowWindow(window, SW_SHOWNORMAL); SetForegroundWindow(window); Keyboard->Clear(); diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 4f8f66c3f..ed87baf06 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -11,6 +11,7 @@ #include "dbgprint.h" #include "globals.h" +#include "goptions.h" #include "movies.h" #include "ui/uicoord.h" #include "ui/uidev.h" @@ -380,6 +381,19 @@ void UI_Shutdown(void) } +bool UI_Use_Rml(void) +{ + return(_Ready && !Options.LegacyDialogs); +} + + +bool UI_Screen_Shown(void) +{ + // No screen exists yet; the modal runner that shows one reports it here. + return(false); +} + + void UI_On_Video_Change(void) { if (!_Ready) { diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 459d267ae..3e8b1c9af 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -20,6 +20,13 @@ bool UI_Init(void); void UI_Shutdown(void); +// True when a migrated screen should open its RmlUi view rather than its Win32 dialog. A +// caller reads it once at screen entry; the answer follows the LegacyDialogs setting. +bool UI_Use_Rml(void); + +// True while a modal screen is shown or closing. The developer overlays are not screens. +bool UI_Screen_Shown(void); + // The frame moved or changed size inside the window. void UI_On_Video_Change(void); diff --git a/code/ui/uisystem.cpp b/code/ui/uisystem.cpp index ae0729bf0..21a4acf89 100644 --- a/code/ui/uisystem.cpp +++ b/code/ui/uisystem.cpp @@ -9,9 +9,14 @@ #include "ui/uisystem.h" +#include "data.h" #include "dbgprint.h" #include "win.h" +#include "opents_strings.h" + +#include + UISystemInterfaceClass::UISystemInterfaceClass(void) : StartTime(timeGetTime()) @@ -33,10 +38,12 @@ bool UISystemInterfaceClass::LogMessage(Rml::Log::Type type, Rml::String const & switch (type) { case Rml::Log::LT_ERROR: level = "error"; + Errors++; break; case Rml::Log::LT_ASSERT: level = "assert"; + Errors++; break; case Rml::Log::LT_WARNING: @@ -63,3 +70,53 @@ void UISystemInterfaceClass::JoinPath(Rml::String & translated, Rml::String cons size_t start = path.find_last_of("/\\"); translated = (start == Rml::String::npos) ? path : path.substr(start + 1); } + + +static int String_Id(Rml::String const & name) +{ + for (OpenTSStringName const & entry : OpenTSStringNames) { + if (std::strcmp(entry.Name, name.c_str()) == 0) { + return(entry.Id); + } + } + return(-1); +} + + +// A document names an engine string as [[TXT_NAME]]. An unknown name stays as typed so that +// it shows where it was written. Fetch_String returns a pointer into a cache that later +// calls reuse, so the text is copied out at once. +int UISystemInterfaceClass::TranslateString(Rml::String & translated, Rml::String const & input) +{ + int count = 0; + size_t from = 0; + + translated.clear(); + + while (from < input.size()) { + size_t open = input.find("[[", from); + size_t close = (open == Rml::String::npos) ? Rml::String::npos : input.find("]]", open + 2); + + if (close == Rml::String::npos) { + translated.append(input, from, Rml::String::npos); + break; + } + + translated.append(input, from, open - from); + + Rml::String name = input.substr(open + 2, close - open - 2); + int id = String_Id(name); + + if (id >= 0) { + translated.append(Fetch_String(id)); + count++; + } else { + DebugString("UI: no string named %s\n", name.c_str()); + translated.append(input, open, close + 2 - open); + } + + from = close + 2; + } + + return(count); +} diff --git a/code/ui/uisystem.h b/code/ui/uisystem.h index 0d36fc2cb..e0ebfb5e1 100644 --- a/code/ui/uisystem.h +++ b/code/ui/uisystem.h @@ -12,7 +12,7 @@ #include -// RmlUi's view of the engine's clock, debug log and resource naming. +// RmlUi's view of the engine's clock, debug log, resource naming and string table. class UISystemInterfaceClass : public Rml::SystemInterface { public: @@ -20,8 +20,13 @@ class UISystemInterfaceClass : public Rml::SystemInterface virtual double GetElapsedTime(void) override; virtual bool LogMessage(Rml::Log::Type type, Rml::String const & message) override; + virtual int TranslateString(Rml::String & translated, Rml::String const & input) override; virtual void JoinPath(Rml::String & translated, Rml::String const & documentpath, Rml::String const & path) override; + // How many errors and assertions RmlUi has logged so far. + int Error_Count(void) const { return(Errors); } + private: unsigned int StartTime; + int Errors = 0; }; diff --git a/code/windlg.cpp b/code/windlg.cpp index 93ba040cb..7ba535c45 100644 --- a/code/windlg.cpp +++ b/code/windlg.cpp @@ -17,9 +17,11 @@ #include "init.h" #include "msgloop.h" #include "ownrdraw.h" +#include "ui/uishell.h" #include "video.h" #include "win.h" +#include #include #include @@ -96,6 +98,9 @@ inline int WS_Dialog_Index(HWND window) /// missing or the dialog could not be created, NULL is returned. HWND WS_Create_Dialog(HINSTANCE instance, int id, HWND parent, DLGPROC proc, BOOL force_show) { + // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. + assert(!UI_Screen_Shown()); + WSDialogStruct *slot = &g_Dialogs[g_DialogCount]; g_Dialogs[g_DialogCount].handle = 0; g_Dialogs[g_DialogCount].id = 0; diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 6d34ad852..bb12bebad 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -186,7 +186,7 @@ written. | `bgfxviews.hh` (in `code/`) | the view ids the presenter and the overlays share | landed | | `uishell.h`, `uishell.cpp` | init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector | landed without the modal runner and selector | | `uirender.h`, `uirender.cpp` | RmlUi render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx | landed | -| `uisystem.h`, `uisystem.cpp` | RmlUi system interface: time, logging to `DebugString`, cursor, clipboard, string translation | landed with time, logging, and resource naming | +| `uisystem.h`, `uisystem.cpp` | RmlUi system interface: time, logging to `DebugString`, cursor, clipboard, string translation | landed with time, logging, resource naming, and string translation; cursor and clipboard wait for the first editable screen | | `uifile.h`, `uifile.cpp` | RmlUi file interface over `CCFileClass` | landed | | `uitexture.h`, `uitexture.cpp` | image decoding, SHP and PCX conversion, surface-backed textures | landed for PNG and TGA | | `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | @@ -567,21 +567,27 @@ byte, which bounds in-game text to the range the transition supports. Documents reference strings by name: `[[TXT_OK]]`. RmlUi passes every text node through `SystemInterface::TranslateString`, where the shell maps the -name to its identifier. The names are `#define`s in `language.h`, so a CMake -script generates the name table into the build's generated directory; no -hand-maintained list. Dynamic text, including player and map names and error -strings, is inserted as text, never as markup. +name to its identifier and copies the string out of the `Fetch_String` cache; +an unknown name stays as typed and is logged. The names are `#define`s in +`language.h`, so `cmake/StringTable.cmake` generates the name table into the +build's generated directory at configure time and per build, beside the build +stamp; no hand-maintained list. RmlUi re-parses a translated text node as +markup only when it contains `<`; no engine string does, and one that did +would need its `<` encoded. Dynamic text, including player and map names and +error strings, is inserted as text, never as markup. ## Configuration -One transitional key in `SUN.INI`, named by the change that introduces it, -returns every migrated screen to its legacy view while that view exists. -Defaults are decided per screen family in code, so a family switches to RmlUi -by default when its evidence is in without a key per family. The key is -deleted with OwnerDraw. There is no build option: RmlUi and ImGui are always +One transitional key in `SUN.INI`, `LegacyDialogs` under `[Options]`, returns +every migrated screen to its legacy view while that view exists; it defaults +to `no`. Defaults are decided per screen family in code, so a family switches +to RmlUi by default when its evidence is in without a key per family. The key +is deleted with OwnerDraw. There is no build option: RmlUi and ImGui are always compiled and linked, so one configuration matrix carries the evidence. -`Options` reads and writes the key where it handles `[Video]` today, and the -key gets a manual page. A sidebar view key follows the sidebar view. +`Options` reads and writes the key with its other `[Options]` settings, a +caller latches `UI_Use_Rml()` at screen entry because `Options` loads after the +shell, and the key has a manual page. A sidebar view key follows the sidebar +view. ## Dear ImGui @@ -812,7 +818,6 @@ geometry memory are recorded on an agreed baseline before defaults change. ## Open decisions -- The kill-switch key name, fixed by the change that introduces it. - The in-game text route for the sidebar view: TrueType conversions of the game fonts or a bitmap font engine for every document. - The document and binding versioning rules for mods, fixed with the first diff --git a/manual/changes/rmlui-version-dialog.md b/manual/changes/rmlui-version-dialog.md new file mode 100644 index 000000000..b30d20119 --- /dev/null +++ b/manual/changes/rmlui-version-dialog.md @@ -0,0 +1,13 @@ +--- +title: Add the LegacyDialogs setting +category: feature +release: 0.2.0 +targets: +- type: key + id: LegacyDialogs + effect: added +credit: +- ZivDero +--- + +`sun.ini` gains `LegacyDialogs` under `[Options]`. Set to `yes`, it opens the Win32 dialog for every screen that also has an RmlUi document; left out or set to `no`, those screens use their documents. The key is read at startup with the other options and written back when the settings are saved. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md new file mode 100644 index 000000000..a30d141b2 --- /dev/null +++ b/manual/content/keys/legacydialogs.md @@ -0,0 +1,11 @@ +--- +key: LegacyDialogs +summary: Selects the Win32 dialog over the RmlUi document for the screens that have both. +when_omitted: + kind: value + value: "no" +--- + +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. No screen has one yet, so the key changes nothing until the first screen migrates. + +The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/data/ini-keys.yaml b/manual/data/ini-keys.yaml index 2f4d3e79a..04d060187 100644 --- a/manual/data/ini-keys.yaml +++ b/manual/data/ini-keys.yaml @@ -12774,6 +12774,24 @@ LastTilesInSet: source: code/isotype.cpp guard: null level: IsometricTileTypeClass +LegacyDialogs: + key: LegacyDialogs + scopes: + - applies_to: + - client settings + file: sun.ini + section: + kind: literal + name: Options + value_type: boolean + status: generated + _provenance: + default_candidate: 'no' + declared_in: OptionsClass + member: LegacyDialogs + source: code/options.cpp + guard: null + level: OptionsClass LegalTarget: key: LegalTarget scopes: diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 9c7ea69bc..1ed19754b 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -9,7 +9,10 @@ add_executable(UIShell target_compile_features(UIShell PRIVATE cxx_std_20) -target_include_directories(UIShell PRIVATE "${CMAKE_SOURCE_DIR}/code") +target_include_directories(UIShell PRIVATE "${CMAKE_SOURCE_DIR}/code" "${OPENTS_GENERATED_DIR}") + +# The string-name table the shell resolves document references with is generated per build. +add_dependencies(UIShell OpenTSBuildStamp) # The documents are read from the source tree, so the test needs no run directory. target_compile_definitions(UIShell PRIVATE WIN32 _WINDOWS NOMINMAX "OPENTS_UI_DIR=\"${CMAKE_SOURCE_DIR}/ui\"") diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 42bcb67e3..4a7d92994 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -10,8 +10,9 @@ // Pins what the UI shell relies on without a window, a renderer or game data: the vendored // toolkits start and stop under the engine's link settings, every shipped document loads // and draws through the render interface methods the shell implements and none it does -// not, the documents name their resources the way the shell resolves them, and the -// pointer mapping into the overlay behaves at the frame's edges. +// not, the documents name their resources the way the shell resolves them and their +// strings by names the generated table knows, and the pointer mapping into the overlay +// behaves at the frame's edges. #include #include @@ -27,6 +28,8 @@ #include "ui/uicoord.h" +#include "opents_strings.h" + namespace { int Failures = 0; @@ -365,6 +368,63 @@ void Test_Coordinates(void) } +// The shell resolves [[TXT_NAME]] through the generated table, so a name a document uses +// must exist there. +void Test_Strings(void) +{ + int entries = (int)(sizeof(OpenTSStringNames) / sizeof(OpenTSStringNames[0])); + Check(OpenTSStringNameCount == entries, "the string table's count matches its entries"); + Check(OpenTSStringNameCount > 700, "the string table carries the language header's identifiers"); + + int ok = -1; + for (OpenTSStringName const & entry : OpenTSStringNames) { + if (std::strcmp(entry.Name, "TXT_OK") == 0) { + ok = entry.Id; + } + } + Check(ok == 10, "TXT_OK maps to its identifier"); + + std::filesystem::path directory(OPENTS_UI_DIR); + int references = 0; + bool resolved = true; + + for (std::filesystem::directory_entry const & entry : std::filesystem::directory_iterator(directory)) { + std::filesystem::path path = entry.path(); + if (path.extension().string() != ".rml") { + continue; + } + + std::string text = Read_Text(path); + size_t from = 0; + while (true) { + size_t open = text.find("[[", from); + size_t close = (open == std::string::npos) ? std::string::npos : text.find("]]", open + 2); + if (close == std::string::npos) { + break; + } + + std::string name = text.substr(open + 2, close - open - 2); + bool known = false; + for (OpenTSStringName const & known_entry : OpenTSStringNames) { + if (name == known_entry.Name) { + known = true; + } + } + if (!known) { + std::printf(" %s names %s, which the table does not know\n", path.filename().string().c_str(), name.c_str()); + resolved = false; + } + + references++; + from = close + 2; + } + } + + Check(resolved, "every string a document names exists in the table"); + std::printf(" %d string references in the shipped documents\n", references); +} + + void Test_Documents(void) { std::filesystem::path directory(OPENTS_UI_DIR); @@ -449,6 +509,7 @@ int main(void) Test_FreeType(); Test_ImGui(); Test_Coordinates(); + Test_Strings(); Test_Documents(); std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); From 8d091a6d87edb442c41716cf7631b231587216ea Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 07:03:49 +0300 Subject: [PATCH 05/52] Show the version dialog as an RmlUi document --- code/init.cpp | 48 ++---- code/ui/uirmlview.cpp | 147 ++++++++++++++++++ code/ui/uirmlview.h | 77 ++++++++++ code/ui/uiscreen.cpp | 44 ++++++ code/ui/uiscreen.h | 60 ++++++++ code/ui/uishell.cpp | 183 ++++++++++++++++++++++- code/ui/uishell.h | 12 +- code/ui/uiversion.cpp | 70 +++++++++ code/ui/uiversion.h | 42 ++++++ code/ui/uiversiondlg.cpp | 78 ++++++++++ docs/UI_DESIGN.md | 22 +-- manual/changes/rmlui-version-dialog.md | 12 +- manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/developer-mode.md | 2 +- manual/content/systems/ui-files.md | 26 ++++ tests/uishell/CMakeLists.txt | 3 + tests/uishell/uishell.cpp | 153 +++++++++++++++++++ ui/version.rcss | 66 ++++++++ ui/version.rml | 14 ++ 19 files changed, 1007 insertions(+), 54 deletions(-) create mode 100644 code/ui/uirmlview.cpp create mode 100644 code/ui/uirmlview.h create mode 100644 code/ui/uiscreen.cpp create mode 100644 code/ui/uiscreen.h create mode 100644 code/ui/uiversion.cpp create mode 100644 code/ui/uiversion.h create mode 100644 code/ui/uiversiondlg.cpp create mode 100644 manual/content/systems/ui-files.md create mode 100644 ui/version.rcss create mode 100644 ui/version.rml diff --git a/code/init.cpp b/code/init.cpp index fe9a7cb8a..62299581c 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -176,6 +176,8 @@ #include "trigger.h" #include "tube.h" #include "tutorial.h" +#include "ui/uishell.h" +#include "ui/uiversion.h" #include "uicontrol.h" #include "unit.h" #include "unittype.h" @@ -3002,7 +3004,6 @@ INT_PTR CALLBACK Version_Dialog_Proc(HWND window, UINT message, WPARAM wparam, L { HWND handle; int *res; - char buffer[256]; INT_PTR rc = OwnerDraw::Default_Dialog_Proc(window, message, wparam, lparam); @@ -3013,45 +3014,16 @@ INT_PTR CALLBACK Version_Dialog_Proc(HWND window, UINT message, WPARAM wparam, L res = (int *)GetWindowLongPtr(window, DWLP_USER); switch (message) { - case WM_INITDIALOG: + case WM_INITDIALOG: { handle = GetDlgItem(window, IDC_VERSION_INFO); - if (Addon_Installed(ADDON_FIRESTORM) == true) { - strcpy(buffer, Fetch_String(TXT_SHORT_TITLE)); - strcat(buffer, ": "); - strcat(buffer, Get_Addon_Title(ADDON_FIRESTORM)); - ListBox_AddString(handle, buffer); - } else { - ListBox_AddString(handle, Fetch_String(TXT_SHORT_TITLE)); + std::vector lines; + UI_Version_Lines(lines); + for (std::string const & line : lines) { + ListBox_AddString(handle, line.c_str()); } - - sprintf(buffer, "Version %s", Version_Name()); - ListBox_AddString(handle, buffer); - - sprintf(buffer, "Internal Version %s", VerNum.Version_Name()); - ListBox_AddString(handle, buffer); - -#ifdef _DEBUG - sprintf(buffer, "Debug Build: %s - %s", OPENTS_BUILD_DESCRIPTION, OPENTS_COMMIT_DATE); -#else - sprintf(buffer, "Release Build: %s - %s", OPENTS_BUILD_DESCRIPTION, OPENTS_COMMIT_DATE); -#endif - ListBox_AddString(handle, buffer); - - // The braces keep the 'case' label from jumping over these initializations. - { - int cpu_type = 5; - char vendor[32]; - vendor[0] = '\0'; - Get_CPU_Type(cpu_type, vendor, sizeof(vendor) - 1); - - sprintf(buffer, "CPU vendor: %s", vendor); - ListBox_AddString(handle, buffer); - } - - Get_Language_Version(buffer); - ListBox_AddString(handle, buffer); break; + } case WM_COMMAND: switch (LOWORD(wparam)) { @@ -3077,6 +3049,10 @@ void Version_Dialog(void) HWND dialog; int res = 0; + if (UI_Use_Rml() && UI_Version_Dialog()) { + return; + } + dialog = OwnerDraw::Begin_Dialog(IDD_VERSION, Version_Dialog_Proc); if (dialog != NULL) { diff --git a/code/ui/uirmlview.cpp b/code/ui/uirmlview.cpp new file mode 100644 index 000000000..592b58d80 --- /dev/null +++ b/code/ui/uirmlview.cpp @@ -0,0 +1,147 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uirmlview.h" + +#include +#include +#include +#include +#include +#include +#include + + +UIRmlViewClass::UIRmlViewClass(UIPresenterClass & presenter, char const * document, char const * model) : + Owner(presenter), + DocumentName(document), + ModelName(model) +{ +} + + +UIRmlViewClass::~UIRmlViewClass(void) +{ + Release(); +} + + +// The model has to exist before the document that names it loads. +bool UIRmlViewClass::Prepare(Rml::Context & context) +{ + Release(); + Host = &context; + Types = Rml::MakeUnique(); + + Rml::DataModelConstructor constructor = context.CreateDataModel(ModelName, Types.get()); + if (!constructor) { + Rml::Log::Message(Rml::Log::LT_ERROR, "The data model %s for %s could not be created.", ModelName.c_str(), DocumentName.c_str()); + Release(); + return(false); + } + ModelCreated = true; + + bool bound = Bind(constructor); + bound = constructor.BindEventCallback("queue", [this](Rml::DataModelHandle, Rml::Event &, Rml::VariantList const & arguments) { + if (!arguments.empty()) { + Queue(arguments[0].Get().c_str(), arguments.size() > 1 ? arguments[1].Get() : 0); + } + }) && bound; + Model = constructor.GetModelHandle(); + + if (!bound) { + Rml::Log::Message(Rml::Log::LT_ERROR, "The data model %s for %s could not be bound.", ModelName.c_str(), DocumentName.c_str()); + Release(); + return(false); + } + + Doc = context.LoadDocument(DocumentName); + if (Doc == nullptr) { + Rml::Log::Message(Rml::Log::LT_ERROR, "%s did not load.", DocumentName.c_str()); + Release(); + return(false); + } + + Doc->AddEventListener(Rml::EventId::Keydown, this); + return(true); +} + + +void UIRmlViewClass::Show(bool modal) +{ + if (Doc != nullptr) { + Doc->Show(modal ? Rml::ModalFlag::Modal : Rml::ModalFlag::None, Rml::FocusFlag::Document); + } +} + + +void UIRmlViewClass::Hide(void) +{ + if (Doc != nullptr) { + Doc->Hide(); + } +} + + +void UIRmlViewClass::Release(void) +{ + if (Doc != nullptr) { + Doc->Hide(); + Doc->RemoveEventListener(Rml::EventId::Keydown, this); + } + + if (Host != nullptr) { + if (ModelCreated) { + Host->RemoveDataModel(ModelName); + } + if (Doc != nullptr) { + Host->UnloadDocument(Doc); + } + } + + Doc = nullptr; + Host = nullptr; + ModelCreated = false; + Model = Rml::DataModelHandle(); + Types.reset(); +} + + +bool UIRmlViewClass::Is_Shown(void) const +{ + return(Doc != nullptr && Doc->IsVisible()); +} + + +// Enter accepts and Escape cancels whichever element has focus, as the dialog keys do. +void UIRmlViewClass::ProcessEvent(Rml::Event & event) +{ + if (event.GetId() != Rml::EventId::Keydown) { + return; + } + + int key = event.GetParameter("key_identifier", 0); + + if (key == Rml::Input::KI_RETURN || key == Rml::Input::KI_NUMPADENTER) { + Queue("ok"); + event.StopPropagation(); + } else if (key == Rml::Input::KI_ESCAPE) { + Queue("cancel"); + event.StopPropagation(); + } +} + + +void UIRmlViewClass::Queue(char const * name, int value) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + Owner.Queue(intent); +} diff --git a/code/ui/uirmlview.h b/code/ui/uirmlview.h new file mode 100644 index 000000000..43102397d --- /dev/null +++ b/code/ui/uirmlview.h @@ -0,0 +1,77 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +// windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its +// element walkers. +#ifdef GetFirstChild +#undef GetFirstChild +#undef GetNextSibling +#endif + +#include +#include +#include +#include + +namespace Rml +{ + class Context; + class ElementDocument; + class Event; +} + + +// One document and its data model over a presenter. Prepare loads it against a context, the +// runner shows, drives and releases it, and the presenter outlives it. +class UIRmlViewClass : public Rml::EventListener +{ + public: + UIRmlViewClass(UIPresenterClass & presenter, char const * document, char const * model); + virtual ~UIRmlViewClass(void); + + // Creates and binds the model, then loads the document. False leaves nothing behind and + // names the failing resource in the RmlUi log. + bool Prepare(Rml::Context & context); + void Show(bool modal); + void Hide(void); + // Detaches the listener, removes the model and unloads the document while the + // presenter's storage still lives; the context frees the document on its next update. + void Release(void); + + UIPresenterClass & Presenter(void) const { return(Owner); } + Rml::ElementDocument * Document(void) const { return(Doc); } + char const * Document_Name(void) const { return(DocumentName.c_str()); } + bool Is_Shown(void) const; + + // Marks the view-model fields that Execute changed. + virtual void Sync(void) = 0; + + protected: + // Binds the view-model fields; the base binds the queue event. + virtual bool Bind(Rml::DataModelConstructor & model) = 0; + virtual void ProcessEvent(Rml::Event & event) override; + void Queue(char const * name, int value = 0); + + Rml::DataModelHandle Model; + + private: + UIPresenterClass & Owner; + Rml::Context * Host = nullptr; + Rml::ElementDocument * Doc = nullptr; + // The context's shared register refuses a type declared twice, so each view brings its own + // and a screen can open again. + Rml::UniquePtr Types; + bool ModelCreated = false; + Rml::String DocumentName; + Rml::String ModelName; +}; diff --git a/code/ui/uiscreen.cpp b/code/ui/uiscreen.cpp new file mode 100644 index 000000000..8a03fe576 --- /dev/null +++ b/code/ui/uiscreen.cpp @@ -0,0 +1,44 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uiscreen.h" + + +void UIPresenterClass::Queue(UIIntent const & intent) +{ + Pending.push_back(intent); +} + + +// Intents queued while one executes wait for the next drain, so a nested screen started from +// Execute begins from the queue one level up. +void UIPresenterClass::Drain(void) +{ + std::vector intents; + intents.swap(Pending); + + for (UIIntent const & intent : intents) { + if (Result.has_value()) { + break; + } + Execute(intent); + } +} + + +void UIPresenterClass::Discard(void) +{ + Pending.clear(); +} + + +bool UIPresenterClass::Has_Pending(void) const +{ + return(!Pending.empty()); +} diff --git a/code/ui/uiscreen.h b/code/ui/uiscreen.h new file mode 100644 index 000000000..3e9fda567 --- /dev/null +++ b/code/ui/uiscreen.h @@ -0,0 +1,60 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The screen contract: a presenter that knows no toolkit, the intents a view raises and the +// result a screen closes with. No window, control, RmlUi, ImGui or renderer type appears here. + +#pragma once + +#include +#include +#include + + +// What a screen reports when it closes. A wrapper maps these onto the values its callers expect. +enum UIResult +{ + UI_RESULT_ACCEPTED, + UI_RESULT_CANCELLED, + UI_RESULT_SESSION_ENDED, + UI_RESULT_FAILED_TO_OPEN, +}; + + +// A user action a view raised, as copied data: never a document node, a borrowed buffer or an +// engine pointer. +struct UIIntent +{ + std::string Name; + int Value = 0; + std::string Text; +}; + + +class UIPresenterClass +{ + public: + virtual ~UIPresenterClass(void) = default; + + void Queue(UIIntent const & intent); + // Executes the queued intents in order at the owner's safe point and drops the rest once + // one of them produced a result. + void Drain(void); + void Discard(void); + bool Has_Pending(void) const; + + virtual void Execute(UIIntent const & intent) = 0; + // Copies engine state into the view-model. + virtual void Refresh(void) = 0; + + std::optional Result; + + private: + std::vector Pending; +}; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index ed87baf06..5ad593531 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -9,16 +9,24 @@ #include "ui/uishell.h" +#include "_keyboar.h" +#include "conquer.h" #include "dbgprint.h" #include "globals.h" #include "goptions.h" +#include "keyboard.h" +#include "mainloop.h" #include "movies.h" +#include "msgloop.h" +#include "session.h" #include "ui/uicoord.h" #include "ui/uidev.h" #include "ui/uifile.h" #include "ui/uirender.h" +#include "ui/uirmlview.h" #include "ui/uisystem.h" #include "video.h" +#include "windlg.h" // windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its // element walkers. @@ -27,6 +35,9 @@ #include +#include +#include + // The interfaces outlive Rml::Shutdown, which releases every resource through them. static UISystemInterfaceClass _System; @@ -55,6 +66,11 @@ static unsigned int _DevOwnedButtons = 0; static bool _TookCapture = false; static bool _MouseInside = false; +// The modal screen the runner is driving, and whether it is between releasing its document +// and handing the input back. +static UIRmlViewClass * _Modal = NULL; +static bool _ModalClosing = false; + static wchar_t _HighSurrogate = 0; static Rml::Input::KeyIdentifier _KeyMap[256]; @@ -358,6 +374,8 @@ void UI_Shutdown(void) } _Ready = false; + _Modal = NULL; + _ModalClosing = false; if (_OwnedButtons != 0) { Drop_Presses(); @@ -389,8 +407,7 @@ bool UI_Use_Rml(void) bool UI_Screen_Shown(void) { - // No screen exists yet; the modal runner that shows one reports it here. - return(false); + return(_Modal != NULL || _ModalClosing); } @@ -712,6 +729,156 @@ static bool Handle_Char(WPARAM wparam) } +static bool Legacy_Dialog_Visible(void) +{ + for (int index = 0; index < g_DialogCount; index++) { + if (g_Dialogs[index].handle != NULL && IsWindowVisible(g_Dialogs[index].handle)) { + return(true); + } + } + return(Any_Modeless_Dialog_Visible()); +} + + +// The mouse, wheel, key and text messages a shown screen takes whole. +static bool Input_Message(UINT message) +{ + switch (message) { + case WM_MOUSEMOVE: + case WM_LBUTTONDOWN: + case WM_LBUTTONDBLCLK: + case WM_RBUTTONDOWN: + case WM_RBUTTONDBLCLK: + case WM_MBUTTONDOWN: + case WM_MBUTTONDBLCLK: + case WM_LBUTTONUP: + case WM_RBUTTONUP: + case WM_MBUTTONUP: + case WM_MOUSEWHEEL: + case WM_KEYDOWN: + case WM_KEYUP: + case WM_CHAR: + return(true); + + default: + return(false); + } +} + + +// The service pass of OwnerDraw::Dialog_Message_Handler without its tick: the runner ticks +// itself so that it can drain the screen's intents between the update and the present. +static bool Service_Game(void) +{ + static bool inmainloop = false; + + Windows_Message_Handler(); + + if (Session.Type != GAME_NORMAL && Session.Type != GAME_SKIRMISH && !Session.NetOpen && !Session.Suspended) { + if (!inmainloop) { + inmainloop = true; + bool ended = Main_Loop(); + inmainloop = false; + return(ended); + } + } else { + Call_Back(); + } + + return(false); +} + + +UIResult UI_Run_Modal(UIRmlViewClass & view) +{ + if (!_Ready) { + return(UI_RESULT_FAILED_TO_OPEN); + } + if (!_FontLoaded) { + DebugString("UI: %s needs OpenSans.ttf, which did not load\n", view.Document_Name()); + return(UI_RESULT_FAILED_TO_OPEN); + } + + // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. + assert(!Legacy_Dialog_Visible()); + + // A style sheet that fails to load leaves the document usable and is reported as an error. + int errors = _System.Error_Count(); + if (!view.Prepare(*_Context) || _System.Error_Count() != errors) { + DebugString("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); + view.Release(); + return(UI_RESULT_FAILED_TO_OPEN); + } + + view.Presenter().Refresh(); + view.Sync(); + + if (_OwnedButtons != 0) { + Drop_Presses(); + } + + char label[160]; + UIRmlViewClass * previous = _Modal; + + _Modal = &view; + view.Show(true); + Video_Mark_Overlay_Dirty(); + std::snprintf(label, sizeof(label), "%s shown", view.Document_Name()); + _Render.Log_Resource_Counts(label); + Keyboard->Clear(); + + UIResult result = UI_RESULT_SESSION_ENDED; + + while (true) { + bool ended = Service_Game(); + if (!_Ready) { + break; + } + + UI_Tick(); + view.Presenter().Drain(); + view.Sync(); + + if (ended) { + break; + } + if (view.Presenter().Result.has_value()) { + result = *view.Presenter().Result; + break; + } + + Video_Mark_Overlay_Dirty(); + Video_Present_If_Dirty(); + } + + _ModalClosing = true; + if (_OwnedButtons != 0) { + Drop_Presses(); + } + view.Presenter().Discard(); + view.Release(); + + if (_Ready) { + _InContext = true; + _Context->Update(); + _InContext = false; + } + + _Modal = previous; + _ModalClosing = false; + + if (_Ready) { + Video_Mark_Overlay_Dirty(); + std::snprintf(label, sizeof(label), "%s closed", view.Document_Name()); + _Render.Log_Resource_Counts(label); + Keyboard->Clear(); + SetFocus(MainWindow); + } + + return(result); +} + + bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) { if (!_Ready || _InHook || hwnd != MainWindow) { @@ -752,10 +919,15 @@ bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM cli return(false); } - if (_InContext || (_OwnedButtons == 0 && !Documents_Visible() && !UIDev_Active())) { + if (_InContext || (_OwnedButtons == 0 && _Modal == NULL && !Documents_Visible() && !UIDev_Active())) { return(false); } + // A closing screen has released its document; the messages it would have taken still end here. + if (_ModalClosing) { + return(Input_Message(message)); + } + _InHook = true; bool consumed = false; @@ -808,6 +980,11 @@ bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM cli break; } + // A shown screen takes every mouse and key message, as a visible legacy dialog does. + if (_Modal != NULL && Input_Message(message)) { + consumed = true; + } + _InHook = false; return(consumed); } diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 3e8b1c9af..b856906ce 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -12,8 +12,11 @@ #pragma once +#include "ui/uiscreen.h" #include "win.h" +class UIRmlViewClass; + // Needs the window, the renderer and the file search chain. A false return leaves every // other entry point inert. @@ -27,11 +30,16 @@ bool UI_Use_Rml(void); // True while a modal screen is shown or closing. The developer overlays are not screens. bool UI_Screen_Shown(void); +// Prepares, shows and drives a modal screen until its presenter reports a result or the game +// ends, then releases it. The view's presenter must outlive the call. +UIResult UI_Run_Modal(UIRmlViewClass & view); + // The frame moved or changed size inside the window. void UI_On_Video_Change(void); -// Advances the documents and executes the intents their events queued. Called at the -// game's service points, never from a paint handler or the message pump. +// Advances the documents and the developer overlays. Called at the game's service points, +// never from a paint handler or the message pump; a modal screen's runner drains its intents +// after each call. void UI_Tick(void); // Draws the visible documents over the frame the renderer has just submitted. diff --git a/code/ui/uiversion.cpp b/code/ui/uiversion.cpp new file mode 100644 index 000000000..42b23f473 --- /dev/null +++ b/code/ui/uiversion.cpp @@ -0,0 +1,70 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uiversion.h" + +#include "ui/uirmlview.h" + +#include + + +UIVersionPresenterClass::UIVersionPresenterClass(std::vector lines) : + Lines(std::move(lines)) +{ +} + + +void UIVersionPresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "ok") { + Result = UI_RESULT_ACCEPTED; + } else if (intent.Name == "cancel") { + Result = UI_RESULT_CANCELLED; + } +} + + +void UIVersionPresenterClass::Refresh(void) +{ +} + + +namespace +{ + +class UIVersionViewClass : public UIRmlViewClass +{ + public: + explicit UIVersionViewClass(UIVersionPresenterClass & presenter) : + UIRmlViewClass(presenter, "version.rml", "version"), + Data(presenter) + { + } + + virtual void Sync(void) override + { + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + return(model.RegisterArray>() && model.Bind("lines", &Data.Lines)); + } + + private: + UIVersionPresenterClass & Data; +}; + +} + + +std::unique_ptr UI_Version_View(UIVersionPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uiversion.h b/code/ui/uiversion.h new file mode 100644 index 000000000..a369beacc --- /dev/null +++ b/code/ui/uiversion.h @@ -0,0 +1,42 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +#include +#include +#include + +class UIRmlViewClass; + + +// Shows fixed lines and closes. The lines are handed in, so the presenter needs no engine state. +class UIVersionPresenterClass : public UIPresenterClass +{ + public: + explicit UIVersionPresenterClass(std::vector lines); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + std::vector Lines; +}; + + +// The RmlUi view over a version presenter, bound to version.rml. The presenter must outlive it. +std::unique_ptr UI_Version_View(UIVersionPresenterClass & presenter); + +// The lines the version dialog shows, gathered from the running game. +void UI_Version_Lines(std::vector & lines); + +// Runs the version dialog as an RmlUi screen. False means it could not be prepared and the +// caller should open its legacy dialog. +bool UI_Version_Dialog(void); diff --git a/code/ui/uiversiondlg.cpp b/code/ui/uiversiondlg.cpp new file mode 100644 index 000000000..59b9da312 --- /dev/null +++ b/code/ui/uiversiondlg.cpp @@ -0,0 +1,78 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the version screen: the lines it shows and the entry the legacy dialog's +// wrapper calls. The presenter and view live in uiversion.cpp so that the test harness can +// drive them without the engine. + +#include "ui/uiversion.h" + +#include "addon.h" +#include "data.h" +#include "getcpu.h" +#include "globals.h" +#include "language/language.h" +#include "ui/uirmlview.h" +#include "ui/uishell.h" +#include "version.h" + +#include "opents_build.h" + +#include +#include + + +void UI_Version_Lines(std::vector & lines) +{ + char buffer[256]; + + lines.clear(); + + std::string title = Fetch_String(TXT_SHORT_TITLE); + if (Addon_Installed(ADDON_FIRESTORM)) { + title += ": "; + title += Get_Addon_Title(ADDON_FIRESTORM); + } + lines.push_back(title); + + std::snprintf(buffer, sizeof(buffer), "Version %s", Version_Name()); + lines.push_back(buffer); + + std::snprintf(buffer, sizeof(buffer), "Internal Version %s", VerNum.Version_Name()); + lines.push_back(buffer); + +#ifdef _DEBUG + std::snprintf(buffer, sizeof(buffer), "Debug Build: %s - %s", OPENTS_BUILD_DESCRIPTION, OPENTS_COMMIT_DATE); +#else + std::snprintf(buffer, sizeof(buffer), "Release Build: %s - %s", OPENTS_BUILD_DESCRIPTION, OPENTS_COMMIT_DATE); +#endif + lines.push_back(buffer); + + int cpu_type = 5; + char vendor[32]; + vendor[0] = '\0'; + Get_CPU_Type(cpu_type, vendor, sizeof(vendor) - 1); + std::snprintf(buffer, sizeof(buffer), "CPU vendor: %s", vendor); + lines.push_back(buffer); + + Get_Language_Version(buffer); + lines.push_back(buffer); +} + + +bool UI_Version_Dialog(void) +{ + std::vector lines; + UI_Version_Lines(lines); + + UIVersionPresenterClass presenter(std::move(lines)); + std::unique_ptr view = UI_Version_View(presenter); + + return(UI_Run_Modal(*view) != UI_RESULT_FAILED_TO_OPEN); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index bb12bebad..48221bda0 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,10 +1,11 @@ # UI system design Status: proposal under implementation. Steps 1 and 2 of the -[migration plan](#migration-plan), the dependencies, the RmlUi shell, and the -Dear ImGui overlays, have landed; everything after them is not yet implemented, -built, or measured. Source inspection and upstream documentation inform the -rest. This page owns the proposed UI architecture and migration; +[migration plan](#migration-plan), the dependencies, the RmlUi shell, the +Dear ImGui overlays, and the version dialog, have landed; everything after +them is not yet implemented, built, or measured. Source inspection and +upstream documentation inform the rest. This page owns the proposed UI +architecture and migration; [Building OpenTS](BUILDING.md) owns build support and [Project direction](DIRECTION.md) the wider architecture. @@ -184,15 +185,15 @@ written. | File | Holds | Status | | --- | --- | --- | | `bgfxviews.hh` (in `code/`) | the view ids the presenter and the overlays share | landed | -| `uishell.h`, `uishell.cpp` | init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector | landed without the modal runner and selector | +| `uishell.h`, `uishell.cpp` | init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector | landed | | `uirender.h`, `uirender.cpp` | RmlUi render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx | landed | | `uisystem.h`, `uisystem.cpp` | RmlUi system interface: time, logging to `DebugString`, cursor, clipboard, string translation | landed with time, logging, resource naming, and string translation; cursor and clipboard wait for the first editable screen | | `uifile.h`, `uifile.cpp` | RmlUi file interface over `CCFileClass` | landed | | `uitexture.h`, `uitexture.cpp` | image decoding, SHP and PCX conversion, surface-backed textures | landed for PNG and TGA | | `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | -| `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | | +| `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | landed, with `uiscreen.cpp` and `uirmlview.cpp` carrying the bodies | | `uidev.h`, `uidev.cpp` | ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | -| one file per screen | presenter, view-model binding, and the RmlUi view glue | | +| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link) | Shipped UI files (documents, styles, images, the font) live in `ui/` at the repository root. The build copies the tree beside the executable as it copies @@ -731,10 +732,13 @@ beyond an ASCII test document. modes; clicks on it, beside any legacy dialog, are consumed; clicks beside it reach the game; legacy dialogs still open and close; repeated open and close leaks nothing. -3. **Version dialog** (S, leaf). The integration pilot: fonts, clipping, +3. **Version dialog** (S, leaf, landed in two changes: the string table, the + `LegacyDialogs` key and the coexistence checks, then the screen contract, + the modal runner and the dialog). The integration pilot: fonts, clipping, mapping, dismissal by mouse and keyboard, focus return, UI-only redraw, resize, preparation failure. The main menu keeps hiding around it. -4. **Modal runner and message boxes** (M, leaf). `WWMessageBox::Process` and +4. **Message boxes** (M, leaf). The modal runner landed with step 3. + `WWMessageBox::Process` and `OwnerDraw::Custom_Message_Box` behind the kill switch, preserving button order, default button, Escape, the no-button case, return mappings, and session-end interruption. Evidence includes the multiplayer cases where diff --git a/manual/changes/rmlui-version-dialog.md b/manual/changes/rmlui-version-dialog.md index b30d20119..95bbe3c2f 100644 --- a/manual/changes/rmlui-version-dialog.md +++ b/manual/changes/rmlui-version-dialog.md @@ -1,13 +1,21 @@ --- -title: Add the LegacyDialogs setting +title: Show the version dialog as an RmlUi document category: feature release: 0.2.0 targets: - type: key id: LegacyDialogs effect: added +- type: system + id: ui-files + effect: added +- type: command + id: fixed:main-menu-version + effect: changed credit: - ZivDero --- -`sun.ini` gains `LegacyDialogs` under `[Options]`. Set to `yes`, it opens the Win32 dialog for every screen that also has an RmlUi document; left out or set to `no`, those screens use their documents. The key is read at startup with the other options and written back when the settings are saved. +The version dialog is now an RmlUi document drawn over the title screen, opened from the main menu entry and Ctrl+V as before and closed by OK, Enter or Escape. `sun.ini` gains `LegacyDialogs` under `[Options]`; set to `yes`, it keeps the Win32 dialog for this and every later screen that gains a document. + +The documents, style sheets and font ship in a `ui` directory beside the executable and load by bare file name through the game's file system, so a loose file or a mix entry can override them. A document, style sheet or font that fails to load falls back to the Win32 dialog. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index a30d141b2..53e47eece 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. No screen has one yet, so the key changes nothing until the first screen migrates. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) is the first such screen; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/developer-mode.md b/manual/content/systems/developer-mode.md index 89148d777..1e6f0968a 100644 --- a/manual/content/systems/developer-mode.md +++ b/manual/content/systems/developer-mode.md @@ -109,7 +109,7 @@ Neither is marked as surviving into multiplayer, so starting a network game clea ### The version dialog -The version dialog reports the title, the game and internal version names, a build line labeled by configuration and naming the commit the build was made from, the branch it sat on and that commit's date, a processor line, and the version of the language resource library. +The version dialog reports the title, the game and internal version names, a build line labeled by configuration and naming the commit the build was made from, the branch it sat on and that commit's date, a processor line, and the version of the language resource library. It opens from the classic main menu's [Ctrl+V](/commands/fixed-main-menu-version/) and from the menu entry, and is drawn as an RmlUi document from the `ui` directory unless [`LegacyDialogs`](/keys/legacydialogs/) is `yes`, which keeps the Win32 dialog; both show the same lines. OK, Enter and Escape close the document. When its document, style sheet or font fails to load, the game logs the file name and opens the Win32 dialog instead. [UI files](/systems/ui-files/) covers the directory. ## Toggles that reach nothing diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md new file mode 100644 index 000000000..ed5cbc653 --- /dev/null +++ b/manual/content/systems/ui-files.md @@ -0,0 +1,26 @@ +--- +title: UI files +summary: Ships the RmlUi documents, style sheets and font in a `ui` directory beside the executable, resolves them by bare file name through the game's file system, and names engine strings in documents as `[[TXT_NAME]]`. +category: interface-controls +keys: + - LegacyDialogs +related: + - type: using + id: game-data + - type: format + id: mix +--- + +The `ui` directory beside the executable holds the RmlUi documents (`.rml`), their style sheets (`.rcss`), and the Open Sans font `OpenSans.ttf` with its license `OFL.txt`. The build copies the directory beside the executable the way it copies `Language.dll`, and the release package carries it. + +## How a file is found + +A document names every file it uses by bare file name, and the game adds the `ui` directory to its search paths at startup, so a name resolves in the same order as any other game file: the user path, the current directory, the search paths, then the mix files. A loose copy earlier in that order overrides the shipped file, and a copy inside a mix is used only when no loose file exists. A document, style sheet or font that fails to load fails the screen's preparation; the game logs the file name and opens the screen's Win32 dialog instead. + +## Strings + +A document names an engine string as `[[TXT_NAME]]`, using the identifier names of the language library. The game replaces the reference with the string of that name as it lays the text out. An unknown name stays as typed, so the mistake shows on screen, and a Debug build logs it. + +## Choosing the Win32 dialogs + +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) is the first screen with both. diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 1ed19754b..63d43f247 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -5,6 +5,9 @@ # point up. add_executable(UIShell "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uiversion.cpp" ) target_compile_features(UIShell PRIVATE cxx_std_20) diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 4a7d92994..e17935738 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -27,6 +28,9 @@ #include #include "ui/uicoord.h" +#include "ui/uirmlview.h" +#include "ui/uiscreen.h" +#include "ui/uiversion.h" #include "opents_strings.h" @@ -425,10 +429,145 @@ void Test_Strings(void) } +// The name a document binds with data-model, or nothing. +std::string Data_Model_Name(std::string const & text) +{ + size_t start = text.find("data-model=\""); + if (start == std::string::npos) { + return(""); + } + start += std::strlen("data-model=\""); + size_t end = text.find('"', start); + return(end == std::string::npos ? "" : text.substr(start, end - start)); +} + + +class MissingViewClass : public UIRmlViewClass +{ + public: + explicit MissingViewClass(UIPresenterClass & presenter) : + UIRmlViewClass(presenter, "missing.rml", "missing") + { + } + + virtual void Sync(void) override + { + } + + protected: + virtual bool Bind(Rml::DataModelConstructor &) override + { + return(true); + } +}; + + +// Drives the version screen the way the runner and the player do: a click on OK queues an +// intent that the drain turns into a result, Enter and Escape do the same through the +// document, and a screen that cannot be prepared leaves nothing behind. +void Test_Version_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + UIVersionPresenterClass presenter({ "Line 1", "Line 2" }); + std::unique_ptr view = UI_Version_View(presenter); + + Check(view->Prepare(context), "the version view prepares against the test context"); + view->Show(true); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the version screen raises no RmlUi warning or error"); + Check(view->Is_Shown(), "the version screen is shown"); + + Rml::ElementDocument * document = view->Document(); + Rml::Element * lines = (document != nullptr) ? document->GetElementById("lines") : nullptr; + + // The data-for template stays in the tree hidden beside the paragraphs it produced. + int visible = 0; + for (int index = 0; lines != nullptr && index < lines->GetNumChildren(); index++) { + if (lines->GetChild(index)->IsVisible()) { + visible++; + } + } + Check(lines != nullptr && visible == 2, "the version screen lists one paragraph per line"); + + Rml::Element * ok = (document != nullptr) ? document->GetElementById("ok") : nullptr; + Check(ok != nullptr, "the version screen has its OK button"); + + if (ok != nullptr) { + Rml::Vector2f at = ok->GetAbsoluteOffset(Rml::BoxArea::Border) + ok->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; + context.ProcessMouseMove((int)at.x, (int)at.y, 0); + context.Update(); + Check(!context.ProcessMouseButtonDown(0, 0), "a press on OK interacts with the document"); + context.ProcessMouseButtonUp(0, 0); + Check(!presenter.Result.has_value() && presenter.Has_Pending(), "the click queues an intent and does not act"); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED, "draining the click accepts the screen"); + } + + UIIntent late; + late.Name = "cancel"; + presenter.Queue(late); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && !presenter.Has_Pending(), "an intent after the result is dropped"); + + view->Release(); + context.Update(); + } + + for (int pass = 0; pass < 2; pass++) { + bool escape = (pass == 0); + UIVersionPresenterClass presenter({ "Line" }); + std::unique_ptr view = UI_Version_View(presenter); + + Check(view->Prepare(context), escape ? "the version view prepares for the Escape pass" : "the version view prepares for the Enter pass"); + view->Show(true); + context.Update(); + context.ProcessKeyDown(escape ? Rml::Input::KI_ESCAPE : Rml::Input::KI_RETURN, 0); + context.ProcessKeyUp(escape ? Rml::Input::KI_ESCAPE : Rml::Input::KI_RETURN, 0); + context.Update(); + presenter.Drain(); + + UIResult expected = escape ? UI_RESULT_CANCELLED : UI_RESULT_ACCEPTED; + Check(presenter.Result.has_value() && *presenter.Result == expected, escape ? "Escape cancels the version screen" : "Enter accepts the version screen"); + + view->Release(); + context.Update(); + } + + { + UIVersionPresenterClass presenter({}); + UIIntent intent; + intent.Name = "ok"; + presenter.Queue(intent); + Check(presenter.Has_Pending(), "a queued intent is pending until drained"); + presenter.Discard(); + Check(!presenter.Has_Pending() && !presenter.Result.has_value(), "discarding drops queued intents without a result"); + } + + { + UIVersionPresenterClass presenter({}); + MissingViewClass view(presenter); + int before = system.Problems; + + Check(!view.Prepare(context), "a missing document fails preparation"); + Check(system.Problems > before, "a failed preparation is reported"); + Check(!context.GetDataModel("missing"), "a failed preparation leaves no data model behind"); + context.Update(); + } +} + + void Test_Documents(void) { std::filesystem::path directory(OPENTS_UI_DIR); + // The shell resolves bare file names through the game's file system; the harness has none, + // so it runs from the ui directory and RmlUi's own file interface finds the same names. + std::filesystem::current_path(directory); + RecordingRenderInterfaceClass render; CountingSystemInterfaceClass system; Rml::SetRenderInterface(&render); @@ -462,6 +601,12 @@ void Test_Documents(void) int problems = system.Problems; render.Scissors.clear(); + // A document over a data model lays out against a permissive stand-in for its screen. + std::string model = Data_Model_Name(Read_Text(path)); + if (!model.empty()) { + context->CreateDataModel(model, nullptr, true); + } + Rml::ElementDocument * document = context->LoadDocument(path.string()); std::string name = path.filename().string(); Check(document != nullptr, (name + " loads").c_str()); @@ -487,10 +632,18 @@ void Test_Documents(void) document->Close(); context->Update(); + + if (!model.empty()) { + context->RemoveDataModel(model); + } } Check(documents > 0, "the ui directory holds at least one document"); + if (context != nullptr) { + Test_Version_Screen(*context, system); + } + if (context != nullptr) { Rml::RemoveContext("test"); } diff --git a/ui/version.rcss b/ui/version.rcss new file mode 100644 index 000000000..e7566b714 --- /dev/null +++ b/ui/version.rcss @@ -0,0 +1,66 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, p +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 408dp; + height: 172dp; + margin-left: -204dp; + margin-top: -86dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +#lines +{ + position: absolute; + left: 33dp; + top: 20dp; + width: 342dp; + height: 89dp; + overflow: hidden; +} + +#lines p +{ + line-height: 15dp; +} + +#ok +{ + position: absolute; + left: 167dp; + top: 130dp; + width: 75dp; + height: 23dp; + display: block; + text-align: center; + line-height: 23dp; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +#ok:hover +{ + background-color: #3f6478; +} + +#ok:active +{ + background-color: #24394a; +} diff --git a/ui/version.rml b/ui/version.rml new file mode 100644 index 000000000..73143b228 --- /dev/null +++ b/ui/version.rml @@ -0,0 +1,14 @@ + + + Version + + + +
+
+

{{line}}

+
+ +
+ +
From 9314e0b05e28f12f6acb2d81f73e76a8769cac2e Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 07:16:27 +0300 Subject: [PATCH 06/52] Show the message boxes as RmlUi documents --- code/msgbox.cpp | 9 ++ code/ui/uimsgbox.cpp | 114 +++++++++++++++++++++ code/ui/uimsgbox.h | 59 +++++++++++ code/ui/uimsgboxdlg.cpp | 51 ++++++++++ code/ui/uishell.cpp | 4 +- code/ui/uishell.h | 4 + docs/UI_DESIGN.md | 27 +++-- manual/changes/rmlui-message-boxes.md | 13 +++ manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/ui-files.md | 2 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 138 ++++++++++++++++++++++++++ ui/message.rcss | 85 ++++++++++++++++ ui/message.rml | 14 +++ 14 files changed, 508 insertions(+), 15 deletions(-) create mode 100644 code/ui/uimsgbox.cpp create mode 100644 code/ui/uimsgbox.h create mode 100644 code/ui/uimsgboxdlg.cpp create mode 100644 manual/changes/rmlui-message-boxes.md create mode 100644 ui/message.rcss create mode 100644 ui/message.rml diff --git a/code/msgbox.cpp b/code/msgbox.cpp index 9877bd534..1e165eb79 100644 --- a/code/msgbox.cpp +++ b/code/msgbox.cpp @@ -39,6 +39,8 @@ #include "globals.h" #include "init.h" #include "ownrdraw.h" +#include "ui/uimsgbox.h" +#include "ui/uishell.h" #include "winfix.h" INT_PTR CALLBACK Message_Box_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); @@ -77,6 +79,13 @@ int _default_response = 0; #define BUTTON_FLAG 0x8000 int WWMessageBox::_Process(const char * msg, int defresponse, const char * b1txt, const char * b2txt, const char * b3txt, bool preserve) { + if (UI_Use_Rml()) { + int choice; + if (UI_Message_Box(msg, defresponse, b1txt, b2txt, b3txt, choice)) { + return(choice); + } + } + int retval = -1; int numbuttons = 0; diff --git a/code/ui/uimsgbox.cpp b/code/ui/uimsgbox.cpp new file mode 100644 index 000000000..fe7ea428d --- /dev/null +++ b/code/ui/uimsgbox.cpp @@ -0,0 +1,114 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uimsgbox.h" + +#include "ui/uirmlview.h" + +#include + + +// The Win32 template puts the first button on the left, the third in the middle and the +// second on the right, and moves a lone first button into the middle. +UIMessageBoxPresenterClass::UIMessageBoxPresenterClass(std::string text, std::vector captions, int defaultresponse) : + Text(std::move(text)), + Default(defaultresponse) +{ + static int const _slots[3][3] = { + { 1, 0, 0 }, + { 0, 2, 0 }, + { 0, 2, 1 }, + }; + + std::vector present; + for (int index = 0; index < (int)captions.size() && index < 3; index++) { + if (!captions[index].empty()) { + UIMessageButton button; + button.Caption = captions[index]; + button.Index = index; + present.push_back(button); + } + } + + int count = (int)present.size(); + for (UIMessageButton & button : present) { + button.Slot = _slots[count - 1][button.Index]; + } + + for (int slot = 0; slot < 3; slot++) { + for (UIMessageButton const & button : present) { + if (button.Slot == slot) { + Buttons.push_back(button); + } + } + } +} + + +void UIMessageBoxPresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "button") { + Choice = intent.Value; + Result = UI_RESULT_ACCEPTED; + } else if (intent.Name == "ok") { + Choice = Default; + Result = UI_RESULT_ACCEPTED; + } else if (intent.Name == "cancel") { + Choice = 1; + Result = UI_RESULT_CANCELLED; + } +} + + +void UIMessageBoxPresenterClass::Refresh(void) +{ +} + + +namespace +{ + +class UIMessageBoxViewClass : public UIRmlViewClass +{ + public: + explicit UIMessageBoxViewClass(UIMessageBoxPresenterClass & presenter) : + UIRmlViewClass(presenter, "message.rml", "message"), + Data(presenter) + { + } + + virtual void Sync(void) override + { + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + Rml::StructHandle button = model.RegisterStruct(); + if (!button) { + return(false); + } + button.RegisterMember("caption", &UIMessageButton::Caption); + button.RegisterMember("index", &UIMessageButton::Index); + button.RegisterMember("slot", &UIMessageButton::Slot); + + return(model.RegisterArray>() && model.Bind("text", &Data.Text) && model.Bind("buttons", &Data.Buttons)); + } + + private: + UIMessageBoxPresenterClass & Data; +}; + +} + + +std::unique_ptr UI_Message_Box_View(UIMessageBoxPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uimsgbox.h b/code/ui/uimsgbox.h new file mode 100644 index 000000000..896a22c6d --- /dev/null +++ b/code/ui/uimsgbox.h @@ -0,0 +1,59 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +#include +#include +#include + +class UIRmlViewClass; + + +// One button of a message box in its display position: slot 0 is the left, 1 the middle and +// 2 the right one. Index is the legacy button number the box returns for it. +struct UIMessageButton +{ + std::string Caption; + int Index = 0; + int Slot = 0; +}; + + +// Shows a message with up to three buttons and reports which one answered. The captions +// arrive in the legacy order, first to third, and an empty caption leaves its button out. +class UIMessageBoxPresenterClass : public UIPresenterClass +{ + public: + UIMessageBoxPresenterClass(std::string text, std::vector captions, int defaultresponse); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + int Button_Count(void) const { return((int)Buttons.size()); } + + std::string Text; + std::vector Buttons; + int Default; + // The legacy return value: the button number, or -1 while nothing has answered. + int Choice = -1; +}; + + +// The RmlUi view over a message box presenter, bound to message.rml. The presenter must +// outlive it. +std::unique_ptr UI_Message_Box_View(UIMessageBoxPresenterClass & presenter); + +// Runs a message box as an RmlUi screen. False means it could not run as one, because a Win32 +// dialog is on screen or the document failed to prepare, and the caller should open its Win32 +// box; otherwise choice carries the legacy return value: the button number, or -1 when the +// session ended. +bool UI_Message_Box(char const * text, int defaultresponse, char const * b1, char const * b2, char const * b3, int & choice); diff --git a/code/ui/uimsgboxdlg.cpp b/code/ui/uimsgboxdlg.cpp new file mode 100644 index 000000000..f6d6d1e4d --- /dev/null +++ b/code/ui/uimsgboxdlg.cpp @@ -0,0 +1,51 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the message box screen: the entry WWMessageBox::Process calls ahead of +// its Win32 box. The presenter and view live in uimsgbox.cpp so that the test harness can +// drive them without the engine. + +#include "ui/uimsgbox.h" + +#include "ui/uirmlview.h" +#include "ui/uishell.h" + +#include + + +bool UI_Message_Box(char const * text, int defaultresponse, char const * b1, char const * b2, char const * b3, int & choice) +{ + std::vector captions; + captions.push_back((b1 != NULL) ? b1 : ""); + captions.push_back((b2 != NULL) ? b2 : ""); + captions.push_back((b3 != NULL) ? b3 : ""); + + UIMessageBoxPresenterClass presenter((text != NULL) ? text : "", std::move(captions), defaultresponse); + + // The Win32 box with no buttons is ended as soon as it is shown and answers 0. + if (presenter.Button_Count() == 0) { + choice = 0; + return(true); + } + + // A visible Win32 dialog takes the mouse before a document can, so a box over one stays Win32. + if (UI_Legacy_Dialog_Visible()) { + return(false); + } + + std::unique_ptr view = UI_Message_Box_View(presenter); + UIResult result = UI_Run_Modal(*view); + + if (result == UI_RESULT_FAILED_TO_OPEN) { + return(false); + } + + choice = (result == UI_RESULT_SESSION_ENDED) ? -1 : presenter.Choice; + return(true); +} diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 5ad593531..739ce75a8 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -729,7 +729,7 @@ static bool Handle_Char(WPARAM wparam) } -static bool Legacy_Dialog_Visible(void) +bool UI_Legacy_Dialog_Visible(void) { for (int index = 0; index < g_DialogCount; index++) { if (g_Dialogs[index].handle != NULL && IsWindowVisible(g_Dialogs[index].handle)) { @@ -800,7 +800,7 @@ UIResult UI_Run_Modal(UIRmlViewClass & view) } // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. - assert(!Legacy_Dialog_Visible()); + assert(!UI_Legacy_Dialog_Visible()); // A style sheet that fails to load leaves the document usable and is reported as an error. int errors = _System.Error_Count(); diff --git a/code/ui/uishell.h b/code/ui/uishell.h index b856906ce..f88030072 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -30,6 +30,10 @@ bool UI_Use_Rml(void); // True while a modal screen is shown or closing. The developer overlays are not screens. bool UI_Screen_Shown(void); +// True while a Win32 dialog is on screen. A screen asked to open over one keeps its legacy +// view, because the visible dialog takes the mouse before a document can. +bool UI_Legacy_Dialog_Visible(void); + // Prepares, shows and drives a modal screen until its presenter reports a result or the game // ends, then releases it. The view's presenter must outlive the call. UIResult UI_Run_Modal(UIRmlViewClass & view); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 48221bda0..4d03faf17 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -450,9 +450,11 @@ int WWMessageBox::Process(...) { Selection is latched at screen entry or at scenario load, never mid-gesture. Preparation (documents, bindings, resources, host scope) completes before a view becomes interactive; a preparation failure reports the resource and -opens the legacy view where one exists. After activation, a view failure -recreates presentation against the surviving presenter state and never -replays accepted intents. +opens the legacy view where one exists. A screen asked to open while a Win32 +dialog is visible opens its legacy view too, so the coexistence rule holds +until that dialog migrates. After activation, a view failure recreates +presentation against the surviving presenter state and never replays +accepted intents. ## Scheduling @@ -737,18 +739,21 @@ beyond an ASCII test document. the modal runner and the dialog). The integration pilot: fonts, clipping, mapping, dismissal by mouse and keyboard, focus return, UI-only redraw, resize, preparation failure. The main menu keeps hiding around it. -4. **Message boxes** (M, leaf). The modal runner landed with step 3. - `WWMessageBox::Process` and - `OwnerDraw::Custom_Message_Box` behind the kill switch, preserving button - order, default button, Escape, the no-button case, return mappings, and - session-end interruption. Evidence includes the multiplayer cases where - `Main_Loop` runs under the box. +4. **Message boxes** (M, leaf, landed). The modal runner landed with step 3. + `WWMessageBox::Process` behind the kill switch, preserving button order, + default button, Escape, the no-button case, return mappings, and + session-end interruption; a box raised over a visible Win32 dialog stays a + Win32 box until that dialog migrates. `OwnerDraw::Custom_Message_Box` is + the modeless progress box of the save and load flows and moves to step 6. + Runtime evidence still owed: the multiplayer cases where `Main_Loop` runs + under the box. 5. **Sound** (M, two changes). The behavior pilot: volumes, eligible themes, selection, availability, shuffle and repeat, immediate previews, play and stop, both templates, frontend and in-game service paths. 6. **Progress and wait** (S, leaf). `IDD_PROGRESS_WAIT`, the saving and - loading boxes in `savemgr.cpp`, the `` element, milestone effects - moved out of drawing. + loading boxes in `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the + modeless box they show, the `` element, milestone effects moved + out of drawing. 7. **Options family** (L, two changes each). Main options, display with its timed rollback, game controls (three variants), keyboard with the hotkey capture control, the display-mode confirmation, abort and surrender. diff --git a/manual/changes/rmlui-message-boxes.md b/manual/changes/rmlui-message-boxes.md new file mode 100644 index 000000000..062423af5 --- /dev/null +++ b/manual/changes/rmlui-message-boxes.md @@ -0,0 +1,13 @@ +--- +title: Show the message boxes as RmlUi documents +category: feature +release: 0.2.0 +targets: +- type: system + id: ui-files + effect: changed +credit: +- ZivDero +--- + +The game's message boxes are now RmlUi documents when no Win32 dialog is on screen, with the same buttons in the same slots, Enter answering with the default button and Escape with the second. A box raised over a Win32 dialog, as the options and network dialogs raise theirs, stays a Win32 box, and so does every box while `LegacyDialogs=yes`. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index 53e47eece..b1cbcffbe 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) is the first such screen; [UI files](/systems/ui-files/) describes where the documents live. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) and the message boxes are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index ed5cbc653..108954541 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -23,4 +23,4 @@ A document names an engine string as `[[TXT_NAME]]`, using the identifier names ## Choosing the Win32 dialogs -[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) is the first screen with both. +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) and the game's message boxes are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 63d43f247..ccbac09fc 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -5,6 +5,7 @@ # point up. add_executable(UIShell "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiversion.cpp" diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index e17935738..000095ad3 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -14,6 +14,7 @@ // strings by names the generated table knows, and the pointer mapping into the overlay // behaves at the frame's edges. +#include #include #include #include @@ -28,6 +29,7 @@ #include #include "ui/uicoord.h" +#include "ui/uimsgbox.h" #include "ui/uirmlview.h" #include "ui/uiscreen.h" #include "ui/uiversion.h" @@ -560,6 +562,141 @@ void Test_Version_Screen(Rml::Context & context, CountingSystemInterfaceClass & } +// The visible buttons of a shown message box, left to right. +std::vector Visible_Buttons(Rml::ElementDocument * document) +{ + std::vector buttons; + if (document == nullptr) { + return(buttons); + } + + Rml::ElementList all; + document->GetElementsByTagName(all, "button"); + for (Rml::Element * element : all) { + if (element->IsVisible()) { + buttons.push_back(element); + } + } + + std::sort(buttons.begin(), buttons.end(), [](Rml::Element * a, Rml::Element * b) { + return(a->GetAbsoluteOffset(Rml::BoxArea::Border).x < b->GetAbsoluteOffset(Rml::BoxArea::Border).x); + }); + return(buttons); +} + + +void Click(Rml::Context & context, Rml::Element * element) +{ + Rml::Vector2f at = element->GetAbsoluteOffset(Rml::BoxArea::Border) + element->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; + context.ProcessMouseMove((int)at.x, (int)at.y, 0); + context.Update(); + context.ProcessMouseButtonDown(0, 0); + context.ProcessMouseButtonUp(0, 0); + context.Update(); +} + + +// Drives the message box the way its callers do: the buttons keep the Win32 template's order +// and slots, a click answers with the button's number, Enter with the default and Escape +// with the second button. +void Test_Message_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + UIMessageBoxPresenterClass presenter("Do you want to abort the mission?", { "First", "Second", "Third" }, 0); + Check(presenter.Button_Count() == 3, "three captions make three buttons"); + + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(view->Prepare(context), "the message box view prepares against the test context"); + view->Show(true); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the message box raises no RmlUi warning or error"); + + std::vector buttons = Visible_Buttons(view->Document()); + Check(buttons.size() == 3, "three buttons are visible"); + bool ordered = buttons.size() == 3 && buttons[0]->GetInnerRML() == "First" && buttons[1]->GetInnerRML() == "Third" && buttons[2]->GetInnerRML() == "Second"; + Check(ordered, "the buttons read first, third, second from left to right"); + + if (buttons.size() == 3) { + Click(context, buttons[1]); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && presenter.Choice == 2, "a click on the middle button answers with the third button"); + } + + view->Release(); + context.Update(); + } + + { + UIMessageBoxPresenterClass presenter("Two buttons", { "OK", "Cancel", "" }, 0); + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(view->Prepare(context), "a two-button box prepares"); + view->Show(true); + context.Update(); + + std::vector buttons = Visible_Buttons(view->Document()); + Check(buttons.size() == 2, "two buttons are visible"); + if (buttons.size() == 2) { + float panel = view->Document()->GetElementById("panel")->GetAbsoluteOffset(Rml::BoxArea::Border).x; + float left = buttons[0]->GetAbsoluteOffset(Rml::BoxArea::Border).x - panel; + float right = buttons[1]->GetAbsoluteOffset(Rml::BoxArea::Border).x - panel; + Check(left < 60.0f && right > 250.0f, "two buttons take the outer slots"); + } + + context.ProcessKeyDown(Rml::Input::KI_ESCAPE, 0); + context.ProcessKeyUp(Rml::Input::KI_ESCAPE, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && presenter.Choice == 1, "Escape answers with the second button"); + + view->Release(); + context.Update(); + } + + { + UIMessageBoxPresenterClass presenter("One button", { "OK", "", "" }, 0); + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(view->Prepare(context), "a one-button box prepares"); + view->Show(true); + context.Update(); + + std::vector buttons = Visible_Buttons(view->Document()); + Check(buttons.size() == 1, "one button is visible"); + if (buttons.size() == 1) { + float panel = view->Document()->GetElementById("panel")->GetAbsoluteOffset(Rml::BoxArea::Border).x; + float left = buttons[0]->GetAbsoluteOffset(Rml::BoxArea::Border).x - panel; + Check(left > 100.0f && left < 200.0f, "a lone button takes the middle slot"); + } + + view->Release(); + context.Update(); + } + + { + UIMessageBoxPresenterClass presenter("Default", { "Yes", "No", "Maybe" }, 2); + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(view->Prepare(context), "a box with a default prepares"); + view->Show(true); + context.Update(); + context.ProcessKeyDown(Rml::Input::KI_RETURN, 0); + context.ProcessKeyUp(Rml::Input::KI_RETURN, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && presenter.Choice == 2, "Enter answers with the default button"); + + view->Release(); + context.Update(); + } + + { + UIMessageBoxPresenterClass presenter("Nothing", { "", "", "" }, 0); + Check(presenter.Button_Count() == 0, "empty captions make no buttons"); + } +} + + void Test_Documents(void) { std::filesystem::path directory(OPENTS_UI_DIR); @@ -642,6 +779,7 @@ void Test_Documents(void) if (context != nullptr) { Test_Version_Screen(*context, system); + Test_Message_Box_Screen(*context, system); } if (context != nullptr) { diff --git a/ui/message.rcss b/ui/message.rcss new file mode 100644 index 000000000..82137aac6 --- /dev/null +++ b/ui/message.rcss @@ -0,0 +1,85 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, p +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 390dp; + height: 137dp; + margin-left: -195dp; + margin-top: -68dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +#text +{ + position: absolute; + left: 33dp; + top: 20dp; + width: 324dp; + height: 62dp; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +#text p +{ + text-align: center; + line-height: 15dp; + white-space: pre-wrap; +} + +button +{ + position: absolute; + top: 94dp; + width: 90dp; + height: 23dp; + display: block; + text-align: center; + line-height: 23dp; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} + +.slot0 +{ + left: 33dp; +} + +.slot1 +{ + left: 150dp; +} + +.slot2 +{ + left: 267dp; +} diff --git a/ui/message.rml b/ui/message.rml new file mode 100644 index 000000000..dbc7d8e7c --- /dev/null +++ b/ui/message.rml @@ -0,0 +1,14 @@ + + + Message + + + +
+
+

{{text}}

+
+ +
+ +
From 220dbd492bbadd2cdee6adaaab934f59d63cebde Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 07:29:13 +0300 Subject: [PATCH 07/52] Put the sound options behaviour behind a presenter --- code/sounddlg.cpp | 176 +++++++++++++++-------------------- code/ui/uisound.cpp | 101 ++++++++++++++++++++ code/ui/uisound.h | 88 ++++++++++++++++++ code/ui/uisounddlg.cpp | 120 ++++++++++++++++++++++++ docs/UI_DESIGN.md | 11 ++- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 107 +++++++++++++++++++++ 7 files changed, 497 insertions(+), 107 deletions(-) create mode 100644 code/ui/uisound.cpp create mode 100644 code/ui/uisound.h create mode 100644 code/ui/uisounddlg.cpp diff --git a/code/sounddlg.cpp b/code/sounddlg.cpp index 6a8c817c8..4838c128c 100644 --- a/code/sounddlg.cpp +++ b/code/sounddlg.cpp @@ -37,18 +37,38 @@ #include "sounddlg.h" #include "dbgprint.h" -#include "audio/audioengine.h" #include "globals.h" #include "goptions.h" #include "incdec.h" #include "init.h" #include "language/language.h" #include "ownrdraw.h" -#include "theme.h" +#include "ui/uiscreen.h" +#include "ui/uisound.h" #include "winfix.h" bool DialogInitialized = false; +// The presenter the dialog procedure is a view of, for the life of one Dialog call. +static UISoundPresenterClass * _Presenter = NULL; + + +static void Queue_And_Drain(UISoundPresenterClass & presenter, char const * name, int value = 0) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + presenter.Queue(intent); + presenter.Drain(); +} + + +static void Sync_Switches(HWND window, UISoundState const & state) +{ + SendDlgItemMessage(window, IDC_SOUND_SHUFFLE, BM_SETCHECK, state.Shuffle ? BST_CHECKED : BST_UNCHECKED, 0); + SendDlgItemMessage(window, IDC_SOUND_REPEAT, BM_SETCHECK, state.Repeat ? BST_CHECKED : BST_UNCHECKED, 0); +} + /// /// Handles the sound and music options dialog. @@ -60,9 +80,16 @@ bool DialogInitialized = false; void SoundControlsClass::Dialog(void) { int rc = -1; + DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); + DialogInitialized = false; + UISoundState state; + UI_Sound_State(state); + UISoundPresenterClass presenter(UI_Sound_Service(), state); + _Presenter = &presenter; + HWND dialog; if (!GameActive) { dialog = OwnerDraw::Begin_Dialog(IDD_SOUND_OPTIONS_DIALOG_LITE, Sound_Option_Dialog_Func); @@ -71,15 +98,14 @@ void SoundControlsClass::Dialog(void) } if (dialog) { - SetWindowLongPtr(dialog, DWLP_USER, (LONG_PTR)&rc); - OwnerDraw::Display_Dialog(dialog); while (rc == -1) { if (OwnerDraw::Dialog_Message_Handler() == true) { rc = 2; } + if (!GameActive) { Title_Screen_Restore(); } @@ -88,6 +114,8 @@ void SoundControlsClass::Dialog(void) OwnerDraw::End_Dialog(dialog); } + _Presenter = NULL; + DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); } @@ -111,167 +139,110 @@ INT_PTR CALLBACK SoundControlsClass::Sound_Option_Dialog_Func(HWND window, UINT INT_PTR rc = OwnerDraw::Default_Dialog_Proc(window, message, wparam, lparam); if (rc == 0) { + UISoundPresenterClass * presenter = _Presenter; + if (presenter == NULL) { + return(FALSE); + } + UISoundState const & state = presenter->State; + switch (message) { case WM_INITDIALOG: { DialogInitialized = false; - bool enabled = AudioEngine.Is_Available(); - /* - ** Music volume slider. - */ + // Seeding the sliders must neither write the options nor make a sound. HWND track = GetDlgItem(window, IDC_MUSIC_VOLUME); if (track) { SendMessage(track, OD_TRACKSILENT, 0, 0); Slider_SetRange(track, 0, VOLUME_LEVELS); - Slider_SetPos(track, (int)(Options.ScoreVolume * (double)VOLUME_LEVELS + 0.5)); - EnableWindow(track, enabled); + Slider_SetPos(track, state.Score); + EnableWindow(track, state.Enabled); } - /* - ** Sound volume slider. - */ track = GetDlgItem(window, IDC_SOUND_VOLUME); if (track) { SendMessage(track, OD_TRACKSILENT, 0, 0); Slider_SetRange(track, 0, VOLUME_LEVELS); - Slider_SetPos(track, (int)(Options.SoundVolume * (double)VOLUME_LEVELS + 0.5)); - EnableWindow(track, enabled); + Slider_SetPos(track, state.Sound); + EnableWindow(track, state.Enabled); } track = GetDlgItem(window, IDC_VOICE_VOLUME); if (track) { SendMessage(track, OD_TRACKSILENT, 0, 0); Slider_SetRange(track, 0, VOLUME_LEVELS); - Slider_SetPos(track, (int)(Options.VoiceVolume * (double)VOLUME_LEVELS + 0.5)); - EnableWindow(track, enabled); + Slider_SetPos(track, state.Voice); + EnableWindow(track, state.Enabled); } if (GameActive) { - - /* - ** Shuffle control. - */ HWND button = GetDlgItem(window, IDC_SOUND_SHUFFLE); if (button) { - Button_SetCheck(button, Options.IsScoreShuffle ? BST_CHECKED : BST_UNCHECKED); - EnableWindow(button, enabled); + Button_SetCheck(button, state.Shuffle ? BST_CHECKED : BST_UNCHECKED); + EnableWindow(button, state.Enabled); } - /* - ** Repeat control. - */ button = GetDlgItem(window, IDC_SOUND_REPEAT); if (button) { - Button_SetCheck(button, Options.IsScoreRepeat ? BST_CHECKED : BST_UNCHECKED); - EnableWindow(button, enabled); + Button_SetCheck(button, state.Repeat ? BST_CHECKED : BST_UNCHECKED); + EnableWindow(button, state.Enabled); } - /* - ** Add all the themes to the list box. The list box entries are constructed - ** and then stored into allocated EMS memory blocks. - */ HWND list = GetDlgItem(window, IDC_SOUND_TRACKLIST); if (list) { - int active_theme = 0; - int visible_num = 1; - ListBox_ResetContent(list); - - for (ThemeType index = THEME_FIRST; index < Theme.Max_Themes(); index++) { - if (Theme.Is_Allowed(index)) { - char buffer[100]; - int length = Theme.Track_Length(index); - char const * fullname = Theme.Full_Name(index); - - sprintf(buffer, "%02d - %s [%d:%02d]", visible_num, fullname, length / 60, length % 60); - visible_num++; - - int row = ListBox_AddString(list, buffer); - if (row != LB_ERR) { - ListBox_SetItemData(list, row, index); - if (Theme.What_Is_Playing() == index) { - active_theme = row; - } - } + for (UISoundTrack const & entry : state.Tracks) { + int row = ListBox_AddString(list, entry.Label.c_str()); + if (row != LB_ERR) { + ListBox_SetItemData(list, row, entry.Theme); } } - - ListBox_SetCurSel(list, active_theme); - ListBox_SetTopIndex(list, active_theme); - EnableWindow(list, enabled); + int selected = (state.Selected >= 0) ? state.Selected : 0; + ListBox_SetCurSel(list, selected); + ListBox_SetTopIndex(list, selected); + EnableWindow(list, state.Enabled); } } DialogInitialized = true; } - break; case WM_COMMAND: switch (LOWORD(wparam)) { - - /* - ** Toggle the shuffle button. - */ case IDC_SOUND_SHUFFLE: - Options.Set_Shuffle(Button_GetCheck((HWND)lparam) == BST_CHECKED); - if (Button_GetCheck((HWND)lparam) == BST_CHECKED) { - SendDlgItemMessage(window, IDC_SOUND_REPEAT, BM_SETCHECK, BST_UNCHECKED, 0); - Options.Set_Repeat(false); - } + Queue_And_Drain(*presenter, "shuffle", Button_GetCheck((HWND)lparam) == BST_CHECKED); + Sync_Switches(window, state); break; - /* - ** Toggle the repeat button. - */ case IDC_SOUND_REPEAT: - Options.Set_Repeat(Button_GetCheck((HWND)lparam) == BST_CHECKED); - if (Button_GetCheck((HWND)lparam) == BST_CHECKED) { - SendDlgItemMessage(window, IDC_SOUND_SHUFFLE, BM_SETCHECK, BST_UNCHECKED, 0); - Options.Set_Shuffle(false); - } + Queue_And_Drain(*presenter, "repeat", Button_GetCheck((HWND)lparam) == BST_CHECKED); + Sync_Switches(window, state); break; - /* - ** Stop all themes from playing. - */ case IDC_SOUND_STOP: if (HIWORD(wparam) == 0) { - Theme.Queue_Song(THEME_QUIET); + Queue_And_Drain(*presenter, "stop"); } break; case IDOK: if (HIWORD(wparam) == 0) { - HWND button = GetDlgItem(window, IDC_MUSIC_VOLUME); - if (button) { - Options.Set_Score_Volume(Slider_GetPos(button) / (double)VOLUME_LEVELS, false); - } - button = GetDlgItem(window, IDC_SOUND_VOLUME); - if (button) { - Options.Set_Sound_Volume(Slider_GetPos(button) / (double)VOLUME_LEVELS, false); + Queue_And_Drain(*presenter, "ok"); + if (presenter->Result.has_value()) { + int * res = (int *)GetWindowLongPtr(window, DWLP_USER); + *res = IDOK; } - button = GetDlgItem(window, IDC_VOICE_VOLUME); - if (button) { - Options.Set_Voice_Volume(Slider_GetPos(button) / (double)VOLUME_LEVELS, false); - } - int * res = (int *)GetWindowLongPtr(window, DWLP_USER); - *res = IDOK; } break; - /* - ** Start the currently selected theme to play. - */ case IDC_SOUND_PLAY: if (HIWORD(wparam) == 0) { HWND list = GetDlgItem(window, IDC_SOUND_TRACKLIST); if (list) { int row = ListBox_GetCurSel(list); if (row != LB_ERR) { - ThemeType theme = (ThemeType)ListBox_GetItemData(list, row); - Theme.Stop(); - Theme.Queue_Song(theme); + Queue_And_Drain(*presenter, "select", row); + Queue_And_Drain(*presenter, "play"); } } } @@ -279,22 +250,21 @@ INT_PTR CALLBACK SoundControlsClass::Sound_Option_Dialog_Func(HWND window, UINT } break; - /* - * Control volume. - */ case WM_HSCROLL: if (DialogInitialized) { HWND track = (HWND)lparam; + if (track == GetDlgItem(window, IDC_MUSIC_VOLUME)) { - Options.Set_Score_Volume(Slider_GetPos(track) / (double)VOLUME_LEVELS, true); + Queue_And_Drain(*presenter, "score", Slider_GetPos(track)); } else if (track == GetDlgItem(window, IDC_SOUND_VOLUME)) { - Options.Set_Sound_Volume(Slider_GetPos(track) / (double)VOLUME_LEVELS, true); + Queue_And_Drain(*presenter, "sound", Slider_GetPos(track)); } else if (track == GetDlgItem(window, IDC_VOICE_VOLUME)) { - Options.Set_Voice_Volume(Slider_GetPos(track) / (double)VOLUME_LEVELS, true); + Queue_And_Drain(*presenter, "voice", Slider_GetPos(track)); } } break; } + return(FALSE); } diff --git a/code/ui/uisound.cpp b/code/ui/uisound.cpp new file mode 100644 index 000000000..96dac5bd9 --- /dev/null +++ b/code/ui/uisound.cpp @@ -0,0 +1,101 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uisound.h" + +#include + + +UISoundPresenterClass::UISoundPresenterClass(UISoundServiceClass & service, UISoundState state) : + State(std::move(state)), + Service(service) +{ +} + + +static int Clamp_Level(int level) +{ + if (level < 0) { + return(0); + } + if (level > UISoundPresenterClass::LEVELS) { + return(UISoundPresenterClass::LEVELS); + } + return(level); +} + + +void UISoundPresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "score") { + State.Score = Clamp_Level(intent.Value); + Service.Set_Score_Volume(Volume_Of(State.Score), true); + + } else if (intent.Name == "sound") { + State.Sound = Clamp_Level(intent.Value); + Service.Set_Sound_Volume(Volume_Of(State.Sound), true); + + } else if (intent.Name == "voice") { + State.Voice = Clamp_Level(intent.Value); + Service.Set_Voice_Volume(Volume_Of(State.Voice), true); + + } else if (intent.Name == "shuffle") { + State.Shuffle = (intent.Value != 0); + Service.Set_Shuffle(State.Shuffle); + if (State.Shuffle) { + State.Repeat = false; + Service.Set_Repeat(false); + } + + } else if (intent.Name == "repeat") { + State.Repeat = (intent.Value != 0); + Service.Set_Repeat(State.Repeat); + if (State.Repeat) { + State.Shuffle = false; + Service.Set_Shuffle(false); + } + + } else if (intent.Name == "select") { + State.Selected = (intent.Value >= 0 && intent.Value < (int)State.Tracks.size()) ? intent.Value : -1; + + } else if (intent.Name == "play") { + if (State.Selected >= 0 && State.Selected < (int)State.Tracks.size()) { + Service.Play(State.Tracks[State.Selected].Theme); + } + + } else if (intent.Name == "stop") { + Service.Stop(); + + } else if (intent.Name == "ok") { + Service.Set_Score_Volume(Volume_Of(State.Score), false); + Service.Set_Sound_Volume(Volume_Of(State.Sound), false); + Service.Set_Voice_Volume(Volume_Of(State.Voice), false); + Result = UI_RESULT_ACCEPTED; + + } else if (intent.Name == "cancel") { + Result = UI_RESULT_ACCEPTED; + } +} + + +void UISoundPresenterClass::Refresh(void) +{ +} + + +int UISoundPresenterClass::Level_Of(float volume) +{ + return(Clamp_Level((int)(volume * (float)LEVELS + 0.5f))); +} + + +float UISoundPresenterClass::Volume_Of(int level) +{ + return((float)Clamp_Level(level) / (float)LEVELS); +} diff --git a/code/ui/uisound.h b/code/ui/uisound.h new file mode 100644 index 000000000..f3a807082 --- /dev/null +++ b/code/ui/uisound.h @@ -0,0 +1,88 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +#include +#include + + +// The engine calls the sound options make. The game supplies one that reaches the options +// and the music player; the test harness supplies one that records the calls. +class UISoundServiceClass +{ + public: + virtual ~UISoundServiceClass(void) = default; + + virtual void Set_Score_Volume(float volume, bool feedback) = 0; + virtual void Set_Sound_Volume(float volume, bool feedback) = 0; + virtual void Set_Voice_Volume(float volume, bool feedback) = 0; + virtual void Set_Shuffle(bool on) = 0; + virtual void Set_Repeat(bool on) = 0; + virtual void Play(int theme) = 0; + virtual void Stop(void) = 0; +}; + + +// One row of the track list: its label as the dialog prints it and the theme it plays. +struct UISoundTrack +{ + std::string Label; + int Theme = 0; +}; + + +// What the dialog shows: the three volumes as slider levels of 0 to 10, the two playlist +// switches, whether the audio device is there, whether a game is running, the tracks the +// playlist allows, and the row of the one that is playing. +struct UISoundState +{ + int Score = 0; + int Sound = 0; + int Voice = 0; + bool Shuffle = false; + bool Repeat = false; + bool Enabled = false; + bool InGame = false; + std::vector Tracks; + int Selected = -1; +}; + + +// Applies each change as it arrives, the way the dialog always has: a slider level previews +// at once, shuffle and repeat switch each other off, play and stop reach the music player, +// and closing re-applies the levels without feedback. Nothing is reverted on close. +class UISoundPresenterClass : public UIPresenterClass +{ + public: + enum { + LEVELS = 10 + }; + + UISoundPresenterClass(UISoundServiceClass & service, UISoundState state); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + static int Level_Of(float volume); + static float Volume_Of(int level); + + UISoundState State; + + private: + UISoundServiceClass & Service; +}; + + +// The game's service and the state of the running game, shared by the Win32 dialog and the +// RmlUi view. +UISoundServiceClass & UI_Sound_Service(void); +void UI_Sound_State(UISoundState & state); diff --git a/code/ui/uisounddlg.cpp b/code/ui/uisounddlg.cpp new file mode 100644 index 000000000..7362952f1 --- /dev/null +++ b/code/ui/uisounddlg.cpp @@ -0,0 +1,120 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the sound options: the service the presenter drives and the state it +// starts from. The presenter lives in uisound.cpp so that the test harness can drive it +// against a recording service. + +#include "ui/uisound.h" + +#include "audio/audioengine.h" +#include "globals.h" +#include "goptions.h" +#include "incdec.h" +#include "theme.h" + +#include + + +namespace +{ + +class UISoundEngineServiceClass : public UISoundServiceClass +{ + public: + virtual void Set_Score_Volume(float volume, bool feedback) override + { + Options.Set_Score_Volume(volume, feedback); + } + + virtual void Set_Sound_Volume(float volume, bool feedback) override + { + Options.Set_Sound_Volume(volume, feedback); + } + + virtual void Set_Voice_Volume(float volume, bool feedback) override + { + Options.Set_Voice_Volume(volume, feedback); + } + + virtual void Set_Shuffle(bool on) override + { + Options.Set_Shuffle(on); + } + + virtual void Set_Repeat(bool on) override + { + Options.Set_Repeat(on); + } + + virtual void Play(int theme) override + { + Theme.Stop(); + Theme.Queue_Song((ThemeType)theme); + } + + virtual void Stop(void) override + { + Theme.Queue_Song(THEME_QUIET); + } +}; + +UISoundEngineServiceClass _Service; + +} + + +UISoundServiceClass & UI_Sound_Service(void) +{ + return(_Service); +} + + +// The track list depends on the scenario and the player's house, so it is built only in game. +void UI_Sound_State(UISoundState & state) +{ + state = UISoundState(); + + state.Score = UISoundPresenterClass::Level_Of(Options.ScoreVolume); + state.Sound = UISoundPresenterClass::Level_Of(Options.SoundVolume); + state.Voice = UISoundPresenterClass::Level_Of(Options.VoiceVolume); + state.Shuffle = Options.IsScoreShuffle; + state.Repeat = Options.IsScoreRepeat; + state.Enabled = AudioEngine.Is_Available(); + state.InGame = GameActive; + + if (!GameActive) { + return; + } + + int visible = 1; + for (ThemeType index = THEME_FIRST; index < Theme.Max_Themes(); index++) { + if (!Theme.Is_Allowed(index)) { + continue; + } + + int length = Theme.Track_Length(index); + char const * fullname = Theme.Full_Name(index); + char buffer[100]; + std::snprintf(buffer, sizeof(buffer), "%02d - %s [%d:%02d]", visible, (fullname != NULL) ? fullname : "", length / 60, length % 60); + visible++; + + UISoundTrack track; + track.Label = buffer; + track.Theme = index; + if (Theme.What_Is_Playing() == index) { + state.Selected = (int)state.Tracks.size(); + } + state.Tracks.push_back(track); + } + + if (state.Selected < 0 && !state.Tracks.empty()) { + state.Selected = 0; + } +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 4d03faf17..39e5fda4d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -193,7 +193,7 @@ written. | `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | | `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | landed, with `uiscreen.cpp` and `uirmlview.cpp` carrying the bodies | | `uidev.h`, `uidev.cpp` | ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | -| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link) | +| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link); the message boxes follow as `uimsgbox.*` and `uimsgboxdlg.cpp`; the sound options as `uisound.*` (presenter and service interface) and `uisounddlg.cpp` (engine service and state), with the Win32 dialog as their view until the document lands | Shipped UI files (documents, styles, images, the font) live in `ui/` at the repository root. The build copies the tree beside the executable as it copies @@ -747,9 +747,12 @@ beyond an ASCII test document. the modeless progress box of the save and load flows and moves to step 6. Runtime evidence still owed: the multiplayer cases where `Main_Loop` runs under the box. -5. **Sound** (M, two changes). The behavior pilot: volumes, eligible themes, - selection, availability, shuffle and repeat, immediate previews, play and - stop, both templates, frontend and in-game service paths. +5. **Sound** (M, two changes; the first landed: the behaviour sits behind + `UISoundPresenterClass` and an engine service, and the Win32 dialog drives + it with the same calls in the same order). The behavior pilot: volumes, + eligible themes, selection, availability, shuffle and repeat, immediate + previews, play and stop, both templates, frontend and in-game service + paths. 6. **Progress and wait** (S, leaf). `IDD_PROGRESS_WAIT`, the saving and loading boxes in `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless box they show, the `` element, milestone effects moved diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index ccbac09fc..b54d83ddb 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(UIShell "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uisound.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiversion.cpp" ) diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 000095ad3..a7a7dab66 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -32,6 +32,7 @@ #include "ui/uimsgbox.h" #include "ui/uirmlview.h" #include "ui/uiscreen.h" +#include "ui/uisound.h" #include "ui/uiversion.h" #include "opents_strings.h" @@ -374,6 +375,111 @@ void Test_Coordinates(void) } +// Records the engine calls the sound presenter makes, in order, so the test can compare +// them with the ones the Win32 dialog made. +class RecordingSoundServiceClass : public UISoundServiceClass +{ + public: + std::vector Calls; + + virtual void Set_Score_Volume(float volume, bool feedback) override { Record("score", volume, feedback); } + virtual void Set_Sound_Volume(float volume, bool feedback) override { Record("sound", volume, feedback); } + virtual void Set_Voice_Volume(float volume, bool feedback) override { Record("voice", volume, feedback); } + virtual void Set_Shuffle(bool on) override { Calls.push_back(on ? "shuffle on" : "shuffle off"); } + virtual void Set_Repeat(bool on) override { Calls.push_back(on ? "repeat on" : "repeat off"); } + virtual void Play(int theme) override { Calls.push_back("play " + std::to_string(theme)); } + virtual void Stop(void) override { Calls.push_back("stop"); } + + std::string Joined(void) const + { + std::string all; + for (std::string const & call : Calls) { + all += (all.empty() ? "" : "; ") + call; + } + return(all); + } + + private: + void Record(char const * what, float volume, bool feedback) + { + char text[64]; + std::snprintf(text, sizeof(text), "%s %.1f%s", what, volume, feedback ? " feedback" : ""); + Calls.push_back(text); + } +}; + + +void Drive(UISoundPresenterClass & presenter, char const * name, int value = 0) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + presenter.Queue(intent); + presenter.Drain(); +} + + +// The sound presenter makes the calls the Win32 dialog procedure made, in the same order. +void Test_Sound_Presenter(void) +{ + Check(UISoundPresenterClass::Level_Of(0.7f) == 7 && UISoundPresenterClass::Level_Of(0.04f) == 0 && UISoundPresenterClass::Level_Of(1.0f) == 10, "volumes round to slider levels the way the dialog did"); + + RecordingSoundServiceClass service; + UISoundState state; + state.Score = 7; + state.Sound = 5; + state.Voice = 10; + state.Enabled = true; + state.InGame = true; + state.Tracks.push_back({ "01 - First [3:00]", 5 }); + state.Tracks.push_back({ "02 - Second [2:30]", 6 }); + state.Tracks.push_back({ "03 - Third [4:05]", 9 }); + state.Selected = 1; + + UISoundPresenterClass presenter(service, state); + + Drive(presenter, "score", 4); + Check(presenter.State.Score == 4 && service.Joined() == "score 0.4 feedback", "a music slider move previews the new volume at once"); + service.Calls.clear(); + + Drive(presenter, "voice", 14); + Check(presenter.State.Voice == 10 && service.Joined() == "voice 1.0 feedback", "a slider level is clamped to the top step"); + service.Calls.clear(); + + Drive(presenter, "shuffle", 1); + Check(presenter.State.Shuffle && !presenter.State.Repeat && service.Joined() == "shuffle on; repeat off", "turning shuffle on turns repeat off"); + service.Calls.clear(); + + Drive(presenter, "repeat", 1); + Check(presenter.State.Repeat && !presenter.State.Shuffle && service.Joined() == "repeat on; shuffle off", "turning repeat on turns shuffle off"); + service.Calls.clear(); + + Drive(presenter, "repeat", 0); + Check(!presenter.State.Repeat && service.Joined() == "repeat off", "turning repeat off leaves shuffle alone"); + service.Calls.clear(); + + Drive(presenter, "play"); + Check(service.Joined() == "play 6", "play starts the selected track"); + service.Calls.clear(); + + Drive(presenter, "select", 2); + Drive(presenter, "play"); + Check(presenter.State.Selected == 2 && service.Joined() == "play 9", "play starts a newly selected track"); + service.Calls.clear(); + + Drive(presenter, "select", 7); + Drive(presenter, "play"); + Check(presenter.State.Selected == -1 && service.Calls.empty(), "a row outside the list selects nothing and plays nothing"); + + Drive(presenter, "stop"); + Check(service.Joined() == "stop", "stop fades the music out"); + service.Calls.clear(); + + Drive(presenter, "ok"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Joined() == "score 0.4; sound 0.5; voice 1.0", "OK re-applies the levels without feedback and closes"); +} + + // The shell resolves [[TXT_NAME]] through the generated table, so a name a document uses // must exist there. void Test_Strings(void) @@ -800,6 +906,7 @@ int main(void) Test_FreeType(); Test_ImGui(); Test_Coordinates(); + Test_Sound_Presenter(); Test_Strings(); Test_Documents(); From b248dc900f90f3f2484cc2090b5beef3be21c77b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 07:34:39 +0300 Subject: [PATCH 08/52] Show the sound options as an RmlUi document --- code/sounddlg.cpp | 19 ++- code/ui/uisound.cpp | 56 ++++++ code/ui/uisound.h | 10 ++ code/ui/uisounddlg.cpp | 18 ++ docs/UI_DESIGN.md | 11 +- manual/changes/rmlui-sound-options.md | 13 ++ manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/ui-files.md | 2 +- tests/uishell/uishell.cpp | 136 +++++++++++++++ ui/sound.rcss | 235 ++++++++++++++++++++++++++ ui/sound.rml | 32 ++++ 11 files changed, 522 insertions(+), 12 deletions(-) create mode 100644 manual/changes/rmlui-sound-options.md create mode 100644 ui/sound.rcss create mode 100644 ui/sound.rml diff --git a/code/sounddlg.cpp b/code/sounddlg.cpp index 4838c128c..b37a102b7 100644 --- a/code/sounddlg.cpp +++ b/code/sounddlg.cpp @@ -44,6 +44,7 @@ #include "language/language.h" #include "ownrdraw.h" #include "ui/uiscreen.h" +#include "ui/uishell.h" #include "ui/uisound.h" #include "winfix.h" @@ -77,12 +78,10 @@ static void Sync_Switches(HWND window, UISoundState const & state) /// when there is no game in progress, since the in game options do not apply there. /// /// This routine will not return until the player closes the dialog. -void SoundControlsClass::Dialog(void) +static void Run_Win32_Dialog(void) { int rc = -1; - DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); - DialogInitialized = false; UISoundState state; @@ -92,9 +91,9 @@ void SoundControlsClass::Dialog(void) HWND dialog; if (!GameActive) { - dialog = OwnerDraw::Begin_Dialog(IDD_SOUND_OPTIONS_DIALOG_LITE, Sound_Option_Dialog_Func); + dialog = OwnerDraw::Begin_Dialog(IDD_SOUND_OPTIONS_DIALOG_LITE, SoundControlsClass::Sound_Option_Dialog_Func); } else { - dialog = OwnerDraw::Begin_Dialog(IDD_SOUND_OPTIONS_DIALOG, Sound_Option_Dialog_Func); + dialog = OwnerDraw::Begin_Dialog(IDD_SOUND_OPTIONS_DIALOG, SoundControlsClass::Sound_Option_Dialog_Func); } if (dialog) { @@ -115,6 +114,16 @@ void SoundControlsClass::Dialog(void) } _Presenter = NULL; +} + + +void SoundControlsClass::Dialog(void) +{ + DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); + + if (!UI_Use_Rml() || !UI_Sound_Dialog()) { + Run_Win32_Dialog(); + } DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); } diff --git a/code/ui/uisound.cpp b/code/ui/uisound.cpp index 96dac5bd9..ea6e1f064 100644 --- a/code/ui/uisound.cpp +++ b/code/ui/uisound.cpp @@ -9,6 +9,8 @@ #include "ui/uisound.h" +#include "ui/uirmlview.h" + #include @@ -99,3 +101,57 @@ float UISoundPresenterClass::Volume_Of(int level) { return((float)Clamp_Level(level) / (float)LEVELS); } + + +namespace +{ + +class UISoundViewClass : public UIRmlViewClass +{ + public: + explicit UISoundViewClass(UISoundPresenterClass & presenter) : + UIRmlViewClass(presenter, "sound.rml", "sound"), + Data(presenter) + { + } + + // The model is small, so every field is re-read after each drain. + virtual void Sync(void) override + { + Model.DirtyAllVariables(); + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + Rml::StructHandle track = model.RegisterStruct(); + if (!track) { + return(false); + } + track.RegisterMember("label", &UISoundTrack::Label); + track.RegisterMember("theme", &UISoundTrack::Theme); + + UISoundState & state = Data.State; + return(model.RegisterArray>() + && model.Bind("score", &state.Score) + && model.Bind("sound", &state.Sound) + && model.Bind("voice", &state.Voice) + && model.Bind("shuffle", &state.Shuffle) + && model.Bind("repeat", &state.Repeat) + && model.Bind("enabled", &state.Enabled) + && model.Bind("ingame", &state.InGame) + && model.Bind("tracks", &state.Tracks) + && model.Bind("selected", &state.Selected)); + } + + private: + UISoundPresenterClass & Data; +}; + +} + + +std::unique_ptr UI_Sound_View(UISoundPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uisound.h b/code/ui/uisound.h index f3a807082..e32bda737 100644 --- a/code/ui/uisound.h +++ b/code/ui/uisound.h @@ -11,9 +11,12 @@ #include "ui/uiscreen.h" +#include #include #include +class UIRmlViewClass; + // The engine calls the sound options make. The game supplies one that reaches the options // and the music player; the test harness supplies one that records the calls. @@ -82,7 +85,14 @@ class UISoundPresenterClass : public UIPresenterClass }; +// The RmlUi view over a sound presenter, bound to sound.rml. The presenter must outlive it. +std::unique_ptr UI_Sound_View(UISoundPresenterClass & presenter); + // The game's service and the state of the running game, shared by the Win32 dialog and the // RmlUi view. UISoundServiceClass & UI_Sound_Service(void); void UI_Sound_State(UISoundState & state); + +// Runs the sound options as an RmlUi screen. False means it could not run as one and the +// caller should open its Win32 dialog. +bool UI_Sound_Dialog(void); diff --git a/code/ui/uisounddlg.cpp b/code/ui/uisounddlg.cpp index 7362952f1..7f430c43b 100644 --- a/code/ui/uisounddlg.cpp +++ b/code/ui/uisounddlg.cpp @@ -18,6 +18,8 @@ #include "goptions.h" #include "incdec.h" #include "theme.h" +#include "ui/uirmlview.h" +#include "ui/uishell.h" #include @@ -118,3 +120,19 @@ void UI_Sound_State(UISoundState & state) state.Selected = 0; } } + + +bool UI_Sound_Dialog(void) +{ + if (UI_Legacy_Dialog_Visible()) { + return(false); + } + + UISoundState state; + UI_Sound_State(state); + + UISoundPresenterClass presenter(UI_Sound_Service(), state); + std::unique_ptr view = UI_Sound_View(presenter); + + return(UI_Run_Modal(*view) != UI_RESULT_FAILED_TO_OPEN); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 39e5fda4d..08d8b3c20 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -193,7 +193,7 @@ written. | `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | | `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | landed, with `uiscreen.cpp` and `uirmlview.cpp` carrying the bodies | | `uidev.h`, `uidev.cpp` | ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | -| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link); the message boxes follow as `uimsgbox.*` and `uimsgboxdlg.cpp`; the sound options as `uisound.*` (presenter and service interface) and `uisounddlg.cpp` (engine service and state), with the Win32 dialog as their view until the document lands | +| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link); the message boxes follow as `uimsgbox.*` and `uimsgboxdlg.cpp`; the sound options as `uisound.*` (presenter, service interface and view) and `uisounddlg.cpp` (engine service, state and entry), with the Win32 dialog as a second view over the same presenter | Shipped UI files (documents, styles, images, the font) live in `ui/` at the repository root. The build copies the tree beside the executable as it copies @@ -747,12 +747,13 @@ beyond an ASCII test document. the modeless progress box of the save and load flows and moves to step 6. Runtime evidence still owed: the multiplayer cases where `Main_Loop` runs under the box. -5. **Sound** (M, two changes; the first landed: the behaviour sits behind - `UISoundPresenterClass` and an engine service, and the Win32 dialog drives - it with the same calls in the same order). The behavior pilot: volumes, +5. **Sound** (M, two changes, landed: the behaviour sits behind + `UISoundPresenterClass` and an engine service, the Win32 dialog drives it + with the same calls in the same order, and `sound.rml` is the RmlUi view + with a `data-if` for the in-game half). The behavior pilot: volumes, eligible themes, selection, availability, shuffle and repeat, immediate previews, play and stop, both templates, frontend and in-game service - paths. + paths. Runtime evidence still owed. 6. **Progress and wait** (S, leaf). `IDD_PROGRESS_WAIT`, the saving and loading boxes in `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless box they show, the `` element, milestone effects moved diff --git a/manual/changes/rmlui-sound-options.md b/manual/changes/rmlui-sound-options.md new file mode 100644 index 000000000..8c9180fab --- /dev/null +++ b/manual/changes/rmlui-sound-options.md @@ -0,0 +1,13 @@ +--- +title: Show the sound options as an RmlUi document +category: feature +release: 0.2.0 +targets: +- type: system + id: ui-files + effect: changed +credit: +- ZivDero +--- + +The sound options open as an RmlUi document from the options menu and from the in-game options, with the same three ten-step sliders and, in game, the same track list, Play, Stop, Shuffle and Repeat. Every change still takes effect at once and nothing is reverted on close; Escape closes the document the way OK does. `LegacyDialogs=yes` keeps the Win32 dialog. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index b1cbcffbe..baf390547 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) and the message boxes are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes and the sound options are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index 108954541..c9be884ee 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -23,4 +23,4 @@ A document names an engine string as `[[TXT_NAME]]`, using the identifier names ## Choosing the Win32 dialogs -[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog) and the game's message boxes are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes and the sound options are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index a7a7dab66..2207c5937 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -803,6 +803,141 @@ void Test_Message_Box_Screen(Rml::Context & context, CountingSystemInterfaceClas } +// The visible elements of one class in a document, in document order. +std::vector Visible_Of_Class(Rml::ElementDocument * document, char const * name) +{ + std::vector found; + if (document == nullptr) { + return(found); + } + + Rml::ElementList all; + document->GetElementsByClassName(all, name); + for (Rml::Element * element : all) { + if (element->IsVisible()) { + found.push_back(element); + } + } + return(found); +} + + +// Drives the sound screen: the sliders, the track rows, the switches and the buttons each +// queue the intent the presenter expects, and the frontend state hides the music half. +void Test_Sound_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + RecordingSoundServiceClass service; + UISoundState state; + state.Score = 7; + state.Sound = 5; + state.Voice = 10; + state.Enabled = true; + state.InGame = true; + state.Tracks.push_back({ "01 - First [3:00]", 5 }); + state.Tracks.push_back({ "02 - Second [2:30]", 6 }); + state.Tracks.push_back({ "03 - Third [4:05]", 9 }); + state.Selected = 1; + + UISoundPresenterClass presenter(service, state); + std::unique_ptr view = UI_Sound_View(presenter); + + Check(view->Prepare(context), "the sound view prepares against the test context"); + view->Show(true); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the sound screen raises no RmlUi warning or error"); + + Rml::ElementDocument * document = view->Document(); + Rml::ElementList inputs; + document->GetElementsByTagName(inputs, "input"); + int sliders = 0; + for (Rml::Element * input : inputs) { + if (input->GetAttribute("type", "") == "range") { + sliders++; + } + } + Check(sliders == 3, "the sound screen has three sliders"); + + Rml::Element * score = document->GetElementById("score"); + Check(score != nullptr && score->GetAttribute("value", -1) == 7, "the music slider starts at the music level"); + + std::vector rows = Visible_Of_Class(document, "track"); + Check(rows.size() == 3, "the track list shows one row per allowed track"); + Check(rows.size() == 3 && rows[1]->IsClassSet("selected") && !rows[0]->IsClassSet("selected"), "the playing track's row is marked selected"); + + if (rows.size() == 3) { + Click(context, rows[2]); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.Selected == 2 && rows[2]->IsClassSet("selected") && !rows[1]->IsClassSet("selected"), "a click on a row selects it"); + } + + Rml::Element * play = document->GetElementById("play"); + if (play != nullptr) { + service.Calls.clear(); + Click(context, play); + presenter.Drain(); + Check(service.Joined() == "play 9", "the Play button plays the selected track"); + } + + Rml::Element * shuffle = document->GetElementById("shuffle"); + if (shuffle != nullptr) { + service.Calls.clear(); + Click(context, shuffle); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.Shuffle && service.Joined() == "shuffle on; repeat off" && shuffle->HasAttribute("checked"), "the shuffle switch turns shuffle on and shows it"); + } + + if (score != nullptr) { + service.Calls.clear(); + Rml::Dictionary parameters; + parameters["value"] = Rml::Variant(3.0f); + score->DispatchEvent(Rml::EventId::Change, parameters); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.Score == 3 && service.Joined() == "score 0.3 feedback" && score->GetAttribute("value", -1) == 3, "a slider change previews the level and the slider follows the model"); + } + + context.ProcessKeyDown(Rml::Input::KI_ESCAPE, 0); + context.ProcessKeyUp(Rml::Input::KI_ESCAPE, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED, "Escape closes the sound screen without reverting anything"); + + view->Release(); + context.Update(); + } + + { + RecordingSoundServiceClass service; + UISoundState state; + state.Enabled = true; + state.InGame = false; + + UISoundPresenterClass presenter(service, state); + std::unique_ptr view = UI_Sound_View(presenter); + + Check(view->Prepare(context), "the frontend sound view prepares"); + view->Show(true); + context.Update(); + + Rml::Element * music = view->Document()->GetElementById("music"); + Check(music != nullptr && !music->IsVisible(), "the frontend sound screen hides the music half"); + Check(Visible_Of_Class(view->Document(), "track").empty(), "the frontend sound screen lists no tracks"); + + view->Release(); + context.Update(); + } +} + + void Test_Documents(void) { std::filesystem::path directory(OPENTS_UI_DIR); @@ -886,6 +1021,7 @@ void Test_Documents(void) if (context != nullptr) { Test_Version_Screen(*context, system); Test_Message_Box_Screen(*context, system); + Test_Sound_Screen(*context, system); } if (context != nullptr) { diff --git a/ui/sound.rcss b/ui/sound.rcss new file mode 100644 index 000000000..8cc79708e --- /dev/null +++ b/ui/sound.rcss @@ -0,0 +1,235 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, label +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 441dp; + height: 349dp; + margin-left: -220dp; + margin-top: -174dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +#panel.lite +{ + height: 182dp; + margin-top: -91dp; +} + +.row +{ + position: absolute; + left: 0; + width: 100%; + height: 24dp; +} + +#row-score +{ + top: 19dp; +} + +#row-sound +{ + top: 55dp; +} + +#row-voice +{ + top: 91dp; +} + +.label +{ + position: absolute; + left: 33dp; + width: 105dp; + line-height: 24dp; + text-align: right; +} + +input.range +{ + position: absolute; + left: 145dp; + width: 262dp; + height: 24dp; +} + +input.range slidertrack +{ + margin-top: 8dp; + height: 8dp; + background-color: #2a3a48; + border: 1dp #6f95a8; +} + +input.range sliderbar +{ + width: 14dp; + height: 24dp; + background-color: #6f95a8; +} + +input.range sliderbar:hover, input.range sliderbar:active +{ + background-color: #8fb5c8; +} + +input.range sliderarrowdec, input.range sliderarrowinc +{ + width: 0; + height: 0; +} + +input.range:disabled sliderbar +{ + background-color: #4a5a68; +} + +button +{ + position: absolute; + display: block; + height: 23dp; + text-align: center; + line-height: 23dp; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} + +#play +{ + left: 33dp; + top: 140dp; + width: 105dp; +} + +#stop +{ + left: 33dp; + top: 184dp; + width: 105dp; +} + +#tracks +{ + position: absolute; + left: 145dp; + top: 133dp; + width: 262dp; + height: 161dp; + overflow-y: auto; + background-color: #0c1116; + border: 1dp #3d5a68; +} + +.track +{ + padding: 2dp 6dp; + line-height: 17dp; +} + +.track:hover +{ + background-color: #1f3140; +} + +.track.selected +{ + background-color: #225061; +} + +scrollbarvertical +{ + width: 12dp; +} + +scrollbarvertical slidertrack +{ + background-color: #1a242c; +} + +scrollbarvertical sliderbar +{ + background-color: #4d6f80; + min-height: 16dp; +} + +scrollbarvertical sliderarrowdec, scrollbarvertical sliderarrowinc +{ + width: 0; + height: 0; +} + +.switch +{ + position: absolute; + left: 33dp; + width: 105dp; + line-height: 23dp; +} + +#switch-shuffle +{ + top: 227dp; +} + +#switch-repeat +{ + top: 271dp; +} + +input.checkbox +{ + width: 14dp; + height: 14dp; + margin-right: 8dp; + vertical-align: -2dp; + background-color: #2a3a48; + border: 1dp #6f95a8; +} + +input.checkbox:checked +{ + background-color: #8fb5c8; +} + +#ok +{ + left: 315dp; + top: 307dp; + width: 93dp; +} + +#panel.lite #ok +{ + left: 172dp; + top: 140dp; +} diff --git a/ui/sound.rml b/ui/sound.rml new file mode 100644 index 000000000..3f32f2499 --- /dev/null +++ b/ui/sound.rml @@ -0,0 +1,32 @@ + + + Sound options + + + +
+
+ Music Volume: + +
+
+ Sound Volume: + +
+
+ Voice Volume: + +
+
+ + +
+
{{track.label}}
+
+ + +
+ +
+ +
From f9c2a52910bb903a949e6a67d74ae2b92a1cac6e Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 07:46:47 +0300 Subject: [PATCH 09/52] Note loading milestones as the progress moves, not in the paint --- code/progress.cpp | 86 ++++++++++++++++++++++++++++++++--------------- code/progress.h | 10 ++++++ docs/UI_DESIGN.md | 22 +++++++----- 3 files changed, 82 insertions(+), 36 deletions(-) diff --git a/code/progress.cpp b/code/progress.cpp index 3fc416d72..e1c559663 100644 --- a/code/progress.cpp +++ b/code/progress.cpp @@ -36,6 +36,22 @@ ProgressScreenClass Progress; +// The loading messages of a single player scenario load and the progress at which each is +// announced. +static struct { + int Progress; + int Text; +} _progress_messages[MAX_PLAYERS] = { + { 0, TXT_LOADING_GAME1A }, + { 12, TXT_LOADING_GAME1B }, + { 20, TXT_LOADING_GAME1C }, + { 30, TXT_LOADING_GAME1D }, + { 50, TXT_LOADING_GAME1E }, + { 70, TXT_LOADING_GAME1F }, + { 80, TXT_LOADING_GAME1G }, + { 100, TXT_LOADING_GAME1H } +}; + /// /// Constructs the progress screen object. @@ -49,6 +65,10 @@ ProgressScreenClass::ProgressScreenClass(void) Shape = NULL; Background = NULL; IsActive = false; + Dialog = NULL; + Percentage = -1; + Reached = -1; + Printed = -1; for (int i = 0; i < MAX_PLAYERS; i++) { PlayerProgress[i] = 0; } @@ -90,6 +110,8 @@ void ProgressScreenClass::Initialize(double progress, int count, bool usedialog) HiddenSurface->Fill(0); } Percentage = -1; + Reached = -1; + Printed = -1; } @@ -196,6 +218,35 @@ double ProgressScreenClass::Get_Current_Progress(void) const } +// A single player load on the full screen announces one loading message per move of the +// progress. The sound plays here, where the progress moves, so a repaint cannot repeat it; +// the message itself waits for the next paint. +void ProgressScreenClass::Advance_Milestone(int index, Point2D pt) +{ + if (Dialog != NULL || PlayerCount != 1 || Shape == NULL || pt != Point2D(-1,-1)) { + return; + } + + if (PlayerProgress[index] > MainProgress) { + PlayerProgress[index] = MainProgress; + } + + int progress = (int)PlayerProgress[index]; + if (progress <= Percentage) { + return; + } + + for (int j = 0; j < ARRAY_SIZE(_progress_messages); j++) { + if (_progress_messages[j].Progress <= progress && _progress_messages[j].Progress > Percentage) { + Sound_Effect(VocClass::From_Name("Notify"), 0.4f); + Percentage = _progress_messages[j].Progress; + Reached = j; + break; + } + } +} + + /// /// Draws the progress screen. /// This routine paints a progress bar for every player being tracked, and in the single @@ -207,20 +258,6 @@ double ProgressScreenClass::Get_Current_Progress(void) const /// Nothing is drawn until Initialize has been called. void ProgressScreenClass::Display_Progress(Point2D xpt) { - static struct { - int Progress; - int Text; - } _progress_messages[MAX_PLAYERS] = { - { 0, TXT_LOADING_GAME1A }, - { 12, TXT_LOADING_GAME1B }, - { 20, TXT_LOADING_GAME1C }, - { 30, TXT_LOADING_GAME1D }, - { 50, TXT_LOADING_GAME1E }, - { 70, TXT_LOADING_GAME1F }, - { 80, TXT_LOADING_GAME1G }, - { 100, TXT_LOADING_GAME1H } - }; - if (IsActive) { Point2D pt = xpt; @@ -244,19 +281,12 @@ void ProgressScreenClass::Display_Progress(Point2D xpt) Get_Display_Rect(GetDlgItem(Dialog, IDC_PROGRESS_BAR_FRAME), &crect); pt = Point2D(crect.left + (crect.right - crect.left) / 2, crect.top + (crect.bottom - crect.top) / 2); } else { - int progress = PlayerProgress[i]; - int percent = Percentage; - if (progress > percent) { - for (int j = 0; j < ARRAY_SIZE(_progress_messages); j++) { - if (_progress_messages[j].Progress <= progress && _progress_messages[j].Progress > percent) { - Fancy_Text_Print(Fetch_String(_progress_messages[j].Text), *HiddenSurface, HiddenSurface->Get_Rect(), Pos + Point2D(0, 10 * j), Fetch_Scheme_By_Name("Green"), 0, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); - Sound_Effect(VocClass::From_Name("Notify"), 0.4f); - Percentage = _progress_messages[j].Progress; - if (surface == HiddenSurface) { - Update_Visible_Surface(); - } - break; - } + // The threshold was noted as the progress moved; only its text is drawn here. + while (Printed < Reached) { + Printed++; + Fancy_Text_Print(Fetch_String(_progress_messages[Printed].Text), *HiddenSurface, HiddenSurface->Get_Rect(), Pos + Point2D(0, 10 * Printed), Fetch_Scheme_By_Name("Green"), 0, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); + if (surface == HiddenSurface) { + Update_Visible_Surface(); } } return; @@ -318,6 +348,7 @@ void ProgressScreenClass::Set_Progress_Percent(int index, double value, Point2D PlayerProgress[index] = (MainProgress / 100.0) * value; if (PlayerProgress[index] != prog1) { + Advance_Milestone(index, pt); if (Dialog != NULL) { SendMessage(Dialog, WM_PAINT, 0, 0); } else { @@ -341,6 +372,7 @@ void ProgressScreenClass::Add_Progress_Percent(int index, double value, Point2D PlayerProgress[index] += (MainProgress / 100.0) * value; if (PlayerProgress[index] != prog1) { + Advance_Milestone(index, pt); if (Dialog != NULL) { SendMessage(Dialog, WM_PAINT, 0, 0); } else { diff --git a/code/progress.h b/code/progress.h index 094397b12..aaa8e632f 100644 --- a/code/progress.h +++ b/code/progress.h @@ -47,6 +47,7 @@ class ProgressScreenClass void End_Dialog(void); private: static INT_PTR CALLBACK Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); + void Advance_Milestone(int index, Point2D pt); public: /* @@ -115,6 +116,15 @@ class ProgressScreenClass * only when the progress first passes its threshold, so that none of them repeat. */ int Percentage; + + /* + * The index of the loading message whose threshold was last crossed, and of the one + * last drawn. Crossing a threshold is noted, and its sound played, when the progress + * moves; the message is drawn by the next paint, so a repaint cannot repeat the sound + * and a paint that never comes cannot lose it. Both are -1 before the first message. + */ + int Reached; + int Printed; }; extern ProgressScreenClass Progress; diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 08d8b3c20..0960be0ec 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -665,11 +665,14 @@ Invariants the split preserves: Progress tracking, clamping, milestone text and sound, and the readiness queries that `scenario.cpp` consumes move out of the draw path into shared behavior, so a repaint cannot repeat a milestone sound and a hidden -presentation cannot lose one. The screen exposes phase, progress, status, and -the operations the loader supports; no cancellation is added to a loader that -cannot cancel. Loading stays on its thread with explicit cooperative service -points that drain nothing unrelated while scenario objects are being -replaced, and the first paint happens before long work begins. +presentation cannot lose one. The milestone half landed with step 6: +`ProgressScreenClass::Advance_Milestone` notes a threshold crossing and plays +its sound when the progress moves, and the paint draws the text still owed. +The screen exposes phase, progress, status, and the operations the loader +supports; no cancellation is added to a loader that cannot cancel. Loading +stays on its thread with explicit cooperative service points that drain +nothing unrelated while scenario objects are being replaced, and the first +paint happens before long work begins. MSEngine screens (campaign selection, briefings, score screens) are features with animation, audio, and navigation. RmlUi can replace their layout and @@ -754,10 +757,11 @@ beyond an ASCII test document. eligible themes, selection, availability, shuffle and repeat, immediate previews, play and stop, both templates, frontend and in-game service paths. Runtime evidence still owed. -6. **Progress and wait** (S, leaf). `IDD_PROGRESS_WAIT`, the saving and - loading boxes in `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the - modeless box they show, the `` element, milestone effects moved - out of drawing. +6. **Progress and wait** (S, leaf, two changes; the first landed: milestone + effects moved out of drawing). `IDD_PROGRESS_WAIT`, the saving and loading + boxes in `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless + box they show. A progress bar needs no engine surface, so the `` + element waits for the map preview in step 10. 7. **Options family** (L, two changes each). Main options, display with its timed rollback, game controls (three variants), keyboard with the hotkey capture control, the display-mode confirmation, abort and surrender. From f7fc88d12acb4bed6fe4dfcef8ae2ce247fdce01 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 07:53:44 +0300 Subject: [PATCH 10/52] Show the saving and loading notices as RmlUi documents --- code/loaddlg.cpp | 21 ++---- code/progress.cpp | 21 +++++- code/progress.h | 7 ++ code/savemgr.cpp | 36 +++------ code/ui/uishell.cpp | 47 ++++++++++++ code/ui/uishell.h | 9 +++ code/ui/uiwaitbox.cpp | 79 +++++++++++++++++++ code/ui/uiwaitbox.h | 65 ++++++++++++++++ code/ui/uiwaitboxdlg.cpp | 109 +++++++++++++++++++++++++++ docs/UI_DESIGN.md | 18 +++-- manual/changes/rmlui-wait-boxes.md | 13 ++++ manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/ui-files.md | 2 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 64 ++++++++++++++++ ui/wait.rcss | 61 +++++++++++++++ ui/wait.rml | 14 ++++ 17 files changed, 520 insertions(+), 49 deletions(-) create mode 100644 code/ui/uiwaitbox.cpp create mode 100644 code/ui/uiwaitbox.h create mode 100644 code/ui/uiwaitboxdlg.cpp create mode 100644 manual/changes/rmlui-wait-boxes.md create mode 100644 ui/wait.rcss create mode 100644 ui/wait.rml diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index 7d5467f38..72e0da009 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -53,6 +53,7 @@ #include "language/language.h" #include "msgbox.h" #include "ownrdraw.h" +#include "ui/uiwaitbox.h" #include "saveload.h" #include "savemgr.h" #include "savever.h" @@ -852,16 +853,12 @@ int __cdecl LoadOptionsClass::Compare(const void * p1, const void * p2) /// bool; Was the game loaded? bool LoadOptionsClass::Load_File(const char * file_name) { - HWND dialog = OwnerDraw::Custom_Message_Box(Fetch_String(TXT_LOADING), NULL, NULL); - if (dialog != 0) { - OwnerDraw::Display_Dialog(dialog); - } + UIWaitBoxClass box; + box.Show(Fetch_String(TXT_LOADING)); ScenarioActive = false; TacticalActive = false; bool loaded = Load_Game(file_name); - if (dialog != 0) { - OwnerDraw::End_Dialog(dialog); - } + box.Hide(); return(loaded); } @@ -875,15 +872,11 @@ bool LoadOptionsClass::Load_File(const char * file_name) /// bool; Was the game saved? bool LoadOptionsClass::Save_File(const char * file_name, const char * descr) { - HWND dialog = OwnerDraw::Custom_Message_Box(Fetch_String(TXT_SAVING_GAME), NULL, NULL); - if (dialog != 0) { - OwnerDraw::Display_Dialog(dialog); - } + UIWaitBoxClass box; + box.Show(Fetch_String(TXT_SAVING_GAME)); bool saved = SaveManager.Request_Save_Game(file_name, descr, false, SaveManagerClass::NoticeType::Requested); - if (dialog != 0) { - OwnerDraw::End_Dialog(dialog); - } + box.Hide(); return(saved); } diff --git a/code/progress.cpp b/code/progress.cpp index e1c559663..f4cbc9352 100644 --- a/code/progress.cpp +++ b/code/progress.cpp @@ -103,7 +103,7 @@ void ProgressScreenClass::Initialize(double progress, int count, bool usedialog) IsActive = true; if (usedialog) { - if (Dialog == NULL) { + if (Dialog == NULL && !Box.Is_Shown()) { Begin_Dialog(); } } else { @@ -223,7 +223,7 @@ double ProgressScreenClass::Get_Current_Progress(void) const // the message itself waits for the next paint. void ProgressScreenClass::Advance_Milestone(int index, Point2D pt) { - if (Dialog != NULL || PlayerCount != 1 || Shape == NULL || pt != Point2D(-1,-1)) { + if (Dialog != NULL || Box.Is_Shown() || PlayerCount != 1 || Shape == NULL || pt != Point2D(-1,-1)) { return; } @@ -258,6 +258,11 @@ void ProgressScreenClass::Advance_Milestone(int index, Point2D pt) /// Nothing is drawn until Initialize has been called. void ProgressScreenClass::Display_Progress(Point2D xpt) { + // The document draws the bar itself. + if (Box.Is_Shown()) { + return; + } + if (IsActive) { Point2D pt = xpt; @@ -351,6 +356,8 @@ void ProgressScreenClass::Set_Progress_Percent(int index, double value, Point2D Advance_Milestone(index, pt); if (Dialog != NULL) { SendMessage(Dialog, WM_PAINT, 0, 0); + } else if (Box.Is_Shown()) { + Box.Set_Fraction(Get_Current_Progress(index)); } else { Display_Progress(pt); } @@ -375,6 +382,8 @@ void ProgressScreenClass::Add_Progress_Percent(int index, double value, Point2D Advance_Milestone(index, pt); if (Dialog != NULL) { SendMessage(Dialog, WM_PAINT, 0, 0); + } else if (Box.Is_Shown()) { + Box.Set_Fraction(Get_Current_Progress(index)); } else { Display_Progress(pt); } @@ -390,6 +399,12 @@ void ProgressScreenClass::Add_Progress_Percent(int index, double value, Point2D /// void ProgressScreenClass::Begin_Dialog(void) { + // The document draws its own bar; the Win32 dialog has it painted by Display_Progress. + if (Box.Show_Document("Working - Please Wait", true)) { + Box.Set_Fraction(0.0); + return; + } + Dialog = OwnerDraw::Begin_Dialog(IDD_PROGRESS_WAIT, ProgressScreenClass::Dialog_Proc); if (Dialog != NULL) { SetWindowLongPtr(Dialog, DWLP_USER, (LONG_PTR)this); @@ -406,6 +421,8 @@ void ProgressScreenClass::Begin_Dialog(void) /// void ProgressScreenClass::End_Dialog(void) { + Box.Hide(); + if (Dialog != NULL) { OwnerDraw::End_Dialog(Dialog); Dialog = NULL; diff --git a/code/progress.h b/code/progress.h index aaa8e632f..3659bb9d5 100644 --- a/code/progress.h +++ b/code/progress.h @@ -13,6 +13,7 @@ #include "point.h" #include "sun.h" +#include "ui/uiwaitbox.h" #include "win.h" class ShapeSet; @@ -104,6 +105,12 @@ class ProgressScreenClass */ HWND Dialog; + /* + * The document that carries the dialog presentation when the shell draws it; the + * Win32 dialog above is used when it cannot. + */ + UIWaitBoxClass Box; + /* * This is the center of the progress bar display, expressed in screen pixels. A job * that names no spot of its own is centered on the hidden surface. diff --git a/code/savemgr.cpp b/code/savemgr.cpp index ec62640cd..fa7a81297 100644 --- a/code/savemgr.cpp +++ b/code/savemgr.cpp @@ -26,6 +26,7 @@ #include "netdlg.h" #include "netglobal.h" #include "ownrdraw.h" +#include "ui/uiwaitbox.h" #include "rawfile.h" #include "rules.h" #include "saveload.h" @@ -150,17 +151,12 @@ void SaveManagerClass::Process_Pending_Save_Game(void) PendingSaveNotice = NoticeType::None; if (MultiplayerSavingAllowed) { - HWND dialog = 0; + UIWaitBoxClass box; if (!quiet) { - dialog = OwnerDraw::Custom_Message_Box(Fetch_String(TXT_SAVING_GAME), NULL, NULL); - } - if (dialog != 0) { - OwnerDraw::Display_Dialog(dialog); + box.Show(Fetch_String(TXT_SAVING_GAME)); } bool saved = Save_Game(file_name.c_str(), description.c_str()); - if (dialog != 0) { - OwnerDraw::End_Dialog(dialog); - } + box.Hide(); Record_Save_Outcome(notice, saved); if (saved && SpawnCopyPending) { Write_Spawn_Copy(); @@ -314,15 +310,11 @@ void SaveManagerClass::Quick_Save_Service(void) char description[512]; std::snprintf(description, sizeof(description), Fetch_String(TXT_QUICKSAVE_DESCRIPTION), Scen->Description); - HWND dialog = OwnerDraw::Custom_Message_Box(Fetch_String(TXT_SAVING_GAME), NULL, NULL); - if (dialog != 0) { - OwnerDraw::Display_Dialog(dialog); - } + UIWaitBoxClass box; + box.Show(Fetch_String(TXT_SAVING_GAME)); Request_Save_Game(Quick_Save_File_Name(Single_Player_Kind()).c_str(), description, false, NoticeType::Requested); - if (dialog != 0) { - OwnerDraw::End_Dialog(dialog); - } + box.Hide(); } @@ -593,28 +585,24 @@ void SaveManagerClass::Process_Pending_Load_Game(void) Session.Suspended++; TacticalActive = false; - HWND dialog = OwnerDraw::Custom_Message_Box(Fetch_String(TXT_LOADING_SAVED_GAME), NULL, NULL); - if (dialog != 0) { - OwnerDraw::Display_Dialog(dialog); - } + UIWaitBoxClass box; + box.Show(Fetch_String(TXT_LOADING_SAVED_GAME)); int shown = -1; while (!MultiplayerLoad.Is_Due(Monotonic_Milliseconds())) { int seconds = MultiplayerLoad.Seconds_Left(Monotonic_Milliseconds()); - if (dialog != 0 && seconds != shown) { + if (box.Is_Shown() && seconds != shown) { shown = seconds; char buffer[128]; std::snprintf(buffer, sizeof(buffer), Fetch_String(seconds == 1 ? TXT_LOADING_IN_SECOND : TXT_LOADING_IN_SECONDS), seconds); - OwnerDraw::Set_Custom_Message_Box_Text(dialog, buffer); + box.Set_Text(buffer); } OwnerDraw::Dialog_Message_Handler(); Sleep(10); } - if (dialog != 0) { - OwnerDraw::End_Dialog(dialog); - } + box.Hide(); Session.Suspended--; TacticalActive = true; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 739ce75a8..72f5e4d8b 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -879,6 +879,53 @@ UIResult UI_Run_Modal(UIRmlViewClass & view) } +bool UI_Show_Modeless(UIRmlViewClass & view) +{ + if (!_Ready || !_FontLoaded || _InContext) { + return(false); + } + + int errors = _System.Error_Count(); + if (!view.Prepare(*_Context) || _System.Error_Count() != errors) { + DebugString("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); + view.Release(); + return(false); + } + + view.Presenter().Refresh(); + view.Sync(); + view.Show(false); + UI_Refresh(); + return(true); +} + + +void UI_Hide_Modeless(UIRmlViewClass & view) +{ + view.Release(); + + if (_Ready && !_InContext) { + _InContext = true; + _Context->Update(); + _InContext = false; + Video_Mark_Overlay_Dirty(); + Video_Present_If_Dirty(); + } +} + + +void UI_Refresh(void) +{ + if (!_Ready || _InContext) { + return; + } + + UI_Tick(); + Video_Mark_Overlay_Dirty(); + Video_Present_If_Dirty(); +} + + bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) { if (!_Ready || _InHook || hwnd != MainWindow) { diff --git a/code/ui/uishell.h b/code/ui/uishell.h index f88030072..8bb4a0485 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -38,6 +38,15 @@ bool UI_Legacy_Dialog_Visible(void); // ends, then releases it. The view's presenter must outlive the call. UIResult UI_Run_Modal(UIRmlViewClass & view); +// Shows a document beside the game without taking its input: a notice the caller updates +// while it works. It is drawn at once, because such a caller pumps nothing. False when the +// shell or the document is not ready, so the caller opens its Win32 presentation. +bool UI_Show_Modeless(UIRmlViewClass & view); +void UI_Hide_Modeless(UIRmlViewClass & view); + +// Advances the documents and presents the overlay now. +void UI_Refresh(void); + // The frame moved or changed size inside the window. void UI_On_Video_Change(void); diff --git a/code/ui/uiwaitbox.cpp b/code/ui/uiwaitbox.cpp new file mode 100644 index 000000000..50d25be55 --- /dev/null +++ b/code/ui/uiwaitbox.cpp @@ -0,0 +1,79 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uiwaitbox.h" + +#include "ui/uirmlview.h" + +#include + + +UIWaitBoxPresenterClass::UIWaitBoxPresenterClass(std::string text, bool bar) : + Text(std::move(text)), + Bar(bar) +{ +} + + +void UIWaitBoxPresenterClass::Execute(UIIntent const &) +{ +} + + +void UIWaitBoxPresenterClass::Refresh(void) +{ +} + + +void UIWaitBoxPresenterClass::Set_Fraction(double fraction) +{ + if (fraction < 0.0) { + fraction = 0.0; + } + if (fraction > 1.0) { + fraction = 1.0; + } + Percent = (int)(fraction * 100.0 + 0.5); +} + + +namespace +{ + +class UIWaitBoxViewClass : public UIRmlViewClass +{ + public: + explicit UIWaitBoxViewClass(UIWaitBoxPresenterClass & presenter) : + UIRmlViewClass(presenter, "wait.rml", "wait"), + Data(presenter) + { + } + + virtual void Sync(void) override + { + Model.DirtyAllVariables(); + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + return(model.Bind("text", &Data.Text) && model.Bind("bar", &Data.Bar) && model.Bind("percent", &Data.Percent)); + } + + private: + UIWaitBoxPresenterClass & Data; +}; + +} + + +std::unique_ptr UI_Wait_Box_View(UIWaitBoxPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uiwaitbox.h b/code/ui/uiwaitbox.h new file mode 100644 index 000000000..6e947339c --- /dev/null +++ b/code/ui/uiwaitbox.h @@ -0,0 +1,65 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" +#include "win.h" + +#include +#include + +class UIRmlViewClass; + + +// A notice shown while the game works: a line of text and, when asked for, a bar. It takes +// no input and raises no intents. +class UIWaitBoxPresenterClass : public UIPresenterClass +{ + public: + UIWaitBoxPresenterClass(std::string text, bool bar); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + // Stores the fraction as a percentage, clamped to 0 through 100. + void Set_Fraction(double fraction); + + std::string Text; + bool Bar; + int Percent = 0; +}; + + +// The RmlUi view over a wait box presenter, bound to wait.rml. The presenter must outlive it. +std::unique_ptr UI_Wait_Box_View(UIWaitBoxPresenterClass & presenter); + + +// The notice a caller shows while it works: a document when the shell can draw one and no +// Win32 dialog is on screen, else the Win32 box. It hides itself when it goes out of scope. +class UIWaitBoxClass +{ + public: + UIWaitBoxClass(void); + ~UIWaitBoxClass(void); + + void Show(char const * text); + // The document alone. False shows nothing, so the caller can open its own Win32 presentation. + bool Show_Document(char const * text, bool bar); + void Set_Text(char const * text); + void Set_Fraction(double fraction); + void Hide(void); + + bool Is_Shown(void) const; + + private: + HWND Dialog; + std::unique_ptr Presenter; + std::unique_ptr View; +}; diff --git a/code/ui/uiwaitboxdlg.cpp b/code/ui/uiwaitboxdlg.cpp new file mode 100644 index 000000000..7a2f71b1b --- /dev/null +++ b/code/ui/uiwaitboxdlg.cpp @@ -0,0 +1,109 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the wait box: the class the save, load and progress code shows while it +// works. The presenter and view live in uiwaitbox.cpp so that the test harness can drive them +// without the engine. + +#include "ui/uiwaitbox.h" + +#include "ownrdraw.h" +#include "ui/uirmlview.h" +#include "ui/uishell.h" + + +UIWaitBoxClass::UIWaitBoxClass(void) : + Dialog(NULL) +{ +} + + +UIWaitBoxClass::~UIWaitBoxClass(void) +{ + Hide(); +} + + +void UIWaitBoxClass::Show(char const * text) +{ + Hide(); + + if (Show_Document(text, false)) { + return; + } + + Dialog = OwnerDraw::Custom_Message_Box(text, NULL, NULL); + if (Dialog != NULL) { + OwnerDraw::Display_Dialog(Dialog); + } +} + + +bool UIWaitBoxClass::Show_Document(char const * text, bool bar) +{ + Hide(); + + // A visible Win32 dialog takes the mouse before a document can, so a notice over one stays Win32. + if (!UI_Use_Rml() || UI_Legacy_Dialog_Visible()) { + return(false); + } + + Presenter = std::make_unique((text != NULL) ? text : "", bar); + View = UI_Wait_Box_View(*Presenter); + + if (!UI_Show_Modeless(*View)) { + View.reset(); + Presenter.reset(); + return(false); + } + return(true); +} + + +void UIWaitBoxClass::Set_Text(char const * text) +{ + if (View != nullptr) { + Presenter->Text = (text != NULL) ? text : ""; + View->Sync(); + UI_Refresh(); + } else if (Dialog != NULL) { + OwnerDraw::Set_Custom_Message_Box_Text(Dialog, text); + } +} + + +void UIWaitBoxClass::Set_Fraction(double fraction) +{ + if (View != nullptr) { + Presenter->Set_Fraction(fraction); + View->Sync(); + UI_Refresh(); + } +} + + +void UIWaitBoxClass::Hide(void) +{ + if (View != nullptr) { + UI_Hide_Modeless(*View); + View.reset(); + Presenter.reset(); + } + + if (Dialog != NULL) { + OwnerDraw::End_Dialog(Dialog); + Dialog = NULL; + } +} + + +bool UIWaitBoxClass::Is_Shown(void) const +{ + return(View != nullptr || Dialog != NULL); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 0960be0ec..5b1091d7c 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -193,7 +193,7 @@ written. | `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | | `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | landed, with `uiscreen.cpp` and `uirmlview.cpp` carrying the bodies | | `uidev.h`, `uidev.cpp` | ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | -| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link); the message boxes follow as `uimsgbox.*` and `uimsgboxdlg.cpp`; the sound options as `uisound.*` (presenter, service interface and view) and `uisounddlg.cpp` (engine service, state and entry), with the Win32 dialog as a second view over the same presenter | +| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link); the message boxes follow as `uimsgbox.*` and `uimsgboxdlg.cpp`; the sound options as `uisound.*` (presenter, service interface and view) and `uisounddlg.cpp` (engine service, state and entry), with the Win32 dialog as a second view over the same presenter; the wait boxes as `uiwaitbox.*` (presenter, view and the `UIWaitBoxClass` the save, load and progress code shows) and `uiwaitboxdlg.cpp` | Shipped UI files (documents, styles, images, the font) live in `ui/` at the repository root. The build copies the tree beside the executable as it copies @@ -498,7 +498,9 @@ served at the next safe point. Non-modal documents are updated by a `UI_Tick` call in `Main_Loop` next to `Map.Input`, and by one at the end of each pass of the legacy dialog driver so that a document stays alive under a menu, and are rendered by every -present. +present. A notice a caller shows while it works goes through +`UI_Show_Modeless`, `UI_Refresh` and `UI_Hide_Modeless`, which tick and +present at once because such a caller pumps nothing. Teardown order: mark the screen closing and invalidate its token, then drop focus and capture and discard its intents, then detach listeners and data @@ -757,11 +759,13 @@ beyond an ASCII test document. eligible themes, selection, availability, shuffle and repeat, immediate previews, play and stop, both templates, frontend and in-game service paths. Runtime evidence still owed. -6. **Progress and wait** (S, leaf, two changes; the first landed: milestone - effects moved out of drawing). `IDD_PROGRESS_WAIT`, the saving and loading - boxes in `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless - box they show. A progress bar needs no engine surface, so the `` - element waits for the map preview in step 10. +6. **Progress and wait** (S, leaf, two changes, landed: milestone effects + moved out of drawing, then `UIWaitBoxClass` over `wait.rml` for the saving + and loading boxes and the progress dialog, with the Win32 boxes kept + behind it). `IDD_PROGRESS_WAIT`, the saving and loading boxes in + `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless box they + show. A progress bar needs no engine surface, so the `` element + waits for the map preview in step 10. Runtime evidence still owed. 7. **Options family** (L, two changes each). Main options, display with its timed rollback, game controls (three variants), keyboard with the hotkey capture control, the display-mode confirmation, abort and surrender. diff --git a/manual/changes/rmlui-wait-boxes.md b/manual/changes/rmlui-wait-boxes.md new file mode 100644 index 000000000..e0c07b69b --- /dev/null +++ b/manual/changes/rmlui-wait-boxes.md @@ -0,0 +1,13 @@ +--- +title: Show the saving and loading notices as RmlUi documents +category: feature +release: 0.2.0 +targets: +- type: system + id: ui-files + effect: changed +credit: +- ZivDero +--- + +The notices the game shows while it saves or loads, for a menu save, a quick save, a load from the load dialog and the multiplayer load countdown, are now RmlUi documents where no Win32 dialog is on screen, and a document notice is drawn before the work starts rather than at the next paint. A progress box raised over a Win32 dialog, as the map generator and the scenario transfer raise theirs, stays a Win32 box, and so does every notice while `LegacyDialogs=yes`. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index baf390547..1d308f7d1 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes and the sound options are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index c9be884ee..bec47283a 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -23,4 +23,4 @@ A document names an engine string as `[[TXT_NAME]]`, using the identifier names ## Choosing the Win32 dialogs -[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes and the sound options are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index b54d83ddb..0d5a1bdbd 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(UIShell "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uisound.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiversion.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uiwaitbox.cpp" ) target_compile_features(UIShell PRIVATE cxx_std_20) diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 2207c5937..2c111ef00 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -15,6 +15,7 @@ // behaves at the frame's edges. #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include +#include #include #include FT_FREETYPE_H #include @@ -34,6 +36,7 @@ #include "ui/uiscreen.h" #include "ui/uisound.h" #include "ui/uiversion.h" +#include "ui/uiwaitbox.h" #include "opents_strings.h" @@ -938,6 +941,66 @@ void Test_Sound_Screen(Rml::Context & context, CountingSystemInterfaceClass & sy } +// Drives the wait box: the text follows the presenter, the frame appears only with a bar, and +// the fill follows the percentage. +void Test_Wait_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + UIWaitBoxPresenterClass presenter("Mission saving - Please Wait...", false); + std::unique_ptr view = UI_Wait_Box_View(presenter); + + Check(view->Prepare(context), "the wait box view prepares against the test context"); + view->Show(false); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the wait box raises no RmlUi warning or error"); + + Rml::ElementDocument * document = view->Document(); + Rml::Element * text = document->GetElementById("text"); + Check(text != nullptr && text->GetInnerRML() == "Mission saving - Please Wait...", "the wait box shows its text"); + + Rml::Element * frame = document->GetElementById("frame"); + Check(frame != nullptr && !frame->IsVisible(), "a wait box without a bar hides the frame"); + + presenter.Text = "Loading in 3 seconds..."; + view->Sync(); + context.Update(); + Check(text != nullptr && text->GetInnerRML() == "Loading in 3 seconds...", "the wait box text follows the presenter"); + + view->Release(); + context.Update(); + } + + { + UIWaitBoxPresenterClass presenter("Working - Please Wait", true); + presenter.Set_Fraction(0.5); + std::unique_ptr view = UI_Wait_Box_View(presenter); + + Check(view->Prepare(context), "a wait box with a bar prepares"); + view->Show(false); + context.Update(); + + Rml::ElementDocument * document = view->Document(); + Rml::Element * frame = document->GetElementById("frame"); + Rml::Element * fill = document->GetElementById("fill"); + Check(frame != nullptr && frame->IsVisible(), "a wait box with a bar shows the frame"); + + Rml::ElementProgress * progress = rmlui_dynamic_cast(fill); + Check(progress != nullptr && std::fabs(progress->GetValue() - 50.0f) < 0.01f, "the fill stands at fifty at fifty percent"); + + presenter.Set_Fraction(1.5); + view->Sync(); + context.Update(); + Check(presenter.Percent == 100 && progress != nullptr && std::fabs(progress->GetValue() - 100.0f) < 0.01f, "the fraction clamps to a full bar"); + + view->Release(); + context.Update(); + } +} + + void Test_Documents(void) { std::filesystem::path directory(OPENTS_UI_DIR); @@ -1022,6 +1085,7 @@ void Test_Documents(void) Test_Version_Screen(*context, system); Test_Message_Box_Screen(*context, system); Test_Sound_Screen(*context, system); + Test_Wait_Box_Screen(*context, system); } if (context != nullptr) { diff --git a/ui/wait.rcss b/ui/wait.rcss new file mode 100644 index 000000000..fb58be758 --- /dev/null +++ b/ui/wait.rcss @@ -0,0 +1,61 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, p +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 327dp; + height: 104dp; + margin-left: -164dp; + margin-top: -52dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +#text +{ + position: absolute; + left: 33dp; + top: 20dp; + width: 261dp; + height: 37dp; + line-height: 18dp; + text-align: center; + white-space: pre-wrap; +} + +#frame +{ + position: absolute; + left: 88dp; + top: 62dp; + width: 150dp; + height: 22dp; + background-color: #0c1116; + border: 1dp #6f95a8; +} + +#fill +{ + display: block; + width: 100%; + height: 100%; +} + +#fill fill +{ + background-color: #6f95a8; +} diff --git a/ui/wait.rml b/ui/wait.rml new file mode 100644 index 000000000..62b9546cb --- /dev/null +++ b/ui/wait.rml @@ -0,0 +1,14 @@ + + + Please wait + + + +
+

{{text}}

+
+ +
+
+ +
From 9fc53a3fe48de544c53bfe834c71eb72373b2af4 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 08:05:14 +0300 Subject: [PATCH 11/52] Put the game controls behaviour behind a presenter --- code/gamedlg.cpp | 201 +++++++++++++++++++---------------- code/gamedlg.h | 3 - code/ui/uigamectrl.cpp | 95 +++++++++++++++++ code/ui/uigamectrl.h | 107 +++++++++++++++++++ code/ui/uigamectrldlg.cpp | 146 +++++++++++++++++++++++++ docs/UI_DESIGN.md | 11 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 93 ++++++++++++++++ 8 files changed, 556 insertions(+), 101 deletions(-) create mode 100644 code/ui/uigamectrl.cpp create mode 100644 code/ui/uigamectrl.h create mode 100644 code/ui/uigamectrldlg.cpp diff --git a/code/gamedlg.cpp b/code/gamedlg.cpp index 4e1cc50e6..d1b8b2c64 100644 --- a/code/gamedlg.cpp +++ b/code/gamedlg.cpp @@ -48,6 +48,8 @@ #include "queue.h" #include "session.h" #include "techno.h" +#include "ui/uigamectrl.h" +#include "ui/uiscreen.h" #include "special.hh" @@ -87,6 +89,72 @@ int GameDifficultyNames[OptionsClass::MAX_DIFFICULTY_SETTING] = { INT_PTR CALLBACK Game_Controls_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); void Game_Controls_Dialog_On_COMMAND(HWND window, UINT message, WPARAM wparam, LPARAM lparam); +// The presenter the dialog procedure is a view of, for the life of one Dialog call. +static UIGameControlsPresenterClass * _Presenter = NULL; + + +static void Queue_And_Drain(UIGameControlsPresenterClass & presenter, char const * name, int value = 0) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + presenter.Queue(intent); + presenter.Drain(); +} + + +// Hands the controls' current values to the presenter as the accept path always read them: +// a speed or scroll slider shows its value reversed. +static void Read_Controls(HWND window, UIGameControlsPresenterClass & presenter) +{ + HWND handle = GetDlgItem(window, IDC_GAME_SPEED_SLIDER); + if (handle) { + Queue_And_Drain(presenter, "speed", (OptionsClass::MAX_SPEED_SETTING-1) - Slider_GetPos(handle)); + } + + handle = GetDlgItem(window, IDC_SCROLL_SPEED_SLIDER); + if (handle) { + Queue_And_Drain(presenter, "scroll", (OptionsClass::MAX_SCROLL_SETTING-1) - Slider_GetPos(handle)); + } + + handle = GetDlgItem(window, IDC_DETAIL_LEVEL_SLIDER); + if (handle) { + Queue_And_Drain(presenter, "detail", Slider_GetPos(handle)); + } + + handle = GetDlgItem(window, IDC_SIDEBAR_TEXT); + if (handle) { + Queue_And_Drain(presenter, "cameo", Button_GetCheck(handle) == TRUE); + } + + handle = GetDlgItem(window, IDC_TARGET_LINES); + if (handle) { + Queue_And_Drain(presenter, "lines", Button_GetCheck(handle) == TRUE); + } + + handle = GetDlgItem(window, IDC_TOOLTIPS); + if (handle) { + Queue_And_Drain(presenter, "tooltips", Button_GetCheck(handle) == TRUE); + } + + handle = GetDlgItem(window, IDC_SCROLL_COASTING); + if (handle) { + Queue_And_Drain(presenter, "coasting", Button_GetCheck(handle) == TRUE); + } + + handle = GetDlgItem(window, IDC_EDGE_SCROLL); + if (handle) { + Queue_And_Drain(presenter, "edge", Button_GetCheck(handle) == TRUE); + } + + if (GameActive == false) { + handle = GetDlgItem(window, IDC_DIFFICULTY_SLIDER); + if (handle) { + Queue_And_Drain(presenter, "difficulty", Slider_GetPos(handle)); + } + } +} + /*********************************************************************************************** * OptionsClass::Process -- Handles all the options graphic interface. * * * @@ -105,6 +173,11 @@ void GameControlsClass::Dialog(void) DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); + UIGameControlsState state; + UI_Game_Controls_State(state); + UIGameControlsPresenterClass presenter(UI_Game_Controls_Service(), state); + _Presenter = &presenter; + if (GameActive == true) { if (Session.Type == GAME_INTERNET) { _Dialog = OwnerDraw::Begin_Dialog(IDD_OPT_CTRL_GAME_WOL, Game_Controls_Dialog_Proc); @@ -129,95 +202,20 @@ void GameControlsClass::Dialog(void) Title_Screen_Restore(); } } - if (res == 1) { - Set(); - Options.Save_Settings(); - } OwnerDraw::End_Dialog(_Dialog); } - DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); -} - - -/// -/// Sets the game options from the game controls dialog. -/// This routine is called when the player accepts the dialog. Each control is asked for -/// its current value and the answer is handed to the option it governs, along with any -/// notification the rest of the game needs -- the map is told to rebuild its cell drawers -/// when the detail level changes, and a game speed change during a network game is issued -/// as an event so that every player stays in step. -/// -void GameControlsClass::Set(void) -{ - HWND handle; - - handle = GetDlgItem(_Dialog, IDC_GAME_SPEED_SLIDER); - if (handle) { - int gamespeed = (OptionsClass::MAX_SPEED_SETTING-1) - Slider_GetPos(handle); - if (Options.GameSpeed != gamespeed) { - if (GameActive == true && Session.Type != GAME_NORMAL && Session.Type != GAME_SKIRMISH) { - OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::GAMESPEED, gamespeed)); - } else { - Options.GameSpeed = gamespeed; - } - } - } - - handle = GetDlgItem(_Dialog, IDC_SCROLL_SPEED_SLIDER); - if (handle) { - Options.ScrollRate = (OptionsClass::MAX_SCROLL_SETTING-1) - Slider_GetPos(handle); - } - - handle = GetDlgItem(_Dialog, IDC_DETAIL_LEVEL_SLIDER); - if (handle) { - int detailevel = Slider_GetPos(handle); - if (Options.DetailLevel != detailevel) { - Options.DetailLevel = detailevel; - Map.Reinit_Cell_Drawers(); - } - } + _Presenter = NULL; - handle = GetDlgItem(_Dialog, IDC_SIDEBAR_TEXT); - if (handle) { - bool cameotext = Button_GetCheck(handle) == TRUE; - if (Options.SidebarCameoText != cameotext) { - Options.SidebarCameoText = cameotext; - Map.Toggle_Cameo_Text(cameotext); - } + // The Sound and Keyboard buttons accept the settings and name the screen that follows. + if (presenter.Next == UIGameControlsPresenterClass::NEXT_SOUND) { + SpecialDialog = SDLG_SOUND; + } else if (presenter.Next == UIGameControlsPresenterClass::NEXT_KEYBOARD) { + SpecialDialog = SDLG_KEYBOARD; } - handle = GetDlgItem(_Dialog, IDC_TARGET_LINES); - if (handle) { - Options.ActionLines = Button_GetCheck(handle) == TRUE; - TechnoClass::Set_Action_Lines(Options.ActionLines); - } - - handle = GetDlgItem(_Dialog, IDC_TOOLTIPS); - if (handle) { - Options.ToolTips = Button_GetCheck(handle) == TRUE; - if (ToolTips != NULL && GameActive == true) { - ToolTips->Activate(Options.ToolTips); - } - } - - handle = GetDlgItem(_Dialog, IDC_SCROLL_COASTING); - if (handle) { - Options.ScrollMethod = Button_GetCheck(handle) == TRUE ? 0 : 1; - } - - handle = GetDlgItem(_Dialog, IDC_EDGE_SCROLL); - if (handle) { - Options.AutoScroll = Button_GetCheck(handle) == TRUE; - } - - if (GameActive == false) { - handle = GetDlgItem(_Dialog, IDC_DIFFICULTY_SLIDER); - if (handle) { - Options.Difficulty = Slider_GetPos(handle); - } - } + DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); } @@ -237,65 +235,71 @@ INT_PTR CALLBACK Game_Controls_Dialog_Proc(HWND window, UINT message, WPARAM wpa INT_PTR rc = OwnerDraw::Default_Dialog_Proc(window, message, wparam, lparam); if (rc == 0) { + UIGameControlsPresenterClass * presenter = _Presenter; + if (presenter == NULL) { + return(FALSE); + } + UIGameControlsState const & state = presenter->State; + switch (message) { case WM_INITDIALOG: handle = GetDlgItem(window, IDC_GAME_SPEED_SLIDER); if (handle) { SendMessage(handle, OD_TRACKNUMBERS, 0, 0); Slider_SetRange(handle, 0, (OptionsClass::MAX_SPEED_SETTING-1)); - Slider_SetPos(handle, (OptionsClass::MAX_SPEED_SETTING-1) - Options.GameSpeed); + Slider_SetPos(handle, (OptionsClass::MAX_SPEED_SETTING-1) - state.Speed); } handle = GetDlgItem(window, IDC_SCROLL_SPEED_SLIDER); if (handle) { SendMessage(handle, OD_TRACKNUMBERS, 0, 0); Slider_SetRange(handle, 0, (OptionsClass::MAX_SCROLL_SETTING-1)); - Slider_SetPos(handle, (OptionsClass::MAX_SCROLL_SETTING-1) - Options.ScrollRate); + Slider_SetPos(handle, (OptionsClass::MAX_SCROLL_SETTING-1) - state.Scroll); } handle = GetDlgItem(window, IDC_DETAIL_LEVEL_SLIDER); if (handle) { SendMessage(handle, OD_TRACKNUMBERS, 0, 0); Slider_SetRange(handle, 0, (OptionsClass::MAX_DETAIL_SETTING-1)); - Slider_SetPos(handle, Options.DetailLevel); + Slider_SetPos(handle, state.Detail); } handle = GetDlgItem(window, IDC_SIDEBAR_TEXT); if (handle) { - Button_SetCheck(handle, Options.SidebarCameoText != false); + Button_SetCheck(handle, state.CameoText); } handle = GetDlgItem(window, IDC_TARGET_LINES); if (handle) { - Button_SetCheck(handle, Options.ActionLines != false); + Button_SetCheck(handle, state.ActionLines); } handle = GetDlgItem(window, IDC_TOOLTIPS); if (handle) { - Button_SetCheck(handle, Options.ToolTips != false); + Button_SetCheck(handle, state.ToolTips); } handle = GetDlgItem(window, IDC_SCROLL_COASTING); if (handle) { - Button_SetCheck(handle, Options.ScrollMethod == 0); + Button_SetCheck(handle, state.Coasting); } handle = GetDlgItem(window, IDC_EDGE_SCROLL); if (handle) { - Button_SetCheck(handle, Options.AutoScroll != false); + Button_SetCheck(handle, state.EdgeScroll); } if (GameActive == true) { handle = GetDlgItem(window, IDC_OPT_SOUND_BTN); if (handle) { - EnableWindow(handle, AudioEngine.Is_Available()); + EnableWindow(handle, state.SoundEnabled); } } else { handle = GetDlgItem(window, IDC_DIFFICULTY_SLIDER); if (handle) { SendMessage(handle, OD_TRACKNUMBERS, 0, 0); Slider_SetRange(handle, 0, (OptionsClass::MAX_DIFFICULTY_SETTING-1)); - Slider_SetPos(handle, Options.Difficulty); + Slider_SetPos(handle, state.Difficulty); } } break; @@ -347,29 +351,38 @@ INT_PTR CALLBACK Game_Controls_Dialog_Proc(HWND window, UINT message, WPARAM wpa void Game_Controls_Dialog_On_COMMAND(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { int* retval = (int *)GetWindowLongPtr(window, DWLP_USER); + UIGameControlsPresenterClass * presenter = _Presenter; + if (presenter == NULL) { + return; + } switch ((INT)message) { case IDC_OPT_KEYBOARD_BTN: if (lparam == 0 && GameActive == true) { - SpecialDialog = SDLG_KEYBOARD; + Read_Controls(window, *presenter); + Queue_And_Drain(*presenter, "keyboard"); *retval = IDOK; } break; case IDC_OPT_SOUND_BTN: if (lparam == 0 && GameActive == true) { - SpecialDialog = SDLG_SOUND; + Read_Controls(window, *presenter); + Queue_And_Drain(*presenter, "sound"); *retval = IDOK; } break; case IDOK: if (lparam == 0) { + Read_Controls(window, *presenter); + Queue_And_Drain(*presenter, "ok"); *retval = IDOK; } break; case IDCANCEL: + Queue_And_Drain(*presenter, "cancel"); *retval = IDCANCEL; break; } diff --git a/code/gamedlg.h b/code/gamedlg.h index 959267548..e30cafa0c 100644 --- a/code/gamedlg.h +++ b/code/gamedlg.h @@ -51,9 +51,6 @@ class GameControlsClass return(GameDifficultyNames[difficulty]); } - private: - void Set(void); - private: /* * This is the window handle of the game controls dialog while it is displayed. The diff --git a/code/ui/uigamectrl.cpp b/code/ui/uigamectrl.cpp new file mode 100644 index 000000000..ec0d485a2 --- /dev/null +++ b/code/ui/uigamectrl.cpp @@ -0,0 +1,95 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uigamectrl.h" + +#include "ui/uirmlview.h" + +#include + + +static int Clamp_Level(int level, int count) +{ + if (level < 0) { + return(0); + } + if (level > count - 1) { + return(count - 1); + } + return(level); +} + + +UIGameControlsPresenterClass::UIGameControlsPresenterClass(UIGameControlsServiceClass & service, UIGameControlsState state) : + State(std::move(state)), + Service(service) +{ +} + + +void UIGameControlsPresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "speed") { + State.Speed = Clamp_Level(intent.Value, SPEED_LEVELS); + } else if (intent.Name == "scroll") { + State.Scroll = Clamp_Level(intent.Value, SCROLL_LEVELS); + } else if (intent.Name == "detail") { + State.Detail = Clamp_Level(intent.Value, DETAIL_LEVELS); + } else if (intent.Name == "difficulty") { + State.Difficulty = Clamp_Level(intent.Value, DIFFICULTY_LEVELS); + } else if (intent.Name == "cameo") { + State.CameoText = (intent.Value != 0); + } else if (intent.Name == "lines") { + State.ActionLines = (intent.Value != 0); + } else if (intent.Name == "tooltips") { + State.ToolTips = (intent.Value != 0); + } else if (intent.Name == "coasting") { + State.Coasting = (intent.Value != 0); + } else if (intent.Name == "edge") { + State.EdgeScroll = (intent.Value != 0); + } else if (intent.Name == "ok") { + Apply(); + Result = UI_RESULT_ACCEPTED; + } else if (intent.Name == "sound") { + Apply(); + Next = NEXT_SOUND; + Result = UI_RESULT_ACCEPTED; + } else if (intent.Name == "keyboard") { + Apply(); + Next = NEXT_KEYBOARD; + Result = UI_RESULT_ACCEPTED; + } else if (intent.Name == "cancel") { + Result = UI_RESULT_CANCELLED; + } +} + + +void UIGameControlsPresenterClass::Refresh(void) +{ +} + + +// The order is the one the dialog's accept path has always used. +void UIGameControlsPresenterClass::Apply(void) +{ + if (State.HasSpeed) { + Service.Set_Game_Speed(State.Speed); + } + Service.Set_Scroll_Rate(State.Scroll); + Service.Set_Detail_Level(State.Detail); + Service.Set_Cameo_Text(State.CameoText); + Service.Set_Action_Lines(State.ActionLines); + Service.Set_Tool_Tips(State.ToolTips); + Service.Set_Scroll_Coasting(State.Coasting); + Service.Set_Edge_Scroll(State.EdgeScroll); + if (State.HasDifficulty) { + Service.Set_Difficulty(State.Difficulty); + } + Service.Save(); +} diff --git a/code/ui/uigamectrl.h b/code/ui/uigamectrl.h new file mode 100644 index 000000000..3cf2052da --- /dev/null +++ b/code/ui/uigamectrl.h @@ -0,0 +1,107 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +#include +#include +#include + +class UIRmlViewClass; + + +// The engine calls the game controls dialog makes when the player accepts. The game supplies one +// that reaches the options and the map; the test harness supplies one that records the calls. +class UIGameControlsServiceClass +{ + public: + virtual ~UIGameControlsServiceClass(void) = default; + + virtual void Set_Game_Speed(int speed) = 0; + virtual void Set_Scroll_Rate(int rate) = 0; + virtual void Set_Detail_Level(int level) = 0; + virtual void Set_Cameo_Text(bool on) = 0; + virtual void Set_Action_Lines(bool on) = 0; + virtual void Set_Tool_Tips(bool on) = 0; + virtual void Set_Scroll_Coasting(bool on) = 0; + virtual void Set_Edge_Scroll(bool on) = 0; + virtual void Set_Difficulty(int difficulty) = 0; + virtual void Save(void) = 0; +}; + + +// What the dialog shows: the settings as values (a slider shows speed and scroll rate +// reversed), the five switches, which controls this context has, and the names of the +// slider positions. +struct UIGameControlsState +{ + int Speed = 0; + int Scroll = 0; + int Detail = 0; + int Difficulty = 0; + bool CameoText = false; + bool ActionLines = false; + bool ToolTips = false; + bool Coasting = false; + bool EdgeScroll = false; + bool InGame = false; + bool HasSpeed = true; + bool HasDifficulty = true; + bool SoundEnabled = false; + std::vector SpeedNames; + std::vector ScrollNames; + std::vector DetailNames; + std::vector DifficultyNames; +}; + + +// Holds the edited settings until the player accepts, then applies them in the order the +// dialog always has and saves. Sound and Keyboard accept as well and name the screen to open +// next. Cancel applies nothing. +class UIGameControlsPresenterClass : public UIPresenterClass +{ + public: + enum { + SPEED_LEVELS = 7, + SCROLL_LEVELS = 7, + DETAIL_LEVELS = 3, + DIFFICULTY_LEVELS = 3 + }; + + enum NextType { + NEXT_NONE, + NEXT_SOUND, + NEXT_KEYBOARD + }; + + UIGameControlsPresenterClass(UIGameControlsServiceClass & service, UIGameControlsState state); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + UIGameControlsState State; + NextType Next = NEXT_NONE; + + private: + void Apply(void); + + UIGameControlsServiceClass & Service; +}; + + +// The RmlUi view over a game controls presenter, bound to gamectrl.rml. The presenter must +// outlive it. +std::unique_ptr UI_Game_Controls_View(UIGameControlsPresenterClass & presenter); + +// The game's service and the state of the running game, shared by the Win32 dialog and the +// RmlUi view. +UIGameControlsServiceClass & UI_Game_Controls_Service(void); +void UI_Game_Controls_State(UIGameControlsState & state); diff --git a/code/ui/uigamectrldlg.cpp b/code/ui/uigamectrldlg.cpp new file mode 100644 index 000000000..75efd4755 --- /dev/null +++ b/code/ui/uigamectrldlg.cpp @@ -0,0 +1,146 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the game controls: the service the presenter drives and the state it +// starts from. The presenter lives in uigamectrl.cpp so that the test harness can drive it +// against a recording service. + +#include "ui/uigamectrl.h" + +#include "_map.h" +#include "_tooltip.h" +#include "audio/audioengine.h" +#include "cctooltip.h" +#include "data.h" +#include "gamedlg.h" +#include "globals.h" +#include "goptions.h" +#include "queue.h" +#include "session.h" +#include "techno.h" + + +namespace +{ + +class UIGameControlsEngineServiceClass : public UIGameControlsServiceClass +{ + public: + // A network game changes its speed for everyone through an event, never locally. + virtual void Set_Game_Speed(int speed) override + { + if (Options.GameSpeed != speed) { + if (GameActive == true && Session.Type != GAME_NORMAL && Session.Type != GAME_SKIRMISH) { + OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::GAMESPEED, speed)); + } else { + Options.GameSpeed = speed; + } + } + } + + virtual void Set_Scroll_Rate(int rate) override + { + Options.ScrollRate = rate; + } + + virtual void Set_Detail_Level(int level) override + { + if (Options.DetailLevel != level) { + Options.DetailLevel = level; + Map.Reinit_Cell_Drawers(); + } + } + + virtual void Set_Cameo_Text(bool on) override + { + if (Options.SidebarCameoText != on) { + Options.SidebarCameoText = on; + Map.Toggle_Cameo_Text(on); + } + } + + virtual void Set_Action_Lines(bool on) override + { + Options.ActionLines = on; + TechnoClass::Set_Action_Lines(Options.ActionLines); + } + + virtual void Set_Tool_Tips(bool on) override + { + Options.ToolTips = on; + if (ToolTips != NULL && GameActive == true) { + ToolTips->Activate(Options.ToolTips); + } + } + + virtual void Set_Scroll_Coasting(bool on) override + { + Options.ScrollMethod = on ? 0 : 1; + } + + virtual void Set_Edge_Scroll(bool on) override + { + Options.AutoScroll = on; + } + + virtual void Set_Difficulty(int difficulty) override + { + Options.Difficulty = difficulty; + } + + virtual void Save(void) override + { + Options.Save_Settings(); + } +}; + +UIGameControlsEngineServiceClass _Service; + + +void Fetch_Names(std::vector & names, int const * ids, int count) +{ + names.clear(); + for (int index = 0; index < count; index++) { + names.push_back(Fetch_String(ids[index])); + } +} + +} + + +UIGameControlsServiceClass & UI_Game_Controls_Service(void) +{ + return(_Service); +} + + +// An Internet game has no game speed control, and only the frontend has the difficulty. +void UI_Game_Controls_State(UIGameControlsState & state) +{ + state = UIGameControlsState(); + + state.Speed = Options.GameSpeed; + state.Scroll = Options.ScrollRate; + state.Detail = Options.DetailLevel; + state.Difficulty = Options.Difficulty; + state.CameoText = Options.SidebarCameoText; + state.ActionLines = Options.ActionLines; + state.ToolTips = Options.ToolTips; + state.Coasting = (Options.ScrollMethod == 0); + state.EdgeScroll = Options.AutoScroll; + state.InGame = GameActive; + state.HasSpeed = !(GameActive && Session.Type == GAME_INTERNET); + state.HasDifficulty = !GameActive; + state.SoundEnabled = AudioEngine.Is_Available(); + + Fetch_Names(state.SpeedNames, GameSpeedNames, OptionsClass::MAX_SPEED_SETTING); + Fetch_Names(state.ScrollNames, GameScrollSpeedNames, OptionsClass::MAX_SCROLL_SETTING); + Fetch_Names(state.DetailNames, GameDetailLevelNames, OptionsClass::MAX_DETAIL_SETTING); + Fetch_Names(state.DifficultyNames, GameDifficultyNames, OptionsClass::MAX_DIFFICULTY_SETTING); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 5b1091d7c..44283066d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -766,10 +766,13 @@ beyond an ASCII test document. `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless box they show. A progress bar needs no engine surface, so the `` element waits for the map preview in step 10. Runtime evidence still owed. -7. **Options family** (L, two changes each). Main options, display with its - timed rollback, game controls (three variants), keyboard with the hotkey - capture control, the display-mode confirmation, abort and surrender. - Evidence: settings round-trip through `SUN.INI` unchanged. +7. **Options family** (L, two changes each; the game controls landed their + first: the behaviour sits behind `UIGameControlsPresenterClass` and an + engine service, with the three Win32 templates as the view). Main options, + display with its timed rollback, game controls (three variants), keyboard + with the hotkey capture control, the display-mode confirmation, abort and + surrender. The in-game options menu opens load, save and delete, so it + follows step 9. Evidence: settings round-trip through `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, multiplayer game selection. The `NewMenuClass` drivers keep their loops. 9. **Load, save, delete** (M, two changes). diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 0d5a1bdbd..72ea5db86 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -5,6 +5,7 @@ # point up. add_executable(UIShell "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uigamectrl.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 2c111ef00..ddaa907c0 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -31,6 +31,7 @@ #include #include "ui/uicoord.h" +#include "ui/uigamectrl.h" #include "ui/uimsgbox.h" #include "ui/uirmlview.h" #include "ui/uiscreen.h" @@ -483,6 +484,97 @@ void Test_Sound_Presenter(void) } +// Records the engine calls the game controls presenter makes, in order. +class RecordingGameControlsServiceClass : public UIGameControlsServiceClass +{ + public: + std::vector Calls; + + virtual void Set_Game_Speed(int speed) override { Calls.push_back("speed " + std::to_string(speed)); } + virtual void Set_Scroll_Rate(int rate) override { Calls.push_back("scroll " + std::to_string(rate)); } + virtual void Set_Detail_Level(int level) override { Calls.push_back("detail " + std::to_string(level)); } + virtual void Set_Cameo_Text(bool on) override { Calls.push_back(on ? "cameo on" : "cameo off"); } + virtual void Set_Action_Lines(bool on) override { Calls.push_back(on ? "lines on" : "lines off"); } + virtual void Set_Tool_Tips(bool on) override { Calls.push_back(on ? "tooltips on" : "tooltips off"); } + virtual void Set_Scroll_Coasting(bool on) override { Calls.push_back(on ? "coasting on" : "coasting off"); } + virtual void Set_Edge_Scroll(bool on) override { Calls.push_back(on ? "edge on" : "edge off"); } + virtual void Set_Difficulty(int difficulty) override { Calls.push_back("difficulty " + std::to_string(difficulty)); } + virtual void Save(void) override { Calls.push_back("save"); } + + std::string Joined(void) const + { + std::string all; + for (std::string const & call : Calls) { + all += (all.empty() ? "" : "; ") + call; + } + return(all); + } +}; + + +void Drive(UIGameControlsPresenterClass & presenter, char const * name, int value = 0) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + presenter.Queue(intent); + presenter.Drain(); +} + + +// The game controls presenter applies the accepted settings in the order the dialog's accept +// path always used, and only when accepted. +void Test_Game_Controls_Presenter(void) +{ + { + RecordingGameControlsServiceClass service; + UIGameControlsState state; + state.Speed = 3; + state.Scroll = 3; + state.Detail = 2; + state.Difficulty = 1; + state.InGame = false; + state.HasSpeed = true; + state.HasDifficulty = true; + + UIGameControlsPresenterClass presenter(service, state); + Drive(presenter, "speed", 5); + Drive(presenter, "detail", 9); + Drive(presenter, "cameo", 1); + Drive(presenter, "edge", 1); + Drive(presenter, "difficulty", 2); + Check(presenter.State.Speed == 5 && presenter.State.Detail == 2 && presenter.State.CameoText && presenter.State.EdgeScroll, "edits are held in the state and clamped"); + Check(service.Calls.empty(), "nothing is applied before the player accepts"); + + Drive(presenter, "ok"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED, "OK accepts the game controls"); + Check(service.Joined() == "speed 5; scroll 3; detail 2; cameo on; lines off; tooltips off; coasting off; edge on; difficulty 2; save", "OK applies the settings in the accept path's order and saves"); + } + + { + RecordingGameControlsServiceClass service; + UIGameControlsState state; + state.InGame = true; + state.HasSpeed = false; + state.HasDifficulty = false; + + UIGameControlsPresenterClass presenter(service, state); + Drive(presenter, "sound"); + Check(presenter.Result.has_value() && presenter.Next == UIGameControlsPresenterClass::NEXT_SOUND, "the Sound button accepts and names the sound options next"); + Check(service.Joined() == "scroll 0; detail 0; cameo off; lines off; tooltips off; coasting off; edge off; save", "an Internet game applies no game speed and no difficulty"); + } + + { + RecordingGameControlsServiceClass service; + UIGameControlsState state; + UIGameControlsPresenterClass presenter(service, state); + Drive(presenter, "scroll", 1); + Drive(presenter, "cancel"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && service.Calls.empty(), "Cancel applies nothing"); + } +} + + // The shell resolves [[TXT_NAME]] through the generated table, so a name a document uses // must exist there. void Test_Strings(void) @@ -1106,6 +1198,7 @@ int main(void) Test_FreeType(); Test_ImGui(); Test_Coordinates(); + Test_Game_Controls_Presenter(); Test_Sound_Presenter(); Test_Strings(); Test_Documents(); From f2ccafb472aea265b5dbcd117c0cf9a41de22cec Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 08:18:34 +0300 Subject: [PATCH 12/52] Show the game controls as an RmlUi document --- code/gamedlg.cpp | 15 +- code/gamedlg.h | 2 + code/ui/uigamectrl.cpp | 83 +++++++++- code/ui/uigamectrl.h | 14 +- code/ui/uigamectrldlg.cpp | 27 ++++ docs/UI_DESIGN.md | 15 +- manual/changes/rmlui-game-controls.md | 13 ++ manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/ui-files.md | 2 +- tests/uishell/uishell.cpp | 208 +++++++++++++++++++++++++- ui/gamectrl.rcss | 177 ++++++++++++++++++++++ ui/gamectrl.rml | 27 ++++ 12 files changed, 566 insertions(+), 19 deletions(-) create mode 100644 manual/changes/rmlui-game-controls.md create mode 100644 ui/gamectrl.rcss create mode 100644 ui/gamectrl.rml diff --git a/code/gamedlg.cpp b/code/gamedlg.cpp index d1b8b2c64..093b2cd1a 100644 --- a/code/gamedlg.cpp +++ b/code/gamedlg.cpp @@ -50,6 +50,7 @@ #include "techno.h" #include "ui/uigamectrl.h" #include "ui/uiscreen.h" +#include "ui/uishell.h" #include "special.hh" @@ -167,12 +168,10 @@ static void Read_Controls(HWND window, UIGameControlsPresenterClass & presenter) * HISTORY: * * 12/31/1994 MML : Created. * *=============================================================================================*/ -void GameControlsClass::Dialog(void) +void GameControlsClass::Run_Win32_Dialog(void) { int res = -1; - DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); - UIGameControlsState state; UI_Game_Controls_State(state); UIGameControlsPresenterClass presenter(UI_Game_Controls_Service(), state); @@ -214,6 +213,16 @@ void GameControlsClass::Dialog(void) } else if (presenter.Next == UIGameControlsPresenterClass::NEXT_KEYBOARD) { SpecialDialog = SDLG_KEYBOARD; } +} + + +void GameControlsClass::Dialog(void) +{ + DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); + + if (!UI_Use_Rml() || !UI_Game_Controls_Dialog()) { + Run_Win32_Dialog(); + } DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); } diff --git a/code/gamedlg.h b/code/gamedlg.h index e30cafa0c..3b74c2e43 100644 --- a/code/gamedlg.h +++ b/code/gamedlg.h @@ -52,6 +52,8 @@ class GameControlsClass } private: + void Run_Win32_Dialog(void); + /* * This is the window handle of the game controls dialog while it is displayed. The * player's settings are read back off its controls, so the handle is only diff --git a/code/ui/uigamectrl.cpp b/code/ui/uigamectrl.cpp index ec0d485a2..2b0e0a9e1 100644 --- a/code/ui/uigamectrl.cpp +++ b/code/ui/uigamectrl.cpp @@ -26,10 +26,20 @@ static int Clamp_Level(int level, int count) } +static std::string Name_Of(std::vector const & names, int level) +{ + if (level < 0 || level >= (int)names.size()) { + return(std::string()); + } + return(names[level]); +} + + UIGameControlsPresenterClass::UIGameControlsPresenterClass(UIGameControlsServiceClass & service, UIGameControlsState state) : State(std::move(state)), Service(service) { + Update_Names(); } @@ -57,9 +67,11 @@ void UIGameControlsPresenterClass::Execute(UIIntent const & intent) Apply(); Result = UI_RESULT_ACCEPTED; } else if (intent.Name == "sound") { - Apply(); - Next = NEXT_SOUND; - Result = UI_RESULT_ACCEPTED; + if (State.SoundEnabled) { + Apply(); + Next = NEXT_SOUND; + Result = UI_RESULT_ACCEPTED; + } } else if (intent.Name == "keyboard") { Apply(); Next = NEXT_KEYBOARD; @@ -67,6 +79,8 @@ void UIGameControlsPresenterClass::Execute(UIIntent const & intent) } else if (intent.Name == "cancel") { Result = UI_RESULT_CANCELLED; } + + Update_Names(); } @@ -93,3 +107,66 @@ void UIGameControlsPresenterClass::Apply(void) } Service.Save(); } + + +void UIGameControlsPresenterClass::Update_Names(void) +{ + State.SpeedName = Name_Of(State.SpeedNames, State.Speed); + State.ScrollName = Name_Of(State.ScrollNames, State.Scroll); + State.DetailName = Name_Of(State.DetailNames, State.Detail); + State.DifficultyName = Name_Of(State.DifficultyNames, State.Difficulty); +} + + +namespace +{ + +class UIGameControlsViewClass : public UIRmlViewClass +{ + public: + explicit UIGameControlsViewClass(UIGameControlsPresenterClass & presenter) : + UIRmlViewClass(presenter, "gamectrl.rml", "gamectrl"), + Data(presenter) + { + } + + // The model is small, so every field is re-read after each drain. + virtual void Sync(void) override + { + Model.DirtyAllVariables(); + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + UIGameControlsState & state = Data.State; + return(model.Bind("speed", &state.Speed) + && model.Bind("scroll", &state.Scroll) + && model.Bind("detail", &state.Detail) + && model.Bind("difficulty", &state.Difficulty) + && model.Bind("cameo", &state.CameoText) + && model.Bind("lines", &state.ActionLines) + && model.Bind("tooltips", &state.ToolTips) + && model.Bind("coasting", &state.Coasting) + && model.Bind("edge", &state.EdgeScroll) + && model.Bind("ingame", &state.InGame) + && model.Bind("hasspeed", &state.HasSpeed) + && model.Bind("hasdifficulty", &state.HasDifficulty) + && model.Bind("soundenabled", &state.SoundEnabled) + && model.Bind("speedname", &state.SpeedName) + && model.Bind("scrollname", &state.ScrollName) + && model.Bind("detailname", &state.DetailName) + && model.Bind("difficultyname", &state.DifficultyName)); + } + + private: + UIGameControlsPresenterClass & Data; +}; + +} + + +std::unique_ptr UI_Game_Controls_View(UIGameControlsPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uigamectrl.h b/code/ui/uigamectrl.h index 3cf2052da..3d11f482f 100644 --- a/code/ui/uigamectrl.h +++ b/code/ui/uigamectrl.h @@ -39,8 +39,8 @@ class UIGameControlsServiceClass // What the dialog shows: the settings as values (a slider shows speed and scroll rate -// reversed), the five switches, which controls this context has, and the names of the -// slider positions. +// reversed), the five switches, which controls this context has, the names of the slider +// positions and the name of each current one. struct UIGameControlsState { int Speed = 0; @@ -60,6 +60,10 @@ struct UIGameControlsState std::vector ScrollNames; std::vector DetailNames; std::vector DifficultyNames; + std::string SpeedName; + std::string ScrollName; + std::string DetailName; + std::string DifficultyName; }; @@ -92,6 +96,7 @@ class UIGameControlsPresenterClass : public UIPresenterClass private: void Apply(void); + void Update_Names(void); UIGameControlsServiceClass & Service; }; @@ -105,3 +110,8 @@ std::unique_ptr UI_Game_Controls_View(UIGameControlsPresenterCla // RmlUi view. UIGameControlsServiceClass & UI_Game_Controls_Service(void); void UI_Game_Controls_State(UIGameControlsState & state); + +// Runs the game controls as an RmlUi screen. False means it could not run as one and the +// caller should open its Win32 dialog. Sound and Keyboard leave SpecialDialog naming the +// screen to open next. +bool UI_Game_Controls_Dialog(void); diff --git a/code/ui/uigamectrldlg.cpp b/code/ui/uigamectrldlg.cpp index 75efd4755..4cc5c048f 100644 --- a/code/ui/uigamectrldlg.cpp +++ b/code/ui/uigamectrldlg.cpp @@ -24,6 +24,8 @@ #include "queue.h" #include "session.h" #include "techno.h" +#include "ui/uirmlview.h" +#include "ui/uishell.h" namespace @@ -144,3 +146,28 @@ void UI_Game_Controls_State(UIGameControlsState & state) Fetch_Names(state.DetailNames, GameDetailLevelNames, OptionsClass::MAX_DETAIL_SETTING); Fetch_Names(state.DifficultyNames, GameDifficultyNames, OptionsClass::MAX_DIFFICULTY_SETTING); } + + +bool UI_Game_Controls_Dialog(void) +{ + if (UI_Legacy_Dialog_Visible()) { + return(false); + } + + UIGameControlsState state; + UI_Game_Controls_State(state); + + UIGameControlsPresenterClass presenter(UI_Game_Controls_Service(), state); + std::unique_ptr view = UI_Game_Controls_View(presenter); + + if (UI_Run_Modal(*view) == UI_RESULT_FAILED_TO_OPEN) { + return(false); + } + + if (presenter.Next == UIGameControlsPresenterClass::NEXT_SOUND) { + SpecialDialog = SDLG_SOUND; + } else if (presenter.Next == UIGameControlsPresenterClass::NEXT_KEYBOARD) { + SpecialDialog = SDLG_KEYBOARD; + } + return(true); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 44283066d..931764725 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -766,13 +766,14 @@ beyond an ASCII test document. `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless box they show. A progress bar needs no engine surface, so the `` element waits for the map preview in step 10. Runtime evidence still owed. -7. **Options family** (L, two changes each; the game controls landed their - first: the behaviour sits behind `UIGameControlsPresenterClass` and an - engine service, with the three Win32 templates as the view). Main options, - display with its timed rollback, game controls (three variants), keyboard - with the hotkey capture control, the display-mode confirmation, abort and - surrender. The in-game options menu opens load, save and delete, so it - follows step 9. Evidence: settings round-trip through `SUN.INI` unchanged. +7. **Options family** (L, two changes each; the game controls landed: the + behaviour sits behind `UIGameControlsPresenterClass` and an engine service, + `gamectrl.rml` covers the three Win32 templates with `data-if`, and the + templates remain the fallback view). Main options, display with its timed + rollback, keyboard with the hotkey capture control, the display-mode + confirmation, abort and surrender remain. The in-game options menu opens + load, save and delete, so it follows step 9. Evidence: settings round-trip + through `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, multiplayer game selection. The `NewMenuClass` drivers keep their loops. 9. **Load, save, delete** (M, two changes). diff --git a/manual/changes/rmlui-game-controls.md b/manual/changes/rmlui-game-controls.md new file mode 100644 index 000000000..7cb4f7af6 --- /dev/null +++ b/manual/changes/rmlui-game-controls.md @@ -0,0 +1,13 @@ +--- +title: Show the game controls as an RmlUi document +category: feature +release: 0.2.0 +targets: +- type: system + id: ui-files + effect: changed +credit: +- ZivDero +--- + +The game controls open as an RmlUi document from the options menu and from the in-game options, with the same sliders, the name of each slider position beside it, the same five switches and, in game, the same Sound and Keyboard buttons. The settings still change only when the player accepts, Sound and Keyboard still accept on the way to their screens, and Escape still discards the edits. An Internet game still has no game speed slider and only the options menu has the difficulty slider. `LegacyDialogs=yes` keeps the Win32 dialog. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index 1d308f7d1..8ba9857a1 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options, the game controls and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index bec47283a..148856857 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -23,4 +23,4 @@ A document names an engine string as `[[TXT_NAME]]`, using the identifier names ## Choosing the Win32 dialogs -[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options, the game controls and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index ddaa907c0..5bb1b5fb2 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -536,14 +536,19 @@ void Test_Game_Controls_Presenter(void) state.InGame = false; state.HasSpeed = true; state.HasDifficulty = true; + state.SpeedNames = { "Slowest", "Slower", "Slow", "Medium", "Fast", "Faster", "Fastest" }; + state.DetailNames = { "Low", "Medium", "High" }; UIGameControlsPresenterClass presenter(service, state); + Check(presenter.State.SpeedName == "Medium" && presenter.State.DetailName == "High" && presenter.State.ScrollName.empty(), "the presenter names the starting slider positions it has names for"); + Drive(presenter, "speed", 5); Drive(presenter, "detail", 9); Drive(presenter, "cameo", 1); Drive(presenter, "edge", 1); Drive(presenter, "difficulty", 2); Check(presenter.State.Speed == 5 && presenter.State.Detail == 2 && presenter.State.CameoText && presenter.State.EdgeScroll, "edits are held in the state and clamped"); + Check(presenter.State.SpeedName == "Faster" && presenter.State.DetailName == "High", "an edit renames the slider position"); Check(service.Calls.empty(), "nothing is applied before the player accepts"); Drive(presenter, "ok"); @@ -560,6 +565,10 @@ void Test_Game_Controls_Presenter(void) UIGameControlsPresenterClass presenter(service, state); Drive(presenter, "sound"); + Check(!presenter.Result.has_value() && service.Calls.empty(), "the Sound button does nothing without an audio device"); + + presenter.State.SoundEnabled = true; + Drive(presenter, "sound"); Check(presenter.Result.has_value() && presenter.Next == UIGameControlsPresenterClass::NEXT_SOUND, "the Sound button accepts and names the sound options next"); Check(service.Joined() == "scroll 0; detail 0; cameo off; lines off; tooltips off; coasting off; edge off; save", "an Internet game applies no game speed and no difficulty"); } @@ -1033,6 +1042,198 @@ void Test_Sound_Screen(Rml::Context & context, CountingSystemInterfaceClass & sy } +// The range inputs a document shows, counting only those inside a visible row. +int Visible_Sliders(Rml::ElementDocument * document) +{ + int sliders = 0; + Rml::ElementList inputs; + document->GetElementsByTagName(inputs, "input"); + for (Rml::Element * input : inputs) { + if (input->GetAttribute("type", "") == "range" && input->IsVisible(true)) { + sliders++; + } + } + return(sliders); +} + + +UIGameControlsState Game_Controls_Fixture(void) +{ + UIGameControlsState state; + state.Speed = 4; + state.Scroll = 2; + state.Detail = 1; + state.Difficulty = 2; + state.CameoText = true; + state.ToolTips = true; + state.SpeedNames = { "Slowest", "Slower", "Slow", "Medium", "Fast", "Faster", "Fastest" }; + state.ScrollNames = state.SpeedNames; + state.DetailNames = { "Low", "Medium", "High" }; + state.DifficultyNames = { "Easy", "Normal", "Hard" }; + return(state); +} + + +// Drives the game controls screen in its three variants: the sliders hold edits and name +// their positions, the switches toggle, Sound applies and names the next screen, and the +// frontend and Internet variants hide the controls their Win32 templates lack. +void Test_Game_Controls_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + RecordingGameControlsServiceClass service; + UIGameControlsState state = Game_Controls_Fixture(); + state.InGame = true; + state.HasSpeed = true; + state.HasDifficulty = false; + state.SoundEnabled = true; + + UIGameControlsPresenterClass presenter(service, state); + std::unique_ptr view = UI_Game_Controls_View(presenter); + + Check(view->Prepare(context), "the game controls view prepares against the test context"); + view->Show(true); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the game controls screen raises no RmlUi warning or error"); + + // Seeding a range input dispatches its change event, so opening queues the starting values. + presenter.Drain(); + Check(presenter.State.Speed == 4 && presenter.State.Scroll == 2 && presenter.State.Detail == 1 && service.Calls.empty(), "opening the game controls holds the starting settings and applies nothing"); + + Rml::ElementDocument * document = view->Document(); + Check(Visible_Sliders(document) == 3, "the in-game screen has three sliders"); + + Rml::Element * speed = document->GetElementById("speed"); + Check(speed != nullptr && speed->GetAttribute("value", -1) == 2, "the speed slider runs from slowest to fastest, so it starts at six minus the speed"); + + Rml::Element * speed_name = document->GetElementById("speed-name"); + Check(speed_name != nullptr && speed_name->GetInnerRML() == "Fast", "the speed's name shows beside its slider"); + + Rml::Element * detail_name = document->GetElementById("detail-name"); + Check(detail_name != nullptr && detail_name->GetInnerRML() == "Medium", "the detail level's name shows beside its slider"); + + Rml::Element * sound = document->GetElementById("sound"); + Rml::Element * keyboard = document->GetElementById("keyboard"); + Rml::Element * options = document->GetElementById("ok-options"); + Check(sound != nullptr && sound->IsVisible() && keyboard != nullptr && keyboard->IsVisible(), "the in-game screen has its Sound and Keyboard buttons"); + Check(options != nullptr && options->IsVisible(true), "the in-game accept button reads Options Menu"); + + if (speed != nullptr && speed_name != nullptr) { + Rml::Dictionary parameters; + parameters["value"] = Rml::Variant(5.0f); + speed->DispatchEvent(Rml::EventId::Change, parameters); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.Speed == 1 && speed_name->GetInnerRML() == "Slower" && service.Calls.empty(), "dragging the speed slider changes the held speed and its name and applies nothing"); + } + + Rml::Element * cameo = document->GetElementById("cameo"); + if (cameo != nullptr) { + Click(context, cameo); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(!presenter.State.CameoText && !cameo->HasAttribute("checked"), "a click on the cameo text switch turns it off and shows it"); + } + + if (sound != nullptr) { + Click(context, sound); + presenter.Drain(); + Check(presenter.Result.has_value() && presenter.Next == UIGameControlsPresenterClass::NEXT_SOUND, "the Sound button accepts the screen and names the sound options next"); + Check(service.Joined() == "speed 1; scroll 2; detail 1; cameo off; lines off; tooltips on; coasting off; edge off; save", "the Sound button applies the edited settings in order and saves"); + } + + view->Release(); + context.Update(); + } + + { + RecordingGameControlsServiceClass service; + UIGameControlsState state = Game_Controls_Fixture(); + state.InGame = false; + state.HasSpeed = true; + state.HasDifficulty = true; + + UIGameControlsPresenterClass presenter(service, state); + std::unique_ptr view = UI_Game_Controls_View(presenter); + + Check(view->Prepare(context), "the frontend game controls view prepares"); + view->Show(true); + context.Update(); + + Rml::ElementDocument * document = view->Document(); + Check(Visible_Sliders(document) == 4, "the frontend screen adds the difficulty slider"); + + Rml::Element * difficulty_name = document->GetElementById("difficulty-name"); + Check(difficulty_name != nullptr && difficulty_name->GetInnerRML() == "Hard", "the difficulty's name shows beside its slider"); + + Rml::Element * sound = document->GetElementById("sound"); + Rml::Element * keyboard = document->GetElementById("keyboard"); + Rml::Element * main = document->GetElementById("ok-main"); + Check(sound != nullptr && !sound->IsVisible() && keyboard != nullptr && !keyboard->IsVisible(), "the frontend screen has no Sound or Keyboard button"); + Check(main != nullptr && main->IsVisible(true), "the frontend accept button reads Main Menu"); + + Rml::Element * edge = document->GetElementById("edge"); + if (edge != nullptr) { + Click(context, edge); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.EdgeScroll && edge->HasAttribute("checked"), "a click on the edge scrolling switch turns it on and shows it"); + } + + context.ProcessKeyDown(Rml::Input::KI_ESCAPE, 0); + context.ProcessKeyUp(Rml::Input::KI_ESCAPE, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && service.Calls.empty(), "Escape leaves the game controls with nothing applied"); + + view->Release(); + context.Update(); + } + + { + RecordingGameControlsServiceClass service; + UIGameControlsState state = Game_Controls_Fixture(); + state.InGame = true; + state.HasSpeed = false; + state.HasDifficulty = false; + state.SoundEnabled = false; + + UIGameControlsPresenterClass presenter(service, state); + std::unique_ptr view = UI_Game_Controls_View(presenter); + + Check(view->Prepare(context), "the Internet game controls view prepares"); + view->Show(true); + context.Update(); + + Rml::ElementDocument * document = view->Document(); + Check(Visible_Sliders(document) == 2, "the Internet screen has no game speed slider"); + + Rml::Element * sound = document->GetElementById("sound"); + Check(sound != nullptr && sound->IsClassSet("disabled"), "the Sound button shows disabled without an audio device"); + + if (sound != nullptr) { + Click(context, sound); + presenter.Drain(); + Check(!presenter.Result.has_value() && service.Calls.empty(), "a disabled Sound button does nothing"); + } + + context.ProcessKeyDown(Rml::Input::KI_RETURN, 0); + context.ProcessKeyUp(Rml::Input::KI_RETURN, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Joined() == "scroll 2; detail 1; cameo on; lines off; tooltips on; coasting off; edge off; save", "Enter accepts the Internet screen without a game speed or a difficulty"); + + view->Release(); + context.Update(); + } +} + + // Drives the wait box: the text follows the presenter, the frame appears only with a bar, and // the fill follows the percentage. void Test_Wait_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) @@ -1134,10 +1335,12 @@ void Test_Documents(void) int problems = system.Problems; render.Scissors.clear(); - // A document over a data model lays out against a permissive stand-in for its screen. + // A document over a data model lays out against a permissive stand-in for its screen, + // which takes the intents a control queues as it is seeded. std::string model = Data_Model_Name(Read_Text(path)); if (!model.empty()) { - context->CreateDataModel(model, nullptr, true); + Rml::DataModelConstructor constructor = context->CreateDataModel(model, nullptr, true); + constructor.BindEventCallback("queue", [](Rml::DataModelHandle, Rml::Event &, Rml::VariantList const &) {}); } Rml::ElementDocument * document = context->LoadDocument(path.string()); @@ -1177,6 +1380,7 @@ void Test_Documents(void) Test_Version_Screen(*context, system); Test_Message_Box_Screen(*context, system); Test_Sound_Screen(*context, system); + Test_Game_Controls_Screen(*context, system); Test_Wait_Box_Screen(*context, system); } diff --git a/ui/gamectrl.rcss b/ui/gamectrl.rcss new file mode 100644 index 000000000..6e914752a --- /dev/null +++ b/ui/gamectrl.rcss @@ -0,0 +1,177 @@ +body +{ + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, label +{ + display: block; +} + +#panel +{ + box-sizing: border-box; + width: 438dp; + padding: 18dp 33dp 21dp 33dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +.row +{ + height: 24dp; + margin-bottom: 9dp; + white-space: nowrap; +} + +.label +{ + display: inline-block; + width: 87dp; + line-height: 24dp; + vertical-align: top; +} + +input.range +{ + display: inline-block; + width: 222dp; + height: 24dp; + vertical-align: top; +} + +.value +{ + display: inline-block; + width: 63dp; + line-height: 24dp; + text-align: right; + vertical-align: top; +} + +input.range slidertrack +{ + margin-top: 8dp; + height: 8dp; + background-color: #2a3a48; + border: 1dp #6f95a8; +} + +input.range sliderbar +{ + width: 14dp; + height: 24dp; + background-color: #6f95a8; +} + +input.range sliderbar:hover, input.range sliderbar:active +{ + background-color: #8fb5c8; +} + +input.range sliderarrowdec, input.range sliderarrowinc +{ + width: 0; + height: 0; +} + +#switches +{ + margin-top: 12dp; + height: 72dp; + white-space: nowrap; +} + +.column +{ + display: inline-block; + width: 186dp; + vertical-align: top; +} + +.switch +{ + line-height: 24dp; +} + +input.checkbox +{ + width: 14dp; + height: 14dp; + margin-right: 8dp; + vertical-align: -2dp; + background-color: #2a3a48; + border: 1dp #6f95a8; +} + +input.checkbox:checked +{ + background-color: #8fb5c8; +} + +#buttons +{ + position: relative; + margin-top: 15dp; + height: 23dp; +} + +button +{ + position: absolute; + display: block; + top: 0; + width: 115dp; + height: 23dp; + text-align: center; + line-height: 23dp; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} + +button.disabled +{ + color: #7a8790; + background-color: #24303a; + border-color: #4a5a68; +} + +#sound +{ + left: 0; +} + +#keyboard +{ + left: 129dp; +} + +#ok +{ + right: 0; +} + +#panel.frontend #ok +{ + left: 88dp; + right: auto; + width: 195dp; +} diff --git a/ui/gamectrl.rml b/ui/gamectrl.rml new file mode 100644 index 000000000..b31c4cd00 --- /dev/null +++ b/ui/gamectrl.rml @@ -0,0 +1,27 @@ + + + Game controls + + + +
+
Game Speed:{{speedname}}
+
Scroll Rate:{{scrollname}}
+
Visual Details:{{detailname}}
+
Difficulty:{{difficultyname}}
+
+ + + +
+ + +
+
+ + + +
+
+ +
From 32127df5935fe8788cdc021503099a2cd5890893 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 08:21:05 +0300 Subject: [PATCH 13/52] Keep the sound options quiet as they open --- code/ui/uisound.cpp | 23 +++++++++++++++++------ tests/uishell/uishell.cpp | 11 +++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/code/ui/uisound.cpp b/code/ui/uisound.cpp index ea6e1f064..fb754b2f1 100644 --- a/code/ui/uisound.cpp +++ b/code/ui/uisound.cpp @@ -33,19 +33,30 @@ static int Clamp_Level(int level) } +// A slider reports its starting level as it is seeded, so a level that is already held plays +// no feedback. void UISoundPresenterClass::Execute(UIIntent const & intent) { if (intent.Name == "score") { - State.Score = Clamp_Level(intent.Value); - Service.Set_Score_Volume(Volume_Of(State.Score), true); + int level = Clamp_Level(intent.Value); + if (level != State.Score) { + State.Score = level; + Service.Set_Score_Volume(Volume_Of(State.Score), true); + } } else if (intent.Name == "sound") { - State.Sound = Clamp_Level(intent.Value); - Service.Set_Sound_Volume(Volume_Of(State.Sound), true); + int level = Clamp_Level(intent.Value); + if (level != State.Sound) { + State.Sound = level; + Service.Set_Sound_Volume(Volume_Of(State.Sound), true); + } } else if (intent.Name == "voice") { - State.Voice = Clamp_Level(intent.Value); - Service.Set_Voice_Volume(Volume_Of(State.Voice), true); + int level = Clamp_Level(intent.Value); + if (level != State.Voice) { + State.Voice = level; + Service.Set_Voice_Volume(Volume_Of(State.Voice), true); + } } else if (intent.Name == "shuffle") { State.Shuffle = (intent.Value != 0); diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 5bb1b5fb2..d821c3041 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -442,10 +442,17 @@ void Test_Sound_Presenter(void) UISoundPresenterClass presenter(service, state); + Drive(presenter, "score", 7); + Drive(presenter, "sound", 5); + Drive(presenter, "voice", 10); + Check(service.Calls.empty(), "a slider reporting the level it already holds previews nothing"); + Drive(presenter, "score", 4); Check(presenter.State.Score == 4 && service.Joined() == "score 0.4 feedback", "a music slider move previews the new volume at once"); service.Calls.clear(); + Drive(presenter, "voice", 2); + service.Calls.clear(); Drive(presenter, "voice", 14); Check(presenter.State.Voice == 10 && service.Joined() == "voice 1.0 feedback", "a slider level is clamped to the top step"); service.Calls.clear(); @@ -954,6 +961,10 @@ void Test_Sound_Screen(Rml::Context & context, CountingSystemInterfaceClass & sy context.Render(); Check(system.Problems == problems, "the sound screen raises no RmlUi warning or error"); + // Seeding a range input dispatches its change event, so opening queues the starting levels. + presenter.Drain(); + Check(presenter.State.Score == 7 && presenter.State.Sound == 5 && presenter.State.Voice == 10 && service.Calls.empty(), "opening the sound screen plays no feedback"); + Rml::ElementDocument * document = view->Document(); Rml::ElementList inputs; document->GetElementsByTagName(inputs, "input"); From f0d3153d84b633917c2dc67d83c7895e7b6d3200 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 08:30:05 +0300 Subject: [PATCH 14/52] Put the display options behaviour behind presenters --- code/mainopt.cpp | 238 +++++++++++++++++------------------ code/ui/uidisplay.cpp | 83 ++++++++++++ code/ui/uidisplay.h | 105 ++++++++++++++++ code/ui/uidisplaydlg.cpp | 80 ++++++++++++ code/ui/uiscreen.h | 14 ++- code/ui/uishell.cpp | 24 ++++ code/ui/uishell.h | 3 + docs/UI_DESIGN.md | 11 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 133 ++++++++++++++++++++ 10 files changed, 562 insertions(+), 130 deletions(-) create mode 100644 code/ui/uidisplay.cpp create mode 100644 code/ui/uidisplay.h create mode 100644 code/ui/uidisplaydlg.cpp diff --git a/code/mainopt.cpp b/code/mainopt.cpp index 4534159ad..7623a43d7 100644 --- a/code/mainopt.cpp +++ b/code/mainopt.cpp @@ -34,6 +34,8 @@ #include "sounddlg.h" #include "stimer.h" #include "surface.h" +#include "ui/uidisplay.h" +#include "ui/uishell.h" #include "wwmouse.h" #include "color.hh" @@ -44,15 +46,29 @@ INT_PTR CALLBACK Display_Options_Dialog_Proc(HWND window, UINT message, WPARAM w bool Change_Display_Mode(int width, int height); bool Test_Display_Mode_Dialog(int width, int height); INT_PTR CALLBACK Test_Display_Mode_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); +static void Display_Options_Dialog(void); -GameOptionsClass TempOptions; +// The presenters the display and confirmation dialog procedures are views of, each for the +// life of one dialog. +static UIDisplayPresenterClass * _DisplayPresenter = NULL; +static UIConfirmModePresenterClass * _ConfirmPresenter = NULL; + + +static void Queue_And_Drain(UIPresenterClass & presenter, char const * name, int value = 0) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + presenter.Queue(intent); + presenter.Drain(); +} /// /// Brings up the main options dialog. -/// This routine drives the options menu, dispatching to the sound, display, network, -/// keyboard and game settings dialogs until the player backs out. A resolution change is -/// offered as a trial first, and the settings are written out when the player leaves. +/// This routine drives the options menu, dispatching to the sound, display, keyboard and +/// game settings dialogs until the player backs out. A resolution change is offered as a +/// trial first, and the settings are written out when the player leaves. /// /// Game logic is suspended for the duration of this routine. void Main_Options_Dialog(void) @@ -63,9 +79,6 @@ void Main_Options_Dialog(void) HWND main_handle; LONG main_rc; - HWND in_handle; - LONG in_rc; - while (true) { do { main_rc = -1; @@ -90,44 +103,9 @@ void Main_Options_Dialog(void) SoundControlsClass().Dialog(); break; - case IDC_OPTMAIN_DISPLAY: { - while (true) { - do { - TempOptions = Options; - in_rc = -1; - in_handle = OwnerDraw::Begin_Dialog(IDD_OPT_DISPLAY, Display_Options_Dialog_Proc); - } while (in_handle == 0); - SetWindowLongPtr(in_handle, DWLP_USER, (LONG_PTR)&in_rc); - OwnerDraw::Display_Dialog(in_handle); - - while (in_rc < 0) { - if (OwnerDraw::Dialog_Message_Handler() == true) { - break; - } - Title_Screen_Restore(); - } - - OwnerDraw::End_Dialog(in_handle); - - if (in_rc != 1) { - break; - } - if (TempOptions.ScreenWidth == Options.ScreenWidth && TempOptions.ScreenHeight == Options.ScreenHeight) { - break; - } - - if (WWMessageBox().Process(TXT_ABOUT_TO_TRY_MODE, TXT_OK, TXT_CANCEL) == 0) { - if (!Test_Display_Mode_Dialog(TempOptions.ScreenWidth, TempOptions.ScreenHeight)) { - continue; - } - Options.ScreenWidth = TempOptions.ScreenWidth; - Options.ScreenHeight = TempOptions.ScreenHeight; - } - - break; - } - } - break; + case IDC_OPTMAIN_DISPLAY: + Display_Options_Dialog(); + break; case IDC_OPTMAIN_KEYBOARD: Options.Hotkey_Dialog(); @@ -321,8 +299,6 @@ bool Change_Display_Mode(int width, int height) /// bool; Was the new display mode accepted and left in place? bool Test_Display_Mode_Dialog(int width, int height) { - int rc = -1; - DebugString("Testing display mode @ %dx%d\n", width, height); Hide_Mouse(); HiddenSurface->Fill(TBLACK); @@ -337,30 +313,31 @@ bool Test_Display_Mode_Dialog(int width, int height) Show_Mouse(); Draw_Menu_Background(); + UIConfirmModePresenterClass presenter(UI_Clock()); + _ConfirmPresenter = &presenter; + HWND dialog = OwnerDraw::Begin_Dialog(IDD_OPT_CONFIRM_MODE, Test_Display_Mode_Dialog_Proc); if (dialog) { - SetWindowLongPtr(dialog, DWLP_USER, (LONG_PTR)&rc); OwnerDraw::Display_Dialog(dialog); - CDTimerClass timer = 10 * TIMER_SECOND; - while (rc < 0) { + presenter.Refresh(); + while (!presenter.Result.has_value()) { if (OwnerDraw::Dialog_Message_Handler() == true) { break; } Title_Screen_Restore(); - if (timer <= 0) { - PostMessage(dialog, WM_COMMAND, WM_DESTROY, 0); - timer = 5 * TIMER_SECOND; - } + presenter.Refresh(); } OwnerDraw::End_Dialog(dialog); - if (rc != IDOK) { - DebugString("Resetting display mode @ %dx%d\n", Options.ScreenWidth, Options.ScreenHeight); - Change_Display_Mode(Options.ScreenWidth, Options.ScreenHeight); - LogicalSurface = HiddenSurface; - return(false); - } + } + _ConfirmPresenter = NULL; + + if (dialog && (!presenter.Result.has_value() || *presenter.Result != UI_RESULT_ACCEPTED)) { + DebugString("Resetting display mode @ %dx%d\n", Options.ScreenWidth, Options.ScreenHeight); + Change_Display_Mode(Options.ScreenWidth, Options.ScreenHeight); + LogicalSurface = HiddenSurface; + return(false); } DebugString("Keeping display mode @ %dx%d\n", width, height); @@ -371,24 +348,24 @@ bool Test_Display_Mode_Dialog(int width, int height) /// /// Handles the mode confirmation dialog. -/// This routine records the button the player pressed so that the mode test can tell -/// whether the new resolution was accepted or rejected. +/// This routine hands the button the player pressed to the confirmation presenter, which +/// tells the mode test whether the new resolution was accepted or rejected. /// INT_PTR CALLBACK Test_Display_Mode_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { - int * result; - int id; - INT_PTR rc = OwnerDraw::Default_Dialog_Proc(window, message, wparam, lparam); if (rc == 0) { - result = (int *)GetWindowLongPtr(window, DWLP_USER); - switch (message) { - case WM_COMMAND: - id = LOWORD(wparam); - if (id > 0 && id <= IDCANCEL) { - *result = LOWORD(wparam); - } - break; + UIConfirmModePresenterClass * presenter = _ConfirmPresenter; + if (presenter != NULL && message == WM_COMMAND) { + switch (LOWORD(wparam)) { + case IDOK: + Queue_And_Drain(*presenter, "ok"); + break; + + case IDCANCEL: + Queue_And_Drain(*presenter, "cancel"); + break; + } } return(0); } @@ -396,26 +373,65 @@ INT_PTR CALLBACK Test_Display_Mode_Dialog_Proc(HWND window, UINT message, WPARAM } +/// +/// Runs the display options until the player leaves them or a new display mode is kept. +/// A picked mode is tried out first, and a mode the player does not confirm brings the +/// dialog back. +/// +static void Display_Options_Dialog(void) +{ + while (true) { + UIDisplayState state; + UI_Display_State(state); + UIDisplayPresenterClass presenter(UI_Display_Service(), state); + _DisplayPresenter = &presenter; + + HWND handle; + LONG rc; + do { + rc = -1; + handle = OwnerDraw::Begin_Dialog(IDD_OPT_DISPLAY, Display_Options_Dialog_Proc); + } while (handle == 0); + SetWindowLongPtr(handle, DWLP_USER, (LONG_PTR)&rc); + OwnerDraw::Display_Dialog(handle); + + while (rc < 0) { + if (OwnerDraw::Dialog_Message_Handler() == true) { + break; + } + Title_Screen_Restore(); + } + + OwnerDraw::End_Dialog(handle); + _DisplayPresenter = NULL; + + if (!presenter.Picked.has_value()) { + break; + } + + if (WWMessageBox().Process(TXT_ABOUT_TO_TRY_MODE, TXT_OK, TXT_CANCEL) != 0) { + break; + } + if (Test_Display_Mode_Dialog(presenter.Picked->Width, presenter.Picked->Height)) { + Options.ScreenWidth = presenter.Picked->Width; + Options.ScreenHeight = presenter.Picked->Height; + break; + } + } +} + + /// /// Handles the display options dialog messages. -/// This routine fills the resolution list with the display modes the hardware reports, -/// remembers which one the player picked, and tracks the movie stretching preference. The -/// chosen resolution is staged in the temporary options so that it can be tested before -/// being made permanent. +/// This routine seeds the resolution list and the movie switch from the presenter's state +/// and hands the player's picks back to it; the presenter records the mode to try. /// static __forceinline BOOL Display_Options_Dialog_Body(HWND window, UINT message, WPARAM wparam) { - enum { - MIN_WIDTH = 640, - MIN_HEIGHT = 400, - MAX_WIDTH = 4096, - MAX_HEIGHT = 4096, - }; - - static int * _modes = NULL; - static int _current_mode = -1; - static int _previous_mode = -1; - static bool _initialized = true; + UIDisplayPresenterClass * presenter = _DisplayPresenter; + if (presenter == NULL) { + return(0); + } int * result = (int *)GetWindowLongPtr(window, DWLP_USER); switch (message) { @@ -426,65 +442,37 @@ static __forceinline BOOL Display_Options_Dialog_Body(HWND window, UINT message, case IDC_DISPLAY_RESLIST: { HWND list = GetDlgItem(window, IDC_DISPLAY_RESLIST); - _current_mode = ListBox_GetCurSel(list); + Queue_And_Drain(*presenter, "select", ListBox_GetCurSel(list)); } return(0); case IDOK: { - if (_previous_mode != _current_mode) { - Center_Window_Within_Window(window, MainWindow); - HWND list = GetDlgItem(window, IDC_DISPLAY_RESLIST); - if (list) { - int index = ListBox_GetItemData(list, _current_mode); - int * modes = &_modes[2 * index]; - TempOptions.ScreenWidth = modes[0]; - TempOptions.ScreenHeight = modes[1]; - } - } HWND button = GetDlgItem(window, IDC_STRETCH_MOVIES); if (button) { - Options.StretchMovies = Button_GetCheck(button) == BST_CHECKED; + Queue_And_Drain(*presenter, "stretch", Button_GetCheck(button) == BST_CHECKED); } + Queue_And_Drain(*presenter, "ok"); } break; case IDCANCEL: + Queue_And_Drain(*presenter, "cancel"); break; } - delete [] _modes; *result = LOWORD(wparam); break; case WM_INITDIALOG: { + UIDisplayState const & state = presenter->State; HWND list = GetDlgItem(window, IDC_DISPLAY_RESLIST); - _modes = EnumDisplayModes(MIN_WIDTH, MIN_HEIGHT, MAX_WIDTH, MAX_HEIGHT); - int * modes = _modes; - int item_index = 0; - int initial_mode = -1; - int mode_index = 0; - if (modes != NULL) { - while (*modes != 0) { - int width = *modes++; - int height = *modes++; - if (width == TempOptions.ScreenWidth && height == TempOptions.ScreenHeight) { - initial_mode = mode_index; - } - char buffer[64]; - sprintf(buffer, "%d x %d", width, height); - int index = ListBox_AddString(list, buffer); - ListBox_SetItemData(list, index, item_index); - mode_index++; - item_index++; - } + for (UIDisplayMode const & mode : state.Modes) { + ListBox_AddString(list, mode.Label.c_str()); } - ListBox_SetCurSel(list, initial_mode); - _initialized = true; - _current_mode = initial_mode; - _previous_mode = initial_mode; + ListBox_SetCurSel(list, state.Selected); HWND button = GetDlgItem(window, IDC_STRETCH_MOVIES); if (button) { - Button_SetCheck(button, Options.StretchMovies != false); + Button_SetCheck(button, state.StretchMovies); } } break; diff --git a/code/ui/uidisplay.cpp b/code/ui/uidisplay.cpp new file mode 100644 index 000000000..ec377c078 --- /dev/null +++ b/code/ui/uidisplay.cpp @@ -0,0 +1,83 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uidisplay.h" + +#include "ui/uirmlview.h" + +#include + + +UIDisplayPresenterClass::UIDisplayPresenterClass(UIDisplayServiceClass & service, UIDisplayState state) : + State(std::move(state)), + Service(service), + Initial(State.Selected) +{ +} + + +void UIDisplayPresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "select") { + State.Selected = (intent.Value >= 0 && intent.Value < (int)State.Modes.size()) ? intent.Value : -1; + + } else if (intent.Name == "stretch") { + State.StretchMovies = (intent.Value != 0); + + } else if (intent.Name == "ok") { + Service.Set_Stretch_Movies(State.StretchMovies); + if (State.Selected >= 0 && State.Selected != Initial) { + Picked = State.Modes[State.Selected]; + } + Result = UI_RESULT_ACCEPTED; + + } else if (intent.Name == "cancel") { + Result = UI_RESULT_CANCELLED; + } +} + + +void UIDisplayPresenterClass::Refresh(void) +{ +} + + +UIConfirmModePresenterClass::UIConfirmModePresenterClass(UIClockClass & clock, int timeout) : + Seconds((timeout + 999) / 1000), + Clock(clock), + Timeout(timeout) +{ +} + + +void UIConfirmModePresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "ok") { + Result = UI_RESULT_ACCEPTED; + } else if (intent.Name == "cancel") { + Result = UI_RESULT_CANCELLED; + } +} + + +void UIConfirmModePresenterClass::Refresh(void) +{ + int now = Clock.Milliseconds(); + if (!Deadline.has_value()) { + Deadline = now + Timeout; + } + + int left = *Deadline - now; + Seconds = (left > 0) ? (left + 999) / 1000 : 0; + + if (left <= 0 && !Result.has_value()) { + TimedOut = true; + Result = UI_RESULT_CANCELLED; + } +} diff --git a/code/ui/uidisplay.h b/code/ui/uidisplay.h new file mode 100644 index 000000000..e766c658c --- /dev/null +++ b/code/ui/uidisplay.h @@ -0,0 +1,105 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +#include +#include +#include +#include + +class UIRmlViewClass; + + +// One row of the resolution list: the mode and its label as the dialog prints it. +struct UIDisplayMode +{ + int Width = 0; + int Height = 0; + std::string Label; +}; + + +// The engine call the display options make when the player accepts. The game supplies one +// that reaches the options; the test harness supplies one that records the call. +class UIDisplayServiceClass +{ + public: + virtual ~UIDisplayServiceClass(void) = default; + + virtual void Set_Stretch_Movies(bool on) = 0; +}; + + +// What the dialog shows: the modes the display reports, the row of the mode the settings +// hold (-1 when no row matches) and the movie switch. +struct UIDisplayState +{ + std::vector Modes; + int Selected = -1; + bool StretchMovies = false; +}; + + +// Holds the picked row and the switch until the player accepts. Accepting applies the switch +// at once and, when the row differs from the starting one, records the mode for the caller +// to try; the resolution itself changes only after the trial the caller runs. +class UIDisplayPresenterClass : public UIPresenterClass +{ + public: + UIDisplayPresenterClass(UIDisplayServiceClass & service, UIDisplayState state); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + UIDisplayState State; + std::optional Picked; + + private: + UIDisplayServiceClass & Service; + int Initial; +}; + + +// Asks the player to keep a display mode just switched to. Silence counts as a refusal, +// because a bad mode may leave the screen unreadable: the screen cancels itself when the +// timeout passes. +class UIConfirmModePresenterClass : public UIPresenterClass +{ + public: + enum { + DEFAULT_TIMEOUT = 10000 + }; + + UIConfirmModePresenterClass(UIClockClass & clock, int timeout = DEFAULT_TIMEOUT); + + virtual void Execute(UIIntent const & intent) override; + // Starts the clock on its first call and cancels the screen once the timeout passes. + virtual void Refresh(void) override; + + int Seconds; + bool TimedOut = false; + + private: + UIClockClass & Clock; + int Timeout; + std::optional Deadline; +}; + + +// The RmlUi views, bound to display.rml and confirm.rml. The presenter must outlive its view. +std::unique_ptr UI_Display_View(UIDisplayPresenterClass & presenter); +std::unique_ptr UI_Confirm_Mode_View(UIConfirmModePresenterClass & presenter); + +// The game's service and the state of the display, shared by the Win32 dialog and the RmlUi +// view. +UIDisplayServiceClass & UI_Display_Service(void); +void UI_Display_State(UIDisplayState & state); diff --git a/code/ui/uidisplaydlg.cpp b/code/ui/uidisplaydlg.cpp new file mode 100644 index 000000000..830d521eb --- /dev/null +++ b/code/ui/uidisplaydlg.cpp @@ -0,0 +1,80 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the display options: the service the presenter drives and the state it +// starts from. The presenters live in uidisplay.cpp so that the test harness can drive them +// against a recording service and a hand-driven clock. + +#include "ui/uidisplay.h" + +#include "globals.h" +#include "goptions.h" +#include "video.h" + +#include + + +namespace +{ + +enum { + MIN_WIDTH = 640, + MIN_HEIGHT = 400, + MAX_WIDTH = 4096, + MAX_HEIGHT = 4096 +}; + + +class UIDisplayEngineServiceClass : public UIDisplayServiceClass +{ + public: + virtual void Set_Stretch_Movies(bool on) override + { + Options.StretchMovies = on; + } +}; + +UIDisplayEngineServiceClass _Service; + +} + + +UIDisplayServiceClass & UI_Display_Service(void) +{ + return(_Service); +} + + +void UI_Display_State(UIDisplayState & state) +{ + state = UIDisplayState(); + state.StretchMovies = Options.StretchMovies; + + int * modes = EnumDisplayModes(MIN_WIDTH, MIN_HEIGHT, MAX_WIDTH, MAX_HEIGHT); + if (modes == NULL) { + return; + } + + for (int * entry = modes; *entry != 0; entry += 2) { + UIDisplayMode mode; + mode.Width = entry[0]; + mode.Height = entry[1]; + + char buffer[64]; + std::snprintf(buffer, sizeof(buffer), "%d x %d", mode.Width, mode.Height); + mode.Label = buffer; + + if (mode.Width == Options.ScreenWidth && mode.Height == Options.ScreenHeight) { + state.Selected = (int)state.Modes.size(); + } + state.Modes.push_back(mode); + } + + delete [] modes; +} diff --git a/code/ui/uiscreen.h b/code/ui/uiscreen.h index 3e9fda567..51b134678 100644 --- a/code/ui/uiscreen.h +++ b/code/ui/uiscreen.h @@ -37,6 +37,17 @@ struct UIIntent }; +// The clock a timed screen reads. The game supplies the system clock; a test supplies one it +// advances by hand. +class UIClockClass +{ + public: + virtual ~UIClockClass(void) = default; + + virtual int Milliseconds(void) = 0; +}; + + class UIPresenterClass { public: @@ -50,7 +61,8 @@ class UIPresenterClass bool Has_Pending(void) const; virtual void Execute(UIIntent const & intent) = 0; - // Copies engine state into the view-model. + // Copies engine state into the view-model. The owner calls it before every drain, so a + // timed screen advances here. virtual void Refresh(void) = 0; std::optional Result; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 72f5e4d8b..2d5a2dbcd 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -836,6 +836,7 @@ UIResult UI_Run_Modal(UIRmlViewClass & view) } UI_Tick(); + view.Presenter().Refresh(); view.Presenter().Drain(); view.Sync(); @@ -914,6 +915,29 @@ void UI_Hide_Modeless(UIRmlViewClass & view) } +namespace +{ + +class UISystemClockClass : public UIClockClass +{ + public: + virtual int Milliseconds(void) override + { + return((int)GetTickCount64()); + } +}; + +UISystemClockClass _Clock; + +} + + +UIClockClass & UI_Clock(void) +{ + return(_Clock); +} + + void UI_Refresh(void) { if (!_Ready || _InContext) { diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 8bb4a0485..2ca95c052 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -47,6 +47,9 @@ void UI_Hide_Modeless(UIRmlViewClass & view); // Advances the documents and presents the overlay now. void UI_Refresh(void); +// The system clock a timed screen's presenter reads. +UIClockClass & UI_Clock(void); + // The frame moved or changed size inside the window. void UI_On_Video_Change(void); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 931764725..303420fe0 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -769,10 +769,13 @@ beyond an ASCII test document. 7. **Options family** (L, two changes each; the game controls landed: the behaviour sits behind `UIGameControlsPresenterClass` and an engine service, `gamectrl.rml` covers the three Win32 templates with `data-if`, and the - templates remain the fallback view). Main options, display with its timed - rollback, keyboard with the hotkey capture control, the display-mode - confirmation, abort and surrender remain. The in-game options menu opens - load, save and delete, so it follows step 9. Evidence: settings round-trip + templates remain the fallback view; the display options and the mode + confirmation landed their first change: `UIDisplayPresenterClass` hands the + caller the mode to try, `UIConfirmModePresenterClass` reads a `UIClockClass` + and cancels itself at the timeout, which replaced the posted `WM_DESTROY`). + Main options, the display documents, keyboard with the hotkey capture + control, abort and surrender remain. The in-game options menu opens load, + save and delete, so it follows step 9. Evidence: settings round-trip through `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, multiplayer game selection. The `NewMenuClass` drivers keep their loops. diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 72ea5db86..9cb65e337 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -5,6 +5,7 @@ # point up. add_executable(UIShell "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uidisplay.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uigamectrl.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index d821c3041..ff6e8ec33 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -31,6 +31,7 @@ #include #include "ui/uicoord.h" +#include "ui/uidisplay.h" #include "ui/uigamectrl.h" #include "ui/uimsgbox.h" #include "ui/uirmlview.h" @@ -423,6 +424,137 @@ void Drive(UISoundPresenterClass & presenter, char const * name, int value = 0) } +void Drive(UIPresenterClass & presenter, char const * name, int value = 0) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + presenter.Queue(intent); + presenter.Drain(); +} + + +// Records the engine call the display options presenter makes. +class RecordingDisplayServiceClass : public UIDisplayServiceClass +{ + public: + std::vector Calls; + + virtual void Set_Stretch_Movies(bool on) override { Calls.push_back(on ? "stretch on" : "stretch off"); } +}; + + +// A clock the test moves by hand. +class FakeClockClass : public UIClockClass +{ + public: + int Now = 0; + + virtual int Milliseconds(void) override { return(Now); } +}; + + +UIDisplayState Display_Fixture(void) +{ + UIDisplayState state; + state.Modes = { { 640, 400, "640 x 400" }, { 1280, 800, "1280 x 800" }, { 1920, 1080, "1920 x 1080" } }; + state.Selected = 1; + return(state); +} + + +// The display presenter applies the movie switch as the player accepts and hands the caller a +// mode to try only when the row changed; the confirmation presenter cancels itself when its +// clock runs out. +void Test_Display_Presenter(void) +{ + { + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + Drive(presenter, "select", 2); + Drive(presenter, "stretch", 1); + Check(presenter.State.Selected == 2 && presenter.State.StretchMovies && service.Calls.empty() && !presenter.Picked.has_value(), "display edits are held until the player accepts"); + + Drive(presenter, "ok"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls.size() == 1 && service.Calls[0] == "stretch on", "accepting the display options applies the movie switch"); + Check(presenter.Picked.has_value() && presenter.Picked->Width == 1920 && presenter.Picked->Height == 1080, "accepting with a new row hands the caller that mode to try"); + } + + { + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + Drive(presenter, "select", 2); + Drive(presenter, "select", 1); + Drive(presenter, "ok"); + Check(presenter.Result.has_value() && !presenter.Picked.has_value() && service.Calls.size() == 1 && service.Calls[0] == "stretch off", "accepting on the starting row applies the switch and tries no mode"); + } + + { + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + Drive(presenter, "select", 0); + Drive(presenter, "stretch", 1); + Drive(presenter, "cancel"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && service.Calls.empty() && !presenter.Picked.has_value(), "cancelling the display options applies nothing"); + } + + { + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + Drive(presenter, "select", 7); + Check(presenter.State.Selected == -1, "a row outside the list selects nothing"); + Drive(presenter, "ok"); + Check(presenter.Result.has_value() && !presenter.Picked.has_value(), "accepting with no row selected tries no mode"); + } + + { + RecordingDisplayServiceClass service; + UIDisplayState state = Display_Fixture(); + state.Selected = -1; + UIDisplayPresenterClass presenter(service, state); + Drive(presenter, "select", 0); + Drive(presenter, "ok"); + Check(presenter.Picked.has_value() && presenter.Picked->Width == 640 && presenter.Picked->Height == 400, "a pick with no starting row is a mode to try"); + } + + { + FakeClockClass clock; + UIConfirmModePresenterClass presenter(clock); + Check(presenter.Seconds == 10, "the confirmation starts with the full ten seconds"); + + clock.Now = 5000; + presenter.Refresh(); + Check(presenter.Seconds == 10 && !presenter.Result.has_value(), "the clock starts at the first refresh"); + + clock.Now = 5000 + 8100; + presenter.Refresh(); + Check(presenter.Seconds == 2 && !presenter.Result.has_value(), "the seconds left count down"); + + clock.Now = 5000 + 10000; + presenter.Refresh(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && presenter.TimedOut && presenter.Seconds == 0, "silence cancels the confirmation when the timeout passes"); + } + + { + FakeClockClass clock; + UIConfirmModePresenterClass presenter(clock); + presenter.Refresh(); + Drive(presenter, "ok"); + clock.Now = 20000; + presenter.Refresh(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && !presenter.TimedOut, "OK keeps the mode and a later refresh does not overturn it"); + } + + { + FakeClockClass clock; + UIConfirmModePresenterClass presenter(clock); + presenter.Refresh(); + Drive(presenter, "cancel"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && !presenter.TimedOut, "Cancel refuses the mode before the timeout"); + } +} + + // The sound presenter makes the calls the Win32 dialog procedure made, in the same order. void Test_Sound_Presenter(void) { @@ -1413,6 +1545,7 @@ int main(void) Test_FreeType(); Test_ImGui(); Test_Coordinates(); + Test_Display_Presenter(); Test_Game_Controls_Presenter(); Test_Sound_Presenter(); Test_Strings(); From 77e2d3a825f86db4ac08502ad75115aa95ef2e74 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 08:38:33 +0300 Subject: [PATCH 15/52] Show the display options as RmlUi documents --- code/mainopt.cpp | 101 ++++++++++------ code/ui/uidisplay.cpp | 80 +++++++++++++ code/ui/uidisplay.h | 10 ++ code/ui/uidisplaydlg.cpp | 46 +++++++ docs/UI_DESIGN.md | 15 +-- manual/changes/rmlui-display-options.md | 13 ++ manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/ui-files.md | 2 +- tests/uishell/uishell.cpp | 144 ++++++++++++++++++++++ ui/confirm.rcss | 75 ++++++++++++ ui/confirm.rml | 15 +++ ui/display.rcss | 153 ++++++++++++++++++++++++ ui/display.rml | 18 +++ 13 files changed, 630 insertions(+), 44 deletions(-) create mode 100644 manual/changes/rmlui-display-options.md create mode 100644 ui/confirm.rcss create mode 100644 ui/confirm.rml create mode 100644 ui/display.rcss create mode 100644 ui/display.rml diff --git a/code/mainopt.cpp b/code/mainopt.cpp index 7623a43d7..ad5b6a699 100644 --- a/code/mainopt.cpp +++ b/code/mainopt.cpp @@ -40,6 +40,8 @@ #include "color.hh" +#include + INT_PTR CALLBACK Main_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); INT_PTR CALLBACK Display_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); @@ -47,6 +49,8 @@ bool Change_Display_Mode(int width, int height); bool Test_Display_Mode_Dialog(int width, int height); INT_PTR CALLBACK Test_Display_Mode_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); static void Display_Options_Dialog(void); +static std::optional Display_Options_Win32_Dialog(void); +static bool Confirm_Mode_Win32_Dialog(void); // The presenters the display and confirmation dialog procedures are views of, each for the // life of one dialog. @@ -313,6 +317,27 @@ bool Test_Display_Mode_Dialog(int width, int height) Show_Mouse(); Draw_Menu_Background(); + bool kept = false; + if (!UI_Use_Rml() || !UI_Confirm_Mode_Dialog(kept)) { + kept = Confirm_Mode_Win32_Dialog(); + } + + if (!kept) { + DebugString("Resetting display mode @ %dx%d\n", Options.ScreenWidth, Options.ScreenHeight); + Change_Display_Mode(Options.ScreenWidth, Options.ScreenHeight); + LogicalSurface = HiddenSurface; + return(false); + } + + DebugString("Keeping display mode @ %dx%d\n", width, height); + LogicalSurface = HiddenSurface; + return(true); +} + + +// A dialog that could not be created keeps the mode, as it always has. +static bool Confirm_Mode_Win32_Dialog(void) +{ UIConfirmModePresenterClass presenter(UI_Clock()); _ConfirmPresenter = &presenter; @@ -333,16 +358,10 @@ bool Test_Display_Mode_Dialog(int width, int height) } _ConfirmPresenter = NULL; - if (dialog && (!presenter.Result.has_value() || *presenter.Result != UI_RESULT_ACCEPTED)) { - DebugString("Resetting display mode @ %dx%d\n", Options.ScreenWidth, Options.ScreenHeight); - Change_Display_Mode(Options.ScreenWidth, Options.ScreenHeight); - LogicalSurface = HiddenSurface; - return(false); + if (dialog == NULL) { + return(true); } - - DebugString("Keeping display mode @ %dx%d\n", width, height); - LogicalSurface = HiddenSurface; - return(true); + return(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED); } @@ -381,46 +400,58 @@ INT_PTR CALLBACK Test_Display_Mode_Dialog_Proc(HWND window, UINT message, WPARAM static void Display_Options_Dialog(void) { while (true) { - UIDisplayState state; - UI_Display_State(state); - UIDisplayPresenterClass presenter(UI_Display_Service(), state); - _DisplayPresenter = &presenter; - - HWND handle; - LONG rc; - do { - rc = -1; - handle = OwnerDraw::Begin_Dialog(IDD_OPT_DISPLAY, Display_Options_Dialog_Proc); - } while (handle == 0); - SetWindowLongPtr(handle, DWLP_USER, (LONG_PTR)&rc); - OwnerDraw::Display_Dialog(handle); - - while (rc < 0) { - if (OwnerDraw::Dialog_Message_Handler() == true) { - break; - } - Title_Screen_Restore(); + std::optional picked; + if (!UI_Use_Rml() || !UI_Display_Dialog(picked)) { + picked = Display_Options_Win32_Dialog(); } - OwnerDraw::End_Dialog(handle); - _DisplayPresenter = NULL; - - if (!presenter.Picked.has_value()) { + if (!picked.has_value()) { break; } if (WWMessageBox().Process(TXT_ABOUT_TO_TRY_MODE, TXT_OK, TXT_CANCEL) != 0) { break; } - if (Test_Display_Mode_Dialog(presenter.Picked->Width, presenter.Picked->Height)) { - Options.ScreenWidth = presenter.Picked->Width; - Options.ScreenHeight = presenter.Picked->Height; + if (Test_Display_Mode_Dialog(picked->Width, picked->Height)) { + Options.ScreenWidth = picked->Width; + Options.ScreenHeight = picked->Height; break; } } } +// The mode the player asked to try, or nothing when the dialog closed without one. +static std::optional Display_Options_Win32_Dialog(void) +{ + UIDisplayState state; + UI_Display_State(state); + UIDisplayPresenterClass presenter(UI_Display_Service(), state); + _DisplayPresenter = &presenter; + + HWND handle; + LONG rc; + do { + rc = -1; + handle = OwnerDraw::Begin_Dialog(IDD_OPT_DISPLAY, Display_Options_Dialog_Proc); + } while (handle == 0); + SetWindowLongPtr(handle, DWLP_USER, (LONG_PTR)&rc); + OwnerDraw::Display_Dialog(handle); + + while (rc < 0) { + if (OwnerDraw::Dialog_Message_Handler() == true) { + break; + } + Title_Screen_Restore(); + } + + OwnerDraw::End_Dialog(handle); + _DisplayPresenter = NULL; + + return(presenter.Picked); +} + + /// /// Handles the display options dialog messages. /// This routine seeds the resolution list and the movie switch from the presenter's state diff --git a/code/ui/uidisplay.cpp b/code/ui/uidisplay.cpp index ec377c078..4b0d753ff 100644 --- a/code/ui/uidisplay.cpp +++ b/code/ui/uidisplay.cpp @@ -81,3 +81,83 @@ void UIConfirmModePresenterClass::Refresh(void) Result = UI_RESULT_CANCELLED; } } + + +namespace +{ + +class UIDisplayViewClass : public UIRmlViewClass +{ + public: + explicit UIDisplayViewClass(UIDisplayPresenterClass & presenter) : + UIRmlViewClass(presenter, "display.rml", "display"), + Data(presenter) + { + } + + // The model is small, so every field is re-read after each drain. + virtual void Sync(void) override + { + Model.DirtyAllVariables(); + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + Rml::StructHandle mode = model.RegisterStruct(); + if (!mode) { + return(false); + } + mode.RegisterMember("label", &UIDisplayMode::Label); + mode.RegisterMember("width", &UIDisplayMode::Width); + mode.RegisterMember("height", &UIDisplayMode::Height); + + UIDisplayState & state = Data.State; + return(model.RegisterArray>() + && model.Bind("modes", &state.Modes) + && model.Bind("selected", &state.Selected) + && model.Bind("stretch", &state.StretchMovies)); + } + + private: + UIDisplayPresenterClass & Data; +}; + + +class UIConfirmModeViewClass : public UIRmlViewClass +{ + public: + explicit UIConfirmModeViewClass(UIConfirmModePresenterClass & presenter) : + UIRmlViewClass(presenter, "confirm.rml", "confirm"), + Data(presenter) + { + } + + virtual void Sync(void) override + { + Model.DirtyAllVariables(); + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + return(model.Bind("seconds", &Data.Seconds)); + } + + private: + UIConfirmModePresenterClass & Data; +}; + +} + + +std::unique_ptr UI_Display_View(UIDisplayPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} + + +std::unique_ptr UI_Confirm_Mode_View(UIConfirmModePresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uidisplay.h b/code/ui/uidisplay.h index e766c658c..042342c49 100644 --- a/code/ui/uidisplay.h +++ b/code/ui/uidisplay.h @@ -103,3 +103,13 @@ std::unique_ptr UI_Confirm_Mode_View(UIConfirmModePresenterClass // view. UIDisplayServiceClass & UI_Display_Service(void); void UI_Display_State(UIDisplayState & state); + +// Runs the display options as an RmlUi screen. False means it could not run as one and the +// caller should open its Win32 dialog; otherwise picked carries the mode the player asked to +// try, if any. +bool UI_Display_Dialog(std::optional & picked); + +// Runs the mode confirmation as an RmlUi screen. False means it could not run as one and the +// caller should open its Win32 dialog; otherwise kept says whether the player kept the mode +// before the timeout. +bool UI_Confirm_Mode_Dialog(bool & kept); diff --git a/code/ui/uidisplaydlg.cpp b/code/ui/uidisplaydlg.cpp index 830d521eb..4180daf75 100644 --- a/code/ui/uidisplaydlg.cpp +++ b/code/ui/uidisplaydlg.cpp @@ -15,6 +15,8 @@ #include "globals.h" #include "goptions.h" +#include "ui/uirmlview.h" +#include "ui/uishell.h" #include "video.h" #include @@ -78,3 +80,47 @@ void UI_Display_State(UIDisplayState & state) delete [] modes; } + + +bool UI_Display_Dialog(std::optional & picked) +{ + picked.reset(); + + if (UI_Legacy_Dialog_Visible()) { + return(false); + } + + UIDisplayState state; + UI_Display_State(state); + + UIDisplayPresenterClass presenter(UI_Display_Service(), state); + std::unique_ptr view = UI_Display_View(presenter); + + if (UI_Run_Modal(*view) == UI_RESULT_FAILED_TO_OPEN) { + return(false); + } + + picked = presenter.Picked; + return(true); +} + + +bool UI_Confirm_Mode_Dialog(bool & kept) +{ + kept = false; + + if (UI_Legacy_Dialog_Visible()) { + return(false); + } + + UIConfirmModePresenterClass presenter(UI_Clock()); + std::unique_ptr view = UI_Confirm_Mode_View(presenter); + + UIResult result = UI_Run_Modal(*view); + if (result == UI_RESULT_FAILED_TO_OPEN) { + return(false); + } + + kept = (result == UI_RESULT_ACCEPTED); + return(true); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 303420fe0..4542187a2 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -770,13 +770,14 @@ beyond an ASCII test document. behaviour sits behind `UIGameControlsPresenterClass` and an engine service, `gamectrl.rml` covers the three Win32 templates with `data-if`, and the templates remain the fallback view; the display options and the mode - confirmation landed their first change: `UIDisplayPresenterClass` hands the - caller the mode to try, `UIConfirmModePresenterClass` reads a `UIClockClass` - and cancels itself at the timeout, which replaced the posted `WM_DESTROY`). - Main options, the display documents, keyboard with the hotkey capture - control, abort and surrender remain. The in-game options menu opens load, - save and delete, so it follows step 9. Evidence: settings round-trip - through `SUN.INI` unchanged. + confirmation landed too: `UIDisplayPresenterClass` hands the caller the + mode to try, `UIConfirmModePresenterClass` reads a `UIClockClass` and + cancels itself at the timeout, which replaced the posted `WM_DESTROY`, and + `display.rml` and `confirm.rml` are their documents, the latter counting + the seconds down). Main options, keyboard with the hotkey capture control, + abort and surrender remain. The in-game options menu opens load, save and + delete, so it follows step 9. Evidence: settings round-trip through + `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, multiplayer game selection. The `NewMenuClass` drivers keep their loops. 9. **Load, save, delete** (M, two changes). diff --git a/manual/changes/rmlui-display-options.md b/manual/changes/rmlui-display-options.md new file mode 100644 index 000000000..264d7c200 --- /dev/null +++ b/manual/changes/rmlui-display-options.md @@ -0,0 +1,13 @@ +--- +title: Show the display options as RmlUi documents +category: feature +release: 0.2.0 +targets: +- type: system + id: ui-files + effect: changed +credit: +- ZivDero +--- + +The display options open as an RmlUi document from the options menu, with the same resolution list and movie switch, and the confirmation shown after a mode change is a document too. Accepting still stores the switch at once and tries a new mode first, and a mode the player does not keep within ten seconds is still restored; the document counts the seconds down where the Win32 dialog only said to wait. `LegacyDialogs=yes` keeps both Win32 dialogs. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index 8ba9857a1..118ae9ace 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options, the game controls and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options, the game controls, the display options with their mode confirmation, and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index 148856857..916acbd7d 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -23,4 +23,4 @@ A document names an engine string as `[[TXT_NAME]]`, using the identifier names ## Choosing the Win32 dialogs -[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options, the game controls and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options, the game controls, the display options with the confirmation that follows a mode change, and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index ff6e8ec33..70b7edded 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -1377,6 +1377,149 @@ void Test_Game_Controls_Screen(Rml::Context & context, CountingSystemInterfaceCl } +// Drives the display options and the mode confirmation: the resolution rows select, the +// switch toggles, OK hands the caller a mode, and the confirmation counts down and cancels +// itself when its clock runs out. +void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + std::unique_ptr view = UI_Display_View(presenter); + + Check(view->Prepare(context), "the display view prepares against the test context"); + view->Show(true); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the display screen raises no RmlUi warning or error"); + + Rml::ElementDocument * document = view->Document(); + std::vector rows = Visible_Of_Class(document, "mode"); + Check(rows.size() == 3, "the display screen lists one row per mode"); + Check(rows.size() == 3 && rows[1]->IsClassSet("selected") && rows[1]->GetInnerRML() == "1280 x 800", "the row of the stored mode starts selected"); + + if (rows.size() == 3) { + Click(context, rows[2]); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.Selected == 2 && rows[2]->IsClassSet("selected") && !rows[1]->IsClassSet("selected"), "a click on a row selects it"); + } + + Rml::Element * stretch = document->GetElementById("stretch"); + if (stretch != nullptr) { + Click(context, stretch); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.StretchMovies && stretch->HasAttribute("checked") && service.Calls.empty(), "the movie switch turns on and shows it without applying"); + } + + Rml::Element * ok = document->GetElementById("ok"); + Rml::Element * cancel = document->GetElementById("cancel"); + Check(ok != nullptr && cancel != nullptr && ok->GetAbsoluteOffset(Rml::BoxArea::Border).x < cancel->GetAbsoluteOffset(Rml::BoxArea::Border).x, "OK sits left of Cancel"); + + if (ok != nullptr) { + Click(context, ok); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls.size() == 1 && service.Calls[0] == "stretch on", "OK applies the movie switch"); + Check(presenter.Picked.has_value() && presenter.Picked->Width == 1920 && presenter.Picked->Height == 1080, "OK hands the caller the picked mode"); + } + + view->Release(); + context.Update(); + } + + { + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + std::unique_ptr view = UI_Display_View(presenter); + + Check(view->Prepare(context), "a second display view prepares"); + view->Show(true); + context.Update(); + + std::vector rows = Visible_Of_Class(view->Document(), "mode"); + if (rows.size() == 3) { + Click(context, rows[0]); + presenter.Drain(); + } + + context.ProcessKeyDown(Rml::Input::KI_ESCAPE, 0); + context.ProcessKeyUp(Rml::Input::KI_ESCAPE, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && service.Calls.empty() && !presenter.Picked.has_value(), "Escape leaves the display options with nothing applied"); + + view->Release(); + context.Update(); + } + + { + FakeClockClass clock; + UIConfirmModePresenterClass presenter(clock); + std::unique_ptr view = UI_Confirm_Mode_View(presenter); + + Check(view->Prepare(context), "the confirmation view prepares against the test context"); + presenter.Refresh(); + view->Show(true); + view->Sync(); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the confirmation screen raises no RmlUi warning or error"); + + Rml::ElementDocument * document = view->Document(); + Rml::Element * seconds = document->GetElementById("seconds"); + Check(seconds != nullptr && seconds->GetInnerRML() == "10", "the confirmation shows the ten seconds left"); + + clock.Now = 7500; + presenter.Refresh(); + view->Sync(); + context.Update(); + Check(seconds != nullptr && seconds->GetInnerRML() == "3", "the seconds shown follow the clock"); + + Check(Visible_Buttons(document).size() == 2, "the confirmation has OK and Cancel"); + + Rml::Element * ok = document->GetElementById("ok"); + if (ok != nullptr) { + Click(context, ok); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && !presenter.TimedOut, "OK keeps the mode"); + } + + view->Release(); + context.Update(); + } + + { + FakeClockClass clock; + UIConfirmModePresenterClass presenter(clock); + std::unique_ptr view = UI_Confirm_Mode_View(presenter); + + Check(view->Prepare(context), "a second confirmation view prepares"); + presenter.Refresh(); + view->Show(true); + view->Sync(); + context.Update(); + + clock.Now = 10000; + presenter.Refresh(); + presenter.Drain(); + view->Sync(); + context.Update(); + + Rml::Element * seconds = view->Document()->GetElementById("seconds"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && presenter.TimedOut, "the confirmation cancels itself when the clock runs out"); + Check(seconds != nullptr && seconds->GetInnerRML() == "0", "the countdown ends at zero"); + + view->Release(); + context.Update(); + } +} + + // Drives the wait box: the text follows the presenter, the frame appears only with a bar, and // the fill follows the percentage. void Test_Wait_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) @@ -1524,6 +1667,7 @@ void Test_Documents(void) Test_Message_Box_Screen(*context, system); Test_Sound_Screen(*context, system); Test_Game_Controls_Screen(*context, system); + Test_Display_Screen(*context, system); Test_Wait_Box_Screen(*context, system); } diff --git a/ui/confirm.rcss b/ui/confirm.rcss new file mode 100644 index 000000000..d7ebae8e4 --- /dev/null +++ b/ui/confirm.rcss @@ -0,0 +1,75 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, p +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 359dp; + height: 105dp; + margin-left: -180dp; + margin-top: -53dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +#text +{ + position: absolute; + left: 33dp; + top: 18dp; + width: 293dp; + height: 40dp; + overflow: hidden; +} + +#text p +{ + line-height: 15dp; +} + +button +{ + position: absolute; + top: 66dp; + width: 75dp; + height: 23dp; + display: block; + text-align: center; + line-height: 23dp; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} + +#ok +{ + left: 167dp; +} + +#cancel +{ + left: 251dp; +} diff --git a/ui/confirm.rml b/ui/confirm.rml new file mode 100644 index 000000000..c1a72d3ae --- /dev/null +++ b/ui/confirm.rml @@ -0,0 +1,15 @@ + + + Confirm display mode + + + +
+
+

Click OK to keep this display mode. Your old display settings will be restored in {{seconds}} secondseconds.

+
+ + +
+ +
diff --git a/ui/display.rcss b/ui/display.rcss new file mode 100644 index 000000000..a30e30656 --- /dev/null +++ b/ui/display.rcss @@ -0,0 +1,153 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, p, label +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 344dp; + height: 294dp; + margin-left: -172dp; + margin-top: -147dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +.heading +{ + position: absolute; + left: 33dp; + width: 278dp; + line-height: 15dp; + text-align: center; +} + +#title +{ + top: 18dp; +} + +#modes-label +{ + top: 37dp; +} + +#modes +{ + position: absolute; + left: 33dp; + top: 56dp; + width: 278dp; + height: 165dp; + overflow-y: auto; + background-color: #0c1116; + border: 1dp #3d5a68; +} + +.mode +{ + padding: 2dp 6dp; + line-height: 17dp; +} + +.mode:hover +{ + background-color: #1f3140; +} + +.mode.selected +{ + background-color: #225061; +} + +scrollbarvertical +{ + width: 12dp; +} + +scrollbarvertical slidertrack +{ + background-color: #1a242c; +} + +scrollbarvertical sliderbar +{ + background-color: #4d6f80; + min-height: 16dp; +} + +scrollbarvertical sliderarrowdec, scrollbarvertical sliderarrowinc +{ + width: 0; + height: 0; +} + +.switch +{ + position: absolute; + left: 33dp; + top: 228dp; + width: 278dp; + line-height: 23dp; +} + +input.checkbox +{ + width: 14dp; + height: 14dp; + margin-right: 8dp; + vertical-align: -2dp; + background-color: #2a3a48; + border: 1dp #6f95a8; +} + +input.checkbox:checked +{ + background-color: #8fb5c8; +} + +button +{ + position: absolute; + top: 255dp; + width: 93dp; + height: 23dp; + display: block; + text-align: center; + line-height: 23dp; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} + +#ok +{ + left: 33dp; +} + +#cancel +{ + left: 218dp; +} diff --git a/ui/display.rml b/ui/display.rml new file mode 100644 index 000000000..79e53c3a5 --- /dev/null +++ b/ui/display.rml @@ -0,0 +1,18 @@ + + + Display options + + + +
+

Display Options:

+

Resolution Modes

+
+
{{mode.label}}
+
+ + + +
+ +
From 8e7ebdfc4433feee4ad420d78fa1f21f9c4ac1a9 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 08:49:31 +0300 Subject: [PATCH 16/52] Put the keyboard dialog behaviour behind a presenter --- code/options.cpp | 243 +++++++++-------------- code/ui/uikeyboard.cpp | 191 ++++++++++++++++++ code/ui/uikeyboard.h | 109 ++++++++++ code/ui/uikeyboarddlg.cpp | 126 ++++++++++++ docs/UI_DESIGN.md | 11 +- manual/changes/keyboard-dialog-cancel.md | 13 ++ manual/content/formats/keyboard-ini.md | 2 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 131 ++++++++++++ 9 files changed, 676 insertions(+), 151 deletions(-) create mode 100644 code/ui/uikeyboard.cpp create mode 100644 code/ui/uikeyboard.h create mode 100644 code/ui/uikeyboarddlg.cpp create mode 100644 manual/changes/keyboard-dialog-cancel.md diff --git a/code/options.cpp b/code/options.cpp index a1bcf503a..8e6049fe9 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -81,6 +81,7 @@ #include "session.h" #include "techno.h" #include "theme.h" +#include "ui/uikeyboard.h" #include "vector.h" #include "video.h" #include "vox.h" @@ -593,34 +594,93 @@ int OptionsClass::Normalize_Volume(int volume) const } -/* - * Internal state-machine messages the hotkey-configuration dialog posts to - * itself. wParam/lParam are unused; each just triggers the matching refresh. - */ -#define HKD_FILL_COMMANDS (WM_USER + 100) /// rebuild the command listbox for the selected category -#define HKD_SHOW_COMMAND (WM_USER + 101) /// refresh the description / assigned-key panel for the selected command -#define HKD_APPLY_HOTKEY (WM_USER + 102) /// assign the hotkey edit's key to the selected command -#define HKD_REINIT (WM_USER + 103) /// full refresh: repopulate the category combo and reset +// The presenter the keyboard dialog procedure is a view of, for the life of one Hotkey_Dialog call. +static UIKeyboardPresenterClass * _KeyboardPresenter = NULL; + + +static void Queue_And_Drain(UIKeyboardPresenterClass & presenter, char const * name, int value = 0) +{ + UIIntent intent; + intent.Name = name; + intent.Value = value; + presenter.Queue(intent); + presenter.Drain(); +} + + +// The description, the selected command's shortcut, the owner of the captured key and the +// capture control's own key, from the state. +static void Hotkey_Dialog_Show(HWND window, UIKeyboardState const & state) +{ + SetWindowText(GetDlgItem(window, IDC_KEY_DESCRIPTION), state.Description.c_str()); + SetWindowText(GetDlgItem(window, IDC_KEY_CURRENT_SHORTCUT), state.Shortcut.c_str()); + SetWindowText(GetDlgItem(window, IDC_KEY_ASSIGNED_TO), state.AssignedTo.c_str()); + + HWND hotkey = GetDlgItem(window, IDC_KEY_HOTKEY); + if (hotkey != NULL && (int)SendMessage(hotkey, HKM_GETHOTKEY, 0, 0) != state.Captured) { + SendMessage(hotkey, HKM_SETHOTKEY, state.Captured, 0); + } +} + + +// The controls sort their rows themselves, so each row carries the presenter's index as its data. +static void Hotkey_Dialog_Fill_Commands(HWND window, UIKeyboardState const & state) +{ + HWND list = GetDlgItem(window, IDC_KEY_COMMANDS); + ListBox_ResetContent(list); + for (int command : state.Visible) { + int index = ListBox_AddString(list, state.Commands[command].Name.c_str()); + if (index != LB_ERR) { + ListBox_SetItemData(list, index, command); + if (command == state.Selected) { + ListBox_SetCurSel(list, index); + } + } + } + + Hotkey_Dialog_Show(window, state); +} + + +static void Hotkey_Dialog_Fill(HWND window, UIKeyboardState const & state) +{ + HWND categories = GetDlgItem(window, IDC_KEY_CATEGORY); + ComboBox_ResetContent(categories); + for (int category = 0; category < (int)state.Categories.size(); category++) { + int index = ComboBox_AddString(categories, state.Categories[category].c_str()); + if (index != CB_ERR) { + ComboBox_SetItemData(categories, index, category); + if (category == state.Category) { + ComboBox_SetCurSel(categories, index); + } + } + } + + Hotkey_Dialog_Fill_Commands(window, state); +} /// /// Handles the messages for the keyboard configuration dialog. -/// This routine drives the category, command and hotkey controls, and hands the reassigned -/// keys back to the hotkey command list. Accepting the dialog writes the assignments out to -/// KEYBOARD.INI; canceling puts the previous assignments back. +/// This routine seeds the category, command and hotkey controls from the keyboard presenter +/// and hands the player's picks back to it. The presenter edits a copy of the assignments: +/// accepting the dialog saves the copy to KEYBOARD.INI, cancelling drops it. /// /// Returns with TRUE if the message was consumed by this dialog. INT_PTR CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { - char buffer[64]; int * retval; - static int current_selection = -1; INT_PTR result = OwnerDraw::Default_Dialog_Proc(window, message, wparam, lparam); if (result) { return(result); } + UIKeyboardPresenterClass * presenter = _KeyboardPresenter; + if (presenter == NULL) { + return(FALSE); + } + retval = (int *)GetWindowLongPtr(window, DWLP_USER); switch (message) { @@ -628,17 +688,7 @@ INT_PTR CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LP switch (LOWORD(wparam)) { case IDOK: if (HIWORD(wparam) == BN_CLICKED) { - CCINIClass ini; - ini.Clear(); - - for (int i = 0; i < HotkeyCommands.Count(); i++) { - CommandClass const * cmd = HotkeyCommands.Fetch_By_Position(i); - int key = HotkeyCommands.Fetch_ID_By_Position(i); - ini.Put_Int("Hotkey", cmd->Get_Unique_Name(), key); - } - - CDFileClass file("Keyboard.ini"); - ini.Save(file, false); + Queue_And_Drain(*presenter, "ok"); *retval = IDOK; return(TRUE); } @@ -646,7 +696,7 @@ INT_PTR CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LP case IDCANCEL: if (HIWORD(wparam) == BN_CLICKED) { - Init_Hotkeys(); + Queue_And_Drain(*presenter, "cancel"); *retval = 2; return(TRUE); } @@ -654,7 +704,10 @@ INT_PTR CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LP case IDC_KEY_COMMANDS: if (HIWORD(wparam) == LBN_SELCHANGE) { - SendMessage(window, HKD_SHOW_COMMAND, 0, 0); + HWND list = (HWND)lparam; + int row = ListBox_GetCurSel(list); + Queue_And_Drain(*presenter, "select", (row == LB_ERR) ? -1 : (int)ListBox_GetItemData(list, row)); + Hotkey_Dialog_Show(window, presenter->State); HWND hotkey = GetDlgItem(window, IDC_KEY_HOTKEY); if (hotkey != NULL) { SetFocus(hotkey); @@ -664,148 +717,40 @@ INT_PTR CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LP break; case IDC_KEY_ASSIGN: - SendMessage(window, HKD_APPLY_HOTKEY, 0, 0); - SendMessage(window, HKD_SHOW_COMMAND, 0, 0); + Queue_And_Drain(*presenter, "assign"); + Hotkey_Dialog_Show(window, presenter->State); return(TRUE); case IDC_KEY_HOTKEY: if (HIWORD(wparam) == EN_CHANGE) { - int key = SendMessage((HWND)lparam, HKM_GETHOTKEY, 0, 0); - char const * key_name; - if (HotkeyCommands.Is_Present(key)) { - key_name = HotkeyCommands[key]->Get_Display_Name(); - if (key_name == NULL) { - key_name = ""; - } - } else { - key_name = ""; - } - HWND hotkey_name = GetDlgItem(window, IDC_KEY_ASSIGNED_TO); - SetWindowText(hotkey_name, key_name); + Queue_And_Drain(*presenter, "capture", (int)SendMessage((HWND)lparam, HKM_GETHOTKEY, 0, 0)); + SetWindowText(GetDlgItem(window, IDC_KEY_ASSIGNED_TO), presenter->State.AssignedTo.c_str()); return(TRUE); } break; case IDC_KEY_RESET_ALL: if (HIWORD(wparam) == BN_CLICKED) { - if (WWMessageBox()._Process(TXT_RESET_HOTKEYS, IDOK, TXT_YES, TXT_NO, TXT_NONE, false) == 0) { - DebugString("Deleting users KEYBOARD.INI\n"); - // Only the player's own file is discarded; the defaults a - // deployment ships are what the reset falls back on. - CCFileClass file("KEYBOARD.INI"); - file.Delete(); - Init_Hotkeys(); - SendMessage(window, HKD_REINIT, 0, 0); - return(TRUE); - } + Queue_And_Drain(*presenter, "reset"); + Hotkey_Dialog_Fill(window, presenter->State); + return(TRUE); } break; case IDC_KEY_CATEGORY: if (HIWORD(wparam) == CBN_SELCHANGE) { - SendMessage(window, HKD_FILL_COMMANDS, 0, 0); + HWND categories = (HWND)lparam; + int row = ComboBox_GetCurSel(categories); + Queue_And_Drain(*presenter, "category", (row == CB_ERR) ? -1 : (int)ComboBox_GetItemData(categories, row)); + Hotkey_Dialog_Fill_Commands(window, presenter->State); return(TRUE); } break; } return(TRUE); - case HKD_APPLY_HOTKEY: { - HWND list_commands = GetDlgItem(window, IDC_KEY_COMMANDS); - int selection = ListBox_GetCurSel(list_commands); - if (selection != LB_ERR) { - CommandClass const * cmd = (CommandClass const *)ListBox_GetItemData(list_commands, selection); - for (int i = 0; i < HotkeyCommands.Count(); i++) { - if (HotkeyCommands.Fetch_By_Position(i) == cmd) { - HotkeyCommands.Remove_Index(HotkeyCommands.Fetch_ID_By_Position(i)); - break; - } - } - HWND hotkey = GetDlgItem(window, IDC_KEY_HOTKEY); - int key = SendMessage(hotkey, HKM_GETHOTKEY, 0, 0); - if (key != 0) { - HotkeyCommands.Remove_Index(key); - HotkeyCommands.Add_Index(key, cmd); - return(TRUE); - } - } - return(TRUE); - } - - case HKD_SHOW_COMMAND: { - HWND list_commands = GetDlgItem(window, IDC_KEY_COMMANDS); - int selection = ListBox_GetCurSel(list_commands); - if (selection != LB_ERR) { - CommandClass const * cmd = (CommandClass const *)ListBox_GetItemData(list_commands, selection); - HWND description = GetDlgItem(window, IDC_KEY_DESCRIPTION); - SetWindowText(description, cmd->Get_Description()); - - int key = 0; - for (int i = 0; i < HotkeyCommands.Count(); i++) { - if (HotkeyCommands.Fetch_By_Position(i) == cmd) { - key = HotkeyCommands.Fetch_ID_By_Position(i); - break; - } - } - - HWND key_label = GetDlgItem(window, IDC_KEY_CURRENT_SHORTCUT); - Build_Hotkey_String((KeyNumType)key, buffer); - SetWindowText(key_label, buffer); - - HWND hotkey = GetDlgItem(window, IDC_KEY_HOTKEY); - SendMessage(hotkey, HKM_SETHOTKEY, 0, 0); - - HWND hotkey_name = GetDlgItem(window, IDC_KEY_ASSIGNED_TO); - SetWindowText(hotkey_name, ""); - return(TRUE); - } - return(TRUE); - } - - case HKD_FILL_COMMANDS: { - HWND cmb_category = GetDlgItem(window, IDC_KEY_CATEGORY); - if (ComboBox_GetCurSel(cmb_category) != current_selection) { - current_selection = ComboBox_GetCurSel(cmb_category); - GetWindowText(cmb_category, buffer, sizeof(buffer)); - HWND list_commands = GetDlgItem(window, IDC_KEY_COMMANDS); - ListBox_ResetContent(list_commands); - for (int i = 0; i < AllCommands.Count(); i++) { - CommandClass const * cmd = AllCommands[i]; - if (stricmp(cmd->Get_Category(), buffer) == 0) { - int index = ListBox_AddString(list_commands, cmd->Get_Display_Name()); - if (index != LB_ERR) { - ListBox_SetItemData(list_commands, index, (LPARAM)cmd); - } - } - } - HWND description = GetDlgItem(window, IDC_KEY_DESCRIPTION); - SetWindowText(description, ""); - ListBox_SetCurSel(description, 0); - SendMessage(window, HKD_SHOW_COMMAND, 0, 0); - return(TRUE); - } - return(TRUE); - } - - case HKD_REINIT: { - HWND cmb_category = GetDlgItem(window, IDC_KEY_CATEGORY); - ComboBox_ResetContent(cmb_category); - for (int i = 0; i < AllCommands.Count(); i++) { - CommandClass const * cmd = AllCommands[i]; - const char * s = cmd->Get_Category(); - if (ComboBox_FindString(cmb_category, 0, s) == CB_ERR) { - s = cmd->Get_Category(); - ComboBox_AddString(cmb_category, s); - } - } - ComboBox_SetCurSel(cmb_category, 0); - SendMessage(window, HKD_FILL_COMMANDS, 0, 0); - current_selection = -1; - return(TRUE); - } - case WM_INITDIALOG: - SendMessage(window, HKD_REINIT, 0, 0); + Hotkey_Dialog_Fill(window, presenter->State); return(FALSE); } @@ -824,6 +769,11 @@ bool OptionsClass::Hotkey_Dialog(void) HWND handle; int res = -1; + UIKeyboardState state; + UI_Keyboard_State(state); + UIKeyboardPresenterClass presenter(UI_Keyboard_Service(), state); + _KeyboardPresenter = &presenter; + handle = OwnerDraw::Begin_Dialog(IDD_OPT_KEYBOARD, Hotkey_Dialog_Proc); if (handle != NULL) { @@ -841,6 +791,7 @@ bool OptionsClass::Hotkey_Dialog(void) OwnerDraw::End_Dialog(handle); } + _KeyboardPresenter = NULL; return(true); } diff --git a/code/ui/uikeyboard.cpp b/code/ui/uikeyboard.cpp new file mode 100644 index 000000000..cfa58efac --- /dev/null +++ b/code/ui/uikeyboard.cpp @@ -0,0 +1,191 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uikeyboard.h" + +#include "ui/uirmlview.h" + +#include +#include +#include + + +// The Win32 controls sorted their rows without regard to case, and the categories were told +// apart the same way. +static int Compare_Ignoring_Case(std::string const & a, std::string const & b) +{ + size_t count = std::min(a.size(), b.size()); + for (size_t index = 0; index < count; index++) { + int ca = std::tolower((unsigned char)a[index]); + int cb = std::tolower((unsigned char)b[index]); + if (ca != cb) { + return((ca < cb) ? -1 : 1); + } + } + if (a.size() == b.size()) { + return(0); + } + return((a.size() < b.size()) ? -1 : 1); +} + + +UIKeyboardPresenterClass::UIKeyboardPresenterClass(UIKeyboardServiceClass & service, UIKeyboardState state) : + State(std::move(state)), + Service(service) +{ + Reload(); +} + + +void UIKeyboardPresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "category") { + Show_Category(intent.Value); + + } else if (intent.Name == "select") { + State.Selected = (intent.Value >= 0 && intent.Value < (int)State.Commands.size()) ? intent.Value : -1; + Show_Command(); + + } else if (intent.Name == "capture") { + State.Captured = intent.Value; + Update_Capture(); + + } else if (intent.Name == "assign") { + if (State.Selected >= 0) { + std::vector & bindings = State.Bindings; + bindings.erase(std::remove_if(bindings.begin(), bindings.end(), [this](UIHotkeyBinding const & binding) { + return(binding.Command == State.Selected || (State.Captured != 0 && binding.Key == State.Captured)); + }), bindings.end()); + if (State.Captured != 0) { + bindings.push_back({ State.Captured, State.Selected }); + } + Show_Command(); + } + + } else if (intent.Name == "reset") { + if (Service.Confirm_Reset()) { + Service.Reset(State.Bindings); + Reload(); + } + + } else if (intent.Name == "ok") { + Service.Save(State.Bindings); + Result = UI_RESULT_ACCEPTED; + + } else if (intent.Name == "cancel") { + Result = UI_RESULT_CANCELLED; + } +} + + +void UIKeyboardPresenterClass::Refresh(void) +{ +} + + +int UIKeyboardPresenterClass::Key_Of(int command) const +{ + for (UIHotkeyBinding const & binding : State.Bindings) { + if (binding.Command == command) { + return(binding.Key); + } + } + return(0); +} + + +int UIKeyboardPresenterClass::Owner_Of(int key) const +{ + if (key == 0) { + return(-1); + } + for (UIHotkeyBinding const & binding : State.Bindings) { + if (binding.Key == key) { + return(binding.Command); + } + } + return(-1); +} + + +void UIKeyboardPresenterClass::Reload(void) +{ + State.Categories.clear(); + for (UIHotkeyCommand const & command : State.Commands) { + bool known = false; + for (std::string const & category : State.Categories) { + if (Compare_Ignoring_Case(category, command.Category) == 0) { + known = true; + } + } + if (!known) { + State.Categories.push_back(command.Category); + } + } + std::sort(State.Categories.begin(), State.Categories.end(), [](std::string const & a, std::string const & b) { + return(Compare_Ignoring_Case(a, b) < 0); + }); + + Show_Category(State.Categories.empty() ? -1 : 0); +} + + +void UIKeyboardPresenterClass::Show_Category(int index) +{ + State.Category = (index >= 0 && index < (int)State.Categories.size()) ? index : -1; + + State.Visible.clear(); + if (State.Category >= 0) { + std::string const & category = State.Categories[State.Category]; + for (int command = 0; command < (int)State.Commands.size(); command++) { + if (Compare_Ignoring_Case(State.Commands[command].Category, category) == 0) { + State.Visible.push_back(command); + } + } + std::stable_sort(State.Visible.begin(), State.Visible.end(), [this](int a, int b) { + return(Compare_Ignoring_Case(State.Commands[a].Name, State.Commands[b].Name) < 0); + }); + } + + State.Selected = -1; + Show_Command(); +} + + +void UIKeyboardPresenterClass::Show_Command(void) +{ + if (State.Selected >= 0) { + State.Description = State.Commands[State.Selected].Description; + State.Shortcut = Name_Of_Key(Key_Of(State.Selected)); + } else { + State.Description.clear(); + State.Shortcut.clear(); + } + + State.Captured = 0; + Update_Capture(); +} + + +void UIKeyboardPresenterClass::Update_Capture(void) +{ + State.CapturedName = Name_Of_Key(State.Captured); + + int owner = Owner_Of(State.Captured); + State.AssignedTo = (owner >= 0) ? State.Commands[owner].Name : std::string(); +} + + +std::string UIKeyboardPresenterClass::Name_Of_Key(int key) +{ + if (key == 0) { + return(std::string()); + } + return(Service.Key_Name(key)); +} diff --git a/code/ui/uikeyboard.h b/code/ui/uikeyboard.h new file mode 100644 index 000000000..60e5cd72a --- /dev/null +++ b/code/ui/uikeyboard.h @@ -0,0 +1,109 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +#include +#include +#include + +class UIRmlViewClass; + + +// One game command as the keyboard dialog lists it. Its index in the state is the command's +// index in the game's command list. +struct UIHotkeyCommand +{ + std::string Category; + std::string Name; + std::string Description; +}; + + +// A key number, in the KEYBOARD.INI encoding, bound to a command. +struct UIHotkeyBinding +{ + int Key = 0; + int Command = -1; +}; + + +// The engine calls the keyboard dialog makes. The game supplies one over the hotkey table, +// the keyboard file and the message box; the test harness supplies one that records the calls. +class UIKeyboardServiceClass +{ + public: + virtual ~UIKeyboardServiceClass(void) = default; + + virtual std::string Key_Name(int key) = 0; + virtual bool Confirm_Reset(void) = 0; + // Discards the player's keyboard file, reloads the game's table and returns it. + virtual void Reset(std::vector & bindings) = 0; + // Makes the table the game's and writes the keyboard file. + virtual void Save(std::vector const & bindings) = 0; +}; + + +// What the dialog shows: the commands, the table being edited, the categories and the +// commands of the open one, the selected command's description and shortcut, and the key in +// the capture control with the command that owns it. +struct UIKeyboardState +{ + std::vector Commands; + std::vector Bindings; + std::vector Categories; + int Category = -1; + std::vector Visible; + int Selected = -1; + std::string Description; + std::string Shortcut; + int Captured = 0; + std::string CapturedName; + std::string AssignedTo; +}; + + +// Edits a copy of the hotkey table. Assigning gives the captured key to the selected command +// and takes it from whichever command held it; an empty capture leaves the command unbound. +// Accepting saves the copy; cancelling drops it; a confirmed reset reloads it from the game. +class UIKeyboardPresenterClass : public UIPresenterClass +{ + public: + UIKeyboardPresenterClass(UIKeyboardServiceClass & service, UIKeyboardState state); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + // The key bound to a command, or 0; the command bound to a key, or -1. + int Key_Of(int command) const; + int Owner_Of(int key) const; + + UIKeyboardState State; + + private: + void Reload(void); + void Show_Category(int index); + void Show_Command(void); + void Update_Capture(void); + std::string Name_Of_Key(int key); + + UIKeyboardServiceClass & Service; +}; + + +// The RmlUi view over a keyboard presenter, bound to keyboard.rml. The presenter must outlive +// it. +std::unique_ptr UI_Keyboard_View(UIKeyboardPresenterClass & presenter); + +// The game's service and the state of the hotkey table, shared by the Win32 dialog and the +// RmlUi view. +UIKeyboardServiceClass & UI_Keyboard_Service(void); +void UI_Keyboard_State(UIKeyboardState & state); diff --git a/code/ui/uikeyboarddlg.cpp b/code/ui/uikeyboarddlg.cpp new file mode 100644 index 000000000..e51e23440 --- /dev/null +++ b/code/ui/uikeyboarddlg.cpp @@ -0,0 +1,126 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the keyboard dialog: the service the presenter drives and the state it +// starts from. The presenter lives in uikeyboard.cpp so that the test harness can drive it +// against a recording service. + +#include "ui/uikeyboard.h" + +#include "_command.h" +#include "ccfile.h" +#include "ccini.h" +#include "cdfile.h" +#include "command.h" +#include "dbgprint.h" +#include "index.h" +#include "init.h" +#include "keyboard.h" +#include "language/language.h" +#include "msgbox.h" +#include "ownrdraw.h" +#include "vector.h" + + +namespace +{ + +void Fetch_Bindings(std::vector & bindings) +{ + bindings.clear(); + + for (int position = 0; position < HotkeyCommands.Count(); position++) { + CommandClass const * command = HotkeyCommands.Fetch_By_Position(position); + for (int index = 0; index < AllCommands.Count(); index++) { + if (AllCommands[index] == command) { + bindings.push_back({ HotkeyCommands.Fetch_ID_By_Position(position), index }); + break; + } + } + } +} + + +class UIKeyboardEngineServiceClass : public UIKeyboardServiceClass +{ + public: + virtual std::string Key_Name(int key) override + { + char buffer[128]; + Build_Hotkey_String((KeyNumType)key, buffer); + return(buffer); + } + + virtual bool Confirm_Reset(void) override + { + return(WWMessageBox()._Process(TXT_RESET_HOTKEYS, IDOK, TXT_YES, TXT_NO, TXT_NONE, false) == 0); + } + + // Only the player's own file is discarded; the defaults a deployment ships are what + // the reset falls back on. + virtual void Reset(std::vector & bindings) override + { + DebugString("Deleting users KEYBOARD.INI\n"); + CCFileClass file("KEYBOARD.INI"); + file.Delete(); + Init_Hotkeys(); + Fetch_Bindings(bindings); + } + + virtual void Save(std::vector const & bindings) override + { + HotkeyCommands.Clear(); + for (UIHotkeyBinding const & binding : bindings) { + if (binding.Key != 0 && binding.Command >= 0 && binding.Command < AllCommands.Count()) { + HotkeyCommands.Add_Index(binding.Key, AllCommands[binding.Command]); + } + } + + CCINIClass ini; + ini.Clear(); + for (int position = 0; position < HotkeyCommands.Count(); position++) { + CommandClass const * command = HotkeyCommands.Fetch_By_Position(position); + ini.Put_Int("Hotkey", command->Get_Unique_Name(), HotkeyCommands.Fetch_ID_By_Position(position)); + } + + CDFileClass file("Keyboard.ini"); + ini.Save(file, false); + } +}; + +UIKeyboardEngineServiceClass _Service; + +} + + +UIKeyboardServiceClass & UI_Keyboard_Service(void) +{ + return(_Service); +} + + +void UI_Keyboard_State(UIKeyboardState & state) +{ + state = UIKeyboardState(); + + for (int index = 0; index < AllCommands.Count(); index++) { + CommandClass const * command = AllCommands[index]; + char const * category = command->Get_Category(); + char const * name = command->Get_Display_Name(); + char const * description = command->Get_Description(); + + UIHotkeyCommand entry; + entry.Category = (category != NULL) ? category : ""; + entry.Name = (name != NULL) ? name : ""; + entry.Description = (description != NULL) ? description : ""; + state.Commands.push_back(entry); + } + + Fetch_Bindings(state.Bindings); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 4542187a2..814b2dd7f 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -774,10 +774,13 @@ beyond an ASCII test document. mode to try, `UIConfirmModePresenterClass` reads a `UIClockClass` and cancels itself at the timeout, which replaced the posted `WM_DESTROY`, and `display.rml` and `confirm.rml` are their documents, the latter counting - the seconds down). Main options, keyboard with the hotkey capture control, - abort and surrender remain. The in-game options menu opens load, save and - delete, so it follows step 9. Evidence: settings round-trip through - `SUN.INI` unchanged. + the seconds down; the keyboard dialog landed its first change: + `UIKeyboardPresenterClass` edits a copy of the hotkey table that OK saves + and Cancel drops, where the Win32 procedure edited the game's table and + reloaded the file on Cancel). Main options, the keyboard document with its + hotkey capture control, abort and surrender remain. The in-game options + menu opens load, save and delete, so it follows step 9. Evidence: settings + round-trip through `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, multiplayer game selection. The `NewMenuClass` drivers keep their loops. 9. **Load, save, delete** (M, two changes). diff --git a/manual/changes/keyboard-dialog-cancel.md b/manual/changes/keyboard-dialog-cancel.md new file mode 100644 index 000000000..63e2a1a61 --- /dev/null +++ b/manual/changes/keyboard-dialog-cancel.md @@ -0,0 +1,13 @@ +--- +title: Drop the keyboard dialog's edits on Cancel +category: fix +release: 0.2.0 +targets: +- type: format + id: keyboard-ini + effect: changed +credit: +- ZivDero +--- + +The keyboard dialog edits a copy of the hotkey table and hands it to the game only when the player accepts. Cancel used to reload `KEYBOARD.INI` over the live table, which kept the edits whenever the file was missing or unreadable; it now drops them in every case. diff --git a/manual/content/formats/keyboard-ini.md b/manual/content/formats/keyboard-ini.md index d7dc75c02..b35c7b7bc 100644 --- a/manual/content/formats/keyboard-ini.md +++ b/manual/content/formats/keyboard-ini.md @@ -22,7 +22,7 @@ ScatterObject=88 ; X After the file loads, OpenTS clears the current hotkey table and adds entries whose command name is registered and whose keyboard identifier is not zero. Unknown command names and zero values are ignored, and a name has to match the registered spelling exactly, including its case. -The table is cleared only once the file has been read, so a file that is missing or that cannot be parsed leaves the bindings already in force rather than emptying them. OpenTS looks the file up through the ordinary file layer, so a loose `KEYBOARD.INI` in the game directory stands in for an archived one. The keyboard dialog writes it back as a loose file from the bindings in force at the time, and its reset control deletes the file and rebuilds the table without it. +The table is cleared only once the file has been read, so a file that is missing or that cannot be parsed leaves the bindings already in force rather than emptying them. OpenTS looks the file up through the ordinary file layer, so a loose `KEYBOARD.INI` in the game directory stands in for an archived one. The keyboard dialog writes it back as a loose file from the bindings the player accepted; cancelling the dialog drops its edits, and its reset control deletes the file and rebuilds the table without it. A command that carries a forced binding is bound again once the file has been processed, taking that key back from whatever the file gave it. The file's own binding for that command is left alone, so it can end up answering to two keys. diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 9cb65e337..d8b55ac97 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(UIShell "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uidisplay.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uigamectrl.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uikeyboard.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 70b7edded..f5d007e7e 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -33,6 +33,7 @@ #include "ui/uicoord.h" #include "ui/uidisplay.h" #include "ui/uigamectrl.h" +#include "ui/uikeyboard.h" #include "ui/uimsgbox.h" #include "ui/uirmlview.h" #include "ui/uiscreen.h" @@ -555,6 +556,135 @@ void Test_Display_Presenter(void) } +// Records the engine calls the keyboard presenter makes; keys are named by number. +class RecordingKeyboardServiceClass : public UIKeyboardServiceClass +{ + public: + std::vector Calls; + bool ConfirmAnswer = true; + std::vector ResetTable; + + virtual std::string Key_Name(int key) override + { + return("K" + std::to_string(key)); + } + + virtual bool Confirm_Reset(void) override + { + Calls.push_back("confirm"); + return(ConfirmAnswer); + } + + virtual void Reset(std::vector & bindings) override + { + Calls.push_back("reset"); + bindings = ResetTable; + } + + // The table is listed by command so the order the presenter keeps it in does not matter. + virtual void Save(std::vector const & bindings) override + { + std::vector sorted = bindings; + std::sort(sorted.begin(), sorted.end(), [](UIHotkeyBinding const & a, UIHotkeyBinding const & b) { + return(a.Command < b.Command); + }); + std::string call = "save"; + for (UIHotkeyBinding const & binding : sorted) { + call += " " + std::to_string(binding.Command) + "=" + std::to_string(binding.Key); + } + Calls.push_back(call); + } +}; + + +UIKeyboardState Keyboard_Fixture(void) +{ + UIKeyboardState state; + state.Commands = { + { "Selection", "Select View", "Selects the view" }, + { "Interface", "Toggle Repair", "Toggles repair mode" }, + { "selection", "Scatter", "Scatters the selection" }, + { "Interface", "Alliance", "Toggles an alliance" }, + }; + state.Bindings = { { 577, 0 }, { 338, 1 }, { 88, 2 } }; + return(state); +} + + +// The keyboard presenter edits a copy of the hotkey table the way the Win32 dialog edited the +// game's: a key moves to the selected command, an empty capture unbinds it, OK saves and Cancel +// drops the edits. +void Test_Keyboard_Presenter(void) +{ + RecordingKeyboardServiceClass service; + UIKeyboardPresenterClass presenter(service, Keyboard_Fixture()); + + Check(presenter.State.Categories == std::vector{ "Interface", "Selection" }, "the categories are listed once each, sorted without regard to case"); + Check(presenter.State.Category == 0 && presenter.State.Visible == std::vector{ 3, 1 } && presenter.State.Selected == -1, "the first category opens with its commands sorted by name and none selected"); + + Drive(presenter, "category", 1); + Check(presenter.State.Visible == std::vector{ 2, 0 } && presenter.State.Description.empty(), "another category lists its own commands with the description cleared"); + + Drive(presenter, "select", 0); + Check(presenter.State.Description == "Selects the view" && presenter.State.Shortcut == "K577", "selecting a command shows its description and shortcut"); + + Drive(presenter, "capture", 338); + Check(presenter.State.CapturedName == "K338" && presenter.State.AssignedTo == "Toggle Repair", "a captured key names the command that holds it"); + + Drive(presenter, "capture", 999); + Check(presenter.State.AssignedTo.empty(), "a free key names no command"); + + Drive(presenter, "capture", 338); + Drive(presenter, "assign"); + Check(presenter.Key_Of(0) == 338 && presenter.Key_Of(1) == 0 && presenter.Owner_Of(577) == -1, "assigning moves the key to the selected command and unbinds its previous holder"); + Check(presenter.State.Shortcut == "K338" && presenter.State.Captured == 0 && presenter.State.AssignedTo.empty(), "assigning shows the new shortcut and clears the capture"); + + Drive(presenter, "select", 2); + Drive(presenter, "assign"); + Check(presenter.Key_Of(2) == 0 && presenter.State.Shortcut.empty(), "assigning an empty capture unbinds the command"); + + Drive(presenter, "select", -1); + Drive(presenter, "capture", 65); + Drive(presenter, "assign"); + Check(presenter.Owner_Of(65) == -1 && presenter.State.Captured == 65, "assigning with no command selected changes nothing"); + + Check(service.Calls.empty(), "nothing reaches the game before the player accepts"); + + Drive(presenter, "ok"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls == std::vector{ "save 0=338" }, "OK saves the edited table"); + + { + RecordingKeyboardServiceClass quiet; + UIKeyboardPresenterClass cancelled(quiet, Keyboard_Fixture()); + Drive(cancelled, "select", 3); + Drive(cancelled, "capture", 70); + Drive(cancelled, "assign"); + Drive(cancelled, "cancel"); + Check(cancelled.Result.has_value() && *cancelled.Result == UI_RESULT_CANCELLED && quiet.Calls.empty(), "Cancel drops the edits without a call"); + } + + { + RecordingKeyboardServiceClass declined; + declined.ConfirmAnswer = false; + UIKeyboardPresenterClass kept(declined, Keyboard_Fixture()); + Drive(kept, "category", 1); + Drive(kept, "reset"); + Check(declined.Calls == std::vector{ "confirm" } && kept.Key_Of(0) == 577 && kept.State.Category == 1, "a declined reset asks and changes nothing"); + } + + { + RecordingKeyboardServiceClass confirmed; + confirmed.ResetTable = { { 65, 3 } }; + UIKeyboardPresenterClass reset(confirmed, Keyboard_Fixture()); + Drive(reset, "category", 1); + Drive(reset, "select", 0); + Drive(reset, "reset"); + Check(confirmed.Calls == std::vector{ "confirm", "reset" } && reset.Key_Of(3) == 65 && reset.Key_Of(0) == 0, "a confirmed reset reloads the table from the game"); + Check(reset.State.Category == 0 && reset.State.Selected == -1 && reset.State.Description.empty(), "a reset reopens the first category with nothing selected"); + } +} + + // The sound presenter makes the calls the Win32 dialog procedure made, in the same order. void Test_Sound_Presenter(void) { @@ -1691,6 +1821,7 @@ int main(void) Test_Coordinates(); Test_Display_Presenter(); Test_Game_Controls_Presenter(); + Test_Keyboard_Presenter(); Test_Sound_Presenter(); Test_Strings(); Test_Documents(); From d1e15a38cbbed6d447391bb7bf098e56b6832e91 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 09:01:09 +0300 Subject: [PATCH 17/52] Show the keyboard dialog as an RmlUi document --- code/options.cpp | 31 ++-- code/ui/uikeyboard.cpp | 109 ++++++++++++- code/ui/uikeyboard.h | 14 +- code/ui/uikeyboarddlg.cpp | 18 +++ code/ui/uikeys.cpp | 166 ++++++++++++++++++++ code/ui/uikeys.h | 30 ++++ code/ui/uirmlview.cpp | 1 + code/ui/uirmlview.h | 2 + code/ui/uishell.cpp | 94 +---------- docs/UI_DESIGN.md | 15 +- manual/changes/rmlui-keyboard.md | 16 ++ manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/ui-files.md | 2 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 164 ++++++++++++++++++- ui/keyboard.rcss | 227 +++++++++++++++++++++++++++ ui/keyboard.rml | 36 +++++ 17 files changed, 810 insertions(+), 118 deletions(-) create mode 100644 code/ui/uikeys.cpp create mode 100644 code/ui/uikeys.h create mode 100644 manual/changes/rmlui-keyboard.md create mode 100644 ui/keyboard.rcss create mode 100644 ui/keyboard.rml diff --git a/code/options.cpp b/code/options.cpp index 8e6049fe9..e8ec79253 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -82,6 +82,7 @@ #include "techno.h" #include "theme.h" #include "ui/uikeyboard.h" +#include "ui/uishell.h" #include "vector.h" #include "video.h" #include "vox.h" @@ -628,11 +629,11 @@ static void Hotkey_Dialog_Fill_Commands(HWND window, UIKeyboardState const & sta { HWND list = GetDlgItem(window, IDC_KEY_COMMANDS); ListBox_ResetContent(list); - for (int command : state.Visible) { - int index = ListBox_AddString(list, state.Commands[command].Name.c_str()); + for (UIHotkeyRow const & row : state.Visible) { + int index = ListBox_AddString(list, row.Name.c_str()); if (index != LB_ERR) { - ListBox_SetItemData(list, index, command); - if (command == state.Selected) { + ListBox_SetItemData(list, index, row.Command); + if (row.Command == state.Selected) { ListBox_SetCurSel(list, index); } } @@ -758,13 +759,8 @@ INT_PTR CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LP } -/// -/// Displays the keyboard configuration dialog. -/// This routine brings up the hotkey assignment dialog and does not return until the player -/// dismisses it. The title screen is kept refreshed while the dialog is up outside of a -/// game. -/// -bool OptionsClass::Hotkey_Dialog(void) +// The title screen is kept refreshed while the dialog is up outside of a game. +static void Hotkey_Win32_Dialog(void) { HWND handle; int res = -1; @@ -792,6 +788,19 @@ bool OptionsClass::Hotkey_Dialog(void) } _KeyboardPresenter = NULL; +} + + +/// +/// Displays the keyboard configuration dialog. +/// This routine brings up the hotkey assignment screen and does not return until the player +/// dismisses it. +/// +bool OptionsClass::Hotkey_Dialog(void) +{ + if (!UI_Use_Rml() || !UI_Keyboard_Dialog()) { + Hotkey_Win32_Dialog(); + } return(true); } diff --git a/code/ui/uikeyboard.cpp b/code/ui/uikeyboard.cpp index cfa58efac..c5782e156 100644 --- a/code/ui/uikeyboard.cpp +++ b/code/ui/uikeyboard.cpp @@ -9,8 +9,13 @@ #include "ui/uikeyboard.h" +#include "ui/uikeys.h" #include "ui/uirmlview.h" +#include +#include +#include + #include #include #include @@ -145,11 +150,11 @@ void UIKeyboardPresenterClass::Show_Category(int index) std::string const & category = State.Categories[State.Category]; for (int command = 0; command < (int)State.Commands.size(); command++) { if (Compare_Ignoring_Case(State.Commands[command].Category, category) == 0) { - State.Visible.push_back(command); + State.Visible.push_back({ command, State.Commands[command].Name }); } } - std::stable_sort(State.Visible.begin(), State.Visible.end(), [this](int a, int b) { - return(Compare_Ignoring_Case(State.Commands[a].Name, State.Commands[b].Name) < 0); + std::stable_sort(State.Visible.begin(), State.Visible.end(), [](UIHotkeyRow const & a, UIHotkeyRow const & b) { + return(Compare_Ignoring_Case(a.Name, b.Name) < 0); }); } @@ -189,3 +194,101 @@ std::string UIKeyboardPresenterClass::Name_Of_Key(int key) } return(Service.Key_Name(key)); } + + +namespace +{ + +class UIKeyboardViewClass : public UIRmlViewClass +{ + public: + explicit UIKeyboardViewClass(UIKeyboardPresenterClass & presenter) : + UIRmlViewClass(presenter, "keyboard.rml", "keyboard"), + Data(presenter) + { + } + + // Selecting a command moves the focus to the capture element, as the Win32 dialog moved it + // to its hotkey control. + virtual void Sync(void) override + { + Model.DirtyAllVariables(); + + if (Data.State.Selected != LastSelected) { + LastSelected = Data.State.Selected; + Rml::Element * capture = Capture(); + if (LastSelected >= 0 && capture != nullptr) { + capture->Focus(); + } + } + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + Rml::StructHandle row = model.RegisterStruct(); + if (!row) { + return(false); + } + row.RegisterMember("command", &UIHotkeyRow::Command); + row.RegisterMember("name", &UIHotkeyRow::Name); + + UIKeyboardState & state = Data.State; + return(model.RegisterArray>() + && model.RegisterArray>() + && model.Bind("categories", &state.Categories) + && model.Bind("categoryindex", &state.Category) + && model.Bind("rows", &state.Visible) + && model.Bind("selected", &state.Selected) + && model.Bind("description", &state.Description) + && model.Bind("shortcut", &state.Shortcut) + && model.Bind("capturedname", &state.CapturedName) + && model.Bind("assignedto", &state.AssignedTo)); + } + + virtual void Loaded(void) override + { + Rml::Element * capture = Capture(); + if (capture != nullptr) { + capture->AddEventListener(Rml::EventId::Keydown, this); + } + } + + // A key pressed in the capture element becomes the captured number. Enter, Escape and + // Tab keep their dialog meaning and a modifier on its own captures nothing. + virtual void ProcessEvent(Rml::Event & event) override + { + if (event.GetId() == Rml::EventId::Keydown && event.GetCurrentElement() != nullptr && event.GetCurrentElement() == Capture()) { + Rml::Input::KeyIdentifier key = (Rml::Input::KeyIdentifier)event.GetParameter("key_identifier", 0); + if (key == Rml::Input::KI_RETURN || key == Rml::Input::KI_NUMPADENTER || key == Rml::Input::KI_ESCAPE || key == Rml::Input::KI_TAB) { + return; + } + + int number = UI_Key_Number(key, event.GetParameter("shift_key", false), event.GetParameter("ctrl_key", false), event.GetParameter("alt_key", false)); + if (number != 0) { + Queue("capture", number); + } + event.StopPropagation(); + return; + } + + UIRmlViewClass::ProcessEvent(event); + } + + private: + Rml::Element * Capture(void) + { + return((Document() != nullptr) ? Document()->GetElementById("capture") : nullptr); + } + + UIKeyboardPresenterClass & Data; + int LastSelected = -1; +}; + +} + + +std::unique_ptr UI_Keyboard_View(UIKeyboardPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uikeyboard.h b/code/ui/uikeyboard.h index 60e5cd72a..b7da1be43 100644 --- a/code/ui/uikeyboard.h +++ b/code/ui/uikeyboard.h @@ -36,6 +36,14 @@ struct UIHotkeyBinding }; +// One row of the command list: the command and its name. +struct UIHotkeyRow +{ + int Command = -1; + std::string Name; +}; + + // The engine calls the keyboard dialog makes. The game supplies one over the hotkey table, // the keyboard file and the message box; the test harness supplies one that records the calls. class UIKeyboardServiceClass @@ -61,7 +69,7 @@ struct UIKeyboardState std::vector Bindings; std::vector Categories; int Category = -1; - std::vector Visible; + std::vector Visible; int Selected = -1; std::string Description; std::string Shortcut; @@ -107,3 +115,7 @@ std::unique_ptr UI_Keyboard_View(UIKeyboardPresenterClass & pres // RmlUi view. UIKeyboardServiceClass & UI_Keyboard_Service(void); void UI_Keyboard_State(UIKeyboardState & state); + +// Runs the keyboard dialog as an RmlUi screen. False means it could not run as one and the +// caller should open its Win32 dialog. +bool UI_Keyboard_Dialog(void); diff --git a/code/ui/uikeyboarddlg.cpp b/code/ui/uikeyboarddlg.cpp index e51e23440..5882822df 100644 --- a/code/ui/uikeyboarddlg.cpp +++ b/code/ui/uikeyboarddlg.cpp @@ -25,6 +25,8 @@ #include "language/language.h" #include "msgbox.h" #include "ownrdraw.h" +#include "ui/uirmlview.h" +#include "ui/uishell.h" #include "vector.h" @@ -124,3 +126,19 @@ void UI_Keyboard_State(UIKeyboardState & state) Fetch_Bindings(state.Bindings); } + + +bool UI_Keyboard_Dialog(void) +{ + if (UI_Legacy_Dialog_Visible()) { + return(false); + } + + UIKeyboardState state; + UI_Keyboard_State(state); + + UIKeyboardPresenterClass presenter(UI_Keyboard_Service(), state); + std::unique_ptr view = UI_Keyboard_View(presenter); + + return(UI_Run_Modal(*view) != UI_RESULT_FAILED_TO_OPEN); +} diff --git a/code/ui/uikeys.cpp b/code/ui/uikeys.cpp new file mode 100644 index 000000000..7a9b38b49 --- /dev/null +++ b/code/ui/uikeys.cpp @@ -0,0 +1,166 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uikeys.h" + +#include + + +namespace +{ + +struct UIKeyMapping +{ + int VirtualKey; + Rml::Input::KeyIdentifier Key; +}; + +const UIKeyMapping _KeyMappings[] = { + { VK_BACK, Rml::Input::KI_BACK }, + { VK_TAB, Rml::Input::KI_TAB }, + { VK_CLEAR, Rml::Input::KI_CLEAR }, + { VK_RETURN, Rml::Input::KI_RETURN }, + { VK_PAUSE, Rml::Input::KI_PAUSE }, + { VK_CAPITAL, Rml::Input::KI_CAPITAL }, + { VK_ESCAPE, Rml::Input::KI_ESCAPE }, + { VK_SPACE, Rml::Input::KI_SPACE }, + { VK_PRIOR, Rml::Input::KI_PRIOR }, + { VK_NEXT, Rml::Input::KI_NEXT }, + { VK_END, Rml::Input::KI_END }, + { VK_HOME, Rml::Input::KI_HOME }, + { VK_LEFT, Rml::Input::KI_LEFT }, + { VK_UP, Rml::Input::KI_UP }, + { VK_RIGHT, Rml::Input::KI_RIGHT }, + { VK_DOWN, Rml::Input::KI_DOWN }, + { VK_SNAPSHOT, Rml::Input::KI_SNAPSHOT }, + { VK_INSERT, Rml::Input::KI_INSERT }, + { VK_DELETE, Rml::Input::KI_DELETE }, + { VK_LWIN, Rml::Input::KI_LWIN }, + { VK_RWIN, Rml::Input::KI_RWIN }, + { VK_APPS, Rml::Input::KI_APPS }, + { VK_MULTIPLY, Rml::Input::KI_MULTIPLY }, + { VK_ADD, Rml::Input::KI_ADD }, + { VK_SEPARATOR, Rml::Input::KI_SEPARATOR }, + { VK_SUBTRACT, Rml::Input::KI_SUBTRACT }, + { VK_DECIMAL, Rml::Input::KI_DECIMAL }, + { VK_DIVIDE, Rml::Input::KI_DIVIDE }, + { VK_NUMLOCK, Rml::Input::KI_NUMLOCK }, + { VK_SCROLL, Rml::Input::KI_SCROLL }, + { VK_SHIFT, Rml::Input::KI_LSHIFT }, + { VK_CONTROL, Rml::Input::KI_LCONTROL }, + { VK_MENU, Rml::Input::KI_LMENU }, + { VK_LSHIFT, Rml::Input::KI_LSHIFT }, + { VK_RSHIFT, Rml::Input::KI_RSHIFT }, + { VK_LCONTROL, Rml::Input::KI_LCONTROL }, + { VK_RCONTROL, Rml::Input::KI_RCONTROL }, + { VK_LMENU, Rml::Input::KI_LMENU }, + { VK_RMENU, Rml::Input::KI_RMENU }, + { VK_OEM_1, Rml::Input::KI_OEM_1 }, + { VK_OEM_PLUS, Rml::Input::KI_OEM_PLUS }, + { VK_OEM_COMMA, Rml::Input::KI_OEM_COMMA }, + { VK_OEM_MINUS, Rml::Input::KI_OEM_MINUS }, + { VK_OEM_PERIOD, Rml::Input::KI_OEM_PERIOD }, + { VK_OEM_2, Rml::Input::KI_OEM_2 }, + { VK_OEM_3, Rml::Input::KI_OEM_3 }, + { VK_OEM_4, Rml::Input::KI_OEM_4 }, + { VK_OEM_5, Rml::Input::KI_OEM_5 }, + { VK_OEM_6, Rml::Input::KI_OEM_6 }, + { VK_OEM_7, Rml::Input::KI_OEM_7 }, + { VK_OEM_8, Rml::Input::KI_OEM_8 }, + { VK_OEM_102, Rml::Input::KI_OEM_102 }, +}; + +Rml::Input::KeyIdentifier _Identifiers[256]; +int _VirtualKeys[256]; +bool _Built = false; + + +// Letters, digits, the keypad digits and the function keys are contiguous in both codings. +// Where two virtual keys share an identifier, the one Windows reports for an unqualified +// press comes first and is the one the identifier maps back to. +void Build(void) +{ + for (int code = 0; code < 256; code++) { + _Identifiers[code] = Rml::Input::KI_UNKNOWN; + _VirtualKeys[code] = 0; + } + + for (UIKeyMapping const & mapping : _KeyMappings) { + _Identifiers[mapping.VirtualKey] = mapping.Key; + } + + for (int letter = 0; letter < 26; letter++) { + _Identifiers['A' + letter] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_A + letter); + } + for (int digit = 0; digit < 10; digit++) { + _Identifiers['0' + digit] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_0 + digit); + _Identifiers[VK_NUMPAD0 + digit] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_NUMPAD0 + digit); + } + for (int function = 0; function < 12; function++) { + _Identifiers[VK_F1 + function] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_F1 + function); + } + + for (int code = 0; code < 256; code++) { + int key = (int)_Identifiers[code]; + if (key != Rml::Input::KI_UNKNOWN && key < 256 && _VirtualKeys[key] == 0) { + _VirtualKeys[key] = code; + } + } + + _Built = true; +} + +} + + +Rml::Input::KeyIdentifier UI_Key_Identifier(int virtualkey) +{ + if (!_Built) { + Build(); + } + if (virtualkey < 0 || virtualkey > 255) { + return(Rml::Input::KI_UNKNOWN); + } + return(_Identifiers[virtualkey]); +} + + +int UI_Virtual_Key(Rml::Input::KeyIdentifier key) +{ + if (!_Built) { + Build(); + } + if ((int)key <= 0 || (int)key >= 256) { + return(0); + } + return(_VirtualKeys[(int)key]); +} + + +int UI_Key_Number(Rml::Input::KeyIdentifier key, bool shift, bool ctrl, bool alt) +{ + switch (key) { + case Rml::Input::KI_LSHIFT: + case Rml::Input::KI_RSHIFT: + case Rml::Input::KI_LCONTROL: + case Rml::Input::KI_RCONTROL: + case Rml::Input::KI_LMENU: + case Rml::Input::KI_RMENU: + return(0); + + default: + break; + } + + int virtualkey = UI_Virtual_Key(key); + if (virtualkey == 0) { + return(0); + } + return(virtualkey | (shift ? UI_KEY_SHIFT : 0) | (ctrl ? UI_KEY_CTRL : 0) | (alt ? UI_KEY_ALT : 0)); +} diff --git a/code/ui/uikeys.h b/code/ui/uikeys.h new file mode 100644 index 000000000..bb8bae0c1 --- /dev/null +++ b/code/ui/uikeys.h @@ -0,0 +1,30 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include + + +// The modifier bits of a KEYBOARD.INI key number, above the Windows virtual key. +enum { + UI_KEY_SHIFT = 0x100, + UI_KEY_CTRL = 0x200, + UI_KEY_ALT = 0x400 +}; + + +// Windows virtual keys and RmlUi key identifiers, either way. An unknown key answers +// KI_UNKNOWN or 0. +Rml::Input::KeyIdentifier UI_Key_Identifier(int virtualkey); +int UI_Virtual_Key(Rml::Input::KeyIdentifier key); + +// The KEYBOARD.INI number of a key pressed in a document, or 0 for a press the game cannot +// bind: a modifier on its own, or a key with no virtual key. +int UI_Key_Number(Rml::Input::KeyIdentifier key, bool shift, bool ctrl, bool alt); diff --git a/code/ui/uirmlview.cpp b/code/ui/uirmlview.cpp index 592b58d80..2d505e67b 100644 --- a/code/ui/uirmlview.cpp +++ b/code/ui/uirmlview.cpp @@ -69,6 +69,7 @@ bool UIRmlViewClass::Prepare(Rml::Context & context) } Doc->AddEventListener(Rml::EventId::Keydown, this); + Loaded(); return(true); } diff --git a/code/ui/uirmlview.h b/code/ui/uirmlview.h index 43102397d..5a96f82eb 100644 --- a/code/ui/uirmlview.h +++ b/code/ui/uirmlview.h @@ -59,6 +59,8 @@ class UIRmlViewClass : public Rml::EventListener protected: // Binds the view-model fields; the base binds the queue event. virtual bool Bind(Rml::DataModelConstructor & model) = 0; + // Runs once the document has loaded, for the listeners a view puts on its elements. + virtual void Loaded(void) {} virtual void ProcessEvent(Rml::Event & event) override; void Queue(char const * name, int value = 0); diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 2d5a2dbcd..c2fa80da4 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -22,6 +22,7 @@ #include "ui/uicoord.h" #include "ui/uidev.h" #include "ui/uifile.h" +#include "ui/uikeys.h" #include "ui/uirender.h" #include "ui/uirmlview.h" #include "ui/uisystem.h" @@ -73,71 +74,6 @@ static bool _ModalClosing = false; static wchar_t _HighSurrogate = 0; -static Rml::Input::KeyIdentifier _KeyMap[256]; - - -struct UIKeyMapping -{ - int VirtualKey; - Rml::Input::KeyIdentifier Key; -}; - -static const UIKeyMapping _KeyMappings[] = { - { VK_BACK, Rml::Input::KI_BACK }, - { VK_TAB, Rml::Input::KI_TAB }, - { VK_CLEAR, Rml::Input::KI_CLEAR }, - { VK_RETURN, Rml::Input::KI_RETURN }, - { VK_PAUSE, Rml::Input::KI_PAUSE }, - { VK_CAPITAL, Rml::Input::KI_CAPITAL }, - { VK_ESCAPE, Rml::Input::KI_ESCAPE }, - { VK_SPACE, Rml::Input::KI_SPACE }, - { VK_PRIOR, Rml::Input::KI_PRIOR }, - { VK_NEXT, Rml::Input::KI_NEXT }, - { VK_END, Rml::Input::KI_END }, - { VK_HOME, Rml::Input::KI_HOME }, - { VK_LEFT, Rml::Input::KI_LEFT }, - { VK_UP, Rml::Input::KI_UP }, - { VK_RIGHT, Rml::Input::KI_RIGHT }, - { VK_DOWN, Rml::Input::KI_DOWN }, - { VK_SNAPSHOT, Rml::Input::KI_SNAPSHOT }, - { VK_INSERT, Rml::Input::KI_INSERT }, - { VK_DELETE, Rml::Input::KI_DELETE }, - { VK_LWIN, Rml::Input::KI_LWIN }, - { VK_RWIN, Rml::Input::KI_RWIN }, - { VK_APPS, Rml::Input::KI_APPS }, - { VK_MULTIPLY, Rml::Input::KI_MULTIPLY }, - { VK_ADD, Rml::Input::KI_ADD }, - { VK_SEPARATOR, Rml::Input::KI_SEPARATOR }, - { VK_SUBTRACT, Rml::Input::KI_SUBTRACT }, - { VK_DECIMAL, Rml::Input::KI_DECIMAL }, - { VK_DIVIDE, Rml::Input::KI_DIVIDE }, - { VK_NUMLOCK, Rml::Input::KI_NUMLOCK }, - { VK_SCROLL, Rml::Input::KI_SCROLL }, - { VK_SHIFT, Rml::Input::KI_LSHIFT }, - { VK_CONTROL, Rml::Input::KI_LCONTROL }, - { VK_MENU, Rml::Input::KI_LMENU }, - { VK_LSHIFT, Rml::Input::KI_LSHIFT }, - { VK_RSHIFT, Rml::Input::KI_RSHIFT }, - { VK_LCONTROL, Rml::Input::KI_LCONTROL }, - { VK_RCONTROL, Rml::Input::KI_RCONTROL }, - { VK_LMENU, Rml::Input::KI_LMENU }, - { VK_RMENU, Rml::Input::KI_RMENU }, - { VK_OEM_1, Rml::Input::KI_OEM_1 }, - { VK_OEM_PLUS, Rml::Input::KI_OEM_PLUS }, - { VK_OEM_COMMA, Rml::Input::KI_OEM_COMMA }, - { VK_OEM_MINUS, Rml::Input::KI_OEM_MINUS }, - { VK_OEM_PERIOD, Rml::Input::KI_OEM_PERIOD }, - { VK_OEM_2, Rml::Input::KI_OEM_2 }, - { VK_OEM_3, Rml::Input::KI_OEM_3 }, - { VK_OEM_4, Rml::Input::KI_OEM_4 }, - { VK_OEM_5, Rml::Input::KI_OEM_5 }, - { VK_OEM_6, Rml::Input::KI_OEM_6 }, - { VK_OEM_7, Rml::Input::KI_OEM_7 }, - { VK_OEM_8, Rml::Input::KI_OEM_8 }, - { VK_OEM_102, Rml::Input::KI_OEM_102 }, -}; - - #ifdef _DEBUG // The test document is a developer's check of the shell; F9 shows and hides it, and F6 the @@ -161,30 +97,6 @@ static UITestListenerClass _TestListener; #endif -// Letters, digits, the keypad digits and the function keys are contiguous in both codings. -static void Build_Key_Map(void) -{ - for (int code = 0; code < 256; code++) { - _KeyMap[code] = Rml::Input::KI_UNKNOWN; - } - - for (UIKeyMapping const & mapping : _KeyMappings) { - _KeyMap[mapping.VirtualKey] = mapping.Key; - } - - for (int letter = 0; letter < 26; letter++) { - _KeyMap['A' + letter] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_A + letter); - } - for (int digit = 0; digit < 10; digit++) { - _KeyMap['0' + digit] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_0 + digit); - _KeyMap[VK_NUMPAD0 + digit] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_NUMPAD0 + digit); - } - for (int function = 0; function < 12; function++) { - _KeyMap[VK_F1 + function] = (Rml::Input::KeyIdentifier)(Rml::Input::KI_F1 + function); - } -} - - static int Key_Modifiers(void) { int modifiers = 0; @@ -328,8 +240,6 @@ bool UI_Init(void) return(true); } - Build_Key_Map(); - if (!_Render.Init()) { return(false); } @@ -677,7 +587,7 @@ static bool Handle_Key(UINT message, WPARAM wparam) return(true); } - Rml::Input::KeyIdentifier key = _KeyMap[wparam & 0xFF]; + Rml::Input::KeyIdentifier key = UI_Key_Identifier((int)(wparam & 0xFF)); if (key == Rml::Input::KI_UNKNOWN) { return(false); } diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 814b2dd7f..5fa705f97 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -774,13 +774,14 @@ beyond an ASCII test document. mode to try, `UIConfirmModePresenterClass` reads a `UIClockClass` and cancels itself at the timeout, which replaced the posted `WM_DESTROY`, and `display.rml` and `confirm.rml` are their documents, the latter counting - the seconds down; the keyboard dialog landed its first change: - `UIKeyboardPresenterClass` edits a copy of the hotkey table that OK saves - and Cancel drops, where the Win32 procedure edited the game's table and - reloaded the file on Cancel). Main options, the keyboard document with its - hotkey capture control, abort and surrender remain. The in-game options - menu opens load, save and delete, so it follows step 9. Evidence: settings - round-trip through `SUN.INI` unchanged. + the seconds down; the keyboard dialog landed too: `UIKeyboardPresenterClass` + edits a copy of the hotkey table that OK saves and Cancel drops, where the + Win32 procedure edited the game's table and reloaded the file on Cancel, + and `keyboard.rml` captures a key through a focusable element that + `uikeys.cpp` turns back into the `KEYBOARD.INI` number). Main options, + abort and surrender remain. The in-game options menu opens load, save and + delete, so it follows step 9. Evidence: settings round-trip through + `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, multiplayer game selection. The `NewMenuClass` drivers keep their loops. 9. **Load, save, delete** (M, two changes). diff --git a/manual/changes/rmlui-keyboard.md b/manual/changes/rmlui-keyboard.md new file mode 100644 index 000000000..34f821c75 --- /dev/null +++ b/manual/changes/rmlui-keyboard.md @@ -0,0 +1,16 @@ +--- +title: Show the keyboard dialog as an RmlUi document +category: feature +release: 0.2.0 +targets: +- type: system + id: ui-files + effect: changed +- type: format + id: keyboard-ini + effect: changed +credit: +- ZivDero +--- + +The keyboard dialog opens as an RmlUi document from the options menu and from the in-game game controls, with the categories and commands as lists, the description and current shortcut of the selected command, a capture box that takes the next key pressed with its modifiers, Assign, Reset All, OK and Cancel. A captured key is stored as the same `KEYBOARD.INI` number the Win32 dialog stored, without the extended-key flag that dialog's hotkey control added to some keys. `LegacyDialogs=yes` keeps the Win32 dialog. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index 118ae9ace..af62bd2a0 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options, the game controls, the display options with their mode confirmation, and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options, the game controls, the display options with their mode confirmation, the keyboard dialog, and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index 916acbd7d..7e444581a 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -23,4 +23,4 @@ A document names an engine string as `[[TXT_NAME]]`, using the identifier names ## Choosing the Win32 dialogs -[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options, the game controls, the display options with the confirmation that follows a mode change, and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options, the game controls, the display options with the confirmation that follows a mode change, the keyboard dialog, and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index d8b55ac97..2d188a0b7 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(UIShell "${CMAKE_SOURCE_DIR}/code/ui/uidisplay.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uigamectrl.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uikeyboard.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uikeys.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index f5d007e7e..b8d9b29b2 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -34,6 +34,7 @@ #include "ui/uidisplay.h" #include "ui/uigamectrl.h" #include "ui/uikeyboard.h" +#include "ui/uikeys.h" #include "ui/uimsgbox.h" #include "ui/uirmlview.h" #include "ui/uiscreen.h" @@ -611,6 +612,41 @@ UIKeyboardState Keyboard_Fixture(void) } +std::vector Visible_Commands(UIKeyboardState const & state) +{ + std::vector commands; + for (UIHotkeyRow const & row : state.Visible) { + commands.push_back(row.Command); + } + return(commands); +} + + +// The key map turns Windows virtual keys into RmlUi identifiers and back, and a press in a +// document into the KEYBOARD.INI number the game binds. +void Test_Keys(void) +{ + Check(UI_Key_Identifier(0x41) == Rml::Input::KI_A && UI_Key_Identifier(0x39) == Rml::Input::KI_9 && UI_Key_Identifier(0x70) == Rml::Input::KI_F1, "letters, digits and function keys map to their identifiers"); + Check(UI_Key_Identifier(0x1B) == Rml::Input::KI_ESCAPE && UI_Key_Identifier(0x07) == Rml::Input::KI_UNKNOWN, "named keys map and an unassigned code stays unknown"); + + bool roundtrip = true; + for (int code = 0; code < 256; code++) { + Rml::Input::KeyIdentifier key = UI_Key_Identifier(code); + if (key != Rml::Input::KI_UNKNOWN && UI_Key_Identifier(UI_Virtual_Key(key)) != key) { + roundtrip = false; + } + } + Check(roundtrip, "every identifier maps back to a virtual key that maps to it"); + Check(UI_Virtual_Key(Rml::Input::KI_UNKNOWN) == 0, "the unknown identifier has no virtual key"); + + Check(UI_Key_Number(Rml::Input::KI_A, false, true, false) == 577, "Control and A make the KEYBOARD.INI number 577"); + Check(UI_Key_Number(Rml::Input::KI_R, true, false, false) == 338 && UI_Key_Number(Rml::Input::KI_X, false, false, false) == 88, "Shift adds 256 and a bare key is its virtual key"); + Check(UI_Key_Number(Rml::Input::KI_F5, false, false, true) == (0x74 | 0x400), "Alt adds 1024"); + Check(UI_Key_Number(Rml::Input::KI_LSHIFT, true, false, false) == 0 && UI_Key_Number(Rml::Input::KI_RCONTROL, false, true, false) == 0 && UI_Key_Number(Rml::Input::KI_LMENU, false, false, true) == 0, "a modifier on its own is no key"); + Check(UI_Key_Number(Rml::Input::KI_UNKNOWN, false, false, false) == 0, "an unknown key is no key"); +} + + // The keyboard presenter edits a copy of the hotkey table the way the Win32 dialog edited the // game's: a key moves to the selected command, an empty capture unbinds it, OK saves and Cancel // drops the edits. @@ -620,10 +656,11 @@ void Test_Keyboard_Presenter(void) UIKeyboardPresenterClass presenter(service, Keyboard_Fixture()); Check(presenter.State.Categories == std::vector{ "Interface", "Selection" }, "the categories are listed once each, sorted without regard to case"); - Check(presenter.State.Category == 0 && presenter.State.Visible == std::vector{ 3, 1 } && presenter.State.Selected == -1, "the first category opens with its commands sorted by name and none selected"); + Check(presenter.State.Category == 0 && Visible_Commands(presenter.State) == std::vector{ 3, 1 } && presenter.State.Selected == -1, "the first category opens with its commands sorted by name and none selected"); + Check(presenter.State.Visible.size() == 2 && presenter.State.Visible[0].Name == "Alliance", "a row carries its command's name"); Drive(presenter, "category", 1); - Check(presenter.State.Visible == std::vector{ 2, 0 } && presenter.State.Description.empty(), "another category lists its own commands with the description cleared"); + Check(Visible_Commands(presenter.State) == std::vector{ 2, 0 } && presenter.State.Description.empty(), "another category lists its own commands with the description cleared"); Drive(presenter, "select", 0); Check(presenter.State.Description == "Selects the view" && presenter.State.Shortcut == "K577", "selecting a command shows its description and shortcut"); @@ -1650,6 +1687,127 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & } +// Drives the keyboard screen: the category and command rows select, a key pressed in the +// focused capture element becomes the captured number, Assign moves it, and the dialog keys +// keep their meaning inside the capture. +void Test_Keyboard_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + RecordingKeyboardServiceClass service; + UIKeyboardPresenterClass presenter(service, Keyboard_Fixture()); + std::unique_ptr view = UI_Keyboard_View(presenter); + + Check(view->Prepare(context), "the keyboard view prepares against the test context"); + view->Show(true); + view->Sync(); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the keyboard screen raises no RmlUi warning or error"); + + Rml::ElementDocument * document = view->Document(); + std::vector rows = Visible_Of_Class(document, "row"); + Check(rows.size() == 4, "the keyboard screen lists the categories and the open category's commands"); + Check(rows.size() == 4 && rows[0]->GetInnerRML() == "Interface" && rows[0]->IsClassSet("selected") && rows[2]->GetInnerRML() == "Alliance" && rows[3]->GetInnerRML() == "Toggle Repair", "the first category is open with its commands sorted by name"); + + if (rows.size() == 4) { + Click(context, rows[1]); + presenter.Drain(); + view->Sync(); + context.Update(); + rows = Visible_Of_Class(document, "row"); + Check(rows.size() == 4 && rows[1]->IsClassSet("selected") && rows[2]->GetInnerRML() == "Scatter" && rows[3]->GetInnerRML() == "Select View", "a click on a category lists its commands"); + } + + Rml::Element * capture = document->GetElementById("capture"); + Rml::Element * description = document->GetElementById("description"); + Rml::Element * shortcut = document->GetElementById("shortcut"); + Check(capture != nullptr && description != nullptr && shortcut != nullptr, "the keyboard screen has its capture element, description and shortcut"); + + if (rows.size() == 4) { + Click(context, rows[3]); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.Selected == 0 && description->GetInnerRML() == "Selects the view" && shortcut->GetInnerRML() == "K577", "a click on a command shows its description and shortcut"); + Check(context.GetFocusElement() == capture, "selecting a command focuses the capture element"); + } + + if (capture != nullptr) { + capture->Focus(); + context.ProcessKeyDown(Rml::Input::KI_R, Rml::Input::KM_SHIFT); + context.ProcessKeyUp(Rml::Input::KI_R, Rml::Input::KM_SHIFT); + context.Update(); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.State.Captured == 338 && presenter.State.AssignedTo == "Toggle Repair", "Shift and R in the capture element become the number 338 and name its holder"); + Check(capture->GetInnerRML().find("K338") != std::string::npos, "the capture element shows the captured key"); + + context.ProcessKeyDown(Rml::Input::KI_LSHIFT, Rml::Input::KM_SHIFT); + context.ProcessKeyUp(Rml::Input::KI_LSHIFT, 0); + context.Update(); + presenter.Drain(); + Check(presenter.State.Captured == 338, "a modifier on its own leaves the capture as it was"); + } + + Rml::Element * assign = document->GetElementById("assign"); + if (assign != nullptr) { + Click(context, assign); + presenter.Drain(); + view->Sync(); + context.Update(); + Check(presenter.Key_Of(0) == 338 && presenter.Key_Of(1) == 0 && shortcut->GetInnerRML() == "K338" && presenter.State.Captured == 0, "Assign moves the key to the selected command and shows the new shortcut"); + } + + if (capture != nullptr) { + capture->Focus(); + context.ProcessKeyDown(Rml::Input::KI_RETURN, 0); + context.ProcessKeyUp(Rml::Input::KI_RETURN, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls == std::vector{ "save 0=338 2=88" }, "Enter in the capture element accepts the screen and saves"); + } + + view->Release(); + context.Update(); + } + + { + RecordingKeyboardServiceClass service; + service.ConfirmAnswer = false; + UIKeyboardPresenterClass presenter(service, Keyboard_Fixture()); + std::unique_ptr view = UI_Keyboard_View(presenter); + + Check(view->Prepare(context), "a second keyboard view prepares"); + view->Show(true); + view->Sync(); + context.Update(); + + Rml::Element * reset = view->Document()->GetElementById("reset"); + if (reset != nullptr) { + Click(context, reset); + presenter.Drain(); + Check(service.Calls == std::vector{ "confirm" } && presenter.Key_Of(0) == 577, "Reset All asks first and a refusal changes nothing"); + } + + Rml::Element * capture = view->Document()->GetElementById("capture"); + if (capture != nullptr) { + capture->Focus(); + } + context.ProcessKeyDown(Rml::Input::KI_ESCAPE, 0); + context.ProcessKeyUp(Rml::Input::KI_ESCAPE, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && service.Calls.size() == 1, "Escape in the capture element cancels the screen without saving"); + + view->Release(); + context.Update(); + } +} + + // Drives the wait box: the text follows the presenter, the frame appears only with a bar, and // the fill follows the percentage. void Test_Wait_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) @@ -1798,6 +1956,7 @@ void Test_Documents(void) Test_Sound_Screen(*context, system); Test_Game_Controls_Screen(*context, system); Test_Display_Screen(*context, system); + Test_Keyboard_Screen(*context, system); Test_Wait_Box_Screen(*context, system); } @@ -1821,6 +1980,7 @@ int main(void) Test_Coordinates(); Test_Display_Presenter(); Test_Game_Controls_Presenter(); + Test_Keys(); Test_Keyboard_Presenter(); Test_Sound_Presenter(); Test_Strings(); diff --git a/ui/keyboard.rcss b/ui/keyboard.rcss new file mode 100644 index 000000000..9c68040b2 --- /dev/null +++ b/ui/keyboard.rcss @@ -0,0 +1,227 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div, p +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 504dp; + height: 360dp; + margin-left: -252dp; + margin-top: -180dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +.heading +{ + position: absolute; + left: 33dp; + top: 18dp; + width: 438dp; + line-height: 15dp; + text-align: center; +} + +.column +{ + position: absolute; + top: 40dp; +} + +#left +{ + left: 33dp; + width: 207dp; +} + +#right +{ + left: 252dp; + width: 219dp; +} + +.label +{ + margin-top: 6dp; + line-height: 15dp; +} + +.value +{ + line-height: 15dp; + white-space: nowrap; + overflow: hidden; +} + +.list +{ + overflow-y: auto; + background-color: #0c1116; + border: 1dp #3d5a68; +} + +#categories +{ + height: 72dp; +} + +#commands +{ + height: 209dp; +} + +.row +{ + padding: 2dp 6dp; + line-height: 17dp; + white-space: nowrap; +} + +.row:hover +{ + background-color: #1f3140; +} + +.row.selected +{ + background-color: #225061; +} + +scrollbarvertical +{ + width: 12dp; +} + +scrollbarvertical slidertrack +{ + background-color: #1a242c; +} + +scrollbarvertical sliderbar +{ + background-color: #4d6f80; + min-height: 16dp; +} + +scrollbarvertical sliderarrowdec, scrollbarvertical sliderarrowinc +{ + width: 0; + height: 0; +} + +.box +{ + box-sizing: border-box; + height: 60dp; + padding: 4dp 6dp; + line-height: 15dp; + overflow: hidden; + background-color: #0c1116; + border: 1dp #3d5a68; +} + +#hotkey +{ + height: 23dp; + white-space: nowrap; +} + +#capture +{ + display: inline-block; + box-sizing: border-box; + width: 127dp; + height: 23dp; + padding: 0 6dp; + line-height: 21dp; + vertical-align: top; + white-space: nowrap; + overflow: hidden; + tab-index: auto; + background-color: #0c1116; + border: 1dp #6f95a8; +} + +#capture:focus +{ + border-color: #c6dde8; + background-color: #182430; +} + +#capture span +{ + focus: none; +} + +button +{ + display: inline-block; + height: 23dp; + text-align: center; + line-height: 23dp; + vertical-align: top; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} + +#assign +{ + margin-left: 5dp; + width: 75dp; +} + +#buttons +{ + position: absolute; + left: 33dp; + top: 320dp; + width: 438dp; + height: 23dp; +} + +#buttons button +{ + position: absolute; + top: 0; + width: 75dp; +} + +#reset +{ + left: 0; + width: 81dp; +} + +#ok +{ + left: 248dp; +} + +#cancel +{ + left: 363dp; +} diff --git a/ui/keyboard.rml b/ui/keyboard.rml new file mode 100644 index 000000000..4eafc3880 --- /dev/null +++ b/ui/keyboard.rml @@ -0,0 +1,36 @@ + + + Customize keyboard + + + +
+

Customize Keyboard

+
+

Category:

+
+
{{name}}
+
+

Description:

+
{{description}}
+

Press new shortcut key:

+
None{{capturedname}}
+

Currently assigned to:

+

{{assignedto}}

+
+ +
+ + + +
+
+ +
From ababf12cd1c36742fdc16df586c4db84ef61e705 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 9 Sep 2026 09:07:07 +0300 Subject: [PATCH 18/52] Show the options menu as an RmlUi document --- code/mainopt.cpp | 78 +++++++++++++------ code/ui/uimainopt.cpp | 107 +++++++++++++++++++++++++++ code/ui/uimainopt.h | 63 ++++++++++++++++ code/ui/uimainoptdlg.cpp | 56 ++++++++++++++ docs/UI_DESIGN.md | 32 ++++---- manual/changes/rmlui-main-options.md | 13 ++++ manual/content/keys/legacydialogs.md | 2 +- manual/content/systems/ui-files.md | 2 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 100 +++++++++++++++++++++++++ ui/mainopt.rcss | 82 ++++++++++++++++++++ ui/mainopt.rml | 15 ++++ 12 files changed, 509 insertions(+), 42 deletions(-) create mode 100644 code/ui/uimainopt.cpp create mode 100644 code/ui/uimainopt.h create mode 100644 code/ui/uimainoptdlg.cpp create mode 100644 manual/changes/rmlui-main-options.md create mode 100644 ui/mainopt.rcss create mode 100644 ui/mainopt.rml diff --git a/code/mainopt.cpp b/code/mainopt.cpp index ad5b6a699..c4f4b6fcf 100644 --- a/code/mainopt.cpp +++ b/code/mainopt.cpp @@ -35,6 +35,7 @@ #include "stimer.h" #include "surface.h" #include "ui/uidisplay.h" +#include "ui/uimainopt.h" #include "ui/uishell.h" #include "wwmouse.h" @@ -48,6 +49,7 @@ INT_PTR CALLBACK Display_Options_Dialog_Proc(HWND window, UINT message, WPARAM w bool Change_Display_Mode(int width, int height); bool Test_Display_Mode_Dialog(int width, int height); INT_PTR CALLBACK Test_Display_Mode_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); +static UIMainOptionsChoice Main_Options_Win32_Dialog(void); static void Display_Options_Dialog(void); static std::optional Display_Options_Win32_Dialog(void); static bool Confirm_Mode_Win32_Dialog(void); @@ -80,42 +82,26 @@ void Main_Options_Dialog(void) bool old_game_active = GameActive; GameActive = false; - HWND main_handle; - LONG main_rc; - while (true) { - do { - main_rc = -1; - main_handle = OwnerDraw::Begin_Dialog(IDD_OPT_MAIN, Main_Options_Dialog_Proc); - } while (main_handle == 0); - SetWindowLongPtr(main_handle, DWLP_USER, (LONG_PTR)&main_rc); - - OwnerDraw::Move_Dialog(main_handle, -1, (HiddenSurface->Get_Height() - 400) / 2 + 147); - OwnerDraw::Display_Dialog(main_handle); - - while (main_rc < 0) { - if (OwnerDraw::Dialog_Message_Handler() == true) { - break; - } - Title_Screen_Restore(); + UIMainOptionsChoice choice = UI_MAIN_OPTIONS_LEAVE; + if (!UI_Use_Rml() || !UI_Main_Options_Dialog(choice)) { + choice = Main_Options_Win32_Dialog(); } - OwnerDraw::End_Dialog(main_handle); - - switch (main_rc) { - case IDC_OPTMAIN_SOUND: + switch (choice) { + case UI_MAIN_OPTIONS_SOUND: SoundControlsClass().Dialog(); break; - case IDC_OPTMAIN_DISPLAY: + case UI_MAIN_OPTIONS_DISPLAY: Display_Options_Dialog(); break; - case IDC_OPTMAIN_KEYBOARD: + case UI_MAIN_OPTIONS_KEYBOARD: Options.Hotkey_Dialog(); break; - case IDC_OPTMAIN_GAME_SETTINGS: + case UI_MAIN_OPTIONS_SETTINGS: GameControlsClass().Dialog(); break; @@ -128,6 +114,50 @@ void Main_Options_Dialog(void) } +// The button the player pressed; Escape, Enter and the end of the session all lead back to +// the main menu. +static UIMainOptionsChoice Main_Options_Win32_Dialog(void) +{ + HWND main_handle; + LONG main_rc; + + do { + main_rc = -1; + main_handle = OwnerDraw::Begin_Dialog(IDD_OPT_MAIN, Main_Options_Dialog_Proc); + } while (main_handle == 0); + SetWindowLongPtr(main_handle, DWLP_USER, (LONG_PTR)&main_rc); + + OwnerDraw::Move_Dialog(main_handle, -1, (HiddenSurface->Get_Height() - 400) / 2 + 147); + OwnerDraw::Display_Dialog(main_handle); + + while (main_rc < 0) { + if (OwnerDraw::Dialog_Message_Handler() == true) { + break; + } + Title_Screen_Restore(); + } + + OwnerDraw::End_Dialog(main_handle); + + switch (main_rc) { + case IDC_OPTMAIN_SOUND: + return(UI_MAIN_OPTIONS_SOUND); + + case IDC_OPTMAIN_DISPLAY: + return(UI_MAIN_OPTIONS_DISPLAY); + + case IDC_OPTMAIN_KEYBOARD: + return(UI_MAIN_OPTIONS_KEYBOARD); + + case IDC_OPTMAIN_GAME_SETTINGS: + return(UI_MAIN_OPTIONS_SETTINGS); + + default: + return(UI_MAIN_OPTIONS_LEAVE); + } +} + + /// /// Handles the main options dialog. /// This routine reports the button the player pressed back to the options dialog driver so diff --git a/code/ui/uimainopt.cpp b/code/ui/uimainopt.cpp new file mode 100644 index 000000000..c1e9c2ca4 --- /dev/null +++ b/code/ui/uimainopt.cpp @@ -0,0 +1,107 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uimainopt.h" + +#include "ui/uirmlview.h" + +#include +#include + +#include +#include + + +UIMainOptionsPresenterClass::UIMainOptionsPresenterClass(UIMainOptionsState state) : + State(std::move(state)) +{ +} + + +void UIMainOptionsPresenterClass::Execute(UIIntent const & intent) +{ + if (intent.Name == "settings") { + Choice = UI_MAIN_OPTIONS_SETTINGS; + Result = UI_RESULT_ACCEPTED; + + } else if (intent.Name == "display") { + Choice = UI_MAIN_OPTIONS_DISPLAY; + Result = UI_RESULT_ACCEPTED; + + } else if (intent.Name == "sound") { + if (State.SoundEnabled) { + Choice = UI_MAIN_OPTIONS_SOUND; + Result = UI_RESULT_ACCEPTED; + } + + } else if (intent.Name == "keyboard") { + Choice = UI_MAIN_OPTIONS_KEYBOARD; + Result = UI_RESULT_ACCEPTED; + + } else if (intent.Name == "ok") { + Choice = UI_MAIN_OPTIONS_LEAVE; + Result = UI_RESULT_ACCEPTED; + + } else if (intent.Name == "cancel") { + Choice = UI_MAIN_OPTIONS_LEAVE; + Result = UI_RESULT_CANCELLED; + } +} + + +void UIMainOptionsPresenterClass::Refresh(void) +{ +} + + +namespace +{ + +class UIMainOptionsViewClass : public UIRmlViewClass +{ + public: + explicit UIMainOptionsViewClass(UIMainOptionsPresenterClass & presenter) : + UIRmlViewClass(presenter, "mainopt.rml", "mainopt"), + Data(presenter) + { + } + + virtual void Sync(void) override + { + Model.DirtyAllVariables(); + } + + protected: + virtual bool Bind(Rml::DataModelConstructor & model) override + { + return(model.Bind("soundenabled", &Data.State.SoundEnabled)); + } + + // The menu sits where the main menu's buttons were, so the panel takes that edge over + // the centred position the style sheet gives it. + virtual void Loaded(void) override + { + Rml::Element * panel = Document()->GetElementById("panel"); + if (panel != nullptr && Data.State.Top >= 0) { + panel->SetProperty("top", std::to_string(Data.State.Top) + "dp"); + panel->SetProperty("margin-top", "0dp"); + } + } + + private: + UIMainOptionsPresenterClass & Data; +}; + +} + + +std::unique_ptr UI_Main_Options_View(UIMainOptionsPresenterClass & presenter) +{ + return(std::make_unique(presenter)); +} diff --git a/code/ui/uimainopt.h b/code/ui/uimainopt.h new file mode 100644 index 000000000..db40808ec --- /dev/null +++ b/code/ui/uimainopt.h @@ -0,0 +1,63 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "ui/uiscreen.h" + +#include + +class UIRmlViewClass; + + +// What the options menu answers with: the dialog to open next, or the way back to the menu. +enum UIMainOptionsChoice +{ + UI_MAIN_OPTIONS_LEAVE, + UI_MAIN_OPTIONS_SETTINGS, + UI_MAIN_OPTIONS_DISPLAY, + UI_MAIN_OPTIONS_SOUND, + UI_MAIN_OPTIONS_KEYBOARD, +}; + + +// What the menu shows: whether the Sound button is live, and the top edge the menu sits at +// in the frame, or -1 to sit in the middle. +struct UIMainOptionsState +{ + bool SoundEnabled = false; + int Top = -1; +}; + + +// A leaf of buttons: each press closes the menu with its choice. Enter and Escape leave the +// way the Main Menu button does, and a Sound button without an audio device does nothing. +class UIMainOptionsPresenterClass : public UIPresenterClass +{ + public: + explicit UIMainOptionsPresenterClass(UIMainOptionsState state); + + virtual void Execute(UIIntent const & intent) override; + virtual void Refresh(void) override; + + UIMainOptionsState State; + UIMainOptionsChoice Choice = UI_MAIN_OPTIONS_LEAVE; +}; + + +// The RmlUi view over an options menu presenter, bound to mainopt.rml. The presenter must +// outlive it. +std::unique_ptr UI_Main_Options_View(UIMainOptionsPresenterClass & presenter); + +// The state of the running game. +void UI_Main_Options_State(UIMainOptionsState & state); + +// Runs the options menu as an RmlUi screen. False means it could not run as one and the +// caller should open its Win32 dialog; otherwise choice carries the player's pick. +bool UI_Main_Options_Dialog(UIMainOptionsChoice & choice); diff --git a/code/ui/uimainoptdlg.cpp b/code/ui/uimainoptdlg.cpp new file mode 100644 index 000000000..327f200b2 --- /dev/null +++ b/code/ui/uimainoptdlg.cpp @@ -0,0 +1,56 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine side of the options menu: the state it starts from and the entry the menu +// driver calls ahead of its Win32 dialog. The presenter and view live in uimainopt.cpp so +// that the test harness can drive them without the engine. + +#include "ui/uimainopt.h" + +#include "_surface.h" +#include "audio/audioengine.h" +#include "surface.h" +#include "ui/uirmlview.h" +#include "ui/uishell.h" + + +// The menu sits where the main menu's buttons were: a 400 pixel layout centred in the +// frame, with the buttons 147 pixels down it. +void UI_Main_Options_State(UIMainOptionsState & state) +{ + state = UIMainOptionsState(); + state.SoundEnabled = AudioEngine.Is_Available(); + if (HiddenSurface != NULL) { + state.Top = (HiddenSurface->Get_Height() - 400) / 2 + 147; + } +} + + +bool UI_Main_Options_Dialog(UIMainOptionsChoice & choice) +{ + choice = UI_MAIN_OPTIONS_LEAVE; + + if (UI_Legacy_Dialog_Visible()) { + return(false); + } + + UIMainOptionsState state; + UI_Main_Options_State(state); + + UIMainOptionsPresenterClass presenter(state); + std::unique_ptr view = UI_Main_Options_View(presenter); + + UIResult result = UI_Run_Modal(*view); + if (result == UI_RESULT_FAILED_TO_OPEN) { + return(false); + } + + choice = (result == UI_RESULT_ACCEPTED) ? presenter.Choice : UI_MAIN_OPTIONS_LEAVE; + return(true); +} diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 5fa705f97..22c79d638 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -766,22 +766,22 @@ beyond an ASCII test document. `savemgr.cpp` with `OwnerDraw::Custom_Message_Box`, the modeless box they show. A progress bar needs no engine surface, so the `` element waits for the map preview in step 10. Runtime evidence still owed. -7. **Options family** (L, two changes each; the game controls landed: the - behaviour sits behind `UIGameControlsPresenterClass` and an engine service, - `gamectrl.rml` covers the three Win32 templates with `data-if`, and the - templates remain the fallback view; the display options and the mode - confirmation landed too: `UIDisplayPresenterClass` hands the caller the - mode to try, `UIConfirmModePresenterClass` reads a `UIClockClass` and - cancels itself at the timeout, which replaced the posted `WM_DESTROY`, and - `display.rml` and `confirm.rml` are their documents, the latter counting - the seconds down; the keyboard dialog landed too: `UIKeyboardPresenterClass` - edits a copy of the hotkey table that OK saves and Cancel drops, where the - Win32 procedure edited the game's table and reloaded the file on Cancel, - and `keyboard.rml` captures a key through a focusable element that - `uikeys.cpp` turns back into the `KEYBOARD.INI` number). Main options, - abort and surrender remain. The in-game options menu opens load, save and - delete, so it follows step 9. Evidence: settings round-trip through - `SUN.INI` unchanged. +7. **Options family** (L, landed for the frontend and the in-game settings: + the game controls sit behind `UIGameControlsPresenterClass` and an engine + service with `gamectrl.rml` covering the three Win32 templates through + `data-if`; `UIDisplayPresenterClass` hands the caller the mode to try and + `UIConfirmModePresenterClass` reads a `UIClockClass` and cancels itself at + the timeout, which replaced the posted `WM_DESTROY`, over `display.rml` + and `confirm.rml`, the latter counting the seconds down; + `UIKeyboardPresenterClass` edits a copy of the hotkey table that OK saves + and Cancel drops, where the Win32 procedure edited the game's table and + reloaded the file on Cancel, and `keyboard.rml` captures a key through a + focusable element that `uikeys.cpp` turns back into the `KEYBOARD.INI` + number; the options menu is `mainopt.rml`, placed where the main menu's + buttons were; abort and surrender already run through the message box + screen). The Win32 templates remain the fallback view of every one. The + in-game options menu opens load, save and delete, so it follows step 9. + Evidence: settings round-trip through `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, multiplayer game selection. The `NewMenuClass` drivers keep their loops. 9. **Load, save, delete** (M, two changes). diff --git a/manual/changes/rmlui-main-options.md b/manual/changes/rmlui-main-options.md new file mode 100644 index 000000000..9d3e68dbc --- /dev/null +++ b/manual/changes/rmlui-main-options.md @@ -0,0 +1,13 @@ +--- +title: Show the options menu as an RmlUi document +category: feature +release: 0.2.0 +targets: +- type: system + id: ui-files + effect: changed +credit: +- ZivDero +--- + +The options menu the main menu opens is an RmlUi document with the same five buttons in the same place, Game Settings, Display, Sound, Keyboard and Main Menu, and the Sound button still goes dead without an audio device. The settings are still written when the player leaves. `LegacyDialogs=yes` keeps the Win32 dialog. diff --git a/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md index af62bd2a0..af355709d 100644 --- a/manual/content/keys/legacydialogs.md +++ b/manual/content/keys/legacydialogs.md @@ -6,6 +6,6 @@ when_omitted: value: "no" --- -`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the sound options, the game controls, the display options with their mode confirmation, the keyboard dialog, and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. +`LegacyDialogs=yes` under `[Options]` returns every screen that has an RmlUi document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the message boxes, the options menu with the sound options, the game controls, the display options, their mode confirmation and the keyboard dialog, and the saving and loading notices are the first such screens; [UI files](/systems/ui-files/) describes where the documents live. The key is read with the other `[Options]` settings when the game starts and written back with them when the settings are saved, so a value written by hand survives the options dialogs. diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index 7e444581a..7f5880389 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -23,4 +23,4 @@ A document names an engine string as `[[TXT_NAME]]`, using the identifier names ## Choosing the Win32 dialogs -[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the sound options, the game controls, the display options with the confirmation that follows a mode change, the keyboard dialog, and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the options and network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. +[`LegacyDialogs`](/keys/legacydialogs/) under `[Options]` returns every screen that has a document to its Win32 dialog. The [version dialog](/systems/developer-mode/#the-version-dialog), the game's message boxes, the options menu with the sound options, the game controls, the display options, the confirmation that follows a mode change and the keyboard dialog, and the notices shown while a game saves or loads are the screens with both. A message box raised while a Win32 dialog is on screen, as the network dialogs raise theirs, stays a Win32 box, because a visible dialog takes the mouse before a document can. The document keeps the Win32 box's layout: up to three buttons in the same slots, Enter answering with the default button and Escape with the second. diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 2d188a0b7..3588a2a23 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable(UIShell "${CMAKE_SOURCE_DIR}/code/ui/uigamectrl.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uikeyboard.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uikeys.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/uimainopt.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index b8d9b29b2..dd57c84a1 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -35,6 +35,7 @@ #include "ui/uigamectrl.h" #include "ui/uikeyboard.h" #include "ui/uikeys.h" +#include "ui/uimainopt.h" #include "ui/uimsgbox.h" #include "ui/uirmlview.h" #include "ui/uiscreen.h" @@ -1808,6 +1809,104 @@ void Test_Keyboard_Screen(Rml::Context & context, CountingSystemInterfaceClass & } +// The buttons of a document from top to bottom. +std::vector Buttons_Top_Down(Rml::ElementDocument * document) +{ + std::vector buttons = Visible_Buttons(document); + std::sort(buttons.begin(), buttons.end(), [](Rml::Element * a, Rml::Element * b) { + return(a->GetAbsoluteOffset(Rml::BoxArea::Border).y < b->GetAbsoluteOffset(Rml::BoxArea::Border).y); + }); + return(buttons); +} + + +// Drives the options menu: each button closes it with its choice, a dead Sound button does +// nothing, Escape leaves, and the panel takes the top edge the game hands it. +void Test_Main_Options_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) +{ + int problems = system.Problems; + + { + UIMainOptionsState state; + state.SoundEnabled = true; + UIMainOptionsPresenterClass presenter(state); + std::unique_ptr view = UI_Main_Options_View(presenter); + + Check(view->Prepare(context), "the options menu view prepares against the test context"); + view->Show(true); + context.Update(); + context.Render(); + Check(system.Problems == problems, "the options menu raises no RmlUi warning or error"); + + std::vector buttons = Buttons_Top_Down(view->Document()); + Check(buttons.size() == 5, "the options menu has five buttons"); + bool ordered = buttons.size() == 5 && buttons[0]->GetId() == "settings" && buttons[1]->GetId() == "display" && buttons[2]->GetId() == "sound" && buttons[3]->GetId() == "keyboard" && buttons[4]->GetId() == "mainmenu"; + Check(ordered, "the buttons run Game Settings, Display, Sound, Keyboard, Main Menu from the top"); + + Rml::Element * panel = view->Document()->GetElementById("panel"); + float centre = (float)context.GetDimensions().y * 0.5f; + Check(panel != nullptr && panel->GetAbsoluteOffset(Rml::BoxArea::Border).y < centre && panel->GetAbsoluteOffset(Rml::BoxArea::Border).y + panel->GetBox().GetSize(Rml::BoxArea::Border).y > centre, "without a top edge the menu sits in the middle"); + + if (buttons.size() == 5) { + Click(context, buttons[1]); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && presenter.Choice == UI_MAIN_OPTIONS_DISPLAY, "the Display button closes the menu with the display choice"); + } + + view->Release(); + context.Update(); + } + + { + UIMainOptionsState state; + state.SoundEnabled = false; + state.Top = 200; + UIMainOptionsPresenterClass presenter(state); + std::unique_ptr view = UI_Main_Options_View(presenter); + + Check(view->Prepare(context), "a second options menu view prepares"); + view->Show(true); + context.Update(); + + Rml::Element * panel = view->Document()->GetElementById("panel"); + Check(panel != nullptr && std::fabs(panel->GetAbsoluteOffset(Rml::BoxArea::Border).y - 200.0f) < 1.0f, "the menu sits at the top edge the game hands it"); + + Rml::Element * sound = view->Document()->GetElementById("sound"); + Check(sound != nullptr && sound->IsClassSet("disabled"), "the Sound button shows disabled without an audio device"); + if (sound != nullptr) { + Click(context, sound); + presenter.Drain(); + Check(!presenter.Result.has_value(), "a disabled Sound button does nothing"); + } + + context.ProcessKeyDown(Rml::Input::KI_ESCAPE, 0); + context.ProcessKeyUp(Rml::Input::KI_ESCAPE, 0); + context.Update(); + presenter.Drain(); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && presenter.Choice == UI_MAIN_OPTIONS_LEAVE, "Escape leaves the options menu"); + + view->Release(); + context.Update(); + } + + { + UIMainOptionsState state; + state.SoundEnabled = true; + UIMainOptionsPresenterClass presenter(state); + Drive(presenter, "sound"); + Check(presenter.Result.has_value() && presenter.Choice == UI_MAIN_OPTIONS_SOUND, "a live Sound button picks the sound options"); + + UIMainOptionsPresenterClass keyboard(state); + Drive(keyboard, "keyboard"); + UIMainOptionsPresenterClass settings(state); + Drive(settings, "settings"); + UIMainOptionsPresenterClass leave(state); + Drive(leave, "ok"); + Check(keyboard.Choice == UI_MAIN_OPTIONS_KEYBOARD && settings.Choice == UI_MAIN_OPTIONS_SETTINGS && leave.Choice == UI_MAIN_OPTIONS_LEAVE && leave.Result.has_value() && *leave.Result == UI_RESULT_ACCEPTED, "Keyboard, Game Settings and Main Menu each answer with their choice"); + } +} + + // Drives the wait box: the text follows the presenter, the frame appears only with a bar, and // the fill follows the percentage. void Test_Wait_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & system) @@ -1957,6 +2056,7 @@ void Test_Documents(void) Test_Game_Controls_Screen(*context, system); Test_Display_Screen(*context, system); Test_Keyboard_Screen(*context, system); + Test_Main_Options_Screen(*context, system); Test_Wait_Box_Screen(*context, system); } diff --git a/ui/mainopt.rcss b/ui/mainopt.rcss new file mode 100644 index 000000000..de1fb8af6 --- /dev/null +++ b/ui/mainopt.rcss @@ -0,0 +1,82 @@ +body +{ + width: 100%; + height: 100%; + font-family: "Open Sans"; + font-size: 13dp; + color: #e6e6e6; +} + +div +{ + display: block; +} + +#panel +{ + position: absolute; + left: 50%; + top: 50%; + width: 300dp; + height: 222dp; + margin-left: -150dp; + margin-top: -111dp; + background-color: #12181fdc; + border: 1dp #5a8f6a; +} + +button +{ + position: absolute; + left: 55dp; + width: 189dp; + height: 27dp; + display: block; + text-align: center; + line-height: 27dp; + tab-index: auto; + background-color: #2f4a5a; + border: 1dp #6f95a8; +} + +button:hover +{ + background-color: #3f6478; +} + +button:active +{ + background-color: #24394a; +} + +button.disabled +{ + color: #7a8790; + background-color: #24303a; + border-color: #4a5a68; +} + +#settings +{ + top: 15dp; +} + +#display +{ + top: 48dp; +} + +#sound +{ + top: 81dp; +} + +#keyboard +{ + top: 114dp; +} + +#mainmenu +{ + top: 180dp; +} diff --git a/ui/mainopt.rml b/ui/mainopt.rml new file mode 100644 index 000000000..b7189ed95 --- /dev/null +++ b/ui/mainopt.rml @@ -0,0 +1,15 @@ + + + Options menu + + + +
+ + + + + +
+ +
From 13c1d910fcfa3782c667d1f5d1cf299e0d2449b9 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:11:43 +0300 Subject: [PATCH 19/52] Group the UI sources into shell, RmlUi, developer and screen folders --- code/CMakeLists.txt | 6 +-- code/gamedlg.cpp | 2 +- code/init.cpp | 2 +- code/loaddlg.cpp | 2 +- code/mainopt.cpp | 4 +- code/msgbox.cpp | 2 +- code/options.cpp | 2 +- code/progress.h | 2 +- code/savemgr.cpp | 2 +- code/sounddlg.cpp | 2 +- code/ui/{ => dev}/uidev.cpp | 10 ++--- code/ui/{ => dev}/uidev.h | 8 ++-- code/ui/{uifile.cpp => rml/rmlfile.cpp} | 14 +++--- code/ui/{uifile.h => rml/rmlfile.h} | 2 +- code/ui/{uikeys.cpp => rml/rmlkeys.cpp} | 2 +- code/ui/{uikeys.h => rml/rmlkeys.h} | 0 code/ui/{uirender.cpp => rml/rmlrender.cpp} | 44 +++++++++---------- code/ui/{uirender.h => rml/rmlrender.h} | 4 +- code/ui/{uisystem.cpp => rml/rmlsystem.cpp} | 12 ++--- code/ui/{uisystem.h => rml/rmlsystem.h} | 4 +- code/ui/{uitexture.cpp => rml/rmltexture.cpp} | 2 +- code/ui/{uitexture.h => rml/rmltexture.h} | 0 code/ui/{uirmlview.cpp => rml/rmlview.cpp} | 2 +- code/ui/{uirmlview.h => rml/rmlview.h} | 0 code/ui/{ => screens/display}/uidisplay.cpp | 4 +- code/ui/{ => screens/display}/uidisplay.h | 0 .../ui/{ => screens/display}/uidisplaydlg.cpp | 4 +- code/ui/{ => screens/gamectrl}/uigamectrl.cpp | 4 +- code/ui/{ => screens/gamectrl}/uigamectrl.h | 0 .../{ => screens/gamectrl}/uigamectrldlg.cpp | 4 +- code/ui/{ => screens/keyboard}/uikeyboard.cpp | 6 +-- code/ui/{ => screens/keyboard}/uikeyboard.h | 0 .../{ => screens/keyboard}/uikeyboarddlg.cpp | 4 +- code/ui/{ => screens/mainopt}/uimainopt.cpp | 4 +- code/ui/{ => screens/mainopt}/uimainopt.h | 0 .../ui/{ => screens/mainopt}/uimainoptdlg.cpp | 4 +- code/ui/{ => screens/msgbox}/uimsgbox.cpp | 4 +- code/ui/{ => screens/msgbox}/uimsgbox.h | 0 code/ui/{ => screens/msgbox}/uimsgboxdlg.cpp | 4 +- code/ui/{ => screens/sound}/uisound.cpp | 4 +- code/ui/{ => screens/sound}/uisound.h | 0 code/ui/{ => screens/sound}/uisounddlg.cpp | 4 +- code/ui/{ => screens/version}/uiversion.cpp | 4 +- code/ui/{ => screens/version}/uiversion.h | 0 .../ui/{ => screens/version}/uiversiondlg.cpp | 4 +- code/ui/{ => screens/waitbox}/uiwaitbox.cpp | 4 +- code/ui/{ => screens/waitbox}/uiwaitbox.h | 0 .../ui/{ => screens/waitbox}/uiwaitboxdlg.cpp | 4 +- code/ui/uishell.cpp | 18 ++++---- docs/UI_DESIGN.md | 43 ++++++++---------- tests/uishell/CMakeLists.txt | 20 ++++----- tests/uishell/uishell.cpp | 20 ++++----- 52 files changed, 146 insertions(+), 151 deletions(-) rename code/ui/{ => dev}/uidev.cpp (98%) rename code/ui/{ => dev}/uidev.h (84%) rename code/ui/{uifile.cpp => rml/rmlfile.cpp} (82%) rename code/ui/{uifile.h => rml/rmlfile.h} (95%) rename code/ui/{uikeys.cpp => rml/rmlkeys.cpp} (99%) rename code/ui/{uikeys.h => rml/rmlkeys.h} (100%) rename code/ui/{uirender.cpp => rml/rmlrender.cpp} (90%) rename code/ui/{uirender.h => rml/rmlrender.h} (96%) rename code/ui/{uisystem.cpp => rml/rmlsystem.cpp} (85%) rename code/ui/{uisystem.h => rml/rmlsystem.h} (92%) rename code/ui/{uitexture.cpp => rml/rmltexture.cpp} (98%) rename code/ui/{uitexture.h => rml/rmltexture.h} (100%) rename code/ui/{uirmlview.cpp => rml/rmlview.cpp} (99%) rename code/ui/{uirmlview.h => rml/rmlview.h} (100%) rename code/ui/{ => screens/display}/uidisplay.cpp (98%) rename code/ui/{ => screens/display}/uidisplay.h (100%) rename code/ui/{ => screens/display}/uidisplaydlg.cpp (97%) rename code/ui/{ => screens/gamectrl}/uigamectrl.cpp (98%) rename code/ui/{ => screens/gamectrl}/uigamectrl.h (100%) rename code/ui/{ => screens/gamectrl}/uigamectrldlg.cpp (98%) rename code/ui/{ => screens/keyboard}/uikeyboard.cpp (98%) rename code/ui/{ => screens/keyboard}/uikeyboard.h (100%) rename code/ui/{ => screens/keyboard}/uikeyboarddlg.cpp (98%) rename code/ui/{ => screens/mainopt}/uimainopt.cpp (97%) rename code/ui/{ => screens/mainopt}/uimainopt.h (100%) rename code/ui/{ => screens/mainopt}/uimainoptdlg.cpp (96%) rename code/ui/{ => screens/msgbox}/uimsgbox.cpp (97%) rename code/ui/{ => screens/msgbox}/uimsgbox.h (100%) rename code/ui/{ => screens/msgbox}/uimsgboxdlg.cpp (96%) rename code/ui/{ => screens/sound}/uisound.cpp (98%) rename code/ui/{ => screens/sound}/uisound.h (100%) rename code/ui/{ => screens/sound}/uisounddlg.cpp (98%) rename code/ui/{ => screens/version}/uiversion.cpp (95%) rename code/ui/{ => screens/version}/uiversion.h (100%) rename code/ui/{ => screens/version}/uiversiondlg.cpp (96%) rename code/ui/{ => screens/waitbox}/uiwaitbox.cpp (95%) rename code/ui/{ => screens/waitbox}/uiwaitbox.h (100%) rename code/ui/{ => screens/waitbox}/uiwaitboxdlg.cpp (96%) diff --git a/code/CMakeLists.txt b/code/CMakeLists.txt index b58a88565..a689a2c4d 100644 --- a/code/CMakeLists.txt +++ b/code/CMakeLists.txt @@ -149,7 +149,7 @@ add_dependencies(OpenTS OpenTSBuildStamp) set(BGFX_ROOT "${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bgfx") set(OPENTS_BGFX_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/bgfxbackend.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/ui/uirender.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/rml/rmlrender.cpp" ) set_source_files_properties(${OPENTS_BGFX_SOURCES} PROPERTIES INCLUDE_DIRECTORIES @@ -165,7 +165,7 @@ set_source_files_properties(${OPENTS_BGFX_SOURCES} PROPERTIES set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/bgfxbackend.cpp" APPEND PROPERTY INCLUDE_DIRECTORIES "${BGFX_ROOT}/examples/common/imgui" ) -set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/ui/uirender.cpp" APPEND PROPERTY +set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/ui/rml/rmlrender.cpp" APPEND PROPERTY INCLUDE_DIRECTORIES "${BGFX_ROOT}/examples/common/debugdraw" ) @@ -179,7 +179,7 @@ if(OPENTS_EXPERIMENTAL_CLANG_CL AND CMAKE_SIZEOF_VOID_P EQUAL 4) endif() # The image decoder is a header bimg carries; only the texture loader compiles it. -set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/ui/uitexture.cpp" APPEND PROPERTY +set_property(SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/ui/rml/rmltexture.cpp" APPEND PROPERTY INCLUDE_DIRECTORIES "${CMAKE_SOURCE_DIR}/thirdparty/bgfx.cmake/bimg/3rdparty/stb" ) diff --git a/code/gamedlg.cpp b/code/gamedlg.cpp index 093b2cd1a..18685ad95 100644 --- a/code/gamedlg.cpp +++ b/code/gamedlg.cpp @@ -48,7 +48,7 @@ #include "queue.h" #include "session.h" #include "techno.h" -#include "ui/uigamectrl.h" +#include "ui/screens/gamectrl/uigamectrl.h" #include "ui/uiscreen.h" #include "ui/uishell.h" diff --git a/code/init.cpp b/code/init.cpp index 17f5faae9..c0310d5bf 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -178,8 +178,8 @@ #include "trigger.h" #include "tube.h" #include "tutorial.h" +#include "ui/screens/version/uiversion.h" #include "ui/uishell.h" -#include "ui/uiversion.h" #include "uicontrol.h" #include "unit.h" #include "unittype.h" diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index 7deae3004..71500b9e4 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -53,7 +53,7 @@ #include "language/language.h" #include "msgbox.h" #include "ownrdraw.h" -#include "ui/uiwaitbox.h" +#include "ui/screens/waitbox/uiwaitbox.h" #include "saveload.h" #include "savemgr.h" #include "savever.h" diff --git a/code/mainopt.cpp b/code/mainopt.cpp index c4f4b6fcf..2038d7c3e 100644 --- a/code/mainopt.cpp +++ b/code/mainopt.cpp @@ -34,8 +34,8 @@ #include "sounddlg.h" #include "stimer.h" #include "surface.h" -#include "ui/uidisplay.h" -#include "ui/uimainopt.h" +#include "ui/screens/display/uidisplay.h" +#include "ui/screens/mainopt/uimainopt.h" #include "ui/uishell.h" #include "wwmouse.h" diff --git a/code/msgbox.cpp b/code/msgbox.cpp index 1e165eb79..86594b5f7 100644 --- a/code/msgbox.cpp +++ b/code/msgbox.cpp @@ -39,7 +39,7 @@ #include "globals.h" #include "init.h" #include "ownrdraw.h" -#include "ui/uimsgbox.h" +#include "ui/screens/msgbox/uimsgbox.h" #include "ui/uishell.h" #include "winfix.h" diff --git a/code/options.cpp b/code/options.cpp index 89491b41b..16cda0f27 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -83,7 +83,7 @@ #include "session.h" #include "techno.h" #include "theme.h" -#include "ui/uikeyboard.h" +#include "ui/screens/keyboard/uikeyboard.h" #include "ui/uishell.h" #include "vector.h" #include "video.h" diff --git a/code/progress.h b/code/progress.h index 3659bb9d5..a7160e5a0 100644 --- a/code/progress.h +++ b/code/progress.h @@ -13,7 +13,7 @@ #include "point.h" #include "sun.h" -#include "ui/uiwaitbox.h" +#include "ui/screens/waitbox/uiwaitbox.h" #include "win.h" class ShapeSet; diff --git a/code/savemgr.cpp b/code/savemgr.cpp index fa7a81297..f3e13ab2b 100644 --- a/code/savemgr.cpp +++ b/code/savemgr.cpp @@ -26,7 +26,7 @@ #include "netdlg.h" #include "netglobal.h" #include "ownrdraw.h" -#include "ui/uiwaitbox.h" +#include "ui/screens/waitbox/uiwaitbox.h" #include "rawfile.h" #include "rules.h" #include "saveload.h" diff --git a/code/sounddlg.cpp b/code/sounddlg.cpp index b37a102b7..7d69d5c41 100644 --- a/code/sounddlg.cpp +++ b/code/sounddlg.cpp @@ -43,9 +43,9 @@ #include "init.h" #include "language/language.h" #include "ownrdraw.h" +#include "ui/screens/sound/uisound.h" #include "ui/uiscreen.h" #include "ui/uishell.h" -#include "ui/uisound.h" #include "winfix.h" bool DialogInitialized = false; diff --git a/code/ui/uidev.cpp b/code/ui/dev/uidev.cpp similarity index 98% rename from code/ui/uidev.cpp rename to code/ui/dev/uidev.cpp index 14ffd1375..35b191d59 100644 --- a/code/ui/uidev.cpp +++ b/code/ui/dev/uidev.cpp @@ -7,7 +7,7 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uidev.h" +#include "ui/dev/uidev.h" #include "_bench.h" #include "bench.h" @@ -17,7 +17,7 @@ #include "logic.h" #include "mono.h" #include "mpu.h" -#include "ui/uirender.h" +#include "ui/rml/rmlrender.h" #include "video.h" #include @@ -395,7 +395,7 @@ bool UIDev_Active(void) } -void UIDev_Toggle(UIRenderInterfaceClass const & render) +void UIDev_Toggle(UIRmlBgfxRenderClass const & render) { if (_Context == NULL) { IMGUI_CHECKVERSION(); @@ -468,7 +468,7 @@ void UIDev_Tick(void) } -void UIDev_Render(UIRenderInterfaceClass & render) +void UIDev_Render(UIRmlBgfxRenderClass & render) { if (!UIDev_Active()) { return; @@ -478,7 +478,7 @@ void UIDev_Render(UIRenderInterfaceClass & render) } -void UIDev_Shutdown(UIRenderInterfaceClass & render) +void UIDev_Shutdown(UIRmlBgfxRenderClass & render) { if (_Context == NULL) { return; diff --git a/code/ui/uidev.h b/code/ui/dev/uidev.h similarity index 84% rename from code/ui/uidev.h rename to code/ui/dev/uidev.h index 994e4c3bc..8045fcd57 100644 --- a/code/ui/uidev.h +++ b/code/ui/dev/uidev.h @@ -11,16 +11,16 @@ #include "win.h" -class UIRenderInterfaceClass; +class UIRmlBgfxRenderClass; // The Dear ImGui developer overlays. The context is created on the first toggle, so a // build whose developer keys never arm allocates nothing here. bool UIDev_Active(void); -void UIDev_Toggle(UIRenderInterfaceClass const & render); +void UIDev_Toggle(UIRmlBgfxRenderClass const & render); void UIDev_Tick(void); -void UIDev_Render(UIRenderInterfaceClass & render); -void UIDev_Shutdown(UIRenderInterfaceClass & render); +void UIDev_Render(UIRmlBgfxRenderClass & render); +void UIDev_Shutdown(UIRmlBgfxRenderClass & render); // Input reaches the overlays before the documents and the game. A true return means the // overlays want that message kept from both. diff --git a/code/ui/uifile.cpp b/code/ui/rml/rmlfile.cpp similarity index 82% rename from code/ui/uifile.cpp rename to code/ui/rml/rmlfile.cpp index 902b7ff00..5e2b1f01f 100644 --- a/code/ui/uifile.cpp +++ b/code/ui/rml/rmlfile.cpp @@ -7,7 +7,7 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uifile.h" +#include "ui/rml/rmlfile.h" #include "ccfile.h" @@ -21,7 +21,7 @@ static Rml::String Base_Name(Rml::String const & path) } -Rml::FileHandle UIFileInterfaceClass::Open(Rml::String const & path) +Rml::FileHandle UIRmlFileClass::Open(Rml::String const & path) { Rml::String name = Base_Name(path); if (name.empty()) { @@ -38,7 +38,7 @@ Rml::FileHandle UIFileInterfaceClass::Open(Rml::String const & path) } -void UIFileInterfaceClass::Close(Rml::FileHandle file) +void UIRmlFileClass::Close(Rml::FileHandle file) { CCFileClass * ccfile = (CCFileClass *)file; if (ccfile != NULL) { @@ -48,7 +48,7 @@ void UIFileInterfaceClass::Close(Rml::FileHandle file) } -size_t UIFileInterfaceClass::Read(void * buffer, size_t size, Rml::FileHandle file) +size_t UIRmlFileClass::Read(void * buffer, size_t size, Rml::FileHandle file) { CCFileClass * ccfile = (CCFileClass *)file; if (ccfile == NULL || size == 0) { @@ -62,7 +62,7 @@ size_t UIFileInterfaceClass::Read(void * buffer, size_t size, Rml::FileHandle fi // The engine reports the position it reached rather than success, so a clamped seek is // recognized by comparing the two. -bool UIFileInterfaceClass::Seek(Rml::FileHandle file, long offset, int origin) +bool UIRmlFileClass::Seek(Rml::FileHandle file, long offset, int origin) { CCFileClass * ccfile = (CCFileClass *)file; if (ccfile == NULL) { @@ -87,7 +87,7 @@ bool UIFileInterfaceClass::Seek(Rml::FileHandle file, long offset, int origin) } -size_t UIFileInterfaceClass::Tell(Rml::FileHandle file) +size_t UIRmlFileClass::Tell(Rml::FileHandle file) { CCFileClass * ccfile = (CCFileClass *)file; if (ccfile == NULL) { @@ -99,7 +99,7 @@ size_t UIFileInterfaceClass::Tell(Rml::FileHandle file) } -size_t UIFileInterfaceClass::Length(Rml::FileHandle file) +size_t UIRmlFileClass::Length(Rml::FileHandle file) { CCFileClass * ccfile = (CCFileClass *)file; if (ccfile == NULL) { diff --git a/code/ui/uifile.h b/code/ui/rml/rmlfile.h similarity index 95% rename from code/ui/uifile.h rename to code/ui/rml/rmlfile.h index f56b40f02..3479660ea 100644 --- a/code/ui/uifile.h +++ b/code/ui/rml/rmlfile.h @@ -14,7 +14,7 @@ // RmlUi's files come through the engine's search chain: a bare name is looked for in the // user path, the run directory, the search drives and then the mix files. -class UIFileInterfaceClass : public Rml::FileInterface +class UIRmlFileClass : public Rml::FileInterface { public: virtual Rml::FileHandle Open(Rml::String const & path) override; diff --git a/code/ui/uikeys.cpp b/code/ui/rml/rmlkeys.cpp similarity index 99% rename from code/ui/uikeys.cpp rename to code/ui/rml/rmlkeys.cpp index 7a9b38b49..245f7c14c 100644 --- a/code/ui/uikeys.cpp +++ b/code/ui/rml/rmlkeys.cpp @@ -7,7 +7,7 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uikeys.h" +#include "ui/rml/rmlkeys.h" #include diff --git a/code/ui/uikeys.h b/code/ui/rml/rmlkeys.h similarity index 100% rename from code/ui/uikeys.h rename to code/ui/rml/rmlkeys.h diff --git a/code/ui/uirender.cpp b/code/ui/rml/rmlrender.cpp similarity index 90% rename from code/ui/uirender.cpp rename to code/ui/rml/rmlrender.cpp index 6b2bf5145..0222390fd 100644 --- a/code/ui/uirender.cpp +++ b/code/ui/rml/rmlrender.cpp @@ -10,12 +10,12 @@ // The bgfx side of the UI overlays. With bgfxbackend.cpp it is one of the two translation // units that include bgfx. -#include "ui/uirender.h" +#include "ui/rml/rmlrender.h" #include "bgfxbackend.h" #include "bgfxviews.hh" #include "dbgprint.h" -#include "ui/uitexture.h" +#include "ui/rml/rmltexture.h" #include #include @@ -60,7 +60,7 @@ static bgfx::TextureHandle Texture_Handle(Rml::TextureHandle handle) } -UIRenderInterfaceClass::UIRenderInterfaceClass(void) : +UIRmlBgfxRenderClass::UIRmlBgfxRenderClass(void) : IsReady(false), Program(bgfx::kInvalidHandle), Sampler(bgfx::kInvalidHandle), @@ -76,7 +76,7 @@ UIRenderInterfaceClass::UIRenderInterfaceClass(void) : } -bool UIRenderInterfaceClass::Init(void) +bool UIRmlBgfxRenderClass::Init(void) { if (IsReady) { return(true); @@ -137,7 +137,7 @@ bool UIRenderInterfaceClass::Init(void) } -void UIRenderInterfaceClass::Shutdown(void) +void UIRmlBgfxRenderClass::Shutdown(void) { if (!IsReady) { return; @@ -160,7 +160,7 @@ void UIRenderInterfaceClass::Shutdown(void) // View state persists across frames and resets, and the prescale pass binds a framebuffer // to a lower view, so everything the overlays rely on is set again each frame. -void UIRenderInterfaceClass::Set_View(unsigned short view, int x, int y, int width, int height) +void UIRmlBgfxRenderClass::Set_View(unsigned short view, int x, int y, int width, int height) { ViewX = x; ViewY = y; @@ -177,19 +177,19 @@ void UIRenderInterfaceClass::Set_View(unsigned short view, int x, int y, int wid } -void UIRenderInterfaceClass::Begin_Frame(int x, int y, int width, int height) +void UIRmlBgfxRenderClass::Begin_Frame(int x, int y, int width, int height) { Set_View(VIEW_UI, x, y, width, height); } -void UIRenderInterfaceClass::Begin_Dev_Frame(int x, int y, int width, int height) +void UIRmlBgfxRenderClass::Begin_Dev_Frame(int x, int y, int width, int height) { Set_View(VIEW_DEV, x, y, width, height); } -int UIRenderInterfaceClass::Texture_Limit(void) const +int UIRmlBgfxRenderClass::Texture_Limit(void) const { if (!IsReady) { return(0); @@ -199,7 +199,7 @@ int UIRenderInterfaceClass::Texture_Limit(void) const } -void UIRenderInterfaceClass::Log_Resource_Counts(char const * when) const +void UIRmlBgfxRenderClass::Log_Resource_Counts(char const * when) const { bgfx::Stats const * stats = bgfx::getStats(); if (stats == NULL) { @@ -211,7 +211,7 @@ void UIRenderInterfaceClass::Log_Resource_Counts(char const * when) const } -Rml::CompiledGeometryHandle UIRenderInterfaceClass::CompileGeometry(Rml::Span vertices, Rml::Span indices) +Rml::CompiledGeometryHandle UIRmlBgfxRenderClass::CompileGeometry(Rml::Span vertices, Rml::Span indices) { if (!IsReady || vertices.empty() || indices.empty()) { return(0); @@ -248,7 +248,7 @@ Rml::CompiledGeometryHandle UIRenderInterfaceClass::CompileGeometry(Rml::Span rgba; int width = 0; @@ -315,7 +315,7 @@ Rml::TextureHandle UIRenderInterfaceClass::LoadTexture(Rml::Vector2i & dimension } -Rml::TextureHandle UIRenderInterfaceClass::GenerateTexture(Rml::Span source, Rml::Vector2i dimensions) +Rml::TextureHandle UIRmlBgfxRenderClass::GenerateTexture(Rml::Span source, Rml::Vector2i dimensions) { if (!IsReady || dimensions.x <= 0 || dimensions.y <= 0) { return(0); @@ -335,7 +335,7 @@ Rml::TextureHandle UIRenderInterfaceClass::GenerateTexture(Rml::SpanStatus == ImTextureStatus_WantCreate) { assert(texture->Format == ImTextureFormat_RGBA32); @@ -424,7 +424,7 @@ void UIRenderInterfaceClass::Update_ImGui_Texture(ImTextureData * texture) } -void UIRenderInterfaceClass::Destroy_ImGui_Textures(void) +void UIRmlBgfxRenderClass::Destroy_ImGui_Textures(void) { for (ImTextureData * texture : ImGui::GetPlatformIO().Textures) { if (texture->TexID != ImTextureID_Invalid) { @@ -439,7 +439,7 @@ void UIRenderInterfaceClass::Destroy_ImGui_Textures(void) // ImGui rebuilds its geometry every frame, so it travels in transient buffers; its colours // carry straight alpha, unlike the premultiplied documents. -void UIRenderInterfaceClass::Render_ImGui(ImDrawData * data) +void UIRmlBgfxRenderClass::Render_ImGui(ImDrawData * data) { if (!IsReady || data == NULL || !data->Valid || data->DisplaySize.x <= 0.0f || data->DisplaySize.y <= 0.0f) { return; diff --git a/code/ui/uirender.h b/code/ui/rml/rmlrender.h similarity index 96% rename from code/ui/uirender.h rename to code/ui/rml/rmlrender.h index 2c90823d4..526a91321 100644 --- a/code/ui/uirender.h +++ b/code/ui/rml/rmlrender.h @@ -19,10 +19,10 @@ struct ImTextureData; // needs the renderer running; Shutdown comes after Rml::Shutdown, which releases every // texture and geometry through this object, and before the renderer stops. bgfx handles // are kept as their indices so that no bgfx type appears here. -class UIRenderInterfaceClass : public Rml::RenderInterface +class UIRmlBgfxRenderClass : public Rml::RenderInterface { public: - UIRenderInterfaceClass(void); + UIRmlBgfxRenderClass(void); bool Init(void); void Shutdown(void); diff --git a/code/ui/uisystem.cpp b/code/ui/rml/rmlsystem.cpp similarity index 85% rename from code/ui/uisystem.cpp rename to code/ui/rml/rmlsystem.cpp index 21a4acf89..6d0e284de 100644 --- a/code/ui/uisystem.cpp +++ b/code/ui/rml/rmlsystem.cpp @@ -7,7 +7,7 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uisystem.h" +#include "ui/rml/rmlsystem.h" #include "data.h" #include "dbgprint.h" @@ -18,20 +18,20 @@ #include -UISystemInterfaceClass::UISystemInterfaceClass(void) : +UIRmlSystemClass::UIRmlSystemClass(void) : StartTime(timeGetTime()) { } // UI animation follows the wall clock, never the game's deterministic timers. -double UISystemInterfaceClass::GetElapsedTime(void) +double UIRmlSystemClass::GetElapsedTime(void) { return((double)(timeGetTime() - StartTime) / 1000.0); } -bool UISystemInterfaceClass::LogMessage(Rml::Log::Type type, Rml::String const & message) +bool UIRmlSystemClass::LogMessage(Rml::Log::Type type, Rml::String const & message) { char const * level = "info"; @@ -65,7 +65,7 @@ bool UISystemInterfaceClass::LogMessage(Rml::Log::Type type, Rml::String const & // Documents name their resources by bare file name, so one name resolves the same way from // the ui directory, a loose override or a mix. -void UISystemInterfaceClass::JoinPath(Rml::String & translated, Rml::String const &, Rml::String const & path) +void UIRmlSystemClass::JoinPath(Rml::String & translated, Rml::String const &, Rml::String const & path) { size_t start = path.find_last_of("/\\"); translated = (start == Rml::String::npos) ? path : path.substr(start + 1); @@ -86,7 +86,7 @@ static int String_Id(Rml::String const & name) // A document names an engine string as [[TXT_NAME]]. An unknown name stays as typed so that // it shows where it was written. Fetch_String returns a pointer into a cache that later // calls reuse, so the text is copied out at once. -int UISystemInterfaceClass::TranslateString(Rml::String & translated, Rml::String const & input) +int UIRmlSystemClass::TranslateString(Rml::String & translated, Rml::String const & input) { int count = 0; size_t from = 0; diff --git a/code/ui/uisystem.h b/code/ui/rml/rmlsystem.h similarity index 92% rename from code/ui/uisystem.h rename to code/ui/rml/rmlsystem.h index e0ebfb5e1..74221fc7c 100644 --- a/code/ui/uisystem.h +++ b/code/ui/rml/rmlsystem.h @@ -13,10 +13,10 @@ // RmlUi's view of the engine's clock, debug log, resource naming and string table. -class UISystemInterfaceClass : public Rml::SystemInterface +class UIRmlSystemClass : public Rml::SystemInterface { public: - UISystemInterfaceClass(void); + UIRmlSystemClass(void); virtual double GetElapsedTime(void) override; virtual bool LogMessage(Rml::Log::Type type, Rml::String const & message) override; diff --git a/code/ui/uitexture.cpp b/code/ui/rml/rmltexture.cpp similarity index 98% rename from code/ui/uitexture.cpp rename to code/ui/rml/rmltexture.cpp index b8d1744a6..eebf904c9 100644 --- a/code/ui/uitexture.cpp +++ b/code/ui/rml/rmltexture.cpp @@ -7,7 +7,7 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uitexture.h" +#include "ui/rml/rmltexture.h" #include "ccfile.h" #include "dbgprint.h" diff --git a/code/ui/uitexture.h b/code/ui/rml/rmltexture.h similarity index 100% rename from code/ui/uitexture.h rename to code/ui/rml/rmltexture.h diff --git a/code/ui/uirmlview.cpp b/code/ui/rml/rmlview.cpp similarity index 99% rename from code/ui/uirmlview.cpp rename to code/ui/rml/rmlview.cpp index 2d505e67b..458249140 100644 --- a/code/ui/uirmlview.cpp +++ b/code/ui/rml/rmlview.cpp @@ -7,7 +7,7 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include #include diff --git a/code/ui/uirmlview.h b/code/ui/rml/rmlview.h similarity index 100% rename from code/ui/uirmlview.h rename to code/ui/rml/rmlview.h diff --git a/code/ui/uidisplay.cpp b/code/ui/screens/display/uidisplay.cpp similarity index 98% rename from code/ui/uidisplay.cpp rename to code/ui/screens/display/uidisplay.cpp index 4b0d753ff..3f2770cb7 100644 --- a/code/ui/uidisplay.cpp +++ b/code/ui/screens/display/uidisplay.cpp @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uidisplay.h" +#include "ui/screens/display/uidisplay.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include diff --git a/code/ui/uidisplay.h b/code/ui/screens/display/uidisplay.h similarity index 100% rename from code/ui/uidisplay.h rename to code/ui/screens/display/uidisplay.h diff --git a/code/ui/uidisplaydlg.cpp b/code/ui/screens/display/uidisplaydlg.cpp similarity index 97% rename from code/ui/uidisplaydlg.cpp rename to code/ui/screens/display/uidisplaydlg.cpp index 4180daf75..ba0bb621c 100644 --- a/code/ui/uidisplaydlg.cpp +++ b/code/ui/screens/display/uidisplaydlg.cpp @@ -11,11 +11,11 @@ // starts from. The presenters live in uidisplay.cpp so that the test harness can drive them // against a recording service and a hand-driven clock. -#include "ui/uidisplay.h" +#include "ui/screens/display/uidisplay.h" #include "globals.h" #include "goptions.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" #include "video.h" diff --git a/code/ui/uigamectrl.cpp b/code/ui/screens/gamectrl/uigamectrl.cpp similarity index 98% rename from code/ui/uigamectrl.cpp rename to code/ui/screens/gamectrl/uigamectrl.cpp index 2b0e0a9e1..49353d325 100644 --- a/code/ui/uigamectrl.cpp +++ b/code/ui/screens/gamectrl/uigamectrl.cpp @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uigamectrl.h" +#include "ui/screens/gamectrl/uigamectrl.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include diff --git a/code/ui/uigamectrl.h b/code/ui/screens/gamectrl/uigamectrl.h similarity index 100% rename from code/ui/uigamectrl.h rename to code/ui/screens/gamectrl/uigamectrl.h diff --git a/code/ui/uigamectrldlg.cpp b/code/ui/screens/gamectrl/uigamectrldlg.cpp similarity index 98% rename from code/ui/uigamectrldlg.cpp rename to code/ui/screens/gamectrl/uigamectrldlg.cpp index 4cc5c048f..05f40187e 100644 --- a/code/ui/uigamectrldlg.cpp +++ b/code/ui/screens/gamectrl/uigamectrldlg.cpp @@ -11,7 +11,7 @@ // starts from. The presenter lives in uigamectrl.cpp so that the test harness can drive it // against a recording service. -#include "ui/uigamectrl.h" +#include "ui/screens/gamectrl/uigamectrl.h" #include "_map.h" #include "_tooltip.h" @@ -24,7 +24,7 @@ #include "queue.h" #include "session.h" #include "techno.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" diff --git a/code/ui/uikeyboard.cpp b/code/ui/screens/keyboard/uikeyboard.cpp similarity index 98% rename from code/ui/uikeyboard.cpp rename to code/ui/screens/keyboard/uikeyboard.cpp index c5782e156..6e32dd4d2 100644 --- a/code/ui/uikeyboard.cpp +++ b/code/ui/screens/keyboard/uikeyboard.cpp @@ -7,10 +7,10 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uikeyboard.h" +#include "ui/screens/keyboard/uikeyboard.h" -#include "ui/uikeys.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlkeys.h" +#include "ui/rml/rmlview.h" #include #include diff --git a/code/ui/uikeyboard.h b/code/ui/screens/keyboard/uikeyboard.h similarity index 100% rename from code/ui/uikeyboard.h rename to code/ui/screens/keyboard/uikeyboard.h diff --git a/code/ui/uikeyboarddlg.cpp b/code/ui/screens/keyboard/uikeyboarddlg.cpp similarity index 98% rename from code/ui/uikeyboarddlg.cpp rename to code/ui/screens/keyboard/uikeyboarddlg.cpp index 5882822df..6d27abf03 100644 --- a/code/ui/uikeyboarddlg.cpp +++ b/code/ui/screens/keyboard/uikeyboarddlg.cpp @@ -11,7 +11,7 @@ // starts from. The presenter lives in uikeyboard.cpp so that the test harness can drive it // against a recording service. -#include "ui/uikeyboard.h" +#include "ui/screens/keyboard/uikeyboard.h" #include "_command.h" #include "ccfile.h" @@ -25,7 +25,7 @@ #include "language/language.h" #include "msgbox.h" #include "ownrdraw.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" #include "vector.h" diff --git a/code/ui/uimainopt.cpp b/code/ui/screens/mainopt/uimainopt.cpp similarity index 97% rename from code/ui/uimainopt.cpp rename to code/ui/screens/mainopt/uimainopt.cpp index c1e9c2ca4..9a82490ee 100644 --- a/code/ui/uimainopt.cpp +++ b/code/ui/screens/mainopt/uimainopt.cpp @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uimainopt.h" +#include "ui/screens/mainopt/uimainopt.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include #include diff --git a/code/ui/uimainopt.h b/code/ui/screens/mainopt/uimainopt.h similarity index 100% rename from code/ui/uimainopt.h rename to code/ui/screens/mainopt/uimainopt.h diff --git a/code/ui/uimainoptdlg.cpp b/code/ui/screens/mainopt/uimainoptdlg.cpp similarity index 96% rename from code/ui/uimainoptdlg.cpp rename to code/ui/screens/mainopt/uimainoptdlg.cpp index 327f200b2..d34834c2c 100644 --- a/code/ui/uimainoptdlg.cpp +++ b/code/ui/screens/mainopt/uimainoptdlg.cpp @@ -11,12 +11,12 @@ // driver calls ahead of its Win32 dialog. The presenter and view live in uimainopt.cpp so // that the test harness can drive them without the engine. -#include "ui/uimainopt.h" +#include "ui/screens/mainopt/uimainopt.h" #include "_surface.h" #include "audio/audioengine.h" #include "surface.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" diff --git a/code/ui/uimsgbox.cpp b/code/ui/screens/msgbox/uimsgbox.cpp similarity index 97% rename from code/ui/uimsgbox.cpp rename to code/ui/screens/msgbox/uimsgbox.cpp index fe7ea428d..3b15b1e3a 100644 --- a/code/ui/uimsgbox.cpp +++ b/code/ui/screens/msgbox/uimsgbox.cpp @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uimsgbox.h" +#include "ui/screens/msgbox/uimsgbox.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include diff --git a/code/ui/uimsgbox.h b/code/ui/screens/msgbox/uimsgbox.h similarity index 100% rename from code/ui/uimsgbox.h rename to code/ui/screens/msgbox/uimsgbox.h diff --git a/code/ui/uimsgboxdlg.cpp b/code/ui/screens/msgbox/uimsgboxdlg.cpp similarity index 96% rename from code/ui/uimsgboxdlg.cpp rename to code/ui/screens/msgbox/uimsgboxdlg.cpp index f6d6d1e4d..fe2cc8cc4 100644 --- a/code/ui/uimsgboxdlg.cpp +++ b/code/ui/screens/msgbox/uimsgboxdlg.cpp @@ -11,9 +11,9 @@ // its Win32 box. The presenter and view live in uimsgbox.cpp so that the test harness can // drive them without the engine. -#include "ui/uimsgbox.h" +#include "ui/screens/msgbox/uimsgbox.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" #include diff --git a/code/ui/uisound.cpp b/code/ui/screens/sound/uisound.cpp similarity index 98% rename from code/ui/uisound.cpp rename to code/ui/screens/sound/uisound.cpp index fb754b2f1..239a21fbf 100644 --- a/code/ui/uisound.cpp +++ b/code/ui/screens/sound/uisound.cpp @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uisound.h" +#include "ui/screens/sound/uisound.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include diff --git a/code/ui/uisound.h b/code/ui/screens/sound/uisound.h similarity index 100% rename from code/ui/uisound.h rename to code/ui/screens/sound/uisound.h diff --git a/code/ui/uisounddlg.cpp b/code/ui/screens/sound/uisounddlg.cpp similarity index 98% rename from code/ui/uisounddlg.cpp rename to code/ui/screens/sound/uisounddlg.cpp index 7f430c43b..5c9af1c1e 100644 --- a/code/ui/uisounddlg.cpp +++ b/code/ui/screens/sound/uisounddlg.cpp @@ -11,14 +11,14 @@ // starts from. The presenter lives in uisound.cpp so that the test harness can drive it // against a recording service. -#include "ui/uisound.h" +#include "ui/screens/sound/uisound.h" #include "audio/audioengine.h" #include "globals.h" #include "goptions.h" #include "incdec.h" #include "theme.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" #include diff --git a/code/ui/uiversion.cpp b/code/ui/screens/version/uiversion.cpp similarity index 95% rename from code/ui/uiversion.cpp rename to code/ui/screens/version/uiversion.cpp index 42b23f473..a65e876a8 100644 --- a/code/ui/uiversion.cpp +++ b/code/ui/screens/version/uiversion.cpp @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uiversion.h" +#include "ui/screens/version/uiversion.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include diff --git a/code/ui/uiversion.h b/code/ui/screens/version/uiversion.h similarity index 100% rename from code/ui/uiversion.h rename to code/ui/screens/version/uiversion.h diff --git a/code/ui/uiversiondlg.cpp b/code/ui/screens/version/uiversiondlg.cpp similarity index 96% rename from code/ui/uiversiondlg.cpp rename to code/ui/screens/version/uiversiondlg.cpp index 59b9da312..02f2ca3e0 100644 --- a/code/ui/uiversiondlg.cpp +++ b/code/ui/screens/version/uiversiondlg.cpp @@ -11,14 +11,14 @@ // wrapper calls. The presenter and view live in uiversion.cpp so that the test harness can // drive them without the engine. -#include "ui/uiversion.h" +#include "ui/screens/version/uiversion.h" #include "addon.h" #include "data.h" #include "getcpu.h" #include "globals.h" #include "language/language.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" #include "version.h" diff --git a/code/ui/uiwaitbox.cpp b/code/ui/screens/waitbox/uiwaitbox.cpp similarity index 95% rename from code/ui/uiwaitbox.cpp rename to code/ui/screens/waitbox/uiwaitbox.cpp index 50d25be55..58f5b7eb6 100644 --- a/code/ui/uiwaitbox.cpp +++ b/code/ui/screens/waitbox/uiwaitbox.cpp @@ -7,9 +7,9 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -#include "ui/uiwaitbox.h" +#include "ui/screens/waitbox/uiwaitbox.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include diff --git a/code/ui/uiwaitbox.h b/code/ui/screens/waitbox/uiwaitbox.h similarity index 100% rename from code/ui/uiwaitbox.h rename to code/ui/screens/waitbox/uiwaitbox.h diff --git a/code/ui/uiwaitboxdlg.cpp b/code/ui/screens/waitbox/uiwaitboxdlg.cpp similarity index 96% rename from code/ui/uiwaitboxdlg.cpp rename to code/ui/screens/waitbox/uiwaitboxdlg.cpp index 7a2f71b1b..99ad40646 100644 --- a/code/ui/uiwaitboxdlg.cpp +++ b/code/ui/screens/waitbox/uiwaitboxdlg.cpp @@ -11,10 +11,10 @@ // works. The presenter and view live in uiwaitbox.cpp so that the test harness can drive them // without the engine. -#include "ui/uiwaitbox.h" +#include "ui/screens/waitbox/uiwaitbox.h" #include "ownrdraw.h" -#include "ui/uirmlview.h" +#include "ui/rml/rmlview.h" #include "ui/uishell.h" diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index c2fa80da4..5655f5c44 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -19,13 +19,13 @@ #include "movies.h" #include "msgloop.h" #include "session.h" +#include "ui/dev/uidev.h" +#include "ui/rml/rmlfile.h" +#include "ui/rml/rmlkeys.h" +#include "ui/rml/rmlrender.h" +#include "ui/rml/rmlsystem.h" +#include "ui/rml/rmlview.h" #include "ui/uicoord.h" -#include "ui/uidev.h" -#include "ui/uifile.h" -#include "ui/uikeys.h" -#include "ui/uirender.h" -#include "ui/uirmlview.h" -#include "ui/uisystem.h" #include "video.h" #include "windlg.h" @@ -41,9 +41,9 @@ // The interfaces outlive Rml::Shutdown, which releases every resource through them. -static UISystemInterfaceClass _System; -static UIFileInterfaceClass _File; -static UIRenderInterfaceClass _Render; +static UIRmlSystemClass _System; +static UIRmlFileClass _File; +static UIRmlBgfxRenderClass _Render; static Rml::Context * _Context = NULL; static bool _Ready = false; diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 22c79d638..c101ff5c4 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -164,7 +164,7 @@ Dependency rules: ImGui, or bgfx types. - Views use their toolkit directly. There is no shared widget API. - RmlUi data bindings and document nodes stay inside the RmlUi view. -- Renderer handles stay inside `code/ui/uirender.cpp`. +- Renderer handles stay inside `code/ui/rml/rmlrender.cpp`. - The shell knows which presentation owns a region and an input scope. It does not know production rules, save semantics, or option behavior. - Existing callers keep their screen functions; composition sits behind @@ -175,29 +175,24 @@ actions is added only where a screen has real state transitions. ### Code layout -New files live in `code/ui/`. The recursive glob in `code/CMakeLists.txt` -picks them up. The library headers reach the whole target through the linked -targets, as bgfx's already do; the per-file properties carry only the shader -headers, the image decoder header, and the bgfx debug define, as -`bgfxbackend.cpp`'s do today. Files without a status column entry are not yet -written. +Sources live under `code/ui/`, grouped by what they may include. The +recursive glob in `code/CMakeLists.txt` picks them up; the per-file +properties carry only the shader headers, the image decoder header, and the +bgfx debug define, as `bgfxbackend.cpp`'s do today. The library headers reach +the whole target through the linked targets, so the containment below is a +rule the tree follows, not a build boundary. -| File | Holds | Status | +| Directory | Holds | Status | | --- | --- | --- | -| `bgfxviews.hh` (in `code/`) | the view ids the presenter and the overlays share | landed | -| `uishell.h`, `uishell.cpp` | init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector | landed | -| `uirender.h`, `uirender.cpp` | RmlUi render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx | landed | -| `uisystem.h`, `uisystem.cpp` | RmlUi system interface: time, logging to `DebugString`, cursor, clipboard, string translation | landed with time, logging, resource naming, and string translation; cursor and clipboard wait for the first editable screen | -| `uifile.h`, `uifile.cpp` | RmlUi file interface over `CCFileClass` | landed | -| `uitexture.h`, `uitexture.cpp` | image decoding, SHP and PCX conversion, surface-backed textures | landed for PNG and TGA | -| `uicoord.h` | the pointer mapping from client pixels into the overlay | landed | -| `uiscreen.h`, `uirmlview.h` | presenter, intent, and result contracts; the RmlUi view base | landed, with `uiscreen.cpp` and `uirmlview.cpp` carrying the bodies | -| `uidev.h`, `uidev.cpp` | ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | -| one file per screen | presenter, view-model binding, and the RmlUi view glue | landed for the version dialog as `uiversion.h`, `uiversion.cpp` (presenter and view, also built into the test) and `uiversiondlg.cpp` (engine entry and data builder, which the test cannot link); the message boxes follow as `uimsgbox.*` and `uimsgboxdlg.cpp`; the sound options as `uisound.*` (presenter, service interface and view) and `uisounddlg.cpp` (engine service, state and entry), with the Win32 dialog as a second view over the same presenter; the wait boxes as `uiwaitbox.*` (presenter, view and the `UIWaitBoxClass` the save, load and progress code shows) and `uiwaitboxdlg.cpp` | +| `code/` | `bgfxviews.hh`, the view ids the presenter and the overlays share | landed | +| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | +| `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging to `DebugString`, string translation; cursor and clipboard wait for the first editable screen), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base) | landed | +| `code/ui/dev/` | `uidev.h`, `uidev.cpp`: the ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | +| `code/ui/screens//` | one family each for `version`, `msgbox`, `waitbox`, `sound`, `gamectrl`, `display`, `keyboard`, `mainopt`: `ui.h` (presenter, service and state declarations, view factory, engine entry), `ui.cpp` (presenter and RmlUi view; built into the test), `uidlg.cpp` (engine service and entry, which the test cannot link) | landed; the sound, game controls, keyboard and display Win32 dialogs drive the same presenter as a second view, and the wait box family carries the `UIWaitBoxClass` the save, load and progress code shows | Shipped UI files (documents, styles, images, the font) live in `ui/` at the -repository root. The build copies the tree beside the executable as it copies -`Language.dll`, and the client package ships it. +repository root. The build places the tree beside the executable, at +`/bin//ui/`, and the client package ships it. ## Rendering @@ -398,7 +393,7 @@ class UIPresenterClass { // uiscreen.h: no toolkit type std::optional Result; }; -class UIRmlViewClass { // uirmlview.h: owns the document +class UIRmlViewClass { // rml/rmlview.h: owns the document public: UIRmlViewClass(UIPresenterClass & presenter, char const * document); virtual void Bind(Rml::DataModelConstructor & model) = 0; // view-model fields and events @@ -597,7 +592,7 @@ view. ## Dear ImGui ImGui is vendored as a submodule, compiled into Debug and Release, and -rendered by a small bgfx adapter in `uirender.cpp` that reuses the RmlUi +rendered by a small bgfx adapter in `rml/rmlrender.cpp` that reuses the RmlUi renderer's program and view setup, on `VIEW_DEV`, with its own vertex layout and straight-alpha blending, since ImGui's colours are not premultiplied. Its geometry travels in transient buffers every frame, and its textures follow the @@ -605,7 +600,7 @@ pinned version's contract: the renderer answers each create, update, and destroy request and acknowledges it. The glyph atlas is created empty and filled by updates, because bgfx makes a texture created with pixels immutable and the atlas grows as glyphs are first drawn. Its platform adapter in -`uidev.cpp` feeds it input through the shell hook ahead of the documents; the +`dev/uidev.cpp` feeds it input through the shell hook ahead of the documents; the default font is scaled by the frame's dp ratio. The context is created on the first toggle, so a build whose developer keys never arm allocates nothing. Overlays are armed by the developer-mode flags the manual documents; tool @@ -776,7 +771,7 @@ beyond an ASCII test document. `UIKeyboardPresenterClass` edits a copy of the hotkey table that OK saves and Cancel drops, where the Win32 procedure edited the game's table and reloaded the file on Cancel, and `keyboard.rml` captures a key through a - focusable element that `uikeys.cpp` turns back into the `KEYBOARD.INI` + focusable element that `rml/rmlkeys.cpp` turns back into the `KEYBOARD.INI` number; the options menu is `mainopt.rml`, placed where the main menu's buttons were; abort and surrender already run through the message box screen). The Win32 templates remain the fallback view of every one. The diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 3588a2a23..e089d0165 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -5,17 +5,17 @@ # point up. add_executable(UIShell "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uidisplay.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uigamectrl.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uikeyboard.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uikeys.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uimainopt.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uimsgbox.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uirmlview.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/display/uidisplay.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/gamectrl/uigamectrl.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/keyboard/uikeyboard.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/rml/rmlkeys.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/mainopt/uimainopt.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/msgbox/uimsgbox.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/rml/rmlview.cpp" "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uisound.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uiversion.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uiwaitbox.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/sound/uisound.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/version/uiversion.cpp" + "${CMAKE_SOURCE_DIR}/code/ui/screens/waitbox/uiwaitbox.cpp" ) target_compile_features(UIShell PRIVATE cxx_std_20) diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index dd57c84a1..adcb37e89 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -30,18 +30,18 @@ #include FT_FREETYPE_H #include +#include "ui/rml/rmlkeys.h" +#include "ui/rml/rmlview.h" +#include "ui/screens/display/uidisplay.h" +#include "ui/screens/gamectrl/uigamectrl.h" +#include "ui/screens/keyboard/uikeyboard.h" +#include "ui/screens/mainopt/uimainopt.h" +#include "ui/screens/msgbox/uimsgbox.h" +#include "ui/screens/sound/uisound.h" +#include "ui/screens/version/uiversion.h" +#include "ui/screens/waitbox/uiwaitbox.h" #include "ui/uicoord.h" -#include "ui/uidisplay.h" -#include "ui/uigamectrl.h" -#include "ui/uikeyboard.h" -#include "ui/uikeys.h" -#include "ui/uimainopt.h" -#include "ui/uimsgbox.h" -#include "ui/uirmlview.h" #include "ui/uiscreen.h" -#include "ui/uisound.h" -#include "ui/uiversion.h" -#include "ui/uiwaitbox.h" #include "opents_strings.h" From dd202e66e5c44d8594eb0424a0133bd8d3a2f0b1 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:13:41 +0300 Subject: [PATCH 20/52] Put the UI harness on opents_add_test and check header containment --- cmake/CheckToolkitHeaders.cmake | 50 +++++++++++++++++++++++++++ docs/UI_DESIGN.md | 3 ++ tests/CMakeLists.txt | 7 ++++ tests/uishell/CMakeLists.txt | 61 ++++++++++++--------------------- 4 files changed, 82 insertions(+), 39 deletions(-) create mode 100644 cmake/CheckToolkitHeaders.cmake diff --git a/cmake/CheckToolkitHeaders.cmake b/cmake/CheckToolkitHeaders.cmake new file mode 100644 index 000000000..08b402260 --- /dev/null +++ b/cmake/CheckToolkitHeaders.cmake @@ -0,0 +1,50 @@ +# Checks that no engine header outside code/ui/rml/ includes a UI toolkit or renderer header, +# and that no engine source outside code/ui/ includes RmlUi or Dear ImGui. docs/UI_DESIGN.md +# owns the rule; this script only enforces it. +# +# Expects OPENTS_SOURCE_DIR to be set. Run with `cmake -DOPENTS_SOURCE_DIR= -P`. + +if(NOT DEFINED OPENTS_SOURCE_DIR) + message(FATAL_ERROR "CheckToolkitHeaders.cmake: OPENTS_SOURCE_DIR is not set.") +endif() + +set(TOOLKIT_INCLUDE "^[ \t]*#[ \t]*include[ \t]*[<\"](RmlUi/|imgui|bgfx/|bx/|bimg/|stb_)") +set(UI_TOOLKIT_INCLUDE "^[ \t]*#[ \t]*include[ \t]*[<\"](RmlUi/|imgui)") + +set(violations "") + +file(GLOB_RECURSE headers RELATIVE "${OPENTS_SOURCE_DIR}" + "${OPENTS_SOURCE_DIR}/code/*.h" + "${OPENTS_SOURCE_DIR}/code/*.hh" + "${OPENTS_SOURCE_DIR}/code/*.hpp" +) +foreach(header IN LISTS headers) + if(header MATCHES "^code/ui/rml/") + continue() + endif() + file(STRINGS "${OPENTS_SOURCE_DIR}/${header}" hits REGEX "${TOOLKIT_INCLUDE}") + foreach(hit IN LISTS hits) + list(APPEND violations "${header}: ${hit}") + endforeach() +endforeach() + +file(GLOB_RECURSE sources RELATIVE "${OPENTS_SOURCE_DIR}" + "${OPENTS_SOURCE_DIR}/code/*.cpp" + "${OPENTS_SOURCE_DIR}/code/*.c" +) +foreach(source IN LISTS sources) + if(source MATCHES "^code/ui/") + continue() + endif() + file(STRINGS "${OPENTS_SOURCE_DIR}/${source}" hits REGEX "${UI_TOOLKIT_INCLUDE}") + foreach(hit IN LISTS hits) + list(APPEND violations "${source}: ${hit}") + endforeach() +endforeach() + +if(violations) + list(JOIN violations "\n " text) + message(FATAL_ERROR "Toolkit headers included outside their module:\n ${text}") +endif() + +message(STATUS "No toolkit header leaks outside code/ui/rml/.") diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index c101ff5c4..5c2588ec6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -165,6 +165,9 @@ Dependency rules: - Views use their toolkit directly. There is no shared widget API. - RmlUi data bindings and document nodes stay inside the RmlUi view. - Renderer handles stay inside `code/ui/rml/rmlrender.cpp`. +- Headers outside `code/ui/rml/` include no toolkit header, and sources + outside `code/ui/` include no RmlUi or ImGui header; the `toolkitheaders` + CTest check enforces both. - The shell knows which presentation owns a region and an input scope. It does not know production rules, save semantics, or option behavior. - Existing callers keep their screen functions; composition sits behind diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 463b45f74..d56b69caf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -107,3 +107,10 @@ add_subdirectory(zbufring) add_subdirectory(priorityqueue) add_subdirectory(save) add_subdirectory(uishell) + +# Only code/ui/rml/ headers may include a UI toolkit or the renderer; the script reads the +# sources directly, so the check needs no compiler and runs in every CI job's ctest step. +add_test(NAME toolkitheaders + COMMAND "${CMAKE_COMMAND}" -DOPENTS_SOURCE_DIR=${CMAKE_SOURCE_DIR} + -P "${CMAKE_SOURCE_DIR}/cmake/CheckToolkitHeaders.cmake" +) diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index e089d0165..a1ab5b621 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -1,42 +1,25 @@ # The harness links the vendored UI toolkits the way the engine does and loads the shipped # documents through a recording render interface, so a runtime library mismatch, a document -# that fails to parse or a style outside the implemented render methods fails here. It lives -# outside code/ so that the recursive glob building OpenTS cannot pick this target's entry -# point up. -add_executable(UIShell - "${CMAKE_CURRENT_SOURCE_DIR}/uishell.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/display/uidisplay.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/gamectrl/uigamectrl.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/keyboard/uikeyboard.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/rml/rmlkeys.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/mainopt/uimainopt.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/msgbox/uimsgbox.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/rml/rmlview.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/uiscreen.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/sound/uisound.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/version/uiversion.cpp" - "${CMAKE_SOURCE_DIR}/code/ui/screens/waitbox/uiwaitbox.cpp" +# that fails to parse or a style outside the implemented render methods fails here. The +# documents are read from the source tree, so it needs no run directory; the string-name +# table it resolves them with is generated per build by the stamp target. +opents_add_test(UIShell + NAME uishell + SOURCES uishell.cpp + ENGINE + ui/uiscreen.cpp + ui/rml/rmlkeys.cpp + ui/rml/rmlview.cpp + ui/screens/display/uidisplay.cpp + ui/screens/gamectrl/uigamectrl.cpp + ui/screens/keyboard/uikeyboard.cpp + ui/screens/mainopt/uimainopt.cpp + ui/screens/msgbox/uimsgbox.cpp + ui/screens/sound/uisound.cpp + ui/screens/version/uiversion.cpp + ui/screens/waitbox/uiwaitbox.cpp + INCLUDES "${OPENTS_GENERATED_DIR}" + DEFINITIONS WIN32 _WINDOWS NOMINMAX "OPENTS_UI_DIR=\"${CMAKE_SOURCE_DIR}/ui\"" + LIBRARIES RmlUi::Core freetype imgui + STAMP ) - -target_compile_features(UIShell PRIVATE cxx_std_20) - -target_include_directories(UIShell PRIVATE "${CMAKE_SOURCE_DIR}/code" "${OPENTS_GENERATED_DIR}") - -# The string-name table the shell resolves document references with is generated per build. -add_dependencies(UIShell OpenTSBuildStamp) - -# The documents are read from the source tree, so the test needs no run directory. -target_compile_definitions(UIShell PRIVATE WIN32 _WINDOWS NOMINMAX "OPENTS_UI_DIR=\"${CMAKE_SOURCE_DIR}/ui\"") - -target_compile_options(UIShell PRIVATE - $<$:/MTd /EHsc /Zc:__cplusplus> - $<$:/MT /EHsc /Zc:__cplusplus> -) - -target_link_libraries(UIShell PRIVATE RmlUi::Core freetype imgui kernel32 user32 shell32) - -set_target_properties(UIShell PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" -) - -add_test(NAME uishell COMMAND UIShell) From 59c03a179cdad9c31f79b15cf779a40f0d60a0a7 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:19:54 +0300 Subject: [PATCH 21/52] Reach the engine from the UI shell through a host interface --- code/ui/dev/uidev.cpp | 6 +- code/ui/dev/uidev.h | 8 +- code/ui/rml/rmlrender.h | 46 +++++--- code/ui/rml/rmlsystem.cpp | 26 +++-- code/ui/rml/rmlsystem.h | 11 +- code/ui/uienginehost.cpp | 176 ++++++++++++++++++++++++++++++ code/ui/uienginehost.h | 22 ++++ code/ui/uihost.h | 64 +++++++++++ code/ui/uishell.cpp | 221 +++++++++++++++++--------------------- code/ui/uishell.h | 13 ++- docs/UI_DESIGN.md | 2 +- 11 files changed, 435 insertions(+), 160 deletions(-) create mode 100644 code/ui/uienginehost.cpp create mode 100644 code/ui/uienginehost.h create mode 100644 code/ui/uihost.h diff --git a/code/ui/dev/uidev.cpp b/code/ui/dev/uidev.cpp index 35b191d59..790fdc130 100644 --- a/code/ui/dev/uidev.cpp +++ b/code/ui/dev/uidev.cpp @@ -395,7 +395,7 @@ bool UIDev_Active(void) } -void UIDev_Toggle(UIRmlBgfxRenderClass const & render) +void UIDev_Toggle(UIRmlRenderClass const & render) { if (_Context == NULL) { IMGUI_CHECKVERSION(); @@ -468,7 +468,7 @@ void UIDev_Tick(void) } -void UIDev_Render(UIRmlBgfxRenderClass & render) +void UIDev_Render(UIRmlRenderClass & render) { if (!UIDev_Active()) { return; @@ -478,7 +478,7 @@ void UIDev_Render(UIRmlBgfxRenderClass & render) } -void UIDev_Shutdown(UIRmlBgfxRenderClass & render) +void UIDev_Shutdown(UIRmlRenderClass & render) { if (_Context == NULL) { return; diff --git a/code/ui/dev/uidev.h b/code/ui/dev/uidev.h index 8045fcd57..1c563e3be 100644 --- a/code/ui/dev/uidev.h +++ b/code/ui/dev/uidev.h @@ -11,16 +11,16 @@ #include "win.h" -class UIRmlBgfxRenderClass; +class UIRmlRenderClass; // The Dear ImGui developer overlays. The context is created on the first toggle, so a // build whose developer keys never arm allocates nothing here. bool UIDev_Active(void); -void UIDev_Toggle(UIRmlBgfxRenderClass const & render); +void UIDev_Toggle(UIRmlRenderClass const & render); void UIDev_Tick(void); -void UIDev_Render(UIRmlBgfxRenderClass & render); -void UIDev_Shutdown(UIRmlBgfxRenderClass & render); +void UIDev_Render(UIRmlRenderClass & render); +void UIDev_Shutdown(UIRmlRenderClass & render); // Input reaches the overlays before the documents and the game. A true return means the // overlays want that message kept from both. diff --git a/code/ui/rml/rmlrender.h b/code/ui/rml/rmlrender.h index 526a91321..543993bf0 100644 --- a/code/ui/rml/rmlrender.h +++ b/code/ui/rml/rmlrender.h @@ -15,36 +15,54 @@ struct ImDrawData; struct ImTextureData; -// Draws RmlUi geometry and Dear ImGui frames through bgfx into the overlay views. Init -// needs the renderer running; Shutdown comes after Rml::Shutdown, which releases every -// texture and geometry through this object, and before the renderer stops. bgfx handles -// are kept as their indices so that no bgfx type appears here. -class UIRmlBgfxRenderClass : public Rml::RenderInterface +// The renderer the shell draws the documents and the developer overlays with. The engine's +// draws through bgfx; a test supplies one that records what it is asked. +class UIRmlRenderClass : public Rml::RenderInterface { public: - UIRmlBgfxRenderClass(void); + virtual ~UIRmlRenderClass(void) = default; - bool Init(void); - void Shutdown(void); + virtual bool Init(void) = 0; + virtual void Shutdown(void) = 0; // Points the document view at the frame's destination rectangle, in window pixels. - void Begin_Frame(int x, int y, int width, int height); + virtual void Begin_Frame(int x, int y, int width, int height) = 0; // Points the developer view at the same rectangle. - void Begin_Dev_Frame(int x, int y, int width, int height); + virtual void Begin_Dev_Frame(int x, int y, int width, int height) = 0; // Draws one Dear ImGui frame into the developer view, creating, updating and // destroying its textures as it asks. - void Render_ImGui(ImDrawData * data); + virtual void Render_ImGui(ImDrawData * data) = 0; // Destroys every texture Dear ImGui still holds. Called before its context goes. - void Destroy_ImGui_Textures(void); + virtual void Destroy_ImGui_Textures(void) = 0; // The largest texture edge the renderer accepts. - int Texture_Limit(void) const; + virtual int Texture_Limit(void) const = 0; // Writes the renderer's live texture and buffer counts to the debug log. - void Log_Resource_Counts(char const * when) const; + virtual void Log_Resource_Counts(char const * when) const = 0; +}; + + +// Draws RmlUi geometry and Dear ImGui frames through bgfx into the overlay views. Init +// needs the renderer running; Shutdown comes after Rml::Shutdown, which releases every +// texture and geometry through this object, and before the renderer stops. bgfx handles +// are kept as their indices so that no bgfx type appears here. +class UIRmlBgfxRenderClass : public UIRmlRenderClass +{ + public: + UIRmlBgfxRenderClass(void); + + virtual bool Init(void) override; + virtual void Shutdown(void) override; + virtual void Begin_Frame(int x, int y, int width, int height) override; + virtual void Begin_Dev_Frame(int x, int y, int width, int height) override; + virtual void Render_ImGui(ImDrawData * data) override; + virtual void Destroy_ImGui_Textures(void) override; + virtual int Texture_Limit(void) const override; + virtual void Log_Resource_Counts(char const * when) const override; virtual Rml::CompiledGeometryHandle CompileGeometry(Rml::Span vertices, Rml::Span indices) override; virtual void RenderGeometry(Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation, Rml::TextureHandle texture) override; diff --git a/code/ui/rml/rmlsystem.cpp b/code/ui/rml/rmlsystem.cpp index 6d0e284de..6e344dc54 100644 --- a/code/ui/rml/rmlsystem.cpp +++ b/code/ui/rml/rmlsystem.cpp @@ -9,17 +9,17 @@ #include "ui/rml/rmlsystem.h" -#include "data.h" -#include "dbgprint.h" -#include "win.h" +#include "ui/uihost.h" #include "opents_strings.h" +#include #include -UIRmlSystemClass::UIRmlSystemClass(void) : - StartTime(timeGetTime()) +UIRmlSystemClass::UIRmlSystemClass(UIShellHostClass & host) : + Host(host), + Start(std::chrono::steady_clock::now()) { } @@ -27,7 +27,7 @@ UIRmlSystemClass::UIRmlSystemClass(void) : // UI animation follows the wall clock, never the game's deterministic timers. double UIRmlSystemClass::GetElapsedTime(void) { - return((double)(timeGetTime() - StartTime) / 1000.0); + return(std::chrono::duration(std::chrono::steady_clock::now() - Start).count()); } @@ -58,7 +58,9 @@ bool UIRmlSystemClass::LogMessage(Rml::Log::Type type, Rml::String const & messa break; } - DebugString("UI %s: %s\n", level, message.c_str()); + char line[1024]; + std::snprintf(line, sizeof(line), "UI %s: %s\n", level, message.c_str()); + Host.Log(line); return(true); } @@ -84,8 +86,8 @@ static int String_Id(Rml::String const & name) // A document names an engine string as [[TXT_NAME]]. An unknown name stays as typed so that -// it shows where it was written. Fetch_String returns a pointer into a cache that later -// calls reuse, so the text is copied out at once. +// it shows where it was written. The host's string is valid only until its next call, so +// the text is copied out at once. int UIRmlSystemClass::TranslateString(Rml::String & translated, Rml::String const & input) { int count = 0; @@ -108,10 +110,12 @@ int UIRmlSystemClass::TranslateString(Rml::String & translated, Rml::String cons int id = String_Id(name); if (id >= 0) { - translated.append(Fetch_String(id)); + translated.append(Host.String(id)); count++; } else { - DebugString("UI: no string named %s\n", name.c_str()); + char line[256]; + std::snprintf(line, sizeof(line), "UI: no string named %s\n", name.c_str()); + Host.Log(line); translated.append(input, open, close + 2 - open); } diff --git a/code/ui/rml/rmlsystem.h b/code/ui/rml/rmlsystem.h index 74221fc7c..940ab0ced 100644 --- a/code/ui/rml/rmlsystem.h +++ b/code/ui/rml/rmlsystem.h @@ -11,12 +11,16 @@ #include +#include -// RmlUi's view of the engine's clock, debug log, resource naming and string table. +class UIShellHostClass; + + +// RmlUi's view of the wall clock, the host's log, resource naming and string table. class UIRmlSystemClass : public Rml::SystemInterface { public: - UIRmlSystemClass(void); + UIRmlSystemClass(UIShellHostClass & host); virtual double GetElapsedTime(void) override; virtual bool LogMessage(Rml::Log::Type type, Rml::String const & message) override; @@ -27,6 +31,7 @@ class UIRmlSystemClass : public Rml::SystemInterface int Error_Count(void) const { return(Errors); } private: - unsigned int StartTime; + UIShellHostClass & Host; + std::chrono::steady_clock::time_point Start; int Errors = 0; }; diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp new file mode 100644 index 000000000..3cb16757c --- /dev/null +++ b/code/ui/uienginehost.cpp @@ -0,0 +1,176 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uienginehost.h" + +#include "_keyboar.h" +#include "conquer.h" +#include "data.h" +#include "dbgprint.h" +#include "globals.h" +#include "goptions.h" +#include "keyboard.h" +#include "mainloop.h" +#include "movies.h" +#include "msgloop.h" +#include "session.h" +#include "ui/uishell.h" +#include "video.h" +#include "windlg.h" + + +class UIEngineHostClass : public UIShellHostClass +{ + public: + virtual HWND Main_Window(void) const override + { + return(MainWindow); + } + + virtual UIFrameRect Frame(void) const override + { + VideoScaleInfo const & scale = Video_Get_Scale_Info(); + UIFrameRect frame; + frame.X = scale.DestX; + frame.Y = scale.DestY; + frame.Width = scale.DestWidth; + frame.Height = scale.DestHeight; + frame.ScaleX = scale.ScaleX; + frame.ScaleY = scale.ScaleY; + return(frame); + } + + virtual void Mark_Overlay_Dirty(void) override + { + Video_Mark_Overlay_Dirty(); + } + + virtual void Present_If_Dirty(void) override + { + Video_Present_If_Dirty(); + } + + virtual bool Movie_Playing(void) const override + { + return(Movie_Is_Playing()); + } + + virtual bool Legacy_Dialog_Visible(void) const override + { + for (int index = 0; index < g_DialogCount; index++) { + if (g_Dialogs[index].handle != NULL && IsWindowVisible(g_Dialogs[index].handle)) { + return(true); + } + } + return(Any_Modeless_Dialog_Visible()); + } + + virtual bool Legacy_Dialogs_Requested(void) const override + { + return(Options.LegacyDialogs); + } + + virtual bool Developer_Keys_Armed(void) const override + { + return(Debug_Flag); + } + + virtual void Clear_Keyboard_Queue(void) override + { + Keyboard->Clear(); + } + + virtual void Focus_Main_Window(void) override + { + SetFocus(MainWindow); + } + + virtual bool Take_Capture(void) override + { + if (GetCapture() == MainWindow) { + return(false); + } + SetCapture(MainWindow); + return(true); + } + + virtual void Release_Capture(void) override + { + if (GetCapture() == MainWindow) { + ReleaseCapture(); + } + } + + virtual bool Screen_To_Client(int & x, int & y) const override + { + POINT point; + point.x = x; + point.y = y; + if (!ScreenToClient(MainWindow, &point)) { + return(false); + } + x = point.x; + y = point.y; + return(true); + } + + virtual char const * String(int id) const override + { + return(Fetch_String(id)); + } + + virtual void Log(char const * text) override + { + DebugString("%s", text); + } +}; + + +static UIEngineHostClass _Host; + + +UIShellHostClass & UI_Engine_Host(void) +{ + return(_Host); +} + + +// The service pass of OwnerDraw::Dialog_Message_Handler without its tick: the runner ticks +// itself so that it can drain the screen's intents between the update and the present. +bool UI_Service_Game(void) +{ + static bool inmainloop = false; + + Windows_Message_Handler(); + + if (Session.Type != GAME_NORMAL && Session.Type != GAME_SKIRMISH && !Session.NetOpen && !Session.Suspended) { + if (!inmainloop) { + inmainloop = true; + bool ended = Main_Loop(); + inmainloop = false; + return(ended); + } + } else { + Call_Back(); + } + + return(false); +} + + +bool UI_Init(void) +{ + return(UI_Init(_Host)); +} + + +UIResult UI_Run_Modal(UIRmlViewClass & view) +{ + return(UI_Run_Modal(view, UI_Service_Game)); +} diff --git a/code/ui/uienginehost.h b/code/ui/uienginehost.h new file mode 100644 index 000000000..e9438034c --- /dev/null +++ b/code/ui/uienginehost.h @@ -0,0 +1,22 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The engine's side of the shell: its host over the window, video, keyboard, options and +// dialogs, and the service pass a modal screen runs the game with. + +#pragma once + +#include "ui/uihost.h" + + +UIShellHostClass & UI_Engine_Host(void); + +// One pass of the game under a modal screen: the message pump, then Main_Loop in a network +// session or Call_Back otherwise. True when the game ended. +bool UI_Service_Game(void); diff --git a/code/ui/uihost.h b/code/ui/uihost.h new file mode 100644 index 000000000..8271f1b4c --- /dev/null +++ b/code/ui/uihost.h @@ -0,0 +1,64 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// What the shell needs from the program around it. The engine supplies its window, frame, +// keyboard queue and dialogs; a test supplies a host it controls by hand. + +#pragma once + +#include "win.h" + + +// Where the game's frame lands in the client area, in client pixels, and how many of them +// one game pixel spans. +struct UIFrameRect +{ + int X; + int Y; + int Width; + int Height; + float ScaleX; + float ScaleY; +}; + + +class UIShellHostClass +{ + public: + virtual ~UIShellHostClass(void) = default; + + virtual HWND Main_Window(void) const = 0; + virtual UIFrameRect Frame(void) const = 0; + + // The overlay changed and should be drawn at the next present. + virtual void Mark_Overlay_Dirty(void) = 0; + virtual void Present_If_Dirty(void) = 0; + virtual bool Movie_Playing(void) const = 0; + + // A Win32 dialog is on screen and takes the mouse before a document can. + virtual bool Legacy_Dialog_Visible(void) const = 0; + // The player asked for the Win32 dialogs instead of the documents. + virtual bool Legacy_Dialogs_Requested(void) const = 0; + virtual bool Developer_Keys_Armed(void) const = 0; + + // Drops the queued keys and mouse events. The engine's implementation pumps the + // window messages to do so, which re-enters the shell. + virtual void Clear_Keyboard_Queue(void) = 0; + virtual void Focus_Main_Window(void) = 0; + + // Takes the mouse capture for the main window. False when it already held it, so + // the caller knows not to release what it did not take. + virtual bool Take_Capture(void) = 0; + virtual void Release_Capture(void) = 0; + virtual bool Screen_To_Client(int & x, int & y) const = 0; + + // An engine string by identifier. The result is valid until the next call. + virtual char const * String(int id) const = 0; + virtual void Log(char const * text) = 0; +}; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 5655f5c44..9c1630201 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -9,16 +9,6 @@ #include "ui/uishell.h" -#include "_keyboar.h" -#include "conquer.h" -#include "dbgprint.h" -#include "globals.h" -#include "goptions.h" -#include "keyboard.h" -#include "mainloop.h" -#include "movies.h" -#include "msgloop.h" -#include "session.h" #include "ui/dev/uidev.h" #include "ui/rml/rmlfile.h" #include "ui/rml/rmlkeys.h" @@ -26,8 +16,7 @@ #include "ui/rml/rmlsystem.h" #include "ui/rml/rmlview.h" #include "ui/uicoord.h" -#include "video.h" -#include "windlg.h" +#include "ui/uihost.h" // windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its // element walkers. @@ -37,11 +26,15 @@ #include #include +#include #include +#include +static UIShellHostClass * _Host = nullptr; + // The interfaces outlive Rml::Shutdown, which releases every resource through them. -static UIRmlSystemClass _System; +static std::unique_ptr _System; static UIRmlFileClass _File; static UIRmlBgfxRenderClass _Render; @@ -97,6 +90,19 @@ static UITestListenerClass _TestListener; #endif +static void Log(char const * format, ...) +{ + char buffer[512]; + va_list args; + + va_start(args, format); + std::vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + + _Host->Log(buffer); +} + + static int Key_Modifiers(void) { int modifiers = 0; @@ -152,22 +158,22 @@ static bool Text_Input_Focused(void) static void Apply_Dimensions(void) { - VideoScaleInfo const & scale = Video_Get_Scale_Info(); + UIFrameRect frame = _Host->Frame(); - float ratio = scale.ScaleX < scale.ScaleY ? scale.ScaleX : scale.ScaleY; + float ratio = frame.ScaleX < frame.ScaleY ? frame.ScaleX : frame.ScaleY; if (ratio <= 0.0f) { ratio = 1.0f; } - _Context->SetDimensions(Rml::Vector2i(scale.DestWidth, scale.DestHeight)); + _Context->SetDimensions(Rml::Vector2i(frame.Width, frame.Height)); _Context->SetDensityIndependentPixelRatio(ratio); } static UIPointerPosition Pointer_Position(LPARAM clientlparam) { - VideoScaleInfo const & scale = Video_Get_Scale_Info(); - return(UI_Client_To_Overlay(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight, GET_X_LPARAM(clientlparam), GET_Y_LPARAM(clientlparam))); + UIFrameRect frame = _Host->Frame(); + return(UI_Client_To_Overlay(frame.X, frame.Y, frame.Width, frame.Height, GET_X_LPARAM(clientlparam), GET_Y_LPARAM(clientlparam))); } @@ -190,9 +196,7 @@ static void Drop_Presses(void) if (_TookCapture) { _TookCapture = false; - if (GetCapture() == MainWindow) { - ReleaseCapture(); - } + _Host->Release_Capture(); } } @@ -202,14 +206,14 @@ static void Drop_Presses(void) static void Toggle_Test_Document(void) { if (!_FontLoaded) { - DebugString("UI: the test document needs the font, which did not load\n"); + Log("UI: the test document needs the font, which did not load\n"); return; } if (_TestDocument == NULL) { _TestDocument = _Context->LoadDocument("test.rml"); if (_TestDocument == NULL) { - DebugString("UI: test.rml did not load\n"); + Log("UI: test.rml did not load\n"); return; } @@ -228,38 +232,44 @@ static void Toggle_Test_Document(void) _Render.Log_Resource_Counts("test document shown"); } - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); } #endif -bool UI_Init(void) +bool UI_Init(UIShellHostClass & host) { if (_Ready) { return(true); } + _Host = &host; + if (!_Render.Init()) { return(false); } - Rml::SetSystemInterface(&_System); + _System = std::make_unique(host); + + Rml::SetSystemInterface(_System.get()); Rml::SetFileInterface(&_File); Rml::SetRenderInterface(&_Render); if (!Rml::Initialise()) { - DebugString("UI: RmlUi did not initialise\n"); + Log("UI: RmlUi did not initialise\n"); _Render.Shutdown(); + _System.reset(); return(false); } - VideoScaleInfo const & scale = Video_Get_Scale_Info(); - _Context = Rml::CreateContext("main", Rml::Vector2i(scale.DestWidth, scale.DestHeight)); + UIFrameRect frame = host.Frame(); + _Context = Rml::CreateContext("main", Rml::Vector2i(frame.Width, frame.Height)); if (_Context == NULL) { - DebugString("UI: the context could not be created\n"); + Log("UI: the context could not be created\n"); Rml::Shutdown(); _Render.Shutdown(); + _System.reset(); return(false); } @@ -267,12 +277,12 @@ bool UI_Init(void) _FontLoaded = Rml::LoadFontFace("OpenSans.ttf"); if (!_FontLoaded) { - DebugString("UI: OpenSans.ttf did not load, so no document can be shown\n"); + Log("UI: OpenSans.ttf did not load, so no document can be shown\n"); } _Ready = true; - DebugString("UI: RmlUi %s ready over a %dx%d frame at %.2f pixels per dp\n", - Rml::GetVersion().c_str(), scale.DestWidth, scale.DestHeight, _Context->GetDensityIndependentPixelRatio()); + Log("UI: RmlUi %s ready over a %dx%d frame at %.2f pixels per dp\n", + Rml::GetVersion().c_str(), frame.Width, frame.Height, _Context->GetDensityIndependentPixelRatio()); return(true); } @@ -305,13 +315,14 @@ void UI_Shutdown(void) Rml::Shutdown(); _Render.Shutdown(); + _System.reset(); _FontLoaded = false; } bool UI_Use_Rml(void) { - return(_Ready && !Options.LegacyDialogs); + return(_Ready && !_Host->Legacy_Dialogs_Requested()); } @@ -333,7 +344,7 @@ void UI_On_Video_Change(void) Apply_Dimensions(); } - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); } @@ -353,14 +364,14 @@ void UI_Tick(void) if (_PendingDevToggle) { _PendingDevToggle = false; UIDev_Toggle(_Render); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); } if (_CloseRequested) { _CloseRequested = false; if (_TestDocument != NULL && _TestDocument->IsVisible()) { _TestDocument->Hide(); _Render.Log_Resource_Counts("test document closed"); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); } } #endif @@ -392,7 +403,7 @@ void UI_Tick(void) static bool devwasactive = false; bool devactive = UIDev_Active(); if (Documents_Visible() || devactive || devwasactive) { - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); } devwasactive = devactive; @@ -402,7 +413,7 @@ void UI_Tick(void) void UI_Render_Overlay(void) { - if (!_Ready || _InContext || Movie_Is_Playing()) { + if (!_Ready || _InContext || _Host->Movie_Playing()) { return; } @@ -412,13 +423,13 @@ void UI_Render_Overlay(void) return; } - VideoScaleInfo const & scale = Video_Get_Scale_Info(); - if (scale.DestWidth <= 0 || scale.DestHeight <= 0) { + UIFrameRect frame = _Host->Frame(); + if (frame.Width <= 0 || frame.Height <= 0) { return; } if (documents) { - _Render.Begin_Frame(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight); + _Render.Begin_Frame(frame.X, frame.Y, frame.Width, frame.Height); _InContext = true; _Context->Render(); @@ -426,7 +437,7 @@ void UI_Render_Overlay(void) } if (overlays) { - _Render.Begin_Dev_Frame(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight); + _Render.Begin_Dev_Frame(frame.X, frame.Y, frame.Width, frame.Height); UIDev_Render(_Render); } } @@ -438,18 +449,18 @@ static bool Handle_Mouse_Move(LPARAM clientlparam) UIDev_Mouse_Position(position.X, position.Y); if (UIDev_Wants_Mouse()) { - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(false); } if (_OwnedButtons != 0 || position.Inside) { _Context->ProcessMouseMove(position.X, position.Y, Key_Modifiers()); _MouseInside = position.Inside; - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); } else if (_MouseInside) { _Context->ProcessMouseLeave(); _MouseInside = false; - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); } return(false); @@ -459,10 +470,7 @@ static bool Handle_Mouse_Move(LPARAM clientlparam) static void Own_Press(int button) { if (_OwnedButtons == 0) { - _TookCapture = (GetCapture() != MainWindow); - if (_TookCapture) { - SetCapture(MainWindow); - } + _TookCapture = _Host->Take_Capture(); } _OwnedButtons |= (1u << button); } @@ -474,9 +482,7 @@ static void Release_Press(int button) _DevOwnedButtons &= ~(1u << button); if (_OwnedButtons == 0 && _TookCapture) { _TookCapture = false; - if (GetCapture() == MainWindow) { - ReleaseCapture(); - } + _Host->Release_Capture(); } } @@ -490,7 +496,7 @@ static bool Handle_Button_Down(int button, LPARAM clientlparam) if (UIDev_Mouse_Button(button, true)) { Own_Press(button); _DevOwnedButtons |= (1u << button); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(true); } } @@ -504,7 +510,7 @@ static bool Handle_Button_Down(int button, LPARAM clientlparam) _MouseInside = position.Inside; bool interacting = !_Context->ProcessMouseButtonDown(button, modifiers); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); if (!interacting) { return(false); @@ -523,7 +529,7 @@ static bool Handle_Button_Up(int button, LPARAM clientlparam) UIDev_Mouse_Position(position.X, position.Y); UIDev_Mouse_Button(button, false); Release_Press(button); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(true); } @@ -541,7 +547,7 @@ static bool Handle_Button_Up(int button, LPARAM clientlparam) _Context->ProcessMouseMove(position.X, position.Y, modifiers); _Context->ProcessMouseButtonUp(button, modifiers); _MouseInside = position.Inside; - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); Release_Press(button); return(true); @@ -550,13 +556,12 @@ static bool Handle_Button_Up(int button, LPARAM clientlparam) static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) { - POINT point; - point.x = GET_X_LPARAM(screenlparam); - point.y = GET_Y_LPARAM(screenlparam); - ScreenToClient(MainWindow, &point); + int x = GET_X_LPARAM(screenlparam); + int y = GET_Y_LPARAM(screenlparam); + _Host->Screen_To_Client(x, y); - VideoScaleInfo const & scale = Video_Get_Scale_Info(); - UIPointerPosition position = UI_Client_To_Overlay(scale.DestX, scale.DestY, scale.DestWidth, scale.DestHeight, point.x, point.y); + UIFrameRect frame = _Host->Frame(); + UIPointerPosition position = UI_Client_To_Overlay(frame.X, frame.Y, frame.Width, frame.Height, x, y); // Windows counts wheel movement away from the user as positive; ImGui scrolls up for it // and RmlUi scrolls down. @@ -565,7 +570,7 @@ static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) if (UIDev_Active()) { UIDev_Mouse_Position(position.X, position.Y); if (UIDev_Mouse_Wheel(delta)) { - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(true); } } @@ -575,7 +580,7 @@ static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) } bool consumed = !_Context->ProcessMouseWheel(Rml::Vector2f(0.0f, -delta), Key_Modifiers()); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(consumed); } @@ -583,7 +588,7 @@ static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) static bool Handle_Key(UINT message, WPARAM wparam) { if (UIDev_Key(wparam, message == WM_KEYDOWN)) { - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(true); } @@ -599,7 +604,7 @@ static bool Handle_Key(UINT message, WPARAM wparam) propagated = _Context->ProcessKeyUp(key, Key_Modifiers()); } - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(!propagated || Text_Input_Focused()); } @@ -611,7 +616,7 @@ static bool Handle_Char(WPARAM wparam) wchar_t unit = (wchar_t)wparam; if (UIDev_Character(unit)) { - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(true); } @@ -634,19 +639,14 @@ static bool Handle_Char(WPARAM wparam) } bool consumed = !_Context->ProcessTextInput((Rml::Character)code); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); return(consumed); } bool UI_Legacy_Dialog_Visible(void) { - for (int index = 0; index < g_DialogCount; index++) { - if (g_Dialogs[index].handle != NULL && IsWindowVisible(g_Dialogs[index].handle)) { - return(true); - } - } - return(Any_Modeless_Dialog_Visible()); + return(_Host != nullptr && _Host->Legacy_Dialog_Visible()); } @@ -676,36 +676,13 @@ static bool Input_Message(UINT message) } -// The service pass of OwnerDraw::Dialog_Message_Handler without its tick: the runner ticks -// itself so that it can drain the screen's intents between the update and the present. -static bool Service_Game(void) -{ - static bool inmainloop = false; - - Windows_Message_Handler(); - - if (Session.Type != GAME_NORMAL && Session.Type != GAME_SKIRMISH && !Session.NetOpen && !Session.Suspended) { - if (!inmainloop) { - inmainloop = true; - bool ended = Main_Loop(); - inmainloop = false; - return(ended); - } - } else { - Call_Back(); - } - - return(false); -} - - -UIResult UI_Run_Modal(UIRmlViewClass & view) +UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) { if (!_Ready) { return(UI_RESULT_FAILED_TO_OPEN); } if (!_FontLoaded) { - DebugString("UI: %s needs OpenSans.ttf, which did not load\n", view.Document_Name()); + Log("UI: %s needs OpenSans.ttf, which did not load\n", view.Document_Name()); return(UI_RESULT_FAILED_TO_OPEN); } @@ -713,9 +690,9 @@ UIResult UI_Run_Modal(UIRmlViewClass & view) assert(!UI_Legacy_Dialog_Visible()); // A style sheet that fails to load leaves the document usable and is reported as an error. - int errors = _System.Error_Count(); - if (!view.Prepare(*_Context) || _System.Error_Count() != errors) { - DebugString("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); + int errors = _System->Error_Count(); + if (!view.Prepare(*_Context) || _System->Error_Count() != errors) { + Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); view.Release(); return(UI_RESULT_FAILED_TO_OPEN); } @@ -732,15 +709,15 @@ UIResult UI_Run_Modal(UIRmlViewClass & view) _Modal = &view; view.Show(true); - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); std::snprintf(label, sizeof(label), "%s shown", view.Document_Name()); _Render.Log_Resource_Counts(label); - Keyboard->Clear(); + _Host->Clear_Keyboard_Queue(); UIResult result = UI_RESULT_SESSION_ENDED; while (true) { - bool ended = Service_Game(); + bool ended = service(); if (!_Ready) { break; } @@ -758,8 +735,8 @@ UIResult UI_Run_Modal(UIRmlViewClass & view) break; } - Video_Mark_Overlay_Dirty(); - Video_Present_If_Dirty(); + _Host->Mark_Overlay_Dirty(); + _Host->Present_If_Dirty(); } _ModalClosing = true; @@ -779,11 +756,11 @@ UIResult UI_Run_Modal(UIRmlViewClass & view) _ModalClosing = false; if (_Ready) { - Video_Mark_Overlay_Dirty(); + _Host->Mark_Overlay_Dirty(); std::snprintf(label, sizeof(label), "%s closed", view.Document_Name()); _Render.Log_Resource_Counts(label); - Keyboard->Clear(); - SetFocus(MainWindow); + _Host->Clear_Keyboard_Queue(); + _Host->Focus_Main_Window(); } return(result); @@ -796,9 +773,9 @@ bool UI_Show_Modeless(UIRmlViewClass & view) return(false); } - int errors = _System.Error_Count(); - if (!view.Prepare(*_Context) || _System.Error_Count() != errors) { - DebugString("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); + int errors = _System->Error_Count(); + if (!view.Prepare(*_Context) || _System->Error_Count() != errors) { + Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); view.Release(); return(false); } @@ -819,8 +796,8 @@ void UI_Hide_Modeless(UIRmlViewClass & view) _InContext = true; _Context->Update(); _InContext = false; - Video_Mark_Overlay_Dirty(); - Video_Present_If_Dirty(); + _Host->Mark_Overlay_Dirty(); + _Host->Present_If_Dirty(); } } @@ -855,20 +832,20 @@ void UI_Refresh(void) } UI_Tick(); - Video_Mark_Overlay_Dirty(); - Video_Present_If_Dirty(); + _Host->Mark_Overlay_Dirty(); + _Host->Present_If_Dirty(); } bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) { - if (!_Ready || _InHook || hwnd != MainWindow) { + if (!_Ready || _InHook || hwnd != _Host->Main_Window()) { return(false); } // Another window taking the capture ends the presses the shell owns. if (message == WM_CAPTURECHANGED) { - if (_OwnedButtons != 0 && (HWND)clientlparam != MainWindow) { + if (_OwnedButtons != 0 && (HWND)clientlparam != _Host->Main_Window()) { _TookCapture = false; if (_InContext) { _PendingRelease = true; @@ -974,13 +951,13 @@ bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM cli bool UI_Intercept_Pumped_Message(MSG const & msg) { #ifdef _DEBUG - if (_Ready && Debug_Flag && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F9) { + if (_Ready && _Host->Developer_Keys_Armed() && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F9) { if (msg.message == WM_KEYDOWN && (msg.lParam & (1 << 30)) == 0) { _PendingToggle = true; } return(true); } - if (_Ready && Debug_Flag && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F6) { + if (_Ready && _Host->Developer_Keys_Armed() && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F6) { if (msg.message == WM_KEYDOWN && (msg.lParam & (1 << 30)) == 0) { _PendingDevToggle = true; } diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 2ca95c052..92d9d4399 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -15,12 +15,19 @@ #include "ui/uiscreen.h" #include "win.h" +#include + class UIRmlViewClass; +class UIShellHostClass; + +// Runs the game for one pass under a modal screen and reports whether it ended. +using UIServiceCallback = std::function; // Needs the window, the renderer and the file search chain. A false return leaves every -// other entry point inert. +// other entry point inert. The engine passes its own host; a test passes one it controls. bool UI_Init(void); +bool UI_Init(UIShellHostClass & host); void UI_Shutdown(void); // True when a migrated screen should open its RmlUi view rather than its Win32 dialog. A @@ -35,8 +42,10 @@ bool UI_Screen_Shown(void); bool UI_Legacy_Dialog_Visible(void); // Prepares, shows and drives a modal screen until its presenter reports a result or the game -// ends, then releases it. The view's presenter must outlive the call. +// ends, then releases it. The view's presenter must outlive the call. The engine's entry +// services the game each pass; a test supplies the service it wants. UIResult UI_Run_Modal(UIRmlViewClass & view); +UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service); // Shows a document beside the game without taking its input: a notice the caller updates // while it works. It is drawn at once, because such a caller pumps nothing. False when the diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 5c2588ec6..6a17a8cb0 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -188,7 +188,7 @@ rule the tree follows, not a build boundary. | Directory | Holds | Status | | --- | --- | --- | | `code/` | `bgfxviews.hh`, the view ids the presenter and the overlays share | landed | -| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | +| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | | `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging to `DebugString`, string translation; cursor and clipboard wait for the first editable screen), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base) | landed | | `code/ui/dev/` | `uidev.h`, `uidev.cpp`: the ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | | `code/ui/screens//` | one family each for `version`, `msgbox`, `waitbox`, `sound`, `gamectrl`, `display`, `keyboard`, `mainopt`: `ui.h` (presenter, service and state declarations, view factory, engine entry), `ui.cpp` (presenter and RmlUi view; built into the test), `uidlg.cpp` (engine service and entry, which the test cannot link) | landed; the sound, game controls, keyboard and display Win32 dialogs drive the same presenter as a second view, and the wait box family carries the `UIWaitBoxClass` the save, load and progress code shows | From 02833dbe125e07f5d90c5d13730b9d69d8671898 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:27:05 +0300 Subject: [PATCH 22/52] Hold the UI shell's state in one UIShellClass instance --- code/_ui.cpp | 29 + code/_ui.h | 14 + code/ui/uienginehost.cpp | 14 +- code/ui/uishell.cpp | 881 +++++++++++++++++------------- code/ui/uishell.h | 202 +++++-- docs/UI_DESIGN.md | 4 +- manual/data/command-adapters.yaml | 4 +- 7 files changed, 707 insertions(+), 441 deletions(-) create mode 100644 code/_ui.cpp create mode 100644 code/_ui.h diff --git a/code/_ui.cpp b/code/_ui.cpp new file mode 100644 index 000000000..fbed65c5d --- /dev/null +++ b/code/_ui.cpp @@ -0,0 +1,29 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "always.h" + +#include "_ui.h" + +#include "ui/rml/rmlfile.h" +#include "ui/rml/rmlrender.h" +#include "ui/rml/rmlsystem.h" +#include "ui/uienginehost.h" +#include "ui/uishell.h" + +#include + + +// The one UI shell, over the engine's window, renderer and file search chain. Its +// constructor stores what it is given and touches none of it, so the order in which the +// program's globals are built does not matter to it. +UIShellClass UIShell(UI_Engine_Host(), + std::make_unique(UI_Engine_Host()), + std::make_unique(), + std::make_unique()); diff --git a/code/_ui.h b/code/_ui.h new file mode 100644 index 000000000..5040845e1 --- /dev/null +++ b/code/_ui.h @@ -0,0 +1,14 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +class UIShellClass; + +extern UIShellClass UIShell; diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index 3cb16757c..345c0990f 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -132,12 +132,12 @@ class UIEngineHostClass : public UIShellHostClass }; -static UIEngineHostClass _Host; - - +// Built on first use, so the shell's global can take it whatever the order of static +// construction. UIShellHostClass & UI_Engine_Host(void) { - return(_Host); + static UIEngineHostClass host; + return(host); } @@ -164,12 +164,6 @@ bool UI_Service_Game(void) } -bool UI_Init(void) -{ - return(UI_Init(_Host)); -} - - UIResult UI_Run_Modal(UIRmlViewClass & view) { return(UI_Run_Modal(view, UI_Service_Game)); diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 9c1630201..44c4222e1 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -9,13 +9,12 @@ #include "ui/uishell.h" +#include "_ui.h" #include "ui/dev/uidev.h" -#include "ui/rml/rmlfile.h" #include "ui/rml/rmlkeys.h" #include "ui/rml/rmlrender.h" #include "ui/rml/rmlsystem.h" #include "ui/rml/rmlview.h" -#include "ui/uicoord.h" #include "ui/uihost.h" // windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its @@ -25,85 +24,49 @@ #include +#include #include #include #include -#include -static UIShellHostClass * _Host = nullptr; - -// The interfaces outlive Rml::Shutdown, which releases every resource through them. -static std::unique_ptr _System; -static UIRmlFileClass _File; -static UIRmlBgfxRenderClass _Render; - -static Rml::Context * _Context = NULL; -static bool _Ready = false; -static bool _FontLoaded = false; - -// Set while the context updates or renders. Work that arrives then waits for the next tick. -static bool _InContext = false; -static bool _InHook = false; -static bool _InTick = false; - -static bool _PendingResize = false; -static bool _PendingLeave = false; -static bool _PendingRelease = false; -static int _PendingDevFocus = -1; +namespace +{ -// The presses the shell consumed, as a mask over the mouse button indices, and whether it -// took the window's capture for them. Their releases belong to the shell wherever they land. -// The developer overlays' own presses are a subset that their release goes back to. -static unsigned int _OwnedButtons = 0; -static unsigned int _DevOwnedButtons = 0; -static bool _TookCapture = false; -static bool _MouseInside = false; +// Sets a flag for the scope, whichever way the scope ends. +class UIReentryGuardClass +{ + public: + explicit UIReentryGuardClass(bool & flag) : + Flag(flag) + { + Flag = true; + } -// The modal screen the runner is driving, and whether it is between releasing its document -// and handing the input back. -static UIRmlViewClass * _Modal = NULL; -static bool _ModalClosing = false; + ~UIReentryGuardClass(void) + { + Flag = false; + } -static wchar_t _HighSurrogate = 0; + UIReentryGuardClass(UIReentryGuardClass const &) = delete; + UIReentryGuardClass & operator=(UIReentryGuardClass const &) = delete; -#ifdef _DEBUG + private: + bool & Flag; +}; -// The test document is a developer's check of the shell; F9 shows and hides it, and F6 the -// developer overlays. -static Rml::ElementDocument * _TestDocument = NULL; -static bool _PendingToggle = false; -static bool _PendingDevToggle = false; -static bool _CloseRequested = false; -class UITestListenerClass : public Rml::EventListener +class UISystemClockClass : public UIClockClass { public: - virtual void ProcessEvent(Rml::Event &) override + virtual int Milliseconds(void) override { - _CloseRequested = true; + return((int)GetTickCount64()); } }; -static UITestListenerClass _TestListener; - -#endif - - -static void Log(char const * format, ...) -{ - char buffer[512]; - va_list args; - - va_start(args, format); - std::vsnprintf(buffer, sizeof(buffer), format, args); - va_end(args); - _Host->Log(buffer); -} - - -static int Key_Modifiers(void) +int Key_Modifiers(void) { int modifiers = 0; @@ -127,15 +90,92 @@ static int Key_Modifiers(void) } -static bool Documents_Visible(void) +// The mouse, wheel, key and text messages a shown screen takes whole. +bool Input_Message(UINT message) +{ + switch (message) { + case WM_MOUSEMOVE: + case WM_LBUTTONDOWN: + case WM_LBUTTONDBLCLK: + case WM_RBUTTONDOWN: + case WM_RBUTTONDBLCLK: + case WM_MBUTTONDOWN: + case WM_MBUTTONDBLCLK: + case WM_LBUTTONUP: + case WM_RBUTTONUP: + case WM_MBUTTONUP: + case WM_MOUSEWHEEL: + case WM_KEYDOWN: + case WM_KEYUP: + case WM_CHAR: + return(true); + + default: + return(false); + } +} + +} + + +// The test document is a developer's check of the shell; F9 shows and hides it, and F6 the +// developer overlays. Its close button asks the shell to hide it at the next tick. +class UITestListenerClass : public Rml::EventListener +{ + public: + explicit UITestListenerClass(UIShellClass & shell) : + Shell(shell) + { + } + + virtual void ProcessEvent(Rml::Event &) override + { + Shell.Deferred.CloseTest = true; + } + + private: + UIShellClass & Shell; +}; + + +UIShellClass::UIShellClass(UIShellHostClass & host, std::unique_ptr system, std::unique_ptr file, std::unique_ptr render) : + Host(host), + System(std::move(system)), + File(std::move(file)), + Render(std::move(render)) +{ +} + + +// A shell still ready at destruction leaks its resources rather than touch a renderer that +// may already be gone. +UIShellClass::~UIShellClass(void) +{ +} + + +void UIShellClass::Log(char const * format, ...) +{ + char buffer[512]; + va_list args; + + va_start(args, format); + std::vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + + Host.Log(buffer); +} + + +bool UIShellClass::Documents_Visible(void) const { - if (_Context == NULL) { + if (Context == nullptr) { return(false); } - for (int index = 0; index < _Context->GetNumDocuments(); index++) { - Rml::ElementDocument * document = _Context->GetDocument(index); - if (document != NULL && document->IsVisible()) { + for (int index = 0; index < Context->GetNumDocuments(); index++) { + Rml::ElementDocument * document = Context->GetDocument(index); + if (document != nullptr && document->IsVisible()) { return(true); } } @@ -144,10 +184,10 @@ static bool Documents_Visible(void) } -static bool Text_Input_Focused(void) +bool UIShellClass::Text_Input_Focused(void) const { - Rml::Element * focus = _Context->GetFocusElement(); - if (focus == NULL) { + Rml::Element * focus = Context->GetFocusElement(); + if (focus == nullptr) { return(false); } @@ -156,264 +196,287 @@ static bool Text_Input_Focused(void) } -static void Apply_Dimensions(void) +void UIShellClass::Apply_Dimensions(void) { - UIFrameRect frame = _Host->Frame(); + UIFrameRect frame = Host.Frame(); float ratio = frame.ScaleX < frame.ScaleY ? frame.ScaleX : frame.ScaleY; if (ratio <= 0.0f) { ratio = 1.0f; } - _Context->SetDimensions(Rml::Vector2i(frame.Width, frame.Height)); - _Context->SetDensityIndependentPixelRatio(ratio); + Context->SetDimensions(Rml::Vector2i(frame.Width, frame.Height)); + Context->SetDensityIndependentPixelRatio(ratio); } -static UIPointerPosition Pointer_Position(LPARAM clientlparam) +UIPointerPosition UIShellClass::Pointer_Position(LPARAM clientlparam) const { - UIFrameRect frame = _Host->Frame(); + UIFrameRect frame = Host.Frame(); return(UI_Client_To_Overlay(frame.X, frame.Y, frame.Width, frame.Height, GET_X_LPARAM(clientlparam), GET_Y_LPARAM(clientlparam))); } // Forgets the presses the shell owns, telling the documents they ended, and gives the // capture back when the shell took it. -static void Drop_Presses(void) +void UIShellClass::Drop_Presses(void) { - unsigned int owned = _OwnedButtons; - unsigned int devowned = _DevOwnedButtons; - _OwnedButtons = 0; - _DevOwnedButtons = 0; + unsigned int owned = OwnedButtons; + unsigned int devowned = DevOwnedButtons; + OwnedButtons = 0; + DevOwnedButtons = 0; for (int button = 0; button < 3; button++) { if (devowned & (1u << button)) { UIDev_Mouse_Button(button, false); } else if (owned & (1u << button)) { - _Context->ProcessMouseButtonUp(button, Key_Modifiers()); + Context->ProcessMouseButtonUp(button, Key_Modifiers()); } } - if (_TookCapture) { - _TookCapture = false; - _Host->Release_Capture(); + if (TookCapture) { + TookCapture = false; + Host.Release_Capture(); } } -#ifdef _DEBUG - -static void Toggle_Test_Document(void) +void UIShellClass::Toggle_Test_Document(void) { - if (!_FontLoaded) { +#ifdef _DEBUG + if (!FontLoaded) { Log("UI: the test document needs the font, which did not load\n"); return; } - if (_TestDocument == NULL) { - _TestDocument = _Context->LoadDocument("test.rml"); - if (_TestDocument == NULL) { + if (TestDocument == nullptr) { + TestDocument = Context->LoadDocument("test.rml"); + if (TestDocument == nullptr) { Log("UI: test.rml did not load\n"); return; } - Rml::Element * close = _TestDocument->GetElementById("close"); - if (close != NULL) { - close->AddEventListener(Rml::EventId::Click, &_TestListener); + Rml::Element * close = TestDocument->GetElementById("close"); + if (close != nullptr) { + if (TestListener == nullptr) { + TestListener = std::make_unique(*this); + } + close->AddEventListener(Rml::EventId::Click, TestListener.get()); } - _TestDocument->Show(); - _Render.Log_Resource_Counts("test document loaded and shown"); - } else if (_TestDocument->IsVisible()) { - _TestDocument->Hide(); - _Render.Log_Resource_Counts("test document hidden"); + TestDocument->Show(); + Render->Log_Resource_Counts("test document loaded and shown"); + } else if (TestDocument->IsVisible()) { + TestDocument->Hide(); + Render->Log_Resource_Counts("test document hidden"); } else { - _TestDocument->Show(); - _Render.Log_Resource_Counts("test document shown"); + TestDocument->Show(); + Render->Log_Resource_Counts("test document shown"); } - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); +#endif } + +void UIShellClass::Drain_Deferred(void) +{ + DeferredWorkType work = Deferred; + Deferred = DeferredWorkType(); + +#ifdef _DEBUG + if (work.ToggleTest) { + Toggle_Test_Document(); + } + if (work.ToggleDev) { + UIDev_Toggle(*Render); + Host.Mark_Overlay_Dirty(); + } + if (work.CloseTest) { + if (TestDocument != nullptr && TestDocument->IsVisible()) { + TestDocument->Hide(); + Render->Log_Resource_Counts("test document closed"); + Host.Mark_Overlay_Dirty(); + } + } #endif + if (work.Resize) { + Apply_Dimensions(); + } + if (work.DropPresses) { + Drop_Presses(); + } + if (work.Leave) { + Context->ProcessMouseLeave(); + MouseInside = false; + } + if (work.DevFocus >= 0) { + UIDev_Focus(work.DevFocus != 0); + } +} -bool UI_Init(UIShellHostClass & host) + +bool UIShellClass::Init(void) { - if (_Ready) { + if (Ready) { return(true); } - _Host = &host; - - if (!_Render.Init()) { + if (!Render->Init()) { return(false); } - _System = std::make_unique(host); - - Rml::SetSystemInterface(_System.get()); - Rml::SetFileInterface(&_File); - Rml::SetRenderInterface(&_Render); + Rml::SetSystemInterface(System.get()); + if (File != nullptr) { + Rml::SetFileInterface(File.get()); + } + Rml::SetRenderInterface(Render.get()); if (!Rml::Initialise()) { Log("UI: RmlUi did not initialise\n"); - _Render.Shutdown(); - _System.reset(); + Render->Shutdown(); return(false); } - UIFrameRect frame = host.Frame(); - _Context = Rml::CreateContext("main", Rml::Vector2i(frame.Width, frame.Height)); - if (_Context == NULL) { + UIFrameRect frame = Host.Frame(); + Context = Rml::CreateContext("main", Rml::Vector2i(frame.Width, frame.Height)); + if (Context == nullptr) { Log("UI: the context could not be created\n"); Rml::Shutdown(); - _Render.Shutdown(); - _System.reset(); + Render->Shutdown(); return(false); } Apply_Dimensions(); - _FontLoaded = Rml::LoadFontFace("OpenSans.ttf"); - if (!_FontLoaded) { + FontLoaded = Rml::LoadFontFace("OpenSans.ttf"); + if (!FontLoaded) { Log("UI: OpenSans.ttf did not load, so no document can be shown\n"); } - _Ready = true; + Ready = true; Log("UI: RmlUi %s ready over a %dx%d frame at %.2f pixels per dp\n", - Rml::GetVersion().c_str(), frame.Width, frame.Height, _Context->GetDensityIndependentPixelRatio()); + Rml::GetVersion().c_str(), frame.Width, frame.Height, Context->GetDensityIndependentPixelRatio()); return(true); } -void UI_Shutdown(void) +void UIShellClass::Shutdown(void) { - if (!_Ready) { + if (!Ready) { return; } - _Ready = false; - _Modal = NULL; - _ModalClosing = false; + Ready = false; + Modals.clear(); + ModalClosing = false; - if (_OwnedButtons != 0) { + if (OwnedButtons != 0) { Drop_Presses(); } - UIDev_Shutdown(_Render); + // The documents go while the context still exists; a caller hiding its notice + // afterwards finds nothing to do. + for (UIRmlViewClass * view : Modeless) { + view->Release(); + } + Modeless.clear(); -#ifdef _DEBUG - _TestDocument = NULL; - _PendingToggle = false; - _PendingDevToggle = false; - _CloseRequested = false; -#endif + UIDev_Shutdown(*Render); + + TestDocument = nullptr; + Deferred = DeferredWorkType(); Rml::RemoveContext("main"); - _Context = NULL; + Context = nullptr; Rml::Shutdown(); - _Render.Shutdown(); - _System.reset(); - _FontLoaded = false; + Render->Shutdown(); + FontLoaded = false; } -bool UI_Use_Rml(void) +bool UIShellClass::Use_Rml(void) const { - return(_Ready && !_Host->Legacy_Dialogs_Requested()); + return(Ready && !Host.Legacy_Dialogs_Requested()); } -bool UI_Screen_Shown(void) +bool UIShellClass::Screen_Shown(void) const { - return(_Modal != NULL || _ModalClosing); + return(!Modals.empty() || ModalClosing); } -void UI_On_Video_Change(void) +bool UIShellClass::Legacy_Dialog_Visible(void) const +{ + return(Host.Legacy_Dialog_Visible()); +} + + +UIRmlViewClass * UIShellClass::Modal(void) const +{ + return(Modals.empty() ? nullptr : Modals.back()); +} + + +int UIShellClass::Modal_Depth(void) const { - if (!_Ready) { + return((int)Modals.size()); +} + + +bool UIShellClass::Is_Modeless_Shown(UIRmlViewClass const & view) const +{ + return(std::find(Modeless.begin(), Modeless.end(), &view) != Modeless.end()); +} + + +void UIShellClass::On_Video_Change(void) +{ + if (!Ready) { return; } - if (_InContext) { - _PendingResize = true; + if (InContext) { + Deferred.Resize = true; } else { Apply_Dimensions(); } - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); } -void UI_Tick(void) +void UIShellClass::Tick(void) { - if (!_Ready || _InTick || _InContext) { + if (!Ready || InTick || InContext) { return; } - _InTick = true; + UIReentryGuardClass ticking(InTick); -#ifdef _DEBUG - if (_PendingToggle) { - _PendingToggle = false; - Toggle_Test_Document(); - } - if (_PendingDevToggle) { - _PendingDevToggle = false; - UIDev_Toggle(_Render); - _Host->Mark_Overlay_Dirty(); - } - if (_CloseRequested) { - _CloseRequested = false; - if (_TestDocument != NULL && _TestDocument->IsVisible()) { - _TestDocument->Hide(); - _Render.Log_Resource_Counts("test document closed"); - _Host->Mark_Overlay_Dirty(); - } - } -#endif + Drain_Deferred(); - if (_PendingResize) { - _PendingResize = false; - Apply_Dimensions(); - } - if (_PendingRelease) { - _PendingRelease = false; - Drop_Presses(); + { + UIReentryGuardClass updating(InContext); + Context->Update(); + UIDev_Tick(); } - if (_PendingLeave) { - _PendingLeave = false; - _Context->ProcessMouseLeave(); - _MouseInside = false; - } - if (_PendingDevFocus >= 0) { - UIDev_Focus(_PendingDevFocus != 0); - _PendingDevFocus = -1; - } - - _InContext = true; - _Context->Update(); - UIDev_Tick(); - _InContext = false; // An overlay closed from inside its own frame still needs one present to clear. - static bool devwasactive = false; bool devactive = UIDev_Active(); - if (Documents_Visible() || devactive || devwasactive) { - _Host->Mark_Overlay_Dirty(); + if (Documents_Visible() || devactive || DevWasActive) { + Host.Mark_Overlay_Dirty(); } - devwasactive = devactive; - - _InTick = false; + DevWasActive = devactive; } -void UI_Render_Overlay(void) +void UIShellClass::Render_Overlay(void) { - if (!_Ready || _InContext || _Host->Movie_Playing()) { + if (!Ready || InContext || Host.Movie_Playing()) { return; } @@ -423,71 +486,72 @@ void UI_Render_Overlay(void) return; } - UIFrameRect frame = _Host->Frame(); + UIFrameRect frame = Host.Frame(); if (frame.Width <= 0 || frame.Height <= 0) { return; } if (documents) { - _Render.Begin_Frame(frame.X, frame.Y, frame.Width, frame.Height); + Render->Begin_Frame(frame.X, frame.Y, frame.Width, frame.Height); - _InContext = true; - _Context->Render(); - _InContext = false; + UIReentryGuardClass rendering(InContext); + Context->Render(); } if (overlays) { - _Render.Begin_Dev_Frame(frame.X, frame.Y, frame.Width, frame.Height); - UIDev_Render(_Render); + Render->Begin_Dev_Frame(frame.X, frame.Y, frame.Width, frame.Height); + UIDev_Render(*Render); } + + Drain_Deferred(); } -static bool Handle_Mouse_Move(LPARAM clientlparam) +bool UIShellClass::Handle_Mouse_Move(LPARAM clientlparam) { UIPointerPosition position = Pointer_Position(clientlparam); UIDev_Mouse_Position(position.X, position.Y); if (UIDev_Wants_Mouse()) { - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); return(false); } - if (_OwnedButtons != 0 || position.Inside) { - _Context->ProcessMouseMove(position.X, position.Y, Key_Modifiers()); - _MouseInside = position.Inside; - _Host->Mark_Overlay_Dirty(); - } else if (_MouseInside) { - _Context->ProcessMouseLeave(); - _MouseInside = false; - _Host->Mark_Overlay_Dirty(); + if (OwnedButtons != 0 || position.Inside) { + Context->ProcessMouseMove(position.X, position.Y, Key_Modifiers()); + MouseInside = position.Inside; + Host.Mark_Overlay_Dirty(); + } else if (MouseInside) { + Context->ProcessMouseLeave(); + MouseInside = false; + Host.Mark_Overlay_Dirty(); } return(false); } -static void Own_Press(int button) +void UIShellClass::Own_Press(int button) { - if (_OwnedButtons == 0) { - _TookCapture = _Host->Take_Capture(); + if (OwnedButtons == 0) { + TookCapture = Host.Take_Capture(); } - _OwnedButtons |= (1u << button); + OwnedButtons |= (1u << button); } -static void Release_Press(int button) +void UIShellClass::Release_Press(int button) { - _OwnedButtons &= ~(1u << button); - _DevOwnedButtons &= ~(1u << button); - if (_OwnedButtons == 0 && _TookCapture) { - _TookCapture = false; - _Host->Release_Capture(); + OwnedButtons &= ~(1u << button); + DevOwnedButtons &= ~(1u << button); + if (OwnedButtons == 0 && TookCapture) { + TookCapture = false; + Host.Release_Capture(); } } -static bool Handle_Button_Down(int button, LPARAM clientlparam) +bool UIShellClass::Handle_Button_Down(int button, LPARAM clientlparam) { UIPointerPosition position = Pointer_Position(clientlparam); @@ -495,22 +559,22 @@ static bool Handle_Button_Down(int button, LPARAM clientlparam) UIDev_Mouse_Position(position.X, position.Y); if (UIDev_Mouse_Button(button, true)) { Own_Press(button); - _DevOwnedButtons |= (1u << button); - _Host->Mark_Overlay_Dirty(); + DevOwnedButtons |= (1u << button); + Host.Mark_Overlay_Dirty(); return(true); } } - if (!position.Inside && _OwnedButtons == 0) { + if (!position.Inside && OwnedButtons == 0) { return(false); } int modifiers = Key_Modifiers(); - _Context->ProcessMouseMove(position.X, position.Y, modifiers); - _MouseInside = position.Inside; + Context->ProcessMouseMove(position.X, position.Y, modifiers); + MouseInside = position.Inside; - bool interacting = !_Context->ProcessMouseButtonDown(button, modifiers); - _Host->Mark_Overlay_Dirty(); + bool interacting = !Context->ProcessMouseButtonDown(button, modifiers); + Host.Mark_Overlay_Dirty(); if (!interacting) { return(false); @@ -521,15 +585,15 @@ static bool Handle_Button_Down(int button, LPARAM clientlparam) } -static bool Handle_Button_Up(int button, LPARAM clientlparam) +bool UIShellClass::Handle_Button_Up(int button, LPARAM clientlparam) { UIPointerPosition position = Pointer_Position(clientlparam); - if (_DevOwnedButtons & (1u << button)) { + if (DevOwnedButtons & (1u << button)) { UIDev_Mouse_Position(position.X, position.Y); UIDev_Mouse_Button(button, false); Release_Press(button); - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); return(true); } @@ -538,29 +602,29 @@ static bool Handle_Button_Up(int button, LPARAM clientlparam) UIDev_Mouse_Button(button, false); } - if ((_OwnedButtons & (1u << button)) == 0) { + if ((OwnedButtons & (1u << button)) == 0) { return(false); } int modifiers = Key_Modifiers(); - _Context->ProcessMouseMove(position.X, position.Y, modifiers); - _Context->ProcessMouseButtonUp(button, modifiers); - _MouseInside = position.Inside; - _Host->Mark_Overlay_Dirty(); + Context->ProcessMouseMove(position.X, position.Y, modifiers); + Context->ProcessMouseButtonUp(button, modifiers); + MouseInside = position.Inside; + Host.Mark_Overlay_Dirty(); Release_Press(button); return(true); } -static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) +bool UIShellClass::Handle_Wheel(WPARAM wparam, LPARAM screenlparam) { int x = GET_X_LPARAM(screenlparam); int y = GET_Y_LPARAM(screenlparam); - _Host->Screen_To_Client(x, y); + Host.Screen_To_Client(x, y); - UIFrameRect frame = _Host->Frame(); + UIFrameRect frame = Host.Frame(); UIPointerPosition position = UI_Client_To_Overlay(frame.X, frame.Y, frame.Width, frame.Height, x, y); // Windows counts wheel movement away from the user as positive; ImGui scrolls up for it @@ -570,7 +634,7 @@ static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) if (UIDev_Active()) { UIDev_Mouse_Position(position.X, position.Y); if (UIDev_Mouse_Wheel(delta)) { - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); return(true); } } @@ -579,16 +643,16 @@ static bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam) return(false); } - bool consumed = !_Context->ProcessMouseWheel(Rml::Vector2f(0.0f, -delta), Key_Modifiers()); - _Host->Mark_Overlay_Dirty(); + bool consumed = !Context->ProcessMouseWheel(Rml::Vector2f(0.0f, -delta), Key_Modifiers()); + Host.Mark_Overlay_Dirty(); return(consumed); } -static bool Handle_Key(UINT message, WPARAM wparam) +bool UIShellClass::Handle_Key(UINT message, WPARAM wparam) { if (UIDev_Key(wparam, message == WM_KEYDOWN)) { - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); return(true); } @@ -599,37 +663,37 @@ static bool Handle_Key(UINT message, WPARAM wparam) bool propagated; if (message == WM_KEYDOWN) { - propagated = _Context->ProcessKeyDown(key, Key_Modifiers()); + propagated = Context->ProcessKeyDown(key, Key_Modifiers()); } else { - propagated = _Context->ProcessKeyUp(key, Key_Modifiers()); + propagated = Context->ProcessKeyUp(key, Key_Modifiers()); } - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); return(!propagated || Text_Input_Focused()); } // Windows delivers a character beyond the basic plane as two messages; the first half // waits for the second. Carriage returns become newlines and control characters stay out. -static bool Handle_Char(WPARAM wparam) +bool UIShellClass::Handle_Char(WPARAM wparam) { wchar_t unit = (wchar_t)wparam; if (UIDev_Character(unit)) { - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); return(true); } if (unit >= 0xD800 && unit < 0xDC00) { - _HighSurrogate = unit; + HighSurrogate = unit; return(false); } char32_t code = unit; - if (unit >= 0xDC00 && unit < 0xE000 && _HighSurrogate != 0) { - code = 0x10000 + (((char32_t)_HighSurrogate - 0xD800) << 10) + ((char32_t)unit - 0xDC00); + if (unit >= 0xDC00 && unit < 0xE000 && HighSurrogate != 0) { + code = 0x10000 + (((char32_t)HighSurrogate - 0xD800) << 10) + ((char32_t)unit - 0xDC00); } - _HighSurrogate = 0; + HighSurrogate = 0; if (code == '\r') { code = '\n'; @@ -638,60 +702,28 @@ static bool Handle_Char(WPARAM wparam) return(false); } - bool consumed = !_Context->ProcessTextInput((Rml::Character)code); - _Host->Mark_Overlay_Dirty(); + bool consumed = !Context->ProcessTextInput((Rml::Character)code); + Host.Mark_Overlay_Dirty(); return(consumed); } -bool UI_Legacy_Dialog_Visible(void) -{ - return(_Host != nullptr && _Host->Legacy_Dialog_Visible()); -} - - -// The mouse, wheel, key and text messages a shown screen takes whole. -static bool Input_Message(UINT message) -{ - switch (message) { - case WM_MOUSEMOVE: - case WM_LBUTTONDOWN: - case WM_LBUTTONDBLCLK: - case WM_RBUTTONDOWN: - case WM_RBUTTONDBLCLK: - case WM_MBUTTONDOWN: - case WM_MBUTTONDBLCLK: - case WM_LBUTTONUP: - case WM_RBUTTONUP: - case WM_MBUTTONUP: - case WM_MOUSEWHEEL: - case WM_KEYDOWN: - case WM_KEYUP: - case WM_CHAR: - return(true); - - default: - return(false); - } -} - - -UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) +UIResult UIShellClass::Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) { - if (!_Ready) { + if (!Ready) { return(UI_RESULT_FAILED_TO_OPEN); } - if (!_FontLoaded) { + if (!FontLoaded) { Log("UI: %s needs OpenSans.ttf, which did not load\n", view.Document_Name()); return(UI_RESULT_FAILED_TO_OPEN); } // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. - assert(!UI_Legacy_Dialog_Visible()); + assert(!Legacy_Dialog_Visible()); // A style sheet that fails to load leaves the document usable and is reported as an error. - int errors = _System->Error_Count(); - if (!view.Prepare(*_Context) || _System->Error_Count() != errors) { + int errors = System->Error_Count(); + if (!view.Prepare(*Context) || System->Error_Count() != errors) { Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); view.Release(); return(UI_RESULT_FAILED_TO_OPEN); @@ -700,29 +732,28 @@ UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) view.Presenter().Refresh(); view.Sync(); - if (_OwnedButtons != 0) { + if (OwnedButtons != 0) { Drop_Presses(); } char label[160]; - UIRmlViewClass * previous = _Modal; - _Modal = &view; + Modals.push_back(&view); view.Show(true); - _Host->Mark_Overlay_Dirty(); + Host.Mark_Overlay_Dirty(); std::snprintf(label, sizeof(label), "%s shown", view.Document_Name()); - _Render.Log_Resource_Counts(label); - _Host->Clear_Keyboard_Queue(); + Render->Log_Resource_Counts(label); + Host.Clear_Keyboard_Queue(); UIResult result = UI_RESULT_SESSION_ENDED; while (true) { bool ended = service(); - if (!_Ready) { + if (!Ready) { break; } - UI_Tick(); + Tick(); view.Presenter().Refresh(); view.Presenter().Drain(); view.Sync(); @@ -735,46 +766,48 @@ UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) break; } - _Host->Mark_Overlay_Dirty(); - _Host->Present_If_Dirty(); + Host.Mark_Overlay_Dirty(); + Host.Present_If_Dirty(); } - _ModalClosing = true; - if (_OwnedButtons != 0) { + ModalClosing = true; + if (OwnedButtons != 0) { Drop_Presses(); } view.Presenter().Discard(); view.Release(); - if (_Ready) { - _InContext = true; - _Context->Update(); - _InContext = false; + if (Ready) { + UIReentryGuardClass updating(InContext); + Context->Update(); } - _Modal = previous; - _ModalClosing = false; + // A shutdown inside the loop has already emptied the stack. + if (!Modals.empty() && Modals.back() == &view) { + Modals.pop_back(); + } + ModalClosing = false; - if (_Ready) { - _Host->Mark_Overlay_Dirty(); + if (Ready) { + Host.Mark_Overlay_Dirty(); std::snprintf(label, sizeof(label), "%s closed", view.Document_Name()); - _Render.Log_Resource_Counts(label); - _Host->Clear_Keyboard_Queue(); - _Host->Focus_Main_Window(); + Render->Log_Resource_Counts(label); + Host.Clear_Keyboard_Queue(); + Host.Focus_Main_Window(); } return(result); } -bool UI_Show_Modeless(UIRmlViewClass & view) +bool UIShellClass::Show_Modeless(UIRmlViewClass & view) { - if (!_Ready || !_FontLoaded || _InContext) { + if (!Ready || !FontLoaded || InContext) { return(false); } - int errors = _System->Error_Count(); - if (!view.Prepare(*_Context) || _System->Error_Count() != errors) { + int errors = System->Error_Count(); + if (!view.Prepare(*Context) || System->Error_Count() != errors) { Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); view.Release(); return(false); @@ -783,110 +816,95 @@ bool UI_Show_Modeless(UIRmlViewClass & view) view.Presenter().Refresh(); view.Sync(); view.Show(false); - UI_Refresh(); + Modeless.push_back(&view); + Refresh(); return(true); } -void UI_Hide_Modeless(UIRmlViewClass & view) +void UIShellClass::Hide_Modeless(UIRmlViewClass & view) { + Modeless.erase(std::remove(Modeless.begin(), Modeless.end(), &view), Modeless.end()); view.Release(); - if (_Ready && !_InContext) { - _InContext = true; - _Context->Update(); - _InContext = false; - _Host->Mark_Overlay_Dirty(); - _Host->Present_If_Dirty(); - } -} - - -namespace -{ - -class UISystemClockClass : public UIClockClass -{ - public: - virtual int Milliseconds(void) override + if (Ready && !InContext) { { - return((int)GetTickCount64()); + UIReentryGuardClass updating(InContext); + Context->Update(); } -}; - -UISystemClockClass _Clock; - + Host.Mark_Overlay_Dirty(); + Host.Present_If_Dirty(); + } } -UIClockClass & UI_Clock(void) +UIClockClass & UIShellClass::Clock(void) { - return(_Clock); + static UISystemClockClass clock; + return(clock); } -void UI_Refresh(void) +void UIShellClass::Refresh(void) { - if (!_Ready || _InContext) { + if (!Ready || InContext) { return; } - UI_Tick(); - _Host->Mark_Overlay_Dirty(); - _Host->Present_If_Dirty(); + Tick(); + Host.Mark_Overlay_Dirty(); + Host.Present_If_Dirty(); } -bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) +bool UIShellClass::Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) { - if (!_Ready || _InHook || hwnd != _Host->Main_Window()) { + if (!Ready || InHook || hwnd != Host.Main_Window()) { return(false); } // Another window taking the capture ends the presses the shell owns. if (message == WM_CAPTURECHANGED) { - if (_OwnedButtons != 0 && (HWND)clientlparam != _Host->Main_Window()) { - _TookCapture = false; - if (_InContext) { - _PendingRelease = true; + if (OwnedButtons != 0 && (HWND)clientlparam != Host.Main_Window()) { + TookCapture = false; + if (InContext) { + Deferred.DropPresses = true; } else { - _InHook = true; + UIReentryGuardClass hooking(InHook); Drop_Presses(); - _InHook = false; } } return(false); } if (message == WM_ACTIVATEAPP) { - if (_InContext) { - _PendingDevFocus = (wparam != 0) ? 1 : 0; + if (InContext) { + Deferred.DevFocus = (wparam != 0) ? 1 : 0; } else { UIDev_Focus(wparam != 0); } - if (wparam == 0 && _MouseInside) { - if (_InContext) { - _PendingLeave = true; + if (wparam == 0 && MouseInside) { + if (InContext) { + Deferred.Leave = true; } else { - _InHook = true; - _Context->ProcessMouseLeave(); - _MouseInside = false; - _InHook = false; + UIReentryGuardClass hooking(InHook); + Context->ProcessMouseLeave(); + MouseInside = false; } } return(false); } - if (_InContext || (_OwnedButtons == 0 && _Modal == NULL && !Documents_Visible() && !UIDev_Active())) { + if (InContext || (OwnedButtons == 0 && Modals.empty() && !Documents_Visible() && !UIDev_Active())) { return(false); } // A closing screen has released its document; the messages it would have taken still end here. - if (_ModalClosing) { + if (ModalClosing) { return(Input_Message(message)); } - _InHook = true; + UIReentryGuardClass hooking(InHook); bool consumed = false; switch (message) { @@ -939,27 +957,26 @@ bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM cli } // A shown screen takes every mouse and key message, as a visible legacy dialog does. - if (_Modal != NULL && Input_Message(message)) { + if (!Modals.empty() && Input_Message(message)) { consumed = true; } - _InHook = false; return(consumed); } -bool UI_Intercept_Pumped_Message(MSG const & msg) +bool UIShellClass::Intercept_Pumped_Message(MSG const & msg) { #ifdef _DEBUG - if (_Ready && _Host->Developer_Keys_Armed() && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F9) { + if (Ready && Host.Developer_Keys_Armed() && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F9) { if (msg.message == WM_KEYDOWN && (msg.lParam & (1 << 30)) == 0) { - _PendingToggle = true; + Deferred.ToggleTest = true; } return(true); } - if (_Ready && _Host->Developer_Keys_Armed() && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F6) { + if (Ready && Host.Developer_Keys_Armed() && (msg.message == WM_KEYDOWN || msg.message == WM_KEYUP) && msg.wParam == VK_F6) { if (msg.message == WM_KEYDOWN && (msg.lParam & (1 << 30)) == 0) { - _PendingDevToggle = true; + Deferred.ToggleDev = true; } return(true); } @@ -968,3 +985,93 @@ bool UI_Intercept_Pumped_Message(MSG const & msg) #endif return(false); } + + +bool UI_Init(void) +{ + return(UIShell.Init()); +} + + +void UI_Shutdown(void) +{ + UIShell.Shutdown(); +} + + +bool UI_Use_Rml(void) +{ + return(UIShell.Use_Rml()); +} + + +bool UI_Screen_Shown(void) +{ + return(UIShell.Screen_Shown()); +} + + +bool UI_Legacy_Dialog_Visible(void) +{ + return(UIShell.Legacy_Dialog_Visible()); +} + + +UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) +{ + return(UIShell.Run_Modal(view, service)); +} + + +bool UI_Show_Modeless(UIRmlViewClass & view) +{ + return(UIShell.Show_Modeless(view)); +} + + +void UI_Hide_Modeless(UIRmlViewClass & view) +{ + UIShell.Hide_Modeless(view); +} + + +void UI_Refresh(void) +{ + UIShell.Refresh(); +} + + +UIClockClass & UI_Clock(void) +{ + return(UIShell.Clock()); +} + + +void UI_On_Video_Change(void) +{ + UIShell.On_Video_Change(); +} + + +void UI_Tick(void) +{ + UIShell.Tick(); +} + + +void UI_Render_Overlay(void) +{ + UIShell.Render_Overlay(); +} + + +bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) +{ + return(UIShell.Handle_Window_Message(hwnd, message, wparam, clientlparam)); +} + + +bool UI_Intercept_Pumped_Message(MSG const & msg) +{ + return(UIShell.Intercept_Pumped_Message(msg)); +} diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 92d9d4399..99c04e607 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -7,16 +7,29 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ -// The UI shell owns the RmlUi context, the overlay pass and the input hook. The rest of -// the engine reaches it through these functions alone; no toolkit type appears here. +// The UI shell owns the RmlUi context, the overlay pass and the input hook. The engine +// holds one instance, UIShell, declared in _ui.h; no toolkit type appears here. #pragma once +#include "ui/uicoord.h" #include "ui/uiscreen.h" #include "win.h" #include - +#include +#include + +namespace Rml +{ + class Context; + class ElementDocument; + class EventListener; + class FileInterface; +} + +class UIRmlRenderClass; +class UIRmlSystemClass; class UIRmlViewClass; class UIShellHostClass; @@ -24,56 +37,165 @@ class UIShellHostClass; using UIServiceCallback = std::function; -// Needs the window, the renderer and the file search chain. A false return leaves every -// other entry point inert. The engine passes its own host; a test passes one it controls. +class UIShellClass +{ + public: + // The interfaces are owned from here on and outlive Rml::Shutdown, which releases + // every resource through them. A null file interface leaves RmlUi's own in place. + UIShellClass(UIShellHostClass & host, std::unique_ptr system, std::unique_ptr file, std::unique_ptr render); + ~UIShellClass(void); + + UIShellClass(UIShellClass const &) = delete; + UIShellClass & operator=(UIShellClass const &) = delete; + + // Needs the window, the renderer and the file search chain. A false return leaves + // every other entry point inert. + bool Init(void); + void Shutdown(void); + + // True when a migrated screen should open its RmlUi view rather than its Win32 + // dialog. A caller reads it once at screen entry; the answer follows the + // LegacyDialogs setting. + bool Use_Rml(void) const; + + // True while a modal screen is shown or closing. The developer overlays are not + // screens. + bool Screen_Shown(void) const; + + // True while a Win32 dialog is on screen. A screen asked to open over one keeps its + // legacy view, because the visible dialog takes the mouse before a document can. + bool Legacy_Dialog_Visible(void) const; + + // Prepares, shows and drives a modal screen until its presenter reports a result or + // the service reports the game ended, then releases it. The view's presenter must + // outlive the call. + UIResult Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service); + + // Shows a document beside the game without taking its input: a notice the caller + // updates while it works. It is drawn at once, because such a caller pumps nothing. + // False when the shell or the document is not ready, so the caller opens its Win32 + // presentation. + bool Show_Modeless(UIRmlViewClass & view); + void Hide_Modeless(UIRmlViewClass & view); + + // Advances the documents and presents the overlay now. + void Refresh(void); + + // The system clock a timed screen's presenter reads. + UIClockClass & Clock(void); + + // The frame moved or changed size inside the window. + void On_Video_Change(void); + + // Advances the documents and the developer overlays. Called at the game's service + // points, never from a paint handler or the message pump; a modal screen's runner + // drains its intents after each call. + void Tick(void); + + // Draws the visible documents over the frame the renderer has just submitted. + void Render_Overlay(void); + + // Offers a main window message to the shell before the game sees it. The position + // is the raw client one, taken before the router translated it. True means the + // message is consumed. + bool Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam); + + // Offers a pumped message to the shell before dispatch, whichever window it is for. + // True means the message is consumed. + bool Intercept_Pumped_Message(MSG const & msg); + + Rml::Context * Rml_Context(void) const { return(Context); } + UIRmlViewClass * Modal(void) const; + int Modal_Depth(void) const; + bool Is_Modeless_Shown(UIRmlViewClass const & view) const; + + private: + friend class UITestListenerClass; + + // Work that arrived while the context was updating or rendering, applied at the + // next safe point in the order the fields are declared. + struct DeferredWorkType + { + bool ToggleTest = false; + bool ToggleDev = false; + bool CloseTest = false; + bool Resize = false; + bool DropPresses = false; + bool Leave = false; + int DevFocus = -1; + }; + + void Log(char const * format, ...); + bool Documents_Visible(void) const; + bool Text_Input_Focused(void) const; + void Apply_Dimensions(void); + UIPointerPosition Pointer_Position(LPARAM clientlparam) const; + void Drop_Presses(void); + void Drain_Deferred(void); + void Toggle_Test_Document(void); + void Own_Press(int button); + void Release_Press(int button); + bool Handle_Mouse_Move(LPARAM clientlparam); + bool Handle_Button_Down(int button, LPARAM clientlparam); + bool Handle_Button_Up(int button, LPARAM clientlparam); + bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam); + bool Handle_Key(UINT message, WPARAM wparam); + bool Handle_Char(WPARAM wparam); + + UIShellHostClass & Host; + std::unique_ptr System; + std::unique_ptr File; + std::unique_ptr Render; + + Rml::Context * Context = nullptr; + bool Ready = false; + bool FontLoaded = false; + + // Set while the context updates or renders, while the hook runs, and while a tick + // runs; each refuses to re-enter itself. + bool InContext = false; + bool InHook = false; + bool InTick = false; + DeferredWorkType Deferred; + + // The presses the shell consumed, as a mask over the mouse button indices, and + // whether it took the window's capture for them. Their releases belong to the shell + // wherever they land. The developer overlays' own presses are a subset that their + // release goes back to. + unsigned int OwnedButtons = 0; + unsigned int DevOwnedButtons = 0; + bool TookCapture = false; + bool MouseInside = false; + wchar_t HighSurrogate = 0; + + // The modal screens the runner is driving, innermost last, and whether the + // innermost is between releasing its document and handing the input back. + std::vector Modals; + bool ModalClosing = false; + std::vector Modeless; + + bool DevWasActive = false; + + // The Debug test document a developer key shows over the game. + Rml::ElementDocument * TestDocument = nullptr; + std::unique_ptr TestListener; +}; + + +// The free functions the engine calls today; each forwards to UIShell. bool UI_Init(void); -bool UI_Init(UIShellHostClass & host); void UI_Shutdown(void); - -// True when a migrated screen should open its RmlUi view rather than its Win32 dialog. A -// caller reads it once at screen entry; the answer follows the LegacyDialogs setting. bool UI_Use_Rml(void); - -// True while a modal screen is shown or closing. The developer overlays are not screens. bool UI_Screen_Shown(void); - -// True while a Win32 dialog is on screen. A screen asked to open over one keeps its legacy -// view, because the visible dialog takes the mouse before a document can. bool UI_Legacy_Dialog_Visible(void); - -// Prepares, shows and drives a modal screen until its presenter reports a result or the game -// ends, then releases it. The view's presenter must outlive the call. The engine's entry -// services the game each pass; a test supplies the service it wants. UIResult UI_Run_Modal(UIRmlViewClass & view); UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service); - -// Shows a document beside the game without taking its input: a notice the caller updates -// while it works. It is drawn at once, because such a caller pumps nothing. False when the -// shell or the document is not ready, so the caller opens its Win32 presentation. bool UI_Show_Modeless(UIRmlViewClass & view); void UI_Hide_Modeless(UIRmlViewClass & view); - -// Advances the documents and presents the overlay now. void UI_Refresh(void); - -// The system clock a timed screen's presenter reads. UIClockClass & UI_Clock(void); - -// The frame moved or changed size inside the window. void UI_On_Video_Change(void); - -// Advances the documents and the developer overlays. Called at the game's service points, -// never from a paint handler or the message pump; a modal screen's runner drains its intents -// after each call. void UI_Tick(void); - -// Draws the visible documents over the frame the renderer has just submitted. void UI_Render_Overlay(void); - -// Offers a main window message to the shell before the game sees it. The position is the -// raw client one, taken before the router translated it. True means the message is consumed. bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam); - -// Offers a pumped message to the shell before dispatch, whichever window it is for. True -// means the message is consumed. bool UI_Intercept_Pumped_Message(MSG const & msg); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 6a17a8cb0..0043174f3 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -187,8 +187,8 @@ rule the tree follows, not a build boundary. | Directory | Holds | Status | | --- | --- | --- | -| `code/` | `bgfxviews.hh`, the view ids the presenter and the overlays share | landed | -| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | +| `code/` | `bgfxviews.hh`, the view ids the presenter and the overlays share; `_ui.h`, `_ui.cpp`, the shell's one instance `UIShell` under the underscore-file convention for globals | landed | +| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (`UIShellClass`: init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector; its toolkit interfaces are injected, so a test builds its own instance); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | | `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging to `DebugString`, string translation; cursor and clipboard wait for the first editable screen), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base) | landed | | `code/ui/dev/` | `uidev.h`, `uidev.cpp`: the ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | | `code/ui/screens//` | one family each for `version`, `msgbox`, `waitbox`, `sound`, `gamectrl`, `display`, `keyboard`, `mainopt`: `ui.h` (presenter, service and state declarations, view factory, engine entry), `ui.cpp` (presenter and RmlUi view; built into the test), `uidlg.cpp` (engine service and entry, which the test cannot link) | landed; the sound, game controls, keyboard and display Win32 dialogs drive the same presenter as a second view, and the wait box family carries the `UIWaitBoxClass` the save, load and progress code shows | diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 2b1e6deca..9687b0a04 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -288,7 +288,7 @@ fixed_controls: context: Any game window focused, with Debug_Flag enabled availability: *debug sites: - - { file: code/ui/uishell.cpp, function: UI_Intercept_Pumped_Message, expression: VK_F9, guard: _DEBUG } + - { file: code/ui/uishell.cpp, function: UIShellClass::Intercept_Pumped_Message, expression: VK_F9, guard: _DEBUG } - id: fixed:debug-benchmark-overlay title: Toggle the benchmark overlay description: Shows or hides the Dear ImGui frame benchmark window drawn over the game and its menus. @@ -297,7 +297,7 @@ fixed_controls: context: Any game window focused, with Debug_Flag enabled availability: *debug sites: - - { file: code/ui/uishell.cpp, function: UI_Intercept_Pumped_Message, expression: VK_F6, guard: _DEBUG } + - { file: code/ui/uishell.cpp, function: UIShellClass::Intercept_Pumped_Message, expression: VK_F6, guard: _DEBUG } fixed_exclusions: - site: { file: code/debug.cpp, function: Debug_Key, expression: KN_BUTTON, guard: _DEBUG } From af054e872e6bb94dd02f583847bac6fde8b5e014 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:29:18 +0300 Subject: [PATCH 23/52] Call the UI shell as UIShell from the engine --- code/gamedlg.cpp | 5 +- code/init.cpp | 9 ++- code/mainloop.cpp | 5 +- code/mainopt.cpp | 13 ++-- code/msgbox.cpp | 3 +- code/msgloop.cpp | 3 +- code/options.cpp | 3 +- code/ownrdraw.cpp | 15 ++-- code/sounddlg.cpp | 3 +- code/startup.cpp | 13 ++-- code/ui/screens/display/uidisplaydlg.cpp | 8 +- code/ui/screens/gamectrl/uigamectrldlg.cpp | 4 +- code/ui/screens/keyboard/uikeyboarddlg.cpp | 4 +- code/ui/screens/mainopt/uimainoptdlg.cpp | 4 +- code/ui/screens/msgbox/uimsgboxdlg.cpp | 4 +- code/ui/screens/sound/uisounddlg.cpp | 4 +- code/ui/screens/version/uiversiondlg.cpp | 2 + code/ui/screens/waitbox/uiwaitboxdlg.cpp | 11 +-- code/ui/uienginehost.cpp | 3 +- code/ui/uienginehost.h | 6 ++ code/ui/uishell.cpp | 91 ---------------------- code/ui/uishell.h | 19 ----- code/video.cpp | 7 +- code/windlg.cpp | 3 +- code/winstub.cpp | 11 +-- docs/UI_DESIGN.md | 14 ++++ 26 files changed, 103 insertions(+), 164 deletions(-) diff --git a/code/gamedlg.cpp b/code/gamedlg.cpp index 18685ad95..77ec2efaf 100644 --- a/code/gamedlg.cpp +++ b/code/gamedlg.cpp @@ -37,10 +37,11 @@ #include "_map.h" #include "_tooltip.h" +#include "_ui.h" +#include "audio/audioengine.h" #include "cctooltip.h" #include "data.h" #include "dbgprint.h" -#include "audio/audioengine.h" #include "globals.h" #include "init.h" #include "language/language.h" @@ -220,7 +221,7 @@ void GameControlsClass::Dialog(void) { DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); - if (!UI_Use_Rml() || !UI_Game_Controls_Dialog()) { + if (!UIShell.Use_Rml() || !UI_Game_Controls_Dialog()) { Run_Win32_Dialog(); } diff --git a/code/init.cpp b/code/init.cpp index c0310d5bf..a1abf9402 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -79,6 +79,7 @@ #include "_theater.h" #include "_timer.h" #include "_tooltip.h" +#include "_ui.h" #include "_uicontrol.h" #include "_voxel.h" #include "abstract.h" @@ -87,6 +88,7 @@ #include "airctype.h" #include "alphashp.h" #include "anim.h" +#include "audio/audioengine.h" #include "autosave.h" #include "bench.h" #include "blight.h" @@ -105,7 +107,6 @@ #include "dbgprint.h" #include "deploymentconfig.h" #include "dialog.h" -#include "audio/audioengine.h" #include "dsurface.h" #include "egos.h" #include "empulse.h" @@ -133,8 +134,8 @@ #include "loaddlg.h" #include "logic.h" #include "mainopt.h" -#include "mixfile.h" #include "misc.h" +#include "mixfile.h" #include "mono.h" #include "movie.h" #include "mplayer.h" @@ -162,10 +163,10 @@ #include "scheme.h" #include "script.h" #include "session.h" -#include "spawner.h" #include "side.h" #include "skirmish.h" #include "smudtype.h" +#include "spawner.h" #include "stimer.h" #include "tactical.h" #include "tag.h" @@ -3024,7 +3025,7 @@ void Version_Dialog(void) HWND dialog; int res = 0; - if (UI_Use_Rml() && UI_Version_Dialog()) { + if (UIShell.Use_Rml() && UI_Version_Dialog()) { return; } diff --git a/code/mainloop.cpp b/code/mainloop.cpp index a60c599e0..00404d9c9 100644 --- a/code/mainloop.cpp +++ b/code/mainloop.cpp @@ -24,7 +24,9 @@ #include "_surface.h" #include "_tactica.h" #include "_timer.h" +#include "_ui.h" #include "_xmouse.h" +#include "audio/audioengine.h" #include "bench.h" #include "chat.h" #include "command.h" @@ -32,7 +34,6 @@ #include "data.h" #include "debug.h" #include "dialog.h" -#include "audio/audioengine.h" #include "dsurface.h" #include "fog.h" #include "globals.h" @@ -279,7 +280,7 @@ bool Main_Loop(void) */ if (!Session.Play) { if (SpecialDialog == SDLG_NONE && GameInFocus) { - UI_Tick(); + UIShell.Tick(); Map.Input(input, x, y); if (input) { Keyboard_Process(input); diff --git a/code/mainopt.cpp b/code/mainopt.cpp index 2038d7c3e..a70fbb954 100644 --- a/code/mainopt.cpp +++ b/code/mainopt.cpp @@ -15,17 +15,17 @@ #include "_mixfile.h" #include "_rect.h" #include "_surface.h" +#include "_ui.h" +#include "audio/audioengine.h" #include "convert.h" #include "data.h" #include "dbgprint.h" -#include "audio/audioengine.h" #include "dsurface.h" #include "gamedlg.h" #include "globals.h" #include "init.h" #include "language/language.h" #include "misc.h" -#include "video.h" #include "mixfile.h" #include "msgbox.h" #include "newmenu.h" @@ -37,6 +37,7 @@ #include "ui/screens/display/uidisplay.h" #include "ui/screens/mainopt/uimainopt.h" #include "ui/uishell.h" +#include "video.h" #include "wwmouse.h" #include "color.hh" @@ -84,7 +85,7 @@ void Main_Options_Dialog(void) while (true) { UIMainOptionsChoice choice = UI_MAIN_OPTIONS_LEAVE; - if (!UI_Use_Rml() || !UI_Main_Options_Dialog(choice)) { + if (!UIShell.Use_Rml() || !UI_Main_Options_Dialog(choice)) { choice = Main_Options_Win32_Dialog(); } @@ -348,7 +349,7 @@ bool Test_Display_Mode_Dialog(int width, int height) Draw_Menu_Background(); bool kept = false; - if (!UI_Use_Rml() || !UI_Confirm_Mode_Dialog(kept)) { + if (!UIShell.Use_Rml() || !UI_Confirm_Mode_Dialog(kept)) { kept = Confirm_Mode_Win32_Dialog(); } @@ -368,7 +369,7 @@ bool Test_Display_Mode_Dialog(int width, int height) // A dialog that could not be created keeps the mode, as it always has. static bool Confirm_Mode_Win32_Dialog(void) { - UIConfirmModePresenterClass presenter(UI_Clock()); + UIConfirmModePresenterClass presenter(UIShell.Clock()); _ConfirmPresenter = &presenter; HWND dialog = OwnerDraw::Begin_Dialog(IDD_OPT_CONFIRM_MODE, Test_Display_Mode_Dialog_Proc); @@ -431,7 +432,7 @@ static void Display_Options_Dialog(void) { while (true) { std::optional picked; - if (!UI_Use_Rml() || !UI_Display_Dialog(picked)) { + if (!UIShell.Use_Rml() || !UI_Display_Dialog(picked)) { picked = Display_Options_Win32_Dialog(); } diff --git a/code/msgbox.cpp b/code/msgbox.cpp index 86594b5f7..df4739ff8 100644 --- a/code/msgbox.cpp +++ b/code/msgbox.cpp @@ -35,6 +35,7 @@ #include "msgbox.h" +#include "_ui.h" #include "data.h" #include "globals.h" #include "init.h" @@ -79,7 +80,7 @@ int _default_response = 0; #define BUTTON_FLAG 0x8000 int WWMessageBox::_Process(const char * msg, int defresponse, const char * b1txt, const char * b2txt, const char * b3txt, bool preserve) { - if (UI_Use_Rml()) { + if (UIShell.Use_Rml()) { int choice; if (UI_Message_Box(msg, defresponse, b1txt, b2txt, b3txt, choice)) { return(choice); diff --git a/code/msgloop.cpp b/code/msgloop.cpp index f96a93eb3..c60da77ab 100644 --- a/code/msgloop.cpp +++ b/code/msgloop.cpp @@ -39,6 +39,7 @@ #include "msgloop.h" #include "_tooltip.h" +#include "_ui.h" #include "cctooltip.h" #include "ui/uishell.h" #include "vector.h" @@ -114,7 +115,7 @@ void Windows_Message_Handler(void) } // Ahead of the dialogs, so that a developer key works whichever window has focus. - if (UI_Intercept_Pumped_Message(msg)) { + if (UIShell.Intercept_Pumped_Message(msg)) { continue; } diff --git a/code/options.cpp b/code/options.cpp index 16cda0f27..07dda045b 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -64,6 +64,7 @@ #include "_deploymentconfig.h" #include "_map.h" #include "_rules.h" +#include "_ui.h" #include "audio/audioengine.h" #include "ccfile.h" #include "ccrand.h" @@ -800,7 +801,7 @@ static void Hotkey_Win32_Dialog(void) ///
bool OptionsClass::Hotkey_Dialog(void) { - if (!UI_Use_Rml() || !UI_Keyboard_Dialog()) { + if (!UIShell.Use_Rml() || !UI_Keyboard_Dialog()) { Hotkey_Win32_Dialog(); } return(true); diff --git a/code/ownrdraw.cpp b/code/ownrdraw.cpp index f0fbe6fb4..21843c0cf 100644 --- a/code/ownrdraw.cpp +++ b/code/ownrdraw.cpp @@ -15,14 +15,15 @@ #include "_mixfile.h" #include "_rules.h" #include "_surface.h" +#include "_ui.h" #include "_xmouse.h" #include "arraylist.h" +#include "audio/audioengine.h" #include "bsurface.h" #include "conquer.h" #include "data.h" #include "dbgprint.h" #include "dict.h" -#include "audio/audioengine.h" #include "dsurface.h" #include "globals.h" #include "goptions.h" @@ -31,11 +32,9 @@ #include "language/language.h" #include "mainloop.h" #include "misc.h" -#include "msgroute.h" -#include "vidscale.h" -#include "video.h" #include "mixfile.h" #include "msgloop.h" +#include "msgroute.h" #include "rgb.h" #include "rules.h" #include "session.h" @@ -43,6 +42,8 @@ #include "theme.h" #include "ui/uishell.h" #include "utf8.h" +#include "video.h" +#include "vidscale.h" #include "voc.h" #include "vox.h" #include "windlg.h" @@ -6741,7 +6742,7 @@ int OwnerDraw::Release_Mouse(void) HWND OwnerDraw::Begin_Dialog(int id, DLGPROC proc) { // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. - assert(!UI_Screen_Shown()); + assert(!UIShell.Screen_Shown()); LPCDLGTEMPLATE templ = (LPCDLGTEMPLATE)Fetch_Resource(MAKEINTRESOURCE(id), (LPCSTR)RT_DIALOG); if (templ == NULL) { @@ -6822,7 +6823,7 @@ void OwnerDraw::End_Dialog(HWND window) ///
void OwnerDraw::Display_Dialog(HWND window) { - assert(!UI_Screen_Shown()); + assert(!UIShell.Screen_Shown()); ShowWindow(window, SW_SHOWNORMAL); SetForegroundWindow(window); @@ -6911,7 +6912,7 @@ bool OwnerDraw::Dialog_Message_Handler(void) Call_Back(); } - UI_Tick(); + UIShell.Tick(); return(false); } diff --git a/code/sounddlg.cpp b/code/sounddlg.cpp index 7d69d5c41..a95841049 100644 --- a/code/sounddlg.cpp +++ b/code/sounddlg.cpp @@ -36,6 +36,7 @@ #include "sounddlg.h" +#include "_ui.h" #include "dbgprint.h" #include "globals.h" #include "goptions.h" @@ -121,7 +122,7 @@ void SoundControlsClass::Dialog(void) { DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); - if (!UI_Use_Rml() || !UI_Sound_Dialog()) { + if (!UIShell.Use_Rml() || !UI_Sound_Dialog()) { Run_Win32_Dialog(); } diff --git a/code/startup.cpp b/code/startup.cpp index b76b4f918..a833c744c 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -45,6 +45,7 @@ #include "_rules.h" #include "_surface.h" #include "_tactica.h" +#include "_ui.h" #include "_zbuffer.h" #include "aircraft.h" #include "airctype.h" @@ -52,6 +53,7 @@ #include "alphashp.h" #include "anim.h" #include "animtype.h" +#include "audio/audioengine.h" #include "blight.h" #include "brain.h" #include "building.h" @@ -68,7 +70,6 @@ #include "deploymentconfig.h" #include "drive.h" #include "droppod.h" -#include "audio/audioengine.h" #include "dsurface.h" #include "empulse.h" #include "except.h" @@ -92,8 +93,8 @@ #include "light.h" #include "lightcon.h" #include "mech.h" -#include "mixfile.h" #include "misc.h" +#include "mixfile.h" #include "movie.h" #include "msgloop.h" #include "netdlg.h" // for Shutdown_Network. @@ -112,9 +113,9 @@ #include "shapeset.h" #include "side.h" #include "sidebar.h" -#include "spawner.h" #include "smudge.h" #include "smudtype.h" +#include "spawner.h" #include "sun.h" #include "super.h" #include "suprtype.h" @@ -138,13 +139,13 @@ #include "tube.h" #include "tunnel.h" #include "tutorial.h" +#include "ui/uishell.h" #include "unit.h" #include "unittype.h" #include "vanim.h" #include "vanimtype.h" #include "vector.h" #include "video.h" -#include "ui/uishell.h" #include "walk.h" #include "warhead.h" #include "wave.h" @@ -214,7 +215,7 @@ void Reset_Surfaces(void) VisibleSurface = NULL; } - UI_Shutdown(); + UIShell.Shutdown(); Video_Shutdown(); surfaces_reset = true; @@ -582,7 +583,7 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho } // The game runs without the UI shell; its own log says why it stayed off. - UI_Init(); + UIShell.Init(); do { Windows_Message_Handler(); diff --git a/code/ui/screens/display/uidisplaydlg.cpp b/code/ui/screens/display/uidisplaydlg.cpp index ba0bb621c..801215cdd 100644 --- a/code/ui/screens/display/uidisplaydlg.cpp +++ b/code/ui/screens/display/uidisplaydlg.cpp @@ -13,9 +13,11 @@ #include "ui/screens/display/uidisplay.h" +#include "_ui.h" #include "globals.h" #include "goptions.h" #include "ui/rml/rmlview.h" +#include "ui/uienginehost.h" #include "ui/uishell.h" #include "video.h" @@ -86,7 +88,7 @@ bool UI_Display_Dialog(std::optional & picked) { picked.reset(); - if (UI_Legacy_Dialog_Visible()) { + if (UIShell.Legacy_Dialog_Visible()) { return(false); } @@ -109,11 +111,11 @@ bool UI_Confirm_Mode_Dialog(bool & kept) { kept = false; - if (UI_Legacy_Dialog_Visible()) { + if (UIShell.Legacy_Dialog_Visible()) { return(false); } - UIConfirmModePresenterClass presenter(UI_Clock()); + UIConfirmModePresenterClass presenter(UIShell.Clock()); std::unique_ptr view = UI_Confirm_Mode_View(presenter); UIResult result = UI_Run_Modal(*view); diff --git a/code/ui/screens/gamectrl/uigamectrldlg.cpp b/code/ui/screens/gamectrl/uigamectrldlg.cpp index 05f40187e..37f441ab8 100644 --- a/code/ui/screens/gamectrl/uigamectrldlg.cpp +++ b/code/ui/screens/gamectrl/uigamectrldlg.cpp @@ -15,6 +15,7 @@ #include "_map.h" #include "_tooltip.h" +#include "_ui.h" #include "audio/audioengine.h" #include "cctooltip.h" #include "data.h" @@ -25,6 +26,7 @@ #include "session.h" #include "techno.h" #include "ui/rml/rmlview.h" +#include "ui/uienginehost.h" #include "ui/uishell.h" @@ -150,7 +152,7 @@ void UI_Game_Controls_State(UIGameControlsState & state) bool UI_Game_Controls_Dialog(void) { - if (UI_Legacy_Dialog_Visible()) { + if (UIShell.Legacy_Dialog_Visible()) { return(false); } diff --git a/code/ui/screens/keyboard/uikeyboarddlg.cpp b/code/ui/screens/keyboard/uikeyboarddlg.cpp index 6d27abf03..6c2cf3959 100644 --- a/code/ui/screens/keyboard/uikeyboarddlg.cpp +++ b/code/ui/screens/keyboard/uikeyboarddlg.cpp @@ -14,6 +14,7 @@ #include "ui/screens/keyboard/uikeyboard.h" #include "_command.h" +#include "_ui.h" #include "ccfile.h" #include "ccini.h" #include "cdfile.h" @@ -26,6 +27,7 @@ #include "msgbox.h" #include "ownrdraw.h" #include "ui/rml/rmlview.h" +#include "ui/uienginehost.h" #include "ui/uishell.h" #include "vector.h" @@ -130,7 +132,7 @@ void UI_Keyboard_State(UIKeyboardState & state) bool UI_Keyboard_Dialog(void) { - if (UI_Legacy_Dialog_Visible()) { + if (UIShell.Legacy_Dialog_Visible()) { return(false); } diff --git a/code/ui/screens/mainopt/uimainoptdlg.cpp b/code/ui/screens/mainopt/uimainoptdlg.cpp index d34834c2c..607970d5f 100644 --- a/code/ui/screens/mainopt/uimainoptdlg.cpp +++ b/code/ui/screens/mainopt/uimainoptdlg.cpp @@ -14,9 +14,11 @@ #include "ui/screens/mainopt/uimainopt.h" #include "_surface.h" +#include "_ui.h" #include "audio/audioengine.h" #include "surface.h" #include "ui/rml/rmlview.h" +#include "ui/uienginehost.h" #include "ui/uishell.h" @@ -36,7 +38,7 @@ bool UI_Main_Options_Dialog(UIMainOptionsChoice & choice) { choice = UI_MAIN_OPTIONS_LEAVE; - if (UI_Legacy_Dialog_Visible()) { + if (UIShell.Legacy_Dialog_Visible()) { return(false); } diff --git a/code/ui/screens/msgbox/uimsgboxdlg.cpp b/code/ui/screens/msgbox/uimsgboxdlg.cpp index fe2cc8cc4..b8552c087 100644 --- a/code/ui/screens/msgbox/uimsgboxdlg.cpp +++ b/code/ui/screens/msgbox/uimsgboxdlg.cpp @@ -13,7 +13,9 @@ #include "ui/screens/msgbox/uimsgbox.h" +#include "_ui.h" #include "ui/rml/rmlview.h" +#include "ui/uienginehost.h" #include "ui/uishell.h" #include @@ -35,7 +37,7 @@ bool UI_Message_Box(char const * text, int defaultresponse, char const * b1, cha } // A visible Win32 dialog takes the mouse before a document can, so a box over one stays Win32. - if (UI_Legacy_Dialog_Visible()) { + if (UIShell.Legacy_Dialog_Visible()) { return(false); } diff --git a/code/ui/screens/sound/uisounddlg.cpp b/code/ui/screens/sound/uisounddlg.cpp index 5c9af1c1e..21e7696f5 100644 --- a/code/ui/screens/sound/uisounddlg.cpp +++ b/code/ui/screens/sound/uisounddlg.cpp @@ -13,12 +13,14 @@ #include "ui/screens/sound/uisound.h" +#include "_ui.h" #include "audio/audioengine.h" #include "globals.h" #include "goptions.h" #include "incdec.h" #include "theme.h" #include "ui/rml/rmlview.h" +#include "ui/uienginehost.h" #include "ui/uishell.h" #include @@ -124,7 +126,7 @@ void UI_Sound_State(UISoundState & state) bool UI_Sound_Dialog(void) { - if (UI_Legacy_Dialog_Visible()) { + if (UIShell.Legacy_Dialog_Visible()) { return(false); } diff --git a/code/ui/screens/version/uiversiondlg.cpp b/code/ui/screens/version/uiversiondlg.cpp index 02f2ca3e0..6232fe343 100644 --- a/code/ui/screens/version/uiversiondlg.cpp +++ b/code/ui/screens/version/uiversiondlg.cpp @@ -13,12 +13,14 @@ #include "ui/screens/version/uiversion.h" +#include "_ui.h" #include "addon.h" #include "data.h" #include "getcpu.h" #include "globals.h" #include "language/language.h" #include "ui/rml/rmlview.h" +#include "ui/uienginehost.h" #include "ui/uishell.h" #include "version.h" diff --git a/code/ui/screens/waitbox/uiwaitboxdlg.cpp b/code/ui/screens/waitbox/uiwaitboxdlg.cpp index 99ad40646..fae976646 100644 --- a/code/ui/screens/waitbox/uiwaitboxdlg.cpp +++ b/code/ui/screens/waitbox/uiwaitboxdlg.cpp @@ -13,6 +13,7 @@ #include "ui/screens/waitbox/uiwaitbox.h" +#include "_ui.h" #include "ownrdraw.h" #include "ui/rml/rmlview.h" #include "ui/uishell.h" @@ -50,14 +51,14 @@ bool UIWaitBoxClass::Show_Document(char const * text, bool bar) Hide(); // A visible Win32 dialog takes the mouse before a document can, so a notice over one stays Win32. - if (!UI_Use_Rml() || UI_Legacy_Dialog_Visible()) { + if (!UIShell.Use_Rml() || UIShell.Legacy_Dialog_Visible()) { return(false); } Presenter = std::make_unique((text != NULL) ? text : "", bar); View = UI_Wait_Box_View(*Presenter); - if (!UI_Show_Modeless(*View)) { + if (!UIShell.Show_Modeless(*View)) { View.reset(); Presenter.reset(); return(false); @@ -71,7 +72,7 @@ void UIWaitBoxClass::Set_Text(char const * text) if (View != nullptr) { Presenter->Text = (text != NULL) ? text : ""; View->Sync(); - UI_Refresh(); + UIShell.Refresh(); } else if (Dialog != NULL) { OwnerDraw::Set_Custom_Message_Box_Text(Dialog, text); } @@ -83,7 +84,7 @@ void UIWaitBoxClass::Set_Fraction(double fraction) if (View != nullptr) { Presenter->Set_Fraction(fraction); View->Sync(); - UI_Refresh(); + UIShell.Refresh(); } } @@ -91,7 +92,7 @@ void UIWaitBoxClass::Set_Fraction(double fraction) void UIWaitBoxClass::Hide(void) { if (View != nullptr) { - UI_Hide_Modeless(*View); + UIShell.Hide_Modeless(*View); View.reset(); Presenter.reset(); } diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index 345c0990f..261d1ca68 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -10,6 +10,7 @@ #include "ui/uienginehost.h" #include "_keyboar.h" +#include "_ui.h" #include "conquer.h" #include "data.h" #include "dbgprint.h" @@ -166,5 +167,5 @@ bool UI_Service_Game(void) UIResult UI_Run_Modal(UIRmlViewClass & view) { - return(UI_Run_Modal(view, UI_Service_Game)); + return(UIShell.Run_Modal(view, UI_Service_Game)); } diff --git a/code/ui/uienginehost.h b/code/ui/uienginehost.h index e9438034c..25b25dc74 100644 --- a/code/ui/uienginehost.h +++ b/code/ui/uienginehost.h @@ -13,6 +13,9 @@ #pragma once #include "ui/uihost.h" +#include "ui/uiscreen.h" + +class UIRmlViewClass; UIShellHostClass & UI_Engine_Host(void); @@ -20,3 +23,6 @@ UIShellHostClass & UI_Engine_Host(void); // One pass of the game under a modal screen: the message pump, then Main_Loop in a network // session or Call_Back otherwise. True when the game ended. bool UI_Service_Game(void); + +// Runs a modal screen on UIShell with the game serviced each pass. +UIResult UI_Run_Modal(UIRmlViewClass & view); diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 44c4222e1..a8872ab22 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -9,7 +9,6 @@ #include "ui/uishell.h" -#include "_ui.h" #include "ui/dev/uidev.h" #include "ui/rml/rmlkeys.h" #include "ui/rml/rmlrender.h" @@ -985,93 +984,3 @@ bool UIShellClass::Intercept_Pumped_Message(MSG const & msg) #endif return(false); } - - -bool UI_Init(void) -{ - return(UIShell.Init()); -} - - -void UI_Shutdown(void) -{ - UIShell.Shutdown(); -} - - -bool UI_Use_Rml(void) -{ - return(UIShell.Use_Rml()); -} - - -bool UI_Screen_Shown(void) -{ - return(UIShell.Screen_Shown()); -} - - -bool UI_Legacy_Dialog_Visible(void) -{ - return(UIShell.Legacy_Dialog_Visible()); -} - - -UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) -{ - return(UIShell.Run_Modal(view, service)); -} - - -bool UI_Show_Modeless(UIRmlViewClass & view) -{ - return(UIShell.Show_Modeless(view)); -} - - -void UI_Hide_Modeless(UIRmlViewClass & view) -{ - UIShell.Hide_Modeless(view); -} - - -void UI_Refresh(void) -{ - UIShell.Refresh(); -} - - -UIClockClass & UI_Clock(void) -{ - return(UIShell.Clock()); -} - - -void UI_On_Video_Change(void) -{ - UIShell.On_Video_Change(); -} - - -void UI_Tick(void) -{ - UIShell.Tick(); -} - - -void UI_Render_Overlay(void) -{ - UIShell.Render_Overlay(); -} - - -bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) -{ - return(UIShell.Handle_Window_Message(hwnd, message, wparam, clientlparam)); -} - - -bool UI_Intercept_Pumped_Message(MSG const & msg) -{ - return(UIShell.Intercept_Pumped_Message(msg)); -} diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 99c04e607..5e8c59e8a 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -180,22 +180,3 @@ class UIShellClass Rml::ElementDocument * TestDocument = nullptr; std::unique_ptr TestListener; }; - - -// The free functions the engine calls today; each forwards to UIShell. -bool UI_Init(void); -void UI_Shutdown(void); -bool UI_Use_Rml(void); -bool UI_Screen_Shown(void); -bool UI_Legacy_Dialog_Visible(void); -UIResult UI_Run_Modal(UIRmlViewClass & view); -UIResult UI_Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service); -bool UI_Show_Modeless(UIRmlViewClass & view); -void UI_Hide_Modeless(UIRmlViewClass & view); -void UI_Refresh(void); -UIClockClass & UI_Clock(void); -void UI_On_Video_Change(void); -void UI_Tick(void); -void UI_Render_Overlay(void); -bool UI_Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam); -bool UI_Intercept_Pumped_Message(MSG const & msg); diff --git a/code/video.cpp b/code/video.cpp index 179c8454c..2e06aeed9 100644 --- a/code/video.cpp +++ b/code/video.cpp @@ -16,6 +16,7 @@ #include "video.h" #include "_surface.h" +#include "_ui.h" #include "bgfxbackend.h" #include "dbgprint.h" #include "dsurface.h" @@ -227,7 +228,7 @@ bool Video_Set_Mode(int width, int height) Update_Scale_Info(); Win_Cursor_Refresh(); - UI_On_Video_Change(); + UIShell.On_Video_Change(); _FrameIsDirty = true; return(true); } @@ -247,7 +248,7 @@ void Video_On_Resize(int drawablewidth, int drawableheight) Backend_On_Resize(drawablewidth, drawableheight); Update_Scale_Info(); Win_Cursor_Refresh(); - UI_On_Video_Change(); + UIShell.On_Video_Change(); Video_Mark_Dirty(); } @@ -316,7 +317,7 @@ static void Present(bool uploadframe) _Presenting = true; if (Backend_Present(uploadframe ? pixels : NULL, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode())) { - UI_Render_Overlay(); + UIShell.Render_Overlay(); Backend_End_Frame(); } _Presenting = false; diff --git a/code/windlg.cpp b/code/windlg.cpp index 7ba535c45..3e3c68f6b 100644 --- a/code/windlg.cpp +++ b/code/windlg.cpp @@ -11,6 +11,7 @@ #include "windlg.h" +#include "_ui.h" #include "arraylist.h" #include "data.h" #include "globals.h" @@ -99,7 +100,7 @@ inline int WS_Dialog_Index(HWND window) HWND WS_Create_Dialog(HINSTANCE instance, int id, HWND parent, DLGPROC proc, BOOL force_show) { // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. - assert(!UI_Screen_Shown()); + assert(!UIShell.Screen_Shown()); WSDialogStruct *slot = &g_Dialogs[g_DialogCount]; g_Dialogs[g_DialogCount].handle = 0; diff --git a/code/winstub.cpp b/code/winstub.cpp index 690a033c5..0278670c9 100644 --- a/code/winstub.cpp +++ b/code/winstub.cpp @@ -48,21 +48,25 @@ #include "_map.h" #include "_rect.h" #include "_tooltip.h" +#include "_ui.h" +#include "audio/audioengine.h" #include "ccfile.h" #include "cctooltip.h" +#include "conquer.h" #include "convert.h" #include "dbgprint.h" #include "draw.h" -#include "audio/audioengine.h" #include "dsurface.h" #include "except.h" #include "gamewindow.h" #include "globals.h" #include "goptions.h" +#include "mainopt.h" #include "misc.h" #include "movie.h" #include "msgroute.h" #include "nativewindow.hh" +#include "opents_version.h" #include "pcx.h" #include "queue.h" #include "resource.h" @@ -75,9 +79,6 @@ #include "windlg.h" #include "winfix.h" #include "wwmouse.h" -#include "mainopt.h" -#include "conquer.h" -#include "opents_version.h" #include #include @@ -192,7 +193,7 @@ LRESULT CALLBACK /*_export*/ Windows_Procedure(HWND hwnd, UINT message, WPARAM w lParam = translated_lparam; } - if (UI_Handle_Window_Message(hwnd, message, wParam, client_lparam)) { + if (UIShell.Handle_Window_Message(hwnd, message, wParam, client_lparam)) { return(0); } diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 0043174f3..31bca8f11 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -176,6 +176,20 @@ Dependency rules: A read-only screen needs a data builder and a close result. A presenter with actions is added only where a screen has real state transitions. +### Shell object + +`UIShellClass` (`uishell.h`) holds the shell's state: the RmlUi context, the +injected system, file and render interfaces, the re-entry guards, the work +deferred while the context runs, the modal stack and the modeless list. The +engine's one instance is `UIShell`, declared `extern` in `code/_ui.h` and +defined in `code/_ui.cpp` over `UI_Engine_Host()`; callers write +`UIShell.Tick()` or `UIShell.Use_Rml()`. What the shell needs from the program +around it comes through `UIShellHostClass` (`uihost.h`), so a harness builds +its own `UIShellClass` over a host and interfaces it controls and never links +the engine. A modal screen's engine entry calls `UI_Run_Modal(view)` from +`uienginehost.h`, which runs the screen on `UIShell` with `UI_Service_Game` +as the service pass. + ### Code layout Sources live under `code/ui/`, grouped by what they may include. The From e48538a15c071de6a4bd52eeebc46e5beaac28f4 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:33:09 +0300 Subject: [PATCH 24/52] Run views through a toolkit-neutral UIViewClass --- code/ui/rml/rmlview.cpp | 13 ++ code/ui/rml/rmlview.h | 19 +-- code/ui/screens/display/uidisplay.cpp | 4 +- code/ui/screens/display/uidisplay.h | 6 +- code/ui/screens/display/uidisplaydlg.cpp | 6 +- code/ui/screens/gamectrl/uigamectrl.cpp | 2 +- code/ui/screens/gamectrl/uigamectrl.h | 4 +- code/ui/screens/gamectrl/uigamectrldlg.cpp | 4 +- code/ui/screens/keyboard/uikeyboard.cpp | 2 +- code/ui/screens/keyboard/uikeyboard.h | 4 +- code/ui/screens/keyboard/uikeyboarddlg.cpp | 4 +- code/ui/screens/mainopt/uimainopt.cpp | 2 +- code/ui/screens/mainopt/uimainopt.h | 4 +- code/ui/screens/mainopt/uimainoptdlg.cpp | 4 +- code/ui/screens/msgbox/uimsgbox.cpp | 2 +- code/ui/screens/msgbox/uimsgbox.h | 4 +- code/ui/screens/msgbox/uimsgboxdlg.cpp | 4 +- code/ui/screens/sound/uisound.cpp | 2 +- code/ui/screens/sound/uisound.h | 4 +- code/ui/screens/sound/uisounddlg.cpp | 4 +- code/ui/screens/version/uiversion.cpp | 2 +- code/ui/screens/version/uiversion.h | 4 +- code/ui/screens/version/uiversiondlg.cpp | 4 +- code/ui/screens/waitbox/uiwaitbox.cpp | 2 +- code/ui/screens/waitbox/uiwaitbox.h | 6 +- code/ui/screens/waitbox/uiwaitboxdlg.cpp | 2 +- code/ui/uienginehost.cpp | 2 +- code/ui/uienginehost.h | 4 +- code/ui/uishell.cpp | 28 ++-- code/ui/uishell.h | 16 +-- code/ui/uiview.h | 38 ++++++ docs/UI_DESIGN.md | 20 ++- tests/uishell/uishell.cpp | 141 +++++++++++---------- 33 files changed, 222 insertions(+), 145 deletions(-) create mode 100644 code/ui/uiview.h diff --git a/code/ui/rml/rmlview.cpp b/code/ui/rml/rmlview.cpp index 458249140..4e272afca 100644 --- a/code/ui/rml/rmlview.cpp +++ b/code/ui/rml/rmlview.cpp @@ -9,6 +9,13 @@ #include "ui/rml/rmlview.h" +#include "ui/uishell.h" + +// windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its +// element walkers. +#undef GetFirstChild +#undef GetNextSibling + #include #include #include @@ -74,6 +81,12 @@ bool UIRmlViewClass::Prepare(Rml::Context & context) } +bool UIRmlViewClass::Prepare(UIShellClass & shell) +{ + return(shell.Rml_Context() != nullptr && Prepare(*shell.Rml_Context())); +} + + void UIRmlViewClass::Show(bool modal) { if (Doc != nullptr) { diff --git a/code/ui/rml/rmlview.h b/code/ui/rml/rmlview.h index 5a96f82eb..7bc0f29a3 100644 --- a/code/ui/rml/rmlview.h +++ b/code/ui/rml/rmlview.h @@ -10,6 +10,7 @@ #pragma once #include "ui/uiscreen.h" +#include "ui/uiview.h" // windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its // element walkers. @@ -33,28 +34,30 @@ namespace Rml // One document and its data model over a presenter. Prepare loads it against a context, the // runner shows, drives and releases it, and the presenter outlives it. -class UIRmlViewClass : public Rml::EventListener +class UIRmlViewClass : public Rml::EventListener, public UIViewClass { public: UIRmlViewClass(UIPresenterClass & presenter, char const * document, char const * model); virtual ~UIRmlViewClass(void); // Creates and binds the model, then loads the document. False leaves nothing behind and - // names the failing resource in the RmlUi log. + // names the failing resource in the RmlUi log. The shell form loads against its context. bool Prepare(Rml::Context & context); - void Show(bool modal); - void Hide(void); + virtual bool Prepare(UIShellClass & shell) override; + virtual void Show(bool modal) override; + virtual void Hide(void) override; // Detaches the listener, removes the model and unloads the document while the // presenter's storage still lives; the context frees the document on its next update. - void Release(void); + virtual void Release(void) override; - UIPresenterClass & Presenter(void) const { return(Owner); } + virtual UIPresenterClass & Presenter(void) const override { return(Owner); } Rml::ElementDocument * Document(void) const { return(Doc); } char const * Document_Name(void) const { return(DocumentName.c_str()); } - bool Is_Shown(void) const; + virtual char const * Name(void) const override { return(DocumentName.c_str()); } + virtual bool Is_Shown(void) const override; // Marks the view-model fields that Execute changed. - virtual void Sync(void) = 0; + virtual void Sync(void) override = 0; protected: // Binds the view-model fields; the base binds the queue event. diff --git a/code/ui/screens/display/uidisplay.cpp b/code/ui/screens/display/uidisplay.cpp index 3f2770cb7..800b750a8 100644 --- a/code/ui/screens/display/uidisplay.cpp +++ b/code/ui/screens/display/uidisplay.cpp @@ -151,13 +151,13 @@ class UIConfirmModeViewClass : public UIRmlViewClass } -std::unique_ptr UI_Display_View(UIDisplayPresenterClass & presenter) +std::unique_ptr UI_Display_View(UIDisplayPresenterClass & presenter) { return(std::make_unique(presenter)); } -std::unique_ptr UI_Confirm_Mode_View(UIConfirmModePresenterClass & presenter) +std::unique_ptr UI_Confirm_Mode_View(UIConfirmModePresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/display/uidisplay.h b/code/ui/screens/display/uidisplay.h index 042342c49..ea678d245 100644 --- a/code/ui/screens/display/uidisplay.h +++ b/code/ui/screens/display/uidisplay.h @@ -16,7 +16,7 @@ #include #include -class UIRmlViewClass; +class UIViewClass; // One row of the resolution list: the mode and its label as the dialog prints it. @@ -96,8 +96,8 @@ class UIConfirmModePresenterClass : public UIPresenterClass // The RmlUi views, bound to display.rml and confirm.rml. The presenter must outlive its view. -std::unique_ptr UI_Display_View(UIDisplayPresenterClass & presenter); -std::unique_ptr UI_Confirm_Mode_View(UIConfirmModePresenterClass & presenter); +std::unique_ptr UI_Display_View(UIDisplayPresenterClass & presenter); +std::unique_ptr UI_Confirm_Mode_View(UIConfirmModePresenterClass & presenter); // The game's service and the state of the display, shared by the Win32 dialog and the RmlUi // view. diff --git a/code/ui/screens/display/uidisplaydlg.cpp b/code/ui/screens/display/uidisplaydlg.cpp index 801215cdd..2a5aa9f4b 100644 --- a/code/ui/screens/display/uidisplaydlg.cpp +++ b/code/ui/screens/display/uidisplaydlg.cpp @@ -16,9 +16,9 @@ #include "_ui.h" #include "globals.h" #include "goptions.h" -#include "ui/rml/rmlview.h" #include "ui/uienginehost.h" #include "ui/uishell.h" +#include "ui/uiview.h" #include "video.h" #include @@ -96,7 +96,7 @@ bool UI_Display_Dialog(std::optional & picked) UI_Display_State(state); UIDisplayPresenterClass presenter(UI_Display_Service(), state); - std::unique_ptr view = UI_Display_View(presenter); + std::unique_ptr view = UI_Display_View(presenter); if (UI_Run_Modal(*view) == UI_RESULT_FAILED_TO_OPEN) { return(false); @@ -116,7 +116,7 @@ bool UI_Confirm_Mode_Dialog(bool & kept) } UIConfirmModePresenterClass presenter(UIShell.Clock()); - std::unique_ptr view = UI_Confirm_Mode_View(presenter); + std::unique_ptr view = UI_Confirm_Mode_View(presenter); UIResult result = UI_Run_Modal(*view); if (result == UI_RESULT_FAILED_TO_OPEN) { diff --git a/code/ui/screens/gamectrl/uigamectrl.cpp b/code/ui/screens/gamectrl/uigamectrl.cpp index 49353d325..db16632f7 100644 --- a/code/ui/screens/gamectrl/uigamectrl.cpp +++ b/code/ui/screens/gamectrl/uigamectrl.cpp @@ -166,7 +166,7 @@ class UIGameControlsViewClass : public UIRmlViewClass } -std::unique_ptr UI_Game_Controls_View(UIGameControlsPresenterClass & presenter) +std::unique_ptr UI_Game_Controls_View(UIGameControlsPresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/gamectrl/uigamectrl.h b/code/ui/screens/gamectrl/uigamectrl.h index 3d11f482f..e8a3967be 100644 --- a/code/ui/screens/gamectrl/uigamectrl.h +++ b/code/ui/screens/gamectrl/uigamectrl.h @@ -15,7 +15,7 @@ #include #include -class UIRmlViewClass; +class UIViewClass; // The engine calls the game controls dialog makes when the player accepts. The game supplies one @@ -104,7 +104,7 @@ class UIGameControlsPresenterClass : public UIPresenterClass // The RmlUi view over a game controls presenter, bound to gamectrl.rml. The presenter must // outlive it. -std::unique_ptr UI_Game_Controls_View(UIGameControlsPresenterClass & presenter); +std::unique_ptr UI_Game_Controls_View(UIGameControlsPresenterClass & presenter); // The game's service and the state of the running game, shared by the Win32 dialog and the // RmlUi view. diff --git a/code/ui/screens/gamectrl/uigamectrldlg.cpp b/code/ui/screens/gamectrl/uigamectrldlg.cpp index 37f441ab8..80fc9e7a2 100644 --- a/code/ui/screens/gamectrl/uigamectrldlg.cpp +++ b/code/ui/screens/gamectrl/uigamectrldlg.cpp @@ -25,9 +25,9 @@ #include "queue.h" #include "session.h" #include "techno.h" -#include "ui/rml/rmlview.h" #include "ui/uienginehost.h" #include "ui/uishell.h" +#include "ui/uiview.h" namespace @@ -160,7 +160,7 @@ bool UI_Game_Controls_Dialog(void) UI_Game_Controls_State(state); UIGameControlsPresenterClass presenter(UI_Game_Controls_Service(), state); - std::unique_ptr view = UI_Game_Controls_View(presenter); + std::unique_ptr view = UI_Game_Controls_View(presenter); if (UI_Run_Modal(*view) == UI_RESULT_FAILED_TO_OPEN) { return(false); diff --git a/code/ui/screens/keyboard/uikeyboard.cpp b/code/ui/screens/keyboard/uikeyboard.cpp index 6e32dd4d2..c46e013db 100644 --- a/code/ui/screens/keyboard/uikeyboard.cpp +++ b/code/ui/screens/keyboard/uikeyboard.cpp @@ -288,7 +288,7 @@ class UIKeyboardViewClass : public UIRmlViewClass } -std::unique_ptr UI_Keyboard_View(UIKeyboardPresenterClass & presenter) +std::unique_ptr UI_Keyboard_View(UIKeyboardPresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/keyboard/uikeyboard.h b/code/ui/screens/keyboard/uikeyboard.h index b7da1be43..657e4646e 100644 --- a/code/ui/screens/keyboard/uikeyboard.h +++ b/code/ui/screens/keyboard/uikeyboard.h @@ -15,7 +15,7 @@ #include #include -class UIRmlViewClass; +class UIViewClass; // One game command as the keyboard dialog lists it. Its index in the state is the command's @@ -109,7 +109,7 @@ class UIKeyboardPresenterClass : public UIPresenterClass // The RmlUi view over a keyboard presenter, bound to keyboard.rml. The presenter must outlive // it. -std::unique_ptr UI_Keyboard_View(UIKeyboardPresenterClass & presenter); +std::unique_ptr UI_Keyboard_View(UIKeyboardPresenterClass & presenter); // The game's service and the state of the hotkey table, shared by the Win32 dialog and the // RmlUi view. diff --git a/code/ui/screens/keyboard/uikeyboarddlg.cpp b/code/ui/screens/keyboard/uikeyboarddlg.cpp index 6c2cf3959..79f67feb4 100644 --- a/code/ui/screens/keyboard/uikeyboarddlg.cpp +++ b/code/ui/screens/keyboard/uikeyboarddlg.cpp @@ -26,9 +26,9 @@ #include "language/language.h" #include "msgbox.h" #include "ownrdraw.h" -#include "ui/rml/rmlview.h" #include "ui/uienginehost.h" #include "ui/uishell.h" +#include "ui/uiview.h" #include "vector.h" @@ -140,7 +140,7 @@ bool UI_Keyboard_Dialog(void) UI_Keyboard_State(state); UIKeyboardPresenterClass presenter(UI_Keyboard_Service(), state); - std::unique_ptr view = UI_Keyboard_View(presenter); + std::unique_ptr view = UI_Keyboard_View(presenter); return(UI_Run_Modal(*view) != UI_RESULT_FAILED_TO_OPEN); } diff --git a/code/ui/screens/mainopt/uimainopt.cpp b/code/ui/screens/mainopt/uimainopt.cpp index 9a82490ee..82ddf1b2f 100644 --- a/code/ui/screens/mainopt/uimainopt.cpp +++ b/code/ui/screens/mainopt/uimainopt.cpp @@ -101,7 +101,7 @@ class UIMainOptionsViewClass : public UIRmlViewClass } -std::unique_ptr UI_Main_Options_View(UIMainOptionsPresenterClass & presenter) +std::unique_ptr UI_Main_Options_View(UIMainOptionsPresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/mainopt/uimainopt.h b/code/ui/screens/mainopt/uimainopt.h index db40808ec..4dfc4a21b 100644 --- a/code/ui/screens/mainopt/uimainopt.h +++ b/code/ui/screens/mainopt/uimainopt.h @@ -13,7 +13,7 @@ #include -class UIRmlViewClass; +class UIViewClass; // What the options menu answers with: the dialog to open next, or the way back to the menu. @@ -53,7 +53,7 @@ class UIMainOptionsPresenterClass : public UIPresenterClass // The RmlUi view over an options menu presenter, bound to mainopt.rml. The presenter must // outlive it. -std::unique_ptr UI_Main_Options_View(UIMainOptionsPresenterClass & presenter); +std::unique_ptr UI_Main_Options_View(UIMainOptionsPresenterClass & presenter); // The state of the running game. void UI_Main_Options_State(UIMainOptionsState & state); diff --git a/code/ui/screens/mainopt/uimainoptdlg.cpp b/code/ui/screens/mainopt/uimainoptdlg.cpp index 607970d5f..380c2e888 100644 --- a/code/ui/screens/mainopt/uimainoptdlg.cpp +++ b/code/ui/screens/mainopt/uimainoptdlg.cpp @@ -17,9 +17,9 @@ #include "_ui.h" #include "audio/audioengine.h" #include "surface.h" -#include "ui/rml/rmlview.h" #include "ui/uienginehost.h" #include "ui/uishell.h" +#include "ui/uiview.h" // The menu sits where the main menu's buttons were: a 400 pixel layout centred in the @@ -46,7 +46,7 @@ bool UI_Main_Options_Dialog(UIMainOptionsChoice & choice) UI_Main_Options_State(state); UIMainOptionsPresenterClass presenter(state); - std::unique_ptr view = UI_Main_Options_View(presenter); + std::unique_ptr view = UI_Main_Options_View(presenter); UIResult result = UI_Run_Modal(*view); if (result == UI_RESULT_FAILED_TO_OPEN) { diff --git a/code/ui/screens/msgbox/uimsgbox.cpp b/code/ui/screens/msgbox/uimsgbox.cpp index 3b15b1e3a..4ac615996 100644 --- a/code/ui/screens/msgbox/uimsgbox.cpp +++ b/code/ui/screens/msgbox/uimsgbox.cpp @@ -108,7 +108,7 @@ class UIMessageBoxViewClass : public UIRmlViewClass } -std::unique_ptr UI_Message_Box_View(UIMessageBoxPresenterClass & presenter) +std::unique_ptr UI_Message_Box_View(UIMessageBoxPresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/msgbox/uimsgbox.h b/code/ui/screens/msgbox/uimsgbox.h index 896a22c6d..c6c3199fb 100644 --- a/code/ui/screens/msgbox/uimsgbox.h +++ b/code/ui/screens/msgbox/uimsgbox.h @@ -15,7 +15,7 @@ #include #include -class UIRmlViewClass; +class UIViewClass; // One button of a message box in its display position: slot 0 is the left, 1 the middle and @@ -50,7 +50,7 @@ class UIMessageBoxPresenterClass : public UIPresenterClass // The RmlUi view over a message box presenter, bound to message.rml. The presenter must // outlive it. -std::unique_ptr UI_Message_Box_View(UIMessageBoxPresenterClass & presenter); +std::unique_ptr UI_Message_Box_View(UIMessageBoxPresenterClass & presenter); // Runs a message box as an RmlUi screen. False means it could not run as one, because a Win32 // dialog is on screen or the document failed to prepare, and the caller should open its Win32 diff --git a/code/ui/screens/msgbox/uimsgboxdlg.cpp b/code/ui/screens/msgbox/uimsgboxdlg.cpp index b8552c087..bc5b9770e 100644 --- a/code/ui/screens/msgbox/uimsgboxdlg.cpp +++ b/code/ui/screens/msgbox/uimsgboxdlg.cpp @@ -14,9 +14,9 @@ #include "ui/screens/msgbox/uimsgbox.h" #include "_ui.h" -#include "ui/rml/rmlview.h" #include "ui/uienginehost.h" #include "ui/uishell.h" +#include "ui/uiview.h" #include @@ -41,7 +41,7 @@ bool UI_Message_Box(char const * text, int defaultresponse, char const * b1, cha return(false); } - std::unique_ptr view = UI_Message_Box_View(presenter); + std::unique_ptr view = UI_Message_Box_View(presenter); UIResult result = UI_Run_Modal(*view); if (result == UI_RESULT_FAILED_TO_OPEN) { diff --git a/code/ui/screens/sound/uisound.cpp b/code/ui/screens/sound/uisound.cpp index 239a21fbf..bb6c3b743 100644 --- a/code/ui/screens/sound/uisound.cpp +++ b/code/ui/screens/sound/uisound.cpp @@ -162,7 +162,7 @@ class UISoundViewClass : public UIRmlViewClass } -std::unique_ptr UI_Sound_View(UISoundPresenterClass & presenter) +std::unique_ptr UI_Sound_View(UISoundPresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/sound/uisound.h b/code/ui/screens/sound/uisound.h index e32bda737..111c2139f 100644 --- a/code/ui/screens/sound/uisound.h +++ b/code/ui/screens/sound/uisound.h @@ -15,7 +15,7 @@ #include #include -class UIRmlViewClass; +class UIViewClass; // The engine calls the sound options make. The game supplies one that reaches the options @@ -86,7 +86,7 @@ class UISoundPresenterClass : public UIPresenterClass // The RmlUi view over a sound presenter, bound to sound.rml. The presenter must outlive it. -std::unique_ptr UI_Sound_View(UISoundPresenterClass & presenter); +std::unique_ptr UI_Sound_View(UISoundPresenterClass & presenter); // The game's service and the state of the running game, shared by the Win32 dialog and the // RmlUi view. diff --git a/code/ui/screens/sound/uisounddlg.cpp b/code/ui/screens/sound/uisounddlg.cpp index 21e7696f5..666d18b6b 100644 --- a/code/ui/screens/sound/uisounddlg.cpp +++ b/code/ui/screens/sound/uisounddlg.cpp @@ -19,9 +19,9 @@ #include "goptions.h" #include "incdec.h" #include "theme.h" -#include "ui/rml/rmlview.h" #include "ui/uienginehost.h" #include "ui/uishell.h" +#include "ui/uiview.h" #include @@ -134,7 +134,7 @@ bool UI_Sound_Dialog(void) UI_Sound_State(state); UISoundPresenterClass presenter(UI_Sound_Service(), state); - std::unique_ptr view = UI_Sound_View(presenter); + std::unique_ptr view = UI_Sound_View(presenter); return(UI_Run_Modal(*view) != UI_RESULT_FAILED_TO_OPEN); } diff --git a/code/ui/screens/version/uiversion.cpp b/code/ui/screens/version/uiversion.cpp index a65e876a8..48fc81b4b 100644 --- a/code/ui/screens/version/uiversion.cpp +++ b/code/ui/screens/version/uiversion.cpp @@ -64,7 +64,7 @@ class UIVersionViewClass : public UIRmlViewClass } -std::unique_ptr UI_Version_View(UIVersionPresenterClass & presenter) +std::unique_ptr UI_Version_View(UIVersionPresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/version/uiversion.h b/code/ui/screens/version/uiversion.h index a369beacc..e80ace08b 100644 --- a/code/ui/screens/version/uiversion.h +++ b/code/ui/screens/version/uiversion.h @@ -15,7 +15,7 @@ #include #include -class UIRmlViewClass; +class UIViewClass; // Shows fixed lines and closes. The lines are handed in, so the presenter needs no engine state. @@ -32,7 +32,7 @@ class UIVersionPresenterClass : public UIPresenterClass // The RmlUi view over a version presenter, bound to version.rml. The presenter must outlive it. -std::unique_ptr UI_Version_View(UIVersionPresenterClass & presenter); +std::unique_ptr UI_Version_View(UIVersionPresenterClass & presenter); // The lines the version dialog shows, gathered from the running game. void UI_Version_Lines(std::vector & lines); diff --git a/code/ui/screens/version/uiversiondlg.cpp b/code/ui/screens/version/uiversiondlg.cpp index 6232fe343..d900bd185 100644 --- a/code/ui/screens/version/uiversiondlg.cpp +++ b/code/ui/screens/version/uiversiondlg.cpp @@ -19,9 +19,9 @@ #include "getcpu.h" #include "globals.h" #include "language/language.h" -#include "ui/rml/rmlview.h" #include "ui/uienginehost.h" #include "ui/uishell.h" +#include "ui/uiview.h" #include "version.h" #include "opents_build.h" @@ -74,7 +74,7 @@ bool UI_Version_Dialog(void) UI_Version_Lines(lines); UIVersionPresenterClass presenter(std::move(lines)); - std::unique_ptr view = UI_Version_View(presenter); + std::unique_ptr view = UI_Version_View(presenter); return(UI_Run_Modal(*view) != UI_RESULT_FAILED_TO_OPEN); } diff --git a/code/ui/screens/waitbox/uiwaitbox.cpp b/code/ui/screens/waitbox/uiwaitbox.cpp index 58f5b7eb6..a94b07199 100644 --- a/code/ui/screens/waitbox/uiwaitbox.cpp +++ b/code/ui/screens/waitbox/uiwaitbox.cpp @@ -73,7 +73,7 @@ class UIWaitBoxViewClass : public UIRmlViewClass } -std::unique_ptr UI_Wait_Box_View(UIWaitBoxPresenterClass & presenter) +std::unique_ptr UI_Wait_Box_View(UIWaitBoxPresenterClass & presenter) { return(std::make_unique(presenter)); } diff --git a/code/ui/screens/waitbox/uiwaitbox.h b/code/ui/screens/waitbox/uiwaitbox.h index 6e947339c..b1a14e675 100644 --- a/code/ui/screens/waitbox/uiwaitbox.h +++ b/code/ui/screens/waitbox/uiwaitbox.h @@ -15,7 +15,7 @@ #include #include -class UIRmlViewClass; +class UIViewClass; // A notice shown while the game works: a line of text and, when asked for, a bar. It takes @@ -38,7 +38,7 @@ class UIWaitBoxPresenterClass : public UIPresenterClass // The RmlUi view over a wait box presenter, bound to wait.rml. The presenter must outlive it. -std::unique_ptr UI_Wait_Box_View(UIWaitBoxPresenterClass & presenter); +std::unique_ptr UI_Wait_Box_View(UIWaitBoxPresenterClass & presenter); // The notice a caller shows while it works: a document when the shell can draw one and no @@ -61,5 +61,5 @@ class UIWaitBoxClass private: HWND Dialog; std::unique_ptr Presenter; - std::unique_ptr View; + std::unique_ptr View; }; diff --git a/code/ui/screens/waitbox/uiwaitboxdlg.cpp b/code/ui/screens/waitbox/uiwaitboxdlg.cpp index fae976646..9db26ac65 100644 --- a/code/ui/screens/waitbox/uiwaitboxdlg.cpp +++ b/code/ui/screens/waitbox/uiwaitboxdlg.cpp @@ -15,8 +15,8 @@ #include "_ui.h" #include "ownrdraw.h" -#include "ui/rml/rmlview.h" #include "ui/uishell.h" +#include "ui/uiview.h" UIWaitBoxClass::UIWaitBoxClass(void) : diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index 261d1ca68..cfbcef48e 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -165,7 +165,7 @@ bool UI_Service_Game(void) } -UIResult UI_Run_Modal(UIRmlViewClass & view) +UIResult UI_Run_Modal(UIViewClass & view) { return(UIShell.Run_Modal(view, UI_Service_Game)); } diff --git a/code/ui/uienginehost.h b/code/ui/uienginehost.h index 25b25dc74..58db304a7 100644 --- a/code/ui/uienginehost.h +++ b/code/ui/uienginehost.h @@ -15,7 +15,7 @@ #include "ui/uihost.h" #include "ui/uiscreen.h" -class UIRmlViewClass; +class UIViewClass; UIShellHostClass & UI_Engine_Host(void); @@ -25,4 +25,4 @@ UIShellHostClass & UI_Engine_Host(void); bool UI_Service_Game(void); // Runs a modal screen on UIShell with the game serviced each pass. -UIResult UI_Run_Modal(UIRmlViewClass & view); +UIResult UI_Run_Modal(UIViewClass & view); diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index a8872ab22..5e6e86003 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -13,8 +13,8 @@ #include "ui/rml/rmlkeys.h" #include "ui/rml/rmlrender.h" #include "ui/rml/rmlsystem.h" -#include "ui/rml/rmlview.h" #include "ui/uihost.h" +#include "ui/uiview.h" // windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its // element walkers. @@ -377,7 +377,7 @@ void UIShellClass::Shutdown(void) // The documents go while the context still exists; a caller hiding its notice // afterwards finds nothing to do. - for (UIRmlViewClass * view : Modeless) { + for (UIViewClass * view : Modeless) { view->Release(); } Modeless.clear(); @@ -414,7 +414,7 @@ bool UIShellClass::Legacy_Dialog_Visible(void) const } -UIRmlViewClass * UIShellClass::Modal(void) const +UIViewClass * UIShellClass::Modal(void) const { return(Modals.empty() ? nullptr : Modals.back()); } @@ -426,7 +426,7 @@ int UIShellClass::Modal_Depth(void) const } -bool UIShellClass::Is_Modeless_Shown(UIRmlViewClass const & view) const +bool UIShellClass::Is_Modeless_Shown(UIViewClass const & view) const { return(std::find(Modeless.begin(), Modeless.end(), &view) != Modeless.end()); } @@ -707,13 +707,13 @@ bool UIShellClass::Handle_Char(WPARAM wparam) } -UIResult UIShellClass::Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service) +UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & service) { if (!Ready) { return(UI_RESULT_FAILED_TO_OPEN); } if (!FontLoaded) { - Log("UI: %s needs OpenSans.ttf, which did not load\n", view.Document_Name()); + Log("UI: %s needs OpenSans.ttf, which did not load\n", view.Name()); return(UI_RESULT_FAILED_TO_OPEN); } @@ -722,8 +722,8 @@ UIResult UIShellClass::Run_Modal(UIRmlViewClass & view, UIServiceCallback const // A style sheet that fails to load leaves the document usable and is reported as an error. int errors = System->Error_Count(); - if (!view.Prepare(*Context) || System->Error_Count() != errors) { - Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); + if (!view.Prepare(*this) || System->Error_Count() != errors) { + Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Name()); view.Release(); return(UI_RESULT_FAILED_TO_OPEN); } @@ -740,7 +740,7 @@ UIResult UIShellClass::Run_Modal(UIRmlViewClass & view, UIServiceCallback const Modals.push_back(&view); view.Show(true); Host.Mark_Overlay_Dirty(); - std::snprintf(label, sizeof(label), "%s shown", view.Document_Name()); + std::snprintf(label, sizeof(label), "%s shown", view.Name()); Render->Log_Resource_Counts(label); Host.Clear_Keyboard_Queue(); @@ -789,7 +789,7 @@ UIResult UIShellClass::Run_Modal(UIRmlViewClass & view, UIServiceCallback const if (Ready) { Host.Mark_Overlay_Dirty(); - std::snprintf(label, sizeof(label), "%s closed", view.Document_Name()); + std::snprintf(label, sizeof(label), "%s closed", view.Name()); Render->Log_Resource_Counts(label); Host.Clear_Keyboard_Queue(); Host.Focus_Main_Window(); @@ -799,15 +799,15 @@ UIResult UIShellClass::Run_Modal(UIRmlViewClass & view, UIServiceCallback const } -bool UIShellClass::Show_Modeless(UIRmlViewClass & view) +bool UIShellClass::Show_Modeless(UIViewClass & view) { if (!Ready || !FontLoaded || InContext) { return(false); } int errors = System->Error_Count(); - if (!view.Prepare(*Context) || System->Error_Count() != errors) { - Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Document_Name()); + if (!view.Prepare(*this) || System->Error_Count() != errors) { + Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Name()); view.Release(); return(false); } @@ -821,7 +821,7 @@ bool UIShellClass::Show_Modeless(UIRmlViewClass & view) } -void UIShellClass::Hide_Modeless(UIRmlViewClass & view) +void UIShellClass::Hide_Modeless(UIViewClass & view) { Modeless.erase(std::remove(Modeless.begin(), Modeless.end(), &view), Modeless.end()); view.Release(); diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 5e8c59e8a..e76a56ef4 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -30,7 +30,7 @@ namespace Rml class UIRmlRenderClass; class UIRmlSystemClass; -class UIRmlViewClass; +class UIViewClass; class UIShellHostClass; // Runs the game for one pass under a modal screen and reports whether it ended. @@ -69,14 +69,14 @@ class UIShellClass // Prepares, shows and drives a modal screen until its presenter reports a result or // the service reports the game ended, then releases it. The view's presenter must // outlive the call. - UIResult Run_Modal(UIRmlViewClass & view, UIServiceCallback const & service); + UIResult Run_Modal(UIViewClass & view, UIServiceCallback const & service); // Shows a document beside the game without taking its input: a notice the caller // updates while it works. It is drawn at once, because such a caller pumps nothing. // False when the shell or the document is not ready, so the caller opens its Win32 // presentation. - bool Show_Modeless(UIRmlViewClass & view); - void Hide_Modeless(UIRmlViewClass & view); + bool Show_Modeless(UIViewClass & view); + void Hide_Modeless(UIViewClass & view); // Advances the documents and presents the overlay now. void Refresh(void); @@ -105,9 +105,9 @@ class UIShellClass bool Intercept_Pumped_Message(MSG const & msg); Rml::Context * Rml_Context(void) const { return(Context); } - UIRmlViewClass * Modal(void) const; + UIViewClass * Modal(void) const; int Modal_Depth(void) const; - bool Is_Modeless_Shown(UIRmlViewClass const & view) const; + bool Is_Modeless_Shown(UIViewClass const & view) const; private: friend class UITestListenerClass; @@ -170,9 +170,9 @@ class UIShellClass // The modal screens the runner is driving, innermost last, and whether the // innermost is between releasing its document and handing the input back. - std::vector Modals; + std::vector Modals; bool ModalClosing = false; - std::vector Modeless; + std::vector Modeless; bool DevWasActive = false; diff --git a/code/ui/uiview.h b/code/ui/uiview.h new file mode 100644 index 000000000..e5995ffc8 --- /dev/null +++ b/code/ui/uiview.h @@ -0,0 +1,38 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The view the shell runs: one presentation of a presenter, whichever toolkit draws it. +// No toolkit type appears here; each toolkit's view implements it over its own. + +#pragma once + +class UIPresenterClass; +class UIShellClass; + + +class UIViewClass +{ + public: + virtual ~UIViewClass(void) = default; + + // Loads what the view needs against the shell. False leaves nothing behind, so the + // caller can open another presentation. + virtual bool Prepare(UIShellClass & shell) = 0; + virtual void Show(bool modal) = 0; + virtual void Hide(void) = 0; + // Drops what Prepare loaded. Safe to call more than once. + virtual void Release(void) = 0; + // Pushes what the presenter changed into the presentation. + virtual void Sync(void) = 0; + virtual bool Is_Shown(void) const = 0; + + virtual UIPresenterClass & Presenter(void) const = 0; + // What to call the view in a log line. + virtual char const * Name(void) const = 0; +}; diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 31bca8f11..b82f6b747 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -410,14 +410,30 @@ class UIPresenterClass { // uiscreen.h: no toolkit type std::optional Result; }; -class UIRmlViewClass { // rml/rmlview.h: owns the document +class UIViewClass { // uiview.h: no toolkit types + public: + virtual bool Prepare(UIShellClass & shell) = 0; // load; false falls back + virtual void Show(bool modal) = 0; + virtual void Hide(void) = 0; + virtual void Release(void) = 0; + virtual void Sync(void) = 0; // presenter changes into the view + virtual UIPresenterClass & Presenter(void) const = 0; +}; + +class UIRmlViewClass : public UIViewClass { // rml/rmlview.h: owns the document public: UIRmlViewClass(UIPresenterClass & presenter, char const * document); virtual void Bind(Rml::DataModelConstructor & model) = 0; // view-model fields and events - virtual void Sync(void) = 0; // dirty what Execute changed + virtual void Sync(void) override = 0; // dirty what Execute changed }; ``` +A screen's factory, `UI__View(presenter)`, returns a +`std::unique_ptr`, so the engine entry that builds the presenter +and runs the view includes no RmlUi header. The shell runs any `UIViewClass`; +the RmlUi view is the only implementation today, and the Win32 dialogs that +drive a presenter do so from their dialog procedures rather than as views. + The view-model is a struct of plain values and vectors that RmlUi's data binding renders; the document uses `data-model`, `data-value`, `data-for`, and `data-event-click="queue('ok')"`. Intents are small tagged values holding diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index adcb37e89..372b84110 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -961,6 +961,13 @@ std::string Data_Model_Name(std::string const & text) } +// Every factory builds an RmlUi view, so a test reaches the document through it. +static UIRmlViewClass & Rml(UIViewClass & view) +{ + return(static_cast(view)); +} + + class MissingViewClass : public UIRmlViewClass { public: @@ -990,16 +997,16 @@ void Test_Version_Screen(Rml::Context & context, CountingSystemInterfaceClass & { UIVersionPresenterClass presenter({ "Line 1", "Line 2" }); - std::unique_ptr view = UI_Version_View(presenter); + std::unique_ptr view = UI_Version_View(presenter); - Check(view->Prepare(context), "the version view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the version view prepares against the test context"); view->Show(true); context.Update(); context.Render(); Check(system.Problems == problems, "the version screen raises no RmlUi warning or error"); Check(view->Is_Shown(), "the version screen is shown"); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Rml::Element * lines = (document != nullptr) ? document->GetElementById("lines") : nullptr; // The data-for template stays in the tree hidden beside the paragraphs it produced. @@ -1039,9 +1046,9 @@ void Test_Version_Screen(Rml::Context & context, CountingSystemInterfaceClass & for (int pass = 0; pass < 2; pass++) { bool escape = (pass == 0); UIVersionPresenterClass presenter({ "Line" }); - std::unique_ptr view = UI_Version_View(presenter); + std::unique_ptr view = UI_Version_View(presenter); - Check(view->Prepare(context), escape ? "the version view prepares for the Escape pass" : "the version view prepares for the Enter pass"); + Check(Rml(*view).Prepare(context), escape ? "the version view prepares for the Escape pass" : "the version view prepares for the Enter pass"); view->Show(true); context.Update(); context.ProcessKeyDown(escape ? Rml::Input::KI_ESCAPE : Rml::Input::KI_RETURN, 0); @@ -1124,14 +1131,14 @@ void Test_Message_Box_Screen(Rml::Context & context, CountingSystemInterfaceClas UIMessageBoxPresenterClass presenter("Do you want to abort the mission?", { "First", "Second", "Third" }, 0); Check(presenter.Button_Count() == 3, "three captions make three buttons"); - std::unique_ptr view = UI_Message_Box_View(presenter); - Check(view->Prepare(context), "the message box view prepares against the test context"); + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(Rml(*view).Prepare(context), "the message box view prepares against the test context"); view->Show(true); context.Update(); context.Render(); Check(system.Problems == problems, "the message box raises no RmlUi warning or error"); - std::vector buttons = Visible_Buttons(view->Document()); + std::vector buttons = Visible_Buttons(Rml(*view).Document()); Check(buttons.size() == 3, "three buttons are visible"); bool ordered = buttons.size() == 3 && buttons[0]->GetInnerRML() == "First" && buttons[1]->GetInnerRML() == "Third" && buttons[2]->GetInnerRML() == "Second"; Check(ordered, "the buttons read first, third, second from left to right"); @@ -1148,15 +1155,15 @@ void Test_Message_Box_Screen(Rml::Context & context, CountingSystemInterfaceClas { UIMessageBoxPresenterClass presenter("Two buttons", { "OK", "Cancel", "" }, 0); - std::unique_ptr view = UI_Message_Box_View(presenter); - Check(view->Prepare(context), "a two-button box prepares"); + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(Rml(*view).Prepare(context), "a two-button box prepares"); view->Show(true); context.Update(); - std::vector buttons = Visible_Buttons(view->Document()); + std::vector buttons = Visible_Buttons(Rml(*view).Document()); Check(buttons.size() == 2, "two buttons are visible"); if (buttons.size() == 2) { - float panel = view->Document()->GetElementById("panel")->GetAbsoluteOffset(Rml::BoxArea::Border).x; + float panel = Rml(*view).Document()->GetElementById("panel")->GetAbsoluteOffset(Rml::BoxArea::Border).x; float left = buttons[0]->GetAbsoluteOffset(Rml::BoxArea::Border).x - panel; float right = buttons[1]->GetAbsoluteOffset(Rml::BoxArea::Border).x - panel; Check(left < 60.0f && right > 250.0f, "two buttons take the outer slots"); @@ -1174,15 +1181,15 @@ void Test_Message_Box_Screen(Rml::Context & context, CountingSystemInterfaceClas { UIMessageBoxPresenterClass presenter("One button", { "OK", "", "" }, 0); - std::unique_ptr view = UI_Message_Box_View(presenter); - Check(view->Prepare(context), "a one-button box prepares"); + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(Rml(*view).Prepare(context), "a one-button box prepares"); view->Show(true); context.Update(); - std::vector buttons = Visible_Buttons(view->Document()); + std::vector buttons = Visible_Buttons(Rml(*view).Document()); Check(buttons.size() == 1, "one button is visible"); if (buttons.size() == 1) { - float panel = view->Document()->GetElementById("panel")->GetAbsoluteOffset(Rml::BoxArea::Border).x; + float panel = Rml(*view).Document()->GetElementById("panel")->GetAbsoluteOffset(Rml::BoxArea::Border).x; float left = buttons[0]->GetAbsoluteOffset(Rml::BoxArea::Border).x - panel; Check(left > 100.0f && left < 200.0f, "a lone button takes the middle slot"); } @@ -1193,8 +1200,8 @@ void Test_Message_Box_Screen(Rml::Context & context, CountingSystemInterfaceClas { UIMessageBoxPresenterClass presenter("Default", { "Yes", "No", "Maybe" }, 2); - std::unique_ptr view = UI_Message_Box_View(presenter); - Check(view->Prepare(context), "a box with a default prepares"); + std::unique_ptr view = UI_Message_Box_View(presenter); + Check(Rml(*view).Prepare(context), "a box with a default prepares"); view->Show(true); context.Update(); context.ProcessKeyDown(Rml::Input::KI_RETURN, 0); @@ -1253,9 +1260,9 @@ void Test_Sound_Screen(Rml::Context & context, CountingSystemInterfaceClass & sy state.Selected = 1; UISoundPresenterClass presenter(service, state); - std::unique_ptr view = UI_Sound_View(presenter); + std::unique_ptr view = UI_Sound_View(presenter); - Check(view->Prepare(context), "the sound view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the sound view prepares against the test context"); view->Show(true); context.Update(); context.Render(); @@ -1265,7 +1272,7 @@ void Test_Sound_Screen(Rml::Context & context, CountingSystemInterfaceClass & sy presenter.Drain(); Check(presenter.State.Score == 7 && presenter.State.Sound == 5 && presenter.State.Voice == 10 && service.Calls.empty(), "opening the sound screen plays no feedback"); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Rml::ElementList inputs; document->GetElementsByTagName(inputs, "input"); int sliders = 0; @@ -1337,15 +1344,15 @@ void Test_Sound_Screen(Rml::Context & context, CountingSystemInterfaceClass & sy state.InGame = false; UISoundPresenterClass presenter(service, state); - std::unique_ptr view = UI_Sound_View(presenter); + std::unique_ptr view = UI_Sound_View(presenter); - Check(view->Prepare(context), "the frontend sound view prepares"); + Check(Rml(*view).Prepare(context), "the frontend sound view prepares"); view->Show(true); context.Update(); - Rml::Element * music = view->Document()->GetElementById("music"); + Rml::Element * music = Rml(*view).Document()->GetElementById("music"); Check(music != nullptr && !music->IsVisible(), "the frontend sound screen hides the music half"); - Check(Visible_Of_Class(view->Document(), "track").empty(), "the frontend sound screen lists no tracks"); + Check(Visible_Of_Class(Rml(*view).Document(), "track").empty(), "the frontend sound screen lists no tracks"); view->Release(); context.Update(); @@ -1401,9 +1408,9 @@ void Test_Game_Controls_Screen(Rml::Context & context, CountingSystemInterfaceCl state.SoundEnabled = true; UIGameControlsPresenterClass presenter(service, state); - std::unique_ptr view = UI_Game_Controls_View(presenter); + std::unique_ptr view = UI_Game_Controls_View(presenter); - Check(view->Prepare(context), "the game controls view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the game controls view prepares against the test context"); view->Show(true); context.Update(); context.Render(); @@ -1413,7 +1420,7 @@ void Test_Game_Controls_Screen(Rml::Context & context, CountingSystemInterfaceCl presenter.Drain(); Check(presenter.State.Speed == 4 && presenter.State.Scroll == 2 && presenter.State.Detail == 1 && service.Calls.empty(), "opening the game controls holds the starting settings and applies nothing"); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Check(Visible_Sliders(document) == 3, "the in-game screen has three sliders"); Rml::Element * speed = document->GetElementById("speed"); @@ -1469,13 +1476,13 @@ void Test_Game_Controls_Screen(Rml::Context & context, CountingSystemInterfaceCl state.HasDifficulty = true; UIGameControlsPresenterClass presenter(service, state); - std::unique_ptr view = UI_Game_Controls_View(presenter); + std::unique_ptr view = UI_Game_Controls_View(presenter); - Check(view->Prepare(context), "the frontend game controls view prepares"); + Check(Rml(*view).Prepare(context), "the frontend game controls view prepares"); view->Show(true); context.Update(); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Check(Visible_Sliders(document) == 4, "the frontend screen adds the difficulty slider"); Rml::Element * difficulty_name = document->GetElementById("difficulty-name"); @@ -1515,13 +1522,13 @@ void Test_Game_Controls_Screen(Rml::Context & context, CountingSystemInterfaceCl state.SoundEnabled = false; UIGameControlsPresenterClass presenter(service, state); - std::unique_ptr view = UI_Game_Controls_View(presenter); + std::unique_ptr view = UI_Game_Controls_View(presenter); - Check(view->Prepare(context), "the Internet game controls view prepares"); + Check(Rml(*view).Prepare(context), "the Internet game controls view prepares"); view->Show(true); context.Update(); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Check(Visible_Sliders(document) == 2, "the Internet screen has no game speed slider"); Rml::Element * sound = document->GetElementById("sound"); @@ -1555,15 +1562,15 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & { RecordingDisplayServiceClass service; UIDisplayPresenterClass presenter(service, Display_Fixture()); - std::unique_ptr view = UI_Display_View(presenter); + std::unique_ptr view = UI_Display_View(presenter); - Check(view->Prepare(context), "the display view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the display view prepares against the test context"); view->Show(true); context.Update(); context.Render(); Check(system.Problems == problems, "the display screen raises no RmlUi warning or error"); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); std::vector rows = Visible_Of_Class(document, "mode"); Check(rows.size() == 3, "the display screen lists one row per mode"); Check(rows.size() == 3 && rows[1]->IsClassSet("selected") && rows[1]->GetInnerRML() == "1280 x 800", "the row of the stored mode starts selected"); @@ -1603,13 +1610,13 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & { RecordingDisplayServiceClass service; UIDisplayPresenterClass presenter(service, Display_Fixture()); - std::unique_ptr view = UI_Display_View(presenter); + std::unique_ptr view = UI_Display_View(presenter); - Check(view->Prepare(context), "a second display view prepares"); + Check(Rml(*view).Prepare(context), "a second display view prepares"); view->Show(true); context.Update(); - std::vector rows = Visible_Of_Class(view->Document(), "mode"); + std::vector rows = Visible_Of_Class(Rml(*view).Document(), "mode"); if (rows.size() == 3) { Click(context, rows[0]); presenter.Drain(); @@ -1628,9 +1635,9 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & { FakeClockClass clock; UIConfirmModePresenterClass presenter(clock); - std::unique_ptr view = UI_Confirm_Mode_View(presenter); + std::unique_ptr view = UI_Confirm_Mode_View(presenter); - Check(view->Prepare(context), "the confirmation view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the confirmation view prepares against the test context"); presenter.Refresh(); view->Show(true); view->Sync(); @@ -1638,7 +1645,7 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & context.Render(); Check(system.Problems == problems, "the confirmation screen raises no RmlUi warning or error"); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Rml::Element * seconds = document->GetElementById("seconds"); Check(seconds != nullptr && seconds->GetInnerRML() == "10", "the confirmation shows the ten seconds left"); @@ -1664,9 +1671,9 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & { FakeClockClass clock; UIConfirmModePresenterClass presenter(clock); - std::unique_ptr view = UI_Confirm_Mode_View(presenter); + std::unique_ptr view = UI_Confirm_Mode_View(presenter); - Check(view->Prepare(context), "a second confirmation view prepares"); + Check(Rml(*view).Prepare(context), "a second confirmation view prepares"); presenter.Refresh(); view->Show(true); view->Sync(); @@ -1678,7 +1685,7 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & view->Sync(); context.Update(); - Rml::Element * seconds = view->Document()->GetElementById("seconds"); + Rml::Element * seconds = Rml(*view).Document()->GetElementById("seconds"); Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && presenter.TimedOut, "the confirmation cancels itself when the clock runs out"); Check(seconds != nullptr && seconds->GetInnerRML() == "0", "the countdown ends at zero"); @@ -1698,16 +1705,16 @@ void Test_Keyboard_Screen(Rml::Context & context, CountingSystemInterfaceClass & { RecordingKeyboardServiceClass service; UIKeyboardPresenterClass presenter(service, Keyboard_Fixture()); - std::unique_ptr view = UI_Keyboard_View(presenter); + std::unique_ptr view = UI_Keyboard_View(presenter); - Check(view->Prepare(context), "the keyboard view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the keyboard view prepares against the test context"); view->Show(true); view->Sync(); context.Update(); context.Render(); Check(system.Problems == problems, "the keyboard screen raises no RmlUi warning or error"); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); std::vector rows = Visible_Of_Class(document, "row"); Check(rows.size() == 4, "the keyboard screen lists the categories and the open category's commands"); Check(rows.size() == 4 && rows[0]->GetInnerRML() == "Interface" && rows[0]->IsClassSet("selected") && rows[2]->GetInnerRML() == "Alliance" && rows[3]->GetInnerRML() == "Toggle Repair", "the first category is open with its commands sorted by name"); @@ -1779,21 +1786,21 @@ void Test_Keyboard_Screen(Rml::Context & context, CountingSystemInterfaceClass & RecordingKeyboardServiceClass service; service.ConfirmAnswer = false; UIKeyboardPresenterClass presenter(service, Keyboard_Fixture()); - std::unique_ptr view = UI_Keyboard_View(presenter); + std::unique_ptr view = UI_Keyboard_View(presenter); - Check(view->Prepare(context), "a second keyboard view prepares"); + Check(Rml(*view).Prepare(context), "a second keyboard view prepares"); view->Show(true); view->Sync(); context.Update(); - Rml::Element * reset = view->Document()->GetElementById("reset"); + Rml::Element * reset = Rml(*view).Document()->GetElementById("reset"); if (reset != nullptr) { Click(context, reset); presenter.Drain(); Check(service.Calls == std::vector{ "confirm" } && presenter.Key_Of(0) == 577, "Reset All asks first and a refusal changes nothing"); } - Rml::Element * capture = view->Document()->GetElementById("capture"); + Rml::Element * capture = Rml(*view).Document()->GetElementById("capture"); if (capture != nullptr) { capture->Focus(); } @@ -1830,20 +1837,20 @@ void Test_Main_Options_Screen(Rml::Context & context, CountingSystemInterfaceCla UIMainOptionsState state; state.SoundEnabled = true; UIMainOptionsPresenterClass presenter(state); - std::unique_ptr view = UI_Main_Options_View(presenter); + std::unique_ptr view = UI_Main_Options_View(presenter); - Check(view->Prepare(context), "the options menu view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the options menu view prepares against the test context"); view->Show(true); context.Update(); context.Render(); Check(system.Problems == problems, "the options menu raises no RmlUi warning or error"); - std::vector buttons = Buttons_Top_Down(view->Document()); + std::vector buttons = Buttons_Top_Down(Rml(*view).Document()); Check(buttons.size() == 5, "the options menu has five buttons"); bool ordered = buttons.size() == 5 && buttons[0]->GetId() == "settings" && buttons[1]->GetId() == "display" && buttons[2]->GetId() == "sound" && buttons[3]->GetId() == "keyboard" && buttons[4]->GetId() == "mainmenu"; Check(ordered, "the buttons run Game Settings, Display, Sound, Keyboard, Main Menu from the top"); - Rml::Element * panel = view->Document()->GetElementById("panel"); + Rml::Element * panel = Rml(*view).Document()->GetElementById("panel"); float centre = (float)context.GetDimensions().y * 0.5f; Check(panel != nullptr && panel->GetAbsoluteOffset(Rml::BoxArea::Border).y < centre && panel->GetAbsoluteOffset(Rml::BoxArea::Border).y + panel->GetBox().GetSize(Rml::BoxArea::Border).y > centre, "without a top edge the menu sits in the middle"); @@ -1862,16 +1869,16 @@ void Test_Main_Options_Screen(Rml::Context & context, CountingSystemInterfaceCla state.SoundEnabled = false; state.Top = 200; UIMainOptionsPresenterClass presenter(state); - std::unique_ptr view = UI_Main_Options_View(presenter); + std::unique_ptr view = UI_Main_Options_View(presenter); - Check(view->Prepare(context), "a second options menu view prepares"); + Check(Rml(*view).Prepare(context), "a second options menu view prepares"); view->Show(true); context.Update(); - Rml::Element * panel = view->Document()->GetElementById("panel"); + Rml::Element * panel = Rml(*view).Document()->GetElementById("panel"); Check(panel != nullptr && std::fabs(panel->GetAbsoluteOffset(Rml::BoxArea::Border).y - 200.0f) < 1.0f, "the menu sits at the top edge the game hands it"); - Rml::Element * sound = view->Document()->GetElementById("sound"); + Rml::Element * sound = Rml(*view).Document()->GetElementById("sound"); Check(sound != nullptr && sound->IsClassSet("disabled"), "the Sound button shows disabled without an audio device"); if (sound != nullptr) { Click(context, sound); @@ -1915,15 +1922,15 @@ void Test_Wait_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & { UIWaitBoxPresenterClass presenter("Mission saving - Please Wait...", false); - std::unique_ptr view = UI_Wait_Box_View(presenter); + std::unique_ptr view = UI_Wait_Box_View(presenter); - Check(view->Prepare(context), "the wait box view prepares against the test context"); + Check(Rml(*view).Prepare(context), "the wait box view prepares against the test context"); view->Show(false); context.Update(); context.Render(); Check(system.Problems == problems, "the wait box raises no RmlUi warning or error"); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Rml::Element * text = document->GetElementById("text"); Check(text != nullptr && text->GetInnerRML() == "Mission saving - Please Wait...", "the wait box shows its text"); @@ -1942,13 +1949,13 @@ void Test_Wait_Box_Screen(Rml::Context & context, CountingSystemInterfaceClass & { UIWaitBoxPresenterClass presenter("Working - Please Wait", true); presenter.Set_Fraction(0.5); - std::unique_ptr view = UI_Wait_Box_View(presenter); + std::unique_ptr view = UI_Wait_Box_View(presenter); - Check(view->Prepare(context), "a wait box with a bar prepares"); + Check(Rml(*view).Prepare(context), "a wait box with a bar prepares"); view->Show(false); context.Update(); - Rml::ElementDocument * document = view->Document(); + Rml::ElementDocument * document = Rml(*view).Document(); Rml::Element * frame = document->GetElementById("frame"); Rml::Element * fill = document->GetElementById("fill"); Check(frame != nullptr && frame->IsVisible(), "a wait box with a bar shows the frame"); From d82701e53fb095667a5a101f362cdebce45d7931 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:37:32 +0300 Subject: [PATCH 25/52] Build the UI shell into its harness over a test host --- tests/uishell/CMakeLists.txt | 7 +- tests/uishell/uidevstub.cpp | 79 ++++++++ tests/uishell/uishell.cpp | 352 ++++++++++++++++++++++++++++++++++- 3 files changed, 428 insertions(+), 10 deletions(-) create mode 100644 tests/uishell/uidevstub.cpp diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index a1ab5b621..2ee7d3dec 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -2,13 +2,16 @@ # documents through a recording render interface, so a runtime library mismatch, a document # that fails to parse or a style outside the implemented render methods fails here. The # documents are read from the source tree, so it needs no run directory; the string-name -# table it resolves them with is generated per build by the stamp target. +# table it resolves them with is generated per build by the stamp target. The shell itself +# is built in over a host the harness controls and a stub in place of the developer overlays. opents_add_test(UIShell NAME uishell - SOURCES uishell.cpp + SOURCES uishell.cpp uidevstub.cpp ENGINE ui/uiscreen.cpp + ui/uishell.cpp ui/rml/rmlkeys.cpp + ui/rml/rmlsystem.cpp ui/rml/rmlview.cpp ui/screens/display/uidisplay.cpp ui/screens/gamectrl/uigamectrl.cpp diff --git a/tests/uishell/uidevstub.cpp b/tests/uishell/uidevstub.cpp new file mode 100644 index 000000000..ef96806ce --- /dev/null +++ b/tests/uishell/uidevstub.cpp @@ -0,0 +1,79 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The developer overlays as the harness sees them: never active, wanting nothing. The +// shell links against these instead of the Dear ImGui module, which reaches the engine. + +#include "ui/dev/uidev.h" + + +bool UIDev_Active(void) +{ + return(false); +} + + +void UIDev_Toggle(UIRmlRenderClass const &) +{ +} + + +void UIDev_Tick(void) +{ +} + + +void UIDev_Render(UIRmlRenderClass &) +{ +} + + +void UIDev_Shutdown(UIRmlRenderClass &) +{ +} + + +void UIDev_Mouse_Position(int, int) +{ +} + + +bool UIDev_Mouse_Button(int, bool) +{ + return(false); +} + + +bool UIDev_Mouse_Wheel(float) +{ + return(false); +} + + +bool UIDev_Key(WPARAM, bool) +{ + return(false); +} + + +bool UIDev_Character(wchar_t) +{ + return(false); +} + + +void UIDev_Focus(bool) +{ +} + + +bool UIDev_Wants_Mouse(void) +{ + return(false); +} diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 372b84110..b3f024cab 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -20,17 +20,14 @@ #include #include #include +#include #include #include #include -#include -#include -#include -#include FT_FREETYPE_H -#include - #include "ui/rml/rmlkeys.h" +#include "ui/rml/rmlrender.h" +#include "ui/rml/rmlsystem.h" #include "ui/rml/rmlview.h" #include "ui/screens/display/uidisplay.h" #include "ui/screens/gamectrl/uigamectrl.h" @@ -41,7 +38,21 @@ #include "ui/screens/version/uiversion.h" #include "ui/screens/waitbox/uiwaitbox.h" #include "ui/uicoord.h" +#include "ui/uihost.h" #include "ui/uiscreen.h" +#include "ui/uishell.h" +#include "ui/uiview.h" + +// windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its +// element walkers. +#undef GetFirstChild +#undef GetNextSibling + +#include +#include +#include +#include FT_FREETYPE_H +#include #include "opents_strings.h" @@ -62,8 +73,9 @@ void Check(bool condition, char const * what) // Counts every call RmlUi makes while a document is laid out and drawn. The methods the // shell leaves at their defaults count as violations of the styling profile the shipped -// documents must stay within. -class RecordingRenderInterfaceClass : public Rml::RenderInterface +// documents must stay within. It is also the renderer the harness's shell draws with, +// so a test can act from inside a render pass through OnRender. +class RecordingRenderInterfaceClass : public UIRmlRenderClass { public: int Compiled = 0; @@ -73,7 +85,44 @@ class RecordingRenderInterfaceClass : public Rml::RenderInterface int Generated = 0; int ReleasedTextures = 0; int Unsupported = 0; + int Frames = 0; std::vector Scissors; + std::function OnRender; + + virtual bool Init(void) override + { + return(true); + } + + virtual void Shutdown(void) override + { + } + + virtual void Begin_Frame(int, int, int, int) override + { + Frames++; + } + + virtual void Begin_Dev_Frame(int, int, int, int) override + { + } + + virtual void Render_ImGui(ImDrawData *) override + { + } + + virtual void Destroy_ImGui_Textures(void) override + { + } + + virtual int Texture_Limit(void) const override + { + return(4096); + } + + virtual void Log_Resource_Counts(char const *) const override + { + } virtual Rml::CompiledGeometryHandle CompileGeometry(Rml::Span, Rml::Span) override { @@ -84,6 +133,9 @@ class RecordingRenderInterfaceClass : public Rml::RenderInterface virtual void RenderGeometry(Rml::CompiledGeometryHandle, Rml::Vector2f, Rml::TextureHandle) override { Rendered++; + if (OnRender) { + OnRender(); + } } virtual void ReleaseGeometry(Rml::CompiledGeometryHandle) override @@ -208,6 +260,150 @@ class CountingSystemInterfaceClass : public Rml::SystemInterface }; +// The program around the shell, as the harness plays it: a frame it can resize, a capture +// it can watch, and a keyboard clear that runs whatever the test wants pumped. +class TestHostClass : public UIShellHostClass +{ + public: + UIFrameRect Rect = { 0, 0, 1280, 800, 1.0f, 1.0f }; + bool LegacyRequested = false; + bool LegacyVisible = false; + bool Captured = false; + int Presents = 0; + int Clears = 0; + int Focuses = 0; + UIShellClass * Shell = nullptr; + std::function OnClear; + + virtual HWND Main_Window(void) const override + { + return(nullptr); + } + + virtual UIFrameRect Frame(void) const override + { + return(Rect); + } + + virtual void Mark_Overlay_Dirty(void) override + { + } + + virtual void Present_If_Dirty(void) override + { + Presents++; + if (Shell != nullptr) { + Shell->Render_Overlay(); + } + } + + virtual bool Movie_Playing(void) const override + { + return(false); + } + + virtual bool Legacy_Dialog_Visible(void) const override + { + return(LegacyVisible); + } + + virtual bool Legacy_Dialogs_Requested(void) const override + { + return(LegacyRequested); + } + + virtual bool Developer_Keys_Armed(void) const override + { + return(false); + } + + virtual void Clear_Keyboard_Queue(void) override + { + Clears++; + if (OnClear) { + OnClear(); + } + } + + virtual void Focus_Main_Window(void) override + { + Focuses++; + } + + virtual bool Take_Capture(void) override + { + bool took = !Captured; + Captured = true; + return(took); + } + + virtual void Release_Capture(void) override + { + Captured = false; + } + + virtual bool Screen_To_Client(int &, int &) const override + { + return(true); + } + + virtual char const * String(int) const override + { + return("string"); + } + + virtual void Log(char const * text) override + { + std::printf(" shell: %s", text); + } +}; + + +// The shell's own system interface with a count of what RmlUi complained about. +class CountingSystemClass : public UIRmlSystemClass +{ + public: + int Problems = 0; + + explicit CountingSystemClass(UIShellHostClass & host) : + UIRmlSystemClass(host) + { + } + + virtual bool LogMessage(Rml::Log::Type type, Rml::String const & message) override + { + if (type == Rml::Log::LT_ERROR || type == Rml::Log::LT_ASSERT || type == Rml::Log::LT_WARNING) { + Problems++; + } + return(UIRmlSystemClass::LogMessage(type, message)); + } +}; + + +// A shell over the harness's host and recording interfaces, which it owns from construction. +struct ShellFixtureType +{ + TestHostClass Host; + RecordingRenderInterfaceClass * Render; + CountingSystemClass * System; + UIShellClass Shell; + + ShellFixtureType(void) : + Render(new RecordingRenderInterfaceClass()), + System(new CountingSystemClass(Host)), + Shell(Host, std::unique_ptr(System), nullptr, std::unique_ptr(Render)) + { + Host.Shell = &Shell; + } +}; + + +bool Send(UIShellClass & shell, UINT message, WPARAM wparam = 0, LPARAM lparam = 0) +{ + return(shell.Handle_Window_Message(nullptr, message, wparam, lparam)); +} + + std::string Read_Text(std::filesystem::path const & path) { std::ifstream stream(path, std::ios::binary); @@ -2077,6 +2273,145 @@ void Test_Documents(void) std::printf(" %d geometries, %d generated textures, %d loaded textures\n", render.Compiled, render.Generated, render.Loaded); } + +// The shell over the harness's host: it runs after Test_Documents has shut RmlUi down, as +// one RmlUi lives per process, and the working directory is still the ui directory. +void Test_Shell(void) +{ + ShellFixtureType fixture; + UIShellClass & shell = fixture.Shell; + TestHostClass & host = fixture.Host; + + Check(!shell.Use_Rml(), "a shell not yet initialised opens no document"); + Check(shell.Init(), "the shell initialises over the injected interfaces"); + Check(shell.Rml_Context() != nullptr, "the shell holds a context"); + Check(shell.Use_Rml(), "documents are used while the host asks for no legacy dialogs"); + host.LegacyRequested = true; + Check(!shell.Use_Rml(), "the LegacyDialogs setting turns the documents off"); + host.LegacyRequested = false; + Check(!shell.Screen_Shown() && shell.Modal() == nullptr && shell.Modal_Depth() == 0, "no screen is shown at start"); + + { + UIVersionPresenterClass presenter({ "one", "two" }); + std::unique_ptr view = UI_Version_View(presenter); + int passes = 0; + bool shownInside = false; + bool consumedWhileOpening = false; + int presents = host.Presents; + + host.OnClear = [&](void) { + // The engine's clear pumps the window messages; a press arriving then meets the + // screen that has just been shown. + if (host.Clears == 1) { + consumedWhileOpening = Send(shell, WM_LBUTTONDOWN, 0, MAKELPARAM(10, 10)); + } + }; + + UIResult result = shell.Run_Modal(*view, [&](void) { + passes++; + if (passes == 1) { + shownInside = shell.Screen_Shown() && shell.Modal() == view.get() && shell.Modal_Depth() == 1; + } + if (passes == 3) { + Send(shell, WM_KEYDOWN, VK_RETURN); + } + return(false); + }); + host.OnClear = nullptr; + + Check(result == UI_RESULT_ACCEPTED, "Enter accepts the modal"); + Check(passes == 3, "the runner stops on the pass that produced the result"); + Check(host.Presents - presents == 2, "the runner presents after each pass that continues"); + Check(shownInside, "the modal is the shown screen while the service runs"); + Check(consumedWhileOpening, "a press pumped while the screen opens is consumed"); + Check(host.Clears == 2, "the keyboard queue is cleared at open and at close"); + Check(host.Focuses == 1, "focus returns to the main window once"); + Check(!shell.Screen_Shown() && shell.Modal() == nullptr, "the modal stack is empty after the close"); + } + + { + UIVersionPresenterClass presenter({ "escape" }); + std::unique_ptr view = UI_Version_View(presenter); + UIResult result = shell.Run_Modal(*view, [&](void) { + Send(shell, WM_KEYDOWN, VK_ESCAPE); + return(false); + }); + Check(result == UI_RESULT_CANCELLED, "Escape cancels the modal"); + } + + { + UIVersionPresenterClass presenter({ "ended" }); + std::unique_ptr view = UI_Version_View(presenter); + int passes = 0; + UIResult result = shell.Run_Modal(*view, [&](void) { + passes++; + return(true); + }); + Check(result == UI_RESULT_SESSION_ENDED && passes == 1, "a service reporting the game ended closes the modal at once"); + Check(!shell.Screen_Shown(), "a modal ended by the game leaves nothing shown"); + } + + { + UIVersionPresenterClass presenter({ "resize" }); + std::unique_ptr view = UI_Version_View(presenter); + int passes = 0; + bool resized = false; + bool unchangedInside = false; + bool appliedBefore = false; + + fixture.Render->OnRender = [&](void) { + if (!resized) { + resized = true; + host.Rect.Width = 640; + host.Rect.Height = 400; + shell.On_Video_Change(); + unchangedInside = shell.Rml_Context()->GetDimensions() == Rml::Vector2i(1280, 800); + } + }; + + shell.Run_Modal(*view, [&](void) { + passes++; + if (passes == 2) { + appliedBefore = shell.Rml_Context()->GetDimensions() == Rml::Vector2i(640, 400); + Send(shell, WM_KEYDOWN, VK_ESCAPE); + } + return(false); + }); + fixture.Render->OnRender = nullptr; + + Check(resized && unchangedInside, "a resize arriving inside a render is deferred"); + Check(appliedBefore, "the deferred resize is applied before the next tick"); + + host.Rect = { 0, 0, 1280, 800, 1.0f, 1.0f }; + shell.On_Video_Change(); + Check(shell.Rml_Context()->GetDimensions() == Rml::Vector2i(1280, 800), "a resize outside a render is applied at once"); + } + + { + UIWaitBoxPresenterClass presenter("Working", false); + std::unique_ptr view = UI_Wait_Box_View(presenter); + + Check(shell.Show_Modeless(*view), "a notice shows beside the game"); + Check(shell.Is_Modeless_Shown(*view) && view->Is_Shown(), "the shell lists the notice while it shows"); + Check(!shell.Screen_Shown(), "a notice is not a screen"); + shell.Hide_Modeless(*view); + Check(!shell.Is_Modeless_Shown(*view) && !view->Is_Shown(), "hiding the notice unlists it"); + Check(shell.Show_Modeless(*view), "the notice shows again"); + + shell.Shutdown(); + Check(!shell.Is_Modeless_Shown(*view) && !view->Is_Shown(), "shutdown releases a notice still shown"); + shell.Hide_Modeless(*view); + Check(!shell.Use_Rml(), "a shut-down shell opens no document"); + } + + Check(shell.Init(), "the shell initialises again after a shutdown"); + shell.Shutdown(); + + Check(fixture.Render->ReleasedGeometry == fixture.Render->Compiled, "the shell releases every geometry it compiled"); + Check(fixture.Render->ReleasedTextures == fixture.Render->Loaded + fixture.Render->Generated, "the shell releases every texture it made"); + Check(fixture.System->Problems == 0, "the shell's screens raise no RmlUi warning or error"); +} + } @@ -2092,6 +2427,7 @@ int main(void) Test_Sound_Presenter(); Test_Strings(); Test_Documents(); + Test_Shell(); std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); return(Failures == 0 ? 0 : 1); From b7cb1591ff89ca6d3550304242396588bfc32b8f Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:40:11 +0300 Subject: [PATCH 26/52] Add the input ownership state and its harness --- code/ui/uiinput.cpp | 248 ++++++++++++++++++++++++++++++++++ code/ui/uiinput.h | 87 ++++++++++++ code/ui/uiinput.hh | 39 ++++++ docs/UI_DESIGN.md | 2 +- tests/CMakeLists.txt | 1 + tests/uilogic/CMakeLists.txt | 8 ++ tests/uilogic/uilogictest.cpp | 172 +++++++++++++++++++++++ 7 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 code/ui/uiinput.cpp create mode 100644 code/ui/uiinput.h create mode 100644 code/ui/uiinput.hh create mode 100644 tests/uilogic/CMakeLists.txt create mode 100644 tests/uilogic/uilogictest.cpp diff --git a/code/ui/uiinput.cpp b/code/ui/uiinput.cpp new file mode 100644 index 000000000..f5ea4ad45 --- /dev/null +++ b/code/ui/uiinput.cpp @@ -0,0 +1,248 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uiinput.h" + + +namespace +{ + +bool Is_UI(UIInputOwner owner) +{ + return(owner == UI_INPUT_RML || owner == UI_INPUT_IMGUI); +} + +} + + +bool UI_Consumes_Input(UIInputOwner owner, bool release) +{ + return(Is_UI(owner) || owner == UI_INPUT_SUPPRESSED || (release && owner == UI_INPUT_NONE)); +} + + +UIInputOwner UIInputStateClass::Press_Key(unsigned key, UIInputOwner owner) +{ + if (key >= Keys.size()) { + return(UI_INPUT_NONE); + } + if (Keys[key] == UI_INPUT_NONE) { + Keys[key] = owner; + } + return(Keys[key]); +} + + +UIInputOwner UIInputStateClass::Release_Key(unsigned key) +{ + if (key >= Keys.size()) { + return(UI_INPUT_NONE); + } + UIInputOwner owner = Keys[key]; + Keys[key] = UI_INPUT_NONE; + return(owner); +} + + +UIInputOwner UIInputStateClass::Key_Owner(unsigned key) const +{ + return(key < Keys.size() ? Keys[key] : UI_INPUT_NONE); +} + + +UIInputOwner UIInputStateClass::Press_Mouse(unsigned button, UIInputOwner owner) +{ + if (button >= Buttons.size()) { + return(UI_INPUT_NONE); + } + if (Buttons[button] == UI_INPUT_NONE) { + Buttons[button] = owner; + } + return(Buttons[button]); +} + + +UIInputOwner UIInputStateClass::Release_Mouse(unsigned button) +{ + if (button >= Buttons.size()) { + return(UI_INPUT_NONE); + } + UIInputOwner owner = Buttons[button]; + Buttons[button] = UI_INPUT_NONE; + return(owner); +} + + +UIInputOwner UIInputStateClass::Mouse_Owner(unsigned button) const +{ + return(button < Buttons.size() ? Buttons[button] : UI_INPUT_NONE); +} + + +UIInputOwner UIInputStateClass::Gesture_Owner(void) const +{ + for (UIInputOwner owner : Buttons) { + if (owner != UI_INPUT_NONE) { + return(owner); + } + } + return(UI_INPUT_NONE); +} + + +bool UIInputStateClass::Has_UI_Mouse(void) const +{ + for (UIInputOwner owner : Buttons) { + if (Is_UI(owner)) { + return(true); + } + } + return(false); +} + + +bool UIInputStateClass::Any_Owned(void) const +{ + for (UIInputOwner owner : Keys) { + if (owner != UI_INPUT_NONE) { + return(true); + } + } + return(Gesture_Owner() != UI_INPUT_NONE); +} + + +bool UIInputStateClass::Any_Suppressed(void) const +{ + for (UIInputOwner owner : Keys) { + if (owner == UI_INPUT_SUPPRESSED) { + return(true); + } + } + for (UIInputOwner owner : Buttons) { + if (owner == UI_INPUT_SUPPRESSED) { + return(true); + } + } + return(false); +} + + +void UIInputStateClass::Cancel_UI(void) +{ + for (UIInputOwner & owner : Keys) { + if (Is_UI(owner)) { + owner = UI_INPUT_SUPPRESSED; + } + } + for (UIInputOwner & owner : Buttons) { + if (Is_UI(owner)) { + owner = UI_INPUT_SUPPRESSED; + } + } +} + + +void UIInputStateClass::Cancel_Mouse(void) +{ + for (UIInputOwner & owner : Buttons) { + if (owner != UI_INPUT_NONE) { + owner = UI_INPUT_SUPPRESSED; + } + } +} + + +void UIInputStateClass::Cancel_All(void) +{ + for (UIInputOwner & owner : Keys) { + if (owner != UI_INPUT_NONE) { + owner = UI_INPUT_SUPPRESSED; + } + } + for (UIInputOwner & owner : Buttons) { + if (owner != UI_INPUT_NONE) { + owner = UI_INPUT_SUPPRESSED; + } + } +} + + +void UIInputStateClass::Reconcile_Cancelled_Keys(std::array const & physical) +{ + for (std::size_t key = 0; key < Keys.size(); key++) { + if (Keys[key] == UI_INPUT_SUPPRESSED && !physical[key]) { + Keys[key] = UI_INPUT_NONE; + } + } +} + + +void UIInputStateClass::Reconcile_Cancelled_Mouse(std::array const & physical) +{ + for (std::size_t button = 0; button < Buttons.size(); button++) { + if (Buttons[button] == UI_INPUT_SUPPRESSED && !physical[button]) { + Buttons[button] = UI_INPUT_NONE; + } + } +} + + +void UIInputStateClass::Reset(void) +{ + Keys.fill(UI_INPUT_NONE); + Buttons.fill(UI_INPUT_NONE); +} + + +UIInputText UIUTF8DecoderClass::Feed(unsigned char byte) +{ + UIInputText result; + + if (Remaining != 0) { + if ((byte & 0xC0) == 0x80) { + Value = (Value << 6) | (byte & 0x3F); + if (--Remaining == 0) { + bool valid = Value >= Minimum && Value <= 0x10FFFF && !(Value >= 0xD800 && Value <= 0xDFFF); + result.Codepoints[result.Count++] = valid ? Value : 0xFFFD; + Reset(); + } + return(result); + } + result.Codepoints[result.Count++] = 0xFFFD; + Reset(); + } + + if (byte < 0x80) { + result.Codepoints[result.Count++] = byte; + } else if (byte >= 0xC2 && byte <= 0xDF) { + Value = byte & 0x1F; + Minimum = 0x80; + Remaining = 1; + } else if (byte >= 0xE0 && byte <= 0xEF) { + Value = byte & 0x0F; + Minimum = 0x800; + Remaining = 2; + } else if (byte >= 0xF0 && byte <= 0xF4) { + Value = byte & 0x07; + Minimum = 0x10000; + Remaining = 3; + } else { + result.Codepoints[result.Count++] = 0xFFFD; + } + return(result); +} + + +void UIUTF8DecoderClass::Reset(void) +{ + Value = 0; + Minimum = 0; + Remaining = 0; +} diff --git a/code/ui/uiinput.h b/code/ui/uiinput.h new file mode 100644 index 000000000..efe9f3fe0 --- /dev/null +++ b/code/ui/uiinput.h @@ -0,0 +1,87 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Who owns each held key and mouse button, decided at the press and returned at the +// release, so a gesture ends where it began whatever moved in between. No toolkit or +// window type appears here; the shell's hook feeds it and a harness drives it by hand. + +#pragma once + +#include "ui/uiinput.hh" + +#include +#include + + +// True when the owner's message must be kept from the game: a toolkit owns it, it was +// cancelled, or it is a release nobody pressed. +bool UI_Consumes_Input(UIInputOwner owner, bool release); + + +class UIInputStateClass +{ + public: + static constexpr unsigned KEY_COUNT = 256; + static constexpr unsigned BUTTON_COUNT = 5; + + // Latches the owner of a press and returns the owner in charge: a held key or button + // keeps its first owner. + UIInputOwner Press_Key(unsigned key, UIInputOwner owner); + UIInputOwner Release_Key(unsigned key); + UIInputOwner Key_Owner(unsigned key) const; + UIInputOwner Press_Mouse(unsigned button, UIInputOwner owner); + UIInputOwner Release_Mouse(unsigned button); + UIInputOwner Mouse_Owner(unsigned button) const; + + // The owner of the first held button, which owns the pointer's motion. + UIInputOwner Gesture_Owner(void) const; + bool Has_UI_Mouse(void) const; + bool Any_Owned(void) const; + bool Any_Suppressed(void) const; + + // What a toolkit held becomes suppressed when its screen closes. + void Cancel_UI(void); + // Every held button becomes suppressed when another window takes the capture. + void Cancel_Mouse(void); + // Everything held becomes suppressed when the window loses focus. + void Cancel_All(void); + // Suppressed entries are forgotten once the physical key or button is up, so a + // release lost to another window cannot hold them forever. + void Reconcile_Cancelled_Keys(std::array const & physical); + void Reconcile_Cancelled_Mouse(std::array const & physical); + void Reset(void); + + private: + std::array Keys {}; + std::array Buttons {}; +}; + + +// The code points one byte of text completed: none while a sequence is pending, two when +// a malformed prefix is repaired and the byte after it stands on its own. +struct UIInputText +{ + std::array Codepoints {}; + unsigned Count = 0; +}; + + +// Decodes the UTF-8 bytes a narrow window delivers one message at a time. A malformed +// sequence becomes U+FFFD and never swallows the byte that follows it. +class UIUTF8DecoderClass +{ + public: + UIInputText Feed(unsigned char byte); + void Reset(void); + + private: + char32_t Value = 0; + char32_t Minimum = 0; + unsigned Remaining = 0; +}; diff --git a/code/ui/uiinput.hh b/code/ui/uiinput.hh new file mode 100644 index 000000000..3a504a116 --- /dev/null +++ b/code/ui/uiinput.hh @@ -0,0 +1,39 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Definitions the shell's input routing and the toolkit adapters share. + +#pragma once + + +// Who a press belongs to for as long as it is held. SUPPRESSED is input the shell once +// owned or has cancelled: its release is swallowed rather than handed to the game. +enum UIInputOwner +{ + UI_INPUT_NONE, + UI_INPUT_GAME, + UI_INPUT_RML, + UI_INPUT_IMGUI, + UI_INPUT_SUPPRESSED +}; + + +// The pointer shape a document asks for. +enum UICursor +{ + UI_CURSOR_ARROW, + UI_CURSOR_TEXT, + UI_CURSOR_HAND, + UI_CURSOR_RESIZE_NS, + UI_CURSOR_RESIZE_EW, + UI_CURSOR_RESIZE_NESW, + UI_CURSOR_RESIZE_NWSE, + UI_CURSOR_MOVE, + UI_CURSOR_UNAVAILABLE +}; diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b82f6b747..5d6931967 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -202,7 +202,7 @@ rule the tree follows, not a build boundary. | Directory | Holds | Status | | --- | --- | --- | | `code/` | `bgfxviews.hh`, the view ids the presenter and the overlays share; `_ui.h`, `_ui.cpp`, the shell's one instance `UIShell` under the underscore-file convention for globals | landed | -| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (`UIShellClass`: init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector; its toolkit interfaces are injected, so a test builds its own instance); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | +| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (`UIShellClass`: init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector; its toolkit interfaces are injected, so a test builds its own instance); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uiview.h` (`UIViewClass`, the view the shell runs); `uiinput.hh`, `uiinput.h`, `uiinput.cpp` (who owns each held key and button, and the UTF-8 decoding of a narrow window's text); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | | `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging to `DebugString`, string translation; cursor and clipboard wait for the first editable screen), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base) | landed | | `code/ui/dev/` | `uidev.h`, `uidev.cpp`: the ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | | `code/ui/screens//` | one family each for `version`, `msgbox`, `waitbox`, `sound`, `gamectrl`, `display`, `keyboard`, `mainopt`: `ui.h` (presenter, service and state declarations, view factory, engine entry), `ui.cpp` (presenter and RmlUi view; built into the test), `uidlg.cpp` (engine service and entry, which the test cannot link) | landed; the sound, game controls, keyboard and display Win32 dialogs drive the same presenter as a second view, and the wait box family carries the `UIWaitBoxClass` the save, load and progress code shows | diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d56b69caf..260be4591 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -107,6 +107,7 @@ add_subdirectory(zbufring) add_subdirectory(priorityqueue) add_subdirectory(save) add_subdirectory(uishell) +add_subdirectory(uilogic) # Only code/ui/rml/ headers may include a UI toolkit or the renderer; the script reads the # sources directly, so the check needs no compiler and runs in every CI job's ctest step. diff --git a/tests/uilogic/CMakeLists.txt b/tests/uilogic/CMakeLists.txt new file mode 100644 index 000000000..fe6156a27 --- /dev/null +++ b/tests/uilogic/CMakeLists.txt @@ -0,0 +1,8 @@ +# The toolkit-free UI state: the input ownership the shell routes messages by and the +# text decoding it feeds the documents, compiled without any UI library so the harness +# stays cheap and the code stays free of toolkit types. +opents_add_test(UILogic + NAME uilogic + SOURCES uilogictest.cpp + ENGINE ui/uiinput.cpp +) diff --git a/tests/uilogic/uilogictest.cpp b/tests/uilogic/uilogictest.cpp new file mode 100644 index 000000000..6aa779887 --- /dev/null +++ b/tests/uilogic/uilogictest.cpp @@ -0,0 +1,172 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Pins the UI state that needs no toolkit: who a held key or button belongs to as screens +// open, close and lose the capture or the focus, and how the bytes of a narrow window's +// text messages become code points. + +#include "ui/uiinput.h" + +#include +#include +#include +#include + +namespace { + +int Failures = 0; + + +void Check(bool condition, char const * what) +{ + std::printf("%-76s %s\n", what, condition ? "ok" : "FAILED"); + + if (!condition) { + Failures++; + } +} + + +std::vector Decode(UIUTF8DecoderClass & decoder, std::initializer_list bytes) +{ + std::vector result; + for (unsigned char byte : bytes) { + UIInputText text = decoder.Feed(byte); + for (unsigned index = 0; index < text.Count; index++) { + result.push_back(text.Codepoints[index]); + } + } + return(result); +} + + +void Test_Ownership(void) +{ + UIInputStateClass state; + + Check(state.Gesture_Owner() == UI_INPUT_NONE && !state.Has_UI_Mouse() && !state.Any_Owned(), "the initial state holds no gesture"); + Check(state.Press_Key(65, UI_INPUT_RML) == UI_INPUT_RML, "RmlUi owns the key it first pressed"); + Check(state.Any_Owned(), "a held key counts as owned input"); + Check(state.Press_Key(65, UI_INPUT_IMGUI) == UI_INPUT_RML, "a repeat keeps the first owner of a key"); + Check(state.Release_Key(65) == UI_INPUT_RML, "a release returns the owner of its press"); + Check(state.Release_Key(65) == UI_INPUT_NONE, "a second release has no owner"); + Check(state.Press_Key(65, UI_INPUT_GAME) == UI_INPUT_GAME, "a later press of the same key can belong to the game"); + Check(state.Press_Key(256, UI_INPUT_RML) == UI_INPUT_NONE && state.Key_Owner(256) == UI_INPUT_NONE, "a key out of range takes no owner"); + Check(state.Release_Key(256) == UI_INPUT_NONE, "a release out of range is ignored"); + + Check(state.Press_Mouse(0, UI_INPUT_IMGUI) == UI_INPUT_IMGUI, "ImGui owns the button it first pressed"); + Check(state.Press_Mouse(0, UI_INPUT_GAME) == UI_INPUT_IMGUI, "a move across the game cannot change a press's owner"); + Check(state.Release_Mouse(0) == UI_INPUT_IMGUI, "a UI release stays a UI release outside the panel"); + Check(state.Press_Mouse(0, UI_INPUT_GAME) == UI_INPUT_GAME, "the next press can go to the game"); + Check(state.Press_Mouse(0, UI_INPUT_RML) == UI_INPUT_GAME, "a game drag cannot become a document drag"); + Check(state.Release_Mouse(0) == UI_INPUT_GAME, "a game release keeps its owner"); + Check(state.Press_Mouse(0, UI_INPUT_RML) == UI_INPUT_RML, "a fresh press can belong to RmlUi"); + Check(state.Press_Mouse(1, UI_INPUT_RML) == UI_INPUT_RML, "a second held button joins the UI gesture"); + Check(state.Release_Mouse(0) == UI_INPUT_RML && state.Has_UI_Mouse(), "releasing one button keeps the other's capture"); + Check(state.Gesture_Owner() == UI_INPUT_RML, "the remaining button owns the pointer's motion"); + Check(state.Release_Mouse(1) == UI_INPUT_RML && !state.Has_UI_Mouse(), "the capture ends with the last button"); + Check(state.Press_Mouse(5, UI_INPUT_IMGUI) == UI_INPUT_NONE && state.Release_Mouse(5) == UI_INPUT_NONE, "a button out of range is ignored"); + + state.Press_Key(66, UI_INPUT_IMGUI); + state.Press_Mouse(0, UI_INPUT_RML); + state.Press_Mouse(2, UI_INPUT_GAME); + Check(!state.Any_Suppressed(), "nothing is suppressed before a cancel"); + state.Cancel_UI(); + Check(state.Key_Owner(65) == UI_INPUT_GAME && state.Mouse_Owner(2) == UI_INPUT_GAME, "closing a screen leaves the game's own presses alone"); + Check(state.Key_Owner(66) == UI_INPUT_SUPPRESSED && state.Mouse_Owner(0) == UI_INPUT_SUPPRESSED, "closing a screen suppresses what the toolkits held"); + Check(state.Any_Suppressed(), "suppressed input is reported"); + Check(state.Press_Key(66, UI_INPUT_GAME) == UI_INPUT_SUPPRESSED, "a repeat of a suppressed key stays suppressed"); + Check(state.Release_Key(66) == UI_INPUT_SUPPRESSED && state.Release_Mouse(0) == UI_INPUT_SUPPRESSED, "a suppressed release never reaches the game"); + Check(state.Press_Key(66, UI_INPUT_GAME) == UI_INPUT_GAME, "a fresh press works once the suppression ended"); + state.Cancel_All(); + Check(state.Release_Key(65) == UI_INPUT_SUPPRESSED && state.Release_Key(66) == UI_INPUT_SUPPRESSED, "losing focus suppresses every held key"); + Check(state.Release_Mouse(2) == UI_INPUT_SUPPRESSED, "losing focus suppresses a game-owned button"); + state.Press_Key(67, UI_INPUT_RML); + state.Press_Mouse(3, UI_INPUT_IMGUI); + state.Reset(); + Check(state.Key_Owner(67) == UI_INPUT_NONE && state.Mouse_Owner(3) == UI_INPUT_NONE && state.Gesture_Owner() == UI_INPUT_NONE && !state.Any_Owned(), "a reset forgets every owner"); +} + + +void Test_Reconciliation(void) +{ + UIInputStateClass state; + std::array released {}; + + state.Press_Mouse(0, UI_INPUT_GAME); + state.Reconcile_Cancelled_Mouse(released); + Check(state.Gesture_Owner() == UI_INPUT_GAME, "a physical release cannot forget a game gesture before its up message"); + Check(!UI_Consumes_Input(state.Gesture_Owner(), false), "a game drag's motion stays the game's over a document"); + Check(!UI_Consumes_Input(state.Release_Mouse(0), true), "a game release reaches the game after the physical release"); + state.Press_Mouse(0, UI_INPUT_IMGUI); + state.Reconcile_Cancelled_Mouse(released); + Check(state.Gesture_Owner() == UI_INPUT_IMGUI && UI_Consumes_Input(state.Release_Mouse(0), true), "a physical release cannot hand a UI gesture to the game"); + + state.Press_Key(70, UI_INPUT_RML); + state.Press_Mouse(0, UI_INPUT_GAME); + state.Press_Mouse(1, UI_INPUT_GAME); + state.Cancel_Mouse(); + Check(state.Key_Owner(70) == UI_INPUT_RML, "losing the capture leaves the keys alone"); + std::array oneheld {}; + oneheld[1] = true; + state.Reconcile_Cancelled_Mouse(oneheld); + Check(state.Mouse_Owner(0) == UI_INPUT_NONE && state.Mouse_Owner(1) == UI_INPUT_SUPPRESSED, "only a cancelled button that is up is forgotten"); + Check(UI_Consumes_Input(state.Release_Mouse(0), true), "an orphan release is swallowed after reconciliation"); + Check(UI_Consumes_Input(state.Release_Mouse(1), true), "a cancelled button still held keeps its release"); + + std::array keysup {}; + state.Press_Key(71, UI_INPUT_IMGUI); + state.Cancel_All(); + keysup[70] = true; + state.Reconcile_Cancelled_Keys(keysup); + Check(state.Key_Owner(70) == UI_INPUT_SUPPRESSED && state.Key_Owner(71) == UI_INPUT_NONE, "only a cancelled key that is up is forgotten"); + Check(!UI_Consumes_Input(UI_INPUT_GAME, false) && UI_Consumes_Input(UI_INPUT_RML, false), "delivery follows the owner of the press, not the pointer's position"); + Check(UI_Consumes_Input(UI_INPUT_NONE, true) && !UI_Consumes_Input(UI_INPUT_NONE, false), "a release nobody pressed is swallowed; a press nobody owns is not"); +} + + +void Test_Text(void) +{ + UIUTF8DecoderClass decoder; + + Check(Decode(decoder, { 0x41, 0x0A }) == std::vector { U'A', U'\n' }, "ASCII text passes through"); + Check(Decode(decoder, { 0xC3, 0xA9, 0xE2, 0x82, 0xAC, 0xF0, 0x9F, 0x98, 0x80 }) == std::vector { 0xE9, 0x20AC, 0x1F600 }, "two-, three- and four-byte sequences decode"); + Check(decoder.Feed(0xE2).Count == 0 && decoder.Feed(0x82).Count == 0, "a partial sequence waits for its bytes"); + UIInputText completed = decoder.Feed(0xAC); + Check(completed.Count == 1 && completed.Codepoints[0] == 0x20AC, "a sequence can span messages"); + Check(decoder.Feed(0xE2).Count == 0, "a truncated prefix stays pending"); + UIInputText repaired = decoder.Feed(0x42); + Check(repaired.Count == 2 && repaired.Codepoints[0] == 0xFFFD && repaired.Codepoints[1] == U'B', "a malformed prefix does not swallow the ASCII after it"); + Check(Decode(decoder, { 0xE2, 0xC3, 0xA9 }) == std::vector { 0xFFFD, 0xE9 }, "a malformed prefix does not swallow the sequence after it"); + Check(Decode(decoder, { 0xC0, 0xAF }) == std::vector { 0xFFFD, 0xFFFD }, "invalid lead bytes are rejected"); + Check(Decode(decoder, { 0xE0, 0x80, 0x80 }) == std::vector { 0xFFFD }, "an overlong encoding is rejected"); + Check(Decode(decoder, { 0xED, 0xA0, 0x80 }) == std::vector { 0xFFFD }, "an encoded surrogate is rejected"); + Check(Decode(decoder, { 0xF4, 0x90, 0x80, 0x80 }) == std::vector { 0xFFFD }, "a code point past U+10FFFF is rejected"); + Check(Decode(decoder, { 0x80, 0x41 }) == std::vector { 0xFFFD, U'A' }, "a stray continuation byte does not swallow the text after it"); + decoder.Feed(0xF0); + decoder.Feed(0x9F); + decoder.Reset(); + Check(Decode(decoder, { 0x43 }) == std::vector { U'C' }, "a reset drops an incomplete sequence"); + decoder.Feed(0xE2); + decoder.Reset(); + Check(Decode(decoder, { 0x82, 0xAC }) == std::vector { 0xFFFD, 0xFFFD }, "a reset cannot splice fragments across owners"); +} + +} + + +int main(void) +{ + Test_Ownership(); + Test_Reconciliation(); + Test_Text(); + + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); + return(Failures == 0 ? 0 : 1); +} From 6eec900f3358751f92104d7bcbcb524ed5f5549b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:53:31 +0300 Subject: [PATCH 27/52] Route UI input by the owner of each press --- code/ui/uienginehost.cpp | 22 ++ code/ui/uihost.h | 8 + code/ui/uiinput.cpp | 4 +- code/ui/uiinput.h | 7 +- code/ui/uishell.cpp | 499 +++++++++++++++++++++++++--------- code/ui/uishell.h | 33 ++- docs/UI_DESIGN.md | 53 ++-- tests/uilogic/uilogictest.cpp | 14 +- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 178 ++++++++++++ 10 files changed, 649 insertions(+), 170 deletions(-) diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index cfbcef48e..6feaafcae 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -121,6 +121,28 @@ class UIEngineHostClass : public UIShellHostClass return(true); } + virtual bool Key_Down(int virtualkey) const override + { + if (GetSystemMetrics(SM_SWAPBUTTON) != 0) { + if (virtualkey == VK_LBUTTON) { + virtualkey = VK_RBUTTON; + } else if (virtualkey == VK_RBUTTON) { + virtualkey = VK_LBUTTON; + } + } + return((GetAsyncKeyState(virtualkey) & 0x8000) != 0); + } + + virtual bool Window_Is_Unicode(void) const override + { + return(IsWindowUnicode(MainWindow) != FALSE); + } + + virtual unsigned int Text_Code_Page(void) const override + { + return(GetACP()); + } + virtual char const * String(int id) const override { return(Fetch_String(id)); diff --git a/code/ui/uihost.h b/code/ui/uihost.h index 8271f1b4c..4cbf88b55 100644 --- a/code/ui/uihost.h +++ b/code/ui/uihost.h @@ -58,6 +58,14 @@ class UIShellHostClass virtual void Release_Capture(void) = 0; virtual bool Screen_To_Client(int & x, int & y) const = 0; + // Whether a key or mouse button is physically down. VK_LBUTTON and VK_RBUTTON name the + // primary and secondary buttons as the messages do, whatever the user swapped. + virtual bool Key_Down(int virtualkey) const = 0; + // True when text messages carry UTF-16 units; otherwise they carry one byte each of + // the code page below. + virtual bool Window_Is_Unicode(void) const = 0; + virtual unsigned int Text_Code_Page(void) const = 0; + // An engine string by identifier. The result is valid until the next call. virtual char const * String(int id) const = 0; virtual void Log(char const * text) = 0; diff --git a/code/ui/uiinput.cpp b/code/ui/uiinput.cpp index f5ea4ad45..2199b4445 100644 --- a/code/ui/uiinput.cpp +++ b/code/ui/uiinput.cpp @@ -21,9 +21,9 @@ bool Is_UI(UIInputOwner owner) } -bool UI_Consumes_Input(UIInputOwner owner, bool release) +bool UI_Consumes_Input(UIInputOwner owner) { - return(Is_UI(owner) || owner == UI_INPUT_SUPPRESSED || (release && owner == UI_INPUT_NONE)); + return(Is_UI(owner) || owner == UI_INPUT_SUPPRESSED); } diff --git a/code/ui/uiinput.h b/code/ui/uiinput.h index efe9f3fe0..4fd69d50c 100644 --- a/code/ui/uiinput.h +++ b/code/ui/uiinput.h @@ -19,9 +19,10 @@ #include -// True when the owner's message must be kept from the game: a toolkit owns it, it was -// cancelled, or it is a release nobody pressed. -bool UI_Consumes_Input(UIInputOwner owner, bool release); +// True when the owner's message must be kept from the game: a toolkit owns it or it was +// cancelled. Input nobody owns is the game's, including a release whose press the shell +// never saw. +bool UI_Consumes_Input(UIInputOwner owner); class UIInputStateClass diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 5e6e86003..a5eb6802a 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -100,10 +100,14 @@ bool Input_Message(UINT message) case WM_RBUTTONDBLCLK: case WM_MBUTTONDOWN: case WM_MBUTTONDBLCLK: + case WM_XBUTTONDOWN: + case WM_XBUTTONDBLCLK: case WM_LBUTTONUP: case WM_RBUTTONUP: case WM_MBUTTONUP: + case WM_XBUTTONUP: case WM_MOUSEWHEEL: + case WM_MOUSEHWHEEL: case WM_KEYDOWN: case WM_KEYUP: case WM_CHAR: @@ -114,6 +118,66 @@ bool Input_Message(UINT message) } } + +// The button a mouse message names, as RmlUi counts them: primary, secondary, middle, then +// the two side buttons. +int Message_Button(UINT message, WPARAM wparam) +{ + switch (message) { + case WM_LBUTTONDOWN: + case WM_LBUTTONDBLCLK: + case WM_LBUTTONUP: + return(0); + + case WM_RBUTTONDOWN: + case WM_RBUTTONDBLCLK: + case WM_RBUTTONUP: + return(1); + + case WM_MBUTTONDOWN: + case WM_MBUTTONDBLCLK: + case WM_MBUTTONUP: + return(2); + + case WM_XBUTTONDOWN: + case WM_XBUTTONDBLCLK: + case WM_XBUTTONUP: + return(GET_XBUTTON_WPARAM(wparam) == XBUTTON1 ? 3 : 4); + + default: + return(-1); + } +} + + +int Button_Virtual_Key(unsigned button) +{ + static int const keys[UIInputStateClass::BUTTON_COUNT] = { VK_LBUTTON, VK_RBUTTON, VK_MBUTTON, VK_XBUTTON1, VK_XBUTTON2 }; + return(keys[button]); +} + + +// The modifiers are read from the keyboard state as each message arrives, so their presses +// are never owned by anyone. +bool Modifier_Key(int virtualkey) +{ + switch (virtualkey) { + case VK_SHIFT: + case VK_CONTROL: + case VK_MENU: + case VK_LSHIFT: + case VK_RSHIFT: + case VK_LCONTROL: + case VK_RCONTROL: + case VK_LMENU: + case VK_RMENU: + return(true); + + default: + return(false); + } +} + } @@ -166,6 +230,13 @@ void UIShellClass::Log(char const * format, ...) } +// While anything is shown or held, every input message is the shell's to look at. +bool UIShellClass::Active(void) const +{ + return(Input.Any_Owned() || !Modals.empty() || Documents_Visible() || UIDev_Active()); +} + + bool UIShellClass::Documents_Visible(void) const { if (Context == nullptr) { @@ -216,23 +287,75 @@ UIPointerPosition UIShellClass::Pointer_Position(LPARAM clientlparam) const } -// Forgets the presses the shell owns, telling the documents they ended, and gives the -// capture back when the shell took it. -void UIShellClass::Drop_Presses(void) +std::array UIShellClass::Physical_Buttons(void) const { - unsigned int owned = OwnedButtons; - unsigned int devowned = DevOwnedButtons; - OwnedButtons = 0; - DevOwnedButtons = 0; + std::array held {}; + for (unsigned button = 0; button < held.size(); button++) { + held[button] = Host.Key_Down(Button_Virtual_Key(button)); + } + return(held); +} - for (int button = 0; button < 3; button++) { - if (devowned & (1u << button)) { - UIDev_Mouse_Button(button, false); - } else if (owned & (1u << button)) { - Context->ProcessMouseButtonUp(button, Key_Modifiers()); + +// What is held as a screen opens or focus returns belongs to nobody now: its release is +// swallowed rather than handed to the game as the end of a press it never saw. +void UIShellClass::Quarantine_Held_Input(void) +{ + Input.Reset(); + + for (unsigned key = VK_XBUTTON2 + 1; key < UIInputStateClass::KEY_COUNT; key++) { + if (!Modifier_Key((int)key) && Host.Key_Down((int)key)) { + Input.Press_Key(key, UI_INPUT_SUPPRESSED); } } + std::array held = Physical_Buttons(); + for (unsigned button = 0; button < held.size(); button++) { + if (held[button]) { + Input.Press_Mouse(button, UI_INPUT_SUPPRESSED); + } + } +} + + +// A suppressed entry ends when the system says its key or button is up, so a release that +// went to another window cannot keep the shell active. +void UIShellClass::Reconcile_Held_Input(void) +{ + if (!Input.Any_Suppressed()) { + return; + } + + std::array keys {}; + for (unsigned key = 1; key < keys.size(); key++) { + keys[key] = Host.Key_Down((int)key); + } + Input.Reconcile_Cancelled_Keys(keys); + Input.Reconcile_Cancelled_Mouse(Physical_Buttons()); +} + + +// Ends the presses the toolkits hold, telling them so, and suppresses every held button: +// their releases stay with the shell wherever they land. +void UIShellClass::Drop_Presses(void) +{ + for (unsigned button = 0; button < UIInputStateClass::BUTTON_COUNT; button++) { + UIInputOwner owner = Input.Mouse_Owner(button); + if (owner == UI_INPUT_IMGUI) { + UIDev_Mouse_Button((int)button, false); + } else if (owner == UI_INPUT_RML) { + Context->ProcessMouseButtonUp((int)button, Key_Modifiers()); + } + } + + Input.Cancel_Mouse(); + Release_UI_Capture(); + Reset_Text(); +} + + +void UIShellClass::Release_UI_Capture(void) +{ if (TookCapture) { TookCapture = false; Host.Release_Capture(); @@ -240,6 +363,14 @@ void UIShellClass::Drop_Presses(void) } +void UIShellClass::Reset_Text(void) +{ + HighSurrogate = 0; + Utf8.Reset(); + LegacyLead = 0; +} + + void UIShellClass::Toggle_Test_Document(void) { #ifdef _DEBUG @@ -371,9 +502,11 @@ void UIShellClass::Shutdown(void) Modals.clear(); ModalClosing = false; - if (OwnedButtons != 0) { + if (Input.Gesture_Owner() != UI_INPUT_NONE) { Drop_Presses(); } + Input.Reset(); + Reset_Text(); // The documents go while the context still exists; a caller hiding its notice // afterwards finds nothing to do. @@ -457,6 +590,7 @@ void UIShellClass::Tick(void) UIReentryGuardClass ticking(InTick); Drain_Deferred(); + Reconcile_Held_Input(); { UIReentryGuardClass updating(InContext); @@ -516,7 +650,7 @@ bool UIShellClass::Handle_Mouse_Move(LPARAM clientlparam) return(false); } - if (OwnedButtons != 0 || position.Inside) { + if (Input.Has_UI_Mouse() || position.Inside) { Context->ProcessMouseMove(position.X, position.Y, Key_Modifiers()); MouseInside = position.Inside; Host.Mark_Overlay_Dirty(); @@ -530,94 +664,81 @@ bool UIShellClass::Handle_Mouse_Move(LPARAM clientlparam) } -void UIShellClass::Own_Press(int button) -{ - if (OwnedButtons == 0) { - TookCapture = Host.Take_Capture(); - } - OwnedButtons |= (1u << button); -} - - -void UIShellClass::Release_Press(int button) -{ - OwnedButtons &= ~(1u << button); - DevOwnedButtons &= ~(1u << button); - if (OwnedButtons == 0 && TookCapture) { - TookCapture = false; - Host.Release_Capture(); - } -} - - +// The press's owner is decided here and kept until its release: the overlays first, then +// a shown screen, which takes every press, then whichever document the pointer is over. bool UIShellClass::Handle_Button_Down(int button, LPARAM clientlparam) { + Input.Reconcile_Cancelled_Mouse(Physical_Buttons()); + UIPointerPosition position = Pointer_Position(clientlparam); + int modifiers = Key_Modifiers(); + bool haduimouse = Input.Has_UI_Mouse(); + UIInputOwner owner = UI_INPUT_GAME; if (UIDev_Active()) { UIDev_Mouse_Position(position.X, position.Y); if (UIDev_Mouse_Button(button, true)) { - Own_Press(button); - DevOwnedButtons |= (1u << button); - Host.Mark_Overlay_Dirty(); - return(true); + owner = UI_INPUT_IMGUI; } } - if (!position.Inside && OwnedButtons == 0) { - return(false); + if (owner == UI_INPUT_GAME) { + bool feed = position.Inside || Input.Gesture_Owner() != UI_INPUT_NONE; + if (feed) { + Context->ProcessMouseMove(position.X, position.Y, modifiers); + MouseInside = position.Inside; + } + if (!Modals.empty()) { + if (feed) { + Context->ProcessMouseButtonDown(button, modifiers); + } + owner = UI_INPUT_RML; + } else if (feed && !Context->ProcessMouseButtonDown(button, modifiers)) { + owner = UI_INPUT_RML; + } } - int modifiers = Key_Modifiers(); - Context->ProcessMouseMove(position.X, position.Y, modifiers); - MouseInside = position.Inside; - - bool interacting = !Context->ProcessMouseButtonDown(button, modifiers); - Host.Mark_Overlay_Dirty(); - - if (!interacting) { - return(false); + UIInputOwner latched = Input.Press_Mouse((unsigned)button, owner); + if (!haduimouse && Input.Has_UI_Mouse() && !TookCapture) { + TookCapture = Host.Take_Capture(); } - Own_Press(button); - return(true); + Host.Mark_Overlay_Dirty(); + return(UI_Consumes_Input(latched)); } bool UIShellClass::Handle_Button_Up(int button, LPARAM clientlparam) { UIPointerPosition position = Pointer_Position(clientlparam); + UIInputOwner owner = Input.Release_Mouse((unsigned)button); - if (DevOwnedButtons & (1u << button)) { + if (owner == UI_INPUT_IMGUI) { UIDev_Mouse_Position(position.X, position.Y); UIDev_Mouse_Button(button, false); - Release_Press(button); - Host.Mark_Overlay_Dirty(); - return(true); - } - - // A release the overlays did not own still ends the press they saw begin. - if (UIDev_Active()) { - UIDev_Mouse_Button(button, false); + } else { + // A release the overlays did not own still ends the press they saw begin. + if (UIDev_Active()) { + UIDev_Mouse_Button(button, false); + } + if (owner == UI_INPUT_RML) { + int modifiers = Key_Modifiers(); + Context->ProcessMouseMove(position.X, position.Y, modifiers); + Context->ProcessMouseButtonUp(button, modifiers); + MouseInside = position.Inside; + } } - if ((OwnedButtons & (1u << button)) == 0) { - return(false); + if (!Input.Has_UI_Mouse()) { + Release_UI_Capture(); } - int modifiers = Key_Modifiers(); - - Context->ProcessMouseMove(position.X, position.Y, modifiers); - Context->ProcessMouseButtonUp(button, modifiers); - MouseInside = position.Inside; Host.Mark_Overlay_Dirty(); - - Release_Press(button); - return(true); + return(UI_Consumes_Input(owner)); } -bool UIShellClass::Handle_Wheel(WPARAM wparam, LPARAM screenlparam) +bool UIShellClass::Handle_Wheel(WPARAM wparam, LPARAM screenlparam, bool horizontal) { int x = GET_X_LPARAM(screenlparam); int y = GET_Y_LPARAM(screenlparam); @@ -627,10 +748,10 @@ bool UIShellClass::Handle_Wheel(WPARAM wparam, LPARAM screenlparam) UIPointerPosition position = UI_Client_To_Overlay(frame.X, frame.Y, frame.Width, frame.Height, x, y); // Windows counts wheel movement away from the user as positive; ImGui scrolls up for it - // and RmlUi scrolls down. + // and RmlUi scrolls down. Sideways, both count rightward as positive. float delta = (float)(short)HIWORD(wparam) / (float)WHEEL_DELTA; - if (UIDev_Active()) { + if (!horizontal && UIDev_Active()) { UIDev_Mouse_Position(position.X, position.Y); if (UIDev_Mouse_Wheel(delta)) { Host.Mark_Overlay_Dirty(); @@ -642,58 +763,150 @@ bool UIShellClass::Handle_Wheel(WPARAM wparam, LPARAM screenlparam) return(false); } - bool consumed = !Context->ProcessMouseWheel(Rml::Vector2f(0.0f, -delta), Key_Modifiers()); + Rml::Vector2f movement = horizontal ? Rml::Vector2f(delta, 0.0f) : Rml::Vector2f(0.0f, -delta); + bool consumed = !Context->ProcessMouseWheel(movement, Key_Modifiers()); Host.Mark_Overlay_Dirty(); return(consumed); } -bool UIShellClass::Handle_Key(UINT message, WPARAM wparam) +// A fresh press decides its owner; a repeat and the release follow it. Keys the toolkits do +// not own are still shown to RmlUi so its modifier state keeps up, as before. +bool UIShellClass::Handle_Key(UINT message, WPARAM wparam, LPARAM lparam) { - if (UIDev_Key(wparam, message == WM_KEYDOWN)) { + unsigned virtualkey = (unsigned)(wparam & 0xFF); + int modifiers = Key_Modifiers(); + Rml::Input::KeyIdentifier key = UI_Key_Identifier((int)virtualkey); + + if (message == WM_KEYUP) { + UIInputOwner owner = Input.Release_Key(virtualkey); + if (owner == UI_INPUT_IMGUI) { + UIDev_Key(wparam, false); + } else if (owner != UI_INPUT_SUPPRESSED) { + if (UIDev_Active()) { + UIDev_Key(wparam, false); + } + if (key != Rml::Input::KI_UNKNOWN) { + Context->ProcessKeyUp(key, modifiers); + } + } Host.Mark_Overlay_Dirty(); - return(true); + return(UI_Consumes_Input(owner)); } - Rml::Input::KeyIdentifier key = UI_Key_Identifier((int)(wparam & 0xFF)); - if (key == Rml::Input::KI_UNKNOWN) { - return(false); + bool repeat = (lparam & (1 << 30)) != 0; + if (!repeat) { + Input.Release_Key(virtualkey); } - bool propagated; - if (message == WM_KEYDOWN) { - propagated = Context->ProcessKeyDown(key, Key_Modifiers()); + UIInputOwner owner = Input.Key_Owner(virtualkey); + if (owner != UI_INPUT_NONE) { + if (owner == UI_INPUT_IMGUI) { + UIDev_Key(wparam, true); + } else if (owner == UI_INPUT_RML && key != Rml::Input::KI_UNKNOWN) { + Context->ProcessKeyDown(key, modifiers); + } } else { - propagated = Context->ProcessKeyUp(key, Key_Modifiers()); + if (UIDev_Key(wparam, true)) { + owner = UI_INPUT_IMGUI; + } else if (!Modals.empty()) { + if (key != Rml::Input::KI_UNKNOWN) { + Context->ProcessKeyDown(key, modifiers); + } + owner = UI_INPUT_RML; + } else if (key == Rml::Input::KI_UNKNOWN) { + owner = UI_INPUT_GAME; + } else { + bool propagated = Context->ProcessKeyDown(key, modifiers); + owner = (!propagated || Text_Input_Focused()) ? UI_INPUT_RML : UI_INPUT_GAME; + } + Input.Press_Key(virtualkey, owner); } Host.Mark_Overlay_Dirty(); - return(!propagated || Text_Input_Focused()); + return(UI_Consumes_Input(owner)); } -// Windows delivers a character beyond the basic plane as two messages; the first half -// waits for the second. Carriage returns become newlines and control characters stay out. bool UIShellClass::Handle_Char(WPARAM wparam) { - wchar_t unit = (wchar_t)wparam; - - if (UIDev_Character(unit)) { - Host.Mark_Overlay_Dirty(); - return(true); + if (Host.Window_Is_Unicode()) { + return(Feed_Text_Unit((wchar_t)wparam)); } + return(Feed_Text_Byte((unsigned char)wparam)); +} + +// A character beyond the basic plane arrives as two units; the first waits for the second, +// and either half on its own becomes U+FFFD. +bool UIShellClass::Feed_Text_Unit(wchar_t unit) +{ if (unit >= 0xD800 && unit < 0xDC00) { + bool consumed = false; + if (HighSurrogate != 0) { + consumed = Handle_Text(0xFFFD); + } HighSurrogate = unit; - return(false); + return(consumed); } char32_t code = unit; - if (unit >= 0xDC00 && unit < 0xE000 && HighSurrogate != 0) { - code = 0x10000 + (((char32_t)HighSurrogate - 0xD800) << 10) + ((char32_t)unit - 0xDC00); + if (unit >= 0xDC00 && unit < 0xE000) { + code = (HighSurrogate != 0) ? 0x10000 + (((char32_t)HighSurrogate - 0xD800) << 10) + ((char32_t)unit - 0xDC00) : 0xFFFD; + } else if (HighSurrogate != 0) { + Handle_Text(0xFFFD); } HighSurrogate = 0; + return(Handle_Text(code)); +} + + +// A narrow window delivers text one byte per message: UTF-8 under the UTF-8 code page, else +// the code page's own single and double bytes. +bool UIShellClass::Feed_Text_Byte(unsigned char byte) +{ + unsigned int codepage = Host.Text_Code_Page(); + + if (codepage == CP_UTF8) { + UIInputText text = Utf8.Feed(byte); + bool consumed = false; + for (unsigned index = 0; index < text.Count; index++) { + consumed = Handle_Text(text.Codepoints[index]) || consumed; + } + return(consumed); + } + + char bytes[2]; + int count; + if (LegacyLead != 0) { + bytes[0] = (char)LegacyLead; + bytes[1] = (char)byte; + count = 2; + LegacyLead = 0; + } else if (IsDBCSLeadByteEx(codepage, byte)) { + LegacyLead = byte; + return(false); + } else { + bytes[0] = (char)byte; + count = 1; + } + + wchar_t wide[2]; + int converted = MultiByteToWideChar(codepage, MB_ERR_INVALID_CHARS, bytes, count, wide, 2); + char32_t code = 0xFFFD; + if (converted == 1) { + code = wide[0]; + } else if (converted == 2 && wide[0] >= 0xD800 && wide[0] < 0xDC00 && wide[1] >= 0xDC00 && wide[1] < 0xE000) { + code = 0x10000 + (((char32_t)wide[0] - 0xD800) << 10) + ((char32_t)wide[1] - 0xDC00); + } + return(Handle_Text(code)); +} + + +// Carriage returns become newlines and control characters stay out, as before. +bool UIShellClass::Handle_Text(char32_t code) +{ if (code == '\r') { code = '\n'; } @@ -701,6 +914,21 @@ bool UIShellClass::Handle_Char(WPARAM wparam) return(false); } + if (UIDev_Active()) { + bool wanted; + if (code > 0xFFFF) { + char32_t offset = code - 0x10000; + wanted = UIDev_Character((wchar_t)(0xD800 + (offset >> 10))); + wanted = UIDev_Character((wchar_t)(0xDC00 + (offset & 0x3FF))) || wanted; + } else { + wanted = UIDev_Character((wchar_t)code); + } + if (wanted) { + Host.Mark_Overlay_Dirty(); + return(true); + } + } + bool consumed = !Context->ProcessTextInput((Rml::Character)code); Host.Mark_Overlay_Dirty(); return(consumed); @@ -731,9 +959,11 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s view.Presenter().Refresh(); view.Sync(); - if (OwnedButtons != 0) { + if (Input.Gesture_Owner() != UI_INPUT_NONE) { Drop_Presses(); } + Quarantine_Held_Input(); + Reset_Text(); char label[160]; @@ -770,9 +1000,11 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s } ModalClosing = true; - if (OwnedButtons != 0) { + if (Ready && Input.Gesture_Owner() != UI_INPUT_NONE) { Drop_Presses(); } + Input.Cancel_UI(); + Reset_Text(); view.Presenter().Discard(); view.Release(); @@ -856,15 +1088,17 @@ void UIShellClass::Refresh(void) } -bool UIShellClass::Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam) +bool UIShellClass::Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { if (!Ready || InHook || hwnd != Host.Main_Window()) { return(false); } - // Another window taking the capture ends the presses the shell owns. - if (message == WM_CAPTURECHANGED) { - if (OwnedButtons != 0 && (HWND)clientlparam != Host.Main_Window()) { + // Another window taking the capture, or the system cancelling it, ends the presses the + // shell holds; the window no longer has the capture to give back. + if (message == WM_CAPTURECHANGED || message == WM_CANCELMODE) { + bool lost = (message == WM_CANCELMODE) || (HWND)lparam != Host.Main_Window(); + if (lost && Input.Gesture_Owner() != UI_INPUT_NONE) { TookCapture = false; if (InContext) { Deferred.DropPresses = true; @@ -877,24 +1111,38 @@ bool UIShellClass::Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, } if (message == WM_ACTIVATEAPP) { + bool activated = (wparam != 0); if (InContext) { - Deferred.DevFocus = (wparam != 0) ? 1 : 0; + Deferred.DevFocus = activated ? 1 : 0; } else { - UIDev_Focus(wparam != 0); + UIDev_Focus(activated); } - if (wparam == 0 && MouseInside) { - if (InContext) { - Deferred.Leave = true; - } else { - UIReentryGuardClass hooking(InHook); - Context->ProcessMouseLeave(); - MouseInside = false; + if (!activated) { + if (MouseInside) { + if (InContext) { + Deferred.Leave = true; + } else { + UIReentryGuardClass hooking(InHook); + Context->ProcessMouseLeave(); + MouseInside = false; + } } + Input.Cancel_All(); + Release_UI_Capture(); + Reset_Text(); + } else if (Active()) { + Quarantine_Held_Input(); + Reset_Text(); } return(false); } - if (InContext || (OwnedButtons == 0 && Modals.empty() && !Documents_Visible() && !UIDev_Active())) { + if (message == WM_INPUTLANGCHANGE) { + Reset_Text(); + return(false); + } + + if (InContext || !Active()) { return(false); } @@ -908,43 +1156,38 @@ bool UIShellClass::Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, switch (message) { case WM_MOUSEMOVE: - consumed = Handle_Mouse_Move(clientlparam); + consumed = Handle_Mouse_Move(lparam); break; case WM_LBUTTONDOWN: case WM_LBUTTONDBLCLK: - consumed = Handle_Button_Down(0, clientlparam); - break; - case WM_RBUTTONDOWN: case WM_RBUTTONDBLCLK: - consumed = Handle_Button_Down(1, clientlparam); - break; - case WM_MBUTTONDOWN: case WM_MBUTTONDBLCLK: - consumed = Handle_Button_Down(2, clientlparam); + case WM_XBUTTONDOWN: + case WM_XBUTTONDBLCLK: + consumed = Handle_Button_Down(Message_Button(message, wparam), lparam); break; case WM_LBUTTONUP: - consumed = Handle_Button_Up(0, clientlparam); - break; - case WM_RBUTTONUP: - consumed = Handle_Button_Up(1, clientlparam); - break; - case WM_MBUTTONUP: - consumed = Handle_Button_Up(2, clientlparam); + case WM_XBUTTONUP: + consumed = Handle_Button_Up(Message_Button(message, wparam), lparam); break; case WM_MOUSEWHEEL: - consumed = Handle_Wheel(wparam, clientlparam); + consumed = Handle_Wheel(wparam, lparam, false); + break; + + case WM_MOUSEHWHEEL: + consumed = Handle_Wheel(wparam, lparam, true); break; case WM_KEYDOWN: case WM_KEYUP: - consumed = Handle_Key(message, wparam); + consumed = Handle_Key(message, wparam, lparam); break; case WM_CHAR: diff --git a/code/ui/uishell.h b/code/ui/uishell.h index e76a56ef4..4e2c88ea7 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -13,9 +13,11 @@ #pragma once #include "ui/uicoord.h" +#include "ui/uiinput.h" #include "ui/uiscreen.h" #include "win.h" +#include #include #include #include @@ -98,7 +100,7 @@ class UIShellClass // Offers a main window message to the shell before the game sees it. The position // is the raw client one, taken before the router translated it. True means the // message is consumed. - bool Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM clientlparam); + bool Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam); // Offers a pumped message to the shell before dispatch, whichever window it is for. // True means the message is consumed. @@ -108,6 +110,7 @@ class UIShellClass UIViewClass * Modal(void) const; int Modal_Depth(void) const; bool Is_Modeless_Shown(UIViewClass const & view) const; + UIInputStateClass const & Input_State(void) const { return(Input); } private: friend class UITestListenerClass; @@ -126,21 +129,28 @@ class UIShellClass }; void Log(char const * format, ...); + bool Active(void) const; bool Documents_Visible(void) const; bool Text_Input_Focused(void) const; void Apply_Dimensions(void); UIPointerPosition Pointer_Position(LPARAM clientlparam) const; + std::array Physical_Buttons(void) const; + void Quarantine_Held_Input(void); + void Reconcile_Held_Input(void); void Drop_Presses(void); + void Release_UI_Capture(void); + void Reset_Text(void); void Drain_Deferred(void); void Toggle_Test_Document(void); - void Own_Press(int button); - void Release_Press(int button); bool Handle_Mouse_Move(LPARAM clientlparam); bool Handle_Button_Down(int button, LPARAM clientlparam); bool Handle_Button_Up(int button, LPARAM clientlparam); - bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam); - bool Handle_Key(UINT message, WPARAM wparam); + bool Handle_Wheel(WPARAM wparam, LPARAM screenlparam, bool horizontal); + bool Handle_Key(UINT message, WPARAM wparam, LPARAM lparam); bool Handle_Char(WPARAM wparam); + bool Feed_Text_Unit(wchar_t unit); + bool Feed_Text_Byte(unsigned char byte); + bool Handle_Text(char32_t code); UIShellHostClass & Host; std::unique_ptr System; @@ -158,15 +168,16 @@ class UIShellClass bool InTick = false; DeferredWorkType Deferred; - // The presses the shell consumed, as a mask over the mouse button indices, and - // whether it took the window's capture for them. Their releases belong to the shell - // wherever they land. The developer overlays' own presses are a subset that their - // release goes back to. - unsigned int OwnedButtons = 0; - unsigned int DevOwnedButtons = 0; + // Who holds each key and button. A press the toolkits own takes the window's + // capture; TookCapture says the shell took it and must give it back. + UIInputStateClass Input; bool TookCapture = false; bool MouseInside = false; + + // Text arrives one unit or byte per message; these carry a sequence between them. wchar_t HighSurrogate = 0; + UIUTF8DecoderClass Utf8; + unsigned char LegacyLead = 0; // The modal screens the runner is driving, innermost last, and whether the // innermost is between releasing its document and handing the input back. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 5d6931967..f66a11d2e 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -365,16 +365,26 @@ The rules the hook applies, in order: This mirrors `IgnoreInput` around a legacy dialog and composes with the scenario's own input locks rather than replacing them. 3. Otherwise mouse moves are always delivered and never consumed, so the - game keeps tracking the cursor. A button or wheel message is consumed when - RmlUi reports that the mouse is interacting with an element (its mouse - functions return `false` for that). Keys and text are consumed when an - element stopped their propagation, or whenever the focused element is a - text field. Documents that float over the game mark their body - `pointer-events: none` so empty space passes through. -4. A button press that a toolkit consumed sets mouse capture on `MainWindow` - until the release, and the owner of a press owns its release: crossing a - region or opening a modal in between completes or cancels that gesture - without activating the newly focused screen. + game keeps tracking the cursor. A button press is owned by whoever takes + it: ImGui, RmlUi when it reports the mouse interacting with an element + (its mouse functions return `false` for that), or the game. A key press + is owned the same way, by RmlUi when an element stopped its propagation + or the focused element is a text field; a wheel message is consumed when + RmlUi consumed it, and text when RmlUi consumed it. Documents that float + over the game mark their body `pointer-events: none` so empty space + passes through. +4. The owner of a press owns its release, wherever the release lands. A + press a toolkit owns sets mouse capture on `MainWindow` until the last + such button is up. A screen closing, another window taking the capture, + or the window losing focus suppresses what is held: the toolkits are told + their presses ended and the releases are swallowed rather than handed to + the game as the end of a press it never saw. What is physically held as a + modal screen opens, or as focus returns while something is shown, is + suppressed the same way. A suppressed key or button is forgotten once the + system reports it up, so a release that went to another window cannot + keep the shell active. The five mouse buttons and both wheel axes are + routed; the modifier keys are read from the keyboard state and never + owned. Gameplay code that polls `Down` still sees held keys; eligibility is applied at the consumers, `GScreenClass::Input` and the gadget and scroll paths, not @@ -382,15 +392,20 @@ by falsifying physical state. ### Focus, cursor, clipboard, text -The shell clears the keyboard queue when a modal document opens or closes, -after marking the screen closing so the pump inside `Keyboard->Clear()` -cannot re-enter it. Focus loss cancels capture, drags, and composition; -focus return does not replay held keys as presses. Cursor requests from RmlUi -(`pointer`, `text`) map to `Win_Cursor_Set` and the previous request is -restored on close. The clipboard interface uses the Win32 clipboard. - -Text input arrives as `WM_CHAR` with surrogate pairs joined. Consuming a -physical key never suppresses the text message it generates. Editable +The shell clears the keyboard queue when a modal document opens and again +after it is released, so the pump inside `Keyboard->Clear()` meets either +the shown screen or the ownership table, never a screen mid-teardown. Focus +loss cancels capture, drags, and composition; focus return does not replay +held keys as presses. Cursor requests from RmlUi (`pointer`, `text`) map to +`Win_Cursor_Set` and the previous request is restored on close. The +clipboard interface uses the Win32 clipboard. + +Text arrives as `WM_CHAR`. The main window is a narrow window, so under the +UTF-8 code page each message carries one byte and the shell decodes the +sequence, replacing a malformed one with U+FFFD; under another code page it +joins a lead byte with its trail byte. A Unicode window would deliver UTF-16 +units, which the shell pairs, and a lone surrogate becomes U+FFFD. Consuming +a physical key never suppresses the text message it generates. Editable screens ship only after Tab and Shift+Tab, Enter and Escape, repeat, modifiers, paste, dead keys, and IME composition have been exercised for the supported languages; the read-only pilot proves none of that. diff --git a/tests/uilogic/uilogictest.cpp b/tests/uilogic/uilogictest.cpp index 6aa779887..9e2b74315 100644 --- a/tests/uilogic/uilogictest.cpp +++ b/tests/uilogic/uilogictest.cpp @@ -102,11 +102,11 @@ void Test_Reconciliation(void) state.Press_Mouse(0, UI_INPUT_GAME); state.Reconcile_Cancelled_Mouse(released); Check(state.Gesture_Owner() == UI_INPUT_GAME, "a physical release cannot forget a game gesture before its up message"); - Check(!UI_Consumes_Input(state.Gesture_Owner(), false), "a game drag's motion stays the game's over a document"); - Check(!UI_Consumes_Input(state.Release_Mouse(0), true), "a game release reaches the game after the physical release"); + Check(!UI_Consumes_Input(state.Gesture_Owner()), "a game drag's motion stays the game's over a document"); + Check(!UI_Consumes_Input(state.Release_Mouse(0)), "a game release reaches the game after the physical release"); state.Press_Mouse(0, UI_INPUT_IMGUI); state.Reconcile_Cancelled_Mouse(released); - Check(state.Gesture_Owner() == UI_INPUT_IMGUI && UI_Consumes_Input(state.Release_Mouse(0), true), "a physical release cannot hand a UI gesture to the game"); + Check(state.Gesture_Owner() == UI_INPUT_IMGUI && UI_Consumes_Input(state.Release_Mouse(0)), "a physical release cannot hand a UI gesture to the game"); state.Press_Key(70, UI_INPUT_RML); state.Press_Mouse(0, UI_INPUT_GAME); @@ -117,8 +117,8 @@ void Test_Reconciliation(void) oneheld[1] = true; state.Reconcile_Cancelled_Mouse(oneheld); Check(state.Mouse_Owner(0) == UI_INPUT_NONE && state.Mouse_Owner(1) == UI_INPUT_SUPPRESSED, "only a cancelled button that is up is forgotten"); - Check(UI_Consumes_Input(state.Release_Mouse(0), true), "an orphan release is swallowed after reconciliation"); - Check(UI_Consumes_Input(state.Release_Mouse(1), true), "a cancelled button still held keeps its release"); + Check(!UI_Consumes_Input(state.Release_Mouse(0)), "a release the shell forgot after reconciliation is the game's"); + Check(UI_Consumes_Input(state.Release_Mouse(1)), "a cancelled button still held keeps its release"); std::array keysup {}; state.Press_Key(71, UI_INPUT_IMGUI); @@ -126,8 +126,8 @@ void Test_Reconciliation(void) keysup[70] = true; state.Reconcile_Cancelled_Keys(keysup); Check(state.Key_Owner(70) == UI_INPUT_SUPPRESSED && state.Key_Owner(71) == UI_INPUT_NONE, "only a cancelled key that is up is forgotten"); - Check(!UI_Consumes_Input(UI_INPUT_GAME, false) && UI_Consumes_Input(UI_INPUT_RML, false), "delivery follows the owner of the press, not the pointer's position"); - Check(UI_Consumes_Input(UI_INPUT_NONE, true) && !UI_Consumes_Input(UI_INPUT_NONE, false), "a release nobody pressed is swallowed; a press nobody owns is not"); + Check(!UI_Consumes_Input(UI_INPUT_GAME) && UI_Consumes_Input(UI_INPUT_RML) && UI_Consumes_Input(UI_INPUT_SUPPRESSED), "delivery follows the owner of the press, not the pointer's position"); + Check(!UI_Consumes_Input(UI_INPUT_NONE), "input nobody owns is the game's"); } diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 2ee7d3dec..19bbbb357 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -9,6 +9,7 @@ opents_add_test(UIShell SOURCES uishell.cpp uidevstub.cpp ENGINE ui/uiscreen.cpp + ui/uiinput.cpp ui/uishell.cpp ui/rml/rmlkeys.cpp ui/rml/rmlsystem.cpp diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index b3f024cab..0f9076ce8 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -269,12 +269,30 @@ class TestHostClass : public UIShellHostClass bool LegacyRequested = false; bool LegacyVisible = false; bool Captured = false; + bool Unicode = false; + unsigned int CodePage = 65001; + bool Down[256] = {}; int Presents = 0; int Clears = 0; int Focuses = 0; UIShellClass * Shell = nullptr; std::function OnClear; + virtual bool Key_Down(int virtualkey) const override + { + return(Down[virtualkey & 0xFF]); + } + + virtual bool Window_Is_Unicode(void) const override + { + return(Unicode); + } + + virtual unsigned int Text_Code_Page(void) const override + { + return(CodePage); + } + virtual HWND Main_Window(void) const override { return(nullptr); @@ -2387,6 +2405,166 @@ void Test_Shell(void) Check(shell.Rml_Context()->GetDimensions() == Rml::Vector2i(1280, 800), "a resize outside a render is applied at once"); } + { + UIVersionPresenterClass presenter({ "held" }); + std::unique_ptr view = UI_Version_View(presenter); + int passes = 0; + bool suppressed = false; + bool swallowed = false; + bool quiet = false; + + host.Down[VK_LBUTTON] = true; + host.Down['A'] = true; + UIResult result = shell.Run_Modal(*view, [&](void) { + passes++; + if (passes == 1) { + suppressed = shell.Input_State().Mouse_Owner(0) == UI_INPUT_SUPPRESSED && shell.Input_State().Key_Owner('A') == UI_INPUT_SUPPRESSED; + swallowed = Send(shell, WM_LBUTTONUP, 0, MAKELPARAM(10, 10)) && Send(shell, WM_KEYUP, 'A'); + host.Down[VK_LBUTTON] = false; + host.Down['A'] = false; + quiet = !presenter.Has_Pending() && shell.Input_State().Mouse_Owner(0) == UI_INPUT_NONE && shell.Input_State().Key_Owner('A') == UI_INPUT_NONE; + } + if (passes == 2) { + Send(shell, WM_KEYDOWN, VK_RETURN); + } + return(false); + }); + Check(suppressed, "input held as a screen opens is suppressed"); + Check(swallowed, "the releases of suppressed input are swallowed"); + Check(quiet && result == UI_RESULT_ACCEPTED, "suppressed releases queue nothing and a fresh press still accepts"); + } + + { + UIVersionPresenterClass presenter({ "capture" }); + std::unique_ptr view = UI_Version_View(presenter); + int passes = 0; + bool owned = false; + bool cancelled = false; + bool swallowed = false; + + shell.Run_Modal(*view, [&](void) { + passes++; + if (passes == 1) { + Send(shell, WM_LBUTTONDOWN, 0, MAKELPARAM(10, 10)); + Send(shell, WM_KEYDOWN, 'A'); + owned = shell.Input_State().Mouse_Owner(0) == UI_INPUT_RML && shell.Input_State().Key_Owner('A') == UI_INPUT_RML && host.Captured; + host.Captured = false; + Send(shell, WM_CAPTURECHANGED, 0, (LPARAM)1); + cancelled = shell.Input_State().Mouse_Owner(0) == UI_INPUT_SUPPRESSED && shell.Input_State().Key_Owner('A') == UI_INPUT_RML && !host.Captured && shell.Modal() == view.get(); + swallowed = Send(shell, WM_LBUTTONUP, 0, MAKELPARAM(10, 10)) && !presenter.Has_Pending(); + Send(shell, WM_KEYUP, 'A'); + } + if (passes == 2) { + Send(shell, WM_KEYDOWN, VK_ESCAPE); + } + return(false); + }); + Check(owned, "a modal owns the presses it is given and takes the capture"); + Check(cancelled, "losing the capture cancels the modal's held buttons and nothing else"); + Check(swallowed, "the release of a cancelled press is swallowed"); + } + + { + // Two live screens need distinct data models, so the inner one is a message box. + UIVersionPresenterClass outer({ "outer" }); + UIMessageBoxPresenterClass inner("Nested", { "OK", "Cancel" }, 0); + std::unique_ptr outerview = UI_Version_View(outer); + std::unique_ptr innerview = UI_Message_Box_View(inner); + int passes = 0; + bool nested = false; + bool restored = false; + bool swallowed = false; + + UIResult result = shell.Run_Modal(*outerview, [&](void) { + passes++; + if (passes == 1) { + int innerpasses = 0; + UIResult innerresult = shell.Run_Modal(*innerview, [&](void) { + innerpasses++; + if (innerpasses == 1) { + nested = shell.Modal() == innerview.get() && shell.Modal_Depth() == 2; + host.Down[VK_LBUTTON] = true; + Send(shell, WM_LBUTTONDOWN, 0, MAKELPARAM(10, 10)); + Send(shell, WM_KEYDOWN, VK_ESCAPE); + } + return(innerpasses >= 5); + }); + restored = innerresult == UI_RESULT_CANCELLED && shell.Modal() == outerview.get() && shell.Modal_Depth() == 1 && shell.Input_State().Mouse_Owner(0) == UI_INPUT_SUPPRESSED; + swallowed = Send(shell, WM_LBUTTONUP, 0, MAKELPARAM(10, 10)); + host.Down[VK_LBUTTON] = false; + } + if (passes == 2) { + Send(shell, WM_KEYDOWN, VK_RETURN); + } + return(false); + }); + Check(nested, "a nested modal is the shown screen at depth two"); + Check(restored && result == UI_RESULT_ACCEPTED, "closing the inner modal restores the outer one, which still accepts"); + Check(swallowed, "a press held across the inner close is swallowed by the outer"); + } + + { + UIVersionPresenterClass presenter({ "messages" }); + std::unique_ptr view = UI_Version_View(presenter); + bool taken = false; + bool focused = false; + + shell.Run_Modal(*view, [&](void) { + taken = Send(shell, WM_MOUSEMOVE, 0, MAKELPARAM(2000, 2000)) + && Send(shell, WM_XBUTTONDOWN, MAKEWPARAM(0, XBUTTON1), MAKELPARAM(10, 10)) + && Send(shell, WM_XBUTTONUP, MAKEWPARAM(0, XBUTTON1), MAKELPARAM(10, 10)) + && Send(shell, WM_MOUSEHWHEEL, MAKEWPARAM(0, WHEEL_DELTA), MAKELPARAM(10, 10)); + host.Down['C'] = true; + Send(shell, WM_ACTIVATEAPP, 1); + focused = shell.Input_State().Key_Owner('C') == UI_INPUT_SUPPRESSED; + host.Down['C'] = false; + Send(shell, WM_KEYUP, 'C'); + Send(shell, WM_KEYDOWN, VK_ESCAPE); + return(false); + }); + Check(taken, "a modal takes moves, side buttons and the horizontal wheel"); + Check(focused, "focus returning to a shown screen quarantines what is held"); + + // The key that closed the screen is released after it; the shell swallows that release + // and, once it ticks, holds nothing. + Check(Send(shell, WM_KEYUP, VK_ESCAPE), "the release of the key that closed a screen is swallowed"); + shell.Tick(); + Check(!shell.Input_State().Any_Owned(), "nothing stays owned once the closing key is up and the shell has ticked"); + + host.Down['C'] = true; + Send(shell, WM_ACTIVATEAPP, 1); + Check(shell.Input_State().Key_Owner('C') == UI_INPUT_NONE, "focus returning to an idle shell quarantines nothing"); + host.Down['C'] = false; + Check(!Send(shell, WM_KEYUP, 'Z'), "a stray release meets an idle shell and reaches the game"); + } + + { + class TextRecorderClass : public Rml::EventListener + { + public: + std::vector Texts; + + virtual void ProcessEvent(Rml::Event & event) override + { + Texts.push_back(event.GetParameter("text", "")); + } + }; + + TextRecorderClass recorder; + UIVersionPresenterClass presenter({ "text" }); + std::unique_ptr view = UI_Version_View(presenter); + + shell.Run_Modal(*view, [&](void) { + Rml(*view).Document()->AddEventListener(Rml::EventId::Textinput, &recorder); + Send(shell, WM_CHAR, 0xC3); + Send(shell, WM_CHAR, 0xA9); + Rml(*view).Document()->RemoveEventListener(Rml::EventId::Textinput, &recorder); + Send(shell, WM_KEYDOWN, VK_ESCAPE); + return(false); + }); + Check(recorder.Texts.size() == 1 && recorder.Texts[0] == "\xC3\xA9", "two UTF-8 bytes on a narrow window reach the document as one character"); + } + { UIWaitBoxPresenterClass presenter("Working", false); std::unique_ptr view = UI_Wait_Box_View(presenter); From 6d0b10811142e320d3c223ac4b8af56d9152f1e7 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 12 Sep 2026 23:58:40 +0300 Subject: [PATCH 28/52] Give the UI documents the clipboard and the pointer shape --- code/ui/rml/rmlsystem.cpp | 82 +++++++++++++++++++++++++++++++ code/ui/rml/rmlsystem.h | 14 +++++- code/ui/uienginehost.cpp | 43 ++++++++++++++++ code/ui/uihost.h | 5 ++ code/ui/uishell.cpp | 54 ++++++++++++++++++++ code/ui/uishell.h | 10 ++++ code/ui/uiunicode.cpp | 75 ++++++++++++++++++++++++++++ code/ui/uiunicode.h | 24 +++++++++ code/winstub.cpp | 2 +- docs/UI_DESIGN.md | 13 +++-- manual/data/command-adapters.yaml | 17 +++++++ tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 58 ++++++++++++++++++++++ 13 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 code/ui/uiunicode.cpp create mode 100644 code/ui/uiunicode.h diff --git a/code/ui/rml/rmlsystem.cpp b/code/ui/rml/rmlsystem.cpp index 6e344dc54..50bb398bb 100644 --- a/code/ui/rml/rmlsystem.cpp +++ b/code/ui/rml/rmlsystem.cpp @@ -10,6 +10,7 @@ #include "ui/rml/rmlsystem.h" #include "ui/uihost.h" +#include "ui/uiunicode.h" #include "opents_strings.h" @@ -124,3 +125,84 @@ int UIRmlSystemClass::TranslateString(Rml::String & translated, Rml::String cons return(count); } + + +// The CSS names a document uses; anything else is the arrow. +void UIRmlSystemClass::SetMouseCursor(Rml::String const & name) +{ + if (name == "text") { + Cursor = UI_CURSOR_TEXT; + } else if (name == "pointer") { + Cursor = UI_CURSOR_HAND; + } else if (name == "move") { + Cursor = UI_CURSOR_MOVE; + } else if (name == "not-allowed") { + Cursor = UI_CURSOR_UNAVAILABLE; + } else { + Cursor = UI_CURSOR_ARROW; + } +} + + +// The clipboard takes ownership of the memory once it accepts it; every earlier exit frees it. +void UIRmlSystemClass::SetClipboardText(Rml::String const & text) +{ + std::wstring wide; + if (!UI_UTF8_To_UTF16(text, wide)) { + return; + } + + std::size_t bytes = (wide.size() + 1) * sizeof(wchar_t); + HGLOBAL memory = GlobalAlloc(GMEM_MOVEABLE, bytes); + if (memory == NULL) { + return; + } + + wchar_t * buffer = (wchar_t *)GlobalLock(memory); + if (buffer == NULL) { + GlobalFree(memory); + return; + } + std::memcpy(buffer, wide.c_str(), bytes); + GlobalUnlock(memory); + + if (!OpenClipboard(Host.Main_Window())) { + GlobalFree(memory); + return; + } + if (!EmptyClipboard() || SetClipboardData(CF_UNICODETEXT, memory) == NULL) { + GlobalFree(memory); + } + CloseClipboard(); +} + + +void UIRmlSystemClass::GetClipboardText(Rml::String & text) +{ + text.clear(); + + if (!OpenClipboard(Host.Main_Window())) { + return; + } + + HANDLE memory = GetClipboardData(CF_UNICODETEXT); + if (memory != NULL) { + wchar_t const * buffer = (wchar_t const *)GlobalLock(memory); + std::size_t capacity = GlobalSize(memory) / sizeof(wchar_t); + if (buffer != NULL) { + if (capacity <= UI_CLIPBOARD_MAX_BYTES / sizeof(wchar_t)) { + std::size_t length = 0; + while (length < capacity && buffer[length] != L'\0') { + length++; + } + // An unterminated block is not text. + if (length < capacity) { + UI_UTF16_To_UTF8(std::wstring_view(buffer, length), text); + } + } + GlobalUnlock(memory); + } + } + + CloseClipboard(); +} diff --git a/code/ui/rml/rmlsystem.h b/code/ui/rml/rmlsystem.h index 940ab0ced..e31b5ff6e 100644 --- a/code/ui/rml/rmlsystem.h +++ b/code/ui/rml/rmlsystem.h @@ -9,6 +9,8 @@ #pragma once +#include "ui/uiinput.hh" + #include #include @@ -16,7 +18,8 @@ class UIShellHostClass; -// RmlUi's view of the wall clock, the host's log, resource naming and string table. +// RmlUi's view of the wall clock, the host's log, resource naming and string table, the +// pointer shape and the clipboard. class UIRmlSystemClass : public Rml::SystemInterface { public: @@ -26,12 +29,21 @@ class UIRmlSystemClass : public Rml::SystemInterface virtual bool LogMessage(Rml::Log::Type type, Rml::String const & message) override; virtual int TranslateString(Rml::String & translated, Rml::String const & input) override; virtual void JoinPath(Rml::String & translated, Rml::String const & documentpath, Rml::String const & path) override; + virtual void SetMouseCursor(Rml::String const & name) override; + virtual void SetClipboardText(Rml::String const & text) override; + virtual void GetClipboardText(Rml::String & text) override; // How many errors and assertions RmlUi has logged so far. int Error_Count(void) const { return(Errors); } + // The pointer shape the documents last asked for; the shell shows it while the + // pointer is theirs and forgets it when a screen closes. + UICursor Cursor_Request(void) const { return(Cursor); } + void Reset_Cursor_Request(void) { Cursor = UI_CURSOR_ARROW; } + private: UIShellHostClass & Host; std::chrono::steady_clock::time_point Start; int Errors = 0; + UICursor Cursor = UI_CURSOR_ARROW; }; diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index 6feaafcae..1f58f0c04 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -23,6 +23,7 @@ #include "session.h" #include "ui/uishell.h" #include "video.h" +#include "wincursor.h" #include "windlg.h" @@ -143,6 +144,48 @@ class UIEngineHostClass : public UIShellHostClass return(GetACP()); } + virtual void Apply_Cursor(UICursor cursor) override + { + LPCTSTR shape = IDC_ARROW; + switch (cursor) { + case UI_CURSOR_TEXT: + shape = IDC_IBEAM; + break; + case UI_CURSOR_HAND: + shape = IDC_HAND; + break; + case UI_CURSOR_RESIZE_NS: + shape = IDC_SIZENS; + break; + case UI_CURSOR_RESIZE_EW: + shape = IDC_SIZEWE; + break; + case UI_CURSOR_RESIZE_NESW: + shape = IDC_SIZENESW; + break; + case UI_CURSOR_RESIZE_NWSE: + shape = IDC_SIZENWSE; + break; + case UI_CURSOR_MOVE: + shape = IDC_SIZEALL; + break; + case UI_CURSOR_UNAVAILABLE: + shape = IDC_NO; + break; + default: + break; + } + SetCursor(LoadCursor(NULL, shape)); + } + + virtual void Restore_Game_Cursor(void) override + { + Win_Cursor_Refresh(); + if (!Win_Cursor_Handle_Set_Cursor()) { + SetCursor(LoadCursor(NULL, IDC_ARROW)); + } + } + virtual char const * String(int id) const override { return(Fetch_String(id)); diff --git a/code/ui/uihost.h b/code/ui/uihost.h index 4cbf88b55..67ee03a09 100644 --- a/code/ui/uihost.h +++ b/code/ui/uihost.h @@ -12,6 +12,7 @@ #pragma once +#include "ui/uiinput.hh" #include "win.h" @@ -66,6 +67,10 @@ class UIShellHostClass virtual bool Window_Is_Unicode(void) const = 0; virtual unsigned int Text_Code_Page(void) const = 0; + // Shows the pointer shape a document asked for, and puts the game's own back. + virtual void Apply_Cursor(UICursor cursor) = 0; + virtual void Restore_Game_Cursor(void) = 0; + // An engine string by identifier. The result is valid until the next call. virtual char const * String(int id) const = 0; virtual void Log(char const * text) = 0; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index a5eb6802a..a886b094e 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -371,6 +371,56 @@ void UIShellClass::Reset_Text(void) } +// The pointer is the documents' while a screen is shown, a document holds a press, or it +// is over an element that takes it. +bool UIShellClass::Pointer_Owned(void) const +{ + return(!Modals.empty() || Input.Has_UI_Mouse() || (MouseInside && Context->IsMouseInteracting())); +} + + +bool UIShellClass::Handle_Set_Cursor(void) +{ + if (!Ready || !Pointer_Owned()) { + return(false); + } + + UICursor request = System->Cursor_Request(); + if (request == UI_CURSOR_ARROW) { + return(false); + } + + Host.Apply_Cursor(request); + AppliedCursor = request; + return(true); +} + + +// A hover that changed the request shows the new shape at once rather than at the next +// WM_SETCURSOR, which only a pointer move brings. +void UIShellClass::Apply_Cursor_Request(void) +{ + UICursor request = Pointer_Owned() ? System->Cursor_Request() : UI_CURSOR_ARROW; + if (request == AppliedCursor) { + return; + } + + if (request == UI_CURSOR_ARROW) { + Restore_Cursor(); + } else { + Host.Apply_Cursor(request); + AppliedCursor = request; + } +} + + +void UIShellClass::Restore_Cursor(void) +{ + AppliedCursor = UI_CURSOR_ARROW; + Host.Restore_Game_Cursor(); +} + + void UIShellClass::Toggle_Test_Document(void) { #ifdef _DEBUG @@ -598,6 +648,8 @@ void UIShellClass::Tick(void) UIDev_Tick(); } + Apply_Cursor_Request(); + // An overlay closed from inside its own frame still needs one present to clear. bool devactive = UIDev_Active(); if (Documents_Visible() || devactive || DevWasActive) { @@ -1023,6 +1075,8 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s Host.Mark_Overlay_Dirty(); std::snprintf(label, sizeof(label), "%s closed", view.Name()); Render->Log_Resource_Counts(label); + System->Reset_Cursor_Request(); + Restore_Cursor(); Host.Clear_Keyboard_Queue(); Host.Focus_Main_Window(); } diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 4e2c88ea7..7a7202ecd 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -106,6 +106,10 @@ class UIShellClass // True means the message is consumed. bool Intercept_Pumped_Message(MSG const & msg); + // Answers WM_SETCURSOR over the client area: true when a document's pointer shape is + // on the pointer, so the game's own stays off it. + bool Handle_Set_Cursor(void); + Rml::Context * Rml_Context(void) const { return(Context); } UIViewClass * Modal(void) const; int Modal_Depth(void) const; @@ -140,6 +144,9 @@ class UIShellClass void Drop_Presses(void); void Release_UI_Capture(void); void Reset_Text(void); + bool Pointer_Owned(void) const; + void Apply_Cursor_Request(void); + void Restore_Cursor(void); void Drain_Deferred(void); void Toggle_Test_Document(void); bool Handle_Mouse_Move(LPARAM clientlparam); @@ -179,6 +186,9 @@ class UIShellClass UIUTF8DecoderClass Utf8; unsigned char LegacyLead = 0; + // The shape last put on the pointer for the documents, if any. + UICursor AppliedCursor = UI_CURSOR_ARROW; + // The modal screens the runner is driving, innermost last, and whether the // innermost is between releasing its document and handing the input back. std::vector Modals; diff --git a/code/ui/uiunicode.cpp b/code/ui/uiunicode.cpp new file mode 100644 index 000000000..cf3044b64 --- /dev/null +++ b/code/ui/uiunicode.cpp @@ -0,0 +1,75 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/uiunicode.h" + +#include + +#include +#include + + +bool UI_UTF8_To_UTF16(std::string_view text, std::wstring & wide) +{ + wide.clear(); + + if (text.size() > UI_CLIPBOARD_MAX_BYTES || text.size() > INT_MAX) { + return(false); + } + if (text.empty()) { + return(true); + } + + int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), (int)text.size(), nullptr, 0); + if (length == 0) { + return(false); + } + + try { + wide.resize((std::size_t)length); + } catch (std::bad_alloc const &) { + return(false); + } + + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), (int)text.size(), wide.data(), length) != length) { + wide.clear(); + return(false); + } + return(true); +} + + +bool UI_UTF16_To_UTF8(std::wstring_view wide, std::string & text) +{ + text.clear(); + + if (wide.size() > UI_CLIPBOARD_MAX_BYTES / sizeof(wchar_t) || wide.size() > INT_MAX) { + return(false); + } + if (wide.empty()) { + return(true); + } + + int length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide.data(), (int)wide.size(), nullptr, 0, nullptr, nullptr); + if (length == 0) { + return(false); + } + + try { + text.resize((std::size_t)length); + } catch (std::bad_alloc const &) { + return(false); + } + + if (WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide.data(), (int)wide.size(), text.data(), length, nullptr, nullptr) != length) { + text.clear(); + return(false); + } + return(true); +} diff --git a/code/ui/uiunicode.h b/code/ui/uiunicode.h new file mode 100644 index 000000000..eb4a04891 --- /dev/null +++ b/code/ui/uiunicode.h @@ -0,0 +1,24 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Conversions between the engine's UTF-8 and the UTF-16 the system's text APIs speak. +// Malformed text fails rather than being repaired, so nothing bad is written anywhere. + +#pragma once + +#include +#include +#include + + +// The most text one clipboard exchange carries. +constexpr std::size_t UI_CLIPBOARD_MAX_BYTES = 16 * 1024 * 1024; + +bool UI_UTF8_To_UTF16(std::string_view text, std::wstring & wide); +bool UI_UTF16_To_UTF8(std::wstring_view wide, std::string & text); diff --git a/code/winstub.cpp b/code/winstub.cpp index 0278670c9..11a5c3620 100644 --- a/code/winstub.cpp +++ b/code/winstub.cpp @@ -244,7 +244,7 @@ LRESULT CALLBACK /*_export*/ Windows_Procedure(HWND hwnd, UINT message, WPARAM w return(1); case WM_SETCURSOR: - if (LOWORD(lParam) == HTCLIENT && Win_Cursor_Handle_Set_Cursor()) { + if (LOWORD(lParam) == HTCLIENT && (UIShell.Handle_Set_Cursor() || Win_Cursor_Handle_Set_Cursor())) { return(TRUE); } break; diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index f66a11d2e..22e9b0ae8 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -202,8 +202,8 @@ rule the tree follows, not a build boundary. | Directory | Holds | Status | | --- | --- | --- | | `code/` | `bgfxviews.hh`, the view ids the presenter and the overlays share; `_ui.h`, `_ui.cpp`, the shell's one instance `UIShell` under the underscore-file convention for globals | landed | -| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (`UIShellClass`: init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector; its toolkit interfaces are injected, so a test builds its own instance); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uiview.h` (`UIViewClass`, the view the shell runs); `uiinput.hh`, `uiinput.h`, `uiinput.cpp` (who owns each held key and button, and the UTF-8 decoding of a narrow window's text); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | -| `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging to `DebugString`, string translation; cursor and clipboard wait for the first editable screen), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base) | landed | +| `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (`UIShellClass`: init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector; its toolkit interfaces are injected, so a test builds its own instance); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uiview.h` (`UIViewClass`, the view the shell runs); `uiinput.hh`, `uiinput.h`, `uiinput.cpp` (who owns each held key and button, and the UTF-8 decoding of a narrow window's text); `uiunicode.h`, `uiunicode.cpp` (strict UTF-8 and UTF-16 conversion for the clipboard); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | +| `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging through the host, string translation, the pointer request, the clipboard), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base) | landed | | `code/ui/dev/` | `uidev.h`, `uidev.cpp`: the ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | | `code/ui/screens//` | one family each for `version`, `msgbox`, `waitbox`, `sound`, `gamectrl`, `display`, `keyboard`, `mainopt`: `ui.h` (presenter, service and state declarations, view factory, engine entry), `ui.cpp` (presenter and RmlUi view; built into the test), `uidlg.cpp` (engine service and entry, which the test cannot link) | landed; the sound, game controls, keyboard and display Win32 dialogs drive the same presenter as a second view, and the wait box family carries the `UIWaitBoxClass` the save, load and progress code shows | @@ -396,9 +396,12 @@ The shell clears the keyboard queue when a modal document opens and again after it is released, so the pump inside `Keyboard->Clear()` meets either the shown screen or the ownership table, never a screen mid-teardown. Focus loss cancels capture, drags, and composition; focus return does not replay -held keys as presses. Cursor requests from RmlUi (`pointer`, `text`) map to -`Win_Cursor_Set` and the previous request is restored on close. The -clipboard interface uses the Win32 clipboard. +held keys as presses. A document's pointer request (`text`, `pointer`, +`move`, `not-allowed`) shows the matching system pointer while the pointer +is the documents': a screen is shown, a document holds a press, or the +pointer is over an element that takes it. The game's own pointer returns +when a screen closes. The clipboard interface exchanges Unicode text with +the Win32 clipboard and refuses malformed text rather than repairing it. Text arrives as `WM_CHAR`. The main window is a narrow window, so under the UTF-8 code page each message carries one byte and the shell decodes the diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 9687b0a04..181c14c69 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -453,6 +453,23 @@ fixed_exclusions: - { file: code/ui/uishell.cpp, function: Key_Modifiers, expression: VK_CAPITAL } - { file: code/ui/uishell.cpp, function: Key_Modifiers, expression: VK_NUMLOCK } reason: UI shell modifier-state encoding for RmlUi, not separate controls. + - sites: + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_SHIFT } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_CONTROL } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_MENU } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_LSHIFT } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_RSHIFT } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_LCONTROL } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_RCONTROL } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_LMENU } + - { file: code/ui/uishell.cpp, function: Modifier_Key, expression: VK_RMENU } + reason: UI shell modifier keys kept out of press ownership, not separate controls. + - site: { file: code/ui/uishell.cpp, function: UIShellClass::Quarantine_Held_Input, expression: VK_XBUTTON2 } + reason: UI shell loop bound over the virtual keys past the mouse buttons, not a control. + - sites: + - { file: code/ui/uienginehost.cpp, function: , expression: VK_LBUTTON } + - { file: code/ui/uienginehost.cpp, function: , expression: VK_RBUTTON } + reason: UI shell host naming the primary and secondary buttons as the messages do when the user swapped them, not controls. launch_options: - id: launch:help diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 19bbbb357..9fd7b1c4b 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -11,6 +11,7 @@ opents_add_test(UIShell ui/uiscreen.cpp ui/uiinput.cpp ui/uishell.cpp + ui/uiunicode.cpp ui/rml/rmlkeys.cpp ui/rml/rmlsystem.cpp ui/rml/rmlview.cpp diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 0f9076ce8..6f1416036 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -41,6 +41,7 @@ #include "ui/uihost.h" #include "ui/uiscreen.h" #include "ui/uishell.h" +#include "ui/uiunicode.h" #include "ui/uiview.h" // windowsx.h, which win.h brings in, names two window walkers the way RmlUi names its @@ -293,6 +294,22 @@ class TestHostClass : public UIShellHostClass return(CodePage); } + int Applied = 0; + int Restored = 0; + UICursor LastCursor = UI_CURSOR_ARROW; + + virtual void Apply_Cursor(UICursor cursor) override + { + Applied++; + LastCursor = cursor; + } + + virtual void Restore_Game_Cursor(void) override + { + Restored++; + LastCursor = UI_CURSOR_ARROW; + } + virtual HWND Main_Window(void) const override { return(nullptr); @@ -2565,6 +2582,47 @@ void Test_Shell(void) Check(recorder.Texts.size() == 1 && recorder.Texts[0] == "\xC3\xA9", "two UTF-8 bytes on a narrow window reach the document as one character"); } + { + char const * sample = "\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80"; + std::wstring wide; + std::string text; + + std::wstring expected = { (wchar_t)0x00E9, (wchar_t)0x20AC, (wchar_t)0xD83D, (wchar_t)0xDE00 }; + Check(UI_UTF8_To_UTF16(sample, wide) && wide == expected, "UTF-8 converts to UTF-16"); + Check(UI_UTF16_To_UTF8(wide, text) && text == sample, "UTF-16 converts back to the same UTF-8"); + Check(!UI_UTF8_To_UTF16("\xC0\xAF", wide), "an overlong sequence is refused, not repaired"); + Check(!UI_UTF16_To_UTF8(std::wstring(1, (wchar_t)0xD800), text), "an unpaired surrogate is refused, not repaired"); + + // The developer's clipboard is put back once the round trip is checked. + Rml::String before; + fixture.System->GetClipboardText(before); + fixture.System->SetClipboardText(sample); + Rml::String after; + fixture.System->GetClipboardText(after); + Check(after == sample, "clipboard text survives a round trip"); + fixture.System->SetClipboardText(before); + } + + { + UIVersionPresenterClass presenter({ "cursor" }); + std::unique_ptr view = UI_Version_View(presenter); + int restored = host.Restored; + bool requested = false; + bool shown = false; + + shell.Run_Modal(*view, [&](void) { + fixture.System->SetMouseCursor("text"); + requested = fixture.System->Cursor_Request() == UI_CURSOR_TEXT; + shown = shell.Handle_Set_Cursor() && host.LastCursor == UI_CURSOR_TEXT; + Send(shell, WM_KEYDOWN, VK_ESCAPE); + return(false); + }); + Check(requested, "a document's pointer request is kept"); + Check(shown, "WM_SETCURSOR shows the requested shape while a screen is shown"); + Check(host.Restored - restored == 1 && fixture.System->Cursor_Request() == UI_CURSOR_ARROW && host.LastCursor == UI_CURSOR_ARROW, "closing a screen puts the game's pointer back once and forgets the request"); + Check(!shell.Handle_Set_Cursor(), "WM_SETCURSOR is the game's again once nothing is shown"); + } + { UIWaitBoxPresenterClass presenter("Working", false); std::unique_ptr view = UI_Wait_Box_View(presenter); From c35f84266c204b3f2d9c645250e44fb962d84d73 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 00:02:40 +0300 Subject: [PATCH 29/52] Add the present marks and guard the renderer's frame end --- code/bgfxbackend.cpp | 29 +++++++++------ code/bgfxbackend.h | 5 +-- code/videodirty.cpp | 68 +++++++++++++++++++++++++++++++++++ code/videodirty.h | 49 +++++++++++++++++++++++++ tests/uilogic/CMakeLists.txt | 10 +++--- tests/uilogic/uilogictest.cpp | 51 ++++++++++++++++++++++++-- 6 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 code/videodirty.cpp create mode 100644 code/videodirty.h diff --git a/code/bgfxbackend.cpp b/code/bgfxbackend.cpp index 3285bfcca..cf72a5994 100644 --- a/code/bgfxbackend.cpp +++ b/code/bgfxbackend.cpp @@ -51,6 +51,9 @@ static int _FrameHeight = 0; // A recreated frame texture holds nothing until the first upload reaches it. static bool _FrameUploaded = false; +// A frame Backend_Present began that Backend_End_Frame has yet to end. +static bool _FramePending = false; + static int _PrescaleWidth = 0; static int _PrescaleHeight = 0; static int _DrawableWidth = 0; @@ -159,14 +162,15 @@ static void Build_Convert_Table(void) /// -/// Submits one textured rectangle covering the given destination. +/// Submits one textured rectangle covering the given destination. False means the +/// transient vertex memory ran out and nothing was submitted. /// -static void Submit_Quad(bgfx::ViewId view, bgfx::TextureHandle texture, float x, float y, float width, float height, unsigned int samplerflags, bool flipv = false) +static bool Submit_Quad(bgfx::ViewId view, bgfx::TextureHandle texture, float x, float y, float width, float height, unsigned int samplerflags, bool flipv = false) { bgfx::TransientVertexBuffer buffer; if (bgfx::getAvailTransientVertexBuffer(6, _VertexLayout) < 6) { - return; + return(false); } bgfx::allocTransientVertexBuffer(&buffer, 6, _VertexLayout); @@ -188,6 +192,7 @@ static void Submit_Quad(bgfx::ViewId view, bgfx::TextureHandle texture, float x, bgfx::setTexture(0, _TextureSampler, texture, samplerflags); bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A); bgfx::submit(view, _Program); + return(true); } @@ -389,6 +394,7 @@ void Backend_Shutdown(void) _FrameWidth = 0; _FrameHeight = 0; _FrameUploaded = false; + _FramePending = false; _Initialized = false; } @@ -488,6 +494,8 @@ bool Backend_Present(void const * pixels, int pitch, int destx, int desty, int d return(false); } + _FramePending = true; + if (pixels != NULL) { if (_FrameIs565) { bgfx::updateTexture2D(_FrameTexture, 0, 0, 0, 0, (uint16_t)_FrameWidth, (uint16_t)_FrameHeight, bgfx::copy(pixels, (uint32_t)(_FrameHeight * pitch)), (uint16_t)pitch); @@ -531,9 +539,10 @@ bool Backend_Present(void const * pixels, int pitch, int destx, int desty, int d bgfx::setViewFrameBuffer(VIEW_PRESCALE, _PrescaleTarget); bgfx::setViewClear(VIEW_PRESCALE, BGFX_CLEAR_COLOR, 0x000000FF); Set_View_Transform(VIEW_PRESCALE, _PrescaleWidth, _PrescaleHeight); - Submit_Quad(VIEW_PRESCALE, _FrameTexture, 0.0f, 0.0f, (float)_PrescaleWidth, (float)_PrescaleHeight, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP | BGFX_SAMPLER_POINT); - source = bgfx::getTexture(_PrescaleTarget); - from_prescale = true; + if (Submit_Quad(VIEW_PRESCALE, _FrameTexture, 0.0f, 0.0f, (float)_PrescaleWidth, (float)_PrescaleHeight, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP | BGFX_SAMPLER_POINT)) { + source = bgfx::getTexture(_PrescaleTarget); + from_prescale = true; + } } } } @@ -545,21 +554,21 @@ bool Backend_Present(void const * pixels, int pitch, int destx, int desty, int d Set_View_Transform(VIEW_PRESENT, _DrawableWidth, _DrawableHeight); bool flipv = from_prescale && bgfx::getCaps()->originBottomLeft; - Submit_Quad(VIEW_PRESENT, source, (float)destx, (float)desty, (float)destwidth, (float)destheight, samplerflags, flipv); - - return(true); + return(Submit_Quad(VIEW_PRESENT, source, (float)destx, (float)desty, (float)destwidth, (float)destheight, samplerflags, flipv)); } /// /// Ends the frame Backend_Present began, putting everything submitted since on the screen. +/// Does nothing when no frame is pending, so it is safe after a refused present. /// void Backend_End_Frame(void) { - if (!_Initialized) { + if (!_Initialized || !_FramePending) { return; } + _FramePending = false; bgfx::frame(); } diff --git a/code/bgfxbackend.h b/code/bgfxbackend.h index 4e89742df..b36ecb5c3 100644 --- a/code/bgfxbackend.h +++ b/code/bgfxbackend.h @@ -42,8 +42,9 @@ void Backend_On_Resize(int drawablewidth, int drawableheight); // Submits the frame, uploading new pixels first when given any. The pixels are 16 bit 565 // and stay owned by the caller; they are consumed before this returns. NULL presents the // frame uploaded last. Nothing reaches the screen until Backend_End_Frame, and what is -// submitted between the two calls draws over the frame. A false return means no frame was -// submitted and the frame must not be ended. +// submitted between the two calls draws over the frame. A false return means the frame did +// not reach the window; Backend_End_Frame is always safe and ends a frame only when one +// was begun. bool Backend_Present(void const * pixels, int pitch, int destx, int desty, int destwidth, int destheight, BackendScaleMode mode); void Backend_End_Frame(void); diff --git a/code/videodirty.cpp b/code/videodirty.cpp new file mode 100644 index 000000000..ccaa30a05 --- /dev/null +++ b/code/videodirty.cpp @@ -0,0 +1,68 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "videodirty.h" + + +void VideoDirtyStateClass::Mark_Game(void) +{ + Game = true; +} + + +void VideoDirtyStateClass::Mark_Overlay(void) +{ + Overlay = true; +} + + +void VideoDirtyStateClass::Invalidate_Frame(void) +{ + Uploaded = false; + Game = true; +} + + +void VideoDirtyStateClass::Upload_Completed(void) +{ + Uploaded = true; +} + + +void VideoDirtyStateClass::Reset(void) +{ + Game = false; + Overlay = false; + Uploaded = false; +} + + +bool VideoDirtyStateClass::Is_Dirty(void) const +{ + return(Game || Overlay); +} + + +VideoDirtySnapshotType VideoDirtyStateClass::Consume(void) +{ + VideoDirtySnapshotType result = { Game, Overlay, Game || !Uploaded }; + Game = false; + Overlay = false; + return(result); +} + + +void VideoDirtyStateClass::Restore(VideoDirtySnapshotType const & snapshot) +{ + Game = Game || snapshot.Game; + Overlay = Overlay || snapshot.Overlay; + if (!Game && !Overlay) { + Overlay = true; + } +} diff --git a/code/videodirty.h b/code/videodirty.h new file mode 100644 index 000000000..a099b7550 --- /dev/null +++ b/code/videodirty.h @@ -0,0 +1,49 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// What the presenter owes the screen: a game frame, a UI overlay, or both. Marks raised +// while a present runs are kept for the next one, and a present the renderer refuses +// gives its marks back. + +#pragma once + + +// What one present has to do: draw the game, draw the overlay, and whether the frame's +// pixels have to be uploaded first. +struct VideoDirtySnapshotType +{ + bool Game; + bool Overlay; + bool Upload; +}; + + +class VideoDirtyStateClass +{ + public: + void Mark_Game(void); + void Mark_Overlay(void); + // The renderer no longer holds the frame, so the next present uploads it. + void Invalidate_Frame(void); + void Upload_Completed(void); + void Reset(void); + + bool Is_Dirty(void) const; + + // Takes the marks for a present that is about to run. + VideoDirtySnapshotType Consume(void); + // Gives back the marks of a present that did not happen. A present is always owed + // afterwards, at least of the overlay, so a refused one is retried. + void Restore(VideoDirtySnapshotType const & snapshot); + + private: + bool Game = false; + bool Overlay = false; + bool Uploaded = false; +}; diff --git a/tests/uilogic/CMakeLists.txt b/tests/uilogic/CMakeLists.txt index fe6156a27..797f1c92b 100644 --- a/tests/uilogic/CMakeLists.txt +++ b/tests/uilogic/CMakeLists.txt @@ -1,8 +1,10 @@ -# The toolkit-free UI state: the input ownership the shell routes messages by and the -# text decoding it feeds the documents, compiled without any UI library so the harness -# stays cheap and the code stays free of toolkit types. +# The toolkit-free UI state: the input ownership the shell routes messages by, the text +# decoding it feeds the documents, and the marks the presenter owes the screen, compiled +# without any UI library so the harness stays cheap and the code stays free of toolkit types. opents_add_test(UILogic NAME uilogic SOURCES uilogictest.cpp - ENGINE ui/uiinput.cpp + ENGINE + ui/uiinput.cpp + videodirty.cpp ) diff --git a/tests/uilogic/uilogictest.cpp b/tests/uilogic/uilogictest.cpp index 9e2b74315..9914aeffc 100644 --- a/tests/uilogic/uilogictest.cpp +++ b/tests/uilogic/uilogictest.cpp @@ -8,10 +8,12 @@ ******************************************************************************/ // Pins the UI state that needs no toolkit: who a held key or button belongs to as screens -// open, close and lose the capture or the focus, and how the bytes of a narrow window's -// text messages become code points. +// open, close and lose the capture or the focus, how the bytes of a narrow window's text +// messages become code points, and what the presenter owes the screen after a present is +// taken, refused or skipped. #include "ui/uiinput.h" +#include "videodirty.h" #include #include @@ -158,6 +160,50 @@ void Test_Text(void) Check(Decode(decoder, { 0x82, 0xAC }) == std::vector { 0xFFFD, 0xFFFD }, "a reset cannot splice fragments across owners"); } + +void Test_Dirty_State(void) +{ + VideoDirtyStateClass dirty; + + Check(!dirty.Is_Dirty(), "nothing is owed at the start"); + + dirty.Mark_Overlay(); + Check(dirty.Is_Dirty(), "an overlay mark makes a present due"); + VideoDirtySnapshotType first = dirty.Consume(); + Check(!first.Game && first.Overlay && first.Upload, "the first present uploads the frame the renderer has never seen"); + Check(!dirty.Is_Dirty(), "consuming takes the marks"); + + dirty.Upload_Completed(); + dirty.Mark_Overlay(); + VideoDirtySnapshotType overlay = dirty.Consume(); + Check(overlay.Overlay && !overlay.Upload, "an overlay-only present leaves the uploaded frame alone"); + + dirty.Mark_Game(); + VideoDirtySnapshotType game = dirty.Consume(); + Check(game.Game && game.Upload, "a game mark uploads the frame"); + + dirty.Mark_Game(); + VideoDirtySnapshotType consumed = dirty.Consume(); + dirty.Mark_Overlay(); + Check(dirty.Is_Dirty(), "a mark raised while a present runs survives it"); + dirty.Restore(consumed); + VideoDirtySnapshotType restored = dirty.Consume(); + Check(restored.Game && restored.Overlay, "restoring a refused present keeps the marks raised since"); + + dirty.Restore(VideoDirtySnapshotType { false, false, false }); + Check(dirty.Is_Dirty() && dirty.Consume().Overlay, "a refused present with nothing marked is retried as an overlay present"); + + dirty.Upload_Completed(); + dirty.Invalidate_Frame(); + VideoDirtySnapshotType invalidated = dirty.Consume(); + Check(invalidated.Game && invalidated.Upload, "a renderer that lost the frame gets it uploaded again"); + + dirty.Mark_Game(); + dirty.Reset(); + VideoDirtySnapshotType reset = dirty.Consume(); + Check(!dirty.Is_Dirty() && !reset.Game && !reset.Overlay && reset.Upload, "a reset forgets the marks and the upload"); +} + } @@ -166,6 +212,7 @@ int main(void) Test_Ownership(); Test_Reconciliation(); Test_Text(); + Test_Dirty_State(); std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); return(Failures == 0 ? 0 : 1); From d708136fe9d83b9e19375072453a2ec3ac703db6 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 00:04:22 +0300 Subject: [PATCH 30/52] Hold the presenter's marks in VideoDirtyStateClass --- code/video.cpp | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/code/video.cpp b/code/video.cpp index 2e06aeed9..65f0adc6f 100644 --- a/code/video.cpp +++ b/code/video.cpp @@ -25,6 +25,7 @@ #include "misc.h" #include "surface.h" #include "ui/uishell.h" +#include "videodirty.h" #include "wincursor.h" #include @@ -46,14 +47,10 @@ bool WindowedMode = false; static bool _Initialized = false; static VideoScaleInfo _ScaleInfo; -// Set whenever the visible surface is written to, and cleared once that frame has been -// presented. A frame that is skipped for pacing stays marked, so the next present shows -// the newest content rather than a stale one. -static bool _FrameIsDirty = false; - -// Set when the UI overlay changed, so a present is due even while the frame is not. The -// frame is then presented again without being uploaded again. -static bool _OverlayIsDirty = false; +// What the screen is owed: a frame, an overlay, or both. A frame skipped for pacing stays +// marked, so the next present shows the newest content rather than a stale one, and an +// overlay change alone presents the frame again without uploading it again. +static VideoDirtyStateClass _Dirty; static unsigned int _LastPresentTime = 0; static unsigned int _PresentInterval = 16; @@ -197,8 +194,7 @@ void Video_Shutdown(void) Win_Cursor_Shutdown(); Backend_Shutdown(); _Initialized = false; - _FrameIsDirty = false; - _OverlayIsDirty = false; + _Dirty.Reset(); _PresentsThisSecond = 0; _PresentsLastSecond = 0; _PresentSecondStart = 0; @@ -229,7 +225,7 @@ bool Video_Set_Mode(int width, int height) Update_Scale_Info(); Win_Cursor_Refresh(); UIShell.On_Video_Change(); - _FrameIsDirty = true; + _Dirty.Invalidate_Frame(); return(true); } @@ -272,7 +268,7 @@ void Video_Set_Refresh_Rate(int refreshrate) /// void Video_Mark_Dirty(void) { - _FrameIsDirty = true; + _Dirty.Mark_Game(); } @@ -281,17 +277,16 @@ void Video_Mark_Dirty(void) /// void Video_Mark_Overlay_Dirty(void) { - _OverlayIsDirty = true; + _Dirty.Mark_Overlay(); } /// /// Puts the frame on the screen with the UI overlay over it. -/// Both marks are cleared before presenting, so anything invalidated while the present is +/// The marks are taken before presenting, so anything invalidated while the present is /// under way is kept for the next one rather than lost with this one. /// -/// Does the visible surface hold newer pixels than the renderer? -static void Present(bool uploadframe) +static void Present(void) { if (!_Initialized || _Presenting || VisibleSurface == NULL) { return; @@ -304,8 +299,7 @@ static void Present(bool uploadframe) return; } - _FrameIsDirty = false; - _OverlayIsDirty = false; + VideoDirtySnapshotType snapshot = _Dirty.Consume(); _LastPresentTime = timeGetTime(); if (_LastPresentTime - _PresentSecondStart >= 1000) { @@ -316,7 +310,10 @@ static void Present(bool uploadframe) _PresentsThisSecond++; _Presenting = true; - if (Backend_Present(uploadframe ? pixels : NULL, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode())) { + if (Backend_Present(snapshot.Game ? pixels : NULL, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode())) { + if (snapshot.Game) { + _Dirty.Upload_Completed(); + } UIShell.Render_Overlay(); Backend_End_Frame(); } @@ -329,7 +326,8 @@ static void Present(bool uploadframe) /// void Video_Present(void) { - Present(true); + _Dirty.Mark_Game(); + Present(); } @@ -341,7 +339,7 @@ void Video_Present(void) /// void Video_Present_If_Dirty(void) { - if (!_FrameIsDirty && !_OverlayIsDirty) { + if (!_Dirty.Is_Dirty()) { return; } @@ -350,7 +348,7 @@ void Video_Present_If_Dirty(void) return; } - Present(_FrameIsDirty); + Present(); } From cf2fc7a76b4bc26f19ec41f9e018399f66d3167b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 00:06:32 +0300 Subject: [PATCH 31/52] Retry a refused present and keep the frame across a resize --- code/ui/dev/uidev.cpp | 1 + code/video.cpp | 88 ++++++++++++++++++++++++++++++++++--------- code/video.h | 7 ++++ docs/UI_DESIGN.md | 32 +++++++++++----- 4 files changed, 100 insertions(+), 28 deletions(-) diff --git a/code/ui/dev/uidev.cpp b/code/ui/dev/uidev.cpp index 790fdc130..439dc924c 100644 --- a/code/ui/dev/uidev.cpp +++ b/code/ui/dev/uidev.cpp @@ -353,6 +353,7 @@ static void Draw_Benchmark_Window(void) ImGui::Text("Logic frames per second %u, frame %d", LastFramesPerSecond, Frame); ImGui::Text("Presents per second %u, present interval %u ms", Video_Presents_Per_Second(), Video_Present_Interval()); + ImGui::Text("%llu presents and %llu frame uploads since start", (unsigned long long)Video_Present_Count(), (unsigned long long)Video_Frame_Upload_Count()); ImGui::Text("Overlay ticks per second %.0f", ImGui::GetIO().Framerate); ImGui::Checkbox("Show the Dear ImGui demo window", &_ShowDemo); ImGui::Separator(); diff --git a/code/video.cpp b/code/video.cpp index 65f0adc6f..a451faa0e 100644 --- a/code/video.cpp +++ b/code/video.cpp @@ -28,6 +28,7 @@ #include "videodirty.h" #include "wincursor.h" +#include #include @@ -62,8 +63,15 @@ static unsigned int _PresentsLastSecond = 0; static unsigned int _PresentSecondStart = 0; // Presents can nest, because a dialog repainting itself presents from inside the paint -// that the engine's own present provoked. +// that the engine's own present provoked. A resize that arrives inside one waits for it. static bool _Presenting = false; +static bool _ResizePending = false; +static int _PendingWidth = 0; +static int _PendingHeight = 0; + +// Presents and frame uploads since the presenter started, for the developer overlays. +static std::uint64_t _PresentCount = 0; +static std::uint64_t _FrameUploadCount = 0; /// @@ -195,9 +203,12 @@ void Video_Shutdown(void) Backend_Shutdown(); _Initialized = false; _Dirty.Reset(); + _ResizePending = false; _PresentsThisSecond = 0; _PresentsLastSecond = 0; _PresentSecondStart = 0; + _PresentCount = 0; + _FrameUploadCount = 0; } @@ -239,13 +250,23 @@ void Video_On_Resize(int drawablewidth, int drawableheight) return; } + // The renderer cannot be reset inside a frame it is drawing. + if (_Presenting) { + _ResizePending = true; + _PendingWidth = drawablewidth; + _PendingHeight = drawableheight; + DebugString("Video: resize to %dx%d deferred past the present under way\n", drawablewidth, drawableheight); + return; + } + _ScaleInfo.DrawableWidth = drawablewidth; _ScaleInfo.DrawableHeight = drawableheight; Backend_On_Resize(drawablewidth, drawableheight); Update_Scale_Info(); Win_Cursor_Refresh(); UIShell.On_Video_Change(); - Video_Mark_Dirty(); + // The renderer keeps the uploaded frame across a reset, so only the overlay is owed. + Video_Mark_Overlay_Dirty(); } @@ -259,7 +280,7 @@ void Video_Set_Refresh_Rate(int refreshrate) } Update_Present_Interval(refreshrate); - Video_Mark_Dirty(); + Video_Mark_Overlay_Dirty(); } @@ -284,40 +305,59 @@ void Video_Mark_Overlay_Dirty(void) /// /// Puts the frame on the screen with the UI overlay over it. /// The marks are taken before presenting, so anything invalidated while the present is -/// under way is kept for the next one rather than lost with this one. +/// under way is kept for the next one rather than lost with this one; a present the +/// renderer refuses gives them back and is retried. A minimized window presents nothing +/// and keeps its marks for the restore. /// static void Present(void) { if (!_Initialized || _Presenting || VisibleSurface == NULL) { return; } - - DSurface * surface = (DSurface *)VisibleSurface; - void * pixels = surface->Get_Buffer(); - - if (pixels == NULL) { + if (MainWindow != NULL && IsIconic(MainWindow)) { return; } VideoDirtySnapshotType snapshot = _Dirty.Consume(); - _LastPresentTime = timeGetTime(); - if (_LastPresentTime - _PresentSecondStart >= 1000) { - _PresentsLastSecond = _PresentsThisSecond; - _PresentsThisSecond = 0; - _PresentSecondStart = _LastPresentTime; + DSurface * surface = (DSurface *)VisibleSurface; + void * pixels = snapshot.Upload ? surface->Get_Buffer() : NULL; + if (snapshot.Upload && pixels == NULL) { + _Dirty.Restore(snapshot); + return; } - _PresentsThisSecond++; + + _LastPresentTime = timeGetTime(); _Presenting = true; - if (Backend_Present(snapshot.Game ? pixels : NULL, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode())) { - if (snapshot.Game) { + bool presented = Backend_Present(pixels, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode()); + if (presented) { + if (snapshot.Upload) { _Dirty.Upload_Completed(); + _FrameUploadCount++; } UIShell.Render_Overlay(); - Backend_End_Frame(); } + Backend_End_Frame(); _Presenting = false; + + if (presented) { + if (_LastPresentTime - _PresentSecondStart >= 1000) { + _PresentsLastSecond = _PresentsThisSecond; + _PresentsThisSecond = 0; + _PresentSecondStart = _LastPresentTime; + } + _PresentsThisSecond++; + _PresentCount++; + } else { + _Dirty.Restore(snapshot); + DebugString("Video: present refused, marks kept\n"); + } + + if (_ResizePending) { + _ResizePending = false; + Video_On_Resize(_PendingWidth, _PendingHeight); + } } @@ -379,6 +419,18 @@ unsigned int Video_Present_Interval(void) } +std::uint64_t Video_Present_Count(void) +{ + return(_PresentCount); +} + + +std::uint64_t Video_Frame_Upload_Count(void) +{ + return(_FrameUploadCount); +} + + /// /// Compares two display modes by width and then height. /// diff --git a/code/video.h b/code/video.h index a3e624c59..8b6cd6a3e 100644 --- a/code/video.h +++ b/code/video.h @@ -11,6 +11,8 @@ #include "nativewindow.hh" +#include + // How the presented frame is filtered when the window is larger than it. enum VideoScaleMode { @@ -55,4 +57,9 @@ VideoScaleInfo const & Video_Get_Scale_Info(void); unsigned int Video_Presents_Per_Second(void); unsigned int Video_Present_Interval(void); +// Presents that reached the window, and frame uploads, since the presenter started. An +// overlay-only present adds to the first and not the second. +std::uint64_t Video_Present_Count(void); +std::uint64_t Video_Frame_Upload_Count(void); + int * EnumDisplayModes(int minwidth, int minheight, int maxwidth, int maxheight); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 22e9b0ae8..b4d05d725 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -225,6 +225,8 @@ UI_Render_Overlay(); // VIEW_UI, then VIEW_DEV Backend_End_Frame(); // bgfx::frame() ``` +`Backend_End_Frame` runs whether or not `Backend_Present` succeeded; it ends +a frame only when one was begun, so a refused present leaves nothing pending. No other code begins or ends a bgfx frame. The view identifiers move from `bgfxbackend.cpp` into a small shared header so both translation units agree on the order. The overlay views use the frame destination rectangle from @@ -251,7 +253,7 @@ methods: | Blending | `ONE, INV_SRC_ALPHA`; vertex colors follow the same premultiplied contract with no double premultiplication. | | Scissor | `bgfx::setScissor` in physical target coordinates, intersected with the viewport, empty regions handled. | | Projection | The overlay view's orthographic transform; no game-image filter state inherited. | -| Reset and resize | Target-dependent resources recreated, viewport and scissor refreshed, a full redraw requested; existing documents redraw without reload. | +| Reset and resize | Target-dependent resources recreated, viewport and scissor refreshed, a present without an upload requested; existing documents redraw without reload. | The program is bgfx's embedded debug-draw texture shader pair (`vs_debugdraw_fill_texture`, `fs_debugdraw_fill_texture`). The imgui pair the @@ -266,15 +268,25 @@ document check enforces it. ### Invalidation -`Video_Present_If_Dirty` grows a second dirty flag for the overlay: a present -happens when either flag is set, but the texture upload happens only when the -frame is dirty. RmlUi has no "needs redraw" query, so the shell marks the -overlay dirty on every tick that a document is visible or an ImGui window is -open, and the present pacing caps the rate. Closing or hiding a document also -marks the overlay dirty so its pixels disappear. A visible menu at 4K then -costs a few draw calls per refresh, not a 16 MB upload. Invalidation raised -during a present is kept for the next one rather than cleared with the -current frame. +The presenter keeps a game mark, an overlay mark and whether the renderer +holds an uploaded frame (`VideoDirtyStateClass`, `code/videodirty.h`). A +present happens when either mark is set; the frame is uploaded only when the +game mark is set or the renderer has never received it. RmlUi has no "needs +redraw" query, so the shell marks the overlay dirty on every tick that a +document is visible or an ImGui window is open, and the present pacing caps +the rate. Closing or hiding a document also marks the overlay dirty so its +pixels disappear. A visible menu at 4K then costs a few draw calls per +refresh, not a 16 MB upload. + +A present consumes both marks first, so invalidation raised while it runs is +kept for the next one rather than cleared with the current frame. A present +the renderer refuses restores what it consumed and is retried as at least an +overlay present. A minimized window presents nothing and keeps its marks for +the restore. A resize arriving inside a present is applied after it. A window +resize or refresh-rate change marks only the overlay, since the renderer +keeps the uploaded frame across a reset; `Video_Present_Count` and +`Video_Frame_Upload_Count` on the developer overlay show a drag-resize +presenting without uploading. Movies keep their own presenter path; the shell renders nothing while a movie plays. From fd77a5537868b3012a979b2c8cf09d99f6bc1da4 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 00:10:18 +0300 Subject: [PATCH 32/52] Check geometry, sizes and scissors before the overlay renderer draws --- code/ui/rml/rmlrendermath.cpp | 91 +++++++++++++++++++++++++++++++++++ code/ui/rml/rmlrendermath.h | 45 +++++++++++++++++ docs/UI_DESIGN.md | 2 +- tests/uilogic/CMakeLists.txt | 6 ++- tests/uilogic/uilogictest.cpp | 45 ++++++++++++++++- tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 19 +++++++- 7 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 code/ui/rml/rmlrendermath.cpp create mode 100644 code/ui/rml/rmlrendermath.h diff --git a/code/ui/rml/rmlrendermath.cpp b/code/ui/rml/rmlrendermath.cpp new file mode 100644 index 000000000..662852932 --- /dev/null +++ b/code/ui/rml/rmlrendermath.cpp @@ -0,0 +1,91 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "ui/rml/rmlrendermath.h" + +#include +#include +#include +#include + + +bool UI_Render_Byte_Count(std::size_t count, std::size_t stride, std::uint32_t & bytes) +{ + bytes = 0; + if (count == 0 || stride == 0 || count > std::numeric_limits::max() / stride) { + return(false); + } + bytes = (std::uint32_t)(count * stride); + return(true); +} + + +bool UI_Render_Index_Range(std::span indices, std::size_t vertexcount) +{ + if (indices.empty() || indices.size() % 3 != 0 || vertexcount == 0) { + return(false); + } + return(std::all_of(indices.begin(), indices.end(), [vertexcount](int index) { + return(index >= 0 && (std::size_t)index < vertexcount); + })); +} + + +bool UI_Render_Clip_Rect(float left, float top, float right, float bottom, int viewportx, int viewporty, int viewportwidth, int viewportheight, UIRenderClip & clip) +{ + clip = UIRenderClip(); + + if (!std::isfinite(left) || !std::isfinite(top) || !std::isfinite(right) || !std::isfinite(bottom) + || viewportx < 0 || viewporty < 0 || viewportwidth <= 0 || viewportheight <= 0 + || (std::int64_t)viewportx + viewportwidth > UINT16_MAX + || (std::int64_t)viewporty + viewportheight > UINT16_MAX + || right <= left || bottom <= top) { + return(false); + } + + double x1 = std::clamp(std::floor((double)left), 0.0, (double)viewportwidth); + double y1 = std::clamp(std::floor((double)top), 0.0, (double)viewportheight); + double x2 = std::clamp(std::ceil((double)right), 0.0, (double)viewportwidth); + double y2 = std::clamp(std::ceil((double)bottom), 0.0, (double)viewportheight); + if (x2 <= x1 || y2 <= y1) { + return(false); + } + + clip.X = (std::uint16_t)(viewportx + x1); + clip.Y = (std::uint16_t)(viewporty + y1); + clip.Width = (std::uint16_t)(x2 - x1); + clip.Height = (std::uint16_t)(y2 - y1); + return(true); +} + + +bool UI_Render_Copy_RGBA_Rect(std::span pixels, int width, int height, int pitch, int x, int y, int rectwidth, int rectheight, std::vector & result) +{ + result.clear(); + + if (width <= 0 || height <= 0 || pitch <= 0 || x < 0 || y < 0 || rectwidth <= 0 || rectheight <= 0 + || x > width || y > height || rectwidth > width - x || rectheight > height - y + || (std::uint64_t)width * 4 > (std::uint64_t)pitch + || (std::uint64_t)pitch * (height - 1) + (std::uint64_t)width * 4 > pixels.size()) { + return(false); + } + + std::uint32_t rowbytes = 0; + std::uint32_t totalbytes = 0; + if (!UI_Render_Byte_Count((std::size_t)rectwidth, 4, rowbytes) || !UI_Render_Byte_Count((std::size_t)rectheight, rowbytes, totalbytes)) { + return(false); + } + + result.resize(totalbytes); + for (int row = 0; row < rectheight; row++) { + std::size_t source = (std::size_t)(y + row) * pitch + (std::size_t)x * 4; + std::memcpy(result.data() + (std::size_t)row * rowbytes, pixels.data() + source, rowbytes); + } + return(true); +} diff --git a/code/ui/rml/rmlrendermath.h b/code/ui/rml/rmlrendermath.h new file mode 100644 index 000000000..7fba62a49 --- /dev/null +++ b/code/ui/rml/rmlrendermath.h @@ -0,0 +1,45 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The checks the overlay renderer makes before it hands anything to bgfx. They know no +// toolkit or renderer type, so a harness runs them without either. + +#pragma once + +#include +#include +#include +#include + + +// A scissor in the target's own pixels, as bgfx takes it. +struct UIRenderClip +{ + std::uint16_t X = 0; + std::uint16_t Y = 0; + std::uint16_t Width = 0; + std::uint16_t Height = 0; +}; + + +// count times stride as a 32-bit byte count; false when either is zero or the product +// does not fit. +bool UI_Render_Byte_Count(std::size_t count, std::size_t stride, std::uint32_t & bytes); + +// True for whole triangles whose every index names one of the vertices. +bool UI_Render_Index_Range(std::span indices, std::size_t vertexcount); + +// The viewport-relative rectangle rounded outward to whole pixels, clipped to the viewport +// and placed in the target. False when nothing is left or the target would not fit bgfx's +// 16-bit coordinates. +bool UI_Render_Clip_Rect(float left, float top, float right, float bottom, int viewportx, int viewporty, int viewportwidth, int viewportheight, UIRenderClip & clip); + +// A tightly packed copy of a rectangle out of a pitched RGBA image. False when the +// rectangle or the image is not what it claims. +bool UI_Render_Copy_RGBA_Rect(std::span pixels, int width, int height, int pitch, int x, int y, int rectwidth, int rectheight, std::vector & result); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index b4d05d725..049182f4e 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -203,7 +203,7 @@ rule the tree follows, not a build boundary. | --- | --- | --- | | `code/` | `bgfxviews.hh`, the view ids the presenter and the overlays share; `_ui.h`, `_ui.cpp`, the shell's one instance `UIShell` under the underscore-file convention for globals | landed | | `code/ui/` | the shell and the toolkit-free contracts: `uishell.h`, `uishell.cpp` (`UIShellClass`: init and shutdown, resize, input hook, developer-key intercept, tick, overlay render entry, modal runner, selector; its toolkit interfaces are injected, so a test builds its own instance); `uihost.h` (`UIShellHostClass`, what the shell needs from the program around it: the window, frame, keyboard queue, dialogs, strings and log); `uienginehost.h`, `uienginehost.cpp` (the engine's host and the game-service pass a modal runs with; the only shell file that includes engine headers); `uiscreen.h`, `uiscreen.cpp` (presenter, intent, result, clock); `uiview.h` (`UIViewClass`, the view the shell runs); `uiinput.hh`, `uiinput.h`, `uiinput.cpp` (who owns each held key and button, and the UTF-8 decoding of a narrow window's text); `uiunicode.h`, `uiunicode.cpp` (strict UTF-8 and UTF-16 conversion for the clipboard); `uicoord.h` (the pointer mapping from client pixels into the overlay) | landed | -| `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging through the host, string translation, the pointer request, the clipboard), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base) | landed | +| `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging through the host, string translation, the pointer request, the clipboard), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base), `rmlrendermath` (the checks the renderer makes before it draws: index ranges, byte counts, scissors; toolkit-free, so the harness runs them) | landed | | `code/ui/dev/` | `uidev.h`, `uidev.cpp`: the ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | | `code/ui/screens//` | one family each for `version`, `msgbox`, `waitbox`, `sound`, `gamectrl`, `display`, `keyboard`, `mainopt`: `ui.h` (presenter, service and state declarations, view factory, engine entry), `ui.cpp` (presenter and RmlUi view; built into the test), `uidlg.cpp` (engine service and entry, which the test cannot link) | landed; the sound, game controls, keyboard and display Win32 dialogs drive the same presenter as a second view, and the wait box family carries the `UIWaitBoxClass` the save, load and progress code shows | diff --git a/tests/uilogic/CMakeLists.txt b/tests/uilogic/CMakeLists.txt index 797f1c92b..223f41a4e 100644 --- a/tests/uilogic/CMakeLists.txt +++ b/tests/uilogic/CMakeLists.txt @@ -1,10 +1,12 @@ # The toolkit-free UI state: the input ownership the shell routes messages by, the text -# decoding it feeds the documents, and the marks the presenter owes the screen, compiled -# without any UI library so the harness stays cheap and the code stays free of toolkit types. +# decoding it feeds the documents, the checks the overlay renderer makes before it draws, +# and the marks the presenter owes the screen, compiled without any UI library so the +# harness stays cheap and the code stays free of toolkit types. opents_add_test(UILogic NAME uilogic SOURCES uilogictest.cpp ENGINE ui/uiinput.cpp + ui/rml/rmlrendermath.cpp videodirty.cpp ) diff --git a/tests/uilogic/uilogictest.cpp b/tests/uilogic/uilogictest.cpp index 9914aeffc..667cae9ae 100644 --- a/tests/uilogic/uilogictest.cpp +++ b/tests/uilogic/uilogictest.cpp @@ -9,15 +9,18 @@ // Pins the UI state that needs no toolkit: who a held key or button belongs to as screens // open, close and lose the capture or the focus, how the bytes of a narrow window's text -// messages become code points, and what the presenter owes the screen after a present is -// taken, refused or skipped. +// messages become code points, what the overlay renderer refuses before it draws, and what +// the presenter owes the screen after a present is taken, refused or skipped. +#include "ui/rml/rmlrendermath.h" #include "ui/uiinput.h" #include "videodirty.h" #include +#include #include #include +#include #include namespace { @@ -161,6 +164,43 @@ void Test_Text(void) } +void Test_Render_Math(void) +{ + std::uint32_t bytes = 0; + Check(UI_Render_Byte_Count(10, 24, bytes) && bytes == 240, "a byte count is the whole product"); + Check(!UI_Render_Byte_Count(std::numeric_limits::max(), 2, bytes) && bytes == 0, "a byte count that overflows fails"); + Check(!UI_Render_Byte_Count(0, 4, bytes), "an empty allocation fails"); + + int const valid[] = { 2, 0, 1 }; + int const negative[] = { -1, 0, 1 }; + int const outside[] = { 0, 1, 3 }; + int const incomplete[] = { 0, 1 }; + Check(UI_Render_Index_Range(valid, 3), "whole triangles over the vertices pass"); + Check(!UI_Render_Index_Range(negative, 3), "a negative index fails"); + Check(!UI_Render_Index_Range(outside, 3), "an index past the last vertex fails"); + Check(!UI_Render_Index_Range(incomplete, 3), "an incomplete triangle fails"); + + UIRenderClip clip; + Check(UI_Render_Clip_Rect(-2.4f, 1.2f, 12.1f, 25.7f, 100, 50, 10, 20, clip) && clip.X == 100 && clip.Y == 51 && clip.Width == 10 && clip.Height == 19, "a fractional scissor rounds outward, clips, and lands in the target"); + Check(!UI_Render_Clip_Rect(-10.0f, 0.0f, -1.0f, 10.0f, 100, 50, 10, 20, clip), "a scissor outside the viewport draws nothing"); + Check(!UI_Render_Clip_Rect(3.0f, 0.0f, 3.0f, 10.0f, 0, 0, 10, 20, clip), "an empty scissor draws nothing"); + Check(!UI_Render_Clip_Rect(0.0f, 0.0f, std::numeric_limits::infinity(), 10.0f, 0, 0, 10, 20, clip), "a non-finite scissor fails"); + Check(!UI_Render_Clip_Rect(0.0f, 0.0f, 10.0f, 10.0f, 65530, 0, 10, 20, clip), "a scissor cannot wrap the 16-bit target coordinates"); + + std::array pixels; + pixels.fill(0xEE); + for (int row = 0; row < 3; row++) { + for (int column = 0; column < 12; column++) { + pixels[(std::size_t)row * 16 + column] = (std::uint8_t)(row * 12 + column); + } + } + std::vector packed; + Check(UI_Render_Copy_RGBA_Rect(pixels, 3, 3, 16, 1, 1, 2, 2, packed) && packed.size() == 16 && packed[0] == 16 && packed[8] == 28, "a rectangle out of a pitched image packs tightly"); + Check(!UI_Render_Copy_RGBA_Rect(pixels, 3, 3, 16, 2, 2, 2, 2, packed), "a rectangle past the image fails"); + Check(!UI_Render_Copy_RGBA_Rect(pixels, 3, 4, 16, 0, 0, 1, 1, packed), "an image larger than its bytes fails"); +} + + void Test_Dirty_State(void) { VideoDirtyStateClass dirty; @@ -212,6 +252,7 @@ int main(void) Test_Ownership(); Test_Reconciliation(); Test_Text(); + Test_Render_Math(); Test_Dirty_State(); std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 9fd7b1c4b..49c226592 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -13,6 +13,7 @@ opents_add_test(UIShell ui/uishell.cpp ui/uiunicode.cpp ui/rml/rmlkeys.cpp + ui/rml/rmlrendermath.cpp ui/rml/rmlsystem.cpp ui/rml/rmlview.cpp ui/screens/display/uidisplay.cpp diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 6f1416036..d1b0a6c77 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -27,6 +27,7 @@ #include "ui/rml/rmlkeys.h" #include "ui/rml/rmlrender.h" +#include "ui/rml/rmlrendermath.h" #include "ui/rml/rmlsystem.h" #include "ui/rml/rmlview.h" #include "ui/screens/display/uidisplay.h" @@ -86,6 +87,7 @@ class RecordingRenderInterfaceClass : public UIRmlRenderClass int Generated = 0; int ReleasedTextures = 0; int Unsupported = 0; + int Invalid = 0; int Frames = 0; std::vector Scissors; std::function OnRender; @@ -125,9 +127,21 @@ class RecordingRenderInterfaceClass : public UIRmlRenderClass { } - virtual Rml::CompiledGeometryHandle CompileGeometry(Rml::Span, Rml::Span) override + // Every fragment is held to what the engine's renderer refuses: whole triangles over + // the vertices, a size that fits, 16-bit indices, and finite positions. + virtual Rml::CompiledGeometryHandle CompileGeometry(Rml::Span vertices, Rml::Span indices) override { Compiled++; + + std::uint32_t bytes = 0; + bool finite = std::all_of(vertices.begin(), vertices.end(), [](Rml::Vertex const & vertex) { + return(std::isfinite(vertex.position.x) && std::isfinite(vertex.position.y) && std::isfinite(vertex.tex_coord.x) && std::isfinite(vertex.tex_coord.y)); + }); + if (!UI_Render_Index_Range(std::span(indices.data(), indices.size()), vertices.size()) + || vertices.size() > 65536 || !UI_Render_Byte_Count(vertices.size(), sizeof(Rml::Vertex), bytes) || !finite) { + Invalid++; + } + return((Rml::CompiledGeometryHandle)Compiled); } @@ -2243,6 +2257,7 @@ void Test_Documents(void) documents++; int rendered = render.Rendered; int unsupported = render.Unsupported; + int invalid = render.Invalid; int problems = system.Problems; render.Scissors.clear(); @@ -2267,6 +2282,7 @@ void Test_Documents(void) Check(render.Rendered > rendered, (name + " draws geometry").c_str()); Check(render.Unsupported == unsupported, (name + " stays within the implemented render methods").c_str()); + Check(render.Invalid == invalid, (name + " compiles whole, in-range, finite geometry").c_str()); Check(system.Problems == problems, (name + " raises no RmlUi warning or error").c_str()); bool clipped = true; @@ -2645,6 +2661,7 @@ void Test_Shell(void) Check(fixture.Render->ReleasedGeometry == fixture.Render->Compiled, "the shell releases every geometry it compiled"); Check(fixture.Render->ReleasedTextures == fixture.Render->Loaded + fixture.Render->Generated, "the shell releases every texture it made"); + Check(fixture.Render->Invalid == 0, "the shell's screens compile only geometry the renderer accepts"); Check(fixture.System->Problems == 0, "the shell's screens raise no RmlUi warning or error"); } From cddcfb73da062651d746ec1a0b77b6493133608b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 00:14:30 +0300 Subject: [PATCH 33/52] Refuse what the overlay renderer cannot draw and fall back --- code/ui/rml/rmlrender.cpp | 222 +++++++++++++++++++++++++++------- code/ui/rml/rmlrender.h | 64 +++++++++- code/ui/rml/rmlrenderbase.cpp | 105 ++++++++++++++++ code/ui/uishell.cpp | 56 ++++++--- code/ui/uishell.h | 1 + docs/UI_DESIGN.md | 11 +- tests/uishell/CMakeLists.txt | 1 + 7 files changed, 394 insertions(+), 66 deletions(-) create mode 100644 code/ui/rml/rmlrenderbase.cpp diff --git a/code/ui/rml/rmlrender.cpp b/code/ui/rml/rmlrender.cpp index 0222390fd..42ada2611 100644 --- a/code/ui/rml/rmlrender.cpp +++ b/code/ui/rml/rmlrender.cpp @@ -15,6 +15,7 @@ #include "bgfxbackend.h" #include "bgfxviews.hh" #include "dbgprint.h" +#include "ui/rml/rmlrendermath.h" #include "ui/rml/rmltexture.h" #include @@ -25,7 +26,8 @@ #include -#include +#include +#include #include #include @@ -42,12 +44,20 @@ static const bgfx::EmbeddedShader _EmbeddedShaders[] = { static bgfx::VertexLayout _VertexLayout; static bgfx::VertexLayout _DevVertexLayout; +// What one geometry or texture may hold, what all of them may hold together, and the +// largest texture edge, whatever the device allows. +static const uint32_t UI_MAX_RESOURCE_BYTES = 64u * 1024u * 1024u; +static const uint64_t UI_MAX_GEOMETRY_BYTES = 128ull * 1024ull * 1024ull; +static const uint64_t UI_MAX_TEXTURE_BYTES = 128ull * 1024ull * 1024ull; +static const int UI_MAX_TEXTURE_DIMENSION = 4096; + // A compiled document fragment, submitted many times with different translations. struct UIGeometry { bgfx::VertexBufferHandle Vertices; bgfx::IndexBufferHandle Indices; + unsigned int Bytes; }; @@ -76,6 +86,12 @@ UIRmlBgfxRenderClass::UIRmlBgfxRenderClass(void) : } +void UIRmlBgfxRenderClass::Report(char const * message) +{ + DebugString("UI renderer: %s\n", message); +} + + bool UIRmlBgfxRenderClass::Init(void) { if (IsReady) { @@ -132,17 +148,26 @@ bool UIRmlBgfxRenderClass::Init(void) Program = program.idx; Sampler = sampler.idx; WhiteTexture = whitetexture.idx; + Statistics = UIRenderStats(); + TextureBytes.clear(); + Clear_Error(); IsReady = true; return(true); } +// RmlUi has released everything through this object by now, so anything still counted is +// a leak worth a log line. void UIRmlBgfxRenderClass::Shutdown(void) { if (!IsReady) { return; } + if (Statistics.GeometryCount != 0 || Statistics.TextureCount != 0) { + DebugString("UI renderer: %u geometries and %u textures were never released\n", Statistics.GeometryCount, Statistics.TextureCount); + } + bgfx::TextureHandle whitetexture = { WhiteTexture }; bgfx::UniformHandle sampler = { Sampler }; bgfx::ProgramHandle program = { Program }; @@ -154,6 +179,8 @@ void UIRmlBgfxRenderClass::Shutdown(void) WhiteTexture = bgfx::kInvalidHandle; Sampler = bgfx::kInvalidHandle; Program = bgfx::kInvalidHandle; + Statistics = UIRenderStats(); + TextureBytes.clear(); IsReady = false; } @@ -179,6 +206,7 @@ void UIRmlBgfxRenderClass::Set_View(unsigned short view, int x, int y, int width void UIRmlBgfxRenderClass::Begin_Frame(int x, int y, int width, int height) { + Statistics.DrawCalls = 0; Set_View(VIEW_UI, x, y, width, height); } @@ -189,46 +217,106 @@ void UIRmlBgfxRenderClass::Begin_Dev_Frame(int x, int y, int width, int height) } +// The policy cap keeps a document from asking for a texture the device would allow but the +// process should not spend; Dear ImGui sizes its atlas by this too. int UIRmlBgfxRenderClass::Texture_Limit(void) const { if (!IsReady) { return(0); } - return((int)bgfx::getCaps()->limits.maxTextureSize); + int limit = (int)bgfx::getCaps()->limits.maxTextureSize; + return(limit < UI_MAX_TEXTURE_DIMENSION ? limit : UI_MAX_TEXTURE_DIMENSION); } void UIRmlBgfxRenderClass::Log_Resource_Counts(char const * when) const { - bgfx::Stats const * stats = bgfx::getStats(); - if (stats == NULL) { - return; + DebugString("UI: %s; %u geometries (%llu bytes), %u textures (%llu bytes)%s%s\n", + when, Statistics.GeometryCount, (unsigned long long)Statistics.GeometryBytes, + Statistics.TextureCount, (unsigned long long)Statistics.TextureBytes, + Error()[0] != '\0' ? "; error: " : "", Error()); +} + + +// Two calls stay in reserve for the presenter's own quads. +bool UIRmlBgfxRenderClass::Draw_Available(void) +{ + if ((uint64_t)Statistics.DrawCalls + 2 >= bgfx::getCaps()->limits.maxDrawCalls) { + return(Fail("the frame's draw-call limit was reached")); } + return(true); +} - DebugString("UI: %s; renderer holds %u textures, %u vertex buffers, %u index buffers\n", - when, (unsigned)stats->numTextures, (unsigned)stats->numVertexBuffers, (unsigned)stats->numIndexBuffers); + +bool UIRmlBgfxRenderClass::Record_Texture(unsigned short index, unsigned int bytes) +{ + TextureBytes[index] = bytes; + Statistics.TextureCount++; + Statistics.TextureBytes += bytes; + return(true); +} + + +void UIRmlBgfxRenderClass::Forget_Texture(unsigned short index) +{ + std::unordered_map::iterator entry = TextureBytes.find(index); + if (entry != TextureBytes.end()) { + Statistics.TextureCount--; + Statistics.TextureBytes -= entry->second; + TextureBytes.erase(entry); + } } Rml::CompiledGeometryHandle UIRmlBgfxRenderClass::CompileGeometry(Rml::Span vertices, Rml::Span indices) { - if (!IsReady || vertices.empty() || indices.empty()) { + if (!IsReady) { return(0); } + if (!UI_Render_Index_Range(std::span(indices.data(), indices.size()), vertices.size())) { + Fail("a fragment's indices do not name whole triangles over its vertices"); + return(0); + } + for (Rml::Vertex const & vertex : vertices) { + if (!std::isfinite(vertex.position.x) || !std::isfinite(vertex.position.y) || !std::isfinite(vertex.tex_coord.x) || !std::isfinite(vertex.tex_coord.y)) { + Fail("a fragment holds a vertex that is not finite"); + return(0); + } + } - bgfx::VertexBufferHandle vertexbuffer = bgfx::createVertexBuffer(bgfx::copy(vertices.data(), (uint32_t)(vertices.size() * sizeof(Rml::Vertex))), _VertexLayout); + uint32_t vertexbytes = 0; + uint32_t indexbytes = 0; + bool wideindices = (bgfx::getCaps()->supported & BGFX_CAPS_INDEX32) != 0; + if (!UI_Render_Byte_Count(vertices.size(), sizeof(Rml::Vertex), vertexbytes) + || !UI_Render_Byte_Count(indices.size(), wideindices ? sizeof(int) : sizeof(uint16_t), indexbytes)) { + Fail("a fragment is too large to measure"); + return(0); + } + if (!wideindices && vertices.size() > 65536) { + Fail("a fragment over 65536 vertices needs 32-bit indices, which this renderer lacks"); + return(0); + } + if ((uint64_t)vertexbytes + indexbytes > UI_MAX_RESOURCE_BYTES) { + Fail("a fragment is larger than the renderer accepts"); + return(0); + } + if (Statistics.GeometryBytes + vertexbytes + indexbytes > UI_MAX_GEOMETRY_BYTES) { + Fail("the documents hold more geometry than the renderer accepts"); + return(0); + } + + bgfx::VertexBufferHandle vertexbuffer = bgfx::createVertexBuffer(bgfx::copy(vertices.data(), vertexbytes), _VertexLayout); bgfx::IndexBufferHandle indexbuffer; - if ((bgfx::getCaps()->supported & BGFX_CAPS_INDEX32) != 0) { - indexbuffer = bgfx::createIndexBuffer(bgfx::copy(indices.data(), (uint32_t)(indices.size() * sizeof(int))), BGFX_BUFFER_INDEX32); + if (wideindices) { + indexbuffer = bgfx::createIndexBuffer(bgfx::copy(indices.data(), indexbytes), BGFX_BUFFER_INDEX32); } else { - assert(vertices.size() <= 65536); std::vector narrow(indices.size()); for (size_t index = 0; index < indices.size(); index++) { narrow[index] = (uint16_t)indices[index]; } - indexbuffer = bgfx::createIndexBuffer(bgfx::copy(narrow.data(), (uint32_t)(narrow.size() * sizeof(uint16_t)))); + indexbuffer = bgfx::createIndexBuffer(bgfx::copy(narrow.data(), indexbytes)); } if (!bgfx::isValid(vertexbuffer) || !bgfx::isValid(indexbuffer)) { @@ -238,12 +326,16 @@ Rml::CompiledGeometryHandle UIRmlBgfxRenderClass::CompileGeometry(Rml::SpanVertices = vertexbuffer; geometry->Indices = indexbuffer; + geometry->Bytes = vertexbytes + indexbytes; + Statistics.GeometryCount++; + Statistics.GeometryBytes += geometry->Bytes; return((Rml::CompiledGeometryHandle)geometry); } @@ -253,6 +345,13 @@ void UIRmlBgfxRenderClass::RenderGeometry(Rml::CompiledGeometryHandle handle, Rm if (!IsReady || handle == 0) { return; } + if (!std::isfinite(translation.x) || !std::isfinite(translation.y)) { + Fail("a fragment's translation is not finite"); + return; + } + if (!Draw_Available()) { + return; + } if (ScissorEnabled && !Apply_Scissor()) { return; @@ -283,6 +382,7 @@ void UIRmlBgfxRenderClass::RenderGeometry(Rml::CompiledGeometryHandle handle, Rm bgfx::setTexture(0, sampler, sampled, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A | BGFX_STATE_BLEND_FUNC(BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA)); bgfx::submit(VIEW_UI, program); + Statistics.DrawCalls++; } @@ -295,6 +395,8 @@ void UIRmlBgfxRenderClass::ReleaseGeometry(Rml::CompiledGeometryHandle handle) UIGeometry * geometry = (UIGeometry *)handle; bgfx::destroy(geometry->Vertices); bgfx::destroy(geometry->Indices); + Statistics.GeometryCount--; + Statistics.GeometryBytes -= geometry->Bytes; delete geometry; } @@ -306,6 +408,9 @@ Rml::TextureHandle UIRmlBgfxRenderClass::LoadTexture(Rml::Vector2i & dimensions, int height = 0; if (!UI_Load_Image(source.c_str(), rgba, width, height)) { + char message[320]; + std::snprintf(message, sizeof(message), "%s did not decode", source.c_str()); + Fail(message); return(0); } @@ -317,20 +422,37 @@ Rml::TextureHandle UIRmlBgfxRenderClass::LoadTexture(Rml::Vector2i & dimensions, Rml::TextureHandle UIRmlBgfxRenderClass::GenerateTexture(Rml::Span source, Rml::Vector2i dimensions) { - if (!IsReady || dimensions.x <= 0 || dimensions.y <= 0) { + if (!IsReady) { return(0); } - uint32_t size = (uint32_t)dimensions.x * (uint32_t)dimensions.y * 4; - if (source.size() < size) { + int limit = Texture_Limit(); + if (dimensions.x <= 0 || dimensions.y <= 0 || dimensions.x > limit || dimensions.y > limit) { + Fail("a texture is empty or larger on a side than the renderer accepts"); + return(0); + } + + uint32_t size = 0; + if (!UI_Render_Byte_Count((size_t)dimensions.x * (size_t)dimensions.y, 4, size) || size > UI_MAX_RESOURCE_BYTES) { + Fail("a texture is larger than the renderer accepts"); + return(0); + } + if (source.size() != size) { + Fail("a texture's pixels do not match its dimensions"); + return(0); + } + if (Statistics.TextureBytes + size > UI_MAX_TEXTURE_BYTES) { + Fail("the documents hold more texture than the renderer accepts"); return(0); } bgfx::TextureHandle texture = bgfx::createTexture2D((uint16_t)dimensions.x, (uint16_t)dimensions.y, false, 1, bgfx::TextureFormat::RGBA8, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP, bgfx::copy(source.data(), size)); if (!bgfx::isValid(texture)) { + Fail("a texture could not be created"); return(0); } + Record_Texture(texture.idx, size); return((Rml::TextureHandle)texture.idx + 1); } @@ -341,7 +463,14 @@ void UIRmlBgfxRenderClass::ReleaseTexture(Rml::TextureHandle texture) return; } - bgfx::destroy(Texture_Handle(texture)); + bgfx::TextureHandle handle = Texture_Handle(texture); + if (TextureBytes.find(handle.idx) == TextureBytes.end()) { + Fail("a texture the renderer does not hold was released"); + return; + } + + Forget_Texture(handle.idx); + bgfx::destroy(handle); } @@ -365,31 +494,37 @@ bool UIRmlBgfxRenderClass::Apply_Scissor(void) const return(false); } - int left = ViewX + Scissor.Left(); - int top = ViewY + Scissor.Top(); - int right = left + Scissor.Width(); - int bottom = top + Scissor.Height(); - - if (left < ViewX) left = ViewX; - if (top < ViewY) top = ViewY; - if (right > ViewX + ViewWidth) right = ViewX + ViewWidth; - if (bottom > ViewY + ViewHeight) bottom = ViewY + ViewHeight; - - if (right <= left || bottom <= top) { + UIRenderClip clip; + if (!UI_Render_Clip_Rect((float)Scissor.Left(), (float)Scissor.Top(), (float)Scissor.Right(), (float)Scissor.Bottom(), ViewX, ViewY, ViewWidth, ViewHeight, clip)) { return(false); } - bgfx::setScissor((uint16_t)left, (uint16_t)top, (uint16_t)(right - left), (uint16_t)(bottom - top)); + bgfx::setScissor(clip.X, clip.Y, clip.Width, clip.Height); return(true); } // Dear ImGui asks for its textures through status requests; each is answered here and -// acknowledged, and a destroyed texture keeps its pixels so that ImGui can ask again. +// acknowledged, and a destroyed texture keeps its pixels so that ImGui can ask again. A +// request the renderer refuses is left unanswered, so ImGui asks again next frame. void UIRmlBgfxRenderClass::Update_ImGui_Texture(ImTextureData * texture) { if (texture->Status == ImTextureStatus_WantCreate) { - assert(texture->Format == ImTextureFormat_RGBA32); + int limit = Texture_Limit(); + uint32_t size = 0; + if (texture->Format != ImTextureFormat_RGBA32) { + Fail("an overlay texture is not RGBA"); + return; + } + if (texture->Width <= 0 || texture->Height <= 0 || texture->Width > limit || texture->Height > limit) { + Fail("an overlay texture is empty or larger on a side than the renderer accepts"); + return; + } + if (!UI_Render_Byte_Count((size_t)texture->Width * (size_t)texture->Height, 4, size) || size > UI_MAX_RESOURCE_BYTES + || Statistics.TextureBytes + size > UI_MAX_TEXTURE_BYTES || (size_t)texture->GetSizeInBytes() != size) { + Fail("an overlay texture is larger than the renderer accepts"); + return; + } // A texture created with its pixels is immutable in bgfx, and the atlas keeps growing. bgfx::TextureHandle handle = bgfx::createTexture2D((uint16_t)texture->Width, (uint16_t)texture->Height, false, 1, bgfx::TextureFormat::RGBA8, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); @@ -398,7 +533,8 @@ void UIRmlBgfxRenderClass::Update_ImGui_Texture(ImTextureData * texture) return; } - bgfx::updateTexture2D(handle, 0, 0, 0, 0, (uint16_t)texture->Width, (uint16_t)texture->Height, bgfx::copy(texture->GetPixels(), (uint32_t)texture->GetSizeInBytes())); + bgfx::updateTexture2D(handle, 0, 0, 0, 0, (uint16_t)texture->Width, (uint16_t)texture->Height, bgfx::copy(texture->GetPixels(), size)); + Record_Texture(handle.idx, size); texture->SetTexID((ImTextureID)handle.idx + 1); texture->SetStatus(ImTextureStatus_OK); } else if (texture->Status == ImTextureStatus_WantUpdates) { @@ -416,6 +552,7 @@ void UIRmlBgfxRenderClass::Update_ImGui_Texture(ImTextureData * texture) if (texture->Status == ImTextureStatus_WantDestroy && texture->UnusedFrames > 0) { if (texture->TexID != ImTextureID_Invalid) { bgfx::TextureHandle handle = { (uint16_t)(texture->TexID - 1) }; + Forget_Texture(handle.idx); bgfx::destroy(handle); texture->SetTexID(ImTextureID_Invalid); } @@ -429,6 +566,7 @@ void UIRmlBgfxRenderClass::Destroy_ImGui_Textures(void) for (ImTextureData * texture : ImGui::GetPlatformIO().Textures) { if (texture->TexID != ImTextureID_Invalid) { bgfx::TextureHandle handle = { (uint16_t)(texture->TexID - 1) }; + Forget_Texture(handle.idx); bgfx::destroy(handle); texture->SetTexID(ImTextureID_Invalid); } @@ -497,18 +635,15 @@ void UIRmlBgfxRenderClass::Render_ImGui(ImDrawData * data) continue; } - int left = ViewX + (int)(command.ClipRect.x - data->DisplayPos.x); - int top = ViewY + (int)(command.ClipRect.y - data->DisplayPos.y); - int right = ViewX + (int)(command.ClipRect.z - data->DisplayPos.x); - int bottom = ViewY + (int)(command.ClipRect.w - data->DisplayPos.y); - - if (left < ViewX) left = ViewX; - if (top < ViewY) top = ViewY; - if (right > ViewX + ViewWidth) right = ViewX + ViewWidth; - if (bottom > ViewY + ViewHeight) bottom = ViewY + ViewHeight; - if (right <= left || bottom <= top) { + UIRenderClip clip; + if (!UI_Render_Clip_Rect(command.ClipRect.x - data->DisplayPos.x, command.ClipRect.y - data->DisplayPos.y, + command.ClipRect.z - data->DisplayPos.x, command.ClipRect.w - data->DisplayPos.y, + ViewX, ViewY, ViewWidth, ViewHeight, clip)) { continue; } + if (!Draw_Available()) { + return; + } bgfx::TextureHandle sampled = { WhiteTexture }; ImTextureID id = command.GetTexID(); @@ -516,13 +651,14 @@ void UIRmlBgfxRenderClass::Render_ImGui(ImDrawData * data) sampled.idx = (uint16_t)(id - 1); } - bgfx::setScissor((uint16_t)left, (uint16_t)top, (uint16_t)(right - left), (uint16_t)(bottom - top)); + bgfx::setScissor(clip.X, clip.Y, clip.Width, clip.Height); bgfx::setTransform(identity); bgfx::setVertexBuffer(0, &vertices, command.VtxOffset, vertexcount - command.VtxOffset); bgfx::setIndexBuffer(&indices, command.IdxOffset, command.ElemCount); bgfx::setTexture(0, sampler, sampled, BGFX_SAMPLER_U_CLAMP | BGFX_SAMPLER_V_CLAMP); bgfx::setState(BGFX_STATE_WRITE_RGB | BGFX_STATE_WRITE_A | BGFX_STATE_BLEND_FUNC_SEPARATE(BGFX_STATE_BLEND_SRC_ALPHA, BGFX_STATE_BLEND_INV_SRC_ALPHA, BGFX_STATE_BLEND_ONE, BGFX_STATE_BLEND_INV_SRC_ALPHA)); bgfx::submit(VIEW_DEV, program); + Statistics.DrawCalls++; } } } diff --git a/code/ui/rml/rmlrender.h b/code/ui/rml/rmlrender.h index 543993bf0..9c3e9e7a7 100644 --- a/code/ui/rml/rmlrender.h +++ b/code/ui/rml/rmlrender.h @@ -11,12 +11,29 @@ #include +#include +#include + struct ImDrawData; struct ImTextureData; +// What the renderer holds for the documents and the overlays, and how often it drew in +// the frame under way. +struct UIRenderStats +{ + unsigned int GeometryCount = 0; + unsigned int TextureCount = 0; + unsigned int DrawCalls = 0; + std::uint64_t GeometryBytes = 0; + std::uint64_t TextureBytes = 0; +}; + + // The renderer the shell draws the documents and the developer overlays with. The engine's -// draws through bgfx; a test supplies one that records what it is asked. +// draws through bgfx; a test supplies one that records what it is asked. A request the +// renderer refuses is latched here with its reason, so the shell can tell a document that +// could not be drawn whole from one that could. class UIRmlRenderClass : public Rml::RenderInterface { public: @@ -25,7 +42,8 @@ class UIRmlRenderClass : public Rml::RenderInterface virtual bool Init(void) = 0; virtual void Shutdown(void) = 0; - // Points the document view at the frame's destination rectangle, in window pixels. + // Points the document view at the frame's destination rectangle, in window pixels, + // and starts the frame's draw count. virtual void Begin_Frame(int x, int y, int width, int height) = 0; // Points the developer view at the same rectangle. @@ -41,8 +59,38 @@ class UIRmlRenderClass : public Rml::RenderInterface // The largest texture edge the renderer accepts. virtual int Texture_Limit(void) const = 0; - // Writes the renderer's live texture and buffer counts to the debug log. + // Writes what the renderer holds to the debug log. virtual void Log_Resource_Counts(char const * when) const = 0; + + // The first refusal since the last clear, empty when there was none. The shell + // clears it before preparing a document and reads it after. + char const * Error(void) const { return(ErrorText); } + void Clear_Error(void) { ErrorText[0] = '\0'; } + UIRenderStats const & Stats(void) const { return(Statistics); } + + // The render effects outside the styling profile the documents keep to. A document + // that reaches one draws without it and the refusal is latched. + virtual void EnableClipMask(bool enable) override; + virtual void RenderToClipMask(Rml::ClipMaskOperation operation, Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation) override; + virtual void SetTransform(Rml::Matrix4f const * transform) override; + virtual Rml::LayerHandle PushLayer(void) override; + virtual void CompositeLayers(Rml::LayerHandle source, Rml::LayerHandle destination, Rml::BlendMode mode, Rml::Span filters) override; + virtual void PopLayer(void) override; + virtual Rml::TextureHandle SaveLayerAsTexture(void) override; + virtual Rml::CompiledFilterHandle SaveLayerAsMaskImage(void) override; + virtual Rml::CompiledFilterHandle CompileFilter(Rml::String const & name, Rml::Dictionary const & parameters) override; + virtual Rml::CompiledShaderHandle CompileShader(Rml::String const & name, Rml::Dictionary const & parameters) override; + virtual void RenderShader(Rml::CompiledShaderHandle shader, Rml::CompiledGeometryHandle geometry, Rml::Vector2f translation, Rml::TextureHandle texture) override; + + protected: + // Latches the first refusal, reports it once and returns false. + bool Fail(char const * message); + virtual void Report(char const * message); + + UIRenderStats Statistics; + + private: + char ErrorText[512] = {}; }; @@ -75,9 +123,15 @@ class UIRmlBgfxRenderClass : public UIRmlRenderClass virtual void EnableScissorRegion(bool enable) override; virtual void SetScissorRegion(Rml::Rectanglei region) override; + protected: + virtual void Report(char const * message) override; + private: void Set_View(unsigned short view, int x, int y, int width, int height); bool Apply_Scissor(void) const; + bool Draw_Available(void); + bool Record_Texture(unsigned short index, unsigned int bytes); + void Forget_Texture(unsigned short index); void Update_ImGui_Texture(ImTextureData * texture); bool IsReady; @@ -93,5 +147,9 @@ class UIRmlBgfxRenderClass : public UIRmlRenderClass bool ScissorEnabled; Rml::Rectanglei Scissor; + // The bytes each live texture holds, by bgfx index, for the documents' and the + // overlays' textures alike. + std::unordered_map TextureBytes; + bool DevShortageLogged; }; diff --git a/code/ui/rml/rmlrenderbase.cpp b/code/ui/rml/rmlrenderbase.cpp new file mode 100644 index 000000000..36a1d1882 --- /dev/null +++ b/code/ui/rml/rmlrenderbase.cpp @@ -0,0 +1,105 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// The part of the renderer that needs no bgfx: the refusal latch and the render effects +// outside the styling profile. The harness builds it under its recording renderer. + +#include "ui/rml/rmlrender.h" + +#include + + +bool UIRmlRenderClass::Fail(char const * message) +{ + if (ErrorText[0] == '\0') { + std::snprintf(ErrorText, sizeof(ErrorText), "%s", message); + Report(message); + } + return(false); +} + + +void UIRmlRenderClass::Report(char const *) +{ +} + + +void UIRmlRenderClass::EnableClipMask(bool enable) +{ + if (enable) { + Fail("clip masks are outside the supported styling profile"); + } +} + + +void UIRmlRenderClass::RenderToClipMask(Rml::ClipMaskOperation, Rml::CompiledGeometryHandle, Rml::Vector2f) +{ + Fail("clip masks are outside the supported styling profile"); +} + + +void UIRmlRenderClass::SetTransform(Rml::Matrix4f const * transform) +{ + if (transform != nullptr) { + Fail("transforms are outside the supported styling profile"); + } +} + + +Rml::LayerHandle UIRmlRenderClass::PushLayer(void) +{ + Fail("layers are outside the supported styling profile"); + return(0); +} + + +void UIRmlRenderClass::CompositeLayers(Rml::LayerHandle, Rml::LayerHandle, Rml::BlendMode, Rml::Span) +{ + Fail("layers are outside the supported styling profile"); +} + + +void UIRmlRenderClass::PopLayer(void) +{ + Fail("layers are outside the supported styling profile"); +} + + +Rml::TextureHandle UIRmlRenderClass::SaveLayerAsTexture(void) +{ + Fail("layers are outside the supported styling profile"); + return(0); +} + + +Rml::CompiledFilterHandle UIRmlRenderClass::SaveLayerAsMaskImage(void) +{ + Fail("layers are outside the supported styling profile"); + return(0); +} + + +Rml::CompiledFilterHandle UIRmlRenderClass::CompileFilter(Rml::String const &, Rml::Dictionary const &) +{ + Fail("filters are outside the supported styling profile"); + return(0); +} + + +Rml::CompiledShaderHandle UIRmlRenderClass::CompileShader(Rml::String const &, Rml::Dictionary const &) +{ + Fail("shaders are outside the supported styling profile"); + return(0); +} + + +void UIRmlRenderClass::RenderShader(Rml::CompiledShaderHandle, Rml::CompiledGeometryHandle, Rml::Vector2f, Rml::TextureHandle) +{ + Fail("shaders are outside the supported styling profile"); +} diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index a886b094e..7e2b5a88f 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -665,20 +665,22 @@ void UIShellClass::Render_Overlay(void) return; } - bool documents = Documents_Visible(); - bool overlays = UIDev_Active(); - if (!documents && !overlays) { + UIFrameRect frame = Host.Frame(); + if (frame.Width <= 0 || frame.Height <= 0) { return; } - UIFrameRect frame = Host.Frame(); - if (frame.Width <= 0 || frame.Height <= 0) { + // The frame begins whether or not anything draws, so the renderer's draw count starts + // afresh at every present. + Render->Begin_Frame(frame.X, frame.Y, frame.Width, frame.Height); + + bool documents = Documents_Visible(); + bool overlays = UIDev_Active(); + if (!documents && !overlays) { return; } if (documents) { - Render->Begin_Frame(frame.X, frame.Y, frame.Width, frame.Height); - UIReentryGuardClass rendering(InContext); Context->Render(); } @@ -1000,11 +1002,7 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s // A legacy dialog and an RmlUi screen never show together; the visible one takes the mouse. assert(!Legacy_Dialog_Visible()); - // A style sheet that fails to load leaves the document usable and is reported as an error. - int errors = System->Error_Count(); - if (!view.Prepare(*this) || System->Error_Count() != errors) { - Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Name()); - view.Release(); + if (!Prepare_View(view)) { return(UI_RESULT_FAILED_TO_OPEN); } @@ -1021,6 +1019,17 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s Modals.push_back(&view); view.Show(true); + + // Images load at the first layout, not when the document does, so the renderer's answer + // is read again after one update. + Tick(); + if (Render->Error()[0] != '\0') { + Log("UI: %s could not be shown (%s); its legacy view stays in charge\n", view.Name(), Render->Error()); + view.Release(); + Modals.pop_back(); + return(UI_RESULT_FAILED_TO_OPEN); + } + Host.Mark_Overlay_Dirty(); std::snprintf(label, sizeof(label), "%s shown", view.Name()); Render->Log_Resource_Counts(label); @@ -1085,16 +1094,31 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s } +// A document the toolkit could not load whole, or that asked the renderer for what it +// refuses, stays unshown; the caller opens its Win32 presentation with the reason logged. +bool UIShellClass::Prepare_View(UIViewClass & view) +{ + Render->Clear_Error(); + int errors = System->Error_Count(); + + // A style sheet that fails to load leaves the document usable and is reported as an error. + bool ready = view.Prepare(*this) && System->Error_Count() == errors && Render->Error()[0] == '\0'; + if (!ready) { + Log("UI: %s could not be prepared (%s); its legacy view stays in charge\n", view.Name(), + Render->Error()[0] != '\0' ? Render->Error() : "see the toolkit's log above"); + view.Release(); + } + return(ready); +} + + bool UIShellClass::Show_Modeless(UIViewClass & view) { if (!Ready || !FontLoaded || InContext) { return(false); } - int errors = System->Error_Count(); - if (!view.Prepare(*this) || System->Error_Count() != errors) { - Log("UI: %s could not be prepared; its legacy view stays in charge\n", view.Name()); - view.Release(); + if (!Prepare_View(view)) { return(false); } diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 7a7202ecd..9882c175e 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -147,6 +147,7 @@ class UIShellClass bool Pointer_Owned(void) const; void Apply_Cursor_Request(void); void Restore_Cursor(void); + bool Prepare_View(UIViewClass & view); void Drain_Deferred(void); void Toggle_Test_Document(void); bool Handle_Mouse_Move(LPARAM clientlparam); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 049182f4e..00ed29342 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -248,10 +248,11 @@ methods: | Capability | Behavior | | --- | --- | -| Compiled geometry | Static vertex and index buffers, since RmlUi 6 compiles geometry once and re-submits it; order preserved; released on request; never dependent on transient memory from a previous frame. | -| Textures | RGBA8, premultiplied alpha as the interface specifies, created and released explicitly, cached by source string, sized for the 32-bit process. | +| Compiled geometry | Static vertex and index buffers, since RmlUi 6 compiles geometry once and re-submits it; order preserved; released on request; never dependent on transient memory from a previous frame. Indices are checked against the vertex count and sizes are checked before the copy; without 32-bit indices, a fragment over 65536 vertices is refused rather than truncated. | +| Textures | RGBA8, premultiplied alpha as the interface specifies, created and released explicitly, cached by source string. Each edge is at most the smaller of the device limit and 4096, and a source must hold exactly width times height times four bytes. | | Blending | `ONE, INV_SRC_ALPHA`; vertex colors follow the same premultiplied contract with no double premultiplication. | -| Scissor | `bgfx::setScissor` in physical target coordinates, intersected with the viewport, empty regions handled. | +| Scissor | `bgfx::setScissor` in physical target coordinates, rounded outward to whole pixels, intersected with the viewport, empty regions handled. | +| Limits | 64 MiB per geometry or texture, 128 MiB of live geometry and 128 MiB of live textures, two draw calls short of the device's frame limit. The first refusal is latched with its reason; the shell clears the latch before preparing a document and reads it after, so a document the renderer could not draw whole opens its Win32 view instead. | | Projection | The overlay view's orthographic transform; no game-image filter state inherited. | | Reset and resize | Target-dependent resources recreated, viewport and scissor refreshed, a present without an upload requested; existing documents redraw without reload. | @@ -264,7 +265,9 @@ attributes (position, texture coordinate, color) match RmlUi's vertex and ImGui's vertex, each with its own layout. Clip masks, transforms, layers, filters, and shaders are deferred; shipped documents stay within a declared profile (text, images, ordinary layout, borders, basic decorators), and a -document check enforces it. +document check enforces it. A document that reaches one of them anyway, as +a mod's may, draws without it: the renderer latches the refusal with one +logged reason and otherwise behaves as RmlUi's defaults do. ### Invalidation diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 49c226592..42cbe23ee 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -13,6 +13,7 @@ opents_add_test(UIShell ui/uishell.cpp ui/uiunicode.cpp ui/rml/rmlkeys.cpp + ui/rml/rmlrenderbase.cpp ui/rml/rmlrendermath.cpp ui/rml/rmlsystem.cpp ui/rml/rmlview.cpp From 324b01373d22d9903619d2a59cc6dd9dc3cf18fa Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 00:31:15 +0300 Subject: [PATCH 34/52] Bring the UI design page and the manual up to the refined shell --- docs/BUILDING.md | 7 +- docs/README.md | 2 +- docs/UI_DESIGN.md | 81 +++++++++++++------ manual/content/systems/ui-files.md | 2 +- .../using/developer-build-troubleshooting.md | 4 +- manual/content/using/game-data.md | 2 +- 6 files changed, 65 insertions(+), 33 deletions(-) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 75c214d54..ba643ff21 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -241,11 +241,12 @@ CI collects the uploaded artifacts from `build/bin//`. ## Verification boundary -The supported matrix was verified on September 11, 2026 with CMake 4.3.3, +The supported matrix was verified on September 13, 2026 with CMake 4.3.3, Visual Studio 2022 Community 17.14.37614.0, MSVC 19.44.35228, and Windows SDK 10.0.26100. Fresh Win32 and x64 builds completed successfully in both -configurations, and CTest passed all 40 tests in each of the four. The builds -retain inherited MSVC warnings; warnings are not treated as errors, but +configurations, and CTest passed all 43 tests in each of the four, including +the `uishell`, `uilogic`, and `toolkitheaders` targets. The builds retain +inherited MSVC warnings; warnings are not treated as errors, but contributions should not add new warnings. This verifies only that the supported toolchain compiles, links, passes the diff --git a/docs/README.md b/docs/README.md index 963715287..8b3bf8be9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,7 @@ The developer guides are split by subject: - [Rationale](RATIONALE.md) — reconstruction tools, recovered structure, and non-obvious implementation choices. - [Project direction](DIRECTION.md) — long-term architecture. -- [UI system design](UI_DESIGN.md) — proposed RmlUi and ImGui integration, +- [UI system design](UI_DESIGN.md) — the RmlUi and ImGui integration, screen-level interchangeable views, and the migration from OwnerDraw. - [The saved game format](SAVE-FORMAT.md) — the layout of a `.SAV` file: its header, listing fields, compressed content, and object records. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 00ed29342..c0cbf6adc 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,12 +1,13 @@ # UI system design -Status: proposal under implementation. Steps 1 and 2 of the -[migration plan](#migration-plan), the dependencies, the RmlUi shell, the -Dear ImGui overlays, and the version dialog, have landed; everything after -them is not yet implemented, built, or measured. Source inspection and -upstream documentation inform the rest. This page owns the proposed UI -architecture and migration; -[Building OpenTS](BUILDING.md) owns build support and +Status: under implementation. Steps 1 through 7 of the +[migration plan](#migration-plan) have landed: the dependencies, the RmlUi +shell, the Dear ImGui overlays, the version dialog, the message boxes, the +sound options, the progress and wait boxes, and the options family (game +controls, display, mode confirmation, keyboard, options menu). Steps 8 +onward are not yet implemented, built, or measured; source inspection and +upstream documentation inform them. This page owns the UI architecture and +migration; [Building OpenTS](BUILDING.md) owns build support and [Project direction](DIRECTION.md) the wider architecture. ## Where the UI stands today @@ -141,10 +142,12 @@ Limits, chosen to keep the work bounded: Three parts, from the bottom up. -The **UI shell** is one module that owns the RmlUi context, the ImGui -context, the bgfx overlay pass, the input hook, and the modal runner. It is -the only code that includes RmlUi or ImGui headers, the way `bgfxbackend.cpp` -is the only code that includes bgfx. +The **UI shell** is the `code/ui/` module: a `UIShellClass` object that owns +the RmlUi context, the injected toolkit interfaces, the bgfx overlay pass, +the input hook, and the modal runner, beside the ImGui context its developer +module holds. Only its `code/ui/rml/` headers include RmlUi, ImGui, or bgfx, +the way `bgfxbackend.cpp` is the only other code that includes bgfx; the +`toolkitheaders` CTest check enforces that. A **screen** is a presenter plus a view. The presenter is a plain C++ object: it holds a view-model struct, answers queries, and executes actions. It never @@ -206,6 +209,7 @@ rule the tree follows, not a build boundary. | `code/ui/rml/` | the RmlUi adapters, the only headers that include a toolkit: `rmlsystem` (system interface: time, logging through the host, string translation, the pointer request, the clipboard), `rmlfile` (file interface over `CCFileClass`), `rmlrender` (render interface and the ImGui renderer on bgfx; with `bgfxbackend.cpp` the only files that include bgfx), `rmltexture` (image decoding: PNG and TGA today, with SHP, PCX and engine surfaces described under [Assets](#assets-and-strings)), `rmlkeys` (virtual keys, `KeyIdentifier`, `KEYBOARD.INI` numbers), `rmlview` (`UIRmlViewClass`, the RmlUi view base), `rmlrendermath` (the checks the renderer makes before it draws: index ranges, byte counts, scissors; toolkit-free, so the harness runs them) | landed | | `code/ui/dev/` | `uidev.h`, `uidev.cpp`: the ImGui context, its input feed, and the developer overlays | landed with the frame benchmark window | | `code/ui/screens//` | one family each for `version`, `msgbox`, `waitbox`, `sound`, `gamectrl`, `display`, `keyboard`, `mainopt`: `ui.h` (presenter, service and state declarations, view factory, engine entry), `ui.cpp` (presenter and RmlUi view; built into the test), `uidlg.cpp` (engine service and entry, which the test cannot link) | landed; the sound, game controls, keyboard and display Win32 dialogs drive the same presenter as a second view, and the wait box family carries the `UIWaitBoxClass` the save, load and progress code shows | +| `tests/uishell/`, `tests/uilogic/` | the two harnesses under [Validation](#validation-and-evidence); `cmake/CheckToolkitHeaders.cmake` is the containment check they run beside | landed | Shipped UI files (documents, styles, images, the font) live in `ui/` at the repository root. The build places the tree beside the executable, at @@ -529,16 +533,22 @@ A migrated dialog driver keeps its shape. `Run_Modal` is the RmlUi twin of the `Dialog_Message_Handler` loop: ```cpp -UIResult UI_Run_Modal(UIScreen & screen); +UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & service); // each pass: -// Windows_Message_Handler(); -// Main_Loop() in a network session, else Call_Back(); -- same test as today +// service(); -- the engine passes UI_Service_Game: Windows_Message_Handler(), +// then Main_Loop() in a network session, else Call_Back(); +// a test passes whatever it wants pumped // context->Update(); // execute the screen's queued intents; // mark the overlay dirty, Video_Present_If_Dirty(); -// until the screen has a result or Main_Loop reports the game ended. +// until the screen has a result or the service reports the game ended. ``` +The service pass is injected rather than written into the runner, so the +shell includes no game-loop header and the harness drives a modal without +the engine; `UI_Run_Modal(view)` in `uienginehost.h` is the engine's entry +and binds `UI_Service_Game`. + The result carries the game-ended flag the way `Dialog_Message_Handler` returns `true`, so callers keep their logic. Wrappers keep their service paths: the main menu keeps title-screen maintenance, an in-game screen keeps @@ -756,7 +766,7 @@ can follow the screen contract when someone wants them. | Configuration | Existing keys and defaults unchanged; new keys get owning documentation. | | Localization | The UTF-8 transition owns the encoding change; the UI adds no conversion of its own. | | Mods and resources | Legacy asset semantics unchanged; document paths, binding names, event names, and the styling profile are experimental until versioned with the first supported override package. | -| Build | 32-bit MSVC with the static CRT for every new dependency. | +| Build | Win32 and x64 MSVC with the static CRT for every new dependency; CI builds and tests both platforms. | ## Dependencies @@ -864,18 +874,20 @@ the credits are unscheduled. ## Validation and evidence -The `tests/uishell` CTest target brings FreeType and Dear ImGui up and down -under the engine's link settings and drives RmlUi core through a recording -render interface and a counting system interface. As screens land it links -`uiscreen.h`, the string table, and the screen presenters. It runs without -game assets: +Three CTest targets run without game assets and build into +`/test-bin/`. + +`tests/uishell` brings FreeType and Dear ImGui up and down under the engine's +link settings, drives RmlUi core through a recording render interface and a +counting system interface, links the string table and the screen presenters, +and builds `UIShellClass` itself over a host the test controls: - Load every shipped document from the source tree with the shipped font, show, update, and render it, and fail on a parse error, an RmlUi warning or error, a call to a render method the shell leaves at its default, a - scissor outside the context, or a resource named by anything but a bare - file name; after shutdown, every compiled geometry and texture has been - released. + fragment the renderer would refuse, a scissor outside the context, or a + resource named by anything but a bare file name; after shutdown, every + compiled geometry and texture has been released. - Bind a presenter, drive it with `Context::ProcessMouseButtonDown` on a known element, and assert the queued intent and result; drive the same actions through the legacy adapter and assert the same ordered service @@ -887,6 +899,24 @@ game assets: - Map client positions into the overlay at integer and fractional scales, with letterboxing, exclusive edges, outside input, and the offset a captured pointer keeps outside. +- Run the shell: initialise over injected interfaces and again after a + shutdown; drive a modal with a stub service to each result; consume a press + pumped while a screen opens; defer a resize arriving inside a render; + suppress what is held as a screen opens, what a lost capture cancels, and + what focus return finds held; restore the outer modal's ownership after a + nested one; take the side buttons and the horizontal wheel under a modal; + decode two UTF-8 bytes into one character; round the clipboard through + UTF-16; show and put back the pointer shape; list, unlist and release a + modeless notice. + +`tests/uilogic` compiles the toolkit-free state with no UI library: the input +ownership table and its cancellations, the UTF-8 decoder, the renderer's +geometry, size and scissor checks, and the presenter's marks through consume, +restore and reset. + +`toolkitheaders` runs `cmake/CheckToolkitHeaders.cmake` over `code/` and +fails on a toolkit or renderer header included outside `code/ui/rml/`, or an +RmlUi or ImGui header included by a source outside `code/ui/`. Runtime evidence stays per pull request, as `CONTRIBUTING.md` requires: the screen exercised in single player, skirmish, and a two-instance LAN game @@ -902,7 +932,8 @@ geometry memory are recorded on an agreed baseline before defaults change. ## Documentation - This page owns the architecture and is updated as steps land. -- `docs/BUILDING.md` lists the new submodules. +- `docs/BUILDING.md` lists the new submodules, the harnesses, and where the + `ui/` directory lands beside the executable. - `THIRD_PARTY_NOTICES.md` and the packaging license copy gain the three projects. - The manual gains a systems page for the UI files (where they live, the diff --git a/manual/content/systems/ui-files.md b/manual/content/systems/ui-files.md index 7f5880389..e113e4f08 100644 --- a/manual/content/systems/ui-files.md +++ b/manual/content/systems/ui-files.md @@ -11,7 +11,7 @@ related: id: mix --- -The `ui` directory beside the executable holds the RmlUi documents (`.rml`), their style sheets (`.rcss`), and the Open Sans font `OpenSans.ttf` with its license `OFL.txt`. The build copies the directory beside the executable the way it copies `Language.dll`, and the release package carries it. +The `ui` directory beside the executable holds the RmlUi documents (`.rml`), their style sheets (`.rcss`), and the Open Sans font `OpenSans.ttf` with its license `OFL.txt`. The build places the directory beside the executable, where `Language.dll` is built, and the release package carries it. ## How a file is found diff --git a/manual/content/using/developer-build-troubleshooting.md b/manual/content/using/developer-build-troubleshooting.md index 15cb56ceb..b2990b616 100644 --- a/manual/content/using/developer-build-troubleshooting.md +++ b/manual/content/using/developer-build-troubleshooting.md @@ -25,8 +25,8 @@ For a Visual Studio installation that CMake cannot discover through the Visual S Builds write their runnable files to `build/bin//` and copy nothing into `Run/`: -- Debug: `GameD.exe`, `GameD.pdb`, `GameD.map`, and `Language.dll` -- Release: `Game.exe`, `Game.pdb`, `Game.map`, and `Language.dll` +- Debug: `GameD.exe`, `GameD.pdb`, `GameD.map`, `Language.dll`, and the `ui/` directory +- Release: `Game.exe`, `Game.pdb`, `Game.map`, `Language.dll`, and the `ui/` directory ## The executable cannot initialize game data diff --git a/manual/content/using/game-data.md b/manual/content/using/game-data.md index 90e3c32a7..4eee7254c 100644 --- a/manual/content/using/game-data.md +++ b/manual/content/using/game-data.md @@ -26,7 +26,7 @@ Place data from a legitimate copy of Tiberian Sun under `Run/`. The tracked `Run Firestorm counts as installed when the game finds `FIRESTRM.INI`. That one file decides it, so a deployment keeping the expansion's content in archives of its own is still played as the expansion, and one without that file is played as the base game. -Do not place game data in the CMake build directory. The build copies OpenTS executables and `Language.dll` into `Run/`, alongside the locally supplied game files. +Do not place game data in the CMake build directory. The build writes nothing into `Run/`; it holds only the locally supplied game files. ## Keeping the data somewhere else From 3f9a7be34063bfc98f6357236e73051d47eecd70 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 04:24:21 +0300 Subject: [PATCH 35/52] Keep a visible pointer over a shown UI screen --- code/ui/uienginehost.cpp | 14 +++++++++++--- code/ui/uishell.cpp | 28 +++++++++++++++------------- code/ui/uishell.h | 6 ++++-- docs/UI_DESIGN.md | 16 ++++++++++------ tests/uishell/uishell.cpp | 7 +++++++ 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index 1f58f0c04..a064572ac 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -27,6 +27,14 @@ #include "windlg.h" +// The pointer the window shows when the game's is off it, as under a Win32 dialog. +static HCURSOR Window_Cursor(void) +{ + HCURSOR cursor = (HCURSOR)GetClassLongPtr(MainWindow, GCLP_HCURSOR); + return(cursor != NULL ? cursor : LoadCursor(NULL, IDC_ARROW)); +} + + class UIEngineHostClass : public UIShellHostClass { public: @@ -146,7 +154,7 @@ class UIEngineHostClass : public UIShellHostClass virtual void Apply_Cursor(UICursor cursor) override { - LPCTSTR shape = IDC_ARROW; + LPCTSTR shape = NULL; switch (cursor) { case UI_CURSOR_TEXT: shape = IDC_IBEAM; @@ -175,14 +183,14 @@ class UIEngineHostClass : public UIShellHostClass default: break; } - SetCursor(LoadCursor(NULL, shape)); + SetCursor(shape != NULL ? LoadCursor(NULL, shape) : Window_Cursor()); } virtual void Restore_Game_Cursor(void) override { Win_Cursor_Refresh(); if (!Win_Cursor_Handle_Set_Cursor()) { - SetCursor(LoadCursor(NULL, IDC_ARROW)); + SetCursor(Window_Cursor()); } } diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 7e2b5a88f..5ea131168 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -379,6 +379,8 @@ bool UIShellClass::Pointer_Owned(void) const } +// An arrow request is applied as well: the game keeps its own shape captured under a +// screen, and in the frontend that shape is blank. bool UIShellClass::Handle_Set_Cursor(void) { if (!Ready || !Pointer_Owned()) { @@ -386,28 +388,25 @@ bool UIShellClass::Handle_Set_Cursor(void) } UICursor request = System->Cursor_Request(); - if (request == UI_CURSOR_ARROW) { - return(false); - } - Host.Apply_Cursor(request); AppliedCursor = request; return(true); } -// A hover that changed the request shows the new shape at once rather than at the next -// WM_SETCURSOR, which only a pointer move brings. +// A screen that opened or a hover that changed the request shows the new shape at once +// rather than at the next WM_SETCURSOR, which only a pointer move brings. void UIShellClass::Apply_Cursor_Request(void) { - UICursor request = Pointer_Owned() ? System->Cursor_Request() : UI_CURSOR_ARROW; - if (request == AppliedCursor) { + if (!Pointer_Owned()) { + if (AppliedCursor.has_value()) { + Restore_Cursor(); + } return; } - if (request == UI_CURSOR_ARROW) { - Restore_Cursor(); - } else { + UICursor request = System->Cursor_Request(); + if (AppliedCursor != request) { Host.Apply_Cursor(request); AppliedCursor = request; } @@ -416,7 +415,7 @@ void UIShellClass::Apply_Cursor_Request(void) void UIShellClass::Restore_Cursor(void) { - AppliedCursor = UI_CURSOR_ARROW; + AppliedCursor.reset(); Host.Restore_Game_Cursor(); } @@ -557,6 +556,9 @@ void UIShellClass::Shutdown(void) } Input.Reset(); Reset_Text(); + if (AppliedCursor.has_value()) { + Restore_Cursor(); + } // The documents go while the context still exists; a caller hiding its notice // afterwards finds nothing to do. @@ -1085,7 +1087,7 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s std::snprintf(label, sizeof(label), "%s closed", view.Name()); Render->Log_Resource_Counts(label); System->Reset_Cursor_Request(); - Restore_Cursor(); + Apply_Cursor_Request(); Host.Clear_Keyboard_Queue(); Host.Focus_Main_Window(); } diff --git a/code/ui/uishell.h b/code/ui/uishell.h index 9882c175e..488cf1a0c 100644 --- a/code/ui/uishell.h +++ b/code/ui/uishell.h @@ -20,6 +20,7 @@ #include #include #include +#include #include namespace Rml @@ -187,8 +188,9 @@ class UIShellClass UIUTF8DecoderClass Utf8; unsigned char LegacyLead = 0; - // The shape last put on the pointer for the documents, if any. - UICursor AppliedCursor = UI_CURSOR_ARROW; + // The shape put on the pointer for the documents, or nothing while the game's + // pointer is in charge. + std::optional AppliedCursor; // The modal screens the runner is driving, innermost last, and whether the // innermost is between releasing its document and handing the input back. diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index c0cbf6adc..ed7fea826 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -415,12 +415,16 @@ The shell clears the keyboard queue when a modal document opens and again after it is released, so the pump inside `Keyboard->Clear()` meets either the shown screen or the ownership table, never a screen mid-teardown. Focus loss cancels capture, drags, and composition; focus return does not replay -held keys as presses. A document's pointer request (`text`, `pointer`, -`move`, `not-allowed`) shows the matching system pointer while the pointer -is the documents': a screen is shown, a document holds a press, or the -pointer is over an element that takes it. The game's own pointer returns -when a screen closes. The clipboard interface exchanges Unicode text with -the Win32 clipboard and refuses malformed text rather than repairing it. +held keys as presses. While the pointer is the documents', because a screen +is shown, a document holds a press, or the pointer is over an element that +takes it, the shell answers `WM_SETCURSOR`: a document's request (`text`, +`pointer`, `move`, `not-allowed`) shows the matching system pointer, and +otherwise the window's arrow, which is what the Win32 dialogs show. The +game's own shape stays captured under a screen and is blank in the +frontend, so the answer is never left to it. The game's pointer returns +when the pointer is no longer the documents'. The clipboard interface +exchanges Unicode text with the Win32 clipboard and refuses malformed text +rather than repairing it. Text arrives as `WM_CHAR`. The main window is a narrow window, so under the UTF-8 code page each message carries one byte and the shell decodes the diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index d1b0a6c77..b1461cc9e 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -2622,17 +2622,24 @@ void Test_Shell(void) { UIVersionPresenterClass presenter({ "cursor" }); std::unique_ptr view = UI_Version_View(presenter); + int applied = host.Applied; int restored = host.Restored; + bool opened = false; + bool arrow = false; bool requested = false; bool shown = false; shell.Run_Modal(*view, [&](void) { + opened = host.Applied > applied && host.LastCursor == UI_CURSOR_ARROW && host.Restored == restored; + arrow = shell.Handle_Set_Cursor() && host.LastCursor == UI_CURSOR_ARROW; fixture.System->SetMouseCursor("text"); requested = fixture.System->Cursor_Request() == UI_CURSOR_TEXT; shown = shell.Handle_Set_Cursor() && host.LastCursor == UI_CURSOR_TEXT; Send(shell, WM_KEYDOWN, VK_ESCAPE); return(false); }); + Check(opened, "opening a screen puts the window's arrow on the pointer before any request"); + Check(arrow, "WM_SETCURSOR over a shown screen is the screen's even with no request"); Check(requested, "a document's pointer request is kept"); Check(shown, "WM_SETCURSOR shows the requested shape while a screen is shown"); Check(host.Restored - restored == 1 && fixture.System->Cursor_Request() == UI_CURSOR_ARROW && host.LastCursor == UI_CURSOR_ARROW, "closing a screen puts the game's pointer back once and forgets the request"); From e89a93c7df4c51913c6679d4aa682beb9032933b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 04:25:39 +0300 Subject: [PATCH 36/52] Drain a screen's intents before its model reaches the document --- code/ui/uishell.cpp | 5 ++- docs/UI_DESIGN.md | 20 +++++++----- tests/uishell/uishell.cpp | 64 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 5ea131168..9eb7db648 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -1039,13 +1039,15 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s UIResult result = UI_RESULT_SESSION_ENDED; + // The intents a pass pumped drain before the update that pushes the model, or the push + // writes the old level back onto a slider and its change event queues that level after + // the player's. while (true) { bool ended = service(); if (!Ready) { break; } - Tick(); view.Presenter().Refresh(); view.Presenter().Drain(); view.Sync(); @@ -1058,6 +1060,7 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s break; } + Tick(); Host.Mark_Overlay_Dirty(); Host.Present_If_Dirty(); } diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index ed7fea826..63edb84e1 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -542,8 +542,8 @@ UIResult UIShellClass::Run_Modal(UIViewClass & view, UIServiceCallback const & s // service(); -- the engine passes UI_Service_Game: Windows_Message_Handler(), // then Main_Loop() in a network session, else Call_Back(); // a test passes whatever it wants pumped +// refresh the presenter, execute the screen's queued intents, sync the model; // context->Update(); -// execute the screen's queued intents; // mark the overlay dirty, Video_Present_If_Dirty(); // until the screen has a result or the service reports the game ended. ``` @@ -559,13 +559,17 @@ paths: the main menu keeps title-screen maintenance, an in-game screen keeps the guarded multiplayer pump, lobby and loading flows keep their own work. Event handlers never act directly. A toolkit event queues an intent, and the -runner executes the queue after `Context::Update` returns. RmlUi gives no -guarantee about re-entering `Update` from its own event dispatch, so a nested -modal (options opening a message box) starts from the queue, one level up, -where `Run_Modal` nests cleanly; and the legacy code already works this way, -`WM_COMMAND` writing `rc` for the driver to act on after the pump. A modal -document is shown with RmlUi's modal flag, which keeps other documents from -taking focus; blocking the game's input is the shell's job through the hook. +runner executes the queue before the `Context::Update` that pushes the model +into the documents, so the push carries what the player just changed. A push +that lagged behind the queue would write the old level onto a slider, whose +own change event would then queue that level after the player's. RmlUi gives +no guarantee about re-entering `Update` from its own event dispatch, so a +nested modal (options opening a message box) starts from the queue, one level +up, where `Run_Modal` nests cleanly; and the legacy code already works this +way, `WM_COMMAND` writing `rc` for the driver to act on after the pump. A +modal document is shown with RmlUi's modal flag, which keeps other documents +from taking focus; blocking the game's input is the shell's job through the +hook. Paint handlers and the pump never drain intents, advance game logic, or update the context; a nested update or present request is recorded and served at the next safe point. diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index b1461cc9e..c394865c1 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -2646,6 +2646,70 @@ void Test_Shell(void) Check(!shell.Handle_Set_Cursor(), "WM_SETCURSOR is the game's again once nothing is shown"); } + { + // A slider dragged through the hook keeps the level the player left it at across the + // passes that follow. The drag waits for the second pass, the first whose update + // follows a sync. + RecordingSoundServiceClass service; + UISoundState state; + state.Score = 5; + state.Sound = 5; + state.Voice = 5; + state.Enabled = true; + UISoundPresenterClass presenter(service, state); + std::unique_ptr view = UI_Sound_View(presenter); + int passes = 0; + int dragged = -1; + bool consumed = false; + bool held = false; + bool kept = false; + + shell.Run_Modal(*view, [&](void) { + passes++; + Rml::Element * score = Rml(*view).Document()->GetElementById("score"); + if (passes == 2 && score != nullptr) { + // The slider builds its track and bar as non-DOM children, which a tag search skips. + Rml::Element * barelement = nullptr; + Rml::Element * trackelement = nullptr; + for (int index = 0; index < score->GetNumChildren(true); index++) { + Rml::Element * child = score->GetChild(index); + if (child->GetTagName() == "sliderbar") { + barelement = child; + } else if (child->GetTagName() == "slidertrack") { + trackelement = child; + } + } + if (barelement != nullptr && trackelement != nullptr) { + Rml::Vector2f bar = barelement->GetAbsoluteOffset(Rml::BoxArea::Border) + barelement->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; + Rml::Vector2f track = trackelement->GetAbsoluteOffset(Rml::BoxArea::Border); + int right = (int)(track.x + trackelement->GetBox().GetSize(Rml::BoxArea::Border).x) + 40; + service.Calls.clear(); + consumed = Send(shell, WM_MOUSEMOVE, 0, MAKELPARAM((int)bar.x, (int)bar.y)); + host.Down[VK_LBUTTON] = true; + consumed = Send(shell, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM((int)bar.x, (int)bar.y)) && consumed; + consumed = Send(shell, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(right, (int)bar.y)) && consumed; + host.Down[VK_LBUTTON] = false; + consumed = Send(shell, WM_LBUTTONUP, 0, MAKELPARAM(right, (int)bar.y)) && consumed; + dragged = score->GetAttribute("value", -1); + } + } + if (passes == 3 && score != nullptr) { + held = presenter.State.Score == dragged && score->GetAttribute("value", -1) == dragged; + } + if (passes == 4 && score != nullptr) { + kept = presenter.State.Score == dragged && score->GetAttribute("value", -1) == dragged; + Send(shell, WM_KEYDOWN, VK_ESCAPE); + } + return(false); + }); + Send(shell, WM_KEYUP, VK_ESCAPE); + shell.Tick(); + + Check(consumed && dragged == UISoundPresenterClass::LEVELS, "a drag to the end of the music slider reaches the document and lands on the top level"); + Check(held, "the level a drag left is what the model holds after the pass"); + Check(kept && service.Calls.size() == 1, "the level stays put on the next pass and previews once"); + } + { UIWaitBoxPresenterClass presenter("Working", false); std::unique_ptr view = UI_Wait_Box_View(presenter); From b3d0ae5c33a3faadc02daee20db7e52193d20fab Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 04:25:39 +0300 Subject: [PATCH 37/52] Give each team command its own name and description storage --- code/init.cpp | 78 +++++++++++++--------- code/ui/screens/keyboard/uikeyboarddlg.cpp | 8 ++- 2 files changed, 52 insertions(+), 34 deletions(-) diff --git a/code/init.cpp b/code/init.cpp index a1abf9402..65437c970 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -202,6 +202,7 @@ #include #include #include +#include #include #include @@ -3261,7 +3262,17 @@ void Draw_Version_Text(Surface * surface) } -static char _cmd_buffer[128]; +// A team command builds its names from its number. Each instance keeps its own copies, +// because one shared buffer cannot answer two of these accessors at once. +static char const * Team_Command_String(std::string & cache, char const * format, int team) +{ + if (cache.empty()) { + char buffer[128]; + snprintf(buffer, sizeof(buffer), format, team); + cache = buffer; + } + return(cache.c_str()); +} static void Select_Team_Members(int team) @@ -3300,19 +3311,16 @@ class CreateTeamCommandClass : public CommandClass CreateTeamCommandClass(int team) : Team(team) {} virtual char const * Get_Unique_Name(void) const { - sprintf(_cmd_buffer, "TeamCreate_%d", Team); - return(_cmd_buffer); + return(Team_Command_String(UniqueName, "TeamCreate_%d", Team)); } virtual char const * Get_Display_Name(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_CREATE_TEAM), Team); - return(_cmd_buffer); + return(Team_Command_String(DisplayName, Fetch_String(TXT_CREATE_TEAM), Team)); } virtual char const * Get_Category(void) const { return(Fetch_String((TXT_TEAM))); } virtual char const * Get_Description(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_CREATE_TEAM_DESC), Team); - return(_cmd_buffer); + return(Team_Command_String(Description, Fetch_String(TXT_CREATE_TEAM_DESC), Team)); } virtual void Execute(void) const { @@ -3321,6 +3329,10 @@ class CreateTeamCommandClass : public CommandClass private: int Team; + + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; }; @@ -3330,19 +3342,16 @@ class SelectTeamCommandClass : public CommandClass SelectTeamCommandClass(int team) : Team(team) {} virtual char const * Get_Unique_Name(void) const { - sprintf(_cmd_buffer, "TeamSelect_%d", Team); - return(_cmd_buffer); + return(Team_Command_String(UniqueName, "TeamSelect_%d", Team)); } virtual char const * Get_Display_Name(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_SELECT_TEAM), Team); - return(_cmd_buffer); + return(Team_Command_String(DisplayName, Fetch_String(TXT_SELECT_TEAM), Team)); } virtual char const * Get_Category(void) const { return(Fetch_String((TXT_TEAM))); } virtual char const * Get_Description(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_SELECT_TEAM_DESC), Team); - return(_cmd_buffer); + return(Team_Command_String(Description, Fetch_String(TXT_SELECT_TEAM_DESC), Team)); } virtual void Execute(void) const { @@ -3376,6 +3385,10 @@ class SelectTeamCommandClass : public CommandClass private: int Team; + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; + inline static int LastTeam = -1; inline static int LastTick = -1; }; @@ -3387,19 +3400,16 @@ class AddTeamCommandClass : public CommandClass AddTeamCommandClass(int team) : Team(team) {} virtual char const * Get_Unique_Name(void) const { - sprintf(_cmd_buffer, "TeamAddSelect_%d", Team); - return(_cmd_buffer); + return(Team_Command_String(UniqueName, "TeamAddSelect_%d", Team)); } virtual char const * Get_Display_Name(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_ADD_SELECT_TEAM), Team); - return(_cmd_buffer); + return(Team_Command_String(DisplayName, Fetch_String(TXT_ADD_SELECT_TEAM), Team)); } virtual char const * Get_Category(void) const { return(Fetch_String((TXT_TEAM))); } virtual char const * Get_Description(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_ADD_SELECT_TEAM_DESC), Team); - return(_cmd_buffer); + return(Team_Command_String(Description, Fetch_String(TXT_ADD_SELECT_TEAM_DESC), Team)); } virtual void Execute(void) const { @@ -3413,6 +3423,10 @@ class AddTeamCommandClass : public CommandClass private: int Team; + + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; }; @@ -3422,19 +3436,16 @@ class AddToTeamCommandClass : public CommandClass AddToTeamCommandClass(int team) : Team(team) {} virtual char const * Get_Unique_Name(void) const { - sprintf(_cmd_buffer, "TeamAddTo_%d", Team); - return(_cmd_buffer); + return(Team_Command_String(UniqueName, "TeamAddTo_%d", Team)); } virtual char const * Get_Display_Name(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_ADD_TO_TEAM), Team); - return(_cmd_buffer); + return(Team_Command_String(DisplayName, Fetch_String(TXT_ADD_TO_TEAM), Team)); } virtual char const * Get_Category(void) const { return(Fetch_String((TXT_TEAM))); } virtual char const * Get_Description(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_ADD_TO_TEAM_DESC), Team); - return(_cmd_buffer); + return(Team_Command_String(Description, Fetch_String(TXT_ADD_TO_TEAM_DESC), Team)); } virtual void Execute(void) const { @@ -3450,6 +3461,10 @@ class AddToTeamCommandClass : public CommandClass private: int Team; + + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; }; @@ -3459,19 +3474,16 @@ class CenterTeamCommandClass : public CommandClass CenterTeamCommandClass(int team) : Team(team) {} virtual char const * Get_Unique_Name(void) const { - sprintf(_cmd_buffer, "TeamCenter_%d", Team); - return(_cmd_buffer); + return(Team_Command_String(UniqueName, "TeamCenter_%d", Team)); } virtual char const * Get_Display_Name(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_CENTER_TEAM), Team); - return(_cmd_buffer); + return(Team_Command_String(DisplayName, Fetch_String(TXT_CENTER_TEAM), Team)); } virtual char const * Get_Category(void) const { return(Fetch_String((TXT_TEAM))); } virtual char const * Get_Description(void) const { - sprintf(_cmd_buffer, Fetch_String(TXT_CENTER_TEAM_DESC), Team); - return(_cmd_buffer); + return(Team_Command_String(Description, Fetch_String(TXT_CENTER_TEAM_DESC), Team)); } virtual void Execute(void) const { @@ -3493,6 +3505,10 @@ class CenterTeamCommandClass : public CommandClass private: int Team; + + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; }; diff --git a/code/ui/screens/keyboard/uikeyboarddlg.cpp b/code/ui/screens/keyboard/uikeyboarddlg.cpp index 79f67feb4..d3b5f7b94 100644 --- a/code/ui/screens/keyboard/uikeyboarddlg.cpp +++ b/code/ui/screens/keyboard/uikeyboarddlg.cpp @@ -115,13 +115,15 @@ void UI_Keyboard_State(UIKeyboardState & state) for (int index = 0; index < AllCommands.Count(); index++) { CommandClass const * command = AllCommands[index]; - char const * category = command->Get_Category(); - char const * name = command->Get_Display_Name(); - char const * description = command->Get_Description(); + // Each string is copied before the next is asked for, because an accessor may answer + // from storage that the next one reuses. UIHotkeyCommand entry; + char const * category = command->Get_Category(); entry.Category = (category != NULL) ? category : ""; + char const * name = command->Get_Display_Name(); entry.Name = (name != NULL) ? name : ""; + char const * description = command->Get_Description(); entry.Description = (description != NULL) ? description : ""; state.Commands.push_back(entry); } From 85a6f562c5c5ff878b7ff3ec05505628c8363717 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 04:26:06 +0300 Subject: [PATCH 38/52] Record portability as a goal for the UI subsystem --- docs/UI_DESIGN.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 63edb84e1..2b06edee4 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -114,6 +114,11 @@ Goals: legacy screen available behind a switch until OwnerDraw is retired. - Make screens interchangeable at the screen level: a model or presenter that knows nothing about the toolkit, and one view per toolkit. +- Keep the subsystem portable by preparation, the approach + [Project direction](DIRECTION.md) sets for the engine. Screen behavior, + the screen contract, input ownership, coordinate mapping, and the render + checks carry no operating system type, so a later port replaces the host + and the platform edge rather than the screens. - Leave GadgetClass and MSEngine in place; migrate them later through the same screen contract when a screen is worth it. The sidebar follows only after the Win32 dialogs are gone, as a player-selectable alternative to the @@ -135,6 +140,10 @@ Limits, chosen to keep the work bounded: - No arbitrary layering of native and GPU UI. The coexistence rule under [Input and focus](#input-and-focus) is the whole policy. - No user UI scale setting yet. Documents follow the frame scale. +- No platform abstraction layer. Windows is the only supported target, so the + shell's message hook and its host interface are written in Win32 terms. A + seam designed against one platform would be guesswork; the coupling is kept + where a port can find it instead, under [Portability](#portability). - The exception and assertion dialogs stay plain Win32. They must work when the renderer is the thing that failed. @@ -775,6 +784,33 @@ can follow the screen contract when someone wants them. | Localization | The UTF-8 transition owns the encoding change; the UI adds no conversion of its own. | | Mods and resources | Legacy asset semantics unchanged; document paths, binding names, event names, and the styling profile are experimental until versioned with the first supported override package. | | Build | Win32 and x64 MSVC with the static CRT for every new dependency; CI builds and tests both platforms. | +| Portability | No new operating system type outside the coupling listed under [Portability](#portability). | + +### Portability + +Windows is the supported target and the only platform the shell is written +for. Portability is a direction rather than a feature here: preparatory work +does not make another platform supported, and no platform layer is invented +before there is a second platform to validate it against. + +These carry no operating system type today, and a port keeps them as they +are: the presenters and their service interfaces, the screen contract, the +view interface, the input ownership table and its text decoding, the pointer +mapping, the render checks, and the bgfx renderer. + +The rest is written in Win32 terms. A port pays for it here: + +| Coupling | What a port costs | +| --- | --- | +| The shell's window message hook and its pumped-message intercept | The structural item. Messages become a neutral event at the platform edge, which rewrites one signature and the body behind it | +| The window handle on `UIShellHostClass` | Three uses: two identity comparisons and the clipboard's owner. An opaque handle would serve, and `uihost.h` would stop pulling `win.h` into everything that includes it | +| Key mapping in `code/ui/rml/rmlkeys.cpp` | Not only code. `KEYBOARD.INI` stores Windows virtual key numbers, so the mapping is also a data-format boundary | +| The clipboard in `rmlsystem.cpp` and the conversions in `uiunicode.cpp` | Replaceable in place; `tests/uishell` already covers the behavior | +| The developer overlay's input entry points | They follow whatever event type the hook adopts | +| `UIWaitBoxClass`'s window member | The only screen header that reaches Win32, and the one leak in the containment rule | + +New UI code adds no operating system type outside that list. A presenter, a +service, or a screen header that needs one has the wrong shape. ## Dependencies From ce4cec347634837eddb52ccde1afc0479d4064ae Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:15:12 +0300 Subject: [PATCH 39/52] Answer WM_SETCURSOR only when the game has a pointer to show --- code/wincursor.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/code/wincursor.cpp b/code/wincursor.cpp index 1f4a36975..5af742ba6 100644 --- a/code/wincursor.cpp +++ b/code/wincursor.cpp @@ -257,6 +257,12 @@ void Win_Cursor_Set_Visible(bool visible) { _CursorVisible = visible; + // Before play begins no shape has been built, so the game has no pointer to show or + // hide and the window's own stands. + if (_CurrentCursor == NULL) { + return; + } + if (MouseCursor != NULL && MouseCursor->Is_Captured()) { SetCursor(visible ? _CurrentCursor : NULL); } @@ -266,14 +272,21 @@ void Win_Cursor_Set_Visible(bool visible) /// /// Puts the game's pointer back after Windows has asked what the cursor should be. /// -/// bool; Was the cursor the game's to choose? While a dialog has the mouse it -/// is not, and Windows keeps its own arrow. +/// bool; Was the cursor the game's to choose? While a dialog has the mouse, or +/// before the game has built a pointer of its own, it is not, and Windows keeps its own +/// arrow. bool Win_Cursor_Handle_Set_Cursor(void) { if (MouseCursor == NULL || !MouseCursor->Is_Captured()) { return(false); } + // With no shape built there is nothing to put back, so the caller falls through to the + // window's own pointer rather than being left with a blank one. + if (_CurrentCursor == NULL) { + return(false); + } + SetCursor(_CursorVisible ? _CurrentCursor : NULL); return(true); } From 1b9caea8a6fb269eebdbe56705103ebb747f034c Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:15:24 +0300 Subject: [PATCH 40/52] Tick the UI shell while the graphical menu is up --- code/grphmenu.cpp | 6 ++++++ code/ui/uishell.cpp | 3 --- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/code/grphmenu.cpp b/code/grphmenu.cpp index 62d4b4f9a..ae0a30cec 100644 --- a/code/grphmenu.cpp +++ b/code/grphmenu.cpp @@ -7,6 +7,8 @@ * See LICENSE.md for applicable additional terms and warranty disclaimers. ******************************************************************************/ +#include "_ui.h" +#include "ui/uishell.h" #include "always.h" #include "grphmenu.h" @@ -217,6 +219,10 @@ int GraphicMenu::Presentation(void) } } + // The menu runs its own loop, so nothing else advances a document or an overlay + // while it is up. + UIShell.Tick(); + Engine.Wait_Delay(1); } diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 9eb7db648..8cf55c17b 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -678,9 +678,6 @@ void UIShellClass::Render_Overlay(void) bool documents = Documents_Visible(); bool overlays = UIDev_Active(); - if (!documents && !overlays) { - return; - } if (documents) { UIReentryGuardClass rendering(InContext); From be3fb6379112107b7db27b0792c94e9ad828a093 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:15:57 +0300 Subject: [PATCH 41/52] Ignore a press the shell quarantined while it was held --- code/ui/uishell.cpp | 8 ++++++++ tests/uishell/uishell.cpp | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 8cf55c17b..37f431332 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -723,6 +723,14 @@ bool UIShellClass::Handle_Button_Down(int button, LPARAM clientlparam) { Input.Reconcile_Cancelled_Mouse(Physical_Buttons()); + // A button quarantined or cancelled while it was held keeps that state until it comes + // up. Showing the press to a document would leave a control pressed whose release is + // swallowed, which is what a click that activates the window used to do. + if (Input.Mouse_Owner((unsigned)button) == UI_INPUT_SUPPRESSED) { + Host.Mark_Overlay_Dirty(); + return(true); + } + UIPointerPosition position = Pointer_Position(clientlparam); int modifiers = Key_Modifiers(); bool haduimouse = Input.Has_UI_Mouse(); diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index c394865c1..96d994dc5 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -2710,6 +2710,49 @@ void Test_Shell(void) Check(kept && service.Calls.size() == 1, "the level stays put on the next pass and previews once"); } + { + // A click that brings the window forward arrives with the button already down, so the + // activation quarantines it. The document must not see that press at all. + UIMessageBoxPresenterClass presenter("Quarantine", { "OK", "Cancel", "" }, 0); + std::unique_ptr view = UI_Message_Box_View(presenter); + int passes = 0; + bool consumed = false; + bool unpressed = false; + bool answered = false; + + shell.Run_Modal(*view, [&](void) { + passes++; + Rml::ElementList buttons; + Rml(*view).Document()->GetElementsByTagName(buttons, "button"); + if (buttons.empty()) { + return(true); + } + Rml::Vector2f centre = buttons[0]->GetAbsoluteOffset(Rml::BoxArea::Border) + buttons[0]->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; + LPARAM at = MAKELPARAM((int)centre.x, (int)centre.y); + + if (passes == 2) { + host.Down[VK_LBUTTON] = true; + Send(shell, WM_ACTIVATEAPP, 1, 0); + consumed = Send(shell, WM_LBUTTONDOWN, MK_LBUTTON, at); + unpressed = !buttons[0]->IsPseudoClassSet("active"); + host.Down[VK_LBUTTON] = false; + Send(shell, WM_LBUTTONUP, 0, at); + } + if (passes == 3) { + answered = !presenter.Result.has_value(); + Send(shell, WM_MOUSEMOVE, 0, at); + Send(shell, WM_LBUTTONDOWN, MK_LBUTTON, at); + Send(shell, WM_LBUTTONUP, 0, at); + } + return(false); + }); + + Check(consumed, "a press quarantined by the window activating is kept from the game"); + Check(unpressed, "that press never reaches the document, so nothing is left pressed"); + Check(answered, "and it answers nothing"); + Check(presenter.Result.has_value() && presenter.Choice == 0, "the next click on the same button answers normally"); + } + { UIWaitBoxPresenterClass presenter("Working", false); std::unique_ptr view = UI_Wait_Box_View(presenter); From 5393ec13da37a83330939cd1995aad9b445912a9 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:16:08 +0300 Subject: [PATCH 42/52] Repaint the menu after a display mode reverts --- code/mainopt.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/code/mainopt.cpp b/code/mainopt.cpp index a70fbb954..2c4cdf6bb 100644 --- a/code/mainopt.cpp +++ b/code/mainopt.cpp @@ -357,6 +357,9 @@ bool Test_Display_Mode_Dialog(int width, int height) DebugString("Resetting display mode @ %dx%d\n", Options.ScreenWidth, Options.ScreenHeight); Change_Display_Mode(Options.ScreenWidth, Options.ScreenHeight); LogicalSurface = HiddenSurface; + + // The mode change leaves the frame blank and the screen that follows draws over it. + Draw_Menu_Background(); return(false); } From 200d6e476d32ec43161ff3063b281ed0c586007b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:16:08 +0300 Subject: [PATCH 43/52] Restore the space before the countdown's unit --- ui/confirm.rml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/confirm.rml b/ui/confirm.rml index c1a72d3ae..7155f1bea 100644 --- a/ui/confirm.rml +++ b/ui/confirm.rml @@ -6,7 +6,7 @@
-

Click OK to keep this display mode. Your old display settings will be restored in {{seconds}} secondseconds.

+

Click OK to keep this display mode. Your old display settings will be restored in {{seconds}} second seconds.

From 832b6b3107013ca9a0ea0516923c637e0b1be68f Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:16:58 +0300 Subject: [PATCH 44/52] Report a notice shown beside the game in the debug log --- code/ui/uishell.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 37f431332..ed8871c7f 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -1137,15 +1137,29 @@ bool UIShellClass::Show_Modeless(UIViewClass & view) view.Show(false); Modeless.push_back(&view); Refresh(); + + char label[160]; + std::snprintf(label, sizeof(label), "%s shown beside the game", view.Name()); + Render->Log_Resource_Counts(label); return(true); } void UIShellClass::Hide_Modeless(UIViewClass & view) { - Modeless.erase(std::remove(Modeless.begin(), Modeless.end(), &view), Modeless.end()); + // A caller hides its notice whether or not one was ever shown, so only a listed view + // is worth reporting. + auto const unlisted = std::remove(Modeless.begin(), Modeless.end(), &view); + bool const listed = unlisted != Modeless.end(); + Modeless.erase(unlisted, Modeless.end()); view.Release(); + if (listed && Ready) { + char label[160]; + std::snprintf(label, sizeof(label), "%s hidden", view.Name()); + Render->Log_Resource_Counts(label); + } + if (Ready && !InContext) { { UIReentryGuardClass updating(InContext); From f6d8d8f183552cae10f8c4431550cb2cd49c61ba Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:16:58 +0300 Subject: [PATCH 45/52] Correct what the plan says the abort dialog uses --- docs/UI_DESIGN.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 2b06edee4..e64effa05 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -893,8 +893,9 @@ beyond an ASCII test document. reloaded the file on Cancel, and `keyboard.rml` captures a key through a focusable element that `rml/rmlkeys.cpp` turns back into the `KEYBOARD.INI` number; the options menu is `mainopt.rml`, placed where the main menu's - buttons were; abort and surrender already run through the message box - screen). The Win32 templates remain the fallback view of every one. The + buttons were; surrender runs through the message box screen, while abort + keeps its own `IDD_MISSION_ABORT` template and its three answers). The + Win32 templates remain the fallback view of every one. The in-game options menu opens load, save and delete, so it follows step 9. Evidence: settings round-trip through `SUN.INI` unchanged. 8. **Main menu family** (M). `IDD_MAIN_MENU`, campaign choice, game type, From d3b3ee29d9ffde379b98dd481b50e069afbfc36c Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:24:58 +0300 Subject: [PATCH 46/52] Record what the runtime pass exercised on each platform --- docs/UI_DESIGN.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index e64effa05..c5e72d41d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -873,7 +873,8 @@ beyond an ASCII test document. with a `data-if` for the in-game half). The behavior pilot: volumes, eligible themes, selection, availability, shuffle and repeat, immediate previews, play and stop, both templates, frontend and in-game service - paths. Runtime evidence still owed. + paths. Runtime evidence so far is under + [What has been exercised](#what-has-been-exercised). 6. **Progress and wait** (S, leaf, two changes, landed: milestone effects moved out of drawing, then `UIWaitBoxClass` over `wait.rml` for the saving and loading boxes and the progress dialog, with the Win32 boxes kept @@ -974,6 +975,36 @@ composition or state the limitation. No performance target is asserted before measurement; idle CPU, update time, submission cost, and texture and geometry memory are recorded on an agreed baseline before defaults change. +### What has been exercised + +A pass by hand on 13 September 2026 drove the migrated screens from the +frontend on both platforms, and from a skirmish on `Win32`. The two platforms +behaved alike. The debug log names each document as it opens and closes, so +the table below is what the logs of that pass contain. + +| Screen | `Win32` | `x64` | +| --- | --- | --- | +| Version | yes | not yet | +| Message box, raised over the keyboard screen by the hotkey reset | yes | yes | +| Sound, frontend and in game | yes | not yet | +| Game controls | frontend and in game | frontend | +| Display, with the mode confirmation, its countdown and its timeout | yes | yes | +| Keyboard, with key capture, reset, and every category | yes | yes | +| Options menu | yes | yes | +| Progress and wait | never shown | never shown | + +The pass found eight defects. The two with the widest effect were in the +shell rather than in any one screen: the pointer shape, and the absence of a +tick while the graphical menu is up, which left every document and overlay +frozen there. A screen that looks right is therefore not evidence that the +shell is. + +Still owed: `wait.rml`, whose notice is raised only for as long as a +synchronous save runs and which no run has yet shown; the progress document, +which belongs to the multiplayer loading, map generation, and file transfer +paths; the multiplayer cases where `Main_Loop` runs under a message box; and, +for each screen, what the paragraph above requires of its own change. + ## Documentation - This page owns the architecture and is updated as steps land. From d572e8d667bc54b1d9a5df2c479e35cf9e330ea6 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:32:42 +0300 Subject: [PATCH 47/52] Present a notice at once instead of when the interval allows --- code/ui/uienginehost.cpp | 5 +++++ code/ui/uihost.h | 3 +++ code/ui/uishell.cpp | 7 +++++-- code/video.cpp | 15 +++++++++++++++ code/video.h | 1 + docs/UI_DESIGN.md | 6 ++++-- tests/uishell/uishell.cpp | 18 ++++++++++++++++++ 7 files changed, 51 insertions(+), 4 deletions(-) diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index a064572ac..6300f9d5a 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -66,6 +66,11 @@ class UIEngineHostClass : public UIShellHostClass Video_Present_If_Dirty(); } + virtual void Present_Now(void) override + { + Video_Present_Now(); + } + virtual bool Movie_Playing(void) const override { return(Movie_Is_Playing()); diff --git a/code/ui/uihost.h b/code/ui/uihost.h index 67ee03a09..1a775416b 100644 --- a/code/ui/uihost.h +++ b/code/ui/uihost.h @@ -40,6 +40,9 @@ class UIShellHostClass // The overlay changed and should be drawn at the next present. virtual void Mark_Overlay_Dirty(void) = 0; virtual void Present_If_Dirty(void) = 0; + // Presents whatever the interval since the last one, for a caller that will not + // pump again before it blocks. + virtual void Present_Now(void) = 0; virtual bool Movie_Playing(void) const = 0; // A Win32 dialog is on screen and takes the mouse before a document can. diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index ed8871c7f..63befe5f9 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -1166,7 +1166,7 @@ void UIShellClass::Hide_Modeless(UIViewClass & view) Context->Update(); } Host.Mark_Overlay_Dirty(); - Host.Present_If_Dirty(); + Host.Present_Now(); } } @@ -1186,7 +1186,10 @@ void UIShellClass::Refresh(void) Tick(); Host.Mark_Overlay_Dirty(); - Host.Present_If_Dirty(); + + // The caller of a notice works without pumping, so the present interval must not be + // what decides whether its notice was ever drawn. + Host.Present_Now(); } diff --git a/code/video.cpp b/code/video.cpp index a451faa0e..aabc72565 100644 --- a/code/video.cpp +++ b/code/video.cpp @@ -392,6 +392,21 @@ void Video_Present_If_Dirty(void) } +/// +/// Puts the frame on the screen now, however recently the last one went out. +/// A caller that shows a notice and then works without pumping gets no second chance, so +/// the interval must not decide whether its notice was ever drawn. +/// +void Video_Present_Now(void) +{ + if (!_Dirty.Is_Dirty()) { + return; + } + + Present(); +} + + /// /// Reports where the game's frame is drawn inside the window. /// diff --git a/code/video.h b/code/video.h index 8b6cd6a3e..54705ad0d 100644 --- a/code/video.h +++ b/code/video.h @@ -51,6 +51,7 @@ void Video_Mark_Dirty(void); void Video_Mark_Overlay_Dirty(void); void Video_Present(void); void Video_Present_If_Dirty(void); +void Video_Present_Now(void); VideoScaleInfo const & Video_Get_Scale_Info(void); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index c5e72d41d..568a36776 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -587,8 +587,10 @@ Non-modal documents are updated by a `UI_Tick` call in `Main_Loop` next to `Map.Input`, and by one at the end of each pass of the legacy dialog driver so that a document stays alive under a menu, and are rendered by every present. A notice a caller shows while it works goes through -`UI_Show_Modeless`, `UI_Refresh` and `UI_Hide_Modeless`, which tick and -present at once because such a caller pumps nothing. +`Show_Modeless`, `Refresh` and `Hide_Modeless`, which tick and present at +once because such a caller pumps nothing. That present ignores the interval +between frames: the caller gets no second chance, so a notice raised soon +after the last frame would otherwise never be drawn at all. Teardown order: mark the screen closing and invalidate its token, then drop focus and capture and discard its intents, then detach listeners and data diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 96d994dc5..b689c2bf9 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -288,6 +288,7 @@ class TestHostClass : public UIShellHostClass unsigned int CodePage = 65001; bool Down[256] = {}; int Presents = 0; + int PresentsNow = 0; int Clears = 0; int Focuses = 0; UIShellClass * Shell = nullptr; @@ -346,6 +347,15 @@ class TestHostClass : public UIShellHostClass } } + virtual void Present_Now(void) override + { + Presents++; + PresentsNow++; + if (Shell != nullptr) { + Shell->Render_Overlay(); + } + } + virtual bool Movie_Playing(void) const override { return(false); @@ -2757,11 +2767,19 @@ void Test_Shell(void) UIWaitBoxPresenterClass presenter("Working", false); std::unique_ptr view = UI_Wait_Box_View(presenter); + int const presentsnow = host.PresentsNow; Check(shell.Show_Modeless(*view), "a notice shows beside the game"); Check(shell.Is_Modeless_Shown(*view) && view->Is_Shown(), "the shell lists the notice while it shows"); Check(!shell.Screen_Shown(), "a notice is not a screen"); + + // Its caller then works without pumping, so the frame it is drawn into cannot be + // left to the present interval. + Check(host.PresentsNow > presentsnow, "showing a notice presents at once rather than when the interval allows"); + + int const hidden = host.PresentsNow; shell.Hide_Modeless(*view); Check(!shell.Is_Modeless_Shown(*view) && !view->Is_Shown(), "hiding the notice unlists it"); + Check(host.PresentsNow > hidden, "and taking it away presents at once too"); Check(shell.Show_Modeless(*view), "the notice shows again"); shell.Shutdown(); From 9e029ebf521e62f5abd0d155380df96118a8aac8 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:34:20 +0300 Subject: [PATCH 48/52] Record the wait notice as exercised --- docs/UI_DESIGN.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 568a36776..0c5243eab 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -993,7 +993,8 @@ the table below is what the logs of that pass contain. | Display, with the mode confirmation, its countdown and its timeout | yes | yes | | Keyboard, with key capture, reset, and every category | yes | yes | | Options menu | yes | yes | -| Progress and wait | never shown | never shown | +| Wait notice, over eleven consecutive quicksaves | yes | not yet | +| Progress, with its bar | never shown | never shown | The pass found eight defects. The two with the widest effect were in the shell rather than in any one screen: the pointer shape, and the absence of a @@ -1001,11 +1002,10 @@ tick while the graphical menu is up, which left every document and overlay frozen there. A screen that looks right is therefore not evidence that the shell is. -Still owed: `wait.rml`, whose notice is raised only for as long as a -synchronous save runs and which no run has yet shown; the progress document, -which belongs to the multiplayer loading, map generation, and file transfer -paths; the multiplayer cases where `Main_Loop` runs under a message box; and, -for each screen, what the paragraph above requires of its own change. +Still owed: the progress document, which belongs to the multiplayer loading, +map generation, and file transfer paths; the multiplayer cases where +`Main_Loop` runs under a message box; and, for each screen, what the paragraph +above requires of its own change. ## Documentation From d2d8710118cb9c0bca0495462508ce54141a5154 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:34:47 +0300 Subject: [PATCH 49/52] Name the shell defects the pass found instead of counting them --- docs/UI_DESIGN.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 0c5243eab..c73564036 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -996,11 +996,11 @@ the table below is what the logs of that pass contain. | Wait notice, over eleven consecutive quicksaves | yes | not yet | | Progress, with its bar | never shown | never shown | -The pass found eight defects. The two with the widest effect were in the -shell rather than in any one screen: the pointer shape, and the absence of a -tick while the graphical menu is up, which left every document and overlay -frozen there. A screen that looks right is therefore not evidence that the -shell is. +The defects the pass found were mostly in the shell rather than in any one +screen: the pointer shape, the absence of a tick while the graphical menu is +up, which left every document and overlay frozen there, and a notice the +present interval could swallow before it was ever drawn. A screen that looks +right is therefore not evidence that the shell is. Still owed: the progress document, which belongs to the multiplayer loading, map generation, and file transfer paths; the multiplayer cases where From b2ce7472694ca408ad6bd797af90a3b1814f03e8 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 05:49:07 +0300 Subject: [PATCH 50/52] Use American spelling in the UI comments and documentation --- code/options.cpp | 2 +- code/ui/rml/rmlrender.cpp | 2 +- code/ui/rml/rmltexture.cpp | 2 +- code/ui/screens/keyboard/uikeyboard.h | 2 +- code/ui/screens/mainopt/uimainopt.cpp | 2 +- code/ui/screens/mainopt/uimainoptdlg.cpp | 2 +- code/ui/uishell.cpp | 4 ++-- docs/UI_DESIGN.md | 6 +++--- manual/content/formats/keyboard-ini.md | 2 +- tests/uishell/uishell.cpp | 8 ++++---- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/code/options.cpp b/code/options.cpp index 07dda045b..9026db6b6 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -668,7 +668,7 @@ static void Hotkey_Dialog_Fill(HWND window, UIKeyboardState const & state) /// Handles the messages for the keyboard configuration dialog. /// This routine seeds the category, command and hotkey controls from the keyboard presenter /// and hands the player's picks back to it. The presenter edits a copy of the assignments: -/// accepting the dialog saves the copy to KEYBOARD.INI, cancelling drops it. +/// accepting the dialog saves the copy to KEYBOARD.INI, canceling drops it. ///
/// Returns with TRUE if the message was consumed by this dialog. INT_PTR CALLBACK Hotkey_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) diff --git a/code/ui/rml/rmlrender.cpp b/code/ui/rml/rmlrender.cpp index 42ada2611..8ce236ff6 100644 --- a/code/ui/rml/rmlrender.cpp +++ b/code/ui/rml/rmlrender.cpp @@ -575,7 +575,7 @@ void UIRmlBgfxRenderClass::Destroy_ImGui_Textures(void) } -// ImGui rebuilds its geometry every frame, so it travels in transient buffers; its colours +// ImGui rebuilds its geometry every frame, so it travels in transient buffers; its colors // carry straight alpha, unlike the premultiplied documents. void UIRmlBgfxRenderClass::Render_ImGui(ImDrawData * data) { diff --git a/code/ui/rml/rmltexture.cpp b/code/ui/rml/rmltexture.cpp index eebf904c9..9a5d0ce2d 100644 --- a/code/ui/rml/rmltexture.cpp +++ b/code/ui/rml/rmltexture.cpp @@ -68,7 +68,7 @@ bool UI_Load_Image(char const * name, std::vector & rgba, int & w rgba.assign(pixels, pixels + (size_t)width * (size_t)height * 4); stbi_image_free(pixels); - // RmlUi composes premultiplied colour. + // RmlUi composes premultiplied color. for (size_t index = 0; index < rgba.size(); index += 4) { unsigned int alpha = rgba[index + 3]; if (alpha != 255) { diff --git a/code/ui/screens/keyboard/uikeyboard.h b/code/ui/screens/keyboard/uikeyboard.h index 657e4646e..99ab973ce 100644 --- a/code/ui/screens/keyboard/uikeyboard.h +++ b/code/ui/screens/keyboard/uikeyboard.h @@ -81,7 +81,7 @@ struct UIKeyboardState // Edits a copy of the hotkey table. Assigning gives the captured key to the selected command // and takes it from whichever command held it; an empty capture leaves the command unbound. -// Accepting saves the copy; cancelling drops it; a confirmed reset reloads it from the game. +// Accepting saves the copy; canceling drops it; a confirmed reset reloads it from the game. class UIKeyboardPresenterClass : public UIPresenterClass { public: diff --git a/code/ui/screens/mainopt/uimainopt.cpp b/code/ui/screens/mainopt/uimainopt.cpp index 82ddf1b2f..6bc608221 100644 --- a/code/ui/screens/mainopt/uimainopt.cpp +++ b/code/ui/screens/mainopt/uimainopt.cpp @@ -84,7 +84,7 @@ class UIMainOptionsViewClass : public UIRmlViewClass } // The menu sits where the main menu's buttons were, so the panel takes that edge over - // the centred position the style sheet gives it. + // the centered position the style sheet gives it. virtual void Loaded(void) override { Rml::Element * panel = Document()->GetElementById("panel"); diff --git a/code/ui/screens/mainopt/uimainoptdlg.cpp b/code/ui/screens/mainopt/uimainoptdlg.cpp index 380c2e888..789046bf0 100644 --- a/code/ui/screens/mainopt/uimainoptdlg.cpp +++ b/code/ui/screens/mainopt/uimainoptdlg.cpp @@ -22,7 +22,7 @@ #include "ui/uiview.h" -// The menu sits where the main menu's buttons were: a 400 pixel layout centred in the +// The menu sits where the main menu's buttons were: a 400 pixel layout centered in the // frame, with the buttons 147 pixels down it. void UI_Main_Options_State(UIMainOptionsState & state) { diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 63befe5f9..c6542be28 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -513,7 +513,7 @@ bool UIShellClass::Init(void) Rml::SetRenderInterface(Render.get()); if (!Rml::Initialise()) { - Log("UI: RmlUi did not initialise\n"); + Log("UI: RmlUi did not initialize\n"); Render->Shutdown(); return(false); } @@ -1199,7 +1199,7 @@ bool UIShellClass::Handle_Window_Message(HWND hwnd, UINT message, WPARAM wparam, return(false); } - // Another window taking the capture, or the system cancelling it, ends the presses the + // Another window taking the capture, or the system canceling it, ends the presses the // shell holds; the window no longer has the capture to give back. if (message == WM_CAPTURECHANGED || message == WM_CANCELMODE) { bool lost = (message == WM_CANCELMODE) || (HWND)lparam != Host.Main_Window(); diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index c73564036..3edfa7d52 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -689,7 +689,7 @@ view. ImGui is vendored as a submodule, compiled into Debug and Release, and rendered by a small bgfx adapter in `rml/rmlrender.cpp` that reuses the RmlUi renderer's program and view setup, on `VIEW_DEV`, with its own vertex layout -and straight-alpha blending, since ImGui's colours are not premultiplied. Its +and straight-alpha blending, since ImGui's colors are not premultiplied. Its geometry travels in transient buffers every frame, and its textures follow the pinned version's contract: the renderer answers each create, update, and destroy request and acknowledges it. The glyph atlas is created empty and @@ -869,7 +869,7 @@ beyond an ASCII test document. the modeless progress box of the save and load flows and moves to step 6. Runtime evidence still owed: the multiplayer cases where `Main_Loop` runs under the box. -5. **Sound** (M, two changes, landed: the behaviour sits behind +5. **Sound** (M, two changes, landed: the behavior sits behind `UISoundPresenterClass` and an engine service, the Win32 dialog drives it with the same calls in the same order, and `sound.rml` is the RmlUi view with a `data-if` for the in-game half). The behavior pilot: volumes, @@ -947,7 +947,7 @@ and builds `UIShellClass` itself over a host the test controls: - Map client positions into the overlay at integer and fractional scales, with letterboxing, exclusive edges, outside input, and the offset a captured pointer keeps outside. -- Run the shell: initialise over injected interfaces and again after a +- Run the shell: initialize over injected interfaces and again after a shutdown; drive a modal with a stub service to each result; consume a press pumped while a screen opens; defer a resize arriving inside a render; suppress what is held as a screen opens, what a lost capture cancels, and diff --git a/manual/content/formats/keyboard-ini.md b/manual/content/formats/keyboard-ini.md index b35c7b7bc..b56b13928 100644 --- a/manual/content/formats/keyboard-ini.md +++ b/manual/content/formats/keyboard-ini.md @@ -22,7 +22,7 @@ ScatterObject=88 ; X After the file loads, OpenTS clears the current hotkey table and adds entries whose command name is registered and whose keyboard identifier is not zero. Unknown command names and zero values are ignored, and a name has to match the registered spelling exactly, including its case. -The table is cleared only once the file has been read, so a file that is missing or that cannot be parsed leaves the bindings already in force rather than emptying them. OpenTS looks the file up through the ordinary file layer, so a loose `KEYBOARD.INI` in the game directory stands in for an archived one. The keyboard dialog writes it back as a loose file from the bindings the player accepted; cancelling the dialog drops its edits, and its reset control deletes the file and rebuilds the table without it. +The table is cleared only once the file has been read, so a file that is missing or that cannot be parsed leaves the bindings already in force rather than emptying them. OpenTS looks the file up through the ordinary file layer, so a loose `KEYBOARD.INI` in the game directory stands in for an archived one. The keyboard dialog writes it back as a loose file from the bindings the player accepted; canceling the dialog drops its edits, and its reset control deletes the file and rebuilds the table without it. A command that carries a forced binding is bound again once the file has been processed, taking that key back from whatever the file gave it. The file's own binding for that command is left alone, so it can end up answering to two keys. diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index b689c2bf9..39d05d0b7 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -500,7 +500,7 @@ bool References_Are_Bare(std::string const & text) void Test_FreeType(void) { FT_Library library = nullptr; - Check(FT_Init_FreeType(&library) == 0 && library != nullptr, "FreeType initialises a library"); + Check(FT_Init_FreeType(&library) == 0 && library != nullptr, "FreeType initializes a library"); if (library != nullptr) { FT_Int major = 0; @@ -2343,8 +2343,8 @@ void Test_Shell(void) UIShellClass & shell = fixture.Shell; TestHostClass & host = fixture.Host; - Check(!shell.Use_Rml(), "a shell not yet initialised opens no document"); - Check(shell.Init(), "the shell initialises over the injected interfaces"); + Check(!shell.Use_Rml(), "a shell not yet initialized opens no document"); + Check(shell.Init(), "the shell initializes over the injected interfaces"); Check(shell.Rml_Context() != nullptr, "the shell holds a context"); Check(shell.Use_Rml(), "documents are used while the host asks for no legacy dialogs"); host.LegacyRequested = true; @@ -2788,7 +2788,7 @@ void Test_Shell(void) Check(!shell.Use_Rml(), "a shut-down shell opens no document"); } - Check(shell.Init(), "the shell initialises again after a shutdown"); + Check(shell.Init(), "the shell initializes again after a shutdown"); shell.Shutdown(); Check(fixture.Render->ReleasedGeometry == fixture.Render->Compiled, "the shell releases every geometry it compiled"); From 0549e84f8e123e8467fbfe14c433c135d81aee47 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 06:01:34 +0300 Subject: [PATCH 51/52] Drive each input mechanism end to end through the shell's hook --- docs/UI_DESIGN.md | 7 + tests/uishell/uishell.cpp | 295 ++++++++++++++++++++++++++++++++++---- 2 files changed, 274 insertions(+), 28 deletions(-) diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 3edfa7d52..a6f6f7987 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -956,6 +956,13 @@ and builds `UIShellClass` itself over a host the test controls: decode two UTF-8 bytes into one character; round the clipboard through UTF-16; show and put back the pointer shape; list, unlist and release a modeless notice. +- Drive whole screens through the hook, one input mechanism each: a button on + the options menu, a mode row on the display options, a switch and a + backwards slider on the game controls, and a captured key on the keyboard + screen. Each is asserted the whole way, from the window message through the + intent and the service call to the model that comes back to the document. + The mode confirmation runs the same way over a clock the test moves, + because its result comes from a refresh rather than from an intent. `tests/uilogic` compiles the toolkit-free state with no UI library: the input ownership table and its cancellations, the UTF-8 decoder, the renderer's diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index 39d05d0b7..29688c0a5 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -463,6 +463,73 @@ bool Send(UIShellClass & shell, UINT message, WPARAM wparam = 0, LPARAM lparam = } +// The middle of an element's border box, in the document's own coordinates. +Rml::Vector2f Center_Of(Rml::Element * element) +{ + return(element->GetAbsoluteOffset(Rml::BoxArea::Border) + element->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f); +} + + +// Where the hook receives a press on an element. The host's frame is where the overlay sits +// inside the client area, so a document position becomes a client one through it. +LPARAM Element_Point(TestHostClass const & host, Rml::Element * element) +{ + Rml::Vector2f center = Center_Of(element); + return(MAKELPARAM((int)center.x + host.Rect.X, (int)center.y + host.Rect.Y)); +} + + +// A click on an element through the shell's hook, with the button state the shell polls kept +// in step with the messages it is sent. +void Click_Through_Hook(UIShellClass & shell, TestHostClass & host, Rml::Element * element) +{ + LPARAM at = Element_Point(host, element); + + Send(shell, WM_MOUSEMOVE, 0, at); + host.Down[VK_LBUTTON] = true; + Send(shell, WM_LBUTTONDOWN, MK_LBUTTON, at); + host.Down[VK_LBUTTON] = false; + Send(shell, WM_LBUTTONUP, 0, at); +} + + +// Drags a range input's bar past the end of its track, so the value lands on the far stop. +// A slider builds its bar and track as non-DOM children, which a tag search would skip. +// False means a message reached the game, or the slider had no parts to take hold of. +bool Drag_Slider_To_End(UIShellClass & shell, TestHostClass & host, Rml::Element * slider) +{ + Rml::Element * bar = nullptr; + Rml::Element * track = nullptr; + + for (int index = 0; index < slider->GetNumChildren(true); index++) { + Rml::Element * child = slider->GetChild(index); + if (child->GetTagName() == "sliderbar") { + bar = child; + } else if (child->GetTagName() == "slidertrack") { + track = child; + } + } + + if (bar == nullptr || track == nullptr) { + return(false); + } + + Rml::Vector2f from = Center_Of(bar); + int const y = (int)from.y + host.Rect.Y; + int const past = (int)(track->GetAbsoluteOffset(Rml::BoxArea::Border).x + track->GetBox().GetSize(Rml::BoxArea::Border).x) + host.Rect.X + 40; + LPARAM const start = MAKELPARAM((int)from.x + host.Rect.X, y); + LPARAM const end = MAKELPARAM(past, y); + + bool consumed = Send(shell, WM_MOUSEMOVE, 0, start); + host.Down[VK_LBUTTON] = true; + consumed = Send(shell, WM_LBUTTONDOWN, MK_LBUTTON, start) && consumed; + consumed = Send(shell, WM_MOUSEMOVE, MK_LBUTTON, end) && consumed; + host.Down[VK_LBUTTON] = false; + consumed = Send(shell, WM_LBUTTONUP, 0, end) && consumed; + return(consumed); +} + + std::string Read_Text(std::filesystem::path const & path) { std::ifstream stream(path, std::ios::binary); @@ -1277,7 +1344,7 @@ void Test_Version_Screen(Rml::Context & context, CountingSystemInterfaceClass & Check(ok != nullptr, "the version screen has its OK button"); if (ok != nullptr) { - Rml::Vector2f at = ok->GetAbsoluteOffset(Rml::BoxArea::Border) + ok->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; + Rml::Vector2f at = Center_Of(ok); context.ProcessMouseMove((int)at.x, (int)at.y, 0); context.Update(); Check(!context.ProcessMouseButtonDown(0, 0), "a press on OK interacts with the document"); @@ -1366,7 +1433,7 @@ std::vector Visible_Buttons(Rml::ElementDocument * document) void Click(Rml::Context & context, Rml::Element * element) { - Rml::Vector2f at = element->GetAbsoluteOffset(Rml::BoxArea::Border) + element->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; + Rml::Vector2f at = Center_Of(element); context.ProcessMouseMove((int)at.x, (int)at.y, 0); context.Update(); context.ProcessMouseButtonDown(0, 0); @@ -2678,30 +2745,9 @@ void Test_Shell(void) passes++; Rml::Element * score = Rml(*view).Document()->GetElementById("score"); if (passes == 2 && score != nullptr) { - // The slider builds its track and bar as non-DOM children, which a tag search skips. - Rml::Element * barelement = nullptr; - Rml::Element * trackelement = nullptr; - for (int index = 0; index < score->GetNumChildren(true); index++) { - Rml::Element * child = score->GetChild(index); - if (child->GetTagName() == "sliderbar") { - barelement = child; - } else if (child->GetTagName() == "slidertrack") { - trackelement = child; - } - } - if (barelement != nullptr && trackelement != nullptr) { - Rml::Vector2f bar = barelement->GetAbsoluteOffset(Rml::BoxArea::Border) + barelement->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; - Rml::Vector2f track = trackelement->GetAbsoluteOffset(Rml::BoxArea::Border); - int right = (int)(track.x + trackelement->GetBox().GetSize(Rml::BoxArea::Border).x) + 40; - service.Calls.clear(); - consumed = Send(shell, WM_MOUSEMOVE, 0, MAKELPARAM((int)bar.x, (int)bar.y)); - host.Down[VK_LBUTTON] = true; - consumed = Send(shell, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM((int)bar.x, (int)bar.y)) && consumed; - consumed = Send(shell, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(right, (int)bar.y)) && consumed; - host.Down[VK_LBUTTON] = false; - consumed = Send(shell, WM_LBUTTONUP, 0, MAKELPARAM(right, (int)bar.y)) && consumed; - dragged = score->GetAttribute("value", -1); - } + service.Calls.clear(); + consumed = Drag_Slider_To_End(shell, host, score); + dragged = score->GetAttribute("value", -1); } if (passes == 3 && score != nullptr) { held = presenter.State.Score == dragged && score->GetAttribute("value", -1) == dragged; @@ -2737,8 +2783,7 @@ void Test_Shell(void) if (buttons.empty()) { return(true); } - Rml::Vector2f centre = buttons[0]->GetAbsoluteOffset(Rml::BoxArea::Border) + buttons[0]->GetBox().GetSize(Rml::BoxArea::Border) * 0.5f; - LPARAM at = MAKELPARAM((int)centre.x, (int)centre.y); + LPARAM at = Element_Point(host, buttons[0]); if (passes == 2) { host.Down[VK_LBUTTON] = true; @@ -2763,6 +2808,200 @@ void Test_Shell(void) Check(presenter.Result.has_value() && presenter.Choice == 0, "the next click on the same button answers normally"); } + { + // A press has to land on the button under it, wherever the menu sits in the frame, and + // a button the presenter refuses answers nothing without closing the screen. + UIMainOptionsState state; + state.SoundEnabled = false; + state.Top = 120; + UIMainOptionsPresenterClass presenter(state); + std::unique_ptr view = UI_Main_Options_View(presenter); + int passes = 0; + bool found = false; + bool refused = false; + + shell.Run_Modal(*view, [&](void) { + passes++; + Rml::ElementDocument * document = Rml(*view).Document(); + Rml::Element * sound = document->GetElementById("sound"); + Rml::Element * display = document->GetElementById("display"); + found = sound != nullptr && display != nullptr; + if (!found) { + return(true); + } + if (passes == 2) { + Click_Through_Hook(shell, host, sound); + } + if (passes == 3) { + refused = !presenter.Result.has_value(); + Click_Through_Hook(shell, host, display); + } + return(passes > 4); + }); + + Check(found && refused && passes == 3, "a click on the refused sound button answers nothing and leaves the menu open"); + Check(presenter.Result.has_value() && presenter.Choice == UI_MAIN_OPTIONS_DISPLAY, "a click reaches the button beneath the pointer and answers with its choice"); + } + + { + // A row hands back its own entry rather than its position, and the model reaches the + // document, so the row the player chose is the one that shows as chosen. + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + std::unique_ptr view = UI_Display_View(presenter); + int passes = 0; + bool listed = false; + bool marked = false; + + shell.Run_Modal(*view, [&](void) { + passes++; + Rml::ElementDocument * document = Rml(*view).Document(); + std::vector rows = Visible_Of_Class(document, "mode"); + Rml::Element * ok = document->GetElementById("ok"); + listed = rows.size() == 3 && ok != nullptr; + if (!listed) { + return(true); + } + if (passes == 2) { + Click_Through_Hook(shell, host, rows[2]); + } + if (passes == 3) { + marked = rows[2]->IsClassSet("selected") && !rows[1]->IsClassSet("selected"); + Click_Through_Hook(shell, host, ok); + } + return(passes > 4); + }); + + Check(listed && marked, "clicking a mode row marks that row chosen in the document"); + Check(presenter.State.Selected == 2 && presenter.Picked.has_value() && presenter.Picked->Width == 1920 && presenter.Picked->Height == 1080, "and accepting hands back the mode the row carried, not its index"); + } + + { + // A switch, a slider whose value runs backwards, and the button that hands on to the + // next screen. Nothing reaches the service until that button is pressed, and then it + // all arrives in one order. + RecordingGameControlsServiceClass service; + UIGameControlsState state = Game_Controls_Fixture(); + state.InGame = true; + state.SoundEnabled = true; + UIGameControlsPresenterClass presenter(service, state); + std::unique_ptr view = UI_Game_Controls_View(presenter); + int passes = 0; + bool found = false; + bool dragged = false; + bool quiet = false; + std::string named; + + shell.Run_Modal(*view, [&](void) { + passes++; + Rml::ElementDocument * document = Rml(*view).Document(); + Rml::Element * cameo = document->GetElementById("cameo"); + Rml::Element * speed = document->GetElementById("speed"); + Rml::Element * name = document->GetElementById("speed-name"); + Rml::Element * sound = document->GetElementById("sound"); + found = cameo != nullptr && speed != nullptr && name != nullptr && sound != nullptr; + if (!found) { + return(true); + } + if (passes == 2) { + Click_Through_Hook(shell, host, cameo); + dragged = Drag_Slider_To_End(shell, host, speed); + } + if (passes == 3) { + named = name->GetInnerRML(); + quiet = service.Calls.empty(); + Click_Through_Hook(shell, host, sound); + } + return(passes > 4); + }); + + Check(found && dragged && presenter.State.Speed == 0 && named == "Slowest", "the speed slider runs backwards, so dragging it to the far end takes the slowest setting"); + Check(!presenter.State.CameoText && quiet, "a switch and a slider change the screen without reaching the engine"); + Check(service.Joined() == "speed 0; scroll 2; detail 1; cameo off; lines off; tooltips on; coasting off; edge off; difficulty 2; save", "the next-screen button applies every setting in one order and saves"); + Check(presenter.Result.has_value() && presenter.Next == UIGameControlsPresenterClass::NEXT_SOUND, "and asks for the screen its button names"); + } + + { + // A key has to reach the element the view focused, not the document's accept and cancel + // handling, and assigning it must take it off whatever held it before. The key is an + // unmodified one because the shell reads its modifiers from the thread's own key state + // rather than through the host, which no test can set without disturbing the machine; + // Test_Keys covers the modifier bits on their own. + RecordingKeyboardServiceClass service; + UIKeyboardPresenterClass presenter(service, Keyboard_Fixture()); + std::unique_ptr view = UI_Keyboard_View(presenter); + int passes = 0; + bool listed = false; + bool focused = false; + bool captured = false; + bool moved = false; + + shell.Run_Modal(*view, [&](void) { + passes++; + Rml::ElementDocument * document = Rml(*view).Document(); + std::vector rows = Visible_Of_Class(document, "row"); + Rml::Element * capture = document->GetElementById("capture"); + Rml::Element * assign = document->GetElementById("assign"); + Rml::Element * ok = document->GetElementById("ok"); + listed = rows.size() == 4 && capture != nullptr && assign != nullptr && ok != nullptr; + if (!listed) { + return(true); + } + + // Rows two and three are the open category's commands: Alliance, which holds no key, + // then Toggle Repair. The key pressed below belongs to a command in the other + // category, so assigning it has to take it away from there. + if (passes == 2) { + Click_Through_Hook(shell, host, rows[2]); + } + if (passes == 3) { + focused = shell.Rml_Context()->GetFocusElement() == capture; + Send(shell, WM_KEYDOWN, 'X'); + Send(shell, WM_KEYUP, 'X'); + } + if (passes == 4) { + captured = presenter.State.Captured == 88 && presenter.State.AssignedTo == "Scatter" && capture->GetInnerRML().find("K88") != std::string::npos; + Click_Through_Hook(shell, host, assign); + } + if (passes == 5) { + moved = presenter.Key_Of(3) == 88 && presenter.Key_Of(2) == 0; + Click_Through_Hook(shell, host, ok); + } + return(passes > 6); + }); + + Check(listed && presenter.State.Selected == 3, "a click on a command row selects the command it names"); + Check(focused, "and moves the focus to the capture element the keys go to"); + Check(captured, "a key sent through the hook becomes its hotkey number there and names the command holding it"); + Check(moved, "assigning it moves the key to the selected command and off its old owner"); + Check(service.Calls == std::vector{ "save 0=577 1=338 3=88" }, "and accepting saves the table the edits left behind"); + } + + { + // The one screen whose result comes from the clock rather than from input, which is + // what proves the runner refreshes a presenter before it drains. + FakeClockClass clock; + UIConfirmModePresenterClass presenter(clock); + std::unique_ptr view = UI_Confirm_Mode_View(presenter); + int passes = 0; + int counted = -1; + + shell.Run_Modal(*view, [&](void) { + passes++; + if (passes == 2) { + clock.Now = UIConfirmModePresenterClass::DEFAULT_TIMEOUT - 2000; + } + if (passes == 3) { + counted = presenter.Seconds; + clock.Now = UIConfirmModePresenterClass::DEFAULT_TIMEOUT; + } + return(passes > 5); + }); + + Check(counted == 2, "the confirmation counts down as the runner refreshes it"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_CANCELLED && presenter.TimedOut && presenter.Seconds == 0, "and silence closes it when the deadline passes, with no intent involved"); + } + { UIWaitBoxPresenterClass presenter("Working", false); std::unique_ptr view = UI_Wait_Box_View(presenter); From 6487cb1daa3b10c6cb0f24e851df047be80a6e79 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 13 Sep 2026 06:08:47 +0300 Subject: [PATCH 52/52] Follow the team commands' new shape in the command extractor --- manual/tools/commands_engine.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/manual/tools/commands_engine.py b/manual/tools/commands_engine.py index dccf3b2ce..09cf239f9 100644 --- a/manual/tools/commands_engine.py +++ b/manual/tools/commands_engine.py @@ -147,8 +147,10 @@ def _method_text(class_name, body, method, resources, team=None, fallback=None): if fallback is not None: return fallback raise ValueError(f"{class_name}: no supported {method}() definition") + # A team command builds its text from its own number, through the helper that gives each + # instance its own storage. formatted = re.search( - r"sprintf\s*\(\s*_cmd_buffer\s*,\s*(?:" + r"Team_Command_String\s*\(\s*\w+\s*,\s*(?:" r'"(?P(?:[^"\\]|\\.)*)"|' r"Fetch_String\s*\(\s*(?PTXT_[A-Z0-9_]+)\s*\))\s*,\s*Team\s*\)", source,