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) <noreply@anthropic.com>
246 lines
9.7 KiB
QML
246 lines
9.7 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
import Quickshell.Io
|
|
import qs.Ui
|
|
import qs.Commons
|
|
import "KeyboardLayoutModel.js" as KeyboardLayoutModel
|
|
|
|
BarWidget {
|
|
id: root
|
|
moduleName: "omarchy.keyboard-layout"
|
|
|
|
|
|
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: ""
|
|
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
|
|
// 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: ({})
|
|
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs)
|
|
|
|
// A query already in flight was started before this event, so it may read the
|
|
// layout the switch replaced. Remember the request and re-run once it lands
|
|
// rather than dropping it; nothing else would correct the label afterwards.
|
|
property bool refreshPending: false
|
|
|
|
function refresh() {
|
|
if (queryProc.running) {
|
|
refreshPending = true
|
|
return
|
|
}
|
|
|
|
refreshPending = false
|
|
queryProc.running = true
|
|
}
|
|
|
|
// Keyboards someone can actually type on, which is not everything Hyprland
|
|
// calls a keyboard.
|
|
function typedKeyboards(keyboards) {
|
|
return keyboards.filter(k => KeyboardLayoutModel.isTypedKeyboard(k.name))
|
|
}
|
|
|
|
// 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 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.
|
|
//
|
|
// 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.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()
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
briefsProc.running = true
|
|
refresh()
|
|
}
|
|
|
|
Connections {
|
|
target: Hyprland
|
|
function onRawEvent(event) {
|
|
if (!event || !event.name) return
|
|
var name = String(event.name)
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: queryProc
|
|
command: ["hyprctl", "-j", "devices"]
|
|
onRunningChanged: {
|
|
if (running) {
|
|
stallTimer.restart()
|
|
return
|
|
}
|
|
|
|
stallTimer.stop()
|
|
if (root.refreshPending) root.refresh()
|
|
}
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
let listed
|
|
try {
|
|
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) {
|
|
// 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
|
|
}
|
|
|
|
root.keyboardUnresolved = false
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// The table only changes when xkb data is upgraded, so read it at startup and
|
|
// leave it alone. The bar is built per monitor, so this runs once per widget.
|
|
// The exotic rulesets cover layouts like trans (IPA) that ship in the same xkb
|
|
// package and set just as well, so load them or those labels lose their code.
|
|
Process {
|
|
id: briefsProc
|
|
command: ["xkbcli", "list", "--load-exotic"]
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: root.layoutBriefs = KeyboardLayoutModel.layoutBriefs(text)
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: refreshTimer
|
|
interval: 600
|
|
onTriggered: root.refresh()
|
|
}
|
|
|
|
// 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, 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
|
|
refreshTimer.restart()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
repeat: true
|
|
onTriggered: root.refresh()
|
|
}
|
|
|
|
visible: layoutLabel !== "" && multipleLayouts
|
|
implicitWidth: button.implicitWidth
|
|
implicitHeight: button.implicitHeight
|
|
|
|
WidgetButton {
|
|
id: button
|
|
anchors.fill: parent
|
|
bar: root.bar
|
|
text: root.layoutLabel
|
|
fontSize: Style.font.caption
|
|
horizontalMargin: 6
|
|
tooltipText: root.layoutFull
|
|
onPressed: function() { root.cycleLayout() }
|
|
}
|
|
}
|