Show input modes in the keyboard indicator
This commit is contained in:
1 parent
22514b59f2
commit
fcda55944a
8 files changed
+208
-7
No files matched your search
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+42
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user