From 9c48c4c443c6d4dfcb42d23846db2c7ef8ae03a1 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 17 Aug 2026 01:48:13 +0100 Subject: [PATCH] Baseline: omarchy.keyboard-layout as shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- KeyboardLayout.qml | 218 +++++++++++++++++++++++++++++++++++++++++ KeyboardLayoutModel.js | 125 +++++++++++++++++++++++ manifest.json | 23 +++++ 3 files changed, 366 insertions(+) create mode 100644 KeyboardLayout.qml create mode 100644 KeyboardLayoutModel.js create mode 100644 manifest.json diff --git a/KeyboardLayout.qml b/KeyboardLayout.qml new file mode 100644 index 0000000..5457d88 --- /dev/null +++ b/KeyboardLayout.qml @@ -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() } + } +} diff --git a/KeyboardLayoutModel.js b/KeyboardLayoutModel.js new file mode 100644 index 0000000..f46ee55 --- /dev/null +++ b/KeyboardLayoutModel.js @@ -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 + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..71f663b --- /dev/null +++ b/manifest.json @@ -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" + } +}