summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-15 14:17:19 +0900
committerAdam Malczewski <[email protected]>2026-06-15 14:17:19 +0900
commitf0f372ef6ca9cf77ca774583192e83a4cde88d32 (patch)
tree871a330407a0f0fca0f32b50edcdea805fece79a
parented1e3d60fe209af06911c640a145936f71adc238 (diff)
downloadunbox-f0f372ef6ca9cf77ca774583192e83a4cde88d32.tar.gz
unbox-f0f372ef6ca9cf77ca774583192e83a4cde88d32.zip
rml-compositing W3: click/tap-to-focus (kernel on_pressed + window-field)
Completes the window-manager input story. kernel (additive): SurfaceElement::on_pressed(std::function<void()>) — the substrate invokes it (error-isolated to the owner) when a pointer button PRESS or touch DOWN is routed to that surface element (root or any subsurface/popup child fires the root element's handler), in addition to the existing client input-back forwarding. Not fired on motion/release/miss. It is the click/tap-to-focus SIGNAL; focus policy stays the wm's. ext-window-field: on map, sets the element's on_pressed -> that toplevel's Toplevel::focus() (keyboard focus + on_toplevel_focused -> RCSS raise/highlight), guarded on the window still being tracked. So a click/tap on a background window now focuses it (the gap noted in Wave 3). kernel + ext-window-field + ext-xdg-shell suites green; build-asan green, no new unbox::-framed leak/UB (the stored handler dies with the element).
-rw-r--r--packages/ext-window-field/src/extension.cpp16
-rw-r--r--packages/ext-window-field/tests/test_glue.cpp9
-rw-r--r--packages/kernel/include/unbox/kernel/ui.hpp17
-rw-r--r--packages/kernel/src/server.cpp5
-rw-r--r--packages/kernel/src/ui_substrate.cpp57
-rw-r--r--packages/kernel/src/ui_substrate.hpp12
-rw-r--r--packages/kernel/tests/test_kernel.cpp49
7 files changed, 156 insertions, 9 deletions
diff --git a/packages/ext-window-field/src/extension.cpp b/packages/ext-window-field/src/extension.cpp
index c1d297e..911283b 100644
--- a/packages/ext-window-field/src/extension.cpp
+++ b/packages/ext-window-field/src/extension.cpp
@@ -172,6 +172,22 @@ private:
}
}
+ // Click/tap-to-focus: a press/down routed to this element's tree focuses
+ // its window. The handler captures `tl` (this window's identity, valid
+ // map..unmapped) but is robust — it only acts if that window is STILL
+ // tracked (index_of >= 0), so a stale fire is a harmless no-op. It is
+ // tiny + non-throwing (Toplevel::focus() is no-op if unmapped) and
+ // error-isolated by the substrate. focus() gives keyboard focus + fires
+ // on_toplevel_focused, so focused_ updates and RCSS raises/highlights it.
+ // The stored std::function dies with the element (dropped on unmap).
+ if (w.element != nullptr) {
+ w.element->on_pressed([this, tl] {
+ if (index_of(tl) >= 0) {
+ tl->focus();
+ }
+ });
+ }
+
// Take the toplevel OUT of wlr_scene: the surface element is now the ONLY
// compositor of those pixels (the substrate drives the client's frame
// callbacks). hide() is NOT unmap — the client stays mapped, its Toplevel*
diff --git a/packages/ext-window-field/tests/test_glue.cpp b/packages/ext-window-field/tests/test_glue.cpp
index d49d26f..b95d1d1 100644
--- a/packages/ext-window-field/tests/test_glue.cpp
+++ b/packages/ext-window-field/tests/test_glue.cpp
@@ -316,8 +316,13 @@ TEST_CASE("ext-window-field: map tracks a window + hides it; a 2nd grows the lis
CHECK(probe->focused_index() == 1); // map-focus moved to the 2nd window
// --- on_toplevel_focused flips the focused flag: re-focus the FIRST window
- // via Toplevel::focus() (exactly what ext-keybindings' Alt+Tab does). The
- // field's focused index must follow back to window 0.
+ // via Toplevel::focus() (exactly what ext-keybindings' Alt+Tab does AND what
+ // the new click/tap-to-focus on_pressed handler calls). On headless pixman no
+ // surface element exists, so the live element's on_pressed handler cannot be
+ // driven through the probe; the wiring compiles and this assertion proves the
+ // SAME focus mechanism it routes through flips the flag. The press-path itself
+ // is covered by the kernel's own on_pressed test. The field's focused index
+ // must follow back to window 0.
REQUIRE(observer->first_mapped() == tl1);
tl1->focus();
for (int i = 0; i < 50 && probe->focused_index() != 0; ++i) {
diff --git a/packages/kernel/include/unbox/kernel/ui.hpp b/packages/kernel/include/unbox/kernel/ui.hpp
index a5e29fc..e0800a3 100644
--- a/packages/kernel/include/unbox/kernel/ui.hpp
+++ b/packages/kernel/include/unbox/kernel/ui.hpp
@@ -386,6 +386,23 @@ public:
// the seat never stays focused on a dead surface.
virtual void focus_keyboard() = 0;
+ // The click/tap-to-focus SIGNAL. The substrate invokes `handler` (event-loop
+ // thread, ERROR-ISOLATED to the owning extension like every other ui callback
+ // — a throw disables YOUR extension only, never the session) when a pointer
+ // button PRESS or a touch DOWN is routed to THIS element: its ROOT surface OR
+ // any of its subsurface/popup child nodes (the element is the whole tree). It
+ // fires IN ADDITION to the automatic input-back that forwards the press/down
+ // to the client; it is purely the "this window was pressed" notification a
+ // window manager raises focus on. The handler decides POLICY (e.g. call
+ // ext-xdg-shell::Toplevel::focus()); the kernel signals only WHICH element.
+ //
+ // NOT fired on pointer motion, on a button RELEASE / touch UP, or on a
+ // press/down that MISSES every surface element (lands on plain RML or a gap).
+ // One handler per element; re-setting REPLACES it. Default is none (no-op).
+ // Capture only state that outlives this element (the stored std::function
+ // dies with the element).
+ virtual void on_pressed(std::function<void()> handler) = 0;
+
// NO refresh() (unlike Preview): a surface element updates itself every
// client commit (seq-gated re-import) and drives the client's frame
// callbacks while it exists.
diff --git a/packages/kernel/src/server.cpp b/packages/kernel/src/server.cpp
index f88fa8f..d4f47c8 100644
--- a/packages/kernel/src/server.cpp
+++ b/packages/kernel/src/server.cpp
@@ -107,7 +107,8 @@ auto Server::ui_create_surface_element_for_test() -> bool {
return false;
}
impl_->test_surface_element =
- impl_->substrate->create_surface_element(impl_->test_last_client_surface);
+ impl_->substrate->create_surface_element(kernel_extension_id,
+ impl_->test_last_client_surface);
return impl_->test_surface_element != nullptr;
}
@@ -261,7 +262,7 @@ auto PerExtensionUi::create_surface_element(wlr_surface* client)
if (server_->substrate == nullptr) {
return nullptr;
}
- return server_->substrate->create_surface_element(client);
+ return server_->substrate->create_surface_element(id_, client);
}
auto PerExtensionUi::available() const -> bool {
diff --git a/packages/kernel/src/ui_substrate.cpp b/packages/kernel/src/ui_substrate.cpp
index 0a4b508..fd42a44 100644
--- a/packages/kernel/src/ui_substrate.cpp
+++ b/packages/kernel/src/ui_substrate.cpp
@@ -659,6 +659,14 @@ struct SurfaceNode {
struct SurfaceElementState {
Substrate::Impl* owner = nullptr;
int id = 0;
+ // The OWNING extension (for error isolation of on_pressed). A throwing
+ // on_pressed handler disables THIS extension only, via owner->disable(who).
+ ExtensionId who{};
+
+ // The click/tap-to-focus SIGNAL handler (ui.hpp SurfaceElement::on_pressed):
+ // invoked, error-isolated, when a pointer-button PRESS or a touch DOWN is
+ // routed to this element (root OR a child node). Default none (no-op).
+ std::function<void()> on_pressed_cb;
// The ROOT node. `root.surface` is the wl_surface passed to
// create_surface_element — a BORROW the caller outlives (ui.hpp). `root.uri`
@@ -874,6 +882,12 @@ struct Substrate::Impl {
std::uint32_t button, std::uint32_t time_msec) -> bool;
auto forward_touch_to_client(Surface& surf, double lx, double ly, std::int32_t id,
bool down, std::uint32_t time_msec) -> bool;
+ // Fire `el`'s on_pressed handler (the click/tap-to-focus SIGNAL), if set.
+ // Called ONLY for a pointer-button PRESS / touch DOWN routed to a surface
+ // element (root OR child) — never on motion/release/miss. Error-isolated to
+ // the owning extension (a throw => disable(el.who)), like the other ui
+ // callbacks; never takes down the session.
+ void fire_pressed(SurfaceElementState& el);
// Keyboard-focus PRIMITIVE (mechanism only — focus POLICY is a later wave).
// Give `el`'s ROOT client surface seat keyboard focus + send the enter with
// the active keyboard's pressed keys + modifiers, so the kernel's existing
@@ -1973,6 +1987,15 @@ auto Substrate::Impl::forward_pointer_to_client(Surface& surf, double lx, double
wlr_seat_pointer_notify_clear_focus(seat);
return false;
}
+ // Click/tap-to-focus SIGNAL (ui.hpp SurfaceElement::on_pressed): a PRESS that
+ // lands on this element (root OR child) notifies the OWNER, in addition to the
+ // client forwarding below. `el` is the owning element (root), so a child-node
+ // press fires the ROOT element's handler. Fired only for button_down (not
+ // motion/release) and only on a hit — and BEFORE the coord projection, so the
+ // focus signal fires even for an edge-on element where no client coord exists.
+ if (kind == PointerKind::button_down && el != nullptr) {
+ fire_pressed(*el);
+ }
// Project the screen point onto the node img's OWN (possibly 3D-transformed)
// plane FIRST (Element::Project: the spike fix; no-op when untransformed),
// then box->surface-local scale. The point fed to the context was in
@@ -2023,6 +2046,13 @@ auto Substrate::Impl::forward_touch_to_client(Surface& surf, double lx, double l
if (node == nullptr || node->surface == nullptr) {
return false;
}
+ // Click/tap-to-focus SIGNAL (mirror forward_pointer_to_client): a touch DOWN
+ // that lands on this element (root OR child) notifies the OWNER too. `el` is
+ // the root element, so a child-node down fires the ROOT element's handler.
+ // Only on a DOWN (not a motion) and only on a hit, before coord projection.
+ if (down && el != nullptr) {
+ fire_pressed(*el);
+ }
Rml::Element* img = find_img_by_src(surf.document, node->uri);
if (img == nullptr) {
return false;
@@ -2047,6 +2077,23 @@ auto Substrate::Impl::forward_touch_to_client(Surface& surf, double lx, double l
return true;
}
+void Substrate::Impl::fire_pressed(SurfaceElementState& el) {
+ // The click/tap-to-focus SIGNAL. Error-isolated exactly like the other
+ // substrate callbacks (bind_event / bind_drag): a throwing handler disables
+ // the OWNING extension only (owner->disable(who)), never the session. A
+ // missing handler (default) is a no-op.
+ if (!el.on_pressed_cb) {
+ return;
+ }
+ try {
+ el.on_pressed_cb();
+ } catch (...) {
+ if (disable) {
+ disable(el.who);
+ }
+ }
+}
+
void Substrate::Impl::focus_keyboard(SurfaceElementState& el) {
// Mechanism only (focus POLICY is Wave 3). Mirror src/input.cpp /
// ext-xdg-shell discipline: pick the seat's current keyboard, set it on the
@@ -2211,7 +2258,8 @@ auto Substrate::create_preview(wlr_scene_tree* source) -> std::unique_ptr<Previe
auto Substrate::preview_import_is_dmabuf() const -> bool { return impl_->last_preview_dmabuf; }
-auto Substrate::create_surface_element(wlr_surface* client) -> std::unique_ptr<SurfaceElement> {
+auto Substrate::create_surface_element(ExtensionId who, wlr_surface* client)
+ -> std::unique_ptr<SurfaceElement> {
impl_->last_surface_element_dmabuf = false;
if (!impl_->available() || client == nullptr) {
return nullptr; // no GL path or no surface: graceful degrade (ui.hpp)
@@ -2220,6 +2268,7 @@ auto Substrate::create_surface_element(wlr_surface* client) -> std::unique_ptr<S
impl_->surface_elements.emplace_back();
SurfaceElementState& s = impl_->surface_elements.back();
s.owner = impl_.get();
+ s.who = who; // for on_pressed error isolation
s.id = ++impl_->next_surface_id;
s.root.surface = client;
s.root.uri = surface_element_uri(s.id); // == source_uri()
@@ -3116,4 +3165,10 @@ auto SurfaceElementHandle::height() const -> int { return state_->root.height; }
void SurfaceElementHandle::focus_keyboard() { substrate_->impl_->focus_keyboard(*state_); }
+void SurfaceElementHandle::on_pressed(std::function<void()> handler) {
+ // One handler per element; re-setting replaces it (the stored std::function
+ // lives in the SurfaceElementState and dies with the element).
+ state_->on_pressed_cb = std::move(handler);
+}
+
} // namespace unbox::kernel
diff --git a/packages/kernel/src/ui_substrate.hpp b/packages/kernel/src/ui_substrate.hpp
index 2c2ffe3..bdedd9f 100644
--- a/packages/kernel/src/ui_substrate.hpp
+++ b/packages/kernel/src/ui_substrate.hpp
@@ -109,6 +109,7 @@ public:
[[nodiscard]] auto width() const -> int override;
[[nodiscard]] auto height() const -> int override;
void focus_keyboard() override;
+ void on_pressed(std::function<void()> handler) override;
private:
Substrate* substrate_;
@@ -195,10 +196,13 @@ public:
auto create_preview(wlr_scene_tree* source) -> std::unique_ptr<Preview>;
// Create a LIVE surface element backed by `client`'s current buffer (the
- // live sibling of create_preview). Returns nullptr if unavailable or the
- // initial import failed. `client` is a borrow the caller must outlive (see
- // ui.hpp UiSubstrate::create_surface_element). Never throws.
- auto create_surface_element(wlr_surface* client) -> std::unique_ptr<SurfaceElement>;
+ // live sibling of create_preview), owned by `who` (so a throwing on_pressed
+ // handler disables that extension only — error isolation, like create_surface).
+ // Returns nullptr if unavailable or the initial import failed. `client` is a
+ // borrow the caller must outlive (see ui.hpp UiSubstrate::create_surface_element).
+ // Never throws.
+ auto create_surface_element(ExtensionId who, wlr_surface* client)
+ -> std::unique_ptr<SurfaceElement>;
// True while >=1 surface element exists: the kernel keeps a frame scheduled
// so the frame-callback duty (send_frame_done_to_surface_elements) keeps the
diff --git a/packages/kernel/tests/test_kernel.cpp b/packages/kernel/tests/test_kernel.cpp
index 0bf6349..2c36df7 100644
--- a/packages/kernel/tests/test_kernel.cpp
+++ b/packages/kernel/tests/test_kernel.cpp
@@ -3135,6 +3135,15 @@ public:
element_->focus_keyboard();
}
}
+ // Register the click/tap-to-focus press hook on the element, counting each
+ // fire (so the test can assert it fires once per press/down and never on
+ // motion/miss). Call after the element exists.
+ void install_press_counter() {
+ if (element_ != nullptr) {
+ element_->on_pressed([this] { ++pressed_count_; });
+ }
+ }
+ [[nodiscard]] auto pressed_count() const -> int { return pressed_count_; }
[[nodiscard]] auto has_element() const -> bool { return element_ != nullptr; }
[[nodiscard]] auto has_surface() const -> bool { return surface_ != nullptr; }
@@ -3166,6 +3175,7 @@ private:
wlr_xdg_shell* xdg_shell_ = nullptr;
wlr_surface* root_surface_ = nullptr;
double transform_deg_ = 0.0;
+ int pressed_count_ = 0;
unbox::kernel::Listener new_toplevel_, new_popup_, map_, commit_, popup_commit_;
std::unique_ptr<unbox::kernel::SurfaceElement> element_;
std::unique_ptr<UiSurface> surface_;
@@ -3573,6 +3583,11 @@ TEST_CASE("surface-element: tree (subsurface + popup) + input-back + keyboard fo
}
pump(*server, 40); // let the tree re-walk + child <img> placement settle
+ // Install the click/tap-to-focus press hook (ui.hpp SurfaceElement::on_pressed).
+ // It must fire exactly once per pointer PRESS / touch DOWN routed to the
+ // element (root OR a child node), and NEVER on motion or on a miss.
+ ext->install_press_counter();
+
// (A) TREE: the root + the subsurface + the popup compose as per-node <img>
// elements (root authored; subsurface + popup created by the substrate).
INFO("img count = ", server->ui_element_count("img"));
@@ -3600,6 +3615,9 @@ TEST_CASE("surface-element: tree (subsurface + popup) + input-back + keyboard fo
CHECK(client.last_ptr_x.load() == doctest::Approx(50.0).epsilon(0.05));
CHECK(client.last_ptr_y.load() == doctest::Approx(60.0).epsilon(0.05));
+ // (B) PRESS HOOK: a MOTION over the element does NOT fire the press hook.
+ CHECK(ext->pressed_count() == 0);
+
// (B) INPUT-BACK pointer over the SUBSURFACE node: the subsurface is 40x40 at
// tree offset (20,30); a point at layout (40+30, 30+45) => surface-local
// (30,45) on the root, which lands inside the subsurface (its <img> spans
@@ -3614,22 +3632,53 @@ TEST_CASE("surface-element: tree (subsurface + popup) + input-back + keyboard fo
// (B) INPUT-BACK button: a press over the root forwards a wl_pointer button.
const int btn0 = client.ptr_buttons.load();
+ const int pressed0 = ext->pressed_count();
server->ui_route_pointer_button_for_test(TreeTestExtension::kSurfX + 50.0,
TreeTestExtension::kSurfY + 60.0, true, 1020);
+ // (B) PRESS HOOK: the PRESS fired the hook EXACTLY ONCE (the click/tap-to-
+ // focus signal, in addition to the client forwarding above).
+ CHECK(ext->pressed_count() == pressed0 + 1);
server->ui_route_pointer_button_for_test(TreeTestExtension::kSurfX + 50.0,
TreeTestExtension::kSurfY + 60.0, false, 1021);
+ // (B) PRESS HOOK: the RELEASE does NOT fire the hook (still one fire total).
+ CHECK(ext->pressed_count() == pressed0 + 1);
pump_until_se(*server, [&] { return client.ptr_buttons.load() > btn0; }, 200);
CHECK(client.ptr_buttons.load() > btn0);
+ // (B) PRESS HOOK over a CHILD node: a press at layout (40+30, 30+45) lands on
+ // the SUBSURFACE child <img> (its region (20,30)..(60,70) in root-local px),
+ // and STILL fires the ROOT element's handler — the element is the whole tree.
+ const int pressed_child0 = ext->pressed_count();
+ server->ui_route_pointer_button_for_test(TreeTestExtension::kSurfX + 30.0,
+ TreeTestExtension::kSurfY + 45.0, true, 1024);
+ server->ui_route_pointer_button_for_test(TreeTestExtension::kSurfX + 30.0,
+ TreeTestExtension::kSurfY + 45.0, false, 1025);
+ CHECK(client.ptr_enter_surface.load() == 1); // the pick hit the subsurface
+ CHECK(ext->pressed_count() == pressed_child0 + 1); // root handler still fired ONCE
+
+ // (B) PRESS HOOK miss: a press OFF the element (far outside the 200x200 surface)
+ // does NOT fire the hook.
+ const int pressed_miss0 = ext->pressed_count();
+ server->ui_route_pointer_button_for_test(TreeTestExtension::kSurfX + 1000.0,
+ TreeTestExtension::kSurfY + 1000.0, true, 1028);
+ server->ui_route_pointer_button_for_test(TreeTestExtension::kSurfX + 1000.0,
+ TreeTestExtension::kSurfY + 1000.0, false, 1029);
+ CHECK(ext->pressed_count() == pressed_miss0);
+
// (B) INPUT-BACK touch: a touch-down over the root forwards a wl_touch down at
// surface-local coords.
+ const int pressed_touch0 = ext->pressed_count();
server->ui_route_touch_down_for_test(7, TreeTestExtension::kSurfX + 50.0,
TreeTestExtension::kSurfY + 60.0, 1030);
pump_until_se(*server, [&] { return client.touch_downs.load() > 0; }, 200);
CHECK(client.touch_downs.load() > 0);
CHECK(client.last_touch_x.load() == doctest::Approx(50.0).epsilon(0.05));
CHECK(client.last_touch_y.load() == doctest::Approx(60.0).epsilon(0.05));
+ // (B) PRESS HOOK: the touch DOWN fired the hook exactly once.
+ CHECK(ext->pressed_count() == pressed_touch0 + 1);
server->ui_route_touch_up_for_test(7, 1031);
+ // (B) PRESS HOOK: the touch UP does NOT fire the hook.
+ CHECK(ext->pressed_count() == pressed_touch0 + 1);
// (C) KEYBOARD FOCUS: focusing the element delivers a wl_keyboard enter, then
// a forwarded key reaches the client.