Skip to content

Add keyboard layout management to the bar picker - #11681

Open
MadMatt341 wants to merge 2 commits into
omacom:quattrofrom
MadMatt341:codex/keyboard-layout-picker
Open

MadMatt341 wants to merge 2 commits into
omacom:quattrofrom
MadMatt341:codex/keyboard-layout-picker

Conversation

@MadMatt341

@MadMatt341 MadMatt341 commented Sep 13, 2026 •

Copy link
Copy Markdown

Add layout management to the existing keyboard indicator: clicking opens a picker, and Edit layouts… adds/removes up to four layouts, searches variants, chooses the next-login default and configures the switching shortcut. The existing widget ID and bar position stay in use. The indicator remains visible with one layout so users can add another.

This is a focused keyboard contribution related to #7252. It adapts the working Keyboard Layouts community plugin to first-party ownership. The picker and backend could also be used by the broader Input panel; this PR makes that integration available for review without adding pointer settings or a second bar entry.

The backend preserves Compose/Caps Lock and other unrelated XKB options, validates characters and shortcut chords with libxkbcommon, verifies physical typing interfaces and rolls back failed edits. Ordinary switching preserves the login default. A fixed package-owned Lua loader reads bounded, non-executable state through a supervised Python helper. Saves and recovery never rewrite the loader, helper or user input.lua. No pip dependencies, raw-input collection or hyprctl eval hl.device calls.

The native version requires no plugin activation command. It starts with no saved overrides, uses a separate state directory, and refuses editing while the community plugin's loader is present. Its existing cleanup procedure remains the transition path; automatic profile migration is outside this proposal. Source and test details are in docs/keyboard-layouts.md; the reused code retains its MIT notice.

Preview

Fixture rendering of the picker, editor and variant search; this is not a live desktop capture.

Keyboard layout picker, editor and search

Actual installed desktop, captured during disposable-VM validation:

Keyboard layout editor on an installed Omarchy desktop

Verification

  • 91 isolated Python tests, including real XKB compilation, native first-save/rollback, loader and next-session promotion, file ownership, stale revisions and supervised-process cleanup.

  • 40 offscreen native UI tests; rendered picker/editor/search/ambiguity states inspected. The entry-point wiring test substitutes the layer-shell container because offscreen Qt has no PanelWindow backend.

  • Omarchy plugin clone and manifest checks; Hyprland keyboard-layout and default-config checks.

  • Fresh ISO built from the source and installed in an encrypted KVM guest. A clean keyboard run passed first-save UI navigation/search, switching, login-default selection, Polish AltGr/Shift+AltGr character entry, both Alt press orders, and persistence across reboot, with the user's input.lua unchanged.

  • Active-layout removal passed, including safe duplicate groups for the current session and promotion to a true single group after another reboot. Actual desktop captures were inspected.

The broader desktop suite had two unrelated OCR failures (weather location and reminder prompt); both texts were visible in the captured UI. Its remaining test files and QMP shortcut smoke checks passed. The ISO harness needed an installer-greeting matcher update, and an initial unencrypted install failed its second boot; the keyboard results above use a fresh encrypted installation. Exact source/ISO identifiers and the verification sequence are in docs/keyboard-layouts.md.

Ready for review, including maintainer direction on the relationship to #7252. Typing was validated through QMP virtual-hardware input on one emulated AT keyboard; multiple physical keyboards and hardware hotplug remain outside the VM evidence. The upstream adaptation has not been installed on the development desktop.

@MadMatt341
MadMatt341 marked this pull request as ready for review September 13, 2026 13:47
@omarchybot omarchybot added the enhancement New feature or request label Sep 27, 2026
@llstrk

llstrk commented Oct 1, 2026

Copy link
Copy Markdown

Automated AI review

Community review: Independent automated community review, unaffiliated with the Omarchy team, intended to help prepare PRs for their review.

Outcome: Selection by sysfs phys drops remapper keyboards and Bluetooth keyboards beside a Bluetooth mouse.

Bluetooth and remapper keyboards are dropped

resolve() excludes typing interfaces whose phys is empty, or shared with a pointer without /input0 (devices.py:60-66). Bluetooth HID devices all get the adapter address; keyd, kanata, kmonad and xremap keyboards get no phys. Impact: the picker configures a keyboard that is not typing, or shows "?".

Suggested change: identify each such interface by name and let the user choose.

Reproducer: Bluetooth keyboard (fails at 16b1317)

Bluetooth keyboard dropped when a Bluetooth mouse is connected

Linux sets the input device phys of every Bluetooth HID device to the local adapter address (net/bluetooth/hidp/core.c hidp_setup_hid; BlueZ src/shared/uhid.c for BLE), with no /inputN suffix. devices.resolve() then treats the keyboard as a non-primary interface of a pointing device and excludes it. The fixture writes a sysfs-shaped tree for one keyboard and one mouse; the controls show the same keyboard is kept alone or over USB.

Save the file below as test/keyboard-layouts/test_repro_bluetooth.py in a checkout of the PR head (16b1317), then run from test/keyboard-layouts/:

OMARCHY_PATH=$(git rev-parse --show-toplevel) PYTHONPATH=$(git rev-parse --show-toplevel)/shell/plugins/bar/widgets/keyboard python3 -B -m unittest -v test_repro_bluetooth
"""A Bluetooth keyboard is dropped when a Bluetooth mouse is connected.

Linux reports the local Bluetooth adapter address as `phys` for every
Bluetooth HID input device (net/bluetooth/hidp/core.c, and BlueZ
src/shared/uhid.c for BLE), so a keyboard and a mouse on the same adapter
share one `phys` value with no `/inputN` suffix.
"""
from pathlib import Path
import tempfile
import unittest

from backend.devices import metadata, resolve
from test_backend import keyboard


def bitmap(bits):
  value = sum(1 << bit for bit in bits)
  words = []
  while value:
    words.append('%x' % (value & (2 ** 64 - 1)))
    value >>= 64
  return ' '.join(reversed(words)) or '0'


def input_device(root, event, name, phys, keys, rel=()):
  base = Path(root) / event / 'device'
  (base / 'capabilities').mkdir(parents=True)
  (base / 'name').write_text(name + '\n')
  (base / 'phys').write_text(phys + '\n')
  (base / 'capabilities/key').write_text(bitmap(keys) + '\n')
  (base / 'capabilities/rel').write_text(bitmap(rel) + '\n')


KEYBOARD_KEYS = list(range(1, 89))
MOUSE_KEYS, MOUSE_REL = [272, 273, 274], [0, 1, 8]


class BluetoothKeyboardTests(unittest.TestCase):
  def groups(self, keyboard_phys, mouse_phys):
    with tempfile.TemporaryDirectory() as folder:
      input_device(folder, 'event5', 'Keychron K2', keyboard_phys, KEYBOARD_KEYS)
      if mouse_phys:
        input_device(folder, 'event6', 'MX Master 3', mouse_phys, MOUSE_KEYS, MOUSE_REL)
      records = metadata(Path(folder))
    return resolve({'keyboards': [keyboard('keychron-k2')]}, records)

  def test_bluetooth_keyboard_next_to_a_bluetooth_mouse_is_offered(self):
    adapter = 'aa:bb:cc:dd:ee:ff'
    groups, excluded = self.groups(adapter, adapter)
    self.assertEqual([group['names'] for group in groups], [['keychron-k2']], f'excluded: {excluded}')

  def test_control_bluetooth_keyboard_alone(self):
    groups, _ = self.groups('aa:bb:cc:dd:ee:ff', None)
    self.assertEqual([group['names'] for group in groups], [['keychron-k2']])

  def test_control_usb_keyboard_and_usb_mouse(self):
    groups, _ = self.groups('usb-0000:00:14.0-2/input0', 'usb-0000:00:14.0-3/input0')
    self.assertEqual([group['names'] for group in groups], [['keychron-k2']])


if __name__ == '__main__':
  unittest.main()

Actual output at the PR head (checkout copied to /tmp/omarchy, OMARCHY_PATH set to it):

test_bluetooth_keyboard_next_to_a_bluetooth_mouse_is_offered (test_repro_bluetooth.BluetoothKeyboardTests.test_bluetooth_keyboard_next_to_a_bluetooth_mouse_is_offered) ... FAIL
test_control_bluetooth_keyboard_alone (test_repro_bluetooth.BluetoothKeyboardTests.test_control_bluetooth_keyboard_alone) ... ok
test_control_usb_keyboard_and_usb_mouse (test_repro_bluetooth.BluetoothKeyboardTests.test_control_usb_keyboard_and_usb_mouse) ... ok

======================================================================
FAIL: test_bluetooth_keyboard_next_to_a_bluetooth_mouse_is_offered (test_repro_bluetooth.BluetoothKeyboardTests.test_bluetooth_keyboard_next_to_a_bluetooth_mouse_is_offered)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_bluetooth.py", line 50, in test_bluetooth_keyboard_next_to_a_bluetooth_mouse_is_offered
    self.assertEqual([group['names'] for group in groups], [['keychron-k2']], f'excluded: {excluded}')
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Lists differ: [] != [['keychron-k2']]

Second list contains 1 additional elements.
First extra element 0:
['keychron-k2']

- []
+ [['keychron-k2']] : excluded: ['keychron-k2']

----------------------------------------------------------------------
Ran 3 tests in 0.002s

FAILED (failures=1)
Reproducer: remapper keyboard (fails at 16b1317)

Typing keyboards with an empty sysfs phys

The input core prints an unset input_dev->phys as an empty line (drivers/input/input.c, INPUT_DEV_STRING_ATTR_SHOW(phys)). uinput sets phys only on UI_SET_PHYS (drivers/input/misc/uinput.c), and keyd, kanata, kmonad, xremap and ydotoold never call it. devices.metadata() turns the empty value into group: "". resolve() keeps only typing records with a non-empty group (devices.py:60), so any keyboard with an empty phys is excluded and never listed.

The fixture writes a sysfs-shaped tree (name, phys, capability bitmaps) and uses the PR's fake compositor from test_backend.py:

  • keyd case: a laptop keyboard plus keyd virtual keyboard. keyd grabs the laptop keyboard (EVIOCGRAB), so all typing reaches Hyprland from the virtual keyboard. kanata, kmonad and xremap take the same path.
  • Only keyboard: a single typing keyboard with an empty phys, as from the kernel drivers that never set it (for example tegra-kbc, omap4-keypad, matrix_keypad).
  • Control: ydotoold virtual device next to a laptop keyboard is ignored, which is the right outcome for a synthetic typing source.

Save the file below as test/keyboard-layouts/test_repro_empty_phys.py in a checkout of the PR head (16b1317), then run from test/keyboard-layouts/:

OMARCHY_PATH=$(git rev-parse --show-toplevel) PYTHONPATH=$(git rev-parse --show-toplevel)/shell/plugins/bar/widgets/keyboard python3 -B -m unittest -v test_repro_empty_phys
"""Typing keyboards whose sysfs `phys` is empty.

The input core prints an unset `input_dev->phys` as an empty line
(drivers/input/input.c, INPUT_DEV_STRING_ATTR_SHOW(phys)). uinput only sets it
on UI_SET_PHYS (drivers/input/misc/uinput.c), which keyd, kanata, kmonad,
xremap and ydotoold do not call. The remappers grab the physical keyboard
(EVIOCGRAB), so every keystroke reaches Hyprland from their virtual keyboard.
"""
import copy
from pathlib import Path
import tempfile
import unittest

import test_backend
from test_backend import keyboard
from backend.deferred import parse
from backend.devices import metadata, resolve
from backend.session import Session

FULL_KEYBOARD = list(range(1, 89))               # Esc .. F12: letters, Enter, Space
POINTER_KEYS, POINTER_REL = [272, 273, 274], [0, 1]  # BTN_LEFT/RIGHT/MIDDLE, REL_X/Y


def bitmap(bits):
  value = sum(1 << bit for bit in bits)
  words = []
  while value:
    words.append('%x' % (value & (2 ** 64 - 1)))
    value >>= 64
  return ' '.join(reversed(words)) or '0'


def sysfs(root, devices):
  # devices: (sysfs name, phys, key bits, rel bits)
  for number, (name, phys, keys, rel) in enumerate(devices):
    base = Path(root) / f'event{number}' / 'device'
    (base / 'capabilities').mkdir(parents=True)
    (base / 'name').write_text(name + '\n')
    (base / 'phys').write_text(phys + '\n')
    (base / 'capabilities/key').write_text(bitmap(keys) + '\n')
    (base / 'capabilities/rel').write_text(bitmap(rel) + '\n')
  return metadata(Path(root))


def hyprland_name(name):
  return name.lower().replace(' ', '-')


class EmptyPhysTests(unittest.TestCase):
  def setUp(self):
    test_backend.TransactionTests.setUp(self)
    self.paths.active.unlink()
    self.paths.pending.unlink()

  def seat(self, devices, keyboards):
    with tempfile.TemporaryDirectory() as folder:
      records = sysfs(folder, devices)
    self.hypr.items = [keyboard(hyprland_name(name), address=name)
                       for name in keyboards]
    self.hypr.base = copy.deepcopy(self.hypr.items)
    return records, Session(self.paths, self.hypr, records)

  def test_keyd_virtual_keyboard_is_the_one_configured(self):
    records, session = self.seat(
      [('AT Translated Set 2 keyboard', 'isa0060/serio0/input0', FULL_KEYBOARD, []),
       ('keyd virtual keyboard', '', FULL_KEYBOARD, [])],
      ['AT Translated Set 2 keyboard', 'keyd virtual keyboard'])
    status = session.status()
    print('\nKEYD records:', [(r['name'], repr(r['physical']), r['group'], r['typing']) for r in records])
    print('KEYD groups/excluded:', resolve(self.hypr.devices(), records))
    print('KEYD status:', {key: status[key] for key in ('deviceLabel', 'deviceNames', 'problem')})
    session.save(['us/', 'de/'], 'both-alt', status['revision'])
    rows = parse(self.paths.active.read_bytes())
    print('KEYD saved hl.device rows:', [(row['name'], row['layout']) for row in rows])
    self.assertIn('keyd-virtual-keyboard', [row['name'] for row in rows])

  def test_only_keyboard_has_empty_phys(self):
    records, session = self.seat([('tegra-kbc', '', FULL_KEYBOARD, [])], ['tegra-kbc'])
    status = session.status()
    print('\nONLY status:', {key: status[key] for key in ('devices', 'device', 'layouts', 'active', 'activeLayouts', 'problem')})
    self.assertNotEqual(status['devices'], [])

  def test_control_ydotoold_virtual_device_is_ignored(self):
    records, session = self.seat(
      [('AT Translated Set 2 keyboard', 'isa0060/serio0/input0', FULL_KEYBOARD, []),
       ('ydotoold virtual device', '', FULL_KEYBOARD + POINTER_KEYS, POINTER_REL)],
      ['AT Translated Set 2 keyboard', 'ydotoold virtual device'])
    status = session.status()
    print('\nYDOTOOLD status:', {key: status[key] for key in ('deviceLabel', 'deviceNames', 'problem')})
    self.assertEqual(status['deviceNames'], ['at-translated-set-2-keyboard'])


if __name__ == '__main__':
  unittest.main()

Actual output at the PR head (checkout copied to /tmp/omarchy, OMARCHY_PATH set to it):

test_control_ydotoold_virtual_device_is_ignored (test_repro_empty_phys.EmptyPhysTests.test_control_ydotoold_virtual_device_is_ignored) ... ok
test_keyd_virtual_keyboard_is_the_one_configured (test_repro_empty_phys.EmptyPhysTests.test_keyd_virtual_keyboard_is_the_one_configured) ... FAIL
test_only_keyboard_has_empty_phys (test_repro_empty_phys.EmptyPhysTests.test_only_keyboard_has_empty_phys) ... FAIL

======================================================================
FAIL: test_keyd_virtual_keyboard_is_the_one_configured (test_repro_empty_phys.EmptyPhysTests.test_keyd_virtual_keyboard_is_the_one_configured)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_empty_phys.py", line 75, in test_keyd_virtual_keyboard_is_the_one_configured
    self.assertIn('keyd-virtual-keyboard', [row['name'] for row in rows])
    ~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 'keyd-virtual-keyboard' not found in ['at-translated-set-2-keyboard']

======================================================================
FAIL: test_only_keyboard_has_empty_phys (test_repro_empty_phys.EmptyPhysTests.test_only_keyboard_has_empty_phys)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_empty_phys.py", line 81, in test_only_keyboard_has_empty_phys
    self.assertNotEqual(status['devices'], [])
    ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: [] == []

----------------------------------------------------------------------
Ran 3 tests in 0.051s

FAILED (failures=2)

YDOTOOLD status: {'deviceLabel': 'AT Translated Set 2 keyboard', 'deviceNames': ['at-translated-set-2-keyboard'], 'problem': ''}

KEYD records: [('at-translated-set-2-keyboard', "'isa0060/serio0/input0'", 'isa0060/serio0', True), ('keyd-virtual-keyboard', "''", '', True)]
KEYD groups/excluded: ([{'id': 'bde912ca5e419e4f79219d53', 'label': 'AT Translated Set 2 keyboard', 'certain': True, 'members': [{'name': 'at-translated-set-2-keyboard', 'address': 'AT Translated Set 2 keyboard', 'rules': '', 'model': '', 'layout': 'us,pl', 'variant': '', 'options': 'compose:caps,shift:both_capslock_cancel,grp:alt_altgr_toggle', 'active_layout_index': 0}], 'names': ['at-translated-set-2-keyboard']}], ['keyd-virtual-keyboard'])
KEYD status: {'deviceLabel': 'AT Translated Set 2 keyboard', 'deviceNames': ['at-translated-set-2-keyboard'], 'problem': ''}
KEYD saved hl.device rows: [('at-translated-set-2-keyboard', 'us,de')]

ONLY status: {'devices': [], 'device': '', 'layouts': [], 'active': -1, 'activeLayouts': [], 'problem': 'Choose the keyboard you type on.'}

A saved keyboard moved to another USB port cannot be saved

The ID hashes the port path, so after a move the bar shows "?". Impact: choosing it again makes every save fail with a misleading recovery error.

Suggested change: replace the profile for the same device name; fall back to a sole keyboard left.

Reproducer (fails at 16b1317)

Keyboard choice and saved profile are tied to the USB port path

Group identities hash the sysfs phys path, which includes the USB port. A saved preferred group that is no longer present is never replaced by the single remaining keyboard, and after the same keyboard is re-chosen on another port, every save fails because the old profile still names the same device.

Save the file below as test/keyboard-layouts/test_repro_moved_keyboard.py in a checkout of the PR head (16b1317), then run from test/keyboard-layouts/:

OMARCHY_PATH=$(git rev-parse --show-toplevel) PYTHONPATH=$(git rev-parse --show-toplevel)/shell/plugins/bar/widgets/keyboard python3 -B -m unittest -v test_repro_moved_keyboard
"""The keyboard choice and saved profile are keyed by the sysfs port path."""
import copy
import unittest

import test_backend
from test_backend import keyboard, record
from backend.session import Session


class MovedKeyboardTests(unittest.TestCase):
  def setUp(self):
    test_backend.TransactionTests.setUp(self)
    self.paths.active.unlink()
    self.paths.pending.unlink()

  def seat(self, *devices):
    # devices: (Hyprland name, sysfs phys without /inputN)
    self.hypr.items = [keyboard(name, address=name) for name, _ in devices]
    self.hypr.base = copy.deepcopy(self.hypr.items)
    return Session(self.paths, self.hypr, [record(name, group) for name, group in devices])

  def test_remaining_keyboard_is_used_after_the_chosen_one_is_unplugged(self):
    both = self.seat(('laptop-keyboard', 'isa0060/serio0'), ('usb-keyboard', 'usb-0000:00:14.0-2'))
    status = both.status()
    usb = next(group for group in status['devices'] if group['label'] == 'usb-keyboard')
    both.choose(usb['id'], status['revision'])

    alone = self.seat(('laptop-keyboard', 'isa0060/serio0'))
    status = alone.status()
    self.assertEqual(status['deviceLabel'], 'laptop-keyboard', status['problem'])

  def test_keyboard_moved_to_another_usb_port_can_be_edited_again(self):
    first = self.seat(('usb-keyboard', 'usb-0000:00:14.0-2'))
    first.save(['us/', 'pl/'], 'both-alt', first.status()['revision'])

    moved = self.seat(('usb-keyboard', 'usb-0000:00:14.0-3'))
    status = moved.status()
    moved.choose(status['devices'][0]['id'], status['revision'])
    moved.save(['us/', 'de/'], 'both-alt', moved.status()['revision'])
    self.assertEqual([row['id'] for row in moved.status()['layouts']], ['us/', 'de/'])


if __name__ == '__main__':
  unittest.main()

Actual output at the PR head (checkout copied to /tmp/omarchy, OMARCHY_PATH set to it):

test_keyboard_moved_to_another_usb_port_can_be_edited_again (test_repro_moved_keyboard.MovedKeyboardTests.test_keyboard_moved_to_another_usb_port_can_be_edited_again) ... ERROR
test_remaining_keyboard_is_used_after_the_chosen_one_is_unplugged (test_repro_moved_keyboard.MovedKeyboardTests.test_remaining_keyboard_is_used_after_the_chosen_one_is_unplugged) ... FAIL

======================================================================
ERROR: test_keyboard_moved_to_another_usb_port_can_be_edited_again (test_repro_moved_keyboard.MovedKeyboardTests.test_keyboard_moved_to_another_usb_port_can_be_edited_again)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_moved_keyboard.py", line 39, in test_keyboard_moved_to_another_usb_port_can_be_edited_again
    moved.save(['us/', 'de/'], 'both-alt', moved.status()['revision'])
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/session.py", line 602, in save
    written_active = render_deferred(active_saved, session)
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/deferred.py", line 117, in render
    return render_rows(targets, session)
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/deferred.py", line 103, in render_rows
    return _render_rows(targets, _session(session))
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/deferred_runtime.py", line 116, in render_rows
    raise InvalidData("saved keyboard names overlap")
backend.deferred_runtime.InvalidData: saved keyboard names overlap

======================================================================
FAIL: test_remaining_keyboard_is_used_after_the_chosen_one_is_unplugged (test_repro_moved_keyboard.MovedKeyboardTests.test_remaining_keyboard_is_used_after_the_chosen_one_is_unplugged)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_moved_keyboard.py", line 30, in test_remaining_keyboard_is_used_after_the_chosen_one_is_unplugged
    self.assertEqual(status['deviceLabel'], 'laptop-keyboard', status['problem'])
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: '' != 'laptop-keyboard'
+ laptop-keyboard
 : Choose the keyboard you type on.

----------------------------------------------------------------------
Ran 2 tests in 0.049s

FAILED (failures=1, errors=1)
More findings: An interrupted save is not recovered after the next login

The journal matches devices by Hyprland address, which changes in a new session. If a save is interrupted (logout, power loss, compositor crash), the next login reverts the applied edit, then fails with "The previous keyboard setup could not be confirmed." and keeps the journal, so every status, switch, choose or save request (status every 20 s) reloads Hyprland again. Impact: the bar stays at "?" until transaction.json is deleted by hand; docs/keyboard-layouts.md says interrupted transactions recover on the next request.

Suggested change: in a new session, finish the transaction from the saved files (the login loader already applied them) instead of comparing runtime addresses.

Reproducer (fails at 16b1317):

Interrupted save is never recovered in the next desktop session

The save is interrupted after Hyprland reloaded the new files (as on logout, reboot, power loss or a compositor crash during a save). In the next session Hyprland's keyboard objects have new addresses, and _restore_runtime() matches devices by address, so recovery reverts the files, reloads Hyprland and then fails, keeping the journal. Every later helper request repeats this. The control shows recovery works when addresses are unchanged.

Save the file below as test/keyboard-layouts/test_repro_recovery.py in a checkout of the PR head (16b1317), then run from test/keyboard-layouts/:

OMARCHY_PATH=$(git rev-parse --show-toplevel) PYTHONPATH=$(git rev-parse --show-toplevel)/shell/plugins/bar/widgets/keyboard python3 -B -m unittest -v test_repro_recovery
"""An interrupted save is never recovered once keyboard addresses change."""
import os
import unittest
from unittest.mock import patch

import test_backend


class InterruptedSaveRecoveryTests(unittest.TestCase):
  def setUp(self):
    test_backend.TransactionTests.setUp(self)

  def interrupt_save_during_reload(self):
    status = self.session.status()
    real_reload = self.hypr.reload

    def reload_then_session_ends():
      real_reload()
      raise KeyboardInterrupt('session ended during the save')

    self.hypr.reload = reload_then_session_ends
    with self.assertRaises(KeyboardInterrupt):
      self.session.save(['us/', 'de/'], 'both-alt', status['revision'])
    self.hypr.reload = real_reload
    self.assertTrue(self.paths.transaction.exists())

  def test_interrupted_save_recovers_in_the_next_desktop_session(self):
    self.interrupt_save_during_reload()
    # Next login: Hyprland creates new keyboard objects, so their addresses change.
    for number, device in enumerate(self.hypr.items):
      device['address'] = '0x%x' % (0x5000 + number)
    with patch.dict(os.environ, {'HYPRLAND_INSTANCE_SIGNATURE': 'next-login'}):
      self.session.recover_pending()
    self.assertFalse(self.paths.transaction.exists())

  def test_control_same_session_recovers(self):
    self.interrupt_save_during_reload()
    self.session.recover_pending()
    self.assertFalse(self.paths.transaction.exists())


if __name__ == '__main__':
  unittest.main()

Actual output at the PR head (checkout copied to /tmp/omarchy, OMARCHY_PATH set to it):

test_control_same_session_recovers (test_repro_recovery.InterruptedSaveRecoveryTests.test_control_same_session_recovers) ... ok
test_interrupted_save_recovers_in_the_next_desktop_session (test_repro_recovery.InterruptedSaveRecoveryTests.test_interrupted_save_recovers_in_the_next_desktop_session) ... ERROR

======================================================================
ERROR: test_interrupted_save_recovers_in_the_next_desktop_session (test_repro_recovery.InterruptedSaveRecoveryTests.test_interrupted_save_recovers_in_the_next_desktop_session)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_recovery.py", line 33, in test_interrupted_save_recovers_in_the_next_desktop_session
    self.session.recover_pending()
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/session.py", line 709, in recover_pending
    self._rollback_live(transaction)
    ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/session.py", line 692, in _rollback_live
    self._restore_runtime(previous)
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/session.py", line 562, in _restore_runtime
    raise SettingsError("The previous keyboard setup could not be confirmed.")
backend.catalog.SettingsError: The previous keyboard setup could not be confirmed.

----------------------------------------------------------------------
Ran 2 tests in 0.062s

FAILED (errors=1)

What each later helper request does

Save this companion file as test/keyboard-layouts/test_repro_recovery_attempts.py next to the file above and run python3 -B -m unittest -v test_repro_recovery_attempts with the same environment. It drives keyboard_settings.response("status", {}) (the request the bar sends every 20 s) after the next login. It also reruns the inherited tests.

"""What each helper request does after the interrupted save, in the next session."""
import os
import unittest
from unittest.mock import patch

from backend.deferred import parse
from backend.keyboard_settings import response
from test_repro_recovery import InterruptedSaveRecoveryTests


class RecoveryAttempts(InterruptedSaveRecoveryTests):
  def saved_layouts(self):
    return sorted({row['layout'] for row in parse(self.paths.active.read_bytes())})

  def test_status_requests_after_next_login(self):
    print('\nsaved before the edit:', self.saved_layouts())
    self.interrupt_save_during_reload()
    print('saved after the interrupted edit:', self.saved_layouts())
    for number, device in enumerate(self.hypr.items):
      device['address'] = '0x%x' % (0x5000 + number)
    self.hypr.calls.clear()
    with patch.dict(os.environ, {'HYPRLAND_INSTANCE_SIGNATURE': 'next-login'}), \
         patch('backend.keyboard_settings.Session', return_value=self.session):
      for attempt in range(1, 4):
        print(f'status request {attempt}:', response('status', {}),
              '| saved:', self.saved_layouts(),
              '| Hyprland reloads so far:', self.hypr.calls.count(('reload', '')),
              '| journal kept:', self.paths.transaction.exists())


del InterruptedSaveRecoveryTests

if __name__ == '__main__':
  unittest.main()

Actual output at the PR head (checkout copied to /tmp/omarchy, OMARCHY_PATH set to it):

test_control_same_session_recovers (test_repro_recovery_attempts.RecoveryAttempts.test_control_same_session_recovers) ... ok
test_interrupted_save_recovers_in_the_next_desktop_session (test_repro_recovery_attempts.RecoveryAttempts.test_interrupted_save_recovers_in_the_next_desktop_session) ... ERROR
test_status_requests_after_next_login (test_repro_recovery_attempts.RecoveryAttempts.test_status_requests_after_next_login) ... ok

======================================================================
ERROR: test_interrupted_save_recovers_in_the_next_desktop_session (test_repro_recovery_attempts.RecoveryAttempts.test_interrupted_save_recovers_in_the_next_desktop_session)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_recovery.py", line 33, in test_interrupted_save_recovers_in_the_next_desktop_session
    self.session.recover_pending()
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/session.py", line 709, in recover_pending
    self._rollback_live(transaction)
    ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/session.py", line 692, in _rollback_live
    self._restore_runtime(previous)
    ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File "/tmp/omarchy/shell/plugins/bar/widgets/keyboard/backend/session.py", line 562, in _restore_runtime
    raise SettingsError("The previous keyboard setup could not be confirmed.")
backend.catalog.SettingsError: The previous keyboard setup could not be confirmed.

----------------------------------------------------------------------
Ran 3 tests in 0.092s

FAILED (errors=1)

saved before the edit: ['us,pl']
saved after the interrupted edit: ['us,de']
status request 1: {'ok': False, 'error': 'The previous keyboard setup could not be confirmed.'} | saved: ['us,pl'] | Hyprland reloads so far: 1 | journal kept: True
status request 2: {'ok': False, 'error': 'The previous keyboard setup could not be confirmed.'} | saved: ['us,pl'] | Hyprland reloads so far: 2 | journal kept: True
status request 3: {'ok': False, 'error': 'The previous keyboard setup could not be confirmed.'} | saved: ['us,pl'] | Hyprland reloads so far: 3 | journal kept: True
More findings: Picker-saved keyboards stop following input.lua

A save writes per-device kb_layout, kb_variant and kb_options through hl.device, and Hyprland applies per-device values over input:kb_*. Impact: later kb_options or kb_layout edits in input.lua (such as the manual's Compose example) no longer reach that keyboard. Open PRs that set or read the global values (#12752, #7252, #9565) would not affect, or would misread, a picker-saved keyboard.

Suggested change: document this in the manual, or build the per-device kb_options at load time from the global input.kb_options (hl.get_config) plus the switching option.

Reproducer:

Picker-saved keyboards stop following input.lua keyboard options

A picker save writes per-device kb_layout, kb_variant and kb_options (a snapshot of the options in effect at save time plus the switching option). default.hypr.toggles loads them after hypr.input through hl.device. Hyprland applies a per-device value over input:kb_* (src/managers/input/InputManager.cpp applyConfigToKeyboard, which calls getDeviceString(devname, "kb_options", "input:kb_options"); src/config/lua/ConfigManager.cpp getDeviceString returns the device value when it is set). So a later kb_options or kb_layout edit in input.lua, such as the manual's Compose example, no longer reaches that keyboard.

The demo saves through the PR backend with a fake compositor (files, catalog and XKB validation are real), then loads Omarchy's input defaults, the user's input.lua and default.hypr.toggles in hyprland.lua order with an hl stub that records what would reach Hyprland. Put shadow_save.py and shadow_load.lua in test/keyboard-layouts/ and shadow_demo.sh at the checkout root, then run bash shadow_demo.sh from the root of a checkout of the PR head (16b1317). The Hyprland precedence step is from source, not executed.

shadow_demo.sh:

#!/bin/bash
# Run from an Omarchy checkout at the PR head, with shadow_save.py and
# shadow_load.lua in the current directory's test/keyboard-layouts/.
set -euo pipefail
export OMARCHY_PATH=$PWD HOME=$(mktemp -d) HYPRLAND_INSTANCE_SIGNATURE=demo-session
export XDG_CONFIG_HOME=$HOME/.config XDG_STATE_HOME=$HOME/.local/state XDG_CACHE_HOME=$HOME/.cache
mkdir -p "$HOME/.config/hypr"
cp config/hypr/hyprland.lua config/hypr/input.lua "$HOME/.config/hypr/"
echo "-- picker saves us,pl while Omarchy's default kb_options are in effect"
PYTHONPATH=shell/plugins/bar/widgets/keyboard python3 -B test/keyboard-layouts/shadow_save.py
lua test/keyboard-layouts/shadow_load.lua
echo "-- user moves Compose to Right Alt in input.lua, as the manual suggests"
echo 'hl.config({ input = { kb_options = "compose:ralt" } })' >> "$HOME/.config/hypr/input.lua"
lua test/keyboard-layouts/shadow_load.lua

test/keyboard-layouts/shadow_save.py:

"""Save one layout set through the PR backend, as the picker would, into $HOME.

The fake compositor reports one laptop keyboard running Omarchy's default
input options. Only the compositor is faked; files, catalog and XKB validation
are the PR's real code.
"""
import copy
import os
from pathlib import Path
import sys

from backend.session import Paths, Session
from backend.deferred import parse

home = Path(os.environ['HOME'])
omarchy = Path(os.environ['OMARCHY_PATH'])


class FakeHyprland:
  def __init__(self, paths):
    self.paths = paths
    self.items = [dict(name='at-translated-set-2-keyboard', address='0x1', rules='', model='',
                       layout='us', variant='', options='compose:caps,shift:both_capslock_cancel',
                       active_layout_index=0)]

  def devices(self):
    return {'keyboards': copy.deepcopy(self.items)}

  def check(self):
    pass

  def switch(self, name, index):
    next(d for d in self.items if d['name'] == name)['active_layout_index'] = index

  def reload(self):
    by_name = {row['name']: row for row in parse(self.paths.active.read_bytes())}
    for item in self.items:
      row = by_name.get(item['name'])
      if row:
        item.update(layout=row['layout'], variant=row['variant'], options=row['options'])
      item['active_layout_index'] = 0


paths = Paths(home / '.config', home / '.local/state', omarchy=omarchy)
records = [dict(name='at-translated-set-2-keyboard', label='AT Translated Set 2 keyboard',
                group='isa0060/serio0', typing=True, pointer=False, primary=True)]
session = Session(paths, FakeHyprland(paths), records)
status = session.status()
session.save(['us/', 'pl/'], 'both-alt', status['revision'])
print('SAVED', [(row['name'], row['layout'], row['options']) for row in parse(paths.active.read_bytes())])

test/keyboard-layouts/shadow_load.lua:

-- Load Omarchy's input defaults, the user's input.lua and default.hypr.toggles
-- in the order config/hypr/hyprland.lua uses, recording what reaches Hyprland.
local home = os.getenv("HOME")
local omarchy = os.getenv("OMARCHY_PATH")
package.path = home .. "/.local/state/?.lua;" .. home .. "/.config/?.lua;" .. omarchy .. "/?.lua;" .. package.path

local real_open = io.open
io.open = function(path, mode)
  if path == "/etc/vconsole.conf" then return nil end
  return real_open(path, mode)
end

local global_options, devices = nil, {}
local function noop() return {} end
hl = setmetatable({
  config = function(config)
    if config.input and config.input.kb_options ~= nil then global_options = config.input.kb_options end
  end,
  device = function(spec) table.insert(devices, spec) end,
}, { __index = function() return noop end })
o = setmetatable({}, { __index = function() return noop end })

require("default.hypr.input")
require("hypr.input")
require("default.hypr.toggles")

print("input.kb_options (global, last hl.config): " .. tostring(global_options))
for _, spec in ipairs(devices) do
  print(("hl.device %s: kb_layout=%s kb_options=%s"):format(spec.name, tostring(spec.kb_layout), tostring(spec.kb_options)))
end

Actual output at the PR head (checkout copied to /tmp/omarchy):

-- picker saves us,pl while Omarchy's default kb_options are in effect
SAVED [('at-translated-set-2-keyboard', 'us,pl', 'compose:caps,shift:both_capslock_cancel,grp:alt_altgr_toggle')]
input.kb_options (global, last hl.config): compose:caps,shift:both_capslock_cancel
hl.device at-translated-set-2-keyboard: kb_layout=us,pl kb_options=compose:caps,shift:both_capslock_cancel,grp:alt_altgr_toggle
-- user moves Compose to Right Alt in input.lua, as the manual suggests
input.kb_options (global, last hl.config): compose:ralt
hl.device at-translated-set-2-keyboard: kb_layout=us,pl kb_options=compose:caps,shift:both_capslock_cancel,grp:alt_altgr_toggle
More findings: Animations probe always reports animations enabled

Hyprland.animations() reads .get("int", 1), but Hyprland 0.56 reports animations:enabled as "bool". Impact: the flag roll and spinners ignore the no-animations mode on quattro.

Suggested change: read .bool, or use Style.reduceMotion on quattro.

Reproducer (fails at 16b1317):

Animations probe reads an int where Hyprland reports a bool

Hyprland 0.56's dispatchGetOption prints "bool": true|false for bool options under the Lua config (src/debug/HyprCtl.cpp), and animations:enabled is a bool (src/config/values/ConfigValues.cpp). Hyprland.animations() reads .get("int", 1), so it always reports animations as enabled.

Save the file below as test/keyboard-layouts/test_repro_animations.py in a checkout of the PR head (16b1317), then run from test/keyboard-layouts/:

OMARCHY_PATH=$(git rev-parse --show-toplevel) PYTHONPATH=$(git rev-parse --show-toplevel)/shell/plugins/bar/widgets/keyboard python3 -B -m unittest -v test_repro_animations
"""The animations probe reads an int where Hyprland reports a bool."""
import subprocess
import unittest
from unittest.mock import patch

from backend.session import Hyprland


def reply(text):
  return subprocess.CompletedProcess(['/usr/bin/hyprctl'], 0, text.encode(), b'')


class AnimationOptionTests(unittest.TestCase):
  def test_disabled_animations_are_reported_disabled(self):
    # Hyprland 0.56 `hyprctl -j getoption animations.enabled` with animations off.
    disabled = '{"option": "animations.enabled", "bool": false, "set": true }'
    with patch.object(Hyprland, '_bounded', return_value=reply(disabled)):
      self.assertFalse(Hyprland().animations())


if __name__ == '__main__':
  unittest.main()

Actual output at the PR head (checkout copied to /tmp/omarchy, OMARCHY_PATH set to it):

test_disabled_animations_are_reported_disabled (test_repro_animations.AnimationOptionTests.test_disabled_animations_are_reported_disabled) ... FAIL

======================================================================
FAIL: test_disabled_animations_are_reported_disabled (test_repro_animations.AnimationOptionTests.test_disabled_animations_are_reported_disabled)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/tmp/omarchy/test/keyboard-layouts/test_repro_animations.py", line 18, in test_disabled_animations_are_reported_disabled
    self.assertFalse(Hyprland().animations())
    ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: True is not false

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)
More findings: Loader errors when OMARCHY_PATH is unset

default/hypr/keyboard-layouts.lua:8 concatenates os.getenv("OMARCHY_PATH") without the /usr/share/omarchy fallback that hyprland.lua, bootstrap.lua and paths.lua use. Impact: a Hyprland config error on every load in that environment, even with no saved state.

Suggested change: use require("default.hypr.paths").

Reproducer (fails at 16b1317):

Loader fails when OMARCHY_PATH is unset

default/hypr/keyboard-layouts.lua line 8 concatenates os.getenv("OMARCHY_PATH") without the /usr/share/omarchy fallback that config/hypr/hyprland.lua, default/hypr/bootstrap.lua and default/hypr/paths.lua use, so it raises even on a fresh install with no saved state. Run from the root of a checkout of the PR head (16b1317):

env -u OMARCHY_PATH lua -e 'hl = { device = function() end }; dofile("default/hypr/keyboard-layouts.lua")'

Actual output:

lua: default/hypr/keyboard-layouts.lua:8: attempt to concatenate a nil value
stack traceback:
	default/hypr/keyboard-layouts.lua:8: in main chunk
	[C]: in global 'dofile'
	(command line):1: in main chunk
	[C]: in ?
More findings: Documentation

manual/05-the-top-bar.md:11 still says the keyboard layout appears only with more than one layout.

Details

Behaviour changes for maintainers (intentional per the PR, from source):

  • The widget is now always visible; the base hid it with one layout, and an existing migration added it to every bar on that premise.
  • Clicking opens the picker instead of cycling layouts.
  • Only the chosen keyboard is configured and switched; with two typing keyboards and no choice, the indicator shows "?".
  • The bar runs python3 and hyprctl every 20 s and after layout and reload events (about 57 ms Python CPU per status request, warm cache, canned hyprctl, one machine), and every Hyprland config load runs the Python helper synchronously (about 27 ms for the helper, 30 ms for the whole loader, warm).

Overlap: #12350, #12679 and #13033 edit KeyboardLayoutModel.js, which this PR deletes; #7219 and #9419 edit KeyboardLayout.qml; #12752 appends after the same last line of default/hypr/toggles.lua.

Verified:

  • The PR's 91 Python and 40 offscreen QML tests pass at the head and merged onto the current quattro tip.
  • A fresh install with no saved state emits no hl.device and never writes input.lua.
  • State, device names and layout strings are hex-encoded or passed as argv; no path into Lua or Hyprland config code was found.
  • The next-login default is promoted only in a new Hyprland session; ordinary switching keeps it.
  • Unrelated XKB options are kept, and unknown grp: options block replacement.

Review information

Test scope: Source and sandbox tests (synthetic sysfs, fake compositor) at 16b1317; no live desktop or real hardware.

AI process: Opus 5.5 Medium coordination and synthesis, Opus 5.5 Xhigh technical review and final fact check, GPT 6 Sol Xhigh search for related issues, Opus 5.5 Medium editorial check.

Opt out: To stop receiving these reviews, reply to this comment saying so.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants