Begin scaffolding keyboard nav

This commit is contained in:
Luke Street
2026-04-29 15:19:15 -06:00
parent 3cb7fbd030
commit d92515f0d4
15 changed files with 409 additions and 90 deletions
+16 -8
View File
@@ -13,6 +13,11 @@ body {
color: #E0DBC8;
}
button {
cursor: pointer;
focus: auto;
}
.window {
max-width: 1088dp;
max-height: 768dp;
@@ -40,22 +45,25 @@ body {
padding: 0 24dp;
line-height: 64dp;
opacity: 0.25;
tab-index: auto;
nav: horizontal;
focus: auto;
decorator: vertical-gradient(#c2a42d00 #c2a42d00);
transition: decorator 0.1s linear-in-out, opacity 0.1s linear-in-out;
}
.window .tab-bar .tab.selected {
opacity: 1;
border-bottom: 4dp #C2A42D;
font-effect: glow(0dp 4dp 0dp 4dp black);
decorator: linear-gradient(to bottom, rgba(194, 164, 45, 0%) 0%, rgba(194, 164, 45, 15%) 100%);
}
.window .tab-bar .tab:focus-visible {
.window .tab-bar .tab:focus-visible,
.window .tab-bar .tab:hover {
opacity: 1;
font-effect: glow(0dp 4dp 0dp 4dp black);
decorator: linear-gradient(to bottom, rgba(194, 164, 45, 0%) 0%, rgba(194, 164, 45, 15%) 100%);
decorator: vertical-gradient(#c2a42d00 #c2a42d26);
}
.window .tab-bar .tab:active {
decorator: vertical-gradient(#c2a42d10 #c2a42d40);
}
.window .content {
@@ -112,7 +120,7 @@ body {
transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out;
}
.button.active, .button:hover {
.button.active, .button:hover, .button:focus-visible {
background-color: rgba(204, 184, 119, 20%);
box-shadow: #C2A42D 0 0 0 2dp;
}
@@ -135,7 +143,7 @@ body {
transition: background-color 0.1s linear-in-out, opacity 0.1s linear-in-out;
}
.select-button.active, .select-button:hover {
.select-button.active, .select-button:hover, .select-button:focus-visible {
background-color: rgba(204, 184, 119, 20%);
box-shadow: #C2A42D 0 0 0 2dp;
}
+9
View File
@@ -24,6 +24,15 @@ Button::Button(Rml::Element* parent, ButtonProps props, const Rml::String& class
mProps.onPressed(event);
}
});
listen(mRoot, Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd == NavCommand::Confirm) {
if (mProps.onPressed) {
mProps.onPressed(event);
}
event.StopPropagation();
}
});
}
void Button::set_text(const Rml::String& text) {
+23
View File
@@ -15,6 +15,20 @@ void Component::update() {
}
}
bool Component::focus() {
// Can we focus self?
if (mRoot->Focus(true)) {
return true;
}
// Otherwise, try to focus a child
for (const auto& child : mChildren) {
if (child->focus()) {
return true;
}
}
return false;
}
Rml::Element* Component::append(Rml::Element* parent, const Rml::String& tag) {
if (parent == nullptr) {
return nullptr;
@@ -35,6 +49,15 @@ void Component::listen(Rml::Element* element, Rml::EventId event,
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) {
+8 -6
View File
@@ -23,15 +23,11 @@ public:
Component& operator=(const Component&) = delete;
virtual void update();
virtual bool focus();
void listen(Rml::Element* element, Rml::EventId event, ScopedEventListener::Callback callback,
bool capture = false);
Rml::Element* root() const { return mRoot; }
protected:
static Rml::Element* append(Rml::Element* parent, const Rml::String& tag);
void clear_children();
bool contains(Rml::Element* element) const;
template <typename T, typename... Args>
requires std::is_base_of_v<Component, T> T& add_child(Args&&... args) {
@@ -41,6 +37,12 @@ protected:
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;
+2 -2
View File
@@ -173,7 +173,7 @@ bool handle_editor_action(const Rml::VariantList& arguments) {
EditorWindow::EditorWindow() {
add_tab("Player Status", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content);
auto& leftPane = add_child<Pane>(content, Pane::Direction::Vertical);
leftPane.add_section("Player");
leftPane.add_select_button({
.key = "Player Name",
@@ -207,7 +207,7 @@ EditorWindow::EditorWindow() {
.selected = false,
});
auto& rightPane = add_child<Pane>(content);
auto& rightPane = add_child<Pane>(content, Pane::Direction::Vertical);
rightPane.add_button({
.text = "Hello, world!",
});
+1
View File
@@ -13,6 +13,7 @@ ScopedEventListener::ScopedEventListener(
ScopedEventListener::~ScopedEventListener() {
if (mElement != nullptr) {
mElement->RemoveEventListener(mEvent, this, mCapture);
mElement = nullptr;
}
}
+5
View File
@@ -14,6 +14,11 @@ public:
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;
+54 -3
View File
@@ -5,16 +5,67 @@
namespace dusk::ui {
namespace {
Rml::Element* createRoot(Rml::Element* parent) {
Rml::Element* createRoot(Rml::Element* parent, const Rml::String& className) {
auto* doc = parent->GetOwnerDocument();
auto elem = doc->CreateElement("div");
elem->SetClass("pane", true);
elem->SetClass(className, true);
return parent->AppendChild(std::move(elem));
}
} // namespace
Pane::Pane(Rml::Element* parent) : Component(createRoot(parent)) {}
Pane::Pane(Rml::Element* parent, Direction direction, const Rml::String& className)
: Component(createRoot(parent, className)), mDirection(direction) {
listen(mRoot, Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
int direction = 0;
if ((mDirection == Direction::Vertical && cmd == NavCommand::Down) ||
(mDirection == Direction::Horizontal && cmd == NavCommand::Right))
{
direction = 1;
} else if ((mDirection == Direction::Vertical && cmd == NavCommand::Up) ||
(mDirection == Direction::Horizontal && cmd == NavCommand::Left))
{
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;
}
}
int i = focusedChild + direction;
if (focusedChild == -1) {
// If the container itself is focused and next is pressed, focus the first element
if (direction == 1) {
i = 0;
} else {
// Otherwise, allow event to bubble
return;
}
}
while (i >= 0 && i < static_cast<int>(mChildren.size())) {
if (mChildren[i]->focus()) {
event.StopPropagation();
break;
}
i += direction;
}
});
}
bool Pane::focus() {
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");
+11 -1
View File
@@ -8,7 +8,14 @@ namespace dusk::ui {
class Pane : public Component {
public:
explicit Pane(Rml::Element* parent);
enum class Direction {
Vertical,
Horizontal,
};
explicit Pane(Rml::Element* parent, Direction direction, const Rml::String& className = "pane");
bool focus() override;
Rml::Element* add_section(const Rml::String& text);
Button& add_button(Button::Props props) { return add_child<Button>(mRoot, std::move(props)); }
@@ -17,6 +24,9 @@ public:
}
Rml::Element* add_text(const Rml::String& text);
void clear();
private:
Direction mDirection;
};
} // namespace dusk::ui
+107 -58
View File
@@ -6,13 +6,73 @@
#include "dusk/audio/DuskAudioSystem.h"
#include "dusk/config.hpp"
#include "pane.hpp"
#include "ui.hpp"
namespace dusk::ui {
template <typename T>
struct ConfigProps {
Rml::String key;
ConfigVar<T>* value;
std::function<void(T)> onChange;
Rml::String helpText;
Pane* rightPane = nullptr;
bool selected = false;
};
class ConfigBoolSelect : public SelectButton {
public:
using Props = ConfigProps<bool>;
ConfigBoolSelect(Rml::Element* parent, Props props)
: SelectButton(
parent, {
.key = std::move(props.key),
.getValue = [this] { return mValue->getValue() ? "On" : "Off"; },
.onPressed = [this](SelectButton&, Rml::Event&) { toggle_value(); },
.selected = props.selected,
}),
mValue(props.value), onChange(std::move(props.onChange)),
mHelpText(std::move(props.helpText)), mRightPane(props.rightPane) {
if (!mHelpText.empty() && mRightPane != nullptr) {
listen(nullptr, Rml::EventId::Focus, [this](Rml::Event&) {
mRightPane->clear();
mRightPane->add_text(mHelpText);
});
listen(nullptr, Rml::EventId::Mouseover, [this](Rml::Event&) {
mRightPane->clear();
mRightPane->add_text(mHelpText);
});
}
listen(nullptr, Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
if (cmd == NavCommand::Left || cmd == NavCommand::Right || cmd == NavCommand::Confirm) {
toggle_value();
event.StopPropagation();
}
});
}
private:
void toggle_value() {
const bool newValue = !mValue->getValue();
mValue->setValue(newValue);
if (onChange) {
onChange(newValue);
}
config::Save();
}
ConfigVar<bool>* mValue;
std::function<void(bool)> onChange;
Rml::String mHelpText;
Pane* mRightPane;
};
SettingsWindow::SettingsWindow() {
add_tab("Audio", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content);
auto& rightPane = add_child<Pane>(content);
auto& leftPane = add_child<Pane>(content, Pane::Direction::Vertical);
auto& rightPane = add_child<Pane>(content, Pane::Direction::Vertical);
leftPane.add_section("Volume");
{
@@ -32,37 +92,30 @@ SettingsWindow::SettingsWindow() {
}
leftPane.add_section("Effects");
{
auto& btn = leftPane.add_select_button({
.key = "Enable Reverb",
.getValue = [] { return getSettings().audio.enableReverb ? "On" : "Off"; },
.onPressed =
[](SelectButton& self, Rml::Event& event) {
getSettings().audio.enableReverb.setValue(
!getSettings().audio.enableReverb);
dusk::audio::SetEnableReverb(getSettings().audio.enableReverb);
config::Save();
},
});
btn.listen(nullptr, Rml::EventId::Focus, [&](Rml::Event&) {
rightPane.clear();
rightPane.add_text("Enables the reverb effect in game audio.");
});
btn.listen(nullptr, Rml::EventId::Mouseover, [&](Rml::Event&) {
rightPane.clear();
rightPane.add_text("Enables the reverb effect in game audio.");
});
}
leftPane.add_child<ConfigBoolSelect>(
leftPane.root(), ConfigBoolSelect::Props{
.key = "Enable Reverb",
.value = &getSettings().audio.enableReverb,
.onChange = [](bool value) { audio::SetEnableReverb(value); },
.helpText = "Enables the reverb effect in game audio.",
.rightPane = &rightPane,
});
leftPane.add_section("Tweaks");
leftPane.add_select_button({
.key = "No Low HP Sound",
.value = "Off",
});
leftPane.add_select_button({
.key = "Non-Stop Midna's Lament",
.value = "On",
});
leftPane.add_child<ConfigBoolSelect>(
leftPane.root(), ConfigBoolSelect::Props{
.key = "No Low HP Sound",
.value = &getSettings().game.noLowHpSound,
.helpText = "Disable the beeping sound when having low health.",
.rightPane = &rightPane,
});
leftPane.add_child<ConfigBoolSelect>(leftPane.root(),
ConfigBoolSelect::Props{
.key = "Non-Stop Midna's Lament",
.value = &getSettings().game.midnasLamentNonStop,
.helpText = "Prevents enemy music while Midna's Lament is playing.",
.rightPane = &rightPane,
});
});
add_tab("Cheats", [this](Rml::Element* content) {
@@ -74,10 +127,11 @@ SettingsWindow::SettingsWindow() {
});
add_tab("Graphics", [this](Rml::Element* content) {
auto& leftPane = add_child<Pane>(content);
auto& rightPane = add_child<Pane>(content);
auto& leftPane = add_child<Pane>(content, Pane::Direction::Vertical);
auto& rightPane = add_child<Pane>(content, Pane::Direction::Vertical);
leftPane.add_section("Display");
leftPane.add_button({
.text = "Toggle Fullscreen",
.onPressed =
@@ -98,31 +152,26 @@ SettingsWindow::SettingsWindow() {
VICenterWindow();
},
});
leftPane.add_select_button({
.key = "Enable VSync",
.getValue = [] { return getSettings().video.enableVsync ? "On" : "Off"; },
.onPressed =
[](SelectButton&, Rml::Event&) {
getSettings().video.enableVsync.setValue(!getSettings().video.enableVsync);
aurora_enable_vsync(getSettings().video.enableVsync);
config::Save();
},
});
leftPane.add_select_button({
.key = "Force 4:3 Aspect Ratio",
.getValue = [] { return getSettings().video.lockAspectRatio ? "On" : "Off"; },
.onPressed =
[](SelectButton&, Rml::Event&) {
getSettings().video.lockAspectRatio.setValue(
!getSettings().video.lockAspectRatio);
if (getSettings().video.lockAspectRatio) {
AuroraSetViewportPolicy(AURORA_VIEWPORT_FIT);
} else {
AuroraSetViewportPolicy(AURORA_VIEWPORT_STRETCH);
}
config::Save();
},
});
leftPane.add_child<ConfigBoolSelect>(leftPane.root(),
ConfigBoolSelect::Props{
.key = "Enable VSync",
.value = &getSettings().video.enableVsync,
.onChange = [](bool value) { aurora_enable_vsync(value); },
.helpText = "Synchronizes the frame rate to your monitor's refresh rate.",
.rightPane = &rightPane,
});
leftPane.add_child<ConfigBoolSelect>(
leftPane.root(), ConfigBoolSelect::Props{
.key = "Lock 4:3 Aspect Ratio",
.value = &getSettings().video.lockAspectRatio,
.onChange =
[](bool value) {
AuroraSetViewportPolicy(
value ? AURORA_VIEWPORT_FIT : AURORA_VIEWPORT_STRETCH);
},
.helpText = "Lock the game's aspect ratio to the original.",
.rightPane = &rightPane,
});
leftPane.add_section("Resolution");
});
+1 -1
View File
@@ -8,4 +8,4 @@ public:
SettingsWindow();
};
}
} // namespace dusk::ui
+25
View File
@@ -97,4 +97,29 @@ std::string escape(std::string_view str) noexcept {
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_NEXT:
return NavCommand::Next;
case Rml::Input::KeyIdentifier::KI_PRIOR:
return NavCommand::Previous;
default:
return NavCommand::None;
}
}
} // namespace dusk::ui
+15 -1
View File
@@ -1,12 +1,24 @@
#pragma once
#include <RmlUi/Core/Event.h>
#include <SDL3/SDL_events.h>
#include <filesystem>
namespace dusk::ui {
class Window;
enum class NavCommand {
None,
Up,
Down,
Left,
Right,
Next, // R1
Previous, // L1
Confirm, // A
Cancel, // B
};
bool initialize() noexcept;
void shutdown() noexcept;
@@ -19,4 +31,6 @@ void remove_window(Window& window) 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;
} // namespace dusk::ui
+126 -9
View File
@@ -2,10 +2,14 @@
#include <RmlUi/Core.h>
#include "aurora/lib/logging.hpp"
#include "aurora/rmlui.hpp"
#include "button.hpp"
#include "magic_enum.hpp"
#include "ui.hpp"
namespace dusk::ui {
static aurora::Module Log{"dusk::ui::window"};
Window::Window() {
auto* context = aurora::rmlui::get_context();
@@ -17,6 +21,37 @@ Window::Window() {
if (mDocument == nullptr) {
return;
}
mKeyListener = std::make_unique<ScopedEventListener>(
mDocument, 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();
return;
}
}
const auto cmd = map_nav_event(event);
if (cmd == NavCommand::None) {
return;
}
auto* target = event.GetTargetElement();
if (target->Closest(".tab-bar")) {
if (handle_tab_bar_nav(event, cmd)) {
event.StopPropagation();
}
} else if (target->Closest(".content")) {
if (handle_content_nav(event, cmd)) {
event.StopPropagation();
}
}
});
}
Window::~Window() {
@@ -45,19 +80,23 @@ void Window::update() {
}
}
void Window::set_active_tab(int index) {
bool Window::set_active_tab(int index) {
if (index < 0 || index >= mTabs.size() || index == mSelectedTabIndex) {
return;
return false;
}
clear_content();
for (int i = 0; i < mTabs.size(); i++) {
mTabs[i].button->set_selected(i == index);
}
mSelectedTabIndex = index;
const auto& tab = mTabs[index];
if (tab.builder) {
tab.builder(mDocument->GetElementById("content"));
if (tab.button->focus()) {
clear_content();
for (int i = 0; i < mTabs.size(); i++) {
mTabs[i].button->set_selected(i == index);
}
mSelectedTabIndex = index;
if (tab.builder) {
tab.builder(mDocument->GetElementById("content"));
}
return true;
}
return false;
}
void Window::add_tab(const Rml::String& title, TabBuilder builder) {
@@ -87,4 +126,82 @@ void Window::clear_content() noexcept {
}
}
bool Window::focus_active_tab() noexcept {
if (mTabs.empty()) {
return false;
}
int i = mSelectedTabIndex;
if (i < 0 || i >= mTabs.size()) {
i = 0;
}
return mTabs[i].button->focus();
}
bool Window::handle_tab_bar_nav(Rml::Event& event, NavCommand cmd) noexcept {
if (cmd == NavCommand::Down) {
if (!mContentComponents.empty()) {
return mContentComponents.front()->focus();
}
} else if (cmd == NavCommand::Left || cmd == NavCommand::Right) {
int currentComponent = -1;
for (int i = 0; i < mTabs.size(); ++i) {
if (mTabs[i].button->contains(event.GetTargetElement())) {
currentComponent = i;
break;
}
}
int direction = cmd == NavCommand::Right ? 1 : -1;
int i = currentComponent + direction;
if (currentComponent == -1) {
// If the container itself is focused and right is pressed, focus the first element
if (cmd == NavCommand::Right) {
i = 0;
} else {
// Otherwise, allow event to bubble
return false;
}
}
while (i >= 0 && i < static_cast<int>(mTabs.size())) {
if (set_active_tab(i)) {
return true;
}
i += direction;
}
} else if (cmd == NavCommand::Cancel) {
// TODO: close window
} else if (cmd == NavCommand::Confirm) {
if (!mContentComponents.empty()) {
return mContentComponents.front()->focus();
}
}
return false;
}
bool Window::handle_content_nav(Rml::Event& event, NavCommand cmd) noexcept {
if (cmd == NavCommand::Up || cmd == NavCommand::Cancel) {
return focus_active_tab();
} else if (cmd == NavCommand::Down) {
if (!mContentComponents.empty()) {
return mContentComponents.front()->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()) {
return mContentComponents[i]->focus();
}
}
return false;
}
} // namespace dusk::ui
+6 -1
View File
@@ -5,6 +5,7 @@
#include "button.hpp"
#include "component.hpp"
#include "ui.hpp"
namespace dusk::ui {
@@ -27,11 +28,14 @@ public:
void hide();
void update();
void set_active_tab(int index);
bool set_active_tab(int index);
protected:
void add_tab(const Rml::String& title, TabBuilder builder);
void clear_content() noexcept;
bool focus_active_tab() noexcept;
bool handle_tab_bar_nav(Rml::Event& event, NavCommand cmd) noexcept;
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) {
@@ -45,6 +49,7 @@ protected:
std::vector<Tab> mTabs;
std::vector<std::unique_ptr<Component> > mContentComponents;
int mSelectedTabIndex = 0;
std::unique_ptr<ScopedEventListener> mKeyListener;
};
} // namespace dusk::ui