From 5c74f823001b03a4607429681c4e7dfc65b065c0 Mon Sep 17 00:00:00 2001 From: markbus-ai <58405544+markbus-ai@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:28:44 -0300 Subject: [PATCH] Ask for the keyboard layout only when the answer can change (#6727) The widget polled hyprctl every 10 seconds per monitor, including on the single-layout install where it never shows. Keep the poll only where its answer can change - a seat with more than one keyboard, where Hyprland moves the main flag with no event to announce it - and stop it entirely once a one-keyboard seat has been read. Also coalesce a refresh that arrives mid-query instead of dropping it, time out a query that never returns rather than letting it hold the guard shut for good, and re-read the layout on configreloaded. Co-Authored-By: markbus-ai --- shell/plugins/bar/widgets/KeyboardLayout.qml | 79 +++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 0fc07191..4a9dbc1b 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -13,6 +13,10 @@ BarWidget { property string layoutFull: "" property string keyboardName: "" + // Real keyboards on the seat, 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. @@ -22,16 +26,31 @@ BarWidget { 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) queryProc.running = true + if (queryProc.running) { + refreshPending = true + return + } + + refreshPending = false + queryProc.running = true } // fcitx5 binds a virtual keyboard and takes over the seat's main flag whenever // it injects, but that keyboard keeps the us layout the input method gave it. - // Stay on the keyboard we last read until a real one is active again, so the - // label keeps tracking that keyboard's layout rather than freezing. - function selectKeyboard(keyboards) { - const typed = keyboards.filter(k => !String(k.name).startsWith("hl-virtual-keyboard")) + // Leave those out, so the label keeps tracking a keyboard someone types on. + function typedKeyboards(keyboards) { + return keyboards.filter(k => !String(k.name).startsWith("hl-virtual-keyboard")) + } + + // Stay on the keyboard we last read until a real one is active again, so an + // input method holding the main flag freezes nothing. + function selectKeyboard(typed) { return typed.find(k => k.main) ?? typed.find(k => k.name === root.keyboardName) } @@ -52,25 +71,49 @@ BarWidget { target: Hyprland function onRawEvent(event) { if (!event || !event.name) return - if (String(event.name).indexOf("activelayout") !== -1) root.refresh() + var name = String(event.name) + // A reload that edits kb_layout changes both the label and whether the + // widget shows at all, and switches no layout, so it raises no + // activelayout of its own. + 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 kb + let typed try { - kb = root.selectKeyboard(JSON.parse(text || "{}").keyboards ?? []) + typed = root.typedKeyboards(JSON.parse(text || "{}").keyboards ?? []) } catch (e) { return } - if (!kb || !kb.active_keymap) return + const kb = root.selectKeyboard(typed) + if (!kb || !kb.active_keymap) { + // Keyboards are there but none of them answers to main, so the seat + // just changed shape under an input method holding the flag. Say so, + // rather than letting a count from before it changed settle the poll. + if (typed.length > 0) root.keyboardUnresolved = true + return + } + // A query the watchdog killed reports nothing at all, so only a reading + // that named a keyboard gets to speak for the seat. + 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 @@ -97,9 +140,25 @@ BarWidget { 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. + Timer { + id: stallTimer + interval: 5000 + onTriggered: queryProc.running = false + } + + // Hyprland hands the seat's main flag to whichever keyboard was typed on last + // and announces nothing when it moves, so a seat holding more than one keyboard + // can only learn which one the label is describing 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: true + running: !root.keyboardName || root.keyboardUnresolved || root.keyboardCount > 1 repeat: true onTriggered: root.refresh() }