diff --git a/README.md b/README.md index b6d6cec863..631b2104c9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![DuskLogo](res/logo-mascot.webp) +![DuskLogo](res/logo-mascot.png) - ### **[Official Website](https://twilitrealm.dev)** - ### **[Discord](https://discord.gg/QACynxeyna)** diff --git a/extern/aurora b/extern/aurora index b8407c6283..fa03533986 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit b8407c6283d757af7c6d55da96fe2ccfe1380510 +Subproject commit fa0353398661b5794322fb84ac67bb1bc7a5c468 diff --git a/files.cmake b/files.cmake index f91756a9d5..7799564f90 100644 --- a/files.cmake +++ b/files.cmake @@ -1462,6 +1462,26 @@ set(DUSK_FILES src/dusk/imgui/ImGuiStateShare.cpp src/dusk/imgui/ImGuiAchievements.hpp src/dusk/imgui/ImGuiAchievements.cpp + src/dusk/ui/button.hpp + src/dusk/ui/button.cpp + src/dusk/ui/disc_state.hpp + src/dusk/ui/disc_state.cpp + src/dusk/ui/element.hpp + src/dusk/ui/element.cpp + src/dusk/ui/focus_border.hpp + src/dusk/ui/focus_border.cpp + src/dusk/ui/game_option.hpp + src/dusk/ui/game_option.cpp + src/dusk/ui/label.hpp + src/dusk/ui/label.cpp + src/dusk/ui/prelaunch_layout.hpp + src/dusk/ui/prelaunch_layout.cpp + src/dusk/ui/prelaunch_screen.hpp + src/dusk/ui/prelaunch_screen.cpp + src/dusk/ui/theme.hpp + src/dusk/ui/theme.cpp + src/dusk/ui/ui.hpp + src/dusk/ui/ui.cpp src/dusk/achievements.cpp src/dusk/iso_validate.cpp src/dusk/livesplit.cpp diff --git a/res/logo-mascot.png b/res/logo-mascot.png new file mode 100644 index 0000000000..9f9a5d1ace Binary files /dev/null and b/res/logo-mascot.png differ diff --git a/res/logo-mascot.webp b/res/logo-mascot.webp deleted file mode 100644 index c22f3bc90f..0000000000 Binary files a/res/logo-mascot.webp and /dev/null differ diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index a94c7d1f5b..c29ebbac0c 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -22,6 +22,7 @@ #include "dusk/livesplit.h" #include "dusk/main.h" #include "dusk/settings.h" +#include "dusk/ui/prelaunch_screen.hpp" #include "m_Do/m_Do_controller_pad.h" #include "m_Do/m_Do_main.h" #include "tracy/Tracy.hpp" @@ -339,7 +340,7 @@ namespace dusk { ImGuiMenuGame::ToggleFullscreen(); } - if (!dusk::IsGameLaunched) { + if (!dusk::IsGameLaunched && !dusk::ui::prelaunch::is_active()) { m_preLaunchWindow.draw(); } diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index e83b3fd34e..a4e64c5b70 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -101,6 +101,10 @@ ValidationError validate(const char* path) { NodHandleWrapper disc; const auto sdlStream = SDL_IOFromFile(path, "rb"); + if (sdlStream == nullptr) { + return ValidationError::IOError; + } + const NodDiscStream nod_stream { .user_data = sdlStream, .read_at = StreamReadAt, diff --git a/src/dusk/ui/button.cpp b/src/dusk/ui/button.cpp new file mode 100644 index 0000000000..69049dc1bf --- /dev/null +++ b/src/dusk/ui/button.cpp @@ -0,0 +1,160 @@ +#include "button.hpp" + +#include "element.hpp" +#include "focus_border.hpp" +#include "label.hpp" +#include "theme.hpp" + +#include + +#include + +namespace dusk::ui { +namespace { + +theme::Color variant_color(ButtonVariant variant) { + switch (variant) { + case ButtonVariant::Primary: + return theme::Primary; + case ButtonVariant::Secondary: + return theme::Secondary; + case ButtonVariant::Quiet: + default: + return theme::Elevated; + } +} + +} // namespace + +Button::Button(Rml::Element* parent, std::string_view id, std::string_view text, + ButtonVariant variant, std::function pressedCallback) + : m_variant(variant), m_pressedCallback(std::move(pressedCallback)) { + using namespace theme; + + m_element = append(parent, "button", id); + set_props(m_element, { + {"display", "flex"}, + {"position", "relative"}, + {"flex-direction", "row"}, + {"align-items", "center"}, + {"justify-content", "center"}, + {"box-sizing", "border-box"}, + {"width", "100%"}, + {"height", "68dp"}, + {"min-height", "68dp"}, + {"max-height", "68dp"}, + {"padding-left", "22dp"}, + {"padding-right", "22dp"}, + {"border-width", dp(BorderWidth)}, + {"border-radius", dp(BorderRadiusMedium)}, + {"cursor", "pointer"}, + {"tab-index", "auto"}, + {"nav-up", "auto"}, + {"nav-down", "auto"}, + {"nav-left", "auto"}, + {"nav-right", "auto"}, + {"opacity", "1"}, + {"font-family", "Inter"}, + {"color", rgba(Text)}, + }); + + add_focus_border(m_element, BorderRadiusMedium); + m_label = append_text(m_element, "span", text); + apply_label_style(m_label, LabelStyle::Medium); + set_props(m_label, { + {"pointer-events", "none"}, + {"text-align", "center"}, + }); + + m_element->AddEventListener(Rml::EventId::Click, this); + m_element->AddEventListener(Rml::EventId::Focus, this); + m_element->AddEventListener(Rml::EventId::Blur, this); + m_element->AddEventListener(Rml::EventId::Mouseover, this); + m_element->AddEventListener(Rml::EventId::Mouseout, this); + apply_style(); +} + +Button::~Button() { + if (m_element == nullptr) { + return; + } + + m_element->RemoveEventListener(Rml::EventId::Click, this); + m_element->RemoveEventListener(Rml::EventId::Focus, this); + m_element->RemoveEventListener(Rml::EventId::Blur, this); + m_element->RemoveEventListener(Rml::EventId::Mouseover, this); + m_element->RemoveEventListener(Rml::EventId::Mouseout, this); + m_element = nullptr; +} + +void Button::ProcessEvent(Rml::Event& event) { + switch (event.GetId()) { + case Rml::EventId::Click: + if (m_pressedCallback) { + m_pressedCallback(); + } + break; + case Rml::EventId::Focus: + m_focused = true; + apply_style(); + break; + case Rml::EventId::Blur: + m_focused = false; + apply_style(); + break; + case Rml::EventId::Mouseover: + m_focused = true; + apply_style(); + break; + case Rml::EventId::Mouseout: + m_focused = false; + apply_style(); + break; + default: + break; + } +} + +Rml::Element* Button::element() const { + return m_element; +} + +std::string Button::id() const { + return m_element == nullptr ? std::string{} : m_element->GetId(); +} + +void Button::set_text(std::string_view text) { + ui::set_text(m_label, text); +} + +void Button::apply_style() { + using namespace theme; + + if (m_element == nullptr) { + return; + } + + const bool active = m_hovered || m_focused; + const bool isBasic = m_variant == ButtonVariant::Quiet; + const Color color = variant_color(m_variant); + + int borderOpacity = isBasic ? 0 : 190; + int backgroundOpacity = isBasic ? 0 : 28; + int backgroundHoverOpacity = 116; + int borderHoverOpacity = isBasic ? backgroundHoverOpacity : 255; + + if (m_variant == ButtonVariant::Quiet) { + backgroundHoverOpacity = 68; + borderHoverOpacity = 150; + } + + m_element->SetProperty("border-color", + rgba(color, active ? borderHoverOpacity : borderOpacity)); + m_element->SetProperty("background-color", + rgba(color, active ? backgroundHoverOpacity : backgroundOpacity)); + m_element->SetProperty("color", rgba(active ? TextActive : Text)); + m_label->SetProperty("color", rgba(active ? TextActive : Text)); + set_focus_border_visible(m_element, m_focused); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/button.hpp b/src/dusk/ui/button.hpp new file mode 100644 index 0000000000..c60e2a0c8d --- /dev/null +++ b/src/dusk/ui/button.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include +#include +#include + +namespace Rml { +class Element; +} + +namespace dusk::ui { + +enum class ButtonVariant { + Primary, + Secondary, + Quiet, +}; + +class Button : public Rml::EventListener { +public: + Button(Rml::Element* parent, std::string_view id, std::string_view text, ButtonVariant variant, + std::function pressedCallback); + ~Button() override; + + Button(const Button&) = delete; + Button& operator=(const Button&) = delete; + + void ProcessEvent(Rml::Event& event) override; + + Rml::Element* element() const; + std::string id() const; + void set_text(std::string_view text); + +private: + Rml::Element* m_element = nullptr; + Rml::Element* m_label = nullptr; + ButtonVariant m_variant = ButtonVariant::Secondary; + std::function m_pressedCallback; + bool m_hovered = false; + bool m_focused = false; + + void apply_style(); +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/disc_state.cpp b/src/dusk/ui/disc_state.cpp new file mode 100644 index 0000000000..c550b4c97c --- /dev/null +++ b/src/dusk/ui/disc_state.cpp @@ -0,0 +1,133 @@ +#include "disc_state.hpp" + +#include "element.hpp" +#include "focus_border.hpp" +#include "label.hpp" +#include "theme.hpp" + +#include + +#include + +namespace dusk::ui { + +DiscState::DiscState(Rml::Element* parent, std::string_view id, std::string_view text, bool error, + std::function pressedCallback) + : m_pressedCallback(std::move(pressedCallback)), m_error(error) { + using namespace theme; + + m_element = append(parent, "button", id); + set_props(m_element, { + {"display", "flex"}, + {"position", "relative"}, + {"flex-direction", "column"}, + {"align-items", "stretch"}, + {"gap", "6dp"}, + {"width", "100%"}, + {"box-sizing", "border-box"}, + {"padding", "14dp 16dp"}, + {"border-width", dp(BorderWidth)}, + {"border-radius", dp(BorderRadiusSmall)}, + {"cursor", "pointer"}, + {"tab-index", "auto"}, + {"nav-up", "auto"}, + {"nav-down", "auto"}, + {"nav-left", "auto"}, + {"nav-right", "auto"}, + {"font-family", "Inter"}, + }); + + add_focus_border(m_element, BorderRadiusSmall); + + m_label = add_label(m_element, error ? "Disc Error" : "Selected Disc", LabelStyle::Annotation); + set_props(m_label, { + {"pointer-events", "none"}, + }); + + m_value = add_label(m_element, text, LabelStyle::Body); + set_props(m_value, { + {"overflow", "hidden"}, + {"text-overflow", "ellipsis"}, + {"white-space", "nowrap"}, + {"pointer-events", "none"}, + }); + + m_element->AddEventListener(Rml::EventId::Click, this); + m_element->AddEventListener(Rml::EventId::Focus, this); + m_element->AddEventListener(Rml::EventId::Blur, this); + m_element->AddEventListener(Rml::EventId::Mouseover, this); + m_element->AddEventListener(Rml::EventId::Mouseout, this); + apply_style(); +} + +DiscState::~DiscState() { + if (m_element == nullptr) { + return; + } + + m_element->RemoveEventListener(Rml::EventId::Click, this); + m_element->RemoveEventListener(Rml::EventId::Focus, this); + m_element->RemoveEventListener(Rml::EventId::Blur, this); + m_element->RemoveEventListener(Rml::EventId::Mouseover, this); + m_element->RemoveEventListener(Rml::EventId::Mouseout, this); + m_element = nullptr; +} + +void DiscState::ProcessEvent(Rml::Event& event) { + switch (event.GetId()) { + case Rml::EventId::Click: + if (m_pressedCallback) { + m_pressedCallback(); + } + break; + case Rml::EventId::Focus: + m_focused = true; + apply_style(); + break; + case Rml::EventId::Blur: + m_focused = false; + apply_style(); + break; + case Rml::EventId::Mouseover: + m_hovered = true; + apply_style(); + break; + case Rml::EventId::Mouseout: + m_hovered = false; + apply_style(); + break; + default: + break; + } +} + +Rml::Element* DiscState::element() const { + return m_element; +} + +std::string DiscState::id() const { + return m_element == nullptr ? std::string{} : m_element->GetId(); +} + +void DiscState::apply_style() { + using namespace theme; + + if (m_element == nullptr) { + return; + } + + const bool active = m_hovered || m_focused; + const Color accent = m_error ? Danger : Primary; + + m_element->SetProperty("background-color", rgba(accent, active ? 52 : (m_error ? 32 : 20))); + m_element->SetProperty("border-color", rgba(accent, active ? 220 : (m_error ? 190 : 120))); + m_element->SetProperty("color", rgba(active ? TextActive : Text)); + + m_label->SetProperty("color", rgba(m_error ? Danger : (active ? TextActive : TextDim), + m_error ? 220 : (active ? TextActive.a : TextDim.a))); + m_value->SetProperty("color", rgba(m_error ? Danger : (active ? TextActive : Text), + m_error ? 255 : (active ? TextActive.a : Text.a))); + set_focus_border_visible(m_element, m_focused); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/disc_state.hpp b/src/dusk/ui/disc_state.hpp new file mode 100644 index 0000000000..bc069482ae --- /dev/null +++ b/src/dusk/ui/disc_state.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include +#include +#include + +namespace Rml { +class Element; +} + +namespace dusk::ui { + +class DiscState : public Rml::EventListener { +public: + DiscState(Rml::Element* parent, std::string_view id, std::string_view text, bool error, + std::function pressedCallback); + ~DiscState() override; + + DiscState(const DiscState&) = delete; + DiscState& operator=(const DiscState&) = delete; + + void ProcessEvent(Rml::Event& event) override; + + Rml::Element* element() const; + std::string id() const; + +private: + Rml::Element* m_element = nullptr; + Rml::Element* m_label = nullptr; + Rml::Element* m_value = nullptr; + std::function m_pressedCallback; + bool m_error = false; + bool m_hovered = false; + bool m_focused = false; + + void apply_style(); +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/element.cpp b/src/dusk/ui/element.cpp new file mode 100644 index 0000000000..c74ad74755 --- /dev/null +++ b/src/dusk/ui/element.cpp @@ -0,0 +1,81 @@ +#include "element.hpp" + +#include + +#include + +namespace dusk::ui { + +std::string escape(std::string_view text) { + std::string result; + result.reserve(text.size()); + for (char c : text) { + switch (c) { + case '&': + result += "&"; + break; + case '<': + result += "<"; + break; + case '>': + result += ">"; + break; + case '"': + result += """; + break; + case '\'': + result += "'"; + break; + default: + result += c; + break; + } + } + return result; +} + +Rml::Element* append(Rml::Element* parent, std::string_view tag, std::string_view id) { + if (parent == nullptr) { + return nullptr; + } + + Rml::ElementDocument* document = parent->GetOwnerDocument(); + if (document == nullptr) { + document = dynamic_cast(parent); + } + if (document == nullptr) { + return nullptr; + } + + Rml::ElementPtr child = document->CreateElement(std::string(tag)); + Rml::Element* rawChild = child.get(); + if (!id.empty()) { + rawChild->SetId(std::string(id)); + } + return parent->AppendChild(std::move(child)); +} + +Rml::Element* append_text(Rml::Element* parent, std::string_view tag, std::string_view text, + std::string_view id) { + Rml::Element* element = append(parent, tag, id); + set_text(element, text); + return element; +} + +void set_text(Rml::Element* element, std::string_view text) { + if (element != nullptr) { + element->SetInnerRML(escape(text)); + } +} + +void set_props(Rml::Element* element, + std::initializer_list > properties) { + if (element == nullptr) { + return; + } + for (const auto& [name, value] : properties) { + element->SetProperty(std::string(name), std::string(value)); + } +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/element.hpp b/src/dusk/ui/element.hpp new file mode 100644 index 0000000000..9d28471bce --- /dev/null +++ b/src/dusk/ui/element.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include +#include + +namespace dusk::ui { + +std::string escape(std::string_view text); +Rml::Element* append(Rml::Element* parent, std::string_view tag, std::string_view id = {}); +Rml::Element* append_text(Rml::Element* parent, std::string_view tag, std::string_view text, + std::string_view id = {}); +void set_text(Rml::Element* element, std::string_view text); +void set_props(Rml::Element* element, + std::initializer_list > properties); + +} // namespace dusk::ui diff --git a/src/dusk/ui/focus_border.cpp b/src/dusk/ui/focus_border.cpp new file mode 100644 index 0000000000..8fc474f956 --- /dev/null +++ b/src/dusk/ui/focus_border.cpp @@ -0,0 +1,36 @@ +#include "focus_border.hpp" + +#include "element.hpp" +#include "theme.hpp" + +namespace dusk::ui { + +Rml::Element* add_focus_border(Rml::Element* parent, float radius) { + using namespace theme; + + auto* border = append(parent, "div"); + set_props(border, { + {"position", "absolute"}, + {"pointer-events", "none"}, + {"left", dp(-(BorderWidth * 3.0f))}, + {"top", dp(-(BorderWidth * 3.0f))}, + {"right", dp(-(BorderWidth * 3.0f))}, + {"bottom", dp(-(BorderWidth * 3.0f))}, + {"border-width", dp(BorderWidth * 2.0f)}, + {"border-radius", dp(radius + BorderWidth * 4.0f)}, + {"border-color", rgba(PrimaryLight, 0)}, + }); + return border; +} + +void set_focus_border_visible(Rml::Element* parent, bool visible) { + if (parent == nullptr || parent->GetNumChildren() == 0) { + return; + } + + parent->GetChild(0)->SetProperty("border-color", visible ? + theme::rgba(theme::PrimaryLight, 255) : + theme::rgba(theme::PrimaryLight, 0)); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/focus_border.hpp b/src/dusk/ui/focus_border.hpp new file mode 100644 index 0000000000..79bc315bbd --- /dev/null +++ b/src/dusk/ui/focus_border.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace dusk::ui { + +Rml::Element* add_focus_border(Rml::Element* parent, float radius); +void set_focus_border_visible(Rml::Element* parent, bool visible); + +} // namespace dusk::ui diff --git a/src/dusk/ui/game_option.cpp b/src/dusk/ui/game_option.cpp new file mode 100644 index 0000000000..2ada2374d9 --- /dev/null +++ b/src/dusk/ui/game_option.cpp @@ -0,0 +1,187 @@ +#include "game_option.hpp" + +#include "element.hpp" +#include "focus_border.hpp" +#include "label.hpp" +#include "theme.hpp" + +#include + +#include + +namespace dusk::ui { + +GameOption::GameOption(Rml::Element* parent, std::string_view id, std::string_view title, + std::string_view value, std::string_view detail, + std::function pressedCallback) + : m_pressedCallback(std::move(pressedCallback)) { + using namespace theme; + + m_element = append(parent, "button", id); + set_props(m_element, { + {"display", "flex"}, + {"position", "relative"}, + {"flex-direction", "row"}, + {"align-items", "center"}, + {"justify-content", "space-between"}, + {"box-sizing", "border-box"}, + {"gap", "16dp"}, + {"width", "100%"}, + {"height", "auto"}, + {"padding", "16dp"}, + {"border-width", dp(BorderWidth)}, + {"border-radius", dp(BorderRadiusSmall)}, + {"background-color", rgba(Transparent)}, + {"border-color", rgba(ElevatedBorder, 0)}, + {"color", rgba(TextDim)}, + {"cursor", "pointer"}, + {"tab-index", "auto"}, + {"nav-up", "auto"}, + {"nav-down", "auto"}, + {"nav-left", "auto"}, + {"nav-right", "auto"}, + {"opacity", "1"}, + {"font-family", "Inter"}, + }); + + add_focus_border(m_element, BorderRadiusSmall); + + auto* left = append(m_element, "div"); + set_props(left, { + {"display", "flex"}, + {"flex-direction", "column"}, + {"gap", "4dp"}, + {"min-width", "0"}, + {"width", "0"}, + {"flex-grow", "1"}, + {"flex-shrink", "1"}, + {"pointer-events", "none"}, + }); + + m_title = add_label(left, title, LabelStyle::Large); + set_props(m_title, { + {"color", rgba(TextDim)}, + {"font-size", "28dp"}, + {"letter-spacing", "1dp"}, + }); + + if (!value.empty() || !detail.empty()) { + auto* right = append(m_element, "div"); + set_props(right, { + {"display", "flex"}, + {"flex-direction", "column"}, + {"align-items", "flex-end"}, + {"justify-content", "center"}, + {"gap", "4dp"}, + {"min-width", "170dp"}, + {"max-width", "48%"}, + {"flex-shrink", "0"}, + {"pointer-events", "none"}, + }); + + if (!value.empty()) { + m_value = add_label(right, value, LabelStyle::Body); + set_props(m_value, { + {"color", rgba(TextDim)}, + {"text-align", "right"}, + {"overflow", "hidden"}, + {"text-overflow", "ellipsis"}, + {"white-space", "nowrap"}, + }); + } + + if (!detail.empty()) { + m_detail = add_label(right, detail, LabelStyle::Annotation); + set_props(m_detail, { + {"color", rgba(TextDim)}, + {"text-align", "right"}, + }); + } + } + + m_element->AddEventListener(Rml::EventId::Click, this); + m_element->AddEventListener(Rml::EventId::Focus, this); + m_element->AddEventListener(Rml::EventId::Blur, this); + m_element->AddEventListener(Rml::EventId::Mouseover, this); + m_element->AddEventListener(Rml::EventId::Mouseout, this); + apply_style(); +} + +GameOption::~GameOption() { + if (m_element == nullptr) { + return; + } + + m_element->RemoveEventListener(Rml::EventId::Click, this); + m_element->RemoveEventListener(Rml::EventId::Focus, this); + m_element->RemoveEventListener(Rml::EventId::Blur, this); + m_element->RemoveEventListener(Rml::EventId::Mouseover, this); + m_element->RemoveEventListener(Rml::EventId::Mouseout, this); + m_element = nullptr; +} + +void GameOption::ProcessEvent(Rml::Event& event) { + switch (event.GetId()) { + case Rml::EventId::Click: + if (m_pressedCallback) { + m_pressedCallback(); + } + break; + case Rml::EventId::Focus: + m_focused = true; + apply_style(); + break; + case Rml::EventId::Blur: + m_focused = false; + apply_style(); + break; + case Rml::EventId::Mouseover: + m_hovered = true; + apply_style(); + break; + case Rml::EventId::Mouseout: + m_hovered = false; + apply_style(); + break; + default: + break; + } +} + +Rml::Element* GameOption::element() const { + return m_element; +} + +std::string GameOption::id() const { + return m_element == nullptr ? std::string{} : m_element->GetId(); +} + +void GameOption::set_value(std::string_view value) { + set_text(m_value, value); +} + +void GameOption::apply_style() { + if (m_element == nullptr) { + return; + } + + const bool active = m_hovered || m_focused; + m_element->SetProperty("background-color", active ? theme::rgba(theme::Primary, 52) : + theme::rgba(theme::Primary, 0)); + m_element->SetProperty("border-color", active ? theme::rgba(theme::Primary, 220) : + theme::rgba(theme::ElevatedBorder, 42)); + m_element->SetProperty("color", + active ? theme::rgba(theme::TextActive) : theme::rgba(theme::TextDim)); + m_title->SetProperty("color", + active ? theme::rgba(theme::TextActive) : theme::rgba(theme::TextDim)); + if (m_value != nullptr) { + m_value->SetProperty("color", + active ? theme::rgba(theme::TextActive) : theme::rgba(theme::TextDim)); + } + if (m_detail != nullptr) { + m_detail->SetProperty("color", theme::rgba(theme::TextDim)); + } + set_focus_border_visible(m_element, m_focused); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/game_option.hpp b/src/dusk/ui/game_option.hpp new file mode 100644 index 0000000000..a8ae5c20b8 --- /dev/null +++ b/src/dusk/ui/game_option.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include +#include +#include + +namespace Rml { +class Element; +} + +namespace dusk::ui { + +class GameOption : public Rml::EventListener { +public: + GameOption(Rml::Element* parent, std::string_view id, std::string_view title, + std::string_view value, std::string_view detail, + std::function pressedCallback); + ~GameOption() override; + + GameOption(const GameOption&) = delete; + GameOption& operator=(const GameOption&) = delete; + + void ProcessEvent(Rml::Event& event) override; + + Rml::Element* element() const; + std::string id() const; + void set_value(std::string_view value); + +private: + Rml::Element* m_element = nullptr; + Rml::Element* m_title = nullptr; + Rml::Element* m_value = nullptr; + Rml::Element* m_detail = nullptr; + std::function m_pressedCallback; + bool m_hovered = false; + bool m_focused = false; + + void apply_style(); +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/label.cpp b/src/dusk/ui/label.cpp new file mode 100644 index 0000000000..ca8ce3d4af --- /dev/null +++ b/src/dusk/ui/label.cpp @@ -0,0 +1,45 @@ +#include "label.hpp" + +#include "element.hpp" +#include "theme.hpp" + +namespace dusk::ui { + +void apply_label_style(Rml::Element* element, LabelStyle style) { + using namespace theme; + + switch (style) { + case LabelStyle::Annotation: + set_props(element, {{"font-size", "18dp"}, + {"letter-spacing", "2dp"}, + {"font-weight", "400"}, + {"color", rgba(TextDim)}}); + break; + case LabelStyle::Body: + set_props(element, {{"font-size", "20dp"}, + {"letter-spacing", "0"}, + {"font-weight", "400"}, + {"color", rgba(Text)}}); + break; + case LabelStyle::Medium: + set_props(element, {{"font-size", "28dp"}, + {"letter-spacing", "3dp"}, + {"font-weight", "700"}, + {"color", rgba(Text)}}); + break; + case LabelStyle::Large: + set_props(element, {{"font-size", "36dp"}, + {"letter-spacing", "4dp"}, + {"font-weight", "700"}, + {"color", rgba(Text)}}); + break; + } +} + +Rml::Element* add_label(Rml::Element* parent, std::string_view text, LabelStyle style) { + Rml::Element* label = append_text(parent, "div", text); + apply_label_style(label, style); + return label; +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/label.hpp b/src/dusk/ui/label.hpp new file mode 100644 index 0000000000..fb23ad408f --- /dev/null +++ b/src/dusk/ui/label.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include + +namespace dusk::ui { + +enum class LabelStyle { + Annotation, + Body, + Medium, + Large, +}; + +Rml::Element* add_label(Rml::Element* parent, std::string_view text, LabelStyle style); +void apply_label_style(Rml::Element* element, LabelStyle style); + +} // namespace dusk::ui diff --git a/src/dusk/ui/prelaunch_layout.cpp b/src/dusk/ui/prelaunch_layout.cpp new file mode 100644 index 0000000000..c5c6ff9dc7 --- /dev/null +++ b/src/dusk/ui/prelaunch_layout.cpp @@ -0,0 +1,113 @@ +#include "prelaunch_layout.hpp" + +#include "element.hpp" +#include "label.hpp" +#include "theme.hpp" + +namespace dusk::ui::prelaunch::layout { + +void style_document(Rml::ElementDocument* document) { + using namespace theme; + + set_props(document, { + {"width", "100%"}, + {"height", "100%"}, + {"margin", "0"}, + {"padding", "0"}, + {"font-family", "Inter"}, + {"background-color", rgba(Background1)}, + {"color", rgba(Text)}, + }); +} + +Rml::Element* add_screen(Rml::ElementDocument* document, ScreenLayout layout) { + using namespace theme; + + auto* screen = append(document, "div", "prelaunch-screen"); + set_props(screen, + { + {"display", "flex"}, + {"position", "absolute"}, + {"left", "0"}, + {"top", "0"}, + {"right", "0"}, + {"bottom", "0"}, + {"flex-direction", layout == ScreenLayout::CompactSplit ? "row" : "column"}, + {"align-items", "center"}, + {"justify-content", "center"}, + {"gap", layout == ScreenLayout::CompactSplit ? "28dp" : "24dp"}, + {"box-sizing", "border-box"}, + {"padding", layout == ScreenLayout::CompactSplit ? "24dp" : "48dp 28dp"}, + {"background-color", rgba(Background1)}, + }); + return screen; +} + +Rml::Element* add_brand(Rml::Element* parent, std::string_view logoPath, bool compact) { + auto* brand = append(parent, "div"); + set_props(brand, { + {"display", "flex"}, + {"flex-direction", "column"}, + {"align-items", "center"}, + {"justify-content", "center"}, + {"gap", compact ? "8dp" : "12dp"}, + {"width", compact ? "260dp" : "100%"}, + {"max-width", compact ? "32%" : "720dp"}, + {"flex-shrink", compact ? "0" : "1"}, + }); + + auto* subtitle = add_label(brand, "Twilit Realm presents", LabelStyle::Annotation); + set_props(subtitle, { + {"text-align", "center"}, + {"font-size", compact ? "14dp" : "18dp"}, + }); + + if (!logoPath.empty()) { + auto* logo = append(brand, "img"); + logo->SetAttribute("src", std::string(logoPath)); + set_props(logo, { + {"width", compact ? "220dp" : "360dp"}, + {"max-width", compact ? "100%" : "70%"}, + {"height", "auto"}, + }); + } else { + auto* title = add_label(brand, "Dusk", LabelStyle::Large); + set_props(title, { + {"font-size", compact ? "42dp" : "54dp"}, + {"letter-spacing", compact ? "3dp" : "4dp"}, + }); + } + return brand; +} + +Rml::Element* add_heading(Rml::Element* parent, std::string_view title) { + auto* heading = add_label(parent, title, LabelStyle::Large); + set_props(heading, { + {"width", "100%"}, + {"max-width", "840dp"}, + {"text-align", "left"}, + }); + return heading; +} + +Rml::Element* add_panel(Rml::Element* parent, bool wide, bool compact) { + using namespace theme; + + auto* panel = append(parent, "div"); + set_props(panel, { + {"display", "flex"}, + {"flex-direction", "column"}, + {"gap", "12dp"}, + {"width", wide ? "840dp" : "520dp"}, + {"max-width", compact ? "62%" : "100%"}, + {"box-sizing", "border-box"}, + {"padding", compact ? "16dp" : "20dp"}, + {"border-width", dp(BorderWidth)}, + {"border-radius", dp(BorderRadiusMedium)}, + {"border-color", rgba(ElevatedBorder, ElevatedBorder.a)}, + {"background-color", rgba(ElevatedSoft, ElevatedSoft.a)}, + }); + return panel; +} + +} // namespace dusk::ui::prelaunch::layout diff --git a/src/dusk/ui/prelaunch_layout.hpp b/src/dusk/ui/prelaunch_layout.hpp new file mode 100644 index 0000000000..c43136a787 --- /dev/null +++ b/src/dusk/ui/prelaunch_layout.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +#include + +namespace dusk::ui::prelaunch::layout { + +enum class ScreenLayout { + Standard, + CompactSplit, +}; + +void style_document(Rml::ElementDocument* document); +Rml::Element* add_screen(Rml::ElementDocument* document, + ScreenLayout layout = ScreenLayout::Standard); +Rml::Element* add_brand(Rml::Element* parent, std::string_view logoPath, bool compact = false); +Rml::Element* add_heading(Rml::Element* parent, std::string_view title); +Rml::Element* add_panel(Rml::Element* parent, bool wide, bool compact = false); + +} // namespace dusk::ui::prelaunch::layout diff --git a/src/dusk/ui/prelaunch_screen.cpp b/src/dusk/ui/prelaunch_screen.cpp new file mode 100644 index 0000000000..3bc2a85865 --- /dev/null +++ b/src/dusk/ui/prelaunch_screen.cpp @@ -0,0 +1,924 @@ +#include "prelaunch_screen.hpp" + +#include "button.hpp" +#include "disc_state.hpp" +#include "game_option.hpp" +#include "prelaunch_layout.hpp" +#include "ui.hpp" + +#include "../file_select.hpp" +#include "../iso_validate.hpp" +#include "dusk/config.hpp" +#include "dusk/main.h" +#include "dusk/settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "aurora/lib/window.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::ui::prelaunch { +namespace { + +enum class View { + Main, + Options, + LanguageSelect, + GraphicsBackendSelect, + SaveFileTypeSelect, +}; + +struct BackendChoice { + AuroraBackend backend = BACKEND_AUTO; + std::string id; + std::string name; +}; + +constexpr std::array kLanguageNames = { + "English", "German", "French", "Spanish", "Italian", +}; + +constexpr std::array kGameDiscFileFilters{{ + {"Game Disc Images", "iso;gcm;ciso;gcz;nfs;rvz;wbfs;wia;tgc"}, + {"All Files", "*"}, +}}; + +std::string iso_validation_error_message(iso::ValidationError code) { + switch (code) { + case iso::ValidationError::IOError: + return "Unable to read selected disc image"; + case iso::ValidationError::InvalidImage: + return "Unable to interpret selected file as a disc image"; + case iso::ValidationError::WrongGame: + return "Selected disc is for a different game"; + case iso::ValidationError::WrongVersion: + return "Selected disc is for an unsupported version. Only NTSC & PAL GameCube are " + "supported at this time"; + case iso::ValidationError::ExecutableMismatch: + return "Selected disc image contains modified executable files."; + case iso::ValidationError::Success: + return {}; + case iso::ValidationError::Unknown: + default: + return "Unknown disc image validation error"; + } +} + +std::string_view backend_name(AuroraBackend backend) { + switch (backend) { + default: + return "Auto"; + case BACKEND_D3D12: + return "D3D12"; + case BACKEND_D3D11: + return "D3D11"; + case BACKEND_METAL: + return "Metal"; + case BACKEND_VULKAN: + return "Vulkan"; + case BACKEND_OPENGL: + return "OpenGL"; + case BACKEND_OPENGLES: + return "OpenGL ES"; + case BACKEND_WEBGPU: + return "WebGPU"; + case BACKEND_NULL: + return "Null"; + } +} + +std::string_view backend_id(AuroraBackend backend) { + switch (backend) { + default: + return "auto"; + case BACKEND_D3D12: + return "d3d12"; + case BACKEND_D3D11: + return "d3d11"; + case BACKEND_METAL: + return "metal"; + case BACKEND_VULKAN: + return "vulkan"; + case BACKEND_OPENGL: + return "opengl"; + case BACKEND_OPENGLES: + return "opengles"; + case BACKEND_WEBGPU: + return "webgpu"; + case BACKEND_NULL: + return "null"; + } +} + +bool try_parse_backend(std::string_view backend, AuroraBackend& outBackend) { + if (backend == "auto") { + outBackend = BACKEND_AUTO; + return true; + } + if (backend == "d3d11") { + outBackend = BACKEND_D3D11; + return true; + } + if (backend == "d3d12") { + outBackend = BACKEND_D3D12; + return true; + } + if (backend == "metal") { + outBackend = BACKEND_METAL; + return true; + } + if (backend == "vulkan") { + outBackend = BACKEND_VULKAN; + return true; + } + if (backend == "opengl") { + outBackend = BACKEND_OPENGL; + return true; + } + if (backend == "opengles") { + outBackend = BACKEND_OPENGLES; + return true; + } + if (backend == "webgpu") { + outBackend = BACKEND_WEBGPU; + return true; + } + if (backend == "null") { + outBackend = BACKEND_NULL; + return true; + } + return false; +} + +std::string_view card_type_name(CARDFileType type) { + switch (type) { + case CARD_GCIFOLDER: + return "GCI Folder"; + case CARD_RAWIMAGE: + return "Card Image"; + default: + return ""; + } +} + +std::filesystem::path resource_path(const char* filename) { + const char* basePath = SDL_GetBasePath(); + if (basePath == nullptr) { + return std::filesystem::path("res") / filename; + } + return std::filesystem::path(basePath) / "res" / filename; +} + +std::string display_path(std::string_view path) { + const char* home = SDL_GetUserFolder(SDL_FOLDER_HOME); + if (home == nullptr || home[0] == '\0') { + home = std::getenv("HOME"); + } + if (home == nullptr || home[0] == '\0') { + return std::string(path); + } + + std::string homePath(home); + while (homePath.size() > 1 && homePath.back() == '/') { + homePath.pop_back(); + } + + if (path == homePath) { + return "~"; + } + + if (path.size() > homePath.size() && path.substr(0, homePath.size()) == homePath && + path[homePath.size()] == '/') + { + return "~" + std::string(path.substr(homePath.size())); + } + + return std::string(path); +} + +std::vector backend_choices() { + std::vector choices; + choices.push_back({BACKEND_AUTO, std::string(backend_id(BACKEND_AUTO)), + std::string(backend_name(BACKEND_AUTO))}); + + size_t backendCount = 0; + const AuroraBackend* availableBackends = aurora_get_available_backends(&backendCount); + for (size_t i = 0; i < backendCount; ++i) { + const AuroraBackend backend = availableBackends[i]; + choices.push_back( + {backend, std::string(backend_id(backend)), std::string(backend_name(backend))}); + } + + return choices; +} + +class Screen : public Rml::EventListener { +public: + bool initialize() { + if (m_initialized) { + return true; + } + if (!ui::initialize()) { + return false; + } + + m_selectedIsoPath = getSettings().backend.isoPath.getValue(); + validate_selected_iso(false); + m_initialGraphicsBackend = getSettings().backend.graphicsBackend.getValue(); + m_initialized = true; + + if (is_selected_path_valid() && getSettings().backend.skipPreLaunchUI.getValue()) { + IsGameLaunched = true; + return true; + } + + set_active(true); + rebuild(); + if (m_document == nullptr) { + shutdown(); + return false; + } + return true; + } + + void shutdown() { + close_document(); + set_active(false); + m_initialized = false; + m_focusIds.clear(); + } + + bool is_active() const { return m_initialized && ui::is_active(); } + + void handle_event(const SDL_Event& event) { ui::handle_event(event); } + + void update() { + if (m_requestedBack) { + m_requestedBack = false; + back(); + if (!is_active()) { + return; + } + } + + if (!m_requestedActivation.empty()) { + const std::string requestedActivation = m_requestedActivation; + m_requestedActivation.clear(); + activate(requestedActivation); + if (!is_active()) { + return; + } + } + + if (m_requestedCycleDirection != 0) { + const std::string requestedCycleId = m_requestedCycleId; + const int requestedCycleDirection = m_requestedCycleDirection; + m_requestedCycleId.clear(); + m_requestedCycleDirection = 0; + cycle_option(requestedCycleId, requestedCycleDirection); + if (!is_active()) { + return; + } + } + + if (is_selected_path_valid() && getSettings().backend.skipPreLaunchUI.getValue()) { + IsGameLaunched = true; + shutdown(); + return; + } + + ui::update(); + rebuild_if_layout_changed(); + } + + void ProcessEvent(Rml::Event& event) override { + if (event.GetId() == Rml::EventId::Keydown) { + const auto key = static_cast( + event.GetParameter("key_identifier", Rml::Input::KI_UNKNOWN)); + if (handle_key(key)) { + event.StopImmediatePropagation(); + } + } + } + +private: + bool m_initialized = false; + View m_view = View::Main; + Rml::ElementDocument* m_document = nullptr; + std::vector m_focusIds; + std::vector > m_buttons; + std::unique_ptr m_discState; + std::vector > m_options; + std::string m_pendingFocusId; + std::string m_requestedActivation; + std::string m_requestedCycleId; + int m_requestedCycleDirection = 0; + bool m_requestedBack = false; + std::string m_selectedIsoPath; + std::string m_errorString; + std::string m_initialGraphicsBackend; + bool m_compactLayout = false; + bool m_selectedIsoValid = false; + bool m_isPal = false; + + bool selected_path_exists() const { +#if TARGET_ANDROID + return !m_selectedIsoPath.empty(); +#else + return !m_selectedIsoPath.empty() && SDL_GetPathInfo(m_selectedIsoPath.c_str(), nullptr); +#endif + } + + bool is_selected_path_valid() const { return m_selectedIsoValid; } + + void validate_selected_iso(bool save_valid_path) { + m_errorString.clear(); + m_selectedIsoValid = false; + m_isPal = false; + + if (m_selectedIsoPath.empty()) { + return; + } + + if (!selected_path_exists()) { + m_errorString = "Selected disc image does not exist"; + return; + } + + const iso::ValidationError validationResult = iso::validate(m_selectedIsoPath.c_str()); + if (validationResult != iso::ValidationError::Success) { + m_errorString = iso_validation_error_message(validationResult); + return; + } + + m_selectedIsoValid = true; + m_isPal = iso::isPal(m_selectedIsoPath.c_str()); + if (save_valid_path) { + getSettings().backend.isoPath.setValue(m_selectedIsoPath); + Save(); + } + } + + void close_document() { + if (m_document == nullptr) { + return; + } + + m_document->RemoveEventListener(Rml::EventId::Keydown, this); + m_buttons.clear(); + m_discState.reset(); + m_options.clear(); + m_focusIds.clear(); + m_document->Close(); + m_document = nullptr; + } + + std::string logo_path() const { + const auto logo_path = resource_path("logo-mascot.png"); + if (std::filesystem::exists(logo_path)) { + return logo_path.string(); + } + + return {}; + } + + std::string disc_state_text() const { + if (!m_errorString.empty()) { + return m_errorString; + } + + if (is_selected_path_valid()) { + return display_path(m_selectedIsoPath); + } + + return "No disc image selected"; + } + + bool should_use_compact_layout() const { + Rml::Context* context = aurora::rmlui::get_context(); + if (context == nullptr) { + return false; + } + + const Rml::Vector2i dimensions = context->GetDimensions(); + float dp_ratio = context->GetDensityIndependentPixelRatio(); + if (dp_ratio <= 0.0f) { + dp_ratio = 1.0f; + } + + const float width = static_cast(dimensions.x) / dp_ratio; + const float height = static_cast(dimensions.y) / dp_ratio; + return height < 680.0f && width >= 720.0f; + } + + void rebuild_if_layout_changed() { + const bool compactLayout = should_use_compact_layout(); + if (compactLayout == m_compactLayout) { + return; + } + + if (Rml::Context* context = aurora::rmlui::get_context()) { + if (Rml::Element* focused = context->GetFocusElement()) { + m_pendingFocusId = focused->GetId(); + } + } + rebuild(); + } + + void queue_activation(std::string id) { m_requestedActivation = std::move(id); } + + void queue_back() { m_requestedBack = true; } + + void queue_cycle(std::string id, int direction) { + m_requestedCycleId = std::move(id); + m_requestedCycleDirection = direction; + } + + void add_button_control(Rml::Element* parent, std::string_view id, std::string_view text, + ButtonVariant variant) { + const std::string idString(id); + m_focusIds.push_back(idString); + m_buttons.push_back(std::make_unique