Files
MosisService/designer/main.cpp
omigamedev cbc03c0674 fix scroll direction: use RmlUi native touch API
Switch from mouse event emulation to RmlUi's native touch API
(ProcessTouchStart/Move/End) for natural scrolling behavior.

When dragging on scrollable content, the content now follows
the finger/cursor direction instead of scrolling in reverse.

- Designer: Use touch API when left mouse button is pressed
- Android kernel: Use touch API for all touch events
2026-01-20 20:30:08 +01:00

1395 lines
52 KiB
C++

// D:\Dev\Mosis\MosisService\designer\main.cpp
// Mosis Designer - Desktop UI development tool with hot-reload
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <RmlUi/Core.h>
#include <RmlUi/Lua.h>
#include <RmlUi/Debugger.h>
#include "RmlUi_Renderer_GL3.h"
#include "platform.h"
#include "desktop_platform.h"
#include "desktop_file_interface.h"
#include "hot_reload.h"
#include "testing/action_recorder.h"
#include "testing/action_player.h"
#include "testing/ui_inspector.h"
#include "testing/visual_capture.h"
#include <RmlUi/Lua/Interpreter.h>
#include <RmlUi/Lua/LuaType.h>
#include <iostream>
#include <fstream>
#include <sstream>
#include <filesystem>
#include <memory>
#include <chrono>
#include <ctime>
#include "src/desktop_sandbox.h"
#include "src/app_discovery.h"
namespace fs = std::filesystem;
// Global state
static GLFWwindow* g_window = nullptr;
static Rml::Context* g_context = nullptr;
static RenderInterface_GL3* g_render_interface = nullptr;
static mosis::DesktopPlatform* g_platform = nullptr;
static mosis::HotReload* g_hot_reload = nullptr;
static std::string g_current_document_path;
static std::string g_current_screen_url; // For hierarchy dump - tracks current screen
static bool g_needs_reload = false;
static std::unique_ptr<mosis::DesktopSandbox> g_sandbox;
// Resolution presets
static int g_width = 540;
static int g_height = 960;
// Test mode
enum class TestMode {
Interactive, // Normal interactive mode with hot-reload
Record, // Record user actions to JSON
Playback, // Playback actions from JSON
Screenshot, // Take a screenshot and exit
DumpHierarchy // Dump UI hierarchy to JSON and exit
};
static TestMode g_test_mode = TestMode::Interactive;
static std::string g_test_input_path; // Input file for playback
static std::string g_test_output_path; // Output file for record/screenshot/hierarchy
static mosis::testing::ActionRecorder* g_action_recorder = nullptr;
static mosis::testing::ActionPlayer* g_action_player = nullptr;
// Touch simulation state for natural scrolling behavior
static bool g_touch_active = false;
static int g_touch_start_x = 0;
static int g_touch_start_y = 0;
// Logging and hierarchy dump for testing
static std::string g_log_file_path; // Log file path (--log)
static std::string g_hierarchy_file_path; // Continuous hierarchy dump path (--hierarchy)
static std::ofstream g_log_file;
// Simulator mode
static bool g_simulator_mode = false;
static bool g_shell_mode = false; // Use persistent shell instead of direct documents
static std::string g_test_apps_path; // Path to test-apps directory
static std::string g_simulator_home_path; // Path to simulator home.rml
static std::string g_shell_path; // Path to shell.rml for shell mode
static std::string g_main_assets_path; // Path to main assets (for goHome)
static std::vector<mosis::AppInfo> g_discovered_apps;
static std::string g_current_app_id; // Currently running app (empty = home)
// Forward declarations
bool InitializeRmlUi(const std::string& assets_path, int fb_width, int fb_height);
void ShutdownRmlUi();
bool LoadDocument(const std::string& path);
void ReloadDocument();
void PopulateSimulatorApps();
// Helper to set the 'document' global in Lua to the current document
static void SetLuaDocumentGlobal(Rml::ElementDocument* doc) {
if (!doc) return;
lua_State* L = Rml::Lua::Interpreter::GetLuaState();
if (!L) return;
// Push the document using RmlUi's Lua type system
Rml::Lua::LuaType<Rml::ElementDocument>::push(L, doc, false);
lua_setglobal(L, "document");
std::cout << "Set Lua 'document' global to: " << doc->GetTitle() << std::endl;
}
// GLFW callbacks
static void ErrorCallback(int error, const char* description) {
std::cerr << "GLFW Error " << error << ": " << description << std::endl;
}
static void KeyCallback(GLFWwindow* window, int key, int scancode, int action, int mods) {
if (action != GLFW_PRESS) return;
// F5 - Reload
if (key == GLFW_KEY_F5) {
g_needs_reload = true;
}
// F12 - Toggle debugger
else if (key == GLFW_KEY_F12) {
Rml::Debugger::SetVisible(!Rml::Debugger::IsVisible());
}
// Escape - Back navigation
else if (key == GLFW_KEY_ESCAPE) {
// Record back button action if recording
if (g_action_recorder && g_action_recorder->IsRecording()) {
g_action_recorder->RecordButton("back");
}
// In simulator mode with an app running, go back to home
if (g_simulator_mode && !g_current_app_id.empty()) {
lua_State* L = Rml::Lua::Interpreter::GetLuaState();
lua_getglobal(L, "simulator");
if (lua_istable(L, -1)) {
lua_getfield(L, -1, "goHome");
if (lua_isfunction(L, -1)) {
if (lua_pcall(L, 0, 0, 0) != LUA_OK) {
std::cerr << "Error calling simulator.goHome: " << lua_tostring(L, -1) << std::endl;
lua_pop(L, 1);
}
// Re-populate apps after returning home
PopulateSimulatorApps();
} else {
lua_pop(L, 1);
}
}
lua_pop(L, 1);
} else if (g_context) {
g_context->ProcessKeyDown(Rml::Input::KI_ESCAPE, 0);
g_context->ProcessKeyUp(Rml::Input::KI_ESCAPE, 0);
}
}
// R - Toggle recording (in interactive mode)
else if (key == GLFW_KEY_R && g_test_mode == TestMode::Interactive) {
if (!g_action_recorder) {
g_action_recorder = new mosis::testing::ActionRecorder(g_width, g_height);
}
if (g_action_recorder->IsRecording()) {
g_action_recorder->StopRecording();
// Generate filename with timestamp
auto now = std::chrono::system_clock::now();
auto time_t = std::chrono::system_clock::to_time_t(now);
std::tm tm_buf;
localtime_s(&tm_buf, &time_t);
char buffer[64];
std::strftime(buffer, sizeof(buffer), "recording_%Y%m%d_%H%M%S.json", &tm_buf);
std::string filename = buffer;
if (g_action_recorder->SaveToFile(filename)) {
std::cout << "Recording saved to: " << filename << std::endl;
} else {
std::cerr << "Failed to save recording" << std::endl;
}
} else {
g_action_recorder->StartRecording();
std::cout << "Recording started. Press R again to stop." << std::endl;
}
}
}
static void MouseButtonCallback(GLFWwindow* window, int button, int action, int mods) {
if (!g_context) return;
double xpos, ypos;
glfwGetCursorPos(window, &xpos, &ypos);
// glfwGetCursorPos returns position in screen coordinates (same as window size)
// which may differ from framebuffer size on high-DPI displays.
// We need to scale to match the RmlUi context (which matches framebuffer).
int winWidth, winHeight;
glfwGetWindowSize(window, &winWidth, &winHeight);
int fbWidth, fbHeight;
glfwGetFramebufferSize(window, &fbWidth, &fbHeight);
// Scale cursor position: screen coords -> framebuffer coords -> RmlUi context
// On high DPI: winWidth=432, fbWidth=540, g_width=540
// Cursor in screen space needs to scale to framebuffer/context space
int mouseX = static_cast<int>(xpos * fbWidth / winWidth);
int mouseY = static_cast<int>(ypos * fbHeight / winHeight);
// Debug logging for click events
std::cout << "MouseButton: " << (action == GLFW_PRESS ? "DOWN" : "UP")
<< " at raw(" << xpos << "," << ypos << ") -> scaled(" << mouseX << "," << mouseY << ")"
<< " win=" << winWidth << "x" << winHeight << " fb=" << fbWidth << "x" << fbHeight << std::endl;
if (g_log_file.is_open()) {
g_log_file << "[DEBUG] MouseButton: " << (action == GLFW_PRESS ? "DOWN" : "UP")
<< " at raw(" << xpos << "," << ypos << ") -> scaled(" << mouseX << "," << mouseY << ")" << std::endl;
g_log_file.flush();
}
int key_modifier = 0;
if (mods & GLFW_MOD_CONTROL) key_modifier |= Rml::Input::KM_CTRL;
if (mods & GLFW_MOD_SHIFT) key_modifier |= Rml::Input::KM_SHIFT;
if (mods & GLFW_MOD_ALT) key_modifier |= Rml::Input::KM_ALT;
// Update mouse position before processing button event
g_context->ProcessMouseMove(mouseX, mouseY, key_modifier);
if (button == GLFW_MOUSE_BUTTON_LEFT) {
if (action == GLFW_PRESS) {
if (g_action_recorder && g_action_recorder->IsRecording()) {
g_action_recorder->RecordMouseDown(mouseX, mouseY);
}
// Use touch API for natural scrolling behavior
g_touch_active = true;
g_touch_start_x = mouseX;
g_touch_start_y = mouseY;
Rml::TouchList touches = {{0, Rml::Vector2f(static_cast<float>(mouseX), static_cast<float>(mouseY))}};
g_context->ProcessTouchStart(touches, 0);
} else if (action == GLFW_RELEASE) {
if (g_action_recorder && g_action_recorder->IsRecording()) {
g_action_recorder->RecordMouseUp(mouseX, mouseY);
}
// End touch for natural scrolling
g_touch_active = false;
Rml::TouchList touches = {{0, Rml::Vector2f(static_cast<float>(mouseX), static_cast<float>(mouseY))}};
g_context->ProcessTouchEnd(touches, 0);
}
}
}
static void CursorPosCallback(GLFWwindow* window, double xpos, double ypos) {
if (!g_context) return;
// Scale from screen coordinates to framebuffer/RmlUi context coordinates
int winWidth, winHeight;
glfwGetWindowSize(window, &winWidth, &winHeight);
int fbWidth, fbHeight;
glfwGetFramebufferSize(window, &fbWidth, &fbHeight);
int mouseX = static_cast<int>(xpos * fbWidth / winWidth);
int mouseY = static_cast<int>(ypos * fbHeight / winHeight);
// Always update mouse position for hover effects
g_context->ProcessMouseMove(mouseX, mouseY, 0);
// If touch is active (left button pressed), also send touch move for natural scrolling
if (g_touch_active) {
Rml::TouchList touches = {{0, Rml::Vector2f(static_cast<float>(mouseX), static_cast<float>(mouseY))}};
g_context->ProcessTouchMove(touches, 0);
}
}
static void ScrollCallback(GLFWwindow* window, double xoffset, double yoffset) {
if (!g_context) return;
g_context->ProcessMouseWheel(static_cast<float>(-yoffset), 0);
}
static void FramebufferSizeCallback(GLFWwindow* window, int width, int height) {
// Update render interface viewport when framebuffer size changes (DPI scaling)
if (g_render_interface) {
g_render_interface->SetViewport(width, height);
std::cout << "Framebuffer resized to: " << width << "x" << height << std::endl;
}
}
// System interface for RmlUi
class DesktopSystemInterface : public Rml::SystemInterface {
public:
double GetElapsedTime() override {
return glfwGetTime();
}
bool LogMessage(Rml::Log::Type type, const Rml::String& message) override {
const char* type_str = "";
switch (type) {
case Rml::Log::LT_ERROR: type_str = "[ERROR]"; break;
case Rml::Log::LT_WARNING: type_str = "[WARN]"; break;
case Rml::Log::LT_INFO: type_str = "[INFO]"; break;
default: type_str = "[DEBUG]"; break;
}
std::cout << type_str << " " << message << std::endl;
// Also log to file if configured
if (g_log_file.is_open()) {
g_log_file << type_str << " " << message << std::endl;
g_log_file.flush();
}
return true;
}
};
static DesktopSystemInterface g_system_interface;
static void PrintUsage() {
std::cout << "Usage: mosis-designer [options] [document.rml]\n"
<< "\nOptions:\n"
<< " --resolution WxH Set window resolution (default: 540x960)\n"
<< " --assets PATH Set assets directory (default: derived from document)\n"
<< " --log FILE Write all log messages to file\n"
<< " --hierarchy FILE Continuously dump UI hierarchy to JSON\n"
<< "\nSimulator mode:\n"
<< " --simulator Run in simulator mode (uses shell by default)\n"
<< " --no-shell Disable shell (use direct document loading)\n"
<< " --test-apps PATH Path to test-apps directory (default: ./test-apps)\n"
<< "\nTest modes:\n"
<< " --record FILE Record user actions to JSON file\n"
<< " --playback FILE Playback actions from JSON file\n"
<< " --screenshot FILE Take screenshot and exit\n"
<< " --dump-hierarchy FILE Dump UI hierarchy to JSON and exit\n"
<< "\nKeys:\n"
<< " F5 Reload document\n"
<< " F12 Toggle RmlUi debugger\n"
<< " ESC Back navigation\n"
<< " R Start/Stop recording (in interactive mode)\n"
<< std::endl;
}
int main(int argc, char* argv[]) {
std::cout << "Mosis Designer v0.1.0" << std::endl;
// Parse arguments
std::string document_path;
std::string assets_path; // Will be derived from document path if not specified
bool assets_path_specified = false;
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg == "--help" || arg == "-h") {
PrintUsage();
return 0;
} else if (arg == "--resolution" && i + 1 < argc) {
std::string res = argv[++i];
size_t x = res.find('x');
if (x != std::string::npos) {
g_width = std::stoi(res.substr(0, x));
g_height = std::stoi(res.substr(x + 1));
}
} else if (arg == "--assets" && i + 1 < argc) {
assets_path = argv[++i];
assets_path_specified = true;
} else if (arg == "--log" && i + 1 < argc) {
g_log_file_path = argv[++i];
} else if (arg == "--hierarchy" && i + 1 < argc) {
g_hierarchy_file_path = argv[++i];
} else if (arg == "--record" && i + 1 < argc) {
g_test_mode = TestMode::Record;
g_test_output_path = argv[++i];
} else if (arg == "--playback" && i + 1 < argc) {
g_test_mode = TestMode::Playback;
g_test_input_path = argv[++i];
} else if (arg == "--screenshot-after" && i + 1 < argc) {
// Capture screenshot after playback completes (used with --playback)
g_test_output_path = argv[++i];
} else if (arg == "--screenshot" && i + 1 < argc) {
g_test_mode = TestMode::Screenshot;
g_test_output_path = argv[++i];
} else if (arg == "--dump-hierarchy" && i + 1 < argc) {
g_test_mode = TestMode::DumpHierarchy;
g_test_output_path = argv[++i];
} else if (arg == "--simulator") {
g_simulator_mode = true;
g_shell_mode = true; // Shell mode is implicit in simulator mode
} else if (arg == "--no-shell") {
g_shell_mode = false; // Disable shell (use direct document loading)
} else if (arg == "--test-apps" && i + 1 < argc) {
g_test_apps_path = argv[++i];
} else if (arg[0] != '-') {
document_path = arg;
}
}
// Simulator mode setup
if (g_simulator_mode) {
// Default test-apps path
if (g_test_apps_path.empty()) {
// Get executable directory for relative path resolution
fs::path exe_path = fs::absolute(argv[0]).parent_path();
// Try to find test-apps relative to executable or working directory
std::vector<fs::path> search_paths = {
"test-apps",
"../test-apps",
"../../test-apps",
"../../../test-apps",
exe_path / "test-apps",
exe_path / "../test-apps",
exe_path / "../../test-apps",
exe_path / "../../../test-apps", // From build/Release -> MosisService
};
for (const auto& path : search_paths) {
if (fs::exists(path) && fs::is_directory(path)) {
g_test_apps_path = fs::absolute(path).string();
break;
}
}
} else {
g_test_apps_path = fs::absolute(g_test_apps_path).string();
}
// Find main assets for simulator mode
// Look for src/main/assets/apps/shell/shell.rml relative to test-apps
fs::path test_apps_fs = fs::path(g_test_apps_path);
std::vector<fs::path> shell_search_paths = {
test_apps_fs.parent_path() / "src" / "main" / "assets" / "apps" / "shell" / "shell.rml",
fs::path("src/main/assets/apps/shell/shell.rml"),
fs::absolute("src/main/assets/apps/shell/shell.rml"),
};
for (const auto& path : shell_search_paths) {
if (fs::exists(path)) {
g_simulator_home_path = fs::absolute(path).string();
break;
}
}
// Override document path to shell
if (!g_simulator_home_path.empty()) {
// Set the main assets path for proper resource loading
// g_simulator_home_path is now shell.rml, go up 3 levels to get assets path
g_main_assets_path = fs::path(g_simulator_home_path).parent_path().parent_path().parent_path().string();
g_shell_path = g_simulator_home_path;
document_path = g_shell_path;
std::cout << "Shell mode enabled" << std::endl;
std::cout << "Shell: " << g_shell_path << std::endl;
std::cout << "Simulator mode enabled" << std::endl;
std::cout << "Test apps path: " << g_test_apps_path << std::endl;
} else {
std::cerr << "Warning: Could not find shell.rml for simulator" << std::endl;
g_simulator_mode = false;
}
}
// Default document
if (document_path.empty()) {
document_path = "apps/shell/shell.rml";
}
// Derive assets path from document path if not specified
// The document path should be something like .../assets/apps/home/home.rml
// We want to find the "assets" directory in the path
if (!assets_path_specified) {
fs::path doc_path = fs::absolute(document_path);
fs::path current = doc_path.parent_path();
fs::path prev_path;
// Walk up the directory tree looking for a folder that ends with "assets"
// or contains typical asset folders like "apps", "ui", "fonts"
while (!current.empty() && current != prev_path) {
std::string folder_name = current.filename().string();
if (folder_name == "assets") {
assets_path = current.string();
break;
}
// Check if this folder contains typical asset subfolders
if (fs::exists(current / "apps") && fs::exists(current / "ui")) {
assets_path = current.string();
break;
}
prev_path = current;
current = current.parent_path();
}
// Fall back options if no standard assets folder found
if (assets_path.empty()) {
// If the document exists, use its parent directory as assets path
fs::path doc_path = fs::absolute(document_path);
if (fs::exists(doc_path)) {
assets_path = doc_path.parent_path().string();
} else {
// Try executable's assets folder
fs::path exe_path = fs::path(argv[0]).parent_path();
if (exe_path.empty()) {
exe_path = ".";
}
fs::path exe_assets = fs::absolute(exe_path) / "assets";
if (fs::exists(exe_assets)) {
assets_path = exe_assets.string();
} else {
// Last resort: current directory
assets_path = fs::absolute(".").string();
}
}
}
}
// Make document_path absolute if it's relative and exists
if (!fs::path(document_path).is_absolute()) {
fs::path abs_doc = fs::absolute(document_path);
if (fs::exists(abs_doc)) {
document_path = abs_doc.string();
}
}
// Make assets_path absolute
assets_path = fs::absolute(assets_path).string();
std::cout << "Assets path: " << assets_path << std::endl;
// Determine main assets path (where home.rml lives)
// This is important for goHome() when running test apps
g_main_assets_path = assets_path;
// Check if we're running from test-apps folder
fs::path assets_fs = fs::path(assets_path);
if (assets_fs.filename().string().find("com.") == 0 ||
assets_fs.parent_path().filename() == "test-apps") {
// Running a test app - find the main assets
fs::path test_apps_root = assets_fs.parent_path();
if (test_apps_root.filename() != "test-apps") {
test_apps_root = test_apps_root.parent_path();
}
fs::path main_assets = test_apps_root.parent_path() / "src" / "main" / "assets";
if (fs::exists(main_assets / "apps" / "shell" / "shell.rml")) {
g_main_assets_path = main_assets.string();
std::cout << "Main assets: " << g_main_assets_path << std::endl;
}
}
std::cout << "Resolution: " << g_width << "x" << g_height << std::endl;
// Print mode info
if (g_test_mode == TestMode::Interactive) {
std::cout << "Press F5 to reload, F12 for debugger, ESC for back, R to record" << std::endl;
} else if (g_test_mode == TestMode::Record) {
std::cout << "Recording mode: actions will be saved to " << g_test_output_path << std::endl;
} else if (g_test_mode == TestMode::Playback) {
std::cout << "Playback mode: playing " << g_test_input_path << std::endl;
} else if (g_test_mode == TestMode::Screenshot) {
std::cout << "Screenshot mode: will save to " << g_test_output_path << std::endl;
} else if (g_test_mode == TestMode::DumpHierarchy) {
std::cout << "Hierarchy dump mode: will save to " << g_test_output_path << std::endl;
}
// Open log file if specified
if (!g_log_file_path.empty()) {
g_log_file.open(g_log_file_path, std::ios::out | std::ios::trunc);
if (g_log_file.is_open()) {
std::cout << "Logging to: " << g_log_file_path << std::endl;
} else {
std::cerr << "Warning: Failed to open log file: " << g_log_file_path << std::endl;
}
}
// Log hierarchy file path if specified
if (!g_hierarchy_file_path.empty()) {
std::cout << "Hierarchy dump to: " << g_hierarchy_file_path << std::endl;
}
// Initialize GLFW
glfwSetErrorCallback(ErrorCallback);
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW" << std::endl;
return 1;
}
// Create window with OpenGL 3.3 core context
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
// Create window with document name in title
std::string window_title = "Mosis Designer - " + fs::path(document_path).filename().string();
g_window = glfwCreateWindow(g_width, g_height, window_title.c_str(), nullptr, nullptr);
if (!g_window) {
std::cerr << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return 1;
}
glfwMakeContextCurrent(g_window);
glfwSwapInterval(1); // VSync
// Set callbacks
glfwSetKeyCallback(g_window, KeyCallback);
glfwSetMouseButtonCallback(g_window, MouseButtonCallback);
glfwSetCursorPosCallback(g_window, CursorPosCallback);
glfwSetScrollCallback(g_window, ScrollCallback);
glfwSetFramebufferSizeCallback(g_window, FramebufferSizeCallback);
// Load OpenGL functions with GLAD
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
std::cerr << "Failed to initialize GLAD" << std::endl;
glfwDestroyWindow(g_window);
glfwTerminate();
return 1;
}
std::cout << "OpenGL " << glGetString(GL_VERSION) << std::endl;
// Get actual framebuffer size (may differ from window size on HiDPI displays)
int fb_width, fb_height;
glfwGetFramebufferSize(g_window, &fb_width, &fb_height);
std::cout << "Framebuffer size: " << fb_width << "x" << fb_height << std::endl;
// Create platform abstraction and set as global singleton
auto platform = std::make_unique<mosis::DesktopPlatform>(g_window, g_width, g_height);
platform->SetAssetsPath(assets_path);
g_platform = platform.get();
mosis::SetPlatform(std::move(platform));
// Initialize RmlUi (use framebuffer size for rendering, logical size for context)
if (!InitializeRmlUi(assets_path, fb_width, fb_height)) {
std::cerr << "Failed to initialize RmlUi" << std::endl;
glfwDestroyWindow(g_window);
glfwTerminate();
return 1;
}
// Load initial document
if (!LoadDocument(document_path)) {
std::cerr << "Failed to load document: " << document_path << std::endl;
}
// Note: In simulator mode, the Lua script will call simulator.getApps()
// when the document is ready, so we don't need to call PopulateSimulatorApps() here
// Initialize test mode components
if (g_test_mode == TestMode::Record) {
g_action_recorder = new mosis::testing::ActionRecorder(g_width, g_height);
g_action_recorder->StartRecording();
std::cout << "Recording started. Close window to save." << std::endl;
} else if (g_test_mode == TestMode::Playback) {
g_action_player = new mosis::testing::ActionPlayer(g_context);
if (!g_action_player->LoadFromFile(g_test_input_path)) {
std::cerr << "Failed to load actions from: " << g_test_input_path << std::endl;
delete g_action_player;
g_action_player = nullptr;
} else {
g_action_player->Start();
std::cout << "Playback started..." << std::endl;
}
}
// Set up hot-reload (only in interactive mode)
if (g_test_mode == TestMode::Interactive) {
g_hot_reload = new mosis::HotReload(assets_path, []() {
g_needs_reload = true;
});
g_hot_reload->Start();
std::cout << "Hot-reload enabled for: " << assets_path << std::endl;
}
// For screenshot/hierarchy modes, render one frame then capture
if (g_test_mode == TestMode::Screenshot || g_test_mode == TestMode::DumpHierarchy) {
// Render one frame
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
if (g_context) {
g_context->Update();
g_render_interface->BeginFrame();
g_context->Render();
g_render_interface->EndFrame(0);
}
// Capture screenshot BEFORE swap (glReadPixels reads from back buffer)
if (g_test_mode == TestMode::Screenshot) {
mosis::testing::VisualCapture capture(fb_width, fb_height);
if (capture.CaptureScreenshot(g_test_output_path)) {
std::cout << "Screenshot saved to: " << g_test_output_path << std::endl;
} else {
std::cerr << "Failed to save screenshot" << std::endl;
}
} else {
mosis::testing::UIInspector inspector(g_context);
if (inspector.SaveHierarchy(g_test_output_path)) {
std::cout << "UI hierarchy saved to: " << g_test_output_path << std::endl;
} else {
std::cerr << "Failed to save hierarchy" << std::endl;
}
}
glfwSwapBuffers(g_window);
// Cleanup and exit
ShutdownRmlUi();
glfwDestroyWindow(g_window);
glfwTerminate();
return 0;
}
// Main loop
while (!glfwWindowShouldClose(g_window)) {
glfwPollEvents();
// Handle hot-reload
if (g_needs_reload) {
g_needs_reload = false;
ReloadDocument();
}
// Update action playback
if (g_action_player && g_action_player->IsPlaying()) {
g_action_player->Update();
// Check if playback finished
if (g_action_player->IsFinished()) {
std::cout << "Playback complete" << std::endl;
// Capture screenshot if --screenshot-after was specified
if (!g_test_output_path.empty()) {
int fb_width, fb_height;
glfwGetFramebufferSize(g_window, &fb_width, &fb_height);
mosis::testing::VisualCapture capture(fb_width, fb_height);
if (capture.CaptureScreenshot(g_test_output_path)) {
std::cout << "Screenshot saved to: " << g_test_output_path << std::endl;
} else {
std::cerr << "Failed to save screenshot" << std::endl;
}
}
// Exit after playback
glfwSetWindowShouldClose(g_window, GLFW_TRUE);
}
}
// Clear
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// Update sandbox (process timers)
if (g_sandbox) {
g_sandbox->Update();
}
// Update and render
if (g_context) {
g_context->Update();
g_render_interface->BeginFrame();
g_context->Render();
g_render_interface->EndFrame(0);
}
// Dump UI hierarchy each frame if configured
if (!g_hierarchy_file_path.empty() && g_context) {
mosis::testing::UIInspector inspector(g_context);
inspector.SetCurrentScreen(g_current_screen_url);
inspector.SaveHierarchy(g_hierarchy_file_path);
}
glfwSwapBuffers(g_window);
}
// Save recording if in record mode
if (g_action_recorder && g_action_recorder->IsRecording()) {
g_action_recorder->StopRecording();
if (g_action_recorder->SaveToFile(g_test_output_path)) {
std::cout << "Recording saved to: " << g_test_output_path << std::endl;
} else {
std::cerr << "Failed to save recording" << std::endl;
}
}
// Cleanup
delete g_action_recorder;
delete g_action_player;
if (g_hot_reload) {
g_hot_reload->Stop();
delete g_hot_reload;
}
if (g_log_file.is_open()) {
g_log_file.close();
}
g_sandbox.reset();
ShutdownRmlUi();
glfwDestroyWindow(g_window);
glfwTerminate();
return 0;
}
bool InitializeRmlUi(const std::string& assets_path, int fb_width, int fb_height) {
// Create render interface
g_render_interface = new RenderInterface_GL3();
if (!*g_render_interface) {
std::cerr << "Failed to create GL3 render interface" << std::endl;
return false;
}
// Use framebuffer size (physical pixels) for the render interface viewport
g_render_interface->SetViewport(fb_width, fb_height);
// Initialize RmlUi
Rml::SetSystemInterface(&g_system_interface);
Rml::SetFileInterface(&g_platform->GetFileInterface());
Rml::SetRenderInterface(g_render_interface);
if (!Rml::Initialise()) {
std::cerr << "Failed to initialize RmlUi" << std::endl;
return false;
}
// Initialize Lua bindings
Rml::Lua::Initialise();
// Get Lua state
lua_State* L = Rml::Lua::Interpreter::GetLuaState();
// Initialize sandbox with APIs (timers, JSON, crypto, fs)
mosis::DesktopSandboxConfig sandbox_config;
sandbox_config.data_root = assets_path + "/sandbox_data";
g_sandbox = std::make_unique<mosis::DesktopSandbox>(sandbox_config);
g_sandbox->RegisterAPIs(L);
// Register loadScreen function for navigation
lua_pushcfunction(L, [](lua_State* L) -> int {
const char* path = luaL_checkstring(L, 1);
if (!g_context) {
lua_pushboolean(L, false);
return 1;
}
// Close existing documents (except debugger)
while (g_context->GetNumDocuments() > 1) {
auto* doc = g_context->GetDocument(0);
if (doc && doc->GetSourceURL().find("__rmlui") == std::string::npos) {
doc->Close();
} else {
break;
}
}
// Load new document
auto* document = g_context->LoadDocument(path);
if (document) {
document->Show();
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
g_current_document_path = path;
g_current_screen_url = path; // Track current screen for hierarchy dump
// Log using RmlUi logging so it appears in log file
Rml::Log::Message(Rml::Log::LT_INFO, "Loaded screen: %s", path);
lua_pushboolean(L, true);
} else {
Rml::Log::Message(Rml::Log::LT_ERROR, "Failed to load screen: %s", path);
lua_pushboolean(L, false);
}
return 1;
});
lua_setglobal(L, "loadScreen");
// Register goHome function to return to home screen
lua_pushcfunction(L, [](lua_State* L) -> int {
if (!g_context) {
lua_pushboolean(L, false);
return 1;
}
std::cout << "goHome called - returning to home screen" << std::endl;
// Reset sandbox back to home context
if (g_sandbox && !g_current_app_id.empty()) {
g_sandbox->UnregisterAPIs(L);
g_sandbox->Reset();
mosis::DesktopSandboxConfig config;
config.app_id = "com.mosis.home";
config.data_root = g_main_assets_path + "/sandbox_data";
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
g_sandbox->RegisterAPIs(L);
std::cout << "Sandbox reset to home context" << std::endl;
}
// Close existing documents (except debugger)
while (g_context->GetNumDocuments() > 1) {
auto* doc = g_context->GetDocument(0);
if (doc && doc->GetSourceURL().find("__rmlui") == std::string::npos) {
doc->Close();
} else {
break;
}
}
// Load shell (which shows home by default)
std::string home_path = (fs::path(g_main_assets_path) / "apps" / "shell" / "shell.rml").string();
std::cout << "Loading shell from: " << home_path << std::endl;
auto* document = g_context->LoadDocument(home_path);
if (document) {
document->Show();
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
g_current_document_path = home_path;
g_current_screen_url = home_path;
g_current_app_id = ""; // Clear current app
Rml::Log::Message(Rml::Log::LT_INFO, "Returned to home screen");
lua_pushboolean(L, true);
} else {
Rml::Log::Message(Rml::Log::LT_ERROR, "Failed to load home screen from: %s", home_path.c_str());
lua_pushboolean(L, false);
}
return 1;
});
lua_setglobal(L, "goHome");
// Register switchAppSandbox function for third-party app launching
lua_pushcfunction(L, [](lua_State* L) -> int {
const char* app_id = luaL_checkstring(L, 1);
const char* install_path = luaL_checkstring(L, 2);
std::cout << "switchAppSandbox called for: " << app_id << " at " << install_path << std::endl;
// Store current app ID
g_current_app_id = app_id;
// Reset sandbox for the new app
if (g_sandbox) {
g_sandbox->UnregisterAPIs(L);
g_sandbox->Reset();
// Re-configure sandbox with app-specific data root
mosis::DesktopSandboxConfig config;
config.app_id = app_id;
config.data_root = std::string(install_path) + "/sandbox_data";
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
g_sandbox->RegisterAPIs(L);
std::cout << "Sandbox switched to: " << app_id << std::endl;
}
lua_pushboolean(L, true);
return 1;
});
lua_setglobal(L, "switchAppSandbox");
// Register loadAppContent function for shell-based app loading
// loadAppContent(element, path) - loads RML content into an element's inner_rml
lua_pushcfunction(L, [](lua_State* L) -> int {
// Get element from first argument (RmlUi element userdata)
Rml::Element* element = Rml::Lua::LuaType<Rml::Element>::check(L, 1);
const char* path = luaL_checkstring(L, 2);
if (!element) {
std::cerr << "loadAppContent: Invalid element" << std::endl;
lua_pushboolean(L, false);
return 1;
}
// Resolve path relative to assets directory
std::string full_path;
if (fs::path(path).is_absolute()) {
full_path = path;
} else {
full_path = (fs::path(g_main_assets_path) / path).string();
}
// Read file content
std::ifstream file(full_path);
if (!file.is_open()) {
std::cerr << "loadAppContent: Cannot open file: " << full_path << std::endl;
lua_pushboolean(L, false);
return 1;
}
std::stringstream buffer;
buffer << file.rdbuf();
std::string content = buffer.str();
// Set as inner_rml
element->SetInnerRML(content);
Rml::Log::Message(Rml::Log::LT_INFO, "Loaded app content from: %s", path);
lua_pushboolean(L, true);
return 1;
});
lua_setglobal(L, "loadAppContent");
std::cout << "Registered Lua loadScreen, goHome, switchAppSandbox, and loadAppContent functions" << std::endl;
// Register simulator API (if in simulator mode)
if (g_simulator_mode) {
// Create simulator table
lua_newtable(L);
// simulator.launchApp(entry, path, id)
lua_pushcfunction(L, [](lua_State* L) -> int {
const char* entry = luaL_checkstring(L, 1);
const char* app_path = luaL_checkstring(L, 2);
const char* app_id = luaL_checkstring(L, 3);
if (!g_context) {
lua_pushboolean(L, false);
return 1;
}
std::cout << "Simulator: Launching app " << app_id << std::endl;
std::cout << " Entry: " << entry << std::endl;
std::cout << " Path: " << app_path << std::endl;
// Store current app ID
g_current_app_id = app_id;
// Reset sandbox for the new app
if (g_sandbox) {
g_sandbox->UnregisterAPIs(L);
g_sandbox->Reset();
// Re-configure sandbox with app-specific data root
mosis::DesktopSandboxConfig config;
config.app_id = app_id;
config.data_root = std::string(app_path) + "/sandbox_data";
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
g_sandbox->RegisterAPIs(L);
}
// Close existing documents
while (g_context->GetNumDocuments() > 0) {
auto* doc = g_context->GetDocument(0);
if (doc) doc->Close();
}
// Load the app
auto* document = g_context->LoadDocument(entry);
if (document) {
document->Show();
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
g_current_document_path = entry;
g_current_screen_url = entry;
std::cout << "Simulator: App launched successfully" << std::endl;
lua_pushboolean(L, true);
} else {
std::cerr << "Simulator: Failed to load app: " << entry << std::endl;
g_current_app_id.clear();
lua_pushboolean(L, false);
}
return 1;
});
lua_setfield(L, -2, "launchApp");
// simulator.goHome()
lua_pushcfunction(L, [](lua_State* L) -> int {
if (!g_context || g_simulator_home_path.empty()) {
lua_pushboolean(L, false);
return 1;
}
std::cout << "Simulator: Returning to home" << std::endl;
g_current_app_id.clear();
// Reset sandbox to default state
if (g_sandbox) {
g_sandbox->UnregisterAPIs(L);
g_sandbox->Reset();
mosis::DesktopSandboxConfig config;
config.data_root = "./sandbox_data";
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
g_sandbox->RegisterAPIs(L);
}
// Close existing documents
while (g_context->GetNumDocuments() > 0) {
auto* doc = g_context->GetDocument(0);
if (doc) doc->Close();
}
// Load simulator home
auto* document = g_context->LoadDocument(g_simulator_home_path);
if (document) {
document->Show();
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
g_current_document_path = g_simulator_home_path;
g_current_screen_url = g_simulator_home_path;
// Re-populate apps in home screen
// This will be done by calling setApps from C++ after document loads
lua_pushboolean(L, true);
} else {
lua_pushboolean(L, false);
}
return 1;
});
lua_setfield(L, -2, "goHome");
// simulator.isInApp()
lua_pushcfunction(L, [](lua_State* L) -> int {
lua_pushboolean(L, !g_current_app_id.empty());
return 1;
});
lua_setfield(L, -2, "isInApp");
// simulator.getCurrentAppId()
lua_pushcfunction(L, [](lua_State* L) -> int {
if (g_current_app_id.empty()) {
lua_pushnil(L);
} else {
lua_pushstring(L, g_current_app_id.c_str());
}
return 1;
});
lua_setfield(L, -2, "getCurrentAppId");
// simulator.getApps() - returns the list of discovered apps
lua_pushcfunction(L, [](lua_State* L) -> int {
// Discover apps if not already done
if (g_discovered_apps.empty() && !g_test_apps_path.empty()) {
g_discovered_apps = mosis::AppDiscovery::DiscoverApps(g_test_apps_path);
}
// Create apps table
lua_newtable(L);
int app_index = 1;
for (const auto& app : g_discovered_apps) {
lua_pushinteger(L, app_index++);
lua_newtable(L);
lua_pushstring(L, app.id.c_str());
lua_setfield(L, -2, "id");
lua_pushstring(L, app.name.c_str());
lua_setfield(L, -2, "name");
lua_pushstring(L, app.GetIconPath().c_str());
lua_setfield(L, -2, "icon");
lua_pushstring(L, app.GetEntryPath().c_str());
lua_setfield(L, -2, "entry");
lua_pushstring(L, app.app_path.c_str());
lua_setfield(L, -2, "path");
lua_pushstring(L, app.description.c_str());
lua_setfield(L, -2, "description");
lua_settable(L, -3); // apps[index] = app_table
}
return 1;
});
lua_setfield(L, -2, "getApps");
lua_setglobal(L, "simulator");
std::cout << "Registered simulator Lua API" << std::endl;
// Also register mosis.apps API for compatibility with home.lua
// Create mosis table (or get existing)
lua_getglobal(L, "mosis");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
// Create mosis.apps table
lua_newtable(L);
// mosis.apps.getInstalled() - returns installed apps
lua_pushcfunction(L, [](lua_State* L) -> int {
// Discover apps if not already done
if (g_discovered_apps.empty() && !g_test_apps_path.empty()) {
g_discovered_apps = mosis::AppDiscovery::DiscoverApps(g_test_apps_path);
std::cout << "Discovered " << g_discovered_apps.size() << " apps in " << g_test_apps_path << std::endl;
}
// Create apps table (array format expected by home.lua)
lua_newtable(L);
int app_index = 1;
for (const auto& app : g_discovered_apps) {
lua_pushinteger(L, app_index++);
lua_newtable(L);
lua_pushstring(L, app.name.c_str());
lua_setfield(L, -2, "name");
lua_pushstring(L, app.id.c_str());
lua_setfield(L, -2, "package_id");
lua_pushstring(L, app.GetIconPath().c_str());
lua_setfield(L, -2, "icon");
lua_pushboolean(L, app.is_system_app);
lua_setfield(L, -2, "is_system_app");
lua_pushstring(L, app.app_path.c_str());
lua_setfield(L, -2, "install_path");
lua_pushstring(L, app.entry.c_str());
lua_setfield(L, -2, "entry_point");
lua_settable(L, -3); // apps[index] = app_table
}
return 1;
});
lua_setfield(L, -2, "getInstalled");
// mosis.apps.launch(package_id) - launch an app
lua_pushcfunction(L, [](lua_State* L) -> int {
const char* package_id = luaL_checkstring(L, 1);
// Find the app
for (const auto& app : g_discovered_apps) {
if (app.id == package_id) {
std::cout << "mosis.apps.launch: Starting " << package_id << std::endl;
g_current_app_id = package_id;
// Reset sandbox for the new app
if (g_sandbox) {
g_sandbox->UnregisterAPIs(L);
g_sandbox->Reset();
mosis::DesktopSandboxConfig config;
config.app_id = package_id;
config.data_root = app.app_path + "/sandbox_data";
g_sandbox = std::make_unique<mosis::DesktopSandbox>(config);
g_sandbox->RegisterAPIs(L);
}
lua_pushboolean(L, true);
return 1;
}
}
std::cerr << "mosis.apps.launch: App not found: " << package_id << std::endl;
lua_pushboolean(L, false);
return 1;
});
lua_setfield(L, -2, "launch");
lua_setfield(L, -2, "apps"); // mosis.apps = apps_table
lua_setglobal(L, "mosis");
std::cout << "Registered mosis.apps Lua API" << std::endl;
}
// Load fonts - search for fonts directory in multiple locations
std::string fonts_root;
std::vector<std::string> font_search_paths = {
assets_path + "/fonts",
assets_path + "/../src/main/assets/fonts", // If assets_path is test-app dir
assets_path + "/../../src/main/assets/fonts",
std::filesystem::absolute("src/main/assets/fonts").string(),
std::filesystem::absolute("assets/fonts").string(), // For simulator mode
};
for (const auto& search_path : font_search_paths) {
if (std::filesystem::exists(search_path + "/LatoLatin-Regular.ttf")) {
fonts_root = search_path;
break;
}
}
if (fonts_root.empty()) {
std::cerr << "Warning: Could not find fonts directory" << std::endl;
} else {
std::cout << "Fonts path: " << fonts_root << std::endl;
std::vector<std::string> fonts = {
fonts_root + "/LatoLatin-Regular.ttf",
fonts_root + "/LatoLatin-Bold.ttf",
fonts_root + "/LatoLatin-Italic.ttf",
};
for (const auto& font : fonts) {
if (!Rml::LoadFontFace(font)) {
std::cerr << "Warning: Failed to load font: " << font << std::endl;
}
}
}
// Create context
g_context = Rml::CreateContext("main", Rml::Vector2i(g_width, g_height));
if (!g_context) {
std::cerr << "Failed to create RmlUi context" << std::endl;
return false;
}
// Initialize debugger
Rml::Debugger::Initialise(g_context);
return true;
}
void ShutdownRmlUi() {
if (g_context) {
Rml::RemoveContext("main");
g_context = nullptr;
}
// Rml::Lua is shut down automatically when Rml::Shutdown() is called
Rml::Shutdown();
delete g_render_interface;
g_render_interface = nullptr;
// Platform (and its file interface) is managed by the global singleton
}
bool LoadDocument(const std::string& path) {
if (!g_context) return false;
// Close existing documents
while (g_context->GetNumDocuments() > 0) {
auto* doc = g_context->GetDocument(0);
if (doc) doc->Close();
}
// Load new document
auto* document = g_context->LoadDocument(path);
if (!document) {
std::cerr << "Failed to load: " << path << std::endl;
return false;
}
document->Show();
SetLuaDocumentGlobal(document); // Set 'document' global for Lua scripts
g_current_document_path = path;
g_current_screen_url = path; // Track current screen for hierarchy dump
std::cout << "Loaded: " << path << std::endl;
return true;
}
void ReloadDocument() {
std::cout << "Reloading..." << std::endl;
// Reset sandbox (clear timers, re-register APIs)
if (g_sandbox) {
lua_State* L = Rml::Lua::Interpreter::GetLuaState();
g_sandbox->UnregisterAPIs(L);
g_sandbox->Reset();
g_sandbox->RegisterAPIs(L);
}
// Reload stylesheets
for (int i = 0; i < g_context->GetNumDocuments(); ++i) {
auto* doc = g_context->GetDocument(i);
if (doc) {
doc->ReloadStyleSheet();
}
}
// Reload document
if (!g_current_document_path.empty()) {
LoadDocument(g_current_document_path);
}
// Re-populate simulator apps if on home screen
if (g_simulator_mode && g_current_app_id.empty()) {
PopulateSimulatorApps();
}
std::cout << "Reload complete" << std::endl;
}
void PopulateSimulatorApps() {
if (!g_simulator_mode || g_test_apps_path.empty()) return;
// Discover apps
g_discovered_apps = mosis::AppDiscovery::DiscoverApps(g_test_apps_path);
std::cout << "Discovered " << g_discovered_apps.size() << " apps" << std::endl;
// Get Lua state
lua_State* L = Rml::Lua::Interpreter::GetLuaState();
// Call setApps(apps) in Lua
lua_getglobal(L, "setApps");
if (!lua_isfunction(L, -1)) {
lua_pop(L, 1);
std::cerr << "Warning: setApps function not found in Lua" << std::endl;
return;
}
// Create apps table
lua_newtable(L);
int app_index = 1;
for (const auto& app : g_discovered_apps) {
lua_pushinteger(L, app_index++);
lua_newtable(L);
lua_pushstring(L, app.id.c_str());
lua_setfield(L, -2, "id");
lua_pushstring(L, app.name.c_str());
lua_setfield(L, -2, "name");
lua_pushstring(L, app.GetIconPath().c_str());
lua_setfield(L, -2, "icon");
lua_pushstring(L, app.GetEntryPath().c_str());
lua_setfield(L, -2, "entry");
lua_pushstring(L, app.app_path.c_str());
lua_setfield(L, -2, "path");
lua_pushstring(L, app.description.c_str());
lua_setfield(L, -2, "description");
lua_settable(L, -3); // apps[index] = app_table
}
// Call setApps(apps)
if (lua_pcall(L, 1, 0, 0) != LUA_OK) {
std::cerr << "Error calling setApps: " << lua_tostring(L, -1) << std::endl;
lua_pop(L, 1);
}
}