swadhinbiswas Initial de-omarchy: Omarchy UI layer for Arch-based distros · 8/22/2026
9f393b2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
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() }
}
}