qt: parity tier 2 batch 3 — live XKB state via wl_keyboard (B22, B29)

New XkbTracker (XkbTracker.{h,cpp}): a process-singleton that binds
the compositor's wl_seat / wl_keyboard via the platform native
interface, listens for keymap, modifiers, enter/leave/key events,
and keeps a live xkb_keymap + xkb_state in sync with the user's
actual keyboard. Hooked in via QGuiApplication's wl_display.

  B29 — Live XKB layout. XkbState now syncs from XkbTracker on
  every public method call: when the live keymap pointer changes
  (e.g. user switched layouts via the compositor's input-method
  manager), we drop and rebuild m_unshifted + m_query from the new
  keymap, and update the active group on m_unshifted. Result:
  unshifted_codepoint and consumed_mods now honor the active layout
  group. A us+ru user gets correct codepoints per active group
  instead of always us. Mirrors GTK's event.getLayout() pattern.

  B22 — Caps Lock and Num Lock state in mods. XkbTracker tracks
  the locked-mods mask from wl_keyboard.modifiers; XkbState
  exposes lockMods() returning GHOSTTY_MODS_CAPS / GHOSTTY_MODS_NUM
  bits. sendKey ORs them into the event mods alongside sided bits
  so kitty CSI-u and any binding that distinguishes caps-locked
  letters now works.

The fallback for the early-startup window (compositor hasn't sent
the keymap yet) is a synthetic xkb_keymap_new_from_names with no
group; replaced as soon as the live keymap arrives via the
modifiers event.

This is Wayland-only (the only target the Qt frontend supports).

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-20 22:12:58 -05:00
parent cd38f4bd5a
commit 913f192d87
4 changed files with 387 additions and 28 deletions

View File

@ -102,6 +102,7 @@ add_executable(ghastty
src/TabWidget.cpp
src/Util.cpp
src/WindowBlur.cpp
src/XkbTracker.cpp
"${BLUR_CODE}"
"${BLUR_HEADER}"
)

View File

@ -6,6 +6,7 @@
#include "SearchBar.h"
#include "TabWidget.h"
#include "Util.h"
#include "XkbTracker.h"
#include <algorithm>
#include <cmath>
@ -595,7 +596,12 @@ public:
// Level-0 (unshifted) Unicode codepoint for `keycode`, or 0 if the
// key has no associated UTF-32 (function keys, modifiers, etc.).
//
// Uses the live keymap from XkbTracker (synced via wl_keyboard) so
// the active layout group is honored. A us+ru user gets the
// correct codepoint per active group, instead of always us.
uint32_t unshiftedCodepoint(uint32_t keycode) const {
syncFromTracker();
if (!m_unshifted) return 0;
const xkb_keysym_t sym =
xkb_state_key_get_one_sym(m_unshifted, keycode);
@ -611,6 +617,7 @@ public:
// bindings that distinguish left-vs-right modifier keys couldn't
// fire.
ghostty_input_mods_e sideBitsForKeycode(uint32_t keycode) const {
syncFromTracker();
if (!m_unshifted) return GHOSTTY_MODS_NONE;
const xkb_keysym_t sym =
xkb_state_key_get_one_sym(m_unshifted, keycode);
@ -629,12 +636,23 @@ public:
return static_cast<ghostty_input_mods_e>(r);
}
// Caps Lock / Num Lock state from the live wl_keyboard tracker.
ghostty_input_mods_e lockMods() const {
int r = GHOSTTY_MODS_NONE;
if (XkbTracker *t = XkbTracker::instance()) {
if (t->capsLockOn()) r |= GHOSTTY_MODS_CAPS;
if (t->numLockOn()) r |= GHOSTTY_MODS_NUM;
}
return static_cast<ghostty_input_mods_e>(r);
}
// Modifiers consumed by the layout to produce `keycode`'s text given
// `mods` are depressed. Returns the consumed subset, expressed as
// ghostty mod bits. Mutates m_query (mutable) — see thread-safety
// note on the class.
ghostty_input_mods_e consumedMods(uint32_t keycode,
ghostty_input_mods_e mods) const {
syncFromTracker();
if (!m_query) return GHOSTTY_MODS_NONE;
xkb_mod_mask_t depressed = 0;
if ((mods & GHOSTTY_MODS_SHIFT) && m_idxShift != XKB_MOD_INVALID)
@ -645,11 +663,15 @@ public:
depressed |= (1u << m_idxAlt);
if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID)
depressed |= (1u << m_idxSuper);
xkb_state_update_mask(m_query, depressed, 0, 0, 0, 0, 0);
// Use the live group from the tracker so a layout switch (e.g.
// us↔ru) takes effect immediately.
const uint32_t group =
XkbTracker::instance() ? XkbTracker::instance()->activeGroup() : 0;
xkb_state_update_mask(m_query, depressed, 0, 0, 0, 0, group);
const xkb_mod_mask_t consumed = xkb_state_key_get_consumed_mods2(
m_query, keycode, XKB_CONSUMED_MODE_XKB);
// Reset so the next query starts from no-mods.
xkb_state_update_mask(m_query, 0, 0, 0, 0, 0, 0);
xkb_state_update_mask(m_query, 0, 0, 0, 0, 0, group);
int r = GHOSTTY_MODS_NONE;
if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift)))
r |= GHOSTTY_MODS_SHIFT;
@ -663,43 +685,76 @@ public:
}
private:
XkbState() {
m_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (!m_ctx) return;
m_keymap = xkb_keymap_new_from_names(m_ctx, nullptr,
XKB_KEYMAP_COMPILE_NO_FLAGS);
if (!m_keymap) return;
m_unshifted = xkb_state_new(m_keymap);
m_query = xkb_state_new(m_keymap);
m_idxShift = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_SHIFT);
m_idxCtrl = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_CTRL);
m_idxAlt = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_ALT);
m_idxSuper = xkb_keymap_mod_get_index(m_keymap, XKB_MOD_NAME_LOGO);
// Lazy: build/rebuild m_unshifted + m_query from the live keymap.
// Called from every public method; cheap when the keymap pointer
// hasn't changed (a single comparison + early-return).
void syncFromTracker() const {
XkbTracker *t = XkbTracker::instance();
xkb_keymap *liveKm = t ? t->keymap() : nullptr;
xkb_keymap *km = liveKm ? liveKm : m_fallbackKeymap;
if (!km && t && t->ctx()) {
// Compositor hasn't sent a keymap yet (early startup). Build a
// throwaway from XKB defaults so the first key event isn't
// dropped; it will be replaced on the next syncFromTracker
// call once the tracker has the live keymap.
m_fallbackKeymap = xkb_keymap_new_from_names(
t->ctx(), nullptr, XKB_KEYMAP_COMPILE_NO_FLAGS);
km = m_fallbackKeymap;
}
if (!km || km == m_keymap) {
// Already synced (or no keymap available at all).
// Update the live group on m_unshifted so the level-0 lookup
// honors the active layout, even when the keymap pointer
// hasn't changed.
if (m_unshifted && t) {
xkb_state_update_mask(m_unshifted, 0, 0, 0, 0, 0, t->activeGroup());
}
return;
}
// The live keymap was rebuilt by the tracker (or we're picking
// up the first one). Drop our derived states and rebuild.
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_query) xkb_state_unref(m_query);
m_unshifted = xkb_state_new(km);
m_query = xkb_state_new(km);
m_idxShift = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_SHIFT);
m_idxCtrl = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_CTRL);
m_idxAlt = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_ALT);
m_idxSuper = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_LOGO);
m_keymap = km; // pointer-identity comparison only; no ref taken
if (t)
xkb_state_update_mask(m_unshifted, 0, 0, 0, 0, 0, t->activeGroup());
}
XkbState() = default;
~XkbState() {
// Run on process exit when the static is destroyed. The OS would
// reclaim regardless, but explicit teardown silences leak checkers
// and documents the ownership chain.
if (m_query) xkb_state_unref(m_query);
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_keymap) xkb_keymap_unref(m_keymap);
if (m_ctx) xkb_context_unref(m_ctx);
if (m_fallbackKeymap) xkb_keymap_unref(m_fallbackKeymap);
}
XkbState(const XkbState &) = delete;
XkbState &operator=(const XkbState &) = delete;
struct xkb_context *m_ctx = nullptr;
struct xkb_keymap *m_keymap = nullptr;
struct xkb_state *m_unshifted = nullptr; // permanent no-mods state
// Reused across consumedMods calls (mutated then reset). Mutable so
// consumedMods can stay logically const.
// Pointer-identity reference to the keymap our derived states were
// built from. NOT owned (the tracker or m_fallbackKeymap owns).
mutable struct xkb_keymap *m_keymap = nullptr;
// Throwaway keymap from XKB defaults, built when the live keymap
// hasn't arrived yet. Owned. Released in dtor; never replaced.
mutable struct xkb_keymap *m_fallbackKeymap = nullptr;
mutable struct xkb_state *m_unshifted = nullptr; // no-mods state
// Reused across consumedMods calls (mutated then reset).
mutable struct xkb_state *m_query = nullptr;
xkb_mod_index_t m_idxShift = XKB_MOD_INVALID;
xkb_mod_index_t m_idxCtrl = XKB_MOD_INVALID;
xkb_mod_index_t m_idxAlt = XKB_MOD_INVALID;
xkb_mod_index_t m_idxSuper = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxShift = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxCtrl = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxAlt = XKB_MOD_INVALID;
mutable xkb_mod_index_t m_idxSuper = XKB_MOD_INVALID;
};
void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) {
@ -725,11 +780,13 @@ void GhosttySurface::sendKey(QKeyEvent *ev, ghostty_input_action_e action) {
k.action = action;
k.mods = translateMods(ev->modifiers());
// OR in any right-side bit for this keycode (e.g. Right-Shift sets
// SHIFT_RIGHT alongside SHIFT). macOS + GTK populate these; without
// SHIFT_RIGHT alongside SHIFT) and the live Caps/Num lock state
// from XkbTracker. macOS + GTK populate all of these; without
// them, keybinds like `right_shift+x` can't distinguish from
// `left_shift+x`.
// `left_shift+x` and the kitty CSI-u encoding loses the lock bits.
k.mods = static_cast<ghostty_input_mods_e>(
k.mods | XkbState::instance().sideBitsForKeycode(keycode));
k.mods | XkbState::instance().sideBitsForKeycode(keycode) |
XkbState::instance().lockMods());
k.keycode = keycode;
k.text = printable ? text.constData() : nullptr;
// XKB lookups: unshifted codepoint (what this physical key would

202
qt/src/XkbTracker.cpp Normal file
View File

@ -0,0 +1,202 @@
#include "XkbTracker.h"
#include <sys/mman.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include <QGuiApplication>
#include <qpa/qplatformnativeinterface.h>
#include <wayland-client.h>
#include <xkbcommon/xkbcommon.h>
namespace {
// Listener structs assembled from the static thunks below.
const wl_keyboard_listener kKeyboardListener = {
&XkbTracker::onKeymap, &XkbTracker::onEnter, &XkbTracker::onLeave,
&XkbTracker::onKey, &XkbTracker::onModifiers,
&XkbTracker::onRepeatInfo,
};
const wl_seat_listener kSeatListener = {
&XkbTracker::onSeatCapabilities, &XkbTracker::onSeatName,
};
const wl_registry_listener kRegistryListener = {
&XkbTracker::onRegistryGlobal, &XkbTracker::onRegistryGlobalRemove,
};
} // namespace
XkbTracker *XkbTracker::instance() {
// Singleton initialised on first call. If Wayland binding fails
// (e.g. running under XWayland with no exposed wl_seat) we return
// a non-null tracker that simply has no state — callers handle a
// null xkb_state gracefully.
static XkbTracker self;
return &self;
}
XkbTracker::XkbTracker() {
m_ctx = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
if (!m_ctx) return;
QPlatformNativeInterface *native =
QGuiApplication::platformNativeInterface();
if (!native) return;
auto *display = static_cast<wl_display *>(
native->nativeResourceForIntegration("wl_display"));
if (!display) return;
// Enumerate the registry on a private event queue so we don't
// disturb Qt's own queue. After we find wl_seat and get the
// wl_keyboard, the keyboard proxy is moved back to the default
// queue so Qt's event loop drives our listener callbacks.
wl_event_queue *queue = wl_display_create_queue(display);
wl_registry *registry = wl_display_get_registry(display);
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(registry), queue);
wl_registry_add_listener(registry, &kRegistryListener, this);
wl_display_roundtrip_queue(display, queue);
wl_registry_destroy(registry);
// Roundtrip again to receive seat capabilities and pick up the
// wl_keyboard; the registry pass only binds the seat.
if (m_keyboard == nullptr)
wl_display_roundtrip_queue(display, queue);
// The keyboard proxy is hot — move it onto the default queue so
// Qt's event loop dispatches our listeners alongside Qt's own
// input events.
if (m_keyboard) {
wl_proxy_set_queue(reinterpret_cast<wl_proxy *>(m_keyboard), nullptr);
}
wl_event_queue_destroy(queue);
}
XkbTracker::~XkbTracker() {
// Process-wide singleton; OS reclaims at exit. Explicit teardown
// keeps leak checkers quiet and documents ownership.
if (m_keyboard) wl_keyboard_destroy(m_keyboard);
if (m_state) xkb_state_unref(m_state);
if (m_keymap) xkb_keymap_unref(m_keymap);
if (m_ctx) xkb_context_unref(m_ctx);
}
bool XkbTracker::capsLockOn() const {
if (m_idxCapsLock == ~0u) return false;
return (m_modsLocked & (1u << m_idxCapsLock)) != 0;
}
bool XkbTracker::numLockOn() const {
if (m_idxNumLock == ~0u) return false;
return (m_modsLocked & (1u << m_idxNumLock)) != 0;
}
// --- Registry / seat binding ----------------------------------------
void XkbTracker::onRegistryGlobal(void *data, wl_registry *registry,
uint32_t name, const char *interface,
uint32_t /*version*/) {
auto *self = static_cast<XkbTracker *>(data);
if (std::strcmp(interface, wl_seat_interface.name) != 0) return;
// Bind the seat at version 5 (which exposes seat name + the
// listener callbacks we need). If the compositor advertises an
// older version, the bind silently downgrades; we only need
// capabilities in either case.
auto *seat = static_cast<wl_seat *>(
wl_registry_bind(registry, name, &wl_seat_interface, 5));
if (!seat) return;
// Subscribe to capability changes; we'll grab the keyboard from
// the capability callback once the seat tells us it has one.
wl_seat_add_listener(seat, &kSeatListener, self);
}
void XkbTracker::onRegistryGlobalRemove(void *, wl_registry *, uint32_t) {}
void XkbTracker::onSeatCapabilities(void *data, wl_seat *seat,
uint32_t capabilities) {
auto *self = static_cast<XkbTracker *>(data);
const bool hasKbd = (capabilities & WL_SEAT_CAPABILITY_KEYBOARD) != 0;
if (hasKbd && !self->m_keyboard) {
self->m_keyboard = wl_seat_get_keyboard(seat);
if (self->m_keyboard)
wl_keyboard_add_listener(self->m_keyboard, &kKeyboardListener, self);
} else if (!hasKbd && self->m_keyboard) {
wl_keyboard_destroy(self->m_keyboard);
self->m_keyboard = nullptr;
}
}
void XkbTracker::onSeatName(void *, wl_seat *, const char *) {}
// --- wl_keyboard listeners ------------------------------------------
void XkbTracker::onKeymap(void *data, wl_keyboard * /*kb*/, uint32_t format,
int32_t fd, uint32_t size) {
auto *self = static_cast<XkbTracker *>(data);
// We can only handle XKB v1 keymaps. Anything else is a Wayland
// protocol extension we don't support; close the FD and bail.
if (format != WL_KEYBOARD_KEYMAP_FORMAT_XKB_V1) {
close(fd);
return;
}
// mmap the keymap text and feed it to xkb. MAP_PRIVATE so writes
// don't propagate; PROT_READ is enough.
void *map = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
if (map == MAP_FAILED) {
std::fprintf(stderr, "[ghastty] xkb keymap mmap failed\n");
close(fd);
return;
}
xkb_keymap *km = xkb_keymap_new_from_string(
self->m_ctx, static_cast<const char *>(map),
XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS);
munmap(map, size);
close(fd);
if (!km) {
std::fprintf(stderr, "[ghastty] xkb keymap compile failed\n");
return;
}
// Replace the previous keymap+state. Anything that captured the
// old xkb_state* must use XkbTracker::state() each call rather
// than caching the pointer — we document that at the call site.
if (self->m_state) {
xkb_state_unref(self->m_state);
self->m_state = nullptr;
}
if (self->m_keymap) xkb_keymap_unref(self->m_keymap);
self->m_keymap = km;
self->m_state = xkb_state_new(km);
self->m_idxCapsLock = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_CAPS);
self->m_idxNumLock = xkb_keymap_mod_get_index(km, XKB_MOD_NAME_NUM);
self->m_modsLocked = 0;
self->m_group = 0;
}
void XkbTracker::onEnter(void *, wl_keyboard *, uint32_t, wl_surface *,
wl_array *) {}
void XkbTracker::onLeave(void *, wl_keyboard *, uint32_t, wl_surface *) {}
void XkbTracker::onKey(void *, wl_keyboard *, uint32_t, uint32_t, uint32_t,
uint32_t) {
// Qt delivers key events; we don't want to double-process here.
}
void XkbTracker::onModifiers(void *data, wl_keyboard *, uint32_t,
uint32_t mods_depressed, uint32_t mods_latched,
uint32_t mods_locked, uint32_t group) {
auto *self = static_cast<XkbTracker *>(data);
if (!self->m_state) return;
// Keep the live state in sync so xkb_state_key_get_one_sym (used
// for unshifted_codepoint) and xkb_state_key_get_consumed_mods2
// see the active layout group and locked-modifier mask.
xkb_state_update_mask(self->m_state, mods_depressed, mods_latched,
mods_locked, 0, 0, group);
self->m_modsLocked = mods_locked;
self->m_group = group;
}
void XkbTracker::onRepeatInfo(void *, wl_keyboard *, int32_t, int32_t) {}

99
qt/src/XkbTracker.h Normal file
View File

@ -0,0 +1,99 @@
#pragma once
#include <cstdint>
struct xkb_context;
struct xkb_keymap;
struct xkb_state;
// Tracks the user's live XKB state on Wayland: the active keymap, the
// effective layout group, and the locked modifier mask (Caps Lock,
// Num Lock).
//
// Qt does not expose any of this directly. We bind to the
// process-wide wl_seat via the platform native interface, install a
// wl_keyboard listener, rebuild our xkb_keymap from the compositor's
// keymap FD on every keymap event, and keep an xkb_state synced via
// the modifiers event.
//
// Read access (modsLocked / activeGroup / xkbState) is from the GUI
// thread only — same as Qt's input event delivery — and these
// methods do not mutate state.
//
// Wayland-only: this file's symbols are referenced from
// GhosttySurface, which already runs only on Wayland.
class XkbTracker {
public:
// Process-wide singleton; returns nullptr if Wayland binding
// failed (e.g. running under XWayland with no wl_seat available
// through Qt).
static XkbTracker *instance();
// The live xkb_state. Owned by the tracker; do not unref. May be
// null if the compositor hasn't sent a keymap yet.
xkb_state *state() const { return m_state; }
// The live xkb_keymap from the compositor. Owned by the tracker;
// do not unref. Replaced on every keymap event from the
// compositor; consumers that cache derived state should compare
// pointer identity to detect rebuilds.
xkb_keymap *keymap() const { return m_keymap; }
// The shared xkb_context. Lives as long as the tracker (process
// lifetime).
xkb_context *ctx() const { return m_ctx; }
// True if Caps Lock is on right now.
bool capsLockOn() const;
// True if Num Lock is on right now.
bool numLockOn() const;
// The active layout group (0-based). 0 when the compositor hasn't
// sent a modifiers event yet.
uint32_t activeGroup() const { return m_group; }
// Listener entry points are public because they're addressed by C
// function pointer in the wl_keyboard_listener / wl_seat_listener
// / wl_registry_listener structs. They are not part of the public
// API; treat as internal.
static void onKeymap(void *data, struct wl_keyboard *kb, uint32_t format,
int32_t fd, uint32_t size);
static void onEnter(void *data, struct wl_keyboard *kb, uint32_t serial,
struct wl_surface *surface, struct wl_array *keys);
static void onLeave(void *data, struct wl_keyboard *kb, uint32_t serial,
struct wl_surface *surface);
static void onKey(void *data, struct wl_keyboard *kb, uint32_t serial,
uint32_t time, uint32_t key, uint32_t state);
static void onModifiers(void *data, struct wl_keyboard *kb, uint32_t serial,
uint32_t mods_depressed, uint32_t mods_latched,
uint32_t mods_locked, uint32_t group);
static void onRepeatInfo(void *data, struct wl_keyboard *kb, int32_t rate,
int32_t delay);
// Registry callbacks (used to find wl_seat).
static void onRegistryGlobal(void *data, struct wl_registry *registry,
uint32_t name, const char *interface,
uint32_t version);
static void onRegistryGlobalRemove(void *data, struct wl_registry *registry,
uint32_t name);
// wl_seat capability-changed callback.
static void onSeatCapabilities(void *data, struct wl_seat *seat,
uint32_t capabilities);
static void onSeatName(void *data, struct wl_seat *seat, const char *name);
private:
XkbTracker();
~XkbTracker();
XkbTracker(const XkbTracker &) = delete;
XkbTracker &operator=(const XkbTracker &) = delete;
xkb_context *m_ctx = nullptr;
xkb_keymap *m_keymap = nullptr;
xkb_state *m_state = nullptr;
uint32_t m_modsLocked = 0;
uint32_t m_group = 0;
// Indices into the keymap for the lock mods. XKB_MOD_INVALID until
// a keymap is loaded.
uint32_t m_idxCapsLock = ~0u;
uint32_t m_idxNumLock = ~0u;
// wl_keyboard handle, owned by us via wl_seat_get_keyboard.
struct wl_keyboard *m_keyboard = nullptr;
};