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:
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user