diff --git a/.gitignore b/.gitignore
index 51269c9..e92ae95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,7 @@ __pycache__/
/build
/build.ninja
/shuriken.override.yaml
+
+# Dependencies
+/vendor
+!/vendor/README.md
diff --git a/assets/fonts/LatoLatin-Regular.ttf b/assets/fonts/LatoLatin-Regular.ttf
new file mode 100644
index 0000000..bcc5778
Binary files /dev/null and b/assets/fonts/LatoLatin-Regular.ttf differ
diff --git a/assets/neofox.png b/assets/neofox.png
new file mode 100644
index 0000000..64dd563
Binary files /dev/null and b/assets/neofox.png differ
diff --git a/assets/sprites/neocat_64.png b/assets/sprites/neocat_64.png
new file mode 100644
index 0000000..b8c0957
Binary files /dev/null and b/assets/sprites/neocat_64.png differ
diff --git a/assets/sprites/neofox_64.png b/assets/sprites/neofox_64.png
new file mode 100644
index 0000000..b00da05
Binary files /dev/null and b/assets/sprites/neofox_64.png differ
diff --git a/assets/tilesets/terrain.png b/assets/tilesets/terrain.png
new file mode 100644
index 0000000..d14db67
Binary files /dev/null and b/assets/tilesets/terrain.png differ
diff --git a/assets/ui/base.rcss b/assets/ui/base.rcss
new file mode 100644
index 0000000..b325039
--- /dev/null
+++ b/assets/ui/base.rcss
@@ -0,0 +1,88 @@
+/* Base style sheet based on: https://mikke89.github.io/RmlUiDoc/pages/rml/html4_style_sheet.html */
+
+body, div, h1, h2, h3, h4, h5, h6, p, hr, pre {
+ display: block;
+}
+
+h1 {
+ font-size: 2em;
+ margin: .67em 0;
+}
+
+h2 {
+ font-size: 1.5em;
+ margin: .75em 0;
+}
+
+h3 {
+ font-size: 1.17em;
+ margin: .83em 0;
+}
+
+h4 {
+ margin: 1.12em 0;
+}
+
+h5 {
+ font-size: .83em;
+ margin: 1.5em 0;
+}
+
+h6 {
+ font-size: .75em;
+ margin: 1.67em 0;
+}
+
+h1, h2, h3, h4, h5, h6, strong {
+ font-weight: bold;
+}
+
+em {
+ font-style: italic;
+}
+
+pre {
+ white-space: pre;
+}
+
+hr {
+ border-width: 1px;
+}
+
+table {
+ box-sizing: border-box;
+ display: table;
+}
+
+tr {
+ box-sizing: border-box;
+ display: table-row;
+}
+
+td {
+ box-sizing: border-box;
+ display: table-cell;
+}
+
+col {
+ box-sizing: border-box;
+ display: table-column;
+}
+
+colgroup {
+ display: table-column-group;
+}
+
+thead, tbody, tfoot {
+ display: table-row-group;
+}
+
+tabset tabs {
+ display: block;
+}
+
+input {
+ background-color: white;
+ color: black;
+ cursor: text;
+}
diff --git a/assets/ui/main_ui.rml b/assets/ui/main_ui.rml
new file mode 100644
index 0000000..9dc12f1
--- /dev/null
+++ b/assets/ui/main_ui.rml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Meow!
+
+
diff --git a/shuriken.yaml b/shuriken.yaml
index 8dd0a71..0a07b3f 100644
--- a/shuriken.yaml
+++ b/shuriken.yaml
@@ -1,10 +1,22 @@
defaults:
cpp_standard: c++23
- cpp_flags: -Wall -Wextra -Werror -pedantic
- linker_args: -lSDL3 -lSDL3_image
+ cpp_flags: >-
+ -Wall
+ -Wextra
+ -pedantic
+ -Ivendor/RmlUi/Include
+ linker_args: >-
+ -lSDL3
+ -lSDL3_image
+ -lfreetype
+ vendor/RmlUi/Build/librmlui_debugger.a
+ vendor/RmlUi/Build/librmlui.a
+
+enable_import_std: true
default_target: linux
targets:
linux:
output_file: rutile_game
+ # TODO: In a release build, set -DNDEBUG
diff --git a/src/main.cpp b/src/main.cpp
index cdfe69e..a6e0353 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,27 +1,30 @@
-import sdl_app;
+import rutile.app;
#define SDL_MAIN_USE_CALLBACKS
#include
-SDL_AppResult SDL_AppInit(void** appstate, int /*argc*/, char** /*argv*/)
+SDL_AppResult SDL_AppInit(void** appstate, const int /*argc*/, char** /*argv*/)
{
- *appstate = new AppState;
- return sdl_app_init(static_cast(*appstate));
-}
-
-SDL_AppResult SDL_AppIterate(void* appstate)
-{
- return sdl_app_iterate(static_cast(appstate));
+ auto* app = new rutile::App;
+ *appstate = app;
+ return static_cast(app->initialize());
}
SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
- return sdl_app_event(static_cast(appstate), event);
+ auto* app = static_cast(appstate);
+ return static_cast(app->handle_event(*event));
}
-void SDL_AppQuit(void* appstate, SDL_AppResult /*result*/)
+SDL_AppResult SDL_AppIterate(void* appstate)
{
- const auto* game_app_state = static_cast(appstate);
- sdl_app_shutdown(game_app_state);
- delete game_app_state;
+ auto* app = static_cast(appstate);
+ return static_cast(app->iterate());
+}
+
+void SDL_AppQuit(void* appstate, const SDL_AppResult /*result*/)
+{
+ const auto* app = static_cast(appstate);
+ app->shutdown();
+ delete app;
}
diff --git a/src/rutile/app.cppm b/src/rutile/app.cppm
new file mode 100644
index 0000000..37deff0
--- /dev/null
+++ b/src/rutile/app.cppm
@@ -0,0 +1,86 @@
+module;
+
+#include
+
+export module rutile.app;
+
+import std;
+import rutile.config;
+import rutile.core.engine;
+import rutile.game.game;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ class App
+ {
+ std::unique_ptr engine_{nullptr};
+ std::unique_ptr game_{nullptr};
+
+ public:
+ App() = default;
+
+ sdl::AppResult initialize()
+ {
+ try {
+ // Set SDL application metadata
+ sdl::set_app_metadata_property(SDL_PROP_APP_METADATA_NAME_STRING, config::app_name);
+ sdl::set_app_metadata_property(SDL_PROP_APP_METADATA_VERSION_STRING, config::app_version);
+ sdl::set_app_metadata_property(SDL_PROP_APP_METADATA_IDENTIFIER_STRING, config::app_identifier);
+ sdl::set_app_metadata_property(SDL_PROP_APP_METADATA_CREATOR_STRING, config::app_creator);
+ sdl::set_app_metadata_property(SDL_PROP_APP_METADATA_COPYRIGHT_STRING, config::app_copyright);
+ sdl::set_app_metadata_property(SDL_PROP_APP_METADATA_URL_STRING, config::app_url);
+ sdl::set_app_metadata_property(SDL_PROP_APP_METADATA_TYPE_STRING, "game");
+
+ // Initialize SDL subsystems
+ sdl::initialize_sdl(sdl::InitFlags::Video | sdl::InitFlags::Events);
+
+ // Create engine (includes window and renderer) and game state
+ engine_ = Engine::create();
+ game_ = Game::create(*engine_);
+ }
+ catch (const std::runtime_error& e) {
+ std::cerr << "Unhandled exception during initialization: " << e.what() << '\n';
+ return sdl::AppResult::Failure;
+ }
+
+ return sdl::AppResult::Continue;
+ }
+
+ sdl::AppResult handle_event(const sdl::Event& event)
+ {
+ try {
+ if (!engine_->handle_event(event)) {
+ game_->handle_event(event);
+ }
+ }
+ catch (const std::runtime_error& e) {
+ std::cerr << "Unhandled exception during event handling: " << e.what() << '\n';
+ return sdl::AppResult::Failure;
+ }
+
+ return engine_->keep_running() ? sdl::AppResult::Continue : sdl::AppResult::Success;
+ }
+
+ sdl::AppResult iterate()
+ {
+ try {
+ engine_->update();
+ game_->update();
+ game_->render();
+ }
+ catch (const std::runtime_error& e) {
+ std::cerr << "Unhandled exception during updating: " << e.what() << '\n';
+ return sdl::AppResult::Failure;
+ }
+
+ return engine_->keep_running() ? sdl::AppResult::Continue : sdl::AppResult::Success;
+ }
+
+ void shutdown() const
+ {
+ engine_->shutdown();
+ game_->shutdown();
+ }
+ };
+}
diff --git a/src/rutile/config.cppm b/src/rutile/config.cppm
new file mode 100644
index 0000000..2e36783
--- /dev/null
+++ b/src/rutile/config.cppm
@@ -0,0 +1,36 @@
+module;
+
+#ifdef NDEBUG
+#define DEBUG_BOOL false
+#else
+#define DEBUG_BOOL true
+#endif
+
+export module rutile.config;
+
+import std;
+
+export namespace rutile::config
+{
+ constexpr auto debug = DEBUG_BOOL;
+
+ constexpr auto app_name = "Rutile Game Prototype";
+ constexpr auto app_version = "0.0.1-dev";
+ constexpr auto app_identifier = "dev.binarydiv.rutile_game";
+ constexpr auto app_creator = "binaryDiv";
+ constexpr auto app_copyright = "Copyright (c) 2025 binaryDiv";
+ constexpr auto app_url = "https://git.0xbd.space/binaryDiv/rutile-game";
+
+ constexpr auto window_width = 640;
+ constexpr auto window_height = 480;
+
+ constexpr auto get_window_title()
+ {
+ if constexpr (debug) {
+ return std::format("{} ({}) [DEBUG]", app_name, app_version);
+ }
+ else {
+ return std::format("{} ({})", app_name, app_version);
+ }
+ }
+}
diff --git a/src/rutile/core/drawing/sprite.cppm b/src/rutile/core/drawing/sprite.cppm
new file mode 100644
index 0000000..1158bde
--- /dev/null
+++ b/src/rutile/core/drawing/sprite.cppm
@@ -0,0 +1,82 @@
+module;
+
+#include
+
+export module rutile.core.drawing.sprite;
+
+import std;
+import rutile.core.render_server;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ class Sprite
+ {
+ // Texture ID (managed by TextureManager in RenderServer)
+ TextureID texture_id_;
+
+ // Texture source boundaries: which part of the texture to render
+ sdl::FRect texture_boundaries_;
+
+ // Texture offset: static offset that is added to the render position (BEFORE scale is applied)
+ sdl::FPoint texture_offset_{0, 0};
+
+ // Texture scale: factors applied to width/height (AFTER offset is applied)
+ // TODO: Proper vector class?
+ sdl::FPoint texture_scale_{1, 1};
+
+ // Sprite position: where to render the sprite (world coordinates)
+ sdl::FPoint position_{0, 0};
+
+ public:
+ explicit Sprite(
+ const TextureID texture_id,
+ const sdl::FRect texture_boundaries,
+ const sdl::FPoint position,
+ const sdl::FPoint texture_offset = {0, 0},
+ const sdl::FPoint texture_scale = {1, 1}
+ )
+ : texture_id_{texture_id},
+ texture_boundaries_{texture_boundaries},
+ texture_offset_{texture_offset},
+ texture_scale_{texture_scale},
+ position_{position}
+ {
+ assert(texture_boundaries_.w > 0 && texture_boundaries_.h > 0);
+ }
+
+ static Sprite create_from_texture(
+ RenderServer& render_server,
+ const std::string& filename,
+ std::optional texture_boundaries,
+ auto... args
+ )
+ {
+ const TextureID texture_id = render_server.load_texture(filename);
+
+ if (!texture_boundaries.has_value()) {
+ const sdl::Texture& texture = render_server.get_texture(texture_id);
+ texture_boundaries = texture.get_boundaries();
+ }
+
+ return Sprite(texture_id, texture_boundaries.value(), args...);
+ }
+
+ void set_position(const sdl::FPoint new_position)
+ {
+ position_ = new_position;
+ }
+
+ void draw(const RenderServer& render_server) const
+ {
+ const sdl::FRect dest_rect{
+ position_.x + (texture_scale_.x * texture_offset_.x),
+ position_.y + (texture_scale_.y * texture_offset_.y),
+ texture_scale_.x * texture_boundaries_.w,
+ texture_scale_.y * texture_boundaries_.h,
+ };
+
+ render_server.render_texture(texture_id_, &texture_boundaries_, &dest_rect);
+ }
+ };
+}
diff --git a/src/rutile/core/drawing/tile_map.cppm b/src/rutile/core/drawing/tile_map.cppm
new file mode 100644
index 0000000..de66b2c
--- /dev/null
+++ b/src/rutile/core/drawing/tile_map.cppm
@@ -0,0 +1,108 @@
+module;
+
+#include
+
+export module rutile.core.drawing.tile_map;
+
+import std;
+import rutile.core.drawing.tile_set;
+import rutile.core.render_server;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ // Restrict map size to 16 bit per dimension, so that width*height still fits within size_t
+ using MapCoord = std::uint16_t;
+
+ class TileMap
+ {
+ // Tile set ID
+ // TODO: Needed later when we centralize tile set management using a ResourceManager
+ [[maybe_unused]]
+ TileSetID tile_set_id_;
+
+ // Tile size
+ int tile_size_;
+
+ // Map boundaries
+ MapCoord width_;
+ MapCoord height_;
+
+ // Tile map origin in world coordinates
+ sdl::FPoint position_{0, 0};
+
+ // Tile map data: Tiles in a continuous array
+ std::unique_ptr tiles_{nullptr};
+
+ public:
+ explicit TileMap(
+ const TileSetID tile_set_id,
+ const int tile_size,
+ const MapCoord width,
+ const MapCoord height,
+ const sdl::FPoint position = {0, 0}
+ )
+ : tile_set_id_(tile_set_id),
+ tile_size_(tile_size),
+ width_(width),
+ height_(height),
+ position_{position}
+ {
+ // TODO: This maybe shouldn't be an assert...
+ assert(width_ > 0 && height_ > 0);
+ tiles_ = std::make_unique(
+ static_cast(width * height)
+ );
+ }
+
+ constexpr std::size_t map_coords_to_index(const MapCoord x, const MapCoord y) const
+ {
+ // TODO: Properly handle x<0, y<0, x>=width, y>=height
+ assert(x < width_ && y < height_);
+ return y * width_ + x;
+ }
+
+ constexpr TileID get_tile(const MapCoord x, const MapCoord y) const
+ {
+ return tiles_[map_coords_to_index(x, y)];
+ }
+
+ constexpr void set_tile(const MapCoord x, const MapCoord y, const TileID tile_id)
+ {
+ tiles_[map_coords_to_index(x, y)] = tile_id;
+ }
+
+ // TODO: Should we get the TileSet from the RenderServer, similar to how we get textures?
+ void draw(const RenderServer& render_server, const TileSet& tile_set) const
+ {
+ // Get tile set texture
+ const sdl::Texture& tile_set_texture = render_server.get_texture(tile_set.get_texture_id());
+
+ // TODO: Implement more efficiently (we could just iterate over tiles_ directly)
+ for (MapCoord y = 0; y < height_; y++) {
+ for (MapCoord x = 0; x < width_; x++) {
+ const int tile_index = get_tile(x, y);
+
+ // Ignore tiles with ID 0
+ if (tile_index == 0) {
+ continue;
+ }
+
+ const auto [tile_atlas_src_rect] = tile_set.get_tile_definition(tile_index);
+ const sdl::FRect tile_dest_rect{
+ position_.x + x * tile_size_,
+ position_.y + y * tile_size_,
+ static_cast(tile_size_),
+ static_cast(tile_size_),
+ };
+
+ render_server.render_texture(
+ tile_set_texture,
+ &tile_atlas_src_rect,
+ &tile_dest_rect
+ );
+ }
+ }
+ }
+ };
+}
diff --git a/src/rutile/core/drawing/tile_set.cppm b/src/rutile/core/drawing/tile_set.cppm
new file mode 100644
index 0000000..ddad86c
--- /dev/null
+++ b/src/rutile/core/drawing/tile_set.cppm
@@ -0,0 +1,92 @@
+module;
+
+#include
+
+export module rutile.core.drawing.tile_set;
+
+import std;
+import rutile.core.render_server;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ using TileSetID = unsigned int;
+ using TileID = unsigned int;
+
+ struct TileDefinition
+ {
+ sdl::FRect atlas_src_rect;
+ };
+
+ class TileSet
+ {
+ // Texture ID of tileset atlas texture
+ TextureID texture_id_;
+
+ // Size of each tile in pixels
+ // TODO: Do we need this?
+ // int tile_size_;
+
+ // Tile definitions: Maps tile ID to a struct describing the tile, e.g. the texture source boundaries,
+ // collision data, etc.
+ std::vector tile_definitions_;
+
+ public:
+ TileSet() = delete;
+
+ explicit TileSet(
+ const TextureID texture_id,
+ std::vector tile_definitions
+ )
+ : texture_id_{texture_id},
+ tile_definitions_{std::move(tile_definitions)}
+ {}
+
+ static TileSet create_from_texture(
+ RenderServer& render_server,
+ const std::string& filename,
+ const int tile_size,
+ const std::vector& tile_atlas_coords
+ )
+ {
+ const TextureID texture_id = render_server.load_texture(filename);
+ const sdl::Texture& texture = render_server.get_texture(texture_id);
+
+ assert(tile_size > 0);
+ // TODO: These probably shouldn't be asserts...
+ assert(texture.get_width() % tile_size == 0);
+ assert(texture.get_height() % tile_size == 0);
+
+ std::vector tile_definitions;
+
+ // TODO: Should we automatically create tile 0 (no tile) instead of listing it in tile_atlas_coords?
+ for (const auto& [x, y] : tile_atlas_coords) {
+ const TileDefinition tile{
+ {
+ static_cast(x * tile_size),
+ static_cast(y * tile_size),
+ static_cast(tile_size),
+ static_cast(tile_size),
+ }
+ };
+
+ // TODO: These probably shouldn't be asserts...
+ assert(tile.atlas_src_rect.x + tile.atlas_src_rect.w <= texture.get_width());
+ assert(tile.atlas_src_rect.y + tile.atlas_src_rect.h <= texture.get_height());
+ tile_definitions.push_back(tile);
+ }
+
+ return TileSet(texture_id, tile_definitions);
+ }
+
+ constexpr TextureID get_texture_id() const
+ {
+ return texture_id_;
+ }
+
+ constexpr const TileDefinition& get_tile_definition(const TileID tile_id) const
+ {
+ return tile_definitions_.at(tile_id);
+ }
+ };
+}
diff --git a/src/rutile/core/engine.cppm b/src/rutile/core/engine.cppm
new file mode 100644
index 0000000..984f323
--- /dev/null
+++ b/src/rutile/core/engine.cppm
@@ -0,0 +1,118 @@
+module;
+
+#include
+
+export module rutile.core.engine;
+
+import std;
+import rutile.config;
+import rutile.core.render_server;
+import rutile.core.ui.ui_server;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ class Engine
+ {
+ // Whether this class is currently instantiated (to prevent multiple instances)
+ static bool instantiated_;
+
+ // If this is set to false, the application will exit
+ bool keep_running_ = true;
+
+ sdl::Window window_;
+ RenderServer render_server_;
+ UIServer ui_server_;
+
+ // Private constructor
+ Engine(sdl::Window&& window, sdl::Renderer&& renderer)
+ : window_{std::move(window)},
+ render_server_{std::move(renderer)},
+ ui_server_{render_server_.get_renderer(), window_}
+ {
+ instantiated_ = true;
+ }
+
+ public:
+ Engine() = delete;
+
+ // No copy or move operations
+ Engine(const Engine&) = delete;
+ Engine& operator=(const Engine&) = delete;
+ Engine(Engine&&) = delete;
+ Engine& operator=(Engine&&) = delete;
+
+ ~Engine()
+ {
+ instantiated_ = false;
+ }
+
+ static std::unique_ptr create()
+ {
+ // Prevent the class from being instantiated multiple times
+ assert(!instantiated_);
+
+ auto [sdl_window, sdl_renderer] = sdl::create_window_and_renderer(
+ config::get_window_title(),
+ config::window_width,
+ config::window_height,
+ 0
+ );
+
+ // NOLINTBEGIN: "Allocated memory is leaked"
+ return std::unique_ptr{
+ new Engine{
+ std::move(sdl_window),
+ std::move(sdl_renderer)
+ }
+ };
+ // NOLINTEND
+ }
+
+ bool keep_running() const
+ {
+ return keep_running_;
+ }
+
+ sdl::Window& get_window()
+ {
+ return window_;
+ }
+
+ RenderServer& get_render_server()
+ {
+ return render_server_;
+ }
+
+ UIServer& get_ui_server()
+ {
+ return ui_server_;
+ }
+
+ // Handles an SDL event. Returns true if the event has been handled.
+ bool handle_event(const sdl::Event& event)
+ {
+ if (event.type == sdl::EventTypes::Quit) {
+ // Exit the application
+ keep_running_ = false;
+ return true;
+ }
+
+ if (ui_server_.handle_event(event)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ void update()
+ {
+ }
+
+ void shutdown()
+ {
+ }
+ };
+
+ bool Engine::instantiated_ = false;
+}
diff --git a/src/rutile/core/render_server.cppm b/src/rutile/core/render_server.cppm
new file mode 100644
index 0000000..7366b13
--- /dev/null
+++ b/src/rutile/core/render_server.cppm
@@ -0,0 +1,89 @@
+module;
+
+export module rutile.core.render_server;
+
+import std;
+import rutile.core.resource_manager;
+import rutile.core.texture_loader;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ using TextureID = unsigned int;
+ using TextureManager = ResourceManager;
+
+ class RenderServer
+ {
+ sdl::Renderer renderer_;
+ TextureLoader texture_loader_;
+ TextureManager texture_manager_;
+
+ public:
+ RenderServer() = delete;
+
+ explicit RenderServer(sdl::Renderer&& renderer)
+ : renderer_{std::move(renderer)},
+ texture_loader_{renderer_},
+ texture_manager_{texture_loader_}
+ {
+ renderer_.set_vsync(1);
+ }
+
+ // No copy or move operations
+ RenderServer(const RenderServer&) = delete;
+ RenderServer& operator=(const RenderServer&) = delete;
+ RenderServer(RenderServer&&) = delete;
+ RenderServer& operator=(RenderServer&&) = delete;
+
+ ~RenderServer() = default;
+
+ constexpr sdl::Renderer& get_renderer()
+ {
+ return renderer_;
+ }
+
+ /**
+ * Load a texture from a file (if not loaded yet) and return its texture ID.
+ */
+ constexpr TextureID load_texture(const std::string& filename)
+ {
+ return texture_manager_.load_resource_by_name(filename);
+ }
+
+ /**
+ * Get a reference to a texture by its texture ID.
+ */
+ constexpr const sdl::Texture& get_texture(const TextureID texture_id) const
+ {
+ return texture_manager_.get_resource(texture_id);
+ }
+
+ void start_frame() const
+ {
+ renderer_.clear();
+ }
+
+ void finish_frame() const
+ {
+ renderer_.present();
+ }
+
+ void render_texture(
+ const TextureID texture_id,
+ const sdl::FRect* src_rect,
+ const sdl::FRect* dest_rect
+ ) const
+ {
+ render_texture(get_texture(texture_id), src_rect, dest_rect);
+ }
+
+ constexpr void render_texture(
+ const sdl::Texture& texture,
+ const sdl::FRect* src_rect,
+ const sdl::FRect* dest_rect
+ ) const
+ {
+ renderer_.render_texture(texture, src_rect, dest_rect);
+ }
+ };
+}
diff --git a/src/rutile/core/resource_manager.cppm b/src/rutile/core/resource_manager.cppm
new file mode 100644
index 0000000..b73f184
--- /dev/null
+++ b/src/rutile/core/resource_manager.cppm
@@ -0,0 +1,120 @@
+module;
+
+#include
+
+export module rutile.core.resource_manager;
+
+import std;
+
+export namespace rutile
+{
+ template
+ concept IsResourceLoader = requires(ResourceLoaderType loader, const std::string& name)
+ {
+ { loader.load_resource(name) } -> std::same_as;
+ };
+
+ template <
+ typename ResourceIDType,
+ typename ResourceType,
+ IsResourceLoader ResourceLoaderType
+ >
+ class ResourceManager
+ {
+ // Resource loader
+ ResourceLoaderType& resource_loader_;
+
+ // Registry of loaded resources, array index is resource ID
+ std::vector resource_registry_;
+
+ // Mapping of all loaded resources from (file) name to resource ID
+ std::map resource_name_map_;
+
+ public:
+ ResourceManager() = delete;
+
+ explicit ResourceManager(ResourceLoaderType& resource_loader)
+ : resource_loader_{resource_loader}
+ {}
+
+ // No copy or move operations
+ ResourceManager(const ResourceManager&) = delete;
+ ResourceManager& operator=(const ResourceManager&) = delete;
+ ResourceManager(ResourceManager&&) = delete;
+ ResourceManager& operator=(ResourceManager&&) = delete;
+
+ ~ResourceManager() = default;
+
+ /**
+ * Adds a new resource to the registry and returns its ID.
+ */
+ ResourceIDType add_resource(ResourceType&& resource)
+ {
+ // Add resource to end of vector
+ resource_registry_.push_back(std::move(resource));
+
+ // Return the index of the newly added resource
+ return static_cast(resource_registry_.size() - 1);
+ }
+
+ /**
+ * Adds a new resource to the registry, associates the name with it and returns its ID.
+ */
+ ResourceIDType add_resource(ResourceType&& resource, const std::string& name)
+ {
+ // Add resource
+ ResourceIDType resource_id = add_resource(std::move(resource));
+
+ // Associate name with resource ID for future access
+ resource_name_map_.emplace(name, resource_id);
+
+ return resource_id;
+ }
+
+ /**
+ * Gets the resource ID for a given resource name, or `std::nullopt` if the resource was not found.
+ */
+ std::optional get_resource_id_by_name(const std::string& name) const
+ {
+ // Check if resource is already loaded
+ if (
+ const auto search = resource_name_map_.find(name);
+ search != resource_name_map_.end()
+ ) {
+ // Return resource ID
+ return search->second;
+ }
+
+ return std::nullopt;
+ }
+
+ /**
+ * Loads a resource (e.g. from a file) and adds it to the registry if it isn't loaded yet.
+ * Returns the resource ID.
+ */
+ ResourceIDType load_resource_by_name(const std::string& name)
+ {
+ // Check if resource is already loaded
+ if (
+ auto resource_id = get_resource_id_by_name(name);
+ resource_id.has_value()
+ ) {
+ return resource_id.value();
+ }
+
+ // Load resource and add it to the registry
+ return add_resource(resource_loader_.load_resource(name), name);
+ }
+
+ /**
+ * Returns a reference to the resource with the given ID.
+ * The reference is not guaranteed to be valid after adding new resources to the registry.
+ * Assumes that the resource ID is valid.
+ */
+ const ResourceType& get_resource(const ResourceIDType resource_id) const
+ {
+ assert(resource_id < resource_registry_.size());
+ return resource_registry_[resource_id];
+ }
+ };
+}
diff --git a/src/rutile/core/texture_loader.cppm b/src/rutile/core/texture_loader.cppm
new file mode 100644
index 0000000..78cb3df
--- /dev/null
+++ b/src/rutile/core/texture_loader.cppm
@@ -0,0 +1,34 @@
+module;
+
+export module rutile.core.texture_loader;
+
+import std;
+import rutile.core.resource_manager;
+import wrappers.sdl;
+import wrappers.sdl_image;
+
+export namespace rutile
+{
+ class TextureLoader
+ {
+ sdl::Renderer& renderer_;
+
+ public:
+ explicit TextureLoader(sdl::Renderer& renderer)
+ : renderer_{renderer}
+ {}
+
+ // No copy or move operations (reference)
+ TextureLoader(const TextureLoader&) = delete;
+ TextureLoader& operator=(const TextureLoader&) = delete;
+ TextureLoader(TextureLoader&&) = delete;
+ TextureLoader& operator=(TextureLoader&&) = delete;
+
+ ~TextureLoader() = default;
+
+ sdl::Texture load_resource(const std::string& filename) const
+ {
+ return sdl_image::load_texture(renderer_, filename);
+ }
+ };
+}
diff --git a/src/rutile/core/ui/backend/rmlui_input_handler.cppm b/src/rutile/core/ui/backend/rmlui_input_handler.cppm
new file mode 100644
index 0000000..2dedff2
--- /dev/null
+++ b/src/rutile/core/ui/backend/rmlui_input_handler.cppm
@@ -0,0 +1,275 @@
+module;
+
+export module rutile.core.ui.backend.rmlui_input_handler;
+
+import wrappers.rmlui.core;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ class RmlUiInputHandler
+ {
+ sdl::Window& window_;
+
+ public:
+ explicit RmlUiInputHandler(sdl::Window& window)
+ : window_{window}
+ {}
+
+ static Rml::Input::KeyIdentifier convert_key(const int sdl_key)
+ {
+ namespace Key = sdl::Key;
+
+ // clang-format off
+ switch (sdl_key) {
+ case Key::Unknown: return Rml::Input::KI_UNKNOWN;
+ case Key::Escape: return Rml::Input::KI_ESCAPE;
+ case Key::Space: return Rml::Input::KI_SPACE;
+ case Key::Num0: return Rml::Input::KI_0;
+ case Key::Num1: return Rml::Input::KI_1;
+ case Key::Num2: return Rml::Input::KI_2;
+ case Key::Num3: return Rml::Input::KI_3;
+ case Key::Num4: return Rml::Input::KI_4;
+ case Key::Num5: return Rml::Input::KI_5;
+ case Key::Num6: return Rml::Input::KI_6;
+ case Key::Num7: return Rml::Input::KI_7;
+ case Key::Num8: return Rml::Input::KI_8;
+ case Key::Num9: return Rml::Input::KI_9;
+ case Key::A: return Rml::Input::KI_A;
+ case Key::B: return Rml::Input::KI_B;
+ case Key::C: return Rml::Input::KI_C;
+ case Key::D: return Rml::Input::KI_D;
+ case Key::E: return Rml::Input::KI_E;
+ case Key::F: return Rml::Input::KI_F;
+ case Key::G: return Rml::Input::KI_G;
+ case Key::H: return Rml::Input::KI_H;
+ case Key::I: return Rml::Input::KI_I;
+ case Key::J: return Rml::Input::KI_J;
+ case Key::K: return Rml::Input::KI_K;
+ case Key::L: return Rml::Input::KI_L;
+ case Key::M: return Rml::Input::KI_M;
+ case Key::N: return Rml::Input::KI_N;
+ case Key::O: return Rml::Input::KI_O;
+ case Key::P: return Rml::Input::KI_P;
+ case Key::Q: return Rml::Input::KI_Q;
+ case Key::R: return Rml::Input::KI_R;
+ case Key::S: return Rml::Input::KI_S;
+ case Key::T: return Rml::Input::KI_T;
+ case Key::U: return Rml::Input::KI_U;
+ case Key::V: return Rml::Input::KI_V;
+ case Key::W: return Rml::Input::KI_W;
+ case Key::X: return Rml::Input::KI_X;
+ case Key::Y: return Rml::Input::KI_Y;
+ case Key::Z: return Rml::Input::KI_Z;
+ case Key::Semicolon: return Rml::Input::KI_OEM_1;
+ case Key::Plus: return Rml::Input::KI_OEM_PLUS;
+ case Key::Comma: return Rml::Input::KI_OEM_COMMA;
+ case Key::Minus: return Rml::Input::KI_OEM_MINUS;
+ case Key::Period: return Rml::Input::KI_OEM_PERIOD;
+ case Key::Slash: return Rml::Input::KI_OEM_2;
+ case Key::Grave: return Rml::Input::KI_OEM_3;
+ case Key::LeftBracket: return Rml::Input::KI_OEM_4;
+ case Key::Backslash: return Rml::Input::KI_OEM_5;
+ case Key::RightBracket: return Rml::Input::KI_OEM_6;
+ case Key::DblApostrophe: return Rml::Input::KI_OEM_7;
+ case Key::Numpad0: return Rml::Input::KI_NUMPAD0;
+ case Key::Numpad1: return Rml::Input::KI_NUMPAD1;
+ case Key::Numpad2: return Rml::Input::KI_NUMPAD2;
+ case Key::Numpad3: return Rml::Input::KI_NUMPAD3;
+ case Key::Numpad4: return Rml::Input::KI_NUMPAD4;
+ case Key::Numpad5: return Rml::Input::KI_NUMPAD5;
+ case Key::Numpad6: return Rml::Input::KI_NUMPAD6;
+ case Key::Numpad7: return Rml::Input::KI_NUMPAD7;
+ case Key::Numpad8: return Rml::Input::KI_NUMPAD8;
+ case Key::Numpad9: return Rml::Input::KI_NUMPAD9;
+ case Key::NumpadEnter: return Rml::Input::KI_NUMPADENTER;
+ case Key::NumpadMultiply: return Rml::Input::KI_MULTIPLY;
+ case Key::NumpadPlus: return Rml::Input::KI_ADD;
+ case Key::NumpadMinus: return Rml::Input::KI_SUBTRACT;
+ case Key::NumpadPeriod: return Rml::Input::KI_DECIMAL;
+ case Key::NumpadDivide: return Rml::Input::KI_DIVIDE;
+ case Key::NumpadEquals: return Rml::Input::KI_OEM_NEC_EQUAL;
+ case Key::Backspace: return Rml::Input::KI_BACK;
+ case Key::Tab: return Rml::Input::KI_TAB;
+ case Key::Clear: return Rml::Input::KI_CLEAR;
+ case Key::Return: return Rml::Input::KI_RETURN;
+ case Key::Pause: return Rml::Input::KI_PAUSE;
+ case Key::CapsLock: return Rml::Input::KI_CAPITAL;
+ case Key::PageUp: return Rml::Input::KI_PRIOR;
+ case Key::PageDown: return Rml::Input::KI_NEXT;
+ case Key::End: return Rml::Input::KI_END;
+ case Key::Home: return Rml::Input::KI_HOME;
+ case Key::Left: return Rml::Input::KI_LEFT;
+ case Key::Up: return Rml::Input::KI_UP;
+ case Key::Right: return Rml::Input::KI_RIGHT;
+ case Key::Down: return Rml::Input::KI_DOWN;
+ case Key::Insert: return Rml::Input::KI_INSERT;
+ case Key::Delete: return Rml::Input::KI_DELETE;
+ case Key::Help: return Rml::Input::KI_HELP;
+ case Key::F1: return Rml::Input::KI_F1;
+ case Key::F2: return Rml::Input::KI_F2;
+ case Key::F3: return Rml::Input::KI_F3;
+ case Key::F4: return Rml::Input::KI_F4;
+ case Key::F5: return Rml::Input::KI_F5;
+ case Key::F6: return Rml::Input::KI_F6;
+ case Key::F7: return Rml::Input::KI_F7;
+ case Key::F8: return Rml::Input::KI_F8;
+ case Key::F9: return Rml::Input::KI_F9;
+ case Key::F10: return Rml::Input::KI_F10;
+ case Key::F11: return Rml::Input::KI_F11;
+ case Key::F12: return Rml::Input::KI_F12;
+ case Key::F13: return Rml::Input::KI_F13;
+ case Key::F14: return Rml::Input::KI_F14;
+ case Key::F15: return Rml::Input::KI_F15;
+ case Key::NumLockClear: return Rml::Input::KI_NUMLOCK;
+ case Key::ScrollLock: return Rml::Input::KI_SCROLL;
+ case Key::LShift: return Rml::Input::KI_LSHIFT;
+ case Key::RShift: return Rml::Input::KI_RSHIFT;
+ case Key::LCtrl: return Rml::Input::KI_LCONTROL;
+ case Key::RCtrl: return Rml::Input::KI_RCONTROL;
+ case Key::LAlt: return Rml::Input::KI_LMENU;
+ case Key::RAlt: return Rml::Input::KI_RMENU;
+ case Key::LGui: return Rml::Input::KI_LMETA;
+ case Key::RGui: return Rml::Input::KI_RMETA;
+ default: return Rml::Input::KI_UNKNOWN;
+ }
+ // clang-format on
+ }
+
+ static int convert_mouse_button(const sdl::MouseButtonIndex sdl_mouse_button)
+ {
+ switch (sdl_mouse_button) {
+ case sdl::MouseButtons::Left: return 0;
+ case sdl::MouseButtons::Right: return 1;
+ case sdl::MouseButtons::Middle: return 2;
+ default: return 3;
+ }
+ }
+
+ static int get_key_modifiers()
+ {
+ const sdl::KeyModState sdl_mods = sdl::get_key_mod_state();
+ int retval = 0;
+
+ if (sdl_mods & sdl::KeyMods::Ctrl) {
+ retval |= Rml::Input::KM_CTRL;
+ }
+ if (sdl_mods & sdl::KeyMods::Shift) {
+ retval |= Rml::Input::KM_SHIFT;
+ }
+ if (sdl_mods & sdl::KeyMods::Alt) {
+ retval |= Rml::Input::KM_ALT;
+ }
+ if (sdl_mods & sdl::KeyMods::NumLock) {
+ retval |= Rml::Input::KM_NUMLOCK;
+ }
+ if (sdl_mods & sdl::KeyMods::CapsLock) {
+ retval |= Rml::Input::KM_CAPSLOCK;
+ }
+
+ return retval;
+ }
+
+ static Rml::TouchList convert_touch_event(const Rml::Context& context, const sdl::Event& ev)
+ {
+ const Rml::Vector2f position = Rml::Vector2f{ev.tfinger.x, ev.tfinger.y}
+ * Rml::Vector2f{context.GetDimensions()};
+ return {Rml::Touch{static_cast(ev.tfinger.fingerID), position}};
+ }
+
+ /**
+ * Handle input events and forward them to RmlUi if relevant.
+ * Returns true if the event was consumed (inverting the return values of RmlUi, where true means the event
+ * was NOT consumed).
+ */
+ bool handle_input_event(Rml::Context& context, const sdl::Event& ev) const
+ {
+ // Return value of RmlUi event handlers, true if the event was NOT consumed.
+ // Result will be inverted at the end of this method to return true if the event WAS consumed.
+ bool result = true;
+
+ switch (ev.type) {
+ case sdl::EventTypes::MouseMotion: {
+ const float pixel_density = window_.get_pixel_density();
+ result = context.ProcessMouseMove(
+ static_cast(ev.motion.x * pixel_density),
+ static_cast(ev.motion.y * pixel_density),
+ get_key_modifiers()
+ );
+ break;
+ }
+
+ case sdl::EventTypes::MouseButtonDown:
+ result = context.ProcessMouseButtonDown(
+ convert_mouse_button(ev.button.button),
+ get_key_modifiers()
+ );
+ sdl::capture_mouse(true);
+ break;
+
+ case sdl::EventTypes::MouseButtonUp:
+ sdl::capture_mouse(false);
+ result = context.ProcessMouseButtonUp(
+ convert_mouse_button(ev.button.button), get_key_modifiers()
+ );
+ break;
+
+ case sdl::EventTypes::MouseWheel:
+ result = context.ProcessMouseWheel(
+ Rml::Vector2f{ev.wheel.x, -ev.wheel.y},
+ get_key_modifiers()
+ );
+ break;
+
+ case sdl::EventTypes::KeyDown:
+ result = context.ProcessKeyDown(convert_key(ev.key.key), get_key_modifiers());
+
+ if (ev.key.key == sdl::Key::Return || ev.key.key == sdl::Key::NumpadEnter) {
+ result &= context.ProcessTextInput('\n');
+ }
+ break;
+
+ case sdl::EventTypes::KeyUp:
+ result = context.ProcessKeyUp(convert_key(ev.key.key), get_key_modifiers());
+ break;
+
+ case sdl::EventTypes::TextInput:
+ result = context.ProcessTextInput(Rml::String(&ev.text.text[0]));
+ break;
+
+ case sdl::EventTypes::FingerDown:
+ result = context.ProcessTouchStart(convert_touch_event(context, ev), get_key_modifiers());
+ break;
+
+ case sdl::EventTypes::FingerMotion:
+ result = context.ProcessTouchMove(convert_touch_event(context, ev), get_key_modifiers());
+ break;
+
+ case sdl::EventTypes::FingerUp:
+ result = context.ProcessTouchEnd(convert_touch_event(context, ev), get_key_modifiers());
+ break;
+
+ case sdl::EventTypes::WindowMouseLeave:
+ context.ProcessMouseLeave();
+ break;
+
+ case sdl::EventTypes::WindowPixelSizeChanged:
+ context.SetDimensions(
+ Rml::Vector2i{ev.window.data1, ev.window.data2}
+ );
+ break;
+
+ case sdl::EventTypes::WindowDisplayScaleChanged:
+ context.SetDensityIndependentPixelRatio(window_.get_display_scale());
+ break;
+
+ default:
+ break;
+ }
+
+ // Invert result to be consistent with the event handling of this app. Return true if the event was
+ // consumed, i.e. no further event handling is necessary.
+ return !result;
+ }
+ };
+}
diff --git a/src/rutile/core/ui/backend/rmlui_render_interface.cppm b/src/rutile/core/ui/backend/rmlui_render_interface.cppm
new file mode 100644
index 0000000..28724c9
--- /dev/null
+++ b/src/rutile/core/ui/backend/rmlui_render_interface.cppm
@@ -0,0 +1,198 @@
+// RmlUi RenderInterface implementation for SDL using SDLrenderer.
+// Based on RmlUi example code: vendor/RmlUi/Backends/RmlUi_Renderer_SDL.{cpp,h}
+
+module;
+
+#include
+#include
+
+export module rutile.core.ui.backend.rmlui_render_interface;
+
+import std;
+import wrappers.rmlui.core;
+import wrappers.sdl;
+import wrappers.sdl_image;
+
+export namespace rutile
+{
+ class RmlUiRenderInterface final : public Rml::RenderInterface
+ {
+ struct GeometryView
+ {
+ Rml::Span vertices;
+ Rml::Span indices;
+ };
+
+ sdl::Renderer& renderer_;
+ SDL_BlendMode blend_mode_ = {};
+ sdl::Rect rect_scissor_ = {};
+ bool scissor_region_enabled_ = false;
+
+ public:
+ explicit RmlUiRenderInterface(sdl::Renderer& renderer)
+ : renderer_{renderer}
+ {
+ // RmlUi serves vertex colors and textures with premultiplied alpha, set the blend mode accordingly.
+ // Equivalent to glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA).
+ blend_mode_ = SDL_ComposeCustomBlendMode(
+ SDL_BLENDFACTOR_ONE,
+ SDL_BLENDFACTOR_ONE_MINUS_SRC_ALPHA,
+ SDL_BLENDOPERATION_ADD,
+ SDL_BLENDFACTOR_ONE,
+ SDL_BLENDFACTOR_ONE_MINUS_SRC_ALPHA,
+ SDL_BLENDOPERATION_ADD
+ );
+ }
+
+ /**
+ * Sets up OpenGL states for taking rendering commands from RmlUi.
+ */
+ void prepare_render() const
+ {
+ renderer_.set_draw_blend_mode(blend_mode_);
+ }
+
+ // -- Inherited from Rml::RenderInterface
+
+ Rml::CompiledGeometryHandle CompileGeometry(
+ const Rml::Span vertices,
+ const Rml::Span indices
+ ) override
+ {
+ // RmlUi handles lifetime and calls ReleaseGeometry to delete the object
+ GeometryView* data = new GeometryView{vertices, indices}; // NOLINT: "Allocated memory is leaked"
+ return reinterpret_cast(data);
+ }
+
+ void ReleaseGeometry(const Rml::CompiledGeometryHandle geometry) override
+ {
+ delete reinterpret_cast(geometry);
+ }
+
+ void RenderGeometry(
+ const Rml::CompiledGeometryHandle handle,
+ const Rml::Vector2f translation,
+ const Rml::TextureHandle texture
+ ) override
+ {
+ const GeometryView* geometry = reinterpret_cast(handle);
+ const Rml::Vertex* vertices = geometry->vertices.data();
+ const std::size_t num_vertices = geometry->vertices.size();
+ const int* indices = geometry->indices.data();
+ const std::size_t num_indices = geometry->indices.size();
+
+ // Convert RmlUi vertices to SDL vertices
+ auto sdl_vertices = std::make_unique(num_vertices);
+
+ for (size_t i = 0; i < num_vertices; i++) {
+ const auto& [position, color, tex_coord] = vertices[i];
+
+ sdl_vertices[i].position = {
+ position.x + translation.x,
+ position.y + translation.y,
+ };
+ sdl_vertices[i].tex_coord = {
+ tex_coord.x,
+ tex_coord.y,
+ };
+ sdl_vertices[i].color = {
+ color.red / 255.f,
+ color.green / 255.f,
+ color.blue / 255.f,
+ color.alpha / 255.f,
+ };
+ }
+
+ renderer_.render_geometry(
+ reinterpret_cast(texture),
+ sdl_vertices.get(),
+ static_cast(num_vertices),
+ indices,
+ static_cast(num_indices)
+ );
+ }
+
+ Rml::TextureHandle LoadTexture(
+ Rml::Vector2i& texture_dimensions,
+ const Rml::String& source
+ ) override
+ {
+ sdl::Surface surface = sdl_image::load(source.c_str());
+
+ if (surface.get_format() != SDL_PIXELFORMAT_RGBA32 && surface.get_format() != SDL_PIXELFORMAT_BGRA32) {
+ surface = surface.convert_format(SDL_PIXELFORMAT_RGBA32);
+ }
+
+ // Convert colors to premultiplied alpha, which is necessary for correct alpha compositing.
+ const std::size_t pixels_byte_size = surface.get_width() * surface.get_height() * 4;
+ Rml::byte* pixels = static_cast(surface.get_raw_pixels());
+
+ for (size_t i = 0; i < pixels_byte_size; i += 4) {
+ const Rml::byte alpha = pixels[i + 3];
+ for (size_t j = 0; j < 3; ++j) {
+ pixels[i + j] = static_cast(
+ static_cast(pixels[i + j]) * static_cast(alpha) / 255
+ );
+ }
+ }
+
+ SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer_.get_raw(), surface.get_raw());
+ if (texture) {
+ SDL_SetTextureBlendMode(texture, blend_mode_);
+ }
+
+ texture_dimensions = {surface.get_width(), surface.get_height()};
+ return reinterpret_cast(texture);
+ }
+
+ Rml::TextureHandle GenerateTexture(
+ const Rml::Span source,
+ const Rml::Vector2i source_dimensions
+ ) override
+ {
+ assert(
+ source.data() && source.size() == static_cast(source_dimensions.x * source_dimensions.y * 4)
+ );
+
+ const sdl::Surface surface = sdl::Surface::create_from(
+ source_dimensions.x,
+ source_dimensions.y,
+ SDL_PIXELFORMAT_RGBA32,
+ // Cast away the const because SDL_CreateSurfaceFrom() expects a void*.
+ // This is fine as long as we don't modify surface.get_raw_pixels().
+ const_cast(source.data()),
+ source_dimensions.x * 4
+ );
+
+ SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer_.get_raw(), surface.get_raw());
+ if (texture) {
+ SDL_SetTextureBlendMode(texture, blend_mode_);
+ }
+
+ return reinterpret_cast(texture);
+ }
+
+ void ReleaseTexture(const Rml::TextureHandle texture_handle) override
+ {
+ SDL_DestroyTexture(reinterpret_cast(texture_handle));
+ }
+
+ void EnableScissorRegion(const bool enable) override
+ {
+ renderer_.set_clip_rect(enable ? &rect_scissor_ : nullptr);
+ scissor_region_enabled_ = enable;
+ }
+
+ void SetScissorRegion(const Rml::Rectanglei region) override
+ {
+ rect_scissor_.x = region.Left();
+ rect_scissor_.y = region.Top();
+ rect_scissor_.w = region.Width();
+ rect_scissor_.h = region.Height();
+
+ if (scissor_region_enabled_) {
+ renderer_.set_clip_rect(&rect_scissor_);
+ }
+ }
+ };
+}
diff --git a/src/rutile/core/ui/backend/rmlui_system_interface.cppm b/src/rutile/core/ui/backend/rmlui_system_interface.cppm
new file mode 100644
index 0000000..3f2ac72
--- /dev/null
+++ b/src/rutile/core/ui/backend/rmlui_system_interface.cppm
@@ -0,0 +1,117 @@
+// RmlUi SystemInterface implementation for SDL.
+// Based on RmlUi example code: vendor/RmlUi/Backends/RmlUi_Platform_SDL.{cpp,h}
+
+module;
+
+#include
+
+export module rutile.core.ui.backend.rmlui_system_interface;
+
+import std;
+import utils.memory;
+import wrappers.rmlui.core;
+import wrappers.sdl;
+
+// Shorthand for a unique pointer to an SDL_Cursor
+// TODO: Build actual wrapper sdl::Cursor at some point?
+using SDL_CursorPtr = std::unique_ptr>;
+
+export namespace rutile
+{
+ class RmlUiSystemInterface final : public Rml::SystemInterface
+ {
+ sdl::Window& window_;
+
+ SDL_CursorPtr cursor_default;
+ SDL_CursorPtr cursor_move;
+ SDL_CursorPtr cursor_pointer;
+ SDL_CursorPtr cursor_resize;
+ SDL_CursorPtr cursor_cross;
+ SDL_CursorPtr cursor_text;
+ SDL_CursorPtr cursor_unavailable;
+
+ public:
+ explicit RmlUiSystemInterface(sdl::Window& window)
+ : window_{window}
+ {
+ cursor_default = SDL_CursorPtr{SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_DEFAULT)};
+ cursor_move = SDL_CursorPtr{SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_MOVE)};
+ cursor_pointer = SDL_CursorPtr{SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_POINTER)};
+ cursor_resize = SDL_CursorPtr{SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_NWSE_RESIZE)};
+ cursor_cross = SDL_CursorPtr{SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_CROSSHAIR)};
+ cursor_text = SDL_CursorPtr{SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_TEXT)};
+ cursor_unavailable = SDL_CursorPtr{SDL_CreateSystemCursor(SDL_SYSTEM_CURSOR_NOT_ALLOWED)};
+ }
+
+ // -- Inherited from Rml::SystemInterface
+
+ double GetElapsedTime() override
+ {
+ // TODO: Add a shared timer to the engine and use that here.
+ static const Uint64 start = SDL_GetPerformanceCounter();
+ static const double frequency = static_cast(SDL_GetPerformanceFrequency());
+ return static_cast(SDL_GetPerformanceCounter() - start) / frequency;
+ }
+
+ void SetMouseCursor(const Rml::String& cursor_name) override
+ {
+ SDL_Cursor* cursor = nullptr;
+
+ if (cursor_name.empty() || cursor_name == "arrow") {
+ cursor = cursor_default.get();
+ }
+ else if (cursor_name == "move") {
+ cursor = cursor_move.get();
+ }
+ else if (cursor_name == "pointer") {
+ cursor = cursor_pointer.get();
+ }
+ else if (cursor_name == "resize") {
+ cursor = cursor_resize.get();
+ }
+ else if (cursor_name == "cross") {
+ cursor = cursor_cross.get();
+ }
+ else if (cursor_name == "text") {
+ cursor = cursor_text.get();
+ }
+ else if (cursor_name == "unavailable") {
+ cursor = cursor_unavailable.get();
+ }
+ else if (Rml::StringUtilities::StartsWith(cursor_name, "rmlui-scroll")) {
+ cursor = cursor_move.get();
+ }
+
+ if (cursor) {
+ SDL_SetCursor(cursor);
+ }
+ }
+
+ void SetClipboardText(const Rml::String& text) override
+ {
+ sdl::set_clipboard_text(text);
+ }
+
+ void GetClipboardText(Rml::String& text) override
+ {
+ text = sdl::get_clipboard_text();
+ }
+
+ void ActivateKeyboard(const Rml::Vector2f caret_position, const float line_height) override
+ {
+ const sdl::Rect rect{
+ static_cast(caret_position.x),
+ static_cast(caret_position.y),
+ 1,
+ static_cast(line_height),
+ };
+ window_.set_text_input_area(&rect, 0);
+ window_.start_text_input();
+ }
+
+ void DeactivateKeyboard() override
+ {
+ window_.stop_text_input();
+ }
+ };
+}
diff --git a/src/rutile/core/ui/ui_server.cppm b/src/rutile/core/ui/ui_server.cppm
new file mode 100644
index 0000000..3e5d3ab
--- /dev/null
+++ b/src/rutile/core/ui/ui_server.cppm
@@ -0,0 +1,124 @@
+module;
+
+#include
+
+export module rutile.core.ui.ui_server;
+
+import std;
+import rutile.core.ui.backend.rmlui_render_interface;
+import rutile.core.ui.backend.rmlui_system_interface;
+import rutile.core.ui.backend.rmlui_input_handler;
+import wrappers.rmlui.core;
+import wrappers.rmlui.debugger;
+import wrappers.sdl;
+
+export namespace rutile
+{
+ class UIServer
+ {
+ // Whether this class is currently instantiated (to prevent multiple instances because we need to do
+ // Rml::Initialise() and Rml::Shutdown()).
+ static bool instantiated_;
+
+ RmlUiSystemInterface system_interface_;
+ RmlUiRenderInterface render_interface_;
+ RmlUiInputHandler input_handler_;
+
+ // RmlUi context (non-owning pointer, lifetime managed by library)
+ Rml::Context* context_;
+
+ public:
+ explicit UIServer(sdl::Renderer& renderer, sdl::Window& window)
+ : system_interface_{window},
+ render_interface_{renderer},
+ input_handler_{window}
+ {
+ // Prevent the class from being instantiated multiple times
+ assert(!instantiated_);
+
+ Rml::SetSystemInterface(&system_interface_);
+ Rml::SetRenderInterface(&render_interface_);
+ Rml::Initialise();
+
+ // Create the main RmlUi context
+ auto [window_width, window_height] = window.get_size();
+ context_ = Rml::CreateContext("main", Rml::Vector2i(window_width, window_height));
+ if (!context_) {
+ throw std::runtime_error("RmlUi: Couldn't create context");
+ }
+
+ // TODO: Implement some kind of switch to disable debugger (just use config::debug?)
+ Rml::Debugger::Initialise(context_);
+
+ instantiated_ = true;
+ }
+
+ // No copy or move operations
+ UIServer(const UIServer&) = delete;
+ UIServer& operator=(const UIServer&) = delete;
+ UIServer(UIServer&&) = delete;
+ UIServer& operator=(UIServer&&) = delete;
+
+ ~UIServer()
+ {
+ Rml::Shutdown();
+ instantiated_ = false;
+ }
+
+ constexpr Rml::Context& get_context() const
+ {
+ // TODO: Can we assume context_ is always non-nullptr?
+ assert(context_);
+ return *context_;
+ }
+
+ // Handles an SDL event. Returns true if the event has been handled.
+ bool handle_event(const sdl::Event& event) const
+ {
+ // Toggle RmlUi debugger with F12
+ if (event.type == sdl::EventTypes::KeyDown && event.key.key == sdl::Key::F12) {
+ Rml::Debugger::SetVisible(!Rml::Debugger::IsVisible());
+ return true;
+ }
+
+ // Let RmlUi handle relevant events
+ if (input_handler_.handle_input_event(get_context(), event)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ void update() const
+ {
+ get_context().Update();
+ }
+
+ void render() const
+ {
+ render_interface_.prepare_render();
+ get_context().Render();
+ }
+
+ static void load_font_face(const std::string& file_path)
+ {
+ if (!Rml::LoadFontFace(file_path)) {
+ throw std::runtime_error(std::format("RmlUi: Couldn't load font face \"{}\"", file_path));
+ }
+ }
+
+ // TODO: Can/should we avoid the (non-owning) raw pointers here?
+ Rml::ElementDocument* load_document(const std::string& file_path) const
+ {
+ Rml::ElementDocument* document = get_context().LoadDocument(file_path);
+
+ if (!document) {
+ throw std::runtime_error(std::format("RmlUi: Couldn't load document \"{}\"", file_path));
+ }
+
+ return document;
+ }
+ };
+
+ bool UIServer::instantiated_ = false;
+}
diff --git a/src/rutile/game/game.cppm b/src/rutile/game/game.cppm
new file mode 100644
index 0000000..9f8ca08
--- /dev/null
+++ b/src/rutile/game/game.cppm
@@ -0,0 +1,161 @@
+module;
+
+#include
+
+export module rutile.game.game;
+
+import std;
+import rutile.core.drawing.sprite;
+import rutile.core.drawing.tile_map;
+import rutile.core.drawing.tile_set;
+import rutile.core.engine;
+import rutile.core.render_server;
+import wrappers.rmlui.core;
+import wrappers.sdl;
+import wrappers.sdl_image;
+
+export namespace rutile
+{
+ class Game
+ {
+ // Whether this class is currently instantiated (to prevent multiple instances)
+ static bool instantiated_;
+
+ // Reference to the engine
+ Engine& engine_;
+
+ // Sprites for testing
+ Sprite player_sprite_;
+ std::vector sprites_;
+
+ // Tile set and tile map (TODO: tile set should be moved to resource manager in engine)
+ TileSet tile_set_;
+ TileMap tile_map_;
+
+ // Private constructor
+ explicit Game(Engine& engine)
+ : engine_(engine),
+ player_sprite_{
+ Sprite::create_from_texture(
+ engine_.get_render_server(),
+ "assets/sprites/neocat_64.png",
+ std::nullopt,
+ sdl::FPoint{0, 0},
+ sdl::FPoint{-32, -32}
+ )
+ },
+ tile_set_{
+ TileSet::create_from_texture(
+ engine_.get_render_server(),
+ "assets/tilesets/terrain.png",
+ 32,
+ {
+ // Tile 0: No tile
+ {0, 0},
+ // Tile 1: Actual first tile in the texture
+ {0, 0},
+ {1, 0},
+ {2, 0},
+ {3, 0},
+ }
+ )
+ },
+ tile_map_(0, 32, 16, 12, {50, 50})
+ {
+ // Initialize UI
+ const auto& ui_server = engine_.get_ui_server();
+ ui_server.load_font_face("assets/fonts/LatoLatin-Regular.ttf");
+ Rml::ElementDocument* document = ui_server.load_document("assets/ui/main_ui.rml");
+ document->Show();
+
+ // Initialize tile map with example data
+ for (auto x = 0; x < 10; x++) {
+ for (auto y = 0; y < 10; y++) {
+ const auto tile_index = 2 + (x + y) % 2;
+ tile_map_.set_tile(x, y, tile_index);
+ }
+ }
+
+ instantiated_ = true;
+ }
+
+ public:
+ Game() = delete;
+
+ // No copy or move operations because we have a reference to the engine
+ Game(const Game&) = delete;
+ Game& operator=(const Game&) = delete;
+ Game(Game&&) = delete;
+ Game& operator=(Game&&) = delete;
+
+ ~Game()
+ {
+ instantiated_ = false;
+ }
+
+ static std::unique_ptr create(Engine& engine)
+ {
+ // Prevent the class from being instantiated multiple times
+ assert(!instantiated_);
+ return std::unique_ptr{
+ new Game(engine)
+ };
+ }
+
+ // Handles an SDL event. Returns true if the event has been handled.
+ bool handle_event(const sdl::Event& event)
+ {
+ if (event.type == sdl::EventTypes::MouseMotion) {
+ player_sprite_.set_position({event.motion.x, event.motion.y});
+ return true;
+ }
+
+ if (event.type == sdl::EventTypes::MouseButtonUp) {
+ sprites_.push_back(
+ Sprite::create_from_texture(
+ engine_.get_render_server(),
+ "assets/sprites/neofox_64.png",
+ std::nullopt,
+ sdl::FPoint{event.motion.x, event.motion.y},
+ sdl::FPoint{-32, -32}
+ )
+ );
+ return true;
+ }
+
+ return false;
+ }
+
+ void update()
+ {
+ // TODO: Move this to Engine
+ engine_.get_ui_server().update();
+ }
+
+ void render() const
+ {
+ const auto& render_server = engine_.get_render_server();
+ render_server.start_frame();
+
+ // Render tilemap
+ tile_map_.draw(render_server, tile_set_);
+
+ // Render sprites
+ for (const Sprite& sprite : sprites_) {
+ sprite.draw(render_server);
+ }
+ player_sprite_.draw(render_server);
+
+ // TODO: Move this to Engine
+ engine_.get_ui_server().render();
+
+ render_server.finish_frame();
+ }
+
+ void shutdown()
+ {
+ }
+ };
+
+ bool Game::instantiated_ = false;
+}
diff --git a/src/sdl_app.cppm b/src/sdl_app.cppm
deleted file mode 100644
index 7e14111..0000000
--- a/src/sdl_app.cppm
+++ /dev/null
@@ -1,95 +0,0 @@
-module;
-
-#include
-#include
-#include
-
-export module sdl_app;
-
-import sprite;
-
-export {
- struct AppState
- {
- SDL_Window* window = nullptr;
- SDL_Renderer* renderer = nullptr;
- Sprite* sprite = nullptr;
- };
-
- SDL_AppResult sdl_panic(
- const std::string& error_prefix,
- SDL_Window* window = nullptr,
- SDL_Renderer* renderer = nullptr
- )
- {
- std::cerr << error_prefix << ": " << SDL_GetError() << '\n';
-
- if (renderer != nullptr) {
- SDL_DestroyRenderer(renderer);
- }
- if (window != nullptr) {
- SDL_DestroyWindow(window);
- }
-
- SDL_Quit();
- return SDL_APP_FAILURE;
- }
-
- SDL_AppResult sdl_app_init(AppState* app_state)
- {
- if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) {
- return sdl_panic("SDL_Init error");
- }
-
- app_state->window = SDL_CreateWindow("BuildSystemTest", 640, 480, 0);
- if (app_state->window == nullptr) {
- return sdl_panic("SDL_CreateWindow error");
- }
-
- app_state->renderer = SDL_CreateRenderer(app_state->window, nullptr);
- if (app_state->renderer == nullptr) {
- return sdl_panic("SDL_CreateRenderer error", app_state->window);
- }
-
- try {
- app_state->sprite = new Sprite(app_state->renderer, "assets/neocat.png", 100, 100);
- }
- catch (const std::runtime_error& e) {
- return sdl_panic(e.what(), app_state->window, app_state->renderer);
- }
-
- return SDL_APP_CONTINUE;
- }
-
- SDL_AppResult sdl_app_event(const AppState* app_state, const SDL_Event* event)
- {
- if (event->type == SDL_EVENT_QUIT) {
- return SDL_APP_SUCCESS;
- }
-
- if (event->type == SDL_EVENT_MOUSE_MOTION) {
- app_state->sprite->move(
- event->motion.x - 50,
- event->motion.y - 50
- );
- }
-
- return SDL_APP_CONTINUE;
- }
-
- SDL_AppResult sdl_app_iterate(const AppState* app_state)
- {
- SDL_RenderClear(app_state->renderer);
- app_state->sprite->render(app_state->renderer);
- SDL_RenderPresent(app_state->renderer);
-
- return SDL_APP_CONTINUE;
- }
-
- void sdl_app_shutdown(const AppState* app_state)
- {
- delete app_state->sprite;
- SDL_DestroyRenderer(app_state->renderer);
- SDL_DestroyWindow(app_state->window);
- }
-}
diff --git a/src/sprite.cppm b/src/sprite.cppm
deleted file mode 100644
index 3318180..0000000
--- a/src/sprite.cppm
+++ /dev/null
@@ -1,81 +0,0 @@
-module;
-
-#include
-#include
-#include
-#include
-
-export module sprite;
-
-export class Sprite
-{
- SDL_Texture* sdl_texture;
- SDL_FRect dest_rect{0, 0, 0, 0};
-
- public:
- explicit Sprite(
- SDL_Renderer* renderer,
- const std::string& filename,
- const int width,
- const int height
- )
- {
- SDL_Surface* texture_surface = IMG_Load(filename.c_str());
- if (texture_surface == nullptr) {
- throw std::runtime_error("IMG_Load error");
- }
-
- sdl_texture = SDL_CreateTextureFromSurface(renderer, texture_surface);
- SDL_DestroySurface(texture_surface);
-
- if (sdl_texture == nullptr) {
- throw std::runtime_error("SDL_CreateTextureFromSurface error");
- }
-
- dest_rect.w = static_cast(width);
- dest_rect.h = static_cast(height);
- }
-
- // Don't allow copy operations
- Sprite(const Sprite&) = delete;
- Sprite& operator=(const Sprite&) = delete;
-
- // Move constructor
- Sprite(Sprite&& other) noexcept
- : sdl_texture(other.sdl_texture),
- dest_rect(other.dest_rect)
- {
- other.sdl_texture = nullptr;
- }
-
- // Move assignment
- Sprite& operator=(Sprite&& other) noexcept
- {
- // Move inner resources from other
- sdl_texture = other.sdl_texture;
- dest_rect = other.dest_rect;
-
- // Reset other to make it safe for deletion
- other.sdl_texture = nullptr;
-
- return *this;
- }
-
- ~Sprite()
- {
- if (sdl_texture != nullptr) {
- SDL_DestroyTexture(sdl_texture);
- }
- }
-
- void move(const float x, const float y)
- {
- dest_rect.x = x;
- dest_rect.y = y;
- }
-
- void render(SDL_Renderer* renderer) const
- {
- SDL_RenderTexture(renderer, sdl_texture, nullptr, &dest_rect);
- }
-};
diff --git a/src/utils/memory.cppm b/src/utils/memory.cppm
new file mode 100644
index 0000000..d1e16ad
--- /dev/null
+++ b/src/utils/memory.cppm
@@ -0,0 +1,21 @@
+module;
+
+export module utils.memory;
+
+export namespace utils
+{
+ /**
+ * Template to generate deleters for `std::unique_ptr` from functions, e.g. to free SDL resources.
+ *
+ * @tparam delete_func Function that takes a pointer to a resource and deletes the resource.
+ */
+ template
+ struct FuncDeleter
+ {
+ template
+ constexpr void operator()(T* ptr) const noexcept
+ {
+ delete_func(ptr);
+ }
+ };
+}
diff --git a/src/wrappers/rmlui/core.cppm b/src/wrappers/rmlui/core.cppm
new file mode 100644
index 0000000..fa209d4
--- /dev/null
+++ b/src/wrappers/rmlui/core.cppm
@@ -0,0 +1,530 @@
+module;
+
+#include
+
+export module wrappers.rmlui.core;
+
+export
+{
+ namespace Rml
+ {
+ using Rml::Animation;
+ using Rml::AnimationList;
+ using Rml::Any;
+ using Rml::Array;
+ using Rml::ArrayDefinition;
+ using Rml::Assert;
+ using Rml::AttributeNameList;
+ using Rml::BasePointerDefinition;
+ using Rml::BaseXMLParser;
+ using Rml::BlendMode;
+ using Rml::Box;
+ using Rml::BoxArea;
+ using Rml::BoxDirection;
+ using Rml::BoxEdge;
+ using Rml::BoxShadow;
+ using Rml::BoxShadowList;
+ using Rml::CallbackTexture;
+ using Rml::CallbackTextureFunction;
+ using Rml::CallbackTextureInterface;
+ using Rml::CallbackTextureSource;
+ using Rml::Character;
+ using Rml::ClipMaskGeometry;
+ using Rml::ClipMaskGeometryList;
+ using Rml::ClipMaskOperation;
+ using Rml::ColorFormat;
+ using Rml::ColorStop;
+ using Rml::ColorStopList;
+ using Rml::Colour;
+ using Rml::Colourb;
+ using Rml::ColourbPremultiplied;
+ using Rml::Colourf;
+ using Rml::ColumnMajorMatrix4f;
+ using Rml::ColumnMajorStorage;
+ using Rml::CompiledFilter;
+ using Rml::CompiledFilterHandle;
+ using Rml::CompiledGeometryHandle;
+ using Rml::CompiledShader;
+ using Rml::CompiledShaderHandle;
+ using Rml::ComputedValues;
+ using Rml::ContainerBox;
+ using Rml::Context;
+ using Rml::ContextInstancer;
+ using Rml::ContextPtr;
+ using Rml::ConvolutionFilter;
+ using Rml::CornerSizes;
+ using Rml::CreateContext;
+ using Rml::CreateString;
+ using Rml::DataAddress;
+ using Rml::DataAddressEntry;
+ using Rml::DataController;
+ using Rml::DataControllerInstancer;
+ using Rml::DataControllerPtr;
+ using Rml::DataEventFunc;
+ using Rml::DataGetFunc;
+ using Rml::DataModel;
+ using Rml::DataModelConstructor;
+ using Rml::DataModelHandle;
+ using Rml::DataSetFunc;
+ using Rml::DataTransformFunc;
+ using Rml::DataTypeGetFunc;
+ using Rml::DataTypeRegister;
+ using Rml::DataTypeSetFunc;
+ using Rml::DataVariable;
+ using Rml::DataVariableType;
+ using Rml::DataView;
+ using Rml::DataViewInstancer;
+ using Rml::DataViewPtr;
+ using Rml::Decorator;
+ using Rml::DecoratorDataHandle;
+ using Rml::DecoratorDeclaration;
+ using Rml::DecoratorDeclarationList;
+ using Rml::DecoratorInstancer;
+ using Rml::DecoratorInstancerInterface;
+ using Rml::DecoratorPtrList;
+ using Rml::DecoratorsPtr;
+ using Rml::DefaultActionPhase;
+ using Rml::DefaultStyleSheetParsers;
+ using Rml::Dictionary;
+ using Rml::DirtyVariables;
+ using Rml::DocumentHeader;
+ using Rml::EdgeSizes;
+ using Rml::EffectSpecification;
+ using Rml::Element;
+ using Rml::ElementAnimation;
+ using Rml::ElementAnimationList;
+ using Rml::ElementAttributes;
+ using Rml::ElementBackgroundBorder;
+ using Rml::ElementDefinition;
+ using Rml::ElementDocument;
+ using Rml::ElementForm;
+ using Rml::ElementFormControl;
+ using Rml::ElementFormControlInput;
+ using Rml::ElementFormControlSelect;
+ using Rml::ElementFormControlTextArea;
+ using Rml::ElementInstancer;
+ using Rml::ElementInstancerElement;
+ using Rml::ElementInstancerGeneric;
+ using Rml::ElementInstancerText;
+ using Rml::ElementList;
+ using Rml::ElementMeta;
+ using Rml::ElementProgress;
+ using Rml::ElementPtr;
+ using Rml::ElementScroll;
+ using Rml::ElementStyle;
+ using Rml::ElementTabSet;
+ using Rml::ElementText;
+ using Rml::ElementUtilities;
+ using Rml::EnableObserverPtr;
+ using Rml::Event;
+ using Rml::EventDispatcher;
+ using Rml::EventId;
+ using Rml::EventInstancer;
+ using Rml::EventListener;
+ using Rml::EventListenerInstancer;
+ using Rml::EventPhase;
+ using Rml::EventPtr;
+ using Rml::EventSpecification;
+ using Rml::Factory;
+ using Rml::Family;
+ using Rml::FamilyBase;
+ using Rml::FamilyId;
+ using Rml::FileHandle;
+ using Rml::FileInterface;
+ using Rml::Filter;
+ using Rml::FilterDeclaration;
+ using Rml::FilterDeclarationList;
+ using Rml::FilterHandleList;
+ using Rml::FilterInstancer;
+ using Rml::FilterOperation;
+ using Rml::FiltersPtr;
+ using Rml::FocusFlag;
+ using Rml::FontEffect;
+ using Rml::FontEffectInstancer;
+ using Rml::FontEffectList;
+ using Rml::FontEffects;
+ using Rml::FontEffectsHandle;
+ using Rml::FontEffectsPtr;
+ using Rml::FontEngineInterface;
+ using Rml::FontFaceHandle;
+ using Rml::FontGlyph;
+ using Rml::FontGlyphMap;
+ using Rml::FontMetrics;
+ using Rml::FormatString;
+ using Rml::FromString;
+ using Rml::FuncDefinition;
+ using Rml::Function;
+ using Rml::Geometry;
+ using Rml::Get;
+ using Rml::GetContext;
+ using Rml::GetFileInterface;
+ using Rml::GetFontEngineInterface;
+ using Rml::GetIf;
+ using Rml::GetNumContexts;
+ using Rml::GetRenderInterface;
+ using Rml::GetSystemInterface;
+ using Rml::GetTextInputHandler;
+ using Rml::GetTextureSourceList;
+ using Rml::GetVersion;
+ using Rml::Hash;
+ using Rml::Initialise;
+ using Rml::InlineLevelBox;
+ using Rml::InputType;
+ using Rml::IsVoidMemberFunc;
+ using Rml::KeyframeBlock;
+ using Rml::Keyframes;
+ using Rml::KeyframesMap;
+ using Rml::LayerHandle;
+ using Rml::LayoutEngine;
+ using Rml::List;
+ using Rml::LoadFontFace;
+ using Rml::Log;
+ using Rml::MakeLiteralIntVariable;
+ using Rml::MakeMoveIterator;
+ using Rml::MakeShared;
+ using Rml::MakeUnique;
+ using Rml::Matrix4;
+ using Rml::Matrix4f;
+ using Rml::MatrixStorageBase;
+ using Rml::MediaBlock;
+ using Rml::MediaBlockList;
+ using Rml::MediaQueryId;
+ using Rml::MediaQueryModifier;
+ using Rml::MemberGetFunc;
+ using Rml::MemberGetFuncDefinition;
+ using Rml::MemberGetterFunc;
+ using Rml::MemberObjectDefinition;
+ using Rml::MemberScalarGetSetFuncDefinition;
+ using Rml::MemberSetFunc;
+ using Rml::MemberSetterFunc;
+ using Rml::Mesh;
+ using Rml::MeshUtilities;
+ using Rml::ModalFlag;
+ using Rml::NamedDecorator;
+ using Rml::NamedDecoratorMap;
+ using Rml::NavigationSearchDirection;
+ using Rml::NonCopyMoveable;
+ using Rml::NumericValue;
+ using Rml::ObserverPtr;
+ using Rml::OwnedElementList;
+ using Rml::Pair;
+ using Rml::ParameterMap;
+ using Rml::Plugin;
+ using Rml::PointerDefinition;
+ using Rml::PointerTraits;
+ using Rml::PropertiesIterator;
+ using Rml::PropertiesIteratorView;
+ using Rml::Property;
+ using Rml::PropertyDefinition;
+ using Rml::PropertyDictionary;
+ using Rml::PropertyId;
+ using Rml::PropertyIdNameMap;
+ using Rml::PropertyIdSet;
+ using Rml::PropertyIdSetIterator;
+ using Rml::PropertyMap;
+ using Rml::PropertyParser;
+ using Rml::PropertySource;
+ using Rml::PropertySpecification;
+ using Rml::Queue;
+ using Rml::Rectangle;
+ using Rml::Rectanglef;
+ using Rml::Rectanglei;
+ using Rml::RegisterEventType;
+ using Rml::RegisterPlugin;
+ using Rml::RelativeTarget;
+ using Rml::Releasable;
+ using Rml::ReleaseCompiledGeometry;
+ using Rml::ReleaseFontResources;
+ using Rml::ReleaseRenderManagers;
+ using Rml::ReleaseTexture;
+ using Rml::ReleaseTextures;
+ using Rml::Releaser;
+ using Rml::ReleaserBase;
+ using Rml::RemoveContext;
+ using Rml::RenderBox;
+ using Rml::RenderInterface;
+ using Rml::RenderManager;
+ using Rml::RenderManagerAccess;
+ using Rml::RenderState;
+ using Rml::ReplacedBox;
+ using Rml::ResolveValue;
+ using Rml::ResolveValueOr;
+ using Rml::RowMajorMatrix4f;
+ using Rml::RowMajorStorage;
+ using Rml::ScalarDefinition;
+ using Rml::ScalarFuncDefinition;
+ using Rml::ScriptInterface;
+ using Rml::ScriptInterfacePtr;
+ using Rml::ScriptObject;
+ using Rml::ScrollAlignment;
+ using Rml::ScrollBehavior;
+ using Rml::ScrollController;
+ using Rml::ScrollIntoViewOptions;
+ using Rml::ScrollParentage;
+ using Rml::SetFileInterface;
+ using Rml::SetFontEngineInterface;
+ using Rml::SetRenderInterface;
+ using Rml::SetSystemInterface;
+ using Rml::SetTextInputHandler;
+ using Rml::SharedPtr;
+ using Rml::ShorthandDefinition;
+ using Rml::ShorthandId;
+ using Rml::ShorthandIdNameMap;
+ using Rml::ShorthandType;
+ using Rml::Shutdown;
+ using Rml::SmallOrderedMap;
+ using Rml::SmallOrderedSet;
+ using Rml::SmallUnorderedMap;
+ using Rml::SmallUnorderedSet;
+ using Rml::Span;
+ using Rml::Sprite;
+ using Rml::SpriteDefinitionList;
+ using Rml::SpriteMap;
+ using Rml::Spritesheet;
+ using Rml::SpritesheetList;
+ using Rml::StableMap;
+ using Rml::StableUnorderedMap;
+ using Rml::StableVector;
+ using Rml::StableVectorIndex;
+ using Rml::Stack;
+ using Rml::StackingContextChild;
+ using Rml::Stream;
+ using Rml::String;
+ using Rml::StringIteratorU8;
+ using Rml::StringList;
+ using Rml::StringView;
+ using Rml::StructDefinition;
+ using Rml::StructHandle;
+ using Rml::StyleSheet;
+ using Rml::StyleSheetContainer;
+ using Rml::StyleSheetIndex;
+ using Rml::StyleSheetNode;
+ using Rml::StyleSheetParser;
+ using Rml::StyleSheetSpecification;
+ using Rml::SystemInterface;
+ using Rml::TextInputHandler;
+ using Rml::TextShapingContext;
+ using Rml::Texture;
+ using Rml::TextureDatabase;
+ using Rml::TextureFileIndex;
+ using Rml::TextureHandle;
+ using Rml::TextureSource;
+ using Rml::TexturedMesh;
+ using Rml::TexturedMeshList;
+ using Rml::ToString;
+ using Rml::Touch;
+ using Rml::TouchId;
+ using Rml::TouchList;
+ using Rml::Transform;
+ using Rml::TransformFuncRegister;
+ using Rml::TransformPrimitive;
+ using Rml::TransformPtr;
+ using Rml::TransformState;
+ using Rml::Transition;
+ using Rml::TransitionList;
+ using Rml::Tween;
+ using Rml::TypeConverter;
+ using Rml::URL;
+ using Rml::UniquePtr;
+ using Rml::UniqueReleaserPtr;
+ using Rml::UniqueRenderResource;
+ using Rml::Unit;
+ using Rml::Units;
+ using Rml::UnorderedMap;
+ using Rml::UnorderedMultimap;
+ using Rml::UnorderedSet;
+ using Rml::UnregisterPlugin;
+ using Rml::VariableDefinition;
+ using Rml::Variant;
+ using Rml::VariantList;
+ using Rml::Vector2;
+ using Rml::Vector2f;
+ using Rml::Vector2i;
+ using Rml::Vector3;
+ using Rml::Vector3f;
+ using Rml::Vector3i;
+ using Rml::Vector4;
+ using Rml::Vector4f;
+ using Rml::Vector4i;
+ using Rml::Vector;
+ using Rml::Vertex;
+ using Rml::VoidMemberFunc;
+ using Rml::WeakPtr;
+ using Rml::WidgetDropDown;
+ using Rml::WidgetScroll;
+ using Rml::WidgetTextInputMultiLine;
+ using Rml::XMLAttributes;
+ using Rml::XMLDataType;
+ using Rml::XMLNodeHandler;
+ using Rml::XMLParser;
+ using Rml::byte;
+ using Rml::is_builtin_data_scalar;
+ using Rml::voidPtr;
+
+ namespace Input
+ {
+ using Rml::Input::KeyIdentifier;
+ using Rml::Input::KeyModifier;
+ }
+
+ namespace Math
+ {
+ using Rml::Math::ACos;
+ using Rml::Math::ASin;
+ using Rml::Math::ATan2;
+ using Rml::Math::Absolute;
+ using Rml::Math::Clamp;
+ using Rml::Math::Cos;
+ using Rml::Math::DecomposeFractionalIntegral;
+ using Rml::Math::DegreesToRadians;
+ using Rml::Math::Exp;
+ using Rml::Math::ExpandToPixelGrid;
+ using Rml::Math::HexToDecimal;
+ using Rml::Math::IsCloseToZero;
+ using Rml::Math::Lerp;
+ using Rml::Math::Log2;
+ using Rml::Math::Max;
+ using Rml::Math::Min;
+ using Rml::Math::NormaliseAngle;
+ using Rml::Math::RadiansToDegrees;
+ using Rml::Math::RandomBool;
+ using Rml::Math::RandomInteger;
+ using Rml::Math::RandomReal;
+ using Rml::Math::Round;
+ using Rml::Math::RoundDown;
+ using Rml::Math::RoundDownToInteger;
+ using Rml::Math::RoundToInteger;
+ using Rml::Math::RoundUp;
+ using Rml::Math::RoundUpToInteger;
+ using Rml::Math::RoundedLerp;
+ using Rml::Math::Sin;
+ using Rml::Math::SnapToPixelGrid;
+ using Rml::Math::SquareRoot;
+ using Rml::Math::Tan;
+ using Rml::Math::ToPowerOfTwo;
+ }
+
+ namespace StringUtilities
+ {
+ using Rml::StringUtilities::BytesUTF8;
+ using Rml::StringUtilities::ConvertByteOffsetToCharacterOffset;
+ using Rml::StringUtilities::ConvertCharacterOffsetToByteOffset;
+ using Rml::StringUtilities::DecodeRml;
+ using Rml::StringUtilities::EncodeRml;
+ using Rml::StringUtilities::EndsWith;
+ using Rml::StringUtilities::ExpandString;
+ using Rml::StringUtilities::IsWhitespace;
+ using Rml::StringUtilities::JoinString;
+ using Rml::StringUtilities::LengthUTF8;
+ using Rml::StringUtilities::Replace;
+ using Rml::StringUtilities::SeekBackwardUTF8;
+ using Rml::StringUtilities::SeekForwardUTF8;
+ using Rml::StringUtilities::StartsWith;
+ using Rml::StringUtilities::StringCompareCaseInsensitive;
+ using Rml::StringUtilities::StripWhitespace;
+ using Rml::StringUtilities::ToCharacter;
+ using Rml::StringUtilities::ToLower;
+ using Rml::StringUtilities::ToUTF8;
+ using Rml::StringUtilities::ToUpper;
+ using Rml::StringUtilities::TrimTrailingDotZeros;
+ }
+
+ namespace Style
+ {
+ using Rml::Style::AlignContent;
+ using Rml::Style::AlignItems;
+ using Rml::Style::AlignSelf;
+ using Rml::Style::Bottom;
+ using Rml::Style::BoxSizing;
+ using Rml::Style::Clear;
+ using Rml::Style::Clip;
+ using Rml::Style::CommonValues;
+ using Rml::Style::ComputedValues;
+ using Rml::Style::Direction;
+ using Rml::Style::Display;
+ using Rml::Style::Drag;
+ using Rml::Style::FlexBasis;
+ using Rml::Style::FlexDirection;
+ using Rml::Style::FlexWrap;
+ using Rml::Style::Float;
+ using Rml::Style::Focus;
+ using Rml::Style::FontKerning;
+ using Rml::Style::FontStyle;
+ using Rml::Style::FontWeight;
+ using Rml::Style::Height;
+ using Rml::Style::InheritedValues;
+ using Rml::Style::JustifyContent;
+ using Rml::Style::Left;
+ using Rml::Style::LengthPercentage;
+ using Rml::Style::LengthPercentageAuto;
+ using Rml::Style::LineHeight;
+ using Rml::Style::Margin;
+ using Rml::Style::MaxHeight;
+ using Rml::Style::MaxWidth;
+ using Rml::Style::MinHeight;
+ using Rml::Style::MinWidth;
+ using Rml::Style::Nav;
+ using Rml::Style::NumberAuto;
+ using Rml::Style::OriginX;
+ using Rml::Style::OriginY;
+ using Rml::Style::Overflow;
+ using Rml::Style::OverscrollBehavior;
+ using Rml::Style::Padding;
+ using Rml::Style::PerspectiveOrigin;
+ using Rml::Style::PointerEvents;
+ using Rml::Style::Position;
+ using Rml::Style::RareValues;
+ using Rml::Style::Right;
+ using Rml::Style::TabIndex;
+ using Rml::Style::TextAlign;
+ using Rml::Style::TextDecoration;
+ using Rml::Style::TextOverflow;
+ using Rml::Style::TextTransform;
+ using Rml::Style::Top;
+ using Rml::Style::TransformOrigin;
+ using Rml::Style::VerticalAlign;
+ using Rml::Style::Visibility;
+ using Rml::Style::WhiteSpace;
+ using Rml::Style::Width;
+ using Rml::Style::WordBreak;
+ using Rml::Style::ZIndex;
+ }
+
+ namespace Transforms
+ {
+ using Rml::Transforms::DecomposedMatrix4;
+ using Rml::Transforms::Matrix2D;
+ using Rml::Transforms::Matrix3D;
+ using Rml::Transforms::Perspective;
+ using Rml::Transforms::ResolvedPrimitive;
+ using Rml::Transforms::Rotate2D;
+ using Rml::Transforms::Rotate3D;
+ using Rml::Transforms::RotateX;
+ using Rml::Transforms::RotateY;
+ using Rml::Transforms::RotateZ;
+ using Rml::Transforms::Scale2D;
+ using Rml::Transforms::Scale3D;
+ using Rml::Transforms::ScaleX;
+ using Rml::Transforms::ScaleY;
+ using Rml::Transforms::ScaleZ;
+ using Rml::Transforms::Skew2D;
+ using Rml::Transforms::SkewX;
+ using Rml::Transforms::SkewY;
+ using Rml::Transforms::Translate2D;
+ using Rml::Transforms::Translate3D;
+ using Rml::Transforms::TranslateX;
+ using Rml::Transforms::TranslateY;
+ using Rml::Transforms::TranslateZ;
+ using Rml::Transforms::UnresolvedPrimitive;
+ }
+
+ namespace Utilities
+ {
+ using Rml::Utilities::HashCombine;
+ }
+ }
+
+ using ::rmlui_dynamic_cast;
+ using ::rmlui_static_cast;
+ using ::rmlui_type_name;
+}
diff --git a/src/wrappers/rmlui/debugger.cppm b/src/wrappers/rmlui/debugger.cppm
new file mode 100644
index 0000000..7dd319a
--- /dev/null
+++ b/src/wrappers/rmlui/debugger.cppm
@@ -0,0 +1,14 @@
+module;
+
+#include
+
+export module wrappers.rmlui.debugger;
+
+export namespace Rml::Debugger
+{
+ using Rml::Debugger::Initialise;
+ using Rml::Debugger::IsVisible;
+ using Rml::Debugger::SetContext;
+ using Rml::Debugger::SetVisible;
+ using Rml::Debugger::Shutdown;
+}
diff --git a/src/wrappers/sdl.cppm b/src/wrappers/sdl.cppm
new file mode 100644
index 0000000..78f7c72
--- /dev/null
+++ b/src/wrappers/sdl.cppm
@@ -0,0 +1,16 @@
+module;
+
+export module wrappers.sdl;
+
+// Export submodules
+export import wrappers.sdl.clipboard;
+export import wrappers.sdl.error;
+export import wrappers.sdl.events;
+export import wrappers.sdl.init;
+export import wrappers.sdl.keyboard;
+export import wrappers.sdl.mouse;
+export import wrappers.sdl.rect;
+export import wrappers.sdl.render;
+export import wrappers.sdl.surface;
+export import wrappers.sdl.utils;
+export import wrappers.sdl.video;
diff --git a/src/wrappers/sdl/clipboard.cppm b/src/wrappers/sdl/clipboard.cppm
new file mode 100644
index 0000000..8ab1278
--- /dev/null
+++ b/src/wrappers/sdl/clipboard.cppm
@@ -0,0 +1,38 @@
+module;
+
+#include
+
+export module wrappers.sdl.clipboard;
+
+import std;
+import wrappers.sdl.error;
+import wrappers.sdl.utils;
+
+export namespace sdl
+{
+ /**
+ * Put UTF-8 text into the clipboard.
+ *
+ * Wrapper for SDL_SetClipboardText().
+ *
+ * Throws an SDLException on failure.
+ */
+ void set_clipboard_text(const std::string& text)
+ {
+ if (!SDL_SetClipboardText(text.c_str())) {
+ throw SDLException("SDL_SetClipboardText");
+ }
+ }
+
+ /**
+ * Get UTF-8 text from the clipboard.
+ *
+ * Wrapper for SDL_GetClipboardText().
+ *
+ * Returns an empty string on failure.
+ */
+ std::string get_clipboard_text()
+ {
+ return wrap_string(SDL_GetClipboardText());
+ }
+}
diff --git a/src/wrappers/sdl/error.cppm b/src/wrappers/sdl/error.cppm
new file mode 100644
index 0000000..0b12725
--- /dev/null
+++ b/src/wrappers/sdl/error.cppm
@@ -0,0 +1,24 @@
+module;
+
+#include
+
+export module wrappers.sdl.error;
+
+import std;
+
+export namespace sdl
+{
+ // Exception that wraps an SDL error (which SDL function caused the error, what's the error).
+ // SDL_GetError() is used in the constructor to get the error message unless specified explicitly.
+ class SDLException final : public std::runtime_error
+ {
+ public:
+ explicit SDLException(const std::string& sdl_func, const std::string& sdl_error)
+ : runtime_error(std::format("Error in SDL function {}: {}", sdl_func, sdl_error))
+ {}
+
+ explicit SDLException(const std::string& sdl_func)
+ : SDLException(sdl_func, SDL_GetError())
+ {}
+ };
+}
diff --git a/src/wrappers/sdl/events.cppm b/src/wrappers/sdl/events.cppm
new file mode 100644
index 0000000..8ef341a
--- /dev/null
+++ b/src/wrappers/sdl/events.cppm
@@ -0,0 +1,47 @@
+module;
+
+#include
+
+export module wrappers.sdl.events;
+
+export namespace sdl
+{
+ // Simple alias for SDL_Event union
+ using Event = SDL_Event;
+
+ // Alias for EventType enum
+ using EventType = SDL_EventType;
+
+ /**
+ * Wrapper for the SDL_EventType enum.
+ *
+ * We're using a namespace here to emulate an enum-like interface, without having to copy the entire enum.
+ * More constants can be added on demand.
+ */
+ namespace EventTypes
+ {
+ // Application events
+ constexpr EventType Quit = SDL_EVENT_QUIT;
+
+ // Window events
+ constexpr EventType WindowMouseLeave = SDL_EVENT_WINDOW_MOUSE_LEAVE;
+ constexpr EventType WindowDisplayScaleChanged = SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED;
+ constexpr EventType WindowPixelSizeChanged = SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED;
+
+ // Keyboard events
+ constexpr EventType KeyDown = SDL_EVENT_KEY_DOWN;
+ constexpr EventType KeyUp = SDL_EVENT_KEY_UP;
+ constexpr EventType TextInput = SDL_EVENT_TEXT_INPUT;
+
+ // Mouse events
+ constexpr EventType MouseMotion = SDL_EVENT_MOUSE_MOTION;
+ constexpr EventType MouseButtonDown = SDL_EVENT_MOUSE_BUTTON_DOWN;
+ constexpr EventType MouseButtonUp = SDL_EVENT_MOUSE_BUTTON_UP;
+ constexpr EventType MouseWheel = SDL_EVENT_MOUSE_WHEEL;
+
+ // Touch events
+ constexpr EventType FingerDown = SDL_EVENT_FINGER_DOWN;
+ constexpr EventType FingerUp = SDL_EVENT_FINGER_UP;
+ constexpr EventType FingerMotion = SDL_EVENT_FINGER_MOTION;
+ }
+}
diff --git a/src/wrappers/sdl/init.cppm b/src/wrappers/sdl/init.cppm
new file mode 100644
index 0000000..f673011
--- /dev/null
+++ b/src/wrappers/sdl/init.cppm
@@ -0,0 +1,62 @@
+module;
+
+#include
+
+export module wrappers.sdl.init;
+
+import std;
+import wrappers.sdl.error;
+
+export namespace sdl
+{
+ using InitFlags_t = SDL_InitFlags;
+
+ /**
+ * Wrapper for the SDL_InitFlags enum/constants.
+ *
+ * We're using a namespace here to emulate an enum-like interface. If we used an actual enum, we would have to
+ * explicitly define bit-wise operations for it (as well as for every other kind of bit flag enum.
+ * (Maybe in the future this can be replaced with a more elegant template-based solution or something.)
+ */
+ namespace InitFlags
+ {
+ constexpr InitFlags_t Audio = SDL_INIT_AUDIO;
+ constexpr InitFlags_t Video = SDL_INIT_VIDEO;
+ constexpr InitFlags_t Joystick = SDL_INIT_JOYSTICK;
+ constexpr InitFlags_t Haptic = SDL_INIT_HAPTIC;
+ constexpr InitFlags_t Gamepad = SDL_INIT_GAMEPAD;
+ constexpr InitFlags_t Events = SDL_INIT_EVENTS;
+ constexpr InitFlags_t Sensor = SDL_INIT_SENSOR;
+ constexpr InitFlags_t Camera = SDL_INIT_CAMERA;
+ }
+
+ /**
+ * Wrapper for the SDL_AppResult enum.
+ */
+ enum class AppResult : std::underlying_type_t
+ {
+ Continue = SDL_APP_CONTINUE,
+ Success = SDL_APP_SUCCESS,
+ Failure = SDL_APP_FAILURE,
+ };
+
+ /**
+ * Wrapper for SDL_Init().
+ */
+ constexpr void initialize_sdl(const InitFlags_t flags)
+ {
+ if (!SDL_Init(flags)) {
+ throw SDLException("SDL_Init");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_SetAppMetadataProperty().
+ */
+ constexpr void set_app_metadata_property(const char* property_name, const char* value)
+ {
+ if (!SDL_SetAppMetadataProperty(property_name, value)) {
+ throw SDLException("SDL_SetAppMetadataProperty");
+ }
+ }
+}
diff --git a/src/wrappers/sdl/keyboard.cppm b/src/wrappers/sdl/keyboard.cppm
new file mode 100644
index 0000000..bed6d25
--- /dev/null
+++ b/src/wrappers/sdl/keyboard.cppm
@@ -0,0 +1,177 @@
+module;
+
+#include
+#include
+
+export module wrappers.sdl.keyboard;
+
+export namespace sdl
+{
+ using Keycode = SDL_Keycode;
+
+ /**
+ * SDL keycode constants (SDLK_...).
+ * This is not a complete collection of all keycodes, only those we need. Add more when needed.
+ * Namespace is just called "Key" instead of "Keycodes" to keep it short.
+ */
+ namespace Key
+ {
+ // clang-format off
+ constexpr Keycode Unknown = SDLK_UNKNOWN;
+ constexpr Keycode Return = SDLK_RETURN;
+ constexpr Keycode Escape = SDLK_ESCAPE;
+ constexpr Keycode Backspace = SDLK_BACKSPACE;
+ constexpr Keycode Tab = SDLK_TAB;
+ constexpr Keycode Space = SDLK_SPACE;
+ constexpr Keycode DblApostrophe = SDLK_DBLAPOSTROPHE;
+ constexpr Keycode Plus = SDLK_PLUS;
+ constexpr Keycode Comma = SDLK_COMMA;
+ constexpr Keycode Minus = SDLK_MINUS;
+ constexpr Keycode Period = SDLK_PERIOD;
+ constexpr Keycode Slash = SDLK_SLASH;
+ constexpr Keycode Num0 = SDLK_0;
+ constexpr Keycode Num1 = SDLK_1;
+ constexpr Keycode Num2 = SDLK_2;
+ constexpr Keycode Num3 = SDLK_3;
+ constexpr Keycode Num4 = SDLK_4;
+ constexpr Keycode Num5 = SDLK_5;
+ constexpr Keycode Num6 = SDLK_6;
+ constexpr Keycode Num7 = SDLK_7;
+ constexpr Keycode Num8 = SDLK_8;
+ constexpr Keycode Num9 = SDLK_9;
+ constexpr Keycode Semicolon = SDLK_SEMICOLON;
+ constexpr Keycode LeftBracket = SDLK_LEFTBRACKET;
+ constexpr Keycode Backslash = SDLK_BACKSLASH;
+ constexpr Keycode RightBracket = SDLK_RIGHTBRACKET;
+ constexpr Keycode Grave = SDLK_GRAVE;
+ constexpr Keycode A = SDLK_A;
+ constexpr Keycode B = SDLK_B;
+ constexpr Keycode C = SDLK_C;
+ constexpr Keycode D = SDLK_D;
+ constexpr Keycode E = SDLK_E;
+ constexpr Keycode F = SDLK_F;
+ constexpr Keycode G = SDLK_G;
+ constexpr Keycode H = SDLK_H;
+ constexpr Keycode I = SDLK_I;
+ constexpr Keycode J = SDLK_J;
+ constexpr Keycode K = SDLK_K;
+ constexpr Keycode L = SDLK_L;
+ constexpr Keycode M = SDLK_M;
+ constexpr Keycode N = SDLK_N;
+ constexpr Keycode O = SDLK_O;
+ constexpr Keycode P = SDLK_P;
+ constexpr Keycode Q = SDLK_Q;
+ constexpr Keycode R = SDLK_R;
+ constexpr Keycode S = SDLK_S;
+ constexpr Keycode T = SDLK_T;
+ constexpr Keycode U = SDLK_U;
+ constexpr Keycode V = SDLK_V;
+ constexpr Keycode W = SDLK_W;
+ constexpr Keycode X = SDLK_X;
+ constexpr Keycode Y = SDLK_Y;
+ constexpr Keycode Z = SDLK_Z;
+ constexpr Keycode Delete = SDLK_DELETE;
+ constexpr Keycode CapsLock = SDLK_CAPSLOCK;
+ constexpr Keycode F1 = SDLK_F1;
+ constexpr Keycode F2 = SDLK_F2;
+ constexpr Keycode F3 = SDLK_F3;
+ constexpr Keycode F4 = SDLK_F4;
+ constexpr Keycode F5 = SDLK_F5;
+ constexpr Keycode F6 = SDLK_F6;
+ constexpr Keycode F7 = SDLK_F7;
+ constexpr Keycode F8 = SDLK_F8;
+ constexpr Keycode F9 = SDLK_F9;
+ constexpr Keycode F10 = SDLK_F10;
+ constexpr Keycode F11 = SDLK_F11;
+ constexpr Keycode F12 = SDLK_F12;
+ constexpr Keycode PrintScreen = SDLK_PRINTSCREEN;
+ constexpr Keycode ScrollLock = SDLK_SCROLLLOCK;
+ constexpr Keycode Pause = SDLK_PAUSE;
+ constexpr Keycode Insert = SDLK_INSERT;
+ constexpr Keycode Home = SDLK_HOME;
+ constexpr Keycode PageUp = SDLK_PAGEUP;
+ constexpr Keycode End = SDLK_END;
+ constexpr Keycode PageDown = SDLK_PAGEDOWN;
+ constexpr Keycode Right = SDLK_RIGHT;
+ constexpr Keycode Left = SDLK_LEFT;
+ constexpr Keycode Down = SDLK_DOWN;
+ constexpr Keycode Up = SDLK_UP;
+ constexpr Keycode NumLockClear = SDLK_NUMLOCKCLEAR;
+ constexpr Keycode NumpadDivide = SDLK_KP_DIVIDE;
+ constexpr Keycode NumpadMultiply = SDLK_KP_MULTIPLY;
+ constexpr Keycode NumpadMinus = SDLK_KP_MINUS;
+ constexpr Keycode NumpadPlus = SDLK_KP_PLUS;
+ constexpr Keycode NumpadEnter = SDLK_KP_ENTER;
+ constexpr Keycode Numpad1 = SDLK_KP_1;
+ constexpr Keycode Numpad2 = SDLK_KP_2;
+ constexpr Keycode Numpad3 = SDLK_KP_3;
+ constexpr Keycode Numpad4 = SDLK_KP_4;
+ constexpr Keycode Numpad5 = SDLK_KP_5;
+ constexpr Keycode Numpad6 = SDLK_KP_6;
+ constexpr Keycode Numpad7 = SDLK_KP_7;
+ constexpr Keycode Numpad8 = SDLK_KP_8;
+ constexpr Keycode Numpad9 = SDLK_KP_9;
+ constexpr Keycode Numpad0 = SDLK_KP_0;
+ constexpr Keycode NumpadPeriod = SDLK_KP_PERIOD;
+ constexpr Keycode NumpadEquals = SDLK_KP_EQUALS;
+ constexpr Keycode F13 = SDLK_F13;
+ constexpr Keycode F14 = SDLK_F14;
+ constexpr Keycode F15 = SDLK_F15;
+ constexpr Keycode Help = SDLK_HELP;
+ constexpr Keycode Clear = SDLK_CLEAR;
+ constexpr Keycode LCtrl = SDLK_LCTRL;
+ constexpr Keycode LShift = SDLK_LSHIFT;
+ constexpr Keycode LAlt = SDLK_LALT;
+ constexpr Keycode LGui = SDLK_LGUI;
+ constexpr Keycode RCtrl = SDLK_RCTRL;
+ constexpr Keycode RShift = SDLK_RSHIFT;
+ constexpr Keycode RAlt = SDLK_RALT;
+ constexpr Keycode RGui = SDLK_RGUI;
+ // clang-format on
+ }
+
+ // Key modifier (e.g. left shift, left ctrl, ...)
+ using KeyMod = SDL_Keymod;
+
+ // Key modifier state: Bitmask of key modifiers (just an alias for KeyMod for slightly better semantics)
+ using KeyModState = KeyMod;
+
+ /**
+ * SDL key modifier constants (SDL_KMOD_...).
+ * This is not a complete collection of all key modifiers, only those we need. Add more when needed.
+ */
+ namespace KeyMods
+ {
+ // clang-format off
+
+ // Single modifiers
+ constexpr KeyMod None = SDL_KMOD_NONE;
+ constexpr KeyMod LShift = SDL_KMOD_LSHIFT;
+ constexpr KeyMod RShift = SDL_KMOD_RSHIFT;
+ constexpr KeyMod LCtrl = SDL_KMOD_LCTRL;
+ constexpr KeyMod RCtrl = SDL_KMOD_RCTRL;
+ constexpr KeyMod LAlt = SDL_KMOD_LALT;
+ constexpr KeyMod RAlt = SDL_KMOD_RALT;
+ constexpr KeyMod LGui = SDL_KMOD_LGUI;
+ constexpr KeyMod RGui = SDL_KMOD_RGUI;
+ constexpr KeyMod NumLock = SDL_KMOD_NUM;
+ constexpr KeyMod CapsLock = SDL_KMOD_CAPS;
+
+ // Combined modifiers
+ constexpr KeyModState Ctrl = SDL_KMOD_CTRL; // Any Ctrl key
+ constexpr KeyModState Shift = SDL_KMOD_SHIFT; // Any Shift key
+ constexpr KeyModState Alt = SDL_KMOD_ALT; // Any Alt key
+ constexpr KeyModState Gui = SDL_KMOD_GUI; // Any GUI key
+
+ // clang-format on
+ }
+
+ /**
+ * Get the current key modifier state for the keyboard.
+ * Wrapper for SDL_GetModState().
+ */
+ constexpr KeyModState get_key_mod_state()
+ {
+ return SDL_GetModState();
+ }
+}
diff --git a/src/wrappers/sdl/mouse.cppm b/src/wrappers/sdl/mouse.cppm
new file mode 100644
index 0000000..e612c00
--- /dev/null
+++ b/src/wrappers/sdl/mouse.cppm
@@ -0,0 +1,29 @@
+module;
+
+#include
+#include
+
+export module wrappers.sdl.mouse;
+
+import std;
+
+export namespace sdl
+{
+ using MouseButtonIndex = std::uint8_t;
+
+ namespace MouseButtons
+ {
+ constexpr MouseButtonIndex Left = SDL_BUTTON_LEFT;
+ constexpr MouseButtonIndex Middle = SDL_BUTTON_MIDDLE;
+ constexpr MouseButtonIndex Right = SDL_BUTTON_RIGHT;
+ }
+
+ /**
+ * Wrapper for SDL_CaptureMouse().
+ */
+ constexpr void capture_mouse(const bool enabled)
+ {
+ // Ignore errors (probably means it's not supported on this platform)
+ SDL_CaptureMouse(enabled);
+ }
+}
diff --git a/src/wrappers/sdl/rect.cppm b/src/wrappers/sdl/rect.cppm
new file mode 100644
index 0000000..6e5dd32
--- /dev/null
+++ b/src/wrappers/sdl/rect.cppm
@@ -0,0 +1,42 @@
+module;
+
+#include
+
+export module wrappers.sdl.rect;
+
+export namespace sdl
+{
+ // Wrappers around SDL_Point and SDL_FPoint (change to wrapper classes when needed)
+ using FPoint = SDL_FPoint;
+ using Point = SDL_Point;
+
+ /**
+ * Wrapper for SDL_FRect using class inheritance.
+ */
+ class FRect : public SDL_FRect
+ {
+ /**
+ * Wrapper for SDL_RectEmptyFloat().
+ * @return True if the floating point rectangle takes no space.
+ */
+ bool is_empty() const
+ {
+ return SDL_RectEmptyFloat(this);
+ }
+ };
+
+ /**
+ * Wrapper for SDL_Rect using class inheritance.
+ */
+ class Rect : public SDL_Rect
+ {
+ /**
+ * Wrapper for SDL_RectEmpty().
+ * @return True if the rectangle takes no space.
+ */
+ bool is_empty() const
+ {
+ return SDL_RectEmpty(this);
+ }
+ };
+}
diff --git a/src/wrappers/sdl/render.cppm b/src/wrappers/sdl/render.cppm
new file mode 100644
index 0000000..a556c3f
--- /dev/null
+++ b/src/wrappers/sdl/render.cppm
@@ -0,0 +1,194 @@
+module;
+
+#include
+#include
+
+export module wrappers.sdl.render;
+
+import std;
+import utils.memory;
+import wrappers.sdl.error;
+import wrappers.sdl.rect;
+import wrappers.sdl.video;
+
+export namespace sdl
+{
+ using Vertex = SDL_Vertex;
+
+ /**
+ * Wrapper for SDL_Texture that manages its lifecycle with a unique_ptr.
+ */
+ class Texture
+ {
+ std::unique_ptr<
+ SDL_Texture,
+ utils::FuncDeleter
+ > raw_texture_;
+
+ public:
+ Texture() = delete;
+
+ explicit Texture(SDL_Texture* raw_texture)
+ : raw_texture_{raw_texture}
+ {}
+
+ constexpr SDL_Texture* get_raw() const
+ {
+ assert(raw_texture_);
+ return raw_texture_.get();
+ }
+
+ constexpr int get_width() const
+ {
+ return raw_texture_->w;
+ }
+
+ constexpr int get_height() const
+ {
+ return raw_texture_->h;
+ }
+
+ constexpr FRect get_boundaries() const
+ {
+ return {0, 0, static_cast(raw_texture_->w), static_cast(raw_texture_->h)};
+ }
+ };
+
+ /**
+ * Wrapper for SDL_Renderer that manages its lifecycle with a unique_ptr.
+ */
+ class Renderer
+ {
+ std::unique_ptr<
+ SDL_Renderer,
+ utils::FuncDeleter
+ > raw_renderer_;
+
+ public:
+ Renderer() = delete;
+
+ explicit Renderer(SDL_Renderer* raw_renderer)
+ : raw_renderer_{raw_renderer}
+ {}
+
+ constexpr SDL_Renderer* get_raw() const
+ {
+ assert(raw_renderer_);
+ return raw_renderer_.get();
+ }
+
+ /**
+ * Wrapper for SDL_SetRenderClipRect().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void set_clip_rect(const Rect* rect) const
+ {
+ if (!SDL_SetRenderClipRect(get_raw(), rect)) {
+ assert(!"SDL_SetRenderClipRect returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_SetRenderDrawBlendMode().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void set_draw_blend_mode(SDL_BlendMode blend_mode) const
+ {
+ if (!SDL_SetRenderDrawBlendMode(get_raw(), blend_mode)) {
+ assert(!"SDL_SetRenderDrawBlendMode returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_SetRenderVSync().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void set_vsync(const int vsync) const
+ {
+ // TODO: These functions probably should never fail? Change this to a runtime exception if necessary.
+ if (!SDL_SetRenderVSync(get_raw(), vsync)) {
+ assert(!"SDL_SetRenderVSync returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_RenderClear().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void clear() const
+ {
+ // TODO: These functions probably should never fail? Change this to a runtime exception if necessary.
+ if (!SDL_RenderClear(get_raw())) {
+ assert(!"SDL_RenderClear returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_RenderPresent().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void present() const
+ {
+ if (!SDL_RenderPresent(get_raw())) {
+ assert(!"SDL_RenderPresent returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_RenderTexture().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void render_texture(
+ const Texture& texture,
+ const FRect* src_rect,
+ const FRect* dest_rect
+ ) const
+ {
+ if (!SDL_RenderTexture(get_raw(), texture.get_raw(), src_rect, dest_rect)) {
+ assert(!"SDL_RenderTexture returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_RenderGeometry().
+ * Uses assert to verify the function returned true.
+ */
+ // TODO: Function uses raw SDL_Texture pointer. Add overload for sdl::Texture& when needed.
+ constexpr void render_geometry(
+ SDL_Texture* texture,
+ const Vertex* vertices,
+ const int num_vertices,
+ const int* indices,
+ const int num_indices
+ ) const
+ {
+ if (!SDL_RenderGeometry(get_raw(), texture, vertices, num_vertices, indices, num_indices)) {
+ assert(!"SDL_RenderGeometry returned false");
+ }
+ }
+ };
+
+ /**
+ * Wrapper for SDL_CreateWindowAndRenderer().
+ * Returns a pair of a Window and a Renderer object.
+ * Throws an SDLException on failure.
+ */
+ std::pair create_window_and_renderer(
+ const std::string& title,
+ const int width,
+ const int height,
+ const SDL_WindowFlags window_flags
+ )
+ {
+ SDL_Window* raw_window = nullptr;
+ SDL_Renderer* raw_renderer = nullptr;
+
+ if (!SDL_CreateWindowAndRenderer(
+ title.c_str(), width, height, window_flags, &raw_window, &raw_renderer
+ )) {
+ throw SDLException("SDL_CreateWindowAndRenderer");
+ }
+
+ return {Window{raw_window}, Renderer{raw_renderer}};
+ }
+}
diff --git a/src/wrappers/sdl/surface.cppm b/src/wrappers/sdl/surface.cppm
new file mode 100644
index 0000000..88cba61
--- /dev/null
+++ b/src/wrappers/sdl/surface.cppm
@@ -0,0 +1,98 @@
+module;
+
+#include
+#include
+
+export module wrappers.sdl.surface;
+
+import std;
+import utils.memory;
+import wrappers.sdl.error;
+
+export namespace sdl
+{
+ /**
+ * Wrapper for SDL_Texture that manages its lifecycle with a unique_ptr.
+ */
+ class Surface
+ {
+ std::unique_ptr<
+ SDL_Surface,
+ utils::FuncDeleter
+ > raw_surface_;
+
+ public:
+ Surface() = delete;
+
+ explicit Surface(SDL_Surface* raw_surface)
+ : raw_surface_{raw_surface}
+ {}
+
+ /**
+ * Allocate a new surface with a specific pixel format and existing pixel data.
+ *
+ * NOTE: No copy is made of the pixel data. Pixel data is not managed automatically; you must free the
+ * surface before you free the pixel data.
+ *
+ * Wrapper for SDL_CreateSurfaceFrom().
+ */
+ static Surface create_from(
+ const int width,
+ const int height,
+ const SDL_PixelFormat format,
+ void* pixels,
+ const int pitch
+ )
+ {
+ SDL_Surface* raw_surface = SDL_CreateSurfaceFrom(width, height, format, pixels, pitch);
+
+ if (raw_surface == nullptr) {
+ throw SDLException("SDL_CreateSurfaceFrom");
+ }
+
+ return Surface(raw_surface);
+ }
+
+ constexpr SDL_Surface* get_raw() const
+ {
+ assert(raw_surface_);
+ return raw_surface_.get();
+ }
+
+ // TODO: Wrap SDL_PixelFormat?
+ constexpr SDL_PixelFormat get_format() const
+ {
+ return raw_surface_->format;
+ }
+
+ constexpr int get_width() const
+ {
+ return raw_surface_->w;
+ }
+
+ constexpr int get_height() const
+ {
+ return raw_surface_->h;
+ }
+
+ constexpr void* get_raw_pixels() const
+ {
+ return raw_surface_->pixels;
+ }
+
+ /**
+ * Wrapper for SDL_ConvertSurface().
+ */
+ Surface convert_format(const SDL_PixelFormat new_format) const
+ {
+ SDL_Surface* new_raw_surface = SDL_ConvertSurface(get_raw(), new_format);
+
+ // TODO: When does this function fail?
+ if (new_raw_surface == nullptr) {
+ throw SDLException("SDL_ConvertSurface");
+ }
+
+ return Surface(new_raw_surface);
+ }
+ };
+}
diff --git a/src/wrappers/sdl/utils.cppm b/src/wrappers/sdl/utils.cppm
new file mode 100644
index 0000000..96dd45e
--- /dev/null
+++ b/src/wrappers/sdl/utils.cppm
@@ -0,0 +1,23 @@
+module;
+
+#include
+
+export module wrappers.sdl.utils;
+
+import std;
+
+export namespace sdl
+{
+ /**
+ * Convert a string returned by an SDL function to std::string and free the original memory using SDL_free().
+ *
+ * Usage:
+ * std::string foo = wrap_string(SDL_FunctionThatReturnsAString());
+ */
+ constexpr std::string wrap_string(char* raw_str)
+ {
+ std::string str{raw_str};
+ SDL_free(raw_str);
+ return str;
+ }
+}
diff --git a/src/wrappers/sdl/video.cppm b/src/wrappers/sdl/video.cppm
new file mode 100644
index 0000000..f393a43
--- /dev/null
+++ b/src/wrappers/sdl/video.cppm
@@ -0,0 +1,101 @@
+module;
+
+#include
+#include
+
+export module wrappers.sdl.video;
+
+import std;
+import utils.memory;
+import wrappers.sdl.error;
+import wrappers.sdl.rect;
+
+export namespace sdl
+{
+ /**
+ * Wrapper for SDL_Window that manages its lifecycle with a unique_ptr.
+ */
+ class Window
+ {
+ std::unique_ptr<
+ SDL_Window,
+ utils::FuncDeleter
+ > raw_window_;
+
+ public:
+ Window() = delete;
+
+ explicit Window(SDL_Window* raw_window)
+ : raw_window_{raw_window}
+ {}
+
+ constexpr SDL_Window* get_raw() const
+ {
+ assert(raw_window_);
+ return raw_window_.get();
+ }
+
+ /**
+ * Wrapper for SDL_GetWindowPixelDensity().
+ */
+ constexpr float get_pixel_density() const
+ {
+ return SDL_GetWindowPixelDensity(get_raw());
+ }
+
+ /**
+ * Wrapper for SDL_GetWindowDisplayScale().
+ */
+ constexpr float get_display_scale() const
+ {
+ return SDL_GetWindowDisplayScale(get_raw());
+ }
+
+ /**
+ * Wrapper for SDL_GetWindowSize().
+ */
+ std::pair get_size() const
+ {
+ int width, height;
+
+ if (!SDL_GetWindowSize(get_raw(), &width, &height)) {
+ throw SDLException("SDL_GetWindowSize");
+ }
+
+ return {width, height};
+ }
+
+ /**
+ * Wrapper for SDL_SetTextInputArea().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void set_text_input_area(const Rect* rect, const int cursor) const
+ {
+ if (!SDL_SetTextInputArea(get_raw(), rect, cursor)) {
+ assert(!"SDL_SetTextInputArea returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_StartTextInput().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void start_text_input() const
+ {
+ if (!SDL_StartTextInput(get_raw())) {
+ assert(!"SDL_StartTextInput returned false");
+ }
+ }
+
+ /**
+ * Wrapper for SDL_StopTextInput().
+ * Uses assert to verify the function returned true.
+ */
+ constexpr void stop_text_input() const
+ {
+ if (!SDL_StopTextInput(get_raw())) {
+ assert(!"SDL_StopTextInput returned false");
+ }
+ }
+ };
+}
diff --git a/src/wrappers/sdl_image.cppm b/src/wrappers/sdl_image.cppm
new file mode 100644
index 0000000..7312e9c
--- /dev/null
+++ b/src/wrappers/sdl_image.cppm
@@ -0,0 +1,43 @@
+module;
+
+#include
+#include
+
+export module wrappers.sdl_image;
+
+import std;
+import utils.memory;
+import wrappers.sdl.error;
+import wrappers.sdl.render;
+import wrappers.sdl.surface;
+
+export namespace sdl_image
+{
+ /**
+ * Wrapper for IMG_Load().
+ */
+ sdl::Surface load(const std::string& filename)
+ {
+ SDL_Surface* raw_surface = IMG_Load(filename.c_str());
+
+ if (raw_surface == nullptr) {
+ throw sdl::SDLException("IMG_Load");
+ }
+
+ return sdl::Surface{raw_surface};
+ }
+
+ /**
+ * Wrapper for IMG_LoadTexture().
+ */
+ sdl::Texture load_texture(const sdl::Renderer& renderer, const std::string& filename)
+ {
+ SDL_Texture* raw_texture = IMG_LoadTexture(renderer.get_raw(), filename.c_str());
+
+ if (raw_texture == nullptr) {
+ throw sdl::SDLException("IMG_LoadTexture");
+ }
+
+ return sdl::Texture{raw_texture};
+ }
+}
diff --git a/tools/shuriken.py b/tools/shuriken.py
index e1ae8aa..b7f38a7 100755
--- a/tools/shuriken.py
+++ b/tools/shuriken.py
@@ -3,14 +3,13 @@
############################################
# shuriken - A ninja-based C++ build tool
# ------------------------------------------
-# Version: 0.1.0
+# Version: 0.1.0 (modified)
# Author: binaryDiv
# License: MIT License
# https://git.0xbd.space/binaryDiv/shuriken
############################################
import argparse
-import copy
import json
import shlex
import subprocess
@@ -18,7 +17,7 @@ import sys
from collections import namedtuple
from dataclasses import asdict, dataclass, field
from pathlib import Path
-from typing import Any
+from typing import Any, Self
import yaml
@@ -60,10 +59,43 @@ class TargetConfig:
return f"{self.linker_args or ''} {self.linker_args_extra or ''}".strip()
+@dataclass(kw_only=True)
+class MergedTargetConfig(TargetConfig):
+ cpp_compiler: str = ''
+ cpp_standard: str = ''
+ cpp_flags: str = ''
+ cpp_flags_extra: str = ''
+
+ linker_flags: str = ''
+ linker_flags_extra: str = ''
+ linker_args: str = ''
+ linker_args_extra: str = ''
+
+ output_file: str = ''
+ run_command: str = ''
+
+ @classmethod
+ def create_from(cls, target_defaults: TargetConfig, target_config: TargetConfig) -> Self:
+ merged_config = cls()
+ merged_config.update(asdict(target_defaults))
+ merged_config.update(asdict(target_config))
+ return merged_config
+
+ def validate(self) -> None:
+ # Validate that all required fields are set
+ if not self.cpp_compiler:
+ raise ConfigValidationException('cpp_compiler must be set')
+ if not self.cpp_standard:
+ raise ConfigValidationException('cpp_standard must be set')
+ if not self.output_file:
+ raise ConfigValidationException('output_file must be set')
+
+
@dataclass(kw_only=True)
class ShurikenConfig:
__path_fields = ['source_dir', 'build_dir']
__list_fields = []
+ __bool_fields = ['enable_import_std']
source_dir: Path = Path('src')
build_dir: Path = Path('build')
@@ -71,14 +103,16 @@ class ShurikenConfig:
defaults: TargetConfig = field(default_factory=lambda: TargetConfig(
cpp_compiler='clang++',
cpp_standard='c++20',
- cpp_flags='',
- linker_flags='',
- linker_args='',
))
+ enable_import_std: bool = False
+
default_target: str = ''
targets: dict[str, TargetConfig] = field(default_factory=dict)
+ # Merged target configs, generated from defaults and targets
+ _merged_target_configs = {}
+
def update(self, data: dict) -> None:
for key, value in data.items():
# Ignore unknown config keys
@@ -86,13 +120,13 @@ class ShurikenConfig:
continue
if key == 'defaults':
- assert (type(value) is dict)
+ assert type(value) is dict
self.defaults.update(value)
elif key == 'targets':
- assert (type(value) is dict)
+ assert type(value) is dict
for target_name, target_config in value.items():
- assert (type(target_name) is str and len(target_name) > 0)
- assert (type(target_config) is dict or target_config is None)
+ assert type(target_name) is str and len(target_name) > 0
+ assert type(target_config) is dict or target_config is None
# Ignore "hidden" targets starting with a dot (can be used for YAML anchors)
if target_name[0] == '.':
@@ -105,11 +139,18 @@ class ShurikenConfig:
elif key in self.__path_fields:
setattr(self, key, Path(value))
elif key in self.__list_fields:
- assert (type(value) is list)
+ assert type(value) is list
setattr(self, key, list(str(item) for item in value))
+ elif key in self.__bool_fields:
+ assert type(value) is bool
+ setattr(self, key, value)
else:
setattr(self, key, str(value))
+ # Generate merged target configs (we generate all of them so that we can validate the full config)
+ for target_name, target_config in self.targets.items():
+ self._merged_target_configs[target_name] = MergedTargetConfig.create_from(self.defaults, target_config)
+
def update_from_yaml(self, file_path: Path) -> None:
with file_path.open('r') as file:
self.update(yaml.safe_load(file))
@@ -131,23 +172,18 @@ class ShurikenConfig:
raise ConfigValidationException('default_target is not defined in targets')
# Validate individual build targets
- for target_name, target_config in self.targets.items():
- if not target_config.cpp_compiler and not self.defaults.cpp_compiler:
- raise ConfigValidationException(f'Target "{target_name}": cpp_compiler must be set')
- if not target_config.cpp_standard and not self.defaults.cpp_standard:
- raise ConfigValidationException(f'Target "{target_name}": cpp_standard must be set')
- if not target_config.output_file and not self.defaults.output_file:
- raise ConfigValidationException(f'Target "{target_name}": output_file must be set')
+ for target_name, merged_target_config in self._merged_target_configs.items():
+ try:
+ merged_target_config.validate()
+ except ConfigValidationException as exc:
+ raise ConfigValidationException(f'Target "{target_name}": {str(exc)}')
def get_ninja_file_path(self, target: str) -> Path:
return self.build_dir / f'build.{target}.ninja'
- def get_merged_target_config(self, target: str) -> TargetConfig:
+ def get_merged_target_config(self, target: str) -> MergedTargetConfig:
assert target in self.targets, f'Target "{target}" not defined!'
-
- merged_config = copy.deepcopy(self.defaults)
- merged_config.update(asdict(self.targets[target]))
- return merged_config
+ return self._merged_target_configs[target]
class ShurikenArgumentParser:
@@ -186,8 +222,8 @@ class ShurikenArgumentParser:
help='Print generated Ninja file to stdout instead of writing to a file',
)
- subparser_build = subparsers.add_parser('build', help='Build project (default command)')
- subparser_compdb = subparsers.add_parser('compdb', help='Generate compilation database')
+ _subparser_build = subparsers.add_parser('build', help='Build project (default command)')
+ _subparser_compdb = subparsers.add_parser('compdb', help='Generate compilation database')
subparser_run = subparsers.add_parser('run', help='Build and run project')
subparser_run.add_argument(
@@ -196,6 +232,13 @@ class ShurikenArgumentParser:
help='Arguments that are passed to the application',
)
+ subparser_lint = subparsers.add_parser('lint', help='Lint the project')
+ subparser_lint.add_argument(
+ 'lint_files',
+ nargs='*',
+ help='Files to lint (defaults to all source files)',
+ )
+
subparser_clean = subparsers.add_parser('clean', help='Remove all build files of the current target')
subparser_clean.add_argument(
'--all',
@@ -204,11 +247,11 @@ class ShurikenArgumentParser:
help='Remove all generated files from all targets',
)
- subparser_dump_config = subparsers.add_parser(
+ _subparser_dump_config = subparsers.add_parser(
'dump-config',
help='Dumps the parsed config as well as the effective build target config (for debugging)',
)
- subparser_dyndep = subparsers.add_parser(
+ _subparser_dyndep = subparsers.add_parser(
'p1689-to-dyndeps',
help='(Internal command) Parse p1689 file and generate Ninja dyndeps',
)
@@ -298,6 +341,9 @@ class ShurikenCli:
case 'run':
self.run_project(selected_target)
+ case 'lint':
+ self.lint_project(selected_target)
+
case 'clean':
if self.args.clean_all:
self.log_info('Cleaning up *all* build targets')
@@ -413,6 +459,26 @@ class ShurikenCli:
except KeyboardInterrupt:
self.log_info('Keyboard interrupt')
+ def lint_project(self, target: str) -> None:
+ # Build project using Ninja (necessary before linting)
+ self.build_project(target)
+
+ # Collect files to lint
+ if self.args.lint_files:
+ files_args = shlex.join(self.args.lint_files)
+ else:
+ files_args = f"$(find {self.config.source_dir} -name '*.cpp' -or -name '*.cppm')"
+
+ self.log_info('Running clangd-tidy')
+ run_result = subprocess.run(
+ f'clangd-tidy -p {self.config.build_dir} --allow-extensions cpp,cppm {files_args}',
+ shell=True,
+ )
+
+ if run_result.returncode > 0:
+ self.log_error(f'clangd-tidy exited with return code {run_result.returncode}')
+ exit(1)
+
def clean_project(self, target: str) -> None:
ninja_file_path = self.config.get_ninja_file_path(target)
@@ -454,13 +520,14 @@ class ShurikenCli:
print('ninja_dyndep_version = 1\n')
for out, deps in build_dependencies.items():
if deps:
- print(f'build {out}: dyndep | {" ".join(deps)}')
+ print(f'build {out}: dyndep | {" ".join(str(dep) for dep in deps)}')
else:
print(f'build {out}: dyndep')
CompileTargetCpp = namedtuple('CompileTargetCpp', ['src', 'obj'])
CompileTargetCppm = namedtuple('CompileTargetCppm', ['src', 'obj', 'pcm'])
+CompileTargetStdModule = namedtuple('CompileTargetStdModule', ['original_src', 'generated_src', 'pcm'])
class NinjaFileGenerator:
@@ -470,6 +537,7 @@ class NinjaFileGenerator:
compile_targets_cpp: list[CompileTargetCpp]
compile_targets_cppm: list[CompileTargetCppm]
+ compile_targets_std_module: list[CompileTargetStdModule]
link_target_obj_files: list[str]
def __init__(self, *, config: ShurikenConfig, target: str):
@@ -487,6 +555,7 @@ class NinjaFileGenerator:
def collect_compile_targets(self) -> None:
self.compile_targets_cpp = []
self.compile_targets_cppm = []
+ self.compile_targets_std_module = []
# Compile targets for .cpp files
for src_file in self.find_source_files('**/*.cpp'):
@@ -499,12 +568,25 @@ class NinjaFileGenerator:
# Compile targets for .cppm files
for src_file in self.find_source_files('**/*.cppm'):
relative_name = src_file.relative_to(self.config.source_dir)
+ module_name = '.'.join(relative_name.with_suffix('.pcm').parts)
self.compile_targets_cppm.append(CompileTargetCppm(
src=str(src_file),
obj=f"$builddir/obj/{relative_name.with_suffix('.o')}",
- pcm=f"$builddir/pcm/{'.'.join(relative_name.with_suffix('.pcm').parts)}",
+ pcm=f"$builddir/pcm/{module_name}",
))
+ # Compile targets for standard library modules ("import std")
+ if self.config.enable_import_std:
+ # NOTE: Add std.compat to the list (here and a few other places) if we ever need it. For now, std is enough.
+ std_module_source_finder = StdModuleSourceFinder(config=self.config, target=self.target)
+ std_module_sources = std_module_source_finder.find_module_sources(['std'])
+ for module_name, module_src_path in std_module_sources.items():
+ self.compile_targets_std_module.append(CompileTargetStdModule(
+ original_src=str(module_src_path),
+ generated_src=f"$builddir/generated/stdlib/{module_name}.cppm",
+ pcm=f"$builddir/pcm/{module_name}.pcm",
+ ))
+
# Link target for output file (executable): gather all object files
self.link_target_obj_files = sorted(
target.obj for target in self.compile_targets_cpp + self.compile_targets_cppm
@@ -518,7 +600,25 @@ class NinjaFileGenerator:
config = self.config
target = self.target
target_config = self.target_config
- build_dir = self.config.build_dir
+ build_dir = config.build_dir
+ ninja_file_path = config.get_ninja_file_path(config.default_target)
+ cpp_compiler = target_config.cpp_compiler
+
+ # Generate list of all source input files to be used as implicit dependencies for the dyndep file
+ dyndep_file_implicit_inputs = ' '.join(sorted(
+ t.src for t in self.compile_targets_cpp + self.compile_targets_cppm
+ ))
+
+ # Generate list of all .dep files that are generated implicitly during dyndep file generation.
+ # This is mostly necessary so that Ninja can create the directories for these files beforehand.
+ dyndep_file_implicit_outputs = ' '.join(sorted(
+ f'{t.obj}.dep' for t in self.compile_targets_cpp + self.compile_targets_cppm
+ ))
+
+ # Generate list of generated source files (currently just the copied std module file)
+ generated_source_files = ' '.join(sorted(
+ t.generated_src for t in self.compile_targets_std_module
+ ))
# Generate Ninja file
return f"""
@@ -530,27 +630,39 @@ compdb_file = {build_dir}/compile_commands.json
dyndep_file = $builddir/.ninja_dyndep
# Compiler flags
-cpp_flags = -std={target_config.cpp_standard} -fprebuilt-module-path=$builddir/pcm/ {target_config.merged_cpp_flags}
+cpp_base_flags = -std={target_config.cpp_standard} -fprebuilt-module-path=$builddir/pcm/
+cpp_flags = $cpp_base_flags {target_config.merged_cpp_flags}
+cpp_std_module_flags = $cpp_base_flags -Wno-reserved-module-identifier -x c++-module
linker_flags = {target_config.merged_linker_flags}
linker_args = {target_config.merged_linker_args}
# -- RULES
+# Rule to copy an arbitrary file (or multiple) from in to out
+rule copy
+ command = cp $in $out
+
# Rule to compile a .cpp file to a .o file
rule cpp
- command = {target_config.cpp_compiler} $cpp_flags -c $in -o $out
+ depfile = $out.dep
+ command = {cpp_compiler} $cpp_flags -MMD -MF $out.dep -c $in -o $out
# Rule to compile a .cppm file (C++20 module) to a .o file ($out) and a .pcm file ($pcm_out)
rule cppm
- command = {target_config.cpp_compiler} $cpp_flags -c $in -o $out -fmodule-output=$pcm_out
+ depfile = $out.dep
+ command = {cpp_compiler} $cpp_flags -MMD -MF $out.dep -c $in -o $out -fmodule-output=$pcm_out
+
+# Rule to compile a standard library module to a .pcm file (e.g. "import std")
+rule std_module
+ command = {cpp_compiler} $cpp_std_module_flags --precompile $in -o $out
# Rule to link several .o files to an executable
rule link
- command = {target_config.cpp_compiler} $linker_flags -o $out $in $linker_args
+ command = {cpp_compiler} $linker_flags -o $out $in $linker_args
# Rule to generate a compilation database (JSON file) from the build targets defined in a Ninja file
rule generate_compdb
- command = ninja -f $in -t compdb cpp cppm > $out
+ command = ninja -f $in -t compdb cpp cppm std_module > $out
# Rule to generate a Ninja dyndep file for C++ module dependencies based on a compilation database
rule generate_dyndeps
@@ -560,13 +672,13 @@ rule generate_dyndeps
# -- STATIC BUILD TARGETS
# Generate compilation database from default target Ninja file
-build $compdb_file: generate_compdb {self.config.get_ninja_file_path(config.default_target)}
+build $compdb_file: generate_compdb {ninja_file_path} | generate_source_files
# Shortcut alias to generate compilation database
build compdb: phony $compdb_file
# Generate Ninja dyndep file from compilation database
-build $dyndep_file: generate_dyndeps $compdb_file
+build $dyndep_file | {dyndep_file_implicit_outputs}: generate_dyndeps $compdb_file | {dyndep_file_implicit_inputs}
# -- GENERATED BUILD TARGETS
@@ -575,15 +687,19 @@ build $dyndep_file: generate_dyndeps $compdb_file
build $builddir/{target_config.output_file}: link {' '.join(self.link_target_obj_files)}
default $builddir/{target_config.output_file}
+# Phony target that depends on all generated source files
+build generate_source_files: phony {generated_source_files}
+
{self.render_compile_targets()}
# End of build.{target}.ninja
""".strip()
def render_compile_targets(self) -> str:
- rendered_targets_cpp = [self.render_compile_target_cpp(target) for target in self.compile_targets_cpp]
- rendered_targets_cppm = [self.render_compile_target_cppm(target) for target in self.compile_targets_cppm]
- return '\n\n'.join(rendered_targets_cpp + rendered_targets_cppm)
+ rendered_targets = list(map(self.render_compile_target_std_module, self.compile_targets_std_module))
+ rendered_targets += list(map(self.render_compile_target_cpp, self.compile_targets_cpp))
+ rendered_targets += list(map(self.render_compile_target_cppm, self.compile_targets_cppm))
+ return '\n\n'.join(rendered_targets)
@staticmethod
def render_compile_target_cpp(target: CompileTargetCpp) -> str:
@@ -600,23 +716,34 @@ build {target.obj} | {target.pcm}: cppm {target.src} || $dyndep_file
pcm_out = {target.pcm}
""".strip()
+ @staticmethod
+ def render_compile_target_std_module(target: CompileTargetStdModule) -> str:
+ return f"""
+build {target.generated_src}: copy {target.original_src}
+
+build {target.pcm}: std_module {target.generated_src} || $dyndep_file
+ dyndep = $dyndep_file
+""".strip()
+
class BuildDependencyParser:
config: ShurikenConfig
target: str
- def __init__(self, config: ShurikenConfig, target: str):
+ def __init__(self, *, config: ShurikenConfig, target: str):
self.config = config
self.target = target
- def parse_build_dependencies(self, parsed_p1689: dict[str, Any]) -> dict[str, list[str]]:
+ def parse_build_dependencies(self, parsed_p1689: dict[str, Any]) -> dict[Path, list[Path]]:
# Shortcut variables
source_dir = self.config.source_dir
target_build_dir = self.config.build_dir / self.target
default_target_build_dir = self.config.build_dir / self.config.default_target
+ # Map of module names to PCM file paths
+ module_map: dict[str, Path] = {}
+
# Validate module name and path convention, construct map of module names to PCM file paths
- module_map = {}
for rule in parsed_p1689['rules']:
provides = rule.get('provides', [])
if provides:
@@ -624,15 +751,19 @@ class BuildDependencyParser:
assert provides[0]['is-interface'] is True, f"Rule provides non-interface module: {rule}"
module_name: str = provides[0]['logical-name']
- assert module_name not in module_map, f"Module {module_name} is provided more than once: {rule}"
+ assert module_name not in module_map, f'Module "{module_name}" is provided more than once: {rule}'
- expected_source_path = str(Path(source_dir, module_name.replace('.', '/') + '.cppm'))
- assert provides[0]['source-path'] == expected_source_path, f"Module name does not match source path: {rule}"
+ if module_name != 'std':
+ expected_source_path = str(Path(source_dir, module_name.replace('.', '/') + '.cppm'))
+ assert provides[0]['source-path'] == expected_source_path, \
+ f"Module name does not match source path: {rule}"
- module_map[module_name] = str(Path(target_build_dir, 'pcm', module_name + '.pcm'))
+ module_map[module_name] = Path(target_build_dir, 'pcm', module_name + '.pcm')
+
+ # Collect all module dependencies to generate dyndeps
+ build_dependencies: dict[Path, list[Path]] = {}
# Parse rules and get module dependencies
- build_dependencies = {}
for rule in parsed_p1689['rules']:
# Compilation database contains "build/{default_target}" paths, we need to map them to "build/{target}"
out = target_build_dir / Path(rule['primary-output']).relative_to(default_target_build_dir)
@@ -640,11 +771,65 @@ class BuildDependencyParser:
build_dependencies[out] = []
for require in rule.get('requires', []):
module_name = require['logical-name']
- assert module_name in module_map, f'Module {module_name} not found in module map'
+ assert module_name in module_map, f'Module "{module_name}" not found in module map'
build_dependencies[out].append(module_map[module_name])
return build_dependencies
+class StdModuleSourceFinder:
+ cpp_compiler: str
+
+ def __init__(self, *, config: ShurikenConfig, target: str):
+ self.cpp_compiler = config.get_merged_target_config(target).cpp_compiler
+
+ def find_module_sources(self, module_names: list[str]) -> dict[str, Path]:
+ # Get normalized path of standard library "module manifest" (JSON file containing source paths for std modules)
+ module_manifest_path = self.get_module_manifest_path()
+ module_manifest_base_dir = module_manifest_path.parent
+
+ # Parse module manifest JSON file
+ with module_manifest_path.open('r') as f:
+ module_manifest_json = json.load(f)
+
+ # Collect the module source paths
+ std_module_sources: dict[str, Path] = {}
+ for modules_item in module_manifest_json['modules']:
+ # Ignore modules we don't need (probably just std.compat)
+ module_name = modules_item['logical-name']
+ if module_name not in module_names:
+ continue
+
+ # The source path is usually relative to the module manifest file, convert to absolute path
+ module_src_path = Path(modules_item['source-path'])
+ if not module_src_path.is_absolute():
+ module_src_path = module_manifest_base_dir / module_src_path
+ if not module_src_path.is_file():
+ raise Exception(f'Source path for module "{module_name}" is not a file: {module_src_path}')
+
+ # Resolve symlinks and normalize path
+ std_module_sources[module_name] = module_src_path.resolve()
+
+ return std_module_sources
+
+ def get_module_manifest_path(self) -> Path:
+ # Use compiler to get the path to the module manifest JSON file
+ clang_cmd = [self.cpp_compiler, '-print-library-module-manifest-path']
+ clang_cmd_result = subprocess.run(clang_cmd, capture_output=True)
+
+ if clang_cmd_result.returncode != 0:
+ cmd_stderr = clang_cmd_result.stderr.decode().strip()
+ raise Exception(f'Subprocess "{" ".join(clang_cmd)}" failed. stderr:\n{cmd_stderr}')
+
+ # Read path from subprocess stdout
+ module_manifest_path = Path(clang_cmd_result.stdout.decode().strip())
+
+ if not module_manifest_path.is_file():
+ raise Exception(f'Could not locate stdlib module manifest: {module_manifest_path}')
+
+ # Resolve symlinks and normalize path
+ return module_manifest_path.resolve()
+
+
if __name__ == '__main__':
ShurikenCli().run()
diff --git a/vendor/README.md b/vendor/README.md
new file mode 100644
index 0000000..c729343
--- /dev/null
+++ b/vendor/README.md
@@ -0,0 +1,30 @@
+# Vendor dependencies
+
+TODO: This should be automated with shuriken at some point. For now, manual instructions.
+
+
+## System dependencies
+
+For now, SDL3 and SDL3_image need to be installed on the system. They should be vendored at some point.
+
+Same for freetype, which is required by RmlUi.
+
+
+## RmlUi
+
+[RmlUi](https://github.com/mikke89/RmlUi) is a UI library.
+
+Within the `vendor` directory, run:
+
+```shell
+# Either clone git repository or download and unpack archive (version 6.2)
+git clone git@github.com:mikke89/RmlUi.git RmlUi --branch 6.2
+cd RmlUi
+
+# Configure and build with cmake (use samples or standalone preset)
+cmake -G Ninja -B Build --preset samples -DBUILD_SHARED_LIBS=false -DRMLUI_BACKEND=SDL_SDLrenderer
+cmake --build Build
+
+# Optionally test by running a sample
+./Build/rmlui_sample_demo
+```