From b8df895129dd35220612390f8d3e667cee896d4a Mon Sep 17 00:00:00 2001 From: LFRon Date: Wed, 9 Sep 2026 16:52:31 +0800 Subject: [PATCH 1/2] 3rdparty/wlroots: xwm: keep X focus on in-client XEmbed subwindows What this fixes --------------- In WPS Office (and other XEmbed-based X11 apps) the input method is dead in the document editing area while it keeps working in the home/search frame. The editor is not a plain Qt child widget: it is an XEmbed plug window on a separate X client connection, reparented inside the Qt shell top-level. To route text into it the shell legitimately issues XSetInputFocus onto that subwindow, exactly what a real X11 window manager allows. Root cause ---------- xwm_handle_focus_in() guards against cross-application focus stealing: when a FocusIn arrives for a window xwm does not manage and whose pid does not match, it snaps input focus back to the previously tracked top-level. XEmbed plug windows are never xwm surfaces (xwm only tracks managed top-levels), so every legitimate in-client focus move into the plug is misread as a steal and yanked back to the shell. The embedded editor never keeps real X input focus, so input-method activation gated on real X focus (XIM/XIC set-focus) never fires: the input method looks dead even though plain X keys still reach the window. The fix ------- Before refocusing, check whether the window that just gained X input focus is located inside the currently focused surface's X subtree. That is an in-application focus move inside the client's own hierarchy -- what a bare X server and KWin leave alone -- so accept it silently instead of grabbing focus back. Genuine cross-application stealing still falls through to the existing refocus path. The check walks upwards from the newly focused window (XQueryTree parent pointers) with a hard cap of 8 hops, stopping at the root window, so it needs at most a handful of synchronous X queries in every case -- one for a plain top-level, two for a framed window. The previous top-down search visited the focused window's whole subtree, which is unbounded in width, on the compositor main loop for every FocusIn that needed the check. A WLR_DEBUG line (child, parent, queries, result) keeps the runtime cost of focus churn observable. Scope: only windows inside the focused top-level's own subtree are exempted; the same-pid (Steam-type) special case, override-redirect handling, the pointer-detail filter and the focus-serial race guard are untouched. --- 3rdparty/wlroots/xwayland/xwm.c | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/3rdparty/wlroots/xwayland/xwm.c b/3rdparty/wlroots/xwayland/xwm.c index f31163d943..f638dc25f6 100644 --- a/3rdparty/wlroots/xwayland/xwm.c +++ b/3rdparty/wlroots/xwayland/xwm.c @@ -1915,6 +1915,48 @@ static bool validate_focus_serial(uint16_t last_focus_seq, uint16_t event_seq) { return true; } +// Returns true if `child` is located inside `parent`'s subtree (not `parent` +// itself), walking at most `max_depth` levels up from `child`. Used to tell +// apart in-application focus moves into XEmbed plug windows (which a real X11 +// window manager never interferes with) from true cross-application focus +// stealing. Walking up from `child` needs at most `max_depth` synchronous X +// queries, while a top-down search has to visit every window of `parent`'s +// subtree (unbounded in width) before it can answer "no" -- which is the +// common case for cross-application focus changes. +static bool window_is_descendant(struct wlr_xwm *xwm, xcb_window_t parent, + xcb_window_t child, unsigned int max_depth) { + xcb_window_t window = child; + unsigned int queries = 0; + bool found = false; + + while (window != parent && queries < max_depth) { + xcb_query_tree_cookie_t cookie = + xcb_query_tree(xwm->xcb_conn, window); + xcb_query_tree_reply_t *reply = + xcb_query_tree_reply(xwm->xcb_conn, cookie, NULL); + if (reply == NULL) { + break; + } + xcb_window_t ancestor = reply->parent; + free(reply); + queries++; + + if (ancestor == parent) { + found = true; + break; + } + if (ancestor == XCB_NONE || ancestor == xwm->screen->root) { + break; + } + window = ancestor; + } + + wlr_log(WLR_DEBUG, "focus descendant check: child=0x%" PRIx32 + " parent=0x%" PRIx32 " queries=%u result=%d", + child, parent, queries, found); + return found; +} + static void xwm_handle_focus_in(struct wlr_xwm *xwm, xcb_focus_in_event_t *ev) { // Ignore pointer focus change events @@ -1951,6 +1993,16 @@ static void xwm_handle_focus_in(struct wlr_xwm *xwm, (xwm->offered_focus && xsurface->pid == xwm->offered_focus->pid))) { xwm_set_focused_window(xwm, xsurface); wl_signal_emit_mutable(&xsurface->events.focus_in, NULL); + } else if (xwm->focus_surface && ev->event != XCB_NONE && + window_is_descendant(xwm, xwm->focus_surface->window_id, + ev->event, 8)) { + // The newly focused window is a subwindow of the currently focused + // surface's hierarchy: an in-client focus move (e.g. XEmbed plug + // windows used by WPS Office and other embedded-toolkit apps). + // A real X11 window manager leaves these alone; refocusing here + // would strip input focus from the embedder's plug and break + // anything inside that window gated on real X focus (notably + // input-method activation via XIM). } else { // Try to prevent clients from changing focus between // applications, by refocusing the previous surface. From a9290e2b6e46fe9e6b31a2cab3c3ddd565471ad9 Mon Sep 17 00:00:00 2001 From: LFRon Date: Wed, 9 Sep 2026 16:52:31 +0800 Subject: [PATCH 2/2] fix(inputmethod): rebuild IME routing and harden XWayland session env Combined input-method fix for treeland sessions: the Wayland input-method stack in waylib, candidate-window anchoring and popup-grab bookkeeping in treeland, and the session environment that X11-side input methods depend on. Wayland input-method stack (waylib): - Route input-method keys through a dedicated WSeat keyboard filter instead of a synthetic wlr_seat_keyboard_grab. The IM grab object and xdg-popup / drag grabs competed for one seat grab slot, and a displaced popup grab left text-input focus, IM activation and keyboard delivery out of sync (folder rename in dde-file-manager broke; XWayland windows stopped receiving keys after popup close). Physical key/modifier events now go to the active input-method keyboard endpoint only while the keyboard focus surface owns an eligible text input, excluding the IM's own virtual keyboards and active drags; popup and drag keep their own wlroots grabs untouched. - Activation stays strictly text-input driven. It is held on the anchor only across the momentary null focus of a window switch, so a focus flickering through null does not tear down and re-create the fcitx5 grab and virtual keyboard. As soon as focus settles on a concrete surface without an eligible text input (terminals, XWayland windows, the compositor's own QML), the input method deactivates: those surfaces must keep their raw keys so the client-side input-method path (XIM / D-Bus frontends) stays in charge, and a held activation would leave the candidate window parented to an unrelated text input. A destroyed or client-disabled anchor still falls through to normal deactivation. - Commits never follow the anchor: a commit is delivered only to the text input exactly matching current keyboard focus. The virtual-keyboard typing fallback stays as a defensive path for commits that arrive while a held activation has not been reconciled against the new keyboard focus yet (latin/symbol keysyms; CJK needs an xwayland-side text-input bridge, out of scope here); other ineligible commits are discarded with a warning instead of being typed at the wrong surface, and the synthetic key events convert the xkb keycode of the keysym lookup back to the evdev keycode wlr_keyboard_notify_key() expects. - Text-input enablement follows the protocols again: a compositor-driven enter/leave is a notification, not a disablement. For text-input-v1 the client activate record is persistent (cleared only by the client's own deactivate or the surface's destruction, enter/leave kept paired), so focus returning to an activated surface re-arms it deterministically. For text-input-v2 the leave no longer emits disabled(); revocation flows only through the client disable request or enabled-surface destruction, and every new enter is preceded by the obsolete leave. - Attach the IM keyboard endpoint to the keyboard group device when it is created (fcitx5 recreates its virtual keyboard right before grab requests), take keyboard-focus enter payloads from the group, restore the group keyboard when a virtual keyboard dies, guard Qt repeat handling against invalid keyboards/disabled rates, and re-run instead of dropping re-entrant focus reconciliations. - Popup focus tracking (seatsurfacemanager) validates exact wlr_xdg_popup_grab membership, tracks replacement and end events separately, restores the concrete pre-popup focus only while the tracked popup still owns focus, and dismisses the concrete popup instead of ending an arbitrary seat grab. - Categorized transition logs (input-method / text-input / popup-focus) carry object and state identifiers only, never key codes, surrounding text, preedit or committed user input. Input-method popup anchoring (waylib + treeland): - Candidate windows (zwp_input_popup_surface_v2) are anchored to the text input that currently owns the input-method focus, not to the surface that happened to be focused when the input method created its panel, so the candidate window follows one application switching text focus between its own windows. * waylib announces the settled focus surface through a new textInputFocusSurfaceChanged signal, emitted once the focus reconciliation settled (idempotent, nothing emitted during teardown). * A popup surface that arrives before any text input is eligible is parked in a pending list and attached on the next reconciliation. The input method reuses its panel surface until the panel hides, so dropping the popup kept the candidate window invisible for that whole period. * treeland resolves the placement parent of an input popup dynamically (the current text-input focus surface looked up in the root surface container, falling back to the popup's own recorded parent), re-parents the popup to the placement parent's output and re-arranges it whenever the text-input focus surface changes. * Input-popup add/remove handling is null-guarded on both sides: a popup whose anchoring surface is already gone is skipped symmetrically instead of dereferencing a wrapper that was never created. XWayland session environment (src/systemd-socket.cpp, misc/systemd/.../treeland-xwayland.service.in): - Publish DISPLAY and XAUTHORITY through systemd1.SetEnvironment in addition to UpdateActivationEnvironment. Transient session units (How apps and fcitx5 are started via StartTransientUnit) inherit the *manager* environment, not the activation one, so X-side input methods were blind to the XWayland display in treeland sessions; dde-session performs this same call on KWin, which is why it only worked there. UnsetEnvironment and ExecStop now clear XAUTHORITY alongside DISPLAY. - Gate unit readiness on the compositor's ActivateWayland business reply: a successful D-Bus call returning false means the compositor is registered but not yet ready for this session socket (login handover). Retried in a bounded ~20s window, then degraded to the historic publish-anyway path so the session can never hang; this stops advertising WAYLAND_DISPLAY and autostarting input methods against a socket nobody serves, where they stayed permanently broken. Input-method Wayland reconnect (src/systemd-socket.cpp): - After a successful wayland activation, hand a fresh Wayland connection to the running input method (fcitx5). fcitx5 opens its Wayland connection once at startup and never retries, so a compositor restart used to leave it alive but without its Wayland input-method frontend until it was restarted by hand. treeland-sd now connects a client socket to the socket it just activated and calls org.fcitx.Fcitx.Controller1.ReopenWaylandConnectionSocket over D-Bus, which replaces fcitx5's main connection instead of adding a second one, so the compositor never sees two input methods. The call is asynchronous (never delays READY=1), is skipped when fcitx5 is not running (D-Bus activation stays disabled), is retried at most once, and only logs on failure. The degraded publish-anyway path never hands over a socket that nothing is serving. Stacking robustness (src/surface/surfacewrapper.cpp): - SurfaceWrapper::stackAfter() falls back to the sub-chain head (validated as a sibling by the entry guard) when its deepest stacked member lives in a different QQuickItem container, as X11 transient children may: QQuickItem::stackAfter() silently rejects non-siblings and aborted the raise with the stacking bookkeeping left out of sync. Together with the vendored wlroots XEmbed focus exemption committed separately, the input method now works in native Wayland clients (folder rename and others, including across popups and window switches, with the candidate window following the focused window), in XWayland clients (WPS Office document editing included), and survives a compositor restart without fcitx5 having to be restarted by hand. --- .../treeland-xwayland.service.in | 3 +- src/core/shellhandler.cpp | 36 +- src/core/shellhandler.h | 4 + src/output/output.cpp | 107 ++- src/output/output.h | 18 +- src/surface/seatsurfacemanager.cpp | 236 ++++- src/surface/seatsurfacemanager.h | 13 +- src/surface/surfacewrapper.cpp | 8 + src/systemd-socket.cpp | 221 ++++- waylib/src/server/kernel/wseat.cpp | 71 +- waylib/src/server/kernel/wseat.h | 13 + .../server/protocols/private/wtextinputv1.cpp | 51 +- .../server/protocols/private/wtextinputv2.cpp | 65 +- .../server/protocols/winputmethodhelper.cpp | 853 +++++++++++++----- .../src/server/protocols/winputmethodhelper.h | 15 +- 15 files changed, 1341 insertions(+), 373 deletions(-) diff --git a/misc/systemd/dde-session-pre.target.wants/treeland-xwayland.service.in b/misc/systemd/dde-session-pre.target.wants/treeland-xwayland.service.in index c84e4e4132..a0af31bc7d 100644 --- a/misc/systemd/dde-session-pre.target.wants/treeland-xwayland.service.in +++ b/misc/systemd/dde-session-pre.target.wants/treeland-xwayland.service.in @@ -17,8 +17,9 @@ ExecCondition=/bin/sh -c 'test "$XDG_SESSION_DESKTOP" = "Treeland" || exit 2' Type=notify Sockets=treeland-xwayland.socket UnsetEnvironment=DISPLAY +UnsetEnvironment=XAUTHORITY ExecStart=@CMAKE_INSTALL_FULL_LIBEXECDIR@/treeland-sd --type xwayland -ExecStop=-/usr/bin/systemctl --user unset-environment DISPLAY +ExecStop=-/usr/bin/systemctl --user unset-environment DISPLAY XAUTHORITY Restart=on-failure RestartSec=3s Slice=session.slice diff --git a/src/core/shellhandler.cpp b/src/core/shellhandler.cpp index 06682120a2..ac517e599b 100644 --- a/src/core/shellhandler.cpp +++ b/src/core/shellhandler.cpp @@ -500,6 +500,10 @@ void ShellHandler::init(WServer *server, WSeat *seat) &WInputMethodHelper::inputPopupSurfaceV2Removed, this, &ShellHandler::onInputPopupSurfaceV2Removed); + connect(m_inputMethodHelper, + &WInputMethodHelper::textInputFocusSurfaceChanged, + this, + &ShellHandler::onTextInputFocusSurfaceChanged); auto *overlay = QQuickOverlay::overlay(m_rootSurfaceContainer->window()); overlay->setZ(RootSurfaceContainer::GlobalOverlayZOrder); @@ -1236,11 +1240,20 @@ void ShellHandler::updateLayerSurfaceContainer(SurfaceWrapper *surface) void ShellHandler::onInputPopupSurfaceV2Added(WInputPopupSurface *surface) { + auto parent = surface->parentSurface(); + auto parentWrapper = m_rootSurfaceContainer->getSurface(parent); + if (!parentWrapper) { + // The anchoring text input surface is gone (client teardown): there is + // nothing to attach the candidate window to. + qCWarning(lcTlShell) << "[popup] skip input popup without a parent wrapper" + << "surface=" << surface + << "parent=" << parent; + return; + } + auto wrapper = new SurfaceWrapper(Helper::instance()->qmlEngine(), surface, SurfaceWrapper::Type::InputPopup); - auto parent = surface->parentSurface(); - auto parentWrapper = m_rootSurfaceContainer->getSurface(parent); parentWrapper->addSubSurface(wrapper); m_popupContainer->addSurface(wrapper); // m_popupContainer is a simple SurfaceContainer, so input popups need the @@ -1248,17 +1261,36 @@ void ShellHandler::onInputPopupSurfaceV2Added(WInputPopupSurface *surface) wrapper->setHasInitializeContainer(true); wrapper->setOwnsOutput(parentWrapper->ownsOutput()); Q_ASSERT(wrapper->parentItem()); + m_inputPopupWrappers.append(wrapper); Q_EMIT surfaceWrapperAdded(wrapper); } void ShellHandler::onInputPopupSurfaceV2Removed(WInputPopupSurface *surface) { auto wrapper = m_rootSurfaceContainer->getSurface(surface->surface()); + if (!wrapper) { + // onInputPopupSurfaceV2Added() skipped this popup (no parent wrapper + // left), so there is no wrapper to detach here either. + return; + } + m_inputPopupWrappers.removeAll(wrapper); Q_EMIT surfaceWrapperAboutToRemove(wrapper); wrapper->setHasInitializeContainer(false); m_rootSurfaceContainer->destroyForSurface(wrapper); } +void ShellHandler::onTextInputFocusSurfaceChanged() +{ + m_inputPopupWrappers.removeIf([](const QPointer &popup) { + return popup.isNull(); + }); + + for (const auto &popup : std::as_const(m_inputPopupWrappers)) { + if (auto *output = popup->ownsOutput()) + output->retargetInputPopupSurface(popup); + } +} + void ShellHandler::setupSurfaceWindowMenu(SurfaceWrapper *wrapper) { Q_ASSERT(m_windowMenu); diff --git a/src/core/shellhandler.h b/src/core/shellhandler.h index 96abac4d87..d567c18657 100644 --- a/src/core/shellhandler.h +++ b/src/core/shellhandler.h @@ -127,6 +127,7 @@ private Q_SLOTS: void setupDockPreview(); void onInputPopupSurfaceV2Removed(WAYLIB_SERVER_NAMESPACE::WInputPopupSurface *surface); + void onTextInputFocusSurfaceChanged(); void onWindowMenuClosed(); private: @@ -192,6 +193,9 @@ private Q_SLOTS: SurfaceContainer *m_popupContainer = nullptr; SurfaceContainer *m_privilegedOverlayContainer = nullptr; IMCandidatePanelManager *m_imCandidatePanelManager = nullptr; + // Input method popup (candidate window) wrappers, re-arranged whenever the + // text input that owns the input method focus moves. + QList> m_inputPopupWrappers; QObject *m_windowMenu = nullptr; // Prelaunch wrappers created before binding to a real shell surface QList m_prelaunchWrappers; diff --git a/src/output/output.cpp b/src/output/output.cpp index bcc6995558..6412a66fbf 100644 --- a/src/output/output.cpp +++ b/src/output/output.cpp @@ -7,6 +7,7 @@ #include "cmdline.h" #include "common/treelandlogging.h" #include "core/rootsurfacecontainer.h" +#include "core/shellhandler.h" #include "core/dconfigmanager.h" #include "outputconfig.hpp" #include "seat/helper.h" @@ -16,6 +17,7 @@ #include "workspace/workspace.h" #include "wallpapermanager.h" +#include #include #include #include @@ -848,7 +850,7 @@ namespace { // - Input popups: cursorRect is in raw surface coordinates; adjust by // content geometry offset (e.g., CSD title bar) to match the rendering // position of WSurfaceItem's contentContainer. -std::optional popupDPos(SurfaceWrapper *surface) +std::optional popupDPos(SurfaceWrapper *surface, SurfaceWrapper *placementParent) { using Type = SurfaceWrapper::Type; if (surface->type() == Type::XdgPopup) { @@ -861,9 +863,8 @@ std::optional popupDPos(SurfaceWrapper *surface) auto *shell = qobject_cast(surface->shellSurface()); if (shell) { QPointF dPos = shell->cursorRect().bottomLeft(); - auto parent = surface->parentSurface(); - if (parent && parent->shellSurface()) { - const QPoint offset = parent->shellSurface()->getContentGeometry().topLeft(); + if (placementParent && placementParent->shellSurface()) { + const QPoint offset = placementParent->shellSurface()->getContentGeometry().topLeft(); dPos -= QPointF(offset.x(), offset.y()); } return dPos; @@ -873,22 +874,45 @@ std::optional popupDPos(SurfaceWrapper *surface) qCWarning(lcTlOutput) << " Invalid popup surface type:" << surface->type(); return std::nullopt; } + +// The input method's panel (candidate window) belongs to the text input that +// currently owns the input method focus. The popup wrapper's own parentSurface() +// only records the surface that happened to be focused when the input method +// created its panel surface, so it goes stale as soon as the same application +// moves the text input focus, e.g. to another of its windows. +SurfaceWrapper *inputPopupPlacementParent(SurfaceWrapper *popup) +{ + auto *helper = Helper::instance(); + if (!helper) + return popup->parentSurface(); + + auto *shellHandler = helper->shellHandler(); + auto *inputMethodHelper = shellHandler ? shellHandler->inputMethodHelper() : nullptr; + auto *rootContainer = helper->rootSurfaceContainer(); + if (inputMethodHelper && rootContainer) { + if (auto *focusSurface = inputMethodHelper->textInputFocusSurface()) { + if (auto *focusWrapper = rootContainer->getSurface(focusSurface)) + return focusWrapper; + } + } + + return popup->parentSurface(); +} } // namespace -QPointF Output::calculateBasePosition(SurfaceWrapper *surface, const QPointF &dPos) const +QPointF Output::calculateBasePosition(SurfaceWrapper *placementParent, const QPointF &dPos) const { - auto parent = surface->parentSurface(); - if (!parent || !parent->surfaceItem()) { + if (!placementParent || !placementParent->surfaceItem()) { qCWarning(lcTlOutput) << " Invalid parent surface or surface item!"; return QPointF(); } - const qreal titlebarOffset = parent->titlebarGeometry().isNull() + const qreal titlebarOffset = placementParent->titlebarGeometry().isNull() ? 0.0 - : parent->titlebarGeometry().height(); + : placementParent->titlebarGeometry().height(); - return QPointF(parent->x() + parent->surfaceItem()->x() + dPos.x(), - parent->y() + parent->surfaceItem()->y() + dPos.y() + titlebarOffset); + return QPointF(placementParent->x() + placementParent->surfaceItem()->x() + dPos.x(), + placementParent->y() + placementParent->surfaceItem()->y() + dPos.y() + titlebarOffset); } void Output::adjustToOutputBounds(QPointF &pos, const QRectF &normalGeo, const QRectF &outputRect) const @@ -908,19 +932,19 @@ void Output::adjustToOutputBounds(QPointF &pos, const QRectF &normalGeo, const Q } } -void Output::handleLayerShellPopup(SurfaceWrapper *surface, const QRectF &normalGeo) +void Output::handleLayerShellPopup(SurfaceWrapper *surface, SurfaceWrapper *placementParent, const QRectF &normalGeo) { - if (!surface->parentSurface() || !surface->parentSurface()->ownsOutput()) { + if (!placementParent || !placementParent->ownsOutput()) { qCWarning(lcTlOutput) << " Invalid LayerShell parent surface!"; return; } - auto parentOutput = surface->parentSurface()->ownsOutput()->outputItem(); - auto dPos = popupDPos(surface); + auto parentOutput = placementParent->ownsOutput()->outputItem(); + auto dPos = popupDPos(surface, placementParent); if (!dPos.has_value()) return; - QPointF pos = calculateBasePosition(surface, dPos.value()); + QPointF pos = calculateBasePosition(placementParent, dPos.value()); if (pos.isNull()) { return; } @@ -930,17 +954,17 @@ void Output::handleLayerShellPopup(SurfaceWrapper *surface, const QRectF &normal surface->moveNormalGeometryInOutput(pos); } -void Output::handleRegularPopup(SurfaceWrapper *surface, const QRectF &normalGeo, WOutputItem *targetOutput) +void Output::handleRegularPopup(SurfaceWrapper *surface, SurfaceWrapper *placementParent, const QRectF &normalGeo, WOutputItem *targetOutput) { if (normalGeo.isEmpty()) { return; } - auto dPos = popupDPos(surface); + auto dPos = popupDPos(surface, placementParent); if (!dPos.has_value()) return; - QPointF pos = calculateBasePosition(surface, dPos.value()); + QPointF pos = calculateBasePosition(placementParent, dPos.value()); if (pos.isNull()) { return; } @@ -976,8 +1000,15 @@ void Output::clearPopupCache(SurfaceWrapper *surface) void Output::arrangePopupSurface(SurfaceWrapper *surface) { - SurfaceWrapper *parentSurfaceWrapper = surface->parentSurface(); - if (!parentSurfaceWrapper) { + SurfaceWrapper *placementParent = surface->parentSurface(); + if (surface->type() == SurfaceWrapper::Type::InputPopup) + placementParent = inputPopupPlacementParent(surface); + arrangePopupSurfaceWith(surface, placementParent); +} + +void Output::arrangePopupSurfaceWith(SurfaceWrapper *surface, SurfaceWrapper *placementParent) +{ + if (!placementParent) { // When an input popup is still alive while its parent text-input client is being torn down, // arrangePopupSurface() can run in a transient state where parentSurface is temporarily unavailable. qCWarning(lcTlSurface) << "[popup] skip arrangePopupSurface: missing parent surface" @@ -996,24 +1027,48 @@ void Output::arrangePopupSurface(SurfaceWrapper *surface) auto *outputAtCursor = Helper::instance()->getOutputAtCursor(); targetOutput = outputAtCursor ? outputAtCursor->outputItem() : nullptr; } else if (surface->isInputPopupLike()) { - auto *parentOutput = parentSurfaceWrapper->ownsOutput(); + auto *parentOutput = placementParent->ownsOutput(); targetOutput = parentOutput ? parentOutput->outputItem() : nullptr; } if (!targetOutput) { qCInfo(lcTlSurface) << "[popup] skip arrangePopupSurface: missing target output" << "surface=" << surface - << "parentSurface=" << parentSurfaceWrapper; + << "parentSurface=" << placementParent; return; } - if (parentSurfaceWrapper->type() == SurfaceWrapper::Type::Layer) { - handleLayerShellPopup(surface, normalGeo); + if (placementParent->type() == SurfaceWrapper::Type::Layer) { + handleLayerShellPopup(surface, placementParent, normalGeo); } else { - handleRegularPopup(surface, normalGeo, targetOutput); + handleRegularPopup(surface, placementParent, normalGeo, targetOutput); } } +void Output::retargetInputPopupSurface(SurfaceWrapper *popup) +{ + if (!popup || popup->type() != SurfaceWrapper::Type::InputPopup) + return; + + auto *placementParent = inputPopupPlacementParent(popup); + if (placementParent) { + if (auto *placementOutput = placementParent->ownsOutput(); + placementOutput && placementOutput != popup->ownsOutput()) { + // Detaches the popup from this output (dropping its cached position) + // and registers it with the new one. + popup->setOwnsOutput(placementOutput); + } + } + + qCDebug(lcTlSurface) << "[popup] retarget input popup" + << "popup=" << popup + << "placementParent=" << placementParent + << "ownsOutput=" << popup->ownsOutput(); + // Reuse the parent resolved above: it walks the root surface container, + // which arrangePopupSurface() would otherwise repeat for input popups. + arrangePopupSurfaceWith(popup, placementParent); +} + void Output::arrangeNonLayerSurfaces(ArrangeReason reason) { for (SurfaceWrapper *surface : std::as_const(surfaces())) { diff --git a/src/output/output.h b/src/output/output.h index 81843a89ba..928d66fab5 100644 --- a/src/output/output.h +++ b/src/output/output.h @@ -106,6 +106,10 @@ class Output : public SurfaceListModel const QRectF &normalGeo, const QRectF &outputRect) const; + // Re-run placement of an input method popup: its anchoring text-input + // surface may have changed (another window of the same application). + void retargetInputPopupSurface(SurfaceWrapper *popup); + Q_SIGNALS: void exclusiveZoneChanged(); void moveResizeFinised(); @@ -153,9 +157,17 @@ public Q_SLOTS: void moveSurfaceWithTitlebarClamp(SurfaceWrapper *surface, const QPointF &pos); qreal preferredScaleFactor() const; - QPointF calculateBasePosition(SurfaceWrapper *surface, const QPointF &dPos) const; - void handleLayerShellPopup(SurfaceWrapper *surface, const QRectF &normalGeo); - void handleRegularPopup(SurfaceWrapper *surface, const QRectF &normalGeo, WOutputItem *targetOutput); + QPointF calculateBasePosition(SurfaceWrapper *placementParent, const QPointF &dPos) const; + // Shared placement body; `placementParent` is the surface the popup is + // positioned relative to and must already be resolved by the caller. + void arrangePopupSurfaceWith(SurfaceWrapper *surface, SurfaceWrapper *placementParent); + void handleLayerShellPopup(SurfaceWrapper *surface, + SurfaceWrapper *placementParent, + const QRectF &normalGeo); + void handleRegularPopup(SurfaceWrapper *surface, + SurfaceWrapper *placementParent, + const QRectF &normalGeo, + WOutputItem *targetOutput); void clearPopupCache(SurfaceWrapper *surface); Type m_type; diff --git a/src/surface/seatsurfacemanager.cpp b/src/surface/seatsurfacemanager.cpp index ed7fda1881..ff3b9a6409 100644 --- a/src/surface/seatsurfacemanager.cpp +++ b/src/surface/seatsurfacemanager.cpp @@ -8,7 +8,6 @@ #include "common/treelandlogging.h" #include "seat/helper.h" #include "seat/seatmanager.h" -#include "core/shellhandler.h" #include "output/output.h" #include @@ -20,15 +19,40 @@ #include #include -#include - #include #include #include +#include + WAYLIB_SERVER_USE_NAMESPACE +namespace { +wlr_seat_keyboard_grab *keyboardGrabForPopup(wlr_xdg_popup *popup) +{ + if (!popup || !popup->seat || !popup->base || !popup->base->client + || !popup->base->client->shell) { + return nullptr; + } + + auto *shell = popup->base->client->shell; + wlr_xdg_popup_grab *popupGrab; + wl_list_for_each(popupGrab, &shell->popup_grabs, link) { + if (popupGrab->seat != popup->seat) + continue; + + wlr_xdg_popup *member; + wl_list_for_each(member, &popupGrab->popups, grab_link) { + if (member == popup) + return &popupGrab->keyboard_grab; + } + } + + return nullptr; +} +} + SeatSurfaceManager::SeatSurfaceManager(WSeat *seat, RootSurfaceContainer *parent) : QObject(parent) , m_seat(seat) @@ -422,16 +446,35 @@ void SeatSurfaceManager::surfaceDestroyed(SurfaceWrapper *surface) setActivatedSurface(nullptr, Qt::OtherFocusReason); } + const bool trackedPopup = isTrackedPopup(surface); + QPointer restoreTarget; + if (trackedPopup && m_keyboardFocusSurface == surface) { + restoreTarget = popupParentFocusTarget(surface, false); + if (!restoreTarget && m_prePopupFocusSurface + && m_prePopupFocusSurface->hasFocusCapability()) { + restoreTarget = m_prePopupFocusSurface; + } + } + + m_popupFocusStack.removeIf([surface](const QPointer &popup) { + return !popup || popup == surface; + }); + if (m_keyboardFocusSurface == surface) { - setKeyboardFocusSurface(nullptr); + setKeyboardFocusSurface(restoreTarget, Qt::ActiveWindowFocusReason); + qCInfo(lcTlPopupFocus) << "Focused surface destroyed" + << "seat" << m_seat->name() + << "surface" << surface + << "trackedPopup" << trackedPopup + << "restoredFocus" << restoreTarget; } + + if (m_popupFocusStack.isEmpty() && !m_popupKeyboardGrab) + m_prePopupFocusSurface.clear(); } void SeatSurfaceManager::givePopupFocus(SurfaceWrapper *popupWrapper) { - if (!m_hasPopupGrab) - return; - Q_ASSERT(popupWrapper); auto *popupSurface = qobject_cast(popupWrapper->shellSurface()); if (!popupSurface) @@ -442,69 +485,166 @@ void SeatSurfaceManager::givePopupFocus(SurfaceWrapper *popupWrapper) if (!wlrPopup || wlrPopup->seat != m_seat->handle()) return; + auto *seatHandle = m_seat->handle(); + auto *grab = seatHandle->keyboard_state.grab; + auto *popupGrab = keyboardGrabForPopup(wlrPopup); + if (!popupGrab || grab != popupGrab) { + qCWarning(lcTlPopupFocus) << "Refusing popup focus without its exact keyboard grab" + << "seat" << m_seat->name() + << "popup" << popupWrapper + << "expectedGrab" << popupGrab + << "currentGrab" << grab; + return; + } + + if (m_popupKeyboardGrab && m_popupKeyboardGrab != popupGrab) { + qCWarning(lcTlPopupFocus) << "Refusing popup focus while another structural grab is active" + << "seat" << m_seat->name() + << "popup" << popupWrapper + << "trackedGrab" << m_popupKeyboardGrab + << "popupGrab" << popupGrab; + return; + } + + if (!m_popupKeyboardGrab) { + m_popupKeyboardGrab = popupGrab; + m_prePopupFocusSurface = m_keyboardFocusSurface; + ++m_popupTransitionSerial; + qCInfo(lcTlPopupFocus) << "Popup keyboard grab tracked" + << "transition" << m_popupTransitionSerial + << "seat" << m_seat->name() + << "grab" << popupGrab + << "previousFocus" << m_prePopupFocusSurface; + } + + m_popupFocusStack.removeIf([](const QPointer &popup) { + return popup.isNull(); + }); + m_popupFocusStack.removeAll(popupWrapper); + m_popupFocusStack.append(popupWrapper); + // Move keyboard focus to the popup surface directly. setKeyboardFocusSurface(popupWrapper, Qt::ActiveWindowFocusReason); - qCDebug(lcTlPopupFocus) << "Moved keyboard focus to popup surface:" << popupWrapper; + qCInfo(lcTlPopupFocus) << "Popup received keyboard focus" + << "transition" << m_popupTransitionSerial + << "seat" << m_seat->name() + << "popup" << popupWrapper + << "parent" << popupWrapper->parentSurface() + << "depth" << m_popupFocusStack.size(); } void SeatSurfaceManager::dismissPopups() { - if (!m_hasPopupGrab) + m_popupFocusStack.removeIf([](const QPointer &popup) { + return popup.isNull(); + }); + if (!m_popupKeyboardGrab || m_popupFocusStack.isEmpty()) return; - qCDebug(lcTlPopupFocus) << "Dismissing popup grab"; - wlr_seat_keyboard_end_grab(m_seat->handle()); -} - -void SeatSurfaceManager::onKeyboardGrabBegin() -{ - if (m_hasPopupGrab) { - // Already tracking a popup grab; nested popups share the same flag. + auto *wrapper = m_popupFocusStack.constLast().data(); + auto *popup = wrapper + ? qobject_cast(wrapper->shellSurface()) + : nullptr; + if (!popup || !popup->handle()) { + qCWarning(lcTlPopupFocus) << "Unable to dismiss tracked popup" + << "seat" << m_seat->name() + << "wrapper" << wrapper + << "grab" << m_popupKeyboardGrab; return; } - auto *seatNative = m_seat->handle(); - auto *grab = seatNative->keyboard_state.grab; - if (!grab) { - qCWarning(lcTlPopupFocus) << "keyboard_state.grab is null"; - return; - } + qCInfo(lcTlPopupFocus) << "Dismissing concrete popup" + << "transition" << m_popupTransitionSerial + << "seat" << m_seat->name() + << "popup" << wrapper + << "grab" << m_popupKeyboardGrab; + popup->close(); +} - // Detect IME keyboard grab: - // WInputMethodHelper::handleNewKGV2 sets activeKeyboardGrab before - // calling keyboard_start_grab, so it is already non-null when we get here. - // Use isActiveKeyboardGrabOwner() to check if the seat's current grab - // is the one installed by the IME helper. - if (auto *imHelper = Helper::instance()->shellHandler()->inputMethodHelper()) { - if (imHelper->isActiveKeyboardGrabOwner()) { - qCDebug(lcTlPopupFocus) << "IME keyboard grab started (not popup)"; - return; - } +SurfaceWrapper *SeatSurfaceManager::popupParentFocusTarget(SurfaceWrapper *popup, + bool skipPopupParents) const +{ + auto *target = popup ? popup->parentSurface() : nullptr; + while (target) { + const bool isPopup = target->type() == SurfaceWrapper::Type::XdgPopup; + if ((!skipPopupParents || !isPopup) && target->hasFocusCapability()) + return target; + if (!isPopup) + break; + target = target->parentSurface(); } + return nullptr; +} - m_hasPopupGrab = true; - qCDebug(lcTlPopupFocus) << "Popup keyboard grab started"; +bool SeatSurfaceManager::isTrackedPopup(SurfaceWrapper *surface) const +{ + return std::any_of(m_popupFocusStack.cbegin(), m_popupFocusStack.cend(), + [surface](const QPointer &popup) { + return popup == surface; + }); } -void SeatSurfaceManager::onKeyboardGrabEnd() +void SeatSurfaceManager::onKeyboardGrabBegin(wlr_seat_keyboard_grab *grab) { - if (!m_hasPopupGrab) + if (!m_popupKeyboardGrab || grab == m_popupKeyboardGrab) return; - m_hasPopupGrab = false; - - qCDebug(lcTlPopupFocus) << "Popup keyboard grab ended, restoring focus to:" - << m_activatedSurface; + auto *oldGrab = m_popupKeyboardGrab; + m_popupKeyboardGrab = nullptr; + m_popupFocusStack.clear(); + m_prePopupFocusSurface.clear(); + ++m_popupTransitionSerial; + + // wlroots replaces keyboard_state.grab directly and does not emit an end + // event for the displaced grab. Forget the old popup state immediately; + // the new grab owns focus policy from this point onward. + qCInfo(lcTlPopupFocus) << "Popup keyboard grab replaced" + << "transition" << m_popupTransitionSerial + << "seat" << m_seat->name() + << "oldGrab" << oldGrab + << "newGrab" << grab + << "currentFocus" << m_keyboardFocusSurface; +} - // While showing the desktop, keyboard focus is on the desktop layer, not on the - // (hidden) activated surface; do not yank it back to the window. - if (auto *helper = Helper::instance()) { - if (helper->showDesktopState() == ShowDesktopInterfaceV1::State::Show) - return; +void SeatSurfaceManager::onKeyboardGrabEnd(wlr_seat_keyboard_grab *grab) +{ + if (!m_popupKeyboardGrab || grab != m_popupKeyboardGrab) { + qCDebug(lcTlPopupFocus) << "Ignoring unrelated keyboard grab end" + << "seat" << m_seat->name() + << "endedGrab" << grab + << "popupGrab" << m_popupKeyboardGrab; + return; } - if (m_activatedSurface && m_activatedSurface->hasFocusCapability()) { - setKeyboardFocusSurface(m_activatedSurface, Qt::ActiveWindowFocusReason); + QPointer currentFocus = m_keyboardFocusSurface; + QPointer restoreTarget; + const bool popupStillFocused = currentFocus && isTrackedPopup(currentFocus); + if (popupStillFocused) + restoreTarget = popupParentFocusTarget(currentFocus, true); + if (!restoreTarget && m_prePopupFocusSurface + && m_prePopupFocusSurface->hasFocusCapability()) { + restoreTarget = m_prePopupFocusSurface; } + + m_popupKeyboardGrab = nullptr; + m_popupFocusStack.clear(); + m_prePopupFocusSurface.clear(); + ++m_popupTransitionSerial; + + // While showing the desktop, keyboard focus is on the desktop layer, not on + // the (hidden) activated surface; do not yank it back to the window. + const bool showingDesktop = Helper::instance() + && Helper::instance()->showDesktopState() == ShowDesktopInterfaceV1::State::Show; + + if (popupStillFocused && !showingDesktop) + setKeyboardFocusSurface(restoreTarget, Qt::ActiveWindowFocusReason); + + qCInfo(lcTlPopupFocus) << "Popup keyboard grab ended" + << "transition" << m_popupTransitionSerial + << "seat" << m_seat->name() + << "endedGrab" << grab + << "previousFocus" << currentFocus + << "restoredFocus" << restoreTarget + << "activationUnchanged" << m_activatedSurface; } diff --git a/src/surface/seatsurfacemanager.h b/src/surface/seatsurfacemanager.h index 9c3bb3503e..5eb49246df 100644 --- a/src/surface/seatsurfacemanager.h +++ b/src/surface/seatsurfacemanager.h @@ -65,7 +65,7 @@ class SeatSurfaceManager : public QObject // Popup keyboard grab management void givePopupFocus(SurfaceWrapper *popupWrapper); void dismissPopups(); - bool hasPopupGrab() const { return m_hasPopupGrab; } + bool hasPopupGrab() const { return m_popupKeyboardGrab != nullptr; } Q_SIGNALS: void activatedSurfaceChanged(SurfaceWrapper *surface); @@ -73,8 +73,10 @@ class SeatSurfaceManager : public QObject private: void onActivatedSurfaceFocusCapabilityChanged(); - void onKeyboardGrabBegin(); - void onKeyboardGrabEnd(); + void onKeyboardGrabBegin(wlr_seat_keyboard_grab *grab); + void onKeyboardGrabEnd(wlr_seat_keyboard_grab *grab); + SurfaceWrapper *popupParentFocusTarget(SurfaceWrapper *popup, bool skipPopupParents) const; + bool isTrackedPopup(SurfaceWrapper *surface) const; WSeat *m_seat = nullptr; RootSurfaceContainer *m_rootContainer = nullptr; @@ -86,7 +88,10 @@ class SeatSurfaceManager : public QObject bool m_metaKeyPressed = false; // Popup grab state - bool m_hasPopupGrab = false; + wlr_seat_keyboard_grab *m_popupKeyboardGrab = nullptr; + QPointer m_prePopupFocusSurface; + QList> m_popupFocusStack; + quint64 m_popupTransitionSerial = 0; QTimer *m_edgeTileDelayTimer = nullptr; bool m_resizeClampActive = false; diff --git a/src/surface/surfacewrapper.cpp b/src/surface/surfacewrapper.cpp index 1c176828dc..8c57103e1e 100644 --- a/src/surface/surfacewrapper.cpp +++ b/src/surface/surfacewrapper.cpp @@ -2261,6 +2261,14 @@ bool SurfaceWrapper::stackAfter(QQuickItem *item) break; } item = s->stackLastSurface(); + // The deepest surface of s's sub-chain may live in a different + // QQuickItem container than ours (X11 transient children can be + // placed by separate layering rules). QQuickItem::stackAfter + // silently refuses non-siblings and would then abort the whole + // raise with the stacking bookkeeping left out of sync; fall back + // to s itself, which the entry guard above validated. + if (!item || item->parentItem() != parentItem()) + item = s; if (m_parentSurface && m_parentSurface == s->m_parentSurface) { QQuickItem::stackAfter(item); diff --git a/src/systemd-socket.cpp b/src/systemd-socket.cpp index bcc89d84d4..6a764e65eb 100644 --- a/src/systemd-socket.cpp +++ b/src/systemd-socket.cpp @@ -7,8 +7,12 @@ #include #include #include +#include #include +#include #include +#include +#include #include #include #include @@ -24,6 +28,9 @@ #include #include +#include +#include +#include #include #include #include @@ -64,6 +71,10 @@ class SocketActivator : public QObject if (tryStart(QDBusConnection::sessionBus()) || tryStart(QDBusConnection::systemBus())) { m_started = true; clearPendingRetry(StartRetry); + // Fresh compositor (re-)registration: start a new activation window. + m_waylandActivateFailures = 0; + m_handoverAttempts = 0; + clearPendingRetry(HandoverRetry); activate(); return; } @@ -87,11 +98,34 @@ public Q_SLOTS: if (updateFd.isValid()) { if (m_type == "wayland") { - if (!callDBus(updateFd, + const auto reply = callDBus(updateFd, QStringLiteral("ActivateWayland"), QStringLiteral("Failed to activate Wayland socket"), - QVariant::fromValue(*m_unixFileDescriptor))) { - return; + QVariant::fromValue(*m_unixFileDescriptor)); + // ReplyMessage carrying `false` means the compositor is on the bus + // but not ready to accept this session's socket (e.g. its user + // session is not registered yet during the login handover). Exporting + // the environment then would start autostart services (fcitx5!) + // against a display that nobody is serving, and they never retry. + // Stay unnotified: Ready=1 (and with it ExecStartPost and + // dde-session-pre.target) must gate the whole session on a socket + // that was really activated. + const bool socketAccepted = reply && reply->arguments().value(0).toBool(); + if (!socketAccepted) { + ++m_waylandActivateFailures; + if (m_waylandActivateFailures <= MaxWaylandActivateRetries) { + qCWarning(lcSdSocket) << "Wayland socket activation not accepted yet, retrying" + << m_waylandActivateFailures; + scheduleActivateRetry(); + return; + } + // Degraded path (compositor persistently refuses): still export + // the environment and notify readiness, matching the historic + // behaviour so the session can never hang on a broken + // activation, while the retry window above covers the normal + // login-handover race. + qCWarning(lcSdSocket) + << "Wayland socket activation repeatedly refused, publishing environment anyway"; } QDBusInterface dbus("org.freedesktop.DBus", @@ -99,7 +133,7 @@ public Q_SLOTS: "org.freedesktop.DBus", QDBusConnection::sessionBus()); StringMap env; - env["WAYLAND_DISPLAY"] = "treeland.socket"; + env["WAYLAND_DISPLAY"] = waylandDisplayName(); const auto extraEnvs = qgetenv("TREELAND_SESSION_ENVIRONMENTS"); if (!extraEnvs.isEmpty()) { @@ -120,6 +154,12 @@ public Q_SLOTS: } sd_notify(0, "READY=1"); + + // Only a socket the compositor really accepted can serve the + // input method. In the degraded path above nothing is listening + // on it, so the running fcitx5 must keep whatever it has. + if (socketAccepted) + handWaylandSocketToInputMethod(); } else if (m_type == "xwayland") { QDBusMessage reply = updateFd.call("XWaylandName"); if (reply.type() == QDBusMessage::ReplyMessage) { @@ -159,6 +199,34 @@ public Q_SLOTS: return; } + // Same half of the publication as dde-session's + // EnvironmentsManager does (systemd1.SetEnvironment in + // addition to UpdateActivationEnvironment), mirroring the + // WAYLAND_DISPLAY/QT_IM_MODULE/*_IM_MODULE set-environment + // the wayland unit posts via ExecStartPost: transient + // session units (and hence services like fcitx5 started + // via StartTransientUnit) inherit the *manager* + // environment, not the activation one. Without this, + // X11-side input methods (the fcitx5 X selection / XIM on + // the XWayland display) never see DISPLAY/XAUTHORITY in a + // treeland session, while KWin-based sessions only work + // because dde-session performs exactly this call. + { + QDBusInterface systemd1("org.freedesktop.systemd1", + "/org/freedesktop/systemd1", + "org.freedesktop.systemd1.Manager", + QDBusConnection::sessionBus()); + if (systemd1.isValid()) { + QStringList envList; + envList << QStringLiteral("DISPLAY=%1").arg(xwaylandName) + << QStringLiteral("XAUTHORITY=%1").arg(authFileName); + callDBus(systemd1, + QStringLiteral("SetEnvironment"), + QStringLiteral("Failed to set XWayland session environment"), + envList); + } + } + sd_notify(0, "READY=1"); m_lastXwaylandAuth = auth; clearPendingRetry(ActivateRetry); @@ -181,6 +249,7 @@ public Q_SLOTS: enum RetryFlag { StartRetry = 1 << 0, ActivateRetry = 1 << 1, + HandoverRetry = 1 << 2, }; bool isRetryPending(RetryFlag flag) const { @@ -219,6 +288,52 @@ public Q_SLOTS: return runtimeFileName(QStringLiteral("treeland-xauthority")); } + // Name of the Wayland socket this session is built on. Must stay in sync + // with the ListenStream of treeland-sd.socket, the WAYLAND_DISPLAY + // published below and the one ExecStartPost sets in the user manager. + static QString waylandDisplayName() + { + return QStringLiteral("treeland.socket"); + } + + // Connect to a listening AF_UNIX socket, returning an owned fd or -1. + int connectToSocket(const QString &path) const + { + const QByteArray encodedPath = QFile::encodeName(path); + + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (encodedPath.size() + 1 > static_cast(sizeof(address.sun_path))) { + errno = ENAMETOOLONG; + return -1; + } + memcpy(address.sun_path, encodedPath.constData(), static_cast(encodedPath.size())); + + int fd = -1; + do { + fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + } while (fd < 0 && errno == EINTR); + if (fd < 0) + return -1; + + int result = -1; + do { + result = ::connect( + fd, + reinterpret_cast(&address), + static_cast(offsetof(sockaddr_un, sun_path) + encodedPath.size() + 1)); + } while (result < 0 && errno == EINTR); + + if (result < 0) { + const int error = errno; + ::close(fd); + errno = error; + return -1; + } + + return fd; + } + bool writeXAuthority(const QString &fileName, const QByteArray &auth) const { QSaveFile file(fileName); if (!file.open(QFile::WriteOnly)) { @@ -332,7 +447,7 @@ public Q_SLOTS: } void scheduleActivateRetry() { - if (m_type != "xwayland" || isRetryPending(ActivateRetry)) + if (isRetryPending(ActivateRetry)) return; setPendingRetry(ActivateRetry); @@ -353,12 +468,108 @@ public Q_SLOTS: clearPendingRetry(ActivateRetry); } + // fcitx5 connects to Wayland exactly once at startup and never retries, so + // a compositor restart leaves it alive without its Wayland input-method + // frontend until it is restarted by hand. It does offer a D-Bus call to + // replace that connection, which is safe to use right after the socket was + // activated. Reopen (instead of Open) keeps fcitx5 on a single main + // connection, so the compositor never sees a second input method. + void handWaylandSocketToInputMethod() + { + if (m_type != "wayland" || m_handoverAttempts >= MaxHandoverAttempts) + return; + + // Never let D-Bus activation start fcitx5 just because it is not + // running: without it there is nothing to reconnect. + const auto *busInterface = QDBusConnection::sessionBus().interface(); + if (!busInterface + || !busInterface->isServiceRegistered(QStringLiteral("org.fcitx.Fcitx5")).value()) { + qCDebug(lcSdSocket) + << "Input method is not running, nothing to hand the Wayland socket to"; + return; + } + + ++m_handoverAttempts; + + const QString displayName = waylandDisplayName(); + const int fd = connectToSocket(runtimeFileName(displayName)); + if (fd < 0) { + const int error = errno; + qCWarning(lcSdSocket) << "Failed to connect to the Wayland socket" << displayName + << "for the input method:" << strerror(error); + scheduleHandoverRetry(); + return; + } + + QDBusUnixFileDescriptor unixFileDescriptor(fd); + // The message owns a duplicate from here on. + ::close(fd); + if (!unixFileDescriptor.isValid()) { + qCWarning(lcSdSocket) << "Failed to duplicate the file descriptor of" << displayName; + scheduleHandoverRetry(); + return; + } + + QDBusMessage message = + QDBusMessage::createMethodCall(QStringLiteral("org.fcitx.Fcitx5"), + QStringLiteral("/controller"), + QStringLiteral("org.fcitx.Fcitx.Controller1"), + QStringLiteral("ReopenWaylandConnectionSocket")); + message.setAutoStartService(false); + message << displayName << QVariant::fromValue(unixFileDescriptor); + + auto *watcher = + new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this); + connect(watcher, + &QDBusPendingCallWatcher::finished, + this, + &SocketActivator::handoverFinished); + } + + void handoverFinished(QDBusPendingCallWatcher *watcher) + { + watcher->deleteLater(); + if (watcher->isError()) { + qCWarning(lcSdSocket) << "Input method rejected the Wayland socket:" + << watcher->error().message(); + scheduleHandoverRetry(); + return; + } + + qCInfo(lcSdSocket) << "Handed a fresh Wayland socket to the input method"; + } + + void scheduleHandoverRetry() + { + if (isRetryPending(HandoverRetry) || m_handoverAttempts >= MaxHandoverAttempts) + return; + + setPendingRetry(HandoverRetry); + QTimer::singleShot(HandoverRetryIntervalMs, this, [this] { + if (!isRetryPending(HandoverRetry)) + return; + + clearPendingRetry(HandoverRetry); + handWaylandSocketToInputMethod(); + }); + } + static constexpr int RetryIntervalMs = 500; + // fcitx5 may still be initializing when its D-Bus name shows up, so allow + // one delayed retry before giving up until the next activation. + static constexpr int HandoverRetryIntervalMs = RetryIntervalMs * 2; + static constexpr int MaxHandoverAttempts = 2; + // Upper bound for the login-handover retry window (40 * 500ms), after which + // we degrade to the historic publish-anyway behaviour rather than hanging + // the session forever on a broken compositor. + static constexpr int MaxWaylandActivateRetries = 40; std::shared_ptr m_unixFileDescriptor; QString m_type; bool m_started = false; int m_pendingRetries = 0; + int m_waylandActivateFailures = 0; + int m_handoverAttempts = 0; QByteArray m_lastXwaylandAuth; std::optional m_compositorBus; }; diff --git a/waylib/src/server/kernel/wseat.cpp b/waylib/src/server/kernel/wseat.cpp index 48b90d7d9c..34052bca34 100644 --- a/waylib/src/server/kernel/wseat.cpp +++ b/waylib/src/server/kernel/wseat.cpp @@ -71,12 +71,20 @@ class Q_DECL_HIDDEN WSeatPrivate : public WObjectPrivate pendingEvents.reserve(2); m_repeatTimer.callOnTimeout([&](){ - if (!focusWindow) { + if (!focusWindow || !m_repeatKey) { + m_repeatTimer.stop(); + m_repeatKey.reset(); return; } - auto rawdevice = WInputDevice::from(m_repeatKey->device())->handle(); - auto wlrKeyboard = rawdevice->type == WLR_INPUT_DEVICE_KEYBOARD + auto inputDevice = WInputDevice::from(m_repeatKey->device()); + auto rawdevice = inputDevice ? inputDevice->handle() : nullptr; + auto wlrKeyboard = rawdevice && rawdevice->type == WLR_INPUT_DEVICE_KEYBOARD ? wlr_keyboard_from_input_device(rawdevice) : nullptr; + if (!wlrKeyboard || wlrKeyboard->repeat_info.rate <= 0) { + m_repeatTimer.stop(); + m_repeatKey.reset(); + return; + } m_repeatTimer.setInterval(1000 / wlrKeyboard->repeat_info.rate); auto evPress = QKeyEvent(QEvent::KeyPress, m_repeatKey->key(), m_repeatKey->modifiers(), m_repeatKey->nativeScanCode(), m_repeatKey->nativeVirtualKey(), m_repeatKey->nativeModifiers(), @@ -223,7 +231,11 @@ class Q_DECL_HIDDEN WSeatPrivate : public WObjectPrivate const wlr_keyboard_modifiers *modifiers = nullptr; const uint32_t *keycodes = nullptr; size_t numKeycodes = 0; - auto keyboard = q_func()->keyboard(); + // Prefer the keyboard group for the enter payload: while a text + // input is active the seat's current keyboard may be the input + // method's virtual keyboard, whose (empty or stale) pressed-key + // and modifier state must never leak into focus transitions. + auto keyboard = groupkeyboardDevice ? groupkeyboardDevice : q_func()->keyboard(); if (keyboard) { auto *wlr_keyboard = wlr_keyboard_from_input_device(keyboard->handle()); if (wlr_keyboard) { @@ -313,8 +325,12 @@ class Q_DECL_HIDDEN WSeatPrivate : public WObjectPrivate // for keyboard event inline bool doNotifyKey(WInputDevice *device, uint32_t keycode, uint32_t state, uint32_t timestamp) { - q_func()->setKeyboard(device); + if (keyboardFocusSurface() && keyboardFilter + && keyboardFilter->filterKey(q_func(), device, keycode, state, timestamp)) { + return true; + } + q_func()->setKeyboard(device); if (!keyboardFocusSurface()) return false; @@ -325,6 +341,11 @@ class Q_DECL_HIDDEN WSeatPrivate : public WObjectPrivate inline bool doNotifyModifiers(WInputDevice *device) { auto keyboard = wlr_keyboard_from_input_device(device->handle()); + if (keyboardFocusSurface() && keyboardFilter + && keyboardFilter->filterModifiers(q_func(), device, &keyboard->modifiers)) { + return true; + } + // wlr_seat_set_keyboard() already sends modifiers when the keyboard // changes, so skip the explicit send to avoid a duplicate. bool keyboardChanged = (q_func()->keyboard() != device); @@ -383,6 +404,7 @@ class Q_DECL_HIDDEN WSeatPrivate : public WObjectPrivate QVector deviceList; QVector touchDeviceList; QPointer eventFilter; + WSeatKeyboardFilter *keyboardFilter = nullptr; QPointer focusWindow; QPointer pointerFocusEventObject; QPointer m_keyboardFocusSurface; @@ -579,6 +601,14 @@ void WSeatPrivate::on_keyboard_key(wlr_keyboard_key_event *event, WInputDevice * { auto keyboard = wlr_keyboard_from_input_device(device->handle()); + if (focusWindow && keyboardFocusSurface() && keyboardFilter + && keyboardFilter->filterKey(q_func(), device, event->keycode, event->state, + event->time_msec)) { + m_repeatTimer.stop(); + m_repeatKey.reset(); + return; + } + auto code = event->keycode + 8; // map to wl_keyboard::keymap_format::keymap_format_xkb_v1 auto et = event->state == WL_KEYBOARD_KEY_STATE_PRESSED ? QEvent::KeyPress : QEvent::KeyRelease; xkb_keysym_t sym = xkb_state_key_get_one_sym(keyboard->xkb_state, code); @@ -609,7 +639,8 @@ void WSeatPrivate::on_keyboard_key(wlr_keyboard_key_event *event, WInputDevice * if (focusWindow) { handleKeyEvent(e); - if (et == QEvent::KeyPress && xkb_keymap_key_repeats(keyboard->keymap, code)) { + if (et == QEvent::KeyPress && keyboard->repeat_info.rate > 0 + && xkb_keymap_key_repeats(keyboard->keymap, code)) { if (m_repeatKey) { m_repeatTimer.stop(); } @@ -1167,6 +1198,8 @@ void WSeat::setKeyboardFocusSurface(WSurface *surface) setKeyboard(d->groupkeyboardDevice); } + d->m_repeatTimer.stop(); + d->m_repeatKey.reset(); d->m_keyboardFocusSurface = surface; if (isValid()) d->doSetKeyboardFocus(surface ? surface->handle() : nullptr); @@ -1183,12 +1216,22 @@ WSurface *WSeat::keyboardFocusSurface() const void WSeat::clearKeyboardFocusSurface() { W_D(WSeat); + d->m_repeatTimer.stop(); + d->m_repeatKey.reset(); d->doSetKeyboardFocus(nullptr); + // Keep the wrapper view and the focus-change signal consistent with the + // raw seat state; listeners (input-method helper) reconcile on it. + d->m_keyboardFocusSurface = nullptr; + Q_EMIT keyboardFocusSurfaceChanged(); } void WSeat::setKeyboardFocusWindow(QWindow *window) { W_D(WSeat); + if (d->focusWindow == window) + return; + d->m_repeatTimer.stop(); + d->m_repeatKey.reset(); d->focusWindow = window; } @@ -1201,6 +1244,8 @@ QWindow *WSeat::keyboardFocusWindow() const void WSeat::clearKeyboardFocusWindow() { W_D(WSeat); + d->m_repeatTimer.stop(); + d->m_repeatKey.reset(); d->focusWindow = nullptr; } @@ -1630,6 +1675,19 @@ void WSeat::setEventFilter(WSeatEventFilter *filter) d->eventFilter = filter; } +WSeatKeyboardFilter *WSeat::keyboardFilter() const +{ + W_DC(WSeat); + return d->keyboardFilter; +} + +void WSeat::setKeyboardFilter(WSeatKeyboardFilter *filter) +{ + W_D(WSeat); + Q_ASSERT(!filter || !d->keyboardFilter || d->keyboardFilter == filter); + d->keyboardFilter = filter; +} + void WSeat::create(WServer *server) { W_D(WSeat); @@ -1678,6 +1736,7 @@ void WSeat::destroy(WServer *) i->setSeat(nullptr); d->deviceList.clear(); + d->keyboardFilter = nullptr; // Need not call the DCursor::detachInputDevice on destroy WSeat, so do // call the detachCursor at clear the deviceList after. diff --git a/waylib/src/server/kernel/wseat.h b/waylib/src/server/kernel/wseat.h index 5cf5308fb2..66d422d360 100644 --- a/waylib/src/server/kernel/wseat.h +++ b/waylib/src/server/kernel/wseat.h @@ -28,6 +28,17 @@ WAYLIB_SERVER_BEGIN_NAMESPACE class WSurface; class WSeat; +class WAYLIB_SERVER_EXPORT WSeatKeyboardFilter +{ +public: + virtual ~WSeatKeyboardFilter() = default; + + virtual bool filterKey(WSeat *seat, WInputDevice *device, uint32_t keycode, + uint32_t state, uint32_t timestamp) = 0; + virtual bool filterModifiers(WSeat *seat, WInputDevice *device, + const wlr_keyboard_modifiers *modifiers) = 0; +}; + class WAYLIB_SERVER_EXPORT WSeatEventFilter : public QObject { friend class WSeat; @@ -86,6 +97,8 @@ class WAYLIB_SERVER_EXPORT WSeat : public QObject, public WObject, public WServe WSeatEventFilter *eventFilter() const; void setEventFilter(WSeatEventFilter *filter); + WSeatKeyboardFilter *keyboardFilter() const; + void setKeyboardFilter(WSeatKeyboardFilter *filter); WSurface *pointerFocusSurface() const; diff --git a/waylib/src/server/protocols/private/wtextinputv1.cpp b/waylib/src/server/protocols/private/wtextinputv1.cpp index 2354fd9cac..cefeddb366 100644 --- a/waylib/src/server/protocols/private/wtextinputv1.cpp +++ b/waylib/src/server/protocols/private/wtextinputv1.cpp @@ -11,6 +11,7 @@ #include #include +#include #include extern "C" { @@ -33,7 +34,14 @@ class Q_DECL_HIDDEN WTextInputV1Private : public WTextInputPrivate wl_resource *resource {nullptr}; WSeat *seat {nullptr}; - WSurface *focusedSurface {nullptr}; + // Client-owned activation record: set by the activate request and only + // cleared by a client deactivation or the surface's destruction. Server + // driven enter/leave notifications do not erase it, so keyboard focus + // returning to the activated surface can re-arm the text input. + QPointer focusedSurface; + // True after an enter event has been sent to the client and no matching + // leave has been sent yet; keeps enter/leave paired. + bool entered {false}; uint32_t currentSerial {0}; bool active {false}; QString surroundingText {}; @@ -139,20 +147,29 @@ IME::Features WTextInputV1::features() const void WTextInputV1::sendEnter(WSurface *surface) { + W_D(WTextInputV1); // Note: For text input v1, activation and surface focus is managed by client. // Do not send focus to text input unless it's activated. - if (d_func()->active) - zwp_text_input_v1_send_enter(d_func()->resource, surface->handle()->resource); + if (d->active && !d->entered) { + zwp_text_input_v1_send_enter(d->resource, surface->handle()->resource); + d->entered = true; + } Q_EMIT this->enabled(); } void WTextInputV1::sendLeave() { - if (focusedSurface()) { - W_D(WTextInputV1); - d->focusedSurface = nullptr; - zwp_text_input_v1_send_leave(d_func()->resource); - Q_EMIT disabled(); + W_D(WTextInputV1); + if (!d->focusedSurface) { + return; + } + // A server-side leave is only a notification: it neither clears the + // client-owned activation record nor revokes the enablement. Only a + // client-side deactivate or destruction of the activated surface does + // (see text_input_handle_deactivate and the beforeDestroy connection). + if (d->entered) { + zwp_text_input_v1_send_leave(d->resource); + d->entered = false; } } @@ -242,8 +259,15 @@ void text_input_handle_activate([[maybe_unused]] wl_client *client, if (text_input->focusedSurface()) text_input->focusedSurface()->disconnect(text_input); d->focusedSurface = wSurface; - QObject::connect(wSurface, &WSurface::beforeDestroy, - text_input, &WTextInputV1::sendLeave); + d->entered = false; + // Destroying the activated surface ends its activation for real (this + // path alone, unlike a plain server-side leave, revokes the record). + QObject::connect(wSurface, &WSurface::beforeDestroy, text_input, [text_input] { + auto *dd = text_input->d_func(); + dd->focusedSurface = nullptr; + dd->entered = false; + Q_EMIT text_input->disabled(); + }); } d->active = true; Q_EMIT text_input->activate(); @@ -262,7 +286,14 @@ void text_input_handle_deactivate([[maybe_unused]] wl_client *client, d->seat = nullptr; d->active = false; + // The client revokes its own activation: this is an authoritative + // disablement (a server-side leave alone no longer is one). Emit while + // the record still exists so the chained requestLeave/sendLeave below can + // pair the native leave event. + Q_EMIT text_input->disabled(); Q_EMIT text_input->deactivate(); + d->focusedSurface = nullptr; + d->entered = false; } void text_input_handle_show_input_panel([[maybe_unused]] wl_client *client, diff --git a/waylib/src/server/protocols/private/wtextinputv2.cpp b/waylib/src/server/protocols/private/wtextinputv2.cpp index fbb86f8e76..7af99369d7 100644 --- a/waylib/src/server/protocols/private/wtextinputv2.cpp +++ b/waylib/src/server/protocols/private/wtextinputv2.cpp @@ -52,6 +52,7 @@ class Q_DECL_HIDDEN WTextInputV2Private : public WTextInputPrivate WClient *client; WSurface *enabledSurface; WSurface *focusedSurface; + QMetaObject::Connection enabledSurfaceDestroyConnection; // Tracks the beforeDestroy connection of the focused surface. Qt 6 does // not support Qt::UniqueConnection with a functor target (it asserts in // debug builds), so the connection is managed explicitly. @@ -211,8 +212,9 @@ void handle_text_input_enable([[maybe_unused]] wl_client *client, wl_resource *r text_input->clearEnabledSurface(); } d->enabledSurface = wSurface; - QObject::connect(wSurface, &WSurface::beforeDestroy, - text_input, &WTextInputV2::clearEnabledSurface); + d->enabledSurfaceDestroyConnection = QObject::connect( + wSurface, &WSurface::beforeDestroy, + text_input, &WTextInputV2::clearEnabledSurface); Q_EMIT text_input->enableOnSurface(wSurface); } @@ -393,6 +395,13 @@ IME::Features WTextInputV2::features() const void WTextInputV2::sendEnter(WSurface *surface) { W_D(WTextInputV2); + Q_ASSERT(surface); + if (!surface || d->focusedSurface == surface) + return; + + if (d->focusedSurface) + sendLeave(); + d->focusedSurface = surface; // Qt::UniqueConnection is only supported for member function targets and // asserts with a functor, so replace the previous focus-destroy @@ -403,10 +412,14 @@ void WTextInputV2::sendEnter(WSurface *surface) } d->focusedSurfaceDestroyConnection = QObject::connect(surface, &WSurface::beforeDestroy, this, [this, d, surface] { - if (d->focusedSurface == surface) - sendLeave(); - }); + if (d->focusedSurface == surface) + sendLeave(); + }); zwp_text_input_v2_send_enter(d->resource, 0, surface->handle()->resource); + qCDebug(lcWlTextInput) << "Text input v2 focus entered" + << "textInput" << this + << "surface" << surface + << "enabledSurface" << d->enabledSurface; if (d->enabledSurface == d->focusedSurface) { Q_EMIT enabled(); } @@ -416,19 +429,29 @@ void WTextInputV2::sendLeave() { W_D(WTextInputV2); if (!d->focusedSurface) { - qCWarning(lcWlTextInput()) << "Send leave to a null focused surface."; + qCDebug(lcWlTextInput) << "Ignoring duplicate text input v2 leave" + << "textInput" << this; return; } - zwp_text_input_v2_send_leave(d->resource, 0, d->focusedSurface->handle()->resource); - if (d->enabledSurface == d->focusedSurface) { - Q_EMIT disabled(); - } - d->focusedSurface = nullptr; + + auto *oldSurface = d->focusedSurface; + const bool wasEnabled = d->enabledSurface == oldSurface; // The focused surface no longer needs its destroy notification. if (d->focusedSurfaceDestroyConnection) { QObject::disconnect(d->focusedSurfaceDestroyConnection); d->focusedSurfaceDestroyConnection = {}; } + d->focusedSurface = nullptr; + zwp_text_input_v2_send_leave(d->resource, 0, oldSurface->handle()->resource); + qCDebug(lcWlTextInput) << "Text input v2 focus left" + << "textInput" << this + << "surface" << oldSurface + << "wasEnabled" << wasEnabled; + // Note: a compositor-driven leave is only a notification. It must NOT + // emit disabled(): the client's own disable request (disableOnSurface) + // or the enabled surface's destruction is what revokes the enablement + // the input-method helper anchors on. Eligibility additionally requires + // focusedSurface() to match the seat focus, which is cleared right here. } void WTextInputV2::sendDone() @@ -454,15 +477,18 @@ void WTextInputV2::handleIMCommitted(WInputMethodV2 *im) WTextInputV2::WTextInputV2(QObject *parent) : WTextInput(*new WTextInputV2Private(this), parent) { - connect(this, &WTextInputV2::enableOnSurface, this, [this] { - if (focusedSurface()) { + connect(this, &WTextInputV2::enableOnSurface, this, [this](WSurface *surface) { + if (focusedSurface() == surface) { Q_EMIT enabled(); } }); - connect(this, &WTextInputV2::disableOnSurface, this, [this] { - if (!focusedSurface()) { - Q_EMIT disabled(); - } + connect(this, &WTextInputV2::disableOnSurface, this, [this](WSurface *) { + // clearEnabledSurface() runs only for an authoritative end of the + // client-side enablement (explicit disable request, or the enabled + // surface's destruction). Compositor-driven leave no longer emits + // disabled(), so revoke unconditionally here regardless of which + // surface currently holds keyboard focus. + Q_EMIT disabled(); }); connect(this, &WTextInput::enabled, this, [this]{ qCDebug(lcWlTextInput()) << "text input v2" << this << "enabled"; @@ -494,7 +520,10 @@ void WTextInputV2::clearEnabledSurface() W_D(WTextInputV2); Q_ASSERT(d->enabledSurface); Q_EMIT disableOnSurface(d->enabledSurface); - d->enabledSurface->disconnect(this); + if (d->enabledSurfaceDestroyConnection) { + QObject::disconnect(d->enabledSurfaceDestroyConnection); + d->enabledSurfaceDestroyConnection = {}; + } d->enabledSurface = nullptr; } WAYLIB_SERVER_END_NAMESPACE diff --git a/waylib/src/server/protocols/winputmethodhelper.cpp b/waylib/src/server/protocols/winputmethodhelper.cpp index dc9a813b3e..1dd9009ce6 100644 --- a/waylib/src/server/protocols/winputmethodhelper.cpp +++ b/waylib/src/server/protocols/winputmethodhelper.cpp @@ -17,60 +17,76 @@ #include -#include - #include +#include + WAYLIB_SERVER_BEGIN_NAMESPACE -struct Q_DECL_HIDDEN GrabHandlerArg { - const WInputMethodHelper *const helper; - wlr_input_method_keyboard_grab_v2 *grab; +namespace { + +struct KeyboardStateSnapshot +{ + xkb_mod_mask_t depressed = 0; + xkb_mod_mask_t latched = 0; + xkb_mod_mask_t locked = 0; + xkb_layout_index_t layout = 0; }; -void handleKey(struct wlr_seat_keyboard_grab *grab, uint32_t time_msec, uint32_t key, uint32_t state) +KeyboardStateSnapshot snapshotKeyboardState(struct xkb_state *state) { - auto arg = reinterpret_cast(grab->data); - if (!arg->grab) { - qCCritical(lcWlInputMethod) << "Ignore key event for destroyed input method keyboard grab" - << "key" << key << "state" << state; - return; - } - for (auto vk: arg->helper->virtualKeyboards()) { - if (wlr_keyboard_from_input_device(vk->handle()) == grab->seat->keyboard_state.keyboard) { - auto *virtualKeyboard = wlr_input_device_get_virtual_keyboard(vk->handle()); - if (virtualKeyboard && virtualKeyboard->resource && arg->grab->resource - && wl_resource_get_client(virtualKeyboard->resource) - == wl_resource_get_client(arg->grab->resource)) { - grab->seat->keyboard_state.default_grab->interface->key(grab, time_msec, key, state); - return; - } + KeyboardStateSnapshot snap; + snap.depressed = xkb_state_serialize_mods(state, XKB_STATE_MODS_DEPRESSED); + snap.latched = xkb_state_serialize_mods(state, XKB_STATE_MODS_LATCHED); + snap.locked = xkb_state_serialize_mods(state, XKB_STATE_MODS_LOCKED); + snap.layout = xkb_state_serialize_layout(state, XKB_STATE_LAYOUT_EFFECTIVE); + return snap; +} + +// Resolve a keysym to a (keycode, needsShift) pair from the given keymap and +// state. Uppercase/symbol keysyms are searched with Shift added to the +// currently effective modifier set. +bool findKeysymWithShift(struct xkb_keymap *keymap, struct xkb_state *state, + xkb_keysym_t keysym, xkb_keycode_t *keycodeOut, bool *shiftOut) +{ + const xkb_keycode_t first = xkb_keymap_min_keycode(keymap); + const xkb_keycode_t last = xkb_keymap_max_keycode(keymap); + for (xkb_keycode_t kc = first; kc <= last; ++kc) { + if (xkb_state_key_get_one_sym(state, kc) == keysym) { + *keycodeOut = kc; + *shiftOut = false; + return true; } } - wlr_input_method_keyboard_grab_v2_send_key(arg->grab, time_msec, key, state); -} -void handleModifiers(struct wlr_seat_keyboard_grab *grab, const struct wlr_keyboard_modifiers *modifiers) -{ - auto arg = reinterpret_cast(grab->data); - if (!arg->grab) { - qCCritical(lcWlInputMethod) << "Ignore modifiers for destroyed input method keyboard grab"; - return; + const xkb_mod_index_t shiftIndex = xkb_keymap_mod_get_index(keymap, XKB_MOD_NAME_SHIFT); + if (shiftIndex == XKB_MOD_INVALID) { + return false; } - for (auto vk: arg->helper->virtualKeyboards()) { - if (wlr_keyboard_from_input_device(vk->handle()) == grab->seat->keyboard_state.keyboard) { - auto *virtualKeyboard = wlr_input_device_get_virtual_keyboard(vk->handle()); - if (virtualKeyboard && virtualKeyboard->resource && arg->grab->resource - && wl_resource_get_client(virtualKeyboard->resource) - == wl_resource_get_client(arg->grab->resource)) { - grab->seat->keyboard_state.default_grab->interface->modifiers(grab, modifiers); - return; + const KeyboardStateSnapshot snap = snapshotKeyboardState(state); + struct xkb_state *shifted = xkb_state_new(keymap); + if (!shifted) { + return false; + } + bool found = false; + if (xkb_state_update_mask(shifted, snap.depressed | (xkb_mod_mask_t(1) << shiftIndex), + snap.latched, snap.locked, 0, 0, snap.layout) == 0) { + for (xkb_keycode_t kc = first; kc <= last; ++kc) { + if (xkb_state_key_get_one_sym(shifted, kc) == keysym) { + *keycodeOut = kc; + *shiftOut = true; + found = true; + break; } } } - wlr_input_method_keyboard_grab_v2_send_modifiers(arg->grab, const_cast(modifiers)); + xkb_state_unref(shifted); + return found; } -class Q_DECL_HIDDEN WInputMethodHelperPrivate : public WObjectPrivate +} // namespace + +class Q_DECL_HIDDEN WInputMethodHelperPrivate : public WObjectPrivate, + public WSeatKeyboardFilter { W_DECLARE_PUBLIC(WInputMethodHelper) public: @@ -86,9 +102,6 @@ class Q_DECL_HIDDEN WInputMethodHelperPrivate : public WObjectPrivate , enabledTextInput(nullptr) , activeInputMethod(nullptr) , activeKeyboardGrab(nullptr) - , keyboardGrab{} - , grabInterface{} - , handlerArg({.helper = qq, .grab = nullptr}) { Q_ASSERT(server); Q_ASSERT(seat); @@ -98,34 +111,15 @@ class Q_DECL_HIDDEN WInputMethodHelperPrivate : public WObjectPrivate Q_ASSERT(textInputManagerV3); } - void endGrab(wlr_input_method_keyboard_grab_v2 *kgv2) + bool isInputMethodVirtualKeyboard(wlr_input_method_keyboard_grab_v2 *kgv2, + WInputDevice *keyboard) const { - if (!seat) { - qCCritical(lcWlInputMethod) << "Failed to end input method keyboard grab - seat is already destroyed" - << kgv2; - return; - } - - auto *kgHandle = kgv2; - if (!kgHandle) { - qCCritical(lcWlInputMethod) << "Failed to end input method keyboard grab - grab handle is invalid" - << kgv2; - return; - } - - if (kgHandle->keyboard) { - wlr_seat_keyboard_send_modifiers(seat->handle(), &kgHandle->keyboard->modifiers); - } - // Only end the grab if our grab is still the active one on the seat. - // A popup grab may have silently replaced us (wlr_seat_keyboard_start_grab - // unconditionally overwrites keyboard_state.grab). - auto isStillActive = seat->handle()->keyboard_state.grab == &keyboardGrab; - qCDebug(lcWlInputMethod) << "endGrab: isStillActive" << isStillActive << "grab ptr" - << seat->handle()->keyboard_state.grab << "&keyboardGrab" - << &keyboardGrab; - if (isStillActive) { - wlr_seat_keyboard_end_grab(seat->handle()); - } + if (!kgv2 || !keyboard) + return false; + auto *virtualKeyboard = wlr_input_device_get_virtual_keyboard(keyboard->handle()); + return virtualKeyboard && virtualKeyboard->resource && kgv2->resource + && wl_resource_get_client(virtualKeyboard->resource) + == wl_resource_get_client(kgv2->resource); } void setKeyboard(wlr_input_method_keyboard_grab_v2 *kgv2, WInputDevice *keyboard) @@ -138,22 +132,124 @@ class Q_DECL_HIDDEN WInputMethodHelperPrivate : public WObjectPrivate } if (keyboard) { - auto *virtualKeyboard = wlr_input_device_get_virtual_keyboard(keyboard->handle()); - // refer to: - // https://github.com/swaywm/sway/blob/master/sway/input/keyboard.c#L391 - if (virtualKeyboard - && virtualKeyboard->resource - && kgHandle->resource - && wl_resource_get_client(virtualKeyboard->resource) - == wl_resource_get_client(kgHandle->resource)) { + if (isInputMethodVirtualKeyboard(kgv2, keyboard)) return; - } wlr_input_method_keyboard_grab_v2_set_keyboard(kgv2, wlr_keyboard_from_input_device(keyboard->handle())); } else { wlr_input_method_keyboard_grab_v2_set_keyboard(kgv2, nullptr); } } + bool isTextInputEligible(WTextInput *ti) const + { + if (!ti || !enabledTextInputs.contains(ti) || !seat) + return false; + + auto *focus = seat->keyboardFocusSurface(); + return focus && ti->seat() == seat && ti->focusedSurface() == focus + && ti->waylandClient() == focus->waylandClient(); + } + + // Newest enabled text input that currently matches the keyboard focus, if + // any. Activation (reconcileTextInput), key routing (keyboardGrabBypassed), + // popup anchoring (handleNewIPSV2) and commit routing (handleIMCommitted) + // are all driven by this same predicate, so they can not disagree about + // which surface currently owns the input method focus. + WTextInput *findEligibleTextInput() const + { + for (auto it = enabledTextInputs.crbegin(); it != enabledTextInputs.crend(); ++it) { + if (isTextInputEligible(*it)) + return *it; + } + return nullptr; + } + + // Announces that textInputFocusSurface() changed. Called once the focus + // state settled (end of reconcileTextInput), so consumers read a + // consistent view. Idempotent. + void notifyTextInputFocusSurfaceChanged() + { + auto *focus = enabledTextInput ? enabledTextInput->focusedSurface() : nullptr; + if (focus == lastTextInputFocusSurface) + return; + lastTextInputFocusSurface = focus; + if (!seat) { + // Teardown: no consumer is left to react. + return; + } + W_Q(WInputMethodHelper); + Q_EMIT q->textInputFocusSurfaceChanged(focus); + } + + // Wraps a native input method popup surface and announces it. Called as + // soon as a text input is eligible to anchor it. + void createPopupSurface(WSurface *focus, const QRect &cursorRect, + wlr_input_popup_surface_v2 *native); + + // Attaches pending popup surfaces to the currently eligible text input. + void drainPendingPopupSurfaces(); + + // Physical keys and modifiers are routed to the input method's keyboard + // endpoint only while the keyboard focus surface owns an eligible text + // input. The input method's activation state alone is not enough: it may + // be held across focus transitions (see reconcileTextInput), and surfaces + // that never use text-input (XWayland windows, plain terminals, games) + // must keep receiving their keys directly so that the client-side input + // method path (XIM / DBus frontends, client-side key handling) stays in + // charge. Commits follow the same rule: without an eligible text input + // there is no surface the text may be delivered to. + bool keyboardGrabBypassed(WSeat *eventSeat, WInputDevice *device) const + { + if (!seat || eventSeat != seat || !inputMethodActive + || !activeKeyboardGrab || isInputMethodVirtualKeyboard(activeKeyboardGrab, device)) { + return true; + } + if (!findEligibleTextInput()) + return true; + // Drag-and-drop owns the seat keyboard grab; keys must follow the + // drag instead of being diverted into the input method. + auto *seatHandle = seat->handle(); + return !seatHandle || seatHandle->drag != nullptr; + } + + bool filterKey(WSeat *eventSeat, WInputDevice *device, uint32_t keycode, + uint32_t state, uint32_t timestamp) override + { + if (keyboardGrabBypassed(eventSeat, device)) { + return false; + } + + setKeyboard(activeKeyboardGrab, device); + wlr_input_method_keyboard_grab_v2_send_key(activeKeyboardGrab, timestamp, keycode, state); + return true; + } + + bool filterModifiers(WSeat *eventSeat, WInputDevice *device, + const wlr_keyboard_modifiers *modifiers) override + { + if (keyboardGrabBypassed(eventSeat, device)) { + return false; + } + + auto *keyboard = wlr_keyboard_from_input_device(device->handle()); + const bool keyboardChanged = activeKeyboardGrab->keyboard != keyboard; + setKeyboard(activeKeyboardGrab, device); + if (!keyboardChanged) { + wlr_input_method_keyboard_grab_v2_send_modifiers( + activeKeyboardGrab, + const_cast(modifiers)); + } + return true; + } + + // Types text into the currently focused surface through the input + // method's virtual keyboard, by mirroring the exact server-side effect of + // a zwp_virtual_keyboard_v1 request. Returns the number of code points + // typed; code points without a keymap mapping (e.g. CJK, which has no + // physical key on any layout) are skipped, matching what any compositor + // relying on virtual-keyboard delivery can express. Never logs the text. + quint32 typeTextViaVirtualKeyboard(const QString &text); + const QPointer server; const QPointer seat; const QPointer inputMethodManagerV2; @@ -164,30 +260,181 @@ class Q_DECL_HIDDEN WInputMethodHelperPrivate : public WObjectPrivate WTextInput *enabledTextInput { nullptr }; WInputMethodV2 *activeInputMethod { nullptr }; wlr_input_method_keyboard_grab_v2 *activeKeyboardGrab {nullptr}; - - wlr_seat_keyboard_grab keyboardGrab; - wlr_keyboard_grab_interface grabInterface; - GrabHandlerArg handlerArg; - std::unique_ptr keyboardGrabListenerOwner; + bool inputMethodActive = false; + bool updatingFocus = false; + bool pendingResync = false; + // True while the input method stays activated across a momentary null + // keyboard focus (see reconcileTextInput). Keys are not routed to the + // input method in that state (there is no eligible text input, see + // keyboardGrabBypassed); the flag only records that the activation was + // deliberately kept alive instead of being torn down. + bool anchorHeld = false; + quint64 transitionSerial = 0; + // Last value announced through textInputFocusSurfaceChanged(). + WSurface *lastTextInputFocusSurface = nullptr; + WScopedListener keyboardGrabDestroyListener; QList textInputs; + QList enabledTextInputs; QList virtualKeyboards; QList popupSurfaces; + // A native popup surface the input method created before any text input + // was eligible to anchor it. The input method reuses the same popup + // surface until its panel hides, so dropping it would keep the candidate + // window invisible for that whole period; keep it until an eligible text + // input shows up, or until the input method releases it. + struct PendingPopupSurface { + wlr_input_popup_surface_v2 *handle = nullptr; + WScopedListener destroyListener; + }; + QList pendingPopupSurfaces; + }; +quint32 WInputMethodHelperPrivate::typeTextViaVirtualKeyboard(const QString &text) +{ + if (!seat || !activeKeyboardGrab || text.isEmpty()) { + return 0; + } + + // Use the newest virtual keyboard owned by the input method client; it + // carries the keymap the input method echoed from our keyboard endpoint. + auto *grabClient = wl_resource_get_client(activeKeyboardGrab->resource); + wlr_keyboard *virtualKeyboard = nullptr; + for (auto it = virtualKeyboards.crbegin(); it != virtualKeyboards.crend(); ++it) { + WInputDevice *device = *it; + if (!device || !device->handle()) { + continue; + } + auto *vk = wlr_input_device_get_virtual_keyboard(device->handle()); + if (vk && vk->has_keymap && vk->resource + && wl_resource_get_client(vk->resource) == grabClient) { + virtualKeyboard = &vk->keyboard; + break; + } + } + if (!virtualKeyboard || !virtualKeyboard->keymap || !virtualKeyboard->xkb_state) { + return 0; + } + + const KeyboardStateSnapshot snap = snapshotKeyboardState(virtualKeyboard->xkb_state); + const xkb_mod_index_t shiftIndex = + xkb_keymap_mod_get_index(virtualKeyboard->keymap, XKB_MOD_NAME_SHIFT); + const xkb_mod_mask_t shiftBit = shiftIndex == XKB_MOD_INVALID + ? xkb_mod_mask_t(0) : (xkb_mod_mask_t(1) << shiftIndex); + + const auto now = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + const uint32_t timeMsec = static_cast(now); + + quint32 typed = 0; + for (int i = 0; i < text.size(); ++i) { + uint32_t codePoint = text.at(i).unicode(); + if (QChar::isHighSurrogate(codePoint) && i + 1 < text.size() + && QChar::isLowSurrogate(text.at(i + 1).unicode())) { + codePoint = QChar::surrogateToUcs4(text.at(i), text.at(i + 1)); + ++i; + } + const xkb_keysym_t keysym = codePoint < 0x100 + ? static_cast(codePoint) + : static_cast(0x01000000u + codePoint); + + xkb_keycode_t keycode = 0; + bool needsShift = false; + if (!findKeysymWithShift(virtualKeyboard->keymap, virtualKeyboard->xkb_state, + keysym, &keycode, &needsShift)) { + continue; // No physical key (e.g. CJK): cannot be typed this way. + } + + const bool shiftAlreadyDown = snap.depressed & shiftBit; + const bool pressShift = needsShift && !shiftAlreadyDown && shiftBit; + if (pressShift) { + wlr_keyboard_notify_modifiers(virtualKeyboard, snap.depressed | shiftBit, + snap.latched, snap.locked, snap.layout); + } + + // Mirror virtual_keyboard_handle_key(): the input method keeps the + // modifier state of its virtual keyboard itself, so the synthetic key + // events must not update it. wlr_keyboard_notify_key() expects evdev + // keycodes (the input method's own deliveries subtract 8 as well), + // while findKeysymWithShift() returns xkb (evdev + 8) keycodes, so + // convert before injecting. + wlr_keyboard_key_event keyEvent {}; + keyEvent.time_msec = timeMsec; + keyEvent.keycode = keycode > 8 ? keycode - 8 : keycode; + keyEvent.update_state = false; + keyEvent.state = WL_KEYBOARD_KEY_STATE_PRESSED; + wlr_keyboard_notify_key(virtualKeyboard, &keyEvent); + keyEvent.state = WL_KEYBOARD_KEY_STATE_RELEASED; + wlr_keyboard_notify_key(virtualKeyboard, &keyEvent); + + if (pressShift) { + wlr_keyboard_notify_modifiers(virtualKeyboard, snap.depressed, + snap.latched, snap.locked, snap.layout); + } + ++typed; + } + return typed; +} + +void WInputMethodHelperPrivate::createPopupSurface(WSurface *focus, const QRect &cursorRect, + wlr_input_popup_surface_v2 *native) +{ + W_Q(WInputMethodHelper); + auto surface = new WInputPopupSurface(native, focus); + popupSurfaces.append(surface); + q->updatePopupSurface(surface, cursorRect); + Q_EMIT q->inputPopupSurfaceV2Added(surface); + auto *listeners = surface->listeners(q); + listeners->add(&native->events.destroy, q, + [q, this, surface] (void *) { + popupSurfaces.removeAll(surface); + Q_EMIT q->inputPopupSurfaceV2Removed(surface); + // Safe to destroy the wrapper from inside its own destroy + // callback: the listener closure is reference-counted, and + // ~WInputPopupSurface clears the reverse mapping while the + // native popup storage is still valid. + delete surface; + }); +} + +void WInputMethodHelperPrivate::drainPendingPopupSurfaces() +{ + if (pendingPopupSurfaces.isEmpty()) + return; + + auto *ti = findEligibleTextInput(); + if (!ti || !ti->focusedSurface()) + return; + + // Take the list before wrapping anything: inputPopupSurfaceV2Added runs + // compositor code synchronously, and a re-entrant drain must not see (and + // wrap twice) entries that are already being attached. A popup created + // during this loop stays pending until the next reconcile. + const auto pending = pendingPopupSurfaces; + pendingPopupSurfaces.clear(); + for (auto *entry : pending) { + // Detach before wrapping so the native destroy signal can no longer + // free the entry from under this loop. + entry->destroyListener.disconnect(); + createPopupSurface(ti->focusedSurface(), ti->cursorRect(), entry->handle); + delete entry; + } +} + WInputMethodHelper::WInputMethodHelper(WServer *server, WSeat *seat) : QObject(server) , WObject(*new WInputMethodHelperPrivate(server, seat, this)) { W_D(WInputMethodHelper); + Q_ASSERT(!d->seat->keyboardFilter()); + d->seat->setKeyboardFilter(d); QObject::connect(d->seat, &WSeat::keyboardFocusSurfaceChanged, this, &WInputMethodHelper::resendKeyboardFocus); QObject::connect(d->seat, &WSeat::keyboardChanged, this, [d] { if (auto *activeKG = d->activeKeyboardGrab) d->setKeyboard(activeKG, d->seat->keyboard()); }); - d->seat->listeners(this)->add(&d->seat->handle()->events.keyboard_grab_begin, this, - &WInputMethodHelper::handleKeyboardGrabBegin); connect(d->inputMethodManagerV2, &WInputMethodManagerV2::newInputMethod, this, &WInputMethodHelper::handleNewIMV2); connect(d->textInputManagerV3, &WTextInputManagerV3::newTextInput, this, &WInputMethodHelper::handleNewTI); connect(d->virtualKeyboardManagerV1, &WVirtualKeyboardManagerV1::newVirtualKeyboard, this, &WInputMethodHelper::handleNewVKV1); @@ -199,6 +446,9 @@ WInputMethodHelper::~WInputMethodHelper() { teardown(); W_D(WInputMethodHelper); + d->keyboardGrabDestroyListener.disconnect(); + if (d->seat && d->seat->keyboardFilter() == d) + d->seat->setKeyboardFilter(nullptr); // The wrappers tracked below have no QObject parent and their destroy // callbacks capture this private; the native objects may outlive the // helper (which is deleted before the WServer), so release them here @@ -222,6 +472,15 @@ WInputMethodHelper::~WInputMethodHelper() delete popup; } + // Popup surfaces that never got a wrapper (no eligible text input). Only + // the tracking is dropped: the native objects stay with the input method. + const auto pendingPopupSurfaces = d->pendingPopupSurfaces; + d->pendingPopupSurfaces.clear(); + for (auto *pending : pendingPopupSurfaces) { + pending->destroyListener.disconnect(); + delete pending; + } + // Virtual keyboard device wrappers (also detach them from the seat). const auto virtualKeyboards = d->virtualKeyboards; d->virtualKeyboards.clear(); @@ -243,15 +502,6 @@ WInputMethodHelper::~WInputMethodHelper() if (d->virtualKeyboardManagerV1) d->virtualKeyboardManagerV1->disconnect(this); } -WTextInput *WInputMethodHelper::focusedTextInput() const -{ - W_DC(WInputMethodHelper); - auto focused = std::find_if(d->textInputs.begin(), d->textInputs.end(), [](WTextInput *ti) { - return ti->focusedSurface() != nullptr; - }); - return focused != d->textInputs.end() ? *focused : nullptr; -} - WTextInput *WInputMethodHelper::enabledTextInput() const { W_DC(WInputMethodHelper); @@ -299,31 +549,12 @@ void WInputMethodHelper::setInputMethod(WInputMethodV2 *im) if (d->activeInputMethod) d->activeInputMethod->removeListeners(this); d->activeInputMethod = im; + d->inputMethodActive = false; if (d->activeInputMethod) d->activeInputMethod->listeners(this)->add(&im->handle()->events.destroy, this, &WInputMethodHelper::handleActiveIMDestroyed); } -wlr_input_method_keyboard_grab_v2 *WInputMethodHelper::activeKeyboardGrab() const -{ - W_DC(WInputMethodHelper); - return d->activeKeyboardGrab; -} - -bool WInputMethodHelper::isActiveKeyboardGrabOwner() const -{ - W_DC(WInputMethodHelper); - if (!d->activeKeyboardGrab) - return false; - return d->seat->handle()->keyboard_state.grab == &d->keyboardGrab; -} - -const QList &WInputMethodHelper::virtualKeyboards() const -{ - W_DC(WInputMethodHelper); - return d->virtualKeyboards; -} - void WInputMethodHelper::handleNewIMV2(wlr_input_method_v2 *imv2) { W_D(WInputMethodHelper); @@ -332,7 +563,7 @@ void WInputMethodHelper::handleNewIMV2(wlr_input_method_v2 *imv2) // afterwards would leak it (the listeners stay registered on the native // handle and assert on its destroy). auto *imSeat = WSeat::fromHandle(imv2->seat); - if (!imSeat || d->seat->name() != imSeat->name()) + if (imSeat != d->seat) return; if (inputMethod()) { qCWarning(lcWlInputMethod) << "Ignore second creation of input on the same seat."; @@ -347,39 +578,50 @@ void WInputMethodHelper::handleNewIMV2(wlr_input_method_v2 *imv2) connect(wimv2, &WInputMethodV2::committed, this, &WInputMethodHelper::handleIMCommitted); connect(wimv2, &WInputMethodV2::newKeyboardGrab, this, &WInputMethodHelper::handleNewKGV2); connect(wimv2, &WInputMethodV2::newPopupSurface, this, &WInputMethodHelper::handleNewIPSV2); - // Once input method is online, try to resend enter to textInput - resendKeyboardFocus(); - // For text input v1, when after sendEnter, enabled signal will be emitted + qCInfo(lcWlInputMethod) << "Input method connected" + << "seat" << d->seat->name() + << "inputMethod" << wimv2; + reconcileTextInput("input method connected"); } void WInputMethodHelper::handleNewKGV2(wlr_input_method_keyboard_grab_v2 *kgv2) { W_D(WInputMethodHelper); Q_ASSERT(d->seat); - if (auto activeKG = activeKeyboardGrab()) { - d->endGrab(activeKG); + if (d->activeKeyboardGrab) { + qCWarning(lcWlInputMethod) << "Replacing an existing input method keyboard endpoint" + << "seat" << d->seat->name() + << "oldGrab" << d->activeKeyboardGrab + << "newGrab" << kgv2; + d->keyboardGrabDestroyListener.disconnect(); } + d->activeKeyboardGrab = kgv2; - d->setKeyboard(kgv2, d->seat->keyboard()); - d->grabInterface = *d->seat->handle()->keyboard_state.grab->interface; - d->grabInterface.key = handleKey; - d->grabInterface.modifiers = handleModifiers; - d->keyboardGrab.seat = d->seat->handle(); - d->handlerArg.grab = kgv2; - d->keyboardGrab.data = &d->handlerArg; - d->keyboardGrab.interface = &d->grabInterface; - wlr_seat_keyboard_start_grab(d->seat->handle(), &d->keyboardGrab); - qCDebug(lcWlInputMethod) << "IME keyboard grab installed"; - d->keyboardGrabListenerOwner = std::make_unique(); - auto *grabOwner = d->keyboardGrabListenerOwner.get(); - d->seat->listeners(grabOwner)->add(&kgv2->events.destroy, this, [this, d, kgv2, grabOwner] { - qCDebug(lcWlInputMethod) << "IME keyboard grab before_destroy"; - Q_ASSERT(activeKeyboardGrab() == kgv2); - d->endGrab(kgv2); + // Prefer the (always non-IME) keyboard-group device: fcitx5 creates its + // virtual keyboard right before the new keyboard endpoint, so at this + // point the seat's current keyboard may still be that virtual keyboard + // (which setKeyboard() would skip), leaving the new endpoint without + // keymap/repeat-info/modifiers until the first physically filtered key. + if (auto *groupKeyboard = d->seat->keyboardGroupKeyboard()) + d->setKeyboard(kgv2, groupKeyboard); + else + d->setKeyboard(kgv2, d->seat->keyboard()); + qCInfo(lcWlInputMethod) << "Input method keyboard endpoint available" + << "seat" << d->seat->name() + << "grab" << kgv2 + << "activeTextInput" << d->enabledTextInput + << "anotherSeatGrabActive" << wlr_seat_keyboard_has_grab(d->seat->handle()); + + d->keyboardGrabDestroyListener.init(&kgv2->events.destroy, this, + [d, kgv2](void *) { + if (d->activeKeyboardGrab != kgv2) + return; + qCInfo(lcWlInputMethod) << "Input method keyboard endpoint destroyed" + << "seat" << d->seat->name() + << "grab" << kgv2 + << "activeTextInput" << d->enabledTextInput; d->activeKeyboardGrab = nullptr; - d->handlerArg.grab = nullptr; - d->seat->removeListeners(grabOwner); - d->keyboardGrabListenerOwner.reset(); + d->keyboardGrabDestroyListener.disconnect(); }); } @@ -387,32 +629,37 @@ void WInputMethodHelper::handleNewIPSV2(wlr_input_popup_surface_v2 *ipsv2) { W_D(WInputMethodHelper); - auto createPopupSurface = [this, d] (WSurface *focus, QRect cursorRect, wlr_input_popup_surface_v2 *popupSurface){ - auto surface = new WInputPopupSurface(popupSurface, focus); - d->popupSurfaces.append(surface); - updatePopupSurface(surface, cursorRect); - Q_EMIT inputPopupSurfaceV2Added(surface); - auto *listeners = surface->listeners(this); - listeners->add(&popupSurface->events.destroy, this, - [this, d, surface] (void *) { - d->popupSurfaces.removeAll(surface); - Q_EMIT inputPopupSurfaceV2Removed(surface); - // Safe to destroy the wrapper from inside its own destroy - // callback: the listener closure is reference-counted, and - // ~WInputPopupSurface clears the reverse mapping while the - // native popup storage is still valid. - delete surface; + // The popup surface is anchored to the text input that currently owns the + // keyboard focus. If there is none yet (the input method asked for its + // panel surface while the focus was in flight or on a surface without a + // text input), keep it pending instead of dropping it: the input method + // reuses the same popup surface until its panel hides, so a dropped popup + // would keep the candidate window invisible for that whole period. + auto *ti = d->findEligibleTextInput(); + if (!ti || !ti->focusedSurface()) { + auto *entry = new WInputMethodHelperPrivate::PendingPopupSurface; + entry->handle = ipsv2; + entry->destroyListener.init(&ipsv2->events.destroy, this, [d, entry](void *) { + d->pendingPopupSurfaces.removeAll(entry); + entry->destroyListener.disconnect(); + delete entry; }); - }; - auto ti = enabledTextInput(); - if (ti && ti->focusedSurface()) { - createPopupSurface(ti->focusedSurface(), ti->cursorRect(), ipsv2); + d->pendingPopupSurfaces.append(entry); + qCInfo(lcWlInputMethod) << "Input method popup surface pending: no eligible text input yet" + << "seat" << (d->seat ? d->seat->name() : QString()) + << "popupSurface" << ipsv2 + << "activeTextInput" << d->enabledTextInput; + return; } + d->createPopupSurface(ti->focusedSurface(), ti->cursorRect(), ipsv2); } void WInputMethodHelper::handleNewVKV1(wlr_virtual_keyboard_v1 *vkv1) { W_D(WInputMethodHelper); + if (vkv1->seat != d->seat->handle()) + return; + auto *keyboard = new WInputDevice(&vkv1->keyboard.base, true); d->virtualKeyboards.append(keyboard); d->seat->attachInputDevice(keyboard); @@ -437,39 +684,75 @@ void WInputMethodHelper::handleNewVKV1(wlr_virtual_keyboard_v1 *vkv1) }); } -void WInputMethodHelper::handleKeyboardGrabBegin() -{ - W_D(WInputMethodHelper); - // If another grab (popup, drag, etc.) silently replaced our keyboard grab, - // notify all text inputs to leave so the IME can deactivate. - // Our grab v2 object is still alive (endGrab only runs on before_destroy), - // so activeKeyboardGrab is non-null, but seat->keyboard_state.grab no longer - // points to our keyboardGrab. - if (d->activeKeyboardGrab && d->seat->handle()->keyboard_state.grab != &d->keyboardGrab) { - qCDebug(lcWlInputMethod) << "IME keyboard grab silently replaced, notifying leave"; - notifyLeave(); - } -} - void WInputMethodHelper::resendKeyboardFocus() { W_D(WInputMethodHelper); - qCInfo(lcWlInputMethod()) << "resend keyboard focus"; auto focus = d->seat->keyboardFocusSurface(); - for (auto textInput : std::as_const(d->textInputs)) { - if (textInput->focusedSurface() && textInput->focusedSurface() != focus) - textInput->sendLeave(); - if (!focus) + if (d->updatingFocus) { + // Re-entrant request (a signal fired while reconciling focus): mark it + // pending instead of silently dropping the state change. + d->pendingResync = true; + return; + } + + d->updatingFocus = true; + qCDebug(lcWlInputMethod) << "Reconciling text-input focus" + << "seat" << d->seat->name() + << "focus" << focus + << "focusClient" << (focus ? focus->waylandClient() : nullptr) + << "textInputCount" << d->textInputs.size(); + + // Send every obsolete leave before any enter. In particular, never emit a + // same-client stale leave after a new enter: Qt's text-input-v2 client + // clears its current surface on every leave event. + // text-input-v1 has no seat before its first activate request; treat a + // null-seat text input as belonging to this seat, matching master. + for (auto *ti : std::as_const(d->textInputs)) { + if (ti->seat() && ti->seat() != d->seat) continue; - qCDebug(lcWlInputMethod()) << "trying to send focus to" << textInput << "from client" << textInput->waylandClient(); - if (focus->waylandClient() == textInput->waylandClient()) { - qCDebug(lcWlInputMethod) << "focus sent to" << textInput; - if ((!textInput->seat() || textInput->seat() == d->seat) - && textInput->focusedSurface() != focus) { - textInput->sendEnter(focus); + + auto *tiFocus = ti->focusedSurface(); + const bool clientMatches = focus && ti->waylandClient() == focus->waylandClient(); + bool shouldFocus = clientMatches; + if (qobject_cast(ti)) + shouldFocus = shouldFocus && tiFocus == focus; + + if (tiFocus && (!shouldFocus || tiFocus != focus)) + ti->sendLeave(); + } + + if (focus) { + for (auto *ti : std::as_const(d->textInputs)) { + if ((ti->seat() && ti->seat() != d->seat) + || ti->waylandClient() != focus->waylandClient()) { + continue; } + + // text-input-v1 focus is client-managed: only acknowledge surfaces + // whose activate request is still recorded. The record survives + // server-driven leave events, so keyboard focus returning to an + // activated v1 surface re-arms it here (sendEnter emits enabled() + // even before the client sees the enter event). + if (qobject_cast(ti)) { + if (ti->focusedSurface() == focus) + ti->sendEnter(focus); + continue; + } + + if (ti->focusedSurface() != focus) + ti->sendEnter(focus); } } + + d->updatingFocus = false; + reconcileTextInput("keyboard focus changed"); + + if (d->pendingResync) { + d->pendingResync = false; + qCDebug(lcWlInputMethod) << "Re-running focus reconciliation after re-entrant request" + << "seat" << d->seat->name(); + resendKeyboardFocus(); + } } void WInputMethodHelper::connectToTI(WTextInput *ti) @@ -482,16 +765,11 @@ void WInputMethodHelper::connectToTI(WTextInput *ti) void WInputMethodHelper::disableTI(WTextInput *ti) { + W_D(WInputMethodHelper); Q_ASSERT(ti); - if (enabledTextInput() == ti) { - // Should we consider the case when the same text input is disabled and then enabled at the same time. - auto im = inputMethod(); - if (im) { - im->sendDeactivate(); - im->sendDone(); - } - setEnabledTextInput(nullptr); - } + d->enabledTextInputs.removeAll(ti); + if (!d->updatingFocus) + reconcileTextInput("text input disabled"); } void WInputMethodHelper::handleNewTI(WTextInput *ti) @@ -511,72 +789,142 @@ void WInputMethodHelper::handleNewTI(WTextInput *ti) // its requestFocus signal for it might request focus from another seat to activate // itself here. For example, text input v1. connect(ti, &WTextInput::requestFocus, this, [this, ti, d]{ - if (ti->seat() && d->seat->name() == ti->seat()->name()) { + if (!ti->seat() || ti->seat() == d->seat) { connectToTI(ti); - if (auto surface = d->seat->keyboardFocusSurface()) { - ti->sendEnter(surface); - } + resendKeyboardFocus(); } }); - if (ti->seat() && d->seat->name() == ti->seat()->name()) { + if (!ti->seat() || ti->seat() == d->seat) { connectToTI(ti); - if (auto *surface = d->seat->keyboardFocusSurface(); surface - && surface->waylandClient() == ti->waylandClient()) { - ti->sendEnter(surface); + if (auto *focus = d->seat->keyboardFocusSurface(); + focus && ti->waylandClient() == focus->waylandClient()) { + ti->sendEnter(focus); } + reconcileTextInput("text input created"); } } void WInputMethodHelper::handleTIEnabled() { + W_D(WInputMethodHelper); WTextInput *ti = qobject_cast(sender()); Q_ASSERT(ti); - auto im = inputMethod(); - auto activeTI = enabledTextInput(); - if (activeTI == ti) - return; - if (activeTI) { - if (im) { - // If current active input method is not null, notify it to deactivate. - im->sendDeactivate(); - im->sendDone(); - } - // Notify last active text input to leave. - activeTI->sendLeave(); + d->enabledTextInputs.removeAll(ti); + d->enabledTextInputs.append(ti); + qCDebug(lcWlInputMethod) << "Text input became eligible candidate" + << "seat" << d->seat->name() + << "textInput" << ti + << "focusedSurface" << ti->focusedSurface() + << "seatFocus" << d->seat->keyboardFocusSurface(); + if (!d->updatingFocus) + reconcileTextInput("text input enabled"); +} + +void WInputMethodHelper::handleTIDisabled() +{ + WTextInput *ti = qobject_cast(sender()); + Q_ASSERT(ti); + disableTI(ti); +} + +void WInputMethodHelper::reconcileTextInput(const char *reason) +{ + W_D(WInputMethodHelper); + + WTextInput *candidate = d->findEligibleTextInput(); + + auto *old = d->enabledTextInput; + auto *im = d->activeInputMethod; + auto *focus = d->seat ? d->seat->keyboardFocusSurface() : nullptr; + const bool textInputChanged = old != candidate; + // Sticky anchor, restricted to the momentary null focus of a switch: while + // no surface owns the keyboard focus (window switch, popup transition, the + // compositor's own QML taking focus) keep the input method activated so + // that a focus flickering through null back to the same text input does + // not tear down and rebuild the input method's keyboard endpoint and + // virtual keyboard. As soon as the focus settles on a concrete surface + // without an eligible text input, deactivate instead: surfaces that never + // use text-input (XWayland windows, plain terminals, games) must keep + // their keys so the client-side input method path (XIM / DBus) stays in + // charge, and a held activation would leave the input method's candidate + // window parented to an unrelated text input. Only keys on an eligible + // text input are routed to the input method anyway (see + // keyboardGrabBypassed). A destroyed or client-disabled anchor (removed + // from enabledTextInputs) falls through to normal deactivation. + const bool holdAnchor = !candidate && !focus && d->inputMethodActive && old + && d->enabledTextInputs.contains(old); + const bool wasAnchorHeld = d->anchorHeld; + d->anchorHeld = holdAnchor; + const bool shouldDeactivate = d->inputMethodActive && !holdAnchor + && (!candidate || textInputChanged || !im); + bool activatedNow = false; + + if (shouldDeactivate && im) { + im->sendDeactivate(); + im->sendDone(); } - setEnabledTextInput(ti); - // Try to activate input method. - if (im) { + if (shouldDeactivate) + d->inputMethodActive = false; + + if (textInputChanged && !holdAnchor) + setEnabledTextInput(candidate); + + if (candidate && im && !d->inputMethodActive) { im->sendActivate(); - if (ti->features().testFlag(IME::F_SurroundingText)) { - im->sendSurroundingText(ti->surroundingText(), ti->surroundingCursor(), ti->surroundingAnchor()); + if (candidate->features().testFlag(IME::F_SurroundingText)) { + im->sendSurroundingText(candidate->surroundingText(), + candidate->surroundingCursor(), + candidate->surroundingAnchor()); } - im->sendTextChangeCause(ti->textChangeCause()); - if (ti->features().testFlag(IME::F_ContentType)) { - im->sendContentType(ti->contentHints().toInt(), ti->contentPurpose()); + im->sendTextChangeCause(candidate->textChangeCause()); + if (candidate->features().testFlag(IME::F_ContentType)) { + im->sendContentType(candidate->contentHints().toInt(), candidate->contentPurpose()); } im->sendDone(); + d->inputMethodActive = true; + activatedNow = true; } -} -void WInputMethodHelper::handleTIDisabled() -{ - WTextInput *ti = qobject_cast(sender()); - disableTI(ti); + if (textInputChanged || shouldDeactivate || activatedNow || (holdAnchor != wasAnchorHeld)) { + ++d->transitionSerial; + qCInfo(lcWlInputMethod) << "Input method state reconciled" + << "transition" << d->transitionSerial + << "reason" << reason + << "seat" << (d->seat ? d->seat->name() : QString()) + << "oldTextInput" << old + << "newTextInput" << candidate + << "focus" << focus + << "inputMethod" << im + << "keyboardGrab" << d->activeKeyboardGrab + << "active" << d->inputMethodActive + << "anchorHeld" << d->anchorHeld + << "candidateCount" << d->enabledTextInputs.size(); + } + + // Notify before draining: consumers re-anchor the popups that already have + // a wrapper, while popups attached here are created against the current + // focus and must not be re-anchored again in the same reconciliation. + d->notifyTextInputFocusSurfaceChanged(); + d->drainPendingPopupSurfaces(); } void WInputMethodHelper::handleFocusedTICommitted() { + W_D(WInputMethodHelper); auto ti = enabledTextInput(); - Q_ASSERT(ti); - if (!ti->focusedSurface()) { - qCWarning(lcWlInputMethod) << "Discard commit to unfocused but not disabled text input."; + if (!ti || !d->isTextInputEligible(ti)) { + qCWarning(lcWlInputMethod) << "Discard commit from ineligible text input" + << "seat" << d->seat->name() + << "textInput" << ti + << "textInputFocus" << (ti ? ti->focusedSurface() : nullptr) + << "seatFocus" << d->seat->keyboardFocusSurface(); + reconcileTextInput("commit from ineligible text input"); return; } qCDebug(lcWlInputMethod) << "Focused text input" << ti << "committed." << "Cursor rectangle:" << ti->cursorRect(); auto im = inputMethod(); - if (im) { + if (im && d->inputMethodActive) { IME::Features features = ti->features(); if (features.testFlag(IME::F_SurroundingText)) { im->sendSurroundingText(ti->surroundingText(), ti->surroundingCursor(), ti->surroundingAnchor()); @@ -593,30 +941,55 @@ void WInputMethodHelper::handleFocusedTICommitted() void WInputMethodHelper::handleIMCommitted() { + W_D(WInputMethodHelper); auto im = inputMethod(); Q_ASSERT(im); auto ti = enabledTextInput(); - if (ti && ti->focusedSurface()) { + if (d->inputMethodActive && d->isTextInputEligible(ti)) { ti->handleIMCommitted(im); + } else if (d->inputMethodActive && d->anchorHeld && d->seat + && d->seat->keyboardFocusSurface()) { + // Defensive fallback: the input method is still activated on its + // anchor while the keyboard focus already moved to a surface without + // an eligible text input. Type the commit through the input method's + // own virtual keyboard so latin/symbol text reaches the focused + // surface instead of being dropped; never route it to the unfocused + // anchor text input, and note that CJK has no keymap mapping (see + // typeTextViaVirtualKeyboard). Normal deliveries go through the + // eligible text input handled above. + const quint32 typed = d->typeTextViaVirtualKeyboard(im->commitString()); + ++d->transitionSerial; + qCInfo(lcWlInputMethod) << "Anchor-held commit delivered via virtual-keyboard typing" + << "transition" << d->transitionSerial + << "seat" << d->seat->name() + << "textInput" << ti + << "seatFocus" << d->seat->keyboardFocusSurface() + << "typedCodePoints" << typed; + if (typed == 0) { + qCDebug(lcWlInputMethod) << "Anchor-held commit could not be delivered; no typeable code point" + << "seat" << d->seat->name() + << "virtualKeyboard" << !d->virtualKeyboards.isEmpty(); + } + } else { + qCWarning(lcWlInputMethod) << "Discard input method commit without an eligible text input" + << "seat" << d->seat->name() + << "textInput" << ti + << "seatFocus" << d->seat->keyboardFocusSurface(); } } void WInputMethodHelper::handleActiveIMDestroyed() { + W_D(WInputMethodHelper); auto im = inputMethod(); Q_ASSERT(im); + qCInfo(lcWlInputMethod) << "Input method disconnected" + << "seat" << d->seat->name() + << "inputMethod" << im + << "activeTextInput" << d->enabledTextInput; setInputMethod(nullptr); delete im; - notifyLeave(); -} - -void WInputMethodHelper::notifyLeave() -{ - W_D(WInputMethodHelper); - for (auto *ti : std::as_const(d->textInputs)) { - if (ti->focusedSurface()) - ti->sendLeave(); - } + reconcileTextInput("input method disconnected"); } void WInputMethodHelper::updateAllPopupSurfaces(QRect cursorRect) diff --git a/waylib/src/server/protocols/winputmethodhelper.h b/waylib/src/server/protocols/winputmethodhelper.h index 7d939d74e0..2d57629434 100644 --- a/waylib/src/server/protocols/winputmethodhelper.h +++ b/waylib/src/server/protocols/winputmethodhelper.h @@ -31,16 +31,16 @@ class WAYLIB_SERVER_EXPORT WInputMethodHelper : public QObject, public WObject WSurface *textInputFocusSurface() const; QRect textInputCursorRect() const; - // Returns true when the seat's current keyboard grab is the one installed by this helper. - bool isActiveKeyboardGrabOwner() const; - Q_SIGNALS: void inputPopupSurfaceV2Added(WInputPopupSurface *popupSurface); void inputPopupSurfaceV2Removed(WInputPopupSurface *popupSurface); void textInputCursorRectChanged(QRect cursorRect); + // Emitted after the text input focus settled when the surface returned by + // textInputFocusSurface() changed (window switch, new anchor, commit + // routed elsewhere). Consumers may re-anchor input popup surfaces on it. + void textInputFocusSurfaceChanged(WSurface *surface); private: - const QList &virtualKeyboards() const; void handleNewTI(WTextInput *ti); void handleNewIMV2(wlr_input_method_v2 *imv2); void handleNewKGV2(wlr_input_method_keyboard_grab_v2 *kgv2); @@ -48,9 +48,8 @@ class WAYLIB_SERVER_EXPORT WInputMethodHelper : public QObject, public WObject void handleNewVKV1(wlr_virtual_keyboard_v1 *vkv1); void updateAllPopupSurfaces(QRect cursorRect); void updatePopupSurface(WInputPopupSurface *popup, QRect cursorRect); - void notifyLeave(); void resendKeyboardFocus(); - void handleKeyboardGrabBegin(); + void reconcileTextInput(const char *reason); void connectToTI(WTextInput *ti); void disableTI(WTextInput *ti); void handleTIEnabled(); @@ -58,14 +57,10 @@ class WAYLIB_SERVER_EXPORT WInputMethodHelper : public QObject, public WObject void handleFocusedTICommitted(); void handleIMCommitted(); void handleActiveIMDestroyed(); - WTextInput *focusedTextInput() const; WTextInput *enabledTextInput() const; void setEnabledTextInput(WTextInput *ti); WInputMethodV2 *inputMethod() const; void setInputMethod(WInputMethodV2 *im); - wlr_input_method_keyboard_grab_v2 *activeKeyboardGrab() const; - friend void handleKey(struct wlr_seat_keyboard_grab *grab, uint32_t time_msec, uint32_t key, uint32_t state); - friend void handleModifiers(struct wlr_seat_keyboard_grab *grab, const struct wlr_keyboard_modifiers *modifiers); }; WAYLIB_SERVER_END_NAMESPACE