Fix Shift+digit heading navigation on Wayland

This commit is contained in:
hjozwiak committed 2026-11-03 16:37:46 -05:00
1 parent 0a3d946ffa
commit da4ae8525f
3 files changed
+196 -2

No files matched your search

+105 -2
View File
@@ -6,6 +6,7 @@ package wayland
/*
#cgo pkg-config: xkbcommon
#include <stdlib.h>
#include <xkbcommon/xkbcommon.h>
*/
import "C"
@@ -14,6 +15,7 @@ import (
"errors"
"fmt"
"sort"
"sync"
"time"
"unsafe"
@@ -51,12 +53,101 @@ func key_name(symbol uint32) string {
func lower_symbol(symbol uint32) uint32 { return uint32(C.xkb_keysym_to_lower(C.xkb_keysym_t(symbol))) }
// KeyboardMonitor sends keysyms rather than a keymap. Letter case is defined
// by XKB; punctuation's physical Shift pairing cannot be inferred from it.
// KeyboardMonitor sends keysyms rather than a keymap, and the symbol for a
// shifted key is the shifted keysym: Shift+3 arrives as numbersign on a US
// layout. Letter case is defined by XKB alone, but a digit or punctuation
// key's Shift partner depends on the layout. Bindings name the unshifted
// symbol plus the Shift bit, as delivered by the Xorg path, so both grab
// registration and event handling translate through shift pairs taken from
// the default XKB keymap (the XKB_DEFAULT_* environment, else libxkbcommon
// defaults). The monitor interface does not expose the compositor's keymap;
// when its layout differs from that keymap, shifted digit and punctuation
// chords keep the previous pass-through behavior instead of misfiring.
func shifted_symbol(symbol uint32) uint32 {
return uint32(C.xkb_keysym_to_upper(C.xkb_keysym_t(symbol)))
}
type shiftPairs struct {
shifted map[uint32][]uint32 // Unshifted symbol to its shifted partners.
unshifted map[uint32]uint32 // Shifted symbol to its one unshifted partner.
}
var loadShiftPairs = sync.OnceValue(func() shiftPairs { return build_shift_pairs("") })
func shift_pair_base(symbol uint32) bool {
if symbol < '!' || symbol > '~' {
return false
}
return (symbol < 'A' || symbol > 'Z') && (symbol < 'a' || symbol > 'z')
}
func shift_pair_partner(symbol, base uint32) bool {
if symbol == 0 || symbol == base || symbol >= 0xfe00 {
return false // NoSymbol, unchanged, or dead/function/modifier ranges.
}
return (symbol < 'A' || symbol > 'Z') && (symbol < 'a' || symbol > 'z')
}
// An empty layout selects the default keymap; tests name layouts explicitly.
func build_shift_pairs(layout string) shiftPairs {
pairs := shiftPairs{shifted: make(map[uint32][]uint32), unshifted: make(map[uint32]uint32)}
context := C.xkb_context_new(C.XKB_CONTEXT_NO_FLAGS)
if context == nil {
return pairs
}
C.xkb_context_set_log_level(context, C.XKB_LOG_LEVEL_CRITICAL) // Failure means empty pairs, not stderr noise.
defer C.xkb_context_unref(context)
var names C.struct_xkb_rule_names
if layout != "" {
clayout := C.CString(layout)
defer C.free(unsafe.Pointer(clayout))
names.layout = clayout
}
keymap := C.xkb_keymap_new_from_names(context, &names, C.XKB_KEYMAP_COMPILE_NO_FLAGS)
if keymap == nil {
return pairs
}
defer C.xkb_keymap_unref(keymap)
cshift := C.CString("Shift")
defer C.free(unsafe.Pointer(cshift))
shiftIndex := C.xkb_keymap_mod_get_index(keymap, cshift)
if shiftIndex == C.XKB_MOD_INVALID {
return pairs
}
state := C.xkb_state_new(keymap)
if state == nil {
return pairs
}
defer C.xkb_state_unref(state)
ambiguous := make(map[uint32]bool)
groups := C.xkb_keymap_num_layouts(keymap)
for group := C.xkb_layout_index_t(0); group < groups; group++ {
for code := C.xkb_keymap_min_keycode(keymap); code <= C.xkb_keymap_max_keycode(keymap); code++ {
C.xkb_state_update_mask(state, 0, 0, 0, 0, 0, group)
base := uint32(C.xkb_state_key_get_one_sym(state, code))
C.xkb_state_update_mask(state, 1<<shiftIndex, 0, 0, 0, 0, group)
shifted := uint32(C.xkb_state_key_get_one_sym(state, code))
if !shift_pair_base(base) || !shift_pair_partner(shifted, base) {
continue
}
found := false
for _, partner := range pairs.shifted[base] {
found = found || partner == shifted
}
if !found {
pairs.shifted[base] = append(pairs.shifted[base], shifted)
}
if previous, ok := pairs.unshifted[shifted]; ok && previous != base {
delete(pairs.unshifted, shifted)
ambiguous[shifted] = true
} else if !ambiguous[shifted] {
pairs.unshifted[shifted] = base
}
}
}
return pairs
}
func normalize_symbol(symbol uint32) uint32 {
symbol = lower_symbol(symbol)
if symbol == 0xfe20 {
@@ -91,6 +182,11 @@ func make_grabs(configuration keyboardConfiguration, policy keyboardPolicy) ([]u
if mask&3 != 0 {
unique[keyGrab{shifted_symbol(chord.Symbol), mask}] = true
}
if mask&1 != 0 {
for _, symbol := range loadShiftPairs().shifted[chord.Symbol] {
unique[keyGrab{symbol, mask}] = true
}
}
}
}
grabs := make([]keyGrab, 0, len(unique))
@@ -125,6 +221,13 @@ func (owner *keyboardOwner) handle_key(key keyEvent) error {
owner.sequence = max(owner.sequence+1, uint32(time.Since(owner.started).Milliseconds())+1)
timestamp := owner.sequence // One local clock shared by monitoring and commands.
symbol := normalize_symbol(key.symbol)
// Monitoring below keeps the raw symbol, so typing "#" still echoes as
// numbersign; only command lookup sees the unshifted binding symbol.
if key.state&1 != 0 {
if base, ok := loadShiftPairs().unshifted[symbol]; ok {
symbol = base
}
}
if key.symbol == 0xffb0 && owner.router.IsModifier(0xff9e) {
symbol = 0xff9e
}