* 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>
219 lines
8.1 KiB
QML
219 lines
8.1 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
|
|
// 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. 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")
|
|
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.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() }
|
|
}
|
|
}
|