qt: phase 7 — extract XkbState into qt/src/input/

The XkbState process-singleton (libxkbcommon keymap + state used to
compute the unshifted codepoint, sided modifier bits, lock state,
and layout-consumed mods for libghostty's kitty encoder) was
embedded ~180 lines deep in GhosttySurface.cpp. The class is fully
self-contained — pure xkbcommon + XkbTracker, no QWidget — so it
moves cleanly into a new qt/src/input/ directory next to its
sibling XkbTracker.

GhosttySurface.cpp now just #includes "input/XkbState.h" and uses
XkbState::instance() at four sendKey callsites. The
<xkbcommon/xkbcommon.h> direct include drops out (the surface
TU no longer touches xkb directly; the header pulls it in for the
xkb_mod_index_t members).

GhosttySurface.cpp shrinks ~180 lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
pull/12846/head
ntomsic 2026-05-23 14:53:14 -05:00
parent c4ab838859
commit f070cff504
4 changed files with 220 additions and 199 deletions

View File

@ -112,6 +112,7 @@ add_executable(ghastty
src/GhosttySurface.cpp
src/GlobalShortcuts.cpp
src/InspectorWindow.cpp
src/input/XkbState.cpp
src/MainWindow.cpp
src/OverlayScrollbar.cpp
src/quickterm/QuickTerminal.cpp

View File

@ -1,6 +1,7 @@
#include "GhosttySurface.h"
#include "config/Config.h"
#include "input/XkbState.h"
#include "InspectorWindow.h"
#include "MainWindow.h"
#include "OverlayScrollbar.h"
@ -49,8 +50,6 @@
#include <QUrl>
#include <QWheelEvent>
#include <xkbcommon/xkbcommon.h>
GhosttySurface::GhosttySurface(ghostty_app_t app, MainWindow *owner,
ghostty_surface_t parent_surface)
: m_app(app), m_owner(owner), m_parentSurface(parent_surface) {
@ -697,203 +696,6 @@ void GhosttySurface::premultiplyFramebuffer() {
// --- input ----------------------------------------------------------
// Wraps a libxkbcommon keymap + state derived from the system's XKB
// defaults (XKB_DEFAULT_LAYOUT etc.). We need this for two things:
//
// 1. The unshifted codepoint a key would produce with no modifiers —
// libghostty's kitty encoder uses it to find a key entry for
// printable keys (without it, punctuation falls into a fallback
// that mis-encodes release events).
//
// 2. Which modifiers the layout "consumed" to produce the event's
// text — e.g. Shift+; → ":" consumes Shift. The encoder uses this
// to decide between plain text and a modifier-bearing CSI; without
// it Shift+punctuation gets emitted as a kitty CSI the shell can't
// decode (Shift+letter happens to work because A-Z survive that
// path).
//
// THREAD SAFETY: this is a process singleton accessed only from the Qt
// GUI thread (Qt key events are dispatched there, and so is libghostty's
// inputMethodEvent forwarding). consumedMods mutates m_query, so a
// second thread would race; do not call from worker threads.
class XkbState {
public:
static XkbState &instance() {
static XkbState self;
return self;
}
// 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);
if (sym == XKB_KEY_NoSymbol) return 0;
return xkb_keysym_to_utf32(sym);
}
// Side bits for the libghostty mods bitfield, derived from a
// keycode — used so that pressing Right-Shift sets BOTH the
// unsided GHOSTTY_MODS_SHIFT and the GHOSTTY_MODS_SHIFT_RIGHT bit
// (a left-side keycode sets only the unsided bit). macOS and GTK
// populate sided bits this way; Qt was leaving them empty so
// 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);
int r = GHOSTTY_MODS_NONE;
switch (sym) {
case XKB_KEY_Shift_R: r |= GHOSTTY_MODS_SHIFT_RIGHT; break;
case XKB_KEY_Control_R: r |= GHOSTTY_MODS_CTRL_RIGHT; break;
// Both Alt_R and ISO_Level3_Shift (AltGr) are right-Alt physically.
case XKB_KEY_Alt_R:
case XKB_KEY_ISO_Level3_Shift: r |= GHOSTTY_MODS_ALT_RIGHT; break;
case XKB_KEY_Super_R:
case XKB_KEY_Hyper_R:
case XKB_KEY_Meta_R: r |= GHOSTTY_MODS_SUPER_RIGHT; break;
default: break;
}
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)
depressed |= (1u << m_idxShift);
if ((mods & GHOSTTY_MODS_CTRL) && m_idxCtrl != XKB_MOD_INVALID)
depressed |= (1u << m_idxCtrl);
if ((mods & GHOSTTY_MODS_ALT) && m_idxAlt != XKB_MOD_INVALID)
depressed |= (1u << m_idxAlt);
if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID)
depressed |= (1u << m_idxSuper);
// 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, group);
int r = GHOSTTY_MODS_NONE;
if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift)))
r |= GHOSTTY_MODS_SHIFT;
if (m_idxCtrl != XKB_MOD_INVALID && (consumed & (1u << m_idxCtrl)))
r |= GHOSTTY_MODS_CTRL;
if (m_idxAlt != XKB_MOD_INVALID && (consumed & (1u << m_idxAlt)))
r |= GHOSTTY_MODS_ALT;
if (m_idxSuper != XKB_MOD_INVALID && (consumed & (1u << m_idxSuper)))
r |= GHOSTTY_MODS_SUPER;
return static_cast<ghostty_input_mods_e>(r);
}
private:
// 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. Take
// an extra ref on the keymap while it's our cached identity so
// the xkb allocator can't free it and reuse the same address
// for a different keymap (the ABA hazard the previous comment
// hand-waved away).
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_query) xkb_state_unref(m_query);
if (m_keymap) xkb_keymap_unref(m_keymap);
m_keymap = xkb_keymap_ref(km);
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);
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_fallbackKeymap) xkb_keymap_unref(m_fallbackKeymap);
}
XkbState(const XkbState &) = delete;
XkbState &operator=(const XkbState &) = delete;
// The keymap our derived states were built from. We hold a ref
// here (taken in syncFromTracker, released on rebuild and in dtor)
// so the xkb allocator can't free + reuse the address while we
// still cache it as our identity.
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;
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) {
if (!m_surface) return;

135
qt/src/input/XkbState.cpp Normal file
View File

@ -0,0 +1,135 @@
#include "XkbState.h"
#include "../XkbTracker.h"
XkbState &XkbState::instance() {
static XkbState self;
return self;
}
XkbState::~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_fallbackKeymap) xkb_keymap_unref(m_fallbackKeymap);
}
uint32_t XkbState::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);
if (sym == XKB_KEY_NoSymbol) return 0;
return xkb_keysym_to_utf32(sym);
}
ghostty_input_mods_e XkbState::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);
int r = GHOSTTY_MODS_NONE;
switch (sym) {
case XKB_KEY_Shift_R: r |= GHOSTTY_MODS_SHIFT_RIGHT; break;
case XKB_KEY_Control_R: r |= GHOSTTY_MODS_CTRL_RIGHT; break;
// Both Alt_R and ISO_Level3_Shift (AltGr) are right-Alt physically.
case XKB_KEY_Alt_R:
case XKB_KEY_ISO_Level3_Shift: r |= GHOSTTY_MODS_ALT_RIGHT; break;
case XKB_KEY_Super_R:
case XKB_KEY_Hyper_R:
case XKB_KEY_Meta_R: r |= GHOSTTY_MODS_SUPER_RIGHT; break;
default: break;
}
return static_cast<ghostty_input_mods_e>(r);
}
ghostty_input_mods_e XkbState::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);
}
ghostty_input_mods_e XkbState::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)
depressed |= (1u << m_idxShift);
if ((mods & GHOSTTY_MODS_CTRL) && m_idxCtrl != XKB_MOD_INVALID)
depressed |= (1u << m_idxCtrl);
if ((mods & GHOSTTY_MODS_ALT) && m_idxAlt != XKB_MOD_INVALID)
depressed |= (1u << m_idxAlt);
if ((mods & GHOSTTY_MODS_SUPER) && m_idxSuper != XKB_MOD_INVALID)
depressed |= (1u << m_idxSuper);
// 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, group);
int r = GHOSTTY_MODS_NONE;
if (m_idxShift != XKB_MOD_INVALID && (consumed & (1u << m_idxShift)))
r |= GHOSTTY_MODS_SHIFT;
if (m_idxCtrl != XKB_MOD_INVALID && (consumed & (1u << m_idxCtrl)))
r |= GHOSTTY_MODS_CTRL;
if (m_idxAlt != XKB_MOD_INVALID && (consumed & (1u << m_idxAlt)))
r |= GHOSTTY_MODS_ALT;
if (m_idxSuper != XKB_MOD_INVALID && (consumed & (1u << m_idxSuper)))
r |= GHOSTTY_MODS_SUPER;
return static_cast<ghostty_input_mods_e>(r);
}
void XkbState::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. Take
// an extra ref on the keymap while it's our cached identity so
// the xkb allocator can't free it and reuse the same address
// for a different keymap (the ABA hazard the previous comment
// hand-waved away).
if (m_unshifted) xkb_state_unref(m_unshifted);
if (m_query) xkb_state_unref(m_query);
if (m_keymap) xkb_keymap_unref(m_keymap);
m_keymap = xkb_keymap_ref(km);
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);
if (t)
xkb_state_update_mask(m_unshifted, 0, 0, 0, 0, 0, t->activeGroup());
}

83
qt/src/input/XkbState.h Normal file
View File

@ -0,0 +1,83 @@
#pragma once
#include <cstdint>
#include <xkbcommon/xkbcommon.h>
#include "ghostty.h"
// Wraps a libxkbcommon keymap + state derived from the live keymap
// XkbTracker syncs via wl_keyboard (with a fallback to the system
// XKB defaults until the compositor's keymap arrives). We need this
// for two things:
//
// 1. The unshifted codepoint a key would produce with no modifiers —
// libghostty's kitty encoder uses it to find a key entry for
// printable keys (without it, punctuation falls into a fallback
// that mis-encodes release events).
//
// 2. Which modifiers the layout "consumed" to produce the event's
// text — e.g. Shift+; → ":" consumes Shift. The encoder uses
// this to decide between plain text and a modifier-bearing CSI;
// without it Shift+punctuation gets emitted as a kitty CSI the
// shell can't decode (Shift+letter happens to work because A-Z
// survive that path).
//
// THREAD SAFETY: this is a process singleton accessed only from the
// Qt GUI thread (Qt key events are dispatched there, and so is
// libghostty's inputMethodEvent forwarding). consumedMods mutates
// internal state; do not call from worker threads.
class XkbState {
public:
static XkbState &instance();
// Level-0 (unshifted) Unicode codepoint for `keycode`, or 0 if the
// key has no associated UTF-32 (function keys, modifiers, etc.).
// Honors the active layout group from the live tracker so a us+ru
// user gets the correct codepoint per active group, not always us.
uint32_t unshiftedCodepoint(uint32_t keycode) const;
// Side bits for the libghostty mods bitfield, derived from a
// keycode — pressing Right-Shift sets BOTH the unsided
// GHOSTTY_MODS_SHIFT and GHOSTTY_MODS_SHIFT_RIGHT bit (a left-side
// keycode sets only the unsided bit). macOS and GTK populate sided
// bits this way; Qt was leaving them empty so bindings that
// distinguish left-vs-right modifier keys couldn't fire.
ghostty_input_mods_e sideBitsForKeycode(uint32_t keycode) const;
// Caps Lock / Num Lock state from the live wl_keyboard tracker.
ghostty_input_mods_e lockMods() const;
// Modifiers consumed by the layout to produce `keycode`'s text
// given `mods` are depressed. Returns the consumed subset
// expressed as ghostty mod bits.
ghostty_input_mods_e consumedMods(uint32_t keycode,
ghostty_input_mods_e mods) const;
XkbState(const XkbState &) = delete;
XkbState &operator=(const XkbState &) = delete;
private:
XkbState() = default;
~XkbState();
// Build / rebuild the derived states from the live keymap. Cheap
// when the keymap pointer is unchanged (one comparison + return).
void syncFromTracker() const;
// The keymap our derived states were built from. A ref taken in
// syncFromTracker (released on rebuild and in dtor) keeps the xkb
// allocator from freeing + reusing the address while we still
// cache it as our identity.
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;
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;
};