diff --git a/shell/plugins/bar/widgets/KeyboardLayout.manifest.json b/shell/plugins/bar/widgets/KeyboardLayout.manifest.json index 35c12691..cc5ad783 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.manifest.json +++ b/shell/plugins/bar/widgets/KeyboardLayout.manifest.json @@ -16,5 +16,13 @@ "description": "Current xkb layout, click cycles", "category": "Compositor", "allowMultiple": false + }, + "omarchy": { + "clonePaths": [ + { + "source": "KeyboardLayoutModel.js", + "target": "KeyboardLayoutModel.js" + } + ] } } diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 67fba33b..0fc07191 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -4,19 +4,23 @@ 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 layoutLabel: "" property string layoutFull: "" property string keyboardName: "" // 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) function refresh() { if (!queryProc.running) queryProc.running = true @@ -39,7 +43,10 @@ BarWidget { refreshTimer.restart() } - Component.onCompleted: refresh() + Component.onCompleted: { + briefsProc.running = true + refresh() + } Connections { target: Hyprland @@ -67,11 +74,23 @@ BarWidget { root.keyboardName = String(kb.name || "") root.multipleLayouts = kb.layout === undefined || String(kb.layout).indexOf(",") !== -1 root.layoutFull = kb.active_keymap - root.layoutLabel = kb.active_keymap.split(/\s+/)[0].substring(0, 3).toUpperCase() } } } + // 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 diff --git a/shell/plugins/bar/widgets/KeyboardLayoutModel.js b/shell/plugins/bar/widgets/KeyboardLayoutModel.js new file mode 100644 index 00000000..444f9809 --- /dev/null +++ b/shell/plugins/bar/widgets/KeyboardLayoutModel.js @@ -0,0 +1,62 @@ +// 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() +} + +if (typeof module !== "undefined") { + module.exports = { + layoutBriefs: layoutBriefs, + shortLabel: shortLabel + } +} diff --git a/test/shell.d/keyboard-layout-test.sh b/test/shell.d/keyboard-layout-test.sh new file mode 100755 index 00000000..fe9597f4 --- /dev/null +++ b/test/shell.d/keyboard-layout-test.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const model = requireFromRoot('shell/plugins/bar/widgets/KeyboardLayoutModel.js') + +// Trimmed from xkbcli list, keeping the format of every section it prints, +// including the options nested under an option group: those quote their +// description and print an empty brief, one indent deeper than a layout's. +const listing = [ + 'models:', + '- name: pc105', + ' vendor: Generic', + ' description: Generic 105-key PC', + 'layouts:', + "- layout: 'us'", + " variant: ''", + " brief: 'en'", + ' description: English (US)', + " iso639: ['eng']", + " iso3166: ['USA']", + "- layout: 'us'", + " variant: 'intl'", + " brief: 'en'", + ' description: English (US, intl., with dead keys)', + "- layout: 'br'", + " variant: ''", + " brief: 'pt'", + ' description: Portuguese (Brazil)', + "- layout: 'epo'", + " variant: ''", + " brief: 'eo'", + ' description: Esperanto', + "- layout: 'latam'", + " variant: ''", + " brief: 'es'", + ' description: Spanish (Latin American)', + "- layout: 'mm'", + " variant: 'zawgyi'", + " brief: 'my-zwg'", + ' description: Burmese (Zawgyi)', + 'option_groups:', + "- name: 'grp'", + ' description: Switching to another layout', + ' allows_multiple: true', + ' options:', + " - name: 'grp:switch'", + " brief: ''", + " description: 'Right Alt (while pressed)'", + ' layout-specific: false', +].join('\n') + +const briefs = model.layoutBriefs(listing) + +assertEqual(briefs['English (US)'], 'en', 'the table reads a layout brief') +assertEqual(briefs['English (US, intl., with dead keys)'], 'en', 'the table reads a variant brief') +assertEqual(briefs['Generic 105-key PC'], undefined, 'the table skips keyboard models') +assertEqual(briefs['Switching to another layout'], undefined, 'the table skips option groups') +assertEqual(briefs["'Right Alt (while pressed)'"], undefined, 'the table skips the options under a group') +assertEqual(Object.keys(briefs).length, 6, 'the table holds nothing but the layouts') + +assertEqual(model.shortLabel('English (US)', briefs), 'EN', 'the label is the language, not the country') +assertEqual(model.shortLabel('Portuguese (Brazil)', briefs), 'PT', 'a country variant keeps its language') +assertEqual(model.shortLabel('Esperanto', briefs), 'EO', 'a layout without a country still gets a code') +assertEqual(model.shortLabel('Spanish (Latin American)', briefs), 'ES', 'a layout spanning countries still gets a code') +assertEqual(model.shortLabel('Burmese (Zawgyi)', briefs), 'MY', 'a brief carrying a script drops it') +assertEqual(model.shortLabel('A user-defined custom Layout', { 'A user-defined custom Layout': 'custom' }), 'CUS', 'a brief that is a word is cut to size') + +// A brief pairs with the description printed under it, so a block that prints +// one without the other must not hand its code to the block that follows. +const orphaned = model.layoutBriefs([ + 'layouts:', + "- layout: 'us'", + " brief: 'en'", + "- layout: 'gr'", + ' description: Greek', +].join('\n')) + +assertEqual(orphaned['Greek'], undefined, 'a brief stops at the end of its block') + +assertEqual(model.shortLabel('Elvish (Tengwar)', briefs), 'ELV', 'an unlisted layout falls back to its description') +assertEqual(model.shortLabel('English (US)', {}), 'ENG', 'the label survives an empty table') +assertEqual(model.shortLabel('constructor', {}), 'CON', 'a description naming a built-in still falls back') +assertEqual(model.shortLabel('', briefs), '', 'no keymap means no label') +JS