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