diff options
| author | Adam Malczewski <[email protected]> | 2026-06-15 14:17:19 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-15 14:17:19 +0900 |
| commit | f0f372ef6ca9cf77ca774583192e83a4cde88d32 (patch) | |
| tree | 871a330407a0f0fca0f32b50edcdea805fece79a | |
| parent | ed1e3d60fe209af06911c640a145936f71adc238 (diff) | |
| download | unbox-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.cpp | 16 | ||||
| -rw-r--r-- | packages/ext-window-field/tests/test_glue.cpp | 9 | ||||
| -rw-r--r-- | packages/kernel/include/unbox/kernel/ui.hpp | 17 | ||||
| -rw-r--r-- | packages/kernel/src/server.cpp | 5 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.cpp | 57 | ||||
| -rw-r--r-- | packages/kernel/src/ui_substrate.hpp | 12 | ||||
| -rw-r--r-- | packages/kernel/tests/test_kernel.cpp | 49 |
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. |
