Baseline: omarchy.keyboard-layout as shipped

KeyboardLayout.qml and KeyboardLayoutModel.js exactly as they ship in
omarchy-dev 4.0.0.r1744.gf002044 (shell/plugins/bar/widgets/), alongside
the manifest `omarchy plugin clone` generates — upstream has no per-widget
manifest, the widgets share the shell's registry.

Kept on its own branch so `git diff stock..master` shows precisely what
this fork changes.
This commit is contained in:
2026-08-17 01:48:13 +01:00
commit 9c48c4c443
3 changed files with 366 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
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() }
}
}
+125
View File
@@ -0,0 +1,125 @@
// Label math for the keyboard layout widget, kept Qt-free so it can be unit
// tested under node (test/shell.d/keyboard-layout-test.sh).
// xkbcli list prints YAML, and every layout and variant block pairs a brief with
// the description hyprctl reports as the active keymap:
//
// - layout: 'us'
// variant: ''
// brief: 'en'
// description: English (US)
//
// The models and option groups it also prints carry no brief of their own, and
// a brief never carries past the block it was printed in, so neither reaches
// the table.
function layoutBriefs(text) {
var briefs = {}
var brief = ""
String(text || "").split("\n").forEach(function (line) {
if (/^\s*- /.test(line)) brief = ""
var field = line.match(/^ (brief|description): (.*)$/)
if (!field) return
if (field[1] === "brief") {
brief = field[2].replace(/^'|'$/g, "")
} else if (brief) {
briefs[field[2]] = brief
brief = ""
}
})
return briefs
}
// The brief is a short language code rather than a country one, which keeps the
// label sensible for the layouts named after a language: Esperanto reads EO and
// Arabic reads AR. It is the same code GNOME shows in its own indicator.
//
// Layouts missing from the table fall back to the first word of the description,
// which reads as ENG/POR but at least says something.
//
// Nearly every brief is a bare two-letter code, but a few tack a script onto it
// (Burmese (Zawgyi) is my-zwg) and the custom layout's is a word, so drop the
// script and cap the result at the same three characters the fallback gets.
// The widget sits between fixed neighbours on the bar and has no room to grow.
function shortLabel(description, briefs) {
if (!description) return ""
// A description like "constructor" reaches an inherited member rather than a
// brief, so take the lookup only when it hands back the string it promises.
var brief = (briefs || {})[description]
var label = typeof brief === "string" && brief ? brief.split("-")[0] : description.split(/\s+/)[0]
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
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"id": "pastilhas.keyboard-layout",
"name": "My Keyboard layout",
"version": "1.0.0",
"author": "Omarchy",
"description": "Current xkb layout, click cycles",
"kinds": [
"bar-widget"
],
"entryPoints": {
"barWidget": "KeyboardLayout.qml"
},
"barWidget": {
"displayName": "My Keyboard layout",
"description": "Current xkb layout, click cycles",
"category": "Compositor",
"allowMultiple": false
},
"omarchy": {
"clonedFrom": "omarchy.keyboard-layout"
}
}