/* Copyright (C) 2025 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "precompiled.h" #include "CGUI.h" #include "graphics/Canvas2D.h" #include "graphics/Color.h" #include "gui/CGUISetting.h" #include "gui/CGUISprite.h" #include "gui/GUIObjectEventBroadcaster.h" #include "gui/IGUIScrollBar.h" #include "gui/ObjectBases/IGUIObject.h" #include "gui/ObjectTypes/CGUIDummyObject.h" #include "gui/ObjectTypes/CTooltip.h" #include "gui/Scripting/JSInterface_GUIProxy.h" #include "gui/Scripting/ScriptFunctions.h" #include "gui/SettingTypes/CGUISize.h" #include "i18n/L10n.h" #include "lib/bits.h" #include "lib/debug.h" #include "lib/external_libraries/libsdl.h" #include "lib/file/vfs/vfs_util.h" #include "lib/input.h" #include "lib/path.h" #include "lib/timer.h" #include "lib/utf8.h" #include "maths/Size2D.h" #include "ps/CLogger.h" #include "ps/Errors.h" #include "ps/Filesystem.h" #include "ps/GameSetup/Config.h" #include "ps/Hotkey.h" #include "ps/VideoMode.h" #include "ps/XMB/XMBData.h" #include "ps/XML/Xeromyces.h" #include "renderer/backend/Sampler.h" #include "scriptinterface/FunctionWrapper.h" #include "scriptinterface/Object.h" #include "scriptinterface/ScriptExceptions.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/ScriptRequest.h" #include #include #include #include #include #include #include #include #include #include #include #include #include const double SELECT_DBLCLICK_RATE = 0.5; const u32 MAX_OBJECT_DEPTH = 100; // Max number of nesting for GUI includes. Used to detect recursive inclusion const CStr CGUI::EventNameLoad = "Load"; const CStr CGUI::EventNameTick = "Tick"; const CStr CGUI::EventNamePress = "Press"; const CStr CGUI::EventNameKeyDown = "KeyDown"; const CStr CGUI::EventNameRelease = "Release"; const CStr CGUI::EventNameMouseRightPress = "MouseRightPress"; const CStr CGUI::EventNameMouseLeftPress = "MouseLeftPress"; const CStr CGUI::EventNameMouseWheelDown = "MouseWheelDown"; const CStr CGUI::EventNameMouseWheelUp = "MouseWheelUp"; const CStr CGUI::EventNameMouseWheelLeft = "MouseWheelLeft"; const CStr CGUI::EventNameMouseWheelRight = "MouseWheelRight"; const CStr CGUI::EventNameMouseLeftDoubleClick = "MouseLeftDoubleClick"; const CStr CGUI::EventNameMouseLeftRelease = "MouseLeftRelease"; const CStr CGUI::EventNameMouseRightDoubleClick = "MouseRightDoubleClick"; const CStr CGUI::EventNameMouseRightRelease = "MouseRightRelease"; CGUI::CGUI(ScriptContext& context) : m_BaseObject(std::make_unique(*this)), m_FocusedObject(nullptr), m_InternalNameNumber(0), m_MouseButtons(0) { m_ScriptInterface = std::make_shared("Engine", "GUIPage", context, [](const VfsPath& path){ return path.string8().find("gui/") == 0; }); m_ScriptInterface->SetCallbackData(this); GuiScriptingInit(*m_ScriptInterface); m_ScriptInterface->LoadGlobalScripts(); } CGUI::~CGUI() = default; InReaction CGUI::HandleEvent(const SDL_Event_* ev) { InReaction ret = IN_PASS; if (ev->ev.type == SDL_HOTKEYDOWN || ev->ev.type == SDL_HOTKEYPRESS || ev->ev.type == SDL_HOTKEYUP) { const char* hotkey = static_cast(ev->ev.user.data1); const CStr& eventName = ev->ev.type == SDL_HOTKEYPRESS ? EventNamePress : ev->ev.type == SDL_HOTKEYDOWN ? EventNameKeyDown : EventNameRelease; if (m_GlobalHotkeys.find(hotkey) != m_GlobalHotkeys.end() && m_GlobalHotkeys[hotkey].find(eventName) != m_GlobalHotkeys[hotkey].end()) { ret = IN_HANDLED; ScriptRequest rq(m_ScriptInterface); JS::RootedObject globalObj(rq.cx, rq.glob); JS::RootedValue result(rq.cx); if (!JS_CallFunctionValue(rq.cx, globalObj, m_GlobalHotkeys[hotkey][eventName], JS::HandleValueArray::empty(), &result)) ScriptException::CatchPending(rq); } std::map >::iterator it = m_HotkeyObjects.find(hotkey); if (it != m_HotkeyObjects.end()) for (IGUIObject* const& obj : it->second) { if (!obj->IsEnabled()) continue; if (ev->ev.type == SDL_HOTKEYPRESS) ret = obj->SendEvent(GUIM_PRESSED, EventNamePress); else if (ev->ev.type == SDL_HOTKEYDOWN) ret = obj->SendEvent(GUIM_KEYDOWN, EventNameKeyDown); else ret = obj->SendEvent(GUIM_RELEASED, EventNameRelease); } } else if (ev->ev.type == SDL_MOUSEMOTION) { // Yes the mouse position is stored as float to avoid // constant conversions when operating in a // float-based environment. m_MousePos = CVector2D((float)ev->ev.motion.x / g_VideoMode.GetScale(), (float)ev->ev.motion.y / g_VideoMode.GetScale()); SGUIMessage msg(GUIM_MOUSE_MOTION); m_BaseObject->RecurseObject(&IGUIObject::IsHiddenOrGhostOrOutOfBoundaries, &IGUIObject::HandleMessage, msg); } // Update m_MouseButtons. (BUTTONUP is handled later.) else if (ev->ev.type == SDL_MOUSEBUTTONDOWN) { switch (ev->ev.button.button) { case SDL_BUTTON_LEFT: case SDL_BUTTON_RIGHT: case SDL_BUTTON_MIDDLE: m_MouseButtons |= Bit(ev->ev.button.button); break; default: break; } } // Update m_MousePos (for delayed mouse button events) CVector2D oldMousePos = m_MousePos; if (ev->ev.type == SDL_MOUSEBUTTONDOWN || ev->ev.type == SDL_MOUSEBUTTONUP) { m_MousePos = CVector2D((float)ev->ev.button.x / g_VideoMode.GetScale(), (float)ev->ev.button.y / g_VideoMode.GetScale()); } // Allow the focused object to pre-empt regular GUI events. if (GetFocusedObject()) ret = GetFocusedObject()->PreemptEvent(ev); // Only one object can be hovered // pNearest will after this point at the hovered object, possibly nullptr IGUIObject* pNearest = FindObjectUnderMouse(); if (ret == IN_PASS) { // Now we'll call UpdateMouseOver on *all* objects, // we'll input the one hovered, and they will each // update their own data and send messages accordingly m_BaseObject->RecurseObject(&IGUIObject::IsHiddenOrGhostOrOutOfBoundaries, &IGUIObject::UpdateMouseOver, static_cast(pNearest)); if (ev->ev.type == SDL_MOUSEBUTTONDOWN) { switch (ev->ev.button.button) { case SDL_BUTTON_LEFT: // Focus the clicked object (or focus none if nothing clicked on) SetFocusedObject(pNearest); if (pNearest) ret = pNearest->SendMouseEvent(GUIM_MOUSE_PRESS_LEFT, EventNameMouseLeftPress); break; case SDL_BUTTON_RIGHT: if (pNearest) ret = pNearest->SendMouseEvent(GUIM_MOUSE_PRESS_RIGHT, EventNameMouseRightPress); break; default: break; } } else if (ev->ev.type == SDL_MOUSEWHEEL && pNearest) { if (ev->ev.wheel.y < 0) ret = pNearest->SendMouseEvent(GUIM_MOUSE_WHEEL_DOWN, EventNameMouseWheelDown); else if (ev->ev.wheel.y > 0) ret = pNearest->SendMouseEvent(GUIM_MOUSE_WHEEL_UP, EventNameMouseWheelUp); if (ev->ev.wheel.x < 0) ret = pNearest->SendMouseEvent(GUIM_MOUSE_WHEEL_LEFT, EventNameMouseWheelLeft); else if (ev->ev.wheel.x > 0) ret = pNearest->SendMouseEvent(GUIM_MOUSE_WHEEL_RIGHT, EventNameMouseWheelRight); } else if (ev->ev.type == SDL_MOUSEBUTTONUP) { switch (ev->ev.button.button) { case SDL_BUTTON_LEFT: if (pNearest) { double timeElapsed = timer_Time() - pNearest->m_LastClickTime[SDL_BUTTON_LEFT]; pNearest->m_LastClickTime[SDL_BUTTON_LEFT] = timer_Time(); if (timeElapsed < SELECT_DBLCLICK_RATE) ret = pNearest->SendMouseEvent(GUIM_MOUSE_DBLCLICK_LEFT, EventNameMouseLeftDoubleClick); else ret = pNearest->SendMouseEvent(GUIM_MOUSE_RELEASE_LEFT, EventNameMouseLeftRelease); } break; case SDL_BUTTON_RIGHT: if (pNearest) { double timeElapsed = timer_Time() - pNearest->m_LastClickTime[SDL_BUTTON_RIGHT]; pNearest->m_LastClickTime[SDL_BUTTON_RIGHT] = timer_Time(); if (timeElapsed < SELECT_DBLCLICK_RATE) ret = pNearest->SendMouseEvent(GUIM_MOUSE_DBLCLICK_RIGHT, EventNameMouseRightDoubleClick); else ret = pNearest->SendMouseEvent(GUIM_MOUSE_RELEASE_RIGHT, EventNameMouseRightRelease); } break; } // Reset all states on all visible objects m_BaseObject->RecurseObject(&IGUIObject::IsHidden, &IGUIObject::ResetStates); // Since the hover state will have been reset, we reload it. m_BaseObject->RecurseObject(&IGUIObject::IsHiddenOrGhostOrOutOfBoundaries, &IGUIObject::UpdateMouseOver, static_cast(pNearest)); } } // BUTTONUP's effect on m_MouseButtons is handled after // everything else, so that e.g. 'press' handlers (activated // on button up) see which mouse button had been pressed. if (ev->ev.type == SDL_MOUSEBUTTONUP) { switch (ev->ev.button.button) { case SDL_BUTTON_LEFT: case SDL_BUTTON_RIGHT: case SDL_BUTTON_MIDDLE: m_MouseButtons &= ~Bit(ev->ev.button.button); break; default: break; } } // Restore m_MousePos (for delayed mouse button events) if (ev->ev.type == SDL_MOUSEBUTTONDOWN || ev->ev.type == SDL_MOUSEBUTTONUP) m_MousePos = oldMousePos; // Let GUI items handle keys after everything else, e.g. for input boxes. if (ret == IN_PASS && GetFocusedObject()) { if (ev->ev.type == SDL_KEYUP || ev->ev.type == SDL_KEYDOWN || ev->ev.type == SDL_HOTKEYUP || ev->ev.type == SDL_HOTKEYDOWN || ev->ev.type == SDL_TEXTINPUT || ev->ev.type == SDL_TEXTEDITING) ret = GetFocusedObject()->ManuallyHandleKeys(ev); // else will return IN_PASS because we never used the button. } return ret; } JS::Value CGUI::GetHotloadData(const ScriptRequest& rq) { JS::RootedValue oldNamespace{rq.cx, m_LoadModuleResult.has_value() ? JS::ObjectValue(*m_LoadModuleResult->moduleNamespace) : rq.globalValue()}; JS::RootedValue hotloadDataVal(rq.cx); ScriptFunction::Call(rq, oldNamespace, "getHotloadData", &hotloadDataVal); return hotloadDataVal; } JSObject* CGUI::CallPageInit(const ScriptRequest& rq, Script::StructuredClone initData, JS::HandleValue hotloadDataVal, const std::string_view scriptName) { JS::RootedValue initDataVal{rq.cx}; if (initData) Script::ReadStructuredClone(rq, initData, &initDataVal); JS::RootedValue newNamespace{rq.cx, m_LoadModuleResult.has_value() ? JS::ObjectValue(*m_LoadModuleResult->moduleNamespace) : rq.globalValue()}; if (!Script::HasProperty(rq, newNamespace, "init")) return nullptr; JS::RootedValue returnValue{rq.cx}; if (!ScriptFunction::Call(rq, newNamespace, "init", &returnValue, initDataVal, hotloadDataVal)) { LOGERROR("GUI page '%s': Failed to call init() function", scriptName); return nullptr; } if (!returnValue.isObject()) return nullptr; JS::RootedObject returnObject{rq.cx, &returnValue.toObject()}; if (!JS::IsPromiseObject(returnObject)) return nullptr; return returnObject; } JSObject* CGUI::TickObjects(const ScriptRequest& rq, Script::StructuredClone initData, const std::string_view scriptName) { JS::RootedObject sendingPromise{rq.cx}; if (m_LoadModuleResult.has_value() && m_LoadModuleResult->iterator->IsDone()) { JS::RootedValue hotloadData{rq.cx, GetHotloadData(rq)}; m_LoadModuleResult->moduleNamespace = m_LoadModuleResult->iterator->Get(); ++m_LoadModuleResult->iterator; sendingPromise = CallPageInit(rq, initData, hotloadData, scriptName); } m_BaseObject->RecurseObject(&IGUIObject::IsHidden, &IGUIObject::DispatchDelayedSettingChanges); m_BaseObject->RecurseObject(&IGUIObject::IsHiddenOrGhostOrOutOfBoundaries, &IGUIObject::Tick); SendEventToAll(EventNameTick); m_Tooltip.Update(FindObjectUnderMouse(), m_MousePos, *this); return sendingPromise; } void CGUI::SendEventToAll(const CStr& eventName) { std::unordered_map>::iterator it = m_EventObjects.find(eventName); if (it == m_EventObjects.end()) return; std::vector copy = it->second; for (IGUIObject* object : copy) object->ScriptEvent(eventName); } void CGUI::SendEventToAll(const CStr& eventName, const JS::HandleValueArray& paramData) { std::unordered_map>::iterator it = m_EventObjects.find(eventName); if (it == m_EventObjects.end()) return; std::vector copy = it->second; for (IGUIObject* object : copy) object->ScriptEvent(eventName, paramData); } void CGUI::Draw(CCanvas2D& canvas) { CGUIObjectEventBroadcaster::RecurseVisibleObject(m_BaseObject.get(), &IGUIObject::Draw, canvas); } void CGUI::DrawSprite(const CGUISpriteInstance& Sprite, CCanvas2D& canvas, const CRect& Rect, const CRect& Clipping) { // If the sprite doesn't exist (name == ""), don't bother drawing anything if (!Sprite) return; std::optional scopedScissor; if (Clipping != CRect()) scopedScissor.emplace(canvas, Clipping); Sprite.Draw(*this, canvas, Rect, m_Sprites); } void CGUI::UpdateResolution() { m_BaseObject->RecurseObject(nullptr, &IGUIObject::UpdateCachedSize); } std::unique_ptr CGUI::ConstructObject(const CStr& str) { std::map::iterator it = m_ObjectTypes.find(str); if (it == m_ObjectTypes.end()) return nullptr; return (*it->second)(*this); } void CGUI::AddObject(IGUIObject& parent, std::unique_ptr child) { if (child->m_Name.empty()) { LOGERROR("Can't register an object without name!"); return; } if (m_pAllObjects.find(child->m_Name) != m_pAllObjects.end()) { LOGERROR("Can't register more than one object of the name %s", child->m_Name.c_str()); return; } parent.RegisterChild(child.get()); m_pAllObjects[child->m_Name] = std::move(child); } IGUIObject* CGUI::GetBaseObject() { return m_BaseObject.get(); }; bool CGUI::ObjectExists(const CStr& Name) const { return m_pAllObjects.find(Name) != m_pAllObjects.end(); } IGUIObject* CGUI::TryFindObjectByName(const CStr& Name) const { map_pObjects::const_iterator it = m_pAllObjects.find(Name); if (it == m_pAllObjects.end()) return nullptr; return it->second.get(); } IGUIObject* CGUI::FindObjectByName(const CStr& Name) const { IGUIObject* obj = TryFindObjectByName(Name); if (obj == nullptr) LOGERROR("Failed to get GUI object by name: object '%s' not found. Note: Use 'Engine.TryGetGUIObjectByName' to query for potentially non-existent objects instead.", Name); return obj; } IGUIObject* CGUI::FindObjectUnderMouse() { IGUIObject* pNearest = nullptr; m_BaseObject->RecurseObject(&IGUIObject::IsHiddenOrGhostOrOutOfBoundaries, &IGUIObject::ChooseMouseOverAndClosest, pNearest); return pNearest; } CSize2D CGUI::GetWindowSize() const { return CSize2D{static_cast(g_xres) / g_VideoMode.GetScale(), static_cast(g_yres) / g_VideoMode.GetScale() }; } void CGUI::SendFocusMessage(EGUIMessageType msgType) { if (m_FocusedObject) { SGUIMessage msg(msgType); m_FocusedObject->HandleMessage(msg); } } void CGUI::SetFocusedObject(IGUIObject* pObject) { if (pObject == m_FocusedObject) return; if (m_FocusedObject) { SGUIMessage msg(GUIM_LOST_FOCUS); m_FocusedObject->HandleMessage(msg); } m_FocusedObject = pObject; if (m_FocusedObject) { SGUIMessage msg(GUIM_GOT_FOCUS); m_FocusedObject->HandleMessage(msg); } } void CGUI::SetObjectStyle(IGUIObject& object, const CStr& styleName) { // If the style is not recognised (or an empty string) then ApplyStyle will // emit an error message. Thus we don't need to handle it here. object.ApplyStyle(styleName); } void CGUI::UnsetObjectStyle(IGUIObject& object) { SetObjectStyle(object, "default"); } void CGUI::SetObjectHotkey(IGUIObject& object, const CStr& hotkeyTag) { if (!hotkeyTag.empty()) m_HotkeyObjects[hotkeyTag].push_back(&object); } void CGUI::UnsetObjectHotkey(IGUIObject& object, const CStr& hotkeyTag) { if (hotkeyTag.empty()) return; std::vector& assignment = m_HotkeyObjects[hotkeyTag]; assignment.erase( std::remove_if( assignment.begin(), assignment.end(), [&object](const IGUIObject* hotkeyObject) { return &object == hotkeyObject; }), assignment.end()); } void CGUI::SetGlobalHotkey(const CStr& hotkeyTag, const CStr& eventName, JS::HandleValue function) { ScriptRequest rq(*m_ScriptInterface); if (hotkeyTag.empty()) throw std::invalid_argument{"Cannot assign a function to an empty hotkey identifier!"}; // Only support "Press", "Keydown" and "Release" events. if (eventName != EventNamePress && eventName != EventNameKeyDown && eventName != EventNameRelease) throw std::invalid_argument{"Cannot assign a function to an unsupported event!"}; if (!function.isObject() || !JS::IsCallable(&function.toObject())) { throw std::invalid_argument{fmt::format( "Cannot assign non-function value to global hotkey '{}'", hotkeyTag.c_str())}; } UnsetGlobalHotkey(hotkeyTag, eventName); m_GlobalHotkeys[hotkeyTag][eventName].init(rq.cx, function); } void CGUI::UnsetGlobalHotkey(const CStr& hotkeyTag, const CStr& eventName) { std::map>::iterator it = m_GlobalHotkeys.find(hotkeyTag); if (it == m_GlobalHotkeys.end()) return; m_GlobalHotkeys[hotkeyTag].erase(eventName); if (m_GlobalHotkeys.count(hotkeyTag) == 0) m_GlobalHotkeys.erase(it); } const SGUIScrollBarStyle* CGUI::GetScrollBarStyle(const CStr& style) const { std::map::const_iterator it = m_ScrollBarStyles.find(style); if (it == m_ScrollBarStyles.end()) return nullptr; return &it->second; } /** * @callgraph */ void CGUI::LoadXmlFile(const VfsPath& Filename, std::unordered_set& Paths) { Paths.insert(Filename); CXeromyces xeroFile; if (xeroFile.Load(g_VFS, Filename, "gui") != PSRETURN_OK) // The error has already been reported by CXeromyces return; XMBElement node = xeroFile.GetRoot(); std::string_view root_name(xeroFile.GetElementStringView(node.GetNodeName())); if (root_name == "objects") Xeromyces_ReadRootObjects(xeroFile, node, Paths); else if (root_name == "sprites") Xeromyces_ReadRootSprites(xeroFile, node); else if (root_name == "styles") Xeromyces_ReadRootStyles(xeroFile, node); else if (root_name == "setup") Xeromyces_ReadRootSetup(xeroFile, node); else LOGERROR("CGUI::LoadXmlFile encountered an unknown XML root node type: %s", root_name.data()); } void CGUI::LoadedXmlFiles() { m_BaseObject->RecurseObject(nullptr, &IGUIObject::UpdateCachedSize); SGUIMessage msg(GUIM_LOAD); m_BaseObject->RecurseObject(nullptr, &IGUIObject::HandleMessage, msg); SendEventToAll(EventNameLoad); } //=================================================================== // XML Reading Xeromyces Specific Sub-Routines //=================================================================== void CGUI::Xeromyces_ReadRootObjects(const XMBData& xmb, XMBElement element, std::unordered_set& Paths) { int el_script = xmb.GetElementID("script"); std::vector > subst; // Iterate main children // they should all be or