/* Copyright (C) 2024 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 "GUIObjectEventBroadcaster.h"
#include "graphics/Canvas2D.h"
#include "gui/IGUIScrollBar.h"
#include "gui/ObjectBases/IGUIObject.h"
#include "gui/ObjectTypes/CGUIDummyObject.h"
#include "gui/ObjectTypes/CTooltip.h"
#include "gui/Scripting/ScriptFunctions.h"
#include "gui/Scripting/JSInterface_GUIProxy.h"
#include "i18n/L10n.h"
#include "lib/bits.h"
#include "lib/input.h"
#include "lib/sysdep/sysdep.h"
#include "lib/timer.h"
#include "lib/utf8.h"
#include "maths/Size2D.h"
#include "ps/CLogger.h"
#include "ps/Filesystem.h"
#include "ps/GameSetup/Config.h"
#include "ps/Globals.h"
#include "ps/Hotkey.h"
#include "ps/Profile.h"
#include "ps/Pyrogenesis.h"
#include "ps/VideoMode.h"
#include "ps/XML/Xeromyces.h"
#include "scriptinterface/ScriptContext.h"
#include "scriptinterface/ScriptInterface.h"
#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);
m_ScriptInterface->SetCallbackData(this);
GuiScriptingInit(*m_ScriptInterface);
m_ScriptInterface->LoadGlobalScripts();
}
CGUI::~CGUI()
{
for (const std::pair& p : m_pAllObjects)
delete p.second;
}
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;
}
void CGUI::TickObjects()
{
m_BaseObject->RecurseObject(&IGUIObject::IsHiddenOrGhostOrOutOfBoundaries, &IGUIObject::Tick);
SendEventToAll(EventNameTick);
m_Tooltip.Update(FindObjectUnderMouse(), m_MousePos, *this);
}
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);
}
IGUIObject* CGUI::ConstructObject(const CStr& str)
{
std::map::iterator it = m_ObjectTypes.find(str);
if (it == m_ObjectTypes.end())
return nullptr;
return (*it->second)(*this);
}
bool CGUI::AddObject(IGUIObject& parent, IGUIObject& child)
{
if (child.m_Name.empty())
{
LOGERROR("Can't register an object without name!");
return false;
}
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 false;
}
m_pAllObjects[child.m_Name] = &child;
parent.RegisterChild(&child);
return true;
}
IGUIObject* CGUI::GetBaseObject()
{
return m_BaseObject.get();
};
bool CGUI::ObjectExists(const CStr& Name) const
{
return m_pAllObjects.find(Name) != m_pAllObjects.end();
}
IGUIObject* CGUI::FindObjectByName(const CStr& Name) const
{
map_pObjects::const_iterator it = m_pAllObjects.find(Name);
if (it == m_pAllObjects.end())
return nullptr;
return it->second;
}
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* pObject, 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.
pObject->ApplyStyle(styleName);
}
void CGUI::UnsetObjectStyle(IGUIObject* pObject)
{
SetObjectStyle(pObject, "default");
}
void CGUI::SetObjectHotkey(IGUIObject* pObject, const CStr& hotkeyTag)
{
if (!hotkeyTag.empty())
m_HotkeyObjects[hotkeyTag].push_back(pObject);
}
void CGUI::UnsetObjectHotkey(IGUIObject* pObject, const CStr& hotkeyTag)
{
if (hotkeyTag.empty())
return;
std::vector& assignment = m_HotkeyObjects[hotkeyTag];
assignment.erase(
std::remove_if(
assignment.begin(),
assignment.end(),
[&pObject](const IGUIObject* hotkeyObject)
{ return pObject == hotkeyObject; }),
assignment.end());
}
void CGUI::SetGlobalHotkey(const CStr& hotkeyTag, const CStr& eventName, JS::HandleValue function)
{
ScriptRequest rq(*m_ScriptInterface);
if (hotkeyTag.empty())
{
ScriptException::Raise(rq, "Cannot assign a function to an empty hotkey identifier!");
return;
}
// Only support "Press", "Keydown" and "Release" events.
if (eventName != EventNamePress && eventName != EventNameKeyDown && eventName != EventNameRelease)
{
ScriptException::Raise(rq, "Cannot assign a function to an unsupported event!");
return;
}
if (!function.isObject() || !JS::IsCallable(&function.toObject()))
{
ScriptException::Raise(rq, "Cannot assign non-function value to global hotkey '%s'", hotkeyTag.c_str());
return;
}
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