UiService v2.1: Virtualized lists (#2342)

* UiService: Add `pane_add_list`

* Fix CSS scoping
This commit is contained in:
Luke Street
2026-08-23 14:33:36 -06:00
committed by GitHub
parent d281c34a65
commit 1db2a74192
17 changed files with 802 additions and 57 deletions
+43
View File
@@ -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<UiListItem> 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
+1 -1
+2
View File
@@ -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
+70 -13
View File
@@ -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;
+47 -4
View File
@@ -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);
+163 -1
View File
@@ -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 <cstddef>
#include <cstdint>
#include <functional>
#include <limits>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
#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<ui::List::Item> 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<ui::List>(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<ui::List::Item> 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<ui::List::Item>& outItems) {
if (itemCount != 0 && items == nullptr) {
return false;
}
std::vector<ui::List::Item> copy;
copy.reserve(itemCount);
std::unordered_set<uint64_t> keys;
keys.reserve(itemCount);
auto cursor = reinterpret_cast<uintptr_t>(items);
for (size_t i = 0; i < itemCount; ++i) {
const auto* item = reinterpret_cast<const UiListItem*>(cursor);
const size_t recordSize = item->struct_size;
if (recordSize < kUiListItemV21Size || recordSize % alignof(UiListItem) != 0 ||
cursor > std::numeric_limits<uintptr_t>::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<ui::List::Item> 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<ui::List::Item> 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
-2
View File
@@ -185,8 +185,6 @@ AchievementsWindow::AchievementsWindow() {
*confirmingAll = false;
clearAllPtr->set_text("Clear All Achievements");
});
pane.finalize();
});
}
}
+363
View File
@@ -0,0 +1,363 @@
#include "list.hpp"
#include "ui.hpp"
#include "Z2AudioLib/Z2SeMgr.h"
#include "m_Do/m_Do_audio.h"
#include <algorithm>
#include <ranges>
#include <utility>
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<Item> 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<int>(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<int>(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<int>(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::Row> List::create_row(const Item& item) {
auto row = std::make_unique<Row>();
row->key = item.key;
row->button = std::make_unique<ControlledButton>(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<Item> items, const std::optional<SnapshotFocus>& snapshotFocus) {
const SnapshotFocus focusState = snapshotFocus ? *snapshotFocus : capture_snapshot_focus();
std::unordered_map<uint64_t, std::unique_ptr<Row>> 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<int>(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<int>(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<int>(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<int>(mRows.size()); i += direction)
{
if (focus_row(i, true)) {
mDoAud_seStartMenu(kSoundItemFocus);
event.StopPropagation();
return;
}
}
}
} // namespace dusk::ui
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include "button.hpp"
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <unordered_map>
#include <vector>
namespace dusk::ui {
class List : public FluentComponent<List> {
public:
struct Item {
uint64_t key = 0;
Rml::String label;
};
struct Props {
std::vector<Item> items;
std::function<void(uint64_t)> onPressed;
std::function<bool(uint64_t)> isSelected;
std::function<bool(uint64_t)> isDisabled;
};
List(Rml::Element* parent, Props props);
void set_items(std::vector<Item> items);
void update() override;
bool focus() override;
bool focus_from(NavCommand direction) override;
private:
struct SnapshotFocus {
bool owned = false;
std::optional<uint64_t> key;
int index = -1;
};
struct Row {
uint64_t key = 0;
std::unique_ptr<ControlledButton> 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<Row> create_row(const Item& item);
void apply_items(std::vector<Item> items, const std::optional<SnapshotFocus>& 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<Item> mItems;
std::optional<std::vector<Item>> mPendingItems;
std::optional<SnapshotFocus> mPendingSnapshotFocus;
std::vector<std::unique_ptr<Row>> mRows;
std::unordered_map<uint64_t, Row*> mRowsByKey;
std::optional<uint64_t> mActiveKey;
std::optional<uint64_t> 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
-1
View File
@@ -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();
+1
View File
@@ -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:
+1
View File
@@ -39,6 +39,7 @@ struct ModControlSpec {
Rml::String suffix;
std::vector<Rml::String> options;
int maxLength = -1;
bool stringSetOnChange = false;
std::vector<Rml::String> colorPresets;
bool colorAlpha = false;
};
-6
View File
@@ -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() {
-19
View File
@@ -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
-3
View File
@@ -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
+26 -6
View File
@@ -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<ScopedEventListener>(mInputElem,
Rml::EventId::Blur, [this](Rml::Event&) { request_stop_editing(true, false); }));
if (mSetOnChange) {
mInputListeners.emplace_back(std::make_unique<ScopedEventListener>(
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)) {}
+5 -1
View File
@@ -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<std::unique_ptr<ScopedEventListener> > mInputListeners;
std::vector<std::unique_ptr<ScopedEventListener>> 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<bool()> isDisabled;
std::function<bool()> isModified;
int maxLength = -1;
bool setOnChange = false;
};
StringButton(Rml::Element* parent, Props props);