summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/kernel/include/unbox/kernel/server.hpp13
-rw-r--r--packages/kernel/include/unbox/kernel/ui.hpp61
-rw-r--r--packages/kernel/src/server.cpp8
-rw-r--r--packages/kernel/src/ui_substrate.cpp290
-rw-r--r--packages/kernel/src/ui_substrate.hpp17
-rw-r--r--packages/kernel/tests/test_kernel.cpp130
6 files changed, 515 insertions, 4 deletions
diff --git a/packages/kernel/include/unbox/kernel/server.hpp b/packages/kernel/include/unbox/kernel/server.hpp
index 190d4d5..4cb5230 100644
--- a/packages/kernel/include/unbox/kernel/server.hpp
+++ b/packages/kernel/include/unbox/kernel/server.hpp
@@ -103,6 +103,19 @@ public:
// known source color reached the expected spot inside an <img>.
[[nodiscard]] auto ui_pixel(int x, int y) const -> unsigned int;
+ // Count elements with the given tag name in the first ui surface's loaded
+ // document. 0 if no surface / no document yet. Lets the suite assert that a
+ // data-for list rendered the expected number of rows (slice 10 / b2 list
+ // bindings) without synthesizing input. Test instrumentation; single-thread.
+ [[nodiscard]] auto ui_element_count(const char* tag) const -> int;
+
+ // Dispatch a click on the `index`-th element with tag `tag` in the first ui
+ // surface's document (RmlUi Element::Click). Returns false if no such
+ // element. Lets the suite fire a list row's data-event-click and assert the
+ // per-row event callback got the right index — no real input device needed.
+ // Test instrumentation; single-thread only.
+ auto ui_click_element(const char* tag, int index) -> bool;
+
// Pin the substrate's touch-mode for tests (none = automatic). Mirrors
// UiSubstrate::TouchModeOverride; lets the suite drive the state machine and
// its on_touch_mode_changed notification. Test instrumentation;
diff --git a/packages/kernel/include/unbox/kernel/ui.hpp b/packages/kernel/include/unbox/kernel/ui.hpp
index 3a704cd..7f7609a 100644
--- a/packages/kernel/include/unbox/kernel/ui.hpp
+++ b/packages/kernel/include/unbox/kernel/ui.hpp
@@ -2,6 +2,7 @@
#include <unbox/kernel/host.hpp> // SceneLayer (and, transitively, wlr.hpp-free types)
+#include <cstddef>
#include <functional>
#include <memory>
#include <string>
@@ -22,9 +23,10 @@
// DEFERRED (documented, not built this slice):
// - Keyboard into ui surfaces (text input + focus): OUT of slice 5. OSK is
// slice 8, launcher text slice 6 — those slices add the keyboard path.
-// - List / container data bindings: see UiSurface::bind_* notes. Slice 6's
-// taskbar will change-request the exact list shape; only scalar + event
-// bindings ship now.
+// - Keyboard into ui surfaces only (text input + focus); see above. List /
+// container data bindings now SHIP (slice 10's stage dock forced the shape):
+// see UiSurface::bind_list / bind_list_<type> / bind_list_event. NESTED
+// lists (a list field whose value is itself a list) remain unsupported.
//
// Everything runs on the single wl_event_loop thread. RML assets live under
// assets/<unit>/ per the harness; pass either inline RML or an asset path.
@@ -91,9 +93,60 @@ public:
// Ignoring it is fine — the surface simply looks the same in both modes.
virtual void on_touch_mode_changed(std::function<void(bool touch)> callback) = 0;
+ // ---- List bindings (typed, RMLUi-free) ----
+ // A LIST is a runtime-sized, indexed sequence the document iterates with
+ // <div data-for="row : <name>"> … {{ row.<field> }} … </div>
+ // (the iterator alias — "row" above — is the author's choice; the count and
+ // the per-field getters you register here drive what each row renders).
+ //
+ // bind_list(name, count): declare the list. `count()` is called by the
+ // substrate every time the list re-renders (after dirty(name) or dirty())
+ // to size the loop; rows are indexed 0..count()-1. It must be cheap and
+ // pure (no event-loop blocking). A count() that throws is caught and
+ // isolates your extension (the list then renders as empty for that frame).
+ //
+ // bind_list_<type>(list, field, getter): declare a per-row FIELD `<field>`
+ // of the named list, read in RML as {{ row.<field> }}. For each visible row
+ // the substrate calls getter(row_index) to produce that cell's value. Same
+ // contract as the scalar getters: cheap, pure, called for the surface's
+ // lifetime, capturing only state that outlives this surface; a throwing
+ // getter isolates your extension. Register a field before the first frame;
+ // re-registering the same (list, field) replaces the getter. string is the
+ // workhorse (preview/favicon URIs, titles); int/double/bool are provided
+ // for numeric/flag cells.
+ //
+ // bind_list_event(list, event, callback): declare a per-row EVENT. Author
+ // it on a row element as e.g. data-event-click="<event>(it_index)" — the
+ // `it_index` argument is the row index the document supplies, and the
+ // substrate delivers it to callback(row_index). (Omit the argument and you
+ // get row 0; always pass it_index for the real row.) Invoked on the
+ // event-loop thread; a throwing callback is caught at the substrate
+ // boundary and disables YOUR extension only — never the session. Register
+ // before the first frame; re-registering the same (list, event) replaces
+ // the callback.
+ //
+ // ALL of bind_list* must be called BEFORE the first frame (same rule as the
+ // scalar bind_*: RmlUi needs the data model complete before it parses the
+ // document). Binding a field/event/list after the first frame is a no-op.
+ // Row fields may be added in any order relative to bind_list for the same
+ // name. NESTED lists (a list field that is itself a list) are NOT supported.
+ virtual void bind_list(std::string_view name, std::function<std::size_t()> count) = 0;
+ virtual void bind_list_string(std::string_view list, std::string_view field,
+ std::function<std::string(std::size_t row)> getter) = 0;
+ virtual void bind_list_int(std::string_view list, std::string_view field,
+ std::function<int(std::size_t row)> getter) = 0;
+ virtual void bind_list_double(std::string_view list, std::string_view field,
+ std::function<double(std::size_t row)> getter) = 0;
+ virtual void bind_list_bool(std::string_view list, std::string_view field,
+ std::function<bool(std::size_t row)> getter) = 0;
+ virtual void bind_list_event(std::string_view list, std::string_view event,
+ std::function<void(std::size_t row)> callback) = 0;
+
// Mark a bound scalar changed so the substrate re-reads its getter and
// re-renders on the next frame. dirty() with no name marks ALL bound
- // scalars dirty (use sparingly).
+ // scalars dirty (use sparingly). For a list, dirty(<list-name>) re-reads
+ // its count() and re-reads every visible row's field getters, growing or
+ // shrinking the rendered rows to match the new count on the next frame.
virtual void dirty(std::string_view name) = 0;
virtual void dirty() = 0;
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index b302c68..95631df 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -89,6 +89,14 @@ auto Server::ui_pixel(int x, int y) const -> unsigned int {
return impl_->substrate != nullptr ? impl_->substrate->surface_pixel(x, y) : 0U;
}
+auto Server::ui_element_count(const char* tag) const -> int {
+ return impl_->substrate != nullptr ? impl_->substrate->element_count(tag) : 0;
+}
+
+auto Server::ui_click_element(const char* tag, int index) -> bool {
+ return impl_->substrate != nullptr && impl_->substrate->click_element(tag, index);
+}
+
void Server::ui_set_touch_override(UiTouchOverride ov) {
if (impl_->substrate == nullptr) {
return;
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index 9c53770..bbe0600 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -5,9 +5,11 @@
#include <RmlUi/Core/Context.h>
#include <RmlUi/Core/Core.h>
#include <RmlUi/Core/DataModelHandle.h>
+#include <RmlUi/Core/DataVariable.h>
#include <RmlUi/Core/Element.h>
#include <RmlUi/Core/ElementDocument.h>
#include <RmlUi/Core/SystemInterface.h>
+#include <RmlUi/Core/Variant.h>
// The kernel owns GL; system EGL/GLES headers are allowed here (same as the
// retired spike). wlr.hpp already pulled <EGL/egl.h>+<EGL/eglext.h> via
@@ -342,6 +344,26 @@ struct Surface {
};
std::list<EventBinding> event_bindings;
+ // List bindings (slice 10 / b2). A bound list is a runtime-sized indexed
+ // sequence the document iterates with data-for; each row exposes named
+ // string/int/double/bool FIELDS read as {{ row.<field> }}. The shape maps
+ // onto RmlUi's data-binding type system via three owned VariableDefinitions
+ // per list (Array -> row Struct -> per-field Scalar); the row index is
+ // smuggled through the DataVariable `void* ptr` (no per-row heap object).
+ // All getters/count follow the scalar contract (cheap, pure, lifetime =
+ // surface, throw => isolate). Stored in a std::list so addresses are stable
+ // (the VariableDefinitions hold a ListBinding*). Defined below the Surface.
+ struct ListBinding;
+ std::list<ListBinding> list_bindings;
+ // Per-list event callbacks (keyed by event name). A row event delivers the
+ // row index extracted from the data expression's first argument (it_index).
+ struct ListEventBinding {
+ std::function<void(std::size_t)> cb;
+ ExtensionId who;
+ Substrate::Impl* owner;
+ };
+ std::list<ListEventBinding> list_event_bindings;
+
// touch-mode-changed notification (one per surface; see ui.hpp). Fired on a
// transition, error-isolated to `who`. touch-mode does NO visual scaling
// (user decision) — this is purely an opt-in signal for extensions.
@@ -353,6 +375,137 @@ struct Surface {
int frame_count = 0;
};
+// ---- List bindings: the RMLUi-free list shape -> RmlUi data-binding types ---
+//
+// data-for="row : <name>" makes RmlUi ask the named variable for its Size() and
+// then a Child per index; {{ row.<field> }} asks that child (a row) for a Child
+// per field name; the field child is a scalar that yields a Variant. We satisfy
+// all three with custom VariableDefinitions (NonCopyMoveable, owned by the
+// Surface for its whole life — they outlive the RmlUi context, which is torn
+// down first in destroy_surface). The row index is carried through the
+// DataVariable's `void* ptr` as an encoded integer, so there is NO per-row heap
+// object and rows cost nothing until rendered. count()/getters are called
+// straight out of the ListBinding; a throw is isolated to the owning extension.
+namespace {
+// Encode/decode a row index in the opaque DataVariable ptr (index + 1 so the
+// encoded value is never the null we hand RmlUi for an out-of-range child).
+inline auto encode_row(std::size_t row) -> void* {
+ return reinterpret_cast<void*>(static_cast<std::uintptr_t>(row) + 1);
+}
+inline auto decode_row(void* ptr) -> std::size_t {
+ return static_cast<std::size_t>(reinterpret_cast<std::uintptr_t>(ptr)) - 1;
+}
+} // namespace
+
+// One bound list's full state: the count getter, the per-field getters (by
+// name+type), and the three VariableDefinitions wired Array -> Struct -> Scalar.
+// `isolate` lets a getter throw without taking down the session (it calls the
+// substrate's DisableSink for `who`). Lives in Surface::list_bindings.
+struct Surface::ListBinding {
+ std::string name;
+ std::function<std::size_t()> count;
+ ExtensionId who{};
+ // A copy of the substrate's DisableSink so a throwing count/getter isolates
+ // the owning extension WITHOUT this struct (defined before Substrate::Impl)
+ // needing the complete Impl type.
+ SubstrateDisableFn disable;
+
+ std::unordered_map<std::string, std::function<bool(std::size_t, Rml::Variant&)>> fields;
+
+ // Run a field/count call, isolating a throw to the owning extension.
+ template <typename Fn>
+ auto isolate(Fn&& fn) -> bool {
+ try {
+ fn();
+ return true;
+ } catch (...) {
+ if (disable) {
+ disable(who);
+ }
+ return false;
+ }
+ }
+
+ // The scalar at (row, field): decode the row, call the field getter.
+ struct FieldDef final : Rml::VariableDefinition {
+ FieldDef(ListBinding* b, std::function<bool(std::size_t, Rml::Variant&)>* f)
+ : Rml::VariableDefinition(Rml::DataVariableType::Scalar), binding(b), field(f) {}
+ bool Get(void* ptr, Rml::Variant& variant) override {
+ const std::size_t row = decode_row(ptr);
+ bool got = false;
+ binding->isolate([&] { got = (*field)(row, variant); });
+ return got;
+ }
+ ListBinding* binding;
+ std::function<bool(std::size_t, Rml::Variant&)>* field;
+ };
+
+ // The row struct: a Child per field name (passing the encoded row through).
+ struct RowDef final : Rml::VariableDefinition {
+ explicit RowDef(ListBinding* b)
+ : Rml::VariableDefinition(Rml::DataVariableType::Struct), binding(b) {}
+ Rml::DataVariable Child(void* ptr, const Rml::DataAddressEntry& address) override {
+ auto it = binding->field_defs.find(address.name);
+ if (it == binding->field_defs.end()) {
+ return Rml::DataVariable();
+ }
+ return Rml::DataVariable(it->second.get(), ptr); // ptr already encodes the row
+ }
+ Rml::StringList ReflectMemberNames() override {
+ Rml::StringList names;
+ for (const auto& [n, def] : binding->field_defs) {
+ names.push_back(n);
+ }
+ return names;
+ }
+ ListBinding* binding;
+ };
+
+ // The array: Size() = count(); Child(i) = a row encoding index i.
+ struct ArrayDef final : Rml::VariableDefinition {
+ explicit ArrayDef(ListBinding* b)
+ : Rml::VariableDefinition(Rml::DataVariableType::Array), binding(b) {}
+ int Size(void* /*ptr*/) override {
+ std::size_t n = 0;
+ if (binding->count) {
+ binding->isolate([&] { n = binding->count(); });
+ }
+ return static_cast<int>(n);
+ }
+ Rml::DataVariable Child(void* /*ptr*/, const Rml::DataAddressEntry& address) override {
+ if (address.index < 0) {
+ return Rml::DataVariable();
+ }
+ return Rml::DataVariable(&binding->row_def, encode_row(static_cast<std::size_t>(address.index)));
+ }
+ ListBinding* binding;
+ };
+
+ // The owned definitions (constructed in init(); addresses stable thereafter
+ // because ListBinding lives in a std::list). field_defs maps field name ->
+ // its scalar definition; row_def/array_def are the single struct/array.
+ std::unordered_map<std::string, std::unique_ptr<FieldDef>> field_defs;
+ RowDef row_def{nullptr};
+ ArrayDef array_def{nullptr};
+
+ void init() {
+ // Re-seat the back-pointers now that the ListBinding has its final
+ // address (it was emplaced into the std::list before init()).
+ row_def.binding = this;
+ array_def.binding = this;
+ }
+ auto add_field(const std::string& field, std::function<bool(std::size_t, Rml::Variant&)> fn)
+ -> void {
+ auto [it, inserted] = fields.insert_or_assign(field, std::move(fn));
+ auto def_it = field_defs.find(field);
+ if (def_it == field_defs.end()) {
+ field_defs.emplace(field, std::make_unique<FieldDef>(this, &it->second));
+ } else {
+ def_it->second->field = &it->second; // re-seat after insert_or_assign
+ }
+ }
+};
+
// ---- PreviewState -----------------------------------------------------------
//
// A frozen snapshot of a scene subtree, imported as a sampled GL texture in the
@@ -1267,6 +1420,34 @@ auto Substrate::surface_pixel(int x, int y) const -> std::uint32_t {
return 0;
}
+auto Substrate::element_count(const char* tag) const -> int {
+ for (const Surface& s : impl_->surfaces) {
+ if (s.document == nullptr) {
+ continue;
+ }
+ Rml::ElementList elements;
+ s.document->GetElementsByTagName(elements, Rml::String(tag));
+ return static_cast<int>(elements.size());
+ }
+ return 0;
+}
+
+auto Substrate::click_element(const char* tag, int index) -> bool {
+ for (Surface& s : impl_->surfaces) {
+ if (s.document == nullptr) {
+ continue;
+ }
+ Rml::ElementList elements;
+ s.document->GetElementsByTagName(elements, Rml::String(tag));
+ if (index < 0 || index >= static_cast<int>(elements.size())) {
+ return false;
+ }
+ elements[static_cast<std::size_t>(index)]->Click();
+ return true;
+ }
+ return false;
+}
+
auto Substrate::orientation() const -> int {
for (const Surface& s : impl_->surfaces) {
if (s.dmabuf || s.shm == nullptr || s.frame_count == 0) {
@@ -1409,6 +1590,115 @@ void SurfaceHandle::bind_event(std::string_view name, std::function<void()> call
});
}
+namespace {
+// Find an existing list binding by name in the surface, or nullptr.
+auto find_list(Surface& s, std::string_view name) -> Surface::ListBinding* {
+ for (auto& b : s.list_bindings) {
+ if (b.name == name) {
+ return &b;
+ }
+ }
+ return nullptr;
+}
+} // namespace
+
+void SurfaceHandle::bind_list(std::string_view name, std::function<std::size_t()> count) {
+ Surface& s = *surface_;
+ if (!s.ctor) {
+ return;
+ }
+ Surface::ListBinding* b = find_list(s, name);
+ if (b == nullptr) {
+ s.list_bindings.emplace_back();
+ b = &s.list_bindings.back();
+ b->name = std::string(name);
+ b->who = s.who;
+ b->disable = s.owner->disable;
+ b->init(); // stable address now -> seat the definition back-pointers
+ // Bind the array variable under the list name; data-for reads its
+ // Size()/Child() to iterate, and each row's Child() resolves the fields.
+ s.ctor.BindCustomDataVariable(b->name,
+ Rml::DataVariable(&b->array_def, nullptr));
+ }
+ b->count = std::move(count);
+}
+
+// One template for the four typed field binds: wrap the typed getter in a
+// Variant-producing closure (Variant's templated setter handles each type),
+// then register it on the list's row struct under `field`.
+namespace {
+template <typename T, typename Getter>
+void bind_list_field_impl(Surface& s, std::string_view list, std::string_view field,
+ Getter getter) {
+ if (!s.ctor) {
+ return;
+ }
+ Surface::ListBinding* b = find_list(s, list);
+ if (b == nullptr) {
+ // The list must be declared first (bind_list); a field on an unknown
+ // list is dropped (documented: register the list before its fields...
+ // they may interleave, but the list name must exist).
+ wlr_log(WLR_INFO, "ui-substrate: bind_list field '%.*s' for unknown list '%.*s'",
+ static_cast<int>(field.size()), field.data(),
+ static_cast<int>(list.size()), list.data());
+ return;
+ }
+ b->add_field(std::string(field),
+ [getter = std::move(getter)](std::size_t row, Rml::Variant& out) -> bool {
+ out = static_cast<T>(getter(row));
+ return true;
+ });
+}
+} // namespace
+
+void SurfaceHandle::bind_list_string(std::string_view list, std::string_view field,
+ std::function<std::string(std::size_t)> getter) {
+ bind_list_field_impl<Rml::String>(*surface_, list, field, std::move(getter));
+}
+void SurfaceHandle::bind_list_int(std::string_view list, std::string_view field,
+ std::function<int(std::size_t)> getter) {
+ bind_list_field_impl<int>(*surface_, list, field, std::move(getter));
+}
+void SurfaceHandle::bind_list_double(std::string_view list, std::string_view field,
+ std::function<double(std::size_t)> getter) {
+ bind_list_field_impl<double>(*surface_, list, field, std::move(getter));
+}
+void SurfaceHandle::bind_list_bool(std::string_view list, std::string_view field,
+ std::function<bool(std::size_t)> getter) {
+ bind_list_field_impl<bool>(*surface_, list, field, std::move(getter));
+}
+
+void SurfaceHandle::bind_list_event(std::string_view /*list*/, std::string_view event,
+ std::function<void(std::size_t)> callback) {
+ // A row event is a normal data-event callback; the row index arrives as the
+ // first data-expression argument (author it as data-event-click="ev(it_index)").
+ // The event name is model-global (RmlUi has no per-list event namespace), so
+ // `list` is documentary only — keep names unique per surface.
+ Surface& s = *surface_;
+ if (!s.ctor) {
+ return;
+ }
+ s.list_event_bindings.push_back({std::move(callback), s.who, s.owner});
+ Surface::ListEventBinding* binding = &s.list_event_bindings.back();
+ s.ctor.BindEventCallback(
+ std::string(event),
+ [binding](Rml::DataModelHandle, Rml::Event&, const Rml::VariantList& args) {
+ try {
+ if (binding->cb) {
+ std::size_t row = 0;
+ if (!args.empty()) {
+ row = static_cast<std::size_t>(args[0].Get<int>());
+ }
+ binding->cb(row);
+ }
+ } catch (...) {
+ if (binding->owner->disable) {
+ binding->owner->disable(binding->who);
+ }
+ }
+ });
+}
+
void SurfaceHandle::on_touch_mode_changed(std::function<void(bool)> callback) {
surface_->touch_mode_cb = std::move(callback);
}
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
index cdba997..33dfd4e 100644
--- a/packages/kernel/src/ui_substrate.hpp
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -97,6 +97,17 @@ public:
void bind_bool(std::string_view name, std::function<bool()> getter) override;
void bind_string(std::string_view name, std::function<std::string()> getter) override;
void bind_event(std::string_view name, std::function<void()> callback) override;
+ void bind_list(std::string_view name, std::function<std::size_t()> count) override;
+ void bind_list_string(std::string_view list, std::string_view field,
+ std::function<std::string(std::size_t)> getter) override;
+ void bind_list_int(std::string_view list, std::string_view field,
+ std::function<int(std::size_t)> getter) override;
+ void bind_list_double(std::string_view list, std::string_view field,
+ std::function<double(std::size_t)> getter) override;
+ void bind_list_bool(std::string_view list, std::string_view field,
+ std::function<bool(std::size_t)> getter) override;
+ void bind_list_event(std::string_view list, std::string_view event,
+ std::function<void(std::size_t)> callback) override;
void on_touch_mode_changed(std::function<void(bool)> callback) override;
void dirty(std::string_view name) override;
void dirty() override;
@@ -178,6 +189,12 @@ public:
// / out of bounds. A position-aware probe (like orientation()) so the suite
// can assert a preview's known source color landed at the expected spot.
[[nodiscard]] auto surface_pixel(int x, int y) const -> std::uint32_t;
+ // Count elements with `tag` in the first surface's loaded document (0 if no
+ // surface / not loaded yet). Proves a data-for list rendered N rows.
+ [[nodiscard]] auto element_count(const char* tag) const -> int;
+ // Click the index-th `tag` element in the first surface's document (fires
+ // its data-event-click). False if no such element. Drives a row event.
+ auto click_element(const char* tag, int index) -> bool;
struct Impl;
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 20cd559..2c9b2fd 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -575,6 +575,136 @@ TEST_CASE("preview: known source color composites into an <img> (position-aware)
}
// ============================================================================
+// slice-10 / b2 LIST BINDINGS. The stage dock is one document iterating a
+// VARIABLE list of slots with data-for; each row reads string fields and a
+// per-row click event delivers the row index back to the extension. The suite
+// proves through the PUBLIC Host::ui() path: (1) a list of N rows renders N row
+// elements, (2) mutating the backing vector + dirty(list) changes the rendered
+// row count on the next tick, and (3) clicking a row fires the per-row callback
+// with the correct index. Headless+gles2 exercises the GL bridge.
+// ============================================================================
+
+namespace {
+
+// The dock document: a row <p> per slot, each carrying the slot's title text
+// and a per-row click that calls restore(it_index). The row tag is <p> so the
+// element-count probe counts exactly the rows (no other <p> in the body).
+const char* kListRml = R"RML(<rml>
+<head>
+<style>
+body { font-family: "Noto Sans"; background: #1e2230; color: #e8ecff;
+ width: 320px; height: 240px; }
+p { display: block; width: 320px; height: 24px; }
+</style>
+</head>
+<body data-model="ui">
+<p data-for="row : slots" data-event-click="restore(it_index)"><span>{{ row.title }} {{ row.fav }}</span></p>
+</body>
+</rml>)RML";
+
+// A test extension owning a ui surface bound to a runtime-sized slot list.
+class ListTestExtension : public unbox::kernel::Extension {
+public:
+ auto manifest() const -> const Manifest& override { return manifest_; }
+
+ void activate(Host& host) override {
+ titles = {"alpha", "beta", "gamma"};
+ UiSurfaceSpec spec;
+ spec.rml_inline = kListRml;
+ spec.x = 0;
+ spec.y = 0;
+ spec.width = 320;
+ spec.height = 240;
+ spec.visible = true;
+ surface_ = host.ui().create_surface(spec);
+ if (surface_ != nullptr) {
+ surface_->bind_list("slots", [this] { return titles.size(); });
+ surface_->bind_list_string("slots", "title",
+ [this](std::size_t r) { return titles.at(r); });
+ // A second string field proves multiple per-row fields coexist.
+ surface_->bind_list_string("slots", "fav",
+ [](std::size_t r) { return "icon" + std::to_string(r); });
+ surface_->bind_list_event("slots", "restore",
+ [this](std::size_t r) {
+ last_restored = static_cast<int>(r);
+ ++restore_calls;
+ });
+ }
+ }
+
+ void set_rows(std::vector<std::string> rows) {
+ titles = std::move(rows);
+ if (surface_ != nullptr) {
+ surface_->dirty("slots");
+ }
+ }
+
+ std::vector<std::string> titles;
+ int last_restored = -1;
+ int restore_calls = 0;
+ [[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; }
+
+private:
+ Manifest manifest_{"list-test", Tier::standard, {}};
+ std::unique_ptr<UiSurface> surface_;
+};
+
+} // namespace
+
+TEST_CASE("substrate: data-for list renders N rows, re-renders on dirty, routes row events") {
+ setenv("WLR_BACKENDS", "headless", 1);
+ setenv("WLR_RENDERER", "gles2", 1);
+ setenv("WLR_HEADLESS_OUTPUTS", "1", 1);
+ setenv("UNBOX_UI_SUBSTRATE_FORCE_SHM", "1", 1);
+
+ auto server = unbox::kernel::Server::create({});
+ auto* ext = new ListTestExtension();
+ server->install(std::unique_ptr<unbox::kernel::Extension>(ext));
+ server->activate_extensions();
+ pump(*server, 60); // load the document + run the data-for loop
+
+ if (!ext->has_surface() || server->ui_frame_count() == 0) {
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM"); // no GL path on this box: skip
+ return;
+ }
+
+ // (1) Three rows in the backing vector => three rendered rows. Each
+ // rendered row carries a <span> (the data-for template <p> keeps no span
+ // child — its inner RML is extracted — so counting <span> counts exactly
+ // the rendered rows).
+ CHECK(server->ui_element_count("span") == 3);
+
+ // (2) Grow then shrink the list + dirty(list): the rendered row count tracks
+ // count() on the next tick.
+ ext->set_rows({"one", "two", "three", "four", "five"});
+ pump(*server, 5);
+ CHECK(server->ui_element_count("span") == 5);
+
+ ext->set_rows({"solo"});
+ pump(*server, 5);
+ CHECK(server->ui_element_count("span") == 1);
+
+ // (3) Restore three rows and click the middle one: the per-row callback
+ // fires with the right index (data-event-click="restore(it_index)"). The
+ // generated rows occupy <p> indices 0..N-1 (the hidden template <p> is last).
+ ext->set_rows({"r0", "r1", "r2"});
+ pump(*server, 5);
+ CHECK(server->ui_element_count("span") == 3);
+ const int before = ext->restore_calls;
+ REQUIRE(server->ui_click_element("p", 1));
+ CHECK(ext->restore_calls == before + 1);
+ CHECK(ext->last_restored == 1);
+
+ // Click row 0 and row 2 to prove the index is the real row, not a constant.
+ REQUIRE(server->ui_click_element("p", 0));
+ CHECK(ext->last_restored == 0);
+ REQUIRE(server->ui_click_element("p", 2));
+ CHECK(ext->last_restored == 2);
+
+ unsetenv("UNBOX_UI_SUBSTRATE_FORCE_SHM");
+}
+
+// ============================================================================
// VT-switch escape hatch — PURE CORE (no wlroots): keysym -> VT number. The
// glue (input.cpp) calls wlr_session_change_vt on a hit and consumes; this
// helper decides the hit. Ctrl+Alt+Fn arrives as XF86Switch_VT_1..12.