From 1db2a74192dfb2c91ab29d9545cafd98eaf3adfa Mon Sep 17 00:00:00 2001 From: Luke Street Date: Sun, 23 Aug 2026 14:33:36 -0600 Subject: [PATCH] UiService v2.1: Virtualized lists (#2342) * UiService: Add `pane_add_list` * Fix CSS scoping --- docs/modding.md | 43 ++++ extern/aurora | 2 +- files.cmake | 2 + res/rml/window.rcss | 83 ++++++-- sdk/include/mods/svc/ui.h | 51 ++++- src/dusk/mods/svc/ui.cpp | 164 ++++++++++++++- src/dusk/ui/achievements.cpp | 2 - src/dusk/ui/list.cpp | 363 ++++++++++++++++++++++++++++++++++ src/dusk/ui/list.hpp | 80 ++++++++ src/dusk/ui/logs_window.cpp | 1 - src/dusk/ui/mod_window.cpp | 1 + src/dusk/ui/mod_window.hpp | 1 + src/dusk/ui/mods_window.cpp | 6 - src/dusk/ui/pane.cpp | 19 -- src/dusk/ui/pane.hpp | 3 - src/dusk/ui/string_button.cpp | 32 ++- src/dusk/ui/string_button.hpp | 6 +- 17 files changed, 802 insertions(+), 57 deletions(-) create mode 100644 src/dusk/ui/list.cpp create mode 100644 src/dusk/ui/list.hpp diff --git a/docs/modding.md b/docs/modding.md index 159311ce18..317ae32c54 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -496,6 +496,49 @@ controls are only available inside window tabs. and calls the group's build callback with that pane, which is useful for organizing related controls without adding more tabs. +**Lists:** `pane_add_list` adds a scrollable virtualized list of items that can be efficiently updated and filtered. +Keys must be unique and remain stable across replacements. + +```cpp +UiListHandle locationList = 0; + +void replace_locations(std::string_view query) { + std::vector items; + for (uint64_t i = 0; i < locations.size(); ++i) { + if (matches(locations[i], query)) { + UiListItem item = UI_LIST_ITEM_INIT; + item.key = i; + item.label = locations[i].c_str(); + items.push_back(item); + } + } + svc_ui->list_set_items(mod_ctx, locationList, items.data(), items.size()); +} + +void set_filter(ModContext*, void*, const UiControlValue* value) { + replace_locations(value->string_value); +} + +bool location_selected(ModContext*, UiListHandle, uint64_t key, void*) { + return selected_locations.contains(key); +} + +UiControlDesc filter = UI_CONTROL_DESC_INIT; +filter.kind = UI_CONTROL_STRING; +filter.label = "Filter"; +filter.get = get_filter; +filter.set = set_filter; +filter.string_set_mode = UI_STRING_SET_ON_CHANGE; /* invoke `set` while typing */ +svc_ui->pane_add_control(mod_ctx, pane, &filter, nullptr); + +UiListDesc list = UI_LIST_DESC_INIT; +/* items may be passed as a part of list creation, or set afterwards */ +list.on_pressed = location_pressed; +list.is_selected = location_selected; +svc_ui->pane_add_list(mod_ctx, pane, &list, &locationList); +replace_locations(""); /* calls `list_set_items` */ +``` + **Windows:** `window_push` pushes a tabbed two-pane window onto the document stack and shows it. Each tab's `build` receives the window handle plus fresh left and right pane handles on every activation. The optional per-tab `update` runs each frame while that tab is active. `on_closed` fires when the window is destroyed. `desc.rcss` optionally styles diff --git a/extern/aurora b/extern/aurora index 0f15fb65d1..2c9c95cfed 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 0f15fb65d13e4a7145667934e0291bad09806bfd +Subproject commit 2c9c95cfed9a06610addcb0c00ad5e366e4e8c0d diff --git a/files.cmake b/files.cmake index 86fe5c5eab..c36bc7a53b 100644 --- a/files.cmake +++ b/files.cmake @@ -1548,6 +1548,8 @@ set(DUSK_FILES src/dusk/ui/input.hpp src/dusk/ui/logs_window.cpp src/dusk/ui/logs_window.hpp + src/dusk/ui/list.cpp + src/dusk/ui/list.hpp src/dusk/ui/menu_bar.cpp src/dusk/ui/menu_bar.hpp src/dusk/ui/mod_texture_provider.cpp diff --git a/res/rml/window.rcss b/res/rml/window.rcss index 364d047c1d..98b63dfcd9 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -107,7 +107,6 @@ window content pane { min-width: 0; min-height: 0; padding: 24dp; - padding-bottom: 0dp; gap: 8dp; overflow: hidden auto; font-size: 20dp; @@ -121,6 +120,76 @@ window content pane > * { flex: 0 0 auto; } +ui-list { + display: flex; + flex-flow: column; + flex: 1 1 0; + min-width: 0; + min-height: 0; +} + +window content pane > ui-list, +.modal-content pane > ui-list { + flex: 1 1 0; + min-width: 0; + min-height: 0; +} + +ui-list-viewport { + display: block; + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden auto; +} + +ui-list-content { + display: flex; + flex-flow: column; + gap: 8dp; + min-width: 0; +} + +ui-list-content > button.ui-list-row { + flex: 0 0 auto; +} + +ui-list-empty { + display: block; + padding: 16dp; + text-align: center; + opacity: 0.45; +} + +window content pane > ui-list { + margin-left: -24dp; + margin-right: -24dp; +} + +window content pane > ui-list:first-child { + margin-top: -24dp; +} + +window content pane > ui-list:last-child { + margin-bottom: -24dp; +} + +window content pane > ui-list ui-list-content, +window content pane > ui-list ui-list-empty { + padding-left: 24dp; + padding-right: 24dp; +} + +window content pane > ui-list:first-child ui-list-content, +window content pane > ui-list:first-child ui-list-empty { + padding-top: 24dp; +} + +window content pane > ui-list:last-child ui-list-content, +window content pane > ui-list:last-child ui-list-empty { + padding-bottom: 24dp; +} + window content pane:last-of-type > div { line-height: 1.625; } @@ -131,14 +200,6 @@ window content pane:last-of-type > div { color: rgba(224, 219, 200, 65%); } -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; @@ -567,10 +628,6 @@ window.modal.danger .modal-header icon { flex: 0 0 auto; } -.modal-content pane > spacer { - display: none; -} - .verification-progress { display: flex; flex-direction: column; diff --git a/sdk/include/mods/svc/ui.h b/sdk/include/mods/svc/ui.h index 7d48c0d241..ea8abe2d7c 100644 --- a/sdk/include/mods/svc/ui.h +++ b/sdk/include/mods/svc/ui.h @@ -9,7 +9,7 @@ #define UI_SERVICE_ID "dev.twilitrealm.dusklight.ui" #define UI_SERVICE_MAJOR 2u -#define UI_SERVICE_MINOR 0u +#define UI_SERVICE_MINOR 1u /* * UI primitives: a panel inside the host Mods window, mod-owned windows, dialogs, toasts, @@ -66,6 +66,11 @@ typedef enum UiControlBinding { UI_BINDING_CONFIG_VAR = 1, } UiControlBinding; +typedef enum UiStringSetMode { + UI_STRING_SET_ON_COMMIT = 0, /* invokes `set` when input is committed */ + UI_STRING_SET_ON_CHANGE = 1, /* invokes `set` on every text change (e.g. while typing) */ +} UiStringSetMode; + /* Tagged by the control's kind: TOGGLE reads bool_value, NUMBER and SELECT read int_value, STRING * and COLOR read string_value. string_value passed to a setter is only valid during the call; a * getter should point it at storage owned by the mod (e.g. a static buffer) that stays valid until @@ -120,13 +125,44 @@ typedef struct UiControlDesc { /* COLOR: optional RRGGBB/RRGGBBAA values for presets. "rainbow" is a special value. */ const char* const* color_presets; size_t color_preset_count; - bool color_alpha; /* COLOR: use RRGGBBAA values instead of RRGGBB */ - UiPredicateFn is_selected; /* BUTTON/GROUP: pptional selected state */ + bool color_alpha; /* COLOR: use RRGGBBAA values instead of RRGGBB */ + UiPredicateFn is_selected; /* BUTTON/GROUP: optional selected state */ + UiStringSetMode string_set_mode; /* STRING: when to invoke the setter */ } UiControlDesc; #define UI_CONTROL_DESC_INIT \ {sizeof(UiControlDesc), UI_CONTROL_BUTTON, NULL, NULL, UI_BINDING_CALLBACKS, 0u, NULL, NULL, \ - NULL, NULL, NULL, NULL, 0, 0, 1, NULL, NULL, NULL, 0u, 0, NULL, 0u, false, NULL} + NULL, NULL, NULL, NULL, 0, 0, 1, NULL, NULL, NULL, 0u, 0, NULL, 0u, false, NULL, \ + UI_STRING_SET_ON_COMMIT} + +typedef uint64_t UiListHandle; + +/* Must be initialized with UI_LIST_ITEM_INIT */ +typedef struct UiListItem { + uint32_t struct_size; + uint64_t key; /* required; unique, stable item key */ + const char* label; /* required; visible item text */ +} UiListItem; + +#define UI_LIST_ITEM_INIT {sizeof(UiListItem), 0u, NULL} + +typedef void (*UiListPressedFn)( + ModContext* ctx, UiListHandle list, uint64_t item_key, void* user_data); +typedef bool (*UiListPredicateFn)( + ModContext* ctx, UiListHandle list, uint64_t item_key, void* user_data); + +/* Must be initialized with UI_LIST_DESC_INIT */ +typedef struct UiListDesc { + uint32_t struct_size; + const UiListItem* items; /* optional; initial set of items */ + size_t item_count; + UiListPressedFn on_pressed; /* required */ + UiListPredicateFn is_selected; /* optional; polled only for render-visible rows */ + UiListPredicateFn is_disabled; /* optional; polled only for render-visible rows */ + void* user_data; +} UiListDesc; + +#define UI_LIST_DESC_INIT {sizeof(UiListDesc), NULL, 0u, NULL, NULL, NULL, NULL} /* Build pane contents. A non-MOD_OK result fails the mod. */ typedef ModResult (*UiPaneBuildFn)( @@ -324,6 +360,13 @@ typedef struct UiService { ModResult (*get_clipboard_text)( ModContext* ctx, char* buffer, size_t bufferSize, size_t* outLength); ModResult (*set_clipboard_text)(ModContext* ctx, const char* text); + + /* A scrollable virtualized list of items. */ + ModResult (*pane_add_list)( + ModContext* ctx, UiElementHandle pane, const UiListDesc* desc, UiListHandle* out_list); + /* Replace all items in a list with a new set. */ + ModResult (*list_set_items)( + ModContext* ctx, UiListHandle list, const UiListItem* items, size_t item_count); } UiService; MOD_DECLARE_SERVICE(UiService, svc_ui, UI_SERVICE_ID, UI_SERVICE_MAJOR, UI_SERVICE_MINOR); diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp index debdd26471..8e5c61bf52 100644 --- a/src/dusk/mods/svc/ui.cpp +++ b/src/dusk/mods/svc/ui.cpp @@ -9,6 +9,7 @@ #include "dusk/mod_loader.hpp" #include "dusk/mods/loader/loader.hpp" #include "dusk/mods/log_buffer.hpp" +#include "dusk/ui/list.hpp" #include "dusk/ui/menu_bar.hpp" #include "dusk/ui/mod_window.hpp" #include "dusk/ui/modal.hpp" @@ -25,16 +26,30 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #include #include #include "SDL3/SDL_clipboard.h" +namespace { + +constexpr size_t kUiControlSelectedSize = + offsetof(UiControlDesc, is_selected) + sizeof(UiPredicateFn); +constexpr size_t kUiControlStringSetModeSize = + offsetof(UiControlDesc, string_set_mode) + sizeof(UiStringSetMode); +constexpr size_t kUiListItemV21Size = offsetof(UiListItem, label) + sizeof(const char*); +constexpr size_t kUiListDescV21Size = offsetof(UiListDesc, user_data) + sizeof(void*); + +} // namespace + namespace dusk::mods::svc::ui_impl { namespace { @@ -47,6 +62,7 @@ enum class UiSlotKind : u8 { Text, Progress, Control, + List, Style, MenuTab, }; @@ -65,6 +81,8 @@ const char* slot_kind_name(UiSlotKind kind) { return "progress"; case UiSlotKind::Control: return "control"; + case UiSlotKind::List: + return "list"; case UiSlotKind::Style: return "style"; case UiSlotKind::MenuTab: @@ -83,6 +101,8 @@ struct UiSlot { // Pane payload ui::Pane* pane = nullptr; ui::Pane* helpPane = nullptr; + // List payload + ui::List* list = nullptr; // Window/Dialog payload (non-owning; the document stack owns the document) ui::Document* document = nullptr; UiWindowClosedFn onClosed = nullptr; @@ -581,7 +601,7 @@ ModResult ui_pane_add_control( case UI_CONTROL_GROUP: spec.kind = desc.kind == UI_CONTROL_BUTTON ? ui::ModControlSpec::Kind::Button : ui::ModControlSpec::Kind::Group; - if (desc.struct_size >= sizeof(UiControlDesc)) { + if (desc.struct_size >= kUiControlSelectedSize) { spec.isSelected = wrap_predicate(mod, desc.is_selected, desc.user_data, pane); } spec.onPressed = [modPtr = &mod, fn = desc.on_pressed, userData = desc.user_data, @@ -612,6 +632,8 @@ ModResult ui_pane_add_control( case UI_CONTROL_STRING: spec.kind = ui::ModControlSpec::Kind::String; spec.maxLength = desc.max_length < 1 ? -1 : desc.max_length; + spec.stringSetOnChange = desc.struct_size >= kUiControlStringSetModeSize && + desc.string_set_mode == UI_STRING_SET_ON_CHANGE; break; case UI_CONTROL_COLOR: spec.kind = ui::ModControlSpec::Kind::Color; @@ -662,6 +684,69 @@ ModResult ui_pane_add_control( return MOD_OK; } +ModResult ui_pane_add_list(LoadedMod& mod, uint64_t pane, const UiListDesc& desc, + std::vector items, uint64_t& outHandle) { + outHandle = 0; + auto* paneSlot = resolve(mod, pane, UiSlotKind::Pane, "pane_add_list"); + if (paneSlot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auto* paneComponent = paneSlot->pane; + + uint64_t handle = 0; + alloc_slot(mod, UiSlotKind::List, handle); + + ui::List::Props props; + props.items = std::move(items); + props.onPressed = [modPtr = &mod, handle, fn = desc.on_pressed, userData = desc.user_data]( + uint64_t key) { + if (!slot_live(handle)) { + return; + } + guarded_call(*modPtr, "list on_pressed callback", + [&] { fn(modPtr->context.get(), handle, key, userData); }); + }; + if (desc.is_selected != nullptr) { + props.isSelected = [modPtr = &mod, handle, fn = desc.is_selected, + userData = desc.user_data](uint64_t key) { + if (!slot_live(handle)) { + return false; + } + return guarded_call(*modPtr, "list is_selected callback", false, + [&] { return fn(modPtr->context.get(), handle, key, userData); }); + }; + } + if (desc.is_disabled != nullptr) { + props.isDisabled = [modPtr = &mod, handle, fn = desc.is_disabled, + userData = desc.user_data](uint64_t key) { + if (!slot_live(handle)) { + return false; + } + return guarded_call(*modPtr, "list is_disabled callback", false, + [&] { return fn(modPtr->context.get(), handle, key, userData); }); + }; + } + + auto& list = paneComponent->add_child(std::move(props)); + auto* listSlot = slot_from_handle(handle); + if (listSlot == nullptr) { + return MOD_ERROR; + } + listSlot->list = &list; + track_element(handle, *listSlot, *list.root()); + outHandle = handle; + return MOD_OK; +} + +ModResult ui_list_set_items(LoadedMod& mod, uint64_t handle, std::vector items) { + auto* slot = resolve(mod, handle, UiSlotKind::List, "list_set_items"); + if (slot == nullptr || slot->list == nullptr) { + return MOD_INVALID_ARGUMENT; + } + slot->list->set_items(std::move(items)); + return MOD_OK; +} + ModResult ui_pane_add_group(LoadedMod& mod, uint64_t groupPaneHandle, uint64_t targetPaneHandle, const UiGroupDesc& desc, uint64_t* outElem) { auto* groupSlot = resolve(mod, groupPaneHandle, UiSlotKind::Pane, "pane_add_group"); @@ -1175,6 +1260,12 @@ bool valid_control_desc(const UiControlDesc& desc) { default: return false; } + if (desc.kind == UI_CONTROL_STRING && desc.struct_size >= kUiControlStringSetModeSize && + desc.string_set_mode != UI_STRING_SET_ON_COMMIT && + desc.string_set_mode != UI_STRING_SET_ON_CHANGE) + { + return false; + } if (desc.kind == UI_CONTROL_SELECT) { if (desc.options == nullptr || desc.option_count == 0) { return false; @@ -1207,6 +1298,36 @@ bool valid_control_desc(const UiControlDesc& desc) { } } +bool copy_list_items( + const UiListItem* items, size_t itemCount, std::vector& outItems) { + if (itemCount != 0 && items == nullptr) { + return false; + } + + std::vector copy; + copy.reserve(itemCount); + std::unordered_set keys; + keys.reserve(itemCount); + auto cursor = reinterpret_cast(items); + for (size_t i = 0; i < itemCount; ++i) { + const auto* item = reinterpret_cast(cursor); + const size_t recordSize = item->struct_size; + if (recordSize < kUiListItemV21Size || recordSize % alignof(UiListItem) != 0 || + cursor > std::numeric_limits::max() - recordSize) + { + return false; + } + if (item->label == nullptr || !keys.insert(item->key).second) { + return false; + } + copy.push_back({.key = item->key, .label = item->label}); + cursor += recordSize; + } + + outItems = std::move(copy); + return true; +} + ModResult ui_register_mods_panel(ModContext* context, const UiModsPanelDesc* desc) { auto* mod = mod_from_context(context); if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(UiModsPanelDesc) || @@ -1273,6 +1394,45 @@ ModResult ui_pane_add_control(ModContext* context, UiElementHandle pane, const U return ui_impl::ui_pane_add_control(*mod, pane, *desc, outElem); } +ModResult ui_pane_add_list( + ModContext* context, UiElementHandle pane, const UiListDesc* desc, UiListHandle* outList) { + if (outList != nullptr) { + *outList = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || pane == 0 || desc == nullptr || desc->struct_size < kUiListDescV21Size || + desc->on_pressed == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + std::vector items; + if ((desc->items != nullptr || desc->item_count > 0) && + !copy_list_items(desc->items, desc->item_count, items)) + { + return MOD_INVALID_ARGUMENT; + } + + uint64_t handle = 0; + const ModResult result = ui_impl::ui_pane_add_list(*mod, pane, *desc, std::move(items), handle); + if (result == MOD_OK && outList != nullptr) { + *outList = handle; + } + return result; +} + +ModResult ui_list_set_items( + ModContext* context, UiListHandle list, const UiListItem* items, size_t itemCount) { + auto* mod = mod_from_context(context); + if (mod == nullptr || list == 0) { + return MOD_INVALID_ARGUMENT; + } + std::vector copiedItems; + if (!copy_list_items(items, itemCount, copiedItems)) { + return MOD_INVALID_ARGUMENT; + } + return ui_impl::ui_list_set_items(*mod, list, std::move(copiedItems)); +} + ModResult ui_pane_add_group(ModContext* context, UiElementHandle groupPane, UiElementHandle targetPane, const UiGroupDesc* desc, UiElementHandle* outElem) { if (outElem != nullptr) { @@ -1630,6 +1790,8 @@ constexpr UiService s_uiService{ .push_toast = ui_push_toast, .get_clipboard_text = ui_get_clipboard_text, .set_clipboard_text = ui_set_clipboard_text, + .pane_add_list = ui_pane_add_list, + .list_set_items = ui_list_set_items, }; } // namespace diff --git a/src/dusk/ui/achievements.cpp b/src/dusk/ui/achievements.cpp index 9d25f1d82a..905fa57f59 100644 --- a/src/dusk/ui/achievements.cpp +++ b/src/dusk/ui/achievements.cpp @@ -185,8 +185,6 @@ AchievementsWindow::AchievementsWindow() { *confirmingAll = false; clearAllPtr->set_text("Clear All Achievements"); }); - - pane.finalize(); }); } } diff --git a/src/dusk/ui/list.cpp b/src/dusk/ui/list.cpp new file mode 100644 index 0000000000..fc8d233847 --- /dev/null +++ b/src/dusk/ui/list.cpp @@ -0,0 +1,363 @@ +#include "list.hpp" + +#include "ui.hpp" + +#include "Z2AudioLib/Z2SeMgr.h" +#include "m_Do/m_Do_audio.h" + +#include +#include +#include + +namespace dusk::ui { +namespace { + +Rml::Element* create_root(Rml::Element* parent) { + auto* document = parent->GetOwnerDocument(); + auto root = document->CreateElement("ui-list"); + return parent->AppendChild(std::move(root)); +} + +Rml::Element* append_element(Rml::Element* parent, const Rml::String& tag) { + auto element = parent->GetOwnerDocument()->CreateElement(tag); + return parent->AppendChild(std::move(element)); +} + +} // namespace + +List::List(Rml::Element* parent, Props props) + : FluentComponent(create_root(parent)), mProps(std::move(props)) { + mViewport = append_element(mRoot, "ui-list-viewport"); + mContent = append_element(mViewport, "ui-list-content"); + mEmpty = append_element(mRoot, "ui-list-empty"); + append_text(mEmpty, "No items"); + + Component::listen(mViewport, Rml::EventId::Scroll, [this](Rml::Event&) { mCullDirty = true; }); + listen(Rml::EventId::Keydown, [this](Rml::Event& event) { handle_keydown(event); }); + + apply_items(std::move(mProps.items)); +} + +void List::set_items(std::vector items) { + mPendingSnapshotFocus = capture_snapshot_focus(); + mPendingItems = std::move(items); +} + +void List::update() { + if (mPendingItems) { + auto items = std::move(*mPendingItems); + const auto snapshotFocus = mPendingSnapshotFocus; + mPendingItems.reset(); + mPendingSnapshotFocus.reset(); + apply_items(std::move(items), snapshotFocus); + } + + for (const auto& row : mRows) { + if (!row->culled) { + row->button->update(); + } + } + + const float scrollTop = mViewport->GetScrollTop(); + const float viewportWidth = mViewport->GetClientWidth(); + const float viewportHeight = mViewport->GetClientHeight(); + if (scrollTop != mLastScrollTop || viewportWidth != mLastViewportWidth || + viewportHeight != mLastViewportHeight) + { + mCullDirty = true; + if (viewportWidth != mLastViewportWidth || viewportHeight != mLastViewportHeight) { + mLayoutScanFrames = std::max(mLayoutScanFrames, 2); + } + mLastScrollTop = scrollTop; + mLastViewportWidth = viewportWidth; + mLastViewportHeight = viewportHeight; + } + + if (mCullDirty || mLayoutScanFrames > 0) { + update_culling(); + mCullDirty = false; + if (mLayoutScanFrames > 0) { + --mLayoutScanFrames; + } + } + + update_pending_focus(); +} + +bool List::focus() { + if (mActiveKey) { + const int activeIndex = row_index(*mActiveKey); + if (activeIndex >= 0 && focus_row(activeIndex, true)) { + return true; + } + } + for (int i = 0; i < static_cast(mRows.size()); ++i) { + if (focus_row(i, true)) { + return true; + } + } + return false; +} + +bool List::focus_from(NavCommand direction) { + if (direction != NavCommand::Up) { + return focus(); + } + for (int i = static_cast(mRows.size()) - 1; i >= 0; --i) { + if (focus_row(i, true)) { + return true; + } + } + return false; +} + +List::Row* List::row_from_element(Rml::Element* element) const { + for (const auto& row : mRows) { + if (row->button->contains(element)) { + return row.get(); + } + } + return nullptr; +} + +int List::row_index(uint64_t key) const { + for (int i = 0; i < static_cast(mRows.size()); ++i) { + if (mRows[i]->key == key) { + return i; + } + } + return -1; +} + +List::SnapshotFocus List::capture_snapshot_focus() { + auto* context = mRoot->GetContext(); + auto* focusedElement = context != nullptr ? context->GetFocusElement() : nullptr; + Row* focusedRow = row_from_element(focusedElement); + if (focusedRow == nullptr) { + return {}; + } + mActiveKey = focusedRow->key; + return { + .owned = true, + .key = focusedRow->key, + .index = row_index(focusedRow->key), + }; +} + +std::unique_ptr List::create_row(const Item& item) { + auto row = std::make_unique(); + row->key = item.key; + row->button = std::make_unique(mContent, + ControlledButton::Props{ + .text = item.label, + .isSelected = + [this, key = item.key] { return mProps.isSelected && mProps.isSelected(key); }, + .isDisabled = + [this, key = item.key] { return mProps.isDisabled && mProps.isDisabled(key); }, + }); + row->button->root()->SetClass("ui-list-row", true); + row->button->root()->SetProperty("visibility", "hidden"); + row->button->Component::listen(row->button->root(), Rml::EventId::Focus, + [this, key = item.key](Rml::Event&) { mActiveKey = key; }); + row->button->on_pressed([this, key = item.key] { + mActiveKey = key; + if (mProps.onPressed) { + mProps.onPressed(key); + } + }); + return row; +} + +void List::apply_items(std::vector items, const std::optional& snapshotFocus) { + const SnapshotFocus focusState = snapshotFocus ? *snapshotFocus : capture_snapshot_focus(); + + std::unordered_map> oldRows; + oldRows.reserve(mRows.size()); + for (auto& row : mRows) { + oldRows.emplace(row->key, std::move(row)); + } + mRows.clear(); + mRows.reserve(items.size()); + + for (const auto& item : items) { + if (auto node = oldRows.extract(item.key); !node.empty()) { + auto row = std::move(node.mapped()); + row->button->set_text(item.label); + mRows.push_back(std::move(row)); + } else { + mRows.push_back(create_row(item)); + } + } + + for (auto& row : oldRows | std::views::values) { + mContent->RemoveChild(row->button->root()); + } + + for (int i = 0; i < static_cast(mRows.size()); ++i) { + auto* desired = mRows[i]->button->root(); + auto* current = mContent->GetChild(i); + if (current != desired) { + auto element = mContent->RemoveChild(desired); + mContent->InsertBefore(std::move(element), current); + } + } + + mRowsByKey.clear(); + mRowsByKey.reserve(mRows.size()); + for (const auto& row : mRows) { + mRowsByKey.emplace(row->key, row.get()); + } + mItems = std::move(items); + + if (mRows.empty()) { + mViewport->SetProperty("display", "none"); + mEmpty->RemoveProperty("display"); + } else { + mViewport->RemoveProperty("display"); + mEmpty->SetProperty("display", "none"); + } + + if (focusState.owned && !mRows.empty()) { + uint64_t targetKey = + mRows[std::min(focusState.index, static_cast(mRows.size()) - 1)]->key; + if (focusState.key && mRowsByKey.contains(*focusState.key)) { + targetKey = *focusState.key; + } + request_focus(targetKey, false); + } else { + mPendingFocusKey.reset(); + mPendingFocusFrames = 0; + mPendingFocusMayEnterList = false; + } + + mCullDirty = true; + mLayoutScanFrames = 2; +} + +void List::update_culling() { + const float viewTop = mViewport->GetAbsoluteOffset(Rml::BoxArea::Border).y; + const float viewHeight = mViewport->GetClientHeight(); + if (viewHeight <= 0.0f) { + return; + } + auto* context = mRoot->GetContext(); + const Row* focusedRow = context != nullptr ? row_from_element(context->GetFocusElement()) : nullptr; + + for (const auto& row : mRows) { + auto* element = row->button->root(); + const float top = element->GetAbsoluteOffset(Rml::BoxArea::Border).y - viewTop; + const bool inWindow = + top + element->GetOffsetHeight() >= -viewHeight && top <= viewHeight * 2.0f; + const bool focusGuard = + row.get() == focusedRow || (mPendingFocusKey && row->key == *mPendingFocusKey); + const bool shouldShow = inWindow || focusGuard; + if (shouldShow && row->culled) { + show_row(*row); + } else if (!shouldShow && !row->culled) { + row->culled = true; + element->SetProperty("visibility", "hidden"); + } + } +} + +void List::show_row(Row& row) { + if (!row.culled) { + return; + } + row.button->update(); + row.button->root()->RemoveProperty("visibility"); + row.culled = false; +} + +bool List::focus_row(int index, bool mayEnterList) { + if (index < 0 || index >= static_cast(mRows.size())) { + return false; + } + auto& row = *mRows[index]; + show_row(row); + row.button->update(); + if (row.button->root()->IsPseudoClassSet("disabled")) { + return false; + } + if (row.button->focus()) { + mActiveKey = row.key; + mPendingFocusKey.reset(); + mPendingFocusFrames = 0; + mPendingFocusMayEnterList = false; + return true; + } + request_focus(row.key, mayEnterList); + return true; +} + +void List::request_focus(uint64_t key, bool mayEnterList) { + const auto it = mRowsByKey.find(key); + if (it == mRowsByKey.end()) { + return; + } + show_row(*it->second); + mPendingFocusKey = key; + mPendingFocusFrames = 2; + mPendingFocusMayEnterList = mayEnterList; +} + +void List::update_pending_focus() { + if (!mPendingFocusKey) { + return; + } + const auto it = mRowsByKey.find(*mPendingFocusKey); + if (it == mRowsByKey.end()) { + mPendingFocusKey.reset(); + return; + } + + auto* context = mRoot->GetContext(); + auto* focusedElement = context != nullptr ? context->GetFocusElement() : nullptr; + if (!mPendingFocusMayEnterList && focusedElement != nullptr && !contains(focusedElement)) { + mPendingFocusKey.reset(); + return; + } + if (it->second->button->contains(focusedElement)) { + mPendingFocusKey.reset(); + mPendingFocusFrames = 0; + mPendingFocusMayEnterList = false; + return; + } + if (mPendingFocusFrames > 0) { + --mPendingFocusFrames; + return; + } + + auto& row = *it->second; + show_row(row); + row.button->update(); + if (!row.button->root()->IsPseudoClassSet("disabled") && row.button->focus()) { + mActiveKey = row.key; + } + mPendingFocusKey.reset(); + mPendingFocusMayEnterList = false; +} + +void List::handle_keydown(Rml::Event& event) { + const NavCommand command = map_nav_event(event); + if (command != NavCommand::Down && command != NavCommand::Up) { + return; + } + Row* focusedRow = row_from_element(event.GetTargetElement()); + if (focusedRow == nullptr) { + return; + } + mActiveKey = focusedRow->key; + const int direction = command == NavCommand::Down ? 1 : -1; + for (int i = row_index(focusedRow->key) + direction; + i >= 0 && i < static_cast(mRows.size()); i += direction) + { + if (focus_row(i, true)) { + mDoAud_seStartMenu(kSoundItemFocus); + event.StopPropagation(); + return; + } + } +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/list.hpp b/src/dusk/ui/list.hpp new file mode 100644 index 0000000000..11a9dab5d6 --- /dev/null +++ b/src/dusk/ui/list.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include "button.hpp" + +#include +#include +#include +#include +#include +#include + +namespace dusk::ui { + +class List : public FluentComponent { +public: + struct Item { + uint64_t key = 0; + Rml::String label; + }; + + struct Props { + std::vector items; + std::function onPressed; + std::function isSelected; + std::function isDisabled; + }; + + List(Rml::Element* parent, Props props); + + void set_items(std::vector items); + void update() override; + bool focus() override; + bool focus_from(NavCommand direction) override; + +private: + struct SnapshotFocus { + bool owned = false; + std::optional key; + int index = -1; + }; + + struct Row { + uint64_t key = 0; + std::unique_ptr button; + bool culled = true; + }; + + Row* row_from_element(Rml::Element* element) const; + int row_index(uint64_t key) const; + SnapshotFocus capture_snapshot_focus(); + std::unique_ptr create_row(const Item& item); + void apply_items(std::vector items, const std::optional& snapshotFocus = {}); + void update_culling(); + void show_row(Row& row); + bool focus_row(int index, bool mayEnterList); + void request_focus(uint64_t key, bool mayEnterList); + void update_pending_focus(); + void handle_keydown(Rml::Event& event); + + Props mProps; + Rml::Element* mViewport = nullptr; + Rml::Element* mContent = nullptr; + Rml::Element* mEmpty = nullptr; + std::vector mItems; + std::optional> mPendingItems; + std::optional mPendingSnapshotFocus; + std::vector> mRows; + std::unordered_map mRowsByKey; + std::optional mActiveKey; + std::optional mPendingFocusKey; + int mPendingFocusFrames = 0; + bool mPendingFocusMayEnterList = false; + bool mCullDirty = true; + int mLayoutScanFrames = 2; + float mLastScrollTop = -1.0f; + float mLastViewportWidth = -1.0f; + float mLastViewportHeight = -1.0f; +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/logs_window.cpp b/src/dusk/ui/logs_window.cpp index 0a6fc2402d..14059874d6 100644 --- a/src/dusk/ui/logs_window.cpp +++ b/src/dusk/ui/logs_window.cpp @@ -129,7 +129,6 @@ void LogsWindow::build_content(Rml::Element* content) { mScrollElem = pane.root(); mLinesElem = append(pane.root(), "div"); mLinesElem->SetClass("log-lines", true); - pane.finalize(); listen(mScrollElem, Rml::EventId::Scroll, [this](Rml::Event&) { const float bottom = mScrollElem->GetScrollHeight() - mScrollElem->GetClientHeight(); diff --git a/src/dusk/ui/mod_window.cpp b/src/dusk/ui/mod_window.cpp index 213ac29b7d..fc1a963c01 100644 --- a/src/dusk/ui/mod_window.cpp +++ b/src/dusk/ui/mod_window.cpp @@ -69,6 +69,7 @@ Component* build_mod_control(Pane& pane, Pane* helpPane, ModControlSpec spec) { .isDisabled = s.isDisabled, .isModified = s.isModified, .maxLength = s.maxLength, + .setOnChange = s.stringSetOnChange, }); break; case ModControlSpec::Kind::Color: diff --git a/src/dusk/ui/mod_window.hpp b/src/dusk/ui/mod_window.hpp index 88e59dd68c..2d8d810417 100644 --- a/src/dusk/ui/mod_window.hpp +++ b/src/dusk/ui/mod_window.hpp @@ -39,6 +39,7 @@ struct ModControlSpec { Rml::String suffix; std::vector options; int maxLength = -1; + bool stringSetOnChange = false; std::vector colorPresets; bool colorAlpha = false; }; diff --git a/src/dusk/ui/mods_window.cpp b/src/dusk/ui/mods_window.cpp index 46a7d3bc52..8b8891cbe1 100644 --- a/src/dusk/ui/mods_window.cpp +++ b/src/dusk/ui/mods_window.cpp @@ -192,8 +192,6 @@ void ModsWindow::build_content(Rml::Element* content) { if (mods::ModLoader::instance().mods().empty()) { listPane.add_text("No mods installed."); - listPane.finalize(); - detailPane.finalize(); return; } @@ -214,8 +212,6 @@ void ModsWindow::build_content(Rml::Element* content) { } build_detail(detailPane, *mSelectedMod); mark_current_entry(); - - listPane.finalize(); } void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) { @@ -279,8 +275,6 @@ void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) { if (mod.active) { mods::svc::ui_build_mods_panels(mod, pane); } - - pane.finalize(); } void ModsWindow::mark_current_entry() { diff --git a/src/dusk/ui/pane.cpp b/src/dusk/ui/pane.cpp index bf47c45202..38aca27aaa 100644 --- a/src/dusk/ui/pane.cpp +++ b/src/dusk/ui/pane.cpp @@ -90,11 +90,6 @@ Pane::Pane(Rml::Element* parent, Type type) : FluentComponent(createRoot(parent) } } -void Pane::update() { - finalize(); - Component::update(); -} - void Pane::set_selected_item(int index) { if (mType == Type::Uncontrolled) { return; @@ -191,22 +186,8 @@ Rml::Element* Pane::add_rml(const Rml::String& 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 diff --git a/src/dusk/ui/pane.hpp b/src/dusk/ui/pane.hpp index e4060cddbb..fe13bd2093 100644 --- a/src/dusk/ui/pane.hpp +++ b/src/dusk/ui/pane.hpp @@ -18,7 +18,6 @@ public: bool focus() override; bool focus_last(); - void update() override; void set_selected_item(int index); Component& register_control( @@ -37,12 +36,10 @@ public: } 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 diff --git a/src/dusk/ui/string_button.cpp b/src/dusk/ui/string_button.cpp index a2410ebef6..cc107eac8e 100644 --- a/src/dusk/ui/string_button.cpp +++ b/src/dusk/ui/string_button.cpp @@ -6,8 +6,8 @@ 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(4); + mMaxLength(props.maxLength), mSetOnChange(props.setOnChange) { + mInputListeners.reserve(5); } void BaseStringButton::update() { @@ -35,8 +35,9 @@ void BaseStringButton::start_editing() { if (mInputElem == nullptr) { return; } + mOriginalValue = input_value(); mInputElem->SetAttribute("type", mType); - mInputElem->SetAttribute("value", input_value()); + mInputElem->SetAttribute("value", mOriginalValue); if (mMaxLength > -1) { mInputElem->SetAttribute("maxlength", mMaxLength); } @@ -58,7 +59,10 @@ void BaseStringButton::start_editing() { mInputElem, Rml::EventId::Textinput, [this](Rml::Event& event) { if (event.GetTargetElement() == mInputElem) { const Rml::String text = event.GetParameter("text", Rml::String{}); - if (!text.empty() && std::ranges::all_of(text, [](const char c) { return c == '\r' || c == '\n' || c == '\t'; })) { + if (!text.empty() && + std::ranges::all_of( + text, [](const char c) { return c == '\r' || c == '\n' || c == '\t'; })) + { event.StopImmediatePropagation(); } } @@ -78,6 +82,14 @@ void BaseStringButton::start_editing() { mInputElem, Rml::EventId::Click, [](Rml::Event& event) { event.StopPropagation(); })); mInputListeners.emplace_back(std::make_unique(mInputElem, Rml::EventId::Blur, [this](Rml::Event&) { request_stop_editing(true, false); })); + if (mSetOnChange) { + mInputListeners.emplace_back(std::make_unique( + mInputElem, Rml::EventId::Change, [this](Rml::Event& event) { + if (event.GetTargetElement() == mInputElem) { + set_value(mInputElem->GetValue()); + } + })); + } } void BaseStringButton::request_stop_editing(bool commit, bool refocusRoot) { @@ -123,12 +135,15 @@ void BaseStringButton::stop_editing(bool commit, bool refocusRoot) { if (!is_editing()) { return; } - if (commit) { + if (!mSetOnChange && commit) { set_value(mInputElem->GetValue()); + } else if (mSetOnChange && !commit) { + set_value(mOriginalValue); } mInputListeners.clear(); mRoot->RemoveChild(mInputElem); mInputElem = nullptr; + mOriginalValue.clear(); // Restore value element mValueElem->SetProperty(Rml::PropertyId::Visibility, Rml::Style::Visibility::Visible); @@ -140,7 +155,12 @@ void BaseStringButton::stop_editing(bool commit, bool refocusRoot) { } StringButton::StringButton(Rml::Element* parent, Props props) - : BaseStringButton(parent, {.key = std::move(props.key), .maxLength = props.maxLength}), + : BaseStringButton(parent, + { + .key = std::move(props.key), + .maxLength = props.maxLength, + .setOnChange = props.setOnChange, + }), mGetValue(std::move(props.getValue)), mSetValue(std::move(props.setValue)), mIsDisabled(std::move(props.isDisabled)), mIsModified(std::move(props.isModified)) {} diff --git a/src/dusk/ui/string_button.hpp b/src/dusk/ui/string_button.hpp index 4366164de7..75e3c323d1 100644 --- a/src/dusk/ui/string_button.hpp +++ b/src/dusk/ui/string_button.hpp @@ -12,6 +12,7 @@ public: Rml::String key; Rml::String type = "text"; int maxLength = -1; + bool setOnChange = false; }; BaseStringButton(Rml::Element* parent, Props props); @@ -30,13 +31,15 @@ private: void stop_editing(bool commit = true, bool refocusRoot = false); Rml::ElementFormControlInput* mInputElem = nullptr; - std::vector > mInputListeners; + std::vector> mInputListeners; Rml::String mType; int mMaxLength; int mPendingInputFocusFrames = 0; bool mPendingStopEditing = false; bool mPendingCommit = true; bool mPendingRefocusRoot = false; + bool mSetOnChange = false; + Rml::String mOriginalValue; }; class StringButton : public BaseStringButton { @@ -48,6 +51,7 @@ public: std::function isDisabled; std::function isModified; int maxLength = -1; + bool setOnChange = false; }; StringButton(Rml::Element* parent, Props props);