From 6ee7479879fa496e21c6e54b8e643a8d77d9ab16 Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:14:25 +0200 Subject: [PATCH] Stop the layout widget switching a device nobody types on Clicking the keyboard layout widget switches one device, chosen by filtering the seat through UNTYPED_KEYBOARDS and then taking whichever survivor sits furthest through the layout list. That filter has to recognise every non-keyboard by name, and a laptop registers far more as a keyboard than it lists. On a Dell XPS 14, Hyprland reports ten keyboards and one of them is a keyboard. The filter catches three of the other nine, leaving vendor hotkey blocks (intel-hid-events, intel-hid-5-button-array, dell-privacy-driver, dell-wmi-hotkeys) and two HID endpoints ahead of at-translated-set-2-keyboard, which sorts last. Every click switches hid-sdw:...-consumer-control instead, so the label cycles convincingly while typing never changes. Device order is stable across polls, so it is deterministic rather than a race, and needs no pre-existing bad state. Switch every keyboard holding the same layout list instead, naming an absolute index. "next" advances each device from wherever it sits, so a seat that has already drifted apart stays drifted and merely inverts; one index converges it in a single click, and a seat in lockstep leaves the reading nothing to disagree about. Keyboards given their own kb_layout hold a different list and are left alone, since an index into this list would not mean the same layout to them. The evdev KEY bitmap would separate these cleanly - the real keyboard emits 167 keys, the pseudo-devices at most 19 - but hyprctl devices reports no capability information, so the switch is taken out from behind the name filter rather than the filter being lengthened. Co-Authored-By: Claude Opus 5 (1M context) --- shell/plugins/bar/widgets/KeyboardLayout.qml | 47 +++++++++++++++----- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 5457d88e..6c3e7206 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -27,6 +27,11 @@ BarWidget { // the widget ships on the bar and stays out of the way until there are two. // An older Hyprland that doesn't report the list keeps showing the label. property bool multipleLayouts: true + // Where the reading sits in the layout list, how long that list is, and every + // keyboard sharing it. A switch moves that set together, so it needs all three. + property int layoutIndex: 0 + property int layoutCount: 0 + property var syncNames: [] // Short language code per layout description ("English (US)": "en"), read from // xkb's own table rather than maintained by hand. property var layoutBriefs: ({}) @@ -63,17 +68,31 @@ BarWidget { } // switchxkblayout is a hyprctl command rather than a dispatcher, so it has to - // 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. + // be run rather than sent over the dispatch socket. + // + // Move every keyboard holding the same layout list, rather than the single one + // the last reading spoke for. Naming one device puts the whole switch behind + // UNTYPED_KEYBOARDS recognising every non-keyboard by name, and that list + // cannot keep up with what a seat carries: vendor hotkey blocks + // (intel-hid-events, dell-wmi-hotkeys), HID consumer controls, and Bluetooth + // AVRCP endpoints from a pair of headphones all arrive holding the seat's + // layout list, and they sort ahead of the keyboard being typed on. The click + // then advances a device nobody types on; that device is now the furthest + // along, so it wins the next reading too, and the label describes it while the + // real keyboard never moved. + // + // An absolute index rather than "next", because "next" advances each device + // from wherever it already sits: a seat that has drifted apart stays drifted + // and merely inverts. One index converges them in a single click, and a seat + // in lockstep is what leaves the reading nothing to disagree about afterwards. + // + // Keyboards given their own kb_layout hold a different list and are left out: + // an index into this list would not mean the same layout to them. function cycleLayout() { - if (!root.keyboardName || !root.bar) return - root.bar.run("hyprctl switchxkblayout " + Util.shellQuote(root.keyboardName) + " next") + if (!root.bar || root.layoutCount < 2 || root.syncNames.length === 0) return + const next = (root.layoutIndex + 1) % root.layoutCount + root.bar.run(root.syncNames.map(name => + "hyprctl switchxkblayout " + Util.shellQuote(name) + " " + next).join("; ")) refreshTimer.restart() } @@ -149,6 +168,14 @@ BarWidget { root.keyboardCount = typed.length root.keyboardName = String(kb.name || "") root.multipleLayouts = kb.layout === undefined || String(kb.layout).indexOf(",") !== -1 + root.layoutIndex = kb.active_layout_index || 0 + root.layoutCount = kb.layout === undefined ? 0 : String(kb.layout).split(",").length + // Buttons and virtual keyboards are included on purpose: they hold the + // same list, and leaving them behind is what lets a reading drift onto + // one of them later. + root.syncNames = listed.filter(k => String(k.layout) === String(kb.layout)) + .map(k => String(k.name || "")) + .filter(name => name !== "") root.layoutFull = kb.active_keymap } }