Fix Shift+digit heading navigation on Wayland
This commit is contained in:
1 parent
0a3d946ffa
commit
da4ae8525f
3 files changed
+196
-2
No files matched your search
@@ -242,6 +242,11 @@ func TestNativeKeyboardBrowseSecureAndLearnTransitions(t *testing.T) {
|
||||
monitor.key(t, 'h', 43, 0, false)
|
||||
monitor.key(t, 'h', 43, 0, true)
|
||||
await_command(t, keyboard, input.DocumentNextHeading)
|
||||
if loadShiftPairs().unshifted['#'] == '3' {
|
||||
monitor.key(t, '#', 12, 1, false)
|
||||
monitor.key(t, '3', 12, 0, true)
|
||||
await_command(t, keyboard, input.DocumentPreviousHeadingAtLevel3)
|
||||
}
|
||||
keyboard.SetNativeButtonFocus(true)
|
||||
wait_snapshot(t, monitor, func(state MonitorSnapshot) bool {
|
||||
for _, grab := range state.grabs {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -136,6 +136,84 @@ func TestBrowseCaseLocksAndFocusedButton(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Compositors emit the shifted keysym for Shift+digit (US layout: Shift+3 is
|
||||
// numbersign), while bindings name the unshifted digit plus the Shift bit.
|
||||
func TestShiftPairsFromKeymapLayouts(t *testing.T) {
|
||||
us := build_shift_pairs("us")
|
||||
for shifted, base := range map[uint32]uint32{'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', ':': ';', '?': '/'} {
|
||||
if us.unshifted[shifted] != base {
|
||||
t.Fatalf("us shifted %q unshifts to %q, want %q", shifted, us.unshifted[shifted], base)
|
||||
}
|
||||
}
|
||||
if partners := us.shifted['3']; len(partners) != 1 || partners[0] != '#' {
|
||||
t.Fatalf("us partners for 3 = %v, want [#]", partners)
|
||||
}
|
||||
// The same physical chord differs by layout: GB Shift+3 is sterling.
|
||||
gb := build_shift_pairs("gb")
|
||||
if gb.unshifted[0xa3] != '3' {
|
||||
t.Fatalf("gb sterling unshifts to %q, want 3", gb.unshifted[0xa3])
|
||||
}
|
||||
if empty := build_shift_pairs("no-such-layout"); len(empty.unshifted) != 0 || len(empty.shifted) != 0 {
|
||||
t.Fatal("unknown layout produced shift pairs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseShiftedDigitHeadingLevels(t *testing.T) {
|
||||
if loadShiftPairs().unshifted['#'] != '3' {
|
||||
t.Skip("default XKB keymap is not US-like; shifted symbols here assume US pairs")
|
||||
}
|
||||
configuration := keyboardConfiguration{layout: input.DesktopKeyboardLayout, modifiers: []string{"Insert"}}
|
||||
previous := []input.Command{
|
||||
input.DocumentPreviousHeadingAtLevel1, input.DocumentPreviousHeadingAtLevel2,
|
||||
input.DocumentPreviousHeadingAtLevel3, input.DocumentPreviousHeadingAtLevel4,
|
||||
input.DocumentPreviousHeadingAtLevel5, input.DocumentPreviousHeadingAtLevel6,
|
||||
}
|
||||
shifted := []uint32{'!', '@', '#', '$', '%', '^'}
|
||||
for _, mask := range []uint32{0, 2, 16, 18} {
|
||||
policy := keyboardPolicy{enabled: true, browse: true}
|
||||
symbols, grabs, bindings := make_grabs(configuration, policy)
|
||||
owner := test_owner(t, configuration.layout, "Insert")
|
||||
owner.bindings, owner.policy, owner.router = bindings, policy, input.NewCommandRouter(symbols, bindings)
|
||||
for level := 0; level < 6; level++ {
|
||||
code := uint16(10 + level)
|
||||
found := false
|
||||
for _, grab := range grabs {
|
||||
if grab == (keyGrab{shifted[level], mask | 1}) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("shifted digit grab missing for level %d mask %d", level+1, mask)
|
||||
}
|
||||
send_key(t, owner, shifted[level], code, mask|1, false)
|
||||
next_command(t, owner.keyboard, previous[level])
|
||||
// Releasing Shift first changes the reported symbol to the digit.
|
||||
send_key(t, owner, uint32('1'+level), code, mask, true)
|
||||
if len(owner.held) != 0 {
|
||||
t.Fatal("changed release symbol left a digit key held")
|
||||
}
|
||||
send_key(t, owner, uint32('1'+level), code, mask, false)
|
||||
next_command(t, owner.keyboard, input.Command("document-next-heading-at-level-"+string(rune('1'+level))))
|
||||
send_key(t, owner, uint32('1'+level), code, mask, true)
|
||||
}
|
||||
}
|
||||
// Alt+Shift+digit heading lists share the translated symbol.
|
||||
policy := keyboardPolicy{enabled: true, browse: true}
|
||||
symbols, _, bindings := make_grabs(configuration, policy)
|
||||
listOwner := test_owner(t, configuration.layout, "Insert")
|
||||
listOwner.bindings, listOwner.policy, listOwner.router = bindings, policy, input.NewCommandRouter(symbols, bindings)
|
||||
send_key(t, listOwner, '#', 12, 1|8, false)
|
||||
next_command(t, listOwner.keyboard, input.DocumentListHeadingsAtLevel3)
|
||||
send_key(t, listOwner, '3', 12, 8, true)
|
||||
// Outside browse mode the shifted symbol must reach monitoring untouched.
|
||||
owner := test_owner(t, input.DesktopKeyboardLayout, "Insert")
|
||||
send_key(t, owner, '#', 12, 1, false)
|
||||
event := <-owner.keyboard.events
|
||||
if event.Name != "numbersign" || len(owner.keyboard.commands) != 0 {
|
||||
t.Fatalf("plain typing changed to %q", event.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLearnModeAndSecureReset(t *testing.T) {
|
||||
owner := test_owner(t, input.LaptopKeyboardLayout, "Insert")
|
||||
send_key(t, owner, 0xff63, 118, 0, false)
|
||||
@@ -149,6 +227,14 @@ func TestLearnModeAndSecureReset(t *testing.T) {
|
||||
send_key(t, owner, 'q', 24, 0, false)
|
||||
next_command(t, owner.keyboard, input.LearnKey)
|
||||
send_key(t, owner, 'q', 24, 0, true)
|
||||
if loadShiftPairs().unshifted['#'] == '3' {
|
||||
send_key(t, owner, '#', 12, 1, false)
|
||||
learned := next_command(t, owner.keyboard, input.LearnKey)
|
||||
if learned.KeyName != "3" || learned.Chord.Symbol != '3' || learned.Chord.Modifiers != 1 {
|
||||
t.Fatalf("learned shifted digit = %q %+v, want Shift+3", learned.KeyName, learned.Chord)
|
||||
}
|
||||
send_key(t, owner, '3', 12, 0, true)
|
||||
}
|
||||
send_key(t, owner, 0xff1b, 9, 0, false)
|
||||
if next_command(t, owner.keyboard, input.ToggleLearnMode).LearnModeStarted {
|
||||
t.Fatal("Escape restarted learn mode")
|
||||
|
||||
Reference in new issue
Block a user