input: aggregate modifier states from all keyboards on focus enter (#14633)

* fix: aggregate modifier states from all keyboards on focus enter

* hyprtester: add keyboard modifier merging e2e test

* nix: install keyboard-modifiers client binary for tests
This commit is contained in:
Ahmed Kallel
2026-06-07 12:26:02 +02:00
committed by Vaxry
parent a5acafacd8
commit 91b45de657
6 changed files with 541 additions and 1 deletions
+1
View File
@@ -102,3 +102,4 @@ clientNew("pointer-warp" PROTOS "pointer-warp-v1" "xdg-shell")
clientNew("pointer-scroll" PROTOS "xdg-shell")
clientNew("child-window" PROTOS "xdg-shell")
clientNew("shortcut-inhibitor" PROTOS "xdg-shell" "keyboard-shortcuts-inhibit-unstable-v1")
clientNew("keyboard-modifiers" PROTOS "xdg-shell")
+285
View File
@@ -0,0 +1,285 @@
#include <cstring>
#include <sys/poll.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
#include <print>
#include <format>
#include <string>
#include <fstream>
#include <wayland-client.h>
#include <wayland.hpp>
#include <xdg-shell.hpp>
#include <hyprutils/memory/SharedPtr.hpp>
#include <hyprutils/math/Vector2D.hpp>
#include <hyprutils/os/FileDescriptor.hpp>
using Hyprutils::Math::Vector2D;
using namespace Hyprutils::Memory;
struct SWlState {
wl_display* display;
CSharedPointer<CCWlRegistry> registry;
CSharedPointer<CCWlCompositor> wlCompositor;
CSharedPointer<CCWlSeat> wlSeat;
CSharedPointer<CCWlShm> wlShm;
CSharedPointer<CCXdgWmBase> xdgShell;
CSharedPointer<CCWlShmPool> shmPool;
CSharedPointer<CCWlBuffer> shmBuf;
int shmFd;
size_t shmBufSize;
bool xrgb8888_support = false;
CSharedPointer<CCWlSurface> surf;
CSharedPointer<CCXdgSurface> xdgSurf;
CSharedPointer<CCXdgToplevel> xdgToplevel;
Vector2D geom;
CSharedPointer<CCWlKeyboard> keyboard;
uint32_t lastLocked = 0;
};
static std::ofstream logfile;
static bool debug, started, shouldExit;
template <typename... Args>
static void clientLog(std::format_string<Args...> fmt, Args&&... args) {
std::string text = std::vformat(fmt.get(), std::make_format_args(args...));
std::println("{}", text);
logfile << text << std::endl;
std::fflush(stdout);
}
template <typename... Args>
static void debugLog(std::format_string<Args...> fmt, Args&&... args) {
std::string text = std::vformat(fmt.get(), std::make_format_args(args...));
logfile << text << std::endl;
if (!debug)
return;
std::println("{}", text);
std::fflush(stdout);
}
static bool bindRegistry(SWlState& state) {
state.registry = makeShared<CCWlRegistry>((wl_proxy*)wl_display_get_registry(state.display));
state.registry->setGlobal([&](CCWlRegistry* r, uint32_t id, const char* name, uint32_t version) {
const std::string NAME = name;
if (NAME == "wl_compositor") {
debugLog(" > binding to global: {} (version {}) with id {}", name, version, id);
state.wlCompositor = makeShared<CCWlCompositor>((wl_proxy*)wl_registry_bind((wl_registry*)state.registry->resource(), id, &wl_compositor_interface, 6));
} else if (NAME == "wl_shm") {
debugLog(" > binding to global: {} (version {}) with id {}", name, version, id);
state.wlShm = makeShared<CCWlShm>((wl_proxy*)wl_registry_bind((wl_registry*)state.registry->resource(), id, &wl_shm_interface, 1));
} else if (NAME == "wl_seat") {
debugLog(" > binding to global: {} (version {}) with id {}", name, version, id);
state.wlSeat = makeShared<CCWlSeat>((wl_proxy*)wl_registry_bind((wl_registry*)state.registry->resource(), id, &wl_seat_interface, 9));
} else if (NAME == "xdg_wm_base") {
debugLog(" > binding to global: {} (version {}) with id {}", name, version, id);
state.xdgShell = makeShared<CCXdgWmBase>((wl_proxy*)wl_registry_bind((wl_registry*)state.registry->resource(), id, &xdg_wm_base_interface, 1));
}
});
state.registry->setGlobalRemove([](CCWlRegistry* r, uint32_t id) { debugLog("Global {} removed", id); });
wl_display_roundtrip(state.display);
if (!state.wlCompositor || !state.wlShm || !state.wlSeat || !state.xdgShell) {
clientLog("Failed to get protocols from Hyprland");
return false;
}
return true;
}
static bool createShm(SWlState& state, Vector2D geom) {
if (!state.xrgb8888_support)
return false;
size_t stride = geom.x * 4;
size_t size = geom.y * stride;
if (!state.shmPool) {
const char* name = "/wl-shm-kb-mods";
state.shmFd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0600);
if (state.shmFd < 0)
return false;
if (shm_unlink(name) < 0 || ftruncate(state.shmFd, size) < 0) {
close(state.shmFd);
return false;
}
state.shmPool = makeShared<CCWlShmPool>(state.wlShm->sendCreatePool(state.shmFd, size));
if (!state.shmPool->resource()) {
close(state.shmFd);
state.shmFd = -1;
state.shmPool.reset();
return false;
}
state.shmBufSize = size;
} else if (size > state.shmBufSize) {
if (ftruncate(state.shmFd, size) < 0) {
close(state.shmFd);
state.shmFd = -1;
state.shmPool.reset();
return false;
}
state.shmPool->sendResize(size);
state.shmBufSize = size;
}
auto buf = makeShared<CCWlBuffer>(state.shmPool->sendCreateBuffer(0, geom.x, geom.y, stride, WL_SHM_FORMAT_XRGB8888));
if (!buf->resource())
return false;
if (state.shmBuf) {
state.shmBuf->sendDestroy();
state.shmBuf.reset();
}
state.shmBuf = buf;
return true;
}
static bool setupToplevel(SWlState& state) {
state.wlShm->setFormat([&](CCWlShm* p, uint32_t format) {
if (format == WL_SHM_FORMAT_XRGB8888)
state.xrgb8888_support = true;
});
state.xdgShell->setPing([&](CCXdgWmBase* p, uint32_t serial) { state.xdgShell->sendPong(serial); });
state.surf = makeShared<CCWlSurface>(state.wlCompositor->sendCreateSurface());
if (!state.surf->resource())
return false;
state.xdgSurf = makeShared<CCXdgSurface>(state.xdgShell->sendGetXdgSurface(state.surf->resource()));
if (!state.xdgSurf->resource())
return false;
state.xdgToplevel = makeShared<CCXdgToplevel>(state.xdgSurf->sendGetToplevel());
if (!state.xdgToplevel->resource())
return false;
state.xdgToplevel->setClose([&](CCXdgToplevel* p) { exit(0); });
state.xdgToplevel->setConfigure([&](CCXdgToplevel* p, int32_t w, int32_t h, wl_array* arr) {
state.geom = {1280, 720};
if (!createShm(state, state.geom))
exit(-1);
});
state.xdgSurf->setConfigure([&](CCXdgSurface* p, uint32_t serial) {
if (!state.shmBuf)
debugLog("xdgSurf configure but no buf made yet?");
state.xdgSurf->sendSetWindowGeometry(0, 0, state.geom.x, state.geom.y);
state.surf->sendAttach(state.shmBuf.get(), 0, 0);
state.surf->sendCommit();
state.xdgSurf->sendAckConfigure(serial);
if (!started) {
started = true;
clientLog("started");
}
});
state.xdgToplevel->sendSetTitle("keyboard-modifiers test client");
state.xdgToplevel->sendSetAppId("keyboard-modifiers");
state.surf->sendAttach(nullptr, 0, 0);
state.surf->sendCommit();
return true;
}
static bool setupSeat(SWlState& state) {
state.keyboard = makeShared<CCWlKeyboard>(state.wlSeat->sendGetKeyboard());
if (!state.keyboard->resource())
return false;
state.keyboard->setModifiers([&](CCWlKeyboard* p, uint32_t serial, uint32_t depressed, uint32_t latched, uint32_t locked, uint32_t group) {
debugLog("modifiers: depressed={} latched={} locked={} group={}", depressed, latched, locked, group);
state.lastLocked = locked;
});
state.keyboard->setKey([&](CCWlKeyboard* p, uint32_t serial, uint32_t time, uint32_t key, uint32_t state) { debugLog("Got key event: key={} state={}", key, state); });
return true;
}
static void parseRequest(SWlState& state, std::string req) {
if (req.starts_with("get"))
clientLog("{}", state.lastLocked);
else if (req.starts_with("exit"))
shouldExit = true;
}
int main(int argc, char** argv) {
logfile.open("keyboard-modifiers.txt", std::ios::trunc);
if (argc != 1 && argc != 2)
clientLog("Only the \"--debug\" switch is allowed, it turns on debug logs.");
if (argc == 2 && std::string{argv[1]} == "--debug")
debug = true;
SWlState state;
state.display = wl_display_connect(nullptr);
if (!state.display) {
clientLog("Failed to connect to wayland display");
return -1;
}
if (!bindRegistry(state) || !setupSeat(state) || !setupToplevel(state))
return -1;
std::array<char, 1024> readBuf;
readBuf.fill(0);
wl_display_flush(state.display);
struct pollfd fds[2] = {{.fd = wl_display_get_fd(state.display), .events = POLLIN | POLLOUT}, {.fd = STDIN_FILENO, .events = POLLIN}};
while (!shouldExit && poll(fds, 2, 0) != -1) {
if (fds[0].revents & POLLIN) {
wl_display_flush(state.display);
if (wl_display_prepare_read(state.display) == 0) {
wl_display_read_events(state.display);
wl_display_dispatch_pending(state.display);
} else
wl_display_dispatch(state.display);
int ret = 0;
do {
ret = wl_display_dispatch_pending(state.display);
wl_display_flush(state.display);
} while (ret > 0);
}
if (fds[1].revents & POLLIN) {
ssize_t bytesRead = read(fds[1].fd, readBuf.data(), 1023);
if (bytesRead == -1)
continue;
readBuf[bytesRead] = 0;
parseRequest(state, std::string{readBuf.data()});
}
}
wl_display* display = state.display;
state = {};
wl_display_disconnect(display);
logfile.flush();
logfile.close();
return 0;
}
+84
View File
@@ -87,6 +87,19 @@ class CTestKeyboard : public IKeyboard {
m_keyboardEvents.key.emit(event);
}
void setMods(uint32_t depressed, uint32_t latched, uint32_t locked, uint32_t group) {
m_modifiersState.depressed = depressed;
m_modifiersState.latched = latched;
m_modifiersState.locked = locked;
m_modifiersState.group = group;
m_keyboardEvents.modifiers.emit(IKeyboard::SModifiersEvent{
.depressed = depressed,
.latched = latched,
.locked = locked,
.group = group,
});
}
void destroy() {
m_events.destroy.emit();
}
@@ -124,6 +137,7 @@ class CTestMouse : public IPointer {
SP<CTestMouse> g_mouse;
SP<CTestKeyboard> g_keyboard;
SP<CTestKeyboard> g_keyboard2;
static SDispatchResult pressAlt(std::string in) {
g_pInputManager->m_lastMods = in == "1" ? HL_MODIFIER_ALT : 0;
@@ -360,6 +374,47 @@ static SDispatchResult keybind(std::string in) {
return {};
}
static SDispatchResult keybind2(std::string in) {
CVarList2 data(std::move(in));
bool press;
uint32_t modifier;
uint32_t key;
try {
press = std::stoul(std::string{data[0]}) == 1;
modifier = std::stoul(std::string{data[1]});
key = std::stoul(std::string{data[2]}) - 8;
} catch (...) { return {.success = false, .error = "invalid input"}; }
uint32_t modifierMask = 0;
if (modifier > 0)
modifierMask = 1 << (modifier - 1);
g_pInputManager->m_lastMods = modifierMask;
g_keyboard2->sendKey(key, press);
return {};
}
static SDispatchResult setMods(std::string in) {
CVarList2 data(std::move(in));
try {
uint32_t kbIndex = std::stoul(std::string{data[0]});
uint32_t depressed = std::stoul(std::string{data[1]});
uint32_t latched = std::stoul(std::string{data[2]});
uint32_t locked = std::stoul(std::string{data[3]});
uint32_t group = std::stoul(std::string{data[4]});
SP<CTestKeyboard> kb = (kbIndex == 0) ? g_keyboard : g_keyboard2;
kb->setMods(depressed, latched, locked, group);
} catch (...) { return {.success = false, .error = "invalid input"}; }
return {};
}
static SDispatchResult nullfocus(std::string in) {
g_pSeatManager->setKeyboardFocus(nullptr);
return {};
}
static Desktop::Rule::CWindowRuleEffectContainer::storageType windowRuleIDX = 0;
//
@@ -567,6 +622,26 @@ static int luaKeybind(lua_State* L) {
return luaResult(L, ::keybind(std::format("{},{},{}", press, modifier, key)));
}
static int luaKeybind2(lua_State* L) {
const auto press = (int)luaL_checkinteger(L, 1);
const auto modifier = (int)luaL_checkinteger(L, 2);
const auto key = (int)luaL_checkinteger(L, 3);
return luaResult(L, ::keybind2(std::format("{},{},{}", press, modifier, key)));
}
static int luaSetMods(lua_State* L) {
const auto kbIndex = (int)luaL_checkinteger(L, 1);
const auto depressed = (int)luaL_checkinteger(L, 2);
const auto latched = (int)luaL_checkinteger(L, 3);
const auto locked = (int)luaL_checkinteger(L, 4);
const auto group = (int)luaL_checkinteger(L, 5);
return luaResult(L, ::setMods(std::format("{},{},{},{},{}", kbIndex, depressed, latched, locked, group)));
}
static int luaNullfocus(lua_State* L) {
return luaResult(L, ::nullfocus(""));
}
static int luaAddWindowRule(lua_State* L) {
return luaResult(L, ::addWindowRule(""));
}
@@ -618,6 +693,9 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
addLuaFn("scroll", ::luaScroll);
addLuaFn("click", ::luaClick);
addLuaFn("keybind", ::luaKeybind);
addLuaFn("keybind2", ::luaKeybind2);
addLuaFn("set_mods", ::luaSetMods);
addLuaFn("nullfocus", ::luaNullfocus);
addLuaFn("add_window_rule", ::luaAddWindowRule);
addLuaFn("check_window_rule", ::luaCheckWindowRule);
addLuaFn("add_layer_rule", ::luaAddLayerRule);
@@ -635,6 +713,10 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
g_keyboard = CTestKeyboard::create(false);
g_pInputManager->newKeyboard(g_keyboard);
// init keyboard2
g_keyboard2 = CTestKeyboard::create(false);
g_pInputManager->newKeyboard(g_keyboard2);
return {"hyprtestplugin", "hyprtestplugin", "Vaxry", "1.0"};
}
@@ -643,4 +725,6 @@ APICALL EXPORT void PLUGIN_EXIT() {
g_mouse.reset();
g_keyboard->destroy();
g_keyboard.reset();
g_keyboard2->destroy();
g_keyboard2.reset();
}
@@ -0,0 +1,159 @@
#include "../../hyprctlCompat.hpp"
#include "../shared.hpp"
#include "tests.hpp"
#include "build.hpp"
#include <hyprutils/os/FileDescriptor.hpp>
#include <hyprutils/os/Process.hpp>
#include <optional>
#include <sys/poll.h>
#include <csignal>
#include <thread>
using namespace Hyprutils::OS;
using namespace Hyprutils::Memory;
#define SP CSharedPointer
namespace {
class CClient {
SP<CProcess> proc;
std::array<char, 1024> readBuf;
CFileDescriptor readFd, writeFd;
struct pollfd fds;
public:
CClient();
~CClient();
uint32_t getLockedMods();
pid_t pid();
};
}
CClient::CClient() {
Tests::killAllWindows();
this->proc = makeShared<CProcess>(binaryDir + "/keyboard-modifiers", std::vector<std::string>{});
this->proc->addEnv("WAYLAND_DISPLAY", WLDISPLAY);
int pipeFds1[2], pipeFds2[2];
if (pipe(pipeFds1) != 0 || pipe(pipeFds2) != 0) {
NLog::log("{}Unable to open pipe to client", Colors::RED);
throw std::exception();
}
this->writeFd = CFileDescriptor(pipeFds1[1]);
this->proc->setStdinFD(pipeFds1[0]);
this->readFd = CFileDescriptor(pipeFds2[0]);
this->proc->setStdoutFD(pipeFds2[1]);
const int COUNT_BEFORE = Tests::windowCount();
this->proc->runAsync();
close(pipeFds1[0]);
close(pipeFds2[1]);
this->fds = {.fd = this->readFd.get(), .events = POLLIN};
if (poll(&this->fds, 1, 1000) != 1 || !(this->fds.revents & POLLIN)) {
NLog::log("{}keyboard-modifiers client failed poll", Colors::RED);
throw std::exception();
}
this->readBuf.fill(0);
if (read(this->readFd.get(), this->readBuf.data(), this->readBuf.size() - 1) == -1) {
NLog::log("{}keyboard-modifiers client read failed", Colors::RED);
throw std::exception();
}
std::string ret = std::string{this->readBuf.data()};
if (ret.find("started") == std::string::npos) {
NLog::log("{}Failed to start keyboard-modifiers client, read {}", Colors::RED, ret);
throw std::exception();
}
int counter = 0;
while (Tests::processAlive(this->proc->pid()) && Tests::windowCount() == COUNT_BEFORE) {
counter++;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (counter > 50) {
NLog::log("{}keyboard-modifiers client took too long to open", Colors::RED);
throw std::exception();
}
}
if (!Tests::processAlive(this->proc->pid())) {
NLog::log("{}keyboard-modifiers client not alive", Colors::RED);
throw std::exception();
}
if (getFromSocket(std::format("/dispatch hl.dsp.focus({{ window = 'pid:{}' }})", this->proc->pid())) != "ok") {
NLog::log("{}Failed to focus keyboard-modifiers client", Colors::RED);
throw std::exception();
}
NLog::log("{}Started keyboard-modifiers client", Colors::YELLOW);
}
CClient::~CClient() {
std::string cmd = "exit\n";
write(this->writeFd.get(), cmd.c_str(), cmd.length());
kill(this->proc->pid(), SIGKILL);
this->proc.reset();
}
uint32_t CClient::getLockedMods() {
std::string cmd = "get\n";
if ((size_t)write(this->writeFd.get(), cmd.c_str(), cmd.length()) != cmd.length())
return false;
if (poll(&this->fds, 1, 1500) != 1 || !(this->fds.revents & POLLIN))
return false;
ssize_t bytesRead = read(this->fds.fd, this->readBuf.data(), 1023);
if (bytesRead == -1)
return false;
this->readBuf[bytesRead] = 0;
std::string received = std::string{this->readBuf.data()};
received.pop_back();
try {
return std::stoul(received);
} catch (...) { return 0; }
}
pid_t CClient::pid() {
return this->proc->pid();
}
TEST_CASE(keyboardModifiersMergedOnFocus) {
NLog::log("{}Testing keyboard modifiers merged on focus", Colors::GREEN);
std::optional<CClient> client;
try {
client.emplace();
} catch (...) { FAIL_TEST("Couldn't start the client"); }
EXPECT(client->getLockedMods(), 0u);
OK(getFromSocket("/eval hl.plugin.test.nullfocus()"));
std::this_thread::sleep_for(std::chrono::milliseconds(50));
OK(getFromSocket("/eval hl.plugin.test.set_mods(0, 0, 0, 2, 0)"));
OK(getFromSocket("/eval hl.plugin.test.set_mods(1, 0, 0, 16, 0)"));
if (getFromSocket(std::format("/dispatch hl.dsp.focus({{ window = 'pid:{}' }})", client->pid())) != "ok") {
FAIL_TEST("Failed to refocus keyboard-modifiers client");
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
const uint32_t locked = client->getLockedMods();
NLog::log("{}Client reports locked mods: {}", Colors::BLUE, locked);
EXPECT(locked, 18u);
}
+1
View File
@@ -259,6 +259,7 @@ customStdenv.mkDerivation (finalAttrs: {
install hyprtester/pointer-warp -t $out/bin
install hyprtester/pointer-scroll -t $out/bin
install hyprtester/shortcut-inhibitor -t $out/bin
install hyprtester/keyboard-modifiers -t $out/bin
install hyprland_gtests -t $out/bin
install hyprtester/child-window -t $out/bin
''}
+11 -1
View File
@@ -161,7 +161,17 @@ void CSeatManager::setKeyboardFocus(SP<CWLSurfaceResource> surf) {
continue;
k->sendEnter(surf, &keys);
k->sendMods(m_keyboard->m_modifiersState.depressed, m_keyboard->m_modifiersState.latched, m_keyboard->m_modifiersState.locked, m_keyboard->m_modifiersState.group);
uint32_t depressed = m_keyboard->m_modifiersState.depressed;
uint32_t latched = m_keyboard->m_modifiersState.latched;
uint32_t locked = m_keyboard->m_modifiersState.locked;
for (auto const& kb : g_pInputManager->m_keyboards) {
if (!kb->m_enabled || !kb->shareStates() || (kb->isVirtual() && g_pInputManager->shouldIgnoreVirtualKeyboard(kb)))
continue;
depressed |= kb->m_modifiersState.depressed;
latched |= kb->m_modifiersState.latched;
locked |= kb->m_modifiersState.locked;
}
k->sendMods(depressed, latched, locked, m_keyboard->m_modifiersState.group);
}
}