diff -ruN a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -244,7 +244,8 @@ "Hyprland headers expose no supported hyprctl plugin command API") endif() - add_library(cua_hyprland_plugin MODULE src/plugin.cpp) + add_library(cua_hyprland_plugin MODULE src/plugin.cpp src/ime_popup_guard.cpp) + target_compile_definitions(cua_hyprland_plugin PRIVATE CUA_HYPRLAND_IME_POPUP_GUARD=1) if(CUA_HYPRLAND_TEST_INPUT OR CUA_HYPRLAND_INPUT) if(NOT HYPRLAND_VERSION STREQUAL "0.56.2") message(FATAL_ERROR "Input is pinned to Hyprland 0.56.2") diff -ruN a/src/ime_popup_guard.cpp b/src/ime_popup_guard.cpp --- a/src/ime_popup_guard.cpp +++ b/src/ime_popup_guard.cpp @@ -0,0 +1,69 @@ +#include "ime_popup_guard.hpp" + +#include +#include +#include + +namespace cua::hyprland { +namespace { + +void* g_plugin = nullptr; +CFunctionHook* g_update_box = nullptr; +CFunctionHook* g_should_be_rendered = nullptr; + +// Hyprland 0.56.2 leaves an input-method popup mapped after its wl_surface is +// destroyed, then places and renders it through the missing surface. fcitx5 +// registers an input method per wl_seat, which is how the agent seats make that +// order reachable when it exits. A popup without a surface is neither placed +// nor rendered. +void guarded_update_box(void* popup) { + if (!static_cast(popup)->getSurface()) + return; + reinterpret_cast(g_update_box->m_original)(popup); +} + +bool guarded_should_be_rendered(void* popup) { + if (!static_cast(popup)->getSurface()) + return false; + return reinterpret_cast(g_should_be_rendered->m_original)(popup); +} + +CFunctionHook* hook_exact(void* plugin, const std::string& symbol, void* replacement) { + const auto matches = HyprlandAPI::findFunctionsByName(plugin, symbol); + if (matches.size() != 1) + return nullptr; + auto* hook = HyprlandAPI::createFunctionHook(plugin, matches.front().address, replacement); + if (hook && !hook->hook()) { + HyprlandAPI::removeFunctionHook(plugin, hook); + return nullptr; + } + return hook; +} + +} // namespace + +bool install_ime_popup_guard(void* plugin) { + if (ime_popup_guard_active()) + return true; + g_plugin = plugin; + g_update_box = hook_exact(plugin, "_ZN11CInputPopup9updateBoxEv", reinterpret_cast(&guarded_update_box)); + g_should_be_rendered = hook_exact(plugin, "_ZN11CInputPopup16shouldBeRenderedEv", + reinterpret_cast(&guarded_should_be_rendered)); + if (!ime_popup_guard_active()) + remove_ime_popup_guard(); + return ime_popup_guard_active(); +} + +void remove_ime_popup_guard() { + for (auto* hook : {g_update_box, g_should_be_rendered}) + if (hook) + HyprlandAPI::removeFunctionHook(g_plugin, hook); + g_update_box = g_should_be_rendered = nullptr; + g_plugin = nullptr; +} + +bool ime_popup_guard_active() { + return g_update_box && g_should_be_rendered; +} + +} // namespace cua::hyprland diff -ruN a/src/ime_popup_guard.hpp b/src/ime_popup_guard.hpp --- a/src/ime_popup_guard.hpp +++ b/src/ime_popup_guard.hpp @@ -0,0 +1,10 @@ +#pragma once + +namespace cua::hyprland { + +// Installs the guard described in ime_popup_guard.cpp. Returns whether it is active. +bool install_ime_popup_guard(void* plugin); +void remove_ime_popup_guard(); +bool ime_popup_guard_active(); + +} // namespace cua::hyprland diff -ruN a/src/input_experiment.cpp b/src/input_experiment.cpp --- a/src/input_experiment.cpp +++ b/src/input_experiment.cpp @@ -369,9 +369,10 @@ if (physical_keymap) xkb_keymap_unref(physical_keymap); if (physical_xkb_context) xkb_context_unref(physical_xkb_context); physical_keyboard_state = state; physical_keymap = map; physical_xkb_context = context; - // Keyboard qualification: every key the KEY command can press must type the - // same keysym as the canonical agent keymap. Checked once per keymap, so a - // different layout refuses before activation rather than mid-string. + // Keyboard qualification: every chord Driver types text with must mean the + // same under this keymap as under the canonical agent keymap. Checked once + // per keymap, so a different layout refuses before activation rather than + // mid-string. typing_keymap = !kProduction || typing_keymap_equivalent(map, keymap); physical_keymap_text = keyboard->m_xkbKeymapV1String; } diff -ruN a/src/keymap_equivalence.hpp b/src/keymap_equivalence.hpp --- a/src/keymap_equivalence.hpp +++ b/src/keymap_equivalence.hpp @@ -31,34 +31,6 @@ } } -// True when typing through `physical` produces the same keysyms as `canonical` -// for every key the foreground KEY command can press (evdev 1-247, minus the -// lock keys): at the base level, and with Shift held for non-modifier keys. -// Options that only change Caps Lock or modifier chords, such as compose:caps -// and shift:both_capslock_cancel, qualify. A different layout, or a remap of a -// letter, digit, punctuation, or modifier key, does not. This compares fresh -// states, so the human keyboard's current locks do not affect it. -inline bool typing_keymap_equivalent(xkb_keymap* physical, xkb_keymap* canonical) { - if (!physical || !canonical) return false; - auto* actual = xkb_state_new(physical); - auto* expected = xkb_state_new(canonical); - bool equal = actual && expected; - constexpr xkb_keycode_t left_shift = 42 + 8; - for (int shifted = 0; equal && shifted < 2; ++shifted) { - if (shifted) { - xkb_state_update_key(actual, left_shift, XKB_KEY_DOWN); - xkb_state_update_key(expected, left_shift, XKB_KEY_DOWN); - } - for (std::uint32_t code = 1; equal && code <= 247; ++code) { - if (foreground_lock_key(code) || (shifted && foreground_modifier_key(code))) continue; - equal = xkb_state_key_get_one_sym(actual, code + 8) == xkb_state_key_get_one_sym(expected, code + 8); - } - } - if (actual) xkb_state_unref(actual); - if (expected) xkb_state_unref(expected); - return equal; -} - using ForegroundXkbState = std::unique_ptr; inline ForegroundXkbState foreground_xkb_state(xkb_keymap* map) { @@ -201,4 +173,35 @@ return ForegroundFailureReason::none; } +// The main-block keys Driver types ASCII text with: digits, letters, punctuation, +// Tab, Return and Space (text_actions in Driver's hyprland_input.rs). Capitals and +// shifted symbols add Shift; nothing else is sent for text. +inline constexpr bool foreground_text_key(std::uint32_t code) { + return (code >= 2 && code <= 13) || (code >= 15 && code <= 28) || (code >= 30 && code <= 53 && code != 42) || + code == 57; +} + +// True when every chord Driver types text with passes foreground_chord_failure +// with Num Lock off and on, the two keyboard states foreground typing admits, so +// a type_text string is admitted or refused before its first key rather than +// partway through. A different layout, or a remap of a key or of Shift that text +// uses, refuses. Remaps of keys text never uses, such as ctrl:swapcaps, +// compose:ralt or altwin:swap_alt_win, qualify, and the per-chord check still +// refuses any requested chord whose meaning they change. +inline bool typing_keymap_equivalent(xkb_keymap* physical, xkb_keymap* canonical) { + if (!physical || !canonical) return false; + const auto numlock = foreground_numlock_mask(physical); + for (std::uint32_t pass = 0; pass < (numlock ? 2u : 1u); ++pass) { + const std::array state{0, 0, pass ? numlock : 0, 0}; + for (std::uint32_t code = 1; code <= 57; ++code) { + if (!foreground_text_key(code)) continue; + const bool shiftable = code != 15 && code != 28 && code != 57; + for (std::uint32_t mods = 0; mods <= (shiftable ? 1u : 0u); ++mods) + if (foreground_chord_failure(physical, canonical, code, mods, state) != ForegroundFailureReason::none) + return false; + } + } + return true; +} + } // namespace cua::hyprland diff -ruN a/src/plugin.cpp b/src/plugin.cpp --- a/src/plugin.cpp +++ b/src/plugin.cpp @@ -2,6 +2,9 @@ #if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT) #include "input_experiment.hpp" #endif +#ifdef CUA_HYPRLAND_IME_POPUP_GUARD +#include "ime_popup_guard.hpp" +#endif #include "cua_hyprland/protocol.hpp" #include "cua_hyprland/status.hpp" @@ -230,6 +233,11 @@ result.pop_back(); result += R"(,"keyboard_layout_independent":true,"foreground_numlock_compatible":true})"; #endif +#ifdef CUA_HYPRLAND_IME_POPUP_GUARD + // Whether the input-method popup guard is hooked into this compositor. + result.pop_back(); + result += cua::hyprland::ime_popup_guard_active() ? R"(,"ime_popup_guard":true})" : R"(,"ime_popup_guard":false})"; +#endif #if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT) if (g_experiment) { #ifdef CUA_HYPRLAND_INPUT @@ -341,6 +349,11 @@ } }); +#ifdef CUA_HYPRLAND_IME_POPUP_GUARD + // Best effort: without the hook the plugin still works, and status says so. + static_cast(cua::hyprland::install_ime_popup_guard(handle)); +#endif + return { "cua-hyprland-plugin", "Discovery foundation for isolated background computer use", @@ -351,6 +364,9 @@ APICALL EXPORT void PLUGIN_EXIT() { g_config_listener.reset(); +#ifdef CUA_HYPRLAND_IME_POPUP_GUARD + cua::hyprland::remove_ime_popup_guard(); +#endif #if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT) // Retire input directly so an in-flight action receives plugin_shutdown, // not the config-disable reason from stop_server()'s suspend path. diff -ruN a/tests/keymap_equivalence_test.cpp b/tests/keymap_equivalence_test.cpp --- a/tests/keymap_equivalence_test.cpp +++ b/tests/keymap_equivalence_test.cpp @@ -22,6 +22,14 @@ return xkb_keymap_new_from_names(context, &names, XKB_KEYMAP_COMPILE_NO_FLAGS); } +// A US keymap with hand-written key overrides, for remaps no stock option makes. +xkb_keymap* custom(xkb_context* context, const char* symbols, const char* types = "") { + const std::string text = std::string("xkb_keymap { xkb_keycodes { include \"evdev+aliases(qwerty)\" }; ") + + "xkb_types { include \"complete\" " + types + " }; xkb_compat { include \"complete\" }; " + + "xkb_symbols { include \"pc+us+inet(evdev)\" " + symbols + " }; };"; + return xkb_keymap_new_from_string(context, text.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS); +} + struct Case { const char* layout; const char* variant; @@ -37,6 +45,9 @@ int main(int argc, char** argv) { check(foreground_lock_key(58) && foreground_lock_key(69) && foreground_lock_key(70), "lock keys"); check(foreground_modifier_key(42) && !foreground_modifier_key(30), "modifier keys"); + check(foreground_text_key(30) && foreground_text_key(28) && foreground_text_key(57), "text keys"); + check(!foreground_text_key(42) && !foreground_text_key(29) && !foreground_text_key(86) && !foreground_text_key(1), + "non-text keys"); auto* context = xkb_context_new(XKB_CONTEXT_NO_FLAGS); auto* us = context ? compile(context, "us", "", "") : nullptr; if (!us) { @@ -63,8 +74,20 @@ {"us", "", "caps:escape", true, "Caps Lock remap"}, {"de", "", "", false, "German layout"}, {"us", "dvorak", "", false, "Dvorak variant"}, - {"us", "", "altwin:swap_alt_win", false, "Alt/Super swap"}, - {"us", "", "ctrl:swapcaps", false, "Ctrl/Caps swap"}, + {"us", "", "altwin:swap_alt_win", true, "Alt/Super swap"}, + {"us", "", "ctrl:swapcaps", true, "Ctrl/Caps swap"}, + {"us", "", "ctrl:swap_lalt_lctl", true, "Alt/Ctrl swap"}, + {"us", "", "compose:ralt", true, "Compose on Right Alt"}, + {"us", "", "compose:menu", true, "Compose on Menu"}, + {"us", "", "compose:102", true, "Compose on the 102nd key"}, + {"us", "", "lv3:lsgt_switch", true, "Level 3 on the 102nd key"}, + {"us", "", "keypad:oss", true, "keypad operators"}, + {"us", "", "caps:swapescape", true, "Caps/Escape swap"}, + {"us", "", "compose:caps,shift:both_capslock_cancel,grp:alts_toggle", true, "Omarchy non-Latin options"}, + {"us,ru", "", "grp:alts_toggle", true, "Omarchy non-Latin layout pair"}, + {"us", "intl", "", false, "US international dead keys"}, + {"fr", "", "", false, "French layout"}, + {"us", "colemak", "", false, "Colemak variant"}, }; for (const auto& test : cases) { auto* map = compile(context, test.layout, test.variant, test.options); @@ -72,6 +95,27 @@ check(typing_keymap_equivalent(map, us) == test.equivalent, test.what); xkb_keymap_unref(map); } + const struct { const char* symbols; bool equivalent; const char* what; } overrides[] = { + {"", true, "unmodified custom keymap"}, + {"key { [ 1, at ] };", false, "Shift level of a digit"}, + {"key { [ Shift_R ] };", false, "Shift key symbol"}, + {"key { [ Multi_key ] };", false, "Return as Compose"}, + {"key { [ dead_acute ] };", false, "Tab as a dead key"}, + {"key { [ Multi_key ] };", true, "key text never uses"}, + }; + for (const auto& test : overrides) { + auto* map = custom(context, test.symbols); + check(map != nullptr, test.what); + check(typing_keymap_equivalent(map, us) == test.equivalent, test.what); + xkb_keymap_unref(map); + } + // A letter whose level changes under Num Lock alone must refuse up front, not at that letter. + auto* numtext = custom(context, "key { type=\"NUMTEXT\", [ b, B, x, X ] };", + "type \"NUMTEXT\" { modifiers = Shift+Mod2; map[None] = Level1; map[Shift] = Level2; " + "map[Mod2] = Level3; map[Shift+Mod2] = Level4; };"); + check(numtext != nullptr, "Num Lock text type compiles"); + check(!typing_keymap_equivalent(numtext, us), "letter changed by Num Lock"); + xkb_keymap_unref(numtext); check(!typing_keymap_equivalent(nullptr, us), "missing keymap"); xkb_keymap_unref(us); xkb_context_unref(context);