/* 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 "GUIManager.h" #include "gui/CGUI.h" #include "lib/timer.h" #include "lobby/IXmppClient.h" #include "ps/CLogger.h" #include "ps/Filesystem.h" #include "ps/GameSetup/Config.h" #include "ps/Profile.h" #include "ps/VideoMode.h" #include "ps/XML/Xeromyces.h" #include "scriptinterface/FunctionWrapper.h" #include "scriptinterface/Object.h" #include "scriptinterface/Promises.h" #include "scriptinterface/ScriptContext.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/StructuredClone.h" #include "js/Equality.h" namespace { const CStr EVENT_NAME_GAME_LOAD_PROGRESS = "GameLoadProgress"; const CStr EVENT_NAME_WINDOW_RESIZED = "WindowResized"; constexpr const char* START_ATLAS{"startAtlas"}; } // anonymous namespace CGUIManager* g_GUI = nullptr; // General TODOs: // // A lot of the CGUI data could (and should) be shared between // multiple pages, instead of treating them as completely independent, to save // memory and loading time. // called from main loop when (input) events are received. // event is passed to other handlers if false is returned. // trampoline: we don't want to make the HandleEvent implementation static InReaction gui_handler(const SDL_Event_* ev) { if (!g_GUI) return IN_PASS; PROFILE("GUI event handler"); return g_GUI->HandleEvent(ev); } static Status ReloadChangedFileCB(void* param, const VfsPath& path) { return static_cast(param)->ReloadChangedFile(path); } CGUIManager::CGUIManager(ScriptContext& scriptContext, ScriptInterface& scriptInterface) : m_ScriptContext{scriptContext}, m_ScriptInterface{scriptInterface} { m_ScriptInterface.SetCallbackData(this); m_ScriptInterface.LoadGlobalScripts(); if (!g_Xeromyces.AddValidator(g_VFS, "gui_page", "gui/gui_page.rng")) LOGERROR("CGUIManager: failed to load GUI page grammar file 'gui/gui_page.rng'"); if (!g_Xeromyces.AddValidator(g_VFS, "gui", "gui/gui.rng")) LOGERROR("CGUIManager: failed to load GUI XML grammar file 'gui/gui.rng'"); RegisterFileReloadFunc(ReloadChangedFileCB, this); } CGUIManager::~CGUIManager() { UnregisterFileReloadFunc(ReloadChangedFileCB, this); } size_t CGUIManager::GetPageCount() const { return m_PageStack.size(); } void CGUIManager::SwitchPage(const CStrW& pageName, const ScriptInterface* srcScriptInterface, JS::HandleValue initData) { // The page stack is cleared (including the script context where initData came from), // therefore we have to clone initData. Script::StructuredClone initDataClone; if (!initData.isUndefined()) { ScriptRequest rq(srcScriptInterface); initDataClone = Script::WriteStructuredClone(rq, initData); } if (!m_PageStack.empty()) { // Make sure we unfocus anything on the current page. m_PageStack.back().gui->SendFocusMessage(GUIM_LOST_FOCUS); m_PageStack.clear(); } OpenChildPage(pageName, initDataClone); } JS::Value CGUIManager::OpenChildPage(const CStrW& pageName, Script::StructuredClone initData) { // Store the callback handler in the current GUI page before opening the new one JS::RootedValue promise{m_ScriptInterface.GetGeneralJSContext(), [&] { if (m_PageStack.empty()) return JS::UndefinedValue(); CGUI& currentPage = *m_PageStack.back().gui; // Make sure we unfocus anything on the current page. currentPage.SendFocusMessage(GUIM_LOST_FOCUS); return m_PageStack.back().ReplacePromise(*currentPage.GetScriptInterface()); }()}; // Emplace the page prior to loading its contents, because that may open // another GUI page on init which should be emplaced on top of this new page. m_PageStack.emplace_back(pageName, initData); m_PageStack.back().LoadPage(m_ScriptContext); return promise; } CGUIManager::SGUIPage::SGUIPage(const CStrW& pageName, const Script::StructuredClone initData) : m_Name(pageName), initData(initData) { } void CGUIManager::SGUIPage::LoadPage(ScriptContext& scriptContext) { // If we're hotloading then try to grab some data from the previous page Script::StructuredClone hotloadData; if (gui) { std::shared_ptr scriptInterface = gui->GetScriptInterface(); ScriptRequest rq(scriptInterface); JS::RootedValue global(rq.cx, rq.globalValue()); JS::RootedValue hotloadDataVal(rq.cx); ScriptFunction::Call(rq, global, "getHotloadData", &hotloadDataVal); hotloadData = Script::WriteStructuredClone(rq, hotloadDataVal); } g_VideoMode.ResetCursor(); inputs.clear(); gui.reset(new CGUI(scriptContext)); const ScriptRequest rq{gui->GetScriptInterface()}; sendingPromise = std::make_shared(rq.cx, JS::NewPromiseObject(rq.cx, nullptr)); { JS::RootedString jsName{rq.cx, JS_NewStringCopyZ(rq.cx, START_ATLAS)}; JS::RootedValue symbol{rq.cx, JS::SymbolValue(JS::NewSymbol(rq.cx, jsName))}; JS::RootedValue nativeScope{rq.cx, JS::ObjectValue(*rq.nativeScope)}; Script::SetProperty(rq, nativeScope, START_ATLAS, symbol, true); } gui->AddObjectTypes(); VfsPath path = VfsPath("gui") / m_Name; inputs.insert(path); CXeromyces xero; if (xero.Load(g_VFS, path, "gui_page") != PSRETURN_OK) // Fail silently (Xeromyces reported the error) return; int elmt_page = xero.GetElementID("page"); int elmt_include = xero.GetElementID("include"); XMBElement root = xero.GetRoot(); if (root.GetNodeName() != elmt_page) { LOGERROR("GUI page '%s' must have root element ", utf8_from_wstring(m_Name)); return; } XERO_ITER_EL(root, node) { if (node.GetNodeName() != elmt_include) { LOGERROR("GUI page '%s' must only have elements inside ", utf8_from_wstring(m_Name)); continue; } CStr8 name = node.GetText(); CStrW nameW = node.GetText().FromUTF8(); PROFILE2("load gui xml"); PROFILE2_ATTR("name: %s", name.c_str()); TIMER(nameW.c_str()); if (name.back() == '/') { VfsPath currentDirectory = VfsPath("gui") / nameW; VfsPaths directories; vfs::GetPathnames(g_VFS, currentDirectory, L"*.xml", directories); for (const VfsPath& directory : directories) gui->LoadXmlFile(directory, inputs); } else { VfsPath directory = VfsPath("gui") / nameW; gui->LoadXmlFile(directory, inputs); } } gui->LoadedXmlFiles(); JS::RootedValue initDataVal(rq.cx); JS::RootedValue hotloadDataVal(rq.cx); JS::RootedValue global(rq.cx, rq.globalValue()); if (initData) Script::ReadStructuredClone(rq, initData, &initDataVal); if (hotloadData) Script::ReadStructuredClone(rq, hotloadData, &hotloadDataVal); if (!Script::HasProperty(rq, global, "init")) return; JS::RootedValue returnValue{rq.cx}; if (!ScriptFunction::Call(rq, global, "init", &returnValue, initDataVal, hotloadDataVal)) { LOGERROR("GUI page '%s': Failed to call init() function", utf8_from_wstring(m_Name)); return; } if (!returnValue.isObject()) return; JS::RootedObject returnObject{rq.cx, &returnValue.toObject()}; if (!JS::IsPromiseObject(returnObject)) return; sendingPromise = std::make_shared(rq.cx, returnObject); } JS::Value CGUIManager::SGUIPage::ReplacePromise(ScriptInterface& scriptInterface) { const ScriptRequest rq{scriptInterface}; receivingPromise = std::make_shared(rq.cx, JS::NewPromiseObject(rq.cx, nullptr)); return JS::ObjectValue(**receivingPromise); } std::optional CGUIManager::SGUIPage::MaybeClose(const bool topmostPage) { if (JS::GetPromiseState(*sendingPromise) == JS::PromiseState::Pending) return std::nullopt; // Make sure we unfocus anything on the current page. gui->SendFocusMessage(GUIM_LOST_FOCUS); const ScriptRequest rq{gui->GetScriptInterface()}; JS::RootedValue arg{rq.cx, JS::GetPromiseResult(*sendingPromise)}; const bool rejected{JS::GetPromiseState(*sendingPromise) == JS::PromiseState::Rejected}; if (topmostPage) { JS::RootedValue nativeScope{rq.cx, JS::ObjectValue(*rq.nativeScope)}; JS::RootedValue symbol{rq.cx}; Script::GetProperty(rq, nativeScope, START_ATLAS, &symbol); bool equals; if (!JS::StrictlyEqual(rq.cx, arg, symbol, &equals)) throw std::runtime_error{"Error while comparing return value to a symbol."}; if (equals) return CGUIManager::SGUIPage::CloseResult{nullptr, rejected}; } return CGUIManager::SGUIPage::CloseResult{Script::WriteStructuredClone(rq, arg), rejected}; } void CGUIManager::SGUIPage::Refocus(const CloseResult& result) { ENSURE(receivingPromise); std::shared_ptr scriptInterface = gui->GetScriptInterface(); ScriptRequest rq(scriptInterface); JS::RootedObject globalObj(rq.cx, rq.glob); JS::RootedObject recv(rq.cx, *std::exchange(receivingPromise, nullptr)); JS::RootedValue argVal(rq.cx); Script::ReadStructuredClone(rq, result.arg, &argVal); // This only resolves the promise, it doesn't call the continuation. (result.rejected ? JS::RejectPromise : JS::ResolvePromise)(rq.cx, recv, argVal); // We return to a page where some object might have been focused. gui->SendFocusMessage(GUIM_GOT_FOCUS); } Status CGUIManager::ReloadChangedFile(const VfsPath& path) { for (SGUIPage& p : m_PageStack) if (p.inputs.find(path) != p.inputs.end()) { LOGMESSAGE("GUI file '%s' changed - reloading page '%s'", path.string8(), utf8_from_wstring(p.m_Name)); p.LoadPage(m_ScriptContext); // TODO: this can crash if LoadPage runs an init script which modifies the page stack and breaks our iterators } return INFO::OK; } Status CGUIManager::ReloadAllPages() { // TODO: this can crash if LoadPage runs an init script which modifies the page stack and breaks our iterators for (SGUIPage& p : m_PageStack) p.LoadPage(m_ScriptContext); return INFO::OK; } InReaction CGUIManager::HandleEvent(const SDL_Event_* ev) { // We want scripts to have access to the raw input events, so they can do complex // processing when necessary (e.g. for unit selection and camera movement). // Sometimes they'll want to be layered behind the GUI widgets (e.g. to detect mousedowns on the // visible game area), sometimes they'll want to intercepts events before the GUI (e.g. // to capture all mouse events until a mouseup after dragging). // So we call two separate handler functions: bool handled = false; { PROFILE("handleInputBeforeGui"); ScriptRequest rq(*top()->GetScriptInterface()); JS::RootedValue global(rq.cx, rq.globalValue()); if (ScriptFunction::Call(rq, global, "handleInputBeforeGui", handled, *ev, top()->FindObjectUnderMouse())) if (handled) return IN_HANDLED; } { PROFILE("handle event in native GUI"); InReaction r = top()->HandleEvent(ev); if (r != IN_PASS) return r; } { // We can't take the following lines out of this scope because top() may be another gui page than it was when calling handleInputBeforeGui! ScriptRequest rq(*top()->GetScriptInterface()); JS::RootedValue global(rq.cx, rq.globalValue()); PROFILE("handleInputAfterGui"); if (ScriptFunction::Call(rq, global, "handleInputAfterGui", handled, *ev)) if (handled) return IN_HANDLED; } return IN_PASS; } void CGUIManager::SendEventToAll(const CStr& eventName) const { const auto pageStack = GetCopyOfFrozenStack(); for (const SGUIPage& p : pageStack) p.gui->SendEventToAll(eventName); } void CGUIManager::SendEventToAll(const CStr& eventName, JS::HandleValueArray paramData) const { const auto pageStack = GetCopyOfFrozenStack(); for (const SGUIPage& p : pageStack) p.gui->SendEventToAll(eventName, paramData); } std::optional CGUIManager::TickObjects() { PROFILE3("gui tick"); // We share the script context with everything else that runs in the same thread. // This call makes sure we trigger GC regularly even if the simulation is not running. m_ScriptContext.MaybeIncrementalGC(); const auto pageStack = GetCopyOfFrozenStack(); for (const SGUIPage& p : pageStack) p.gui->TickObjects(); m_ScriptContext.RunJobs(); while (!m_PageStack.empty()) { const size_t stackSize{m_PageStack.size()}; const std::optional result{ m_PageStack.back().MaybeClose(stackSize == 1)}; if (!result.has_value()) break; ENSURE(m_PageStack.size() == stackSize); m_PageStack.pop_back(); if (m_PageStack.empty()) return !result.value().arg; else m_PageStack.back().Refocus(result.value()); m_ScriptContext.RunJobs(); } return std::nullopt; } void CGUIManager::Draw(CCanvas2D& canvas) const { PROFILE3("gui"); for (const SGUIPage& p : m_PageStack) p.gui->Draw(canvas); } void CGUIManager::UpdateResolution() { const auto pageStack = GetCopyOfFrozenStack(); for (const SGUIPage& p : pageStack) { p.gui->UpdateResolution(); p.gui->SendEventToAll(EVENT_NAME_WINDOW_RESIZED); } } bool CGUIManager::TemplateExists(const std::string& templateName) const { return m_TemplateLoader.TemplateExists(templateName); } const CParamNode& CGUIManager::GetTemplate(const std::string& templateName) { const CParamNode& templateRoot = m_TemplateLoader.GetTemplateFileData(templateName).GetOnlyChild(); if (!templateRoot.IsOk()) LOGERROR("Invalid template found for '%s'", templateName.c_str()); return templateRoot; } void CGUIManager::DisplayLoadProgress(int percent, const wchar_t* pending_task) { const ScriptInterface& scriptInterface = *(GetActiveGUI()->GetScriptInterface()); ScriptRequest rq(scriptInterface); JS::RootedValueVector paramData(rq.cx); ignore_result(paramData.append(JS::NumberValue(percent))); JS::RootedValue valPendingTask(rq.cx); Script::ToJSVal(rq, &valPendingTask, pending_task); ignore_result(paramData.append(valPendingTask)); SendEventToAll(EVENT_NAME_GAME_LOAD_PROGRESS, paramData); } // This returns a shared_ptr to make sure the CGUI doesn't get deallocated // while we're in the middle of calling a function on it (e.g. if a GUI script // calls SwitchPage) std::shared_ptr CGUIManager::top() const { ENSURE(m_PageStack.size()); return m_PageStack.back().gui; } PS::StaticVector CGUIManager::GetCopyOfFrozenStack() const { PS::StaticVector stack; std::copy(m_PageStack.begin(), m_PageStack.end(), std::back_inserter(stack)); return stack; }