Merge branch 'rmlui-integration'

This commit is contained in:
Luke Street
2026-05-02 10:50:18 -06:00
60 changed files with 6871 additions and 15 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
Language: Cpp
Standard: C++03
AccessModifierOffset: -4
AlignAfterOpenBracket: Align
AlignAfterOpenBracket: DontAlign
AlignConsecutiveAssignments: false
AlignConsecutiveDeclarations: false
AlignOperands: true
+1
View File
@@ -100,6 +100,7 @@ if (CMAKE_SYSTEM_NAME STREQUAL Linux)
endif ()
set(AURORA_ENABLE_DVD ON CACHE BOOL "Enable DVD API support" FORCE)
set(AURORA_ENABLE_CARD ON CACHE BOOL "Enable CARD API support" FORCE)
set(AURORA_ENABLE_RMLUI ON CACHE BOOL "Enable RmlUi UI support" FORCE)
add_subdirectory(extern/aurora EXCLUDE_FROM_ALL)
add_subdirectory(libs/freeverb)
+1 -1
View File
@@ -1,4 +1,4 @@
![DuskLogo](res/logo-mascot.webp)
![DuskLogo](res/logo-mascot.png)
- ### **[Official Website](https://twilitrealm.dev)**
- ### **[Discord](https://discord.gg/QACynxeyna)**
+1 -1
+39
View File
@@ -1462,6 +1462,45 @@ set(DUSK_FILES
src/dusk/imgui/ImGuiStateShare.cpp
src/dusk/imgui/ImGuiAchievements.hpp
src/dusk/imgui/ImGuiAchievements.cpp
src/dusk/ui/bool_button.cpp
src/dusk/ui/bool_button.hpp
src/dusk/ui/button.cpp
src/dusk/ui/button.hpp
src/dusk/ui/component.cpp
src/dusk/ui/component.hpp
src/dusk/ui/document.cpp
src/dusk/ui/document.hpp
src/dusk/ui/editor.cpp
src/dusk/ui/editor.hpp
src/dusk/ui/event.cpp
src/dusk/ui/event.hpp
src/dusk/ui/input.cpp
src/dusk/ui/input.hpp
src/dusk/ui/nav_types.hpp
src/dusk/ui/number_button.cpp
src/dusk/ui/number_button.hpp
src/dusk/ui/overlay.cpp
src/dusk/ui/overlay.hpp
src/dusk/ui/pane.cpp
src/dusk/ui/pane.hpp
src/dusk/ui/popup.cpp
src/dusk/ui/popup.hpp
src/dusk/ui/prelaunch.cpp
src/dusk/ui/prelaunch.hpp
src/dusk/ui/prelaunch_options.cpp
src/dusk/ui/prelaunch_options.hpp
src/dusk/ui/select_button.cpp
src/dusk/ui/select_button.hpp
src/dusk/ui/settings.cpp
src/dusk/ui/settings.hpp
src/dusk/ui/string_button.cpp
src/dusk/ui/string_button.hpp
src/dusk/ui/tab_bar.cpp
src/dusk/ui/tab_bar.hpp
src/dusk/ui/ui.cpp
src/dusk/ui/ui.hpp
src/dusk/ui/window.cpp
src/dusk/ui/window.hpp
src/dusk/achievements.cpp
src/dusk/iso_validate.cpp
src/dusk/livesplit.cpp
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+147
View File
@@ -0,0 +1,147 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
overflow: visible;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-family: "Fira Sans Condensed";
font-size: 24dp;
color: #FFFFFF;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
}
.overlay-root {
width: 100%;
min-height: 45%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
decorator: vertical-gradient(#00000000 #151610F2);
padding: 48dp 0 40dp 0;
filter: opacity(0);
transition: filter 0.2s linear-in-out;
}
.overlay-root[open] {
filter: opacity(1);
}
.overlay {
width: 100%;
max-width: 1216dp;
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
gap: 24dp;
padding: 0 32dp;
}
@media (max-height: 800dp) {
.overlay-root {
min-height: 38%;
padding: 32dp 0 28dp 0;
}
.overlay {
gap: 16dp;
padding: 0 24dp;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24dp;
}
.carousel-container {
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
min-width: 0;
}
.description {
font-size: 18dp;
line-height: 22dp;
color: rgba(255, 255, 255, 50%);
}
.divider {
margin: 1dp 0;
border-top: 1dp rgba(217, 217, 217, 50%);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24dp;
}
footer-button {
display: block;
width: 100%;
max-width: 220dp;
border: 0;
padding: 0;
background-color: transparent;
font-family: "Fira Sans Condensed";
font-weight: bold;
font-size: 20dp;
line-height: 24dp;
text-transform: uppercase;
color: #FFFFFF;
opacity: 1;
cursor: pointer;
}
footer-button.return {
text-align: left;
}
footer-button.reset {
text-align: right;
}
.stepped-carousel {
display: flex;
align-items: center;
justify-content: center;
gap: 16dp;
width: auto;
min-width: 246dp;
padding: 0;
background-color: transparent;
font-family: "Fira Sans Condensed";
font-weight: bold;
}
.stepped-carousel-value {
line-height: 29dp;
min-width: 166dp;
text-align: center;
white-space: nowrap;
opacity: 0.9;
}
.stepped-carousel-arrow {
width: 24dp;
height: 24dp;
min-width: 24dp;
padding: 0;
border: 0;
background-color: transparent;
opacity: 1;
cursor: pointer;
}
+46
View File
@@ -0,0 +1,46 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
overflow: visible;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-family: "Fira Sans Condensed";
font-weight: bold;
font-size: 18dp;
color: #E0DBC8;
}
button {
cursor: pointer;
focus: auto;
}
popup {
width: 100%;
display: flex;
align-items: stretch;
width: 100%;
height: 64dp;
background-color: rgba(21, 22, 16, 80%);
border-bottom: 2dp #92875B;
backdrop-filter: blur(5dp);
transform: translateY(-64dp);
transition: transform 0.2s cubic-in-out;
}
popup[open] {
transform: translateY(0);
}
popup tab-bar {
flex: 1 1 0;
}
popup tab-bar tab {
opacity: 0.35;
color: #E0DBC8;
}
+169
View File
@@ -0,0 +1,169 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
width: 100%;
height: 100%;
font-family: "Fira Sans";
font-weight: normal;
font-size: 20dp;
color: #FFFFFF;
background-color: #000000;
decorator: image(../prelaunch-bg.png cover left center);
}
.menu {
position: absolute;
left: 96dp;
top: 50%;
transform: translateY(-50%);
/* Scale based on a reference screen width, 428/1216 */
width: 35.230264vw;
min-width: 428dp;
max-width: 856dp;
height: auto;
display: flex;
flex-direction: column;
gap: 48dp;
}
.hero {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 8dp;
}
.hero img {
width: 100%;
}
.eyebrow {
font-family: "Alegreya SC";
font-size: 32dp;
}
@media (min-width: 1216dp) {
.eyebrow {
/* Same logic as .menu, 32/1216 */
font-size: 2.631579vw;
}
}
.eyebrow span {
font-weight: bold;
}
#menu-list {
display: flex;
flex-direction: column;
gap: 12dp;
}
#menu-list button {
width: 428dp;
height: 54dp;
padding: 8dp 16dp;
border-radius: 8dp;
text-transform: uppercase;
font-family: "Fira Sans Condensed";
font-size: 32dp;
font-weight: normal;
cursor: pointer;
/* Define a fully transparent gradient as the default state, otherwise a white flash occurs */
decorator: horizontal-gradient(#00000000 #00000000);
}
#menu-list button[anim-done] {
transition: decorator color 0.1s linear-in-out;
}
#menu-list button:hover,
#menu-list button:focus-visible {
color: black;
decorator: horizontal-gradient(#FEE685FF #FEE68500);
}
.disk-status {
position: absolute;
left: 96dp;
bottom: 72dp;
display: flex;
flex-direction: column;
gap: 8dp;
}
.version-info {
position: absolute;
right: 96dp;
bottom: 72dp;
display: flex;
flex-direction: column;
gap: 8dp;
text-align: right;
}
.status,
.version {
font-size: 24dp;
}
.status,
.update {
color: #D8F999;
}
.status[bad] {
color: #FFC9C9;
}
/* TODO: Hidden until an actual update checker is introduced */
.update {
display: none;
font-size: 16dp;
font-weight: bold;
cursor: pointer;
}
.detail,
.update span {
color: #A6A09B;
}
/* Startup animation */
.intro-item {
opacity: 0;
transform: translateY(10dp);
transition: opacity transform 0.3s 0.1s cubic-in-out;
}
body.animate-in .intro-item {
opacity: 1;
transform: translateY(0dp);
}
.delay-0 {
transition: opacity transform 0.3s 0.1s cubic-in-out;
}
.delay-1 {
transition: opacity transform 0.3s 0.2s cubic-in-out;
}
.delay-2 {
transition: opacity transform 0.3s 0.3s cubic-in-out;
}
.delay-3 {
transition: opacity transform 0.3s 0.4s cubic-in-out;
}
.delay-4 {
transition: opacity transform 0.3s 0.5s cubic-in-out;
}
.delay-5 {
transition: opacity transform 0.3s 0.6s cubic-in-out;
}
+33
View File
@@ -0,0 +1,33 @@
tab-bar {
display: flex;
min-width: 0;
overflow: auto hidden;
text-transform: uppercase;
}
tab-bar tab {
flex: 0 0 auto;
padding: 0 24dp;
line-height: 64dp;
white-space: nowrap;
decorator: vertical-gradient(#c2a42d00 #c2a42d00);
transition: decorator 0.1s linear-in-out, opacity 0.1s linear-in-out;
cursor: pointer;
}
tab-bar tab:selected {
opacity: 1;
border-bottom: 4dp #C2A42D;
font-effect: glow(0dp 4dp 0dp 4dp black);
}
tab-bar tab:focus-visible,
tab-bar tab:hover {
opacity: 1;
font-effect: glow(0dp 4dp 0dp 4dp black);
decorator: vertical-gradient(#c2a42d00 #c2a42d26);
}
tab-bar tab:active {
decorator: vertical-gradient(#c2a42d10 #c2a42d40);
}
+238
View File
@@ -0,0 +1,238 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
width: 100%;
height: 100%;
padding: 64dp;
font-family: "Fira Sans";
font-weight: normal;
font-style: normal;
font-size: 15dp;
color: #E0DBC8;
}
window {
display: flex;
flex-flow: column;
height: 100%;
max-width: 1088dp;
max-height: 768dp;
margin: auto;
border-radius: 14dp;
overflow: hidden;
border: 2dp #92875B;
backdrop-filter: blur(5dp);
box-shadow: 0 0 25dp 5dp;
background-color: rgba(21, 22, 16, 90%);
filter: opacity(0);
transform: scale(0.9);
transform-origin: center;
transition: filter transform 0.2s cubic-in-out;
}
window[open] {
filter: opacity(1);
transform: scale(1);
}
@media (max-height: 640dp) {
body {
padding: 16dp;
}
window {
box-shadow: none;
}
}
window tab-bar {
flex: 0 0 64dp;
height: 64dp;
background-color: rgba(217, 217, 217, 10%);
font-family: "Fira Sans Condensed";
font-weight: bold;
font-size: 18dp;
border-bottom: 2dp #92875B;
}
window tab-bar tab {
opacity: 0.25;
}
window content {
display: flex;
flex: 1 1 0;
min-width: 0;
min-height: 0;
overflow: hidden;
}
window content pane {
display: flex;
flex-flow: column;
flex: 1 1 0;
height: 100%;
min-width: 0;
min-height: 0;
padding: 24dp;
padding-bottom: 0dp;
gap: 8dp;
overflow: hidden auto;
}
window content pane:not(:last-of-type) {
border-right: 1dp #92875B;
}
window content pane > * {
flex: 0 0 auto;
}
window content pane > spacer {
display: block;
/* Completes the 24dp bottom inset after the pane's 8dp gap. */
flex: 0 0 16dp;
height: 16dp;
pointer-events: none;
}
scrollbarvertical {
width: 8dp;
margin: 4dp 4dp 4dp 0;
}
scrollbarvertical sliderarrowdec,
scrollbarvertical sliderarrowinc {
width: 0;
height: 0;
}
scrollbarvertical slidertrack {
width: 8dp;
}
scrollbarvertical sliderbar {
width: 8dp;
min-height: 24dp;
background-color: rgba(224, 219, 200, 45%);
border-radius: 2dp;
transition: background-color 0.2s cubic-in-out;
}
scrollbarvertical sliderbar:hover,
scrollbarvertical sliderbar:active {
background-color: rgba(194, 164, 45, 80%);
}
scrollbarhorizontal {
height: 0;
}
scrollbarhorizontal sliderarrowdec,
scrollbarhorizontal sliderarrowinc {
width: 0;
height: 0;
}
scrollbarhorizontal slidertrack,
scrollbarhorizontal sliderbar {
width: 0;
height: 0;
}
.section-heading {
font-family: "Fira Sans Condensed";
font-weight: bold;
text-transform: uppercase;
font-size: 22dp;
opacity: 0.25;
}
.section-heading:not(:first-of-type) {
padding-top: 12dp;
}
button {
text-align: center;
background-color: rgba(17, 16, 10, 20%);
opacity: 0.9;
padding: 8dp 16dp;
border-radius: 14dp;
box-shadow: rgba(146, 135, 91, 25%) 0 0 0 1dp;
font-size: 20dp;
transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out;
cursor: pointer;
focus: auto;
}
button:not(:disabled):hover,
button:not(:disabled):focus-visible {
background-color: rgba(204, 184, 119, 20%);
box-shadow: #C2A42D 0 0 0 2dp;
}
button:not(:disabled):selected {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
}
button:not(:disabled):active {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
box-shadow: #C2A42D 0 0 0 2dp;
}
select-button {
display: flex;
align-items: center;
gap: 8dp;
background-color: rgba(17, 16, 10, 20%);
opacity: 0.9;
padding: 8dp 16dp;
border-radius: 14dp;
box-shadow: rgba(146, 135, 91, 25%) 0 0 0 1dp;
transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out;
cursor: pointer;
focus: auto;
}
select-button:not(:disabled):hover,
select-button:not(:disabled):focus-visible {
background-color: rgba(204, 184, 119, 20%);
box-shadow: #C2A42D 0 0 0 2dp;
}
select-button:not(:disabled):selected {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
}
select-button:not(:disabled):active {
opacity: 1;
background-color: rgba(204, 184, 119, 40%);
box-shadow: #C2A42D 0 0 0 2dp;
}
select-button:disabled {
opacity: 0.35;
cursor: default;
}
select-button key {
font-family: "Fira Sans Condensed";
font-weight: bold;
font-size: 18dp;
text-transform: uppercase;
flex: 1 0 auto;
}
select-button value {
margin-left: auto;
font-size: 20dp;
}
select-button input {
text-align: right;
font-size: 20dp;
}
+10 -10
View File
@@ -324,18 +324,18 @@ namespace dusk {
ImGuiMenuGame::ToggleFullscreen();
}
if (!dusk::IsGameLaunched) {
m_preLaunchWindow.draw();
}
// if (!dusk::IsGameLaunched) {
// m_preLaunchWindow.draw();
// }
m_isHidden = !getSettings().backend.duskMenuOpen;
bool showMenu = !dusk::IsGameLaunched || !CheckMenuViewToggle(ImGuiKey_F1, m_isHidden);
if (dusk::IsGameLaunched) {
const bool menuOpen = !m_isHidden;
if (getSettings().backend.duskMenuOpen != menuOpen) {
getSettings().backend.duskMenuOpen.setValue(menuOpen);
Save();
}
if (ImGui::GetIO().KeyShift && ImGui::IsKeyPressed(ImGuiKey_F1)) {
m_isHidden = !m_isHidden;
}
bool showMenu = !m_isHidden;
if (getSettings().backend.duskMenuOpen != showMenu) {
getSettings().backend.duskMenuOpen.setValue(showMenu);
Save();
}
// The menu bar renders with ImGuiCol_WindowBg behind it. We just want ImGuiCol_MenuBarBg,
+4
View File
@@ -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,
+29
View File
@@ -0,0 +1,29 @@
#include "bool_button.hpp"
namespace dusk::ui {
BoolButton::BoolButton(Rml::Element* parent, Props props)
: BaseControlledSelectButton(parent, {std::move(props.key)}),
mGetValue(std::move(props.getValue)), mSetValue(std::move(props.setValue)),
mIsDisabled(std::move(props.isDisabled)) {}
bool BoolButton::disabled() const {
if (mIsDisabled) {
return mIsDisabled();
}
return BaseControlledSelectButton::disabled();
}
Rml::String BoolButton::format_value() {
return mGetValue() ? "On" : "Off";
}
bool BoolButton::handle_nav_command(NavCommand cmd) {
if (cmd == NavCommand::Confirm || cmd == NavCommand::Left || cmd == NavCommand::Right) {
mSetValue(!mGetValue());
return true;
}
return false;
}
} // namespace dusk::ui
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "select_button.hpp"
namespace dusk::ui {
class BoolButton : public BaseControlledSelectButton {
public:
struct Props {
Rml::String key;
std::function<bool()> getValue;
std::function<void(bool)> setValue;
std::function<bool()> isDisabled;
};
BoolButton(Rml::Element* parent, Props props);
bool disabled() const override;
protected:
Rml::String format_value() override;
bool handle_nav_command(NavCommand cmd) override;
private:
std::function<int()> mGetValue;
std::function<void(int)> mSetValue;
std::function<bool()> mIsDisabled;
};
} // namespace dusk::ui
+64
View File
@@ -0,0 +1,64 @@
#include "button.hpp"
#include "ui.hpp"
#include <utility>
namespace dusk::ui {
namespace {
Rml::Element* createRoot(Rml::Element* parent, const Rml::String& tagName) {
auto* doc = parent->GetOwnerDocument();
auto elem = doc->CreateElement(tagName);
return parent->AppendChild(std::move(elem));
}
} // namespace
Button::Button(Rml::Element* parent, Props props, const Rml::String& tagName)
: FluentComponent(createRoot(parent, tagName)) {
update_props(std::move(props));
}
void Button::set_text(const Rml::String& text) {
if (mProps.text != text) {
mRoot->SetInnerRML(escape(text));
mProps.text = text;
}
}
Button& Button::on_pressed(ButtonCallback callback) {
if (!callback) {
return *this;
}
// TODO: convert this to a FluentComponent method?
on_nav_command([callback = std::move(callback)](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm) {
callback();
return true;
}
return false;
});
return *this;
}
void Button::update_props(Props props) {
set_text(props.text);
mProps = std::move(props);
}
void ControlledButton::update() {
if (mIsSelected) {
set_selected(mIsSelected());
}
Button::update();
}
bool ControlledButton::selected() const {
if (mIsSelected) {
return mIsSelected();
}
return Button::selected();
}
} // namespace dusk::ui
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include "component.hpp"
namespace dusk::ui {
using ButtonCallback = std::function<void()>;
class Button : public FluentComponent<Button> {
public:
struct Props {
Rml::String text;
};
Button(Rml::Element* parent, Props props, const Rml::String& tagName = "button");
Button(Rml::Element* parent, Rml::String text, const Rml::String& tagName = "button")
: Button(parent, Props{std::move(text)}, tagName) {}
void set_text(const Rml::String& text);
Button& on_pressed(ButtonCallback callback);
const Rml::String& get_text() const { return mProps.text; }
private:
void update_props(Props props);
Props mProps;
};
class ControlledButton : public Button {
public:
struct Props {
Rml::String text;
std::function<bool()> isSelected;
};
ControlledButton(Rml::Element* parent, Props props, const Rml::String& tagName = "button")
: Button(parent, {std::move(props.text)}, tagName),
mIsSelected(std::move(props.isSelected)) {}
void update() override;
bool selected() const override;
private:
std::function<bool()> mIsSelected;
};
} // namespace dusk::ui
+97
View File
@@ -0,0 +1,97 @@
#include "component.hpp"
namespace dusk::ui {
Component::Component(Rml::Element* root) : mRoot(root) {}
Component::~Component() = default;
void Component::update() {
for (const auto& child : mChildren) {
child->update();
}
}
bool Component::focus() {
if (disabled()) {
return false;
}
// Can we focus self?
if (mRoot->Focus(true)) {
mRoot->ScrollIntoView(Rml::ScrollIntoViewOptions{
Rml::ScrollAlignment::Center,
Rml::ScrollAlignment::Center,
Rml::ScrollBehavior::Smooth,
Rml::ScrollParentage::Closest,
});
return true;
}
// Otherwise, try to focus a child
for (const auto& child : mChildren) {
if (child->focus()) {
return true;
}
}
return false;
}
void Component::set_selected(bool value) {
// Subclasses may override selected() to return a dynamic value, but
// we're only interested in if the pseudoclass is set or not, so we
// use Component::selected() directly rather than selected().
if (Component::selected() == value) {
return;
}
mRoot->SetPseudoClass("selected", value);
}
void Component::set_disabled(bool value) {
if (Component::disabled() == value) {
return;
}
if (value) {
mRoot->SetAttribute("disabled", "");
mRoot->SetPseudoClass("disabled", true);
mRoot->Blur();
} else {
mRoot->RemoveAttribute("disabled");
mRoot->SetPseudoClass("disabled", false);
}
}
Rml::Element* Component::append(Rml::Element* parent, const Rml::String& tag) {
if (parent == nullptr) {
return nullptr;
}
auto* doc = parent->GetOwnerDocument();
if (doc == nullptr) {
return nullptr;
}
return parent->AppendChild(doc->CreateElement(tag));
}
void Component::listen(Rml::Element* element, Rml::EventId event,
ScopedEventListener::Callback callback, bool capture) {
if (element == nullptr) {
element = mRoot;
}
mListeners.emplace_back(
std::make_unique<ScopedEventListener>(element, event, std::move(callback), capture));
}
bool Component::contains(Rml::Element* element) const {
for (const auto* node = element; node != nullptr; node = node->GetParentNode()) {
if (node == mRoot) {
return true;
}
}
return false;
}
void Component::clear_children() {
mChildren.clear();
while (mRoot->GetNumChildren() > 0) {
mRoot->RemoveChild(mRoot->GetFirstChild());
}
}
} // namespace dusk::ui
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include "event.hpp"
#include "ui.hpp"
#include <RmlUi/Core.h>
#include <memory>
#include <utility>
#include <vector>
namespace Rml {
class Element;
}
namespace dusk::ui {
class Component {
public:
Component() = default;
explicit Component(Rml::Element* root);
virtual ~Component();
Component(const Component&) = delete;
Component& operator=(const Component&) = delete;
virtual void update();
virtual bool focus();
virtual bool selected() const { return mRoot->IsPseudoClassSet("selected"); }
virtual void set_selected(bool selected);
virtual bool disabled() const { return mRoot->IsPseudoClassSet("disabled"); }
virtual void set_disabled(bool disabled);
void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback,
bool capture = false);
bool contains(Rml::Element* element) const;
template <typename T, typename... Args>
requires std::is_base_of_v<Component, T> T& add_child(Args&&... args) {
auto child = std::make_unique<T>(mRoot, std::forward<Args>(args)...);
T& ref = *child;
mChildren.emplace_back(std::move(child));
return ref;
}
Rml::Element* root() const { return mRoot; }
protected:
static Rml::Element* append(Rml::Element* parent, const Rml::String& tag);
void clear_children();
Rml::Element* mRoot = nullptr;
std::vector<std::unique_ptr<Component> > mChildren;
std::vector<std::unique_ptr<ScopedEventListener> > mListeners;
};
template <class Derived>
class FluentComponent : public Component {
public:
using Component::Component;
Derived& listen(
Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false) {
Component::listen(mRoot, event, std::move(callback), capture);
return static_cast<Derived&>(*this);
}
Derived& on_focus(ScopedEventListener::Callback callback) {
return listen(
Rml::EventId::Focus, [this, callback = std::move(callback)](Rml::Event& event) {
if (!disabled()) {
callback(event);
}
});
}
Derived& on_nav_command(std::function<bool(Rml::Event&, NavCommand)> callback) {
listen(Rml::EventId::Click, [this, callback](Rml::Event& event) {
if (!disabled() && callback(event, NavCommand::Confirm)) {
event.StopPropagation();
}
});
listen(Rml::EventId::Keydown, [this, callback = std::move(callback)](Rml::Event& event) {
if (disabled()) {
return;
}
const auto cmd = map_nav_event(event);
if (cmd != NavCommand::None && callback(event, cmd)) {
event.StopPropagation();
}
});
return static_cast<Derived&>(*this);
}
};
} // namespace dusk::ui
+105
View File
@@ -0,0 +1,105 @@
#include "document.hpp"
#include "aurora/rmlui.hpp"
#include "ui.hpp"
namespace dusk::ui {
namespace {
Rml::ElementDocument* load_document(const Rml::String& source) {
auto* context = aurora::rmlui::get_context();
if (context == nullptr) {
return nullptr;
}
return context->LoadDocumentFromMemory(source);
}
} // namespace
Document::Document(const Rml::String& source) : mDocument(load_document(source)) {
// Block keydown events while hidden (except for Menu)
listen(
Rml::EventId::Keydown,
[this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd != NavCommand::Menu && !visible()) {
event.StopImmediatePropagation();
}
},
true);
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd != NavCommand::None && handle_nav_command(event, cmd)) {
event.StopPropagation();
}
});
}
Document::~Document() {
mListeners.clear();
if (mDocument != nullptr) {
mDocument->Close();
mDocument = nullptr;
}
}
void Document::show() {
if (mDocument != nullptr) {
// Attempt to preserve the previously focused element
mDocument->Show(Rml::ModalFlag::None, Rml::FocusFlag::Keep, Rml::ScrollFlag::None);
// If nothing is focused, let the document decide the initial focus
auto* leaf = mDocument->GetFocusLeafNode();
if (leaf == nullptr || leaf == mDocument) {
focus();
}
}
}
void Document::hide() {
if (mDocument != nullptr) {
mDocument->Hide();
}
}
void Document::update() {}
bool Document::focus() {
return false;
}
void Document::listen(Rml::Element* element, Rml::EventId event,
ScopedEventListener::Callback callback, bool capture) {
if (element == nullptr) {
element = mDocument;
}
if (element == nullptr || !callback) {
return;
}
mListeners.emplace_back(
std::make_unique<ScopedEventListener>(element, event, std::move(callback), capture));
}
bool Document::can_destroy() const {
if (mDocument == nullptr) {
return true;
}
return *mDocument->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Hidden;
}
bool Document::visible() const {
if (mDocument == nullptr) {
return false;
}
return *mDocument->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Visible;
}
bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) {
if (cmd == NavCommand::Menu) {
toggle_top_document();
return true;
}
return false;
}
} // namespace dusk::ui
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "component.hpp"
#include "ui.hpp"
namespace dusk::ui {
class Document {
public:
Document(const Rml::String& source);
virtual ~Document();
Document(const Document&) = delete;
Document& operator=(const Document&) = delete;
virtual void show();
virtual void hide();
virtual void update();
virtual bool focus();
virtual bool visible() const;
void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback,
bool capture = false);
void listen(Rml::EventId event, ScopedEventListener::Callback callback, bool capture = false) {
listen(mDocument, event, std::move(callback), capture);
}
bool can_destroy() const;
protected:
virtual bool handle_nav_command(Rml::Event& event, NavCommand cmd);
Rml::ElementDocument* mDocument;
std::vector<std::unique_ptr<ScopedEventListener> > mListeners;
};
} // namespace dusk::ui
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "window.hpp"
namespace dusk::ui {
class EditorWindow : public Window {
public:
EditorWindow();
};
} // namespace dusk::ui
+32
View File
@@ -0,0 +1,32 @@
#include "event.hpp"
#include <utility>
namespace dusk::ui {
ScopedEventListener::ScopedEventListener(
Rml::Element* element, Rml::EventId event, Callback callback, bool capture)
: mElement(element), mEvent(event), mCapture(capture), mCallback(std::move(callback)) {
mElement->AddEventListener(mEvent, this, mCapture);
}
ScopedEventListener::~ScopedEventListener() {
if (mElement != nullptr) {
mElement->RemoveEventListener(mEvent, this, mCapture);
mElement = nullptr;
}
}
void ScopedEventListener::ProcessEvent(Rml::Event& event) {
if (mCallback) {
mCallback(event);
}
}
void ScopedEventListener::OnDetach(Rml::Element* element) {
if (element == mElement) {
mElement = nullptr;
}
}
} // namespace dusk::ui
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <RmlUi/Core.h>
#include <functional>
namespace dusk::ui {
class ScopedEventListener final : public Rml::EventListener {
public:
using Callback = std::function<void(Rml::Event&)>;
ScopedEventListener(
Rml::Element* element, Rml::EventId event, Callback callback, bool capture = false);
~ScopedEventListener() override;
ScopedEventListener(const ScopedEventListener&) = delete;
ScopedEventListener& operator=(const ScopedEventListener&) = delete;
ScopedEventListener(ScopedEventListener&&) = delete;
ScopedEventListener& operator=(ScopedEventListener&&) = delete;
void ProcessEvent(Rml::Event& event) override;
void OnDetach(Rml::Element* element) override;
private:
Rml::Element* mElement = nullptr;
Rml::EventId mEvent = Rml::EventId::Invalid;
bool mCapture = false;
Callback mCallback;
};
} // namespace dusk::ui
+610
View File
@@ -0,0 +1,610 @@
#include "input.hpp"
#include "ui.hpp"
#include <RmlUi/Core.h>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_timer.h>
#include <aurora/rmlui.hpp>
#include <dolphin/pad.h>
#include <algorithm>
#include <array>
namespace dusk::ui {
namespace {
constexpr double kGamepadRepeatInitialDelay = 0.32;
constexpr double kGamepadRepeatStartInterval = 0.12;
constexpr double kGamepadRepeatMinInterval = 0.045;
constexpr double kGamepadRepeatRampDuration = 1.0;
constexpr double kGamepadMenuChordGraceDuration = 0.12;
constexpr Sint16 kGamepadAxisPressThreshold = 16384;
constexpr Sint16 kGamepadAxisReleaseThreshold = 12000;
constexpr int kGamepadAxisDirectionCount = SDL_GAMEPAD_AXIS_COUNT * 2;
struct GamepadRepeatState {
Rml::Input::KeyIdentifier key = Rml::Input::KI_UNKNOWN;
double pressedAt = 0.0;
double nextRepeatAt = 0.0;
bool held = false;
bool repeatable = false;
bool pending = false;
};
bool sPadInputBlocked = false;
std::array<GamepadRepeatState, SDL_GAMEPAD_BUTTON_COUNT> sGamepadButtonRepeats;
std::array<GamepadRepeatState, kGamepadAxisDirectionCount> sGamepadAxisRepeats;
std::array<u32, PAD_MAX_CONTROLLERS> sPadHoldMasks;
std::array<bool, PAD_MAX_CONTROLLERS> sMenuChordConsumed;
double now_seconds() noexcept {
return static_cast<double>(SDL_GetTicksNS()) / 1000000000.0;
}
bool is_menu_chord_part(PADButton button) noexcept {
return button == PAD_TRIGGER_R || button == PAD_BUTTON_START;
}
bool has_menu_chord_part_held(u32 port) noexcept {
if (port >= sPadHoldMasks.size()) {
return false;
}
const u32 held = sPadHoldMasks[port];
return (held & (PAD_TRIGGER_R | PAD_BUTTON_START)) != 0;
}
bool should_block_pad_for_menu_chord() noexcept {
for (u32 port = 0; port < sPadHoldMasks.size(); ++port) {
if (sMenuChordConsumed[port] && has_menu_chord_part_held(port)) {
return true;
}
}
return false;
}
PADButton pad_button_from_axis(PADAxis axis) noexcept {
switch (axis) {
case PAD_AXIS_TRIGGER_R:
return PAD_TRIGGER_R;
case PAD_AXIS_TRIGGER_L:
return PAD_TRIGGER_L;
default:
return 0;
}
}
void set_pad_button_held(u32 port, PADButton button, bool held) noexcept {
if (port >= sPadHoldMasks.size() || button == 0) {
return;
}
if (held) {
sPadHoldMasks[port] |= button;
} else {
sPadHoldMasks[port] &= ~button;
}
}
bool is_menu_chord(u32 port) noexcept {
if (port >= sPadHoldMasks.size()) {
return false;
}
const u32 held = sPadHoldMasks[port];
return (held & PAD_TRIGGER_R) != 0 && (held & PAD_BUTTON_START) != 0;
}
bool any_menu_chord() noexcept {
return std::any_of(sPadHoldMasks.begin(), sPadHoldMasks.end(),
[](u32 held) { return (held & PAD_TRIGGER_R) != 0 && (held & PAD_BUTTON_START) != 0; });
}
Rml::Input::KeyIdentifier map_pad_button(PADButton button) noexcept {
switch (button) {
case PAD_BUTTON_UP:
return Rml::Input::KI_UP;
case PAD_BUTTON_DOWN:
return Rml::Input::KI_DOWN;
case PAD_BUTTON_LEFT:
return Rml::Input::KI_LEFT;
case PAD_BUTTON_RIGHT:
return Rml::Input::KI_RIGHT;
case PAD_BUTTON_B:
return Rml::Input::KI_ESCAPE;
case PAD_BUTTON_A:
return Rml::Input::KI_RETURN;
case PAD_TRIGGER_R:
return Rml::Input::KI_NEXT;
case PAD_TRIGGER_L:
return Rml::Input::KI_PRIOR;
default:
return Rml::Input::KI_UNKNOWN;
}
}
Rml::Input::KeyIdentifier map_pad_axis(PADAxis axis) noexcept {
switch (axis) {
case PAD_AXIS_LEFT_X_POS:
return Rml::Input::KI_RIGHT;
case PAD_AXIS_LEFT_X_NEG:
return Rml::Input::KI_LEFT;
case PAD_AXIS_LEFT_Y_POS:
return Rml::Input::KI_UP;
case PAD_AXIS_LEFT_Y_NEG:
return Rml::Input::KI_DOWN;
case PAD_AXIS_TRIGGER_R:
return Rml::Input::KI_NEXT;
case PAD_AXIS_TRIGGER_L:
return Rml::Input::KI_PRIOR;
default:
return Rml::Input::KI_UNKNOWN;
}
}
Rml::Input::KeyIdentifier map_raw_gamepad_button(SDL_GamepadButton button) noexcept {
switch (button) {
case SDL_GAMEPAD_BUTTON_DPAD_UP:
return Rml::Input::KI_UP;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
return Rml::Input::KI_DOWN;
case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
return Rml::Input::KI_LEFT;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
return Rml::Input::KI_RIGHT;
case SDL_GAMEPAD_BUTTON_EAST:
return Rml::Input::KI_ESCAPE;
case SDL_GAMEPAD_BUTTON_SOUTH:
return Rml::Input::KI_RETURN;
case SDL_GAMEPAD_BUTTON_BACK:
return Rml::Input::KI_F1;
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return Rml::Input::KI_NEXT;
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return Rml::Input::KI_PRIOR;
default:
return Rml::Input::KI_UNKNOWN;
}
}
Rml::Input::KeyIdentifier map_raw_button_alias(SDL_GamepadButton button) noexcept {
switch (button) {
case SDL_GAMEPAD_BUTTON_BACK:
return Rml::Input::KI_F1;
case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER:
return Rml::Input::KI_NEXT;
case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER:
return Rml::Input::KI_PRIOR;
default:
return Rml::Input::KI_UNKNOWN;
}
}
Rml::Input::KeyIdentifier map_raw_gamepad_axis(SDL_GamepadAxis axis, PADAxisSign sign) noexcept {
switch (axis) {
case SDL_GAMEPAD_AXIS_LEFTX:
return sign == AXIS_SIGN_POSITIVE ? Rml::Input::KI_RIGHT : Rml::Input::KI_LEFT;
case SDL_GAMEPAD_AXIS_LEFTY:
return sign == AXIS_SIGN_NEGATIVE ? Rml::Input::KI_UP : Rml::Input::KI_DOWN;
case SDL_GAMEPAD_AXIS_RIGHT_TRIGGER:
return sign == AXIS_SIGN_POSITIVE ? Rml::Input::KI_NEXT : Rml::Input::KI_UNKNOWN;
case SDL_GAMEPAD_AXIS_LEFT_TRIGGER:
return sign == AXIS_SIGN_POSITIVE ? Rml::Input::KI_PRIOR : Rml::Input::KI_UNKNOWN;
default:
return Rml::Input::KI_UNKNOWN;
}
}
bool find_event_port(SDL_JoystickID instance, u32& port) noexcept {
for (u32 candidate = 0; candidate < PAD_MAX_CONTROLLERS; ++candidate) {
const s32 index = PADGetIndexForPort(candidate);
if (index < 0) {
continue;
}
SDL_Gamepad* gamepad = PADGetSDLGamepadForIndex(static_cast<u32>(index));
if (gamepad == nullptr) {
continue;
}
SDL_Joystick* joystick = SDL_GetGamepadJoystick(gamepad);
if (joystick != nullptr && SDL_GetJoystickID(joystick) == instance) {
port = candidate;
return true;
}
}
return false;
}
bool find_mapped_pad_button(u32 port, SDL_GamepadButton nativeButton, PADButton& button) noexcept {
u32 buttonCount = 0;
PADButtonMapping* buttons = PADGetButtonMappings(port, &buttonCount);
if (buttons != nullptr) {
for (u32 i = 0; i < buttonCount; ++i) {
if (buttons[i].nativeButton == static_cast<u32>(nativeButton)) {
button = buttons[i].padButton;
return true;
}
}
}
u32 axisCount = 0;
PADAxisMapping* axes = PADGetAxisMappings(port, &axisCount);
if (axes != nullptr) {
for (u32 i = 0; i < axisCount; ++i) {
if (axes[i].nativeButton == nativeButton) {
button = pad_button_from_axis(axes[i].padAxis);
return button != 0;
}
}
}
return false;
}
bool find_mapped_pad_axis(
u32 port, SDL_GamepadAxis nativeAxis, PADAxisSign sign, PADAxis& axis) noexcept {
u32 buttonCount = 0;
PADGetButtonMappings(port, &buttonCount);
u32 axisCount = 0;
PADAxisMapping* axes = PADGetAxisMappings(port, &axisCount);
if (axes == nullptr) {
return false;
}
for (u32 i = 0; i < axisCount; ++i) {
const PADSignedNativeAxis mappedAxis = axes[i].nativeAxis;
if (mappedAxis.nativeAxis == nativeAxis && mappedAxis.sign == sign) {
axis = axes[i].padAxis;
return true;
}
}
return false;
}
bool find_event_pad_button(
const SDL_GamepadButtonEvent& event, u32& port, PADButton& button) noexcept {
return find_event_port(event.which, port) &&
find_mapped_pad_button(port, static_cast<SDL_GamepadButton>(event.button), button);
}
Rml::Input::KeyIdentifier map_gamepad_button(const SDL_GamepadButtonEvent& event) noexcept {
const auto nativeButton = static_cast<SDL_GamepadButton>(event.button);
if (nativeButton == SDL_GAMEPAD_BUTTON_BACK) {
return Rml::Input::KI_F1;
}
u32 port = 0;
if (!find_event_port(event.which, port)) {
return map_raw_gamepad_button(nativeButton);
}
PADButton button = 0;
if (find_mapped_pad_button(port, nativeButton, button)) {
const auto key = map_pad_button(button);
return key == Rml::Input::KI_UNKNOWN ? map_raw_button_alias(nativeButton) : key;
}
return map_raw_button_alias(nativeButton);
}
Rml::Input::KeyIdentifier map_gamepad_axis(
const SDL_GamepadAxisEvent& event, PADAxisSign sign) noexcept {
u32 port = 0;
if (!find_event_port(event.which, port)) {
return map_raw_gamepad_axis(static_cast<SDL_GamepadAxis>(event.axis), sign);
}
PADAxis axis = 0;
if (find_mapped_pad_axis(port, static_cast<SDL_GamepadAxis>(event.axis), sign, axis)) {
return map_pad_axis(axis);
}
return Rml::Input::KI_UNKNOWN;
}
bool is_repeatable_key(Rml::Input::KeyIdentifier key) noexcept {
switch (key) {
case Rml::Input::KI_UP:
case Rml::Input::KI_DOWN:
case Rml::Input::KI_LEFT:
case Rml::Input::KI_RIGHT:
case Rml::Input::KI_NEXT:
case Rml::Input::KI_PRIOR:
return true;
default:
return false;
}
}
double repeat_interval(double heldFor) noexcept {
const double ramp = std::clamp(heldFor / kGamepadRepeatRampDuration, 0.0, 1.0);
return kGamepadRepeatStartInterval +
(kGamepadRepeatMinInterval - kGamepadRepeatStartInterval) * ramp;
}
GamepadRepeatState* button_repeat_state(SDL_GamepadButton button) noexcept {
const auto index = static_cast<int>(button);
if (index < 0 || index >= static_cast<int>(sGamepadButtonRepeats.size())) {
return nullptr;
}
return &sGamepadButtonRepeats[index];
}
GamepadRepeatState* axis_repeat_state(SDL_GamepadAxis axis, PADAxisSign sign) noexcept {
const auto axisIndex = static_cast<int>(axis);
if (axisIndex < 0 || axisIndex >= SDL_GAMEPAD_AXIS_COUNT) {
return nullptr;
}
const int directionOffset = sign == AXIS_SIGN_POSITIVE ? 0 : 1;
return &sGamepadAxisRepeats[axisIndex * 2 + directionOffset];
}
void clear_gamepad_repeats() noexcept {
for (auto& repeat : sGamepadButtonRepeats) {
repeat = {};
}
for (auto& repeat : sGamepadAxisRepeats) {
repeat = {};
}
sPadHoldMasks.fill(0);
sMenuChordConsumed.fill(false);
}
void begin_gamepad_key(GamepadRepeatState& repeat, Rml::Input::KeyIdentifier key) noexcept {
if (repeat.held) {
return;
}
const double now = now_seconds();
repeat.key = key;
repeat.pressedAt = now;
repeat.held = true;
repeat.repeatable = is_repeatable_key(key);
repeat.nextRepeatAt = repeat.repeatable ? now + kGamepadRepeatInitialDelay : 0.0;
repeat.pending = false;
}
void begin_pending_gamepad_key(GamepadRepeatState& repeat, Rml::Input::KeyIdentifier key) noexcept {
if (repeat.held) {
return;
}
const double now = now_seconds();
repeat.key = key;
repeat.pressedAt = now;
repeat.held = true;
repeat.repeatable = is_repeatable_key(key);
repeat.nextRepeatAt = 0.0;
repeat.pending = true;
}
void consume_menu_chord(u32 port, Rml::Context& context) noexcept {
if (port < sMenuChordConsumed.size()) {
sMenuChordConsumed[port] = true;
}
auto cancel_next = [&context](GamepadRepeatState& repeat) {
if (!repeat.held || repeat.key != Rml::Input::KI_NEXT) {
return;
}
if (!repeat.pending) {
context.ProcessKeyUp(repeat.key, 0);
}
repeat = {};
};
for (auto& repeat : sGamepadButtonRepeats) {
cancel_next(repeat);
}
for (auto& repeat : sGamepadAxisRepeats) {
cancel_next(repeat);
}
}
void update_menu_chord_release(u32 port) noexcept {
if (port >= sMenuChordConsumed.size() || has_menu_chord_part_held(port)) {
return;
}
sMenuChordConsumed[port] = false;
}
bool should_defer_menu_chord_part(PADButton button, Rml::Input::KeyIdentifier key) noexcept {
return button == PAD_TRIGGER_R && key == Rml::Input::KI_NEXT;
}
void process_axis_direction(
Rml::Context& context, const SDL_GamepadAxisEvent& event, PADAxisSign sign) noexcept {
GamepadRepeatState* repeat = axis_repeat_state(static_cast<SDL_GamepadAxis>(event.axis), sign);
if (repeat == nullptr) {
return;
}
const bool active = sign == AXIS_SIGN_POSITIVE ? event.value >= kGamepadAxisPressThreshold :
event.value <= -kGamepadAxisPressThreshold;
const bool released = sign == AXIS_SIGN_POSITIVE ? event.value <= kGamepadAxisReleaseThreshold :
event.value >= -kGamepadAxisReleaseThreshold;
u32 port = 0;
PADAxis padAxis = 0;
const bool hasPadAxis =
find_event_port(event.which, port) &&
find_mapped_pad_axis(port, static_cast<SDL_GamepadAxis>(event.axis), sign, padAxis);
const PADButton heldPadButton = hasPadAxis ? pad_button_from_axis(padAxis) : 0;
if (repeat->held) {
if (released) {
if (!repeat->pending) {
context.ProcessKeyUp(repeat->key, 0);
}
set_pad_button_held(port, heldPadButton, false);
*repeat = {};
update_menu_chord_release(port);
}
return;
}
if (!active) {
return;
}
set_pad_button_held(port, heldPadButton, true);
const bool chorded = heldPadButton == PAD_TRIGGER_R && is_menu_chord(port);
if (chorded) {
consume_menu_chord(port, context);
}
const auto key = chorded ? Rml::Input::KI_F1 : map_gamepad_axis(event, sign);
if (key == Rml::Input::KI_UNKNOWN) {
return;
}
if (!chorded && should_defer_menu_chord_part(heldPadButton, key)) {
begin_pending_gamepad_key(*repeat, key);
return;
}
begin_gamepad_key(*repeat, key);
context.ProcessMouseLeave();
context.ProcessKeyDown(key, 0);
}
} // namespace
void sync_input_block() noexcept {
const bool shouldBlock = any_document_visible() || should_block_pad_for_menu_chord();
if (sPadInputBlocked == shouldBlock) {
return;
}
PADBlockInput(shouldBlock);
sPadInputBlocked = shouldBlock;
}
void release_input_block() noexcept {
if (!sPadInputBlocked) {
return;
}
PADBlockInput(false);
sPadInputBlocked = false;
}
void reset_input_state() noexcept {
clear_gamepad_repeats();
}
void handle_event(const SDL_Event& event) noexcept {
if (event.type == SDL_EVENT_GAMEPAD_REMOVED || event.type == SDL_EVENT_WINDOW_FOCUS_LOST) {
reset_input_state();
sync_input_block();
return;
}
if (event.type != SDL_EVENT_GAMEPAD_BUTTON_DOWN && event.type != SDL_EVENT_GAMEPAD_BUTTON_UP &&
event.type != SDL_EVENT_GAMEPAD_AXIS_MOTION)
{
return;
}
auto* context = aurora::rmlui::get_context();
if (context == nullptr) {
return;
}
if (event.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) {
process_axis_direction(*context, event.gaxis, AXIS_SIGN_POSITIVE);
process_axis_direction(*context, event.gaxis, AXIS_SIGN_NEGATIVE);
sync_input_block();
return;
}
auto* repeat = button_repeat_state(static_cast<SDL_GamepadButton>(event.gbutton.button));
u32 port = 0;
PADButton button = 0;
const bool hasPadButton = find_event_pad_button(event.gbutton, port, button);
if (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN) {
set_pad_button_held(port, button, true);
const bool chorded = hasPadButton && is_menu_chord_part(button) && is_menu_chord(port);
if (chorded) {
consume_menu_chord(port, *context);
}
const auto key = chorded ? Rml::Input::KI_F1 : map_gamepad_button(event.gbutton);
if (key != Rml::Input::KI_UNKNOWN) {
bool deferred = false;
if (repeat != nullptr) {
if (!chorded && should_defer_menu_chord_part(button, key)) {
begin_pending_gamepad_key(*repeat, key);
deferred = true;
} else {
begin_gamepad_key(*repeat, key);
}
}
if (!deferred) {
context->ProcessMouseLeave();
context->ProcessKeyDown(key, 0);
}
}
} else {
const auto key = repeat != nullptr && repeat->held ? repeat->key : Rml::Input::KI_UNKNOWN;
const bool wasPending = repeat != nullptr && repeat->pending;
set_pad_button_held(port, button, false);
update_menu_chord_release(port);
if (key != Rml::Input::KI_UNKNOWN) {
if (repeat != nullptr) {
*repeat = {};
}
if (!wasPending) {
context->ProcessKeyUp(key, 0);
}
}
}
sync_input_block();
}
void update_input() noexcept {
auto* context = aurora::rmlui::get_context();
if (context != nullptr) {
const double now = now_seconds();
auto process_repeats = [context, now](auto& repeats) {
for (auto& repeat : repeats) {
if (!repeat.held) {
continue;
}
if (repeat.pending) {
if (now < repeat.pressedAt + kGamepadMenuChordGraceDuration) {
continue;
}
repeat.pending = false;
repeat.pressedAt = now;
repeat.nextRepeatAt =
repeat.repeatable ? now + kGamepadRepeatInitialDelay : 0.0;
context->ProcessMouseLeave();
context->ProcessKeyDown(repeat.key, 0);
continue;
}
if (!repeat.repeatable || now < repeat.nextRepeatAt ||
(repeat.key == Rml::Input::KI_NEXT && any_menu_chord()))
{
continue;
}
context->ProcessKeyDown(repeat.key, 0);
const double heldFor = now - repeat.pressedAt;
repeat.nextRepeatAt = now + repeat_interval(heldFor);
}
};
process_repeats(sGamepadButtonRepeats);
process_repeats(sGamepadAxisRepeats);
} else {
reset_input_state();
}
}
} // namespace dusk::ui
+10
View File
@@ -0,0 +1,10 @@
#pragma once
namespace dusk::ui {
void update_input() noexcept;
void reset_input_state() noexcept;
void sync_input_block() noexcept;
void release_input_block() noexcept;
} // namespace dusk::ui
+18
View File
@@ -0,0 +1,18 @@
#pragma once
namespace dusk::ui {
enum class NavCommand {
None,
Up,
Down,
Left,
Right,
Next, // R1
Previous, // L1
Confirm, // A
Cancel, // B
Menu, // Back/Minus, or R + Start
};
} // namespace dusk::ui
+56
View File
@@ -0,0 +1,56 @@
#include "number_button.hpp"
#include <charconv>
#include <fmt/format.h>
namespace dusk::ui {
NumberButton::NumberButton(Rml::Element* parent, Props props)
: BaseStringButton(parent, {.key = std::move(props.key), .type = "number"}),
mGetValue(std::move(props.getValue)), mSetValue(std::move(props.setValue)),
mIsDisabled(std::move(props.isDisabled)), mMin(props.min), mMax(props.max), mStep(props.step),
mPrefix(std::move(props.prefix)), mSuffix(std::move(props.suffix)) {}
bool NumberButton::disabled() const {
if (mIsDisabled) {
return mIsDisabled();
}
return BaseStringButton::disabled();
}
Rml::String NumberButton::format_value() {
return fmt::format("{}{}{}", mPrefix, mGetValue(), mSuffix);
}
Rml::String NumberButton::input_value() {
return fmt::to_string(mGetValue());
}
void NumberButton::set_value(Rml::String value) {
if (!mSetValue) {
return;
}
int parsedValue = 0;
const char* begin = value.data();
const char* end = begin + value.size();
const auto result = std::from_chars(begin, end, parsedValue);
if (result.ec != std::errc() || result.ptr != end) {
return;
}
mSetValue(std::clamp(parsedValue, mMin, mMax));
}
bool NumberButton::handle_nav_command(NavCommand cmd) {
if (cmd == NavCommand::Left) {
mSetValue(std::clamp(mGetValue() - mStep, mMin, mMax));
return true;
} else if (cmd == NavCommand::Right) {
mSetValue(std::clamp(mGetValue() + mStep, mMin, mMax));
return true;
}
return BaseStringButton::handle_nav_command(cmd);
}
} // namespace dusk::ui
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "string_button.hpp"
namespace dusk::ui {
class NumberButton : public BaseStringButton {
public:
struct Props {
Rml::String key;
std::function<int()> getValue;
std::function<void(int)> setValue;
std::function<bool()> isDisabled;
int min = 0;
int max = INT_MAX;
int step = 1;
Rml::String prefix;
Rml::String suffix;
};
NumberButton(Rml::Element* parent, Props props);
bool disabled() const override;
protected:
Rml::String format_value() override;
Rml::String input_value() override;
void set_value(Rml::String value) override;
bool handle_nav_command(NavCommand cmd) override;
private:
std::function<int()> mGetValue;
std::function<void(int)> mSetValue;
std::function<bool()> mIsDisabled;
int mMin;
int mMax;
int mStep;
Rml::String mPrefix;
Rml::String mSuffix;
};
} // namespace dusk::ui
+273
View File
@@ -0,0 +1,273 @@
#include "overlay.hpp"
#include <dolphin/gx/GXAurora.h>
#include <dolphin/vi.h>
#include <fmt/format.h>
#include "dusk/config.hpp"
#include "dusk/settings.h"
#include <algorithm>
#include <string>
namespace dusk::ui {
namespace {
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/overlay.rcss" />
</head>
<body>
<div id="root" class="overlay-root">
<div class="overlay">
<div class="header">
<div id="title"></div>
<div id="carousel-container" class="carousel-container"></div>
</div>
<div id="description" class="description"></div>
<div class="divider"></div>
<div id="footer" class="footer"></div>
</div>
</div>
</body>
</rml>
)RML";
int get_value(GraphicsOption option) {
switch (option) {
case GraphicsOption::InternalResolution:
return getSettings().game.internalResolutionScale.getValue();
case GraphicsOption::ShadowResolution:
return getSettings().game.shadowResolutionMultiplier.getValue();
case GraphicsOption::BloomMode:
return static_cast<int>(getSettings().game.bloomMode.getValue());
case GraphicsOption::BloomMultiplier:
return std::clamp(
static_cast<int>(getSettings().game.bloomMultiplier.getValue() * 100.0f + 0.5f), 0,
100);
}
return 0;
}
void set_value(GraphicsOption option, int value) {
switch (option) {
case GraphicsOption::InternalResolution:
getSettings().game.internalResolutionScale.setValue(value);
VISetFrameBufferScale(static_cast<float>(value));
break;
case GraphicsOption::ShadowResolution:
getSettings().game.shadowResolutionMultiplier.setValue(value);
break;
case GraphicsOption::BloomMode:
getSettings().game.bloomMode.setValue(static_cast<BloomMode>(std::clamp(
value, static_cast<int>(BloomMode::Off), static_cast<int>(BloomMode::Dusk))));
break;
case GraphicsOption::BloomMultiplier:
getSettings().game.bloomMultiplier.setValue(std::clamp(value, 0, 100) / 100.0f);
break;
}
config::Save();
}
Rml::Element* create_stepped_carousel_root(Rml::Element* parent) {
auto* doc = parent->GetOwnerDocument();
auto root = doc->CreateElement("div");
root->SetClass("stepped-carousel", true);
root->SetAttribute("tabindex", "0");
return parent->AppendChild(std::move(root));
}
Rml::Element* create_stepped_carousel_arrow(
Rml::Element* parent, const Rml::String& className, const Rml::String& label) {
auto* doc = parent->GetOwnerDocument();
auto button = doc->CreateElement("button");
button->SetClass("stepped-carousel-arrow", true);
button->SetClass(className, true);
button->SetInnerRML(escape(label));
return parent->AppendChild(std::move(button));
}
} // namespace
SteppedCarousel::SteppedCarousel(Rml::Element* parent, Props props)
: Component(create_stepped_carousel_root(parent)), mProps(std::move(props)) {
Rml::Element* prevElem = create_stepped_carousel_arrow(mRoot, "prev", "<");
mValueElem = append(mRoot, "div");
mValueElem->SetClass("stepped-carousel-value", true);
Rml::Element* nextElem = create_stepped_carousel_arrow(mRoot, "next", ">");
listen(prevElem, Rml::EventId::Click,
[this](Rml::Event&) { handle_nav_command(NavCommand::Left); });
listen(nextElem, Rml::EventId::Click,
[this](Rml::Event&) { handle_nav_command(NavCommand::Right); });
listen(mRoot, Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd != NavCommand::None && handle_nav_command(cmd)) {
event.StopPropagation();
}
});
}
bool SteppedCarousel::focus() {
return Component::focus();
}
void SteppedCarousel::update() {
if (mValueElem == nullptr) {
return;
}
const int value = std::clamp(mProps.getValue ? mProps.getValue() : 0, mProps.min, mProps.max);
if (mProps.formatValue) {
mValueElem->SetInnerRML(mProps.formatValue(value));
} else {
mValueElem->SetInnerRML(std::to_string(value));
}
}
bool SteppedCarousel::handle_nav_command(NavCommand cmd) {
if (cmd == NavCommand::Left) {
const int value = mProps.getValue ? mProps.getValue() : 0;
apply(std::clamp(value - mProps.step, mProps.min, mProps.max));
return true;
}
if (cmd == NavCommand::Right) {
const int value = mProps.getValue ? mProps.getValue() : 0;
apply(std::clamp(value + mProps.step, mProps.min, mProps.max));
return true;
}
return false;
}
void SteppedCarousel::apply(int value) {
const int nextValue = std::clamp(value, mProps.min, mProps.max);
const int currentValue =
std::clamp(mProps.getValue ? mProps.getValue() : 0, mProps.min, mProps.max);
if (nextValue == currentValue) {
return;
}
if (mProps.onChange) {
mProps.onChange(nextValue);
}
}
Rml::String format_graphics_setting_value(GraphicsOption option, int value) {
switch (option) {
case GraphicsOption::InternalResolution:
if (value <= 0) {
return "Auto";
} else {
u32 width = 0;
u32 height = 0;
AuroraGetRenderSize(&width, &height);
return fmt::format("{}x ({}x{})", value, width, height);
}
case GraphicsOption::ShadowResolution:
return fmt::format("{}x", value);
case GraphicsOption::BloomMode:
switch (static_cast<BloomMode>(value)) {
case BloomMode::Off:
return "Off";
case BloomMode::Classic:
return "Classic";
case BloomMode::Dusk:
return "Dusk";
}
break;
case GraphicsOption::BloomMultiplier:
return fmt::format("{}%", value);
}
return "";
}
Overlay::Overlay(OverlayProps props)
: Document(kDocumentSource), mOption(props.option), mValueMin(props.valueMin),
mValueMax(props.valueMax), mDefaultValue(props.defaultValue) {
if (mDocument == nullptr) {
return;
}
if (auto* title = mDocument->GetElementById("title")) {
title->SetInnerRML(escape(props.title));
}
if (auto* description = mDocument->GetElementById("description")) {
description->SetInnerRML(escape(props.helpText));
}
if (auto* carouselParent = mDocument->GetElementById("carousel-container")) {
add_component<SteppedCarousel>(carouselParent,
SteppedCarousel::Props{
.min = mValueMin,
.max = mValueMax,
.step = 1,
.getValue = [this] { return get_value(mOption); },
.onChange = [this](int value) { set_value(mOption, value); },
.formatValue =
[this](int value) { return format_graphics_setting_value(mOption, value); },
});
}
if (auto* footer = mDocument->GetElementById("footer")) {
auto& returnButton = add_component<Button>(footer, "\xE2\x86\x90 Return", "footer-button")
.on_pressed(pop_document);
returnButton.root()->SetClass("return", true);
auto& resetButton =
add_component<Button>(footer, "Reset to default", "footer-button").on_pressed([this] {
reset_default();
});
resetButton.root()->SetClass("reset", true);
}
// Hide document after transition completion
mRoot = mDocument->GetElementById("root");
listen(mRoot, Rml::EventId::Transitionend, [this](Rml::Event& event) {
if (event.GetTargetElement() == mRoot &&
*mRoot->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Visible &&
!mRoot->HasAttribute("open"))
{
Document::hide();
}
});
}
void Overlay::show() {
Document::show();
mRoot->SetAttribute("open", "");
}
void Overlay::hide() {
mRoot->RemoveAttribute("open");
}
void Overlay::update() {
for (const auto& component : mComponents) {
component->update();
}
Document::update();
}
bool Overlay::focus() {
for (const auto& component : mComponents) {
if (component->focus()) {
return true;
}
}
return false;
}
bool Overlay::visible() const {
return mRoot->HasAttribute("open");
}
bool Overlay::handle_nav_command(Rml::Event& event, NavCommand cmd) {
if (cmd == NavCommand::Cancel) {
pop_document();
return true;
}
return Document::handle_nav_command(event, cmd);
}
void Overlay::reset_default() {
set_value(mOption, mDefaultValue);
}
} // namespace dusk::ui
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include "button.hpp"
#include "component.hpp"
#include "document.hpp"
#include "ui.hpp"
#include <functional>
#include <memory>
#include <type_traits>
#include <utility>
#include <vector>
namespace dusk::ui {
class SteppedCarousel : public Component {
public:
struct Props {
int min = 0;
int max = 0;
int step = 1;
std::function<int()> getValue;
std::function<void(int)> onChange;
std::function<Rml::String(int)> formatValue;
};
SteppedCarousel(Rml::Element* parent, Props props);
bool focus() override;
void update() override;
private:
bool handle_nav_command(NavCommand cmd);
void apply(int value);
Props mProps;
Rml::Element* mValueElem = nullptr;
};
enum class GraphicsOption {
InternalResolution,
ShadowResolution,
BloomMode,
BloomMultiplier,
};
Rml::String format_graphics_setting_value(GraphicsOption option, int value);
struct OverlayProps {
GraphicsOption option;
Rml::String title;
Rml::String helpText;
int valueMin = 0;
int valueMax = 0;
int defaultValue = 0;
};
class Overlay : public Document {
public:
explicit Overlay(OverlayProps props);
void show() override;
void hide() override;
void update() override;
bool focus() override;
bool visible() const override;
protected:
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
private:
template <typename T, typename... Args>
requires std::is_base_of_v<Component, T> T& add_component(Args&&... args) {
auto child = std::make_unique<T>(std::forward<Args>(args)...);
T& ref = *child;
mComponents.emplace_back(std::move(child));
return ref;
}
void reset_default();
GraphicsOption mOption;
int mValueMin = 0;
int mValueMax = 0;
int mDefaultValue = 0;
std::vector<std::unique_ptr<Component> > mComponents;
Rml::Element* mRoot;
bool mDismissed = false;
};
} // namespace dusk::ui
+151
View File
@@ -0,0 +1,151 @@
#include "pane.hpp"
#include "ui.hpp"
namespace dusk::ui {
namespace {
Rml::Element* createRoot(Rml::Element* parent) {
auto* doc = parent->GetOwnerDocument();
auto elem = doc->CreateElement("pane");
return parent->AppendChild(std::move(elem));
}
} // namespace
Pane::Pane(Rml::Element* parent, Type type) : FluentComponent(createRoot(parent)), mType(type) {
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
// If navigating to the next pane, select the focused item
if (mType == Type::Controlled && cmd == NavCommand::Right) {
auto* target = event.GetTargetElement();
int focusedChild = -1;
for (size_t i = 0; i < mChildren.size(); ++i) {
if (mChildren[i]->contains(target)) {
focusedChild = i;
break;
}
}
if (focusedChild == -1) {
return;
}
set_selected_item(focusedChild);
return;
}
int direction = 0;
if (cmd == NavCommand::Down) {
direction = 1;
} else if (cmd == NavCommand::Up) {
direction = -1;
} else {
return;
}
auto* target = event.GetTargetElement();
int focusedChild = -1;
for (size_t i = 0; i < mChildren.size(); ++i) {
if (mChildren[i]->contains(target)) {
focusedChild = i;
break;
}
}
if (focusedChild == -1) {
return;
}
int i = focusedChild + direction;
while (i >= 0 && i < mChildren.size()) {
if (mChildren[i]->focus()) {
event.StopPropagation();
break;
}
i += direction;
}
});
if (type == Type::Controlled) {
// For controlled panes, handle SelectButton Submit events for item selection
listen(Rml::EventId::Submit, [this](Rml::Event& event) {
int childIndex = -1;
for (int i = 0; i < mChildren.size(); ++i) {
if (event.GetTargetElement() == mChildren[i]->root()) {
childIndex = i;
}
}
set_selected_item(childIndex);
// If the selection was handled locally, don't allow it to bubble up to window
if (event.GetParameter("handled", false)) {
event.StopPropagation();
}
});
}
}
void Pane::update() {
finalize();
Component::update();
}
void Pane::set_selected_item(int index) {
if (mType == Type::Uncontrolled) {
return;
}
for (int i = 0; i < mChildren.size(); ++i) {
mChildren[i]->set_selected(i == index);
}
}
bool Pane::focus() {
// Focus the first selected child
for (const auto& child : mChildren) {
if (child->selected() && child->focus()) {
return true;
}
}
// Otherwise, focus the first focusable child
for (const auto& child : mChildren) {
if (child->focus()) {
return true;
}
}
return false;
}
Rml::Element* Pane::add_section(const Rml::String& text) {
auto* elem = append(mRoot, "div");
elem->SetClass("section-heading", true);
elem->SetInnerRML(escape(text));
return elem;
}
Rml::Element* Pane::add_text(const Rml::String& text) {
auto* elem = append(mRoot, "div");
elem->SetInnerRML(escape(text));
return elem;
}
Rml::Element* Pane::add_rml(const Rml::String& rml) {
auto* elem = append(mRoot, "div");
elem->SetInnerRML(rml);
return elem;
}
void Pane::finalize() {
if (finalized) {
return;
}
finalized = true;
// Append spacer element to the bottom. RmlUi does not properly handle
// padding-bottom or margin-bottom on a scrollable flex container, so
// we need to create a fake spacer with an actual layout height to get
// padding at the bottom of a scrollable container.
append(mRoot, "spacer");
}
void Pane::clear() {
clear_children();
finalized = false;
}
} // namespace dusk::ui
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include "button.hpp"
#include "component.hpp"
#include "select_button.hpp"
namespace dusk::ui {
class Pane : public FluentComponent<Pane> {
public:
enum class Type {
Controlled,
Uncontrolled,
};
explicit Pane(Rml::Element* parent, Type type);
bool focus() override;
void update() override;
void set_selected_item(int index);
Rml::Element* add_section(const Rml::String& text);
ControlledButton& add_button(ControlledButton::Props props) {
return add_child<ControlledButton>(std::move(props));
}
Button& add_button(Rml::String text) { return add_child<Button>(std::move(text)); }
ControlledSelectButton& add_select_button(ControlledSelectButton::Props props) {
return add_child<ControlledSelectButton>(std::move(props));
}
Rml::Element* add_text(const Rml::String& text);
Rml::Element* add_rml(const Rml::String& rml);
void finalize();
void clear();
private:
Type mType;
bool finalized = false;
};
} // namespace dusk::ui
+126
View File
@@ -0,0 +1,126 @@
#include "popup.hpp"
#include <RmlUi/Core.h>
#include "aurora/rmlui.hpp"
#include "editor.hpp"
#include "settings.hpp"
#include "ui.hpp"
#include "window.hpp"
#include <chrono>
#include "dusk/main.h"
#include <cmath>
namespace dusk::ui {
namespace {
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/tabbing.rcss" />
<link type="text/rcss" href="res/rml/popup.rcss" />
</head>
<body>
<popup id="popup"></div>
</body>
</rml>
)RML";
}
Popup::Popup() : Document(kDocumentSource), mRoot(mDocument->GetElementById("popup")) {
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{.autoSelect = false});
mTabBar->add_tab("Settings", [] { push_document(std::make_unique<SettingsWindow>()); });
mTabBar->add_tab("Warp", [] {
// TODO
});
mTabBar->add_tab("Editor", [] { push_document(std::make_unique<EditorWindow>()); });
mTabBar->add_tab("Reset", [this] {
JUTGamePad::C3ButtonReset::sResetSwitchPushing = true;
mTabBar->set_active_tab(-1);
hide();
});
mTabBar->add_tab("Exit", [] { IsRunning = false; });
// Hide document after transition completion
listen(mRoot, Rml::EventId::Transitionend, [this](Rml::Event& event) {
if (event.GetTargetElement() == mRoot &&
*mRoot->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Visible &&
!mRoot->HasAttribute("open"))
{
Document::hide();
}
});
// We start hidden, but want focus for an open nav event
mDocument->Focus();
}
void Popup::show() {
Document::show();
mRoot->SetAttribute("open", "");
mTabBar->set_active_tab(-1);
}
void Popup::hide() {
mRoot->RemoveAttribute("open");
}
void Popup::update() {
update_safe_area();
Document::update();
}
void Popup::update_safe_area() noexcept {
if (mDocument == nullptr || mTabBar == nullptr) {
return;
}
Rml::Context* context = mDocument->GetContext();
Insets safeInsets = safe_area_insets(context);
safeInsets = {
0.0f,
std::round(safeInsets.right),
0.0f,
std::round(safeInsets.left),
};
if (safeInsets == mTabBarPadding) {
return;
}
mTabBarPadding = safeInsets;
auto* tabBar = mTabBar->root();
tabBar->SetProperty(
Rml::PropertyId::PaddingRight, Rml::Property(safeInsets.right, Rml::Unit::PX));
tabBar->SetProperty(
Rml::PropertyId::PaddingLeft, Rml::Property(safeInsets.left, Rml::Unit::PX));
}
void Popup::toggle() {
if (visible()) {
hide();
} else {
show();
}
}
bool Popup::visible() const {
return mRoot->HasAttribute("open");
}
bool Popup::handle_nav_command(Rml::Event& event, NavCommand cmd) {
if (cmd == NavCommand::Cancel) {
hide();
return true;
}
return Document::handle_nav_command(event, cmd);
}
bool Popup::focus() {
return mTabBar->focus();
}
} // namespace dusk::ui
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include "button.hpp"
#include "document.hpp"
#include "tab_bar.hpp"
#include <memory>
namespace dusk::ui {
class Popup : public Document {
public:
Popup();
Popup(const Popup&) = delete;
Popup& operator=(const Popup&) = delete;
void show() override;
void hide() override;
void update() override;
bool focus() override;
bool visible() const override;
void toggle();
protected:
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
private:
void update_safe_area() noexcept;
Rml::Element* mRoot;
std::unique_ptr<TabBar> mTabBar;
std::unique_ptr<Button> mCloseButton;
Insets mTabBarPadding;
bool mVisible = false;
};
} // namespace dusk::ui
+230
View File
@@ -0,0 +1,230 @@
#include "prelaunch.hpp"
#include "dusk/config.hpp"
#include "dusk/file_select.hpp"
#include "dusk/iso_validate.hpp"
#include "dusk/main.h"
#include "dusk/ui/prelaunch_options.hpp"
#include "version.h"
#include <aurora/lib/window.hpp>
#include <SDL3/SDL_dialog.h>
#include <SDL3/SDL_filesystem.h>
namespace dusk::ui {
namespace {
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/prelaunch.rcss" />
</head>
<body>
<div class="menu">
<div class="hero intro-item delay-0">
<div class="eyebrow"><span>Twilit Realm</span> presents</div>
<img src="res/logo-mascot.png" />
</div>
<div id="menu-list" />
</div>
<div class="disk-status intro-item delay-4">
<span id="status" class="status" />
<span id="detail" class="detail" />
</div>
<div class="version-info intro-item delay-5">
<div class="version">Version <span id="version-text"></span></div>
<div class="update"><span>Update available!</span> Download</div>
</div>
</body>
</rml>
)RML";
static constexpr std::array<SDL_DialogFileFilter, 2> kDiscFileFilters{{
{"Game Disc Images", "iso;gcm;ciso;gcz;nfs;rvz;wbfs;wia;tgc"},
{"All Files", "*"},
}};
void file_dialog_callback(void*, const char* path, const char* error) {
auto& state = prelaunch_state();
if (error != nullptr) {
return;
}
if (path == nullptr) {
return;
}
state.selectedIsoPath = path;
state.errorString.clear();
refresh_path_state();
getSettings().backend.isoPath.setValue(state.selectedIsoPath);
config::Save();
}
} // namespace
PrelaunchState sPrelaunchState;
PrelaunchState& prelaunch_state() noexcept {
return sPrelaunchState;
}
void refresh_path_state() noexcept {
auto& state = prelaunch_state();
state.isPal = !state.selectedIsoPath.empty() && iso::isPal(state.selectedIsoPath.c_str());
}
void ensure_initialized() noexcept {
auto& state = prelaunch_state();
if (state.initialized) {
return;
}
state.selectedIsoPath = getSettings().backend.isoPath;
state.initialGraphicsBackend = getSettings().backend.graphicsBackend;
state.errorString.clear();
state.initialized = true;
refresh_path_state();
}
bool is_selected_path_valid() noexcept {
return !prelaunch_state().selectedIsoPath.empty() && SDL_GetPathInfo(prelaunch_state().selectedIsoPath.c_str(), nullptr);
}
void open_iso_picker() noexcept {
ensure_initialized();
ShowFileSelect(&file_dialog_callback, nullptr, aurora::window::get_sdl_window(),
kDiscFileFilters.data(), static_cast<int>(kDiscFileFilters.size()), nullptr, false);
}
void apply_intro_animation(Rml::Element* element, const char* delay_class) {
if (element == nullptr || delay_class == nullptr) {
return;
}
element->SetClass("intro-item", true);
element->SetClass(delay_class, true);
}
Prelaunch::Prelaunch() : Document(kDocumentSource) {
ensure_initialized();
if (auto* menuList = mDocument->GetElementById("menu-list")) {
const bool hasValidPath = is_selected_path_valid();
mMenuButtons.push_back(std::make_unique<Button>(menuList, hasValidPath ? "Start Game" : "Select Disk Image"));
mMenuButtons.back()->on_pressed([] {
if (!is_selected_path_valid()) {
open_iso_picker();
return;
}
pop_document();
IsGameLaunched = true;
});
apply_intro_animation(mMenuButtons.back()->root(), "delay-1");
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Options"));
mMenuButtons.back()->on_pressed([] { push_document(std::make_unique<PrelaunchOptions>());});
apply_intro_animation(mMenuButtons.back()->root(), "delay-2");
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Quit To Desktop"));
mMenuButtons.back()->on_pressed([] { IsRunning = false; });
apply_intro_animation(mMenuButtons.back()->root(), "delay-3");
}
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
int direction = 0;
if (cmd == NavCommand::Down) {
direction = 1;
} else if (cmd == NavCommand::Up) {
direction = -1;
} else {
return;
}
auto* target = event.GetTargetElement();
int focusedButton = -1;
for (size_t i = 0; i < mMenuButtons.size(); ++i) {
if (mMenuButtons[i]->contains(target)) {
focusedButton = i;
break;
}
}
if (focusedButton == -1) {
return;
}
int i = focusedButton + direction;
while (i >= 0 && i < mMenuButtons.size()) {
if (mMenuButtons[i]->focus()) {
event.StopPropagation();
break;
}
i += direction;
}
});
mDiscStatus = mDocument->GetElementById("status");
mDiscDetail = mDocument->GetElementById("detail");
mVersion = mDocument->GetElementById("version-text");
listen(mDocument, Rml::EventId::Transitionend, [this](Rml::Event& event) {
auto* target = event.GetTargetElement();
if (target != nullptr && target->GetTagName() == "button") {
target->SetClass("anim-done", true);
}
});
}
void Prelaunch::update() {
ensure_initialized();
refresh_path_state();
if (!mEntranceAnimationStarted && mDocument != nullptr) {
mDocument->SetClass("animate-in", true);
mEntranceAnimationStarted = true;
}
auto& state = prelaunch_state();
const bool hasValidPath = is_selected_path_valid();
if (hasValidPath && getSettings().backend.skipPreLaunchUI) {
pop_document();
IsGameLaunched = true;
}
if (!mMenuButtons.empty()) {
mMenuButtons[0]->set_text(hasValidPath ? "Start Game" : "Select Disk Image");
}
if (mDiscStatus != nullptr) {
if (hasValidPath) {
mDiscStatus->RemoveAttribute("bad");
mDiscStatus->SetInnerRML("Disc Ready");
} else {
mDiscStatus->SetAttribute("bad", "");
mDiscStatus->SetInnerRML("Disk Not Found");
}
}
if (mDiscDetail != nullptr) {
if (hasValidPath) {
mDiscDetail->SetProperty(Rml::PropertyId::Display, Rml::Style::Display::Block);
mDiscDetail->SetInnerRML(prelaunch_state().isPal ? "GameCube • PAL" : "GameCube • USA");
} else {
mDiscDetail->SetProperty(Rml::PropertyId::Display, Rml::Style::Display::None);
}
}
if (mVersion != nullptr) {
mVersion->SetInnerRML(escape(DUSK_WC_DESCRIBE));
}
Document::update();
}
bool Prelaunch::focus() {
if (mMenuButtons.empty()) {
return false;
}
return mMenuButtons[0]->focus();
}
bool Prelaunch::handle_nav_command(Rml::Event& event, NavCommand cmd) {
return false;
}
} // namespace dusk::ui
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "button.hpp"
#include "document.hpp"
#include <memory>
#include <string>
#include <vector>
namespace dusk::ui {
class Prelaunch : public Document {
public:
Prelaunch();
void update() override;
bool focus() override;
protected:
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
private:
bool mEntranceAnimationStarted = false;
std::vector<std::unique_ptr<Button>> mMenuButtons;
Rml::Element* mDiscStatus = nullptr;
Rml::Element* mDiscDetail = nullptr;
Rml::Element* mVersion = nullptr;
};
class PrelaunchOptions;
struct PrelaunchState {
std::string selectedIsoPath;
std::string errorString;
std::string initialGraphicsBackend;
bool isPal = false;
bool initialized = false;
};
PrelaunchState& prelaunch_state() noexcept;
void ensure_initialized() noexcept;
void refresh_path_state() noexcept;
bool is_selected_path_valid() noexcept;
void open_iso_picker() noexcept;
} // namespace dusk::ui
+263
View File
@@ -0,0 +1,263 @@
#include "prelaunch_options.hpp"
#include "dusk/config.hpp"
#include "dusk/settings.h"
#include "pane.hpp"
#include "prelaunch.hpp"
namespace dusk::ui {
namespace {
static constexpr std::array<const char*, 5> kLanguageNames = {
"English", "German", "French", "Spanish", "Italian",
};
// TODO: Copied from ImGui prelaunch. Needs a refactor?
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 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";
}
}
std::vector<AuroraBackend> available_backends() {
std::vector<AuroraBackend> backends;
backends.emplace_back(BACKEND_AUTO);
size_t backendCount = 0;
const AuroraBackend* raw = aurora_get_available_backends(&backendCount);
for (size_t i = 0; i < backendCount; ++i) {
// Do not expose NULL or D3D11
if (raw[i] != BACKEND_NULL || raw[i] == BACKEND_D3D11) {
backends.emplace_back(raw[i]);
}
}
return backends;
}
class LanguageSelect final : public SelectButton {
public:
explicit LanguageSelect(Rml::Element* parent) : SelectButton(parent, Props{.key = "Language"}) {}
void update() override {
ensure_initialized();
refresh_path_state();
const bool validPath = is_selected_path_valid();
const bool ntscDiscLocked = validPath && !prelaunch_state().isPal;
if (ntscDiscLocked) {
if (getSettings().game.language.getValue() != GameLanguage::English) {
getSettings().game.language.setValue(GameLanguage::English);
config::Save();
}
set_disabled(true);
} else {
set_disabled(false);
}
const auto lang = getSettings().game.language.getValue();
auto value = static_cast<u8>(lang);
if (value >= kLanguageNames.size()) {
getSettings().game.language.setValue(GameLanguage::English);
config::Save();
value = static_cast<u8>(getSettings().game.language.getValue());
}
set_value_label(kLanguageNames[value]);
SelectButton::update();
}
protected:
bool handle_nav_command(NavCommand cmd) override {
if (disabled()) {
return false;
}
if (cmd != NavCommand::Confirm && cmd != NavCommand::Left && cmd != NavCommand::Right) {
return false;
}
constexpr int n = static_cast<int>(kLanguageNames.size());
int idx = static_cast<int>(getSettings().game.language.getValue());
const int dir = (cmd == NavCommand::Left) ? -1 : 1;
idx = ((idx + dir) % n + n) % n;
getSettings().game.language.setValue(static_cast<GameLanguage>(idx));
config::Save();
return true;
}
};
class BackendSelect final : public SelectButton {
public:
explicit BackendSelect(Rml::Element* parent) : SelectButton(parent, Props{.key = "Graphics Backend"}) {}
void update() override {
AuroraBackend configuredBackend = BACKEND_AUTO;
const auto configuredId = getSettings().backend.graphicsBackend.getValue();
if (!try_parse_backend(configuredId, configuredBackend)) {
configuredBackend = BACKEND_AUTO;
}
// Do not expose NULL or D3D11
if (configuredBackend == BACKEND_NULL || configuredBackend == BACKEND_D3D11) {
getSettings().backend.graphicsBackend.setValue("auto");
config::Save();
configuredBackend = BACKEND_AUTO;
}
const auto backend = getSettings().backend.graphicsBackend.getValue();
Rml::String value = backend_name(configuredBackend).data();
if (backend != prelaunch_state().initialGraphicsBackend) {
value += " (restart required)";
}
set_value_label(value);
SelectButton::update();
}
protected:
bool handle_nav_command(NavCommand cmd) override {
if (cmd != NavCommand::Confirm && cmd != NavCommand::Left && cmd != NavCommand::Right) {
return false;
}
const auto backends = available_backends();
const int n = static_cast<int>(backends.size());
if (n <= 0) {
return false;
}
AuroraBackend configuredBackend = BACKEND_AUTO;
const auto configuredId = getSettings().backend.graphicsBackend.getValue();
if (!try_parse_backend(configuredId, configuredBackend)) {
configuredBackend = BACKEND_AUTO;
}
int idx = 0;
for (int i = 0; i < n; ++i) {
if (backends[static_cast<size_t>(i)] == configuredBackend) {
idx = i;
break;
}
}
const int dir = (cmd == NavCommand::Left) ? -1 : 1;
idx = ((idx + dir) % n + n) % n;
getSettings().backend.graphicsBackend.setValue(std::string(backend_id(backends[static_cast<size_t>(idx)])));
config::Save();
return true;
}
};
class SaveTypeSelect final : public SelectButton {
public:
explicit SaveTypeSelect(Rml::Element* parent) : SelectButton(parent, Props{.key = "Save File Type"}) {}
void update() override {
const CARDFileType cft = static_cast<CARDFileType>(getSettings().backend.cardFileType.getValue());
set_value_label(cft == CARD_GCIFOLDER ? "GCI Folder" : "Card Image");
SelectButton::update();
}
protected:
bool handle_nav_command(NavCommand cmd) override {
if (cmd != NavCommand::Confirm && cmd != NavCommand::Left && cmd != NavCommand::Right) {
return false;
}
CARDFileType cft = static_cast<CARDFileType>(getSettings().backend.cardFileType.getValue());
const CARDFileType newValue = cft == CARD_GCIFOLDER ? CARD_RAWIMAGE : CARD_GCIFOLDER;
getSettings().backend.cardFileType.setValue(newValue);
config::Save();
return true;
}
};
} // namespace
PrelaunchOptions::PrelaunchOptions() {
add_tab("Options", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled);
leftPane.add_child<LanguageSelect>();
leftPane.add_child<BackendSelect>();
leftPane.add_child<SaveTypeSelect>();
});
}
} // namespace dusk::ui
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "window.hpp"
namespace dusk::ui {
class PrelaunchOptions : public Window {
public:
PrelaunchOptions();
};
} // namespace dusk::ui
+69
View File
@@ -0,0 +1,69 @@
#include "select_button.hpp"
#include "ui.hpp"
#include <utility>
namespace dusk::ui {
namespace {
Rml::Element* createRoot(Rml::Element* parent) {
auto* doc = parent->GetOwnerDocument();
auto elem = doc->CreateElement("select-button");
return parent->AppendChild(std::move(elem));
}
} // namespace
SelectButton::SelectButton(Rml::Element* parent, Props props)
: FluentComponent(createRoot(parent)) {
mKeyElem = append(mRoot, "key");
mValueElem = append(mRoot, "value");
update_props(std::move(props));
on_nav_command([this](Rml::Event&, NavCommand cmd) { return handle_nav_command(cmd); });
}
void SelectButton::set_value_label(const Rml::String& value) {
if (mProps.value != value) {
mValueElem->SetInnerRML(escape(value));
mProps.value = value;
}
}
void SelectButton::update_props(Props props) {
if (mProps.key != props.key) {
mKeyElem->SetInnerRML(escape(props.key));
}
set_value_label(props.value);
mProps = std::move(props);
}
bool SelectButton::handle_nav_command(NavCommand cmd) {
if (cmd == NavCommand::Confirm) {
mRoot->DispatchEvent(Rml::EventId::Submit, {});
return true;
}
return false;
}
void BaseControlledSelectButton::update() {
set_disabled(disabled());
set_value_label(format_value());
SelectButton::update();
}
bool ControlledSelectButton::disabled() const {
if (mIsDisabled) {
return mIsDisabled();
}
return BaseControlledSelectButton::disabled();
}
Rml::String ControlledSelectButton::format_value() {
if (!mGetValue) {
return "";
}
return mGetValue();
}
} // namespace dusk::ui
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include "component.hpp"
#include "ui.hpp"
namespace dusk::ui {
class SelectButton : public FluentComponent<SelectButton> {
public:
struct Props {
Rml::String key;
Rml::String value;
};
SelectButton(Rml::Element* parent, Props props);
void set_value_label(const Rml::String& value);
protected:
void update_props(Props props);
virtual bool handle_nav_command(NavCommand cmd);
Props mProps;
Rml::Element* mKeyElem = nullptr;
Rml::Element* mValueElem = nullptr;
std::function<void()> mOnHover;
};
class BaseControlledSelectButton : public SelectButton {
public:
BaseControlledSelectButton(Rml::Element* parent, Props props)
: SelectButton(parent, std::move(props)) {}
void update() override;
protected:
virtual Rml::String format_value() = 0;
};
class ControlledSelectButton : public BaseControlledSelectButton {
public:
struct Props {
Rml::String key;
std::function<Rml::String()> getValue;
std::function<bool()> isDisabled;
};
ControlledSelectButton(Rml::Element* parent, Props props)
: BaseControlledSelectButton(parent, {std::move(props.key)}),
mGetValue(std::move(props.getValue)), mIsDisabled(std::move(props.isDisabled)) {}
bool disabled() const override;
protected:
Rml::String format_value() override;
std::function<Rml::String()> mGetValue;
std::function<bool()> mIsDisabled;
};
} // namespace dusk::ui
+581
View File
@@ -0,0 +1,581 @@
#include "settings.hpp"
#include <fmt/format.h>
#include "aurora/gfx.h"
#include "bool_button.hpp"
#include "dusk/audio/DuskAudioSystem.h"
#include "dusk/config.hpp"
#include "dusk/livesplit.h"
#include "m_Do/m_Do_main.h"
#include "number_button.hpp"
#include "overlay.hpp"
#include "pane.hpp"
#include "ui.hpp"
#include <algorithm>
namespace dusk::ui {
namespace {
void reset_for_speedrun_mode() {
mDoMain::developmentMode = -1;
getSettings().game.damageMultiplier.setValue(1);
getSettings().game.instantDeath.setValue(false);
getSettings().game.noHeartDrops.setValue(false);
getSettings().game.infiniteHearts.setValue(false);
getSettings().game.infiniteArrows.setValue(false);
getSettings().game.infiniteBombs.setValue(false);
getSettings().game.infiniteOil.setValue(false);
getSettings().game.infiniteOxygen.setValue(false);
getSettings().game.infiniteRupees.setValue(false);
getSettings().game.enableIndefiniteItemDrops.setValue(false);
getSettings().game.moonJump.setValue(false);
getSettings().game.superClawshot.setValue(false);
getSettings().game.alwaysGreatspin.setValue(false);
getSettings().game.enableFastIronBoots.setValue(false);
getSettings().game.canTransformAnywhere.setValue(false);
getSettings().game.fastSpinner.setValue(false);
getSettings().game.freeMagicArmor.setValue(false);
getSettings().game.enableTurboKeybind.setValue(false);
}
const Rml::String kInternalResolutionHelpText =
"Configure the resolution used for rendering the game. Higher values are more demanding on "
"your graphics hardware.";
const Rml::String kShadowResolutionHelpText =
"Configure the shadow-map resolution. Higher values improve shadow quality but increase GPU "
"and memory usage.";
const Rml::String kBloomHelpText =
"Configure the post-processing bloom effect. Classic uses the original bloom pass; Dusk uses "
"a higher-quality bloom pass.";
const Rml::String kBloomBrightnessHelpText =
"Configure bloom intensity. Higher values make bright areas glow more strongly.";
int bloom_multiplier_percent() {
return std::clamp(
static_cast<int>(getSettings().game.bloomMultiplier.getValue() * 100.0f + 0.5f), 0, 100);
}
int float_setting_percent(ConfigVar<float>& var) {
return static_cast<int>(var.getValue() * 100.0f + 0.5f);
}
bool gyro_enabled() {
return getSettings().game.enableGyroAim || getSettings().game.enableGyroRollgoal;
}
struct ConfigBoolProps {
Rml::String key;
Rml::String helpText;
std::function<void(bool)> onChange;
std::function<bool()> isDisabled;
};
SelectButton& config_bool_select(
Pane& leftPane, Pane& rightPane, ConfigVar<bool>& var, ConfigBoolProps props) {
return leftPane
.add_child<BoolButton>(BoolButton::Props{
.key = std::move(props.key),
.getValue = [&var] { return var.getValue(); },
.setValue =
[&var, callback = std::move(props.onChange)](bool value) {
if (value == var.getValue()) {
return;
}
var.setValue(value);
config::Save();
if (callback) {
callback(value);
}
},
.isDisabled = std::move(props.isDisabled),
})
.on_focus([&rightPane, helpText = std::move(props.helpText)](Rml::Event&) {
rightPane.clear();
rightPane.add_rml(helpText);
});
}
SelectButton& config_percent_select(Pane& leftPane, Pane& rightPane, ConfigVar<float>& var,
Rml::String key, Rml::String helpText, int min, int max, int step = 5,
std::function<bool()> isDisabled = {}) {
return leftPane
.add_child<NumberButton>(NumberButton::Props{
.key = std::move(key),
.getValue = [&var] { return float_setting_percent(var); },
.setValue =
[&var, min, max](int value) {
var.setValue(std::clamp(value, min, max) / 100.0f);
config::Save();
},
.isDisabled = std::move(isDisabled),
.min = min,
.max = max,
.step = step,
.suffix = "%",
})
.on_focus([&rightPane, helpText = std::move(helpText)](Rml::Event&) {
rightPane.clear();
rightPane.add_text(helpText);
});
}
class ControllerConfigWindow : public Window {
public:
ControllerConfigWindow() {
for (int i = 0; i < 4; ++i) {
add_tab(fmt::format("Port {}", i + 1), [this](Rml::Element* content) {
auto& pane = add_child<Pane>(content, Pane::Type::Controlled);
pane.add_section("Coming soon");
});
}
}
};
} // namespace
SettingsWindow::SettingsWindow() {
add_tab("Audio", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled);
auto& rightPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
leftPane.add_section("Volume");
leftPane
.add_child<NumberButton>(NumberButton::Props{
.key = "Master Volume",
.getValue = [] { return getSettings().audio.masterVolume.getValue(); },
.setValue =
[](int value) {
getSettings().audio.masterVolume.setValue(value);
config::Save();
audio::SetMasterVolume(value / 100.f);
},
.max = 100,
.suffix = "%",
})
.on_focus([&rightPane](Rml::Event&) {
rightPane.clear();
rightPane.add_text("Adjusts the volume of all sounds in the game.");
});
leftPane.add_section("Effects");
config_bool_select(leftPane, rightPane, getSettings().audio.enableReverb,
{
.key = "Enable Reverb",
.helpText = "Enables the reverb effect in game audio.",
.onChange = [](bool value) { audio::SetEnableReverb(value); },
});
leftPane.add_section("Tweaks");
config_bool_select(leftPane, rightPane, getSettings().game.noLowHpSound,
{
.key = "No Low HP Sound",
.helpText = "Disable the beeping sound when having low health.",
});
config_bool_select(leftPane, rightPane, getSettings().game.midnasLamentNonStop,
{
.key = "Non-Stop Midna's Lament",
.helpText = "Prevents enemy music while Midna's Lament is playing.",
});
});
add_tab("Cheats", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled);
auto& rightPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
auto addCheat = [&](const Rml::String& key, ConfigVar<bool>& value,
const Rml::String& helpText) {
config_bool_select(leftPane, rightPane, value,
{
.key = key,
.helpText = helpText,
.isDisabled = [] { return getSettings().game.speedrunMode; },
});
};
leftPane.add_section("Resources");
addCheat("Infinite Hearts", getSettings().game.infiniteHearts, "Keeps your health full.");
addCheat(
"Infinite Arrows", getSettings().game.infiniteArrows, "Keeps your arrow count full.");
addCheat("Infinite Bombs", getSettings().game.infiniteBombs, "Keeps all bomb bags full.");
addCheat("Infinite Oil", getSettings().game.infiniteOil, "Keeps your lantern oil full.");
addCheat("Infinite Oxygen", getSettings().game.infiniteOxygen,
"Keeps your underwater oxygen meter full.");
addCheat(
"Infinite Rupees", getSettings().game.infiniteRupees, "Keeps your rupee count full.");
addCheat("No Item Timer", getSettings().game.enableIndefiniteItemDrops,
"Item drops such as rupees and hearts will never disappear after they drop.");
leftPane.add_section("Abilities");
addCheat(
"Moon Jump (R+A)", getSettings().game.moonJump, "Hold R and A to rise into the air.");
addCheat("Super Clawshot", getSettings().game.superClawshot,
"Extends clawshot behavior beyond the normal game rules.");
addCheat("Always Greatspin", getSettings().game.alwaysGreatspin,
"Allows the Great Spin attack without requiring full health.");
addCheat("Fast Iron Boots", getSettings().game.enableFastIronBoots,
"Speeds up movement while wearing the Iron Boots.");
addCheat("Can Transform Anywhere", getSettings().game.canTransformAnywhere,
"Allows transforming even if NPCs are looking.");
addCheat("Fast Spinner", getSettings().game.fastSpinner,
"Speeds up Spinner movement while holding R.");
addCheat("Free Magic Armor", getSettings().game.freeMagicArmor,
"Lets the magic armor work without consuming rupees.");
});
add_tab("Gameplay", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled);
auto& rightPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
auto addOption = [&](const Rml::String& key, ConfigVar<bool>& value,
const Rml::String& helpText) {
config_bool_select(leftPane, rightPane, value,
{
.key = key,
.helpText = helpText,
});
};
auto addSpeedrunDisabledOption = [&](const Rml::String& key, ConfigVar<bool>& value,
const Rml::String& helpText) {
config_bool_select(leftPane, rightPane, value,
{
.key = key,
.helpText = helpText,
.isDisabled = [] { return getSettings().game.speedrunMode; },
});
};
leftPane.add_section("General");
addOption("Mirror Mode", getSettings().game.enableMirrorMode,
"Mirrors the world horizontally, matching the Wii version of the game.");
addOption("Disable Main HUD", getSettings().game.disableMainHUD,
"Disables the main HUD of the game.<br/>Useful for recording or a more immersive "
"experience.");
addOption("Restore Wii 1.0 Glitches", getSettings().game.restoreWiiGlitches,
"Restores patched glitches from Wii USA 1.0, the first released version.");
addOption("Enable Rotating Link Doll", getSettings().game.enableLinkDollRotation,
"Enables rotating Link in the collection menu with the C-Stick.");
leftPane.add_section("Difficulty");
leftPane
.add_child<NumberButton>(NumberButton::Props{
.key = "Damage Multiplier",
.getValue = [] { return getSettings().game.damageMultiplier.getValue(); },
.setValue =
[](int value) {
getSettings().game.damageMultiplier.setValue(value);
config::Save();
},
.isDisabled = [] { return getSettings().game.speedrunMode; },
.min = 1,
.max = 8,
.prefix = "x",
})
.on_focus([&rightPane](Rml::Event&) {
rightPane.clear();
rightPane.add_text("Multiplies incoming damage.");
});
addSpeedrunDisabledOption(
"Instant Death", getSettings().game.instantDeath, "Any hit will instantly kill you.");
addSpeedrunDisabledOption("No Heart Drops", getSettings().game.noHeartDrops,
"Hearts will never drop from enemies, pots, and various other places.");
leftPane.add_section("Quality of Life");
addOption("Bigger Wallets", getSettings().game.biggerWallets,
"Wallet sizes are like in the HD version. (500, 1000, 2000)");
addOption("Disable Rupee Cutscenes", getSettings().game.disableRupeeCutscenes,
"Rupees will not play cutscenes after you have collected them the first time.");
addOption("Faster Climbing", getSettings().game.fastClimbing,
"Quicker climbing on ladders and vines like the HD version.");
addOption("Faster Tears of Light", getSettings().game.fastTears,
"Tears of Light dropped by Shadow Insects pop out faster like the HD version.");
addOption("Instant Saves", getSettings().game.instantSaves,
"Skips the delay when writing to the Memory Card.");
addOption("Hold B for Instant Text", getSettings().game.instantText,
"Makes text scroll immediately by holding B.");
addOption("No Climbing Miss Animation", getSettings().game.noMissClimbing,
"Prevents Link from playing a struggle animation when grabbing ledges or climbing on "
"vines.");
addOption("No Rupee Returns", getSettings().game.noReturnRupees,
"Always collect Rupees even if your Wallet is too full.");
addOption("No Sword Recoil", getSettings().game.noSwordRecoil,
"Link will not recoil when his sword hits walls.");
addOption("No 2nd Fish for Cat", getSettings().game.no2ndFishForCat,
"Skip needing to catch a second fish for Sera's cat.");
addOption("Skip TV Settings Screen", getSettings().game.hideTvSettingsScreen,
"Skips the TV calibration screen shown when loading a save.");
addOption("Skip Warning Screen", getSettings().game.skipWarningScreen,
"Skips the warning screen shown when starting the game.");
addOption("Sun's Song (R+X)", getSettings().game.sunsSong,
"Allows Wolf Link to howl and change the time of day.");
addOption("Quick Transform (R+Y)", getSettings().game.enableQuickTransform,
"Transform instantly by pressing R and Y simultaneously.");
leftPane.add_section("Speedrunning");
config_bool_select(leftPane, rightPane, getSettings().game.speedrunMode,
{
.key = "Speedrun Mode",
.helpText =
"Enables speedrunning options while restricting certain gameplay modifiers.",
.onChange = [](bool) { reset_for_speedrun_mode(); },
});
config_bool_select(leftPane, rightPane, getSettings().game.liveSplitEnabled,
{
.key = "LiveSplit Connection",
.helpText = "Connect to LiveSplit server on localhost:16834.",
.onChange =
[](bool enabled) {
if (enabled) {
speedrun::connectLiveSplit();
} else {
speedrun::disconnectLiveSplit();
}
},
.isDisabled = [] { return !getSettings().game.speedrunMode; },
});
});
add_tab("Input", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled);
auto& rightPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
auto addOption = [&](const Rml::String& key, ConfigVar<bool>& value,
const Rml::String& helpText, std::function<bool()> isDisabled = {}) {
config_bool_select(leftPane, rightPane, value,
{
.key = key,
.helpText = helpText,
.isDisabled = std::move(isDisabled),
});
};
leftPane.add_section("Controller");
leftPane.add_button("Configure Controller")
.on_pressed([] { push_document(std::make_unique<ControllerConfigWindow>()); })
.on_focus([&rightPane](Rml::Event&) {
rightPane.clear();
rightPane.add_text("Open controller binding configuration.");
});
leftPane.add_section("Camera");
addOption("Free Camera", getSettings().game.freeCamera,
"Enables twin-stick camera control, letting the C-Stick move the camera vertically as "
"well as horizontally.");
addOption("Invert Camera X Axis", getSettings().game.invertCameraXAxis,
"Invert horizontal camera movement.");
addOption("Invert Camera Y Axis", getSettings().game.invertCameraYAxis,
"Invert vertical camera movement when Free Camera is enabled.",
[] { return !getSettings().game.freeCamera; });
config_percent_select(leftPane, rightPane, getSettings().game.freeCameraSensitivity,
"Free Camera Sensitivity", "Adjusts twin-stick camera sensitivity.", 50, 200, 5,
[] { return !getSettings().game.freeCamera; });
leftPane.add_section("Gyro");
addOption("Gyro Aim", getSettings().game.enableGyroAim,
"Enables gyro aiming on supported controllers while in look mode and while aiming "
"items.");
addOption("Gyro Rollgoal", getSettings().game.enableGyroRollgoal,
"Enables gyro controls for Rollgoal in Hena's Cabin.");
config_percent_select(leftPane, rightPane, getSettings().game.gyroSensitivityY,
"Gyro Pitch Sensitivity", "Controls vertical gyro aiming sensitivity.", 25, 400, 5,
[] { return !gyro_enabled(); });
config_percent_select(leftPane, rightPane, getSettings().game.gyroSensitivityX,
"Gyro Yaw Sensitivity", "Controls horizontal gyro aiming sensitivity.", 25, 400, 5,
[] { return !gyro_enabled(); });
config_percent_select(leftPane, rightPane, getSettings().game.gyroSensitivityRollgoal,
"Rollgoal Sensitivity", "Controls how strongly gyro input tilts the Rollgoal table.",
25, 400, 5, [] { return !getSettings().game.enableGyroRollgoal; });
config_percent_select(leftPane, rightPane, getSettings().game.gyroDeadband, "Gyro Deadband",
"Ignores small gyro movement to reduce drift and jitter.", 0, 50, 1,
[] { return !gyro_enabled(); });
config_percent_select(leftPane, rightPane, getSettings().game.gyroSmoothing,
"Gyro Smoothing", "Higher values smooth gyro input over time.", 0, 100, 1,
[] { return !gyro_enabled(); });
addOption("Invert Gyro Pitch", getSettings().game.gyroInvertPitch,
"Invert vertical gyro aiming.", [] { return !gyro_enabled(); });
addOption("Invert Gyro Yaw", getSettings().game.gyroInvertYaw,
"Invert horizontal gyro aiming.", [] { return !gyro_enabled(); });
leftPane.add_section("Tools");
addOption("Turbo Key", getSettings().game.enableTurboKeybind,
"Hold Tab to increase game speed by up to 4x.",
[] { return getSettings().game.speedrunMode; });
});
add_tab("Graphics", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content, Pane::Type::Controlled);
auto& rightPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
leftPane.add_section("Display");
leftPane.add_button("Toggle Fullscreen").on_pressed([] {
getSettings().video.enableFullscreen.setValue(!getSettings().video.enableFullscreen);
VISetWindowFullscreen(getSettings().video.enableFullscreen);
config::Save();
});
leftPane.add_button("Restore Default Window Size").on_pressed([] {
getSettings().video.enableFullscreen.setValue(false);
VISetWindowFullscreen(false);
VISetWindowSize(FB_WIDTH * 2, FB_HEIGHT * 2);
VICenterWindow();
});
config_bool_select(leftPane, rightPane, getSettings().video.enableVsync,
{
.key = "Enable VSync",
.helpText = "Synchronizes the frame rate to your monitor's refresh rate.",
.onChange = [](bool value) { aurora_enable_vsync(value); },
});
config_bool_select(leftPane, rightPane, getSettings().video.lockAspectRatio,
{
.key = "Lock 4:3 Aspect Ratio",
.helpText = "Lock the game's aspect ratio to the original.",
.onChange =
[](bool value) {
AuroraSetViewportPolicy(
value ? AURORA_VIEWPORT_FIT : AURORA_VIEWPORT_STRETCH);
},
});
leftPane.add_section("Resolution");
leftPane
.add_select_button({
.key = "Internal Resolution",
.getValue =
[] {
return format_graphics_setting_value(GraphicsOption::InternalResolution,
getSettings().game.internalResolutionScale.getValue());
},
})
.on_nav_command([](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm || cmd == NavCommand::Left ||
cmd == NavCommand::Right) {
push_document(std::make_unique<Overlay>(OverlayProps{
.option = GraphicsOption::InternalResolution,
.title = "Internal Resolution",
.helpText = kInternalResolutionHelpText,
.valueMin = 0,
.valueMax = 12,
.defaultValue = 0,
}));
return true;
}
return false;
})
.on_focus([&rightPane](Rml::Event&) {
rightPane.clear();
rightPane.add_text(kInternalResolutionHelpText);
});
leftPane
.add_select_button({
.key = "Shadow Resolution",
.getValue =
[] {
return format_graphics_setting_value(GraphicsOption::ShadowResolution,
getSettings().game.shadowResolutionMultiplier.getValue());
},
})
.on_nav_command([](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm || cmd == NavCommand::Left ||
cmd == NavCommand::Right) {
push_document(std::make_unique<Overlay>(OverlayProps{
.option = GraphicsOption::ShadowResolution,
.title = "Shadow Resolution",
.helpText = kShadowResolutionHelpText,
.valueMin = 1,
.valueMax = 8,
.defaultValue = 1,
}));
return true;
}
return false;
})
.on_focus([&rightPane](Rml::Event&) {
rightPane.clear();
rightPane.add_text(kShadowResolutionHelpText);
});
leftPane.add_section("Post-Processing");
leftPane
.add_select_button({
.key = "Bloom",
.getValue =
[] {
return format_graphics_setting_value(GraphicsOption::BloomMode,
static_cast<int>(getSettings().game.bloomMode.getValue()));
},
})
.on_nav_command([](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm || cmd == NavCommand::Left ||
cmd == NavCommand::Right) {
push_document(std::make_unique<Overlay>(OverlayProps{
.option = GraphicsOption::BloomMode,
.title = "Bloom",
.helpText = kBloomHelpText,
.valueMin = static_cast<int>(BloomMode::Off),
.valueMax = static_cast<int>(BloomMode::Dusk),
.defaultValue = static_cast<int>(BloomMode::Classic),
}));
return true;
}
return false;
})
.on_focus([&rightPane](Rml::Event&) {
rightPane.clear();
rightPane.add_text(kBloomHelpText);
});
leftPane
.add_select_button({
.key = "Bloom Brightness",
.getValue =
[] {
return format_graphics_setting_value(
GraphicsOption::BloomMultiplier, bloom_multiplier_percent());
},
.isDisabled =
[] { return getSettings().game.bloomMode.getValue() == BloomMode::Off; },
})
.on_nav_command([](Rml::Event&, NavCommand cmd) {
if (cmd == NavCommand::Confirm || cmd == NavCommand::Left ||
cmd == NavCommand::Right) {
push_document(std::make_unique<Overlay>(OverlayProps{
.option = GraphicsOption::BloomMultiplier,
.title = "Bloom Brightness",
.helpText = kBloomBrightnessHelpText,
.valueMin = 0,
.valueMax = 100,
.defaultValue = 100,
}));
return true;
}
return false;
})
.on_focus([&rightPane](Rml::Event&) {
rightPane.clear();
rightPane.add_text(kBloomBrightnessHelpText);
});
leftPane.add_section("Rendering");
config_bool_select(leftPane, rightPane, getSettings().game.enableFrameInterpolation,
{
.key = "Unlock Framerate",
.helpText =
"Uses inter-frame interpolation to enable higher frame rates.<br/><br/>Visual "
"artifacts, animation glitches, or instability may occur.",
});
config_bool_select(leftPane, rightPane, getSettings().game.enableDepthOfField,
{
.key = "Enable Depth of Field",
});
config_bool_select(leftPane, rightPane, getSettings().game.enableMapBackground,
{
.key = "Enable Mini-Map Shadows",
});
});
}
} // namespace dusk::ui
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "window.hpp"
namespace dusk::ui {
class SettingsWindow : public Window {
public:
SettingsWindow();
};
} // namespace dusk::ui
+137
View File
@@ -0,0 +1,137 @@
#include "string_button.hpp"
#include <aurora/rmlui.hpp>
namespace dusk::ui {
BaseStringButton::BaseStringButton(Rml::Element* parent, Props props)
: BaseControlledSelectButton(parent, {std::move(props.key)}), mType(std::move(props.type)),
mMaxLength(props.maxLength) {
mInputListeners.reserve(3);
}
void BaseStringButton::update() {
if (mPendingStopEditing) {
stop_editing(mPendingCommit, mPendingRefocusRoot);
}
if (mPendingInputFocusFrames > 0) {
--mPendingInputFocusFrames;
if (mPendingInputFocusFrames == 0) {
focus_input();
}
}
BaseControlledSelectButton::update();
}
void BaseStringButton::start_editing() {
if (mInputElem != nullptr) {
return;
}
// Create input element
auto* doc = mRoot->GetOwnerDocument();
auto elemPtr = doc->CreateElement("input");
mInputElem = rmlui_dynamic_cast<Rml::ElementFormControlInput*>(elemPtr.get());
if (mInputElem == nullptr) {
return;
}
mInputElem->SetAttribute("type", mType);
mInputElem->SetAttribute("value", input_value());
if (mMaxLength > -1) {
mInputElem->SetAttribute("maxlength", mMaxLength);
}
mRoot->AppendChild(std::move(elemPtr));
// Hide value element
mValueElem->SetProperty(Rml::PropertyId::Visibility, Rml::Style::Visibility::Hidden);
// RmlUi lays out the new input during render. Wait one full frame before focusing it so
// mobile keyboard placement gets a valid caret rectangle.
mPendingInputFocusFrames = 2;
// Dispatch a submit event so the pane can handle item selection
// However, mark it as "handled" to ensure that we don't steal focus away
mRoot->DispatchEvent(Rml::EventId::Submit, {{"handled", Rml::Variant{true}}});
// Register input listeners
mInputListeners.emplace_back(std::make_unique<ScopedEventListener>(
mInputElem, Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd == NavCommand::Confirm) {
request_stop_editing(true, true);
event.StopImmediatePropagation();
} else if (cmd == NavCommand::Cancel) {
request_stop_editing(false, true);
event.StopImmediatePropagation();
}
}));
mInputListeners.emplace_back(std::make_unique<ScopedEventListener>(
mInputElem, Rml::EventId::Click, [](Rml::Event& event) { event.StopPropagation(); }));
mInputListeners.emplace_back(std::make_unique<ScopedEventListener>(mInputElem,
Rml::EventId::Blur, [this](Rml::Event&) { request_stop_editing(true, false); }));
}
void BaseStringButton::request_stop_editing(bool commit, bool refocusRoot) {
mPendingStopEditing = true;
mPendingCommit = commit;
mPendingRefocusRoot = refocusRoot;
}
bool BaseStringButton::handle_nav_command(NavCommand cmd) {
if (cmd == NavCommand::Confirm) {
if (mInputElem == nullptr) {
start_editing();
} else {
request_stop_editing(true, true);
}
return true;
} else if (cmd == NavCommand::Cancel) {
if (mInputElem != nullptr) {
request_stop_editing(false, true);
return true;
}
}
return false;
}
void BaseStringButton::focus_input() {
if (mInputElem == nullptr) {
return;
}
aurora::rmlui::set_input_type(
mType == "number" ? aurora::rmlui::InputType::Number : aurora::rmlui::InputType::Text);
if (mInputElem->Focus(true)) {
const int end = static_cast<int>(Rml::StringUtilities::LengthUTF8(mInputElem->GetValue()));
mInputElem->SetSelectionRange(0, end);
}
}
void BaseStringButton::stop_editing(bool commit, bool refocusRoot) {
mPendingStopEditing = false;
mPendingInputFocusFrames = 0;
if (mInputElem == nullptr) {
return;
}
if (commit) {
set_value(mInputElem->GetValue());
}
mInputListeners.clear();
mRoot->RemoveChild(mInputElem);
mInputElem = nullptr;
// Restore value element
mValueElem->SetProperty(Rml::PropertyId::Visibility, Rml::Style::Visibility::Visible);
set_selected(false);
if (refocusRoot) {
mRoot->Focus(true);
}
}
StringButton::StringButton(Rml::Element* parent, Props props)
: BaseStringButton(parent, {.key = std::move(props.key), .maxLength = props.maxLength}),
mGetValue(std::move(props.getValue)), mSetValue(std::move(props.setValue)) {}
} // namespace dusk::ui
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include "select_button.hpp"
#include <RmlUi/Config/Config.h>
namespace dusk::ui {
class BaseStringButton : public BaseControlledSelectButton {
public:
struct Props {
Rml::String key;
Rml::String type = "text";
int maxLength = -1;
};
BaseStringButton(Rml::Element* parent, Props props);
void update() override;
void start_editing();
void request_stop_editing(bool commit, bool refocusRoot);
protected:
bool handle_nav_command(NavCommand cmd) override;
virtual void set_value(Rml::String value) = 0;
virtual Rml::String input_value() { return format_value(); }
private:
void focus_input();
void stop_editing(bool commit = true, bool refocusRoot = false);
Rml::ElementFormControlInput* mInputElem = nullptr;
std::vector<std::unique_ptr<ScopedEventListener> > mInputListeners;
Rml::String mType;
int mMaxLength;
int mPendingInputFocusFrames = 0;
bool mPendingStopEditing = false;
bool mPendingCommit = true;
bool mPendingRefocusRoot = false;
};
class StringButton : public BaseStringButton {
public:
struct Props {
Rml::String key;
std::function<Rml::String()> getValue;
std::function<void(Rml::String)> setValue;
int maxLength = -1;
};
StringButton(Rml::Element* parent, Props props);
protected:
Rml::String format_value() override { return mGetValue(); }
void set_value(Rml::String value) override {
if (mSetValue) {
mSetValue(std::move(value));
}
}
private:
std::function<Rml::String()> mGetValue;
std::function<void(Rml::String)> mSetValue;
};
} // namespace dusk::ui
+128
View File
@@ -0,0 +1,128 @@
#include "tab_bar.hpp"
namespace dusk::ui {
namespace {
Rml::Element* createRoot(Rml::Element* parent) {
auto* doc = parent->GetOwnerDocument();
auto elem = doc->CreateElement("tab-bar");
return parent->AppendChild(std::move(elem));
}
} // namespace
TabBar::TabBar(Rml::Element* parent, Props props)
: FluentComponent(createRoot(parent)), mProps(std::move(props)) {
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd != NavCommand::None && handle_nav_command(event, cmd)) {
event.StopPropagation();
}
});
}
bool TabBar::focus() {
if (mProps.selectedTabIndex >= 0 && mProps.selectedTabIndex < mTabs.size()) {
// Try to focus the currently selected tab
if (mTabs[mProps.selectedTabIndex].button.focus()) {
return true;
}
}
// Otherwise, focus the first enabled tab
for (const auto& tab : mTabs) {
if (tab.button.focus()) {
return true;
}
}
return false;
}
void TabBar::add_tab(const Rml::String& title, TabCallback callback) {
const int index = static_cast<int>(mTabs.size());
const bool selected = index == mProps.selectedTabIndex;
if (selected && callback) {
callback();
}
auto& button = add_child<Button>(Button::Props{title}, "tab");
button.on_pressed([this, index] { set_active_tab(index); });
if (selected) {
button.set_selected(true);
}
mTabs.emplace_back(Tab{
.title = title,
.button = button,
.callback = std::move(callback),
});
}
bool TabBar::set_active_tab(int index) {
if (index == -1) {
// Clear currently selected tab
for (int i = 0; i < static_cast<int>(mTabs.size()); ++i) {
mTabs[i].button.set_selected(false);
}
mProps.selectedTabIndex = -1;
return true;
}
if (index < 0 || index >= mTabs.size() || index == mProps.selectedTabIndex) {
return false;
}
const auto& tab = mTabs[index];
if (tab.button.focus()) {
for (int i = 0; i < static_cast<int>(mTabs.size()); ++i) {
mTabs[i].button.set_selected(i == index);
}
mProps.selectedTabIndex = index;
if (tab.callback) {
tab.callback();
}
return true;
}
return false;
}
bool TabBar::focus_tab(int index) {
if (index < 0 || index >= mTabs.size() || index == mProps.selectedTabIndex) {
return false;
}
return mTabs[index].button.focus();
}
int TabBar::tab_containing(Rml::Element* element) const {
for (int i = 0; i < mTabs.size(); ++i) {
if (mTabs[i].button.contains(element)) {
return i;
}
}
return -1;
}
bool TabBar::handle_nav_command(Rml::Event& event, NavCommand cmd) {
if (cmd == NavCommand::Left || cmd == NavCommand::Right || cmd == NavCommand::Next ||
cmd == NavCommand::Previous)
{
bool isNext = cmd == NavCommand::Right || cmd == NavCommand::Next;
int currentComponent = tab_containing(event.GetTargetElement());
int direction = isNext ? 1 : -1;
int i = currentComponent + direction;
if (currentComponent == -1) {
// If the container itself is focused and right is pressed, focus the first element
if (isNext) {
i = 0;
} else {
// Otherwise, allow event to bubble
return false;
}
}
while (i >= 0 && i < mTabs.size()) {
if (mProps.autoSelect ? set_active_tab(i) : focus_tab(i)) {
return true;
}
i += direction;
}
}
return false;
}
} // namespace dusk::ui
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include "button.hpp"
#include "component.hpp"
#include "select_button.hpp"
namespace dusk::ui {
using TabCallback = std::function<void()>;
struct Tab {
Rml::String title;
Button& button;
TabCallback callback;
};
class TabBar : public FluentComponent<TabBar> {
public:
struct Props {
std::function<void()> onClose;
int selectedTabIndex = -1;
bool autoSelect = true;
};
explicit TabBar(Rml::Element* parent, Props props);
bool focus() override;
void add_tab(const Rml::String& title, TabCallback callback);
bool set_active_tab(int index);
bool focus_tab(int index);
bool handle_nav_command(Rml::Event& event, NavCommand cmd);
private:
int tab_containing(Rml::Element* element) const;
Props mProps;
std::vector<Tab> mTabs;
};
} // namespace dusk::ui
+217
View File
@@ -0,0 +1,217 @@
#include "ui.hpp"
#include <RmlUi/Core.h>
#include <SDL3/SDL_filesystem.h>
#include <aurora/rmlui.hpp>
#include <algorithm>
#include <filesystem>
#include "aurora/lib/window.hpp"
#include "input.hpp"
#include "window.hpp"
namespace dusk::ui {
namespace {
void load_font(const char* filename, bool fallback = false) {
Rml::LoadFontFace(resource_path(filename).string(), fallback);
}
bool sInitialized = false;
struct OpenDocument {
std::unique_ptr<Document> doc;
bool pendingDestroy = false;
};
std::vector<OpenDocument> sDocuments;
} // namespace
bool initialize() noexcept {
if (sInitialized) {
return true;
}
if (!aurora::rmlui::is_initialized()) {
return false;
}
load_font("FiraSans-Regular.ttf", true);
load_font("FiraSansCondensed-Regular.ttf");
load_font("FiraSansCondensed-Bold.ttf");
load_font("AlegreyaSC-Regular.ttf");
load_font("AlegreyaSC-Bold.ttf");
sInitialized = true;
return true;
}
void shutdown() noexcept {
sDocuments.clear();
reset_input_state();
release_input_block();
sInitialized = false;
}
Document& push_document(std::unique_ptr<Document> doc, bool show) noexcept {
if (auto* doc = top_document()) {
doc->hide();
}
Document& ret = *doc;
sDocuments.push_back({std::move(doc)});
if (show) {
ret.show();
}
sync_input_block();
return ret;
}
void pop_document() noexcept {
for (auto it = sDocuments.rbegin(); it != sDocuments.rend(); ++it) {
if (!it->pendingDestroy) {
it->doc->hide();
it->pendingDestroy = true;
break;
}
}
if (auto* doc = top_document()) {
doc->show();
}
sync_input_block();
}
void toggle_top_document() noexcept {
auto* doc = top_document();
if (doc == nullptr) {
return;
}
if (doc->visible()) {
doc->hide();
} else {
doc->show();
}
sync_input_block();
}
bool any_document_visible() noexcept {
return std::any_of(sDocuments.begin(), sDocuments.end(),
[](const OpenDocument& doc) { return doc.doc != nullptr && doc.doc->visible(); });
}
Document* top_document() noexcept {
for (auto it = sDocuments.rbegin(); it != sDocuments.rend(); ++it) {
if (!it->pendingDestroy) {
return it->doc.get();
}
}
return nullptr;
}
void update() noexcept {
update_input();
for (const auto& doc : sDocuments) {
doc.doc->update();
}
sDocuments.erase(
std::remove_if(sDocuments.begin(), sDocuments.end(),
[](const OpenDocument& doc) { return doc.pendingDestroy && doc.doc->can_destroy(); }),
sDocuments.end());
sync_input_block();
}
std::filesystem::path resource_path(const std::filesystem::path& filename) noexcept {
const char* basePath = SDL_GetBasePath();
if (basePath == nullptr) {
return std::filesystem::path("res") / filename;
}
return std::filesystem::path(basePath) / "res" / filename;
}
std::string escape(std::string_view str) noexcept {
std::string result;
result.reserve(str.size());
for (const char c : str) {
switch (c) {
case '&':
result += "&amp;";
break;
case '<':
result += "&lt;";
break;
case '>':
result += "&gt;";
break;
case '"':
result += "&quot;";
break;
default:
result += c;
break;
}
}
return result;
}
NavCommand map_nav_event(const Rml::Event& event) noexcept {
const auto key = static_cast<Rml::Input::KeyIdentifier>(
event.GetParameter<int>("key_identifier", Rml::Input::KI_UNKNOWN));
switch (key) {
case Rml::Input::KeyIdentifier::KI_UP:
return NavCommand::Up;
case Rml::Input::KeyIdentifier::KI_DOWN:
return NavCommand::Down;
case Rml::Input::KeyIdentifier::KI_LEFT:
return NavCommand::Left;
case Rml::Input::KeyIdentifier::KI_RIGHT:
return NavCommand::Right;
case Rml::Input::KeyIdentifier::KI_ESCAPE:
return NavCommand::Cancel;
case Rml::Input::KeyIdentifier::KI_RETURN:
return NavCommand::Confirm;
case Rml::Input::KeyIdentifier::KI_F1:
return event.GetParameter<int>("shift_key", 0) ? NavCommand::None : NavCommand::Menu;
case Rml::Input::KeyIdentifier::KI_NEXT:
return NavCommand::Next;
case Rml::Input::KeyIdentifier::KI_PRIOR:
return NavCommand::Previous;
default:
return NavCommand::None;
}
}
Insets safe_area_insets(Rml::Context* context) noexcept {
if (context == nullptr) {
return {};
}
auto* window = aurora::window::get_sdl_window();
if (window == nullptr) {
return {};
}
const AuroraWindowSize windowSize = aurora::window::get_window_size();
if (windowSize.width == 0 || windowSize.height == 0) {
return {};
}
SDL_Rect safeRect{};
if (!SDL_GetWindowSafeArea(window, &safeRect)) {
return {};
}
const Rml::Vector2i contextSize = context->GetDimensions();
const float scaleX = static_cast<float>(contextSize.x) / static_cast<float>(windowSize.width);
const float scaleY = static_cast<float>(contextSize.y) / static_cast<float>(windowSize.height);
const float safeRight = static_cast<float>(safeRect.x + safeRect.w);
const float safeBottom = static_cast<float>(safeRect.y + safeRect.h);
return {
.top = std::max(0.0f, static_cast<float>(safeRect.y)) * scaleY,
.right = std::max(0.0f, static_cast<float>(windowSize.width) - safeRight) * scaleX,
.bottom = std::max(0.0f, static_cast<float>(windowSize.height) - safeBottom) * scaleY,
.left = std::max(0.0f, static_cast<float>(safeRect.x)) * scaleX,
};
}
} // namespace dusk::ui
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <RmlUi/Core.h>
#include <SDL3/SDL_events.h>
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
#include "nav_types.hpp"
namespace dusk::ui {
class Document;
class Popup;
struct Insets {
float top = 0.0f;
float right = 0.0f;
float bottom = 0.0f;
float left = 0.0f;
bool operator==(const Insets& other) const noexcept {
return top == other.top && right == other.right && bottom == other.bottom &&
left == other.left;
}
};
bool initialize() noexcept;
void shutdown() noexcept;
void handle_event(const SDL_Event& event) noexcept;
void update() noexcept;
Document& push_document(std::unique_ptr<Document> doc, bool show = true) noexcept;
void pop_document() noexcept;
void toggle_top_document() noexcept;
bool any_document_visible() noexcept;
Document* top_document() noexcept;
Popup& add_popup(std::unique_ptr<Popup> popup) noexcept;
std::filesystem::path resource_path(const std::filesystem::path& filename) noexcept;
std::string escape(std::string_view str) noexcept;
NavCommand map_nav_event(const Rml::Event& event) noexcept;
Insets safe_area_insets(Rml::Context* context) noexcept;
} // namespace dusk::ui
+239
View File
@@ -0,0 +1,239 @@
#include "window.hpp"
#include "aurora/lib/window.hpp"
#include "aurora/rmlui.hpp"
#include "magic_enum.hpp"
#include "pane.hpp"
#include "ui.hpp"
#include <algorithm>
#include <cmath>
namespace dusk::ui {
namespace {
float base_body_padding(Rml::Context* context) noexcept {
if (context == nullptr) {
return 64.0f;
}
const float dpRatio = std::max(context->GetDensityIndependentPixelRatio(), 0.001f);
const float heightDp = static_cast<float>(context->GetDimensions().y) / dpRatio;
if (heightDp <= 640.0f) {
return 16.0f * dpRatio;
}
return 64.0f * dpRatio;
}
const Rml::String kDocumentSource = R"RML(
<rml>
<head>
<link type="text/rcss" href="res/rml/tabbing.rcss" />
<link type="text/rcss" href="res/rml/window.rcss" />
</head>
<body>
<window id="window"></window>
</body>
</rml>
)RML";
} // namespace
Window::Window() : Document(kDocumentSource), mRoot(mDocument->GetElementById("window")) {
mTabBar = std::make_unique<TabBar>(mRoot, TabBar::Props{
.selectedTabIndex = 0,
.autoSelect = true,
});
auto elem = mDocument->CreateElement("content");
elem->SetAttribute("id", "content");
mContentRoot = mRoot->AppendChild(std::move(elem));
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
// 1-9 for quick switching tabs
const auto key = static_cast<Rml::Input::KeyIdentifier>(
event.GetParameter<int>("key_identifier", Rml::Input::KI_UNKNOWN));
if (key >= Rml::Input::KeyIdentifier::KI_1 && key <= Rml::Input::KeyIdentifier::KI_9) {
if (set_active_tab(key - Rml::Input::KeyIdentifier::KI_1)) {
if (!mContentComponents.empty()) {
mContentComponents.front()->focus();
}
event.StopPropagation();
}
}
});
// Hide document after transition completion
listen(mRoot, Rml::EventId::Transitionend, [this](Rml::Event& event) {
if (event.GetTargetElement() == mRoot &&
*mRoot->GetProperty(Rml::PropertyId::Visibility) == Rml::Style::Visibility::Visible &&
!mRoot->HasAttribute("open"))
{
Document::hide();
}
});
// If an item is selected in a pane, focus the next pane in the tree
listen(mRoot, Rml::EventId::Submit, [this](Rml::Event& event) {
int paneIndex = -1;
for (int i = 0; i < mContentComponents.size(); i++) {
if (mContentComponents[i]->contains(event.GetTargetElement())) {
paneIndex = i;
break;
}
}
if (paneIndex >= 0 && paneIndex < mContentComponents.size() - 1) {
mContentComponents[paneIndex + 1]->focus();
}
});
}
void Window::show() {
Document::show();
mRoot->SetAttribute("open", "");
}
void Window::hide() {
mRoot->RemoveAttribute("open");
// Document will be hidden after transition
}
void Window::update() {
update_safe_area();
for (const auto& component : mContentComponents) {
component->update();
}
Document::update();
}
void Window::update_safe_area() noexcept {
if (mDocument == nullptr) {
return;
}
Rml::Context* context = mDocument->GetContext();
const float basePadding = base_body_padding(context);
Insets safeInsets = safe_area_insets(context);
safeInsets = {
std::round(std::max(basePadding, safeInsets.top)),
std::round(std::max(basePadding, safeInsets.right)),
std::round(std::max(basePadding, safeInsets.bottom)),
std::round(std::max(basePadding, safeInsets.left)),
};
if (safeInsets == mBodyPadding) {
return;
}
mBodyPadding = safeInsets;
mDocument->SetProperty(
Rml::PropertyId::PaddingTop, Rml::Property(safeInsets.top, Rml::Unit::PX));
mDocument->SetProperty(
Rml::PropertyId::PaddingRight, Rml::Property(safeInsets.right, Rml::Unit::PX));
mDocument->SetProperty(
Rml::PropertyId::PaddingBottom, Rml::Property(safeInsets.bottom, Rml::Unit::PX));
mDocument->SetProperty(
Rml::PropertyId::PaddingLeft, Rml::Property(safeInsets.left, Rml::Unit::PX));
}
bool Window::set_active_tab(int index) {
return mTabBar->set_active_tab(index);
}
void Window::add_tab(const Rml::String& title, TabBuilder builder) {
mTabBar->add_tab(title, [this, builder = std::move(builder)] {
clear_content();
if (builder) {
builder(mContentRoot);
}
});
}
void Window::clear_content() noexcept {
mContentComponents.clear();
while (mContentRoot->GetNumChildren() != 0) {
mContentRoot->RemoveChild(mContentRoot->GetFirstChild());
}
}
bool Window::focus() {
return mTabBar->focus();
}
bool Window::visible() const {
return mRoot->HasAttribute("open");
}
bool Window::handle_nav_command(Rml::Event& event, NavCommand cmd) {
auto* target = event.GetTargetElement();
if (cmd != NavCommand::Next && cmd != NavCommand::Previous && target->Closest("content")) {
if (handle_content_nav(event, cmd)) {
return true;
}
}
if (cmd == NavCommand::Confirm || cmd == NavCommand::Down) {
if (!mContentComponents.empty()) {
return mContentComponents.front()->focus();
}
}
if (cmd == NavCommand::Cancel) {
pop_document();
return true;
}
if (mTabBar->handle_nav_command(event, cmd)) {
return true;
}
return Document::handle_nav_command(event, cmd);
}
bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
if (cmd == NavCommand::Up) {
return focus();
} else if (cmd == NavCommand::Cancel) {
int currentComponent = -1;
for (int i = 0; i < mContentComponents.size(); ++i) {
if (mContentComponents[i]->contains(event.GetTargetElement())) {
currentComponent = i;
break;
}
}
for (; currentComponent > 0; --currentComponent) {
if (mContentComponents[currentComponent - 1]->focus()) {
// When returning to a previous pane, deselect the item after focusing
if (auto* pane =
dynamic_cast<Pane*>(mContentComponents[currentComponent - 1].get()))
{
pane->set_selected_item(-1);
}
return true;
}
}
return focus();
} else if (cmd == NavCommand::Left || cmd == NavCommand::Right) {
int currentComponent = -1;
for (int i = 0; i < mContentComponents.size(); ++i) {
if (mContentComponents[i]->contains(event.GetTargetElement())) {
currentComponent = i;
break;
}
}
int direction = cmd == NavCommand::Right ? 1 : -1;
int i = currentComponent + direction;
if (currentComponent == -1) {
if (cmd == NavCommand::Right) {
return mContentComponents.front()->focus();
}
} else if (i >= 0 && i < mContentComponents.size()) {
if (mContentComponents[i]->focus()) {
if (direction == -1) {
// When returning to a previous pane, deselect the item after focusing
if (auto* pane = dynamic_cast<Pane*>(mContentComponents[i].get())) {
pane->set_selected_item(-1);
}
}
return true;
}
}
}
return false;
}
} // namespace dusk::ui
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include "button.hpp"
#include "component.hpp"
#include "document.hpp"
#include "nav_types.hpp"
#include "tab_bar.hpp"
#include "ui.hpp"
namespace dusk::ui {
class Window : public Document {
public:
using TabBuilder = std::function<void(Rml::Element*)>;
struct Tab {
Rml::String title;
std::unique_ptr<Button> button;
TabBuilder builder;
};
Window();
Window(const Window&) = delete;
Window& operator=(const Window&) = delete;
void show() override;
void hide() override;
void update() override;
bool focus() override;
bool visible() const override;
bool set_active_tab(int index);
protected:
void add_tab(const Rml::String& title, TabBuilder builder);
void update_safe_area() noexcept;
void clear_content() noexcept;
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
bool handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept;
template <typename T, typename... Args>
requires std::is_base_of_v<Component, T> T& add_child(Args&&... args) {
auto child = std::make_unique<T>(std::forward<Args>(args)...);
T& ref = *child;
mContentComponents.emplace_back(std::move(child));
return ref;
}
Rml::Element* mRoot;
Rml::Element* mContentRoot;
std::unique_ptr<TabBar> mTabBar;
std::vector<std::unique_ptr<Component> > mContentComponents;
Insets mBodyPadding;
bool mVisible = false;
};
} // namespace dusk::ui
+20 -2
View File
@@ -56,6 +56,11 @@
#include "dusk/logging.h"
#include "dusk/main.h"
#include "dusk/imgui/ImGuiConsole.hpp"
#include "dusk/ui/ui.hpp"
#include "dusk/ui/editor.hpp"
#include "dusk/ui/popup.hpp"
#include "dusk/ui/prelaunch.hpp"
#include "dusk/ui/settings.hpp"
#include "version.h"
#include <aurora/aurora.h>
@@ -70,13 +75,13 @@
#include "dusk/audio/DuskAudioSystem.h"
#include "dusk/audio/DuskDsp.hpp"
#include "dusk/config.hpp"
#include "dusk/imgui/ImGuiConsole.hpp"
#include "dusk/settings.h"
#include "dusk/version.hpp"
#include "dusk/discord_presence.hpp"
#include "tracy/Tracy.hpp"
#include "f_pc/f_pc_draw.h"
#include "tracy/Tracy.hpp"
#include <RmlUi/Core.h>
// --- GLOBALS ---
s8 mDoMain::developmentMode = -1;
@@ -140,6 +145,7 @@ bool launchUILoop() {
while (event != nullptr && event->type != AURORA_NONE) {
switch (event->type) {
case AURORA_SDL_EVENT:
dusk::ui::handle_event(event->sdl);
dusk::g_imguiConsole.HandleSDLEvent(event->sdl);
break;
case AURORA_DISPLAY_SCALE_CHANGED:
@@ -157,8 +163,9 @@ bool launchUILoop() {
continue;
}
dusk::g_imguiConsole.PreDraw();
dusk::ui::update();
dusk::g_imguiConsole.PreDraw();
dusk::g_imguiConsole.PostDraw();
aurora_end_frame();
@@ -215,6 +222,7 @@ void main01(void) {
case AURORA_NONE:
goto eventsDone;
case AURORA_SDL_EVENT:
dusk::ui::handle_event(event->sdl);
dusk::g_imguiConsole.HandleSDLEvent(event->sdl);
if (event->sdl.type == SDL_EVENT_WINDOW_FOCUS_LOST &&
dusk::getSettings().game.pauseOnFocusLost) {
@@ -254,6 +262,8 @@ void main01(void) {
mDoGph_gInf_c::updateRenderSize();
dusk::ui::update();
const auto pacing = dusk::game_clock::advance_main_loop();
if (pacing.is_interpolating) {
if (pacing.sim_ticks_to_run > 0) {
@@ -305,6 +315,7 @@ void main01(void) {
} while (dusk::IsRunning);
exit:;
dusk::ui::shutdown();
}
static bool IsBackendAvailable(AuroraBackend backend) {
@@ -580,6 +591,8 @@ int game_main(int argc, char* argv[]) {
dusk::audio::SetEnableReverb(dusk::getSettings().audio.enableReverb);
dusk::audio::EnableHrtf = dusk::getSettings().audio.enableHrtf;
dusk::ui::initialize();
std::string dvd_path;
bool dvd_opened = false;
if (parsed_arg_options.count("dvd")) {
@@ -596,6 +609,8 @@ int game_main(int argc, char* argv[]) {
}
if (!dvd_opened) {
dusk::ui::push_document(std::make_unique<dusk::ui::Prelaunch>(), true);
// pre game launch ui main loop
if (!launchUILoop()) {
dusk::ShutdownCrashReporting();
@@ -617,6 +632,8 @@ int game_main(int argc, char* argv[]) {
}
}
dusk::ui::push_document(std::make_unique<dusk::ui::Popup>(), false);
dusk::version::init();
LanguageInit();
@@ -657,6 +674,7 @@ int game_main(int argc, char* argv[]) {
#ifdef DUSK_DISCORD_RPC
dusk::discord::Shutdown();
#endif
dusk::ui::shutdown();
aurora_shutdown();
return 0;