From 66e3f479a61ad4dcb7da02202ad974238e20173a Mon Sep 17 00:00:00 2001 From: KazeTachinuu <90521430+KazeTachinuu@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:57:57 +0200 Subject: [PATCH] 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 --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 --- shell/plugins/bar/widgets/KeyboardLayout.qml | 98 +++++++++++++------ .../bar/widgets/KeyboardLayoutModel.js | 63 ++++++++++++ test/shell.d/keyboard-layout-test.sh | 48 +++++++++ 3 files changed, 179 insertions(+), 30 deletions(-) diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 4a9dbc1b..5457d88e 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -12,9 +12,15 @@ BarWidget { property string layoutFull: "" + // The keyboard the last reading spoke for, which is the one a click switches, + // and separately the one activelayout named as being typed on. A reading + // confirms the first is really there, so the click has a keyboard to reach + // from the first reading onwards rather than only after a switch, and stops + // naming one that has been unplugged. property string keyboardName: "" - // Real keyboards on the seat, virtual ones excluded, and whether the last - // reading left that shape in doubt. + property string typedKeyboardName: "" + // Keyboards on the seat, buttons and virtual ones excluded, and whether the + // last reading left that shape in doubt. property int keyboardCount: 0 property bool keyboardUnresolved: false // Nothing to read or switch on the single-layout install most people run, so @@ -41,21 +47,30 @@ BarWidget { queryProc.running = true } - // fcitx5 binds a virtual keyboard and takes over the seat's main flag whenever - // it injects, but that keyboard keeps the us layout the input method gave it. - // Leave those out, so the label keeps tracking a keyboard someone types on. + // Keyboards someone can actually type on, which is not everything Hyprland + // calls a keyboard. function typedKeyboards(keyboards) { - return keyboards.filter(k => !String(k.name).startsWith("hl-virtual-keyboard")) + return keyboards.filter(k => KeyboardLayoutModel.isTypedKeyboard(k.name)) } - // Stay on the keyboard we last read until a real one is active again, so an - // input method holding the main flag freezes nothing. + // The main flag names no keyboard for long: fcitx5 takes it with the virtual + // keyboard it binds to inject, which leaves no typed keyboard holding it and + // nothing to read at all, and once that unbinds it lands on whichever device + // Hyprland saw last, a power button included. Go by layout progress instead, + // and by the keyboard activelayout named. function selectKeyboard(typed) { - return typed.find(k => k.main) ?? typed.find(k => k.name === root.keyboardName) + return KeyboardLayoutModel.selectKeyboard(typed, root.typedKeyboardName) } // switchxkblayout is a hyprctl command rather than a dispatcher, so it has to - // be run rather than sent over the dispatch socket. + // be run rather than sent over the dispatch socket. It switches the keyboard + // the last reading spoke for, so a click always advances the device the label + // is describing. Switching the seat together would reach the typed keyboard + // without having to name it, but it would also carry the buttons along, and + // the whole read depends on those staying where they started: once a button + // has been advanced too, a toggle that wraps the keyboard back to the first + // layout leaves the button reading as the furthest along, and the label + // follows the button. function cycleLayout() { if (!root.keyboardName || !root.bar) return root.bar.run("hyprctl switchxkblayout " + Util.shellQuote(root.keyboardName) + " next") @@ -72,9 +87,16 @@ BarWidget { function onRawEvent(event) { if (!event || !event.name) return var name = String(event.name) - // A reload that edits kb_layout changes both the label and whether the - // widget shows at all, and switches no layout, so it raises no - // activelayout of its own. + // The event names the keyboard that switched ahead of the layout it moved + // to, and that is the keyboard being typed on whatever holds the main flag. + if (name === "activelayout") { + const named = KeyboardLayoutModel.eventKeyboardName(event) + if (named) root.typedKeyboardName = named + } + + // A reload that adds a layout to kb_layout decides whether the widget + // shows at all, and leaves every keyboard on the layout it was already + // reading, so it raises no activelayout to notice it by. if (name.indexOf("activelayout") !== -1 || name === "configreloaded") root.refresh() } } @@ -94,24 +116,35 @@ BarWidget { stdout: StdioCollector { waitForEnd: true onStreamFinished: { - let typed + let listed try { - typed = root.typedKeyboards(JSON.parse(text || "{}").keyboards ?? []) + listed = JSON.parse(text || "{}").keyboards } catch (e) { return } + // A query the watchdog killed reports nothing at all, and an empty + // string parses into the same shape a seat with no keyboards would. + // Tell them apart by the list itself, so only a reading that reached + // hyprctl gets to speak for the seat. + if (!Array.isArray(listed)) return + + const typed = root.typedKeyboards(listed) const kb = root.selectKeyboard(typed) if (!kb || !kb.active_keymap) { - // Keyboards are there but none of them answers to main, so the seat - // just changed shape under an input method holding the flag. Say so, - // rather than letting a count from before it changed settle the poll. - if (typed.length > 0) root.keyboardUnresolved = true + // Either the last keyboard has been unplugged, which the label has to + // stop describing and the click has to stop naming, or keyboards are + // there and none of them reports a keymap. Both leave the shape in + // doubt, so keep asking rather than letting a count from before it + // changed settle the poll. + root.keyboardUnresolved = true + if (typed.length === 0) { + root.layoutFull = "" + root.keyboardName = "" + } return } - // A query the watchdog killed reports nothing at all, so only a reading - // that named a keyboard gets to speak for the seat. root.keyboardUnresolved = false root.keyboardCount = typed.length root.keyboardName = String(kb.name || "") @@ -142,20 +175,25 @@ BarWidget { // A query that never returns would freeze the label until the shell restarts, // since a Process that is already running can't be re-run. Give up on one that - // overstays so the next refresh gets through. + // overstays so the next refresh gets through, and ask again: the reading it + // never delivered may have been the only one due on a settled seat, and + // nothing else would come back for it. Timer { id: stallTimer interval: 5000 - onTriggered: queryProc.running = false + onTriggered: { + queryProc.running = false + refreshTimer.restart() + } } - // Hyprland hands the seat's main flag to whichever keyboard was typed on last - // and announces nothing when it moves, so a seat holding more than one keyboard - // can only learn which one the label is describing by asking. Poll while there - // is that ambiguity, until a first reading lands so a query that failed at login - // still recovers, and while a reading has left the seat's shape in doubt. The - // one-keyboard install has none of those, and is left alone rather than spawning - // hyprctl forever for an answer that cannot change. + // Which keyboard on a crowded seat the label is describing can change without + // Hyprland announcing it, since a device arriving or leaving raises no event + // of its own, and that can only be learned by asking. Poll while there is that + // ambiguity, until a first reading lands so a query that failed at login still + // recovers, and while a reading has left the seat's shape in doubt. The + // one-keyboard install has none of those, and is left alone rather than + // spawning hyprctl forever for an answer that cannot change. Timer { interval: 10000 running: !root.keyboardName || root.keyboardUnresolved || root.keyboardCount > 1 diff --git a/shell/plugins/bar/widgets/KeyboardLayoutModel.js b/shell/plugins/bar/widgets/KeyboardLayoutModel.js index 444f9809..f46ee555 100644 --- a/shell/plugins/bar/widgets/KeyboardLayoutModel.js +++ b/shell/plugins/bar/widgets/KeyboardLayoutModel.js @@ -54,9 +54,72 @@ function shortLabel(description, briefs) { return label.substring(0, 3).toUpperCase() } +// Hyprland's activelayout event pairs the keyboard that switched with the layout +// it moved to. Quickshell cuts the event into that many fields, so a description +// carrying a comma of its own stays in one piece; a binding old enough to hand +// back only the raw string gets split by hand. The virtual keyboard fcitx5 binds +// to inject announces switches too, and names a keyboard nobody types on. +function eventKeyboardName(event) { + var parts + + try { + if (event && event.parse) parts = event.parse(2) + } catch (error) { + } + + if (!parts) parts = String(event && event.data ? event.data : "").split(",") + + var name = String(parts[0] || "") + return name.indexOf("hl-virtual-keyboard") === 0 ? "" : name +} + +// Hyprland reports more than keyboards as keyboards. fcitx5 binds a virtual one +// to inject through, which keeps the us layout the input method gave it, and the +// ACPI power button, lid switch and sleep key each arrive carrying the seat's +// layout list without anyone ever typing on them. Both answer to switchxkblayout +// and both can hold the main flag, so a widget that reads or switches whatever +// the seat hands it ends up describing a button. Leave them out and what remains +// is keyboards, which is what the rest of this file can then assume. +// +// Missing a name here costs the accuracy the seat had before, never a keyboard: +// anything unrecognised stays in the list. +var UNTYPED_KEYBOARDS = /^(hl-virtual-keyboard|power-button|sleep-button|lid-switch|video-bus)/ + +function isTypedKeyboard(name) { + return !UNTYPED_KEYBOARDS.test(String(name || "")) +} + +// Every keyboard on the seat carries the same layout list unless one was given +// its own, but only the one being typed on advances through it. So the +// furthest-advanced is the one worth reading, and a switch names the keyboard it +// moved, which settles a seat holding two real keyboards outright. +// +// The name is taken whenever a keyboard still answers to it, wherever that +// keyboard sits in the list. Comparing positions instead would read the wrong +// keyboard the moment one wrapped from the last layout back to the first, which +// is the ordinary way round a pair of them. Applying a layout to the whole seat +// names a keyboard too, but leaves every one of them on the same layout, so the +// label reads the same whichever of them the name settles on. +function selectKeyboard(typed, namedByEvent) { + var keyboards = typed || [] + + return keyboards.find(function (keyboard) { + return keyboard.name === namedByEvent + }) || keyboards.reduce(function (furthest, keyboard) { + return layoutIndex(keyboard) > layoutIndex(furthest) ? keyboard : furthest + }, keyboards[0]) +} + +function layoutIndex(keyboard) { + return (keyboard && keyboard.active_layout_index) || 0 +} + if (typeof module !== "undefined") { module.exports = { + eventKeyboardName: eventKeyboardName, + isTypedKeyboard: isTypedKeyboard, layoutBriefs: layoutBriefs, + selectKeyboard: selectKeyboard, shortLabel: shortLabel } } diff --git a/test/shell.d/keyboard-layout-test.sh b/test/shell.d/keyboard-layout-test.sh index fe9597f4..5158186f 100755 --- a/test/shell.d/keyboard-layout-test.sh +++ b/test/shell.d/keyboard-layout-test.sh @@ -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