diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py new file mode 100644 index 00000000..95e022f4 --- /dev/null +++ b/default/input-methods/indicator.py @@ -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)) diff --git a/install/user/first-run/welcome.sh b/install/user/first-run/welcome.sh index 22e6629e..f345f32e 100644 --- a/install/user/first-run/welcome.sh +++ b/install/user/first-run/welcome.sh @@ -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 diff --git a/manual/34-keyboard-mouse-trackpad.md b/manual/34-keyboard-mouse-trackpad.md index 202f46ad..0dd32753 100644 --- a/manual/34-keyboard-mouse-trackpad.md +++ b/manual/34-keyboard-mouse-trackpad.md @@ -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. diff --git a/shell/plugins/bar/widgets/KeyboardLayout.manifest.json b/shell/plugins/bar/widgets/KeyboardLayout.manifest.json index cc5ad783..2b161dc6 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.manifest.json +++ b/shell/plugins/bar/widgets/KeyboardLayout.manifest.json @@ -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 }, diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 6c3e7206..82d77c46 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -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'") + } + } } } diff --git a/shell/plugins/bar/widgets/KeyboardLayoutModel.js b/shell/plugins/bar/widgets/KeyboardLayoutModel.js index f46ee555..69ece973 100644 --- a/shell/plugins/bar/widgets/KeyboardLayoutModel.js +++ b/shell/plugins/bar/widgets/KeyboardLayoutModel.js @@ -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 } } diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh new file mode 100755 index 00000000..91c8e175 --- /dev/null +++ b/test/shell.d/input-indicator-test.sh @@ -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 diff --git a/test/shell.d/keyboard-layout-test.sh b/test/shell.d/keyboard-layout-test.sh index 5158186f..a2181b1b 100755 --- a/test/shell.d/keyboard-layout-test.sh +++ b/test/shell.d/keyboard-layout-test.sh @@ -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.