From 51339606a2365ac035e364fa66009c0c39021ca8 Mon Sep 17 00:00:00 2001 From: khodowany Date: Fri, 4 Sep 2026 19:30:11 -0500 Subject: [PATCH] Initial public khodo snapshot --- .gitea/workflows/ci.yml | 105 ++ .gitignore | 20 + CMakeLists.txt | 77 ++ LICENSE | 21 + README.md | 43 + cmake/luma_config.cmake.in | 6 + conanfile.py | 104 ++ include/luma.hpp | 15 + include/luma/animation.hpp | 54 + include/luma/application.hpp | 81 ++ include/luma/assets.hpp | 36 + include/luma/config.hpp | 45 + include/luma/control.hpp | 40 + include/luma/controls.hpp | 185 +++ include/luma/detail/errors.hpp | 28 + include/luma/geometry.hpp | 70 + include/luma/input.hpp | 29 + include/luma/overlays.hpp | 67 + include/luma/render.hpp | 39 + include/luma/routing.hpp | 40 + include/luma/theme.hpp | 49 + include/luma/window.hpp | 94 ++ modulus.json | 11 + modulus.py | 367 +++++ src/animation.cpp | 193 +++ src/application.cpp | 1264 +++++++++++++++++ src/assets.cpp | 126 ++ src/config.cpp | 261 ++++ src/control.cpp | 77 ++ src/controls.cpp | 1631 ++++++++++++++++++++++ src/detail/errors.cpp | 10 + src/overlays.cpp | 359 +++++ src/routing.cpp | 131 ++ src/theme.cpp | 352 +++++ src/window.cpp | 540 ++++++++ tests/CMakeLists.txt | 28 + tests/test.cpp | 2387 ++++++++++++++++++++++++++++++++ tests/test.hpp | 4 + 38 files changed, 8989 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmake/luma_config.cmake.in create mode 100644 conanfile.py create mode 100644 include/luma.hpp create mode 100644 include/luma/animation.hpp create mode 100644 include/luma/application.hpp create mode 100644 include/luma/assets.hpp create mode 100644 include/luma/config.hpp create mode 100644 include/luma/control.hpp create mode 100644 include/luma/controls.hpp create mode 100644 include/luma/detail/errors.hpp create mode 100644 include/luma/geometry.hpp create mode 100644 include/luma/input.hpp create mode 100644 include/luma/overlays.hpp create mode 100644 include/luma/render.hpp create mode 100644 include/luma/routing.hpp create mode 100644 include/luma/theme.hpp create mode 100644 include/luma/window.hpp create mode 100644 modulus.json create mode 100755 modulus.py create mode 100644 src/animation.cpp create mode 100644 src/application.cpp create mode 100644 src/assets.cpp create mode 100644 src/config.cpp create mode 100644 src/control.cpp create mode 100644 src/controls.cpp create mode 100644 src/detail/errors.cpp create mode 100644 src/overlays.cpp create mode 100644 src/routing.cpp create mode 100644 src/theme.cpp create mode 100644 src/window.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test.cpp create mode 100644 tests/test.hpp diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..24b1552 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,105 @@ +name: publish + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + actions: write + +env: + CONAN_HOME: build/build_common/conan_cache + MODULUS_DIRECT: "1" + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: | + if [ -r /etc/os-release ]; then + . /etc/os-release + fi + if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + printf '%s\n' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + '' \ + 'Types: deb' \ + 'URIs: http://ports.ubuntu.com/ubuntu-ports/' \ + "Suites: ${VERSION_CODENAME}-security" \ + 'Components: main universe restricted multiverse' \ + 'Architectures: arm64' \ + 'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \ + > /etc/apt/sources.list.d/ubuntu-arm64.sources + elif [ -f /etc/apt/sources.list ]; then + sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list + sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list + printf '%s\n' \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \ + "deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \ + > /etc/apt/sources.list.d/ubuntu-arm64.list + fi + fi + dpkg --add-architecture arm64 + apt-get update + apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + cmake \ + g++-12 \ + g++-aarch64-linux-gnu \ + gcc-12 \ + gcc-aarch64-linux-gnu \ + git \ + libdrm-dev \ + libdrm-dev:arm64 \ + libegl-dev \ + libegl-dev:arm64 \ + libgbm-dev \ + libgbm-dev:arm64 \ + libgles-dev \ + libgles-dev:arm64 \ + libglvnd-dev \ + libglvnd-dev:arm64 \ + ninja-build \ + pkg-config \ + python3 \ + python3-venv + python3 -m venv build/build_common/ci_python + build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 + echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH" + - name: Restore Conan cache + uses: actions/cache@v4 + with: + path: build/build_common/conan_cache + key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }} + restore-keys: conan-${{ runner.os }}- + - name: Configure native release + run: python3 modulus.py configure --target x86_64_gcc12_release + - name: Build native release + run: python3 modulus.py build --target x86_64_gcc12_release + - name: Test native release + run: python3 modulus.py test --target x86_64_gcc12_release + - name: Create and upload packages + if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} + env: + CONAN_USER: ${{ secrets.CONAN_USER }} + CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }} + LUMA_CONAN_UPLOAD: "1" + MODULUS_ALLOW_PACKAGE_REPLACE: "1" + run: python3 modulus.py package --all --replace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f782367 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +build/ +.clangd +.clang-format +pyrightconfig.json + +**/__pycache__/ +*.pyc +*.pyo +*.pyd +*.log + +package/CMakeCache.txt +package/CMakeFiles/ +package/metadata/ +package/.ninja_* +package/*.json +package/*.cmake +package/*.ninja +package/*.sh +package/*-smoke diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..a3e9f50 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,77 @@ +cmake_minimum_required(VERSION 3.25) + +project(luma VERSION 0.1.0 LANGUAGES CXX) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) +include(CTest) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(modulus CONFIG REQUIRED) + +add_library(luma + src/detail/errors.cpp + src/animation.cpp + src/application.cpp + src/assets.cpp + src/config.cpp + src/control.cpp + src/controls.cpp + src/overlays.cpp + src/routing.cpp + src/theme.cpp + src/window.cpp +) +target_include_directories(luma PUBLIC + $ + $ +) +target_compile_features(luma PUBLIC cxx_std_23) +target_compile_definitions(luma PRIVATE + LUMA_RAYLIB_CUSTOM_FRAME_CONTROL=1 +) +target_link_libraries(luma PUBLIC + modulus::modulus + modulus::boost_json + modulus::raylib +) +add_library(luma::luma ALIAS luma) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + +install(TARGETS luma + EXPORT luma-targets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT luma-targets + NAMESPACE luma:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/luma +) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/luma_config.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/luma-config.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/luma +) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/luma-config-version.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/luma-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/luma-config-version.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/luma +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f93603 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Khodo + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0d7c2e --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Luma + +Luma is a C++23 embedded touch UI library. It provides the reusable application shell, view routing, layout primitives, touch input handling, focus state, themes, text rendering conventions, frame timing, and controls used by Product Vamp. + +Luma uses Modulus for shared build tooling, Boost.JSON, and raylib integration. Product-specific screens and workflows belong in consuming applications. + +## Build + +Configure, build, and test the default target: + +```sh +python3 modulus.py configure +python3 modulus.py build +python3 modulus.py test +``` + +Release verification: + +```sh +python3 modulus.py configure --target x86_64_gcc12_release +python3 modulus.py build --target x86_64_gcc12_release +python3 modulus.py test --target x86_64_gcc12_release +``` + +Create packages: + +```sh +python3 modulus.py package --all +``` + +Configure/build/test can read public packages anonymously from the khodo Conan registry. Publishing requires credentials only on the upload step: + +```sh +CONAN_USER= \ +CONAN_TOKEN= \ +LUMA_CONAN_UPLOAD=1 \ +MODULUS_ALLOW_PACKAGE_REPLACE=1 \ +python3 modulus.py package --all --replace +``` + +## License + +Luma is released under the MIT License. diff --git a/cmake/luma_config.cmake.in b/cmake/luma_config.cmake.in new file mode 100644 index 0000000..b3bef84 --- /dev/null +++ b/cmake/luma_config.cmake.in @@ -0,0 +1,6 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(modulus CONFIG REQUIRED) + +include("${CMAKE_CURRENT_LIST_DIR}/luma-targets.cmake") diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 0000000..81f99da --- /dev/null +++ b/conanfile.py @@ -0,0 +1,104 @@ +import conan +import conan.tools.build +import conan.tools.cmake + +class luma_conan(conan.ConanFile): + name = "luma" + version = "latest" + description = "C++23 embedded touch UI library" + license = "MIT" + package_type = "library" + settings = "os", "arch", "compiler", "build_type" + options = { + "shared": [ + True, + False, + ], + "fPIC": [ + True, + False, + ], + "with_tests": [ + True, + False, + ], + } + default_options = { + "shared": False, + "fPIC": True, + "with_tests": False, + } + exports = "modulus.json" + exports_sources = ( + "CMakeLists.txt", + "cmake/*.in", + "include/*.hpp", + "include/luma/*.hpp", + "include/luma/detail/*.hpp", + "src/*.cpp", + "tests/CMakeLists.txt", + "tests/*.cpp", + "tests/*.hpp", + ) + + def config_options(self): + if self.settings.os == "Windows": + del self.options.fPIC + + def configure(self): + if self.options.shared: + del self.options.fPIC + + def requirements(self): + self.requires( + "modulus/latest", + transitive_headers=True, + transitive_libs=True, + ) + + def generate(self): + deps = conan.tools.cmake.CMakeDeps(self) + deps.generate() + toolchain = conan.tools.cmake.CMakeToolchain(self) + if hasattr(toolchain, "user_presets_path"): + toolchain.user_presets_path = False + toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared) + toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests) + toolchain.generate() + + def build(self): + cmake = conan.tools.cmake.CMake(self) + cmake.configure() + cmake.build() + if self.options.with_tests and conan.tools.build.can_run(self): + cmake.test(target="check") + + def package_id(self): + self.info.options.rm_safe("with_tests") + for requirement in ( + "openssl", + "sqlite3", + "miniaudio", + ): + if requirement in self.info.requires.pkg_names: + self.info.requires.remove(requirement) + + def package(self): + cmake = conan.tools.cmake.CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.set_property( + "cmake_file_name", + "luma", + ) + self.cpp_info.set_property( + "cmake_target_name", + "luma::luma", + ) + self.cpp_info.libs = ["luma"] + self.cpp_info.requires = [ + "modulus::modulus", + "modulus::boost_json", + "modulus::raylib", + ] diff --git a/include/luma.hpp b/include/luma.hpp new file mode 100644 index 0000000..e10f147 --- /dev/null +++ b/include/luma.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/include/luma/animation.hpp b/include/luma/animation.hpp new file mode 100644 index 0000000..15dac17 --- /dev/null +++ b/include/luma/animation.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace luma { + +enum class easing { + LINEAR, + EASE_OUT, + EASE_IN_OUT, +}; + +struct animation { + std::float32_t from{}; + std::float32_t to{}; + std::chrono::milliseconds duration{200}; + luma::easing easing{luma::easing::EASE_OUT}; + std::function apply{}; + std::function finished{}; +}; + +class animation_graph { +public: + auto add(luma::animation animation) -> std::expected; + auto tick(std::chrono::steady_clock::duration delta) -> std::expected; + auto empty() const -> std::bool_t; + auto clear() -> void; +private: + struct running_animation { + luma::animation animation{}; + std::chrono::steady_clock::duration elapsed{}; + }; + enum class pending_kind { + ADD, + CLEAR, + }; + struct pending_operation { + luma::animation_graph::pending_kind kind{luma::animation_graph::pending_kind::CLEAR}; + luma::animation_graph::running_animation animation{}; + }; + std::vector animations_{}; + std::vector pending_{}; + std::bool_t ticking_{false}; + std::bool_t clear_requested_{false}; +}; + +auto interpolate(std::float32_t from, std::float32_t to, std::float32_t progress, luma::easing easing) -> std::expected; + +} diff --git a/include/luma/application.hpp b/include/luma/application.hpp new file mode 100644 index 0000000..b4e5af2 --- /dev/null +++ b/include/luma/application.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace luma { + +namespace detail { +struct application_core; +} + +struct frame_stats { + std::uint64_t frame{}; + std::chrono::steady_clock::duration delta{}; +}; + +class application { +public: + explicit application(luma::application_config config = {}); + application(luma::application const&) = delete; + application(luma::application&&) noexcept; + ~application(); + auto operator=(luma::application const&) -> luma::application& = delete; + auto operator=(luma::application&&) noexcept -> luma::application&; + auto dispatch(std::function()> task) -> std::expected; + auto register_route(std::string name, std::function, modulus::error>()> factory) -> std::expected; + auto replace_route(std::string name) -> std::expected; + auto push_route(std::string name) -> std::expected; + auto pop_route() -> std::expected; + auto current_route() const -> std::expected; + auto add_animation(luma::animation animation) -> std::expected; + auto clear_animations() -> std::expected; + auto animations_empty() const -> std::expected; + auto open_window(luma::window_options options, std::unique_ptr content) -> std::expected; + auto close_window(std::uint64_t id) -> std::expected; + auto bring_window_to_front(std::uint64_t id) -> std::expected; + auto set_window_bounds(std::uint64_t id, luma::rect bounds) -> std::expected; + auto set_window_visible(std::uint64_t id, std::bool_t visible) -> std::expected; + auto load_texture(std::string path) -> std::expected, modulus::error>; + auto request_layout() -> std::expected; + auto set_root(std::expected, modulus::error> root) -> std::expected; + auto set_root(std::unique_ptr root) -> std::expected; + template + requires (std::is_base_of_v && !std::is_same_v) + auto set_root(std::unique_ptr root) -> std::expected; + auto show_dialog(luma::dialog_options options) -> std::expected; + auto close_dialog(luma::dialog_result result) -> std::expected; + auto show_toast(luma::toast toast) -> std::expected; + auto request_stop() -> void; + auto should_stop() const -> std::bool_t; + auto run() -> std::expected; +private: + auto core_snapshot() const -> std::shared_ptr; + mutable std::mutex core_mutex_{}; + std::shared_ptr core_{}; +}; + +template + requires (std::is_base_of_v && !std::is_same_v) +auto application::set_root(std::unique_ptr root) -> std::expected { + return set_root(std::unique_ptr{std::move(root)}); +} + +} diff --git a/include/luma/assets.hpp b/include/luma/assets.hpp new file mode 100644 index 0000000..fc4be87 --- /dev/null +++ b/include/luma/assets.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace luma { + +class renderer_resources { +public: + virtual ~renderer_resources() = default; + virtual auto load_texture(std::string const& path) -> std::expected, modulus::error> = 0; +}; + +class asset_cache { +public: + explicit asset_cache(luma::renderer_resources& resources); + auto texture(std::string const& path) -> std::expected, modulus::error>; + auto clear() -> void; +private: + struct in_flight_load; + luma::renderer_resources* resources_{}; + mutable std::mutex mutex_{}; + std::uint64_t generation_{}; + std::unordered_map> textures_{}; + std::unordered_map> loading_{}; +}; + +} diff --git a/include/luma/config.hpp b/include/luma/config.hpp new file mode 100644 index 0000000..56c0c09 --- /dev/null +++ b/include/luma/config.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +#include + +namespace luma { + +struct display_config { + std::int32_t width{1024}; + std::int32_t height{600}; + std::int32_t target_fps{60}; + std::int32_t idle_fps{15}; + std::bool_t fullscreen{true}; +}; + +struct touch_config { + std::int32_t max_points{5}; +}; + +struct application_config { + std::string title{"Luma"}; + luma::display_config display{}; + luma::touch_config touch{}; + luma::theme theme{luma::default_theme()}; +}; + +auto validate(luma::display_config const& config) -> std::expected; +auto validate(luma::touch_config const& config) -> std::expected; +auto validate(luma::application_config const& config) -> std::expected; +auto parse_display_config(boost::json::value const& value) -> std::expected; +auto parse_touch_config(boost::json::value const& value) -> std::expected; +auto parse_application_config(boost::json::value const& value) -> std::expected; +auto load_application_config(std::string const& path) -> std::expected; +auto to_json(luma::display_config const& config) -> boost::json::value; +auto to_json(luma::touch_config const& config) -> boost::json::value; +auto to_json(luma::application_config const& config) -> boost::json::value; + +} diff --git a/include/luma/control.hpp b/include/luma/control.hpp new file mode 100644 index 0000000..9ed819f --- /dev/null +++ b/include/luma/control.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include + +#include + +namespace luma { + +class draw_context; +struct theme; +struct touch_event; + +class control { +public: + virtual ~control(); + virtual auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size = 0; + virtual auto arrange(luma::rect bounds) -> void; + virtual auto draw(luma::draw_context& context, luma::theme const& theme) const -> void = 0; + virtual auto handle_touch(luma::touch_event const& event) -> std::bool_t; + virtual auto invalidate_layout() -> void; + virtual auto bind_layout_invalidator(std::function invalidator) -> void; + virtual auto focusable() const -> std::bool_t; + virtual auto set_focused(std::bool_t value) -> void; + auto focused() const -> std::bool_t; + auto set_enabled(std::bool_t value) -> void; + auto enabled() const -> std::bool_t; + auto bounds() const -> luma::rect; +protected: + auto notify_layout_changed() const -> void; + auto layout_invalidator() const -> std::function; +private: + luma::rect bounds_{}; + std::function layout_invalidator_{}; + std::bool_t focused_{false}; + std::bool_t enabled_{true}; +}; + +} diff --git a/include/luma/controls.hpp b/include/luma/controls.hpp new file mode 100644 index 0000000..7a27e54 --- /dev/null +++ b/include/luma/controls.hpp @@ -0,0 +1,185 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace luma { + +enum class direction { + ROW, + COLUMN, +}; + +enum class alignment { + START, + CENTER, + END, + STRETCH, +}; + +enum class text_input_mode { + TEXT, + INTEGER, + DECIMAL, +}; + +enum class image_fit { + CONTAIN, + COVER, + FILL, + CENTER, +}; + +enum class keyboard_key_kind { + CHARACTER, + BACKSPACE, + SPACE, + ENTER, +}; + +struct keyboard_key { + std::string label{}; + std::string value{}; + luma::keyboard_key_kind kind{luma::keyboard_key_kind::CHARACTER}; +}; + +struct keyboard_layout { + std::vector> rows{}; +}; + +struct text_field_options { + std::string text{}; + std::string placeholder{}; + luma::text_input_mode mode{luma::text_input_mode::TEXT}; + std::bool_t password{false}; + std::size_t max_length{256}; +}; + +class text_input_state { +public: + struct layout_listener { + std::weak_ptr lifetime{}; + std::function callback{}; + }; + + text_input_state(luma::text_field_options options, std::function on_change); + auto snapshot() const -> luma::text_field_options; + auto text() const -> std::string; + auto focused() const -> std::bool_t; + auto set_focused(std::bool_t value) -> void; + auto version() const -> std::uint64_t; + auto add_layout_listener(std::weak_ptr lifetime, std::function callback) -> void; + auto append(std::string_view value) -> std::expected; + auto backspace() -> std::expected; + auto set_text(std::string value) -> std::expected; +private: + mutable std::mutex mutex_{}; + luma::text_field_options options_{}; + std::function on_change_{}; + std::vector layout_listeners_{}; + std::bool_t focused_{false}; + std::uint64_t version_{}; +}; + +auto default_text_keyboard() -> luma::keyboard_layout; +auto numeric_keyboard() -> luma::keyboard_layout; +auto make_text_input(luma::text_field_options options, std::function on_change) -> std::expected, modulus::error>; + +template +auto children(CONTROLS&&... controls) -> std::expected>, modulus::error>; + +auto label(std::string text) -> std::expected, modulus::error>; +auto button(std::string text, std::function on_click) -> std::expected, modulus::error>; +auto text_field(luma::text_field_options options, std::function on_change) -> std::expected, modulus::error>; +auto text_field(std::shared_ptr input) -> std::expected, modulus::error>; +auto soft_keyboard(luma::keyboard_layout layout, std::shared_ptr input, std::function on_enter = {}) -> std::expected, modulus::error>; +auto soft_keyboard(luma::keyboard_layout layout, std::function on_text, std::function on_enter = {}) -> std::expected, modulus::error>; +auto panel(std::expected>, modulus::error> children = luma::children()) -> std::expected, modulus::error>; +auto panel(std::vector> children) -> std::expected, modulus::error>; +auto spacer(luma::size value) -> std::expected, modulus::error>; +auto progress_bar(std::float32_t value) -> std::expected, modulus::error>; +auto image(std::shared_ptr texture, luma::image_fit fit = luma::image_fit::CONTAIN) -> std::expected, modulus::error>; +auto row(std::expected>, modulus::error> children, std::float32_t gap = 8.0F, luma::alignment align = luma::alignment::CENTER) -> std::expected, modulus::error>; +auto row(std::vector> children, std::float32_t gap = 8.0F, luma::alignment align = luma::alignment::CENTER) -> std::expected, modulus::error>; +auto column(std::expected>, modulus::error> children, std::float32_t gap = 8.0F, luma::alignment align = luma::alignment::STRETCH) -> std::expected, modulus::error>; +auto column(std::vector> children, std::float32_t gap = 8.0F, luma::alignment align = luma::alignment::STRETCH) -> std::expected, modulus::error>; +auto stack(std::expected>, modulus::error> children) -> std::expected, modulus::error>; +auto stack(std::vector> children) -> std::expected, modulus::error>; +auto scroll_view(std::expected, modulus::error> child, std::bool_t vertical = true, std::bool_t horizontal = false) -> std::expected, modulus::error>; +auto scroll_view(std::unique_ptr child, std::bool_t vertical = true, std::bool_t horizontal = false) -> std::expected, modulus::error>; +template + requires (std::is_base_of_v && !std::is_same_v) +auto scroll_view(std::unique_ptr child, std::bool_t vertical = true, std::bool_t horizontal = false) -> std::expected, modulus::error>; +auto list(std::expected>, modulus::error> children, std::float32_t gap = 4.0F) -> std::expected, modulus::error>; +auto list(std::vector> children, std::float32_t gap = 4.0F) -> std::expected, modulus::error>; + +} + +namespace luma::detail { + +template + requires std::is_base_of_v +auto normalize_child(std::unique_ptr child) -> std::expected, modulus::error> { + if (child == nullptr) { + return luma::detail::fail>(luma::detail::error_invalid_argument, "child control must not be null"); + } + return modulus::success(std::unique_ptr{std::move(child)}); +} + +template + requires std::is_base_of_v +auto normalize_child(std::expected, modulus::error> child) -> std::expected, modulus::error> { + if (!child) { + return modulus::fail>(child.error()); + } + return luma::detail::normalize_child(std::move(*child)); +} + +} + +template + requires (std::is_base_of_v && !std::is_same_v) +auto luma::scroll_view(std::unique_ptr child, std::bool_t vertical, std::bool_t horizontal) -> std::expected, modulus::error> { + return luma::scroll_view(std::unique_ptr{std::move(child)}, vertical, horizontal); +} + +template +auto luma::children(CONTROLS&&... controls) -> std::expected>, modulus::error> { + auto result = std::vector>{}; + result.reserve(sizeof...(controls)); + + auto append = [&result](auto&& input) -> std::expected { + auto child = luma::detail::normalize_child(std::forward(input)); + if (!child) { + return modulus::fail(child.error()); + } + result.push_back(std::move(*child)); + return {}; + }; + + auto status = std::expected{}; + ([&] { + if (status) { + status = append(std::forward(controls)); + } + }(), ...); + if (!status) { + return modulus::fail>>(status.error()); + } + return modulus::success(std::move(result)); +} diff --git a/include/luma/detail/errors.hpp b/include/luma/detail/errors.hpp new file mode 100644 index 0000000..5191419 --- /dev/null +++ b/include/luma/detail/errors.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace luma::detail { + +constexpr std::int32_t error_invalid_argument{1}; +constexpr std::int32_t error_not_found{2}; +constexpr std::int32_t error_unavailable{3}; +constexpr std::int32_t error_timeout{4}; +constexpr std::int32_t error_internal{5}; + +auto make_error(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; + +template +auto fail(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected; + +} + +template +auto luma::detail::fail(std::int32_t code, std::string message, modulus::source_location location) -> std::expected { + return modulus::fail(luma::detail::make_error(code, std::move(message), location)); +} diff --git a/include/luma/geometry.hpp b/include/luma/geometry.hpp new file mode 100644 index 0000000..d0fe2f0 --- /dev/null +++ b/include/luma/geometry.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include + +namespace luma { + +struct point { + std::float32_t x{}; + std::float32_t y{}; +}; + +struct size { + std::float32_t width{}; + std::float32_t height{}; +}; + +struct rect { + std::float32_t x{}; + std::float32_t y{}; + std::float32_t width{}; + std::float32_t height{}; +}; + +struct edge_insets { + std::float32_t left{}; + std::float32_t top{}; + std::float32_t right{}; + std::float32_t bottom{}; +}; + +struct constraints { + luma::size min{}; + luma::size max{.width = std::numeric_limits::max(), .height = std::numeric_limits::max()}; +}; + +constexpr auto right(luma::rect value) -> std::float32_t; +constexpr auto bottom(luma::rect value) -> std::float32_t; +constexpr auto contains(luma::rect area, luma::point value) -> std::bool_t; +constexpr auto inset(luma::rect area, luma::edge_insets value) -> luma::rect; +constexpr auto clamp(luma::size value, luma::constraints limits) -> luma::size; +constexpr auto deflate(luma::constraints limits, luma::edge_insets value) -> luma::constraints; + +} + +constexpr auto luma::right(luma::rect value) -> std::float32_t { + return value.x + value.width; +} + +constexpr auto luma::bottom(luma::rect value) -> std::float32_t { + return value.y + value.height; +} + +constexpr auto luma::contains(luma::rect area, luma::point value) -> std::bool_t { + return value.x >= area.x && value.x <= luma::right(area) && value.y >= area.y && value.y <= luma::bottom(area); +} + +constexpr auto luma::inset(luma::rect area, luma::edge_insets value) -> luma::rect { + return {.x = area.x + value.left, .y = area.y + value.top, .width = std::max(std::float32_t{}, area.width - value.left - value.right), .height = std::max(std::float32_t{}, area.height - value.top - value.bottom)}; +} + +constexpr auto luma::clamp(luma::size value, luma::constraints limits) -> luma::size { + return {.width = std::clamp(value.width, limits.min.width, limits.max.width), .height = std::clamp(value.height, limits.min.height, limits.max.height)}; +} + +constexpr auto luma::deflate(luma::constraints limits, luma::edge_insets value) -> luma::constraints { + return {.min = {.width = std::max(std::float32_t{}, limits.min.width - value.left - value.right), .height = std::max(std::float32_t{}, limits.min.height - value.top - value.bottom)}, .max = {.width = std::max(std::float32_t{}, limits.max.width - value.left - value.right), .height = std::max(std::float32_t{}, limits.max.height - value.top - value.bottom)}}; +} diff --git a/include/luma/input.hpp b/include/luma/input.hpp new file mode 100644 index 0000000..9a3e46d --- /dev/null +++ b/include/luma/input.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +#include + +namespace luma { + +enum class touch_phase { + DOWN, + MOVE, + UP, + CANCEL, +}; + +struct touch_point { + std::int32_t id{}; + luma::point position{}; + luma::touch_phase phase{luma::touch_phase::MOVE}; +}; + +struct touch_event { + std::vector points{}; + std::chrono::steady_clock::time_point time{std::chrono::steady_clock::now()}; +}; + +} diff --git a/include/luma/overlays.hpp b/include/luma/overlays.hpp new file mode 100644 index 0000000..a1afa25 --- /dev/null +++ b/include/luma/overlays.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace luma { + +enum class dialog_result { + NONE, + ACCEPTED, + CANCELLED, +}; + +struct dialog_options { + std::string title{}; + std::unique_ptr body{}; + std::string accept_text{"OK"}; + std::string cancel_text{"Cancel"}; + std::function on_close{}; +}; + +struct toast { + std::string message{}; + std::chrono::milliseconds duration{2500}; +}; + +class overlay_host final : public luma::control { +public: + auto show_dialog(luma::dialog_options options) -> std::expected; + auto close_dialog(luma::dialog_result result) -> std::expected; + auto show_toast(luma::toast toast) -> std::expected; + auto tick(std::chrono::steady_clock::duration delta) -> std::bool_t; + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + auto bind_layout_invalidator(std::function invalidator) -> void override; +private: + friend auto overlays(std::unique_ptr content) -> std::expected, modulus::error>; + explicit overlay_host(std::unique_ptr content); + auto begin_traversal() const -> void; + auto end_traversal() const -> void; + auto retire_dialog_body(std::unique_ptr body) -> void; + auto flush_retired_dialog_bodies() const -> void; + std::unique_ptr content_{}; + std::optional dialog_{}; + std::deque toasts_{}; + std::chrono::steady_clock::duration toast_elapsed_{}; + mutable std::vector> retired_dialog_bodies_{}; + mutable std::uint32_t traversal_depth_{}; +}; + +auto overlays(std::unique_ptr content) -> std::expected, modulus::error>; + +} diff --git a/include/luma/render.hpp b/include/luma/render.hpp new file mode 100644 index 0000000..8203443 --- /dev/null +++ b/include/luma/render.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include + +#include +#include + +namespace luma { + +struct texture_info { + std::int32_t width{}; + std::int32_t height{}; +}; + +class texture { +public: + virtual ~texture() = default; + virtual auto info() const -> luma::texture_info = 0; +}; + +class draw_context { +public: + virtual ~draw_context() = default; + virtual auto fill_rect(luma::rect bounds, luma::color color) -> void = 0; + virtual auto stroke_rect(luma::rect bounds, std::float32_t thickness, luma::color color) -> void = 0; + virtual auto stroke_line(luma::point from, luma::point to, std::float32_t thickness, luma::color color) -> void = 0; + virtual auto fill_ellipse(luma::rect bounds, luma::color color) -> void = 0; + virtual auto stroke_ellipse(luma::rect bounds, std::float32_t thickness, luma::color color) -> void = 0; + virtual auto draw_text(std::string_view text, luma::point origin, luma::text_style const& style, luma::color color) -> void = 0; + virtual auto draw_texture(luma::texture const& texture, luma::rect source, luma::rect destination, luma::color tint) -> void = 0; + virtual auto push_clip(luma::rect bounds) -> void = 0; + virtual auto pop_clip() -> void = 0; + virtual auto measure_text(std::string_view text, luma::text_style const& style) const -> luma::size = 0; +}; + +} diff --git a/include/luma/routing.hpp b/include/luma/routing.hpp new file mode 100644 index 0000000..9bfc625 --- /dev/null +++ b/include/luma/routing.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace luma { + +class router { +public: + auto register_route(std::string name, std::function, modulus::error>()> factory) -> std::expected; + auto set_retire_handler(std::function)> handler) -> void; + auto replace(std::string const& name) -> std::expected; + auto push(std::string const& name) -> std::expected; + auto pop() -> std::expected; + auto clear_stack() -> void; + auto current() -> luma::control*; + auto current() const -> luma::control const*; + auto current_route() const -> std::string const&; +private: + struct entry { + std::string name{}; + std::unique_ptr view{}; + }; + auto retire(std::unique_ptr view) -> void; + auto retire_stack() -> void; + std::unordered_map, modulus::error>()>> routes_{}; + std::vector stack_{}; + std::string empty_route_{}; + std::function)> retire_handler_{}; +}; + +} diff --git a/include/luma/theme.hpp b/include/luma/theme.hpp new file mode 100644 index 0000000..eb10fe7 --- /dev/null +++ b/include/luma/theme.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +#include + +#include + +namespace luma { + +struct color { + std::uint8_t r{}; + std::uint8_t g{}; + std::uint8_t b{}; + std::uint8_t a{255}; +}; + +struct text_style { + std::string font{"default"}; + std::float32_t size{24.0F}; + std::float32_t line_height{30.0F}; +}; + +struct theme { + luma::color background{18, 19, 22, 255}; + luma::color surface{32, 34, 38, 255}; + luma::color surface_active{46, 94, 170, 255}; + luma::color text{242, 244, 248, 255}; + luma::color text_muted{170, 176, 186, 255}; + luma::color border{82, 88, 99, 255}; + luma::color accent{84, 160, 255, 255}; + luma::text_style body{}; + luma::text_style title{.font = "default", .size = 32.0F, .line_height = 40.0F}; + std::float32_t radius{4.0F}; + std::float32_t spacing{8.0F}; +}; + +auto default_theme() -> luma::theme; +auto validate(luma::text_style const& style) -> std::expected; +auto validate(luma::theme const& theme) -> std::expected; +auto parse_color(boost::json::value const& value) -> std::expected; +auto parse_theme(boost::json::value const& value) -> std::expected; +auto to_json(luma::color const& color) -> boost::json::value; +auto to_json(luma::text_style const& style) -> boost::json::value; +auto to_json(luma::theme const& theme) -> boost::json::value; + +} diff --git a/include/luma/window.hpp b/include/luma/window.hpp new file mode 100644 index 0000000..b6b0dac --- /dev/null +++ b/include/luma/window.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace luma { + +class window_manager; + +struct window_options { + std::string title{}; + luma::rect bounds{}; + std::bool_t modal{false}; + std::bool_t decorated{true}; + std::bool_t visible{true}; +}; + +class window { +public: + window(luma::window const&) = delete; + window(luma::window&&) noexcept = default; + auto operator=(luma::window const&) -> luma::window& = delete; + auto operator=(luma::window&&) noexcept -> luma::window& = default; + auto id() const -> std::uint64_t; + auto options() const -> luma::window_options const&; + auto set_bounds(luma::rect bounds) -> std::expected; + auto set_visible(std::bool_t value) -> void; + auto visible() const -> std::bool_t; + auto content() -> luma::control*; + auto content() const -> luma::control const*; +private: + friend class luma::window_manager; + window(std::uint64_t id, luma::window_options options, std::unique_ptr content); + std::uint64_t id_{}; + luma::window_options options_{}; + std::unique_ptr content_{}; +}; + +class window_manager final : public luma::control { +public: + auto open(luma::window_options options, std::unique_ptr content) -> std::expected; + auto close(std::uint64_t id) -> std::expected; + auto bring_to_front(std::uint64_t id) -> std::expected; + auto set_bounds(std::uint64_t id, luma::rect bounds) -> std::expected; + auto set_visible(std::uint64_t id, std::bool_t visible) -> std::expected; + auto find(std::uint64_t id) -> luma::window*; + auto clear() -> void; + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + auto bind_layout_invalidator(std::function invalidator) -> void override; +private: + enum class pending_kind { + OPEN, + CLOSE, + BRING_TO_FRONT, + SET_BOUNDS, + SET_VISIBLE, + CLEAR, + }; + struct pending_operation { + luma::window_manager::pending_kind kind{luma::window_manager::pending_kind::CLEAR}; + std::uint64_t id{}; + std::unique_ptr window{}; + luma::rect bounds{}; + std::bool_t visible{}; + }; + auto contains_effective(std::uint64_t id) const -> std::bool_t; + auto close_now(std::uint64_t id) const -> void; + auto bring_to_front_now(std::uint64_t id) const -> void; + auto set_bounds_now(std::uint64_t id, luma::rect bounds) const -> void; + auto set_visible_now(std::uint64_t id, std::bool_t visible) const -> void; + auto apply_pending_operations() const -> void; + std::uint64_t next_id_{1}; + mutable std::float32_t title_line_height_{30.0F}; + mutable std::vector> windows_{}; + mutable std::unordered_map active_touches_{}; + mutable std::vector pending_operations_{}; + mutable std::uint32_t mutation_depth_{}; +}; + +} diff --git a/modulus.json b/modulus.json new file mode 100644 index 0000000..b605c25 --- /dev/null +++ b/modulus.json @@ -0,0 +1,11 @@ +{ + "package": { + "name": "luma" + }, + "tooling": { + "remote": { + "name": "khodo", + "url": "https://git.thyme-labs.com/api/packages/khodo/conan" + } + } +} diff --git a/modulus.py b/modulus.py new file mode 100755 index 0000000..836b640 --- /dev/null +++ b/modulus.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import shlex +import subprocess +import sys + +PACKAGE_REF = "modulus/latest" +CONAN_VERSION = "2.26.2" +FETCH_IMAGE = "modulus-fetch:dev" +COMMANDS = ( + "configure", + "build", + "test", + "package", + "lsp", +) +COMMON_DIR = pathlib.Path("build") / "build_common" +TOOLS_DIR = pathlib.Path("build") / "build_tools" +RUNTIME_DIR = COMMON_DIR / "modulus" +CONAN_HOME_DIR = COMMON_DIR / "conan_cache" +DEFAULT_REMOTE_NAME = "khodo" +DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan" +DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image" + +def repo_root(): + root = pathlib.Path(__file__).resolve().parent + if not (root / "modulus.json").is_file(): + raise SystemExit("modulus.py must be run from a Modulus-managed repository root") + return root + +def read_json(path): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"failed to read {path}: {error}") from error + if not isinstance(data, dict): + raise SystemExit(f"{path} must contain a JSON object") + return data + +def remote_config(root): + config = read_json(root / "modulus.json") + remote = config.get( + "tooling", + {}, + ).get("remote", {}) + if not isinstance(remote, dict): + remote = {} + return ( + str(remote.get("name") or DEFAULT_REMOTE_NAME), + str(remote.get("url") or DEFAULT_REMOTE_URL), + ) + +def runtime_ready(root): + return ( + (root / TOOLS_DIR / "configure.py").is_file() + and (root / TOOLS_DIR / "lsp.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "export.py").is_file() + ) + +def optional_credentials(): + user = os.environ.get("CONAN_USER") + token = os.environ.get("CONAN_TOKEN") + if bool(user) != bool(token): + raise SystemExit("CONAN_USER and CONAN_TOKEN must either both be set or both be unset") + return user, token + +def env_flag(name): + return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} + +def docker_marker(root): + config = read_json(root / "modulus.json") + marker = config.get( + "tooling", + {}, + ).get("docker", {}).get("marker", DEFAULT_DOCKER_MARKER) + return pathlib.Path(str(marker)) + +def direct_mode(root): + return env_flag("MODULUS_DIRECT") or docker_marker(root).exists() + +def run(command, input_text=None, capture=False, display_command=None, env=None, check=True): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + completed = subprocess.run( + [str(part) for part in command], + input=input_text, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + text=True, + check=False, + env=env, + ) + if check and completed.returncode != 0: + if capture and completed.stdout: + print(completed.stdout, end="") + if capture and completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + raise SystemExit(completed.returncode) + return completed + +def fetch_dockerfile(): + return f"""FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + ca-certificates \\ + python3 \\ + python3-venv \\ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv /opt/modulus-python + +ENV PATH=/opt/modulus-python/bin:$PATH + +RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION} + +WORKDIR /workspace +""" + +def ensure_fetch_image(): + inspected = subprocess.run( + [ + "docker", + "image", + "inspect", + FETCH_IMAGE, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if inspected.returncode == 0: + return + run( + [ + "docker", + "build", + "--tag", + FETCH_IMAGE, + "-", + ], + input_text=fetch_dockerfile(), + ) + +def in_container(): + return pathlib.Path("/.dockerenv").exists() + +def docker_workspace_args(root): + if in_container(): + container = os.environ.get("HOSTNAME", "").strip() + if not container: + raise SystemExit("HOSTNAME is required to share the CI workspace with the Modulus fetch container") + return [ + "--volumes-from", + container, + ] + return [ + "--volume", + f"{root}:{root}", + ] + +def fetch_env(root): + env = os.environ.copy() + env["CONAN_HOME"] = str(root / CONAN_HOME_DIR) + return env + +def run_fetch(root, command, capture=False, display_command=None, check=True): + if direct_mode(root): + return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) + ensure_fetch_image() + docker_command = [ + "docker", + "run", + "--rm", + "--interactive", + "--user", + f"{os.getuid()}:{os.getgid()}", + "--env", + "HOME=/tmp", + "--env", + "CONAN_HOME", + "--env", + "CONAN_USER", + "--env", + "CONAN_TOKEN", + *docker_workspace_args(root), + "--workdir", + str(root), + FETCH_IMAGE, + *command, + ] + return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) + +def configure_remote(root, remote_name, remote_url): + user, token = optional_credentials() + run_fetch( + root, + [ + "conan", + "remote", + "add", + "-f", + remote_name, + remote_url, + ], + ) + if not user and not token: + return + run_fetch( + root, + [ + "conan", + "remote", + "login", + remote_name, + user, + "-p", + token, + ], + display_command=[ + "conan", + "remote", + "login", + remote_name, + user, + "-p", + "", + ], + ) + +def package_refs(data): + refs = [] + for location_data in data.values(): + if not isinstance(location_data, dict): + continue + for ref, ref_data in location_data.items(): + if not ref.startswith(PACKAGE_REF) or not isinstance(ref_data, dict): + continue + revisions = ref_data.get( + "revisions", + {}, + ) + if not isinstance(revisions, dict): + continue + for recipe_revision, revision_data in revisions.items(): + if not isinstance(revision_data, dict): + continue + packages = revision_data.get( + "packages", + {}, + ) + if not isinstance(packages, dict): + continue + for package_id, package_data in packages.items(): + package_revisions = package_data.get( + "revisions", + {}, + )if isinstance(package_data, dict) else {} + if isinstance(package_revisions, dict) and package_revisions: + for package_revision in package_revisions: + refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}#{package_revision}") + else: + refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}") + return sorted(refs) + +def remove_cached_modulus(root): + run_fetch( + root, + [ + "conan", + "remove", + PACKAGE_REF + ":*", + "-c", + ], + check=False, + ) + +def download_modulus(root, remote_name): + remove_cached_modulus(root) + run_fetch( + root, + [ + "conan", + "download", + PACKAGE_REF + ":*", + "-r", + remote_name, + ], + ) + completed = run_fetch( + root, + [ + "conan", + "list", + PACKAGE_REF + ":*", + "-c", + "-f", + "json", + ], + capture=True, + ) + refs = package_refs(json.loads(completed.stdout)) + if not refs: + raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache") + package_ref = refs[-1] + completed = run_fetch( + root, + [ + "conan", + "cache", + "path", + package_ref, + ], + capture=True, + ) + package_root = pathlib.Path(completed.stdout.strip()) + if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir(): + raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}") + return package_root + +def materialize_runtime(root): + remote_name, remote_url = remote_config(root) + configure_remote(root, remote_name, remote_url) + package_root = download_modulus(root, remote_name) + sys.path.insert(0, str(package_root)) + import modulus.export + modulus.export.sync_project(root, package_root) + +def ensure_runtime(root): + if runtime_ready(root): + return + materialize_runtime(root) + +def delegate(command, argv): + root = repo_root() + ensure_runtime(root) + sys.path.insert(0, str(root / RUNTIME_DIR)) + import modulus.cli + previous_argv = sys.argv + previous_cwd = pathlib.Path.cwd() + sys.argv = [ + str(root / "modulus.py"), + *argv, + ] + os.chdir(root) + try: + return modulus.cli.main(command) + finally: + os.chdir(previous_cwd) + sys.argv = previous_argv + +def main(argv): + if not argv or argv[0] in ("-h", "--help"): + print("usage: python3 modulus.py " + "|".join(COMMANDS) + " ...") + return 0 if argv else 2 + command = argv[0] + if command not in COMMANDS: + raise SystemExit(f"unknown Modulus command: {command}") + return delegate(command, argv[1:]) + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/animation.cpp b/src/animation.cpp new file mode 100644 index 0000000..39f4f65 --- /dev/null +++ b/src/animation.cpp @@ -0,0 +1,193 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace { + +auto invalid(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +template +auto invalid_value(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto internal(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_internal, std::move(message)); +} + +auto validate(luma::animation const& animation) -> std::expected { + if (!std::isfinite(animation.from)) { + return invalid("animation from value must be finite"); + } + if (!std::isfinite(animation.to)) { + return invalid("animation to value must be finite"); + } + if (animation.duration.count() <= 0) { + return invalid("animation duration must be positive"); + } + if (!animation.apply) { + return invalid("animation apply callback must not be empty"); + } + return {}; +} + +auto callback_error(std::char_t const* action, std::exception const& error) -> std::expected { + return internal(std::string{"animation "} + action + " callback failed: " + error.what()); +} + +auto callback_error(std::char_t const* action) -> std::expected { + return internal(std::string{"animation "} + action + " callback failed with an unknown exception"); +} + +} + +auto luma::interpolate(std::float32_t from, std::float32_t to, std::float32_t progress, luma::easing easing) -> std::expected { + if (!std::isfinite(from)) { + return invalid_value("interpolation from value must be finite"); + } + if (!std::isfinite(to)) { + return invalid_value("interpolation to value must be finite"); + } + if (!std::isfinite(progress)) { + return invalid_value("interpolation progress must be finite"); + } + auto clamped = std::clamp(progress, std::float32_t{}, std::float32_t{1}); + auto eased = clamped; + switch (easing) { + case luma::easing::LINEAR: + eased = clamped; + break; + case luma::easing::EASE_OUT: + eased = 1.0F - (1.0F - clamped) * (1.0F - clamped); + break; + case luma::easing::EASE_IN_OUT: + eased = clamped < 0.5F ? 2.0F * clamped * clamped : 1.0F - std::pow(-2.0F * clamped + 2.0F, 2.0F) / 2.0F; + break; + } + return modulus::success(from + (to - from) * eased); +} + +auto luma::animation_graph::add(luma::animation animation) -> std::expected { + if (auto status = validate(animation); !status) { + return status; + } + auto running = luma::animation_graph::running_animation{ + .animation = std::move(animation), + .elapsed = {}, + }; + if (ticking_) { + pending_.push_back({ + .kind = luma::animation_graph::pending_kind::ADD, + .animation = std::move(running), + }); + } else { + animations_.push_back(std::move(running)); + } + return {}; +} + +auto luma::animation_graph::tick(std::chrono::steady_clock::duration delta) -> std::expected { + if (delta < std::chrono::steady_clock::duration::zero()) { + return invalid("animation tick delta must be non-negative"); + } + if (ticking_) { + return internal("animation graph is already ticking"); + } + + auto active = std::move(animations_); + animations_.clear(); + ticking_ = true; + clear_requested_ = false; + + auto fail_tick = [this](std::expected status) { + ticking_ = false; + clear_requested_ = false; + animations_.clear(); + pending_.clear(); + return status; + }; + + for (auto& running : active) { + running.elapsed += delta; + auto duration = std::chrono::duration_cast(running.animation.duration); + if (duration.count() <= 0) { + return fail_tick(invalid("animation duration must be positive")); + } + auto progress = static_cast(running.elapsed.count()) / static_cast(duration.count()); + progress = std::clamp(progress, std::float32_t{}, std::float32_t{1}); + if (!running.animation.apply) { + return fail_tick(invalid("animation apply callback must not be empty")); + } + auto interpolated = luma::interpolate(running.animation.from, running.animation.to, progress, running.animation.easing); + if (!interpolated) { + return fail_tick(modulus::fail(interpolated.error())); + } + try { + running.animation.apply(*interpolated); + } catch (std::exception const& error) { + return fail_tick(callback_error("apply", error)); + } catch (...) { + return fail_tick(callback_error("apply")); + } + if (clear_requested_) { + break; + } + if (progress >= 1.0F) { + if (running.animation.finished) { + try { + running.animation.finished(); + } catch (std::exception const& error) { + return fail_tick(callback_error("finished", error)); + } catch (...) { + return fail_tick(callback_error("finished")); + } + } + if (clear_requested_) { + break; + } + continue; + } + animations_.push_back(std::move(running)); + } + + ticking_ = false; + for (auto& operation : pending_) { + if (operation.kind == luma::animation_graph::pending_kind::CLEAR) { + animations_.clear(); + } else { + animations_.push_back(std::move(operation.animation)); + } + } + pending_.clear(); + clear_requested_ = false; + return {}; +} + +auto luma::animation_graph::empty() const -> std::bool_t { + return animations_.empty(); +} + +auto luma::animation_graph::clear() -> void { + if (ticking_) { + pending_.push_back({ + .kind = luma::animation_graph::pending_kind::CLEAR, + .animation = {}, + }); + clear_requested_ = true; + } else { + animations_.clear(); + pending_.clear(); + clear_requested_ = false; + } +} diff --git a/src/application.cpp b/src/application.cpp new file mode 100644 index 0000000..f4eca45 --- /dev/null +++ b/src/application.cpp @@ -0,0 +1,1264 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace luma::detail { + +struct application_core { + explicit application_core(luma::application_config value); + mutable std::mutex lifecycle_mutex{}; + mutable std::recursive_mutex immediate_mutex{}; + boost::asio::io_context ui_context{}; + boost::asio::strand ui_strand; + std::thread::id ui_thread{}; + std::atomic_bool running{false}; + std::atomic_bool accepting_tasks{false}; + std::atomic_bool stopping{false}; + std::atomic_bool stop_requested{false}; + std::atomic_bool renderer_active{false}; + luma::application_config config{}; + luma::router router{}; + std::unique_ptr root{}; + luma::animation_graph animations{}; + luma::window_manager windows{}; + std::unique_ptr assets{}; + std::unique_ptr resources{}; + luma::overlay_host* overlays{}; + std::atomic_bool layout_dirty{true}; + std::atomic_bool redraw_dirty{true}; + std::bool_t layout_ready{false}; + luma::rect layout_display{}; + std::uint32_t traversal_depth{}; + std::vector> retired_controls{}; +}; + +} + +namespace { + +struct font_key { + std::string path{}; + std::int32_t size{}; + + auto operator==(font_key const& other) const -> std::bool_t; +}; + +struct font_key_hash { + auto operator()(font_key const& key) const noexcept -> std::size_t; +}; + +struct raylib_resource_state { + std::thread::id ui_thread{}; + std::atomic_bool active{false}; + std::mutex mutex{}; + struct texture_record { + Texture2D texture{}; + std::bool_t unloaded{false}; + }; + std::vector> records{}; + std::vector> pending_unloads{}; + std::unordered_map fonts{}; +}; + +class raylib_texture final : public luma::texture { +public: + raylib_texture(std::shared_ptr record, std::shared_ptr state); + ~raylib_texture() override; + auto info() const -> luma::texture_info override; + auto valid() const -> std::bool_t; + auto native() const -> Texture2D; +private: + std::shared_ptr record_{}; + std::shared_ptr state_{}; +}; + +class raylib_resources final : public luma::renderer_resources { +public: + explicit raylib_resources(std::shared_ptr state); + auto load_texture(std::string const& path) -> std::expected, modulus::error> override; + auto state() const -> std::shared_ptr; +private: + std::shared_ptr state_{}; +}; + +class raylib_draw_context final : public luma::draw_context { +public: + explicit raylib_draw_context(std::shared_ptr state); + auto fill_rect(luma::rect bounds, luma::color color) -> void override; + auto stroke_rect(luma::rect bounds, std::float32_t thickness, luma::color color) -> void override; + auto stroke_line(luma::point from, luma::point to, std::float32_t thickness, luma::color color) -> void override; + auto fill_ellipse(luma::rect bounds, luma::color color) -> void override; + auto stroke_ellipse(luma::rect bounds, std::float32_t thickness, luma::color color) -> void override; + auto draw_text(std::string_view text, luma::point origin, luma::text_style const& style, luma::color color) -> void override; + auto draw_texture(luma::texture const& texture, luma::rect source, luma::rect destination, luma::color tint) -> void override; + auto push_clip(luma::rect bounds) -> void override; + auto pop_clip() -> void override; + auto measure_text(std::string_view text, luma::text_style const& style) const -> luma::size override; +private: + std::shared_ptr state_{}; + std::vector clip_stack_{}; +}; + +class scope_exit { +public: + explicit scope_exit(std::function action); + scope_exit(scope_exit const&) = delete; + auto operator=(scope_exit const&) -> scope_exit& = delete; + ~scope_exit(); +private: + std::function action_{}; + std::bool_t active_{true}; +}; + +class drawing_scope { +public: + drawing_scope(); + drawing_scope(drawing_scope const&) = delete; + auto operator=(drawing_scope const&) -> drawing_scope& = delete; + ~drawing_scope(); + auto end() -> void; +private: + std::bool_t active_{true}; +}; + +class ui_traversal_scope { +public: + explicit ui_traversal_scope(luma::detail::application_core& core); + ui_traversal_scope(ui_traversal_scope const&) = delete; + auto operator=(ui_traversal_scope const&) -> ui_traversal_scope& = delete; + ~ui_traversal_scope(); +private: + luma::detail::application_core& core_; +}; + +constexpr auto max_layout_passes = std::uint32_t{8}; + +auto to_raylib(luma::color color) -> Color { + return { + .r = color.r, + .g = color.g, + .b = color.b, + .a = color.a, + }; +} + +template +auto unavailable(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_unavailable, std::move(message)); +} + +template +auto internal(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_internal, std::move(message)); +} + +auto internal_error(std::string message) -> modulus::error { + return luma::detail::make_error(luma::detail::error_internal, std::move(message)); +} + +auto finite_rect(luma::rect value) -> std::bool_t { + return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.width) && std::isfinite(value.height) && value.width >= 0.0F && value.height >= 0.0F; +} + +auto same_rect(luma::rect left, luma::rect right) -> std::bool_t { + return left.x == right.x && left.y == right.y && left.width == right.width && left.height == right.height; +} + +auto make_event(luma::touch_event const& event, luma::touch_point point) -> luma::touch_event { + return { + .points = {point}, + .time = event.time, + }; +} + +auto invalidate_layout_cache(luma::detail::application_core& core) -> void; + +auto mark_redraw_dirty(luma::detail::application_core& core) -> void { + core.redraw_dirty.store(true, std::memory_order_release); +} + +auto mark_layout_dirty(luma::detail::application_core& core) -> void { + core.layout_dirty.store(true, std::memory_order_release); + mark_redraw_dirty(core); +} + +auto make_layout_invalidator(std::shared_ptr const& core) -> std::function { + auto weak = std::weak_ptr{core}; + return [weak] { + if (auto locked = weak.lock()) { + mark_layout_dirty(*locked); + } + }; +} + +auto bind_layout_invalidator(std::shared_ptr const& core, luma::control& control) -> void { + control.bind_layout_invalidator(make_layout_invalidator(core)); +} + +auto flush_retired_controls(luma::detail::application_core& core) -> void { + if (core.traversal_depth == 0) { + core.retired_controls.clear(); + } +} + +auto retire_control(luma::detail::application_core& core, std::unique_ptr control) -> void { + if (control == nullptr) { + return; + } + if (core.traversal_depth == 0) { + control.reset(); + return; + } + core.retired_controls.push_back(std::move(control)); +} + +auto install_retire_handlers(std::shared_ptr const& core) -> void { + auto weak = std::weak_ptr{core}; + core->router.set_retire_handler([weak](std::unique_ptr control) mutable { + if (auto locked = weak.lock()) { + retire_control(*locked, std::move(control)); + } + }); +} + +auto active_control(luma::detail::application_core& core) -> luma::control* { + auto* active = core.root.get(); + if (active == nullptr) { + active = core.router.current(); + } + return active; +} + +auto invalidate_layout_cache(luma::detail::application_core& core) -> void { + auto traversal = ui_traversal_scope{core}; + if (auto* active = active_control(core); active != nullptr) { + active->invalidate_layout(); + } + core.windows.invalidate_layout(); +} + +template +auto run_task(TASK& task) -> std::expected { + try { + return task(); + } catch (std::exception const& error) { + return internal(error.what()); + } catch (...) { + return internal("UI task failed with an unknown exception"); + } +} + +template +auto run_ui_task(luma::detail::application_core& core, TASK& task, std::bool_t dirty_after) -> std::expected { + auto result = run_task(task); + if (dirty_after) { + mark_layout_dirty(core); + } + return result; +} + +template +auto dispatch_to_ui(std::shared_ptr const& core, TASK&& input, std::bool_t dirty_after = true) -> std::expected { + if (!core) { + return unavailable("application is not initialized"); + } + + auto task = std::decay_t{std::forward(input)}; + auto should_post = false; + auto wait_for_startup = false; + + { + auto lock = std::unique_lock{core->lifecycle_mutex}; + if (core->stopping.load(std::memory_order_acquire)) { + return unavailable("application is shutting down"); + } + if (core->running.load(std::memory_order_acquire) && core->ui_thread == std::this_thread::get_id()) { + lock.unlock(); + return run_ui_task(*core, task, dirty_after); + } + auto const running = core->running.load(std::memory_order_acquire); + auto const accepting = core->accepting_tasks.load(std::memory_order_acquire); + should_post = running && accepting; + wait_for_startup = running && !accepting; + } + + if (!should_post || wait_for_startup) { + auto immediate_lock = std::scoped_lock{core->immediate_mutex}; + auto lock = std::unique_lock{core->lifecycle_mutex}; + if (core->stopping.load(std::memory_order_acquire)) { + return unavailable("application is shutting down"); + } + if (core->running.load(std::memory_order_acquire) && core->ui_thread == std::this_thread::get_id()) { + lock.unlock(); + return run_ui_task(*core, task, dirty_after); + } + if (!core->running.load(std::memory_order_acquire)) { + lock.unlock(); + return run_ui_task(*core, task, dirty_after); + } + if (!core->accepting_tasks.load(std::memory_order_acquire)) { + return unavailable("application is not accepting UI work"); + } + should_post = true; + } + + auto future = std::future>{}; + auto promise = std::make_shared>>(); + future = promise->get_future(); + auto task_holder = std::make_shared>>(std::move(task)); + try { + auto lock = std::scoped_lock{core->lifecycle_mutex}; + if (core->stopping.load(std::memory_order_acquire)) { + return unavailable("application is shutting down"); + } + if (!core->running.load(std::memory_order_acquire) || !core->accepting_tasks.load(std::memory_order_acquire)) { + return unavailable("application is not accepting UI work"); + } + boost::asio::post(core->ui_strand, [core, promise = std::move(promise), task_holder = std::move(task_holder), dirty_after]() mutable { + auto queued = std::move(**task_holder); + task_holder->reset(); + if (core->stopping.load(std::memory_order_acquire)) { + promise->set_value(unavailable("application is shutting down")); + return; + } + promise->set_value(run_ui_task(*core, queued, dirty_after)); + }); + } catch (std::exception const& error) { + return internal(error.what()); + } catch (...) { + return internal("failed to queue UI task"); + } + + return future.get(); +} + +auto drain_ui_tasks(luma::detail::application_core& core) -> std::size_t { + core.ui_context.restart(); + return core.ui_context.poll(); +} + +auto finish_run(luma::detail::application_core& core) -> void { + auto lock = std::scoped_lock{core.lifecycle_mutex}; + core.accepting_tasks.store(false, std::memory_order_release); + core.running.store(false, std::memory_order_release); + core.ui_thread = {}; + core.stopping.store(false, std::memory_order_release); + core.stop_requested.store(false, std::memory_order_release); +} + +auto cleanup_ui_state(luma::detail::application_core& core) -> void { + if (core.assets) { + core.assets->clear(); + } + core.assets.reset(); + core.overlays = nullptr; + retire_control(core, std::move(core.root)); + core.animations.clear(); + core.windows.clear(); + core.router.clear_stack(); + core.retired_controls.clear(); + core.resources.reset(); + core.layout_ready = false; + mark_layout_dirty(core); +} + +auto unload_record(raylib_resource_state::texture_record& record) -> void { + if (!record.unloaded && record.texture.id != 0) { + UnloadTexture(record.texture); + record.unloaded = true; + record.texture.id = 0; + } +} + +auto flush_pending_unloads(raylib_resource_state& state) -> std::bool_t { + assert(state.ui_thread == std::this_thread::get_id()); + auto pending = std::vector>{}; + { + auto lock = std::scoped_lock{state.mutex}; + pending.swap(state.pending_unloads); + } + auto unloaded = false; + for (auto& record : pending) { + if (record) { + unload_record(*record); + unloaded = true; + } + } + return unloaded; +} + +auto unload_all_textures(raylib_resource_state& state) -> void { + assert(state.ui_thread == std::this_thread::get_id()); + static_cast(flush_pending_unloads(state)); + auto records = std::vector>{}; + { + auto lock = std::scoped_lock{state.mutex}; + auto write = state.records.begin(); + for (auto read = state.records.begin(); read != state.records.end(); ++read) { + if (auto record = read->lock()) { + records.push_back(record); + *write = *read; + ++write; + } + } + state.records.erase(write, state.records.end()); + } + for (auto& record : records) { + unload_record(*record); + } +} + +auto unload_all_fonts(raylib_resource_state& state) -> void { + assert(state.ui_thread == std::this_thread::get_id()); + for (auto& [path, font] : state.fonts) { + static_cast(path); + if (font.texture.id != 0) { + UnloadFont(font); + } + } + state.fonts.clear(); +} + +auto resolve_font(raylib_resource_state& state, luma::text_style const& style) -> Font { + assert(state.ui_thread == std::this_thread::get_id()); + assert(IsWindowReady()); + if (style.font.empty()) { + throw std::runtime_error{"text style font must not be empty"}; + } + if (style.font == "default") { + return GetFontDefault(); + } + auto key = font_key{ + .path = style.font, + .size = std::max(std::int32_t{1}, static_cast(std::ceil(style.size))), + }; + if (auto found = state.fonts.find(key); found != state.fonts.end()) { + return found->second; + } + auto loaded = LoadFontEx(style.font.c_str(), key.size, nullptr, 0); + if (loaded.texture.id == 0) { + throw std::runtime_error{"failed to load font: " + style.font}; + } + auto inserted = state.fonts.emplace(std::move(key), loaded); + return inserted.first->second; +} + +auto intersect_clip(luma::rect left, luma::rect right) -> luma::rect { + auto x = std::max(left.x, right.x); + auto y = std::max(left.y, right.y); + auto end_x = std::min(luma::right(left), luma::right(right)); + auto end_y = std::min(luma::bottom(left), luma::bottom(right)); + return { + .x = x, + .y = y, + .width = std::max(std::float32_t{}, end_x - x), + .height = std::max(std::float32_t{}, end_y - y), + }; +} + +auto begin_clip(luma::rect bounds) -> void { + BeginScissorMode(static_cast(bounds.x), static_cast(bounds.y), static_cast(bounds.width), static_cast(bounds.height)); +} + +auto poll_touch(std::unordered_map& previous, std::int32_t max_points) -> luma::touch_event { + assert(IsWindowReady()); + assert(max_points > 0); + auto current = std::unordered_map{}; + auto points = std::vector{}; + auto count = std::min(GetTouchPointCount(), max_points); + for (auto index = 0; index < count; ++index) { + auto id = GetTouchPointId(index); + auto position = GetTouchPosition(index); + auto point = luma::point{ + .x = position.x, + .y = position.y, + }; + auto phase = previous.contains(id) ? luma::touch_phase::MOVE : luma::touch_phase::DOWN; + current.emplace(id, point); + points.push_back({ + .id = id, + .position = point, + .phase = phase, + }); + } + for (auto const& [id, position] : previous) { + if (!current.contains(id)) { + points.push_back({ + .id = id, + .position = position, + .phase = luma::touch_phase::UP, + }); + } + } + previous = std::move(current); + return { + .points = std::move(points), + .time = std::chrono::steady_clock::now(), + }; +} + +auto font_key::operator==(font_key const& other) const -> std::bool_t { + return path == other.path && size == other.size; +} + +auto font_key_hash::operator()(font_key const& key) const noexcept -> std::size_t { + auto seed = std::hash{}(key.path); + seed ^= std::hash{}(key.size) + 0x9e3779b9U + (seed << 6U) + (seed >> 2U); + return seed; +} + +scope_exit::scope_exit(std::function action) : action_{std::move(action)} {} + +scope_exit::~scope_exit() { + if (!active_ || !action_) { + return; + } + try { + action_(); + } catch (...) { + } +} + +drawing_scope::drawing_scope() { + BeginDrawing(); +} + +drawing_scope::~drawing_scope() { + if (active_) { + end(); + } +} + +auto drawing_scope::end() -> void { + if (active_) { + EndDrawing(); +#if defined(LUMA_RAYLIB_CUSTOM_FRAME_CONTROL) && LUMA_RAYLIB_CUSTOM_FRAME_CONTROL + SwapScreenBuffer(); + PollInputEvents(); +#endif + active_ = false; + } +} + +ui_traversal_scope::ui_traversal_scope(luma::detail::application_core& core) : core_{core} { + ++core_.traversal_depth; +} + +ui_traversal_scope::~ui_traversal_scope() { + assert(core_.traversal_depth > 0); + --core_.traversal_depth; + flush_retired_controls(core_); +} + +raylib_texture::raylib_texture(std::shared_ptr record, std::shared_ptr state) : record_{std::move(record)}, state_{std::move(state)} { + assert(record_ != nullptr); + assert(record_->texture.id != 0); + assert(record_->texture.width > 0); + assert(record_->texture.height > 0); + assert(state_ != nullptr); +} + +raylib_texture::~raylib_texture() { + if (!record_ || record_->unloaded || !state_ || !state_->active.load(std::memory_order_acquire)) { + return; + } + if (state_->ui_thread == std::this_thread::get_id()) { + unload_record(*record_); + return; + } + auto lock = std::scoped_lock{state_->mutex}; + state_->pending_unloads.push_back(record_); +} + +auto raylib_texture::info() const -> luma::texture_info { + assert(record_ != nullptr); + return { + .width = record_->texture.width, + .height = record_->texture.height, + }; +} + +auto raylib_texture::valid() const -> std::bool_t { + return record_ != nullptr && !record_->unloaded && record_->texture.id != 0; +} + +auto raylib_texture::native() const -> Texture2D { + assert(record_ != nullptr); + return record_->texture; +} + +raylib_resources::raylib_resources(std::shared_ptr state) : state_{std::move(state)} { + assert(state_ != nullptr); +} + +auto raylib_resources::load_texture(std::string const& path) -> std::expected, modulus::error> { + assert(state_ != nullptr); + assert(state_->active.load(std::memory_order_acquire)); + assert(state_->ui_thread == std::this_thread::get_id()); + assert(IsWindowReady()); + if (path.empty()) { + return luma::detail::fail>(luma::detail::error_invalid_argument, "texture path must not be empty"); + } + auto texture = LoadTexture(path.c_str()); + if (texture.id == 0) { + return luma::detail::fail>(luma::detail::error_not_found, "failed to load texture: " + path); + } + auto record = std::make_shared(); + record->texture = texture; + { + auto lock = std::scoped_lock{state_->mutex}; + state_->records.push_back(record); + } + return modulus::success(std::make_shared(record, state_)); +} + +auto raylib_resources::state() const -> std::shared_ptr { + return state_; +} + +raylib_draw_context::raylib_draw_context(std::shared_ptr state) : state_{std::move(state)} { + assert(state_ != nullptr); +} + +auto raylib_draw_context::fill_rect(luma::rect bounds, luma::color color) -> void { + assert(IsWindowReady()); + DrawRectangleRec({ + .x = bounds.x, + .y = bounds.y, + .width = bounds.width, + .height = bounds.height, + }, to_raylib(color)); +} + +auto raylib_draw_context::stroke_rect(luma::rect bounds, std::float32_t thickness, luma::color color) -> void { + assert(IsWindowReady()); + assert(thickness >= 0.0F); + DrawRectangleLinesEx({ + .x = bounds.x, + .y = bounds.y, + .width = bounds.width, + .height = bounds.height, + }, thickness, to_raylib(color)); +} + +auto raylib_draw_context::stroke_line(luma::point from, luma::point to, std::float32_t thickness, luma::color color) -> void { + assert(IsWindowReady()); + assert(thickness >= 0.0F); + DrawLineEx({.x = from.x, .y = from.y}, {.x = to.x, .y = to.y}, thickness, to_raylib(color)); +} + +auto raylib_draw_context::fill_ellipse(luma::rect bounds, luma::color color) -> void { + assert(IsWindowReady()); + auto center_x = bounds.x + bounds.width * 0.5F; + auto center_y = bounds.y + bounds.height * 0.5F; + auto radius_x = std::max(std::float32_t{}, bounds.width * 0.5F); + auto radius_y = std::max(std::float32_t{}, bounds.height * 0.5F); + if (radius_x == 0.0F || radius_y == 0.0F) { + return; + } + DrawEllipse(static_cast(std::lround(center_x)), static_cast(std::lround(center_y)), radius_x, radius_y, to_raylib(color)); +} + +auto raylib_draw_context::stroke_ellipse(luma::rect bounds, std::float32_t thickness, luma::color color) -> void { + assert(IsWindowReady()); + assert(thickness >= 0.0F); + auto center_x = bounds.x + bounds.width * 0.5F; + auto center_y = bounds.y + bounds.height * 0.5F; + auto radius_x = std::max(std::float32_t{}, bounds.width * 0.5F); + auto radius_y = std::max(std::float32_t{}, bounds.height * 0.5F); + if (radius_x == 0.0F || radius_y == 0.0F || thickness == 0.0F) { + return; + } + constexpr auto segments = std::int32_t{72}; + constexpr auto tau = std::float32_t{6.2831853071795864769F}; + auto previous = luma::point{.x = center_x + radius_x, .y = center_y}; + for (auto index = std::int32_t{1}; index <= segments; ++index) { + auto angle = tau * static_cast(index) / static_cast(segments); + auto current = luma::point{ + .x = center_x + std::cos(angle) * radius_x, + .y = center_y + std::sin(angle) * radius_y, + }; + DrawLineEx({.x = previous.x, .y = previous.y}, {.x = current.x, .y = current.y}, thickness, to_raylib(color)); + previous = current; + } +} + +auto raylib_draw_context::draw_text(std::string_view text, luma::point origin, luma::text_style const& style, luma::color color) -> void { + assert(IsWindowReady()); + assert(state_ != nullptr); + assert(style.size > 0.0F); + auto owned = std::string{text}; + auto font = resolve_font(*state_, style); + DrawTextEx(font, owned.c_str(), { + .x = origin.x, + .y = origin.y, + }, style.size, 1.0F, to_raylib(color)); +} + +auto raylib_draw_context::draw_texture(luma::texture const& texture, luma::rect source, luma::rect destination, luma::color tint) -> void { + assert(IsWindowReady()); + auto const* native = dynamic_cast(&texture); + if (native == nullptr || !native->valid()) { + return; + } + DrawTexturePro( + native->native(), + { + .x = source.x, + .y = source.y, + .width = source.width, + .height = source.height, + }, + { + .x = destination.x, + .y = destination.y, + .width = destination.width, + .height = destination.height, + }, + { + .x = 0.0F, + .y = 0.0F, + }, + 0.0F, + to_raylib(tint)); +} + +auto raylib_draw_context::push_clip(luma::rect bounds) -> void { + assert(IsWindowReady()); + auto clipped = clip_stack_.empty() ? bounds : intersect_clip(clip_stack_.back(), bounds); + clip_stack_.push_back(clipped); + begin_clip(clipped); +} + +auto raylib_draw_context::pop_clip() -> void { + assert(IsWindowReady()); + assert(!clip_stack_.empty()); + EndScissorMode(); + clip_stack_.pop_back(); + if (!clip_stack_.empty()) { + begin_clip(clip_stack_.back()); + } +} + +auto raylib_draw_context::measure_text(std::string_view text, luma::text_style const& style) const -> luma::size { + assert(IsWindowReady()); + assert(state_ != nullptr); + assert(style.size > 0.0F); + auto owned = std::string{text}; + auto font = resolve_font(*state_, style); + auto measured = MeasureTextEx(font, owned.c_str(), style.size, 1.0F); + return { + .width = measured.x, + .height = style.line_height, + }; +} + +} + +luma::detail::application_core::application_core(luma::application_config value) : ui_strand{boost::asio::make_strand(ui_context)}, config{std::move(value)} {} + +luma::application::application(luma::application_config config) : core_{std::make_shared(std::move(config))} { + assert(core_ != nullptr); + install_retire_handlers(core_); + core_->windows.bind_layout_invalidator(make_layout_invalidator(core_)); +} + +luma::application::application(luma::application&& other) noexcept { + auto lock = std::scoped_lock{other.core_mutex_}; + core_ = std::move(other.core_); +} + +luma::application::~application() { + request_stop(); +} + +auto luma::application::operator=(luma::application&& other) noexcept -> luma::application& { + if (this != &other) { + request_stop(); + auto lock = std::scoped_lock{ + core_mutex_, + other.core_mutex_, + }; + core_ = std::move(other.core_); + } + return *this; +} + +auto luma::application::core_snapshot() const -> std::shared_ptr { + auto lock = std::scoped_lock{core_mutex_}; + return core_; +} + +auto luma::application::dispatch(std::function()> task) -> std::expected { + if (!task) { + return luma::detail::fail(luma::detail::error_invalid_argument, "UI task must not be empty"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, task = std::move(task)]() mutable { + auto status = task(); + if (status && core) { + invalidate_layout_cache(*core); + } + return status; + }); +} + +auto luma::application::register_route(std::string name, std::function, modulus::error>()> factory) -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, name = std::move(name), factory = std::move(factory)]() mutable { + return core->router.register_route(std::move(name), std::move(factory)); + }); +} + +auto luma::application::replace_route(std::string name) -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, name = std::move(name)] { + auto status = core->router.replace(name); + if (status) { + if (auto* current = core->router.current(); current != nullptr) { + bind_layout_invalidator(core, *current); + } + } + return status; + }); +} + +auto luma::application::push_route(std::string name) -> std::expected { + modulus::log_debug("luma application push_route requested: " + name); + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, name = std::move(name)] { + auto status = core->router.push(name); + if (status) { + if (auto* current = core->router.current(); current != nullptr) { + bind_layout_invalidator(core, *current); + } + } + return status; + }); +} + +auto luma::application::pop_route() -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core] { + auto status = core->router.pop(); + if (status) { + if (auto* current = core->router.current(); current != nullptr) { + bind_layout_invalidator(core, *current); + } + } + return status; + }); +} + +auto luma::application::current_route() const -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core] { + return modulus::success(std::string{core->router.current_route()}); + }, false); +} + +auto luma::application::add_animation(luma::animation animation) -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, animation = std::move(animation)]() mutable { + return core->animations.add(std::move(animation)); + }); +} + +auto luma::application::clear_animations() -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core] { + core->animations.clear(); + return std::expected{}; + }); +} + +auto luma::application::animations_empty() const -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core] { + return modulus::success(core->animations.empty()); + }, false); +} + +auto luma::application::open_window(luma::window_options options, std::unique_ptr content) -> std::expected { + if (content == nullptr) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window content must not be null"); + } + if (!finite_rect(options.bounds)) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window bounds must be finite and non-negative"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, options = std::move(options), content = std::move(content)]() mutable { + return core->windows.open(std::move(options), std::move(content)); + }); +} + +auto luma::application::close_window(std::uint64_t id) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, id] { + return core->windows.close(id); + }); +} + +auto luma::application::bring_window_to_front(std::uint64_t id) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, id] { + return core->windows.bring_to_front(id); + }); +} + +auto luma::application::set_window_bounds(std::uint64_t id, luma::rect bounds) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + if (!finite_rect(bounds)) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window bounds must be finite and non-negative"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, id, bounds] { + return core->windows.set_bounds(id, bounds); + }); +} + +auto luma::application::set_window_visible(std::uint64_t id, std::bool_t visible) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, id, visible] { + return core->windows.set_visible(id, visible); + }); +} + +auto luma::application::load_texture(std::string path) -> std::expected, modulus::error> { + modulus::log_debug("luma texture load requested: " + path); + if (path.empty()) { + return luma::detail::fail>(luma::detail::error_invalid_argument, "texture path must not be empty"); + } + auto core = core_snapshot(); + return dispatch_to_ui>(core, [core, path = std::move(path)] { + if (!core->renderer_active.load(std::memory_order_acquire) || !core->assets) { + return unavailable>("texture loading requires an active renderer"); + } + return core->assets->texture(path); + }, false); +} + +auto luma::application::request_layout() -> std::expected { + auto core = core_snapshot(); + return dispatch_to_ui(core, [core] { + if (core) { + invalidate_layout_cache(*core); + } + return std::expected{}; + }); +} + +auto luma::application::set_root(std::unique_ptr root) -> std::expected { + if (root == nullptr) { + return luma::detail::fail(luma::detail::error_invalid_argument, "root control must not be null"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, root = std::move(root)]() mutable { + auto host = luma::overlays(std::move(root)); + if (!host) { + return modulus::fail(host.error()); + } + retire_control(*core, std::move(core->root)); + core->overlays = host->get(); + core->root = std::move(*host); + bind_layout_invalidator(core, *core->root); + return std::expected{}; + }); +} + +auto luma::application::set_root(std::expected, modulus::error> root) -> std::expected { + if (!root) { + return modulus::fail(root.error()); + } + return set_root(std::move(*root)); +} + +auto luma::application::show_dialog(luma::dialog_options options) -> std::expected { + if (options.body == nullptr) { + return luma::detail::fail(luma::detail::error_invalid_argument, "dialog body must not be null"); + } + if (options.accept_text.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "dialog accept text must not be empty"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, options = std::move(options)]() mutable { + if (core->overlays == nullptr) { + return unavailable("root control must be set before showing dialogs"); + } + return core->overlays->show_dialog(std::move(options)); + }); +} + +auto luma::application::close_dialog(luma::dialog_result result) -> std::expected { + if (result == luma::dialog_result::NONE) { + return luma::detail::fail(luma::detail::error_invalid_argument, "dialog close result must not be NONE"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, result] { + if (core->overlays == nullptr) { + return unavailable("root control must be set before closing dialogs"); + } + return core->overlays->close_dialog(result); + }); +} + +auto luma::application::show_toast(luma::toast toast) -> std::expected { + if (toast.message.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "toast message must not be empty"); + } + if (toast.duration.count() <= 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "toast duration must be positive"); + } + auto core = core_snapshot(); + return dispatch_to_ui(core, [core, toast = std::move(toast)]() mutable { + if (core->overlays == nullptr) { + return unavailable("root control must be set before showing toasts"); + } + return core->overlays->show_toast(std::move(toast)); + }); +} + +auto luma::application::request_stop() -> void { + auto core = core_snapshot(); + if (core) { + core->stop_requested.store(true, std::memory_order_release); + } +} + +auto luma::application::should_stop() const -> std::bool_t { + auto core = core_snapshot(); + return core && core->stop_requested.load(std::memory_order_acquire); +} + +auto luma::application::run() -> std::expected { + auto core = core_snapshot(); + if (!core) { + return unavailable("application is not initialized"); + } + auto startup_lock = std::unique_lock{core->immediate_mutex}; + if (auto status = luma::validate(core->config); !status) { + return modulus::fail(status.error()); + } + { + auto lock = std::scoped_lock{core->lifecycle_mutex}; + if (core->running.load(std::memory_order_acquire)) { + return unavailable("application is already running"); + } + if (core->stopping.load(std::memory_order_acquire)) { + return unavailable("application is shutting down"); + } + core->ui_thread = std::this_thread::get_id(); + core->stop_requested.store(false, std::memory_order_release); + core->stopping.store(false, std::memory_order_release); + core->running.store(true, std::memory_order_release); + core->accepting_tasks.store(false, std::memory_order_release); + } + + auto resource_state = std::shared_ptr{}; + auto window_ready = false; + [[maybe_unused]] auto cleanup = scope_exit{[&core, &resource_state, &window_ready] { + { + auto lock = std::scoped_lock{core->lifecycle_mutex}; + core->stopping.store(true, std::memory_order_release); + core->accepting_tasks.store(false, std::memory_order_release); + } + drain_ui_tasks(*core); + cleanup_ui_state(*core); + if (resource_state) { + unload_all_textures(*resource_state); + unload_all_fonts(*resource_state); + resource_state->active.store(false, std::memory_order_release); + } + core->renderer_active.store(false, std::memory_order_release); + if (window_ready) { + CloseWindow(); + window_ready = false; + } + finish_run(*core); + }}; + + InitWindow(core->config.display.width, core->config.display.height, core->config.title.c_str()); + if (!IsWindowReady()) { + return unavailable("failed to initialize raylib window"); + } + window_ready = true; + if (core->config.display.fullscreen) { + ToggleFullscreen(); + } + auto current_target_fps = core->config.display.target_fps; + auto const idle_fps = core->config.display.idle_fps; + SetTargetFPS(current_target_fps); + + resource_state = std::make_shared(); + resource_state->ui_thread = std::this_thread::get_id(); + resource_state->active.store(true, std::memory_order_release); + core->renderer_active.store(true, std::memory_order_release); + core->resources = std::make_unique(resource_state); + core->assets = std::make_unique(*core->resources); + { + auto lock = std::scoped_lock{core->lifecycle_mutex}; + core->accepting_tasks.store(true, std::memory_order_release); + } + startup_lock.unlock(); + + auto context = raylib_draw_context{resource_state}; + auto previous_touches = std::unordered_map{}; + auto previous_frame = std::chrono::steady_clock::now(); + try { + while (!WindowShouldClose() && !core->stop_requested.load(std::memory_order_acquire)) { + auto const ran_ui_tasks = drain_ui_tasks(*core) > 0; + if (flush_pending_unloads(*resource_state)) { + mark_redraw_dirty(*core); + } + if (ran_ui_tasks) { + mark_redraw_dirty(*core); + } + auto frame_traversal = ui_traversal_scope{*core}; + auto now = std::chrono::steady_clock::now(); + auto delta = now - previous_frame; + previous_frame = now; + auto animations_active = !core->animations.empty(); + if (auto status = core->animations.tick(delta); !status) { + return modulus::fail(status.error()); + } + if (animations_active) { + mark_layout_dirty(*core); + } + if (core->overlays != nullptr && core->overlays->tick(delta)) { + mark_redraw_dirty(*core); + } + auto event = poll_touch(previous_touches, core->config.touch.max_points); + auto const touch_active = !event.points.empty(); + if (touch_active) { + mark_redraw_dirty(*core); + } + auto touch_requires_layout = false; + if (!event.points.empty()) { + for (auto const& point : event.points) { + auto single = make_event(event, point); + if (core->windows.handle_touch(single)) { + touch_requires_layout = touch_requires_layout || point.phase != luma::touch_phase::MOVE; + continue; + } + auto* active = active_control(*core); + if (active != nullptr && active->handle_touch(single)) { + touch_requires_layout = touch_requires_layout || point.phase != luma::touch_phase::MOVE; + } + } + } + if (touch_requires_layout) { + mark_layout_dirty(*core); + } + auto display = luma::rect{ + .x = 0.0F, + .y = 0.0F, + .width = static_cast(core->config.display.width), + .height = static_cast(core->config.display.height), + }; + auto limits = luma::constraints{ + .min = { + .width = display.width, + .height = display.height, + }, + .max = { + .width = display.width, + .height = display.height, + }, + }; + auto display_changed = !core->layout_ready || !same_rect(core->layout_display, display); + auto needs_layout = core->layout_dirty.exchange(false, std::memory_order_acq_rel) || display_changed; + auto did_layout = needs_layout; + if (needs_layout) { + auto layout_again = true; + auto pass = std::uint32_t{}; + while (layout_again) { + ++pass; + if (pass > max_layout_passes) { + return modulus::fail(internal_error("application layout did not stabilize")); + } + auto* active = active_control(*core); + if (active != nullptr) { + active->measure(context, core->config.theme, limits); + active->arrange(display); + } + core->windows.measure(context, core->config.theme, limits); + core->windows.arrange(display); + core->layout_display = display; + core->layout_ready = true; + layout_again = core->layout_dirty.exchange(false, std::memory_order_acq_rel); + } + } + auto const desired_target_fps = (ran_ui_tasks || animations_active || touch_active || did_layout) ? core->config.display.target_fps : idle_fps; + if (desired_target_fps != current_target_fps) { + SetTargetFPS(desired_target_fps); + current_target_fps = desired_target_fps; + } + auto const should_draw = core->redraw_dirty.exchange(false, std::memory_order_acq_rel) || did_layout || animations_active || touch_active; + if (should_draw) { + auto drawing = drawing_scope{}; + ClearBackground(to_raylib(core->config.theme.background)); + auto* active = active_control(*core); + if (active != nullptr) { + active->draw(context, core->config.theme); + } + core->windows.draw(context, core->config.theme); + drawing.end(); + } else { + WaitTime(1.0 / static_cast(current_target_fps)); + PollInputEvents(); + } + } + } catch (std::exception const& error) { + return modulus::fail(internal_error(std::string{"application frame failed: "} + error.what())); + } catch (...) { + return modulus::fail(internal_error("application frame failed with an unknown exception")); + } + return {}; +} diff --git a/src/assets.cpp b/src/assets.cpp new file mode 100644 index 0000000..f0396a3 --- /dev/null +++ b/src/assets.cpp @@ -0,0 +1,126 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +struct luma::asset_cache::in_flight_load { + std::condition_variable ready{}; + std::thread::id owner{}; + std::expected, modulus::error> result{}; + std::bool_t done{false}; +}; + +luma::asset_cache::asset_cache(luma::renderer_resources& resources) : resources_{&resources} { + assert(resources_ != nullptr); +} + +auto luma::asset_cache::texture(std::string const& path) -> std::expected, modulus::error> { + assert(resources_ != nullptr); + if (path.empty()) { + return luma::detail::fail>(luma::detail::error_invalid_argument, "texture path must not be empty"); + } + auto generation = std::uint64_t{}; + auto loading = std::shared_ptr{}; + { + auto lock = std::unique_lock{mutex_}; + if (auto found = textures_.find(path); found != textures_.end()) { + assert(found->second != nullptr); + return modulus::success(found->second); + } + if (auto found = loading_.find(path); found != loading_.end()) { + loading = found->second; + assert(loading != nullptr); + if (loading->owner == std::this_thread::get_id()) { + return luma::detail::fail>(luma::detail::error_unavailable, "texture load reentered for path: " + path); + } + loading->ready.wait(lock, [&loading] { + return loading->done; + }); + if (auto cached = textures_.find(path); cached != textures_.end()) { + assert(cached->second != nullptr); + return modulus::success(cached->second); + } + if (!loading->result) { + return modulus::fail>(loading->result.error()); + } + assert(*loading->result != nullptr); + return modulus::success(*loading->result); + } + loading = std::make_shared(); + loading->owner = std::this_thread::get_id(); + loading_.emplace(path, loading); + generation = generation_; + } + auto loaded = std::expected, modulus::error>{}; + modulus::log_debug("luma texture loading: " + path); + try { + loaded = resources_->load_texture(path); + } catch (std::exception const& error) { + loaded = luma::detail::fail>(luma::detail::error_internal, std::string{"texture load failed: "} + error.what()); + } catch (...) { + loaded = luma::detail::fail>(luma::detail::error_internal, "texture load failed with an unknown exception"); + } + if (!loaded) { + modulus::log_debug("luma texture load failed: " + path); + auto lock = std::scoped_lock{mutex_}; + loading->result = modulus::fail>(loaded.error()); + loading->done = true; + loading_.erase(path); + loading->ready.notify_all(); + return loaded; + } + if (*loaded == nullptr) { + modulus::log_debug("luma texture load returned null: " + path); + auto result = luma::detail::fail>(luma::detail::error_not_found, "texture load returned no texture: " + path); + auto lock = std::scoped_lock{mutex_}; + loading->result = modulus::fail>(result.error()); + loading->done = true; + loading_.erase(path); + loading->ready.notify_all(); + return result; + } + auto texture = *loaded; + auto lock = std::scoped_lock{mutex_}; + if (auto found = textures_.find(path); found != textures_.end()) { + assert(found->second != nullptr); + loading->result = modulus::success(found->second); + loading->done = true; + loading_.erase(path); + loading->ready.notify_all(); + return modulus::success(found->second); + } + if (generation != generation_) { + loading->result = modulus::success(texture); + loading->done = true; + loading_.erase(path); + loading->ready.notify_all(); + return modulus::success(std::move(texture)); + } + auto inserted = textures_.emplace(path, std::move(texture)); + assert(inserted.first->second != nullptr); + loading->result = modulus::success(inserted.first->second); + loading->done = true; + loading_.erase(path); + loading->ready.notify_all(); + return modulus::success(inserted.first->second); +} + +auto luma::asset_cache::clear() -> void { + assert(resources_ != nullptr); + auto lock = std::scoped_lock{mutex_}; + ++generation_; + textures_.clear(); +} diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..e26e7db --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,261 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include + +namespace { + +constexpr auto json_options = modulus::json_config::options{ + .category = "luma", + .invalid_argument_code = luma::detail::error_invalid_argument, + .not_found_code = luma::detail::error_not_found +}; + +auto invalid_display(std::string message) -> std::expected { + assert(!message.empty()); + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto invalid_touch(std::string message) -> std::expected { + assert(!message.empty()); + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto invalid_application(std::string message) -> std::expected { + assert(!message.empty()); + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +} + +auto luma::validate(luma::display_config const& config) -> std::expected { + if (config.width <= 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "display.width must be positive"); + } + if (config.height <= 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "display.height must be positive"); + } + if (config.target_fps <= 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "display.target_fps must be positive"); + } + if (config.idle_fps <= 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "display.idle_fps must be positive"); + } + if (config.idle_fps > config.target_fps) { + return luma::detail::fail(luma::detail::error_invalid_argument, "display.idle_fps must be less than or equal to display.target_fps"); + } + return {}; +} + +auto luma::validate(luma::touch_config const& config) -> std::expected { + if (config.max_points <= 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "touch.max_points must be positive"); + } + return {}; +} + +auto luma::validate(luma::application_config const& config) -> std::expected { + if (config.title.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "title must not be empty"); + } + if (auto status = luma::validate(config.display); !status) { + return modulus::fail(status.error()); + } + if (auto status = luma::validate(config.touch); !status) { + return modulus::fail(status.error()); + } + if (auto status = luma::validate(config.theme); !status) { + return modulus::fail(status.error()); + } + return {}; +} + +auto luma::parse_display_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "display", json_options); + if (!object) { + return invalid_display(std::string{object.error().message()}); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "width", + "height", + "target_fps", + "idle_fps", + "fullscreen", + }, "display", json_options); + if (!unknown) { + return invalid_display(std::string{unknown.error().message()}); + } + auto config = luma::display_config{}; + auto width = modulus::json_config::optional_int32_field(**object, "width", config.width, std::numeric_limits::min(), std::numeric_limits::max(), json_options); + auto height = modulus::json_config::optional_int32_field(**object, "height", config.height, std::numeric_limits::min(), std::numeric_limits::max(), json_options); + auto target_fps = modulus::json_config::optional_int32_field(**object, "target_fps", config.target_fps, std::numeric_limits::min(), std::numeric_limits::max(), json_options); + auto idle_fps = modulus::json_config::optional_int32_field(**object, "idle_fps", config.idle_fps, std::numeric_limits::min(), std::numeric_limits::max(), json_options); + auto fullscreen = modulus::json_config::optional_bool_field(**object, "fullscreen", config.fullscreen, json_options); + if (!width) { + return invalid_display(std::string{width.error().message()}); + } + if (!height) { + return invalid_display(std::string{height.error().message()}); + } + if (!target_fps) { + return invalid_display(std::string{target_fps.error().message()}); + } + if (!idle_fps) { + return invalid_display(std::string{idle_fps.error().message()}); + } + if (!fullscreen) { + return invalid_display(std::string{fullscreen.error().message()}); + } + config.width = *width; + config.height = *height; + config.target_fps = *target_fps; + config.idle_fps = *idle_fps; + config.fullscreen = *fullscreen; + if (auto status = luma::validate(config); !status) { + return invalid_display(std::string{status.error().message()}); + } + return modulus::success(config); +} + +auto luma::parse_touch_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "touch", json_options); + if (!object) { + return invalid_touch(std::string{object.error().message()}); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, {"max_points"}, "touch", json_options); + if (!unknown) { + return invalid_touch(std::string{unknown.error().message()}); + } + auto config = luma::touch_config{}; + auto max_points = modulus::json_config::optional_int32_field(**object, "max_points", config.max_points, std::numeric_limits::min(), std::numeric_limits::max(), json_options); + if (!max_points) { + return invalid_touch(std::string{max_points.error().message()}); + } + config.max_points = *max_points; + if (auto status = luma::validate(config); !status) { + return invalid_touch(std::string{status.error().message()}); + } + return modulus::success(config); +} + +auto luma::parse_application_config(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "application config", json_options); + if (!object) { + return invalid_application(std::string{object.error().message()}); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "title", + "display", + "touch", + "theme", + }, "application config", json_options); + if (!unknown) { + return invalid_application(std::string{unknown.error().message()}); + } + auto config = luma::application_config{}; + auto title = modulus::json_config::optional_string_field(**object, "title", config.title, json_options); + if (!title) { + return invalid_application(std::string{title.error().message()}); + } + config.title = *title; + if (auto const* display = (*object)->if_contains("display")) { + auto parsed = luma::parse_display_config(*display); + if (!parsed) { + return invalid_application(std::string{parsed.error().message()}); + } + config.display = *parsed; + } + if (auto const* touch = (*object)->if_contains("touch")) { + auto parsed = luma::parse_touch_config(*touch); + if (!parsed) { + return invalid_application(std::string{parsed.error().message()}); + } + config.touch = *parsed; + } + if (auto const* theme = (*object)->if_contains("theme")) { + auto parsed = luma::parse_theme(*theme); + if (!parsed) { + return invalid_application(std::string{parsed.error().message()}); + } + config.theme = *parsed; + } + if (auto status = luma::validate(config); !status) { + return invalid_application(std::string{status.error().message()}); + } + return modulus::success(config); +} + +auto luma::load_application_config(std::string const& path) -> std::expected { + auto value = modulus::json_config::load_value(path, json_options); + if (!value) { + return modulus::fail(value.error()); + } + return luma::parse_application_config(*value); +} + +auto luma::to_json(luma::display_config const& config) -> boost::json::value { + return boost::json::object{ + { + "width", + config.width, + }, + { + "height", + config.height, + }, + { + "target_fps", + config.target_fps, + }, + { + "idle_fps", + config.idle_fps, + }, + { + "fullscreen", + config.fullscreen, + }, + }; +} + +auto luma::to_json(luma::touch_config const& config) -> boost::json::value { + return boost::json::object{ + { + "max_points", + config.max_points, + }, + }; +} + +auto luma::to_json(luma::application_config const& config) -> boost::json::value { + return boost::json::object{ + { + "title", + config.title, + }, + { + "display", + luma::to_json(config.display), + }, + { + "touch", + luma::to_json(config.touch), + }, + { + "theme", + luma::to_json(config.theme), + }, + }; +} diff --git a/src/control.cpp b/src/control.cpp new file mode 100644 index 0000000..1b778a6 --- /dev/null +++ b/src/control.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include +#include + +#include + +namespace { + +auto valid_rect(luma::rect value) -> std::bool_t { + return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.width) && std::isfinite(value.height) && value.width >= 0.0F && value.height >= 0.0F; +} + +} + +luma::control::~control() = default; + +auto luma::control::arrange(luma::rect bounds) -> void { + assert(valid_rect(bounds)); + bounds_ = bounds; +} + +auto luma::control::handle_touch(luma::touch_event const& event) -> std::bool_t { + static_cast(event); + return false; +} + +auto luma::control::invalidate_layout() -> void {} + +auto luma::control::bind_layout_invalidator(std::function invalidator) -> void { + layout_invalidator_ = std::move(invalidator); +} + +auto luma::control::focusable() const -> std::bool_t { + return false; +} + +auto luma::control::set_focused(std::bool_t value) -> void { + assert(!value || focusable()); + focused_ = value; +} + +auto luma::control::focused() const -> std::bool_t { + return focused_; +} + +auto luma::control::set_enabled(std::bool_t value) -> void { + enabled_ = value; + if (!enabled_) { + focused_ = false; + } +} + +auto luma::control::enabled() const -> std::bool_t { + return enabled_; +} + +auto luma::control::bounds() const -> luma::rect { + assert(valid_rect(bounds_)); + return bounds_; +} + +auto luma::control::notify_layout_changed() const -> void { + if (!layout_invalidator_) { + return; + } + try { + layout_invalidator_(); + } catch (...) { + } +} + +auto luma::control::layout_invalidator() const -> std::function { + return layout_invalidator_; +} diff --git a/src/controls.cpp b/src/controls.cpp new file mode 100644 index 0000000..7ddcd05 --- /dev/null +++ b/src/controls.cpp @@ -0,0 +1,1631 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr auto panel_padding = luma::edge_insets{ + .left = 8.0F, + .top = 8.0F, + .right = 8.0F, + .bottom = 8.0F +}; +constexpr auto scroll_drag_threshold = std::float32_t{6.0F}; + +class label_control final : public luma::control { +public: + explicit label_control(std::string text); + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; +private: + std::string text_; +}; + +class button_control final : public luma::control { +public: + button_control(std::string text, std::function on_click); + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto focusable() const -> std::bool_t override; +private: + std::string text_; + std::function on_click_; + std::optional pressed_id_; +}; + +class text_field_control final : public luma::control { +public: + explicit text_field_control(std::shared_ptr input); + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + auto bind_layout_invalidator(std::function invalidator) -> void override; + auto focusable() const -> std::bool_t override; +private: + std::shared_ptr input_; + std::shared_ptr layout_listener_lifetime_{}; + mutable std::optional cached_limits_{}; + mutable std::optional cached_style_{}; + mutable std::uint64_t cached_version_{}; + mutable luma::size cached_size_{}; +}; + +class soft_keyboard_control final : public luma::control { +public: + soft_keyboard_control(luma::keyboard_layout layout, std::function on_text, std::function on_enter); + soft_keyboard_control(luma::keyboard_layout layout, std::shared_ptr input, std::function on_enter); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; +private: + luma::keyboard_layout layout_; + std::function on_text_; + std::function on_enter_; + std::shared_ptr input_{}; +}; + +class panel_control final : public luma::control { +public: + explicit panel_control(std::vector> children); + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + auto bind_layout_invalidator(std::function invalidator) -> void override; +private: + std::vector> children_; +}; + +class spacer_control final : public luma::control { +public: + explicit spacer_control(luma::size value); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; +private: + luma::size value_; +}; + +class progress_bar_control final : public luma::control { +public: + explicit progress_bar_control(std::float32_t value); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; +private: + std::float32_t value_; +}; + +class image_control final : public luma::control { +public: + image_control(std::shared_ptr texture, luma::image_fit fit); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; +private: + std::shared_ptr texture_; + luma::image_fit fit_; +}; + +class linear_control final : public luma::control { +public: + linear_control(luma::direction direction, std::vector> children, std::float32_t gap, luma::alignment align); + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + auto bind_layout_invalidator(std::function invalidator) -> void override; +private: + luma::direction direction_; + std::vector> children_; + std::float32_t gap_; + luma::alignment align_; + std::vector measured_; +}; + +class stack_control final : public luma::control { +public: + explicit stack_control(std::vector> children); + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + auto bind_layout_invalidator(std::function invalidator) -> void override; +private: + std::vector> children_; +}; + +class scroll_view_control final : public luma::control { +public: + scroll_view_control(std::unique_ptr child, std::bool_t vertical, std::bool_t horizontal); + auto measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context& context, luma::theme const& theme) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + auto bind_layout_invalidator(std::function invalidator) -> void override; +private: + struct touch_state { + luma::point start{}; + luma::point last{}; + std::bool_t child_active{false}; + }; + auto handle_point(luma::touch_event const& event, luma::touch_point const& point) -> std::bool_t; + auto invalidate_child_measure() -> void; + auto arrange_child() -> void; + auto clamp_offset() -> void; + std::unique_ptr child_; + std::bool_t vertical_; + std::bool_t horizontal_; + luma::size content_size_{}; + std::optional cached_child_limits_{}; + std::optional cached_theme_{}; + luma::size cached_content_size_{}; + std::bool_t content_measure_valid_{false}; + luma::point offset_{}; + luma::point velocity_{}; + std::unordered_map touches_{}; + std::optional scroll_id_{}; + std::bool_t dragging_{false}; +}; + +auto finite_size(luma::size value) -> std::bool_t { + return std::isfinite(value.width) && std::isfinite(value.height) && value.width >= 0.0F && value.height >= 0.0F; +} + +auto finite_rect(luma::rect value) -> std::bool_t { + return std::isfinite(value.x) && std::isfinite(value.y) && finite_size({ + .width = value.width, + .height = value.height, + }); +} + +auto finite_constraints(luma::constraints value) -> std::bool_t { + return finite_size(value.min) && finite_size(value.max) && value.min.width <= value.max.width && value.min.height <= value.max.height; +} + +auto same_size(luma::size left, luma::size right) -> std::bool_t { + return left.width == right.width && left.height == right.height; +} + +auto same_constraints(luma::constraints left, luma::constraints right) -> std::bool_t { + return same_size(left.min, right.min) && same_size(left.max, right.max); +} + +auto same_text_style(luma::text_style const& left, luma::text_style const& right) -> std::bool_t { + return left.font == right.font && left.size == right.size && left.line_height == right.line_height; +} + +auto same_theme_metrics(luma::theme const& left, luma::theme const& right) -> std::bool_t { + return same_text_style(left.body, right.body) && same_text_style(left.title, right.title) && left.radius == right.radius && left.spacing == right.spacing; +} + +template +auto invalid(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto throw_error(modulus::error const& error) -> void { + throw std::runtime_error(error.describe()); +} + +auto throw_if_failed(std::expected const& status) -> void { + if (!status) { + throw_error(status.error()); + } +} + +auto make_event(luma::touch_event const& event, luma::touch_point point) -> luma::touch_event { + return { + .points = {point}, + .time = event.time, + }; +} + +auto dispatch_to_children(std::vector>& children, luma::touch_event const& event) -> std::bool_t { + auto handled = false; + for (auto const& point : event.points) { + auto single = make_event(event, point); + for (auto current = children.rbegin(); current != children.rend(); ++current) { + assert(*current != nullptr); + if ((*current)->handle_touch(single)) { + handled = true; + break; + } + } + } + return handled; +} + +auto find_point(luma::touch_event const& event, std::int32_t id) -> luma::touch_point const* { + for (auto const& point : event.points) { + if (point.id == id) { + return &point; + } + } + return nullptr; +} + +auto first_point_with_phase(luma::touch_event const& event, luma::touch_phase phase) -> luma::touch_point const* { + for (auto const& point : event.points) { + if (point.phase == phase) { + return &point; + } + } + return nullptr; +} + +auto first_point_inside(luma::touch_event const& event, luma::rect bounds, luma::touch_phase phase) -> luma::touch_point const* { + for (auto const& point : event.points) { + if (point.phase == phase && luma::contains(bounds, point.position)) { + return &point; + } + } + return nullptr; +} + +auto valid_keyboard_layout(luma::keyboard_layout const& layout) -> std::expected { + if (layout.rows.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "keyboard layout must contain at least one row"); + } + auto has_key = false; + for (auto const& row : layout.rows) { + for (auto const& key : row) { + has_key = true; + if (key.label.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "keyboard key label must not be empty"); + } + } + } + if (!has_key) { + return luma::detail::fail(luma::detail::error_invalid_argument, "keyboard layout must contain at least one key"); + } + return {}; +} + +auto validate_children(std::vector> const& children) -> std::expected { + for (auto const& child : children) { + if (child == nullptr) { + return luma::detail::fail(luma::detail::error_invalid_argument, "child control must not be null"); + } + } + return {}; +} + +auto centered_text_origin(luma::draw_context& context, std::string const& text, luma::rect bounds, luma::text_style const& style) -> luma::point { + assert(finite_rect(bounds)); + auto measured = context.measure_text(text, style); + assert(finite_size(measured)); + return { + .x = bounds.x + (bounds.width - measured.width) * 0.5F, + .y = bounds.y + (bounds.height - measured.height) * 0.5F, + }; +} + +auto fit_rect(luma::rect bounds, luma::texture_info info, luma::image_fit fit) -> luma::rect { + assert(finite_rect(bounds)); + assert(info.width > 0); + assert(info.height > 0); + auto source_width = static_cast(info.width); + auto source_height = static_cast(info.height); + if (fit == luma::image_fit::FILL) { + return bounds; + } + if (fit == luma::image_fit::CENTER) { + return { + .x = bounds.x + (bounds.width - source_width) * 0.5F, + .y = bounds.y + (bounds.height - source_height) * 0.5F, + .width = source_width, + .height = source_height, + }; + } + auto scale_x = bounds.width / source_width; + auto scale_y = bounds.height / source_height; + auto scale = fit == luma::image_fit::COVER ? std::max(scale_x, scale_y) : std::min(scale_x, scale_y); + auto width = source_width * scale; + auto height = source_height * scale; + return { + .x = bounds.x + (bounds.width - width) * 0.5F, + .y = bounds.y + (bounds.height - height) * 0.5F, + .width = width, + .height = height, + }; +} + +label_control::label_control(std::string text) : text_{std::move(text)} { + assert(!text_.empty()); +} + +auto label_control::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + auto measured = context.measure_text(text_, theme.body); + assert(finite_size(measured)); + return luma::clamp(measured, limits); +} + +auto label_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + assert(!text_.empty()); + context.draw_text(text_, { + .x = bounds().x, + .y = bounds().y, + }, theme.body, enabled() ? theme.text : theme.text_muted); +} + +button_control::button_control(std::string text, std::function on_click) : text_{std::move(text)}, on_click_{std::move(on_click)} { + assert(!text_.empty()); + assert(on_click_); +} + +auto button_control::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + auto text_size = context.measure_text(text_, theme.body); + assert(finite_size(text_size)); + return luma::clamp({ + .width = text_size.width + 32.0F, + .height = std::max(std::float32_t{48}, text_size.height + std::float32_t{16}), + }, limits); +} + +auto button_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + auto fill = pressed_id_.has_value() ? theme.surface_active : theme.surface; + context.fill_rect(bounds(), fill); + context.stroke_rect(bounds(), 1.0F, focused() ? theme.accent : theme.border); + context.draw_text(text_, centered_text_origin(context, text_, bounds(), theme.body), theme.body, enabled() ? theme.text : theme.text_muted); +} + +auto button_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + if (!enabled()) { + return false; + } + auto const* point = pressed_id_ ? find_point(event, *pressed_id_) : first_point_inside(event, bounds(), luma::touch_phase::DOWN); + if (point == nullptr) { + return false; + } + assert(std::isfinite(point->position.x)); + assert(std::isfinite(point->position.y)); + if (point->phase == luma::touch_phase::DOWN && luma::contains(bounds(), point->position)) { + pressed_id_ = point->id; + return true; + } + if (pressed_id_ == point->id && point->phase == luma::touch_phase::UP) { + auto inside = luma::contains(bounds(), point->position); + pressed_id_.reset(); + if (inside) { + assert(on_click_); + on_click_(); + } + return true; + } + if (pressed_id_ == point->id && point->phase == luma::touch_phase::CANCEL) { + pressed_id_.reset(); + return true; + } + return pressed_id_ == point->id; +} + +auto button_control::focusable() const -> std::bool_t { + return true; +} + +text_field_control::text_field_control(std::shared_ptr input) : input_{std::move(input)} { + assert(input_ != nullptr); +} + +auto text_field_control::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + auto version = input_->version(); + if (cached_limits_ && cached_style_ && same_constraints(*cached_limits_, limits) && same_text_style(*cached_style_, theme.body) && cached_version_ == version) { + return cached_size_; + } + auto options = input_->snapshot(); + auto text = options.text.empty() ? std::string{"Text"} : options.text; + auto measured = context.measure_text(text, theme.body); + cached_limits_ = limits; + cached_style_ = theme.body; + cached_version_ = version; + cached_size_ = luma::clamp({ + .width = std::max(std::float32_t{240}, measured.width + std::float32_t{24}), + .height = std::max(std::float32_t{48}, measured.height + std::float32_t{16}), + }, limits); + return cached_size_; +} + +auto text_field_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + context.fill_rect(bounds(), theme.surface); + context.stroke_rect(bounds(), 1.0F, input_->focused() ? theme.accent : theme.border); + auto options = input_->snapshot(); + auto text = options.text; + if (text.empty()) { + text = options.placeholder; + } else if (options.password) { + text = std::string(options.text.size(), '*'); + } + context.draw_text(text, { + .x = bounds().x + 12.0F, + .y = bounds().y + 8.0F, + }, theme.body, options.text.empty() ? theme.text_muted : theme.text); +} + +auto text_field_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + if (!enabled()) { + return false; + } + auto const* point = first_point_inside(event, bounds(), luma::touch_phase::DOWN); + if (point == nullptr) { + point = first_point_with_phase(event, luma::touch_phase::DOWN); + } + if (point == nullptr) { + return false; + } + auto hit = luma::contains(bounds(), point->position); + input_->set_focused(hit); + set_focused(hit); + return hit; +} + +auto text_field_control::invalidate_layout() -> void { + cached_limits_.reset(); + cached_style_.reset(); + cached_version_ = {}; + cached_size_ = {}; +} + +auto text_field_control::bind_layout_invalidator(std::function invalidator) -> void { + luma::control::bind_layout_invalidator(invalidator); + if (!invalidator) { + layout_listener_lifetime_.reset(); + return; + } + layout_listener_lifetime_ = std::make_shared(); + input_->add_layout_listener(layout_listener_lifetime_, [invalidator = std::move(invalidator)] { + invalidator(); + }); +} + +auto text_field_control::focusable() const -> std::bool_t { + return true; +} + +soft_keyboard_control::soft_keyboard_control(luma::keyboard_layout layout, std::function on_text, std::function on_enter) : layout_{std::move(layout)}, on_text_{std::move(on_text)}, on_enter_{std::move(on_enter)} { + assert(!layout_.rows.empty()); + assert(on_text_); +} + +soft_keyboard_control::soft_keyboard_control(luma::keyboard_layout layout, std::shared_ptr input, std::function on_enter) : layout_{std::move(layout)}, on_enter_{std::move(on_enter)}, input_{std::move(input)} { + assert(!layout_.rows.empty()); + assert(input_ != nullptr); +} + +auto soft_keyboard_control::measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + return luma::clamp({ + .width = 640.0F, + .height = static_cast(layout_.rows.size()) * 56.0F, + }, limits); +} + +auto soft_keyboard_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + auto key_height = bounds().height / static_cast(layout_.rows.size()); + for (auto row_index = std::size_t{}; row_index < layout_.rows.size(); ++row_index) { + auto const& row = layout_.rows[row_index]; + if (row.empty()) { + continue; + } + auto key_width = bounds().width / static_cast(row.size()); + for (auto column_index = std::size_t{}; column_index < row.size(); ++column_index) { + auto rect = luma::rect{ + .x = bounds().x + key_width * static_cast(column_index), + .y = bounds().y + key_height * static_cast(row_index), + .width = key_width - 4.0F, + .height = key_height - 4.0F, + }; + context.fill_rect(rect, theme.surface); + context.stroke_rect(rect, 1.0F, theme.border); + context.draw_text(row[column_index].label, { + .x = rect.x + 12.0F, + .y = rect.y + 10.0F, + }, theme.body, theme.text); + } + } +} + +auto soft_keyboard_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + auto const* point = first_point_inside(event, bounds(), luma::touch_phase::UP); + if (point == nullptr) { + return false; + } + auto row_count = layout_.rows.size(); + assert(row_count > 0); + auto row_index = std::min(row_count - 1, static_cast((point->position.y - bounds().y) / (bounds().height / static_cast(row_count)))); + auto const& row = layout_.rows[row_index]; + if (row.empty()) { + return true; + } + auto column_index = std::min(row.size() - 1, static_cast((point->position.x - bounds().x) / (bounds().width / static_cast(row.size())))); + auto const& key = row[column_index]; + switch (key.kind) { + case luma::keyboard_key_kind::CHARACTER: + if (input_) { + throw_if_failed(input_->append(key.value.empty() ? key.label : key.value)); + } else { + assert(on_text_); + on_text_(key.value.empty() ? key.label : key.value); + } + break; + case luma::keyboard_key_kind::BACKSPACE: + if (input_) { + throw_if_failed(input_->backspace()); + } else { + assert(on_text_); + on_text_("\b"); + } + break; + case luma::keyboard_key_kind::SPACE: + if (input_) { + throw_if_failed(input_->append(" ")); + } else { + assert(on_text_); + on_text_(" "); + } + break; + case luma::keyboard_key_kind::ENTER: + if (on_enter_) { + on_enter_(); + } + break; + } + return true; +} + +panel_control::panel_control(std::vector> children) : children_{std::move(children)} {} + +auto panel_control::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + auto child_limits = luma::deflate(limits, panel_padding); + auto result = child_limits.min; + for (auto& child : children_) { + assert(child != nullptr); + auto measured = child->measure(context, theme, child_limits); + result.width = std::max(result.width, measured.width); + result.height = std::max(result.height, measured.height); + } + result.width += panel_padding.left + panel_padding.right; + result.height += panel_padding.top + panel_padding.bottom; + return luma::clamp(result, limits); +} + +auto panel_control::arrange(luma::rect bounds) -> void { + assert(finite_rect(bounds)); + luma::control::arrange(bounds); + auto content = luma::inset(bounds, panel_padding); + for (auto& child : children_) { + assert(child != nullptr); + child->arrange(content); + } +} + +auto panel_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + context.fill_rect(bounds(), theme.surface); + for (auto const& child : children_) { + assert(child != nullptr); + child->draw(context, theme); + } +} + +auto panel_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + return dispatch_to_children(children_, event); +} + +auto panel_control::invalidate_layout() -> void { + for (auto& child : children_) { + assert(child != nullptr); + child->invalidate_layout(); + } +} + +auto panel_control::bind_layout_invalidator(std::function invalidator) -> void { + luma::control::bind_layout_invalidator(invalidator); + for (auto& child : children_) { + assert(child != nullptr); + child->bind_layout_invalidator(invalidator); + } +} + +spacer_control::spacer_control(luma::size value) : value_{value} { + assert(finite_size(value_)); +} + +auto spacer_control::measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + return luma::clamp(value_, limits); +} + +auto spacer_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +progress_bar_control::progress_bar_control(std::float32_t value) : value_{std::clamp(value, std::float32_t{}, std::float32_t{1})} { + assert(std::isfinite(value)); +} + +auto progress_bar_control::measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + return luma::clamp({ + .width = 160.0F, + .height = 16.0F, + }, limits); +} + +auto progress_bar_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + context.fill_rect(bounds(), theme.surface); + context.fill_rect({ + .x = bounds().x, + .y = bounds().y, + .width = bounds().width * value_, + .height = bounds().height, + }, theme.accent); +} + +image_control::image_control(std::shared_ptr texture, luma::image_fit fit) : texture_{std::move(texture)}, fit_{fit} { + assert(texture_ != nullptr); +} + +auto image_control::measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + auto info = texture_->info(); + if (info.width <= 0 || info.height <= 0) { + return luma::clamp({}, limits); + } + return luma::clamp({ + .width = static_cast(info.width), + .height = static_cast(info.height), + }, limits); +} + +auto image_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + auto info = texture_->info(); + if (info.width <= 0 || info.height <= 0) { + return; + } + auto source = luma::rect{ + .x = 0.0F, + .y = 0.0F, + .width = static_cast(info.width), + .height = static_cast(info.height), + }; + context.draw_texture(*texture_, source, fit_rect(bounds(), info, fit_), theme.text); +} + +linear_control::linear_control(luma::direction direction, std::vector> children, std::float32_t gap, luma::alignment align) : direction_{direction}, children_{std::move(children)}, gap_{gap}, align_{align} { + assert(std::isfinite(gap_)); + assert(gap_ >= 0.0F); +} + +auto linear_control::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + measured_.clear(); + measured_.reserve(children_.size()); + auto result = luma::size{}; + for (auto index = std::size_t{}; index < children_.size(); ++index) { + auto& child = children_[index]; + assert(child != nullptr); + auto child_limits = limits; + if (direction_ == luma::direction::ROW) { + auto used = result.width + (index == 0 ? 0.0F : gap_); + child_limits.min.width = 0.0F; + child_limits.max.width = std::max(std::float32_t{}, limits.max.width - used); + } else { + auto used = result.height + (index == 0 ? 0.0F : gap_); + child_limits.min.height = 0.0F; + child_limits.max.height = std::max(std::float32_t{}, limits.max.height - used); + } + auto measured = child->measure(context, theme, child_limits); + measured_.push_back(measured); + if (direction_ == luma::direction::ROW) { + result.width += measured.width + (index == 0 ? 0.0F : gap_); + result.height = std::max(result.height, measured.height); + } else { + result.width = std::max(result.width, measured.width); + result.height += measured.height + (index == 0 ? 0.0F : gap_); + } + } + return luma::clamp(result, limits); +} + +auto linear_control::arrange(luma::rect bounds) -> void { + assert(finite_rect(bounds)); + luma::control::arrange(bounds); + auto cursor = direction_ == luma::direction::ROW ? bounds.x : bounds.y; + for (auto index = std::size_t{}; index < children_.size(); ++index) { + auto& child = children_[index]; + assert(child != nullptr); + auto measured = index < measured_.size() ? measured_[index] : luma::size{}; + auto child_bounds = bounds; + if (direction_ == luma::direction::ROW) { + child_bounds.x = cursor; + child_bounds.width = std::min(measured.width, std::max(std::float32_t{}, luma::right(bounds) - cursor)); + child_bounds.height = align_ == luma::alignment::STRETCH ? bounds.height : std::min(measured.height, bounds.height); + if (align_ == luma::alignment::CENTER) { + child_bounds.y = bounds.y + (bounds.height - child_bounds.height) * 0.5F; + } else if (align_ == luma::alignment::END) { + child_bounds.y = luma::bottom(bounds) - child_bounds.height; + } + cursor += child_bounds.width + gap_; + } else { + child_bounds.y = cursor; + child_bounds.width = align_ == luma::alignment::STRETCH ? bounds.width : std::min(measured.width, bounds.width); + child_bounds.height = std::min(measured.height, std::max(std::float32_t{}, luma::bottom(bounds) - cursor)); + if (align_ == luma::alignment::CENTER) { + child_bounds.x = bounds.x + (bounds.width - child_bounds.width) * 0.5F; + } else if (align_ == luma::alignment::END) { + child_bounds.x = luma::right(bounds) - child_bounds.width; + } + cursor += child_bounds.height + gap_; + } + child->arrange(child_bounds); + } +} + +auto linear_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + for (auto const& child : children_) { + assert(child != nullptr); + child->draw(context, theme); + } +} + +auto linear_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + return dispatch_to_children(children_, event); +} + +auto linear_control::invalidate_layout() -> void { + measured_.clear(); + for (auto& child : children_) { + assert(child != nullptr); + child->invalidate_layout(); + } +} + +auto linear_control::bind_layout_invalidator(std::function invalidator) -> void { + luma::control::bind_layout_invalidator(invalidator); + for (auto& child : children_) { + assert(child != nullptr); + child->bind_layout_invalidator(invalidator); + } +} + + +stack_control::stack_control(std::vector> children) : children_{std::move(children)} {} + +auto stack_control::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + auto result = limits.min; + for (auto& child : children_) { + assert(child != nullptr); + auto measured = child->measure(context, theme, limits); + result.width = std::max(result.width, measured.width); + result.height = std::max(result.height, measured.height); + } + return luma::clamp(result, limits); +} + +auto stack_control::arrange(luma::rect bounds) -> void { + assert(finite_rect(bounds)); + luma::control::arrange(bounds); + for (auto& child : children_) { + assert(child != nullptr); + child->arrange(bounds); + } +} + +auto stack_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + for (auto const& child : children_) { + assert(child != nullptr); + child->draw(context, theme); + } +} + +auto stack_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + return dispatch_to_children(children_, event); +} + +auto stack_control::invalidate_layout() -> void { + for (auto& child : children_) { + assert(child != nullptr); + child->invalidate_layout(); + } +} + +auto stack_control::bind_layout_invalidator(std::function invalidator) -> void { + luma::control::bind_layout_invalidator(invalidator); + for (auto& child : children_) { + assert(child != nullptr); + child->bind_layout_invalidator(invalidator); + } +} + + +scroll_view_control::scroll_view_control(std::unique_ptr child, std::bool_t vertical, std::bool_t horizontal) : child_{std::move(child)}, vertical_{vertical}, horizontal_{horizontal} { + assert(child_ != nullptr); + assert(vertical_ || horizontal_); +} + +auto scroll_view_control::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(finite_constraints(limits)); + auto max = std::numeric_limits::max(); + auto child_limits = luma::constraints{ + .min = {}, + .max = { + .width = horizontal_ ? max : limits.max.width, + .height = vertical_ ? max : limits.max.height, + }, + }; + if (content_measure_valid_ && cached_child_limits_ && cached_theme_ && same_constraints(*cached_child_limits_, child_limits) && same_theme_metrics(*cached_theme_, theme)) { + content_size_ = cached_content_size_; + return luma::clamp(content_size_, limits); + } + content_size_ = child_->measure(context, theme, child_limits); + cached_child_limits_ = child_limits; + cached_theme_ = theme; + cached_content_size_ = content_size_; + content_measure_valid_ = true; + return luma::clamp(content_size_, limits); +} + +auto scroll_view_control::arrange(luma::rect bounds) -> void { + assert(finite_rect(bounds)); + luma::control::arrange(bounds); + clamp_offset(); + arrange_child(); +} + +auto scroll_view_control::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + context.push_clip(bounds()); + child_->draw(context, theme); + context.pop_clip(); +} + +auto scroll_view_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + auto handled = false; + for (auto const& point : event.points) { + handled = handle_point(event, point) || handled; + } + return handled; +} + +auto scroll_view_control::handle_point(luma::touch_event const& event, luma::touch_point const& point) -> std::bool_t { + if (point.phase == luma::touch_phase::DOWN) { + if (!luma::contains(bounds(), point.position)) { + return false; + } + auto child_active = child_->handle_touch(make_event(event, point)); + if (child_active) { + invalidate_child_measure(); + } + auto state = scroll_view_control::touch_state{ + .start = point.position, + .last = point.position, + .child_active = child_active, + }; + touches_[point.id] = state; + if (!scroll_id_) { + dragging_ = false; + velocity_ = {}; + } + return true; + } + + auto found = touches_.find(point.id); + if (found == touches_.end()) { + return false; + } + auto& state = found->second; + + if (point.phase == luma::touch_phase::MOVE) { + auto delta = luma::point{ + .x = point.position.x - state.last.x, + .y = point.position.y - state.last.y, + }; + if (!scroll_id_) { + auto total = luma::point{ + .x = point.position.x - state.start.x, + .y = point.position.y - state.start.y, + }; + auto moved = std::float32_t{}; + if (horizontal_) { + moved = std::max(moved, std::abs(total.x)); + } + if (vertical_) { + moved = std::max(moved, std::abs(total.y)); + } + if (moved >= scroll_drag_threshold) { + scroll_id_ = point.id; + dragging_ = true; + if (state.child_active) { + auto cancel = point; + cancel.phase = luma::touch_phase::CANCEL; + static_cast(child_->handle_touch(make_event(event, cancel))); + state.child_active = false; + } + } + } + if (scroll_id_ == point.id && dragging_) { + if (horizontal_) { + offset_.x -= delta.x; + velocity_.x = delta.x; + } + if (vertical_) { + offset_.y -= delta.y; + velocity_.y = delta.y; + } + state.last = point.position; + clamp_offset(); + arrange_child(); + return true; + } + state.last = point.position; + if (state.child_active) { + state.child_active = child_->handle_touch(make_event(event, point)); + invalidate_child_measure(); + } + return true; + } + + if (point.phase == luma::touch_phase::UP || point.phase == luma::touch_phase::CANCEL) { + auto const scrolling = scroll_id_ == point.id && dragging_; + if (state.child_active) { + if (scrolling) { + auto cancel = point; + cancel.phase = luma::touch_phase::CANCEL; + static_cast(child_->handle_touch(make_event(event, cancel))); + } else { + static_cast(child_->handle_touch(make_event(event, point))); + } + invalidate_child_measure(); + } + if (scroll_id_ == point.id) { + scroll_id_.reset(); + dragging_ = false; + velocity_ = {}; + } + touches_.erase(found); + return true; + } + + return true; +} + +auto scroll_view_control::invalidate_layout() -> void { + invalidate_child_measure(); +} + +auto scroll_view_control::bind_layout_invalidator(std::function invalidator) -> void { + luma::control::bind_layout_invalidator(invalidator); + child_->bind_layout_invalidator(std::move(invalidator)); +} + +auto scroll_view_control::invalidate_child_measure() -> void { + content_measure_valid_ = false; + cached_child_limits_.reset(); + cached_theme_.reset(); + cached_content_size_ = {}; + child_->invalidate_layout(); +} + +auto scroll_view_control::arrange_child() -> void { + auto area = bounds(); + child_->arrange({ + .x = area.x - offset_.x, + .y = area.y - offset_.y, + .width = std::max(area.width, content_size_.width), + .height = std::max(area.height, content_size_.height), + }); +} + +auto scroll_view_control::clamp_offset() -> void { + offset_.x = horizontal_ ? std::clamp(offset_.x, std::float32_t{}, std::max(std::float32_t{}, content_size_.width - bounds().width)) : std::float32_t{}; + offset_.y = vertical_ ? std::clamp(offset_.y, std::float32_t{}, std::max(std::float32_t{}, content_size_.height - bounds().height)) : std::float32_t{}; +} + +} + +namespace { + +auto accepts_text(luma::text_input_mode mode, std::string_view current, std::string_view value) -> std::bool_t { + if (mode == luma::text_input_mode::TEXT) { + return true; + } + auto decimal_seen = current.find('.') != std::string::npos; + for (auto ch : value) { + if (ch >= '0' && ch <= '9') { + continue; + } + if (mode == luma::text_input_mode::DECIMAL && ch == '.' && !decimal_seen) { + decimal_seen = true; + continue; + } + return false; + } + return true; +} + +auto validate_text_options(luma::text_field_options const& options) -> std::expected { + if (options.max_length == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "text field max length must be positive"); + } + if (options.text.size() > options.max_length) { + return luma::detail::fail(luma::detail::error_invalid_argument, "text field initial text exceeds max length"); + } + if (!accepts_text(options.mode, "", options.text)) { + return luma::detail::fail(luma::detail::error_invalid_argument, "text field initial text does not match input mode"); + } + return {}; +} + +auto callback_failure(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_internal, std::move(message)); +} + +auto notify_text_changed(std::function const& callback, std::string value) -> std::expected { + if (!callback) { + return {}; + } + try { + callback(std::move(value)); + } catch (std::exception const& error) { + return callback_failure(std::string{"text change callback failed: "} + error.what()); + } catch (...) { + return callback_failure("text change callback failed with an unknown exception"); + } + return {}; +} + +auto notify_layout_listeners(std::vector> const& listeners) -> void { + for (auto const& listener : listeners) { + if (!listener) { + continue; + } + try { + listener(); + } catch (...) { + } + } +} + +auto collect_layout_listeners(std::vector& listeners) -> std::vector> { + auto callbacks = std::vector>{}; + auto write = listeners.begin(); + for (auto read = listeners.begin(); read != listeners.end(); ++read) { + if (read->lifetime.expired()) { + continue; + } + callbacks.push_back(read->callback); + if (write != read) { + *write = std::move(*read); + } + ++write; + } + listeners.erase(write, listeners.end()); + return callbacks; +} + +} + +luma::text_input_state::text_input_state(luma::text_field_options options, std::function on_change) : options_{std::move(options)}, on_change_{std::move(on_change)} { + assert(options_.max_length > 0); + assert(on_change_); +} + +auto luma::text_input_state::snapshot() const -> luma::text_field_options { + auto lock = std::scoped_lock{mutex_}; + return options_; +} + +auto luma::text_input_state::text() const -> std::string { + auto lock = std::scoped_lock{mutex_}; + return options_.text; +} + +auto luma::text_input_state::focused() const -> std::bool_t { + auto lock = std::scoped_lock{mutex_}; + return focused_; +} + +auto luma::text_input_state::set_focused(std::bool_t value) -> void { + auto lock = std::scoped_lock{mutex_}; + focused_ = value; +} + +auto luma::text_input_state::version() const -> std::uint64_t { + auto lock = std::scoped_lock{mutex_}; + return version_; +} + +auto luma::text_input_state::add_layout_listener(std::weak_ptr lifetime, std::function callback) -> void { + if (!callback) { + return; + } + auto lock = std::scoped_lock{mutex_}; + layout_listeners_.push_back({ + .lifetime = std::move(lifetime), + .callback = std::move(callback), + }); +} + +auto luma::text_input_state::append(std::string_view value) -> std::expected { + if (value == "\b") { + return backspace(); + } + auto callback_text = std::string{}; + auto layout_listeners = std::vector>{}; + auto changed = false; + { + auto lock = std::scoped_lock{mutex_}; + if (value.empty()) { + return {}; + } + if (options_.text.size() + value.size() > options_.max_length) { + return luma::detail::fail(luma::detail::error_invalid_argument, "text field max length exceeded"); + } + if (!accepts_text(options_.mode, options_.text, value)) { + return luma::detail::fail(luma::detail::error_invalid_argument, "text does not match input mode"); + } + options_.text.append(value.data(), value.size()); + ++version_; + callback_text = options_.text; + layout_listeners = collect_layout_listeners(layout_listeners_); + changed = true; + } + if (changed) { + auto status = notify_text_changed(on_change_, std::move(callback_text)); + notify_layout_listeners(layout_listeners); + return status; + } + return {}; +} + +auto luma::text_input_state::backspace() -> std::expected { + auto callback_text = std::string{}; + auto layout_listeners = std::vector>{}; + auto changed = false; + { + auto lock = std::scoped_lock{mutex_}; + if (options_.text.empty()) { + return {}; + } + options_.text.pop_back(); + ++version_; + callback_text = options_.text; + layout_listeners = collect_layout_listeners(layout_listeners_); + changed = true; + } + if (changed) { + auto status = notify_text_changed(on_change_, std::move(callback_text)); + notify_layout_listeners(layout_listeners); + return status; + } + return {}; +} + +auto luma::text_input_state::set_text(std::string value) -> std::expected { + auto callback_text = std::string{}; + auto layout_listeners = std::vector>{}; + { + auto lock = std::scoped_lock{mutex_}; + if (value.size() > options_.max_length) { + return luma::detail::fail(luma::detail::error_invalid_argument, "text field max length exceeded"); + } + if (!accepts_text(options_.mode, "", value)) { + return luma::detail::fail(luma::detail::error_invalid_argument, "text does not match input mode"); + } + options_.text = std::move(value); + ++version_; + callback_text = options_.text; + layout_listeners = collect_layout_listeners(layout_listeners_); + } + auto status = notify_text_changed(on_change_, std::move(callback_text)); + notify_layout_listeners(layout_listeners); + return status; +} + +auto luma::make_text_input(luma::text_field_options options, std::function on_change) -> std::expected, modulus::error> { + if (!on_change) { + return luma::detail::fail>(luma::detail::error_invalid_argument, "text field change callback must not be empty"); + } + if (auto status = validate_text_options(options); !status) { + return modulus::fail>(status.error()); + } + return modulus::success(std::make_shared(std::move(options), std::move(on_change))); +} + +auto luma::default_text_keyboard() -> luma::keyboard_layout { + return { + .rows = { + { + { + "Q", + "q", + }, + { + "W", + "w", + }, + { + "E", + "e", + }, + { + "R", + "r", + }, + { + "T", + "t", + }, + { + "Y", + "y", + }, + { + "U", + "u", + }, + { + "I", + "i", + }, + { + "O", + "o", + }, + { + "P", + "p", + }, + }, + { + { + "A", + "a", + }, + { + "S", + "s", + }, + { + "D", + "d", + }, + { + "F", + "f", + }, + { + "G", + "g", + }, + { + "H", + "h", + }, + { + "J", + "j", + }, + { + "K", + "k", + }, + { + "L", + "l", + }, + }, + { + { + "Z", + "z", + }, + { + "X", + "x", + }, + { + "C", + "c", + }, + { + "V", + "v", + }, + { + "B", + "b", + }, + { + "N", + "n", + }, + { + "M", + "m", + }, + { + "<-", + "", + luma::keyboard_key_kind::BACKSPACE, + }, + }, + { + { + "Space", + " ", + luma::keyboard_key_kind::SPACE, + }, + { + "Enter", + "", + luma::keyboard_key_kind::ENTER, + }, + }, + }, + }; +} + +auto luma::numeric_keyboard() -> luma::keyboard_layout { + return { + .rows = { + { + { + "1", + "1", + }, + { + "2", + "2", + }, + { + "3", + "3", + }, + }, + { + { + "4", + "4", + }, + { + "5", + "5", + }, + { + "6", + "6", + }, + }, + { + { + "7", + "7", + }, + { + "8", + "8", + }, + { + "9", + "9", + }, + }, + { + { + "0", + "0", + }, + { + ".", + ".", + }, + { + "<-", + "", + luma::keyboard_key_kind::BACKSPACE, + }, + }, + }, + }; +} + +auto luma::label(std::string text) -> std::expected, modulus::error> { + if (text.empty()) { + return invalid>("label text must not be empty"); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(text))}); +} + +auto luma::button(std::string text, std::function on_click) -> std::expected, modulus::error> { + if (text.empty()) { + return invalid>("button text must not be empty"); + } + if (!on_click) { + return invalid>("button click callback must not be empty"); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(text), std::move(on_click))}); +} + +auto luma::text_field(luma::text_field_options options, std::function on_change) -> std::expected, modulus::error> { + auto input = luma::make_text_input(std::move(options), std::move(on_change)); + if (!input) { + return modulus::fail>(input.error()); + } + return luma::text_field(std::move(*input)); +} + +auto luma::text_field(std::shared_ptr input) -> std::expected, modulus::error> { + if (input == nullptr) { + return invalid>("text input state must not be null"); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(input))}); +} + +auto luma::soft_keyboard(luma::keyboard_layout layout, std::shared_ptr input, std::function on_enter) -> std::expected, modulus::error> { + if (auto status = valid_keyboard_layout(layout); !status) { + return modulus::fail>(status.error()); + } + if (input == nullptr) { + return invalid>("text input state must not be null"); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(layout), std::move(input), std::move(on_enter))}); +} + +auto luma::soft_keyboard(luma::keyboard_layout layout, std::function on_text, std::function on_enter) -> std::expected, modulus::error> { + if (auto status = valid_keyboard_layout(layout); !status) { + return modulus::fail>(status.error()); + } + if (!on_text) { + return invalid>("keyboard text callback must not be empty"); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(layout), std::move(on_text), std::move(on_enter))}); +} + +auto luma::panel(std::expected>, modulus::error> children) -> std::expected, modulus::error> { + if (!children) { + return modulus::fail>(children.error()); + } + return luma::panel(std::move(*children)); +} + +auto luma::panel(std::vector> children) -> std::expected, modulus::error> { + if (auto status = validate_children(children); !status) { + return modulus::fail>(status.error()); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(children))}); +} + +auto luma::spacer(luma::size value) -> std::expected, modulus::error> { + if (!finite_size(value)) { + return invalid>("spacer size must be finite and non-negative"); + } + return modulus::success(std::unique_ptr{std::make_unique(value)}); +} + +auto luma::progress_bar(std::float32_t value) -> std::expected, modulus::error> { + if (!std::isfinite(value)) { + return invalid>("progress value must be finite"); + } + return modulus::success(std::unique_ptr{std::make_unique(value)}); +} + +auto luma::image(std::shared_ptr texture, luma::image_fit fit) -> std::expected, modulus::error> { + if (texture == nullptr) { + return invalid>("image texture must not be null"); + } + auto info = texture->info(); + if (info.width <= 0 || info.height <= 0) { + return invalid>("image texture dimensions must be positive"); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(texture), fit)}); +} + +auto luma::row(std::expected>, modulus::error> children, std::float32_t gap, luma::alignment align) -> std::expected, modulus::error> { + if (!children) { + return modulus::fail>(children.error()); + } + return luma::row(std::move(*children), gap, align); +} + +auto luma::row(std::vector> children, std::float32_t gap, luma::alignment align) -> std::expected, modulus::error> { + if (auto status = validate_children(children); !status) { + return modulus::fail>(status.error()); + } + if (!std::isfinite(gap) || gap < 0.0F) { + return invalid>("row gap must be finite and non-negative"); + } + return modulus::success(std::unique_ptr{std::make_unique(luma::direction::ROW, std::move(children), gap, align)}); +} + +auto luma::column(std::expected>, modulus::error> children, std::float32_t gap, luma::alignment align) -> std::expected, modulus::error> { + if (!children) { + return modulus::fail>(children.error()); + } + return luma::column(std::move(*children), gap, align); +} + +auto luma::column(std::vector> children, std::float32_t gap, luma::alignment align) -> std::expected, modulus::error> { + if (auto status = validate_children(children); !status) { + return modulus::fail>(status.error()); + } + if (!std::isfinite(gap) || gap < 0.0F) { + return invalid>("column gap must be finite and non-negative"); + } + return modulus::success(std::unique_ptr{std::make_unique(luma::direction::COLUMN, std::move(children), gap, align)}); +} + +auto luma::stack(std::expected>, modulus::error> children) -> std::expected, modulus::error> { + if (!children) { + return modulus::fail>(children.error()); + } + return luma::stack(std::move(*children)); +} + +auto luma::stack(std::vector> children) -> std::expected, modulus::error> { + if (auto status = validate_children(children); !status) { + return modulus::fail>(status.error()); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(children))}); +} + +auto luma::scroll_view(std::expected, modulus::error> child, std::bool_t vertical, std::bool_t horizontal) -> std::expected, modulus::error> { + if (!child) { + return modulus::fail>(child.error()); + } + return luma::scroll_view(std::move(*child), vertical, horizontal); +} + +auto luma::scroll_view(std::unique_ptr child, std::bool_t vertical, std::bool_t horizontal) -> std::expected, modulus::error> { + if (child == nullptr) { + return invalid>("scroll view child must not be null"); + } + if (!vertical && !horizontal) { + return invalid>("scroll view must enable at least one axis"); + } + return modulus::success(std::unique_ptr{std::make_unique(std::move(child), vertical, horizontal)}); +} + +auto luma::list(std::expected>, modulus::error> children, std::float32_t gap) -> std::expected, modulus::error> { + if (!children) { + return modulus::fail>(children.error()); + } + return luma::list(std::move(*children), gap); +} + +auto luma::list(std::vector> children, std::float32_t gap) -> std::expected, modulus::error> { + if (!std::isfinite(gap) || gap < 0.0F) { + return invalid>("list gap must be finite and non-negative"); + } + return luma::column(std::move(children), gap, luma::alignment::STRETCH); +} diff --git a/src/detail/errors.cpp b/src/detail/errors.cpp new file mode 100644 index 0000000..930c07f --- /dev/null +++ b/src/detail/errors.cpp @@ -0,0 +1,10 @@ +#include + +#include +#include + +#include + +auto luma::detail::make_error(std::int32_t code, std::string message, modulus::source_location location) -> modulus::error { + return modulus::make_error("luma", code, std::move(message), location); +} diff --git a/src/overlays.cpp b/src/overlays.cpp new file mode 100644 index 0000000..3413a94 --- /dev/null +++ b/src/overlays.cpp @@ -0,0 +1,359 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +class scope_exit { +public: + explicit scope_exit(std::function action); + scope_exit(scope_exit const&) = delete; + auto operator=(scope_exit const&) -> scope_exit& = delete; + ~scope_exit(); +private: + std::function action_{}; +}; + +auto invalid(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto unavailable(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_unavailable, std::move(message)); +} + +template +auto invalid_value(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto dialog_bounds(luma::rect bounds) -> luma::rect { + return { + .x = bounds.x + bounds.width * 0.1F, + .y = bounds.y + bounds.height * 0.15F, + .width = bounds.width * 0.8F, + .height = bounds.height * 0.7F, + }; +} + +auto dialog_body_bounds(luma::rect bounds) -> luma::rect { + return luma::inset(dialog_bounds(bounds), { + 16.0F, + 56.0F, + 16.0F, + 72.0F, + }); +} + +auto accept_bounds(luma::rect bounds, std::bool_t has_cancel) -> luma::rect { + auto dialog = dialog_bounds(bounds); + auto width = 128.0F; + auto x = has_cancel ? luma::right(dialog) - 16.0F - width : dialog.x + (dialog.width - width) * 0.5F; + return { + .x = x, + .y = luma::bottom(dialog) - 56.0F, + .width = width, + .height = 40.0F, + }; +} + +auto cancel_bounds(luma::rect bounds) -> luma::rect { + auto accept = accept_bounds(bounds, true); + return { + .x = accept.x - 136.0F, + .y = accept.y, + .width = 128.0F, + .height = 40.0F, + }; +} + +auto draw_button(luma::draw_context& context, luma::theme const& theme, luma::rect bounds, std::string const& text, luma::color fill) -> void { + context.fill_rect(bounds, fill); + context.stroke_rect(bounds, 1.0F, theme.border); + auto measured = context.measure_text(text, theme.body); + context.draw_text(text, { + .x = bounds.x + (bounds.width - measured.width) * 0.5F, + .y = bounds.y + (bounds.height - measured.height) * 0.5F, + }, theme.body, theme.text); +} + +auto first_point_inside(luma::touch_event const& event, luma::rect bounds, luma::touch_phase phase) -> luma::touch_point const* { + for (auto const& point : event.points) { + if (point.phase == phase && luma::contains(bounds, point.position)) { + return &point; + } + } + return nullptr; +} + +auto any_point_inside(luma::touch_event const& event, luma::rect bounds) -> std::bool_t { + for (auto const& point : event.points) { + if (luma::contains(bounds, point.position)) { + return true; + } + } + return false; +} + +auto throw_if_failed(std::expected const& status) -> void { + if (!status) { + throw std::runtime_error(status.error().describe()); + } +} + +scope_exit::scope_exit(std::function action) : action_{std::move(action)} {} + +scope_exit::~scope_exit() { + if (action_) { + action_(); + } +} + +} + +luma::overlay_host::overlay_host(std::unique_ptr content) : content_{std::move(content)} { + assert(content_ != nullptr); +} + +auto luma::overlay_host::show_dialog(luma::dialog_options options) -> std::expected { + if (options.body == nullptr) { + return invalid("dialog body must not be null"); + } + if (options.accept_text.empty()) { + return invalid("dialog accept text must not be empty"); + } + options.body->bind_layout_invalidator(layout_invalidator()); + if (dialog_) { + auto body = std::move(dialog_->body); + dialog_.reset(); + retire_dialog_body(std::move(body)); + } + dialog_ = std::move(options); + modulus::log_debug("luma dialog shown"); + return {}; +} + +auto luma::overlay_host::close_dialog(luma::dialog_result result) -> std::expected { + if (result == luma::dialog_result::NONE) { + return invalid("dialog close result must not be NONE"); + } + if (!dialog_) { + return unavailable("no dialog is open"); + } + auto callback = std::move(dialog_->on_close); + auto body = std::move(dialog_->body); + dialog_.reset(); + retire_dialog_body(std::move(body)); + modulus::log_debug("luma dialog closed"); + if (callback) { + try { + callback(result); + } catch (std::exception const& error) { + return luma::detail::fail(luma::detail::error_internal, std::string{"dialog close callback failed: "} + error.what()); + } catch (...) { + return luma::detail::fail(luma::detail::error_internal, "dialog close callback failed with an unknown exception"); + } + } + return {}; +} + +auto luma::overlay_host::begin_traversal() const -> void { + ++traversal_depth_; +} + +auto luma::overlay_host::end_traversal() const -> void { + assert(traversal_depth_ > 0); + --traversal_depth_; + flush_retired_dialog_bodies(); +} + +auto luma::overlay_host::retire_dialog_body(std::unique_ptr body) -> void { + if (body == nullptr) { + return; + } + if (traversal_depth_ == 0) { + body.reset(); + return; + } + retired_dialog_bodies_.push_back(std::move(body)); +} + +auto luma::overlay_host::flush_retired_dialog_bodies() const -> void { + if (traversal_depth_ == 0) { + retired_dialog_bodies_.clear(); + } +} + +auto luma::overlay_host::show_toast(luma::toast toast) -> std::expected { + if (toast.message.empty()) { + return invalid("toast message must not be empty"); + } + if (toast.duration.count() <= 0) { + return invalid("toast duration must be positive"); + } + toasts_.push_back(std::move(toast)); + return {}; +} + +auto luma::overlay_host::tick(std::chrono::steady_clock::duration delta) -> std::bool_t { + assert(content_ != nullptr); + assert(delta >= std::chrono::steady_clock::duration::zero()); + if (toasts_.empty()) { + toast_elapsed_ = {}; + return false; + } + toast_elapsed_ += delta; + if (toast_elapsed_ >= toasts_.front().duration) { + toasts_.pop_front(); + toast_elapsed_ = {}; + return true; + } + return false; +} + +auto luma::overlay_host::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + assert(content_ != nullptr); + begin_traversal(); + auto guard = scope_exit{[this] { + end_traversal(); + }}; + auto measured = content_->measure(context, theme, limits); + if (dialog_ && dialog_->body) { + auto* body = dialog_->body.get(); + body->measure(context, theme, { + .min = {}, + .max = { + .width = limits.max.width * 0.8F, + .height = limits.max.height * 0.8F, + }, + }); + } + return measured; +} + +auto luma::overlay_host::arrange(luma::rect bounds) -> void { + assert(content_ != nullptr); + begin_traversal(); + auto guard = scope_exit{[this] { + end_traversal(); + }}; + luma::control::arrange(bounds); + content_->arrange(bounds); + if (dialog_ && dialog_->body) { + auto* body = dialog_->body.get(); + body->arrange(dialog_body_bounds(bounds)); + } +} + +auto luma::overlay_host::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + assert(content_ != nullptr); + begin_traversal(); + auto guard = scope_exit{[this] { + end_traversal(); + }}; + content_->draw(context, theme); + if (dialog_ && dialog_->body) { + auto dialog = dialog_bounds(bounds()); + auto title = dialog_->title; + auto accept_text = dialog_->accept_text; + auto cancel_text = dialog_->cancel_text; + auto* body = dialog_->body.get(); + context.fill_rect(dialog, theme.surface); + context.stroke_rect(dialog, 2.0F, theme.accent); + context.draw_text(title, { + .x = dialog.x + 16.0F, + .y = dialog.y + 16.0F, + }, theme.title, theme.text); + body->draw(context, theme); + auto has_cancel = !cancel_text.empty(); + if (has_cancel) { + draw_button(context, theme, cancel_bounds(bounds()), cancel_text, theme.surface); + } + draw_button(context, theme, accept_bounds(bounds(), has_cancel), accept_text, theme.surface_active); + } + if (!toasts_.empty()) { + auto toast_bounds = luma::rect{ + .x = bounds().x + bounds().width * 0.2F, + .y = bounds().y + bounds().height - 72.0F, + .width = bounds().width * 0.6F, + .height = 48.0F, + }; + context.fill_rect(toast_bounds, theme.surface_active); + context.draw_text(toasts_.front().message, { + .x = toast_bounds.x + 16.0F, + .y = toast_bounds.y + 10.0F, + }, theme.body, theme.text); + } +} + +auto luma::overlay_host::handle_touch(luma::touch_event const& event) -> std::bool_t { + assert(content_ != nullptr); + begin_traversal(); + auto guard = scope_exit{[this] { + end_traversal(); + }}; + if (dialog_) { + if (event.points.empty()) { + return true; + } + auto has_cancel = !dialog_->cancel_text.empty(); + if (first_point_inside(event, accept_bounds(bounds(), has_cancel), luma::touch_phase::UP) != nullptr) { + throw_if_failed(close_dialog(luma::dialog_result::ACCEPTED)); + return true; + } + if (has_cancel && first_point_inside(event, cancel_bounds(bounds()), luma::touch_phase::UP) != nullptr) { + throw_if_failed(close_dialog(luma::dialog_result::CANCELLED)); + return true; + } + if (dialog_->body && any_point_inside(event, dialog_body_bounds(bounds()))) { + static_cast(dialog_->body->handle_touch(event)); + } + return true; + } + return content_->handle_touch(event); +} + +auto luma::overlay_host::invalidate_layout() -> void { + assert(content_ != nullptr); + begin_traversal(); + auto guard = scope_exit{[this] { + end_traversal(); + }}; + content_->invalidate_layout(); + if (dialog_ && dialog_->body) { + dialog_->body->invalidate_layout(); + } +} + +auto luma::overlay_host::bind_layout_invalidator(std::function invalidator) -> void { + luma::control::bind_layout_invalidator(invalidator); + assert(content_ != nullptr); + content_->bind_layout_invalidator(invalidator); + if (dialog_ && dialog_->body) { + dialog_->body->bind_layout_invalidator(std::move(invalidator)); + } +} + +auto luma::overlays(std::unique_ptr content) -> std::expected, modulus::error> { + if (content == nullptr) { + return invalid_value>("overlay content must not be null"); + } + return modulus::success(std::unique_ptr{ + new luma::overlay_host{std::move(content)}, + }); +} diff --git a/src/routing.cpp b/src/routing.cpp new file mode 100644 index 0000000..0dfec3c --- /dev/null +++ b/src/routing.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include +#include +#include + +#include +#include + +#include + +auto luma::router::register_route(std::string name, std::function, modulus::error>()> factory) -> std::expected { + if (name.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "route name must not be empty"); + } + if (!factory) { + return luma::detail::fail(luma::detail::error_invalid_argument, "route factory must not be empty"); + } + routes_[std::move(name)] = std::move(factory); + return {}; +} + +auto luma::router::set_retire_handler(std::function)> handler) -> void { + retire_handler_ = std::move(handler); +} + +auto luma::router::replace(std::string const& name) -> std::expected { + if (name.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "route name must not be empty"); + } + auto found = routes_.find(name); + if (found == routes_.end()) { + return luma::detail::fail(luma::detail::error_not_found, "route not found: " + name); + } + assert(found->second); + auto view = found->second(); + if (!view) { + return modulus::fail(view.error()); + } + if (*view == nullptr) { + return luma::detail::fail(luma::detail::error_internal, "route factory returned null view: " + name); + } + retire_stack(); + stack_.push_back({ + .name = name, + .view = std::move(*view), + }); + modulus::log_debug("luma route replaced: " + name); + return {}; +} + +auto luma::router::push(std::string const& name) -> std::expected { + if (name.empty()) { + return luma::detail::fail(luma::detail::error_invalid_argument, "route name must not be empty"); + } + auto found = routes_.find(name); + if (found == routes_.end()) { + return luma::detail::fail(luma::detail::error_not_found, "route not found: " + name); + } + assert(found->second); + auto view = found->second(); + if (!view) { + return modulus::fail(view.error()); + } + if (*view == nullptr) { + return luma::detail::fail(luma::detail::error_internal, "route factory returned null view: " + name); + } + stack_.push_back({ + .name = name, + .view = std::move(*view), + }); + modulus::log_debug("luma route pushed: " + name); + return {}; +} + +auto luma::router::pop() -> std::expected { + if (stack_.size() <= 1) { + return luma::detail::fail(luma::detail::error_unavailable, "cannot pop the last route"); + } + auto view = std::move(stack_.back().view); + auto name = stack_.back().name; + stack_.pop_back(); + retire(std::move(view)); + modulus::log_debug("luma route popped: " + name); + return {}; +} + +auto luma::router::clear_stack() -> void { + retire_stack(); +} + +auto luma::router::current() -> luma::control* { + if (stack_.empty()) { + return nullptr; + } + assert(stack_.back().view != nullptr); + return stack_.back().view.get(); +} + +auto luma::router::current() const -> luma::control const* { + if (stack_.empty()) { + return nullptr; + } + assert(stack_.back().view != nullptr); + return stack_.back().view.get(); +} + +auto luma::router::current_route() const -> std::string const& { + if (stack_.empty()) { + return empty_route_; + } + return stack_.back().name; +} + +auto luma::router::retire(std::unique_ptr view) -> void { + if (view == nullptr) { + return; + } + if (retire_handler_) { + retire_handler_(std::move(view)); + } +} + +auto luma::router::retire_stack() -> void { + auto removed = std::vector{}; + removed.swap(stack_); + for (auto& entry : removed) { + retire(std::move(entry.view)); + } +} diff --git a/src/theme.cpp b/src/theme.cpp new file mode 100644 index 0000000..c561f21 --- /dev/null +++ b/src/theme.cpp @@ -0,0 +1,352 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include + +namespace { + +constexpr auto json_options = modulus::json_config::options{ + .category = "luma", + .invalid_argument_code = luma::detail::error_invalid_argument, + .not_found_code = luma::detail::error_not_found +}; + +auto invalid_color(std::string message) -> std::expected { + assert(!message.empty()); + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto invalid_theme(std::string message) -> std::expected { + assert(!message.empty()); + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto invalid_status(std::string message) -> std::expected { + assert(!message.empty()); + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +auto byte_value(boost::json::value const& value, std::char_t const* name) -> std::expected { + assert(name != nullptr); + auto const* integer = value.if_int64(); + if (integer == nullptr || *integer < 0 || *integer > 255) { + return luma::detail::fail(luma::detail::error_invalid_argument, std::string{name} + " must be an integer from 0 to 255"); + } + return modulus::success(static_cast(*integer)); +} + +auto apply_color(boost::json::object const& object, std::char_t const* name, luma::color& target) -> std::expected { + assert(name != nullptr); + if (auto const* value = object.if_contains(name)) { + auto parsed = luma::parse_color(*value); + if (!parsed) { + return modulus::fail(parsed.error()); + } + target = *parsed; + } + return {}; +} + +auto number_value(boost::json::value const& value) -> std::optional; + +auto apply_text_style(boost::json::object const& object, std::char_t const* name, luma::text_style& target) -> std::expected { + assert(name != nullptr); + if (auto const* value = object.if_contains(name)) { + auto style = modulus::json_config::require_object(*value, name, json_options); + if (!style) { + return modulus::fail(style.error()); + } + auto unknown = modulus::json_config::reject_unknown_fields(**style, { + "font", + "size", + "line_height", + }, name, json_options); + if (!unknown) { + return modulus::fail(unknown.error()); + } + if (auto const* font = (*style)->if_contains("font")) { + auto const* text = font->if_string(); + if (text == nullptr) { + return luma::detail::fail(luma::detail::error_invalid_argument, std::string{name} + ".font must be a string"); + } + target.font = std::string{text->c_str()}; + } + if (auto const* size = (*style)->if_contains("size")) { + auto number = number_value(*size); + if (!number || *number <= 0.0) { + return luma::detail::fail(luma::detail::error_invalid_argument, std::string{name} + ".size must be positive"); + } + target.size = static_cast(*number); + } + if (auto const* line_height = (*style)->if_contains("line_height")) { + auto number = number_value(*line_height); + if (!number || *number <= 0.0) { + return luma::detail::fail(luma::detail::error_invalid_argument, std::string{name} + ".line_height must be positive"); + } + target.line_height = static_cast(*number); + } + } + return {}; +} + +auto number_value(boost::json::value const& value) -> std::optional { + if (auto const* number = value.if_double()) { + return *number; + } + if (auto const* integer = value.if_int64()) { + return static_cast(*integer); + } + return std::nullopt; +} + +} + +auto luma::default_theme() -> luma::theme { + return {}; +} + +auto luma::validate(luma::text_style const& style) -> std::expected { + if (style.font.empty()) { + return invalid_status("text style font must not be empty"); + } + if (!std::isfinite(style.size) || style.size <= 0.0F) { + return invalid_status("text style size must be finite and positive"); + } + if (!std::isfinite(style.line_height) || style.line_height <= 0.0F) { + return invalid_status("text style line height must be finite and positive"); + } + return {}; +} + +auto luma::validate(luma::theme const& theme) -> std::expected { + if (auto status = luma::validate(theme.body); !status) { + return modulus::fail(status.error()); + } + if (auto status = luma::validate(theme.title); !status) { + return modulus::fail(status.error()); + } + if (!std::isfinite(theme.radius) || theme.radius < 0.0F) { + return invalid_status("theme.radius must be finite and non-negative"); + } + if (!std::isfinite(theme.spacing) || theme.spacing < 0.0F) { + return invalid_status("theme.spacing must be finite and non-negative"); + } + return {}; +} + +auto luma::parse_color(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "color", json_options); + if (!object) { + return invalid_color(std::string{object.error().message()}); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "r", + "g", + "b", + "a", + }, "color", json_options); + if (!unknown) { + return invalid_color(std::string{unknown.error().message()}); + } + auto const* red_value = (*object)->if_contains("r"); + auto const* green_value = (*object)->if_contains("g"); + auto const* blue_value = (*object)->if_contains("b"); + if (red_value == nullptr || green_value == nullptr || blue_value == nullptr) { + return invalid_color("color requires r, g, and b fields"); + } + auto red = byte_value(*red_value, "r"); + auto green = byte_value(*green_value, "g"); + auto blue = byte_value(*blue_value, "b"); + if (!red) { + return invalid_color(std::string{red.error().message()}); + } + if (!green) { + return invalid_color(std::string{green.error().message()}); + } + if (!blue) { + return invalid_color(std::string{blue.error().message()}); + } + auto alpha = std::uint8_t{255}; + if (auto const* alpha_value = (*object)->if_contains("a")) { + auto parsed = byte_value(*alpha_value, "a"); + if (!parsed) { + return invalid_color(std::string{parsed.error().message()}); + } + alpha = *parsed; + } + return modulus::success(luma::color{ + .r = *red, + .g = *green, + .b = *blue, + .a = alpha, + }); +} + +auto luma::parse_theme(boost::json::value const& value) -> std::expected { + auto object = modulus::json_config::require_object(value, "theme", json_options); + if (!object) { + return invalid_theme(std::string{object.error().message()}); + } + auto unknown = modulus::json_config::reject_unknown_fields(**object, { + "background", + "surface", + "surface_active", + "text", + "text_muted", + "border", + "accent", + "body", + "title", + "radius", + "spacing", + }, "theme", json_options); + if (!unknown) { + return invalid_theme(std::string{unknown.error().message()}); + } + auto parsed = luma::default_theme(); + if (auto status = apply_color(**object, "background", parsed.background); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_color(**object, "surface", parsed.surface); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_color(**object, "surface_active", parsed.surface_active); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_color(**object, "text", parsed.text); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_color(**object, "text_muted", parsed.text_muted); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_color(**object, "border", parsed.border); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_color(**object, "accent", parsed.accent); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_text_style(**object, "body", parsed.body); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto status = apply_text_style(**object, "title", parsed.title); !status) { + return invalid_theme(std::string{status.error().message()}); + } + if (auto const* radius = (*object)->if_contains("radius")) { + auto number = number_value(*radius); + if (!number || *number < 0.0) { + return invalid_theme("theme.radius must be non-negative"); + } + parsed.radius = static_cast(*number); + } + if (auto const* spacing = (*object)->if_contains("spacing")) { + auto number = number_value(*spacing); + if (!number || *number < 0.0) { + return invalid_theme("theme.spacing must be non-negative"); + } + parsed.spacing = static_cast(*number); + } + if (auto status = luma::validate(parsed); !status) { + return invalid_theme(std::string{status.error().message()}); + } + return modulus::success(parsed); +} + +auto luma::to_json(luma::color const& color) -> boost::json::value { + return boost::json::object{ + { + "r", + static_cast(color.r), + }, + { + "g", + static_cast(color.g), + }, + { + "b", + static_cast(color.b), + }, + { + "a", + static_cast(color.a), + }, + }; +} + +auto luma::to_json(luma::text_style const& style) -> boost::json::value { + return boost::json::object{ + { + "font", + style.font, + }, + { + "size", + static_cast(style.size), + }, + { + "line_height", + static_cast(style.line_height), + }, + }; +} + +auto luma::to_json(luma::theme const& theme) -> boost::json::value { + return boost::json::object{ + { + "background", + luma::to_json(theme.background), + }, + { + "surface", + luma::to_json(theme.surface), + }, + { + "surface_active", + luma::to_json(theme.surface_active), + }, + { + "text", + luma::to_json(theme.text), + }, + { + "text_muted", + luma::to_json(theme.text_muted), + }, + { + "border", + luma::to_json(theme.border), + }, + { + "accent", + luma::to_json(theme.accent), + }, + { + "body", + luma::to_json(theme.body), + }, + { + "title", + luma::to_json(theme.title), + }, + { + "radius", + static_cast(theme.radius), + }, + { + "spacing", + static_cast(theme.spacing), + }, + }; +} diff --git a/src/window.cpp b/src/window.cpp new file mode 100644 index 0000000..a614eb5 --- /dev/null +++ b/src/window.cpp @@ -0,0 +1,540 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +class scope_exit { +public: + explicit scope_exit(std::function action); + scope_exit(scope_exit const&) = delete; + auto operator=(scope_exit const&) -> scope_exit& = delete; + ~scope_exit(); +private: + std::function action_{}; +}; + +constexpr auto window_title_vertical_padding = std::float32_t{6.0F}; + +auto finite_rect(luma::rect value) -> std::bool_t { + return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.width) && std::isfinite(value.height) && value.width >= 0.0F && value.height >= 0.0F; +} + +auto title_height(luma::window_options const& options, std::float32_t line_height) -> std::float32_t { + if (!options.decorated) { + return {}; + } + auto desired = line_height + window_title_vertical_padding * 2.0F; + return std::min(desired, options.bounds.height); +} + +auto title_bounds(luma::window_options const& options, std::float32_t line_height) -> luma::rect { + return { + .x = options.bounds.x, + .y = options.bounds.y, + .width = options.bounds.width, + .height = title_height(options, line_height), + }; +} + +auto client_bounds(luma::window_options const& options, std::float32_t line_height) -> luma::rect { + if (!options.decorated) { + return options.bounds; + } + auto title = title_bounds(options, line_height); + return { + .x = options.bounds.x, + .y = options.bounds.y + title.height, + .width = options.bounds.width, + .height = std::max(std::float32_t{}, options.bounds.height - title.height), + }; +} + +template +auto invalid(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_invalid_argument, std::move(message)); +} + +template +auto unavailable(std::string message) -> std::expected { + return luma::detail::fail(luma::detail::error_unavailable, std::move(message)); +} + +auto make_event(luma::touch_event const& event, luma::touch_point point) -> luma::touch_event { + return { + .points = {point}, + .time = event.time, + }; +} + +scope_exit::scope_exit(std::function action) : action_{std::move(action)} {} + +scope_exit::~scope_exit() { + if (action_) { + action_(); + } +} + +} + +luma::window::window(std::uint64_t id, luma::window_options options, std::unique_ptr content) : id_{id}, options_{std::move(options)}, content_{std::move(content)} { + assert(id_ != 0); + assert(finite_rect(options_.bounds)); + assert(content_ != nullptr); +} + +auto luma::window::id() const -> std::uint64_t { + assert(id_ != 0); + return id_; +} + +auto luma::window::options() const -> luma::window_options const& { + assert(finite_rect(options_.bounds)); + return options_; +} + +auto luma::window::set_bounds(luma::rect bounds) -> std::expected { + if (!finite_rect(bounds)) { + return invalid("window bounds must be finite and non-negative"); + } + options_.bounds = bounds; + return {}; +} + +auto luma::window::set_visible(std::bool_t value) -> void { + options_.visible = value; +} + +auto luma::window::visible() const -> std::bool_t { + return options_.visible; +} + +auto luma::window::content() -> luma::control* { + assert(content_ != nullptr); + return content_.get(); +} + +auto luma::window::content() const -> luma::control const* { + assert(content_ != nullptr); + return content_.get(); +} + +auto luma::window_manager::open(luma::window_options options, std::unique_ptr content) -> std::expected { + if (next_id_ == 0) { + return unavailable("window id space exhausted"); + } + if (!finite_rect(options.bounds)) { + return invalid("window bounds must be finite and non-negative"); + } + if (content == nullptr) { + return invalid("window content must not be null"); + } + content->bind_layout_invalidator(layout_invalidator()); + auto id = next_id_++; + auto created = std::unique_ptr{ + new luma::window{ + id, + std::move(options), + std::move(content), + }, + }; + if (mutation_depth_ > 0) { + pending_operations_.push_back({ + .kind = luma::window_manager::pending_kind::OPEN, + .id = id, + .window = std::move(created), + }); + } else { + windows_.push_back(std::move(created)); + } + modulus::log_debug("luma window opened: " + std::to_string(id)); + return modulus::success(id); +} + +auto luma::window_manager::close(std::uint64_t id) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + if (!contains_effective(id)) { + return luma::detail::fail(luma::detail::error_not_found, "window not found"); + } + if (mutation_depth_ > 0) { + pending_operations_.push_back({ + .kind = luma::window_manager::pending_kind::CLOSE, + .id = id, + }); + } else { + close_now(id); + } + modulus::log_debug("luma window close requested: " + std::to_string(id)); + return {}; +} + +auto luma::window_manager::bring_to_front(std::uint64_t id) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + if (!contains_effective(id)) { + return luma::detail::fail(luma::detail::error_not_found, "window not found"); + } + if (mutation_depth_ > 0) { + pending_operations_.push_back({ + .kind = luma::window_manager::pending_kind::BRING_TO_FRONT, + .id = id, + }); + } else { + bring_to_front_now(id); + } + return {}; +} + +auto luma::window_manager::set_bounds(std::uint64_t id, luma::rect bounds) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + if (!finite_rect(bounds)) { + return invalid("window bounds must be finite and non-negative"); + } + if (!contains_effective(id)) { + return luma::detail::fail(luma::detail::error_not_found, "window not found"); + } + if (mutation_depth_ > 0) { + pending_operations_.push_back({ + .kind = luma::window_manager::pending_kind::SET_BOUNDS, + .id = id, + .bounds = bounds, + }); + } else { + set_bounds_now(id, bounds); + } + return {}; +} + +auto luma::window_manager::set_visible(std::uint64_t id, std::bool_t visible) -> std::expected { + if (id == 0) { + return luma::detail::fail(luma::detail::error_invalid_argument, "window id must not be zero"); + } + if (!contains_effective(id)) { + return luma::detail::fail(luma::detail::error_not_found, "window not found"); + } + if (mutation_depth_ > 0) { + pending_operations_.push_back({ + .kind = luma::window_manager::pending_kind::SET_VISIBLE, + .id = id, + .visible = visible, + }); + } else { + set_visible_now(id, visible); + } + return {}; +} + +auto luma::window_manager::find(std::uint64_t id) -> luma::window* { + if (id == 0) { + return nullptr; + } + auto found = std::find_if(windows_.begin(), windows_.end(), [id](auto const& window) { return window && window->id() == id; }); + return found == windows_.end() ? nullptr : found->get(); +} + +auto luma::window_manager::clear() -> void { + if (mutation_depth_ > 0) { + pending_operations_.push_back({ + .kind = luma::window_manager::pending_kind::CLEAR, + }); + return; + } + windows_.clear(); + active_touches_.clear(); +} + +auto luma::window_manager::measure(luma::draw_context& context, luma::theme const& theme, luma::constraints limits) -> luma::size { + title_line_height_ = theme.body.line_height; + ++mutation_depth_; + auto guard = scope_exit{[this] { + assert(mutation_depth_ > 0); + --mutation_depth_; + apply_pending_operations(); + }}; + for (auto& window : windows_) { + assert(window != nullptr); + if (!window->visible()) { + continue; + } + assert(window->content() != nullptr); + auto client = client_bounds(window->options(), title_line_height_); + window->content()->measure(context, theme, { + .min = {}, + .max = { + .width = client.width, + .height = client.height, + }, + }); + } + return luma::clamp(limits.max, limits); +} + +auto luma::window_manager::arrange(luma::rect bounds) -> void { + assert(finite_rect(bounds)); + ++mutation_depth_; + auto guard = scope_exit{[this] { + assert(mutation_depth_ > 0); + --mutation_depth_; + apply_pending_operations(); + }}; + luma::control::arrange(bounds); + for (auto& window : windows_) { + assert(window != nullptr); + if (window->visible()) { + assert(window->content() != nullptr); + window->content()->arrange(client_bounds(window->options(), title_line_height_)); + } + } +} + +auto luma::window_manager::draw(luma::draw_context& context, luma::theme const& theme) const -> void { + title_line_height_ = theme.body.line_height; + ++mutation_depth_; + auto guard = scope_exit{[this] { + assert(mutation_depth_ > 0); + --mutation_depth_; + apply_pending_operations(); + }}; + for (auto const& window : windows_) { + assert(window != nullptr); + if (!window->visible()) { + continue; + } + auto bounds = window->options().bounds; + auto client = client_bounds(window->options(), title_line_height_); + context.fill_rect(bounds, theme.surface); + assert(window->content() != nullptr); + context.push_clip(client); + window->content()->draw(context, theme); + context.pop_clip(); + if (window->options().decorated) { + auto title = title_bounds(window->options(), title_line_height_); + context.fill_rect(title, theme.surface_active); + context.draw_text(window->options().title, { + .x = title.x + 8.0F, + .y = title.y + window_title_vertical_padding, + }, theme.body, theme.text); + } + context.stroke_rect(bounds, 1.0F, theme.border); + } +} + +auto luma::window_manager::handle_touch(luma::touch_event const& event) -> std::bool_t { + if (event.points.empty()) { + return false; + } + ++mutation_depth_; + auto guard = scope_exit{[this] { + assert(mutation_depth_ > 0); + --mutation_depth_; + apply_pending_operations(); + }}; + auto handled = false; + for (auto const& point : event.points) { + if (auto captured = active_touches_.find(point.id); captured != active_touches_.end()) { + auto const window_id = captured->second; + auto* captured_window = find(window_id); + auto const captured_visible = captured_window != nullptr && captured_window->visible(); + if (captured_visible) { + assert(captured_window->content() != nullptr); + auto single = make_event(event, point); + static_cast(captured_window->content()->handle_touch(single)); + handled = true; + } + if (point.phase == luma::touch_phase::UP || point.phase == luma::touch_phase::CANCEL || !captured_visible) { + active_touches_.erase(point.id); + } + continue; + } + + for (auto current = windows_.rbegin(); current != windows_.rend(); ++current) { + assert(*current != nullptr); + if (!(*current)->visible()) { + continue; + } + if (luma::contains((*current)->options().bounds, point.position)) { + auto consumed = false; + if (luma::contains(client_bounds((*current)->options(), title_line_height_), point.position)) { + assert((*current)->content() != nullptr); + auto single = make_event(event, point); + consumed = (*current)->content()->handle_touch(single); + if (point.phase == luma::touch_phase::DOWN && (consumed || (*current)->options().modal)) { + active_touches_[point.id] = (*current)->id(); + } + } + handled = true; + break; + } + if ((*current)->options().modal) { + handled = true; + break; + } + } + } + return handled; +} + +auto luma::window_manager::contains_effective(std::uint64_t id) const -> std::bool_t { + if (id == 0) { + return false; + } + auto exists = std::find_if(windows_.begin(), windows_.end(), [id](auto const& window) { return window && window->id() == id; }) != windows_.end(); + for (auto const& operation : pending_operations_) { + switch (operation.kind) { + case luma::window_manager::pending_kind::OPEN: + if (operation.id == id) { + exists = true; + } + break; + case luma::window_manager::pending_kind::CLOSE: + if (operation.id == id) { + exists = false; + } + break; + case luma::window_manager::pending_kind::BRING_TO_FRONT: + case luma::window_manager::pending_kind::SET_BOUNDS: + case luma::window_manager::pending_kind::SET_VISIBLE: + break; + case luma::window_manager::pending_kind::CLEAR: + exists = false; + break; + } + } + return exists; +} + +auto luma::window_manager::close_now(std::uint64_t id) const -> void { + auto found = std::find_if(windows_.begin(), windows_.end(), [id](auto const& window) { return window && window->id() == id; }); + if (found == windows_.end()) { + return; + } + auto const closed_id = (*found)->id(); + for (auto current = active_touches_.begin(); current != active_touches_.end();) { + if (current->second == closed_id) { + current = active_touches_.erase(current); + } else { + ++current; + } + } + windows_.erase(found); +} + +auto luma::window_manager::bring_to_front_now(std::uint64_t id) const -> void { + auto found = std::find_if(windows_.begin(), windows_.end(), [id](auto const& window) { return window && window->id() == id; }); + if (found == windows_.end()) { + return; + } + auto moved = std::move(*found); + windows_.erase(found); + windows_.push_back(std::move(moved)); +} + +auto luma::window_manager::set_bounds_now(std::uint64_t id, luma::rect bounds) const -> void { + auto* window = const_cast(this)->find(id); + if (window != nullptr) { + static_cast(window->set_bounds(bounds)); + } +} + +auto luma::window_manager::set_visible_now(std::uint64_t id, std::bool_t visible) const -> void { + auto* window = const_cast(this)->find(id); + if (window != nullptr) { + window->set_visible(visible); + } +} + +auto luma::window_manager::apply_pending_operations() const -> void { + if (mutation_depth_ != 0 || pending_operations_.empty()) { + return; + } + auto pending = std::vector{}; + pending.swap(pending_operations_); + for (auto& operation : pending) { + switch (operation.kind) { + case luma::window_manager::pending_kind::OPEN: + if (operation.window) { + windows_.push_back(std::move(operation.window)); + } + break; + case luma::window_manager::pending_kind::CLOSE: + close_now(operation.id); + break; + case luma::window_manager::pending_kind::BRING_TO_FRONT: + bring_to_front_now(operation.id); + break; + case luma::window_manager::pending_kind::SET_BOUNDS: + set_bounds_now(operation.id, operation.bounds); + break; + case luma::window_manager::pending_kind::SET_VISIBLE: + set_visible_now(operation.id, operation.visible); + break; + case luma::window_manager::pending_kind::CLEAR: + windows_.clear(); + active_touches_.clear(); + break; + } + } +} + +auto luma::window_manager::invalidate_layout() -> void { + ++mutation_depth_; + auto guard = scope_exit{[this] { + assert(mutation_depth_ > 0); + --mutation_depth_; + apply_pending_operations(); + }}; + auto controls = std::vector{}; + controls.reserve(windows_.size() + pending_operations_.size()); + for (auto& window : windows_) { + assert(window != nullptr); + assert(window->content() != nullptr); + controls.push_back(window->content()); + } + for (auto& operation : pending_operations_) { + if (operation.window) { + assert(operation.window->content() != nullptr); + controls.push_back(operation.window->content()); + } + } + for (auto* control : controls) { + assert(control != nullptr); + control->invalidate_layout(); + } +} + +auto luma::window_manager::bind_layout_invalidator(std::function invalidator) -> void { + luma::control::bind_layout_invalidator(invalidator); + for (auto& window : windows_) { + assert(window != nullptr); + assert(window->content() != nullptr); + window->content()->bind_layout_invalidator(invalidator); + } + for (auto& operation : pending_operations_) { + if (operation.window) { + assert(operation.window->content() != nullptr); + operation.window->content()->bind_layout_invalidator(invalidator); + } + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..0b632a5 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,28 @@ +add_executable(luma-test + test.cpp +) +find_package(Threads REQUIRED) +target_include_directories(luma-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(luma-test PRIVATE + luma::luma + Threads::Threads +) +target_compile_features(luma-test PRIVATE cxx_std_23) +target_compile_definitions(luma-test PRIVATE + $<$:LUMA_EXPECT_DEBUG_ASSERTS> + $<$:LUMA_EXPECT_NDEBUG> + $<$:LUMA_EXPECT_NDEBUG> + $<$:LUMA_EXPECT_NDEBUG> +) +add_test(NAME luma COMMAND luma-test) +set_tests_properties(luma PROPERTIES TIMEOUT 20) +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir ${CMAKE_BINARY_DIR} + -R ^luma$ + --output-on-failure + --timeout 20 + USES_TERMINAL +) diff --git a/tests/test.cpp b/tests/test.cpp new file mode 100644 index 0000000..d320c2a --- /dev/null +++ b/tests/test.cpp @@ -0,0 +1,2387 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include + +namespace { + +struct fake_draw_context final : public luma::draw_context { + auto fill_rect(luma::rect bounds, luma::color) -> void override; + auto stroke_rect(luma::rect, std::float32_t, luma::color) -> void override; + auto stroke_line(luma::point, luma::point, std::float32_t, luma::color) -> void override; + auto fill_ellipse(luma::rect, luma::color) -> void override; + auto stroke_ellipse(luma::rect, std::float32_t, luma::color) -> void override; + auto draw_text(std::string_view, luma::point origin, luma::text_style const& style, luma::color) -> void override; + auto draw_texture(luma::texture const&, luma::rect, luma::rect, luma::color) -> void override; + auto push_clip(luma::rect bounds) -> void override; + auto pop_clip() -> void override; + auto measure_text(std::string_view text, luma::text_style const& style) const -> luma::size override; + std::vector filled{}; + std::vector clips{}; + std::vector drawn_styles{}; + std::vector drawn_origins{}; + std::int32_t clip_depth{}; + std::int32_t max_clip_depth{}; +}; + +class fixed_control final : public luma::control { +public: + explicit fixed_control(luma::size value); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + luma::size value_{}; + luma::rect arranged{}; + std::bool_t touched{false}; + luma::touch_phase last_phase{luma::touch_phase::MOVE}; +}; + +struct counting_control final : public luma::control { + explicit counting_control(luma::size value); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + auto invalidate_layout() -> void override; + luma::size value_{}; + luma::rect arranged{}; + std::int32_t measure_count{}; + std::int32_t arrange_count{}; + std::int32_t invalidate_count{}; +}; + +struct filling_control final : public luma::control { + auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context& context, luma::theme const&) const -> void override; + luma::rect arranged{}; + std::int32_t measure_count{}; +}; + +class fake_texture final : public luma::texture { +public: + fake_texture(std::int32_t width = 16, std::int32_t height = 16); + auto info() const -> luma::texture_info override; +private: + luma::texture_info info_{}; +}; + +struct fake_resources final : public luma::renderer_resources { + auto load_texture(std::string const&) -> std::expected, modulus::error> override; +}; + +struct reentrant_resources final : public luma::renderer_resources { + auto load_texture(std::string const& path) -> std::expected, modulus::error> override; + luma::asset_cache* cache{}; + std::int32_t load_count{}; + std::bool_t inner_loaded{false}; +}; + +struct same_path_reentrant_resources final : public luma::renderer_resources { + auto load_texture(std::string const& path) -> std::expected, modulus::error> override; + luma::asset_cache* cache{}; + std::int32_t load_count{}; + std::bool_t reentered{false}; + std::bool_t recursive_failed{false}; + std::int32_t recursive_code{}; +}; + +class blocking_resources final : public luma::renderer_resources { +public: + auto load_texture(std::string const&) -> std::expected, modulus::error> override; + auto wait_for_loads(std::int32_t count) -> std::bool_t; + auto release() -> void; + auto load_count() const -> std::int32_t; +private: + mutable std::mutex mutex_{}; + std::condition_variable load_entered_{}; + std::condition_variable release_load_{}; + std::bool_t released_{false}; + std::int32_t load_count_{}; +}; + +struct recording_control final : public luma::control { + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto handle_touch(luma::touch_event const& event) -> std::bool_t override; + std::vector phases{}; + std::vector ids{}; +}; + +struct lifetime_state { + std::bool_t in_handle{false}; + std::bool_t destroyed{false}; + std::bool_t destroyed_while_active{false}; + std::bool_t status_ok{false}; + std::bool_t visible_during_callback{false}; + std::uint64_t opened_id{}; +}; + +enum class close_phase { + MEASURE, + ARRANGE, + DRAW, + TOUCH, + INVALIDATE, +}; + +class destroy_probe_control final : public luma::control { +public: + explicit destroy_probe_control(std::shared_ptr state); + ~destroy_probe_control() override; + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; +private: + std::shared_ptr state_{}; +}; + +class closing_window_control final : public luma::control { +public: + closing_window_control(luma::window_manager& manager, std::uint64_t& id, std::shared_ptr state); + ~closing_window_control() override; + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto handle_touch(luma::touch_event const&) -> std::bool_t override; +private: + luma::window_manager& manager_; + std::uint64_t& id_; + std::shared_ptr state_{}; +}; + +class opening_window_control final : public luma::control { +public: + opening_window_control(luma::window_manager& manager, std::shared_ptr state); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto handle_touch(luma::touch_event const&) -> std::bool_t override; +private: + luma::window_manager& manager_; + std::shared_ptr state_{}; +}; + +class updating_window_control final : public luma::control { +public: + updating_window_control(luma::window_manager& manager, std::uint64_t& id, std::shared_ptr state); + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto handle_touch(luma::touch_event const&) -> std::bool_t override; +private: + luma::window_manager& manager_; + std::uint64_t& id_; + std::shared_ptr state_{}; +}; + +class replacing_root_invalidate_control final : public luma::control { +public: + replacing_root_invalidate_control(luma::application& app, std::shared_ptr state); + ~replacing_root_invalidate_control() override; + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto invalidate_layout() -> void override; +private: + luma::application& app_; + std::shared_ptr state_{}; +}; + +class closing_window_invalidate_control final : public luma::control { +public: + closing_window_invalidate_control(luma::window_manager& manager, std::uint64_t& id, std::shared_ptr state); + ~closing_window_invalidate_control() override; + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto invalidate_layout() -> void override; +private: + luma::window_manager& manager_; + std::uint64_t& id_; + std::shared_ptr state_{}; +}; + +class closing_dialog_control final : public luma::control { +public: + closing_dialog_control(luma::overlay_host& host, std::shared_ptr state, close_phase phase); + ~closing_dialog_control() override; + auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override; + auto arrange(luma::rect bounds) -> void override; + auto draw(luma::draw_context&, luma::theme const&) const -> void override; + auto handle_touch(luma::touch_event const&) -> std::bool_t override; + auto invalidate_layout() -> void override; +private: + auto close_if(close_phase phase) const -> void; + luma::overlay_host& host_; + std::shared_ptr state_{}; + close_phase phase_{close_phase::TOUCH}; +}; + +auto fake_draw_context::fill_rect(luma::rect bounds, luma::color) -> void { + filled.push_back(bounds); +} + +auto fake_draw_context::stroke_rect(luma::rect, std::float32_t, luma::color) -> void {} + +auto fake_draw_context::stroke_line(luma::point, luma::point, std::float32_t, luma::color) -> void {} + +auto fake_draw_context::fill_ellipse(luma::rect, luma::color) -> void {} + +auto fake_draw_context::stroke_ellipse(luma::rect, std::float32_t, luma::color) -> void {} + +auto fake_draw_context::draw_text(std::string_view, luma::point origin, luma::text_style const& style, luma::color) -> void { + drawn_styles.push_back(style); + drawn_origins.push_back(origin); +} + +auto fake_draw_context::draw_texture(luma::texture const&, luma::rect, luma::rect, luma::color) -> void {} + +auto fake_draw_context::push_clip(luma::rect bounds) -> void { + clips.push_back(bounds); + ++clip_depth; + max_clip_depth = std::max(max_clip_depth, clip_depth); +} + +auto fake_draw_context::pop_clip() -> void { + --clip_depth; +} + +auto fake_draw_context::measure_text(std::string_view text, luma::text_style const& style) const -> luma::size { + return { + .width = static_cast(text.size()) * style.size * std::float32_t{0.5F}, + .height = style.line_height, + }; +} + +fixed_control::fixed_control(luma::size value) : value_{value} {} + +auto fixed_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return value_; +} + +auto fixed_control::arrange(luma::rect bounds) -> void { + luma::control::arrange(bounds); + arranged = bounds; +} + +auto fixed_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto fixed_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + for (auto const& point : event.points) { + if (luma::contains(bounds(), point.position)) { + touched = true; + last_phase = point.phase; + return true; + } + } + return false; +} + +counting_control::counting_control(luma::size value) : value_{value} {} + +auto counting_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + ++measure_count; + return value_; +} + +auto counting_control::arrange(luma::rect bounds) -> void { + luma::control::arrange(bounds); + arranged = bounds; + ++arrange_count; +} + +auto counting_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto counting_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + for (auto const& point : event.points) { + if (luma::contains(bounds(), point.position)) { + return true; + } + } + return false; +} + +auto counting_control::invalidate_layout() -> void { + ++invalidate_count; +} + +auto filling_control::measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size { + ++measure_count; + return luma::clamp({ + .width = 20.0F, + .height = 20.0F, + }, limits); +} + +auto filling_control::arrange(luma::rect bounds) -> void { + luma::control::arrange(bounds); + arranged = bounds; +} + +auto filling_control::draw(luma::draw_context& context, luma::theme const&) const -> void { + context.fill_rect(bounds(), {}); +} + +fake_texture::fake_texture(std::int32_t width, std::int32_t height) : info_{.width = width, .height = height} {} + +auto fake_texture::info() const -> luma::texture_info { + return info_; +} + +auto fake_resources::load_texture(std::string const&) -> std::expected, modulus::error> { + return modulus::success(std::make_shared()); +} + +auto reentrant_resources::load_texture(std::string const& path) -> std::expected, modulus::error> { + ++load_count; + if (path == "outer.png" && cache != nullptr) { + auto inner = cache->texture("inner.png"); + inner_loaded = inner.has_value(); + } + return modulus::success(std::make_shared()); +} + +auto same_path_reentrant_resources::load_texture(std::string const& path) -> std::expected, modulus::error> { + ++load_count; + if (!reentered && cache != nullptr) { + reentered = true; + auto recursive = cache->texture(path); + recursive_failed = !recursive; + if (!recursive) { + recursive_code = recursive.error().code(); + } + } + return modulus::success(std::make_shared()); +} + +auto blocking_resources::load_texture(std::string const&) -> std::expected, modulus::error> { + auto lock = std::unique_lock{mutex_}; + ++load_count_; + load_entered_.notify_all(); + release_load_.wait(lock, [this] { + return released_; + }); + return modulus::success(std::make_shared()); +} + +auto blocking_resources::wait_for_loads(std::int32_t count) -> std::bool_t { + auto lock = std::unique_lock{mutex_}; + return load_entered_.wait_for(lock, std::chrono::seconds{1}, [this, count] { + return load_count_ >= count; + }); +} + +auto blocking_resources::release() -> void { + auto lock = std::scoped_lock{mutex_}; + released_ = true; + release_load_.notify_all(); +} + +auto blocking_resources::load_count() const -> std::int32_t { + auto lock = std::scoped_lock{mutex_}; + return load_count_; +} + +auto recording_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return { + .width = 20.0F, + .height = 20.0F, + }; +} + +auto recording_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto recording_control::handle_touch(luma::touch_event const& event) -> std::bool_t { + for (auto const& point : event.points) { + phases.push_back(point.phase); + ids.push_back(point.id); + } + return true; +} + +destroy_probe_control::destroy_probe_control(std::shared_ptr state) : state_{std::move(state)} {} + +destroy_probe_control::~destroy_probe_control() { + state_->destroyed = true; + if (state_->in_handle) { + state_->destroyed_while_active = true; + } +} + +auto destroy_probe_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return { + .width = 20.0F, + .height = 20.0F, + }; +} + +auto destroy_probe_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +closing_window_control::closing_window_control(luma::window_manager& manager, std::uint64_t& id, std::shared_ptr state) : manager_{manager}, id_{id}, state_{std::move(state)} {} + +closing_window_control::~closing_window_control() { + state_->destroyed = true; + if (state_->in_handle) { + state_->destroyed_while_active = true; + } +} + +auto closing_window_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return { + .width = 20.0F, + .height = 20.0F, + }; +} + +auto closing_window_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto closing_window_control::handle_touch(luma::touch_event const&) -> std::bool_t { + state_->in_handle = true; + auto status = manager_.close(id_); + state_->status_ok = status.has_value(); + state_->in_handle = false; + return true; +} + +opening_window_control::opening_window_control(luma::window_manager& manager, std::shared_ptr state) : manager_{manager}, state_{std::move(state)} {} + +auto opening_window_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return { + .width = 20.0F, + .height = 20.0F, + }; +} + +auto opening_window_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto opening_window_control::handle_touch(luma::touch_event const&) -> std::bool_t { + auto opened = manager_.open({ + .title = "Opened", + .bounds = { + .x = 250.0F, + .y = 100.0F, + .width = 50.0F, + .height = 50.0F, + }, + }, std::make_unique()); + state_->status_ok = opened.has_value(); + if (opened) { + state_->opened_id = *opened; + state_->visible_during_callback = manager_.find(*opened) != nullptr; + } + return true; +} + +updating_window_control::updating_window_control(luma::window_manager& manager, std::uint64_t& id, std::shared_ptr state) : manager_{manager}, id_{id}, state_{std::move(state)} {} + +auto updating_window_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return { + .width = 20.0F, + .height = 20.0F, + }; +} + +auto updating_window_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto updating_window_control::handle_touch(luma::touch_event const&) -> std::bool_t { + auto bounds = manager_.set_bounds(id_, { + .x = 5.0F, + .y = 6.0F, + .width = 20.0F, + .height = 30.0F, + }); + auto visible = manager_.set_visible(id_, false); + state_->status_ok = bounds.has_value() && visible.has_value(); + if (auto* window = manager_.find(id_)) { + state_->visible_during_callback = window->visible(); + } + return true; +} + +closing_dialog_control::closing_dialog_control(luma::overlay_host& host, std::shared_ptr state, close_phase phase) : host_{host}, state_{std::move(state)}, phase_{phase} {} + +closing_dialog_control::~closing_dialog_control() { + state_->destroyed = true; + if (state_->in_handle) { + state_->destroyed_while_active = true; + } +} + +auto closing_dialog_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + close_if(close_phase::MEASURE); + return { + .width = 20.0F, + .height = 20.0F, + }; +} + +auto closing_dialog_control::arrange(luma::rect bounds) -> void { + luma::control::arrange(bounds); + close_if(close_phase::ARRANGE); +} + +auto closing_dialog_control::draw(luma::draw_context&, luma::theme const&) const -> void { + close_if(close_phase::DRAW); +} + +auto closing_dialog_control::handle_touch(luma::touch_event const&) -> std::bool_t { + close_if(close_phase::TOUCH); + return true; +} + +auto closing_dialog_control::invalidate_layout() -> void { + close_if(close_phase::INVALIDATE); +} + +auto closing_dialog_control::close_if(close_phase phase) const -> void { + if (phase_ != phase) { + return; + } + state_->in_handle = true; + auto status = host_.close_dialog(luma::dialog_result::CANCELLED); + state_->status_ok = status.has_value(); + state_->in_handle = false; +} + +replacing_root_invalidate_control::replacing_root_invalidate_control(luma::application& app, std::shared_ptr state) : app_{app}, state_{std::move(state)} {} + +replacing_root_invalidate_control::~replacing_root_invalidate_control() { + state_->destroyed = true; + if (state_->in_handle) { + state_->destroyed_while_active = true; + } +} + +auto replacing_root_invalidate_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return { + .width = 20.0F, + .height = 20.0F, + }; +} + +auto replacing_root_invalidate_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto replacing_root_invalidate_control::invalidate_layout() -> void { + state_->in_handle = true; + auto next = std::unique_ptr{std::make_unique()}; + auto status = app_.set_root(std::move(next)); + state_->status_ok = status.has_value(); + state_->in_handle = false; +} + +closing_window_invalidate_control::closing_window_invalidate_control(luma::window_manager& manager, std::uint64_t& id, std::shared_ptr state) : manager_{manager}, id_{id}, state_{std::move(state)} {} + +closing_window_invalidate_control::~closing_window_invalidate_control() { + state_->destroyed = true; + if (state_->in_handle) { + state_->destroyed_while_active = true; + } +} + +auto closing_window_invalidate_control::measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size { + return { + .width = 20.0F, + .height = 20.0F, + }; +} +auto closing_window_invalidate_control::draw(luma::draw_context&, luma::theme const&) const -> void {} + +auto closing_window_invalidate_control::invalidate_layout() -> void { + state_->in_handle = true; + auto status = manager_.close(id_); + state_->status_ok = status.has_value(); + state_->in_handle = false; +} + +} + +BOOST_AUTO_TEST_CASE(debug_assert_mode_matches_build_type) { +#if defined(LUMA_EXPECT_NDEBUG) && !defined(NDEBUG) + BOOST_FAIL("release-style builds must define NDEBUG"); +#endif +#if defined(LUMA_EXPECT_DEBUG_ASSERTS) && defined(NDEBUG) + BOOST_FAIL("debug builds must not define NDEBUG"); +#endif +} + +BOOST_AUTO_TEST_CASE(router_failure_uses_modulus_error) { + auto router = luma::router{}; + auto result = router.replace("missing"); + BOOST_REQUIRE(!result); + BOOST_TEST(std::string{result.error().category()} == "luma"); + BOOST_TEST(result.error().code() == 2); +} + +BOOST_AUTO_TEST_CASE(rect_contains_points) { + auto area = luma::rect{ + .x = 10.0F, + .y = 20.0F, + .width = 100.0F, + .height = 50.0F, + }; + BOOST_TEST(luma::contains(area, { + .x = 10.0F, + .y = 20.0F, + })); + BOOST_TEST(luma::contains(area, { + .x = 110.0F, + .y = 70.0F, + })); + BOOST_TEST(!luma::contains(area, { + .x = 111.0F, + .y = 70.0F, + })); +} + +BOOST_AUTO_TEST_CASE(default_application_config_targets_scale_display) { + auto config = luma::application_config{}; + BOOST_TEST(config.display.width == 1024); + BOOST_TEST(config.display.height == 600); + BOOST_TEST(config.touch.max_points == 5); + BOOST_TEST(config.display.idle_fps == 15); +} + +BOOST_AUTO_TEST_CASE(router_replaces_current_view) { + auto router = luma::router{}; + BOOST_REQUIRE(router.register_route("home", [] { return luma::label("Home"); })); + BOOST_REQUIRE(router.replace("home")); + BOOST_REQUIRE(router.current() != nullptr); + BOOST_TEST(router.current_route() == "home"); +} + +BOOST_AUTO_TEST_CASE(router_retire_handler_can_defer_replaced_views) { + auto router = luma::router{}; + auto state = std::make_shared(); + auto retired = std::vector>{}; + router.set_retire_handler([&retired](std::unique_ptr view) { + retired.push_back(std::move(view)); + }); + BOOST_REQUIRE(router.register_route("first", [state] { + return modulus::success(std::unique_ptr{std::make_unique(state)}); + })); + BOOST_REQUIRE(router.register_route("second", [] { + return luma::label("Second"); + })); + BOOST_REQUIRE(router.replace("first")); + BOOST_REQUIRE(router.replace("second")); + BOOST_TEST(!state->destroyed); + BOOST_TEST(retired.size() == 1U); + retired.clear(); + BOOST_TEST(state->destroyed); +} + +BOOST_AUTO_TEST_CASE(button_activates_on_release_inside_bounds) { + auto context = fake_draw_context{}; + std::bool_t clicked = false; + auto control = luma::button("Tap", [&] { clicked = true; }); + BOOST_REQUIRE(control); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 50.0F, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 10.0F, + .y = 10.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 10.0F, + .y = 10.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(clicked); +} + +BOOST_AUTO_TEST_CASE(button_uses_matching_touch_when_not_first_point) { + auto clicked = false; + auto control = luma::button("Tap", [&] { + clicked = true; + }); + BOOST_REQUIRE(control); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 50.0F, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 200.0F, + .y = 200.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + { + .id = 2, + .position = { + .x = 10.0F, + .y = 10.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 200.0F, + .y = 200.0F, + }, + .phase = luma::touch_phase::UP, + }, + { + .id = 2, + .position = { + .x = 10.0F, + .y = 10.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(clicked); +} + +BOOST_AUTO_TEST_CASE(label_measures_text) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto control = luma::label("Scale"); + BOOST_REQUIRE(control); + auto measured = (*control)->measure(context, theme, { + .max = { + .width = 1024.0F, + .height = 600.0F, + }, + }); + BOOST_TEST(measured.width > 0.0F); + BOOST_TEST(measured.height > 0.0F); +} + +BOOST_AUTO_TEST_CASE(text_controls_measure_with_supplied_theme) { + auto context = fake_draw_context{}; + auto small = luma::default_theme(); + small.body.size = 10.0F; + small.body.line_height = 12.0F; + auto large = small; + large.body.size = 40.0F; + large.body.line_height = 48.0F; + auto label = luma::label("Scale"); + BOOST_REQUIRE(label); + auto small_label = (*label)->measure(context, small, { + .max = { + .width = 1024.0F, + .height = 600.0F, + }, + }); + auto large_label = (*label)->measure(context, large, { + .max = { + .width = 1024.0F, + .height = 600.0F, + }, + }); + BOOST_TEST(large_label.width > small_label.width); + BOOST_TEST(large_label.height > small_label.height); + auto button = luma::button("Scale", [] {}); + BOOST_REQUIRE(button); + auto small_button = (*button)->measure(context, small, { + .max = { + .width = 1024.0F, + .height = 600.0F, + }, + }); + auto large_button = (*button)->measure(context, large, { + .max = { + .width = 1024.0F, + .height = 600.0F, + }, + }); + BOOST_TEST(large_button.width > small_button.width); + BOOST_TEST(large_button.height > small_button.height); + auto field = luma::text_field({ + .text = "Scale", + }, [](std::string) {}); + BOOST_REQUIRE(field); + auto small_field = (*field)->measure(context, small, { + .max = { + .width = 1024.0F, + .height = 600.0F, + }, + }); + auto large_field = (*field)->measure(context, large, { + .max = { + .width = 1024.0F, + .height = 600.0F, + }, + }); + BOOST_TEST(large_field.height > small_field.height); +} + +BOOST_AUTO_TEST_CASE(text_controls_draw_with_supplied_theme_font) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + theme.body.font = "body.ttf"; + auto control = luma::label("Scale"); + BOOST_REQUIRE(control); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 120.0F, + .height = 40.0F, + }); + (*control)->draw(context, theme); + BOOST_REQUIRE(context.drawn_styles.size() == 1U); + BOOST_TEST(context.drawn_styles.front().font == "body.ttf"); +} + +BOOST_AUTO_TEST_CASE(application_dispatch_serializes_concurrent_callers) { + auto app = luma::application{}; + auto values = std::vector{}; + auto threads = std::vector{}; + for (auto index = 0; index < 16; ++index) { + threads.emplace_back([&app, &values, index] { + auto result = app.dispatch([&values, index] -> std::expected { + values.push_back(index); + return {}; + }); + BOOST_REQUIRE(result); + }); + } + for (auto& thread : threads) { + thread.join(); + } + BOOST_TEST(values.size() == 16U); + BOOST_TEST(std::accumulate(values.begin(), values.end(), 0) == 120); +} + +BOOST_AUTO_TEST_CASE(application_routes_use_thread_safe_boundary) { + auto app = luma::application{}; + BOOST_REQUIRE(app.register_route("home", [] { return luma::label("Home"); })); + BOOST_REQUIRE(app.replace_route("home")); + auto route = app.current_route(); + BOOST_REQUIRE(route); + BOOST_TEST(*route == "home"); +} + +BOOST_AUTO_TEST_CASE(application_rejects_texture_load_without_renderer) { + auto app = luma::application{}; + auto texture = app.load_texture("missing.png"); + BOOST_REQUIRE(!texture); + BOOST_TEST(texture.error().code() == 3); +} + +BOOST_AUTO_TEST_CASE(application_show_toast_requires_root) { + auto app = luma::application{}; + auto status = app.show_toast({ + .message = "hello", + }); + BOOST_REQUIRE(!status); + BOOST_TEST(status.error().code() == 3); +} + +BOOST_AUTO_TEST_CASE(application_request_stop_is_thread_safe) { + auto app = luma::application{}; + auto thread = std::thread{[&app] { + app.request_stop(); + }}; + thread.join(); + BOOST_TEST(app.should_stop()); +} + +BOOST_AUTO_TEST_CASE(application_request_layout_is_thread_safe) { + auto app = luma::application{}; + auto ok = false; + auto thread = std::thread{[&app, &ok] { + ok = app.request_layout().has_value(); + }}; + thread.join(); + BOOST_TEST(ok); +} + +BOOST_AUTO_TEST_CASE(application_request_layout_defers_root_replacement_during_invalidation) { + auto app = luma::application{}; + auto state = std::make_shared(); + auto root = std::unique_ptr{std::make_unique(app, state)}; + BOOST_REQUIRE(app.set_root(std::move(root))); + auto status = app.request_layout(); + BOOST_REQUIRE(status); + BOOST_TEST(state->status_ok); + BOOST_TEST(!state->destroyed_while_active); + BOOST_TEST(state->destroyed); +} + +BOOST_AUTO_TEST_CASE(custom_control_unique_ptr_overloads_are_unambiguous) { + auto app = luma::application{}; + BOOST_REQUIRE(app.set_root(std::make_unique(luma::size{ + .width = 10.0F, + .height = 10.0F, + }))); + auto scroller = luma::scroll_view(std::make_unique(luma::size{ + .width = 10.0F, + .height = 100.0F, + })); + BOOST_REQUIRE(scroller); + auto children = luma::children(std::make_unique(luma::size{ + .width = 10.0F, + .height = 10.0F, + }), luma::label("Label")); + BOOST_REQUIRE(children); + BOOST_TEST(children->size() == 2U); +} + +BOOST_AUTO_TEST_CASE(factory_validation_returns_errors) { + BOOST_TEST(!luma::label("")); + BOOST_TEST(!luma::button("OK", {})); + BOOST_TEST(!luma::text_field({ + .max_length = 0, + }, [](std::string) {})); + BOOST_TEST(!luma::image(nullptr)); + BOOST_TEST(!luma::image(std::make_shared(0, 16))); + BOOST_TEST(!luma::scroll_view(std::unique_ptr{})); + auto children = std::vector>{}; + children.push_back(nullptr); + BOOST_TEST(!luma::row(std::move(children))); + auto resources = fake_resources{}; + auto assets = luma::asset_cache{resources}; + auto texture = assets.texture(""); + BOOST_REQUIRE(!texture); + BOOST_TEST(texture.error().code() == 1); + auto invalid_config = luma::application_config{}; + invalid_config.theme.body.size = -1.0F; + auto config_status = luma::validate(invalid_config); + BOOST_REQUIRE(!config_status); + BOOST_TEST(config_status.error().code() == 1); + auto host = luma::overlays(nullptr); + BOOST_REQUIRE(!host); + BOOST_TEST(host.error().code() == 1); + auto manager = luma::window_manager{}; + auto window = manager.open({}, nullptr); + BOOST_REQUIRE(!window); + BOOST_TEST(window.error().code() == 1); +} + +BOOST_AUTO_TEST_CASE(asset_cache_allows_reentrant_resource_loads) { + auto resources = reentrant_resources{}; + auto assets = luma::asset_cache{resources}; + resources.cache = &assets; + auto texture = assets.texture("outer.png"); + BOOST_REQUIRE(texture); + BOOST_TEST(resources.inner_loaded); + BOOST_TEST(resources.load_count == 2); + BOOST_REQUIRE(assets.texture("outer.png")); + BOOST_TEST(resources.load_count == 2); +} + +BOOST_AUTO_TEST_CASE(asset_cache_serializes_concurrent_same_path_loads) { + auto resources = blocking_resources{}; + auto assets = luma::asset_cache{resources}; + auto start_mutex = std::mutex{}; + auto start_ready = std::condition_variable{}; + auto start = false; + auto ok = std::vector(4, 0); + auto textures = std::vector>(4); + auto threads = std::vector{}; + for (auto index = std::size_t{}; index < ok.size(); ++index) { + threads.emplace_back([&assets, &ok, &textures, &start_mutex, &start_ready, &start, index] { + { + auto lock = std::unique_lock{start_mutex}; + start_ready.wait(lock, [&start] { + return start; + }); + } + auto texture = assets.texture("shared.png"); + ok[index] = texture.has_value() ? 1 : 0; + if (texture) { + textures[index] = *texture; + } + }); + } + { + auto lock = std::scoped_lock{start_mutex}; + start = true; + } + start_ready.notify_all(); + BOOST_REQUIRE(resources.wait_for_loads(1)); + resources.release(); + for (auto& thread : threads) { + thread.join(); + } + BOOST_TEST(resources.load_count() == 1); + for (auto value : ok) { + BOOST_TEST(value == 1); + } + for (auto const& texture : textures) { + BOOST_TEST(texture == textures.front()); + } +} + +BOOST_AUTO_TEST_CASE(asset_cache_rejects_recursive_same_path_load) { + auto resources = same_path_reentrant_resources{}; + auto assets = luma::asset_cache{resources}; + resources.cache = &assets; + auto texture = assets.texture("same.png"); + BOOST_REQUIRE(texture); + BOOST_TEST(resources.load_count == 1); + BOOST_TEST(resources.recursive_failed); + BOOST_TEST(resources.recursive_code == 3); +} + +BOOST_AUTO_TEST_CASE(text_input_state_filters_modes_and_keyboard_updates_bound_field) { + auto changes = std::vector{}; + auto input = luma::make_text_input({ + .mode = luma::text_input_mode::INTEGER, + .max_length = 3, + }, [&](std::string text) { + changes.push_back(std::move(text)); + }); + BOOST_REQUIRE(input); + BOOST_REQUIRE((*input)->append("1")); + BOOST_TEST(!(*input)->append("x")); + BOOST_REQUIRE((*input)->append("2")); + BOOST_TEST(!(*input)->append("345")); + BOOST_TEST((*input)->text() == "12"); + auto keyboard = luma::soft_keyboard({ + .rows = { + { + { + "3", + "3", + }, + { + "<-", + "", + luma::keyboard_key_kind::BACKSPACE, + }, + }, + }, + }, *input); + BOOST_REQUIRE(keyboard); + (*keyboard)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 200.0F, + .height = 50.0F, + }); + (*keyboard)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 25.0F, + .y = 25.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST((*input)->text() == "123"); + (*keyboard)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 150.0F, + .y = 25.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST((*input)->text() == "12"); + BOOST_TEST(!changes.empty()); +} + +BOOST_AUTO_TEST_CASE(decimal_text_input_accepts_one_decimal_point) { + auto input = luma::make_text_input({ + .mode = luma::text_input_mode::DECIMAL, + }, [](std::string) {}); + BOOST_REQUIRE(input); + BOOST_REQUIRE((*input)->append("1.5")); + BOOST_TEST(!(*input)->append(".")); + BOOST_TEST(!(*input)->append("a")); + BOOST_TEST((*input)->text() == "1.5"); +} + +BOOST_AUTO_TEST_CASE(text_input_callback_exceptions_return_errors) { + auto input = luma::make_text_input({}, [](std::string) { + throw std::runtime_error{"boom"}; + }); + BOOST_REQUIRE(input); + auto status = (*input)->append("x"); + BOOST_REQUIRE(!status); + BOOST_TEST(status.error().code() == 5); +} + +BOOST_AUTO_TEST_CASE(text_input_state_notifies_bound_layout_invalidator) { + auto input = luma::make_text_input({ + .text = "a", + }, [](std::string) {}); + BOOST_REQUIRE(input); + auto field = luma::text_field(*input); + BOOST_REQUIRE(field); + auto invalidations = 0; + (*field)->bind_layout_invalidator([&] { + ++invalidations; + }); + BOOST_REQUIRE((*input)->set_text("longer")); + BOOST_TEST(invalidations == 1); + BOOST_REQUIRE((*input)->append("x")); + BOOST_TEST(invalidations == 2); + BOOST_REQUIRE((*input)->backspace()); + BOOST_TEST(invalidations == 3); + field->reset(); + BOOST_REQUIRE((*input)->set_text("after")); + BOOST_TEST(invalidations == 3); +} + +BOOST_AUTO_TEST_CASE(text_field_layout_invalidator_propagates_through_containers) { + auto input = luma::make_text_input({ + .text = "a", + }, [](std::string) {}); + BOOST_REQUIRE(input); + auto field = luma::text_field(*input); + BOOST_REQUIRE(field); + auto children = luma::children(std::move(*field)); + BOOST_REQUIRE(children); + auto column = luma::column(std::move(*children)); + BOOST_REQUIRE(column); + auto invalidations = 0; + (*column)->bind_layout_invalidator([&] { + ++invalidations; + }); + BOOST_REQUIRE((*input)->set_text("longer")); + BOOST_TEST(invalidations == 1); +} + +BOOST_AUTO_TEST_CASE(dialog_accept_and_cancel_close_with_callbacks) { + auto root = luma::label("Root"); + BOOST_REQUIRE(root); + auto host_result = luma::overlays(std::move(*root)); + BOOST_REQUIRE(host_result); + auto& host = **host_result; + host.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 1000.0F, + .height = 600.0F, + }); + auto result = luma::dialog_result::NONE; + auto body = luma::label("Body"); + BOOST_REQUIRE(body); + BOOST_REQUIRE(host.show_dialog({ + .title = "Title", + .body = std::move(*body), + .on_close = [&](luma::dialog_result value) { result = value; }, + })); + host.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 800.0F, + .y = 470.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(static_cast(result) == static_cast(luma::dialog_result::ACCEPTED)); + result = luma::dialog_result::NONE; + body = luma::label("Body"); + BOOST_REQUIRE(body); + BOOST_REQUIRE(host.show_dialog({ + .title = "Title", + .body = std::move(*body), + .on_close = [&](luma::dialog_result value) { result = value; }, + })); + host.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 660.0F, + .y = 470.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(static_cast(result) == static_cast(luma::dialog_result::CANCELLED)); +} + +BOOST_AUTO_TEST_CASE(dialog_uses_matching_touch_when_not_first_point) { + auto root = luma::label("Root"); + BOOST_REQUIRE(root); + auto host_result = luma::overlays(std::move(*root)); + BOOST_REQUIRE(host_result); + auto& host = **host_result; + host.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 1000.0F, + .height = 600.0F, + }); + auto result = luma::dialog_result::NONE; + auto body = luma::label("Body"); + BOOST_REQUIRE(body); + BOOST_REQUIRE(host.show_dialog({ + .title = "Title", + .body = std::move(*body), + .on_close = [&](luma::dialog_result value) { result = value; }, + })); + host.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::UP, + }, + { + .id = 2, + .position = { + .x = 800.0F, + .y = 470.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(static_cast(result) == static_cast(luma::dialog_result::ACCEPTED)); +} + +BOOST_AUTO_TEST_CASE(dialog_body_receives_touches) { + auto root = luma::label("Root"); + BOOST_REQUIRE(root); + auto host_result = luma::overlays(std::move(*root)); + BOOST_REQUIRE(host_result); + auto& host = **host_result; + auto body = std::make_unique(luma::size{ + .width = 100.0F, + .height = 100.0F, + }); + auto* body_ptr = body.get(); + BOOST_REQUIRE(host.show_dialog({ + .title = "Title", + .body = std::move(body), + })); + host.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 1000.0F, + .height = 600.0F, + }); + host.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 120.0F, + .y = 150.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + BOOST_TEST(body_ptr->touched); +} + +BOOST_AUTO_TEST_CASE(dialog_body_can_close_dialog_from_forwarded_methods_without_mid_callback_destruction) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto phases = std::vector{ + close_phase::MEASURE, + close_phase::ARRANGE, + close_phase::DRAW, + close_phase::TOUCH, + close_phase::INVALIDATE, + }; + for (auto phase : phases) { + auto root = luma::label("Root"); + BOOST_REQUIRE(root); + auto host_result = luma::overlays(std::move(*root)); + BOOST_REQUIRE(host_result); + auto& host = **host_result; + auto state = std::make_shared(); + auto body = std::make_unique(host, state, phase); + BOOST_REQUIRE(host.show_dialog({ + .title = "Title", + .body = std::move(body), + })); + if (phase == close_phase::MEASURE) { + host.measure(context, theme, { + .max = { + .width = 1000.0F, + .height = 600.0F, + }, + }); + } else if (phase == close_phase::ARRANGE) { + host.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 1000.0F, + .height = 600.0F, + }); + } else if (phase == close_phase::DRAW) { + host.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 1000.0F, + .height = 600.0F, + }); + host.draw(context, theme); + } else if (phase == close_phase::TOUCH) { + host.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 1000.0F, + .height = 600.0F, + }); + host.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 120.0F, + .y = 150.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + } else { + host.invalidate_layout(); + } + BOOST_TEST(state->status_ok); + BOOST_TEST(!state->destroyed_while_active); + BOOST_TEST(state->destroyed); + } +} + +BOOST_AUTO_TEST_CASE(window_manager_captures_touch_by_id) { + auto manager = luma::window_manager{}; + auto content = std::make_unique(); + auto* content_ptr = content.get(); + auto id = manager.open({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::move(content)); + BOOST_REQUIRE(id); + BOOST_TEST(*id != 0U); + BOOST_TEST(manager.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 10.0F, + .y = 10.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + { + .id = 2, + .position = { + .x = 120.0F, + .y = 150.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + })); + BOOST_REQUIRE(content_ptr->ids.size() == 1U); + BOOST_TEST(content_ptr->ids.front() == 2); + BOOST_TEST(static_cast(content_ptr->phases.front()) == static_cast(luma::touch_phase::DOWN)); + BOOST_TEST(manager.handle_touch({ + .points = { + { + .id = 2, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + })); + BOOST_REQUIRE(content_ptr->phases.size() == 2U); + BOOST_TEST(static_cast(content_ptr->phases.back()) == static_cast(luma::touch_phase::UP)); +} + +BOOST_AUTO_TEST_CASE(window_set_bounds_validates_release_inputs) { + auto manager = luma::window_manager{}; + auto id = manager.open({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::make_unique()); + BOOST_REQUIRE(id); + auto* window = manager.find(*id); + BOOST_REQUIRE(window != nullptr); + auto original = window->options().bounds; + auto invalid = window->set_bounds({ + .x = 0.0F, + .y = 0.0F, + .width = -1.0F, + .height = 10.0F, + }); + BOOST_REQUIRE(!invalid); + BOOST_TEST(window->options().bounds.width == original.width); + auto valid = window->set_bounds({ + .x = 5.0F, + .y = 6.0F, + .width = 20.0F, + .height = 30.0F, + }); + BOOST_REQUIRE(valid); + BOOST_TEST(window->options().bounds.x == 5.0F); + BOOST_TEST(window->options().bounds.height == 30.0F); +} + +BOOST_AUTO_TEST_CASE(window_manager_uses_decorated_client_area_for_content) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto manager = luma::window_manager{}; + auto content = std::make_unique(); + auto* content_ptr = content.get(); + auto id = manager.open({ + .title = "Tool", + .bounds = { + .x = 10.0F, + .y = 20.0F, + .width = 100.0F, + .height = 80.0F, + }, + }, std::move(content)); + BOOST_REQUIRE(id); + manager.measure(context, theme, { + .max = { + .width = 200.0F, + .height = 200.0F, + }, + }); + manager.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 200.0F, + .height = 200.0F, + }); + BOOST_TEST(content_ptr->arranged.x == 10.0F); + BOOST_TEST(content_ptr->arranged.y == 62.0F); + BOOST_TEST(content_ptr->arranged.width == 100.0F); + BOOST_TEST(content_ptr->arranged.height == 38.0F); + manager.draw(context, theme); + BOOST_REQUIRE(context.clips.size() == 1U); + BOOST_TEST(context.clips.front().y == 62.0F); + BOOST_TEST(context.clips.front().height == 38.0F); + BOOST_REQUIRE(context.filled.size() >= 3U); + BOOST_TEST(context.filled[0].y == 20.0F); + BOOST_TEST(context.filled[1].y == 62.0F); + BOOST_TEST(context.filled[2].y == 20.0F); + BOOST_TEST(context.clip_depth == 0); +} + +BOOST_AUTO_TEST_CASE(window_manager_uses_theme_body_line_height_for_decorated_chrome) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + theme.body.line_height = 60.0F; + auto manager = luma::window_manager{}; + auto content = std::make_unique(); + auto* content_ptr = content.get(); + auto id = manager.open({ + .title = "Tool", + .bounds = { + .x = 10.0F, + .y = 20.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::move(content)); + BOOST_REQUIRE(id); + manager.measure(context, theme, { + .max = { + .width = 200.0F, + .height = 200.0F, + }, + }); + manager.arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 200.0F, + .height = 200.0F, + }); + BOOST_TEST(content_ptr->arranged.y == 92.0F); + BOOST_TEST(content_ptr->arranged.height == 28.0F); + manager.draw(context, theme); + BOOST_REQUIRE(context.clips.size() == 1U); + BOOST_TEST(context.clips.front().y == 92.0F); + BOOST_TEST(context.clips.front().height == 28.0F); + BOOST_REQUIRE(!context.drawn_origins.empty()); + BOOST_TEST(context.drawn_origins.back().y == 26.0F); +} + +BOOST_AUTO_TEST_CASE(window_manager_skips_hidden_windows_during_measure) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto manager = luma::window_manager{}; + auto content = std::make_unique(luma::size{ + .width = 20.0F, + .height = 20.0F, + }); + auto* content_ptr = content.get(); + auto id = manager.open({ + .title = "Tool", + .bounds = { + .x = 10.0F, + .y = 20.0F, + .width = 100.0F, + .height = 80.0F, + }, + .visible = false, + }, std::move(content)); + BOOST_REQUIRE(id); + manager.measure(context, theme, { + .max = { + .width = 200.0F, + .height = 200.0F, + }, + }); + BOOST_TEST(content_ptr->measure_count == 0); + BOOST_REQUIRE(manager.set_visible(*id, true)); + manager.measure(context, theme, { + .max = { + .width = 200.0F, + .height = 200.0F, + }, + }); + BOOST_TEST(content_ptr->measure_count == 1); +} + +BOOST_AUTO_TEST_CASE(window_manager_id_mutators_validate_and_update_windows) { + auto manager = luma::window_manager{}; + auto id = manager.open({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::make_unique()); + BOOST_REQUIRE(id); + BOOST_TEST(!manager.set_bounds(0, { + .x = 1.0F, + .y = 2.0F, + .width = 3.0F, + .height = 4.0F, + })); + BOOST_TEST(!manager.set_bounds(*id, { + .x = 1.0F, + .y = 2.0F, + .width = -3.0F, + .height = 4.0F, + })); + BOOST_REQUIRE(manager.set_bounds(*id, { + .x = 5.0F, + .y = 6.0F, + .width = 20.0F, + .height = 30.0F, + })); + BOOST_REQUIRE(manager.set_visible(*id, false)); + auto* window = manager.find(*id); + BOOST_REQUIRE(window != nullptr); + BOOST_TEST(window->options().bounds.x == 5.0F); + BOOST_TEST(window->options().bounds.height == 30.0F); + BOOST_TEST(!window->visible()); +} + +BOOST_AUTO_TEST_CASE(window_manager_find_pointer_is_stable_across_open_and_reorder) { + auto manager = luma::window_manager{}; + auto first = manager.open({ + .title = "First", + .bounds = { + .x = 0.0F, + .y = 0.0F, + .width = 10.0F, + .height = 10.0F, + }, + }, std::make_unique()); + BOOST_REQUIRE(first); + auto* first_ptr = manager.find(*first); + BOOST_REQUIRE(first_ptr != nullptr); + for (auto index = 0; index < 32; ++index) { + auto opened = manager.open({ + .title = "Other", + .bounds = { + .x = static_cast(index + 20), + .y = 0.0F, + .width = 10.0F, + .height = 10.0F, + }, + }, std::make_unique()); + BOOST_REQUIRE(opened); + } + BOOST_TEST(manager.find(*first) == first_ptr); + BOOST_REQUIRE(manager.bring_to_front(*first)); + BOOST_TEST(manager.find(*first) == first_ptr); +} + +BOOST_AUTO_TEST_CASE(window_manager_defers_reentrant_close_until_touch_returns) { + auto manager = luma::window_manager{}; + auto state = std::make_shared(); + auto id = std::uint64_t{}; + auto opened = manager.open({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::make_unique(manager, id, state)); + BOOST_REQUIRE(opened); + id = *opened; + BOOST_TEST(manager.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 120.0F, + .y = 150.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + })); + BOOST_TEST(state->status_ok); + BOOST_TEST(!state->destroyed_while_active); + BOOST_TEST(state->destroyed); + BOOST_TEST(manager.find(id) == nullptr); +} + +BOOST_AUTO_TEST_CASE(window_manager_defers_reentrant_close_until_invalidation_returns) { + auto manager = luma::window_manager{}; + auto state = std::make_shared(); + auto id = std::uint64_t{}; + auto opened = manager.open({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::make_unique(manager, id, state)); + BOOST_REQUIRE(opened); + id = *opened; + manager.invalidate_layout(); + BOOST_TEST(state->status_ok); + BOOST_TEST(!state->destroyed_while_active); + BOOST_TEST(state->destroyed); + BOOST_TEST(manager.find(id) == nullptr); +} + +BOOST_AUTO_TEST_CASE(window_manager_defers_reentrant_open_and_returns_reserved_id) { + auto manager = luma::window_manager{}; + auto state = std::make_shared(); + auto opened = manager.open({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::make_unique(manager, state)); + BOOST_REQUIRE(opened); + BOOST_TEST(manager.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 120.0F, + .y = 150.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + })); + BOOST_TEST(state->status_ok); + BOOST_TEST(state->opened_id != 0U); + BOOST_TEST(!state->visible_during_callback); + BOOST_TEST(manager.find(state->opened_id) != nullptr); +} + +BOOST_AUTO_TEST_CASE(window_manager_defers_reentrant_bounds_and_visibility_updates) { + auto manager = luma::window_manager{}; + auto state = std::make_shared(); + auto id = std::uint64_t{}; + auto opened = manager.open({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::make_unique(manager, id, state)); + BOOST_REQUIRE(opened); + id = *opened; + BOOST_TEST(manager.handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 120.0F, + .y = 150.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + })); + BOOST_TEST(state->status_ok); + BOOST_TEST(state->visible_during_callback); + auto* window = manager.find(id); + BOOST_REQUIRE(window != nullptr); + BOOST_TEST(window->options().bounds.x == 5.0F); + BOOST_TEST(window->options().bounds.height == 30.0F); + BOOST_TEST(!window->visible()); +} + +BOOST_AUTO_TEST_CASE(application_window_facade_validates_pre_run_calls) { + auto app = luma::application{}; + auto opened = app.open_window({ + .title = "Tool", + .bounds = { + .x = 100.0F, + .y = 100.0F, + .width = 100.0F, + .height = 100.0F, + }, + }, std::make_unique()); + BOOST_REQUIRE(opened); + BOOST_REQUIRE(app.set_window_bounds(*opened, { + .x = 5.0F, + .y = 6.0F, + .width = 20.0F, + .height = 30.0F, + })); + BOOST_REQUIRE(app.set_window_visible(*opened, false)); + BOOST_TEST(!app.set_window_bounds(0, { + .x = 5.0F, + .y = 6.0F, + .width = 20.0F, + .height = 30.0F, + })); + BOOST_TEST(!app.set_window_bounds(*opened, { + .x = 5.0F, + .y = 6.0F, + .width = -20.0F, + .height = 30.0F, + })); +} + +BOOST_AUTO_TEST_CASE(containers_route_simultaneous_touches_to_siblings) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto first = std::make_unique(luma::size{ + .width = 50.0F, + .height = 50.0F, + }); + auto* first_ptr = first.get(); + auto second = std::make_unique(luma::size{ + .width = 50.0F, + .height = 50.0F, + }); + auto* second_ptr = second.get(); + auto control = luma::row(luma::children(modulus::success(std::unique_ptr{std::move(first)}), modulus::success(std::unique_ptr{std::move(second)})), 0.0F, luma::alignment::START); + BOOST_REQUIRE(control); + (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 50.0F, + }, + }); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 50.0F, + }); + BOOST_TEST((*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 10.0F, + .y = 10.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + { + .id = 2, + .position = { + .x = 60.0F, + .y = 10.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + })); + BOOST_TEST(first_ptr->touched); + BOOST_TEST(second_ptr->touched); +} + +BOOST_AUTO_TEST_CASE(row_and_column_apply_cross_axis_alignment) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto first = std::make_unique(luma::size{ + .width = 20.0F, + .height = 10.0F, + }); + auto* first_ptr = first.get(); + auto second = std::make_unique(luma::size{ + .width = 20.0F, + .height = 20.0F, + }); + auto* second_ptr = second.get(); + auto row = luma::row(luma::children(modulus::success(std::unique_ptr{std::move(first)}), modulus::success(std::unique_ptr{std::move(second)})), 0.0F, luma::alignment::END); + BOOST_REQUIRE(row); + (*row)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + (*row)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + BOOST_TEST(first_ptr->arranged.y == 90.0F); + BOOST_TEST(second_ptr->arranged.y == 80.0F); + auto child = std::make_unique(luma::size{ + .width = 20.0F, + .height = 10.0F, + }); + auto* child_ptr = child.get(); + auto column = luma::column(luma::children(modulus::success(std::unique_ptr{std::move(child)})), 0.0F, luma::alignment::CENTER); + BOOST_REQUIRE(column); + (*column)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + (*column)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + BOOST_TEST(child_ptr->arranged.x == 40.0F); +} + +BOOST_AUTO_TEST_CASE(panel_measure_includes_padding) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto child = std::make_unique(luma::size{ + .width = 20.0F, + .height = 10.0F, + }); + auto control = luma::panel(luma::children(modulus::success(std::unique_ptr{std::move(child)}))); + BOOST_REQUIRE(control); + auto measured = (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + BOOST_TEST(measured.width == 36.0F); + BOOST_TEST(measured.height == 26.0F); +} + +BOOST_AUTO_TEST_CASE(scroll_view_uses_nested_clips) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto inner = luma::scroll_view(luma::label("Inner")); + auto outer = luma::scroll_view(std::move(inner)); + BOOST_REQUIRE(outer); + (*outer)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + (*outer)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + (*outer)->draw(context, theme); + BOOST_TEST(context.max_clip_depth == 2); + BOOST_TEST(context.clip_depth == 0); +} + +BOOST_AUTO_TEST_CASE(scroll_view_forwards_taps_to_child) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto clicked = false; + auto control = luma::scroll_view(luma::button("Tap", [&] { + clicked = true; + })); + BOOST_REQUIRE(control); + (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(clicked); +} + +BOOST_AUTO_TEST_CASE(scroll_view_forwards_simultaneous_child_taps) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto first_clicked = false; + auto second_clicked = false; + auto content = luma::row(luma::children(luma::button("A", [&] {first_clicked = true;}), luma::button("B", [&] {second_clicked = true;})), 0.0F, luma::alignment::START); + auto control = luma::scroll_view(std::move(content)); + BOOST_REQUIRE(control); + (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + { + .id = 2, + .position = { + .x = 70.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::UP, + }, + { + .id = 2, + .position = { + .x = 70.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(first_clicked); + BOOST_TEST(second_clicked); +} + +BOOST_AUTO_TEST_CASE(scroll_view_drag_suppresses_child_click) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto clicked = false; + auto control = luma::scroll_view(luma::button("Tap", [&] { + clicked = true; + })); + BOOST_REQUIRE(control); + (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 80.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::MOVE, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::UP, + }, + }, + }); + BOOST_TEST(!clicked); +} + +BOOST_AUTO_TEST_CASE(scroll_view_allows_content_beyond_four_viewports) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto child = std::make_unique(luma::size{ + .width = 20.0F, + .height = 1000.0F, + }); + auto* child_ptr = child.get(); + auto control = luma::scroll_view(std::unique_ptr{std::move(child)}); + BOOST_REQUIRE(control); + auto measured = (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + BOOST_TEST(measured.height == 100.0F); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 90.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = -610.0F, + }, + .phase = luma::touch_phase::MOVE, + }, + }, + }); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + BOOST_TEST(child_ptr->arranged.y <= -600.0F); +} + +BOOST_AUTO_TEST_CASE(scroll_view_caches_measurement_and_rearranges_on_scroll_move) { + auto context = fake_draw_context{}; + auto theme = luma::default_theme(); + auto child = std::make_unique(luma::size{ + .width = 20.0F, + .height = 1000.0F, + }); + auto* child_ptr = child.get(); + auto control = luma::scroll_view(std::unique_ptr{std::move(child)}); + BOOST_REQUIRE(control); + auto measured = (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + BOOST_TEST(measured.height == 100.0F); + measured = (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + BOOST_TEST(measured.height == 100.0F); + BOOST_TEST(child_ptr->measure_count == 1); + (*control)->arrange({ + .x = 0.0F, + .y = 0.0F, + .width = 100.0F, + .height = 100.0F, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 90.0F, + }, + .phase = luma::touch_phase::DOWN, + }, + }, + }); + (*control)->handle_touch({ + .points = { + { + .id = 1, + .position = { + .x = 20.0F, + .y = 20.0F, + }, + .phase = luma::touch_phase::MOVE, + }, + }, + }); + BOOST_TEST(child_ptr->measure_count == 1); + BOOST_TEST(child_ptr->arranged.y < 0.0F); + (*control)->invalidate_layout(); + measured = (*control)->measure(context, theme, { + .max = { + .width = 100.0F, + .height = 100.0F, + }, + }); + BOOST_TEST(measured.height == 100.0F); + BOOST_TEST(child_ptr->measure_count == 2); + BOOST_TEST(child_ptr->invalidate_count >= 1); +} + +BOOST_AUTO_TEST_CASE(interpolate_validates_public_inputs) { + auto linear = luma::interpolate(0.0F, 10.0F, 0.5F, luma::easing::LINEAR); + BOOST_REQUIRE(linear); + BOOST_TEST(*linear == 5.0F); + auto clamped = luma::interpolate(0.0F, 10.0F, 2.0F, luma::easing::EASE_OUT); + BOOST_REQUIRE(clamped); + BOOST_TEST(*clamped == 10.0F); + auto invalid_from = luma::interpolate(std::numeric_limits::quiet_NaN(), 10.0F, 0.5F, luma::easing::LINEAR); + BOOST_REQUIRE(!invalid_from); + BOOST_TEST(invalid_from.error().code() == 1); + auto invalid_progress = luma::interpolate(0.0F, 10.0F, std::numeric_limits::infinity(), luma::easing::LINEAR); + BOOST_REQUIRE(!invalid_progress); + BOOST_TEST(invalid_progress.error().code() == 1); +} + +BOOST_AUTO_TEST_CASE(animation_graph_validates_inputs_and_converts_callback_exceptions) { + auto graph = luma::animation_graph{}; + auto invalid = graph.add({ + .from = 0.0F, + .to = 1.0F, + .duration = std::chrono::milliseconds{0}, + .apply = [](std::float32_t) {}, + }); + BOOST_REQUIRE(!invalid); + BOOST_TEST(invalid.error().code() == 1); + BOOST_REQUIRE(graph.add({ + .from = 0.0F, + .to = 1.0F, + .duration = std::chrono::milliseconds{1}, + .apply = [](std::float32_t) { + throw std::runtime_error{"boom"}; + }, + })); + auto status = graph.tick(std::chrono::milliseconds{1}); + BOOST_REQUIRE(!status); + BOOST_TEST(status.error().code() == 5); +} + +BOOST_AUTO_TEST_CASE(animation_graph_defers_reentrant_add_and_clear) { + auto graph = luma::animation_graph{}; + auto added_applied = 0; + BOOST_REQUIRE(graph.add({ + .from = 0.0F, + .to = 1.0F, + .duration = std::chrono::milliseconds{10}, + .apply = [&](std::float32_t) { + BOOST_REQUIRE(graph.add({ + .from = 0.0F, + .to = 1.0F, + .duration = std::chrono::milliseconds{10}, + .apply = [&](std::float32_t) { ++added_applied; }, + })); + }, + })); + BOOST_REQUIRE(graph.tick(std::chrono::milliseconds{5})); + BOOST_REQUIRE(graph.tick(std::chrono::milliseconds{5})); + BOOST_TEST(added_applied == 1); + graph.clear(); + auto second_applied = false; + BOOST_REQUIRE(graph.add({ + .from = 0.0F, + .to = 1.0F, + .duration = std::chrono::milliseconds{10}, + .apply = [&](std::float32_t) { graph.clear(); }, + })); + BOOST_REQUIRE(graph.add({ + .from = 0.0F, + .to = 1.0F, + .duration = std::chrono::milliseconds{10}, + .apply = [&](std::float32_t) { second_applied = true; }, + })); + BOOST_REQUIRE(graph.tick(std::chrono::milliseconds{5})); + BOOST_TEST(!second_applied); + BOOST_TEST(graph.empty()); +} + +BOOST_AUTO_TEST_CASE(dispatch_allows_recursive_calls_before_run) { + auto app = luma::application{}; + auto result = app.dispatch([&app] -> std::expected { + return app.dispatch([] -> std::expected { + return {}; + }); + }); + BOOST_REQUIRE(result); +} + +BOOST_AUTO_TEST_CASE(config_parsing_checks_integer_range_and_accepts_integer_theme_numbers) { + auto invalid = luma::parse_display_config(boost::json::parse(R"({"width": 2147483648, "height": 600, "target_fps": 60})")); + BOOST_TEST(!invalid); + auto theme = luma::parse_theme(boost::json::parse(R"({"body": {"size": 24, "line_height": 30}, "radius": 4, "spacing": 8})")); + BOOST_REQUIRE(theme); + BOOST_TEST(theme->body.size == 24.0F); + BOOST_TEST(theme->body.line_height == 30.0F); + BOOST_TEST(theme->radius == 4.0F); + BOOST_TEST(theme->spacing == 8.0F); + auto config = luma::parse_application_config(boost::json::parse(R"({"title": "Scale", "display": {"width": 1024, "height": 600, "target_fps": 30, "idle_fps": 10, "fullscreen": false}, "touch": {"max_points": 5}, "theme": {"body": {"font": "default", "size": 24, "line_height": 30}, "radius": 4, "spacing": 8}})")); + BOOST_REQUIRE(config); + auto round_trip = luma::parse_application_config(luma::to_json(*config)); + BOOST_REQUIRE(round_trip); + BOOST_TEST(round_trip->title == "Scale"); + BOOST_TEST(round_trip->display.target_fps == 30); + BOOST_TEST(round_trip->display.idle_fps == 10); + BOOST_TEST(!round_trip->display.fullscreen); + BOOST_TEST(round_trip->theme.radius == 4.0F); + auto invalid_idle = luma::parse_display_config(boost::json::parse(R"({"target_fps": 30, "idle_fps": 31})")); + BOOST_REQUIRE(!invalid_idle); + BOOST_TEST(invalid_idle.error().describe().find("display.idle_fps must be less than or equal to display.target_fps") != std::string::npos); + auto zero_idle = luma::parse_display_config(boost::json::parse(R"({"target_fps": 30, "idle_fps": 0})")); + BOOST_REQUIRE(!zero_idle); + BOOST_TEST(zero_idle.error().describe().find("display.idle_fps must be positive") != std::string::npos); + auto unknown = luma::parse_application_config(boost::json::parse(R"({"title":"Scale","displayz":{}})")); + BOOST_REQUIRE(!unknown); + BOOST_TEST(unknown.error().describe().find("application config has unknown key: displayz") != std::string::npos); + auto unknown_theme = luma::parse_theme(boost::json::parse(R"({"body":{"size":24,"weight":"bold"}})")); + BOOST_REQUIRE(!unknown_theme); + BOOST_TEST(unknown_theme.error().describe().find("body has unknown key: weight") != std::string::npos); + auto empty_font_theme = luma::parse_theme(boost::json::parse(R"({"body":{"font":"","size":24,"line_height":30}})")); + BOOST_REQUIRE(!empty_font_theme); + BOOST_TEST(empty_font_theme.error().describe().find("text style font must not be empty") != std::string::npos); +} diff --git a/tests/test.hpp b/tests/test.hpp new file mode 100644 index 0000000..6a91c62 --- /dev/null +++ b/tests/test.hpp @@ -0,0 +1,4 @@ +#pragma once + +#define BOOST_TEST_MODULE luma +#include