Follow the keyboard being typed on in the layout widget (#6740)

* Read the keyboard being typed on rather than the one holding main

The main flag names no keyboard for long. fcitx5 takes it with the
virtual keyboard it binds to inject, and those are filtered out, so on a
seat running an input method the pick lands on nothing at all: no label,
and the widget hides itself off the bar. #6727 keeps polling in that
state rather than settling it, and the poll has nothing new to read.
Once fcitx5 unbinds, the flag lands on whichever device libinput listed
last, as easily a lid switch as a keyboard, and a device that never
receives the toggle reports the layout it started on forever, which is
the reading #6574 opened.

Every device carries the seat's layout list, but only the keyboard being
typed on advances through it, so read the furthest-advanced one.
activelayout names the keyboard it moved ahead of the layout, so take
that name and let it settle the pick, and the click that switches it.

* Leave the buttons out of the seat the widget reads

Reading the keyboard being typed on left keyboardName standing for two
things at once: the device a click switches, and the device activelayout
last named. Only the second was still being set, so the first went empty
until a switch happened -- which left the click doing nothing on a seat
whose only switch is the click, and left the poll running forever on the
one-keyboard install it was written to leave alone. Give each its own
property, and set the switch target from the reading that confirmed the
keyboard is there.

Layout progress only points at the keyboard being typed on while the
other devices stay where they started, and the ACPI power button, lid
switch and sleep key never do move on their own -- but they answer to
switchxkblayout and can hold the main flag, so anything that reads or
switches whatever the seat hands back can end up describing a button, and
unplugging the keyboard beside one leaves it standing in for the seat.
Drop them where the virtual keyboards are already dropped.

A reading that reaches hyprctl and finds no keyboard now clears the label
rather than leaving a device that is gone described on the bar, told
apart from the empty output a killed query leaves by the device list
itself, and the watchdog asks again rather than waiting for a poll that a
settled seat has already stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
KazeTachinuu
2026-08-12 16:57:57 +02:00
committed by GitHub
co-authored by Claude Opus 5 David Heinemeier Hansson
parent 0fa3170504
commit 66e3f479a6
3 changed files with 179 additions and 30 deletions
+48
View File
@@ -85,4 +85,52 @@ assertEqual(model.shortLabel('Elvish (Tengwar)', briefs), 'ELV', 'an unlisted la
assertEqual(model.shortLabel('English (US)', {}), 'ENG', 'the label survives an empty table')
assertEqual(model.shortLabel('constructor', {}), 'CON', 'a description naming a built-in still falls back')
assertEqual(model.shortLabel('', briefs), '', 'no keymap means no label')
// The seat as hyprctl reports it, virtual keyboards already filtered out: the
// buttons libinput calls keyboards sit beside the one being typed on, and the
// main flag lands on either, or on the virtual keyboard that isn't here.
const seat = (activeIndex, main) => [
{ name: 'power-button', active_layout_index: 0, active_keymap: 'English (US)', main: main === 'power-button' },
{ name: 'at-translated-set-2-keyboard', active_layout_index: activeIndex, active_keymap: activeIndex ? 'French' : 'English (US)', main: main === 'keyboard' },
]
assertEqual(model.selectKeyboard(seat(1)).active_keymap, 'French', 'the keyboard that switched is read when nothing holds main')
assertEqual(model.selectKeyboard(seat(1, 'power-button')).active_keymap, 'French', 'the keyboard that switched outranks a button holding main')
assertEqual(model.selectKeyboard(seat(0)).active_keymap, 'English (US)', 'before a switch every keyboard reads the same layout')
assertEqual(model.selectKeyboard(seat(0), 'at-translated-set-2-keyboard').name, 'at-translated-set-2-keyboard', 'the keyboard activelayout named is kept once it switches back')
assertEqual(model.selectKeyboard([{ name: 'a' }, { name: 'b' }]).name, 'a', 'a keyboard reporting no index still gets picked')
assertEqual(model.selectKeyboard([]), undefined, 'a seat with no typed keyboard picks none')
// Two real keyboards on the layout the seat started on, so nothing but the name
// says which one is being typed on. The one that wrapped from the last layout
// back to the first sits behind the other and is still the one that switched.
const pair = (activeIndex, otherIndex) => [
{ name: 'at-translated-set-2-keyboard', active_layout_index: otherIndex, active_keymap: otherIndex ? 'German' : 'English (US)' },
{ name: 'usb-keyboard', active_layout_index: activeIndex, active_keymap: activeIndex ? 'German' : 'English (US)' },
]
assertEqual(model.selectKeyboard(pair(0, 0), 'usb-keyboard').name, 'usb-keyboard', 'the keyboard that switched is read once it is named')
assertEqual(model.selectKeyboard(pair(0, 2), 'usb-keyboard').name, 'usb-keyboard', 'a keyboard that wrapped back to the first layout is still the one that switched')
assertEqual(model.selectKeyboard(pair(0, 2), 'gone-keyboard').name, 'at-translated-set-2-keyboard', 'a name no keyboard answers to falls back to layout progress')
// Hyprland calls the ACPI buttons keyboards too, and hands them the seat's
// layout list. Reading one would describe a layout nobody typed, and switching
// one would leave the keyboard where it was.
assertEqual(model.isTypedKeyboard('at-translated-set-2-keyboard'), true, 'a real keyboard is typed on')
assertEqual(model.isTypedKeyboard('power-button'), false, 'a power button is not')
assertEqual(model.isTypedKeyboard('lid-switch'), false, 'a lid switch is not')
assertEqual(model.isTypedKeyboard('sleep-button'), false, 'a sleep button is not')
assertEqual(model.isTypedKeyboard('hl-virtual-keyboard-1'), false, 'the keyboard an input method injects through is not')
assertEqual(model.isTypedKeyboard(''), true, 'a keyboard reporting no name is left where it was found')
// The activelayout event names the keyboard ahead of the layout it moved to,
// and a description with a comma in it has to survive the split.
const rawEvent = data => ({ data })
const parsedEvent = data => ({ data, parse: count => data.split(',', count - 1).concat(data.split(',').slice(count - 1).join(',')) })
assertEqual(model.eventKeyboardName(rawEvent('at-translated-set-2-keyboard,French')), 'at-translated-set-2-keyboard', 'the event names its keyboard')
assertEqual(model.eventKeyboardName(parsedEvent('at-translated-set-2-keyboard,English (US, intl.)')), 'at-translated-set-2-keyboard', 'a description carrying a comma leaves the name alone')
assertEqual(model.eventKeyboardName(rawEvent('hl-virtual-keyboard,English (US)')), '', 'the keyboard an input method injects through is not typed on')
assertEqual(model.eventKeyboardName({ parse: () => { throw new Error('unsupported') }, data: 'kb,French' }), 'kb', 'a binding without parse falls back to the raw data')
assertEqual(model.eventKeyboardName({}), '', 'an event with nothing in it names no keyboard')
JS