#ifdef _WIN32 #define WIN32_LEAN_AND_MEAN #include #else #include #endif #include "rt64_render_hooks.h" #include "concurrentqueue.h" #include "RmlUi/Core.h" #include "RmlUi/Debugger.h" #include "RmlUi/Core/RenderInterfaceCompatibility.h" #include "RmlUi/../../Source/Core/Elements/ElementLabel.h" #include "RmlUi_Platform_SDL.h" #include "recomp_ui.h" #include "recomp_input.h" #include "librecomp/game.hpp" #include "zelda_config.h" #include "ui_rml_hacks.hpp" #include "ui_elements.h" #include "ui_mod_menu.h" #include "ui_renderer.h" bool can_focus(Rml::Element* element) { return element->GetOwnerDocument() != nullptr && element->GetProperty(Rml::PropertyId::TabIndex)->Get() != Rml::Style::TabIndex::None; } //! Copied from lib\RmlUi\Source\Core\Elements\ElementLabel.cpp // Get the first descending element whose tag name matches one of tags. static Rml::Element* TagMatchRecursive(const Rml::StringList& tags, Rml::Element* element) { const int num_children = element->GetNumChildren(); for (int i = 0; i < num_children; i++) { Rml::Element* child = element->GetChild(i); for (const Rml::String& tag : tags) { if (child->GetTagName() == tag) return child; } Rml::Element* matching_element = TagMatchRecursive(tags, child); if (matching_element) return matching_element; } return nullptr; } Rml::Element* get_target(Rml::ElementDocument* document, Rml::Element* element) { // Labels can have targets, so check if this element is a label. if (element->GetTagName() == "label") { Rml::ElementLabel* labelElement = (Rml::ElementLabel*)element; const Rml::String target_id = labelElement->GetAttribute("for", ""); if (target_id.empty()) { const Rml::StringList matching_tags = {"button", "input", "textarea", "progress", "progressbar", "select"}; return TagMatchRecursive(matching_tags, element); } else { Rml::Element* target = labelElement->GetElementById(target_id); if (target != element) return target; } return nullptr; } // Return the element directly if no target exists. return element; } namespace recompui { class UiEventListener : public Rml::EventListener { event_handler_t* handler_; Rml::String param_; public: UiEventListener(event_handler_t* handler, Rml::String&& param) : handler_(handler), param_(std::move(param)) {} void ProcessEvent(Rml::Event& event) override { handler_(param_, event); } }; class UiEventListenerInstancer : public Rml::EventListenerInstancer { std::unordered_map handler_map_; std::unordered_map listener_map_; public: Rml::EventListener* InstanceEventListener(const Rml::String& value, Rml::Element* element) override { // Check if a listener has already been made for the full event string and return it if so. auto find_listener_it = listener_map_.find(value); if (find_listener_it != listener_map_.end()) { return &find_listener_it->second; } // No existing listener, so check if a handler has been registered for this event type and create a listener for it if so. size_t delimiter_pos = value.find(':'); Rml::String event_type = value.substr(0, delimiter_pos); auto find_handler_it = handler_map_.find(event_type); if (find_handler_it != handler_map_.end()) { // A handler was found, create a listener and return it. Rml::String event_param = value.substr(std::min(delimiter_pos, value.size())); return &listener_map_.emplace(value, UiEventListener{ find_handler_it->second, std::move(event_param) }).first->second; } return nullptr; } void register_event(const Rml::String& value, event_handler_t* handler) { handler_map_.emplace(value, handler); } }; } void recompui::register_event(UiEventListenerInstancer& listener, const std::string& name, event_handler_t* handler) { listener.register_event(name, handler); } Rml::Element* find_autofocus_element(Rml::Element* start) { Rml::Element* cur_element = start; while (cur_element) { if (cur_element->HasAttribute("autofocus")) { break; } cur_element = RecompRml::FindNextTabElement(cur_element, true); } return cur_element; } struct ContextDetails { recompui::ContextId context; Rml::ElementDocument* document; bool takes_input; }; class UIState { Rml::Element* prev_focused = nullptr; bool mouse_is_active_changed = false; std::unique_ptr launcher_menu_controller{}; std::unique_ptr config_menu_controller{}; std::vector opened_contexts{}; public: bool mouse_is_active_initialized = false; bool mouse_is_active = false; bool cont_is_active = false; bool submenu_is_active = false; bool await_stick_return_x = false; bool await_stick_return_y = false; int last_active_mouse_position[2] = {0, 0}; std::unique_ptr config_controller; std::unique_ptr launcher_controller; std::unique_ptr system_interface; recompui::RmlRenderInterface_RT64 render_interface; Rml::Context* context; recompui::UiEventListenerInstancer event_listener_instancer; UIState(const UIState& rhs) = delete; UIState& operator=(const UIState& rhs) = delete; UIState(UIState&& rhs) = delete; UIState& operator=(UIState&& rhs) = delete; UIState(SDL_Window* window, RT64::RenderInterface* interface, RT64::RenderDevice* device) { launcher_menu_controller = recompui::create_launcher_menu(); config_menu_controller = recompui::create_config_menu(); system_interface = std::make_unique(); system_interface->SetWindow(window); render_interface.init(interface, device); launcher_menu_controller->register_events(event_listener_instancer); config_menu_controller->register_events(event_listener_instancer); Rml::SetSystemInterface(system_interface.get()); Rml::SetRenderInterface(render_interface.get_rml_interface()); Rml::Factory::RegisterEventListenerInstancer(&event_listener_instancer); recompui::register_custom_elements(); Rml::Initialise(); // Apply the hack to replace RmlUi's default color parser with one that conforms to HTML5 alpha parsing for SASS compatibility recompui::apply_color_hack(); int width, height; SDL_GetWindowSizeInPixels(window, &width, &height); context = Rml::CreateContext("main", Rml::Vector2i(width, height)); launcher_menu_controller->make_bindings(context); config_menu_controller->make_bindings(context); Rml::Debugger::Initialise(context); { const Rml::String directory = "assets/"; struct FontFace { const char* filename; bool fallback_face; }; FontFace font_faces[] = { {"LatoLatin-Regular.ttf", false}, {"ChiaroNormal.otf", false}, {"ChiaroBold.otf", false}, {"LatoLatin-Italic.ttf", false}, {"LatoLatin-Bold.ttf", false}, {"LatoLatin-BoldItalic.ttf", false}, {"NotoEmoji-Regular.ttf", true}, {"promptfont/promptfont.ttf", false}, }; for (const FontFace& face : font_faces) { Rml::LoadFontFace(directory + face.filename, face.fallback_face); } } launcher_menu_controller->load_document(context); config_menu_controller->load_document(context); } void unload() { render_interface.reset(); } void update_primary_input(bool mouse_moved, bool non_mouse_interacted) { mouse_is_active_changed = false; if (non_mouse_interacted) { // controller newly interacted with if (mouse_is_active) { mouse_is_active = false; mouse_is_active_changed = true; } } else if (mouse_moved) { // mouse newly interacted with if (!mouse_is_active) { mouse_is_active = true; mouse_is_active_changed = true; } } if (mouse_moved || non_mouse_interacted) { mouse_is_active_initialized = true; } if (mouse_is_active_initialized) { recompui::set_cursor_visible(mouse_is_active); } Rml::ElementDocument* current_document = top_input_document(); if (current_document == nullptr) { return; } // TODO is this needed? Rml::Element* window_el = current_document->GetElementById("window"); if (window_el != nullptr) { if (mouse_is_active) { if (!window_el->HasAttribute("mouse-active")) { window_el->SetAttribute("mouse-active", true); } } else if (window_el->HasAttribute("mouse-active")) { window_el->RemoveAttribute("mouse-active"); } } } void update_focus(bool mouse_moved, bool non_mouse_interacted) { Rml::ElementDocument* current_document = top_input_document(); if (current_document == nullptr) { return; } if (cont_is_active || non_mouse_interacted) { if (non_mouse_interacted && !submenu_is_active) { auto focusedEl = current_document->GetFocusLeafNode(); if (focusedEl == nullptr || RecompRml::CanFocusElement(focusedEl) != RecompRml::CanFocus::Yes) { Rml::Element* element = find_autofocus_element(current_document); if (element != nullptr) { element->Focus(); } } } return; } // If there was mouse motion, get the current hovered element (or its target if it points to one) and focus that if applicable. if (mouse_is_active) { if (mouse_is_active_changed) { Rml::Element* focused = current_document->GetFocusLeafNode(); if (focused) focused->Blur(); } else if (mouse_moved) { Rml::Element* hovered = context->GetHoverElement(); if (hovered) { Rml::Element* hover_target = get_target(current_document, hovered); if (hover_target && can_focus(hover_target)) { prev_focused = hover_target; } } } } if (!mouse_is_active) { if (!prev_focused || !can_focus(prev_focused)) { // Find the autofocus element in the tab chain Rml::Element* element = find_autofocus_element(current_document); if (element && can_focus(element)) { prev_focused = element; } } if (mouse_is_active_changed && prev_focused && can_focus(prev_focused)) { prev_focused->Focus(); } } } void show_context(recompui::ContextId context) { if (std::find_if(opened_contexts.begin(), opened_contexts.end(), [context](auto& c){ return c.context == context; }) != opened_contexts.end()) { recompui::message_box("Attemped to show the same context twice"); assert(false); } bool takes_input = context.takes_input(); Rml::ElementDocument* document = context.get_document(); opened_contexts.push_back(ContextDetails{ .context = context, .document = document, .takes_input = takes_input }); document->PullToFront(); document->Show(); } void hide_context(recompui::ContextId context) { auto remove_it = std::remove_if(opened_contexts.begin(), opened_contexts.end(), [context](auto& c) { return c.context == context; }); if (remove_it == opened_contexts.end()) { recompui::message_box("Attemped to hide a context that isn't shown"); assert(false); } opened_contexts.erase(remove_it, opened_contexts.end()); context.get_document()->Hide(); } void hide_all_contexts() { for (auto& context : opened_contexts) { context.document->Hide(); } opened_contexts.clear(); } bool is_context_open(recompui::ContextId context) { return std::find_if(opened_contexts.begin(), opened_contexts.end(), [context](auto& c){ return c.context == context; }) != opened_contexts.end(); } bool is_context_taking_input() { return std::find_if(opened_contexts.begin(), opened_contexts.end(), [](auto& c){ return c.takes_input; }) != opened_contexts.end(); } bool is_any_context_open() { return !opened_contexts.empty(); } Rml::ElementDocument* top_input_document() { // Iterate backwards and stop at the first context that takes input. for (auto it = opened_contexts.rbegin(); it != opened_contexts.rend(); it++) { if (it->takes_input) { return it->document; } } return nullptr; } }; std::unique_ptr ui_state; std::recursive_mutex ui_state_mutex{}; // TODO make this not be global extern SDL_Window* window; void recompui::get_window_size(int& width, int& height) { SDL_GetWindowSizeInPixels(window, &width, &height); } inline const std::string read_file_to_string(std::filesystem::path path) { std::ifstream stream = std::ifstream{path}; std::ostringstream ss; ss << stream.rdbuf(); return ss.str(); } void init_hook(RT64::RenderInterface* interface, RT64::RenderDevice* device) { #if defined(__linux__) std::locale::global(std::locale::classic()); #endif ui_state = std::make_unique(window, interface, device); } moodycamel::ConcurrentQueue ui_event_queue{}; void recompui::queue_event(const SDL_Event& event) { ui_event_queue.enqueue(event); } bool recompui::try_deque_event(SDL_Event& out) { return ui_event_queue.try_dequeue(out); } int cont_button_to_key(SDL_ControllerButtonEvent& button) { // Configurable accept button in menu auto menuAcceptBinding0 = recomp::get_input_binding(recomp::GameInput::ACCEPT_MENU, 0, recomp::InputDevice::Controller); auto menuAcceptBinding1 = recomp::get_input_binding(recomp::GameInput::ACCEPT_MENU, 1, recomp::InputDevice::Controller); // note - magic number: 0 is InputType::None if ((menuAcceptBinding0.input_type != 0 && button.button == menuAcceptBinding0.input_id) || (menuAcceptBinding1.input_type != 0 && button.button == menuAcceptBinding1.input_id)) { return SDLK_RETURN; } // Configurable apply button in menu auto menuApplyBinding0 = recomp::get_input_binding(recomp::GameInput::APPLY_MENU, 0, recomp::InputDevice::Controller); auto menuApplyBinding1 = recomp::get_input_binding(recomp::GameInput::APPLY_MENU, 1, recomp::InputDevice::Controller); // note - magic number: 0 is InputType::None if ((menuApplyBinding0.input_type != 0 && button.button == menuApplyBinding0.input_id) || (menuApplyBinding1.input_type != 0 && button.button == menuApplyBinding1.input_id)) { return SDLK_f; } // Allows closing the menu auto menuToggleBinding0 = recomp::get_input_binding(recomp::GameInput::TOGGLE_MENU, 0, recomp::InputDevice::Controller); auto menuToggleBinding1 = recomp::get_input_binding(recomp::GameInput::TOGGLE_MENU, 1, recomp::InputDevice::Controller); // note - magic number: 0 is InputType::None if ((menuToggleBinding0.input_type != 0 && button.button == menuToggleBinding0.input_id) || (menuToggleBinding1.input_type != 0 && button.button == menuToggleBinding1.input_id)) { return SDLK_ESCAPE; } switch (button.button) { case SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_DPAD_UP: return SDLK_UP; case SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_DPAD_DOWN: return SDLK_DOWN; case SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_DPAD_LEFT: return SDLK_LEFT; case SDL_GameControllerButton::SDL_CONTROLLER_BUTTON_DPAD_RIGHT: return SDLK_RIGHT; } return 0; } int cont_axis_to_key(SDL_ControllerAxisEvent& axis, float value) { switch (axis.axis) { case SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_LEFTY: if (value < 0) return SDLK_UP; return SDLK_DOWN; case SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_LEFTX: if (value >= 0) return SDLK_RIGHT; return SDLK_LEFT; } return 0; } void apply_background_input_mode() { static recomp::BackgroundInputMode last_input_mode = recomp::BackgroundInputMode::OptionCount; recomp::BackgroundInputMode cur_input_mode = recomp::get_background_input_mode(); if (last_input_mode != cur_input_mode) { SDL_SetHint( SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, cur_input_mode == recomp::BackgroundInputMode::On ? "1" : "0" ); } last_input_mode = cur_input_mode; } bool recompui::get_cont_active() { return ui_state->cont_is_active; } void recompui::set_cont_active(bool active) { ui_state->cont_is_active = active; } void recompui::activate_mouse() { ui_state->update_primary_input(true, false); ui_state->update_focus(true, false); } void draw_hook(RT64::RenderCommandList* command_list, RT64::RenderFramebuffer* swap_chain_framebuffer) { apply_background_input_mode(); // Return early if the ui context has been destroyed already. if (!ui_state) { return; } // Return to the launcher if no menu is open and the game isn't started. if (!recompui::is_any_context_open() && !ultramodern::is_game_started()) { recompui::show_context(recompui::get_launcher_context_id(), ""); } std::lock_guard lock{ ui_state_mutex }; SDL_Event cur_event{}; bool mouse_moved = false; bool mouse_clicked = false; bool non_mouse_interacted = false; bool cont_interacted = false; bool kb_interacted = false; bool config_was_open = recompui::is_context_open(recompui::get_config_context_id()); while (recompui::try_deque_event(cur_event)) { bool context_taking_input = recompui::is_context_taking_input(); if (!recomp::all_input_disabled()) { // Implement some additional behavior for specific events on top of what RmlUi normally does with them. switch (cur_event.type) { case SDL_EventType::SDL_MOUSEMOTION: { int *last_mouse_pos = ui_state->last_active_mouse_position; if (!ui_state->mouse_is_active) { float xD = cur_event.motion.x - last_mouse_pos[0]; float yD = cur_event.motion.y - last_mouse_pos[1]; if (sqrt(xD * xD + yD * yD) < 100) { break; } } last_mouse_pos[0] = cur_event.motion.x; last_mouse_pos[1] = cur_event.motion.y; // if controller is the primary input, don't use mouse movement to allow cursor to reactivate if (recompui::get_cont_active()) { break; } } // fallthrough case SDL_EventType::SDL_MOUSEBUTTONDOWN: mouse_moved = true; mouse_clicked = true; break; case SDL_EventType::SDL_CONTROLLERBUTTONDOWN: { int rml_key = cont_button_to_key(cur_event.cbutton); if (context_taking_input && rml_key) { ui_state->context->ProcessKeyDown(RmlSDL::ConvertKey(rml_key), 0); } non_mouse_interacted = true; cont_interacted = true; break; } case SDL_EventType::SDL_KEYDOWN: non_mouse_interacted = true; kb_interacted = true; break; case SDL_EventType::SDL_USEREVENT: if (cur_event.user.code == SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_LEFTY) { ui_state->await_stick_return_y = true; } else if (cur_event.user.code == SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_LEFTX) { ui_state->await_stick_return_x = true; } break; case SDL_EventType::SDL_CONTROLLERAXISMOTION: SDL_ControllerAxisEvent* axis_event = &cur_event.caxis; if (axis_event->axis != SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_LEFTY && axis_event->axis != SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_LEFTX) { break; } float axis_value = axis_event->value * (1 / 32768.0f); bool* await_stick_return = axis_event->axis == SDL_GameControllerAxis::SDL_CONTROLLER_AXIS_LEFTY ? &ui_state->await_stick_return_y : &ui_state->await_stick_return_x; if (fabsf(axis_value) > 0.5f) { if (!*await_stick_return) { *await_stick_return = true; non_mouse_interacted = true; int rml_key = cont_axis_to_key(cur_event.caxis, axis_value); if (context_taking_input && rml_key) { ui_state->context->ProcessKeyDown(RmlSDL::ConvertKey(rml_key), 0); } } non_mouse_interacted = true; cont_interacted = true; } else if (*await_stick_return && fabsf(axis_value) < 0.15f) { *await_stick_return = false; } break; } if (context_taking_input) { RmlSDL::InputEventHandler(ui_state->context, cur_event); } } // If the config menu isn't open and the game has been started and either the escape key or select button are pressed, open the config menu. if (!config_was_open && ultramodern::is_game_started()) { bool open_config = false; switch (cur_event.type) { case SDL_EventType::SDL_KEYDOWN: if (cur_event.key.keysym.scancode == SDL_Scancode::SDL_SCANCODE_ESCAPE) { open_config = true; } break; case SDL_EventType::SDL_CONTROLLERBUTTONDOWN: auto menuToggleBinding0 = recomp::get_input_binding(recomp::GameInput::TOGGLE_MENU, 0, recomp::InputDevice::Controller); auto menuToggleBinding1 = recomp::get_input_binding(recomp::GameInput::TOGGLE_MENU, 1, recomp::InputDevice::Controller); // note - magic number: 0 is InputType::None if ((menuToggleBinding0.input_type != 0 && cur_event.cbutton.button == menuToggleBinding0.input_id) || (menuToggleBinding1.input_type != 0 && cur_event.cbutton.button == menuToggleBinding1.input_id)) { open_config = true; } break; } recompui::hide_all_contexts(); if (open_config) { recompui::show_context(recompui::get_config_context_id(), ""); } } } // end dequeue event loop if (cont_interacted || kb_interacted || mouse_clicked) { recompui::set_cont_active(cont_interacted); } recomp::config_menu_set_cont_or_kb(ui_state->cont_is_active); recomp::InputField scanned_field = recomp::get_scanned_input(); if (scanned_field != recomp::InputField{}) { recomp::finish_scanning_input(scanned_field); } ui_state->update_primary_input(mouse_moved, non_mouse_interacted); ui_state->update_focus(mouse_moved, non_mouse_interacted); if (recompui::is_any_context_open()) { int width = swap_chain_framebuffer->getWidth(); int height = swap_chain_framebuffer->getHeight(); // Scale the UI based on the window size with 1080 vertical resolution as the reference point. ui_state->context->SetDensityIndependentPixelRatio((height) / 1080.0f); ui_state->render_interface.start(command_list, width, height); static int prev_width = 0; static int prev_height = 0; if (prev_width != width || prev_height != height) { ui_state->context->SetDimensions({ width, height }); } prev_width = width; prev_height = height; ui_state->context->Update(); ui_state->context->Render(); ui_state->render_interface.end(command_list, swap_chain_framebuffer); } } void deinit_hook() { recompui::destroy_all_contexts(); std::lock_guard lock {ui_state_mutex}; Rml::Debugger::Shutdown(); Rml::Shutdown(); ui_state->unload(); ui_state.reset(); } void recompui::set_render_hooks() { RT64::SetRenderHooks(init_hook, draw_hook, deinit_hook); } void recompui::message_box(const char* msg) { SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, zelda64::program_name.data(), msg, nullptr); printf("[ERROR] %s\n", msg); } void recompui::show_context(ContextId context, std::string_view param) { std::lock_guard lock{ui_state_mutex}; // TODO call the context's on_show callback with the param. ui_state->show_context(context); } void recompui::hide_context(ContextId context) { std::lock_guard lock{ui_state_mutex}; ui_state->hide_context(context); } void recompui::hide_all_contexts() { std::lock_guard lock{ui_state_mutex}; if (ui_state) { ui_state->hide_all_contexts(); } } bool recompui::is_context_open(ContextId context) { std::lock_guard lock{ui_state_mutex}; if (!ui_state) { return false; } return ui_state->is_context_open(context); } bool recompui::is_context_taking_input() { std::lock_guard lock{ui_state_mutex}; if (!ui_state) { return false; } return ui_state->is_context_taking_input(); } bool recompui::is_any_context_open() { std::lock_guard lock{ui_state_mutex}; if (!ui_state) { return false; } return ui_state->is_any_context_open(); }