diff --git a/.github/workflows/engine-build.yml b/.github/workflows/engine-build.yml index b5218d00e..ec4044990 100644 --- a/.github/workflows/engine-build.yml +++ b/.github/workflows/engine-build.yml @@ -81,10 +81,12 @@ jobs: # The artifact carries what a downloaded build needs to run and debug itself, so it # names those files rather than taking the output directory wholesale. The symbol # file answers everything the linker map would, and is what the crash handler reads. + # The ui directory travels whole: the game resolves its files by name at runtime. engine=Game if [ "${{ matrix.configuration }}" = "Debug" ]; then engine=GameD; fi built=build/bin/${{ matrix.configuration }} cp "${built}/${engine}.exe" "${built}/${engine}.pdb" "${built}/Language.dll" artifact/ + cp -r "${built}/ui" artifact/ui cp LICENSE.md THIRD_PARTY_NOTICES.md artifact/ mkdir -p artifact/OpenTS_THIRD_PARTY_LICENSES cp thirdparty/bgfx.cmake/bgfx/LICENSE artifact/OpenTS_THIRD_PARTY_LICENSES/bgfx.txt @@ -100,6 +102,15 @@ 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 + 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 901dec7a6..11b7345e8 100644 --- a/.github/workflows/engine-release.yml +++ b/.github/workflows/engine-release.yml @@ -43,7 +43,7 @@ jobs: # prefix itself contains a hyphen, so count the fields from the end. platform="$(basename "${dir}" | awk -F- '{print $(NF-2)}')" ( cd "${dir}" && zip -r "${GITHUB_WORKSPACE}/OpenTS-${TAG}-${platform}.zip" \ - Game.exe Game.pdb Language.dll \ + Game.exe Game.pdb Language.dll ui \ LICENSE.md THIRD_PARTY_NOTICES.md OpenTS_THIRD_PARTY_LICENSES ) done diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml index 43dea5150..9d6f4abc7 100644 --- a/.github/workflows/engine.yml +++ b/.github/workflows/engine.yml @@ -8,6 +8,7 @@ on: - "cmake/**" - "tests/**" - "thirdparty/**" + - "ui/**" - "CMakeLists.txt" - ".gitmodules" - ".github/msvc-problem-matcher.json" @@ -20,6 +21,7 @@ on: - "cmake/**" - "tests/**" - "thirdparty/**" + - "ui/**" - "CMakeLists.txt" - ".gitmodules" - ".github/msvc-problem-matcher.json" 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/CMakeLists.txt b/CMakeLists.txt index ed25a280c..5d344d501 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,10 +60,12 @@ set(OPENTS_GAME_DIR "${CMAKE_SOURCE_DIR}/Run" CACHE PATH "Tiberian Sun game data # 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 @@ -79,11 +81,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/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b767b00a1..ef64c60c0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -16,6 +16,15 @@ remains under its own license and copyright notices. | [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 | | [LZO](https://www.oberhumer.com/opensource/lzo/) | LZO1X compression for maps, saves, and network blocks | GPL-2.0-or-later | +| [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 | +| [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/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/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/CMakeLists.txt b/code/CMakeLists.txt index 36f24e2a1..a689a2c4d 100644 --- a/code/CMakeLists.txt +++ b/code/CMakeLists.txt @@ -144,26 +144,45 @@ 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/rml/rmlrender.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" 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/rml/rmlrender.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 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/rml/rmltexture.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 @@ -196,6 +215,8 @@ target_link_libraries(OpenTS PRIVATE bimg miniaudio lzo + RmlUi::Core + imgui comctl32 dbghelp iphlpapi @@ -270,6 +291,20 @@ source_group("Interface Files" FILES source_group("Source Files" REGULAR_EXPRESSION ".*\\.(c|cpp)$") source_group("Header Files" REGULAR_EXPRESSION ".*\\.(h|hpp)$") +# +# --------------------------------------------------------- +# UI files +# --------------------------------------------------------- +# +# The UI documents, styles and font are read beside the executable, like Language.dll. Their +# own target copies them, so an edited document reaches the output without a relink. +add_custom_target(OpenTSUIFiles ALL + COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/ui" "$/ui" + COMMENT "Copying the UI files beside the executable" + VERBATIM +) +add_dependencies(OpenTS OpenTSUIFiles) + # # --------------------------------------------------------- # Visual Studio startup project 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/bgfxbackend.cpp b/code/bgfxbackend.cpp index af72c26a9..cf72a5994 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,13 @@ 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; + +// 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; @@ -162,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); @@ -191,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); } @@ -198,7 +200,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 +225,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 +393,8 @@ void Backend_Shutdown(void) _FrameWidth = 0; _FrameHeight = 0; + _FrameUploaded = false; + _FramePending = false; _Initialized = false; } @@ -421,6 +425,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 +466,51 @@ 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]]; + _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); + _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; @@ -520,9 +539,10 @@ void 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; + } } } } @@ -534,8 +554,21 @@ void 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(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 || !_FramePending) { + return; + } + _FramePending = false; bgfx::frame(); } diff --git a/code/bgfxbackend.h b/code/bgfxbackend.h index 8f3cb4184..b36ecb5c3 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,17 @@ 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 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); + +// 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/gamedlg.cpp b/code/gamedlg.cpp index 4e1cc50e6..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" @@ -48,6 +49,9 @@ #include "queue.h" #include "session.h" #include "techno.h" +#include "ui/screens/gamectrl/uigamectrl.h" +#include "ui/uiscreen.h" +#include "ui/uishell.h" #include "special.hh" @@ -87,6 +91,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. * * * @@ -99,11 +169,14 @@ void Game_Controls_Dialog_On_COMMAND(HWND window, UINT message, WPARAM wparam, L * 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); + _Presenter = &presenter; if (GameActive == true) { if (Session.Type == GAME_INTERNET) { @@ -129,95 +202,30 @@ 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(); - } - } - - 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); - } - } + _Presenter = NULL; - handle = GetDlgItem(_Dialog, IDC_TARGET_LINES); - if (handle) { - Options.ActionLines = Button_GetCheck(handle) == TRUE; - TechnoClass::Set_Action_Lines(Options.ActionLines); + // 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_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; - } +void GameControlsClass::Dialog(void) +{ + DebugString("GameControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); - handle = GetDlgItem(_Dialog, IDC_EDGE_SCROLL); - if (handle) { - Options.AutoScroll = Button_GetCheck(handle) == TRUE; + if (!UIShell.Use_Rml() || !UI_Game_Controls_Dialog()) { + Run_Win32_Dialog(); } - 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 +245,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 +361,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..3b74c2e43 100644 --- a/code/gamedlg.h +++ b/code/gamedlg.h @@ -52,9 +52,8 @@ class GameControlsClass } private: - void Set(void); + void Run_Win32_Dialog(void); - private: /* * 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/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/init.cpp b/code/init.cpp index 3b81e0899..65437c970 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" @@ -178,6 +179,8 @@ #include "trigger.h" #include "tube.h" #include "tutorial.h" +#include "ui/screens/version/uiversion.h" +#include "ui/uishell.h" #include "uicontrol.h" #include "unit.h" #include "unittype.h" @@ -199,6 +202,7 @@ #include #include #include +#include #include #include @@ -2977,7 +2981,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); @@ -2988,45 +2991,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)); - } - - 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); + std::vector lines; + UI_Version_Lines(lines); + for (std::string const & line : lines) { + ListBox_AddString(handle, line.c_str()); } - - Get_Language_Version(buffer); - ListBox_AddString(handle, buffer); break; + } case WM_COMMAND: switch (LOWORD(wparam)) { @@ -3052,6 +3026,10 @@ void Version_Dialog(void) HWND dialog; int res = 0; + if (UIShell.Use_Rml() && UI_Version_Dialog()) { + return; + } + dialog = OwnerDraw::Begin_Dialog(IDD_VERSION, Version_Dialog_Proc); if (dialog != NULL) { @@ -3284,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) @@ -3323,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 { @@ -3344,6 +3329,10 @@ class CreateTeamCommandClass : public CommandClass private: int Team; + + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; }; @@ -3353,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 { @@ -3399,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; }; @@ -3410,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 { @@ -3436,6 +3423,10 @@ class AddTeamCommandClass : public CommandClass private: int Team; + + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; }; @@ -3445,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 { @@ -3473,6 +3461,10 @@ class AddToTeamCommandClass : public CommandClass private: int Team; + + mutable std::string UniqueName; + mutable std::string DisplayName; + mutable std::string Description; }; @@ -3482,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 { @@ -3516,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/loaddlg.cpp b/code/loaddlg.cpp index 164a00f2d..71500b9e4 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/screens/waitbox/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/mainloop.cpp b/code/mainloop.cpp index 19c4a68ce..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" @@ -60,6 +61,7 @@ #include "theme.h" #include "timer.h" #include "tracker.h" +#include "ui/uishell.h" #include "bench.hh" #include "special.hh" @@ -278,6 +280,7 @@ bool Main_Loop(void) */ if (!Session.Play) { if (SpecialDialog == SDLG_NONE && GameInFocus) { + UIShell.Tick(); Map.Input(input, x, y); if (input) { Keyboard_Process(input); diff --git a/code/mainopt.cpp b/code/mainopt.cpp index 4534159ad..2c4cdf6bb 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" @@ -34,25 +34,48 @@ #include "sounddlg.h" #include "stimer.h" #include "surface.h" +#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" +#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); 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); + +// 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; -GameOptionsClass TempOptions; + +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) @@ -60,80 +83,26 @@ void Main_Options_Dialog(void) bool old_game_active = GameActive; GameActive = false; - HWND main_handle; - LONG main_rc; - - HWND in_handle; - LONG in_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 (!UIShell.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: { - 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 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; @@ -146,6 +115,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 @@ -321,8 +334,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,58 +348,77 @@ bool Test_Display_Mode_Dialog(int width, int height) Show_Mouse(); Draw_Menu_Background(); + bool kept = false; + if (!UIShell.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; + + // The mode change leaves the frame blank and the screen that follows draws over it. + Draw_Menu_Background(); + 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(UIShell.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; - DebugString("Keeping display mode @ %dx%d\n", width, height); - LogicalSurface = HiddenSurface; - return(true); + if (dialog == NULL) { + return(true); + } + return(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED); } /// /// 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 +426,77 @@ 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) { + std::optional picked; + if (!UIShell.Use_Rml() || !UI_Display_Dialog(picked)) { + picked = Display_Options_Win32_Dialog(); + } + + if (!picked.has_value()) { + break; + } + + if (WWMessageBox().Process(TXT_ABOUT_TO_TRY_MODE, TXT_OK, TXT_CANCEL) != 0) { + break; + } + 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 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 +507,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/msgbox.cpp b/code/msgbox.cpp index 9877bd534..df4739ff8 100644 --- a/code/msgbox.cpp +++ b/code/msgbox.cpp @@ -35,10 +35,13 @@ #include "msgbox.h" +#include "_ui.h" #include "data.h" #include "globals.h" #include "init.h" #include "ownrdraw.h" +#include "ui/screens/msgbox/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 +80,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 (UIShell.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/msgloop.cpp b/code/msgloop.cpp index dcbe981cb..c60da77ab 100644 --- a/code/msgloop.cpp +++ b/code/msgloop.cpp @@ -39,7 +39,9 @@ #include "msgloop.h" #include "_tooltip.h" +#include "_ui.h" #include "cctooltip.h" +#include "ui/uishell.h" #include "vector.h" #include "video.h" @@ -112,6 +114,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 (UIShell.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 @@ -119,7 +126,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; } @@ -214,6 +223,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 00d68720d..9026db6b6 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" @@ -83,6 +84,8 @@ #include "session.h" #include "techno.h" #include "theme.h" +#include "ui/screens/keyboard/uikeyboard.h" +#include "ui/uishell.h" #include "vector.h" #include "video.h" #include "vox.h" @@ -117,6 +120,7 @@ OptionsClass::OptionsClass(void) : SoundVolume(.7f), VoiceVolume(1.0f), ScoreVolume(.5f), + LegacyDialogs(false), AutoScroll(true), IsScoreRepeat(false), IsScoreShuffle(false), @@ -380,6 +384,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); @@ -470,6 +477,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); @@ -590,34 +598,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 (UIHotkeyRow const & row : state.Visible) { + int index = ListBox_AddString(list, row.Name.c_str()); + if (index != LB_ERR) { + ListBox_SetItemData(list, index, row.Command); + if (row.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, 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) { - 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) { @@ -625,17 +692,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); } @@ -643,7 +700,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); } @@ -651,7 +708,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); @@ -661,148 +721,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); } @@ -810,17 +762,17 @@ 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; + 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) { @@ -838,6 +790,20 @@ bool OptionsClass::Hotkey_Dialog(void) OwnerDraw::End_Dialog(handle); } + _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 (!UIShell.Use_Rml() || !UI_Keyboard_Dialog()) { + Hotkey_Win32_Dialog(); + } return(true); } 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 7d7592e10..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,21 +32,23 @@ #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" #include "srfcache.h" #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" +#include #include #include #include @@ -6738,6 +6741,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(!UIShell.Screen_Shown()); + LPCDLGTEMPLATE templ = (LPCDLGTEMPLATE)Fetch_Resource(MAKEINTRESOURCE(id), (LPCSTR)RT_DIALOG); if (templ == NULL) { return(NULL); @@ -6817,6 +6823,8 @@ void OwnerDraw::End_Dialog(HWND window) /// void OwnerDraw::Display_Dialog(HWND window) { + assert(!UIShell.Screen_Shown()); + ShowWindow(window, SW_SHOWNORMAL); SetForegroundWindow(window); Keyboard->Clear(); @@ -6904,6 +6912,8 @@ bool OwnerDraw::Dialog_Message_Handler(void) Call_Back(); } + UIShell.Tick(); + return(false); } diff --git a/code/progress.cpp b/code/progress.cpp index 3fc416d72..f4cbc9352 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; } @@ -83,13 +103,15 @@ 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 { 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 || Box.Is_Shown() || 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,19 +258,10 @@ 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 } - }; + // The document draws the bar itself. + if (Box.Is_Shown()) { + return; + } if (IsActive) { Point2D pt = xpt; @@ -244,19 +286,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,8 +353,11 @@ 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 if (Box.Is_Shown()) { + Box.Set_Fraction(Get_Current_Progress(index)); } else { Display_Progress(pt); } @@ -341,8 +379,11 @@ 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 if (Box.Is_Shown()) { + Box.Set_Fraction(Get_Current_Progress(index)); } else { Display_Progress(pt); } @@ -358,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); @@ -374,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 094397b12..a7160e5a0 100644 --- a/code/progress.h +++ b/code/progress.h @@ -13,6 +13,7 @@ #include "point.h" #include "sun.h" +#include "ui/screens/waitbox/uiwaitbox.h" #include "win.h" class ShapeSet; @@ -47,6 +48,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: /* @@ -103,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. @@ -115,6 +123,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/code/savemgr.cpp b/code/savemgr.cpp index ec62640cd..f3e13ab2b 100644 --- a/code/savemgr.cpp +++ b/code/savemgr.cpp @@ -26,6 +26,7 @@ #include "netdlg.h" #include "netglobal.h" #include "ownrdraw.h" +#include "ui/screens/waitbox/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/sounddlg.cpp b/code/sounddlg.cpp index 6a8c817c8..a95841049 100644 --- a/code/sounddlg.cpp +++ b/code/sounddlg.cpp @@ -36,19 +36,41 @@ #include "sounddlg.h" +#include "_ui.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/screens/sound/uisound.h" +#include "ui/uiscreen.h" +#include "ui/uishell.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. @@ -57,29 +79,33 @@ bool DialogInitialized = false; /// 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; + 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); + 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) { - 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,18 @@ void SoundControlsClass::Dialog(void) OwnerDraw::End_Dialog(dialog); } + _Presenter = NULL; +} + + +void SoundControlsClass::Dialog(void) +{ + DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); + + if (!UIShell.Use_Rml() || !UI_Sound_Dialog()) { + Run_Win32_Dialog(); + } + DebugString("SoundControls: GameSpeed = %d, ScrollRate = %d, Detail = %d\n", Options.GameSpeed, Options.ScrollRate, Options.DetailLevel); } @@ -111,167 +149,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); - } - button = GetDlgItem(window, IDC_VOICE_VOLUME); - if (button) { - Options.Set_Voice_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; } - 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 +260,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/startup.cpp b/code/startup.cpp index 7f8105e7a..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,6 +139,7 @@ #include "tube.h" #include "tunnel.h" #include "tutorial.h" +#include "ui/uishell.h" #include "unit.h" #include "unittype.h" #include "vanim.h" @@ -213,6 +215,7 @@ void Reset_Surfaces(void) VisibleSurface = NULL; } + UIShell.Shutdown(); Video_Shutdown(); surfaces_reset = true; @@ -499,6 +502,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"); @@ -570,6 +582,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. + UIShell.Init(); + do { Windows_Message_Handler(); } diff --git a/code/ui/dev/uidev.cpp b/code/ui/dev/uidev.cpp new file mode 100644 index 000000000..439dc924c --- /dev/null +++ b/code/ui/dev/uidev.cpp @@ -0,0 +1,575 @@ +/******************************************************************************* + * 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/dev/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/rml/rmlrender.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("%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(); + + 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(UIRmlRenderClass 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(UIRmlRenderClass & render) +{ + if (!UIDev_Active()) { + return; + } + + render.Render_ImGui(ImGui::GetDrawData()); +} + + +void UIDev_Shutdown(UIRmlRenderClass & 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/dev/uidev.h b/code/ui/dev/uidev.h new file mode 100644 index 000000000..1c563e3be --- /dev/null +++ b/code/ui/dev/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 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(UIRmlRenderClass const & render); +void UIDev_Tick(void); +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. +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/rml/rmlfile.cpp b/code/ui/rml/rmlfile.cpp new file mode 100644 index 000000000..5e2b1f01f --- /dev/null +++ b/code/ui/rml/rmlfile.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/rml/rmlfile.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 UIRmlFileClass::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 UIRmlFileClass::Close(Rml::FileHandle file) +{ + CCFileClass * ccfile = (CCFileClass *)file; + if (ccfile != NULL) { + ccfile->Close(); + delete ccfile; + } +} + + +size_t UIRmlFileClass::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 UIRmlFileClass::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 UIRmlFileClass::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 UIRmlFileClass::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/rml/rmlfile.h b/code/ui/rml/rmlfile.h new file mode 100644 index 000000000..3479660ea --- /dev/null +++ b/code/ui/rml/rmlfile.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 UIRmlFileClass : 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/rml/rmlkeys.cpp b/code/ui/rml/rmlkeys.cpp new file mode 100644 index 000000000..245f7c14c --- /dev/null +++ b/code/ui/rml/rmlkeys.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/rml/rmlkeys.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/rml/rmlkeys.h b/code/ui/rml/rmlkeys.h new file mode 100644 index 000000000..bb8bae0c1 --- /dev/null +++ b/code/ui/rml/rmlkeys.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/rml/rmlrender.cpp b/code/ui/rml/rmlrender.cpp new file mode 100644 index 000000000..8ce236ff6 --- /dev/null +++ b/code/ui/rml/rmlrender.cpp @@ -0,0 +1,664 @@ +/******************************************************************************* + * 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 overlays. With bgfxbackend.cpp it is one of the two translation +// units that include bgfx. + +#include "ui/rml/rmlrender.h" + +#include "bgfxbackend.h" +#include "bgfxviews.hh" +#include "dbgprint.h" +#include "ui/rml/rmlrendermath.h" +#include "ui/rml/rmltexture.h" + +#include +#include + +#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() +}; + +// RmlUi and Dear ImGui order their vertex members differently. +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; +}; + + +// 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) }; + return(texture); +} + + +UIRmlBgfxRenderClass::UIRmlBgfxRenderClass(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()), + DevShortageLogged(false) +{ +} + + +void UIRmlBgfxRenderClass::Report(char const * message) +{ + DebugString("UI renderer: %s\n", message); +} + + +bool UIRmlBgfxRenderClass::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(); + + _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"); + + 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; + 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 }; + + bgfx::destroy(whitetexture); + bgfx::destroy(sampler); + bgfx::destroy(program); + + WhiteTexture = bgfx::kInvalidHandle; + Sampler = bgfx::kInvalidHandle; + Program = bgfx::kInvalidHandle; + Statistics = UIRenderStats(); + TextureBytes.clear(); + IsReady = false; +} + + +// 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 UIRmlBgfxRenderClass::Set_View(unsigned short view, 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, 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 UIRmlBgfxRenderClass::Begin_Frame(int x, int y, int width, int height) +{ + Statistics.DrawCalls = 0; + Set_View(VIEW_UI, x, y, width, height); +} + + +void UIRmlBgfxRenderClass::Begin_Dev_Frame(int x, int y, int width, int height) +{ + Set_View(VIEW_DEV, x, y, width, 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); + } + + 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 +{ + 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); +} + + +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) { + 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); + } + } + + 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 (wideindices) { + indexbuffer = bgfx::createIndexBuffer(bgfx::copy(indices.data(), indexbytes), BGFX_BUFFER_INDEX32); + } else { + 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(), indexbytes)); + } + + if (!bgfx::isValid(vertexbuffer) || !bgfx::isValid(indexbuffer)) { + if (bgfx::isValid(vertexbuffer)) { + bgfx::destroy(vertexbuffer); + } + if (bgfx::isValid(indexbuffer)) { + bgfx::destroy(indexbuffer); + } + Fail("a fragment's buffers could not be created"); + return(0); + } + + UIGeometry * geometry = new UIGeometry; + geometry->Vertices = vertexbuffer; + geometry->Indices = indexbuffer; + geometry->Bytes = vertexbytes + indexbytes; + Statistics.GeometryCount++; + Statistics.GeometryBytes += geometry->Bytes; + return((Rml::CompiledGeometryHandle)geometry); +} + + +void UIRmlBgfxRenderClass::RenderGeometry(Rml::CompiledGeometryHandle handle, Rml::Vector2f translation, Rml::TextureHandle texture) +{ + 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; + } + + 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); + Statistics.DrawCalls++; +} + + +void UIRmlBgfxRenderClass::ReleaseGeometry(Rml::CompiledGeometryHandle handle) +{ + if (handle == 0) { + return; + } + + UIGeometry * geometry = (UIGeometry *)handle; + bgfx::destroy(geometry->Vertices); + bgfx::destroy(geometry->Indices); + Statistics.GeometryCount--; + Statistics.GeometryBytes -= geometry->Bytes; + delete geometry; +} + + +Rml::TextureHandle UIRmlBgfxRenderClass::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)) { + char message[320]; + std::snprintf(message, sizeof(message), "%s did not decode", source.c_str()); + Fail(message); + return(0); + } + + dimensions.x = width; + dimensions.y = height; + return(GenerateTexture(Rml::Span(rgba.data(), rgba.size()), dimensions)); +} + + +Rml::TextureHandle UIRmlBgfxRenderClass::GenerateTexture(Rml::Span source, Rml::Vector2i dimensions) +{ + if (!IsReady) { + return(0); + } + + 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); +} + + +void UIRmlBgfxRenderClass::ReleaseTexture(Rml::TextureHandle texture) +{ + if (texture == 0) { + return; + } + + 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); +} + + +void UIRmlBgfxRenderClass::EnableScissorRegion(bool enable) +{ + ScissorEnabled = enable; +} + + +void UIRmlBgfxRenderClass::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 UIRmlBgfxRenderClass::Apply_Scissor(void) const +{ + if (!Scissor.Valid()) { + return(false); + } + + 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(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. 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) { + 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); + 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(), size)); + Record_Texture(handle.idx, size); + 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) }; + Forget_Texture(handle.idx); + bgfx::destroy(handle); + texture->SetTexID(ImTextureID_Invalid); + } + texture->SetStatus(ImTextureStatus_Destroyed); + } +} + + +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); + } + texture->SetStatus(ImTextureStatus_Destroyed); + } +} + + +// 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) +{ + 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; + } + + 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(); + if (id != ImTextureID_Invalid) { + sampled.idx = (uint16_t)(id - 1); + } + + 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 new file mode 100644 index 000000000..9c3e9e7a7 --- /dev/null +++ b/code/ui/rml/rmlrender.h @@ -0,0 +1,155 @@ +/******************************************************************************* + * 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 + +#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. 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: + virtual ~UIRmlRenderClass(void) = default; + + virtual bool Init(void) = 0; + virtual void Shutdown(void) = 0; + + // 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. + 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. + virtual void Render_ImGui(ImDrawData * data) = 0; + + // Destroys every texture Dear ImGui still holds. Called before its context goes. + virtual void Destroy_ImGui_Textures(void) = 0; + + // The largest texture edge the renderer accepts. + virtual int Texture_Limit(void) const = 0; + + // 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] = {}; +}; + + +// 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; + 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; + + 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; + unsigned short Program; + unsigned short Sampler; + unsigned short WhiteTexture; + + int ViewX; + int ViewY; + int ViewWidth; + int ViewHeight; + + 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/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/code/ui/rml/rmlsystem.cpp b/code/ui/rml/rmlsystem.cpp new file mode 100644 index 000000000..50bb398bb --- /dev/null +++ b/code/ui/rml/rmlsystem.cpp @@ -0,0 +1,208 @@ +/******************************************************************************* + * 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/rmlsystem.h" + +#include "ui/uihost.h" +#include "ui/uiunicode.h" + +#include "opents_strings.h" + +#include +#include + + +UIRmlSystemClass::UIRmlSystemClass(UIShellHostClass & host) : + Host(host), + Start(std::chrono::steady_clock::now()) +{ +} + + +// UI animation follows the wall clock, never the game's deterministic timers. +double UIRmlSystemClass::GetElapsedTime(void) +{ + return(std::chrono::duration(std::chrono::steady_clock::now() - Start).count()); +} + + +bool UIRmlSystemClass::LogMessage(Rml::Log::Type type, Rml::String const & message) +{ + char const * level = "info"; + + 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: + level = "warning"; + break; + + case Rml::Log::LT_DEBUG: + level = "debug"; + break; + + default: + break; + } + + char line[1024]; + std::snprintf(line, sizeof(line), "UI %s: %s\n", level, message.c_str()); + Host.Log(line); + 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 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); +} + + +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. 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; + 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(Host.String(id)); + count++; + } else { + 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); + } + + from = close + 2; + } + + 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 new file mode 100644 index 000000000..e31b5ff6e --- /dev/null +++ b/code/ui/rml/rmlsystem.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. + ******************************************************************************/ + +#pragma once + +#include "ui/uiinput.hh" + +#include + +#include + +class UIShellHostClass; + + +// 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: + UIRmlSystemClass(UIShellHostClass & host); + + 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; + 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/rml/rmltexture.cpp b/code/ui/rml/rmltexture.cpp new file mode 100644 index 000000000..9a5d0ce2d --- /dev/null +++ b/code/ui/rml/rmltexture.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/rml/rmltexture.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 color. + 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/rml/rmltexture.h b/code/ui/rml/rmltexture.h new file mode 100644 index 000000000..1f6e822df --- /dev/null +++ b/code/ui/rml/rmltexture.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/ui/rml/rmlview.cpp b/code/ui/rml/rmlview.cpp new file mode 100644 index 000000000..4e272afca --- /dev/null +++ b/code/ui/rml/rmlview.cpp @@ -0,0 +1,161 @@ +/******************************************************************************* + * 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/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 +#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); + Loaded(); + return(true); +} + + +bool UIRmlViewClass::Prepare(UIShellClass & shell) +{ + return(shell.Rml_Context() != nullptr && Prepare(*shell.Rml_Context())); +} + + +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/rml/rmlview.h b/code/ui/rml/rmlview.h new file mode 100644 index 000000000..7bc0f29a3 --- /dev/null +++ b/code/ui/rml/rmlview.h @@ -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. + ******************************************************************************/ + +#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. +#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 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. The shell form loads against its context. + bool Prepare(Rml::Context & context); + 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. + virtual void Release(void) override; + + 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()); } + 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) override = 0; + + 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); + + 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/screens/display/uidisplay.cpp b/code/ui/screens/display/uidisplay.cpp new file mode 100644 index 000000000..800b750a8 --- /dev/null +++ b/code/ui/screens/display/uidisplay.cpp @@ -0,0 +1,163 @@ +/******************************************************************************* + * 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/screens/display/uidisplay.h" + +#include "ui/rml/rmlview.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; + } +} + + +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/screens/display/uidisplay.h b/code/ui/screens/display/uidisplay.h new file mode 100644 index 000000000..ea678d245 --- /dev/null +++ b/code/ui/screens/display/uidisplay.h @@ -0,0 +1,115 @@ +/******************************************************************************* + * 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 UIViewClass; + + +// 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); + +// 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/screens/display/uidisplaydlg.cpp b/code/ui/screens/display/uidisplaydlg.cpp new file mode 100644 index 000000000..2a5aa9f4b --- /dev/null +++ b/code/ui/screens/display/uidisplaydlg.cpp @@ -0,0 +1,128 @@ +/******************************************************************************* + * 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/screens/display/uidisplay.h" + +#include "_ui.h" +#include "globals.h" +#include "goptions.h" +#include "ui/uienginehost.h" +#include "ui/uishell.h" +#include "ui/uiview.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; +} + + +bool UI_Display_Dialog(std::optional & picked) +{ + picked.reset(); + + if (UIShell.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 (UIShell.Legacy_Dialog_Visible()) { + return(false); + } + + UIConfirmModePresenterClass presenter(UIShell.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/code/ui/screens/gamectrl/uigamectrl.cpp b/code/ui/screens/gamectrl/uigamectrl.cpp new file mode 100644 index 000000000..db16632f7 --- /dev/null +++ b/code/ui/screens/gamectrl/uigamectrl.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. + ******************************************************************************/ + +#include "ui/screens/gamectrl/uigamectrl.h" + +#include "ui/rml/rmlview.h" + +#include + + +static int Clamp_Level(int level, int count) +{ + if (level < 0) { + return(0); + } + if (level > count - 1) { + return(count - 1); + } + return(level); +} + + +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(); +} + + +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") { + if (State.SoundEnabled) { + 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; + } + + Update_Names(); +} + + +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(); +} + + +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/screens/gamectrl/uigamectrl.h b/code/ui/screens/gamectrl/uigamectrl.h new file mode 100644 index 000000000..e8a3967be --- /dev/null +++ b/code/ui/screens/gamectrl/uigamectrl.h @@ -0,0 +1,117 @@ +/******************************************************************************* + * 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 UIViewClass; + + +// 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, the names of the slider +// positions and the name of each current one. +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; + std::string SpeedName; + std::string ScrollName; + std::string DetailName; + std::string DifficultyName; +}; + + +// 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); + void Update_Names(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); + +// 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/screens/gamectrl/uigamectrldlg.cpp b/code/ui/screens/gamectrl/uigamectrldlg.cpp new file mode 100644 index 000000000..80fc9e7a2 --- /dev/null +++ b/code/ui/screens/gamectrl/uigamectrldlg.cpp @@ -0,0 +1,175 @@ +/******************************************************************************* + * 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/screens/gamectrl/uigamectrl.h" + +#include "_map.h" +#include "_tooltip.h" +#include "_ui.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" +#include "ui/uienginehost.h" +#include "ui/uishell.h" +#include "ui/uiview.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); +} + + +bool UI_Game_Controls_Dialog(void) +{ + if (UIShell.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/code/ui/screens/keyboard/uikeyboard.cpp b/code/ui/screens/keyboard/uikeyboard.cpp new file mode 100644 index 000000000..c46e013db --- /dev/null +++ b/code/ui/screens/keyboard/uikeyboard.cpp @@ -0,0 +1,294 @@ +/******************************************************************************* + * 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/screens/keyboard/uikeyboard.h" + +#include "ui/rml/rmlkeys.h" +#include "ui/rml/rmlview.h" + +#include +#include +#include + +#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, State.Commands[command].Name }); + } + } + std::stable_sort(State.Visible.begin(), State.Visible.end(), [](UIHotkeyRow const & a, UIHotkeyRow const & b) { + return(Compare_Ignoring_Case(a.Name, 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)); +} + + +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/screens/keyboard/uikeyboard.h b/code/ui/screens/keyboard/uikeyboard.h new file mode 100644 index 000000000..99ab973ce --- /dev/null +++ b/code/ui/screens/keyboard/uikeyboard.h @@ -0,0 +1,121 @@ +/******************************************************************************* + * 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 UIViewClass; + + +// 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; +}; + + +// 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 +{ + 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; canceling 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); + +// 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/screens/keyboard/uikeyboarddlg.cpp b/code/ui/screens/keyboard/uikeyboarddlg.cpp new file mode 100644 index 000000000..d3b5f7b94 --- /dev/null +++ b/code/ui/screens/keyboard/uikeyboarddlg.cpp @@ -0,0 +1,148 @@ +/******************************************************************************* + * 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/screens/keyboard/uikeyboard.h" + +#include "_command.h" +#include "_ui.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 "ui/uienginehost.h" +#include "ui/uishell.h" +#include "ui/uiview.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]; + + // 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); + } + + Fetch_Bindings(state.Bindings); +} + + +bool UI_Keyboard_Dialog(void) +{ + if (UIShell.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/screens/mainopt/uimainopt.cpp b/code/ui/screens/mainopt/uimainopt.cpp new file mode 100644 index 000000000..6bc608221 --- /dev/null +++ b/code/ui/screens/mainopt/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/screens/mainopt/uimainopt.h" + +#include "ui/rml/rmlview.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 centered 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/screens/mainopt/uimainopt.h b/code/ui/screens/mainopt/uimainopt.h new file mode 100644 index 000000000..4dfc4a21b --- /dev/null +++ b/code/ui/screens/mainopt/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 UIViewClass; + + +// 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/screens/mainopt/uimainoptdlg.cpp b/code/ui/screens/mainopt/uimainoptdlg.cpp new file mode 100644 index 000000000..789046bf0 --- /dev/null +++ b/code/ui/screens/mainopt/uimainoptdlg.cpp @@ -0,0 +1,58 @@ +/******************************************************************************* + * 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/screens/mainopt/uimainopt.h" + +#include "_surface.h" +#include "_ui.h" +#include "audio/audioengine.h" +#include "surface.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 centered 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 (UIShell.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/code/ui/screens/msgbox/uimsgbox.cpp b/code/ui/screens/msgbox/uimsgbox.cpp new file mode 100644 index 000000000..4ac615996 --- /dev/null +++ b/code/ui/screens/msgbox/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/screens/msgbox/uimsgbox.h" + +#include "ui/rml/rmlview.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/screens/msgbox/uimsgbox.h b/code/ui/screens/msgbox/uimsgbox.h new file mode 100644 index 000000000..c6c3199fb --- /dev/null +++ b/code/ui/screens/msgbox/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 UIViewClass; + + +// 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/screens/msgbox/uimsgboxdlg.cpp b/code/ui/screens/msgbox/uimsgboxdlg.cpp new file mode 100644 index 000000000..bc5b9770e --- /dev/null +++ b/code/ui/screens/msgbox/uimsgboxdlg.cpp @@ -0,0 +1,53 @@ +/******************************************************************************* + * 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/screens/msgbox/uimsgbox.h" + +#include "_ui.h" +#include "ui/uienginehost.h" +#include "ui/uishell.h" +#include "ui/uiview.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 (UIShell.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/screens/sound/uisound.cpp b/code/ui/screens/sound/uisound.cpp new file mode 100644 index 000000000..bb6c3b743 --- /dev/null +++ b/code/ui/screens/sound/uisound.cpp @@ -0,0 +1,168 @@ +/******************************************************************************* + * 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/screens/sound/uisound.h" + +#include "ui/rml/rmlview.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); +} + + +// 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") { + 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") { + 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") { + 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); + 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); +} + + +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/screens/sound/uisound.h b/code/ui/screens/sound/uisound.h new file mode 100644 index 000000000..111c2139f --- /dev/null +++ b/code/ui/screens/sound/uisound.h @@ -0,0 +1,98 @@ +/******************************************************************************* + * 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 UIViewClass; + + +// 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 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/screens/sound/uisounddlg.cpp b/code/ui/screens/sound/uisounddlg.cpp new file mode 100644 index 000000000..666d18b6b --- /dev/null +++ b/code/ui/screens/sound/uisounddlg.cpp @@ -0,0 +1,140 @@ +/******************************************************************************* + * 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/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/uienginehost.h" +#include "ui/uishell.h" +#include "ui/uiview.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; + } +} + + +bool UI_Sound_Dialog(void) +{ + if (UIShell.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/code/ui/screens/version/uiversion.cpp b/code/ui/screens/version/uiversion.cpp new file mode 100644 index 000000000..48fc81b4b --- /dev/null +++ b/code/ui/screens/version/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/screens/version/uiversion.h" + +#include "ui/rml/rmlview.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/screens/version/uiversion.h b/code/ui/screens/version/uiversion.h new file mode 100644 index 000000000..e80ace08b --- /dev/null +++ b/code/ui/screens/version/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 UIViewClass; + + +// 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/screens/version/uiversiondlg.cpp b/code/ui/screens/version/uiversiondlg.cpp new file mode 100644 index 000000000..d900bd185 --- /dev/null +++ b/code/ui/screens/version/uiversiondlg.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 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/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/uienginehost.h" +#include "ui/uishell.h" +#include "ui/uiview.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/code/ui/screens/waitbox/uiwaitbox.cpp b/code/ui/screens/waitbox/uiwaitbox.cpp new file mode 100644 index 000000000..a94b07199 --- /dev/null +++ b/code/ui/screens/waitbox/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/screens/waitbox/uiwaitbox.h" + +#include "ui/rml/rmlview.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/screens/waitbox/uiwaitbox.h b/code/ui/screens/waitbox/uiwaitbox.h new file mode 100644 index 000000000..b1a14e675 --- /dev/null +++ b/code/ui/screens/waitbox/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 UIViewClass; + + +// 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/screens/waitbox/uiwaitboxdlg.cpp b/code/ui/screens/waitbox/uiwaitboxdlg.cpp new file mode 100644 index 000000000..9db26ac65 --- /dev/null +++ b/code/ui/screens/waitbox/uiwaitboxdlg.cpp @@ -0,0 +1,110 @@ +/******************************************************************************* + * 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/screens/waitbox/uiwaitbox.h" + +#include "_ui.h" +#include "ownrdraw.h" +#include "ui/uishell.h" +#include "ui/uiview.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 (!UIShell.Use_Rml() || UIShell.Legacy_Dialog_Visible()) { + return(false); + } + + Presenter = std::make_unique((text != NULL) ? text : "", bar); + View = UI_Wait_Box_View(*Presenter); + + if (!UIShell.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(); + UIShell.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(); + UIShell.Refresh(); + } +} + + +void UIWaitBoxClass::Hide(void) +{ + if (View != nullptr) { + UIShell.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/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/uienginehost.cpp b/code/ui/uienginehost.cpp new file mode 100644 index 000000000..6300f9d5a --- /dev/null +++ b/code/ui/uienginehost.cpp @@ -0,0 +1,249 @@ +/******************************************************************************* + * 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 "_ui.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 "wincursor.h" +#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: + 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 void Present_Now(void) override + { + Video_Present_Now(); + } + + 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 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 void Apply_Cursor(UICursor cursor) override + { + LPCTSTR shape = NULL; + 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(shape != NULL ? LoadCursor(NULL, shape) : Window_Cursor()); + } + + virtual void Restore_Game_Cursor(void) override + { + Win_Cursor_Refresh(); + if (!Win_Cursor_Handle_Set_Cursor()) { + SetCursor(Window_Cursor()); + } + } + + virtual char const * String(int id) const override + { + return(Fetch_String(id)); + } + + virtual void Log(char const * text) override + { + DebugString("%s", text); + } +}; + + +// Built on first use, so the shell's global can take it whatever the order of static +// construction. +UIShellHostClass & UI_Engine_Host(void) +{ + static UIEngineHostClass host; + 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); +} + + +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 new file mode 100644 index 000000000..58db304a7 --- /dev/null +++ b/code/ui/uienginehost.h @@ -0,0 +1,28 @@ +/******************************************************************************* + * 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" +#include "ui/uiscreen.h" + +class UIViewClass; + + +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(UIViewClass & view); diff --git a/code/ui/uihost.h b/code/ui/uihost.h new file mode 100644 index 000000000..1a775416b --- /dev/null +++ b/code/ui/uihost.h @@ -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. + ******************************************************************************/ + +// 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 "ui/uiinput.hh" +#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; + // 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. + 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; + + // 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; + + // 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/uiinput.cpp b/code/ui/uiinput.cpp new file mode 100644 index 000000000..2199b4445 --- /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) +{ + return(Is_UI(owner) || owner == UI_INPUT_SUPPRESSED); +} + + +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..4fd69d50c --- /dev/null +++ b/code/ui/uiinput.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. + ******************************************************************************/ + +// 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 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 +{ + 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/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..51b134678 --- /dev/null +++ b/code/ui/uiscreen.h @@ -0,0 +1,72 @@ +/******************************************************************************* + * 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; +}; + + +// 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: + 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. The owner calls it before every drain, so a + // timed screen advances here. + virtual void Refresh(void) = 0; + + std::optional Result; + + private: + std::vector Pending; +}; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp new file mode 100644 index 000000000..c6542be28 --- /dev/null +++ b/code/ui/uishell.cpp @@ -0,0 +1,1334 @@ +/******************************************************************************* + * 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 "ui/dev/uidev.h" +#include "ui/rml/rmlkeys.h" +#include "ui/rml/rmlrender.h" +#include "ui/rml/rmlsystem.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. +#undef GetFirstChild +#undef GetNextSibling + +#include + +#include +#include +#include +#include + + +namespace +{ + +// Sets a flag for the scope, whichever way the scope ends. +class UIReentryGuardClass +{ + public: + explicit UIReentryGuardClass(bool & flag) : + Flag(flag) + { + Flag = true; + } + + ~UIReentryGuardClass(void) + { + Flag = false; + } + + UIReentryGuardClass(UIReentryGuardClass const &) = delete; + UIReentryGuardClass & operator=(UIReentryGuardClass const &) = delete; + + private: + bool & Flag; +}; + + +class UISystemClockClass : public UIClockClass +{ + public: + virtual int Milliseconds(void) override + { + return((int)GetTickCount64()); + } +}; + + +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); +} + + +// 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_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: + return(true); + + default: + return(false); + } +} + + +// 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); + } +} + +} + + +// 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); +} + + +// 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) { + return(false); + } + + for (int index = 0; index < Context->GetNumDocuments(); index++) { + Rml::ElementDocument * document = Context->GetDocument(index); + if (document != nullptr && document->IsVisible()) { + return(true); + } + } + + return(false); +} + + +bool UIShellClass::Text_Input_Focused(void) const +{ + Rml::Element * focus = Context->GetFocusElement(); + if (focus == nullptr) { + return(false); + } + + Rml::String const & tag = focus->GetTagName(); + return(tag == "input" || tag == "textarea"); +} + + +void UIShellClass::Apply_Dimensions(void) +{ + 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); +} + + +UIPointerPosition UIShellClass::Pointer_Position(LPARAM clientlparam) const +{ + 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))); +} + + +std::array UIShellClass::Physical_Buttons(void) const +{ + std::array held {}; + for (unsigned button = 0; button < held.size(); button++) { + held[button] = Host.Key_Down(Button_Virtual_Key(button)); + } + return(held); +} + + +// 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(); + } +} + + +void UIShellClass::Reset_Text(void) +{ + HighSurrogate = 0; + Utf8.Reset(); + LegacyLead = 0; +} + + +// 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())); +} + + +// 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()) { + return(false); + } + + UICursor request = System->Cursor_Request(); + Host.Apply_Cursor(request); + AppliedCursor = request; + return(true); +} + + +// 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) +{ + if (!Pointer_Owned()) { + if (AppliedCursor.has_value()) { + Restore_Cursor(); + } + return; + } + + UICursor request = System->Cursor_Request(); + if (AppliedCursor != request) { + Host.Apply_Cursor(request); + AppliedCursor = request; + } +} + + +void UIShellClass::Restore_Cursor(void) +{ + AppliedCursor.reset(); + Host.Restore_Game_Cursor(); +} + + +void UIShellClass::Toggle_Test_Document(void) +{ +#ifdef _DEBUG + if (!FontLoaded) { + Log("UI: the test document needs the font, which did not load\n"); + return; + } + + 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 != 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"); + } else { + TestDocument->Show(); + Render->Log_Resource_Counts("test document shown"); + } + + 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 UIShellClass::Init(void) +{ + if (Ready) { + return(true); + } + + if (!Render->Init()) { + return(false); + } + + Rml::SetSystemInterface(System.get()); + if (File != nullptr) { + Rml::SetFileInterface(File.get()); + } + Rml::SetRenderInterface(Render.get()); + + if (!Rml::Initialise()) { + Log("UI: RmlUi did not initialize\n"); + Render->Shutdown(); + return(false); + } + + 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(); + return(false); + } + + Apply_Dimensions(); + + FontLoaded = Rml::LoadFontFace("OpenSans.ttf"); + if (!FontLoaded) { + Log("UI: OpenSans.ttf did not load, so no document can be shown\n"); + } + + 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()); + return(true); +} + + +void UIShellClass::Shutdown(void) +{ + if (!Ready) { + return; + } + + Ready = false; + Modals.clear(); + ModalClosing = false; + + if (Input.Gesture_Owner() != UI_INPUT_NONE) { + Drop_Presses(); + } + 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. + for (UIViewClass * view : Modeless) { + view->Release(); + } + Modeless.clear(); + + UIDev_Shutdown(*Render); + + TestDocument = nullptr; + Deferred = DeferredWorkType(); + + Rml::RemoveContext("main"); + Context = nullptr; + + Rml::Shutdown(); + Render->Shutdown(); + FontLoaded = false; +} + + +bool UIShellClass::Use_Rml(void) const +{ + return(Ready && !Host.Legacy_Dialogs_Requested()); +} + + +bool UIShellClass::Screen_Shown(void) const +{ + return(!Modals.empty() || ModalClosing); +} + + +bool UIShellClass::Legacy_Dialog_Visible(void) const +{ + return(Host.Legacy_Dialog_Visible()); +} + + +UIViewClass * UIShellClass::Modal(void) const +{ + return(Modals.empty() ? nullptr : Modals.back()); +} + + +int UIShellClass::Modal_Depth(void) const +{ + return((int)Modals.size()); +} + + +bool UIShellClass::Is_Modeless_Shown(UIViewClass const & view) const +{ + return(std::find(Modeless.begin(), Modeless.end(), &view) != Modeless.end()); +} + + +void UIShellClass::On_Video_Change(void) +{ + if (!Ready) { + return; + } + + if (InContext) { + Deferred.Resize = true; + } else { + Apply_Dimensions(); + } + + Host.Mark_Overlay_Dirty(); +} + + +void UIShellClass::Tick(void) +{ + if (!Ready || InTick || InContext) { + return; + } + + UIReentryGuardClass ticking(InTick); + + Drain_Deferred(); + Reconcile_Held_Input(); + + { + UIReentryGuardClass updating(InContext); + Context->Update(); + 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) { + Host.Mark_Overlay_Dirty(); + } + DevWasActive = devactive; +} + + +void UIShellClass::Render_Overlay(void) +{ + if (!Ready || InContext || Host.Movie_Playing()) { + return; + } + + UIFrameRect frame = Host.Frame(); + if (frame.Width <= 0 || frame.Height <= 0) { + return; + } + + // 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) { + UIReentryGuardClass rendering(InContext); + Context->Render(); + } + + if (overlays) { + Render->Begin_Dev_Frame(frame.X, frame.Y, frame.Width, frame.Height); + UIDev_Render(*Render); + } + + Drain_Deferred(); +} + + +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(); + return(false); + } + + if (Input.Has_UI_Mouse() || 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); +} + + +// 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()); + + // 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(); + UIInputOwner owner = UI_INPUT_GAME; + + if (UIDev_Active()) { + UIDev_Mouse_Position(position.X, position.Y); + if (UIDev_Mouse_Button(button, true)) { + owner = UI_INPUT_IMGUI; + } + } + + 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; + } + } + + UIInputOwner latched = Input.Press_Mouse((unsigned)button, owner); + if (!haduimouse && Input.Has_UI_Mouse() && !TookCapture) { + TookCapture = Host.Take_Capture(); + } + + 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 (owner == UI_INPUT_IMGUI) { + UIDev_Mouse_Position(position.X, position.Y); + 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 (!Input.Has_UI_Mouse()) { + Release_UI_Capture(); + } + + Host.Mark_Overlay_Dirty(); + return(UI_Consumes_Input(owner)); +} + + +bool UIShellClass::Handle_Wheel(WPARAM wparam, LPARAM screenlparam, bool horizontal) +{ + int x = GET_X_LPARAM(screenlparam); + int y = GET_Y_LPARAM(screenlparam); + Host.Screen_To_Client(x, 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. Sideways, both count rightward as positive. + float delta = (float)(short)HIWORD(wparam) / (float)WHEEL_DELTA; + + if (!horizontal && UIDev_Active()) { + UIDev_Mouse_Position(position.X, position.Y); + if (UIDev_Mouse_Wheel(delta)) { + Host.Mark_Overlay_Dirty(); + return(true); + } + } + + if (!position.Inside) { + return(false); + } + + 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); +} + + +// 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) +{ + 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(UI_Consumes_Input(owner)); + } + + bool repeat = (lparam & (1 << 30)) != 0; + if (!repeat) { + Input.Release_Key(virtualkey); + } + + 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 { + 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(UI_Consumes_Input(owner)); +} + + +bool UIShellClass::Handle_Char(WPARAM wparam) +{ + 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(consumed); + } + + char32_t code = unit; + 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'; + } + if ((code < 32 && code != '\n') || code == 127) { + 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); +} + + +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.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()); + + if (!Prepare_View(view)) { + return(UI_RESULT_FAILED_TO_OPEN); + } + + view.Presenter().Refresh(); + view.Sync(); + + if (Input.Gesture_Owner() != UI_INPUT_NONE) { + Drop_Presses(); + } + Quarantine_Held_Input(); + Reset_Text(); + + char label[160]; + + 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); + Host.Clear_Keyboard_Queue(); + + 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; + } + + view.Presenter().Refresh(); + view.Presenter().Drain(); + view.Sync(); + + if (ended) { + break; + } + if (view.Presenter().Result.has_value()) { + result = *view.Presenter().Result; + break; + } + + Tick(); + Host.Mark_Overlay_Dirty(); + Host.Present_If_Dirty(); + } + + ModalClosing = true; + if (Ready && Input.Gesture_Owner() != UI_INPUT_NONE) { + Drop_Presses(); + } + Input.Cancel_UI(); + Reset_Text(); + view.Presenter().Discard(); + view.Release(); + + if (Ready) { + UIReentryGuardClass updating(InContext); + Context->Update(); + } + + // 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(); + std::snprintf(label, sizeof(label), "%s closed", view.Name()); + Render->Log_Resource_Counts(label); + System->Reset_Cursor_Request(); + Apply_Cursor_Request(); + Host.Clear_Keyboard_Queue(); + Host.Focus_Main_Window(); + } + + return(result); +} + + +// 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); + } + + if (!Prepare_View(view)) { + return(false); + } + + view.Presenter().Refresh(); + view.Sync(); + 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) +{ + // 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); + Context->Update(); + } + Host.Mark_Overlay_Dirty(); + Host.Present_Now(); + } +} + + +UIClockClass & UIShellClass::Clock(void) +{ + static UISystemClockClass clock; + return(clock); +} + + +void UIShellClass::Refresh(void) +{ + if (!Ready || InContext) { + return; + } + + Tick(); + Host.Mark_Overlay_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(); +} + + +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, 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(); + if (lost && Input.Gesture_Owner() != UI_INPUT_NONE) { + TookCapture = false; + if (InContext) { + Deferred.DropPresses = true; + } else { + UIReentryGuardClass hooking(InHook); + Drop_Presses(); + } + } + return(false); + } + + if (message == WM_ACTIVATEAPP) { + bool activated = (wparam != 0); + if (InContext) { + Deferred.DevFocus = activated ? 1 : 0; + } else { + UIDev_Focus(activated); + } + 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 (message == WM_INPUTLANGCHANGE) { + Reset_Text(); + return(false); + } + + if (InContext || !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)); + } + + UIReentryGuardClass hooking(InHook); + bool consumed = false; + + switch (message) { + case WM_MOUSEMOVE: + consumed = Handle_Mouse_Move(lparam); + break; + + case WM_LBUTTONDOWN: + case WM_LBUTTONDBLCLK: + case WM_RBUTTONDOWN: + case WM_RBUTTONDBLCLK: + case WM_MBUTTONDOWN: + case WM_MBUTTONDBLCLK: + case WM_XBUTTONDOWN: + case WM_XBUTTONDBLCLK: + consumed = Handle_Button_Down(Message_Button(message, wparam), lparam); + break; + + case WM_LBUTTONUP: + case WM_RBUTTONUP: + case WM_MBUTTONUP: + case WM_XBUTTONUP: + consumed = Handle_Button_Up(Message_Button(message, wparam), lparam); + break; + + case WM_MOUSEWHEEL: + 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, lparam); + break; + + case WM_CHAR: + consumed = Handle_Char(wparam); + break; + + default: + break; + } + + // A shown screen takes every mouse and key message, as a visible legacy dialog does. + if (!Modals.empty() && Input_Message(message)) { + consumed = true; + } + + return(consumed); +} + + +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 (msg.message == WM_KEYDOWN && (msg.lParam & (1 << 30)) == 0) { + Deferred.ToggleTest = true; + } + return(true); + } + 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) { + Deferred.ToggleDev = 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..488cf1a0c --- /dev/null +++ b/code/ui/uishell.h @@ -0,0 +1,206 @@ +/******************************************************************************* + * 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 engine +// holds one instance, UIShell, declared in _ui.h; no toolkit type appears here. + +#pragma once + +#include "ui/uicoord.h" +#include "ui/uiinput.h" +#include "ui/uiscreen.h" +#include "win.h" + +#include +#include +#include +#include +#include + +namespace Rml +{ + class Context; + class ElementDocument; + class EventListener; + class FileInterface; +} + +class UIRmlRenderClass; +class UIRmlSystemClass; +class UIViewClass; +class UIShellHostClass; + +// Runs the game for one pass under a modal screen and reports whether it ended. +using UIServiceCallback = std::function; + + +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(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(UIViewClass & view); + void Hide_Modeless(UIViewClass & 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 lparam); + + // 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); + + // 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; + bool Is_Modeless_Shown(UIViewClass const & view) const; + UIInputStateClass const & Input_State(void) const { return(Input); } + + 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 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); + 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); + bool Handle_Button_Down(int button, LPARAM clientlparam); + bool Handle_Button_Up(int button, LPARAM clientlparam); + 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; + 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; + + // 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 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. + 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; +}; 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/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/code/video.cpp b/code/video.cpp index f262d5b5d..aabc72565 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" @@ -23,8 +24,11 @@ #include "goptions.h" #include "misc.h" #include "surface.h" +#include "ui/uishell.h" +#include "videodirty.h" #include "wincursor.h" +#include #include @@ -44,16 +48,30 @@ 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; +// 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; +// 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. +// 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; /// @@ -184,7 +202,13 @@ void Video_Shutdown(void) Win_Cursor_Shutdown(); Backend_Shutdown(); _Initialized = false; - _FrameIsDirty = false; + _Dirty.Reset(); + _ResizePending = false; + _PresentsThisSecond = 0; + _PresentsLastSecond = 0; + _PresentSecondStart = 0; + _PresentCount = 0; + _FrameUploadCount = 0; } @@ -211,7 +235,8 @@ bool Video_Set_Mode(int width, int height) Update_Scale_Info(); Win_Cursor_Refresh(); - _FrameIsDirty = true; + UIShell.On_Video_Change(); + _Dirty.Invalidate_Frame(); return(true); } @@ -225,12 +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(); - Video_Mark_Dirty(); + UIShell.On_Video_Change(); + // The renderer keeps the uploaded frame across a reset, so only the overlay is owed. + Video_Mark_Overlay_Dirty(); } @@ -244,7 +280,7 @@ void Video_Set_Refresh_Rate(int refreshrate) } Update_Present_Interval(refreshrate); - Video_Mark_Dirty(); + Video_Mark_Overlay_Dirty(); } @@ -253,44 +289,97 @@ void Video_Set_Refresh_Rate(int refreshrate) /// void Video_Mark_Dirty(void) { - _FrameIsDirty = true; + _Dirty.Mark_Game(); } /// -/// 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) +{ + _Dirty.Mark_Overlay(); +} + + +/// +/// 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; 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; } + if (MainWindow != NULL && IsIconic(MainWindow)) { + return; + } - DSurface * surface = (DSurface *)VisibleSurface; - void * pixels = surface->Get_Buffer(); + VideoDirtySnapshotType snapshot = _Dirty.Consume(); - if (pixels == NULL) { + DSurface * surface = (DSurface *)VisibleSurface; + void * pixels = snapshot.Upload ? surface->Get_Buffer() : NULL; + if (snapshot.Upload && pixels == NULL) { + _Dirty.Restore(snapshot); return; } + _LastPresentTime = timeGetTime(); + _Presenting = true; - Backend_Present(pixels, surface->Stride(), _ScaleInfo.DestX, _ScaleInfo.DestY, _ScaleInfo.DestWidth, _ScaleInfo.DestHeight, Backend_Scale_Mode()); + 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(); _Presenting = false; - _FrameIsDirty = false; - _LastPresentTime = timeGetTime(); + 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); + } +} + + +/// +/// Puts the visible surface on the screen whatever its state. +/// +void Video_Present(void) +{ + _Dirty.Mark_Game(); + Present(); } /// -/// 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 (!_Dirty.Is_Dirty()) { return; } @@ -299,7 +388,22 @@ void Video_Present_If_Dirty(void) return; } - Video_Present(); + Present(); +} + + +/// +/// 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(); } @@ -312,6 +416,36 @@ 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); +} + + +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 d3ec1b094..54705ad0d 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 { @@ -46,9 +48,19 @@ 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); +void Video_Present_Now(void); 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/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/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); } diff --git a/code/windlg.cpp b/code/windlg.cpp index 93ba040cb..3e3c68f6b 100644 --- a/code/windlg.cpp +++ b/code/windlg.cpp @@ -11,15 +11,18 @@ #include "windlg.h" +#include "_ui.h" #include "arraylist.h" #include "data.h" #include "globals.h" #include "init.h" #include "msgloop.h" #include "ownrdraw.h" +#include "ui/uishell.h" #include "video.h" #include "win.h" +#include #include #include @@ -96,6 +99,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(!UIShell.Screen_Shown()); + WSDialogStruct *slot = &g_Dialogs[g_DialogCount]; g_Dialogs[g_DialogCount].handle = 0; g_Dialogs[g_DialogCount].id = 0; diff --git a/code/winstub.cpp b/code/winstub.cpp index e11e6fc62..11a5c3620 100644 --- a/code/winstub.cpp +++ b/code/winstub.cpp @@ -48,35 +48,37 @@ #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" #include "session.h" #include "theme.h" +#include "ui/uishell.h" #include "video.h" #include "win.h" #include "wincursor.h" #include "windlg.h" #include "winfix.h" #include "wwmouse.h" -#include "mainopt.h" -#include "conquer.h" -#include "opents_version.h" #include #include @@ -175,6 +177,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 +193,10 @@ LRESULT CALLBACK /*_export*/ Windows_Procedure(HWND hwnd, UINT message, WPARAM w lParam = translated_lparam; } + if (UIShell.Handle_Window_Message(hwnd, message, wParam, client_lparam)) { + return(0); + } + int low_param = LOWORD(wParam); Map.Message_Handler(hwnd, message, wParam, lParam); @@ -234,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/BUILDING.md b/docs/BUILDING.md index abfd7448c..ba643ff21 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -54,6 +54,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. @@ -96,8 +104,12 @@ the selected build directory. | Configuration | Runtime files | | --- | --- | -| Debug | `GameD.exe`, `GameD.pdb`, `GameD.map`, `Language.dll` | -| Release | `Game.exe`, `Game.pdb`, `Game.map`, `Language.dll` | +| Debug | `GameD.exe`, `GameD.pdb`, `GameD.map`, `Language.dll`, `ui/` | +| Release | `Game.exe`, `Game.pdb`, `Game.map`, `Language.dll`, `ui/` | + +`ui/` is the repository's directory of UI documents, styles, and font, placed +beside the executable by its own target, `OpenTSUIFiles`, so an edited +document reaches the output without a relink. Run a build from its output directory, naming the game data with `-DATADIR=`: @@ -208,8 +220,9 @@ the latest successful scheduled run attached to downloadable artifacts. Both use the reusable `Engine build` workflow. It runs one job per platform and configuration, four by default, each on its own Windows runner with Visual Studio 2022. A job configures and builds its platform with the commands above, -runs CTest, and uploads the executable, language library, symbol file, and -license notices. Artifact names contain the platform, configuration, and short +runs CTest, and uploads the executable, language library, symbol file, `ui/` +directory, and license notices. Artifact names contain the platform, +configuration, and short commit, as in `opents-x64-Release-ab12cd3`. Linker maps are omitted because the symbol files are sufficient. A failure on either platform fails the workflow. After a successful pull-request build, `Engine build comment` maintains one @@ -217,8 +230,8 @@ pull-request comment with direct nightly.link downloads. Publishing a GitHub release runs `Engine release`. It builds the release commit for both platforms with `-DOPENTS_OFFICIAL_BUILD=ON`, and packages each one's -`Game.exe`, `Language.dll`, `Game.pdb`, and the project and third-party license -notices in a zip named after the release tag and the platform, such as +`Game.exe`, `Language.dll`, `Game.pdb`, `ui/`, and the project and third-party +license notices in a zip named after the release tag and the platform, such as `OpenTS-v0.2.0-x64.zip`. It attaches both to the release, and appends notes generated from the manual's change records by `python manual/tools/manage.py release-notes`. See @@ -228,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 edebbfcb8..a6f6f7987 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,9 +1,14 @@ # 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: 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 @@ -109,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 @@ -130,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. @@ -137,10 +151,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 @@ -160,7 +176,10 @@ 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`. +- 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 @@ -169,27 +188,41 @@ 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 -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. +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 | -| --- | --- | -| `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 | +| 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); `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), `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 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 @@ -205,6 +238,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 @@ -226,31 +261,48 @@ 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 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. +| 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 +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. 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 -`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. @@ -305,19 +357,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: @@ -336,16 +393,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 @@ -353,15 +420,27 @@ 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. 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 +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. @@ -381,14 +460,30 @@ class UIPresenterClass { // uiscreen.h: no toolkit type std::optional Result; }; -class UIRmlViewClass { // uirmlview.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 @@ -433,9 +528,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 @@ -449,35 +546,51 @@ 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 +// 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 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 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. 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. A notice a caller shows while it works goes through +`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 @@ -495,19 +608,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 @@ -519,9 +636,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: @@ -531,49 +650,61 @@ 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. 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 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 `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 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 +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 +`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 +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 @@ -626,11 +757,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 @@ -651,7 +785,34 @@ 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. | +| 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 @@ -661,14 +822,14 @@ 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. Dependency upgrades are separate changes. ## Migration plan @@ -680,35 +841,65 @@ 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 - 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. -3. **Version dialog** (S, leaf). The integration pilot: fonts, clipping, +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; 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 + close leaks nothing. +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 - `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. -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. -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. +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, 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, + eligible themes, selection, availability, shuffle and repeat, immediate + previews, play and stop, both templates, frontend and in-game service + 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 + 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, 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 `rml/rmlkeys.cpp` turns back into the `KEYBOARD.INI` + number; the options menu is `mainopt.rml`, placed where the main menu's + 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, multiplayer game selection. The `NewMenuClass` drivers keep their loops. @@ -725,18 +916,26 @@ text 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 -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: +Three CTest targets run without game assets and build into +`/test-bin/`. -- Load every shipped document and fail on a parse error or a property - outside the declared profile. +`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 + 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 @@ -745,8 +944,34 @@ and a null system interface. It 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. +- 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 + 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. +- 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 +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 @@ -759,10 +984,41 @@ 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 | +| Wait notice, over eleven consecutive quicksaves | yes | not yet | +| Progress, with its bar | never shown | never shown | + +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 +`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. -- `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 @@ -772,8 +1028,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. - The document and binding versioning rules for mods, fixed with the first 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/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/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/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/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/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/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/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/changes/rmlui-version-dialog.md b/manual/changes/rmlui-version-dialog.md new file mode 100644 index 000000000..95bbe3c2f --- /dev/null +++ b/manual/changes/rmlui-version-dialog.md @@ -0,0 +1,21 @@ +--- +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 +--- + +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/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/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-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/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/formats/keyboard-ini.md b/manual/content/formats/keyboard-ini.md index d7dc75c02..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 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; 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/manual/content/keys/legacydialogs.md b/manual/content/keys/legacydialogs.md new file mode 100644 index 000000000..af355709d --- /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. 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/developer-mode.md b/manual/content/systems/developer-mode.md index 6f2b32430..1e6f0968a 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 @@ -45,6 +45,16 @@ 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, 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 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. @@ -99,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..e113e4f08 --- /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 places the directory beside the executable, where `Language.dll` is built, 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), 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/manual/content/using/build-and-run.md b/manual/content/using/build-and-run.md index c1eed1c48..9c8187b60 100644 --- a/manual/content/using/build-and-run.md +++ b/manual/content/using/build-and-run.md @@ -24,7 +24,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A Win32 cmake --build build --config Debug ``` -The Debug build writes `GameD.exe`, its symbols, map file, and the matching `Language.dll` to `build/bin/Debug/`. Use `--config Release` to write `Game.exe` to `build/bin/Release/` instead. Nothing is copied out of the build directory, so the two configurations never overwrite each other. +The Debug build writes `GameD.exe`, its symbols, map file, and the matching `Language.dll` to `build/bin/Debug/`, and places the repository's `ui/` directory of UI documents, styles, and font beside them. Use `--config Release` to write `Game.exe` to `build/bin/Release/` instead. Nothing is copied out of the build directory, so the two configurations never overwrite each other. `-A x64` builds the 64-bit executable. A build directory holds one platform, so give the 64-bit build its own, such as `-B build/x64`. A saved game belongs to the platform that wrote it, and a network game needs every player on the same platform. 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 diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 3496023ef..181c14c69 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -280,6 +280,24 @@ 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: 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. + audience: debug + bindings: [F6] + context: Any game window focused, with Debug_Flag enabled + availability: *debug + sites: + - { 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 } @@ -428,6 +446,30 @@ 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. + - 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/manual/data/commands.yaml b/manual/data/commands.yaml index d286e7be0..cdffab980 100644 --- a/manual/data/commands.yaml +++ b/manual/data/commands.yaml @@ -2197,6 +2197,32 @@ 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 +- 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/manual/data/ini-keys.yaml b/manual/data/ini-keys.yaml index 7d90356fb..a88f136d7 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/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, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5017e9a09..260be4591 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,3 +106,12 @@ add_subdirectory(shapefacing) 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. +add_test(NAME toolkitheaders + COMMAND "${CMAKE_COMMAND}" -DOPENTS_SOURCE_DIR=${CMAKE_SOURCE_DIR} + -P "${CMAKE_SOURCE_DIR}/cmake/CheckToolkitHeaders.cmake" +) diff --git a/tests/uilogic/CMakeLists.txt b/tests/uilogic/CMakeLists.txt new file mode 100644 index 000000000..223f41a4e --- /dev/null +++ b/tests/uilogic/CMakeLists.txt @@ -0,0 +1,12 @@ +# The toolkit-free UI state: the input ownership the shell routes messages by, the text +# 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 new file mode 100644 index 000000000..667cae9ae --- /dev/null +++ b/tests/uilogic/uilogictest.cpp @@ -0,0 +1,260 @@ +/******************************************************************************* + * 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, how the bytes of a narrow window's text +// 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 { + +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()), "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)), "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)), "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); + 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) && 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"); +} + + +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"); +} + + +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; + + 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"); +} + +} + + +int main(void) +{ + Test_Ownership(); + Test_Reconciliation(); + Test_Text(); + Test_Render_Math(); + Test_Dirty_State(); + + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); + return(Failures == 0 ? 0 : 1); +} diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt new file mode 100644 index 000000000..42cbe23ee --- /dev/null +++ b/tests/uishell/CMakeLists.txt @@ -0,0 +1,32 @@ +# 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. 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. 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 uidevstub.cpp + ENGINE + ui/uiscreen.cpp + ui/uiinput.cpp + 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 + 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 +) 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 new file mode 100644 index 000000000..29688c0a5 --- /dev/null +++ b/tests/uishell/uishell.cpp @@ -0,0 +1,3058 @@ +/******************************************************************************* + * 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 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 their +// strings by names the generated table knows, and the pointer mapping into the overlay +// behaves at the frame's edges. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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" +#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/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 +// element walkers. +#undef GetFirstChild +#undef GetNextSibling + +#include +#include +#include +#include FT_FREETYPE_H +#include + +#include "opents_strings.h" + +namespace { + +int Failures = 0; + + +void Check(bool condition, char const * what) +{ + std::printf("%-76s %s\n", what, condition ? "ok" : "FAILED"); + + if (!condition) { + Failures++; + } +} + + +// 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. 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; + int Rendered = 0; + int ReleasedGeometry = 0; + int Loaded = 0; + int Generated = 0; + int ReleasedTextures = 0; + int Unsupported = 0; + int Invalid = 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 + { + } + + // 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); + } + + virtual void RenderGeometry(Rml::CompiledGeometryHandle, Rml::Vector2f, Rml::TextureHandle) override + { + Rendered++; + if (OnRender) { + OnRender(); + } + } + + 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); + } +}; + + +// 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; + bool Unicode = false; + unsigned int CodePage = 65001; + bool Down[256] = {}; + int Presents = 0; + int PresentsNow = 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); + } + + 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); + } + + 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 void Present_Now(void) override + { + Presents++; + PresentsNow++; + 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)); +} + + +// 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); + 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); +} + + +void Test_FreeType(void) +{ + FT_Library library = nullptr; + Check(FT_Init_FreeType(&library) == 0 && library != nullptr, "FreeType initializes 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 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"); + + ImGuiContext * context = ImGui::CreateContext(); + Check(context != nullptr, "ImGui creates a context"); + std::printf(" Dear ImGui %s\n", ImGui::GetVersion()); + if (context == nullptr) { + return; + } + + 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); +} + + +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"); +} + + +// 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(); +} + + +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"); + } +} + + +// 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); +} + + +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. +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 && 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(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"); + + 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) +{ + 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", 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(); + + 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"); +} + + +// 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; + 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"); + 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() && 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"); + } + + { + 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) +{ + 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); +} + + +// 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)); +} + + +// 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: + 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(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 = 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. + 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 = 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"); + 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(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); + 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(); + } +} + + +// 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 = Center_Of(element); + 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(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(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"); + + 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(Rml(*view).Prepare(context), "a two-button box prepares"); + view->Show(true); + context.Update(); + + std::vector buttons = Visible_Buttons(Rml(*view).Document()); + Check(buttons.size() == 2, "two buttons are visible"); + if (buttons.size() == 2) { + 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"); + } + + 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(Rml(*view).Prepare(context), "a one-button box prepares"); + view->Show(true); + context.Update(); + + std::vector buttons = Visible_Buttons(Rml(*view).Document()); + Check(buttons.size() == 1, "one button is visible"); + if (buttons.size() == 1) { + 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"); + } + + view->Release(); + context.Update(); + } + + { + UIMessageBoxPresenterClass presenter("Default", { "Yes", "No", "Maybe" }, 2); + 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); + 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"); + } +} + + +// 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(Rml(*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"); + + // 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 = Rml(*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(Rml(*view).Prepare(context), "the frontend sound view prepares"); + view->Show(true); + context.Update(); + + 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(Rml(*view).Document(), "track").empty(), "the frontend sound screen lists no tracks"); + + view->Release(); + context.Update(); + } +} + + +// 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(Rml(*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 = Rml(*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(Rml(*view).Prepare(context), "the frontend game controls view prepares"); + view->Show(true); + context.Update(); + + 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"); + 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(Rml(*view).Prepare(context), "the Internet game controls view prepares"); + view->Show(true); + context.Update(); + + 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"); + 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 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(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 = 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"); + + 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(Rml(*view).Prepare(context), "a second display view prepares"); + view->Show(true); + context.Update(); + + std::vector rows = Visible_Of_Class(Rml(*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(Rml(*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 = Rml(*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(Rml(*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 = 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"); + + view->Release(); + context.Update(); + } +} + + +// 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(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 = 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"); + + 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(Rml(*view).Prepare(context), "a second keyboard view prepares"); + view->Show(true); + view->Sync(); + context.Update(); + + 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 = Rml(*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(); + } +} + + +// 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(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(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 = 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"); + + 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(Rml(*view).Prepare(context), "a second options menu view prepares"); + view->Show(true); + context.Update(); + + 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 = 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); + 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) +{ + int problems = system.Problems; + + { + UIWaitBoxPresenterClass presenter("Mission saving - Please Wait...", false); + std::unique_ptr view = UI_Wait_Box_View(presenter); + + 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 = Rml(*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(Rml(*view).Prepare(context), "a wait box with a bar prepares"); + view->Show(false); + context.Update(); + + 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"); + + 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); + + // 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); + 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 invalid = render.Invalid; + int problems = system.Problems; + render.Scissors.clear(); + + // 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()) { + 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()); + 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(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; + 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(); + + 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); + Test_Message_Box_Screen(*context, system); + Test_Sound_Screen(*context, system); + 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); + } + + 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); +} + + +// 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 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; + 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"); + } + + { + 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"); + } + + { + 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 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"); + 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) { + 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; + } + 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"); + } + + { + // 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); + } + LPARAM at = Element_Point(host, buttons[0]); + + 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"); + } + + { + // 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); + + 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(); + 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 initializes 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.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"); +} + +} + + +int main(void) +{ + Test_FreeType(); + Test_ImGui(); + Test_Coordinates(); + Test_Display_Presenter(); + Test_Game_Controls_Presenter(); + Test_Keys(); + Test_Keyboard_Presenter(); + Test_Sound_Presenter(); + Test_Strings(); + Test_Documents(); + Test_Shell(); + + 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 64f91576d..16c4f5b29 100644 --- a/thirdparty/CMakeLists.txt +++ b/thirdparty/CMakeLists.txt @@ -102,6 +102,85 @@ target_include_directories(lzo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/lzo/src" ) +# +# --------------------------------------------------------- +# 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-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/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. +------------------------------------------------------------------------------ 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 000000000..9db85693b Binary files /dev/null and b/ui/OpenSans.ttf differ 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..7155f1bea --- /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}} second seconds.

+
+ + +
+ +
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}}
+
+ + + +
+ +
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}}
+
+ + + +
+ + +
+
+ + + +
+
+ +
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}}

+
+ +
+ + + +
+
+ +
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 + + + +
+ + + + + +
+ +
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}}

+
+ +
+ +
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}}
+
+ + +
+ +
+ +
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.

+ +
+ +
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}}

+
+ +
+ +
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}}

+
+ +
+
+ +