Show input modes in the keyboard indicator

This commit is contained in:
David Heinemeier Hansson committed 2026-10-07 16:57:03 +02:00
1 parent 22514b59f2
commit fcda55944a
8 files changed
+208 -7

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
"""Live Fcitx state for the existing keyboard layout widget."""
import json
import sys
from gi.repository import Gio, GLib
BUS = "org.fcitx.Fcitx5"
INTERFACE = "org.fcitx.Fcitx.Controller1"
def call(bus, method, args=None):
# Reading the bar must neither activate a stopped service nor wait indefinitely.
return bus.call_sync(BUS, "/controller", INTERFACE, method, args, None,
Gio.DBusCallFlags.NO_AUTO_START, 1000, None).unpack()
def snapshot(bus):
group = call(bus, "CurrentInputMethodGroup")[0]
methods = list(dict.fromkeys(item[0] for item in
call(bus, "InputMethodGroupInfo", GLib.Variant("(s)", (group,)))[1]))
info = call(bus, "CurrentInputMethodInfo")
return {"methods": methods, "current": info[0], "name": info[1], "label": info[4], "language": info[5]}
def cycle(bus):
state = snapshot(bus)
methods = state["methods"]
if len(methods) > 1 and state["current"] in methods:
following = methods[(methods.index(state["current"]) + 1) % len(methods)]
call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,)))
def watch(bus):
previous = None
def refresh():
nonlocal previous
try:
state = snapshot(bus)
except GLib.Error:
state = {"methods": [], "current": "", "name": "", "language": ""}
line = json.dumps(state, ensure_ascii=False)
if line != previous:
print(line, flush=True)
previous = line
return GLib.SOURCE_CONTINUE
# Fcitx's controller has no current-method-changed signal. Keep one bus
# connection open instead of spawning a command on every poll. Polling also
# follows per-application input contexts and recovers after service restarts.
refresh()
GLib.timeout_add(500, refresh)
GLib.MainLoop().run()
if __name__ == "__main__":
try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
if sys.argv[1:] == ["cycle"]:
cycle(bus)
else:
watch(bus)
except GLib.Error as error:
raise SystemExit(str(error))
+5
View File
@@ -3,3 +3,8 @@
omarchy-notification-send -u critical -g  "Learn Keybindings" \
$'Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.' \
--exec omarchy-menu-keybindings
if [[ $(omarchy-input-method status | jq -r .installed_method) == "mozc" ]]; then
omarchy-notification-send -u critical "Japanese input is ready" \
$'Click the keyboard indicator in the bar to switch input.\nType nihongo, then press Space twice for character choices.'
fi
+2
View File
@@ -63,6 +63,8 @@ Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional,
Typing starts in Latin mode. Press `Ctrl + Shift + Space` to switch input on or off. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key.
The keyboard indicator in the menu bar appears for non-English keyboards and whenever you have multiple layouts or input modes. Click it to switch input languages; Japanese displays あ for hiragana. With only one input mode, clicking opens input language setup. For Japanese character choices, switch to Japanese, type a word such as `nihongo`, then press Space twice. Press Enter to accept your choice. If you also have multiple keyboard layouts, right-click the indicator to cycle those.
To add another language later, use _Setup > Input Language_ or run `omarchy setup input` (or `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`). Your existing input methods and custom switching keys are preserved. For alternative engine layouts or keys, install `fcitx5-configtool` with `omarchy pkg add fcitx5-configtool` and launch it from a terminal. Selecting an input language does not change the desktop language.
On keyboards without dedicated input keys, use `Ctrl + Shift + Space`. This input shortcut takes precedence over app shortcuts such as code completion; you can change it in `fcitx5-configtool`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session.
@@ -4,7 +4,7 @@
"name": "Keyboard layout",
"version": "1.0.0",
"author": "Omarchy",
"description": "Current xkb layout, click cycles",
"description": "Current keyboard layout and input language, click cycles",
"kinds": [
"bar-widget"
],
@@ -13,7 +13,7 @@
},
"barWidget": {
"displayName": "Keyboard layout",
"description": "Current xkb layout, click cycles",
"description": "Current keyboard layout and input language, click cycles",
"category": "Compositor",
"allowMultiple": false
},
+49 -4
View File
@@ -36,6 +36,9 @@ BarWidget {
// xkb's own table rather than maintained by hand.
property var layoutBriefs: ({})
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs)
property var inputState: ({})
readonly property bool multipleInputs: (inputState.methods || []).length > 1
readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel)
// 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
@@ -228,7 +231,40 @@ BarWidget {
onTriggered: root.refresh()
}
visible: layoutLabel !== "" && multipleLayouts
Process {
id: inputWatcher
command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"]
running: true
stdout: SplitParser {
onRead: data => {
try { root.inputState = JSON.parse(data) } catch (e) {}
}
}
onExited: {
root.inputState = ({})
inputRestart.restart()
}
}
Timer {
id: inputRestart
interval: 5000
onTriggered: inputWatcher.running = true
}
Process {
id: inputCycle
property int pending: 0
command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py", "cycle"]
onExited: {
if (pending > 0) {
pending--
running = true
}
}
}
visible: inputLabel !== "" && KeyboardLayoutModel.showIndicator(layoutLabel, multipleLayouts, multipleInputs)
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
@@ -236,10 +272,19 @@ BarWidget {
id: button
anchors.fill: parent
bar: root.bar
text: root.layoutLabel
text: root.inputLabel
fontSize: Style.font.caption
horizontalMargin: 6
tooltipText: root.layoutFull
onPressed: function() { root.cycleLayout() }
tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts)
onPressed: function(button) {
if (root.multipleInputs && button === Qt.LeftButton) {
if (inputCycle.running) inputCycle.pending++
else inputCycle.running = true
} else if (root.multipleLayouts) {
root.cycleLayout()
} else if (!root.multipleInputs && root.bar) {
root.bar.run("omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'")
}
}
}
}
@@ -110,6 +110,28 @@ function selectKeyboard(typed, namedByEvent) {
}, keyboards[0])
}
function inputLabel(state, fallback) {
if (!state || !state.current || state.current.indexOf("keyboard-") === 0) return fallback
if (state.label) return String(state.label).substring(0, 3)
var labels = { mozc: "あ", hangul: "한", pinyin: "拼", chewing: "注" }
return labels[state.current] || shortLabel(state.language || state.name, {})
}
function inputTooltip(state, layout, multipleLayouts) {
if (!state || !state.methods || state.methods.length < 2)
return layout + (multipleLayouts ? "" : "\nClick to set up an input language")
var text = state.name + "\nClick to switch input language"
if (state.methods.indexOf("mozc") !== -1)
text += "\nJapanese: type a word, then Space twice for character choices"
if (multipleLayouts) text += "\nRight-click to switch keyboard layout"
return text
}
function showIndicator(layoutLabel, multipleLayouts, multipleInputs) {
return multipleInputs || (layoutLabel !== "" &&
(multipleLayouts || (layoutLabel !== "EN" && layoutLabel !== "ENG")))
}
function layoutIndex(keyboard) {
return (keyboard && keyboard.active_layout_index) || 0
}
@@ -120,6 +142,9 @@ if (typeof module !== "undefined") {
isTypedKeyboard: isTypedKeyboard,
layoutBriefs: layoutBriefs,
selectKeyboard: selectKeyboard,
shortLabel: shortLabel
shortLabel: shortLabel,
inputLabel: inputLabel,
inputTooltip: inputTooltip,
showIndicator: showIndicator
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
OMARCHY_PATH="$ROOT" /usr/bin/python - <<'PY'
import importlib.util
import os
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("indicator", os.environ["OMARCHY_PATH"] + "/default/input-methods/indicator.py")
indicator = importlib.util.module_from_spec(spec)
spec.loader.exec_module(indicator)
with patch.object(indicator, "call", side_effect=[
("Default",),
("us", [("keyboard-us", ""), ("mozc", ""), ("mozc", "")]),
("mozc", "Mozc", "", "fcitx_mozc", "あ", "ja", "mozc", True, "", {}),
]):
state = indicator.snapshot(None)
assert state == {"methods": ["keyboard-us", "mozc"], "current": "mozc", "name": "Mozc", "label": "あ", "language": "ja"}
print("ok - live state deduplicates modes and reads language independently of the engine label")
def check_cycle(current, methods, expected):
state = {"current": current, "methods": methods}
with patch.object(indicator, "snapshot", return_value=state), patch.object(indicator, "call") as call:
indicator.cycle(None)
if expected is None:
call.assert_not_called()
else:
assert call.call_args.args[1] == "SetCurrentIM"
assert call.call_args.args[2].unpack() == (expected,)
check_cycle("keyboard-us", ["keyboard-us", "mozc"], "mozc")
check_cycle("mozc", ["keyboard-us", "mozc"], "keyboard-us")
check_cycle("mozc", ["keyboard-us", "mozc", "hangul"], "hangul")
check_cycle("hangul", ["keyboard-us", "mozc", "hangul"], "keyboard-us")
check_cycle("keyboard-us", ["keyboard-us"], None)
check_cycle("removed", ["keyboard-us", "mozc"], None)
check_cycle("", [], None)
print("ok - input cycling covers Latin, multiple engines, single modes, and stale contexts")
PY
+16
View File
@@ -7,6 +7,22 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const model = requireFromRoot('shell/plugins/bar/widgets/KeyboardLayoutModel.js')
const japanese = { methods: ['keyboard-us', 'mozc'], current: 'mozc', name: 'Mozc', language: 'ja' }
assertEqual(model.inputLabel(japanese, 'EN'), 'あ', 'Japanese input has a distinct active label')
assertEqual(model.inputLabel({ ...japanese, label: 'ア' }, 'EN'), 'ア', 'the live engine mode takes precedence over its default glyph')
assertEqual(model.inputLabel({ ...japanese, current: 'keyboard-us' }, 'EN'), 'EN', 'Latin input retains the keyboard label')
assertEqual(model.inputLabel({}, 'EN'), 'EN', 'a stopped input service retains the keyboard label')
assertEqual(model.inputLabel({ current: 'hangul' }, 'EN'), '한', 'Korean input has a distinct label')
assertEqual(model.inputLabel({ current: 'custom', language: 'vi' }, 'EN'), 'VI', 'other engines use their reported language')
assertEqual(model.inputTooltip(japanese, 'English (US)', false).includes('Space twice'), true, 'Japanese explains character choices')
assertEqual(model.inputTooltip(japanese, 'English (US)', true).includes('Right-click'), true, 'combined input and layout switching stays discoverable')
assertEqual(model.inputTooltip({ methods: ['keyboard-fr'] }, 'French', false), 'French\nClick to set up an input language', 'one foreign mode offers input setup')
assertEqual(model.showIndicator('EN', false, false), false, 'English alone stays hidden')
assertEqual(model.showIndicator('ENG', false, false), false, 'English before the brief table loads stays hidden')
assertEqual(model.showIndicator('FR', false, false), true, 'a single non-English layout stays available')
assertEqual(model.showIndicator('EN', false, true), true, 'Latin with another input mode stays available')
assertEqual(model.showIndicator('EN', true, false), true, 'multiple keyboard layouts stay available')
// 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.