Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
242627e
Add omarchy keyboard layout bar plugin
heyssh Jul 23, 2026
40228ae
Add Setup toggle to show/hide the keyboard layout bar icon
heyssh Jul 25, 2026
dc7c924
Merge branch 'quattro' into add/omarchy-keyboard-layout
heyssh Jul 25, 2026
3fd646b
Potential fix for pull request finding
heyssh Jul 25, 2026
8ac3a1e
Update omarchy-keyboard-layout
heyssh Jul 25, 2026
fb89c8f
Update Model.js
heyssh Jul 25, 2026
e8dbee1
Improve panel and model files and add more descriptive comments
heyssh Jul 25, 2026
fa9f789
Remove Both Shift preset, add keybinding to open panel when enabled
heyssh Jul 26, 2026
97d6ddb
fix(panel): Enter in add-language search now adds highlighted row, no…
heyssh Jul 26, 2026
422c956
Rename "Keyboard" label to "Keyboard layout"
heyssh Jul 26, 2026
1dd80e4
Update omarchy-menu.jsonc
heyssh Jul 26, 2026
3a4e2d6
Update keyboard layout binding
heyssh Jul 26, 2026
062ce31
Update "keyboard layout" label to "keyboard layout while enabled"
heyssh Jul 26, 2026
ea0a9ae
Update Panel.qml
heyssh Jul 26, 2026
b3c1d8b
Potential fix for pull request finding
heyssh Jul 26, 2026
01008bf
feat(osd): improve notification display for toggle actions
heyssh Jul 27, 2026
07746fb
default/hypr: sync input.lua with keyboard panel
heyssh Jul 28, 2026
29fe044
fix(keyboard-panel): allow Up from any switcher pill to reach languages
heyssh Jul 28, 2026
2354816
Merge branch 'quattro' into add/omarchy-keyboard-layout
heyssh Jul 28, 2026
92b66d9
fix(keyboard-panel): stop repeated Enter from adding extra languages
heyssh Jul 29, 2026
f7aa9df
improve keyboard panel
heyssh Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions bin/omarchy-keyboard-layout
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
#!/bin/bash

# omarchy:summary=Get, switch, or configure keyboard layouts
# omarchy:group=keyboard
# omarchy:args=[status|available|next|set|add|remove|switcher] [value]
# omarchy:examples=omarchy keyboard layout add de

set -e

STATE_DIR="$HOME/.local/state/omarchy/settings"
STATE_FILE="$STATE_DIR/keyboard.json"
XKB_LIST="/usr/share/X11/xkb/rules/base.lst"

usage() {
echo "Usage: omarchy-keyboard-layout [status|available|next|set <code>|add <code>|remove <code>|switcher <preset>|open|bar-icon <status|toggle|show|hide>]" >&2
exit 1
}

# Looks up the human-readable label xkb ships for a given layout code.
# This is the same file base.lst that `localectl list-x11-keymap-layouts` reads.
layout_label() {
local code=$1
awk -v code="$code" '
/^! layout/ { insection=1; next }
/^!/ { insection=0 }
insection && $1 == code {
line = $0
sub(/^[ \t]+/, "", line)
sub(/^[^ \t]+[ \t]+/, "", line)
print line
exit
}
' "$XKB_LIST" 2>/dev/null || true
}

# Every layout code + label xkb knows about, as a JSON array, sorted by label.
# This feeds the panel's "Add language" search list.
available_layouts_json() {
awk '
/^! layout/ { insection=1; next }
/^!/ { insection=0 }
insection && NF && $1 != "custom" {
rest = $0
sub(/^[ \t]+/, "", rest)
code = $1
sub(/^[^ \t]+[ \t]+/, "", rest)
printf "%s\t%s\n", code, rest
}
' "$XKB_LIST" 2>/dev/null | sort -t $'\t' -k2 | jq -R -s '
split("\n") | map(select(length > 0) | split("\t") | {code: .[0], label: .[1]})
' 2>/dev/null || echo "[]"
}

# Creates the state file on first run, seeded from the system's boot-time
# keyboard layout so a fresh install starts with something sensible instead
# of an empty list. Runs once; leaves an existing state file untouched.
ensure_state() {
mkdir -p "$STATE_DIR"
[[ -f $STATE_FILE ]] && return

local primary label
primary=$(awk -F= '/^XKBLAYOUT/{gsub(/"/, "", $2); print $2}' /etc/vconsole.conf 2>/dev/null || true)
primary=${primary:-us}
label=$(layout_label "$primary")

jq -n --arg code "$primary" --arg label "${label:-$primary}" \
'{layouts: [{code: $code, label: $label}], switcher: "alt_shift"}' > "$STATE_FILE"
}

# Picks the physical keyboard device to act on, ignoring virtual/software
# devices (input-method frameworks, on-screen keyboards, media buttons)
# that would otherwise be mistaken for a real keyboard.
main_keyboard() {
hyprctl devices -j 2>/dev/null | jq -r '
[.keyboards[] | select(.name | test("virtual|fcitx|ibus|video-bus|buttons"; "i") | not)] as $real
| (($real[] | select(.main == true) | .name) // ($real[0].name // empty))
' 2>/dev/null || true
}

# hyprctl reports the active layout as a human-readable description rather
# than the xkb code we store internally, so match it against the labels we
# already saved for the configured layouts rather than re-deriving a code
# from free text.
active_index() {
local device keymap
device=$(main_keyboard)
[[ -n $device ]] || { echo 0; return; }

keymap=$(hyprctl devices -j 2>/dev/null | jq -r --arg dev "$device" \
'.keyboards[] | select(.name == $dev) | .active_keymap // ""' 2>/dev/null || true)

jq -r --arg keymap "$keymap" '
([.layouts[].label] | map(. as $l | (($keymap|ascii_downcase) | contains($l|ascii_downcase)))
| index(true)) // 0
' "$STATE_FILE"
}

# The full state, plus which configured layout is currently active and
# whether the bar icon is visible -- the single source of truth the panel
# and CLI both read after every action.
status_json() {
local idx
idx=$(active_index)
jq --argjson idx "$idx" '. + {active: (.layouts[$idx].code // .layouts[0].code), show_bar_icon: (if .show_bar_icon == null then true else .show_bar_icon end)}' "$STATE_FILE"
}

# Applies config changes (new layout, new switcher preset) without a full
# Hyprland restart. Failure is non-fatal -- the state file is already
# correct, the running session just won't reflect it until the next
# natural reload.
reload_hyprland() {
hyprctl reload >/dev/null 2>&1 || true
}

# Applies a jq filter to the state file and writes the result back
# atomically (write to a temp file, then rename over the original) so a
# crash or a concurrent read never sees a half-written file.
write_state() {
local filter=$1; shift
local tmp
(
flock -x 200
tmp=$(mktemp)
jq "$@" "$filter" "$STATE_FILE" > "$tmp"
mv "$tmp" "$STATE_FILE"
) 200>"$STATE_FILE.lock"
}

# Feedback for bar-icon show/hide/toggle (not the plain `status` query),
# since there's no checkmark in the menu to reflect state instead. Uses the
# same on-screen-display helper as the other hardware toggles (touchpad,
# etc.) instead of notify-send, for a consistent look/timing.
notify_bar_icon() {
if [[ $1 == "true" ]]; then
omarchy-osd -i keyboard -m "Keyboard layout enabled"
else
omarchy-osd -i keyboard -m "Keyboard layout disabled"
fi
}

# Entry point: dispatches to the requested action. Every branch ends by
# printing the current status_json so callers (the panel, the CLI, or a
# person in a terminal) always get a fresh, consistent snapshot back.
action=${1:-status}
ensure_state

case "$action" in
status)
status_json
;;

available)
available_layouts_json
;;

next)
device=$(main_keyboard)
[[ -n $device ]] || { echo "No keyboard device found" >&2; exit 1; }
hyprctl switchxkblayout "$device" next >/dev/null
status_json
;;

set)
code=${2:-}
[[ -n $code ]] || usage
device=$(main_keyboard)
[[ -n $device ]] || { echo "No keyboard device found" >&2; exit 1; }
index=$(jq -r --arg code "$code" '[.layouts[].code] | index($code) // empty' "$STATE_FILE")
[[ -n $index ]] || { echo "Layout '$code' is not configured" >&2; exit 1; }
hyprctl switchxkblayout "$device" "$index" >/dev/null
status_json
;;

add)
code=${2:-}
[[ -n $code ]] || usage
already=$(jq -r --arg code "$code" '[.layouts[].code] | index($code) // empty' "$STATE_FILE")
if [[ -z $already ]]; then
label=$(layout_label "$code")
[[ -n $label ]] || { echo "Unknown layout '$code'" >&2; exit 1; }
write_state '.layouts += [{code: $code, label: $label}]' --arg code "$code" --arg label "$label"
reload_hyprland
fi
status_json
;;

remove)
code=${2:-}
[[ -n $code ]] || usage
exists=$(jq -r --arg code "$code" '[.layouts[].code] | index($code) // empty' "$STATE_FILE")
[[ -n $exists ]] || { echo "Layout '$code' is not configured" >&2; exit 1; }
count=$(jq '.layouts | length' "$STATE_FILE")
primary_code=$(jq -r '.layouts[0].code // empty' "$STATE_FILE")
if [[ $count -gt 1 && $code != "$primary_code" ]]; then
write_state '.layouts |= map(select(.code != $code))' --arg code "$code"
reload_hyprland
fi
status_json
;;

switcher)
preset=${2:-}
case "$preset" in
alt_shift|ctrl_shift|right_alt) ;;
*) usage ;;
esac
write_state '.switcher = $s' --arg s "$preset"
reload_hyprland
status_json
;;

# Meant to be called directly by the keyboard-shortcut keybinding, e.g.:
# o.bind("SUPER + CTRL + K", "Keyboard", "omarchy-keyboard-layout open")
# Deliberately a single plain subcommand with no shell operators (&&, ;)
# or quoting -- o.bind's dispatcher goes through hl.dsp.exec_cmd, which
# adds its own layer of escaping, and chained/quoted commands passed
# through that layer got mangled in practice. Checking show_bar_icon here
# keeps the visibility check entirely inside our own script instead.
open)
visible=$(jq -r 'if .show_bar_icon == null then true else .show_bar_icon end' "$STATE_FILE")
if [[ $visible == "true" ]]; then
omarchy-shell shell toggle omarchy.keyboard
fi
;;

# `status` is a plain exit-code query (0 = shown, 1 = hidden) meant for
# use as a `checked:` guard in omarchy-menu.jsonc (Trigger > Toggle >
# Keyboard Layout). toggle/show/hide are for the corresponding
# `action:`, or for manual use. The bar widget picks up the new value on
# its next `status` poll, so no Hyprland reload is needed here. A
# notification is fired on show/hide/toggle (not on the plain `status`
# query) since there's no longer a checkmark in the menu to reflect state.
bar-icon)
sub=${2:-status}
case "$sub" in
status)
visible=$(jq -r 'if .show_bar_icon == null then true else .show_bar_icon end' "$STATE_FILE")
[[ $visible == "true" ]]
;;
toggle)
write_state '.show_bar_icon = ((if .show_bar_icon == null then true else .show_bar_icon end) | not)'
notify_bar_icon "$(jq -r '.show_bar_icon' "$STATE_FILE")"
status_json
;;
show)
write_state '.show_bar_icon = true'
notify_bar_icon true
status_json
;;
hide)
write_state '.show_bar_icon = false'
notify_bar_icon false
status_json
;;
*)
usage
;;
esac
;;

*)
usage
;;
esac

1 change: 1 addition & 0 deletions default/hypr/bindings/utilities.lua
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ o.bind("SUPER + CTRL + D", "Display", "omarchy-shell shell toggle omarchy.monito
o.bind("SUPER + CTRL + ALT + D", "Calendar", "omarchy-shell shell toggle omarchy.clock")
o.bind("SUPER + CTRL + W", "Network", "omarchy-shell shell toggle omarchy.network")
o.bind("SUPER + CTRL + P", "Power", "omarchy-shell shell toggle omarchy.power")
o.bind("SUPER + CTRL + K", "Keyboard layout while enabled", "omarchy-keyboard-layout open") -- When the keyboard layout indicator is enabled in the bar
o.bind("SUPER + CTRL + T", "Activity", { tui = "btop" })

o.bind("SUPER + CTRL + Z", "Zoom in", function()
Expand Down
78 changes: 59 additions & 19 deletions default/hypr/input.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
-- https://wiki.hypr.land/Configuring/Basics/Variables/#input

-- Reads the boot-time console/keyboard settings written by the installer,
-- used only as a fallback for a system that hasn't configured a layout
-- through the panel yet.
local function read_vconsole()
local values = {}
local file = io.open("/etc/vconsole.conf", "r")
Expand All @@ -21,32 +24,68 @@ local function read_vconsole()
return values
end

-- Layouts that can't type Latin letters. Keep in sync with the list in
-- etc/mkinitcpio.conf.d/omarchy_hooks.conf.
local non_latin_layouts =
" af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua "
-- The keyboard panel (bin/omarchy-keyboard-layout) owns this file: it's the
-- list of layouts the user picked via "Add language" plus their chosen
-- switch shortcut. jq is already a hard dependency of the Omarchy CLI
-- scripts, so shelling out to it here is simpler and less error-prone than
-- hand-rolling JSON parsing in Lua.
local STATE_FILE = os.getenv("HOME") .. "/.local/state/omarchy/settings/keyboard.json"

local vconsole = read_vconsole()
-- Runs a shell command and returns its trimmed stdout, or an empty string
-- if the command couldn't even be started.
local function popen_trim(cmd)
local handle = io.popen(cmd)
if not handle then
return ""
end
local result = handle:read("*a") or ""
handle:close()
return (result:gsub("^%s+", ""):gsub("%s+$", ""))
end

local kb_layout = vconsole.XKBLAYOUT or "us"
local kb_variant = vconsole.XKBVARIANT or ""
local kb_options = "compose:caps,shift:both_capslock"

-- Hyprland resolves keybindings against the first entry in kb_layout, not the
-- layout that's currently active, so Omarchy's Latin-keysym bindings (SUPER + W
-- and friends) only fire when a Latin layout leads. Installing with a non-Latin
-- one would otherwise leave the desktop unusable.
if non_latin_layouts:find(" " .. kb_layout:match("^[^,]*") .. " ", 1, true) then
kb_layout = "us," .. kb_layout
kb_variant = "," .. kb_variant
-- Reach the original layout with Left Alt + Right Alt.
kb_options = kb_options .. ",grp:alts_toggle"
-- Reads one field out of the keyboard state file via jq. Falls back
-- cleanly if the file is missing, unreadable, or the field isn't set --
-- Hyprland should never fail to start just because this file is stale
-- or hasn't been created yet.
local function state_field(jq_filter, fallback)
local cmd = string.format("jq -r %s %q 2>/dev/null", jq_filter, STATE_FILE)
local value = popen_trim(cmd)
if value == "" or value == "null" then
return fallback
end
return value
end

local vconsole = read_vconsole()

local kb_layout = state_field("'[.layouts[].code] | join(\",\")'", vconsole.XKBLAYOUT or "us")
local switcher = state_field("'.switcher // \"alt_shift\"'", "alt_shift")

-- The three switch-shortcut presets offered by the keyboard panel's pill
-- row, mapped to the matching xkb grp option.
local SWITCHER_OPTIONS = {
alt_shift = "grp:alt_shift_toggle",
ctrl_shift = "grp:ctrl_shift_toggle",
right_alt = "grp:toggle",
}

local grp_option = SWITCHER_OPTIONS[switcher] or SWITCHER_OPTIONS.alt_shift

Comment on lines +27 to +73
-- Compose is bound to the Menu key, not Caps Lock. "compose:caps" plus
-- "shift:both_capslock" was tried here -- on real hardware it broke every
-- switcher preset outright (Alt+Shift, Ctrl+Shift, Right Alt, Right Shift
-- all stopped switching), not just single-Shift capitalization as originally
-- suspected. Whatever is going on, that combination isn't safe to ship
-- alongside grp switching, so it's reverted to the working baseline.
local kb_options = "compose:menu," .. grp_option

-- Applies everything computed above as Hyprland's live input configuration.
hl.config({
input = {
kb_layout = kb_layout,
kb_variant = kb_variant,
-- Falls back to "" (xkb's default variant) if the system's vconsole.conf
-- never set one, or the user hasn't customized their keyboard variant.
kb_variant = vconsole.XKBVARIANT or "",
kb_model = "",
kb_options = kb_options,
kb_rules = "",
Expand All @@ -73,3 +112,4 @@ hl.config({
-- Scroll nicely in the terminal.
o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 })
o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 })

2 changes: 2 additions & 0 deletions default/omarchy/omarchy-menu.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"trigger.toggle.window-gaps": {"icon":"","label":"Window Gaps","action":"omarchy-hyprland-window-gaps-toggle"},
"trigger.toggle.one-window-ratio": {"icon":"","label":"1-Window Ratio","action":"omarchy-hyprland-window-single-square-aspect-toggle"},


// Style
"style.theme": {"icon":"󰸌","label":"Theme","aliases":["theme","themes"],"action":"theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\""},
"style.unlock": {"icon":"󰟵","label":"Unlock","aliases":["unlock"],"action":"unlock=$(omarchy-plymouth-switcher); if [[ $unlock == default ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-plymouth-reset; elif [[ -n $unlock ]]; then omarchy-launch-floating-terminal-with-presentation \"omarchy-plymouth-set-by-theme '$unlock'\"; fi"},
Expand Down Expand Up @@ -121,6 +122,7 @@
"setup.monitors": {"icon":"󰍹","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""},
"setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""},
"setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""},
"setup.keyboard-layout-icon": {"icon":"󰌌","label":"Keyboard Layout","action":"omarchy-keyboard-layout bar-icon toggle"},
"setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"},
"setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]},
"setup.default.browser": {"icon":"","label":"Browser"},
Expand Down
Loading