From 0469e1c1497a54c3a542553905ff821a43830ce7 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 24 Aug 2026 17:40:34 +0200 Subject: [PATCH 1/3] refactor(runtime): add generic action and event queue Written by inayayousfi, typed by gpt-5.6-sol running in OpenCode. Every call here is inayayousfi's, and no agent acted on its own. Replace concrete runtime command and event DTOs with registered native-data messages, then migrate built-in producers, handlers, and tests. Add full-source Pyright checks and document the queue contract. --- .github/workflows/reusable-check-ubuntu.yml | 5 +- .github/workflows/reusable-check-windows.yml | 5 +- AGENTS.md | 69 ++-- README.md | 42 --- pyproject.toml | 6 + .../application/linux_permissions.py | 4 +- src/axidev_osk/cli/linux_greeter.py | 24 +- src/axidev_osk/components/grid/keyboard.py | 108 +++--- src/axidev_osk/components/key/builder.py | 2 + src/axidev_osk/components/prompt/builder.py | 4 +- src/axidev_osk/config/defaults/us_iso.py | 56 ++-- src/axidev_osk/config/models.py | 4 + src/axidev_osk/hot_corner/controller.py | 25 +- src/axidev_osk/messages.py | 83 +++++ src/axidev_osk/models.py | 61 ++-- src/axidev_osk/runtime/actions.py | 205 ++++++++++++ src/axidev_osk/runtime/application.py | 35 +- src/axidev_osk/runtime/commands.py | 137 -------- src/axidev_osk/runtime/context.py | 2 +- src/axidev_osk/runtime/decoding.py | 164 +++++++++ src/axidev_osk/runtime/dispatcher.py | 286 +++++++++------- src/axidev_osk/runtime/event_handlers.py | 312 ++++++++++++------ src/axidev_osk/runtime/events.py | 270 ++++++++++----- src/axidev_osk/runtime/prompt.py | 12 +- src/axidev_osk/runtime/registries.py | 56 ++-- src/axidev_osk/runtime/state_store.py | 8 +- src/axidev_osk/runtime/testing.py | 38 ++- src/axidev_osk/services/keyboard/io.py | 7 + src/axidev_osk/services/keyboard/service.py | 41 ++- src/axidev_osk/services/single_instance.py | 6 +- src/axidev_osk/windows/builder.py | 4 +- .../windows/overlay/always_on_top.py | 2 +- tests/test_application_runtime.py | 4 +- tests/test_hot_corner_events.py | 92 +++--- tests/test_keyboard_service.py | 100 ++++-- tests/test_prompt_component.py | 16 +- tests/test_runtime_identity.py | 17 + tests/test_runtime_messages.py | 249 ++++++++++++++ tests/test_single_instance.py | 14 +- tests/test_us_iso_layout.py | 12 +- 40 files changed, 1806 insertions(+), 781 deletions(-) create mode 100644 src/axidev_osk/messages.py create mode 100644 src/axidev_osk/runtime/actions.py delete mode 100644 src/axidev_osk/runtime/commands.py create mode 100644 src/axidev_osk/runtime/decoding.py create mode 100644 tests/test_runtime_messages.py diff --git a/.github/workflows/reusable-check-ubuntu.yml b/.github/workflows/reusable-check-ubuntu.yml index 88b1b2f..bfefc90 100644 --- a/.github/workflows/reusable-check-ubuntu.yml +++ b/.github/workflows/reusable-check-ubuntu.yml @@ -18,12 +18,15 @@ jobs: uses: ./.github/actions/setup-ubuntu-runner - name: Install Python check tools - run: python -m pip install --upgrade pip build flake8 + run: python -m pip install --upgrade pip build flake8 pyright - name: Lint top-level Python code run: | python -c "import os, pathlib, subprocess, sysconfig; scripts = pathlib.Path(sysconfig.get_path('scripts')); exe = scripts / ('flake8.exe' if os.name == 'nt' else 'flake8'); raise SystemExit(subprocess.run([str(exe), '--select=F,E9,W6', 'src']).returncode)" + - name: Type-check application source + run: python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" + - name: Build vendored axidev-io stack uses: ./.github/actions/build-vendored-axidev-io-ubuntu with: diff --git a/.github/workflows/reusable-check-windows.yml b/.github/workflows/reusable-check-windows.yml index 42ae1be..911a2f0 100644 --- a/.github/workflows/reusable-check-windows.yml +++ b/.github/workflows/reusable-check-windows.yml @@ -18,12 +18,15 @@ jobs: uses: ./.github/actions/setup-windows-runner - name: Install Python check tools - run: python -m pip install --upgrade pip build flake8 + run: python -m pip install --upgrade pip build flake8 pyright - name: Lint top-level Python code run: | python -c "import os, pathlib, subprocess, sysconfig; scripts = pathlib.Path(sysconfig.get_path('scripts')); exe = scripts / ('flake8.exe' if os.name == 'nt' else 'flake8'); raise SystemExit(subprocess.run([str(exe), '--select=F,E9,W6', 'src']).returncode)" + - name: Type-check application source + run: python -m pyright --pythonpath (python -c "import sys; print(sys.executable)") + - name: Build vendored axidev-io stack uses: ./.github/actions/build-vendored-axidev-io-windows with: diff --git a/AGENTS.md b/AGENTS.md index 820820f..cc2c0e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,6 @@ +Written by inayayousfi, typed by gpt-5.6-sol running in OpenCode. +Every call here is inayayousfi's, and no agent acted on its own. + # AGENTS.md This file defines the architectural guardrails for humans and coding agents working in this repository. @@ -26,9 +29,9 @@ The Lua configuration layer is not implemented yet. That does not reduce its imp 4. Keep widget construction separate from process orchestration and backend/input logic. 5. Prefer composition through data and registries over special-case window subclasses. 6. New APIs should be designed so a future Lua config can describe and assemble them. -7. Runtime subsystems should communicate through the central event/command queue, not direct cross-subsystem calls. +7. Runtime subsystems should communicate through the central event/action queue, not direct cross-subsystem calls. 8. Durable application state belongs to the main process/runtime state store, not individual widgets, components, or Lua globals. -9. Services, UI widgets, backend adapters, timers, and platform integrations must not call window managers, widgets, backends, Lua callbacks, or other subsystems directly when a runtime event or command can represent the interaction. +9. Services, UI widgets, backend adapters, timers, and platform integrations must not call window managers, widgets, backends, Lua callbacks, or other subsystems directly when a runtime event or action can represent the interaction. ## Mental Model @@ -36,7 +39,7 @@ The Lua configuration layer is not implemented yet. That does not reduce its imp - Grids are components that place buttons or other controls. - Windows are components/surfaces that host one or more grids. - One main process coordinates windows, services, state, queues, and future config loading. -- UI, backend, Lua, timers, and app controls are event producers/consumers connected through the queue. +- UI, backend, Lua, timers, and app controls are event and action producers/consumers connected through the queue. This means the current `MainWindow` is an implementation detail, not the final shape of the application. @@ -53,7 +56,7 @@ When adding or refactoring code, keep these boundaries clear: - backend/service concerns: Keyboard emission, config loading, registries, state synchronization, and future Lua integration. - runtime/orchestration concerns: - Event queue ownership, command routing, callback scheduling, state store updates, and subsystem boundaries. + Event queue ownership, action routing, callback scheduling, state store updates, and subsystem boundaries. ## Preferred Direction For New Work @@ -62,8 +65,8 @@ When adding or refactoring code, keep these boundaries clear: - Prefer registries/factories over `if` ladders tied to one known surface. - Prefer interfaces that allow multiple instances of the same window/surface type. - Prefer names that describe reusable concepts like `surface`, `grid`, `panel`, `component`, or `controller` when accurate. -- Prefer event/command messages over direct calls between UI, backend, Lua, and application orchestration. -- Treat runtime events and commands as the default integration boundary between subsystems; direct calls are acceptable only inside one subsystem's own implementation or when adapting an event/command in the main runtime. +- Prefer event/action messages over direct calls between UI, backend, Lua, and application orchestration. +- Treat runtime events and actions as the default integration boundary between subsystems; direct calls are acceptable only inside one subsystem's own implementation or when adapting an event/action in the main runtime. - Prefer main-owned state updates that can be reset, replayed, logged, and cleaned up during config reloads or profile switches. ## Avoid @@ -75,7 +78,7 @@ When adding or refactoring code, keep these boundaries clear: - writing new code that makes multi-window composition harder - mixing backend emission logic into button rendering code - letting UI widgets directly invoke backend services or Lua callbacks when an event can be routed through the queue instead -- letting services directly invoke window managers, windows, widgets, backend adapters, or other services instead of emitting a runtime event or command +- letting services directly invoke window managers, windows, widgets, backend adapters, or other services instead of emitting a runtime event or action - adding hidden shared runtime state to reusable layouts; reused layouts should instantiate fresh runtime state ## Lua Readiness @@ -97,25 +100,53 @@ Bundled layouts such as the default US ISO keyboard should eventually be ordinar ## Queue And State Architecture -The target runtime architecture is queue-driven: +The runtime uses one synchronous first-in, first-out queue for events and actions. Producers add messages to the queue. The dispatcher drains them in order on the calling thread. A handler can return more events or actions, and the dispatcher appends those messages after the handler finishes. + +### Message Contract + +An event reports something that happened: + +RuntimeEvent(event="component.pressed", arguments={...}) + +An action requests an effect: + +RuntimeAction(action="window.show", arguments={...}) + +The name must be lowercase and dot-separated. The arguments must contain only native data that Lua and Python can exchange without live object references: null, booleans, finite numbers, strings, lists, and string-keyed maps. + +Queue messages must not contain Qt objects, backend objects, Python callbacks, Lua functions, dataclass instances, or other process-local values. Use stable IDs and native data. A subsystem can resolve an ID to an object only inside the registered handler that owns that subsystem. + +Configured behavior uses the same RuntimeAction shape as queued behavior. Keys, buttons, menu items, hot corners, timers, profile controls, and future Lua callbacks must not introduce parallel action formats. + +### Registration And Typing + +Every event and action name must be registered before use. A registration supplies an argument decoder and, for an action, its handler. Built-in definitions also provide typed argument records and typed constructors so Pyright checks repository-owned call sites. Lua-defined names remain open-ended and receive runtime checks from their registered decoders. + +Duplicate registration fails by default. An explicit override replaces the whole definition, including its decoder and handler. Code that overrides a built-in name is responsible for any incompatibility with existing producers. + +Handlers return an ordered list of follow-up RuntimeEvent and RuntimeAction messages. They must not call another subsystem or recursively dispatch messages. The main runtime may adapt a registered action into a concrete call on the window manager, state store, backend, or another runtime-owned service. + +### Failures And Ordering + +An unknown action, invalid action arguments, or an action-handler exception is logged and produces action.failed. The failure event contains the action name, original arguments, failure stage, exception type, and message. + +An unknown event, invalid event arguments, or an event-handler exception is logged. The dispatcher skips the remaining handlers for that event and continues with the queue. It does not emit a second failure event, which avoids recursive failure handling. + +The dispatcher warns after every 10,000 messages processed without returning. It does not stop the drain. Custom actions are allowed to produce unbounded work, so a cyclic action can keep the UI thread busy and produce unlimited logs. -- UI widgets emit interaction events into the queue. -- Backend/input services emit observed input or status events into the queue. -- Timers, app controls, profile switching, and config reloads emit events into the queue. -- The main runtime consumes ordered events, updates the main-owned state store, routes Lua callback work to the Lua actor, and applies returned commands through the queue. -- Lua callbacks do not directly mutate widgets, backend objects, or durable state. They receive context and event objects, then return or enqueue commands. +### Lua Boundary -Use this model even when the current implementation is still simpler. New work should move the app toward explicit events, commands, and state-store updates rather than direct object-to-object coupling. +Lua tables convert recursively to the native argument map. JSON text is not the queue format. Lua-defined actions register names, decoders, and callback references through the future Lua actor. The queue stores the reference and native arguments, never the Lua function itself. -When a subsystem observes something, it should emit an event DTO. When a subsystem wants something to happen, it should dispatch a command DTO. The main runtime/orchestration layer owns translating those events and commands into concrete calls on window managers, services, state stores, and platform adapters. Do not wire services directly to windows or window managers, and do not wire UI components directly to backend services, unless the call remains entirely inside the same subsystem and cannot reasonably cross the runtime event/command boundary. +Lua callbacks are deferred by default. They receive event or action context and return events or actions to the queue. They do not mutate widgets, backend objects, or durable state directly. Cancellation, replacement, and extension of default behavior must be represented by explicit queue data rather than direct cross-subsystem calls. -Durable state should be namespaced by app/profile/window/layout/component identity, but owned centrally by the main process. Components render state snapshots and emit events; they should not be the source of truth for application state. Profile switches, config reloads, and runtime resets should be able to cleanly discard all non-preserved state from this central store. +### State Ownership -Callbacks should be treated as deferred/asynchronous by default. Callback ordering must remain deterministic through the queue, and callbacks should be able to cancel or replace default behavior through explicit event/command APIs. +Durable state remains owned by the main runtime state store and is namespaced by app, profile, window, layout, and component identity. Components render state snapshots and emit events. Profile switches, config reloads, and runtime resets must be able to discard all non-preserved state without depending on widget or Lua closure lifetime. -User configs should be loaded from standard locations such as `XDG_CONFIG_HOME` or `~/.config` on Unix-like systems, and the usual per-user config location on Windows. Bundled configs should be used as fallback defaults and examples when user config is missing or invalid. +User configs should be loaded from standard locations such as XDG_CONFIG_HOME or ~/.config on Unix-like systems, and the usual per-user config location on Windows. Bundled configs should be fallback defaults and examples when user config is missing or invalid. -For more detailed Lua config and runtime architecture direction, refer to GitHub issue #8: `Define Lua config architecture`. +For more detailed Lua config direction, refer to GitHub issue #8: Define Lua config architecture. ## Practical Rule For Contributors diff --git a/README.md b/README.md index 7c6a64d..b27250e 100644 --- a/README.md +++ b/README.md @@ -150,48 +150,6 @@ What's planned: - config-driven composition of grids and layouts - more reusable grid and container primitives -## Contributing - -Changes should land through pull requests rather than direct pushes to `main`. - -Clone the repository with submodules: - -```bash -git clone --recurse-submodules https://github.com/axide-dev/axidev-osk.git -cd axidev-osk -``` - -For normal development, install the vendored input backend and this project into a local virtual environment: - -```bash -python -m venv .venv -.venv/bin/python -m pip install -e ./vendor/axidev-io-python -e . -``` - -Start the app from the checkout: - -```bash -PYTHONPATH=src .venv/bin/python -m axidev_osk -``` - -Read [`AGENTS.md`](./AGENTS.md) before structural changes. It documents the modular architecture rules. - -PR guidance: - -- keep each PR focused on one concern -- call out architectural impact when changing windows, grids, layouts, or orchestration -- note platform-specific behavior when Windows, X11, or Wayland changes - -### Commit Style - -Commits use this subject format: - -```text -type(scope): short imperative summary -``` - -Use lowercase `type` and `scope`. Keep the summary short and imperative. - ## License Axidev OSK is licensed under GPLv3. See [`LICENSE`](./LICENSE). diff --git a/pyproject.toml b/pyproject.toml index aa9cfe5..09deb47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,9 @@ where = ["src"] [tool.setuptools.package-data] "axidev_osk.assets" = ["*.ico", "*.svg"] + +[tool.pyright] +include = ["src/axidev_osk"] +typeCheckingMode = "standard" +pythonVersion = "3.10" +pythonPlatform = "All" diff --git a/src/axidev_osk/application/linux_permissions.py b/src/axidev_osk/application/linux_permissions.py index 7d34eb3..261db0c 100644 --- a/src/axidev_osk/application/linux_permissions.py +++ b/src/axidev_osk/application/linux_permissions.py @@ -16,7 +16,7 @@ from ..runtime.prompt import PromptResolutionWaiter if TYPE_CHECKING: - from ..config.models import AppConfig, WindowConfig + from ..config.models import AppConfig, PromptConfig, WindowConfig from ..runtime.dispatcher import Dispatcher from ..runtime.window_manager import WindowManager from ..services.keyboard import KeyboardService @@ -32,7 +32,7 @@ def __init__( dispatcher: "Dispatcher", keyboard: "KeyboardService", window_manager: "WindowManager", - build_prompt_window_config: Callable[[object], "WindowConfig"], + build_prompt_window_config: Callable[["PromptConfig"], "WindowConfig"], ) -> None: self._config = config self._dispatcher = dispatcher diff --git a/src/axidev_osk/cli/linux_greeter.py b/src/axidev_osk/cli/linux_greeter.py index 05c8072..ae92916 100644 --- a/src/axidev_osk/cli/linux_greeter.py +++ b/src/axidev_osk/cli/linux_greeter.py @@ -254,9 +254,12 @@ def _select_manager( key_reader: Callable[[], str] | None = None, output: TextIO | None = None, ) -> str: - output = output or sys.stdout + output = output if output is not None else sys.stdout + if output is None: + raise linux.LinuxSetupError("interactive selection requires an output stream") if key_reader is None: - if not sys.stdin.isatty() or not output.isatty(): + input_stream = sys.stdin + if input_stream is None or not input_stream.isatty() or not output.isatty(): raise linux.LinuxSetupError("--manager is required without an interactive terminal") key_reader = _terminal_key_reader @@ -287,10 +290,13 @@ def _select_manager( def _terminal_key_reader() -> str: if termios is None or tty is None: raise linux.LinuxSetupError("interactive selection is unavailable in this terminal") - descriptor = sys.stdin.fileno() - previous = termios.tcgetattr(descriptor) + input_stream = sys.stdin + if input_stream is None: + raise linux.LinuxSetupError("interactive selection requires an input stream") + descriptor = input_stream.fileno() + previous = termios.tcgetattr(descriptor) # type: ignore[attr-defined] try: - tty.setraw(descriptor) + tty.setraw(descriptor) # type: ignore[attr-defined] first = os.read(descriptor, 1) if first in {b"\r", b"\n"}: return "enter" @@ -304,7 +310,7 @@ def _terminal_key_reader() -> str: third = os.read(descriptor, 1) return {b"A": "up", b"B": "down"}.get(third, "unknown") finally: - termios.tcsetattr(descriptor, termios.TCSADRAIN, previous) + termios.tcsetattr(descriptor, termios.TCSADRAIN, previous) # type: ignore[attr-defined] def _installed_launcher() -> Path: @@ -974,7 +980,11 @@ def _read_process_environment(pid: int) -> dict[str, str] | None: def _install_signal_handlers(handler: Callable[[int, Any], None]) -> dict[int, Any]: previous = {} - for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signals = [signal.SIGTERM, signal.SIGINT] + sighup = getattr(signal, "SIGHUP", None) + if sighup is not None: + signals.append(sighup) + for signum in signals: previous[signum] = signal.signal(signum, handler) return previous diff --git a/src/axidev_osk/components/grid/keyboard.py b/src/axidev_osk/components/grid/keyboard.py index 553b263..0a72738 100644 --- a/src/axidev_osk/components/grid/keyboard.py +++ b/src/axidev_osk/components/grid/keyboard.py @@ -8,12 +8,29 @@ from PySide6.QtCore import QObject, Signal from PySide6.QtWidgets import QFrame, QGridLayout, QPushButton, QWidget -from ...config.models import GridConfig, KeyConfig, LayoutConfig +from ...config.models import GridConfig, KeyConfig, LayoutConfig, SpacerConfig from ...models import KeySpec -from ...runtime.commands import KeyboardKeyDown, KeyboardRegisterKeySpec, KeyboardKeyUp, KeyboardSyncLatchedKey, StateSet +from ...messages import MessageResult, RuntimeAction, RuntimeEvent +from ...runtime.actions import ( + keyboard_key_down, + keyboard_key_up, + keyboard_register_key_spec, + keyboard_sync_latched_key, + state_set, +) from ...runtime.context import Context from ...runtime.diagnostics import keyboard_debug_enabled -from ...runtime.events import BackendKeyRegistered, BackendKeyStateChanged, ComponentPressed, ComponentReleased, ComponentStateChanged, KeyLatchChanged +from ...runtime.events import ( + KEYBOARD_KEY_REGISTERED, + KEYBOARD_KEY_STATE_CHANGED, + KEYBOARD_LATCH_CHANGED, + KeyboardKeyRegisteredArguments, + KeyboardKeyStateChangedArguments, + KeyboardLatchChangedArguments, + component_pressed, + component_released, + component_state_changed, +) from ...runtime.identity import component_state_namespace, keyboard_key_states_namespace, keyboard_latches_namespace from ..button.key import create_key_button, set_key_button_label from ..button.state import KeyInteractionState, KeyStateChange, KeyStateMachine @@ -36,7 +53,7 @@ class KeyboardWidget(QFrame): """Keyboard grid component built from declarative layout data. The widget is a reusable composition primitive: it accepts a ``LayoutConfig`` - and uses the runtime ``Context`` (when present) to dispatch commands and + and uses the runtime ``Context`` (when present) to dispatch actions and events through the central runtime instead of calling backend services directly. @@ -90,7 +107,7 @@ def __init__( self._buttons_by_component_id: dict[str, QPushButton] = {} self._state_machines_by_key_id: dict[str, list[KeyStateMachine]] = {} self._key_state_bridge = _KeyStateBridge(self) - self._event_unsubscribe: Unsubscribe | None = None + self._event_unsubscribes: list[Unsubscribe] = [] self.setObjectName("keyboard") self.setProperty("componentType", "grid") @@ -180,7 +197,7 @@ def _count_occupied_columns(self, specs: list[KeySpec]) -> int: def _add_function_row( self, container: QGridLayout, - components: list[KeyConfig], + components: list[KeyConfig | SpacerConfig], *, nav_start_column: int, body_column_map: dict[int, int], @@ -215,7 +232,11 @@ def _add_function_row( ) container.addWidget(self._build_item(component), 0, dense_column, spec.height, column_span) - def _add_body_grid(self, container: QGridLayout, components: list[KeyConfig]) -> None: + def _add_body_grid( + self, + container: QGridLayout, + components: list[KeyConfig | SpacerConfig], + ) -> None: """Place body-row components using a dense column map.""" column_map = self._build_dense_column_map([component.spec for component in components]) @@ -225,7 +246,7 @@ def _add_body_grid(self, container: QGridLayout, components: list[KeyConfig]) -> dense_column = column_map[spec.column] container.addWidget(self._build_item(component), spec.row, dense_column, spec.height, column_span) - def _build_item(self, component: KeyConfig) -> QWidget: + def _build_item(self, component: KeyConfig | SpacerConfig) -> QWidget: """Build one child widget for the grid via the component registry. The keyboard widget passes itself as the explicit ``host`` so the @@ -346,22 +367,22 @@ def on_state_change( self._buttons_by_spec.append((button, spec)) self._buttons_by_component_id[component_id] = button if spec.action is None: - self._dispatch_command(KeyboardRegisterKeySpec(self._layout_config.id, component_id, spec)) + self._dispatch_action(keyboard_register_key_spec(self._layout_config.id, component_id, spec)) return button def _handle_key_press(self, component_id: str, spec: KeySpec) -> None: - """Dispatch a press event/command through the runtime.""" + """Dispatch a press event/action through the runtime.""" - self._dispatch_event(ComponentPressed(component_id=component_id, key_spec=spec)) + self._dispatch_event(component_pressed(component_id, spec.action)) if spec.action is None and not spec.holds_when_latched: - self._context.dispatcher.dispatch_command(KeyboardKeyDown(self._layout_config.id, spec, component_id)) + self._context.dispatcher.dispatch_action(keyboard_key_down(self._layout_config.id, component_id)) def _handle_key_release(self, component_id: str, spec: KeySpec) -> None: - """Dispatch a release event/command through the runtime.""" + """Dispatch a release event/action through the runtime.""" - self._dispatch_event(ComponentReleased(component_id=component_id)) + self._dispatch_event(component_released(component_id)) if spec.action is None and not spec.holds_when_latched: - self._context.dispatcher.dispatch_command(KeyboardKeyUp(self._layout_config.id, spec, component_id)) + self._context.dispatcher.dispatch_action(keyboard_key_up(self._layout_config.id, component_id)) def _handle_key_registered(self, layout_id: str, component_id: str, io_key_name: object) -> None: """Apply backend registration metadata returned through runtime events.""" @@ -401,23 +422,30 @@ def _subscribe_to_runtime_key_state(self) -> None: self._key_state_bridge.key_latch_changed.connect(self._handle_key_latch_change) self._key_state_bridge.key_registered.connect(self._handle_key_registered) - def handle_event(event: object) -> None: - if isinstance(event, BackendKeyRegistered): - self._key_state_bridge.key_registered.emit(event.layout_id, event.component_id, event.io_key_name) - elif isinstance(event, BackendKeyStateChanged): - self._key_state_bridge.key_state_changed.emit(event.layout_id, event.key_id, event.pressed, event.latched) - elif isinstance(event, KeyLatchChanged): - self._key_state_bridge.key_latch_changed.emit(event.layout_id, event.key_id, event.latched) - - self._event_unsubscribe = self._context.dispatcher.add_event_handler(handle_event) + self._event_unsubscribes = [ + self._context.dispatcher.add_event_handler(KEYBOARD_KEY_REGISTERED, self._receive_key_registered), + self._context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, self._receive_key_state_changed), + self._context.dispatcher.add_event_handler(KEYBOARD_LATCH_CHANGED, self._receive_latch_changed), + ] def _unsubscribe_from_runtime_key_state(self) -> None: """Detach runtime event handling when the widget is destroyed.""" - if self._event_unsubscribe is None: - return - self._event_unsubscribe() - self._event_unsubscribe = None + for unsubscribe in self._event_unsubscribes: + unsubscribe() + self._event_unsubscribes.clear() + + def _receive_key_registered(self, event: KeyboardKeyRegisteredArguments) -> MessageResult: + self._key_state_bridge.key_registered.emit(event.layout_id, event.component_id, event.io_key_name) + return [] + + def _receive_key_state_changed(self, event: KeyboardKeyStateChangedArguments) -> MessageResult: + self._key_state_bridge.key_state_changed.emit(event.layout_id, event.key_id, event.pressed, event.latched) + return [] + + def _receive_latch_changed(self, event: KeyboardLatchChangedArguments) -> MessageResult: + self._key_state_bridge.key_latch_changed.emit(event.layout_id, event.key_id, event.latched) + return [] def _handle_latch_state_change( self, @@ -439,7 +467,7 @@ def _handle_latch_state_change( None. Side effects: - Updates latched-key registry, dispatches state events/commands, + Updates latched-key registry, dispatches state events/actions, and synchronizes sibling latch buttons in the same group. """ @@ -468,10 +496,12 @@ def _handle_latch_state_change( } if previously_latched != currently_latched: - self._dispatch_event(ComponentStateChanged(component_id=component_id, key_id=key_id, latched=currently_latched)) - self._dispatch_command(StateSet(namespace=component_state_namespace(component_id), key="latched", value=currently_latched)) + self._dispatch_event(component_state_changed(component_id, key_id, currently_latched)) + self._dispatch_action(state_set(component_state_namespace(component_id), "latched", currently_latched)) if key_id not in self._syncing_latch_keys: - self._dispatch_command(KeyboardSyncLatchedKey(self._layout_config.id, spec, currently_latched, component_id)) + self._dispatch_action( + keyboard_sync_latched_key(self._layout_config.id, component_id, currently_latched) + ) self._syncing_latch_keys.add(key_id) try: @@ -484,9 +514,9 @@ def _handle_latch_state_change( if spec.holds_when_latched: if not change.previous.is_active and change.current.is_active: - self._dispatch_command(KeyboardKeyDown(self._layout_config.id, spec, component_id)) + self._dispatch_action(keyboard_key_down(self._layout_config.id, component_id)) elif change.previous.is_active and not change.current.is_active: - self._dispatch_command(KeyboardKeyUp(self._layout_config.id, spec, component_id)) + self._dispatch_action(keyboard_key_up(self._layout_config.id, component_id)) if previously_latched != currently_latched or spec.holds_when_latched: self._refresh_key_legends() @@ -508,15 +538,15 @@ def _refresh_key_legends(self) -> None: display = spec.resolve_display(active_modifiers) set_key_button_label(button, display.label, display.secondary_label) - def _dispatch_event(self, event: object) -> None: + def _dispatch_event(self, event: RuntimeEvent) -> None: """Forward an event to the runtime dispatcher.""" - self._context.dispatcher.dispatch_event(event) # type: ignore[arg-type] + self._context.dispatcher.dispatch_event(event) - def _dispatch_command(self, command: object) -> None: - """Forward a fire-and-forget command to the runtime dispatcher.""" + def _dispatch_action(self, action: RuntimeAction) -> None: + """Forward a fire-and-forget action to the runtime dispatcher.""" - self._context.dispatcher.dispatch_command(command) # type: ignore[arg-type] + self._context.dispatcher.dispatch_action(action) def _state_key_for_spec(self, spec: KeySpec) -> str | None: return spec.io_key or spec.label or spec.key_id diff --git a/src/axidev_osk/components/key/builder.py b/src/axidev_osk/components/key/builder.py index 19fdbca..f72c8b0 100644 --- a/src/axidev_osk/components/key/builder.py +++ b/src/axidev_osk/components/key/builder.py @@ -19,9 +19,11 @@ class KeyboardGridHost(Protocol): @property def key_metrics(self) -> KeyboardMetrics: """Pixel metrics inherited by child key/spacer components.""" + ... def build_key_from_config(self, config: KeyConfig, context: Context) -> QWidget: """Build a key child using the owning grid's runtime wiring.""" + ... def register(registry: ComponentRegistry) -> None: diff --git a/src/axidev_osk/components/prompt/builder.py b/src/axidev_osk/components/prompt/builder.py index eaae351..aab6c62 100644 --- a/src/axidev_osk/components/prompt/builder.py +++ b/src/axidev_osk/components/prompt/builder.py @@ -7,7 +7,7 @@ from ...config.models import ButtonConfig, ComponentConfig, PromptConfig from ...runtime.context import Context -from ...runtime.events import PromptResolved +from ...runtime.events import prompt_resolved from ...runtime.identity import prompt_button_id from ...runtime.registries import ComponentRegistry @@ -190,4 +190,4 @@ def _resolve_prompt(window_child: QWidget, context: Context, prompt_id: str, but """ del window_child - context.dispatcher.dispatch_event(PromptResolved(prompt_id=prompt_id, result=button.role)) + context.dispatcher.dispatch_event(prompt_resolved(prompt_id, button.role)) diff --git a/src/axidev_osk/config/defaults/us_iso.py b/src/axidev_osk/config/defaults/us_iso.py index 7dfc478..b3a5cd2 100644 --- a/src/axidev_osk/config/defaults/us_iso.py +++ b/src/axidev_osk/config/defaults/us_iso.py @@ -16,7 +16,11 @@ from __future__ import annotations -from ...models import KeyDisplay, KeySpec, WindowAction +from dataclasses import replace + +from ...messages import RuntimeAction +from ...models import KeyDisplay, KeySpec +from ...runtime.actions import window_toggle_opacity from ...runtime.identity import key_component_id, stable_id, validate_unique_ids from ..models import GridConfig, KeyConfig, LayoutConfig, SpacerConfig @@ -24,6 +28,8 @@ UNIT = 4 MAIN_BLOCK_WIDTH = 60 NAV_START = 64 +LAYOUT_ID = "layout:us-iso" +GRID_ID = "grid:us-iso:keyboard" def key( @@ -42,7 +48,7 @@ def key( honors_latched_modifiers: bool = True, repeats: bool = True, display_variants: tuple[KeyDisplay, ...] = (), - action: WindowAction | None = None, + action: RuntimeAction | None = None, ) -> KeySpec: """Build a key spec with default keyboard-layout behavior.""" @@ -102,6 +108,21 @@ def u(value: int) -> int: return value * UNIT +def _component_id(grid_id: str, spec: KeySpec) -> str: + kind = "spacer" if spec.is_spacer else "key" + return key_component_id( + grid_id, + kind, + row=spec.row, + column=spec.column, + width=spec.width, + height=spec.height, + key_id=spec.key_id, + io_key=spec.io_key, + label=spec.label, + ) + + def shifted_key( label: str, shifted_label: str, @@ -179,7 +200,7 @@ def letter_key( def build_us_iso_layout(*, target_window_id: str = "window:keyboard") -> list[KeySpec]: """Return the bundled US ISO layout as ordered key specs.""" - return [ + specs = [ key("Esc", row=0, column=u(0), io_key="Escape"), key("F1", row=0, column=u(2)), key("F2", row=0, column=u(3)), @@ -232,11 +253,6 @@ def build_us_iso_layout(*, target_window_id: str = "window:keyboard") -> list[Ke row=2, column=54, repeats=False, - action=WindowAction( - kind="toggle-opacity", - target_window_id=target_window_id, - opacity=0.01, - ), ), key("Del", row=2, column=NAV_START, io_key="Delete"), key("End", row=2, column=NAV_START + u(1), io_key="End"), @@ -319,6 +335,13 @@ def build_us_iso_layout(*, target_window_id: str = "window:keyboard") -> list[Ke key("↓", row=5, column=NAV_START + u(1), io_key="Down"), key("→", row=5, column=NAV_START + u(2), io_key="Right"), ] + ghost_index = next(index for index, spec in enumerate(specs) if spec.label == "Ghost") + ghost = specs[ghost_index] + specs[ghost_index] = replace( + ghost, + action=window_toggle_opacity(target_window_id, _component_id(GRID_ID, ghost), 0.01), + ) + return specs def build_us_iso_layout_config( @@ -338,22 +361,11 @@ def build_us_iso_layout_config( Raises ``ValueError`` if deterministic IDs collide. """ - layout_id = stable_id(parent_id, "layout", "us_iso", stable_override="layout:us-iso") - grid_id = stable_id(layout_id, "grid", "keyboard", stable_override="grid:us-iso:keyboard") + layout_id = stable_id(parent_id, "layout", "us_iso", stable_override=LAYOUT_ID) + grid_id = stable_id(layout_id, "grid", "keyboard", stable_override=GRID_ID) components: list[KeyConfig | SpacerConfig] = [] for spec in build_us_iso_layout(target_window_id=target_window_id): - kind = "spacer" if spec.is_spacer else "key" - component_id = key_component_id( - grid_id, - kind, - row=spec.row, - column=spec.column, - width=spec.width, - height=spec.height, - key_id=spec.key_id, - io_key=spec.io_key, - label=spec.label, - ) + component_id = _component_id(grid_id, spec) if spec.is_spacer: components.append(SpacerConfig(id=component_id, spec=spec)) continue diff --git a/src/axidev_osk/config/models.py b/src/axidev_osk/config/models.py index acc829d..5d387bd 100644 --- a/src/axidev_osk/config/models.py +++ b/src/axidev_osk/config/models.py @@ -146,6 +146,10 @@ def __post_init__(self) -> None: """Validate IDs at the layout composition boundary.""" validate_unique_ids((grid.id for grid in self.grids), scope=f"layout {self.id!r} grids") + validate_unique_ids( + (component.id for grid in self.grids for component in grid.components), + scope=f"layout {self.id!r} components", + ) @dataclass(frozen=True, slots=True) diff --git a/src/axidev_osk/hot_corner/controller.py b/src/axidev_osk/hot_corner/controller.py index ec2c21f..f653a12 100644 --- a/src/axidev_osk/hot_corner/controller.py +++ b/src/axidev_osk/hot_corner/controller.py @@ -1,7 +1,7 @@ """Hot-corner dwell trigger that emits runtime events. TEMPORARY: this subsystem currently lives outside the main runtime -event/command queue and talks to its own overlays directly. It is +event/action queue and talks to its own overlays directly. It is intentionally kept self-contained so it can be ported to per-corner events through the central runtime queue later (see issue #8). New features should not extend this controller — add them through the @@ -22,6 +22,7 @@ import time from dataclasses import dataclass from enum import Enum +from typing import Protocol from PySide6.QtCore import QMargins, QObject, QPoint, QRect, QRectF, QSize, QTimer, Qt, Signal from PySide6.QtGui import QColor, QCursor, QGuiApplication, QPainter, QPaintEvent, QPen, QScreen @@ -29,7 +30,7 @@ from ..config.models import HotCornerConfig from ..runtime.dispatcher import Dispatcher -from ..runtime.events import HotCornerTriggered +from ..runtime.events import hot_corner_triggered from ..platform.layer_shell import ( ANCHOR_BOTTOM, ANCHOR_LEFT, @@ -163,7 +164,7 @@ def _current_screen_geometry(self) -> QRect: screen = self._window.screen() if screen is None: app = QGuiApplication.instance() - screen = app.primaryScreen() if app is not None else None + screen = app.primaryScreen() if isinstance(app, QGuiApplication) else None if screen is None: return QRect(0, 0, 1920, 1080) return screen.geometry() @@ -178,6 +179,14 @@ class ScreenCorner(str, Enum): BOTTOM_RIGHT = "bottom_right" +class HotCornerOverlay(Protocol): + """Overlay operations used by indicator and sensor windows.""" + + def handle_show(self) -> bool: ... + + def move_to(self, position: QPoint, *, screen_geometry: QRect | None = None) -> None: ... + + @dataclass(slots=True) class HotCornerSensorHandle: """Runtime objects owned for a single hot-corner sensor window. @@ -192,7 +201,7 @@ class HotCornerSensorHandle: corner: ScreenCorner screen: QScreen window: "HotCornerSensorWindow" - overlay: object + overlay: HotCornerOverlay class HotCornerIndicator(QWidget): @@ -402,7 +411,7 @@ def _poll_active_sensor(self) -> None: self._emit_hot_corner_triggered(self._active_corner) def _emit_hot_corner_triggered(self, corner: ScreenCorner) -> None: - self._dispatcher.dispatch_event(HotCornerTriggered(corner=corner.value)) + self._dispatcher.dispatch_event(hot_corner_triggered(corner.value)) def _reset_corner_tracking(self) -> None: self._active_corner = None @@ -414,7 +423,7 @@ def _detect_corner(self, cursor_pos: QPoint) -> ScreenCorner | None: screen = QGuiApplication.screenAt(cursor_pos) if screen is None: app = QGuiApplication.instance() - screen = app.primaryScreen() if app is not None else None + screen = app.primaryScreen() if isinstance(app, QGuiApplication) else None if screen is None: return None @@ -447,7 +456,7 @@ def _show_indicator( screen = QGuiApplication.screenAt(cursor_pos) if screen is None: app = QGuiApplication.instance() - screen = app.primaryScreen() if app is not None else None + screen = app.primaryScreen() if isinstance(app, QGuiApplication) else None if screen is None: self._indicator.hide() return @@ -496,7 +505,7 @@ def _sensor_position(self, geometry: QRect, corner: ScreenCorner) -> QPoint: def _create_sensor_handles(self) -> list[HotCornerSensorHandle]: handles: list[HotCornerSensorHandle] = [] app = QGuiApplication.instance() - screens = app.screens() if app is not None else [] + screens = app.screens() if isinstance(app, QGuiApplication) else [] for screen in screens: for corner in ScreenCorner: sensor_window = HotCornerSensorWindow(size_px=self._config.corner_size_px) diff --git a/src/axidev_osk/messages.py b/src/axidev_osk/messages.py new file mode 100644 index 0000000..c694786 --- /dev/null +++ b/src/axidev_osk/messages.py @@ -0,0 +1,83 @@ +"""Generic native-data messages shared by config and runtime code.""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from typing import TypeAlias + + +DataValue: TypeAlias = None | bool | int | float | str | list["DataValue"] | dict[str, "DataValue"] +DataMap: TypeAlias = dict[str, DataValue] + +_MESSAGE_NAME = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") + + +def copy_data_map(value: object) -> DataMap: + """Validate and recursively copy a native-data map.""" + + copied = _copy_data_value(value, path="arguments") + if not isinstance(copied, dict): + raise TypeError("arguments must be a map") + return copied + + +def _copy_data_value(value: object, *, path: str) -> DataValue: + if value is None or isinstance(value, (bool, str)): + return value + if isinstance(value, int): + return value + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError(f"{path} must contain only finite numbers") + return value + if isinstance(value, list): + return [_copy_data_value(item, path=f"{path}[{index}]") for index, item in enumerate(value)] + if isinstance(value, dict): + copied: DataMap = {} + for key, item in value.items(): + if not isinstance(key, str): + raise TypeError(f"{path} keys must be strings") + copied[key] = _copy_data_value(item, path=f"{path}.{key}") + return copied + raise TypeError(f"{path} contains unsupported value {type(value).__name__}") + + +def _validate_message_name(name: str, *, field: str) -> None: + if not _MESSAGE_NAME.fullmatch(name): + raise ValueError(f"{field} must be a lowercase dot-separated name") + + +@dataclass(frozen=True, slots=True) +class RuntimeAction: + """A queue-ready request to execute a registered action.""" + + action: str + arguments: DataMap + + def __post_init__(self) -> None: + _validate_message_name(self.action, field="action") + object.__setattr__(self, "arguments", copy_data_map(self.arguments)) + + +@dataclass(frozen=True, slots=True) +class RuntimeEvent: + """A queue-ready observation delivered to registered event handlers.""" + + event: str + arguments: DataMap + + def __post_init__(self) -> None: + _validate_message_name(self.event, field="event") + object.__setattr__(self, "arguments", copy_data_map(self.arguments)) + + +RuntimeMessage: TypeAlias = RuntimeAction | RuntimeEvent +MessageResult: TypeAlias = list[RuntimeMessage] + + +def runtime_action_to_data(action: RuntimeAction) -> DataMap: + """Encode a runtime action as native data.""" + + return {"action": action.action, "arguments": action.arguments} diff --git a/src/axidev_osk/models.py b/src/axidev_osk/models.py index 45b5c7f..954bf3e 100644 --- a/src/axidev_osk/models.py +++ b/src/axidev_osk/models.py @@ -3,26 +3,8 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Literal - -@dataclass(frozen=True) -class WindowAction: - """Declarative action targeting a configured window.""" - - kind: Literal["toggle-opacity"] - target_window_id: str - opacity: float = 0.01 - - def __post_init__(self) -> None: - """Validate values before the action reaches runtime routing.""" - - if self.kind != "toggle-opacity": - raise ValueError(f"Unsupported window action kind: {self.kind!r}") - if not self.target_window_id.strip(): - raise ValueError("Window action target ID must not be empty") - if not 0.0 <= self.opacity < 1.0: - raise ValueError("Window action opacity must be at least 0.0 and less than 1.0") +from .messages import DataMap, DataValue, RuntimeAction, runtime_action_to_data @dataclass(frozen=True) @@ -78,7 +60,7 @@ class KeySpec: honors_latched_modifiers: bool = True repeats: bool = True display_variants: tuple[KeyDisplay, ...] = () - action: WindowAction | None = None + action: RuntimeAction | None = None def __post_init__(self) -> None: """Reject action keys with conflicting keyboard behavior.""" @@ -113,3 +95,42 @@ def resolve_display(self, active_modifiers: frozenset[str]) -> KeyDisplay: return best_match return KeyDisplay(label=self.label, secondary_label=self.secondary_label) + + +def key_spec_to_data(spec: KeySpec) -> DataMap: + """Encode a key specification as queue-safe native data.""" + + display_variants: list[DataValue] = [] + for variant in spec.display_variants: + required_modifiers: list[DataValue] = [] + required_modifiers.extend(sorted(variant.requires_modifiers)) + excluded_modifiers: list[DataValue] = [] + excluded_modifiers.extend(sorted(variant.excludes_modifiers)) + display_variant: DataMap = { + "label": variant.label, + "secondary_label": variant.secondary_label, + "requires_modifiers": required_modifiers, + "excludes_modifiers": excluded_modifiers, + } + display_variants.append(display_variant) + action_data: DataMap | None = None + if spec.action is not None: + action_data = runtime_action_to_data(spec.action) + data: DataMap = { + "label": spec.label, + "row": spec.row, + "column": spec.column, + "width": spec.width, + "height": spec.height, + "is_spacer": spec.is_spacer, + "secondary_label": spec.secondary_label, + "key_id": spec.key_id, + "latchable": spec.latchable, + "io_key": spec.io_key, + "holds_when_latched": spec.holds_when_latched, + "honors_latched_modifiers": spec.honors_latched_modifiers, + "repeats": spec.repeats, + "display_variants": display_variants, + "action": action_data, + } + return data diff --git a/src/axidev_osk/runtime/actions.py b/src/axidev_osk/runtime/actions.py new file mode 100644 index 0000000..1052dc7 --- /dev/null +++ b/src/axidev_osk/runtime/actions.py @@ -0,0 +1,205 @@ +"""Typed constructors and decoders for built-in runtime actions.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from ..messages import DataMap, DataValue, RuntimeAction +from ..models import KeySpec, key_spec_to_data +from .decoding import ( + bool_value, + data_value, + int_value, + key_spec_from_data, + map_value, + non_empty_string_value, + number_value, + require_keys, +) + +APP_QUIT = "app.quit" +KEYBOARD_KEY_DOWN = "keyboard.key_down" +KEYBOARD_KEY_UP = "keyboard.key_up" +KEYBOARD_REGISTER_KEY_SPEC = "keyboard.register_key_spec" +KEYBOARD_SYNC_LATCHED_KEY = "keyboard.sync_latched_key" +STATE_SET = "state.set" +WINDOW_CLOSE = "window.close" +WINDOW_HIDE = "window.hide" +WINDOW_SHOW = "window.show" +WINDOW_TOGGLE_OPACITY = "window.toggle_opacity" + + +@dataclass(frozen=True, slots=True) +class AppQuitArguments: + exit_code: int + + +@dataclass(frozen=True, slots=True) +class KeyboardRegisterKeySpecArguments: + layout_id: str + component_id: str + key_spec: KeySpec + + +@dataclass(frozen=True, slots=True) +class KeyboardKeyArguments: + layout_id: str + component_id: str + + +@dataclass(frozen=True, slots=True) +class KeyboardSyncLatchedKeyArguments: + layout_id: str + component_id: str + latched: bool + + +@dataclass(frozen=True, slots=True) +class StateSetArguments: + namespace: str + key: str + value: DataValue + + +@dataclass(frozen=True, slots=True) +class WindowArguments: + window_id: str + + +@dataclass(frozen=True, slots=True) +class WindowToggleOpacityArguments: + window_id: str + component_id: str + opacity: float + + +def app_quit(exit_code: int = 0) -> RuntimeAction: + return _validated_action(APP_QUIT, {"exit_code": exit_code}, decode_app_quit) + + +def keyboard_register_key_spec(layout_id: str, component_id: str, key_spec: KeySpec) -> RuntimeAction: + return _validated_action( + KEYBOARD_REGISTER_KEY_SPEC, + {"layout_id": layout_id, "component_id": component_id, "key_spec": key_spec_to_data(key_spec)}, + decode_keyboard_register_key_spec, + ) + + +def keyboard_key_down(layout_id: str, component_id: str) -> RuntimeAction: + return _validated_action( + KEYBOARD_KEY_DOWN, + {"layout_id": layout_id, "component_id": component_id}, + decode_keyboard_key, + ) + + +def keyboard_key_up(layout_id: str, component_id: str) -> RuntimeAction: + return _validated_action( + KEYBOARD_KEY_UP, + {"layout_id": layout_id, "component_id": component_id}, + decode_keyboard_key, + ) + + +def keyboard_sync_latched_key(layout_id: str, component_id: str, latched: bool) -> RuntimeAction: + return _validated_action( + KEYBOARD_SYNC_LATCHED_KEY, + {"layout_id": layout_id, "component_id": component_id, "latched": latched}, + decode_keyboard_sync_latched_key, + ) + + +def state_set(namespace: str, key: str, value: DataValue) -> RuntimeAction: + return _validated_action( + STATE_SET, + {"namespace": namespace, "key": key, "value": value}, + decode_state_set, + ) + + +def window_show(window_id: str) -> RuntimeAction: + return _validated_action(WINDOW_SHOW, {"window_id": window_id}, decode_window) + + +def window_hide(window_id: str) -> RuntimeAction: + return _validated_action(WINDOW_HIDE, {"window_id": window_id}, decode_window) + + +def window_close(window_id: str) -> RuntimeAction: + return _validated_action(WINDOW_CLOSE, {"window_id": window_id}, decode_window) + + +def window_toggle_opacity(window_id: str, component_id: str, opacity: float) -> RuntimeAction: + return _validated_action( + WINDOW_TOGGLE_OPACITY, + {"window_id": window_id, "component_id": component_id, "opacity": opacity}, + decode_window_toggle_opacity, + ) + + +def decode_app_quit(arguments: DataMap) -> AppQuitArguments: + require_keys(arguments, ("exit_code",)) + return AppQuitArguments(exit_code=int_value(arguments, "exit_code")) + + +def decode_keyboard_register_key_spec(arguments: DataMap) -> KeyboardRegisterKeySpecArguments: + require_keys(arguments, ("layout_id", "component_id", "key_spec")) + return KeyboardRegisterKeySpecArguments( + layout_id=non_empty_string_value(arguments, "layout_id"), + component_id=non_empty_string_value(arguments, "component_id"), + key_spec=key_spec_from_data(map_value(arguments, "key_spec")), + ) + + +def decode_keyboard_key(arguments: DataMap) -> KeyboardKeyArguments: + require_keys(arguments, ("layout_id", "component_id")) + return KeyboardKeyArguments( + layout_id=non_empty_string_value(arguments, "layout_id"), + component_id=non_empty_string_value(arguments, "component_id"), + ) + + +def decode_keyboard_sync_latched_key(arguments: DataMap) -> KeyboardSyncLatchedKeyArguments: + require_keys(arguments, ("layout_id", "component_id", "latched")) + return KeyboardSyncLatchedKeyArguments( + layout_id=non_empty_string_value(arguments, "layout_id"), + component_id=non_empty_string_value(arguments, "component_id"), + latched=bool_value(arguments, "latched"), + ) + + +def decode_state_set(arguments: DataMap) -> StateSetArguments: + require_keys(arguments, ("namespace", "key", "value")) + return StateSetArguments( + namespace=non_empty_string_value(arguments, "namespace"), + key=non_empty_string_value(arguments, "key"), + value=data_value(arguments, "value"), + ) + + +def decode_window(arguments: DataMap) -> WindowArguments: + require_keys(arguments, ("window_id",)) + return WindowArguments(window_id=non_empty_string_value(arguments, "window_id")) + + +def decode_window_toggle_opacity(arguments: DataMap) -> WindowToggleOpacityArguments: + require_keys(arguments, ("window_id", "component_id", "opacity")) + opacity = number_value(arguments, "opacity") + if not 0.0 <= opacity < 1.0: + raise ValueError("Argument 'opacity' must be at least 0.0 and less than 1.0") + return WindowToggleOpacityArguments( + window_id=non_empty_string_value(arguments, "window_id"), + component_id=non_empty_string_value(arguments, "component_id"), + opacity=opacity, + ) + + +def _validated_action( + name: str, + arguments: DataMap, + decoder: Callable[[DataMap], object], +) -> RuntimeAction: + action = RuntimeAction(name, arguments) + decoder(action.arguments) + return action diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index 4e95658..302b4d3 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -8,6 +8,7 @@ from PySide6.QtCore import QEventLoop from PySide6.QtWidgets import QApplication, QWidget +from ..messages import MessageResult from ..application.linux_permissions import LinuxPermissionController from ..application.quit_controller import ApplicationQuitController from ..components import register_components @@ -20,12 +21,17 @@ from .context import Context from .dispatcher import Dispatcher from .event_handlers import ( - register_context_command_handlers, + register_context_action_handlers, register_event_handlers, route_component_pressed, route_hot_corner_triggered, ) -from .events import WindowCloseRequested +from .events import ( + ComponentPressedArguments, + HotCornerTriggeredArguments, + WindowCloseRequestedArguments, + register_builtin_events, +) from .prompt import PromptResolutionWaiter from .registries import ComponentRegistry, EventHandlerRegistry, ServiceRegistry, SurfaceRegistry from .state_store import StateStore @@ -63,6 +69,7 @@ def __init__( self._app = app self._config = config or build_default_app_config() self._dispatcher = Dispatcher() + register_builtin_events(self._dispatcher) self._services = services or ServiceRegistry() if services is None: register_services(self._services, parent=app) @@ -83,9 +90,8 @@ def __init__( components=self._components, surfaces=self._surfaces, ) - self._dispatcher.bind_context(self.context) context_handlers = EventHandlerRegistry() - register_context_command_handlers(context_handlers) + register_context_action_handlers(context_handlers) context_handlers.install(self._dispatcher, self.context) self._window_manager = WindowManager(self.context) self._event_handlers.install(self._dispatcher, self) @@ -127,8 +133,8 @@ def start(self) -> int: self._linux_permissions.prompt_if_needed() return self._app.exec() - def _handle_window_close_requested(self, event: object) -> None: - """Route ``WindowCloseRequested`` events to the quit controller. + def _handle_window_close_requested(self, event: WindowCloseRequestedArguments) -> MessageResult: + """Route close-request events to the quit controller. Args: event: Any runtime event; non-matching events are ignored. @@ -141,18 +147,19 @@ def _handle_window_close_requested(self, event: object) -> None: event is a ``WindowCloseRequested``. """ - if isinstance(event, WindowCloseRequested): - self._quit_controller.request_quit() + del event + self._quit_controller.request_quit() + return [] - def _handle_hot_corner_triggered(self, event: object) -> None: - """Map hot-corner events to managed window visibility commands.""" + def _handle_hot_corner_triggered(self, event: HotCornerTriggeredArguments) -> MessageResult: + """Map hot-corner events to managed window visibility actions.""" - route_hot_corner_triggered(event, self) + return route_hot_corner_triggered(event, self) - def _handle_component_pressed(self, event: object) -> None: - """Map configured component actions to runtime commands.""" + def _handle_component_pressed(self, event: ComponentPressedArguments) -> MessageResult: + """Map configured component actions to runtime actions.""" - route_component_pressed(event, self) + return route_component_pressed(event, self) def _show_quit_prompt(self, parent: QWidget | None) -> bool: prompt_config = self._config.quit_prompt diff --git a/src/axidev_osk/runtime/commands.py b/src/axidev_osk/runtime/commands.py deleted file mode 100644 index 584fa69..0000000 --- a/src/axidev_osk/runtime/commands.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Queue-ready command DTOs applied by services and runtime controllers.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from ..models import KeySpec - - -@dataclass(frozen=True, slots=True) -class KeyboardRegisterKeySpec: - """Command registering a key for backend state observation. - - Attributes: - layout_id: Deterministic keyboard layout instance ID. - component_id: Deterministic key component ID. - key_spec: Key semantics to observe. - """ - - layout_id: str - component_id: str - key_spec: KeySpec - - -@dataclass(frozen=True, slots=True) -class KeyboardKeyDown: - """Command requesting keyboard output for a key press. - - Attributes: - layout_id: Deterministic keyboard layout instance ID. - key_spec: Key semantics to emit. - """ - - layout_id: str - key_spec: KeySpec - component_id: str | None = None - - -@dataclass(frozen=True, slots=True) -class KeyboardKeyUp: - """Command requesting release of a key press owned by the keyboard service. - - Attributes: - layout_id: Deterministic keyboard layout instance ID. - key_spec: Key semantics to release. - """ - - layout_id: str - key_spec: KeySpec - component_id: str | None = None - - -@dataclass(frozen=True, slots=True) -class KeyboardSyncLatchedKey: - """Command requesting backend synchronization for a latched modifier. - - Attributes: - layout_id: Deterministic keyboard layout instance ID. - key_spec: Latchable key semantics. - latched: Desired latched state. - """ - - layout_id: str - key_spec: KeySpec - latched: bool - component_id: str | None = None - - -@dataclass(frozen=True, slots=True) -class StateSet: - """Command storing durable runtime state in the central state store. - - Attributes: - namespace: State namespace, usually profile/window/layout/component identity. - key: State key inside the namespace. - value: Serializable state value. - """ - - namespace: str - key: str - value: object - - -@dataclass(frozen=True, slots=True) -class WindowShow: - """Command requesting a managed window to be shown. - - Attributes: - window_id: Deterministic window ID. - """ - - window_id: str - - -@dataclass(frozen=True, slots=True) -class WindowHide: - """Command requesting a managed window to be hidden. - - Attributes: - window_id: Deterministic window ID. - """ - - window_id: str - - -@dataclass(frozen=True, slots=True) -class WindowToggleOpacity: - """Command toggling a managed window's low-opacity input-blocking mode.""" - - window_id: str - component_id: str - opacity: float - - -@dataclass(frozen=True, slots=True) -class WindowClose: - """Command requesting a managed window to be closed. - - Attributes: - window_id: Deterministic window ID. - """ - - window_id: str - - -@dataclass(frozen=True, slots=True) -class AppQuit: - """Command requesting application shutdown. - - Attributes: - exit_code: Process exit code supplied to QApplication. - """ - - exit_code: int = 0 - - -RuntimeCommand = KeyboardRegisterKeySpec | KeyboardKeyDown | KeyboardKeyUp | KeyboardSyncLatchedKey | StateSet | WindowShow | WindowHide | WindowToggleOpacity | WindowClose | AppQuit diff --git a/src/axidev_osk/runtime/context.py b/src/axidev_osk/runtime/context.py index befb144..230b650 100644 --- a/src/axidev_osk/runtime/context.py +++ b/src/axidev_osk/runtime/context.py @@ -20,7 +20,7 @@ class Context: Attributes: config: Loaded declarative app config. - dispatcher: Synchronous dispatcher with queue-ready command/event shape. + dispatcher: Synchronous dispatcher with queue-ready action/event shape. keyboard: Keyboard service wrapping backend access. state: Central state store. components: Component builder registry. diff --git a/src/axidev_osk/runtime/decoding.py b/src/axidev_osk/runtime/decoding.py new file mode 100644 index 0000000..4c5f98f --- /dev/null +++ b/src/axidev_osk/runtime/decoding.py @@ -0,0 +1,164 @@ +"""Small native-data decoders used by registered runtime messages.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from ..messages import DataMap, DataValue, RuntimeAction +from ..models import KeyDisplay, KeySpec + + +def require_keys(arguments: DataMap, required: Iterable[str], *, optional: Iterable[str] = ()) -> None: + """Require exactly the declared argument keys.""" + + required_set = set(required) + allowed = required_set | set(optional) + missing = required_set - arguments.keys() + unexpected = arguments.keys() - allowed + if missing: + raise ValueError(f"Missing arguments: {', '.join(sorted(missing))}") + if unexpected: + raise ValueError(f"Unexpected arguments: {', '.join(sorted(unexpected))}") + + +def string_value(arguments: DataMap, key: str) -> str: + value = arguments[key] + if not isinstance(value, str): + raise TypeError(f"Argument {key!r} must be a string") + return value + + +def non_empty_string_value(arguments: DataMap, key: str) -> str: + value = string_value(arguments, key) + if not value.strip(): + raise ValueError(f"Argument {key!r} must not be empty") + return value + + +def optional_string_value(arguments: DataMap, key: str) -> str | None: + value = arguments[key] + if value is not None and not isinstance(value, str): + raise TypeError(f"Argument {key!r} must be a string or null") + return value + + +def bool_value(arguments: DataMap, key: str) -> bool: + value = arguments[key] + if not isinstance(value, bool): + raise TypeError(f"Argument {key!r} must be a boolean") + return value + + +def int_value(arguments: DataMap, key: str) -> int: + value = arguments[key] + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"Argument {key!r} must be an integer") + return value + + +def number_value(arguments: DataMap, key: str) -> float: + value = arguments[key] + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"Argument {key!r} must be a number") + return float(value) + + +def map_value(arguments: DataMap, key: str) -> DataMap: + value = arguments[key] + if not isinstance(value, dict): + raise TypeError(f"Argument {key!r} must be a map") + return value + + +def data_value(arguments: DataMap, key: str) -> DataValue: + return arguments[key] + + +def runtime_action_from_data(arguments: DataMap) -> RuntimeAction: + """Decode a native-data runtime action.""" + + require_keys(arguments, ("action", "arguments")) + return RuntimeAction( + action=string_value(arguments, "action"), + arguments=map_value(arguments, "arguments"), + ) + + +def key_spec_from_data(arguments: DataMap) -> KeySpec: + """Decode a native-data key specification.""" + + require_keys( + arguments, + ( + "label", + "row", + "column", + "width", + "height", + "is_spacer", + "secondary_label", + "key_id", + "latchable", + "io_key", + "holds_when_latched", + "honors_latched_modifiers", + "repeats", + "display_variants", + "action", + ), + ) + variants_value = arguments["display_variants"] + if not isinstance(variants_value, list): + raise TypeError("Argument 'display_variants' must be a list") + variants: list[KeyDisplay] = [] + for index, value in enumerate(variants_value): + if not isinstance(value, dict): + raise TypeError(f"Display variant {index} must be a map") + require_keys( + value, + ("label", "secondary_label", "requires_modifiers", "excludes_modifiers"), + ) + required = _string_set(value, "requires_modifiers") + excluded = _string_set(value, "excludes_modifiers") + variants.append( + KeyDisplay( + label=string_value(value, "label"), + secondary_label=optional_string_value(value, "secondary_label"), + requires_modifiers=required, + excludes_modifiers=excluded, + ) + ) + + action_value = arguments["action"] + action: RuntimeAction | None = None + if action_value is not None: + if not isinstance(action_value, dict): + raise TypeError("Argument 'action' must be a map or null") + action = runtime_action_from_data(action_value) + + return KeySpec( + label=string_value(arguments, "label"), + row=int_value(arguments, "row"), + column=int_value(arguments, "column"), + width=number_value(arguments, "width"), + height=int_value(arguments, "height"), + is_spacer=bool_value(arguments, "is_spacer"), + secondary_label=optional_string_value(arguments, "secondary_label"), + key_id=optional_string_value(arguments, "key_id"), + latchable=bool_value(arguments, "latchable"), + io_key=optional_string_value(arguments, "io_key"), + holds_when_latched=bool_value(arguments, "holds_when_latched"), + honors_latched_modifiers=bool_value(arguments, "honors_latched_modifiers"), + repeats=bool_value(arguments, "repeats"), + display_variants=tuple(variants), + action=action, + ) + + +def _string_set(arguments: DataMap, key: str) -> frozenset[str]: + value = arguments[key] + if not isinstance(value, list): + raise TypeError(f"Argument {key!r} must be a list") + if not all(isinstance(item, str) for item in value): + raise TypeError(f"Argument {key!r} must contain only strings") + return frozenset(item for item in value if isinstance(item, str)) diff --git a/src/axidev_osk/runtime/dispatcher.py b/src/axidev_osk/runtime/dispatcher.py index 851bca0..1dea52e 100644 --- a/src/axidev_osk/runtime/dispatcher.py +++ b/src/axidev_osk/runtime/dispatcher.py @@ -1,151 +1,183 @@ -"""Synchronous event and command dispatcher with queue-ready DTO boundaries.""" +"""Registered generic messages routed through one synchronous FIFO queue.""" from __future__ import annotations +import logging +from collections import deque from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Generic, TypeVar, cast -from typing import TYPE_CHECKING +from ..messages import DataMap, MessageResult, RuntimeAction, RuntimeEvent, RuntimeMessage -from .commands import RuntimeCommand -from .events import RuntimeEvent -if TYPE_CHECKING: - from .context import Context - - -EventHandler = Callable[[RuntimeEvent], object | None] -CommandHandler = Callable[[RuntimeCommand], object | None] +DecodedT = TypeVar("DecodedT") +Decoder = Callable[[DataMap], DecodedT] +MessageHandler = Callable[[DecodedT], MessageResult] Unsubscribe = Callable[[], None] +_logger = logging.getLogger(__name__) +_DRAIN_WARNING_INTERVAL = 10_000 -class Dispatcher: - """Routes runtime events and commands synchronously for now. - - The public shape accepts DTOs and applies commands without returning service - results, which keeps UI code independent from direct service calls and allows - a later async queue swap. - """ - - def __init__(self) -> None: - """Create an unbound dispatcher. - - Args: - None. - - Returns: - None. - - Side effects: - None. - """ - self._context: Context | None = None - self._event_handlers: list[EventHandler] = [] - self._command_handlers: dict[type[object], CommandHandler] = {} +@dataclass(slots=True) +class _ActionDefinition(Generic[DecodedT]): + decoder: Decoder[DecodedT] + handler: MessageHandler[DecodedT] - def bind_context(self, context: "Context") -> None: - """Bind the main context after all runtime objects are created. - Args: - context: Runtime context. +@dataclass(slots=True) +class _EventDefinition(Generic[DecodedT]): + decoder: Decoder[DecodedT] + handlers: list[MessageHandler[DecodedT]] = field(default_factory=list) - Returns: - None. - Side effects: - Stores the context for diagnostics and future queue ownership. - """ - - self._context = context - - def add_event_handler(self, handler: EventHandler) -> Unsubscribe: - """Register an event observer. - - Args: - handler: Callable invoked for every dispatched event. - - Returns: - Callable that removes the handler when invoked. - - Side effects: - Mutates dispatcher handler list. - """ +class Dispatcher: + """Own registered message definitions and drain them in FIFO order.""" - self._event_handlers.append(handler) + def __init__(self) -> None: + self._actions: dict[str, _ActionDefinition[object]] = {} + self._events: dict[str, _EventDefinition[object]] = {} + self._queue: deque[RuntimeMessage] = deque() + self._draining = False + + def register_action( + self, + name: str, + decoder: Decoder[DecodedT], + handler: MessageHandler[DecodedT], + *, + override: bool = False, + ) -> None: + """Register one action definition, optionally replacing it in full.""" + + if name in self._actions and not override: + raise ValueError(f"Action {name!r} is already registered") + if name in self._actions: + _logger.warning("Overriding registered action %s", name) + definition = _ActionDefinition(decoder=decoder, handler=handler) + self._actions[name] = cast(_ActionDefinition[object], definition) + + def register_event( + self, + name: str, + decoder: Decoder[DecodedT], + *, + override: bool = False, + ) -> None: + """Register one event definition, optionally replacing it in full.""" + + if name in self._events and not override: + raise ValueError(f"Event {name!r} is already registered") + if name in self._events: + _logger.warning("Overriding registered event %s", name) + definition: _EventDefinition[DecodedT] = _EventDefinition(decoder=decoder) + self._events[name] = cast(_EventDefinition[object], definition) + + def add_event_handler( + self, + name: str, + handler: MessageHandler[DecodedT], + ) -> Unsubscribe: + """Subscribe a typed handler to one registered event name.""" + + definition = self._events.get(name) + if definition is None: + raise ValueError(f"Event {name!r} is not registered") + erased_handler = cast(MessageHandler[object], handler) + definition.handlers.append(erased_handler) def unsubscribe() -> None: - if handler in self._event_handlers: - self._event_handlers.remove(handler) + if erased_handler in definition.handlers: + definition.handlers.remove(erased_handler) return unsubscribe - def add_command_handler(self, command_type: type[object], handler: CommandHandler) -> None: - """Register or replace a command handler. - - Args: - command_type: DTO class handled by ``handler``. - handler: Callable that applies the command. + def dispatch_action(self, action: RuntimeAction) -> None: + """Append an action and drain the queue unless a drain is active.""" - Returns: - None. - - Side effects: - Mutates dispatcher handler map. - """ - - self._command_handlers[command_type] = handler + self._enqueue(action) def dispatch_event(self, event: RuntimeEvent) -> None: - """Dispatch an event to registered observers. - - Args: - event: Runtime event DTO. - - Returns: - None. - - Side effects: - Invokes registered handlers synchronously. - """ - - for handler in tuple(self._event_handlers): - handler(event) - - def dispatch_command(self, command: RuntimeCommand) -> None: - """Apply a command without returning its handler result. - - This is the queue-ready dispatch path: when the runtime gains a - proper async queue, ``dispatch_command`` will enqueue the command - for asynchronous handling and callers will receive results - through events. New call sites should use this method. - - Args: - command: Runtime command DTO. - - Returns: - None. - - Side effects: - Invokes the command handler synchronously. - """ - - self._dispatch_command_internal(command) - - def _dispatch_command_internal(self, command: RuntimeCommand) -> object | None: - """Look up and invoke a command handler. - - Args: - command: Runtime command DTO. - - Returns: - Handler-specific result, or ``None`` for fire-and-forget - handlers. - - Side effects: - Invokes the command handler synchronously. - """ - - handler = self._command_handlers.get(type(command)) - if handler is None: - raise ValueError(f"No command handler registered for {type(command).__name__}") - return handler(command) + """Append an event and drain the queue unless a drain is active.""" + + self._enqueue(event) + + def _enqueue(self, message: RuntimeMessage) -> None: + self._queue.append(message) + if self._draining: + return + self._draining = True + processed = 0 + try: + while self._queue: + current = self._queue.popleft() + processed += 1 + if processed % _DRAIN_WARNING_INTERVAL == 0: + _logger.warning("Runtime queue drain has processed %d messages without returning", processed) + if isinstance(current, RuntimeAction): + self._process_action(current) + else: + self._process_event(current) + finally: + self._draining = False + + def _process_action(self, action: RuntimeAction) -> None: + definition = self._actions.get(action.action) + if definition is None: + self._fail_action(action, stage="lookup", error=ValueError(f"Action {action.action!r} is not registered")) + return + try: + decoded = definition.decoder(action.arguments) + except Exception as exc: + self._fail_action(action, stage="decode", error=exc) + return + try: + self._append_results(definition.handler(decoded)) + except Exception as exc: + self._fail_action(action, stage="execute", error=exc) + + def _process_event(self, event: RuntimeEvent) -> None: + definition = self._events.get(event.event) + if definition is None: + _logger.error("Discarding unregistered event %s with arguments %r", event.event, event.arguments) + return + try: + decoded = definition.decoder(event.arguments) + except Exception: + _logger.exception("Discarding event %s with invalid arguments %r", event.event, event.arguments) + return + for handler in tuple(definition.handlers): + try: + self._append_results(handler(decoded)) + except Exception: + _logger.exception("Event handler failed for %s with arguments %r", event.event, event.arguments) + return + + def _append_results(self, messages: MessageResult) -> None: + for message in messages: + if not isinstance(message, (RuntimeAction, RuntimeEvent)): + raise TypeError(f"Message handlers must return runtime messages, got {type(message).__name__}") + self._queue.extend(messages) + + def _fail_action(self, action: RuntimeAction, *, stage: str, error: Exception) -> None: + _logger.error( + "Action %s failed during %s with arguments %r: %s: %s", + action.action, + stage, + action.arguments, + type(error).__name__, + error, + ) + self._queue.append( + RuntimeEvent( + event="action.failed", + arguments={ + "action": action.action, + "arguments": action.arguments, + "stage": stage, + "exception_type": type(error).__name__, + "message": str(error), + }, + ) + ) diff --git a/src/axidev_osk/runtime/event_handlers.py b/src/axidev_osk/runtime/event_handlers.py index d62a9c5..35bdbde 100644 --- a/src/axidev_osk/runtime/event_handlers.py +++ b/src/axidev_osk/runtime/event_handlers.py @@ -1,135 +1,253 @@ -"""Default runtime event and command handler registration.""" +"""Default built-in action and event handler registration.""" from __future__ import annotations +from collections.abc import Callable from typing import Protocol -from .commands import ( - AppQuit, - KeyboardKeyDown, - KeyboardRegisterKeySpec, - KeyboardKeyUp, - KeyboardSyncLatchedKey, - StateSet, - WindowClose, - WindowHide, - WindowShow, - WindowToggleOpacity, +from ..messages import MessageResult +from .actions import ( + APP_QUIT, + KEYBOARD_KEY_DOWN, + KEYBOARD_KEY_UP, + KEYBOARD_REGISTER_KEY_SPEC, + KEYBOARD_SYNC_LATCHED_KEY, + STATE_SET, + WINDOW_CLOSE, + WINDOW_HIDE, + WINDOW_SHOW, + WINDOW_TOGGLE_OPACITY, + AppQuitArguments, + KeyboardKeyArguments, + KeyboardRegisterKeySpecArguments, + KeyboardSyncLatchedKeyArguments, + StateSetArguments, + WindowArguments, + WindowToggleOpacityArguments, + decode_app_quit, + decode_keyboard_key, + decode_keyboard_register_key_spec, + decode_keyboard_sync_latched_key, + decode_state_set, + decode_window, + decode_window_toggle_opacity, + window_hide, + window_show, +) +from .events import ( + COMPONENT_PRESSED, + HOT_CORNER_TRIGGERED, + WINDOW_CLOSE_REQUESTED, + ComponentPressedArguments, + HotCornerTriggeredArguments, + WindowCloseRequestedArguments, ) -from .events import ComponentPressed, HotCornerTriggered from .registries import EventHandlerRegistry class _WindowVisibilityManager(Protocol): - """Minimal window-manager surface needed by hot-corner routing.""" + def is_visible(self, window_id: str) -> bool: ... + def is_minimized(self, window_id: str) -> bool: ... + def is_opacity_reduced(self, window_id: str) -> bool: ... + - def is_visible(self, window_id: str) -> bool: - """Return whether the managed window is currently visible.""" +class _ApplicationEventRuntime(Protocol): + def _handle_window_close_requested( + self, + event: WindowCloseRequestedArguments, + ) -> MessageResult: ... - def is_minimized(self, window_id: str) -> bool: - """Return whether the managed window is currently minimized.""" + def _handle_hot_corner_triggered( + self, + event: HotCornerTriggeredArguments, + ) -> MessageResult: ... - def is_opacity_reduced(self, window_id: str) -> bool: - """Return whether the managed window is in low-opacity mode.""" + def _handle_component_pressed( + self, + event: ComponentPressedArguments, + ) -> MessageResult: ... -def register_context_command_handlers(registry: EventHandlerRegistry) -> None: - """Register context-level command handlers in deterministic order.""" +def register_context_action_handlers(registry: EventHandlerRegistry) -> None: + """Register context-owned built-in actions.""" - registry.register_command_handler( - KeyboardRegisterKeySpec, - lambda context: lambda command: context.keyboard.register_key_spec( - command.layout_id, - command.key_spec, - component_id=command.component_id, - ), + registry.register_action_handler( + KEYBOARD_REGISTER_KEY_SPEC, + decode_keyboard_register_key_spec, + lambda context: lambda arguments: _keyboard_register(context, arguments), ) - registry.register_command_handler( - KeyboardKeyDown, - lambda context: lambda command: context.keyboard.key_down(command.layout_id, command.key_spec), + registry.register_action_handler( + KEYBOARD_KEY_DOWN, + decode_keyboard_key, + lambda context: lambda arguments: _keyboard_down(context, arguments), ) - registry.register_command_handler( - KeyboardKeyUp, - lambda context: lambda command: context.keyboard.key_up(command.layout_id, command.key_spec), + registry.register_action_handler( + KEYBOARD_KEY_UP, + decode_keyboard_key, + lambda context: lambda arguments: _keyboard_up(context, arguments), ) - registry.register_command_handler( - KeyboardSyncLatchedKey, - lambda context: lambda command: context.keyboard.sync_latched_key( - command.layout_id, - command.key_spec, - command.latched, - ), + registry.register_action_handler( + KEYBOARD_SYNC_LATCHED_KEY, + decode_keyboard_sync_latched_key, + lambda context: lambda arguments: _keyboard_sync_latch(context, arguments), ) - registry.register_command_handler( - StateSet, - lambda context: lambda command: context.state.set(command.namespace, command.key, command.value), + registry.register_action_handler( + STATE_SET, + decode_state_set, + lambda context: lambda arguments: _state_set(context, arguments), ) def register_event_handlers(registry: EventHandlerRegistry) -> None: - """Register application-level runtime handlers in deterministic order.""" + """Register application-owned built-in actions and event handlers.""" - registry.register_command_handler( - WindowShow, - lambda runtime: lambda command: runtime._window_manager.show(command.window_id), + registry.register_action_handler( + WINDOW_SHOW, + decode_window, + lambda runtime: lambda arguments: _window_show(runtime, arguments), + ) + registry.register_action_handler( + WINDOW_HIDE, + decode_window, + lambda runtime: lambda arguments: _window_hide(runtime, arguments), + ) + registry.register_action_handler( + WINDOW_CLOSE, + decode_window, + lambda runtime: lambda arguments: _window_close(runtime, arguments), + ) + registry.register_action_handler( + WINDOW_TOGGLE_OPACITY, + decode_window_toggle_opacity, + lambda runtime: lambda arguments: _window_toggle_opacity(runtime, arguments), ) - registry.register_command_handler( - WindowHide, - lambda runtime: lambda command: runtime._window_manager.hide(command.window_id), + registry.register_action_handler( + APP_QUIT, + decode_app_quit, + lambda runtime: lambda arguments: _app_quit(runtime, arguments), ) - registry.register_command_handler( - WindowClose, - lambda runtime: lambda command: runtime._window_manager.close(command.window_id), + registry.register_event_handler( + WINDOW_CLOSE_REQUESTED, + _window_close_requested_handler, ) - registry.register_command_handler( - WindowToggleOpacity, - lambda runtime: lambda command: runtime._window_manager.toggle_opacity( - command.window_id, - component_id=command.component_id, - opacity=command.opacity, - ), + registry.register_event_handler( + HOT_CORNER_TRIGGERED, + _hot_corner_triggered_handler, ) - registry.register_command_handler( - AppQuit, - lambda runtime: lambda command: runtime._app.exit(command.exit_code), + registry.register_event_handler( + COMPONENT_PRESSED, + _component_pressed_handler, ) - registry.register_event_handler(lambda runtime: runtime._handle_window_close_requested) - registry.register_event_handler(lambda runtime: runtime._handle_hot_corner_triggered) - registry.register_event_handler(lambda runtime: runtime._handle_component_pressed) -def route_hot_corner_triggered(event: object, runtime: object) -> None: - """Map hot-corner events to managed window visibility commands.""" +def route_hot_corner_triggered( + event: HotCornerTriggeredArguments, + runtime: object, +) -> MessageResult: + """Map a hot-corner event to ordered window visibility actions.""" - if not isinstance(event, HotCornerTriggered): - return - config = runtime._config # noqa: SLF001 - dispatcher = runtime._dispatcher # noqa: SLF001 - window_manager: _WindowVisibilityManager = runtime._window_manager # noqa: SLF001 + config = runtime._config # type: ignore[attr-defined] # noqa: SLF001 + window_manager: _WindowVisibilityManager = runtime._window_manager # type: ignore[attr-defined] # noqa: SLF001 + actions: MessageResult = [] for window_id in config.hot_corner.bindings.get(event.corner, []): if window_manager.is_minimized(window_id): - dispatcher.dispatch_command(WindowShow(window_id)) + actions.append(window_show(window_id)) elif window_manager.is_opacity_reduced(window_id): - dispatcher.dispatch_command(WindowShow(window_id)) + actions.append(window_show(window_id)) elif window_manager.is_visible(window_id): - dispatcher.dispatch_command(WindowHide(window_id)) + actions.append(window_hide(window_id)) else: - dispatcher.dispatch_command(WindowShow(window_id)) - - -def route_component_pressed(event: object, runtime: object) -> None: - """Map configured key actions to managed-window commands.""" - - if not isinstance(event, ComponentPressed) or event.key_spec is None: - return - action = event.key_spec.action - if action is None: - return - if action.kind == "toggle-opacity": - runtime._dispatcher.dispatch_command( # noqa: SLF001 - WindowToggleOpacity( - window_id=action.target_window_id, - component_id=event.component_id, - opacity=action.opacity, - ) - ) + actions.append(window_show(window_id)) + return actions + + +def route_component_pressed( + event: ComponentPressedArguments, + runtime: object, +) -> MessageResult: + """Return the configured action attached to a pressed key.""" + + del runtime + if event.action is None: + return [] + return [event.action] + + +def _window_close_requested_handler( + runtime: _ApplicationEventRuntime, +) -> Callable[[WindowCloseRequestedArguments], MessageResult]: + return runtime._handle_window_close_requested + + +def _hot_corner_triggered_handler( + runtime: _ApplicationEventRuntime, +) -> Callable[[HotCornerTriggeredArguments], MessageResult]: + return runtime._handle_hot_corner_triggered + + +def _component_pressed_handler( + runtime: _ApplicationEventRuntime, +) -> Callable[[ComponentPressedArguments], MessageResult]: + return runtime._handle_component_pressed + + +def _keyboard_register(context: object, arguments: KeyboardRegisterKeySpecArguments) -> MessageResult: + context.keyboard.register_key_spec( # type: ignore[attr-defined] + arguments.layout_id, + arguments.key_spec, + component_id=arguments.component_id, + ) + return [] + + +def _keyboard_down(context: object, arguments: KeyboardKeyArguments) -> MessageResult: + context.keyboard.key_down(arguments.layout_id, arguments.component_id) # type: ignore[attr-defined] + return [] + + +def _keyboard_up(context: object, arguments: KeyboardKeyArguments) -> MessageResult: + context.keyboard.key_up(arguments.layout_id, arguments.component_id) # type: ignore[attr-defined] + return [] + + +def _keyboard_sync_latch(context: object, arguments: KeyboardSyncLatchedKeyArguments) -> MessageResult: + context.keyboard.sync_latched_key( # type: ignore[attr-defined] + arguments.layout_id, + arguments.component_id, + arguments.latched, + ) + return [] + + +def _state_set(context: object, arguments: StateSetArguments) -> MessageResult: + context.state.set(arguments.namespace, arguments.key, arguments.value) # type: ignore[attr-defined] + return [] + + +def _window_show(runtime: object, arguments: WindowArguments) -> MessageResult: + runtime._window_manager.show(arguments.window_id) # type: ignore[attr-defined] # noqa: SLF001 + return [] + + +def _window_hide(runtime: object, arguments: WindowArguments) -> MessageResult: + runtime._window_manager.hide(arguments.window_id) # type: ignore[attr-defined] # noqa: SLF001 + return [] + + +def _window_close(runtime: object, arguments: WindowArguments) -> MessageResult: + runtime._window_manager.close(arguments.window_id) # type: ignore[attr-defined] # noqa: SLF001 + return [] + + +def _window_toggle_opacity(runtime: object, arguments: WindowToggleOpacityArguments) -> MessageResult: + runtime._window_manager.toggle_opacity( # type: ignore[attr-defined] # noqa: SLF001 + arguments.window_id, + component_id=arguments.component_id, + opacity=arguments.opacity, + ) + return [] + + +def _app_quit(runtime: object, arguments: AppQuitArguments) -> MessageResult: + runtime._app.exit(arguments.exit_code) # type: ignore[attr-defined] # noqa: SLF001 + return [] diff --git a/src/axidev_osk/runtime/events.py b/src/axidev_osk/runtime/events.py index 42f3ebe..daa806a 100644 --- a/src/axidev_osk/runtime/events.py +++ b/src/axidev_osk/runtime/events.py @@ -1,77 +1,76 @@ -"""Queue-ready event DTOs emitted by UI components and runtime controllers.""" +"""Typed constructors and decoders for built-in runtime events.""" from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ..messages import DataMap, RuntimeAction, RuntimeEvent, runtime_action_to_data +from .decoding import ( + bool_value, + map_value, + optional_string_value, + require_keys, + runtime_action_from_data, + string_value, +) + +if TYPE_CHECKING: + from .dispatcher import Dispatcher -from ..models import KeySpec +ACTION_FAILED = "action.failed" +COMPONENT_PRESSED = "component.pressed" +COMPONENT_RELEASED = "component.released" +COMPONENT_STATE_CHANGED = "component.state_changed" +HOT_CORNER_TRIGGERED = "hot_corner.triggered" +KEYBOARD_KEY_REGISTERED = "keyboard.key_registered" +KEYBOARD_KEY_STATE_CHANGED = "keyboard.key_state_changed" +KEYBOARD_LATCH_CHANGED = "keyboard.latch_changed" +PROMPT_RESOLVED = "prompt.resolved" +WINDOW_CLOSE_REQUESTED = "window.close_requested" @dataclass(frozen=True, slots=True) -class ComponentPressed: - """A component was pressed by the user. +class ActionFailedArguments: + action: str + arguments: DataMap + stage: str + exception_type: str + message: str - Attributes: - component_id: Deterministic component ID. - key_spec: Optional key semantics for keyboard components. - """ +@dataclass(frozen=True, slots=True) +class ComponentPressedArguments: component_id: str - key_spec: KeySpec | None = None + action: RuntimeAction | None @dataclass(frozen=True, slots=True) -class ComponentReleased: - """A component was released by the user. - - Attributes: - component_id: Deterministic component ID. - """ - +class ComponentReleasedArguments: component_id: str @dataclass(frozen=True, slots=True) -class ComponentStateChanged: - """A component state changed and should be reflected in runtime state. - - Attributes: - component_id: Deterministic component ID. - key_id: Logical key group for latchable keys. - latched: New latched state. - """ - +class ComponentStateChangedArguments: component_id: str key_id: str latched: bool @dataclass(frozen=True, slots=True) -class BackendKeyRegistered: - """A keyboard key was registered with the backend observation service. +class HotCornerTriggeredArguments: + corner: str - Attributes: - layout_id: Deterministic keyboard layout instance ID. - component_id: Deterministic key component ID. - io_key_name: Canonical backend key name, when the backend can resolve one. - """ +@dataclass(frozen=True, slots=True) +class KeyboardKeyRegisteredArguments: layout_id: str component_id: str io_key_name: str | None @dataclass(frozen=True, slots=True) -class BackendKeyStateChanged: - """Observed backend key state changed for a registered key. - - Attributes: - layout_id: Deterministic keyboard layout instance ID. - key_id: Logical key group for the changed key. - pressed: Whether the physical/backend key is currently pressed. - latched: Whether the runtime considers the key latched. - """ - +class KeyboardKeyStateChangedArguments: layout_id: str key_id: str pressed: bool @@ -79,63 +78,166 @@ class BackendKeyStateChanged: @dataclass(frozen=True, slots=True) -class KeyLatchChanged: - """Runtime latch state changed for a logical key. - - Attributes: - layout_id: Deterministic keyboard layout instance ID. - key_id: Logical key group for the latchable key. - latched: New latched state. - """ - +class KeyboardLatchChangedArguments: layout_id: str key_id: str latched: bool @dataclass(frozen=True, slots=True) -class HotCornerTriggered: - """A configured hot corner completed its dwell trigger. +class PromptResolvedArguments: + prompt_id: str + result: str + + +@dataclass(frozen=True, slots=True) +class WindowCloseRequestedArguments: + window_id: str - Attributes: - corner: Stable corner ID that triggered. - """ - corner: str +def component_pressed(component_id: str, action: RuntimeAction | None = None) -> RuntimeEvent: + return RuntimeEvent( + COMPONENT_PRESSED, + {"component_id": component_id, "action": runtime_action_to_data(action) if action is not None else None}, + ) -@dataclass(frozen=True, slots=True) -class WindowCloseRequested: - """A managed window requested application shutdown confirmation. +def component_released(component_id: str) -> RuntimeEvent: + return RuntimeEvent(COMPONENT_RELEASED, {"component_id": component_id}) - Attributes: - window_id: Deterministic window ID. - """ - window_id: str +def component_state_changed(component_id: str, key_id: str, latched: bool) -> RuntimeEvent: + return RuntimeEvent( + COMPONENT_STATE_CHANGED, + {"component_id": component_id, "key_id": key_id, "latched": latched}, + ) -@dataclass(frozen=True, slots=True) -class PromptResolved: - """A prompt window resolved to the selected button role. +def hot_corner_triggered(corner: str) -> RuntimeEvent: + return RuntimeEvent(HOT_CORNER_TRIGGERED, {"corner": corner}) - Attributes: - prompt_id: Deterministic prompt ID. - result: Selected prompt button role. - """ - prompt_id: str - result: str +def keyboard_key_registered(layout_id: str, component_id: str, io_key_name: str | None) -> RuntimeEvent: + return RuntimeEvent( + KEYBOARD_KEY_REGISTERED, + {"layout_id": layout_id, "component_id": component_id, "io_key_name": io_key_name}, + ) -RuntimeEvent = ( - ComponentPressed - | ComponentReleased - | ComponentStateChanged - | BackendKeyRegistered - | BackendKeyStateChanged - | KeyLatchChanged - | HotCornerTriggered - | WindowCloseRequested - | PromptResolved -) +def keyboard_key_state_changed(layout_id: str, key_id: str, pressed: bool, latched: bool) -> RuntimeEvent: + return RuntimeEvent( + KEYBOARD_KEY_STATE_CHANGED, + {"layout_id": layout_id, "key_id": key_id, "pressed": pressed, "latched": latched}, + ) + + +def keyboard_latch_changed(layout_id: str, key_id: str, latched: bool) -> RuntimeEvent: + return RuntimeEvent( + KEYBOARD_LATCH_CHANGED, + {"layout_id": layout_id, "key_id": key_id, "latched": latched}, + ) + + +def prompt_resolved(prompt_id: str, result: str) -> RuntimeEvent: + return RuntimeEvent(PROMPT_RESOLVED, {"prompt_id": prompt_id, "result": result}) + + +def window_close_requested(window_id: str) -> RuntimeEvent: + return RuntimeEvent(WINDOW_CLOSE_REQUESTED, {"window_id": window_id}) + + +def decode_action_failed(arguments: DataMap) -> ActionFailedArguments: + require_keys(arguments, ("action", "arguments", "stage", "exception_type", "message")) + return ActionFailedArguments( + action=string_value(arguments, "action"), + arguments=map_value(arguments, "arguments"), + stage=string_value(arguments, "stage"), + exception_type=string_value(arguments, "exception_type"), + message=string_value(arguments, "message"), + ) + + +def decode_component_pressed(arguments: DataMap) -> ComponentPressedArguments: + require_keys(arguments, ("component_id", "action")) + action_value = arguments["action"] + if action_value is not None and not isinstance(action_value, dict): + raise TypeError("Argument 'action' must be a map or null") + return ComponentPressedArguments( + component_id=string_value(arguments, "component_id"), + action=runtime_action_from_data(action_value) if isinstance(action_value, dict) else None, + ) + + +def decode_component_released(arguments: DataMap) -> ComponentReleasedArguments: + require_keys(arguments, ("component_id",)) + return ComponentReleasedArguments(component_id=string_value(arguments, "component_id")) + + +def decode_component_state_changed(arguments: DataMap) -> ComponentStateChangedArguments: + require_keys(arguments, ("component_id", "key_id", "latched")) + return ComponentStateChangedArguments( + component_id=string_value(arguments, "component_id"), + key_id=string_value(arguments, "key_id"), + latched=bool_value(arguments, "latched"), + ) + + +def decode_hot_corner_triggered(arguments: DataMap) -> HotCornerTriggeredArguments: + require_keys(arguments, ("corner",)) + return HotCornerTriggeredArguments(corner=string_value(arguments, "corner")) + + +def decode_keyboard_key_registered(arguments: DataMap) -> KeyboardKeyRegisteredArguments: + require_keys(arguments, ("layout_id", "component_id", "io_key_name")) + return KeyboardKeyRegisteredArguments( + layout_id=string_value(arguments, "layout_id"), + component_id=string_value(arguments, "component_id"), + io_key_name=optional_string_value(arguments, "io_key_name"), + ) + + +def decode_keyboard_key_state_changed(arguments: DataMap) -> KeyboardKeyStateChangedArguments: + require_keys(arguments, ("layout_id", "key_id", "pressed", "latched")) + return KeyboardKeyStateChangedArguments( + layout_id=string_value(arguments, "layout_id"), + key_id=string_value(arguments, "key_id"), + pressed=bool_value(arguments, "pressed"), + latched=bool_value(arguments, "latched"), + ) + + +def decode_keyboard_latch_changed(arguments: DataMap) -> KeyboardLatchChangedArguments: + require_keys(arguments, ("layout_id", "key_id", "latched")) + return KeyboardLatchChangedArguments( + layout_id=string_value(arguments, "layout_id"), + key_id=string_value(arguments, "key_id"), + latched=bool_value(arguments, "latched"), + ) + + +def decode_prompt_resolved(arguments: DataMap) -> PromptResolvedArguments: + require_keys(arguments, ("prompt_id", "result")) + return PromptResolvedArguments( + prompt_id=string_value(arguments, "prompt_id"), + result=string_value(arguments, "result"), + ) + + +def decode_window_close_requested(arguments: DataMap) -> WindowCloseRequestedArguments: + require_keys(arguments, ("window_id",)) + return WindowCloseRequestedArguments(window_id=string_value(arguments, "window_id")) + + +def register_builtin_events(dispatcher: "Dispatcher") -> None: + """Register every built-in event decoder on a dispatcher-shaped object.""" + + dispatcher.register_event(ACTION_FAILED, decode_action_failed) + dispatcher.register_event(COMPONENT_PRESSED, decode_component_pressed) + dispatcher.register_event(COMPONENT_RELEASED, decode_component_released) + dispatcher.register_event(COMPONENT_STATE_CHANGED, decode_component_state_changed) + dispatcher.register_event(HOT_CORNER_TRIGGERED, decode_hot_corner_triggered) + dispatcher.register_event(KEYBOARD_KEY_REGISTERED, decode_keyboard_key_registered) + dispatcher.register_event(KEYBOARD_KEY_STATE_CHANGED, decode_keyboard_key_state_changed) + dispatcher.register_event(KEYBOARD_LATCH_CHANGED, decode_keyboard_latch_changed) + dispatcher.register_event(PROMPT_RESOLVED, decode_prompt_resolved) + dispatcher.register_event(WINDOW_CLOSE_REQUESTED, decode_window_close_requested) diff --git a/src/axidev_osk/runtime/prompt.py b/src/axidev_osk/runtime/prompt.py index 1386c4d..57fc05a 100644 --- a/src/axidev_osk/runtime/prompt.py +++ b/src/axidev_osk/runtime/prompt.py @@ -4,8 +4,9 @@ from PySide6.QtCore import QEventLoop +from ..messages import MessageResult from .dispatcher import Dispatcher, Unsubscribe -from .events import PromptResolved +from .events import PROMPT_RESOLVED, PromptResolvedArguments class PromptResolutionWaiter: @@ -29,7 +30,7 @@ def result(self) -> str: def start(self) -> None: """Subscribe to prompt resolution events.""" - self._unsubscribe = self._dispatcher.add_event_handler(self._handle_prompt) + self._unsubscribe = self._dispatcher.add_event_handler(PROMPT_RESOLVED, self._handle_prompt) def stop(self) -> None: """Unsubscribe from prompt resolution events.""" @@ -38,11 +39,10 @@ def stop(self) -> None: self._unsubscribe() self._unsubscribe = None - def _handle_prompt(self, event: object) -> None: - if not isinstance(event, PromptResolved): - return + def _handle_prompt(self, event: PromptResolvedArguments) -> MessageResult: if event.prompt_id != self._prompt_id: - return + return [] self._result = event.result if self._event_loop.isRunning(): self._event_loop.quit() + return [] diff --git a/src/axidev_osk/runtime/registries.py b/src/axidev_osk/runtime/registries.py index b748661..3bdac13 100644 --- a/src/axidev_osk/runtime/registries.py +++ b/src/axidev_osk/runtime/registries.py @@ -4,7 +4,7 @@ component and surface registries instead of extending ``Dispatcher`` directly. Application/window orchestration handlers need runtime-owned collaborators such as ``WindowManager`` and ``QApplication``; keeping those factories in a registry -preserves ``Dispatcher`` as a generic command/event router rather than making it +preserves ``Dispatcher`` as a generic action/event router rather than making it aware of application policy. """ @@ -16,8 +16,7 @@ from PySide6.QtWidgets import QWidget from ..config.models import ComponentConfig, SurfaceConfig -from .commands import RuntimeCommand -from .events import RuntimeEvent +from ..messages import DataMap, MessageResult if TYPE_CHECKING: from .context import Context @@ -39,8 +38,10 @@ def stop(self) -> None: """Stop the service and release owned resources.""" -CommandHandlerFactory = Callable[[RuntimeT], Callable[[RuntimeCommand], object | None]] -EventHandlerFactory = Callable[[RuntimeT], Callable[[RuntimeEvent], object | None]] +DecodedT = TypeVar("DecodedT") +Decoder = Callable[[DataMap], DecodedT] +MessageHandler = Callable[[DecodedT], MessageResult] +MessageHandlerFactory = Callable[[RuntimeT], MessageHandler[DecodedT]] class ComponentRegistry: @@ -201,32 +202,47 @@ def services(self) -> Iterable[RuntimeService]: class EventHandlerRegistry: - """Stores default command and event handler factories for installation.""" + """Stores default action and event handler factories for installation.""" def __init__(self) -> None: """Create an empty handler registry.""" - self._command_handlers: list[tuple[type[object], CommandHandlerFactory[object]]] = [] - self._event_handlers: list[EventHandlerFactory[object]] = [] + self._action_handlers: list[ + tuple[str, Decoder[object], MessageHandlerFactory[object, object]] + ] = [] + self._event_handlers: list[tuple[str, MessageHandlerFactory[object, object]]] = [] - def register_command_handler( + def register_action_handler( self, - command_type: type[object], - factory: CommandHandlerFactory[RuntimeT], + name: str, + decoder: Decoder[DecodedT], + factory: MessageHandlerFactory[RuntimeT, DecodedT], ) -> None: - """Register a command handler factory.""" + """Register an action decoder and typed handler factory.""" - self._command_handlers.append((command_type, cast(CommandHandlerFactory[object], factory))) + self._action_handlers.append( + ( + name, + cast(Decoder[object], decoder), + cast(MessageHandlerFactory[object, object], factory), + ) + ) - def register_event_handler(self, factory: EventHandlerFactory[RuntimeT]) -> None: - """Register an event handler factory.""" + def register_event_handler( + self, + name: str, + factory: MessageHandlerFactory[RuntimeT, DecodedT], + ) -> None: + """Register a typed event handler factory.""" - self._event_handlers.append(cast(EventHandlerFactory[object], factory)) + self._event_handlers.append( + (name, cast(MessageHandlerFactory[object, object], factory)) + ) def install(self, dispatcher: "Dispatcher", runtime: object) -> None: """Install all registered handlers onto a dispatcher.""" - for command_type, factory in self._command_handlers: - dispatcher.add_command_handler(command_type, factory(runtime)) - for factory in self._event_handlers: - dispatcher.add_event_handler(factory(runtime)) + for name, decoder, factory in self._action_handlers: + dispatcher.register_action(name, decoder, factory(runtime)) + for name, factory in self._event_handlers: + dispatcher.add_event_handler(name, factory(runtime)) diff --git a/src/axidev_osk/runtime/state_store.py b/src/axidev_osk/runtime/state_store.py index 08df091..dc27611 100644 --- a/src/axidev_osk/runtime/state_store.py +++ b/src/axidev_osk/runtime/state_store.py @@ -2,12 +2,14 @@ from __future__ import annotations +from copy import deepcopy + class StateStore: """Owns durable runtime state outside widgets and services. The store is intentionally small in this refactor. It provides the boundary that - future config reloads, profile switches, and queued command replay can target. + future config reloads, profile switches, and queued action replay can target. """ def __init__(self) -> None: @@ -40,7 +42,7 @@ def set(self, namespace: str, key: str, value: object) -> None: Mutates the in-memory state store. """ - self._values.setdefault(namespace, {})[key] = value + self._values.setdefault(namespace, {})[key] = deepcopy(value) def get(self, namespace: str, key: str, default: object | None = None) -> object | None: """Read a value from a namespace. @@ -57,7 +59,7 @@ def get(self, namespace: str, key: str, default: object | None = None) -> object None. """ - return self._values.get(namespace, {}).get(key, default) + return deepcopy(self._values.get(namespace, {}).get(key, default)) def clear_namespace(self, namespace: str) -> None: """Remove all state in a namespace. diff --git a/src/axidev_osk/runtime/testing.py b/src/axidev_osk/runtime/testing.py index 79a1d33..ed7cbbc 100644 --- a/src/axidev_osk/runtime/testing.py +++ b/src/axidev_osk/runtime/testing.py @@ -19,23 +19,29 @@ from ..config.models import AppConfig from ..services import register_services from ..services.keyboard import KeyboardService -from .commands import AppQuit +from ..messages import MessageResult +from .actions import app_quit from .context import Context from .dispatcher import Dispatcher from .event_handlers import ( - register_context_command_handlers, + register_context_action_handlers, register_event_handlers, route_component_pressed, route_hot_corner_triggered, ) -from .events import WindowCloseRequested +from .events import ( + ComponentPressedArguments, + HotCornerTriggeredArguments, + WindowCloseRequestedArguments, + register_builtin_events, +) from .registries import ComponentRegistry, EventHandlerRegistry, ServiceRegistry, SurfaceRegistry from .state_store import StateStore from .window_manager import WindowManager class _TestApplication: - """Minimal QApplication-shaped adapter for default command handlers.""" + """Minimal QApplication-shaped adapter for default action handlers.""" def __init__(self) -> None: """Create an adapter that records requested exit codes.""" @@ -59,21 +65,21 @@ def __init__(self, context: Context) -> None: self._window_manager = WindowManager(context) self._app = _TestApplication() - def _handle_window_close_requested(self, event: object) -> None: - """Map close requests to a direct test quit command.""" + def _handle_window_close_requested(self, event: WindowCloseRequestedArguments) -> MessageResult: + """Map close requests to a direct test quit action.""" - if isinstance(event, WindowCloseRequested): - self._dispatcher.dispatch_command(AppQuit()) + del event + return [app_quit()] - def _handle_hot_corner_triggered(self, event: object) -> None: - """Route hot-corner visibility commands through production helper.""" + def _handle_hot_corner_triggered(self, event: HotCornerTriggeredArguments) -> MessageResult: + """Route hot-corner visibility actions through production helper.""" - route_hot_corner_triggered(event, self) + return route_hot_corner_triggered(event, self) - def _handle_component_pressed(self, event: object) -> None: + def _handle_component_pressed(self, event: ComponentPressedArguments) -> MessageResult: """Route configured component actions through production helper.""" - route_component_pressed(event, self) + return route_component_pressed(event, self) def make_test_context( keyboard_backend: Any, @@ -87,7 +93,7 @@ def make_test_context( """Build a runtime ``Context`` wrapping a test keyboard backend. The returned context is fully functional: it owns its own - ``Dispatcher`` (with default command handlers bound), ``StateStore``, + ``Dispatcher`` (with default action handlers bound), ``StateStore``, and registries. Tests can therefore exercise the same dispatch and state paths the production runtime uses. @@ -117,6 +123,7 @@ def make_test_context( """ dispatcher = Dispatcher() + register_builtin_events(dispatcher) keyboard = KeyboardService(cast(Any, keyboard_backend)) if components is None: # Lazy import: avoids pulling Qt-bound builders into modules @@ -133,9 +140,8 @@ def make_test_context( components=components, surfaces=surfaces or SurfaceRegistry(), ) - dispatcher.bind_context(context) context_handlers = EventHandlerRegistry() - register_context_command_handlers(context_handlers) + register_context_action_handlers(context_handlers) context_handlers.install(dispatcher, context) if services is None: keyboard.bind_context(context) diff --git a/src/axidev_osk/services/keyboard/io.py b/src/axidev_osk/services/keyboard/io.py index c92aab9..21a3ff9 100644 --- a/src/axidev_osk/services/keyboard/io.py +++ b/src/axidev_osk/services/keyboard/io.py @@ -6,6 +6,7 @@ import sys from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from threading import RLock from typing import Any, Mapping @@ -305,6 +306,12 @@ def _build_install_hint(self) -> str: return "Install the submodule package with `python -m pip install -e ./vendor/axidev-io-python`." return "Initialize the submodule, then install it with `python -m pip install -e ./vendor/axidev-io-python`." + @staticmethod + def _repo_root() -> Path: + """Return the source checkout root containing the vendored backend.""" + + return Path(__file__).resolve().parents[4] + def _build_permission_setup_text(self) -> str: return ( "Linux blocked keyboard output because this session does not currently have access to /dev/uinput.\n\n" diff --git a/src/axidev_osk/services/keyboard/service.py b/src/axidev_osk/services/keyboard/service.py index 7d68dbc..ecb3888 100644 --- a/src/axidev_osk/services/keyboard/service.py +++ b/src/axidev_osk/services/keyboard/service.py @@ -1,4 +1,4 @@ -"""Keyboard service boundary used by runtime commands and components.""" +"""Keyboard service boundary used by runtime actions and components.""" from __future__ import annotations @@ -8,12 +8,12 @@ from typing import TYPE_CHECKING from ...models import KeySpec -from ...runtime.events import BackendKeyRegistered, BackendKeyStateChanged, KeyLatchChanged +from ...runtime.events import keyboard_key_registered, keyboard_key_state_changed, keyboard_latch_changed from ...runtime.identity import keyboard_key_states_namespace, keyboard_latches_namespace from .io import AxidevIoKeyboardBackend if TYPE_CHECKING: - from ..runtime.context import Context + from ...runtime.context import Context Unsubscribe = Callable[[], None] @@ -21,7 +21,7 @@ class KeyboardService: - """Owns keyboard backend lifecycle and exposes command-friendly methods.""" + """Owns keyboard backend lifecycle and exposes action-friendly methods.""" def __init__(self, backend: AxidevIoKeyboardBackend | None = None) -> None: """Create a keyboard service. @@ -42,6 +42,7 @@ def __init__(self, backend: AxidevIoKeyboardBackend | None = None) -> None: self._press_handles: dict[tuple[str, str], object | None] = {} self._latched_keys: dict[tuple[str, str], bool] = {} self._specs_by_key_name: dict[str, list[tuple[str, KeySpec]]] = {} + self._specs_by_component: dict[tuple[str, str], KeySpec] = {} self._layouts: set[str] = set() self._backend_listener_unsubscribe: Unsubscribe | None = None @@ -132,6 +133,8 @@ def register_key_spec(self, layout_id: str, spec: KeySpec, *, component_id: str key_name = self._backend.key_name_for_spec(spec) state_key = self._state_key_for_spec(spec) self._layouts.add(layout_id) + if component_id is not None: + self._specs_by_component[(layout_id, component_id)] = spec if state_key is None: return key_name latched = self._is_spec_latched(layout_id, spec) @@ -148,11 +151,7 @@ def register_key_spec(self, layout_id: str, spec: KeySpec, *, component_id: str self._emit_key_state(layout_id, state_key, pressed=True, latched=latched) if component_id is not None and self._context is not None: self._context.dispatcher.dispatch_event( - BackendKeyRegistered( - layout_id=layout_id, - component_id=component_id, - io_key_name=key_name, - ) + keyboard_key_registered(layout_id, component_id, key_name) ) return key_name @@ -173,15 +172,19 @@ def reset_state(self) -> None: layouts.update(layout for layout, _key_id in self._latched_keys) self._release_press_handles() self._latched_keys.clear() + self._specs_by_key_name.clear() + self._specs_by_component.clear() + self._layouts.clear() if self._context is None: return for layout_id in layouts: self._context.state.clear_namespace(keyboard_key_states_namespace(layout_id)) self._context.state.clear_namespace(keyboard_latches_namespace(layout_id)) - def key_down(self, layout_id: str, spec: KeySpec) -> None: + def key_down(self, layout_id: str, component_id: str) -> None: """Emit a key-down action through the backend.""" + spec = self._registered_spec(layout_id, component_id) latched_keys = self._latched_snapshot(layout_id) press_handle = self._backend.key_down(spec, latched_keys) state_key = self._state_key_for_spec(spec) @@ -189,9 +192,10 @@ def key_down(self, layout_id: str, spec: KeySpec) -> None: self._press_handles[self._press_handle_key(layout_id, spec, state_key)] = press_handle self._emit_key_state(layout_id, state_key, pressed=True, latched=self._is_spec_latched(layout_id, spec)) - def key_up(self, layout_id: str, spec: KeySpec) -> None: + def key_up(self, layout_id: str, component_id: str) -> None: """Emit a key-up action through the backend.""" + spec = self._registered_spec(layout_id, component_id) state_key = self._state_key_for_spec(spec) latched = self._is_spec_latched(layout_id, spec) press_handle = ( @@ -208,9 +212,10 @@ def key_up(self, layout_id: str, spec: KeySpec) -> None: latched=latched, ) - def sync_latched_key(self, layout_id: str, spec: KeySpec, latched: bool) -> None: + def sync_latched_key(self, layout_id: str, component_id: str, latched: bool) -> None: """Synchronize logical latch state without changing backend activity.""" + spec = self._registered_spec(layout_id, component_id) if spec.key_id is not None: self._set_latch_state(layout_id, spec.key_id, latched) @@ -231,13 +236,13 @@ def _set_latch_state(self, layout_id: str, key_id: str, latched: bool) -> None: self._latched_keys[(layout_id, key_id)] = latched self._write_latch_state(layout_id, key_id, latched) if self._context is not None: - self._context.dispatcher.dispatch_event(KeyLatchChanged(layout_id=layout_id, key_id=key_id, latched=latched)) + self._context.dispatcher.dispatch_event(keyboard_latch_changed(layout_id, key_id, latched)) def _emit_key_state(self, layout_id: str, key_id: str, *, pressed: bool, latched: bool) -> None: self._write_key_state(layout_id, key_id, pressed=pressed, latched=latched) if self._context is not None: self._context.dispatcher.dispatch_event( - BackendKeyStateChanged(layout_id=layout_id, key_id=key_id, pressed=pressed, latched=latched) + keyboard_key_state_changed(layout_id, key_id, pressed, latched) ) def _write_key_state(self, layout_id: str, key_id: str, *, pressed: bool, latched: bool) -> None: @@ -268,6 +273,14 @@ def _latched_snapshot(self, layout_id: str) -> dict[str, bool]: def _registered_specs_for_layout(self, layout_id: str) -> list[tuple[str, KeySpec]]: return [registration for registrations in self._specs_by_key_name.values() for registration in registrations if registration[0] == layout_id] + def _registered_spec(self, layout_id: str, component_id: str) -> KeySpec: + spec = self._specs_by_component.get((layout_id, component_id)) + if spec is None: + raise ValueError( + f"No key specification registered for layout {layout_id!r}, component {component_id!r}" + ) + return spec + def _state_key_for_spec(self, spec: KeySpec) -> str | None: return spec.io_key or spec.label or spec.key_id diff --git a/src/axidev_osk/services/single_instance.py b/src/axidev_osk/services/single_instance.py index cfb4b29..ddfb267 100644 --- a/src/axidev_osk/services/single_instance.py +++ b/src/axidev_osk/services/single_instance.py @@ -11,7 +11,7 @@ from PySide6.QtCore import QLockFile, QObject from PySide6.QtNetwork import QLocalServer, QLocalSocket -from ..runtime.commands import WindowShow +from ..runtime.actions import window_show from ..runtime.context import Context @@ -30,7 +30,7 @@ def _lock_path() -> Path: class WindowsSingleInstanceService(QObject): - """Keep one Windows process and route later launches through the command queue.""" + """Keep one Windows process and route later launches through the action queue.""" def __init__(self, *, parent: QObject | None = None) -> None: super().__init__(parent) @@ -102,4 +102,4 @@ def _activate_running_window(self) -> None: connection.deleteLater() received_request = True if received_request: - context.dispatcher.dispatch_command(WindowShow(context.config.keyboard_window_id)) + context.dispatcher.dispatch_action(window_show(context.config.keyboard_window_id)) diff --git a/src/axidev_osk/windows/builder.py b/src/axidev_osk/windows/builder.py index eefaf85..6e540f4 100644 --- a/src/axidev_osk/windows/builder.py +++ b/src/axidev_osk/windows/builder.py @@ -8,7 +8,7 @@ from ..config.models import WindowConfig from ..runtime.context import Context -from ..runtime.events import WindowCloseRequested +from ..runtime.events import window_close_requested from .chrome import install_overlay_chrome from .overlay import configure_always_on_top_window, configure_plain_window @@ -116,7 +116,7 @@ def closeEvent(self, event: QCloseEvent) -> None: # type: ignore[override] if not self._quit_controller_managed: super().closeEvent(event) return - self._context.dispatcher.dispatch_event(WindowCloseRequested(window_id=self._config.id)) + self._context.dispatcher.dispatch_event(window_close_requested(self._config.id)) event.ignore() def showEvent(self, event: QShowEvent) -> None: # type: ignore[override] diff --git a/src/axidev_osk/windows/overlay/always_on_top.py b/src/axidev_osk/windows/overlay/always_on_top.py index 7d6a942..fb5a8b7 100644 --- a/src/axidev_osk/windows/overlay/always_on_top.py +++ b/src/axidev_osk/windows/overlay/always_on_top.py @@ -536,7 +536,7 @@ def _current_screen_geometry(self, *, for_layer_shell: bool = False) -> QRect: screen = self._window.screen() if screen is None: app = QGuiApplication.instance() - screen = app.primaryScreen() if app is not None else None + screen = app.primaryScreen() if isinstance(app, QGuiApplication) else None if screen is None: geometry = None elif for_layer_shell: diff --git a/tests/test_application_runtime.py b/tests/test_application_runtime.py index 8efee3c..d3ab974 100644 --- a/tests/test_application_runtime.py +++ b/tests/test_application_runtime.py @@ -11,7 +11,7 @@ from axidev_osk.config.defaults import build_default_app_config from axidev_osk.config.models import WindowConfig from axidev_osk.runtime.application import ApplicationRuntime -from axidev_osk.runtime.events import PromptResolved +from axidev_osk.runtime.events import prompt_resolved def _app() -> QApplication: @@ -33,7 +33,7 @@ def show(self) -> None: QTimer.singleShot( 0, lambda: self._runtime.context.dispatcher.dispatch_event( - PromptResolved(prompt_id=prompt.id, result=self._result), + prompt_resolved(prompt.id, self._result), ), ) diff --git a/tests/test_hot_corner_events.py b/tests/test_hot_corner_events.py index 2370b81..8f8b510 100644 --- a/tests/test_hot_corner_events.py +++ b/tests/test_hot_corner_events.py @@ -9,11 +9,25 @@ from PySide6.QtCore import QPoint, QRect from PySide6.QtWidgets import QApplication +from axidev_osk.messages import MessageResult, RuntimeAction from axidev_osk.config.models import HotCornerConfig from axidev_osk.hot_corner.controller import HotCornerWindowToggleController, ScreenCorner from axidev_osk.runtime.application import ApplicationRuntime -from axidev_osk.runtime.commands import WindowHide, WindowShow, WindowToggleOpacity -from axidev_osk.runtime.events import ComponentPressed, HotCornerTriggered +from axidev_osk.runtime.actions import ( + WINDOW_SHOW, + WindowArguments, + decode_window, + window_hide, + window_show, + window_toggle_opacity, +) +from axidev_osk.runtime.events import ( + HOT_CORNER_TRIGGERED, + HotCornerTriggeredArguments, + component_pressed, + decode_component_pressed, + hot_corner_triggered, +) from axidev_osk.runtime.testing import make_test_context from axidev_osk.windows.overlay.always_on_top import OverlayBackend @@ -78,8 +92,13 @@ def tearDownClass(cls) -> None: def test_dwell_completion_emits_hot_corner_triggered(self) -> None: context = make_test_context(FakeKeyboardBackend()) - events: list[object] = [] - context.dispatcher.add_event_handler(events.append) + events: list[HotCornerTriggeredArguments] = [] + + def record_event(event: HotCornerTriggeredArguments) -> MessageResult: + events.append(event) + return [] + + context.dispatcher.add_event_handler(HOT_CORNER_TRIGGERED, record_event) overlay = FakeOverlayController(backend=OverlayBackend.X11_UTILITY_BRIDGE) with patch( @@ -98,7 +117,7 @@ def test_dwell_completion_emits_hot_corner_triggered(self) -> None: with patch.object(controller, "_show_indicator_for_screen"): controller._poll_active_sensor() - self.assertEqual(events, [HotCornerTriggered(corner="bottom_left")]) + self.assertEqual(events, [HotCornerTriggeredArguments(corner="bottom_left")]) finally: controller.stop() controller._indicator.close() @@ -109,10 +128,6 @@ def test_runtime_handler_dispatches_bound_window_command(self) -> None: context.config, hot_corner=HotCornerConfig(bindings={"bottom_left": ["window:keyboard"]}), ) - commands: list[object] = [] - context.dispatcher.add_command_handler(WindowShow, lambda command: commands.append(command)) - context.dispatcher.add_command_handler(WindowHide, lambda command: commands.append(command)) - class FakeWindowManager: def is_minimized(self, window_id: str) -> bool: return False @@ -128,9 +143,9 @@ def is_visible(self, window_id: str) -> bool: runtime._dispatcher = context.dispatcher runtime._window_manager = FakeWindowManager() - runtime._handle_hot_corner_triggered(HotCornerTriggered(corner="bottom_left")) + actions = runtime._handle_hot_corner_triggered(HotCornerTriggeredArguments(corner="bottom_left")) - self.assertEqual(commands, [WindowShow("window:keyboard")]) + self.assertEqual(actions, [window_show("window:keyboard")]) def test_runtime_handler_hides_visible_bound_window(self) -> None: context = make_test_context(FakeKeyboardBackend()) @@ -138,10 +153,6 @@ def test_runtime_handler_hides_visible_bound_window(self) -> None: context.config, hot_corner=HotCornerConfig(bindings={"bottom_left": ["window:keyboard"]}), ) - commands: list[object] = [] - context.dispatcher.add_command_handler(WindowShow, lambda command: commands.append(command)) - context.dispatcher.add_command_handler(WindowHide, lambda command: commands.append(command)) - class FakeWindowManager: def is_minimized(self, window_id: str) -> bool: return False @@ -157,9 +168,9 @@ def is_visible(self, window_id: str) -> bool: runtime._dispatcher = context.dispatcher runtime._window_manager = FakeWindowManager() - runtime._handle_hot_corner_triggered(HotCornerTriggered(corner="bottom_left")) + actions = runtime._handle_hot_corner_triggered(HotCornerTriggeredArguments(corner="bottom_left")) - self.assertEqual(commands, [WindowHide("window:keyboard")]) + self.assertEqual(actions, [window_hide("window:keyboard")]) def test_runtime_handler_restores_minimized_bound_window(self) -> None: context = make_test_context(FakeKeyboardBackend()) @@ -167,9 +178,6 @@ def test_runtime_handler_restores_minimized_bound_window(self) -> None: context.config, hot_corner=HotCornerConfig(bindings={"bottom_left": ["window:keyboard"]}), ) - commands: list[object] = [] - context.dispatcher.add_command_handler(WindowShow, lambda command: commands.append(command)) - class FakeWindowManager: def is_minimized(self, window_id: str) -> bool: return True @@ -185,9 +193,9 @@ def is_visible(self, window_id: str) -> bool: runtime._dispatcher = context.dispatcher runtime._window_manager = FakeWindowManager() - runtime._handle_hot_corner_triggered(HotCornerTriggered(corner="bottom_left")) + actions = runtime._handle_hot_corner_triggered(HotCornerTriggeredArguments(corner="bottom_left")) - self.assertEqual(commands, [WindowShow("window:keyboard")]) + self.assertEqual(actions, [window_show("window:keyboard")]) def test_runtime_handler_restores_ghosted_window_without_hiding_it(self) -> None: context = make_test_context(FakeKeyboardBackend()) @@ -195,10 +203,6 @@ def test_runtime_handler_restores_ghosted_window_without_hiding_it(self) -> None context.config, hot_corner=HotCornerConfig(bindings={"bottom_left": ["window:keyboard"]}), ) - commands: list[object] = [] - context.dispatcher.add_command_handler(WindowShow, lambda command: commands.append(command)) - context.dispatcher.add_command_handler(WindowHide, lambda command: commands.append(command)) - class FakeWindowManager: def is_minimized(self, window_id: str) -> bool: return False @@ -214,9 +218,9 @@ def is_visible(self, window_id: str) -> bool: runtime._dispatcher = context.dispatcher runtime._window_manager = FakeWindowManager() - runtime._handle_hot_corner_triggered(HotCornerTriggered(corner="bottom_left")) + actions = runtime._handle_hot_corner_triggered(HotCornerTriggeredArguments(corner="bottom_left")) - self.assertEqual(commands, [WindowShow("window:keyboard")]) + self.assertEqual(actions, [window_show("window:keyboard")]) def test_make_test_context_installs_default_event_handlers(self) -> None: config = replace( @@ -224,12 +228,17 @@ def test_make_test_context_installs_default_event_handlers(self) -> None: hot_corner=HotCornerConfig(bindings={"bottom_left": ["window:keyboard"]}), ) context = make_test_context(FakeKeyboardBackend(), config=config, event_handlers=True) - commands: list[object] = [] - context.dispatcher.add_command_handler(WindowShow, lambda command: commands.append(command)) + actions: list[RuntimeAction] = [] + + def record_action(arguments: WindowArguments) -> MessageResult: + actions.append(window_show(arguments.window_id)) + return [] + + context.dispatcher.register_action(WINDOW_SHOW, decode_window, record_action, override=True) - context.dispatcher.dispatch_event(HotCornerTriggered(corner="bottom_left")) + context.dispatcher.dispatch_event(hot_corner_triggered("bottom_left")) - self.assertEqual(commands, [WindowShow("window:keyboard")]) + self.assertEqual(actions, [window_show("window:keyboard")]) def test_component_action_dispatches_configured_window_opacity_command(self) -> None: context = make_test_context(FakeKeyboardBackend()) @@ -238,26 +247,17 @@ def test_component_action_dispatches_configured_window_opacity_command(self) -> for component in context.config.windows[0].surface.components[0].layout.grids[0].components if component.spec.label == "Ghost" ) - commands: list[object] = [] - context.dispatcher.add_command_handler( - WindowToggleOpacity, - lambda command: commands.append(command), - ) runtime = ApplicationRuntime.__new__(ApplicationRuntime) runtime._dispatcher = context.dispatcher - runtime._handle_component_pressed( - ComponentPressed(component_id=ghost.id, key_spec=ghost.spec) - ) + event = component_pressed(ghost.id, ghost.spec.action) + arguments = decode_component_pressed(event.arguments) + actions = runtime._handle_component_pressed(arguments) self.assertEqual( - commands, + actions, [ - WindowToggleOpacity( - window_id="window:keyboard", - component_id=ghost.id, - opacity=0.01, - ) + window_toggle_opacity("window:keyboard", ghost.id, 0.01) ], ) diff --git a/tests/test_keyboard_service.py b/tests/test_keyboard_service.py index c7f830f..746d5d9 100644 --- a/tests/test_keyboard_service.py +++ b/tests/test_keyboard_service.py @@ -8,9 +8,21 @@ from axidev_osk.components.grid.keyboard import KeyboardWidget from axidev_osk.config.defaults.us_iso import build_us_iso_layout_config -from axidev_osk.models import KeySpec, WindowAction -from axidev_osk.runtime.commands import KeyboardKeyDown, KeyboardSyncLatchedKey -from axidev_osk.runtime.events import BackendKeyStateChanged, ComponentPressed, KeyLatchChanged +from axidev_osk.messages import MessageResult, RuntimeAction +from axidev_osk.models import KeySpec +from axidev_osk.runtime.actions import ( + keyboard_key_down, + keyboard_sync_latched_key, + window_toggle_opacity, +) +from axidev_osk.runtime.events import ( + COMPONENT_PRESSED, + KEYBOARD_KEY_STATE_CHANGED, + KEYBOARD_LATCH_CHANGED, + ComponentPressedArguments, + KeyboardKeyStateChangedArguments, + KeyboardLatchChangedArguments, +) from axidev_osk.runtime.identity import keyboard_key_states_namespace, keyboard_latches_namespace from axidev_osk.runtime.testing import make_test_context @@ -66,15 +78,12 @@ def emit_key_state(self, key_name: str, pressed: bool) -> None: class KeyboardServiceTests(unittest.TestCase): - def test_window_actions_reject_unknown_kinds_and_empty_targets(self) -> None: - with self.assertRaisesRegex(ValueError, "Unsupported window action kind"): - WindowAction(kind="unknown", target_window_id="window:keyboard") # type: ignore[arg-type] - - with self.assertRaisesRegex(ValueError, "target ID must not be empty"): - WindowAction(kind="toggle-opacity", target_window_id=" ") + def test_runtime_actions_reject_non_namespaced_names(self) -> None: + with self.assertRaisesRegex(ValueError, "dot-separated"): + RuntimeAction(action="unknown", arguments={}) def test_action_keys_reject_keyboard_behavior(self) -> None: - action = WindowAction(kind="toggle-opacity", target_window_id="window:keyboard") + action = window_toggle_opacity("window:keyboard", "key:ghost", 0.01) with self.assertRaisesRegex(ValueError, "keyboard output or latch behavior"): KeySpec(label="Ghost", row=0, column=0, io_key="A", repeats=False, action=action) @@ -85,10 +94,13 @@ def test_ghost_key_emits_action_event_without_keyboard_output(self) -> None: _app() backend = FakeKeyboardBackend() context = make_test_context(backend) - events: list[ComponentPressed] = [] - context.dispatcher.add_event_handler( - lambda event: events.append(event) if isinstance(event, ComponentPressed) else None - ) + events: list[ComponentPressedArguments] = [] + + def record(event: ComponentPressedArguments) -> MessageResult: + events.append(event) + return [] + + context.dispatcher.add_event_handler(COMPONENT_PRESSED, record) widget = KeyboardWidget(layout_config=build_us_iso_layout_config(), context=context) self.addCleanup(widget.close) ghost = next( @@ -101,7 +113,7 @@ def test_ghost_key_emits_action_event_without_keyboard_output(self) -> None: self.assertEqual(len(events), 1) self.assertEqual(events[0].component_id, ghost.property("componentId")) - self.assertIsNotNone(events[0].key_spec.action) + self.assertIsNotNone(events[0].action) backend.key_down.assert_not_called() backend.key_up.assert_not_called() @@ -109,13 +121,18 @@ def test_service_emits_backend_key_state_changed_on_backend_update(self) -> None backend = FakeKeyboardBackend() context = make_test_context(backend, services={"keyboard"}) spec = KeySpec(label="A", row=0, column=0, io_key="A") - events: list[BackendKeyStateChanged] = [] - context.dispatcher.add_event_handler(lambda event: events.append(event) if isinstance(event, BackendKeyStateChanged) else None) + events: list[KeyboardKeyStateChangedArguments] = [] + + def record(event: KeyboardKeyStateChangedArguments) -> MessageResult: + events.append(event) + return [] + + context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, record) context.keyboard.register_key_spec(LAYOUT_ID, spec) backend.emit_key_state("A", True) - self.assertEqual(events, [BackendKeyStateChanged(layout_id=LAYOUT_ID, key_id="A", pressed=True, latched=False)]) + self.assertEqual(events, [KeyboardKeyStateChangedArguments(layout_id=LAYOUT_ID, key_id="A", pressed=True, latched=False)]) def test_service_writes_keyboard_key_state_namespace(self) -> None: backend = FakeKeyboardBackend() @@ -149,22 +166,32 @@ def test_service_emits_key_latch_changed_on_latch_toggle(self) -> None: backend = FakeKeyboardBackend() context = make_test_context(backend) spec = KeySpec(label="Shift", row=0, column=0, key_id="shift", io_key="leftshift", latchable=True) - events: list[KeyLatchChanged] = [] - context.dispatcher.add_event_handler(lambda event: events.append(event) if isinstance(event, KeyLatchChanged) else None) + events: list[KeyboardLatchChangedArguments] = [] + + def record(event: KeyboardLatchChangedArguments) -> MessageResult: + events.append(event) + return [] - context.dispatcher.dispatch_command(KeyboardSyncLatchedKey(LAYOUT_ID, spec, True)) + context.dispatcher.add_event_handler(KEYBOARD_LATCH_CHANGED, record) + context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:shift") + context.dispatcher.dispatch_action(keyboard_sync_latched_key(LAYOUT_ID, "key:shift", True)) - self.assertEqual(events, [KeyLatchChanged(layout_id=LAYOUT_ID, key_id="shift", latched=True)]) + self.assertEqual(events, [KeyboardLatchChangedArguments(layout_id=LAYOUT_ID, key_id="shift", latched=True)]) self.assertTrue(context.state.get(keyboard_latches_namespace(LAYOUT_ID), "shift")) def test_non_held_latch_does_not_emit_backend_pressed_state(self) -> None: backend = FakeKeyboardBackend() context = make_test_context(backend) spec = KeySpec(label="Caps", row=0, column=0, key_id="caps", io_key="capslock", latchable=True) - events: list[BackendKeyStateChanged] = [] - context.dispatcher.add_event_handler(lambda event: events.append(event) if isinstance(event, BackendKeyStateChanged) else None) + events: list[KeyboardKeyStateChangedArguments] = [] - context.dispatcher.dispatch_command(KeyboardSyncLatchedKey(LAYOUT_ID, spec, True)) + def record(event: KeyboardKeyStateChangedArguments) -> MessageResult: + events.append(event) + return [] + + context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, record) + context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:caps") + context.dispatcher.dispatch_action(keyboard_sync_latched_key(LAYOUT_ID, "key:caps", True)) self.assertEqual(events, []) self.assertTrue(context.state.get(keyboard_latches_namespace(LAYOUT_ID), "caps")) @@ -197,7 +224,8 @@ def test_reset_state_releases_active_press_handles(self) -> None: context = make_test_context(backend) spec = KeySpec(label="Shift", row=0, column=0, key_id="shift", io_key="leftshift", holds_when_latched=True) - context.dispatcher.dispatch_command(KeyboardKeyDown(LAYOUT_ID, spec)) + context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:shift") + context.dispatcher.dispatch_action(keyboard_key_down(LAYOUT_ID, "key:shift")) context.keyboard.reset_state() backend.key_up.assert_called_once_with(backend.key_down.return_value) @@ -289,13 +317,21 @@ def test_key_down_without_backend_press_does_not_emit_pressed_state(self) -> Non backend.key_down.return_value = None context = make_test_context(backend) spec = KeySpec(label="Caps", row=0, column=0, key_id="caps", io_key="capslock", latchable=True) - events: list[BackendKeyStateChanged] = [] - context.dispatcher.add_event_handler(lambda event: events.append(event) if isinstance(event, BackendKeyStateChanged) else None) + events: list[KeyboardKeyStateChangedArguments] = [] + + def record(event: KeyboardKeyStateChangedArguments) -> MessageResult: + events.append(event) + return [] - context.dispatcher.dispatch_command(KeyboardKeyDown(LAYOUT_ID, spec)) + context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, record) + context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:caps") + context.dispatcher.dispatch_action(keyboard_key_down(LAYOUT_ID, "key:caps")) self.assertEqual(events, []) - self.assertIsNone(context.state.get(keyboard_key_states_namespace(LAYOUT_ID), "capslock")) + self.assertEqual( + context.state.get(keyboard_key_states_namespace(LAYOUT_ID), "capslock"), + {"pressed": False, "latched": False}, + ) def test_shared_latch_keys_keep_distinct_backend_pressed_state(self) -> None: backend = FakeKeyboardBackend() @@ -321,8 +357,8 @@ def test_service_reset_state_clears_latches_for_registered_layout(self) -> None: context = make_test_context(backend) spec = KeySpec(label="Shift", row=0, column=0, key_id="shift", io_key="leftshift", latchable=True) - context.keyboard.register_key_spec(LAYOUT_ID, spec) - context.dispatcher.dispatch_command(KeyboardSyncLatchedKey(LAYOUT_ID, spec, True)) + context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:shift") + context.dispatcher.dispatch_action(keyboard_sync_latched_key(LAYOUT_ID, "key:shift", True)) context.keyboard.reset_state() self.assertIsNone(context.state.get(keyboard_latches_namespace(LAYOUT_ID), "shift")) diff --git a/tests/test_prompt_component.py b/tests/test_prompt_component.py index 2546c45..82390e4 100644 --- a/tests/test_prompt_component.py +++ b/tests/test_prompt_component.py @@ -4,8 +4,9 @@ from PySide6.QtWidgets import QApplication, QPushButton, QWidget +from axidev_osk.messages import MessageResult from axidev_osk.config.defaults import build_default_app_config -from axidev_osk.runtime.events import PromptResolved +from axidev_osk.runtime.events import PROMPT_RESOLVED, PromptResolvedArguments from axidev_osk.runtime.testing import make_test_context @@ -32,10 +33,13 @@ def test_prompt_button_only_emits_resolution_event(self) -> None: config = build_default_app_config() context = make_test_context(FakeKeyboardBackend(), config=config) prompt = config.quit_prompt - resolved: list[PromptResolved] = [] - context.dispatcher.add_event_handler( - lambda event: resolved.append(event) if isinstance(event, PromptResolved) else None, - ) + resolved: list[PromptResolvedArguments] = [] + + def record(event: PromptResolvedArguments) -> MessageResult: + resolved.append(event) + return [] + + context.dispatcher.add_event_handler(PROMPT_RESOLVED, record) window = QWidget() self.addCleanup(window.close) prompt_widget = context.components.build(prompt, context, host=window) @@ -49,7 +53,7 @@ def test_prompt_button_only_emits_resolution_event(self) -> None: ) button.click() - self.assertEqual(resolved, [PromptResolved(prompt_id=prompt.id, result="accepted")]) + self.assertEqual(resolved, [PromptResolvedArguments(prompt_id=prompt.id, result="accepted")]) self.assertTrue(window.isVisible()) diff --git a/tests/test_runtime_identity.py b/tests/test_runtime_identity.py index 67a1c55..034d8b1 100644 --- a/tests/test_runtime_identity.py +++ b/tests/test_runtime_identity.py @@ -2,6 +2,8 @@ import unittest +from axidev_osk.config.models import GridConfig, KeyConfig, LayoutConfig +from axidev_osk.models import KeySpec from axidev_osk.runtime.identity import key_component_id, prompt_button_id, stable_id, validate_unique_ids @@ -49,6 +51,21 @@ def test_key_component_id_collides_for_duplicate_grid_position(self) -> None: self.assertEqual(first, second) + def test_layout_rejects_component_ids_reused_across_grids(self) -> None: + first = GridConfig( + id="grid:first", + components=(KeyConfig(id="component:shared", spec=KeySpec("A", 0, 0)),), + nav_start_column=0, + ) + second = GridConfig( + id="grid:second", + components=(KeyConfig(id="component:shared", spec=KeySpec("B", 0, 0)),), + nav_start_column=0, + ) + + with self.assertRaisesRegex(ValueError, "layout 'layout:test' components: component:shared"): + LayoutConfig(id="layout:test", name="test", grids=(first, second)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runtime_messages.py b/tests/test_runtime_messages.py new file mode 100644 index 0000000..b2c8c75 --- /dev/null +++ b/tests/test_runtime_messages.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import math +import unittest + +from axidev_osk.messages import DataMap, MessageResult, RuntimeAction, RuntimeEvent +from axidev_osk.runtime.actions import window_toggle_opacity +from axidev_osk.runtime.dispatcher import Dispatcher +from axidev_osk.runtime.events import ACTION_FAILED, ActionFailedArguments, register_builtin_events +from axidev_osk.runtime.state_store import StateStore + + +def _identity(arguments: DataMap) -> DataMap: + return arguments + + +class RuntimeMessageTests(unittest.TestCase): + def test_action_copies_nested_arguments(self) -> None: + source = {"nested": {"items": ["first"]}} + + action = RuntimeAction("test.copy", source) + source["nested"]["items"].append("changed") + + self.assertEqual(action.arguments, {"nested": {"items": ["first"]}}) + + def test_messages_reject_non_native_data(self) -> None: + with self.assertRaisesRegex(TypeError, "unsupported value tuple"): + RuntimeAction("test.invalid", {"value": (1, 2)}) # type: ignore[dict-item] + with self.assertRaisesRegex(TypeError, "keys must be strings"): + RuntimeEvent("test.invalid", {1: "value"}) # type: ignore[dict-item] + with self.assertRaisesRegex(ValueError, "finite numbers"): + RuntimeAction("test.invalid", {"value": math.inf}) + + def test_names_must_be_lowercase_and_namespaced(self) -> None: + for name in ("invalid", "Invalid.name", "invalid-name.value"): + with self.subTest(name=name), self.assertRaisesRegex(ValueError, "dot-separated"): + RuntimeAction(name, {}) + + def test_duplicate_registration_requires_explicit_override(self) -> None: + dispatcher = Dispatcher() + calls: list[str] = [] + + def first(_arguments: DataMap) -> MessageResult: + calls.append("first") + return [] + + def replacement(_arguments: DataMap) -> MessageResult: + calls.append("replacement") + return [] + + dispatcher.register_action("test.override", _identity, first) + with self.assertRaisesRegex(ValueError, "already registered"): + dispatcher.register_action("test.override", _identity, replacement) + dispatcher.register_action("test.override", _identity, replacement, override=True) + + dispatcher.dispatch_action(RuntimeAction("test.override", {})) + + self.assertEqual(calls, ["replacement"]) + + def test_handler_results_keep_fifo_order(self) -> None: + dispatcher = Dispatcher() + order: list[str] = [] + dispatcher.register_event("test.first", _identity) + dispatcher.register_event("test.second", _identity) + + def action_handler(_arguments: DataMap) -> MessageResult: + order.append("action") + return [RuntimeEvent("test.first", {}), RuntimeEvent("test.second", {})] + + def first_handler(_arguments: DataMap) -> MessageResult: + order.append("first") + return [RuntimeEvent("test.second", {"source": "first"})] + + def second_handler(arguments: DataMap) -> MessageResult: + order.append(str(arguments.get("source", "second"))) + return [] + + dispatcher.register_action("test.start", _identity, action_handler) + dispatcher.add_event_handler("test.first", first_handler) + dispatcher.add_event_handler("test.second", second_handler) + + dispatcher.dispatch_action(RuntimeAction("test.start", {})) + + self.assertEqual(order, ["action", "first", "second", "first"]) + + def test_action_decode_failure_emits_arguments_and_continues(self) -> None: + dispatcher = Dispatcher() + register_builtin_events(dispatcher) + failures: list[ActionFailedArguments] = [] + + def decode(_arguments: DataMap) -> DataMap: + raise ValueError("bad field") + + def unused(_arguments: DataMap) -> MessageResult: + self.fail("invalid action reached its handler") + + def record_failure(arguments: ActionFailedArguments) -> MessageResult: + failures.append(arguments) + return [] + + dispatcher.register_action("test.failure", decode, unused) + dispatcher.add_event_handler(ACTION_FAILED, record_failure) + + dispatcher.dispatch_action(RuntimeAction("test.failure", {"secret": "included"})) + + self.assertEqual(len(failures), 1) + self.assertEqual(failures[0].action, "test.failure") + self.assertEqual(failures[0].arguments, {"secret": "included"}) + self.assertEqual(failures[0].stage, "decode") + self.assertEqual(failures[0].exception_type, "ValueError") + self.assertEqual(failures[0].message, "bad field") + + def test_unknown_action_emits_action_failed(self) -> None: + dispatcher = Dispatcher() + register_builtin_events(dispatcher) + failures: list[ActionFailedArguments] = [] + + def record_failure(arguments: ActionFailedArguments) -> MessageResult: + failures.append(arguments) + return [] + + dispatcher.add_event_handler(ACTION_FAILED, record_failure) + + dispatcher.dispatch_action(RuntimeAction("test.missing", {"value": 1})) + + self.assertEqual(failures[0].stage, "lookup") + self.assertEqual(failures[0].arguments, {"value": 1}) + + def test_action_handler_failure_emits_action_failed(self) -> None: + dispatcher = Dispatcher() + register_builtin_events(dispatcher) + failures: list[ActionFailedArguments] = [] + + def fail(_arguments: DataMap) -> MessageResult: + raise RuntimeError("handler broke") + + def record_failure(arguments: ActionFailedArguments) -> MessageResult: + failures.append(arguments) + return [] + + dispatcher.register_action("test.execute", _identity, fail) + dispatcher.add_event_handler(ACTION_FAILED, record_failure) + + dispatcher.dispatch_action(RuntimeAction("test.execute", {"value": 2})) + + self.assertEqual(failures[0].stage, "execute") + self.assertEqual(failures[0].arguments, {"value": 2}) + + def test_invalid_handler_result_does_not_enqueue_partial_results(self) -> None: + dispatcher = Dispatcher() + register_builtin_events(dispatcher) + handled: list[str] = [] + failures: list[ActionFailedArguments] = [] + + def invalid_result(_arguments: DataMap) -> MessageResult: + return [RuntimeEvent("test.followup", {}), object()] # type: ignore[list-item] + + def record_followup(_arguments: DataMap) -> MessageResult: + handled.append("followup") + return [] + + def record_failure(arguments: ActionFailedArguments) -> MessageResult: + failures.append(arguments) + return [] + + dispatcher.register_event("test.followup", _identity) + dispatcher.add_event_handler("test.followup", record_followup) + dispatcher.add_event_handler(ACTION_FAILED, record_failure) + dispatcher.register_action("test.partial", _identity, invalid_result) + + dispatcher.dispatch_action(RuntimeAction("test.partial", {})) + + self.assertEqual(handled, []) + self.assertEqual(failures[0].stage, "execute") + + def test_builtin_constructor_validates_arguments_immediately(self) -> None: + with self.assertRaisesRegex(ValueError, "must not be empty"): + window_toggle_opacity("", "component:ghost", 0.01) + with self.assertRaisesRegex(ValueError, "less than 1.0"): + window_toggle_opacity("window:keyboard", "component:ghost", 1.0) + + def test_state_store_copies_values_on_write_and_read(self) -> None: + state = StateStore() + source = {"nested": [1]} + + state.set("test", "value", source) + source["nested"].append(2) + stored = state.get("test", "value") + self.assertEqual(stored, {"nested": [1]}) + + assert isinstance(stored, dict) + stored["nested"].append(3) + self.assertEqual(state.get("test", "value"), {"nested": [1]}) + + def test_event_handler_failure_skips_remaining_handlers_but_continues_queue(self) -> None: + dispatcher = Dispatcher() + order: list[str] = [] + dispatcher.register_event("test.source", _identity) + dispatcher.register_event("test.followup", _identity) + + def enqueue_followup(_arguments: DataMap) -> MessageResult: + order.append("first") + return [RuntimeEvent("test.followup", {})] + + def fail(_arguments: DataMap) -> MessageResult: + raise RuntimeError("broken handler") + + def skipped(_arguments: DataMap) -> MessageResult: + order.append("skipped") + return [] + + def followup(_arguments: DataMap) -> MessageResult: + order.append("followup") + return [] + + dispatcher.add_event_handler("test.source", enqueue_followup) + dispatcher.add_event_handler("test.source", fail) + dispatcher.add_event_handler("test.source", skipped) + dispatcher.add_event_handler("test.followup", followup) + + with self.assertLogs("axidev_osk.runtime.dispatcher", level="ERROR"): + dispatcher.dispatch_event(RuntimeEvent("test.source", {})) + + self.assertEqual(order, ["first", "followup"]) + + def test_unbounded_drain_warns_every_ten_thousand_messages_without_stopping(self) -> None: + dispatcher = Dispatcher() + handled = 0 + + def repeat(_arguments: DataMap) -> MessageResult: + nonlocal handled + handled += 1 + if handled >= 20_001: + return [] + return [RuntimeAction("test.repeat", {})] + + dispatcher.register_action("test.repeat", _identity, repeat) + + with self.assertLogs("axidev_osk.runtime.dispatcher", level="WARNING") as logs: + dispatcher.dispatch_action(RuntimeAction("test.repeat", {})) + + self.assertEqual(handled, 20_001) + self.assertEqual(len(logs.output), 2) + self.assertIn("10000 messages", logs.output[0]) + self.assertIn("20000 messages", logs.output[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_single_instance.py b/tests/test_single_instance.py index e68afc3..ff080e3 100644 --- a/tests/test_single_instance.py +++ b/tests/test_single_instance.py @@ -10,7 +10,8 @@ from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication -from axidev_osk.runtime.commands import WindowShow +from axidev_osk.messages import MessageResult +from axidev_osk.runtime.actions import WINDOW_SHOW, WindowArguments, decode_window, window_show from axidev_osk.runtime.registries import ServiceRegistry from axidev_osk.runtime.testing import make_test_context from axidev_osk.services import register_services @@ -52,8 +53,13 @@ def test_service_is_inactive_off_windows(self) -> None: def test_second_launch_activates_primary_instance(self) -> None: _app() context = make_test_context(FakeKeyboardBackend()) - commands: list[object] = [] - context.dispatcher.add_command_handler(WindowShow, commands.append) + actions: list[object] = [] + + def record(arguments: WindowArguments) -> MessageResult: + actions.append(window_show(arguments.window_id)) + return [] + + context.dispatcher.register_action(WINDOW_SHOW, decode_window, record) primary = WindowsSingleInstanceService() secondary = WindowsSingleInstanceService() server_name = f"axidev-osk-test-{uuid4().hex}" @@ -74,7 +80,7 @@ def test_second_launch_activates_primary_instance(self) -> None: secondary.stop() primary.stop() - self.assertEqual(commands, [WindowShow(context.config.keyboard_window_id)]) + self.assertEqual(actions, [window_show(context.config.keyboard_window_id)]) if __name__ == "__main__": diff --git a/tests/test_us_iso_layout.py b/tests/test_us_iso_layout.py index ade60e7..853ebb8 100644 --- a/tests/test_us_iso_layout.py +++ b/tests/test_us_iso_layout.py @@ -92,11 +92,17 @@ def test_ghost_key_uses_the_near_bracket_slot_and_targets_configured_window() -> target_window_id = "window:alternate" specs = build_us_iso_layout(target_window_id=target_window_id) ghost = next(spec for spec in specs if spec.label == "Ghost") + config_ghost = next( + component + for component in build_us_iso_layout_config(target_window_id=target_window_id).grids[0].components + if component.spec.label == "Ghost" + ) assert (ghost.row, ghost.column, ghost.width) == (2, 54, 1.0) assert ghost.io_key is None assert ghost.repeats is False assert ghost.action is not None - assert ghost.action.kind == "toggle-opacity" - assert ghost.action.target_window_id == target_window_id - assert ghost.action.opacity == 0.01 + assert ghost.action.action == "window.toggle_opacity" + assert ghost.action.arguments["window_id"] == target_window_id + assert ghost.action.arguments["component_id"] == config_ghost.id + assert ghost.action.arguments["opacity"] == 0.01 From 25b6ca5b6adb1477433be4f89988d67bc8d32c13 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 24 Aug 2026 21:35:26 +0200 Subject: [PATCH 2/3] fix(ci): run pyright after dependency installation Written by inayayousfi, typed by gpt-5.6-sol running in OpenCode. Every call here is inayayousfi's, and no agent acted on its own. Run type checking only after PySide6, axidev-io, and Axidev OSK are available to the selected Python interpreter. --- .github/workflows/reusable-check-ubuntu.yml | 6 +++--- .github/workflows/reusable-check-windows.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/reusable-check-ubuntu.yml b/.github/workflows/reusable-check-ubuntu.yml index bfefc90..42b7fef 100644 --- a/.github/workflows/reusable-check-ubuntu.yml +++ b/.github/workflows/reusable-check-ubuntu.yml @@ -24,9 +24,6 @@ jobs: run: | python -c "import os, pathlib, subprocess, sysconfig; scripts = pathlib.Path(sysconfig.get_path('scripts')); exe = scripts / ('flake8.exe' if os.name == 'nt' else 'flake8'); raise SystemExit(subprocess.run([str(exe), '--select=F,E9,W6', 'src']).returncode)" - - name: Type-check application source - run: python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" - - name: Build vendored axidev-io stack uses: ./.github/actions/build-vendored-axidev-io-ubuntu with: @@ -48,6 +45,9 @@ jobs: - name: Install axidev-osk run: python -m pip install -e . + - name: Type-check application source + run: python -m pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" + - name: Run app tests env: PYTHONPATH: src diff --git a/.github/workflows/reusable-check-windows.yml b/.github/workflows/reusable-check-windows.yml index 911a2f0..ed7e548 100644 --- a/.github/workflows/reusable-check-windows.yml +++ b/.github/workflows/reusable-check-windows.yml @@ -24,9 +24,6 @@ jobs: run: | python -c "import os, pathlib, subprocess, sysconfig; scripts = pathlib.Path(sysconfig.get_path('scripts')); exe = scripts / ('flake8.exe' if os.name == 'nt' else 'flake8'); raise SystemExit(subprocess.run([str(exe), '--select=F,E9,W6', 'src']).returncode)" - - name: Type-check application source - run: python -m pyright --pythonpath (python -c "import sys; print(sys.executable)") - - name: Build vendored axidev-io stack uses: ./.github/actions/build-vendored-axidev-io-windows with: @@ -48,6 +45,9 @@ jobs: - name: Install axidev-osk run: python -m pip install -e . + - name: Type-check application source + run: python -m pyright --pythonpath (python -c "import sys; print(sys.executable)") + - name: Run app tests env: PYTHONPATH: src From 511fb6883ac7736014805bdb546c764b7ea81162 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 24 Aug 2026 23:15:53 +0200 Subject: [PATCH 3/3] refactor(runtime): separate visual config from behavior Written by inayayousfi, typed by gpt-5.6-sol running in OpenCode. Every call here is inayayousfi's, and no agent acted on its own. Move component policy and keyboard output into root SourcePath behavior bindings backed by runtime state snapshots and ordered hooks. Keep controls render-only, isolate keyboard backend I/O, and migrate the bundled config and tests to explicit visual IDs and behavior maps. --- AGENTS.md | 14 + src/axidev_osk/components/button/__init__.py | 7 +- src/axidev_osk/components/button/builder.py | 34 +- src/axidev_osk/components/button/key.py | 177 ++---- src/axidev_osk/components/button/state.py | 163 ----- src/axidev_osk/components/grid/builder.py | 11 +- src/axidev_osk/components/grid/keyboard.py | 588 +++++-------------- src/axidev_osk/components/key/builder.py | 26 +- src/axidev_osk/components/prompt/builder.py | 39 +- src/axidev_osk/config/defaults/__init__.py | 123 +++- src/axidev_osk/config/defaults/us_iso.py | 548 +++++++++-------- src/axidev_osk/config/models.py | 68 ++- src/axidev_osk/models.py | 119 +--- src/axidev_osk/runtime/actions.py | 138 +++-- src/axidev_osk/runtime/application.py | 14 +- src/axidev_osk/runtime/behavior_models.py | 58 ++ src/axidev_osk/runtime/behaviors.py | 500 ++++++++++++++++ src/axidev_osk/runtime/config_paths.py | 119 ++++ src/axidev_osk/runtime/context.py | 3 + src/axidev_osk/runtime/decoding.py | 74 +-- src/axidev_osk/runtime/event_handlers.py | 95 ++- src/axidev_osk/runtime/events.py | 211 +++---- src/axidev_osk/runtime/identity.py | 42 -- src/axidev_osk/runtime/registries.py | 15 +- src/axidev_osk/runtime/source.py | 83 +++ src/axidev_osk/runtime/testing.py | 24 +- src/axidev_osk/services/keyboard/io.py | 94 +-- src/axidev_osk/services/keyboard/service.py | 279 ++------- src/axidev_osk/windows/builder.py | 6 +- src/axidev_osk/windows/surface.py | 14 +- tests/test_application_runtime.py | 9 +- tests/test_behaviors.py | 411 +++++++++++++ tests/test_hot_corner_events.py | 63 +- tests/test_key_state_listener.py | 139 +++-- tests/test_keyboard_io_repeat.py | 23 +- tests/test_keyboard_metrics.py | 23 +- tests/test_keyboard_service.py | 486 +++++---------- tests/test_prompt_component.py | 26 +- tests/test_runtime_identity.py | 70 ++- tests/test_service_registry.py | 30 +- tests/test_single_instance.py | 16 + tests/test_us_iso_layout.py | 273 +++++---- tests/test_window_builder.py | 28 +- 43 files changed, 2920 insertions(+), 2363 deletions(-) delete mode 100644 src/axidev_osk/components/button/state.py create mode 100644 src/axidev_osk/runtime/behavior_models.py create mode 100644 src/axidev_osk/runtime/behaviors.py create mode 100644 src/axidev_osk/runtime/config_paths.py create mode 100644 src/axidev_osk/runtime/source.py create mode 100644 tests/test_behaviors.py diff --git a/AGENTS.md b/AGENTS.md index cc2c0e4..650c48b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,20 @@ When adding or refactoring code, keep these boundaries clear: - runtime/orchestration concerns: Event queue ownership, action routing, callback scheduling, state store updates, and subsystem boundaries. +## Visual And Behavior Configuration + +Visual config describes what a component looks like and where it appears. It may contain labels, display variants, geometry, component kinds, and stable IDs. It must not contain backend keys, runtime actions, latch policy, callbacks, or durable interaction state. + +Behavior config is separate and belongs to the root application config. Each interactive key or generic button must have exactly one `BehaviorBinding` addressed by its full `SourcePath`. Loading must fail before window construction when a target is missing, duplicated, unresolved, attached to a non-interactive node, or uses an unknown behavior or hook kind. + +Components emit only raw interactions such as `component.pressed` and `component.released`. They render complete state snapshots from the central runtime store. Components must not decide keyboard policy, resolve prompt results, call backend services, or keep durable latch state. + +Behavior handlers and hooks return queue messages. Blocking before-hooks may cancel or replace default behavior. Later before-hooks still run, and the last cancel or replace decision wins. After-hooks may extend completed behavior but cannot undo it. + +Keyboard behavior must declare an explicit output key and interaction mode. The keyboard service owns backend lifecycle, output registration, backend observations, and active press handles. It does not own visual data, latch policy, or durable component state. + +A `SourcePath` contains ordered app, profile, window, surface, component, layout, grid, and child-component segments as applicable. Queue messages carry the path as native data. Runtime state uses a collision-free encoding of the full path. + ## Preferred Direction For New Work - Prefer data-driven builders over handwritten widget trees. diff --git a/src/axidev_osk/components/button/__init__.py b/src/axidev_osk/components/button/__init__.py index 1026b30..395f357 100644 --- a/src/axidev_osk/components/button/__init__.py +++ b/src/axidev_osk/components/button/__init__.py @@ -1,14 +1,9 @@ """Button component registration and primitives.""" from .builder import build_button_component, register -from .key import KeyButton, create_key_button -from .state import KeyInteractionState, KeyStateChange, KeyStateMachine +from .key import create_key_button __all__ = [ - "KeyButton", - "KeyInteractionState", - "KeyStateChange", - "KeyStateMachine", "build_button_component", "create_key_button", "register", diff --git a/src/axidev_osk/components/button/builder.py b/src/axidev_osk/components/button/builder.py index 35ff4e3..44f8dd7 100644 --- a/src/axidev_osk/components/button/builder.py +++ b/src/axidev_osk/components/button/builder.py @@ -2,12 +2,17 @@ from __future__ import annotations +from collections.abc import Mapping + from PySide6.QtWidgets import QPushButton, QWidget from ...config.models import ButtonConfig, ComponentConfig +from ...messages import MessageResult from ...runtime.context import Context +from ...runtime.events import STATE_CHANGED, StateChangedArguments, component_pressed, component_released from ...runtime.registries import ComponentRegistry +from ...runtime.source import SourcePath def register(registry: ComponentRegistry) -> None: @@ -30,6 +35,7 @@ def build_button_component( config: ComponentConfig, context: Context, *, + source_path: SourcePath, host: QWidget | None = None, ) -> QPushButton: """Build a QPushButton from declarative config. @@ -37,24 +43,46 @@ def build_button_component( Args: config: Button component config. context: Runtime context. + source_path: Exact runtime identity used for events and state. host: Unused; accepted for registry signature parity. Returns: Constructed QPushButton. Side effects: - None beyond widget construction. + Subscribes to runtime state and emits raw component interactions. """ - del context, host + del host if not isinstance(config, ButtonConfig): raise TypeError(f"Expected ButtonConfig, got {type(config).__name__}") button = QPushButton(config.label) button.setProperty("componentType", "button") button.setProperty("componentId", config.id) - button.setProperty("role", config.role) if config.object_name is not None: button.setObjectName(config.object_name) if config.style_sheet is not None: button.setStyleSheet(config.style_sheet) + + def render_state(state: Mapping[str, object]) -> None: + button.setProperty("pressed", bool(state.get("pressed", False))) + button.setProperty("latched", bool(state.get("latched", False))) + button.style().unpolish(button) + button.style().polish(button) + button.update() + + def receive_state(event: StateChangedArguments) -> MessageResult: + if event.source == source_path: + render_state(event.state) + return [] + + render_state(context.behaviors.state_snapshot(source_path)) + unsubscribe = context.dispatcher.add_event_handler(STATE_CHANGED, receive_state) + button.destroyed.connect(lambda _object=None: unsubscribe()) + button.pressed.connect( + lambda: context.dispatcher.dispatch_event(component_pressed(source_path)) + ) + button.released.connect( + lambda: context.dispatcher.dispatch_event(component_released(source_path)) + ) return button diff --git a/src/axidev_osk/components/button/key.py b/src/axidev_osk/components/button/key.py index 02efa02..158b31a 100644 --- a/src/axidev_osk/components/button/key.py +++ b/src/axidev_osk/components/button/key.py @@ -1,98 +1,48 @@ -"""Key button widget construction and label/state helpers. - -Key buttons are reusable Qt widgets paired with a ``KeyStateMachine``. -Construction stays in this leaf component; latch wiring, event dispatch, -and durable state ownership are the responsibility of the containing -keyboard grid and the runtime context. -""" +"""Render-only key button construction and state helpers.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass +from collections.abc import Callable, Mapping from PySide6.QtCore import Qt from PySide6.QtWidgets import QPushButton, QSizePolicy from ..grid.metrics import DEFAULT_KEYBOARD_METRICS, KeyboardMetrics -from .state import KeyStateMachine, StateListener VoidCallback = Callable[[], None] -@dataclass(frozen=True) -class KeyButton: - """Construction result pairing a key button with its state machine. - - The pair is returned together so callers do not need to fish the - state machine out of a private widget attribute. Durable ownership - of the machine belongs to the runtime state store; this dataclass is - just the construction handoff. - """ - - button: QPushButton - state_machine: KeyStateMachine - - def format_key_label(label: str, secondary_label: str | None = None) -> str: - """Format a key button label with an optional secondary line above it. - - Args: - label: Primary label text. - secondary_label: Optional shifted/secondary glyph rendered above the - primary label. - - Returns: - Combined label string, with a newline separating secondary and primary - when both are present. - - Side effects: - None. - """ - if secondary_label is None: return label return f"{secondary_label}\n{label}" -def set_key_button_label(button: QPushButton, label: str, secondary_label: str | None = None) -> None: - """Apply a formatted label to an existing key button. - - Args: - button: Existing key button. - label: Primary label text. - secondary_label: Optional secondary glyph rendered above the primary. - - Returns: - None. - - Side effects: - Mutates the button's displayed text. - """ - +def set_key_button_label( + button: QPushButton, + label: str, + secondary_label: str | None = None, +) -> None: button.setText(format_key_label(label, secondary_label)) -def refresh_key_button(button: QPushButton, state_machine: KeyStateMachine) -> None: - """Sync a key button's Qt properties with its state machine. - - Args: - button: Key button created by ``create_key_button``. - state_machine: The button's interaction state machine. - - Returns: - None. - - Side effects: - Updates dynamic Qt properties (``pressed``, ``latched``, - ``interactionState``), the checked flag, and triggers a style - repolish so QSS selectors react to the new state. - """ - - button.setProperty("pressed", state_machine.is_pressed) - button.setProperty("latched", state_machine.is_latched) - button.setProperty("interactionState", state_machine.state.value) - button.setChecked(state_machine.is_latched) +def render_key_button_state(button: QPushButton, state: Mapping[str, object]) -> None: + """Render a complete runtime-owned state snapshot on one key button.""" + + pressed = bool(state.get("pressed", False)) + latched = bool(state.get("latched", False)) + if pressed and latched: + interaction_state = "latched_pressed" + elif pressed: + interaction_state = "pressed" + elif latched: + interaction_state = "latched" + else: + interaction_state = "idle" + button.setProperty("pressed", pressed) + button.setProperty("latched", latched) + button.setProperty("interactionState", interaction_state) + button.setChecked(latched) button.style().unpolish(button) button.style().polish(button) button.update() @@ -102,93 +52,32 @@ def create_key_button( label: str, *, component_id: str, - state_machine: KeyStateMachine | None = None, - latchable: bool = False, - initial_latched: bool = False, - on_state_change: StateListener | None = None, width: float = 1.0, secondary_label: str | None = None, - key_id: str | None = None, - io_key: str | None = None, profile: str | None = None, layout: str | None = None, on_press: VoidCallback | None = None, on_release: VoidCallback | None = None, metrics: KeyboardMetrics | None = None, -) -> KeyButton: - """Create a configured key button paired with its state machine. - - Args: - label: Visible primary label. - state_machine: Optional pre-constructed state machine. When omitted, - a new one is created using ``latchable`` and ``initial_latched``. - latchable: Whether the button supports latched/locked behavior. - initial_latched: Initial latch state when constructing a new machine. - on_state_change: Optional listener called for every state transition. - component_id: Required deterministic component ID stored on the Qt widget. - width: Layout width in keyboard units; controls minimum width and the - ``keyWidth`` Qt dynamic property. - secondary_label: Optional shifted glyph rendered above the primary. - key_id: Modifier identity string (e.g. ``"shift"``). - io_key: Backend input key name forwarded to the keyboard service. - profile: Active profile string written to the ``profile`` Qt property. - layout: Active deterministic layout ID written to the ``layout`` Qt property. - on_press: Optional callback fired on Qt ``pressed``. - on_release: Optional callback fired on Qt ``released`` after the - internal state machine processes the release and toggles latch. - metrics: Pixel metrics used to size the button. Defaults to - ``DEFAULT_KEYBOARD_METRICS`` when omitted. - - Returns: - ``KeyButton`` pairing the constructed ``QPushButton`` with the - ``KeyStateMachine`` that drives it. Callers are responsible for - storing the machine wherever durable ownership lives (typically - the runtime state store, namespaced by component ID). - - Side effects: - Connects ``pressed``/``released`` signals to internal handlers and - registers a listener that keeps Qt properties synced. - """ +) -> QPushButton: + """Create a visual key button that emits callbacks but owns no state.""" button = QPushButton() cell_metrics = metrics or DEFAULT_KEYBOARD_METRICS - machine = state_machine or KeyStateMachine(latchable=latchable, initial_latched=initial_latched) set_key_button_label(button, label, secondary_label) button.setProperty("componentType", "key") button.setProperty("componentId", component_id) - button.setProperty("keyId", key_id) - button.setProperty("ioKey", io_key) button.setProperty("profile", profile) button.setProperty("layout", layout) button.setProperty("keyWidth", width) - button.setProperty("pressed", machine.is_pressed) - button.setProperty("latched", machine.is_latched) - button.setProperty("latchable", machine.latchable) - button.setProperty("interactionState", machine.state.value) button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - button.setCheckable(machine.latchable) + button.setCheckable(True) button.setMinimumHeight(cell_metrics.span_height(1)) button.setMinimumWidth(cell_metrics.span_width(width)) button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - refresh_key_button(button, machine) - - machine.add_listener(lambda _change: refresh_key_button(button, machine)) - if on_state_change is not None: - machine.add_listener(on_state_change) - - def handle_press() -> None: - machine.press() - if on_press is not None: - on_press() - - def handle_release() -> None: - if machine.latchable: - machine.release_and_toggle_latched() - else: - machine.release() - if on_release is not None: - on_release() - - button.pressed.connect(handle_press) - button.released.connect(handle_release) - return KeyButton(button=button, state_machine=machine) + render_key_button_state(button, {}) + if on_press is not None: + button.pressed.connect(on_press) + if on_release is not None: + button.released.connect(on_release) + return button diff --git a/src/axidev_osk/components/button/state.py b/src/axidev_osk/components/button/state.py deleted file mode 100644 index ad4d2a4..0000000 --- a/src/axidev_osk/components/button/state.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Interaction state machine for key buttons. - -Tracks the orthogonal pressed/latched dimensions of a button and exposes -a single composed ``KeyInteractionState`` value plus a listener stream -of ``KeyStateChange`` records. Pure data; no Qt dependencies. -""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from enum import Enum - - -class KeyInteractionState(str, Enum): - """Composed pressed/latched state of a key button.""" - - IDLE = "idle" - PRESSED = "pressed" - LATCHED = "latched" - LATCHED_PRESSED = "latched_pressed" - - @property - def is_active(self) -> bool: - """Whether this UX state requires the backend key to stay down.""" - - return self is not KeyInteractionState.IDLE - - -@dataclass(frozen=True, slots=True) -class KeyStateChange: - """Listener payload describing a single state transition. - - Attributes: - previous: State immediately before the transition. - current: State after the transition. - reason: Free-form tag identifying which call drove the change - (``"press"``, ``"release"``, ``"toggle_latched"``, ...). - """ - - previous: KeyInteractionState - current: KeyInteractionState - reason: str - - -StateListener = Callable[[KeyStateChange], None] - - -class KeyStateMachine: - """Pressed/latched state machine with listener fan-out. - - Side effects: - Listeners registered via ``add_listener`` are invoked synchronously - on every distinct transition. Listener order is registration order; - a snapshot of the listener list is taken before dispatch so a - listener may freely register or remove other listeners during - notification without affecting the current dispatch. - """ - - def __init__(self, *, latchable: bool = False, initial_latched: bool = False) -> None: - self._latchable = latchable - self._state = self._compose_state(pressed=False, latched=initial_latched) - self._listeners: list[StateListener] = [] - - @property - def latchable(self) -> bool: - """Whether latch transitions are enabled for this key.""" - - return self._latchable - - @property - def state(self) -> KeyInteractionState: - """Current composed interaction state.""" - - return self._state - - @property - def is_pressed(self) -> bool: - """Whether the key is currently pressed.""" - - return self._state in { - KeyInteractionState.PRESSED, - KeyInteractionState.LATCHED_PRESSED, - } - - @property - def is_latched(self) -> bool: - """Whether the key is currently latched.""" - - return self._state in { - KeyInteractionState.LATCHED, - KeyInteractionState.LATCHED_PRESSED, - } - - @property - def is_active(self) -> bool: - """Whether pressed or latched state keeps this key active.""" - - return self._state.is_active - - def add_listener(self, listener: StateListener) -> None: - """Register a synchronous state-change listener.""" - - self._listeners.append(listener) - - def press(self) -> None: - """Transition to the pressed state when needed.""" - - self.set_pressed(True, reason="press") - - def release(self) -> None: - """Transition out of the pressed state when needed.""" - - self.set_pressed(False, reason="release") - - def release_and_toggle_latched(self) -> None: - """Finish a latchable click without publishing an idle handoff state.""" - - if not self._latchable: - self.release() - return - self._transition_to( - self._compose_state(pressed=False, latched=not self.is_latched), - "release_and_toggle_latched", - ) - - def set_pressed(self, pressed: bool, *, reason: str = "set_pressed") -> None: - """Set the pressed dimension while preserving latch state.""" - - self._transition_to(self._compose_state(pressed=pressed, latched=self.is_latched), reason) - - def toggle_latched(self) -> None: - """Toggle latch state when this machine is latchable.""" - - if not self._latchable: - return - self.set_latched(not self.is_latched, reason="toggle_latched") - - def set_latched(self, latched: bool, *, reason: str = "set_latched") -> None: - """Set the latch dimension when this machine is latchable.""" - - if not self._latchable: - return - self._transition_to(self._compose_state(pressed=self.is_pressed, latched=latched), reason) - - def _compose_state(self, *, pressed: bool, latched: bool) -> KeyInteractionState: - if pressed and latched: - return KeyInteractionState.LATCHED_PRESSED - if pressed: - return KeyInteractionState.PRESSED - if latched: - return KeyInteractionState.LATCHED - return KeyInteractionState.IDLE - - def _transition_to(self, next_state: KeyInteractionState, reason: str) -> None: - if next_state == self._state: - return - - previous = self._state - self._state = next_state - change = KeyStateChange(previous=previous, current=next_state, reason=reason) - for listener in tuple(self._listeners): - listener(change) diff --git a/src/axidev_osk/components/grid/builder.py b/src/axidev_osk/components/grid/builder.py index 06356c7..563c9e2 100644 --- a/src/axidev_osk/components/grid/builder.py +++ b/src/axidev_osk/components/grid/builder.py @@ -7,6 +7,7 @@ from ...config.models import ComponentConfig, KeyboardGridConfig, KeyboardStatusConfig from ...runtime.context import Context from ...runtime.registries import ComponentRegistry +from ...runtime.source import SourcePath from .keyboard import KeyboardWidget @@ -31,20 +32,22 @@ def build_keyboard_grid_component( config: ComponentConfig, context: Context, *, + source_path: SourcePath, host: QWidget | None = None, ) -> QWidget: """Build a keyboard grid component from layout config. Args: config: Keyboard grid config carrying a ``LayoutConfig`` payload. - context: Runtime context, used for the keyboard service and dispatcher. + context: Runtime context used for state and interaction events. + source_path: Exact runtime identity of the grid component. host: Unused; accepted for registry signature parity. Returns: Constructed ``KeyboardWidget`` populated with keys from the layout. Side effects: - Subscribes the widget to the keyboard service for live key state. + Subscribes the widget to runtime-owned state snapshots. """ del host @@ -53,6 +56,7 @@ def build_keyboard_grid_component( widget = KeyboardWidget( layout_config=config.layout, context=context, + source_path=source_path, metrics=config.metrics, ) widget.setProperty("componentId", config.id) @@ -63,6 +67,7 @@ def build_keyboard_status_component( config: ComponentConfig, context: Context, *, + source_path: SourcePath, host: QWidget | None = None, ) -> QWidget: """Build a keyboard backend status label when output is unavailable. @@ -79,7 +84,7 @@ def build_keyboard_status_component( None beyond widget construction. """ - del host + del host, source_path if not isinstance(config, KeyboardStatusConfig): raise TypeError(f"Expected KeyboardStatusConfig, got {type(config).__name__}") label = QLabel(context.keyboard.status_text) diff --git a/src/axidev_osk/components/grid/keyboard.py b/src/axidev_osk/components/grid/keyboard.py index 0a72738..bc91b88 100644 --- a/src/axidev_osk/components/grid/keyboard.py +++ b/src/axidev_osk/components/grid/keyboard.py @@ -1,558 +1,244 @@ -"""Keyboard grid widget that builds key buttons from declarative layout config.""" +"""Visual keyboard grid built from layout config and runtime snapshots.""" from __future__ import annotations -import logging from collections.abc import Callable from PySide6.QtCore import QObject, Signal from PySide6.QtWidgets import QFrame, QGridLayout, QPushButton, QWidget from ...config.models import GridConfig, KeyConfig, LayoutConfig, SpacerConfig -from ...models import KeySpec -from ...messages import MessageResult, RuntimeAction, RuntimeEvent -from ...runtime.actions import ( - keyboard_key_down, - keyboard_key_up, - keyboard_register_key_spec, - keyboard_sync_latched_key, - state_set, -) +from ...messages import MessageResult +from ...models import KeyVisual, SpacerVisual from ...runtime.context import Context -from ...runtime.diagnostics import keyboard_debug_enabled from ...runtime.events import ( - KEYBOARD_KEY_REGISTERED, - KEYBOARD_KEY_STATE_CHANGED, - KEYBOARD_LATCH_CHANGED, - KeyboardKeyRegisteredArguments, - KeyboardKeyStateChangedArguments, - KeyboardLatchChangedArguments, + STATE_CHANGED, + StateChangedArguments, component_pressed, component_released, - component_state_changed, ) -from ...runtime.identity import component_state_namespace, keyboard_key_states_namespace, keyboard_latches_namespace -from ..button.key import create_key_button, set_key_button_label -from ..button.state import KeyInteractionState, KeyStateChange, KeyStateMachine +from ...runtime.source import SourcePath +from ..button.key import create_key_button, render_key_button_state, set_key_button_label from .metrics import KeyboardMetrics Unsubscribe = Callable[[], None] - -_logger = logging.getLogger(__name__) +GridVisual = KeyVisual | SpacerVisual class _KeyStateBridge(QObject): - """Qt signal relay used to marshal backend key-state callbacks into the GUI thread.""" - - key_state_changed = Signal(str, str, bool, bool) - key_latch_changed = Signal(str, str, bool) - key_registered = Signal(str, str, object) + state_changed = Signal(object, object) class KeyboardWidget(QFrame): - """Keyboard grid component built from declarative layout data. - - The widget is a reusable composition primitive: it accepts a ``LayoutConfig`` - and uses the runtime ``Context`` (when present) to dispatch actions and - events through the central runtime instead of calling backend services - directly. - - A ``KeyboardWidget`` does not know about any specific bundled layout. Callers - that want the default Axidev US ISO layout must build a ``LayoutConfig`` via - the bundled config layer (``config.defaults``) and pass it in. - """ + """Place visual keys and render state owned by the main runtime.""" def __init__( self, *, layout_config: LayoutConfig, context: Context, + source_path: SourcePath, metrics: KeyboardMetrics | None = None, ) -> None: - """Construct a keyboard grid populated from layout data. - - Args: - layout_config: Declarative layout describing grids, keys, and - spacers. Required so the widget never embeds a default layout. - context: Runtime context that owns the keyboard service, - dispatcher, and state store. All backend interaction and - event dispatch flow through it. Tests should build a - context via ``axidev_osk.runtime.testing.make_test_context``. - metrics: Pixel metrics applied to keys in this grid. When - omitted, defaults to ``KeyboardMetrics()``. - - Returns: - None. - - Side effects: - Builds child widgets and subscribes to the keyboard service for - live key state changes. - """ - super().__init__() self._metrics = metrics or KeyboardMetrics() self._context = context self._layout_config = layout_config - self._latch_groups: dict[str, list[KeyStateMachine]] = { - "shift": [], - "caps": [], - "ctrl": [], - "alt": [], - "altgr": [], - "super": [], - } - self._syncing_latch_keys: set[str] = set() - self._hold_visual_modifiers: set[str] = set() - self._buttons_by_spec: list[tuple[QPushButton, KeySpec]] = [] - self._buttons_by_component_id: dict[str, QPushButton] = {} - self._state_machines_by_key_id: dict[str, list[KeyStateMachine]] = {} - self._key_state_bridge = _KeyStateBridge(self) + self._source_path = source_path + self._layout_path = source_path.child("layout", layout_config.id) + self._active_state_tags = self._read_layout_tags() + self._buttons_by_source: dict[SourcePath, QPushButton] = {} + self._buttons_by_visual: list[tuple[QPushButton, KeyVisual]] = [] + self._state_bridge = _KeyStateBridge(self) self._event_unsubscribes: list[Unsubscribe] = [] self.setObjectName("keyboard") self.setProperty("componentType", "grid") - self.setProperty("componentId", self._layout_config.id) - self.setProperty("layout", self._layout_config.id) - self.setProperty("layoutName", self._layout_config.name) + self.setProperty("componentId", source_path.segments[-1].id) + self.setProperty("layout", layout_config.id) + self.setProperty("layoutName", layout_config.name) self.setFrameShape(QFrame.Shape.NoFrame) - self._subscribe_to_runtime_key_state() + self._subscribe_to_runtime_state() container = QGridLayout(self) container.setContentsMargins(0, 0, 0, 0) container.setHorizontalSpacing(self._metrics.grid_gap_px) container.setVerticalSpacing(self._metrics.grid_gap_px) - - for grid in self._layout_config.grids: + for grid in layout_config.grids: body_column_count = self._add_grid(container, grid) - for column in range(body_column_count): container.setColumnStretch(column, 1) - for row in range(grid.body_row_count): container.setRowStretch(row, 1) self._refresh_key_legends() - self.destroyed.connect(lambda _object=None: self._unsubscribe_from_runtime_key_state()) + self.destroyed.connect(lambda _object=None: self._unsubscribe_from_runtime_state()) @property def key_metrics(self) -> KeyboardMetrics: - """Return metrics inherited by child key and spacer builders.""" - return self._metrics - def _add_grid(self, container: QGridLayout, grid: GridConfig) -> int: - """Place a single grid's components into the Qt container. - - Args: - container: Target ``QGridLayout``. - grid: Grid DTO containing keys/spacers and metadata. - - Returns: - The number of dense body columns produced; used by the caller to - apply column stretch. - - Side effects: - Adds child widgets to the container. - """ + def build_key_from_config( + self, + config: KeyConfig, + context: Context, + source_path: SourcePath, + ) -> QPushButton: + del context + visual = config.visual + display = visual.resolve_display(self._active_state_tags) + button = create_key_button( + display.label, + component_id=config.id, + width=visual.width, + secondary_label=display.secondary_label, + profile=self._context.config.active_profile_id, + layout=self._layout_config.id, + on_press=lambda: self._context.dispatcher.dispatch_event( + component_pressed(source_path) + ), + on_release=lambda: self._context.dispatcher.dispatch_event( + component_released(source_path) + ), + metrics=self._metrics, + ) + if visual.height > 1: + button.setMinimumHeight(self._metrics.span_height(visual.height)) + render_key_button_state(button, self._context.behaviors.state_snapshot(source_path)) + self._buttons_by_source[source_path] = button + self._buttons_by_visual.append((button, visual)) + return button - function_components = [component for component in grid.components if component.spec.row == 0] - body_components = [component for component in grid.components if component.spec.row > 0] - body_column_map = self._build_dense_column_map([component.spec for component in body_components]) - body_column_count = self._count_occupied_columns([component.spec for component in body_components]) + def _add_grid(self, container: QGridLayout, grid: GridConfig) -> int: + grid_path = self._layout_path.child("grid", grid.id) + function_components = [component for component in grid.components if component.visual.row == 0] + body_components = [component for component in grid.components if component.visual.row > 0] + body_column_map = self._build_dense_column_map( + [component.visual for component in body_components] + ) + body_column_count = len(body_column_map) self._add_function_row( container, function_components, + grid_path=grid_path, nav_start_column=grid.nav_start_column, body_column_map=body_column_map, ) - self._add_body_grid(container, body_components) + self._add_body_grid(container, body_components, grid_path) return body_column_count - def _build_dense_column_map(self, specs: list[KeySpec]) -> dict[int, int]: - """Compute dense column indices for a sparse component column layout. - - Args: - specs: Specs whose ``column`` and ``width`` define occupied cells. - - Returns: - Mapping from sparse column index to dense column index. - - Side effects: - None. - """ - + def _build_dense_column_map(self, visuals: list[GridVisual]) -> dict[int, int]: occupied_columns: set[int] = set() - for spec in specs: - column_span = int(spec.width * 4) - occupied_columns.update(range(spec.column, spec.column + column_span)) + for visual in visuals: + column_span = int(visual.width * 4) + occupied_columns.update(range(visual.column, visual.column + column_span)) return { - column: dense_index for dense_index, column in enumerate(sorted(occupied_columns)) + column: dense_index + for dense_index, column in enumerate(sorted(occupied_columns)) } - def _count_occupied_columns(self, specs: list[KeySpec]) -> int: - """Count how many dense columns the supplied specs occupy.""" - - return len(self._build_dense_column_map(specs)) - def _add_function_row( self, container: QGridLayout, components: list[KeyConfig | SpacerConfig], *, + grid_path: SourcePath, nav_start_column: int, body_column_map: dict[int, int], ) -> None: - """Place row-0 (function row) components. - - Args: - container: Target Qt grid layout. - components: Function-row components to place. - nav_start_column: Sparse column where the navigation block begins. - body_column_map: Dense column map produced from the body rows; the - navigation block is aligned against the body so the function - row sits visually correctly above it. - - Returns: - None. - - Side effects: - Adds child widgets to the container. - """ - - left_block_specs = [component.spec for component in components if component.spec.column < nav_start_column] - left_column_map = self._build_dense_column_map(left_block_specs) - + left_block = [ + component.visual + for component in components + if component.visual.column < nav_start_column + ] + left_column_map = self._build_dense_column_map(left_block) for component in components: - spec = component.spec - column_span = int(spec.width * 4) + visual = component.visual + column_span = int(visual.width * 4) dense_column = ( - body_column_map[spec.column] - if spec.column >= nav_start_column - else left_column_map[spec.column] + body_column_map[visual.column] + if visual.column >= nav_start_column + else left_column_map[visual.column] + ) + container.addWidget( + self._build_item(component, grid_path), + 0, + dense_column, + visual.height, + column_span, ) - container.addWidget(self._build_item(component), 0, dense_column, spec.height, column_span) def _add_body_grid( self, container: QGridLayout, components: list[KeyConfig | SpacerConfig], + grid_path: SourcePath, ) -> None: - """Place body-row components using a dense column map.""" - - column_map = self._build_dense_column_map([component.spec for component in components]) + column_map = self._build_dense_column_map( + [component.visual for component in components] + ) for component in components: - spec = component.spec - column_span = int(spec.width * 4) - dense_column = column_map[spec.column] - container.addWidget(self._build_item(component), spec.row, dense_column, spec.height, column_span) - - def _build_item(self, component: KeyConfig | SpacerConfig) -> QWidget: - """Build one child widget for the grid via the component registry. - - The keyboard widget passes itself as the explicit ``host`` so the - key builder can wire latch state through this grid. - - Args: - component: Key or spacer config to materialize. - - Returns: - Constructed Qt widget reparented to this grid. - - Side effects: - Reparents the widget under this grid. - """ - - widget = self._context.components.build(component, self._context, host=self) - widget.setParent(self) - return widget - - def build_key_from_config(self, config: KeyConfig, context: Context) -> QPushButton: - """Build a key button from config inside this grid's latch wiring. - - Args: - config: Key component config. - context: Unused; accepted for symmetry with builder signatures. - - Returns: - Constructed key button. - - Side effects: - Registers the new button with this grid's latch and listener - bookkeeping. - """ - - del context - return self._build_key(config.spec, component_id=config.id) - - def _build_key(self, spec: KeySpec, *, component_id: str) -> QPushButton: - """Construct a single key button and wire it into the grid's state. - - Args: - spec: Keyboard key spec describing label, modifiers, and layout - placement. - component_id: Deterministic ID for the resulting key. - - Returns: - The constructed key ``QPushButton``. - - Side effects: - Registers the button's state machine in latch groups and listener - tables so live key state can drive its visual state. - """ - - latched = bool(spec.key_id is not None and self._context.state.get(self._latch_namespace(), spec.key_id, False)) - state_key = self._state_key_for_spec(spec) - # Late-bound holder so ``on_state_change`` (constructed before the - # button exists) can reach the state machine after construction. - machine_ref: list[KeyStateMachine | None] = [None] - - def on_press(key_spec: KeySpec = spec) -> None: - self._handle_key_press(component_id, key_spec) - - def on_release(key_spec: KeySpec = spec) -> None: - self._handle_key_release(component_id, key_spec) - - def on_state_change( - change: KeyStateChange, - key_spec: KeySpec = spec, - key_id: str | None = spec.key_id, - ) -> None: - if key_id is None: - return - machine = machine_ref[0] - if machine is None: - return - self._handle_latch_state_change( - component_id, - key_spec, - key_id, - machine, - change, + visual = component.visual + container.addWidget( + self._build_item(component, grid_path), + visual.row, + column_map[visual.column], + visual.height, + int(visual.width * 4), ) - display = spec.resolve_display(self._active_display_modifiers()) - key_button = create_key_button( - display.label, - latchable=spec.latchable, - initial_latched=latched, - on_state_change=on_state_change if spec.latchable and spec.key_id is not None else None, - component_id=component_id, - width=spec.width, - secondary_label=display.secondary_label, - key_id=spec.key_id, - io_key=spec.io_key, - profile="default", - layout=self._layout_config.id, - on_press=on_press, - on_release=on_release, - metrics=self._metrics, + def _build_item( + self, + component: KeyConfig | SpacerConfig, + grid_path: SourcePath, + ) -> QWidget: + widget = self._context.components.build( + component, + self._context, + source_path=grid_path.child("component", component.id), + host=self, ) - button = key_button.button - state_machine = key_button.state_machine - machine_ref[0] = state_machine - if state_key is not None: - self._state_machines_by_key_id.setdefault(state_key, []).append(state_machine) - snapshot = self._context.state.get(self._key_states_namespace(), state_key, {}) - if isinstance(snapshot, dict): - state_machine.set_pressed(bool(snapshot.get("pressed", False)), reason="store_snapshot") - if spec.key_id is not None: - state_machine.set_latched(bool(self._context.state.get(self._latch_namespace(), spec.key_id, False)), reason="store_snapshot") - if spec.latchable and spec.key_id is not None: - if spec.holds_when_latched: - self._hold_visual_modifiers.add(spec.key_id) - self._latch_groups.setdefault(spec.key_id, []).append(state_machine) - if spec.height > 1: - button.setMinimumHeight(self._metrics.span_height(spec.height)) - - self._buttons_by_spec.append((button, spec)) - self._buttons_by_component_id[component_id] = button - if spec.action is None: - self._dispatch_action(keyboard_register_key_spec(self._layout_config.id, component_id, spec)) - return button - - def _handle_key_press(self, component_id: str, spec: KeySpec) -> None: - """Dispatch a press event/action through the runtime.""" - - self._dispatch_event(component_pressed(component_id, spec.action)) - if spec.action is None and not spec.holds_when_latched: - self._context.dispatcher.dispatch_action(keyboard_key_down(self._layout_config.id, component_id)) - - def _handle_key_release(self, component_id: str, spec: KeySpec) -> None: - """Dispatch a release event/action through the runtime.""" - - self._dispatch_event(component_released(component_id)) - if spec.action is None and not spec.holds_when_latched: - self._context.dispatcher.dispatch_action(keyboard_key_up(self._layout_config.id, component_id)) - - def _handle_key_registered(self, layout_id: str, component_id: str, io_key_name: object) -> None: - """Apply backend registration metadata returned through runtime events.""" - - if layout_id != self._layout_config.id or not isinstance(io_key_name, str): - return - button = self._buttons_by_component_id.get(component_id) - if button is not None: - button.setProperty("ioKeyName", io_key_name) - - def _handle_backend_key_state_change(self, layout_id: str, key_id: str, pressed: bool, latched: bool) -> None: - """Apply a backend key state change to all matching button state machines.""" - - if layout_id != self._layout_config.id: - return - for state_machine in self._state_machines_by_key_id.get(key_id, []): - state_machine.set_pressed(pressed and not latched, reason="listener") - state_machine.set_latched(latched, reason="listener") - - def _handle_key_latch_change(self, layout_id: str, key_id: str, latched: bool) -> None: - """Apply a latch state change from the runtime store.""" - - if layout_id != self._layout_config.id: - return - self._syncing_latch_keys.add(key_id) - try: - for state_machine in self._latch_groups.get(key_id, []): - state_machine.set_latched(latched, reason="store_event") - finally: - self._syncing_latch_keys.discard(key_id) - self._refresh_key_legends() - - def _subscribe_to_runtime_key_state(self) -> None: - """Subscribe the grid to runtime key state events via signal bridges.""" - - self._key_state_bridge.key_state_changed.connect(self._handle_backend_key_state_change) - self._key_state_bridge.key_latch_changed.connect(self._handle_key_latch_change) - self._key_state_bridge.key_registered.connect(self._handle_key_registered) + widget.setParent(self) + return widget + def _subscribe_to_runtime_state(self) -> None: + self._state_bridge.state_changed.connect(self._apply_state_change) self._event_unsubscribes = [ - self._context.dispatcher.add_event_handler(KEYBOARD_KEY_REGISTERED, self._receive_key_registered), - self._context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, self._receive_key_state_changed), - self._context.dispatcher.add_event_handler(KEYBOARD_LATCH_CHANGED, self._receive_latch_changed), + self._context.dispatcher.add_event_handler(STATE_CHANGED, self._receive_state_change), ] - def _unsubscribe_from_runtime_key_state(self) -> None: - """Detach runtime event handling when the widget is destroyed.""" - + def _unsubscribe_from_runtime_state(self) -> None: for unsubscribe in self._event_unsubscribes: unsubscribe() self._event_unsubscribes.clear() - def _receive_key_registered(self, event: KeyboardKeyRegisteredArguments) -> MessageResult: - self._key_state_bridge.key_registered.emit(event.layout_id, event.component_id, event.io_key_name) + def _receive_state_change(self, event: StateChangedArguments) -> MessageResult: + self._state_bridge.state_changed.emit(event.source, event.state) return [] - def _receive_key_state_changed(self, event: KeyboardKeyStateChangedArguments) -> MessageResult: - self._key_state_bridge.key_state_changed.emit(event.layout_id, event.key_id, event.pressed, event.latched) - return [] - - def _receive_latch_changed(self, event: KeyboardLatchChangedArguments) -> MessageResult: - self._key_state_bridge.key_latch_changed.emit(event.layout_id, event.key_id, event.latched) - return [] - - def _handle_latch_state_change( - self, - component_id: str, - spec: KeySpec, - key_id: str, - state_machine: KeyStateMachine, - change: KeyStateChange, - ) -> None: - """Update grid-wide latch state when a button transitions latch state. - - Args: - component_id: Stable key component ID. - spec: Key spec being toggled. - key_id: Modifier identity string for the key. - state_machine: State machine of the button that initiated the change. - change: State machine transition record. - Returns: - None. - - Side effects: - Updates latched-key registry, dispatches state events/actions, - and synchronizes sibling latch buttons in the same group. - """ - - if change.reason in {"sync_group", "store_snapshot", "store_event", "listener"}: - if spec.holds_when_latched: - self._refresh_key_legends() + def _apply_state_change(self, source: object, state: object) -> None: + if not isinstance(source, SourcePath) or not isinstance(state, dict): return - - if spec.holds_when_latched and keyboard_debug_enabled(): - _logger.info( - "keyboard modifier state: component_id=%r, key_id=%r, reason=%r, previous=%r, current=%r", - component_id, - key_id, - change.reason, - change.previous.value, - change.current.value, - ) - - previously_latched = change.previous in { - KeyInteractionState.LATCHED, - KeyInteractionState.LATCHED_PRESSED, - } - currently_latched = change.current in { - KeyInteractionState.LATCHED, - KeyInteractionState.LATCHED_PRESSED, - } - - if previously_latched != currently_latched: - self._dispatch_event(component_state_changed(component_id, key_id, currently_latched)) - self._dispatch_action(state_set(component_state_namespace(component_id), "latched", currently_latched)) - if key_id not in self._syncing_latch_keys: - self._dispatch_action( - keyboard_sync_latched_key(self._layout_config.id, component_id, currently_latched) + if source == self._layout_path: + tags = state.get("state_tags", []) + if isinstance(tags, list): + self._active_state_tags = frozenset( + item for item in tags if isinstance(item, str) ) + self._refresh_key_legends() + return + button = self._buttons_by_source.get(source) + if button is not None: + render_key_button_state(button, state) - self._syncing_latch_keys.add(key_id) - try: - for sibling in self._latch_groups.get(key_id, []): - if sibling is state_machine: - continue - sibling.set_latched(currently_latched, reason="sync_group") - finally: - self._syncing_latch_keys.discard(key_id) - - if spec.holds_when_latched: - if not change.previous.is_active and change.current.is_active: - self._dispatch_action(keyboard_key_down(self._layout_config.id, component_id)) - elif change.previous.is_active and not change.current.is_active: - self._dispatch_action(keyboard_key_up(self._layout_config.id, component_id)) - - if previously_latched != currently_latched or spec.holds_when_latched: - self._refresh_key_legends() - - def _active_display_modifiers(self) -> frozenset[str]: - """Return the set of modifier IDs that should affect key display.""" - - active = {key_id for key_id in self._latch_groups if self._context.state.get(self._latch_namespace(), key_id, False)} - for key_id in self._hold_visual_modifiers: - if any(machine.is_pressed for machine in self._latch_groups.get(key_id, [])): - active.add(key_id) - return frozenset(active) + def _read_layout_tags(self) -> frozenset[str]: + state = self._context.behaviors.state_snapshot(self._layout_path) + tags = state.get("state_tags", []) + if not isinstance(tags, list): + return frozenset() + return frozenset(item for item in tags if isinstance(item, str)) def _refresh_key_legends(self) -> None: - """Recompute every button's primary/secondary label from active modifiers.""" - - active_modifiers = self._active_display_modifiers() - for button, spec in self._buttons_by_spec: - display = spec.resolve_display(active_modifiers) + for button, visual in self._buttons_by_visual: + display = visual.resolve_display(self._active_state_tags) set_key_button_label(button, display.label, display.secondary_label) - - def _dispatch_event(self, event: RuntimeEvent) -> None: - """Forward an event to the runtime dispatcher.""" - - self._context.dispatcher.dispatch_event(event) - - def _dispatch_action(self, action: RuntimeAction) -> None: - """Forward a fire-and-forget action to the runtime dispatcher.""" - - self._context.dispatcher.dispatch_action(action) - - def _state_key_for_spec(self, spec: KeySpec) -> str | None: - return spec.io_key or spec.label or spec.key_id - - def _latch_namespace(self) -> str: - return keyboard_latches_namespace(self._layout_config.id) - - def _key_states_namespace(self) -> str: - return keyboard_key_states_namespace(self._layout_config.id) diff --git a/src/axidev_osk/components/key/builder.py b/src/axidev_osk/components/key/builder.py index f72c8b0..d4185cf 100644 --- a/src/axidev_osk/components/key/builder.py +++ b/src/axidev_osk/components/key/builder.py @@ -10,6 +10,7 @@ from ...config.models import ComponentConfig, KeyboardMetrics, KeyConfig, SpacerConfig from ...runtime.context import Context from ...runtime.registries import ComponentRegistry +from ...runtime.source import SourcePath @runtime_checkable @@ -21,7 +22,12 @@ def key_metrics(self) -> KeyboardMetrics: """Pixel metrics inherited by child key/spacer components.""" ... - def build_key_from_config(self, config: KeyConfig, context: Context) -> QWidget: + def build_key_from_config( + self, + config: KeyConfig, + context: Context, + source_path: SourcePath, + ) -> QWidget: """Build a key child using the owning grid's runtime wiring.""" ... @@ -47,6 +53,7 @@ def build_key_component( config: ComponentConfig, context: Context, *, + source_path: SourcePath, host: QWidget | None = None, ) -> QWidget: """Build a key button component. @@ -54,16 +61,14 @@ def build_key_component( Args: config: Key component config. context: Runtime context. - host: Containing keyboard grid that owns latch state and event wiring. - Required because keys are tightly coupled to their host grid; the - registry forwards this from the parent component during build. + source_path: Exact runtime identity used for interactions and state. + host: Containing keyboard grid that owns placement and rendering. Returns: Constructed key button widget. Side effects: - Registers the key with the host keyboard grid for latch and listener - bookkeeping. + Registers the key with its host for runtime snapshot rendering. """ if not isinstance(config, KeyConfig): @@ -73,13 +78,14 @@ def build_key_component( "Key components must be built with a keyboard grid host; " "the parent grid is responsible for forwarding host=self." ) - return host.build_key_from_config(config, context) + return host.build_key_from_config(config, context, source_path) def build_spacer_component( config: ComponentConfig, context: Context, *, + source_path: SourcePath, host: QWidget | None = None, ) -> QWidget: """Build a spacer component. @@ -98,7 +104,7 @@ def build_spacer_component( None beyond widget construction. """ - del context + del context, source_path if not isinstance(config, SpacerConfig): raise TypeError(f"Expected SpacerConfig, got {type(config).__name__}") metrics = host.key_metrics if isinstance(host, KeyboardGridHost) else KeyboardMetrics() @@ -106,7 +112,7 @@ def build_spacer_component( spacer.setProperty("componentType", "spacer") spacer.setProperty("componentId", config.id) spacer.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - spacer.setMinimumWidth(metrics.span_width(config.spec.width)) - spacer.setMinimumHeight(metrics.span_height(config.spec.height)) + spacer.setMinimumWidth(metrics.span_width(config.visual.width)) + spacer.setMinimumHeight(metrics.span_height(config.visual.height)) spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) return spacer diff --git a/src/axidev_osk/components/prompt/builder.py b/src/axidev_osk/components/prompt/builder.py index aab6c62..a74cd11 100644 --- a/src/axidev_osk/components/prompt/builder.py +++ b/src/axidev_osk/components/prompt/builder.py @@ -7,9 +7,9 @@ from ...config.models import ButtonConfig, ComponentConfig, PromptConfig from ...runtime.context import Context -from ...runtime.events import prompt_resolved from ...runtime.identity import prompt_button_id from ...runtime.registries import ComponentRegistry +from ...runtime.source import SourcePath _ACCEPT_BUTTON_QSS = """ QPushButton#confirmAcceptButton { @@ -62,13 +62,15 @@ def build_prompt_component( config: ComponentConfig, context: Context, *, + source_path: SourcePath, host: QWidget | None = None, ) -> QWidget: """Build a prompt component. Args: config: Prompt component config. - context: Runtime context, used to dispatch ``PromptResolved`` events. + context: Runtime context used to build the prompt buttons. + source_path: Exact runtime identity of the prompt component. host: Unused; accepted for registry signature parity. Returns: @@ -76,8 +78,7 @@ def build_prompt_component( action buttons. Side effects: - Wires button click signals to dispatch ``PromptResolved``. Prompt - window lifecycle remains owned by the runtime prompt flow. + Builds visual buttons whose behavior bindings resolve the prompt. """ del host @@ -135,10 +136,14 @@ def build_prompt_component( buttons = QHBoxLayout() buttons.setSpacing(8) for button_config in config.buttons: - button = context.components.build(button_config, context, host=widget) + button = context.components.build( + button_config, + context, + source_path=source_path.child("component", button_config.id), + host=widget, + ) if not isinstance(button, QPushButton): raise TypeError("Prompt button builder must return QPushButton") - button.clicked.connect(lambda _checked=False, item=button_config: _resolve_prompt(widget, context, config.id, item)) buttons.addWidget(button) layout.addLayout(buttons) return widget @@ -166,28 +171,6 @@ def prompt_button_config(parent_id: str, *, role: str, label: str) -> ButtonConf return ButtonConfig( id=prompt_button_id(parent_id, role), label=f"{glyph} {label}", - role=role, object_name=object_name, style_sheet=style_sheet, ) - - -def _resolve_prompt(window_child: QWidget, context: Context, prompt_id: str, button: ButtonConfig) -> None: - """Dispatch the prompt resolution event. - - Args: - window_child: Any widget inside the prompt window; accepted so signal - wiring can keep a stable signature without owning window lifecycle. - context: Runtime context used to dispatch the event. - prompt_id: Stable ID of the resolving prompt component. - button: Button config describing which action was clicked. - - Returns: - None. - - Side effects: - Dispatches ``PromptResolved``. - """ - - del window_child - context.dispatcher.dispatch_event(prompt_resolved(prompt_id, button.role)) diff --git a/src/axidev_osk/config/defaults/__init__.py b/src/axidev_osk/config/defaults/__init__.py index 727faea..864424b 100644 --- a/src/axidev_osk/config/defaults/__init__.py +++ b/src/axidev_osk/config/defaults/__init__.py @@ -3,10 +3,14 @@ from __future__ import annotations from ...components.prompt import prompt_button_config +from ...runtime.actions import prompt_resolve, window_toggle_opacity +from ...runtime.behaviors import action_behavior from ...runtime.identity import stable_id, validate_unique_ids +from ...runtime.source import SourcePath, SourcePathSegment from ...windows.overlay import AlwaysOnTopWindowConfig, OverlayPlacement from ..models import ( AppConfig, + BehaviorBinding, ButtonConfig, ChromeConfig, HotCornerConfig, @@ -17,7 +21,7 @@ SurfaceConfig, WindowConfig, ) -from .us_iso import build_us_iso_layout_config +from .us_iso import build_us_iso_behavior_configs, build_us_iso_layout_config def build_default_app_config() -> AppConfig: @@ -34,10 +38,12 @@ def build_default_app_config() -> AppConfig: """ app_id = "app:axidev-osk" + active_profile_id = "profile:default" keyboard_window_id = stable_id(app_id, "window", "keyboard", stable_override="window:keyboard") keyboard_surface_id = stable_id(keyboard_window_id, "surface", "keyboard", stable_override="surface:keyboard") keyboard_grid_id = stable_id(keyboard_surface_id, "component", "keyboard-grid", stable_override="component:keyboard-grid") keyboard_status_id = stable_id(keyboard_surface_id, "component", "keyboard-status", stable_override="component:keyboard-status") + keyboard_layout = build_us_iso_layout_config() keyboard_window = WindowConfig( id=keyboard_window_id, title="axidev OSK", @@ -46,10 +52,7 @@ def build_default_app_config() -> AppConfig: components=( KeyboardGridConfig( id=keyboard_grid_id, - layout=build_us_iso_layout_config( - parent_id=keyboard_surface_id, - target_window_id=keyboard_window_id, - ), + layout=keyboard_layout, ), KeyboardStatusConfig(id=keyboard_status_id), ), @@ -67,13 +70,26 @@ def build_default_app_config() -> AppConfig: opacity=0.85, ) + quit_prompt = _build_default_quit_prompt(app_id) + linux_permission_prompt = _build_default_linux_permission_prompt(app_id) + behaviors = _build_default_behaviors( + app_id=app_id, + active_profile_id=active_profile_id, + keyboard_window=keyboard_window, + keyboard_grid_id=keyboard_grid_id, + quit_prompt=quit_prompt, + linux_permission_prompt=linux_permission_prompt, + ) validate_unique_ids((keyboard_window.id,), scope="default app windows") return AppConfig( + app_id=app_id, + active_profile_id=active_profile_id, windows=(keyboard_window,), + behaviors=behaviors, startup_window_ids=(keyboard_window.id,), keyboard_window_id=keyboard_window.id, - quit_prompt=_build_default_quit_prompt(app_id), - linux_permission_prompt=_build_default_linux_permission_prompt(app_id), + quit_prompt=quit_prompt, + linux_permission_prompt=linux_permission_prompt, hot_corner=HotCornerConfig( bindings={ "top_left": [keyboard_window.id], @@ -85,6 +101,96 @@ def build_default_app_config() -> AppConfig: ) +def _build_default_behaviors( + *, + app_id: str, + active_profile_id: str, + keyboard_window: WindowConfig, + keyboard_grid_id: str, + quit_prompt: PromptConfig, + linux_permission_prompt: PromptConfig, +) -> tuple[BehaviorBinding, ...]: + root = SourcePath( + ( + SourcePathSegment("app", app_id), + SourcePathSegment("profile", active_profile_id), + ) + ) + surface_path = root.child("window", keyboard_window.id).child( + "surface", keyboard_window.surface.id + ) + keyboard_config = keyboard_window.surface.components[0] + if not isinstance(keyboard_config, KeyboardGridConfig): + raise TypeError("Default keyboard surface must start with a keyboard grid") + layout_path = surface_path.child("component", keyboard_grid_id).child( + "layout", keyboard_config.layout.id + ) + grid = keyboard_config.layout.grids[0] + grid_path = layout_path.child("grid", grid.id) + keyboard_behaviors = build_us_iso_behavior_configs() + bindings = [ + BehaviorBinding( + target=grid_path.child("component", component_id), + default=behavior, + ) + for component_id, behavior in keyboard_behaviors.items() + ] + + all_key_ids = {component.id for component in grid.components} + ghost_ids = all_key_ids - keyboard_behaviors.keys() + if len(ghost_ids) != 1: + raise ValueError("US ISO layout must contain exactly one non-keyboard control") + ghost_id = next(iter(ghost_ids)) + bindings.append( + BehaviorBinding( + target=grid_path.child("component", ghost_id), + default=action_behavior( + pressed_actions=( + window_toggle_opacity(keyboard_window.id, ghost_id, 0.01), + ) + ), + ) + ) + bindings.extend( + _prompt_behavior_bindings( + root, + quit_prompt, + ("accepted", "rejected"), + ) + ) + bindings.extend( + _prompt_behavior_bindings( + root, + linux_permission_prompt, + ("open_terminal", "already_configured", "rejected"), + ) + ) + return tuple(bindings) + + +def _prompt_behavior_bindings( + root: SourcePath, + prompt: PromptConfig, + results: tuple[str, ...], +) -> list[BehaviorBinding]: + if len(prompt.buttons) != len(results): + raise ValueError(f"Prompt {prompt.id!r} button/result counts differ") + prompt_path = ( + root.child("window", prompt.window_id) + .child("surface", prompt.surface_id) + .child("component", prompt.id) + ) + return [ + BehaviorBinding( + target=prompt_path.child("component", button.id), + default=action_behavior( + released_actions=(prompt_resolve(prompt.id, result),) + ), + ) + for button, result in zip(prompt.buttons, results, strict=True) + ] + + def _build_default_quit_prompt(app_id: str) -> PromptConfig: """Build the bundled quit confirmation prompt config. @@ -161,7 +267,6 @@ def _build_default_linux_permission_prompt(app_id: str) -> PromptConfig: "open_terminal", stable_override="prompt:linux-permission:button:open_terminal", ), - role="open_terminal", label="Open In Terminal", ), ButtonConfig( @@ -171,7 +276,6 @@ def _build_default_linux_permission_prompt(app_id: str) -> PromptConfig: "already_configured", stable_override="prompt:linux-permission:button:already_configured", ), - role="already_configured", label="Already Configured", ), ButtonConfig( @@ -181,7 +285,6 @@ def _build_default_linux_permission_prompt(app_id: str) -> PromptConfig: "rejected", stable_override="prompt:linux-permission:button:rejected", ), - role="rejected", label="Cancel", ), ), diff --git a/src/axidev_osk/config/defaults/us_iso.py b/src/axidev_osk/config/defaults/us_iso.py index b3a5cd2..23d59ac 100644 --- a/src/axidev_osk/config/defaults/us_iso.py +++ b/src/axidev_osk/config/defaults/us_iso.py @@ -1,29 +1,11 @@ -"""Bundled US ISO keyboard layout, expressed as plain config DTOs. - -This module is the canonical example of a default layout produced by -the Python side of the project. It exists to: - -- ship a working layout out of the box; -- exercise the same ``LayoutConfig`` / ``GridConfig`` / ``KeyConfig`` - data path that user configs will use; -- act as a parity reference for the future Lua config loader. - -It is intentionally pure data: no Qt, no widgets, no registries. When -the Lua config layer lands (issue #8), this file should be replicable -as a Lua bundled config and treated as a fallback default rather than -embedded Python knowledge. -""" +"""Bundled US ISO visual layout and its separate behavior catalog.""" from __future__ import annotations -from dataclasses import replace - -from ...messages import RuntimeAction -from ...models import KeyDisplay, KeySpec -from ...runtime.actions import window_toggle_opacity -from ...runtime.identity import key_component_id, stable_id, validate_unique_ids -from ..models import GridConfig, KeyConfig, LayoutConfig, SpacerConfig - +from ...models import KeyDisplay, KeyVisual +from ...runtime.behavior_models import KeyboardBehaviorMode, KeyboardOutput +from ...runtime.behaviors import keyboard_behavior +from ..models import BehaviorConfig, GridConfig, KeyConfig, LayoutConfig UNIT = 4 MAIN_BLOCK_WIDTH = 60 @@ -31,6 +13,106 @@ LAYOUT_ID = "layout:us-iso" GRID_ID = "grid:us-iso:keyboard" +# These IDs were generated from the shipped layout. Each visual uses its ID +# directly below, while output behavior is joined through this exact-ID map. +_OUTPUT_BY_COMPONENT_ID = { + "key-5c54ee726af74aa7": "Escape", + "key-bdcf09da3a202e62": "F1", + "key-fdc70245aae3410c": "F2", + "key-ca16eda6b1891af4": "F3", + "key-82acdc858cccdc1b": "F4", + "key-e7eb5db66f33d248": "F5", + "key-042972b66ec9da88": "F6", + "key-799472e937dd282b": "F7", + "key-be4f90c7f2b56a31": "F8", + "key-e0502bedca3e8d2c": "F9", + "key-7602dede35eaf26e": "F10", + "key-4d2488576bc83769": "F11", + "key-c98f8b8aa6abd0ac": "F12", + "key-515671db3895067f": "PrintScreen", + "key-d31535e62738fcdb": "ScrollLock", + "key-56dd6f4fafb550fe": "Pause", + "key-5749d70b98266d03": "`", + "key-1da0a2bf04836f6f": "1", + "key-4ad6a4ef3130e307": "2", + "key-7f8a03f59966fd32": "3", + "key-4259e339e7e34c6a": "4", + "key-04bf56cecc0bfcfd": "5", + "key-da83e7d272b40a2d": "6", + "key-d6b44e927b612e2a": "7", + "key-7e279f2d411d9c78": "8", + "key-1341fbdefce5f24e": "9", + "key-b372aa400246633e": "0", + "key-6257b82b013c89fe": "-", + "key-fcad4cda09d33753": "=", + "key-fa2d3e3fb57cb334": "Backspace", + "key-d1e2779baa751f68": "Insert", + "key-43a9f8fba5d37497": "Home", + "key-efb8c355bd7250c6": "PageUp", + "key-2defdac8c250c324": "Tab", + "key-18404b2cce96ff35": "Q", + "key-a08f85301c8d97bd": "W", + "key-44d947c0b66bb3fb": "E", + "key-30ea38dd5965f55e": "R", + "key-1594ef70ee70106f": "T", + "key-dc8121e76f1582df": "Y", + "key-1124a591d73619be": "U", + "key-2b5bf05506b0f0bf": "I", + "key-998869f839580451": "O", + "key-136eb67434983829": "P", + "key-d4d064bb7ee9b43e": "[", + "key-b72cd12dae4c6310": "]", + "key-ba8bdcd343001c8e": "Delete", + "key-d855d8ccc4e10e16": "End", + "key-8ba4cd7f0a6ddafc": "PageDown", + "key-a3b9c717473feb03": "CapsLock", + "key-57c70e7dcd3f77aa": "A", + "key-6c34719afd75b5ba": "S", + "key-d6e04757428495b2": "D", + "key-fca72c22c4b5085c": "F", + "key-b4cd3dabf6da1101": "G", + "key-14e15477653007fa": "H", + "key-bbbc099b70e28078": "J", + "key-6b5a8c52a3dfe4c2": "K", + "key-7283b50370a56b26": "L", + "key-bd279ec10b0bb5ad": ";", + "key-98a4191de5a08f40": "'", + "key-7348d7fa1b425df8": "Enter", + "key-79dd3bb91c1989ce": "ShiftLeft", + "key-d907156057e1aa4f": "\\", + "key-0a707c2d086c5b9e": "Z", + "key-e2bc96e8c835c177": "X", + "key-611579a0df6eb4fa": "C", + "key-40e8b534df124e61": "V", + "key-6c342e16ce323176": "B", + "key-d9de0985f4d133e7": "N", + "key-6c13726499dbb1b1": "M", + "key-e47031a1301ddb73": ",", + "key-bf34cafff7e1f9fd": ".", + "key-97b1ba40ab436eb4": "/", + "key-993aa770de985a17": "ShiftRight", + "key-d6395a9121316843": "Up", + "key-1c7aa4c8f2ad736c": "CtrlLeft", + "key-30053be7a830def5": "SuperLeft", + "key-855853a6e6554165": "AltLeft", + "key-26f49093d0a0e64e": "Space", + "key-75f932e22d8cb9e4": "AltRight", + "key-4de28b604a2a60f7": "SuperRight", + "key-0390554e1df20555": "Menu", + "key-398ba92c947ae2ca": "CtrlRight", + "key-8ec8a1a38a4c3052": "Left", + "key-e3095d897213c4de": "Down", + "key-ab9809958f5303c4": "Right", +} + +_HELD_TOGGLE_KEYS = frozenset( + {"ShiftLeft", "ShiftRight", "CtrlLeft", "CtrlRight", "SuperLeft", "SuperRight", "AltLeft", "AltRight"} +) + + +def u(value: int) -> int: + return value * UNIT + def key( label: str, @@ -39,87 +121,19 @@ def key( column: int, width: float = 1.0, height: int = 1, - is_spacer: bool = False, secondary_label: str | None = None, - key_id: str | None = None, - latchable: bool = False, - io_key: str | None = None, - holds_when_latched: bool = False, - honors_latched_modifiers: bool = True, - repeats: bool = True, display_variants: tuple[KeyDisplay, ...] = (), - action: RuntimeAction | None = None, -) -> KeySpec: - """Build a key spec with default keyboard-layout behavior.""" +) -> KeyVisual: + """Build visual-only key data.""" - return KeySpec( + return KeyVisual( label=label, row=row, column=column, width=width, height=height, - is_spacer=is_spacer, secondary_label=secondary_label, - key_id=key_id, - latchable=latchable, - io_key=io_key, - holds_when_latched=holds_when_latched, - honors_latched_modifiers=honors_latched_modifiers, - repeats=repeats, display_variants=display_variants, - action=action, - ) - - -def held_modifier( - label: str, - *, - row: int, - column: int, - width: float, - key_id: str, - io_key: str, -) -> KeySpec: - """Build a latchable modifier that keeps its backend key held.""" - - return key( - label, - row=row, - column=column, - width=width, - key_id=key_id, - latchable=True, - io_key=io_key, - holds_when_latched=True, - honors_latched_modifiers=False, - repeats=True, - ) - - -def spacer(*, row: int, column: int, width: float = 1.0, height: int = 1) -> KeySpec: - """Build a spacer spec that reserves grid space without a key widget.""" - - return key("", row=row, column=column, width=width, height=height, is_spacer=True) - - -def u(value: int) -> int: - """Convert logical layout units to sparse grid columns.""" - - return value * UNIT - - -def _component_id(grid_id: str, spec: KeySpec) -> str: - kind = "spacer" if spec.is_spacer else "key" - return key_component_id( - grid_id, - kind, - row=spec.row, - column=spec.column, - width=spec.width, - height=spec.height, - key_id=spec.key_id, - io_key=spec.io_key, - label=spec.label, ) @@ -131,32 +145,15 @@ def shifted_key( column: int, width: float = 1.0, height: int = 1, - key_id: str | None = None, - latchable: bool = False, - io_key: str | None = None, - holds_when_latched: bool = False, - honors_latched_modifiers: bool = True, - repeats: bool = True, -) -> KeySpec: - """Build a key with an alternate display while shift is active.""" - +) -> KeyVisual: return key( label, row=row, column=column, width=width, height=height, - key_id=key_id, - latchable=latchable, - io_key=io_key, - holds_when_latched=holds_when_latched, - honors_latched_modifiers=honors_latched_modifiers, - repeats=repeats, display_variants=( - KeyDisplay( - label=shifted_label, - requires_modifiers=frozenset({"shift"}), - ), + KeyDisplay(label=shifted_label, requires_state_tags=frozenset({"shift"})), ), ) @@ -168,10 +165,7 @@ def letter_key( column: int, width: float = 1.0, height: int = 1, - repeats: bool = True, -) -> KeySpec: - """Build a letter key with shift and caps-aware display variants.""" - +) -> KeyVisual: lower_label = label.lower() upper_label = label.upper() return key( @@ -180,197 +174,187 @@ def letter_key( column=column, width=width, height=height, - io_key=upper_label, - repeats=repeats, display_variants=( KeyDisplay( label=upper_label, - requires_modifiers=frozenset({"shift"}), - excludes_modifiers=frozenset({"caps"}), + requires_state_tags=frozenset({"shift"}), + excludes_state_tags=frozenset({"caps"}), ), KeyDisplay( label=upper_label, - requires_modifiers=frozenset({"caps"}), - excludes_modifiers=frozenset({"shift"}), + requires_state_tags=frozenset({"caps"}), + excludes_state_tags=frozenset({"shift"}), ), ), ) -def build_us_iso_layout(*, target_window_id: str = "window:keyboard") -> list[KeySpec]: - """Return the bundled US ISO layout as ordered key specs.""" - - specs = [ - key("Esc", row=0, column=u(0), io_key="Escape"), - key("F1", row=0, column=u(2)), - key("F2", row=0, column=u(3)), - key("F3", row=0, column=u(4)), - key("F4", row=0, column=u(5)), - key("F5", row=0, column=u(7)), - key("F6", row=0, column=u(8)), - key("F7", row=0, column=u(9)), - key("F8", row=0, column=u(10)), - key("F9", row=0, column=u(12)), - key("F10", row=0, column=u(13)), - key("F11", row=0, column=u(14)), - key("F12", row=0, column=u(15)), - key("PrtSc", row=0, column=NAV_START, io_key="PrintScreen"), - key("ScrLk", row=0, column=NAV_START + u(1), io_key="ScrollLock"), - key("Pause", row=0, column=NAV_START + u(2), io_key="Pause"), - shifted_key("`", "~", row=1, column=u(0)), - shifted_key("1", "!", row=1, column=u(1)), - shifted_key("2", "@", row=1, column=u(2)), - shifted_key("3", "#", row=1, column=u(3)), - shifted_key("4", "$", row=1, column=u(4)), - shifted_key("5", "%", row=1, column=u(5)), - shifted_key("6", "^", row=1, column=u(6)), - # Qt button text treats '&' as a mnemonic marker, so escape it here. - shifted_key("7", "&&", row=1, column=u(7)), - shifted_key("8", "*", row=1, column=u(8)), - shifted_key("9", "(", row=1, column=u(9)), - shifted_key("0", ")", row=1, column=u(10)), - shifted_key("-", "_", row=1, column=u(11)), - shifted_key("=", "+", row=1, column=u(12)), - key("Backspace", row=1, column=u(13), width=2.0, io_key="Backspace"), - key("Ins", row=1, column=NAV_START, io_key="Insert"), - key("Home", row=1, column=NAV_START + u(1), io_key="Home"), - key("PgUp", row=1, column=NAV_START + u(2), io_key="PageUp"), - key("Tab", row=2, column=u(0), width=1.5, io_key="Tab"), - letter_key("Q", row=2, column=6), - letter_key("W", row=2, column=10), - letter_key("E", row=2, column=14), - letter_key("R", row=2, column=18), - letter_key("T", row=2, column=22), - letter_key("Y", row=2, column=26), - letter_key("U", row=2, column=30), - letter_key("I", row=2, column=34), - letter_key("O", row=2, column=38), - letter_key("P", row=2, column=42), - shifted_key("[", "{", row=2, column=46), - shifted_key("]", "}", row=2, column=50), - key( - "Ghost", - row=2, - column=54, - repeats=False, +def _build_us_iso_components() -> tuple[KeyConfig, ...]: + """Pair every shipped stable ID directly with its visual definition.""" + + return ( + KeyConfig("key-5c54ee726af74aa7", key("Esc", row=0, column=u(0))), + KeyConfig("key-bdcf09da3a202e62", key("F1", row=0, column=u(2))), + KeyConfig("key-fdc70245aae3410c", key("F2", row=0, column=u(3))), + KeyConfig("key-ca16eda6b1891af4", key("F3", row=0, column=u(4))), + KeyConfig("key-82acdc858cccdc1b", key("F4", row=0, column=u(5))), + KeyConfig("key-e7eb5db66f33d248", key("F5", row=0, column=u(7))), + KeyConfig("key-042972b66ec9da88", key("F6", row=0, column=u(8))), + KeyConfig("key-799472e937dd282b", key("F7", row=0, column=u(9))), + KeyConfig("key-be4f90c7f2b56a31", key("F8", row=0, column=u(10))), + KeyConfig("key-e0502bedca3e8d2c", key("F9", row=0, column=u(12))), + KeyConfig("key-7602dede35eaf26e", key("F10", row=0, column=u(13))), + KeyConfig("key-4d2488576bc83769", key("F11", row=0, column=u(14))), + KeyConfig("key-c98f8b8aa6abd0ac", key("F12", row=0, column=u(15))), + KeyConfig("key-515671db3895067f", key("PrtSc", row=0, column=NAV_START)), + KeyConfig( + "key-d31535e62738fcdb", + key("ScrLk", row=0, column=NAV_START + u(1)), + ), + KeyConfig( + "key-56dd6f4fafb550fe", + key("Pause", row=0, column=NAV_START + u(2)), ), - key("Del", row=2, column=NAV_START, io_key="Delete"), - key("End", row=2, column=NAV_START + u(1), io_key="End"), - key("PgDn", row=2, column=NAV_START + u(2), io_key="PageDown"), - key( - "Caps", - row=3, - column=u(0), - width=1.75, - key_id="caps", - latchable=True, - io_key="CapsLock", + KeyConfig("key-5749d70b98266d03", shifted_key("`", "~", row=1, column=u(0))), + KeyConfig("key-1da0a2bf04836f6f", shifted_key("1", "!", row=1, column=u(1))), + KeyConfig("key-4ad6a4ef3130e307", shifted_key("2", "@", row=1, column=u(2))), + KeyConfig("key-7f8a03f59966fd32", shifted_key("3", "#", row=1, column=u(3))), + KeyConfig("key-4259e339e7e34c6a", shifted_key("4", "$", row=1, column=u(4))), + KeyConfig("key-04bf56cecc0bfcfd", shifted_key("5", "%", row=1, column=u(5))), + KeyConfig("key-da83e7d272b40a2d", shifted_key("6", "^", row=1, column=u(6))), + KeyConfig("key-d6b44e927b612e2a", shifted_key("7", "&&", row=1, column=u(7))), + KeyConfig("key-7e279f2d411d9c78", shifted_key("8", "*", row=1, column=u(8))), + KeyConfig("key-1341fbdefce5f24e", shifted_key("9", "(", row=1, column=u(9))), + KeyConfig("key-b372aa400246633e", shifted_key("0", ")", row=1, column=u(10))), + KeyConfig("key-6257b82b013c89fe", shifted_key("-", "_", row=1, column=u(11))), + KeyConfig("key-fcad4cda09d33753", shifted_key("=", "+", row=1, column=u(12))), + KeyConfig( + "key-fa2d3e3fb57cb334", + key("Backspace", row=1, column=u(13), width=2.0), ), - letter_key("A", row=3, column=7), - letter_key("S", row=3, column=11), - letter_key("D", row=3, column=15), - letter_key("F", row=3, column=19), - letter_key("G", row=3, column=23), - letter_key("H", row=3, column=27), - letter_key("J", row=3, column=31), - letter_key("K", row=3, column=35), - letter_key("L", row=3, column=39), - shifted_key(";", ":", row=3, column=43), - shifted_key("'", '"', row=3, column=47), - key("Enter", row=3, column=51, width=2.25, io_key="Enter"), - held_modifier( - "Shift", row=4, column=u(0), width=1.25, key_id="shift", io_key="ShiftLeft" + KeyConfig("key-d1e2779baa751f68", key("Ins", row=1, column=NAV_START)), + KeyConfig( + "key-43a9f8fba5d37497", + key("Home", row=1, column=NAV_START + u(1)), ), - shifted_key("\\", "|", row=4, column=5), - letter_key("Z", row=4, column=9), - letter_key("X", row=4, column=13), - letter_key("C", row=4, column=17), - letter_key("V", row=4, column=21), - letter_key("B", row=4, column=25), - letter_key("N", row=4, column=29), - letter_key("M", row=4, column=33), - shifted_key(",", "<", row=4, column=37), - shifted_key(".", ">", row=4, column=41), - shifted_key("/", "?", row=4, column=45), - held_modifier( - "Shift", - row=4, - column=49, - width=2.75, - key_id="shift", - io_key="ShiftRight", + KeyConfig( + "key-efb8c355bd7250c6", + key("PgUp", row=1, column=NAV_START + u(2)), ), - key("↑", row=4, column=NAV_START + u(1), io_key="Up"), - held_modifier( - "Ctrl", row=5, column=u(0), width=1.25, key_id="ctrl", io_key="CtrlLeft" + KeyConfig("key-2defdac8c250c324", key("Tab", row=2, column=u(0), width=1.5)), + KeyConfig("key-18404b2cce96ff35", letter_key("Q", row=2, column=6)), + KeyConfig("key-a08f85301c8d97bd", letter_key("W", row=2, column=10)), + KeyConfig("key-44d947c0b66bb3fb", letter_key("E", row=2, column=14)), + KeyConfig("key-30ea38dd5965f55e", letter_key("R", row=2, column=18)), + KeyConfig("key-1594ef70ee70106f", letter_key("T", row=2, column=22)), + KeyConfig("key-dc8121e76f1582df", letter_key("Y", row=2, column=26)), + KeyConfig("key-1124a591d73619be", letter_key("U", row=2, column=30)), + KeyConfig("key-2b5bf05506b0f0bf", letter_key("I", row=2, column=34)), + KeyConfig("key-998869f839580451", letter_key("O", row=2, column=38)), + KeyConfig("key-136eb67434983829", letter_key("P", row=2, column=42)), + KeyConfig("key-d4d064bb7ee9b43e", shifted_key("[", "{", row=2, column=46)), + KeyConfig("key-b72cd12dae4c6310", shifted_key("]", "}", row=2, column=50)), + KeyConfig("key-08f8b62608b6de45", key("Ghost", row=2, column=54)), + KeyConfig("key-ba8bdcd343001c8e", key("Del", row=2, column=NAV_START)), + KeyConfig( + "key-d855d8ccc4e10e16", + key("End", row=2, column=NAV_START + u(1)), ), - held_modifier( - "Super", row=5, column=5, width=1.25, key_id="super", io_key="SuperLeft" + KeyConfig( + "key-8ba4cd7f0a6ddafc", + key("PgDn", row=2, column=NAV_START + u(2)), ), - held_modifier( - "Alt", row=5, column=10, width=1.25, key_id="alt", io_key="AltLeft" + KeyConfig( + "key-a3b9c717473feb03", + key("Caps", row=3, column=u(0), width=1.75), ), - key("Space", row=5, column=15, width=6.25, io_key="Space"), - held_modifier( - "AltGr", row=5, column=40, width=1.25, key_id="altgr", io_key="AltRight" + KeyConfig("key-57c70e7dcd3f77aa", letter_key("A", row=3, column=7)), + KeyConfig("key-6c34719afd75b5ba", letter_key("S", row=3, column=11)), + KeyConfig("key-d6e04757428495b2", letter_key("D", row=3, column=15)), + KeyConfig("key-fca72c22c4b5085c", letter_key("F", row=3, column=19)), + KeyConfig("key-b4cd3dabf6da1101", letter_key("G", row=3, column=23)), + KeyConfig("key-14e15477653007fa", letter_key("H", row=3, column=27)), + KeyConfig("key-bbbc099b70e28078", letter_key("J", row=3, column=31)), + KeyConfig("key-6b5a8c52a3dfe4c2", letter_key("K", row=3, column=35)), + KeyConfig("key-7283b50370a56b26", letter_key("L", row=3, column=39)), + KeyConfig("key-bd279ec10b0bb5ad", shifted_key(";", ":", row=3, column=43)), + KeyConfig("key-98a4191de5a08f40", shifted_key("'", '"', row=3, column=47)), + KeyConfig("key-7348d7fa1b425df8", key("Enter", row=3, column=51, width=2.25)), + KeyConfig("key-79dd3bb91c1989ce", key("Shift", row=4, column=u(0), width=1.25)), + KeyConfig("key-d907156057e1aa4f", shifted_key("\\", "|", row=4, column=5)), + KeyConfig("key-0a707c2d086c5b9e", letter_key("Z", row=4, column=9)), + KeyConfig("key-e2bc96e8c835c177", letter_key("X", row=4, column=13)), + KeyConfig("key-611579a0df6eb4fa", letter_key("C", row=4, column=17)), + KeyConfig("key-40e8b534df124e61", letter_key("V", row=4, column=21)), + KeyConfig("key-6c342e16ce323176", letter_key("B", row=4, column=25)), + KeyConfig("key-d9de0985f4d133e7", letter_key("N", row=4, column=29)), + KeyConfig("key-6c13726499dbb1b1", letter_key("M", row=4, column=33)), + KeyConfig("key-e47031a1301ddb73", shifted_key(",", "<", row=4, column=37)), + KeyConfig("key-bf34cafff7e1f9fd", shifted_key(".", ">", row=4, column=41)), + KeyConfig("key-97b1ba40ab436eb4", shifted_key("/", "?", row=4, column=45)), + KeyConfig("key-993aa770de985a17", key("Shift", row=4, column=49, width=2.75)), + KeyConfig( + "key-d6395a9121316843", + key("↑", row=4, column=NAV_START + u(1)), ), - held_modifier( - "Super", - row=5, - column=45, - width=1.25, - key_id="super", - io_key="SuperRight", + KeyConfig("key-1c7aa4c8f2ad736c", key("Ctrl", row=5, column=u(0), width=1.25)), + KeyConfig("key-30053be7a830def5", key("Super", row=5, column=5, width=1.25)), + KeyConfig("key-855853a6e6554165", key("Alt", row=5, column=10, width=1.25)), + KeyConfig("key-26f49093d0a0e64e", key("Space", row=5, column=15, width=6.25)), + KeyConfig("key-75f932e22d8cb9e4", key("AltGr", row=5, column=40, width=1.25)), + KeyConfig("key-4de28b604a2a60f7", key("Super", row=5, column=45, width=1.25)), + KeyConfig("key-0390554e1df20555", key("Menu", row=5, column=50, width=1.25)), + KeyConfig("key-398ba92c947ae2ca", key("Ctrl", row=5, column=55, width=1.25)), + KeyConfig("key-8ec8a1a38a4c3052", key("←", row=5, column=NAV_START)), + KeyConfig( + "key-e3095d897213c4de", + key("↓", row=5, column=NAV_START + u(1)), ), - key("Menu", row=5, column=50, width=1.25, io_key="Menu"), - held_modifier( - "Ctrl", - row=5, - column=55, - width=1.25, - key_id="ctrl", - io_key="CtrlRight", + KeyConfig( + "key-ab9809958f5303c4", + key("→", row=5, column=NAV_START + u(2)), ), - key("←", row=5, column=NAV_START, io_key="Left"), - key("↓", row=5, column=NAV_START + u(1), io_key="Down"), - key("→", row=5, column=NAV_START + u(2), io_key="Right"), - ] - ghost_index = next(index for index, spec in enumerate(specs) if spec.label == "Ghost") - ghost = specs[ghost_index] - specs[ghost_index] = replace( - ghost, - action=window_toggle_opacity(target_window_id, _component_id(GRID_ID, ghost), 0.01), ) - return specs -def build_us_iso_layout_config( - *, - parent_id: str = "default", - target_window_id: str = "window:keyboard", -) -> LayoutConfig: - """Build the bundled US ISO keyboard as pure layout/grid/component data. +def build_us_iso_layout() -> list[KeyVisual]: + """Return the bundled US ISO visual layout in display order.""" - Args: - parent_id: Parent config ID used when deriving deterministic IDs. + return [component.visual for component in _build_us_iso_components()] - Returns: - Layout config containing one grid with key and spacer component DTOs. - Side effects: - Raises ``ValueError`` if deterministic IDs collide. - """ +def build_us_iso_layout_config() -> LayoutConfig: + components = _build_us_iso_components() + return LayoutConfig( + id=LAYOUT_ID, + name="us-iso", + grids=(GridConfig(id=GRID_ID, components=components, nav_start_column=NAV_START),), + ) - layout_id = stable_id(parent_id, "layout", "us_iso", stable_override=LAYOUT_ID) - grid_id = stable_id(layout_id, "grid", "keyboard", stable_override=GRID_ID) - components: list[KeyConfig | SpacerConfig] = [] - for spec in build_us_iso_layout(target_window_id=target_window_id): - component_id = _component_id(grid_id, spec) - if spec.is_spacer: - components.append(SpacerConfig(id=component_id, spec=spec)) - continue - components.append(KeyConfig(id=component_id, spec=spec)) - validate_unique_ids((component.id for component in components), scope="US ISO keyboard grid") - grid = GridConfig(id=grid_id, components=tuple(components), nav_start_column=NAV_START) - return LayoutConfig(id=layout_id, name="us-iso", grids=(grid,)) +def build_us_iso_behavior_configs() -> dict[str, BehaviorConfig]: + """Return keyboard behavior by explicit component ID; Ghost is excluded.""" + + component_ids = {component.id for component in _build_us_iso_components()} + unknown_ids = _OUTPUT_BY_COMPONENT_ID.keys() - component_ids + if unknown_ids: + raise ValueError(f"US ISO outputs target unknown component IDs: {sorted(unknown_ids)}") + behaviors: dict[str, BehaviorConfig] = {} + for component_id, output_key in _OUTPUT_BY_COMPONENT_ID.items(): + mode = KeyboardBehaviorMode.MOMENTARY + uses_active_state_tags = True + if output_key == "CapsLock": + mode = KeyboardBehaviorMode.LOGICAL_TOGGLE + uses_active_state_tags = False + elif output_key in _HELD_TOGGLE_KEYS: + mode = KeyboardBehaviorMode.HELD_TOGGLE + uses_active_state_tags = False + behaviors[component_id] = keyboard_behavior( + mode, + KeyboardOutput( + output_key=output_key, + repeats=True, + uses_active_state_tags=uses_active_state_tags, + ), + ) + return behaviors diff --git a/src/axidev_osk/config/models.py b/src/axidev_osk/config/models.py index 5d387bd..7c1445e 100644 --- a/src/axidev_osk/config/models.py +++ b/src/axidev_osk/config/models.py @@ -6,8 +6,10 @@ from enum import Enum from typing import Literal -from ..models import KeySpec +from ..messages import DataMap, RuntimeAction +from ..models import KeyVisual, SpacerVisual from ..runtime.identity import validate_unique_ids +from ..runtime.source import SourcePath class OverlayPlacement(str, Enum): @@ -60,15 +62,15 @@ class ChromeConfig: @dataclass(frozen=True, slots=True) class KeyConfig: - """Declarative key component placement and behavior. + """Declarative visual key component. Attributes: id: Deterministic component ID used by events, state, and Qt properties. - spec: Keyboard key semantics and grid placement inherited from the prototype model. + visual: Visible text and grid placement. """ id: str - spec: KeySpec + visual: KeyVisual kind: Literal["key"] = "key" @@ -78,11 +80,11 @@ class SpacerConfig: Attributes: id: Deterministic component ID used by validation and Qt properties. - spec: Spacer geometry stored in the same unit system as keys. + visual: Spacer geometry stored in the same unit system as keys. """ id: str - spec: KeySpec + visual: SpacerVisual kind: Literal["spacer"] = "spacer" @@ -93,14 +95,12 @@ class ButtonConfig: Attributes: id: Deterministic component ID used by events and Qt properties. label: Visible text shown on the button. - role: Semantic action emitted by the button. object_name: Optional Qt object name for existing QSS/tests. style_sheet: Optional local stylesheet for prompt buttons. """ id: str label: str - role: str object_name: str | None = None style_sheet: str | None = None kind: Literal["button"] = "button" @@ -246,6 +246,43 @@ class KeyboardStatusConfig: ComponentConfig = KeyConfig | SpacerConfig | ButtonConfig | PromptConfig | KeyboardGridConfig | KeyboardStatusConfig +@dataclass(frozen=True, slots=True) +class BehaviorConfig: + """Registered behavior kind plus queue-safe native arguments.""" + + kind: str + arguments: DataMap + + def __post_init__(self) -> None: + validated = RuntimeAction(self.kind, self.arguments) + object.__setattr__(self, "arguments", validated.arguments) + + +@dataclass(frozen=True, slots=True) +class BehaviorHook: + """One event-filtered hook around a component's default behavior.""" + + events: frozenset[str] + blocking: bool + config: BehaviorConfig + + def __post_init__(self) -> None: + if not self.events: + raise ValueError("Behavior hook must match at least one event") + for event in self.events: + RuntimeAction(event, {}) + + +@dataclass(frozen=True, slots=True) +class BehaviorBinding: + """Attach one default behavior and its hooks to an exact source path.""" + + target: SourcePath + default: BehaviorConfig + before_hooks: tuple[BehaviorHook, ...] = () + after_hooks: tuple[BehaviorHook, ...] = () + + @dataclass(frozen=True, slots=True) class HotCornerConfig: """Hot-corner trigger behavior and runtime window bindings. @@ -325,7 +362,10 @@ class AppConfig: """Root application configuration owned by the runtime. Attributes: + app_id: Stable application identity used by paths and state. + active_profile_id: Active profile identity used by paths and state. windows: Windows available to the runtime, keyed by deterministic IDs. + behaviors: Exact behavior bindings for every interactive control. startup_window_ids: IDs of windows shown during startup. keyboard_window_id: ID of the default keyboard surface. quit_prompt: Declarative quit confirmation prompt. @@ -333,7 +373,10 @@ class AppConfig: hot_corner: Hot-corner trigger behavior and window bindings. """ + app_id: str + active_profile_id: str windows: tuple[WindowConfig, ...] + behaviors: tuple[BehaviorBinding, ...] startup_window_ids: tuple[str, ...] keyboard_window_id: str quit_prompt: PromptConfig @@ -343,6 +386,11 @@ class AppConfig: def __post_init__(self) -> None: """Validate IDs at the root app composition boundary.""" + if not self.app_id: + raise ValueError("App ID cannot be empty") + if not self.active_profile_id: + raise ValueError("Active profile ID cannot be empty") + validate_unique_ids((window.id for window in self.windows), scope="app windows") validate_unique_ids( (window.surface.id for window in self.windows), @@ -364,3 +412,7 @@ def __post_init__(self) -> None: ), scope="app surface and prompt surface IDs", ) + validate_unique_ids( + (repr(binding.target) for binding in self.behaviors), + scope="app behavior targets", + ) diff --git a/src/axidev_osk/models.py b/src/axidev_osk/models.py index 954bf3e..0ea3452 100644 --- a/src/axidev_osk/models.py +++ b/src/axidev_osk/models.py @@ -1,136 +1,57 @@ -"""Keyboard layout DTOs shared by config builders and runtime components.""" +"""Visual layout DTOs shared by config builders and components.""" from __future__ import annotations from dataclasses import dataclass -from .messages import DataMap, DataValue, RuntimeAction, runtime_action_to_data - -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True) class KeyDisplay: - """Resolved display text for a key under a modifier state. - - Attributes: - label: Primary label shown on the key. - secondary_label: Optional secondary label shown with the primary label. - requires_modifiers: Modifier IDs that must be active for this display to apply. - excludes_modifiers: Modifier IDs that must be inactive for this display to apply. - """ + """Resolved key text selected by active runtime state tags.""" label: str secondary_label: str | None = None - requires_modifiers: frozenset[str] = frozenset() - excludes_modifiers: frozenset[str] = frozenset() - + requires_state_tags: frozenset[str] = frozenset() + excludes_state_tags: frozenset[str] = frozenset() -@dataclass(frozen=True) -class KeySpec: - """Declarative keyboard key or spacer geometry and behavior. - Attributes: - label: Primary default label. - row: Sparse layout row. - column: Sparse layout column. - width: Width in keyboard units. - height: Height in keyboard rows. - is_spacer: Whether this spec reserves space without creating a key widget. - secondary_label: Optional default secondary label. - key_id: Logical key identity used for shared state. - latchable: Whether this key can stay logically active after release. - io_key: Backend key name emitted for normal presses. - holds_when_latched: Whether the backend key should stay held while latched. - honors_latched_modifiers: Whether display resolution should account for active latches. - repeats: Whether holding this key should produce repeat events. - display_variants: Modifier-aware display alternatives. - action: Optional window action used instead of keyboard output. - """ +@dataclass(frozen=True, slots=True) +class KeyVisual: + """Visual text and grid placement for one key component.""" label: str row: int column: int width: float = 1.0 height: int = 1 - is_spacer: bool = False secondary_label: str | None = None - key_id: str | None = None - latchable: bool = False - io_key: str | None = None - holds_when_latched: bool = False - honors_latched_modifiers: bool = True - repeats: bool = True display_variants: tuple[KeyDisplay, ...] = () - action: RuntimeAction | None = None - def __post_init__(self) -> None: - """Reject action keys with conflicting keyboard behavior.""" - - if self.action is None: - return - if self.is_spacer: - raise ValueError("Action keys cannot be spacers") - if self.io_key is not None or self.key_id is not None or self.latchable or self.holds_when_latched: - raise ValueError("Action keys cannot define keyboard output or latch behavior") - if self.repeats: - raise ValueError("Action keys cannot repeat") - - def resolve_display(self, active_modifiers: frozenset[str]) -> KeyDisplay: - """Return the most specific display variant for active modifier IDs.""" + def resolve_display(self, active_state_tags: frozenset[str]) -> KeyDisplay: + """Return the most specific display variant for active state tags.""" best_match: KeyDisplay | None = None best_specificity = -1 - for variant in self.display_variants: - if not variant.requires_modifiers.issubset(active_modifiers): + if not variant.requires_state_tags.issubset(active_state_tags): continue - if variant.excludes_modifiers & active_modifiers: + if variant.excludes_state_tags & active_state_tags: continue - - specificity = len(variant.requires_modifiers) + len(variant.excludes_modifiers) + specificity = len(variant.requires_state_tags) + len(variant.excludes_state_tags) if specificity > best_specificity: best_match = variant best_specificity = specificity if best_match is not None: return best_match - return KeyDisplay(label=self.label, secondary_label=self.secondary_label) -def key_spec_to_data(spec: KeySpec) -> DataMap: - """Encode a key specification as queue-safe native data.""" +@dataclass(frozen=True, slots=True) +class SpacerVisual: + """Grid placement for one non-interactive spacer component.""" - display_variants: list[DataValue] = [] - for variant in spec.display_variants: - required_modifiers: list[DataValue] = [] - required_modifiers.extend(sorted(variant.requires_modifiers)) - excluded_modifiers: list[DataValue] = [] - excluded_modifiers.extend(sorted(variant.excludes_modifiers)) - display_variant: DataMap = { - "label": variant.label, - "secondary_label": variant.secondary_label, - "requires_modifiers": required_modifiers, - "excludes_modifiers": excluded_modifiers, - } - display_variants.append(display_variant) - action_data: DataMap | None = None - if spec.action is not None: - action_data = runtime_action_to_data(spec.action) - data: DataMap = { - "label": spec.label, - "row": spec.row, - "column": spec.column, - "width": spec.width, - "height": spec.height, - "is_spacer": spec.is_spacer, - "secondary_label": spec.secondary_label, - "key_id": spec.key_id, - "latchable": spec.latchable, - "io_key": spec.io_key, - "holds_when_latched": spec.holds_when_latched, - "honors_latched_modifiers": spec.honors_latched_modifiers, - "repeats": spec.repeats, - "display_variants": display_variants, - "action": action_data, - } - return data + row: int + column: int + width: float = 1.0 + height: int = 1 diff --git a/src/axidev_osk/runtime/actions.py b/src/axidev_osk/runtime/actions.py index 1052dc7..1ce122a 100644 --- a/src/axidev_osk/runtime/actions.py +++ b/src/axidev_osk/runtime/actions.py @@ -6,23 +6,25 @@ from dataclasses import dataclass from ..messages import DataMap, DataValue, RuntimeAction -from ..models import KeySpec, key_spec_to_data +from .behavior_models import KeyboardOutput from .decoding import ( bool_value, data_value, int_value, - key_spec_from_data, map_value, non_empty_string_value, number_value, require_keys, + string_set_value, ) +from .source import SourcePath, source_path_from_data, source_path_to_data APP_QUIT = "app.quit" KEYBOARD_KEY_DOWN = "keyboard.key_down" KEYBOARD_KEY_UP = "keyboard.key_up" -KEYBOARD_REGISTER_KEY_SPEC = "keyboard.register_key_spec" -KEYBOARD_SYNC_LATCHED_KEY = "keyboard.sync_latched_key" +KEYBOARD_REGISTER_OUTPUT = "keyboard.register_output" +PROMPT_RESOLVE = "prompt.resolve" +STATE_REPLACE = "state.replace" STATE_SET = "state.set" WINDOW_CLOSE = "window.close" WINDOW_HIDE = "window.hide" @@ -36,23 +38,27 @@ class AppQuitArguments: @dataclass(frozen=True, slots=True) -class KeyboardRegisterKeySpecArguments: - layout_id: str - component_id: str - key_spec: KeySpec +class KeyboardKeyArguments: + source: SourcePath + active_state_tags: frozenset[str] @dataclass(frozen=True, slots=True) -class KeyboardKeyArguments: - layout_id: str - component_id: str +class KeyboardRegisterOutputArguments: + source: SourcePath + output: KeyboardOutput @dataclass(frozen=True, slots=True) -class KeyboardSyncLatchedKeyArguments: - layout_id: str - component_id: str - latched: bool +class PromptResolveArguments: + prompt_id: str + result: str + + +@dataclass(frozen=True, slots=True) +class StateReplaceArguments: + source: SourcePath + state: DataMap @dataclass(frozen=True, slots=True) @@ -78,35 +84,35 @@ def app_quit(exit_code: int = 0) -> RuntimeAction: return _validated_action(APP_QUIT, {"exit_code": exit_code}, decode_app_quit) -def keyboard_register_key_spec(layout_id: str, component_id: str, key_spec: KeySpec) -> RuntimeAction: +def keyboard_register_output(source: SourcePath, output: KeyboardOutput) -> RuntimeAction: return _validated_action( - KEYBOARD_REGISTER_KEY_SPEC, - {"layout_id": layout_id, "component_id": component_id, "key_spec": key_spec_to_data(key_spec)}, - decode_keyboard_register_key_spec, + KEYBOARD_REGISTER_OUTPUT, + {"source": source_path_to_data(source), "output": keyboard_output_to_data(output)}, + decode_keyboard_register_output, ) -def keyboard_key_down(layout_id: str, component_id: str) -> RuntimeAction: - return _validated_action( - KEYBOARD_KEY_DOWN, - {"layout_id": layout_id, "component_id": component_id}, - decode_keyboard_key, - ) +def keyboard_key_down(source: SourcePath, active_state_tags: frozenset[str]) -> RuntimeAction: + return _keyboard_key_action(KEYBOARD_KEY_DOWN, source, active_state_tags) + +def keyboard_key_up(source: SourcePath, active_state_tags: frozenset[str]) -> RuntimeAction: + return _keyboard_key_action(KEYBOARD_KEY_UP, source, active_state_tags) -def keyboard_key_up(layout_id: str, component_id: str) -> RuntimeAction: + +def prompt_resolve(prompt_id: str, result: str) -> RuntimeAction: return _validated_action( - KEYBOARD_KEY_UP, - {"layout_id": layout_id, "component_id": component_id}, - decode_keyboard_key, + PROMPT_RESOLVE, + {"prompt_id": prompt_id, "result": result}, + decode_prompt_resolve, ) -def keyboard_sync_latched_key(layout_id: str, component_id: str, latched: bool) -> RuntimeAction: +def state_replace(source: SourcePath, state: DataMap) -> RuntimeAction: return _validated_action( - KEYBOARD_SYNC_LATCHED_KEY, - {"layout_id": layout_id, "component_id": component_id, "latched": latched}, - decode_keyboard_sync_latched_key, + STATE_REPLACE, + {"source": source_path_to_data(source), "state": state}, + decode_state_replace, ) @@ -138,34 +144,57 @@ def window_toggle_opacity(window_id: str, component_id: str, opacity: float) -> ) +def keyboard_output_to_data(output: KeyboardOutput) -> DataMap: + return { + "output_key": output.output_key, + "repeats": output.repeats, + "uses_active_state_tags": output.uses_active_state_tags, + } + + +def decode_keyboard_output(arguments: DataMap) -> KeyboardOutput: + require_keys(arguments, ("output_key", "repeats", "uses_active_state_tags")) + return KeyboardOutput( + output_key=non_empty_string_value(arguments, "output_key"), + repeats=bool_value(arguments, "repeats"), + uses_active_state_tags=bool_value(arguments, "uses_active_state_tags"), + ) + + def decode_app_quit(arguments: DataMap) -> AppQuitArguments: require_keys(arguments, ("exit_code",)) return AppQuitArguments(exit_code=int_value(arguments, "exit_code")) -def decode_keyboard_register_key_spec(arguments: DataMap) -> KeyboardRegisterKeySpecArguments: - require_keys(arguments, ("layout_id", "component_id", "key_spec")) - return KeyboardRegisterKeySpecArguments( - layout_id=non_empty_string_value(arguments, "layout_id"), - component_id=non_empty_string_value(arguments, "component_id"), - key_spec=key_spec_from_data(map_value(arguments, "key_spec")), +def decode_keyboard_register_output(arguments: DataMap) -> KeyboardRegisterOutputArguments: + require_keys(arguments, ("source", "output")) + return KeyboardRegisterOutputArguments( + source=source_path_from_data(arguments["source"]), + output=decode_keyboard_output(map_value(arguments, "output")), ) def decode_keyboard_key(arguments: DataMap) -> KeyboardKeyArguments: - require_keys(arguments, ("layout_id", "component_id")) + require_keys(arguments, ("source", "active_state_tags")) return KeyboardKeyArguments( - layout_id=non_empty_string_value(arguments, "layout_id"), - component_id=non_empty_string_value(arguments, "component_id"), + source=source_path_from_data(arguments["source"]), + active_state_tags=string_set_value(arguments, "active_state_tags"), ) -def decode_keyboard_sync_latched_key(arguments: DataMap) -> KeyboardSyncLatchedKeyArguments: - require_keys(arguments, ("layout_id", "component_id", "latched")) - return KeyboardSyncLatchedKeyArguments( - layout_id=non_empty_string_value(arguments, "layout_id"), - component_id=non_empty_string_value(arguments, "component_id"), - latched=bool_value(arguments, "latched"), +def decode_prompt_resolve(arguments: DataMap) -> PromptResolveArguments: + require_keys(arguments, ("prompt_id", "result")) + return PromptResolveArguments( + prompt_id=non_empty_string_value(arguments, "prompt_id"), + result=non_empty_string_value(arguments, "result"), + ) + + +def decode_state_replace(arguments: DataMap) -> StateReplaceArguments: + require_keys(arguments, ("source", "state")) + return StateReplaceArguments( + source=source_path_from_data(arguments["source"]), + state=map_value(arguments, "state"), ) @@ -195,6 +224,19 @@ def decode_window_toggle_opacity(arguments: DataMap) -> WindowToggleOpacityArgum ) +def _keyboard_key_action( + name: str, + source: SourcePath, + active_state_tags: frozenset[str], +) -> RuntimeAction: + state_tags: list[DataValue] = list(sorted(active_state_tags)) + return _validated_action( + name, + {"source": source_path_to_data(source), "active_state_tags": state_tags}, + decode_keyboard_key, + ) + + def _validated_action( name: str, arguments: DataMap, diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index 302b4d3..ee92cbb 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -19,15 +19,14 @@ from ..styles.theme import apply_theme from ..windows.surface import register_surfaces from .context import Context +from .behaviors import BehaviorRegistry, register_builtin_behaviors from .dispatcher import Dispatcher from .event_handlers import ( register_context_action_handlers, register_event_handlers, - route_component_pressed, route_hot_corner_triggered, ) from .events import ( - ComponentPressedArguments, HotCornerTriggeredArguments, WindowCloseRequestedArguments, register_builtin_events, @@ -77,6 +76,9 @@ def __init__( self._state = StateStore() self._components = ComponentRegistry() self._surfaces = SurfaceRegistry() + self._behaviors = BehaviorRegistry() + register_builtin_behaviors(self._behaviors) + self._behaviors.load(self._config) self._event_handlers = event_handlers or EventHandlerRegistry() if event_handlers is None: register_event_handlers(self._event_handlers) @@ -89,10 +91,12 @@ def __init__( state=self._state, components=self._components, surfaces=self._surfaces, + behaviors=self._behaviors, ) context_handlers = EventHandlerRegistry() register_context_action_handlers(context_handlers) context_handlers.install(self._dispatcher, self.context) + self._behaviors.bind_context(self.context) self._window_manager = WindowManager(self.context) self._event_handlers.install(self._dispatcher, self) self._quit_controller = ApplicationQuitController( @@ -124,6 +128,7 @@ def start(self) -> int: apply_theme(self._app) for service in self._services.services(): service.start(self.context) + self._behaviors.activate() for window_id in self._config.startup_window_ids: window = self._window_manager.show(window_id) self._quit_controller.register_window(window) @@ -156,11 +161,6 @@ def _handle_hot_corner_triggered(self, event: HotCornerTriggeredArguments) -> Me return route_hot_corner_triggered(event, self) - def _handle_component_pressed(self, event: ComponentPressedArguments) -> MessageResult: - """Map configured component actions to runtime actions.""" - - return route_component_pressed(event, self) - def _show_quit_prompt(self, parent: QWidget | None) -> bool: prompt_config = self._config.quit_prompt prompt_window = self._window_manager.create_transient( diff --git a/src/axidev_osk/runtime/behavior_models.py b/src/axidev_osk/runtime/behavior_models.py new file mode 100644 index 0000000..383a497 --- /dev/null +++ b/src/axidev_osk/runtime/behavior_models.py @@ -0,0 +1,58 @@ +"""Typed records used by built-in component behavior kinds.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from ..messages import RuntimeAction, RuntimeEvent + + +class KeyboardBehaviorMode(str, Enum): + """How a keyboard behavior reacts to press and release interactions.""" + + MOMENTARY = "momentary" + LOGICAL_TOGGLE = "logical-toggle" + HELD_TOGGLE = "held-toggle" + + +@dataclass(frozen=True, slots=True) +class KeyboardOutput: + """Backend-ready output metadata with no visual component data.""" + + output_key: str + repeats: bool = True + uses_active_state_tags: bool = True + + +@dataclass(frozen=True, slots=True) +class KeyboardBehavior: + """Runtime interaction policy for one keyboard control.""" + + mode: KeyboardBehaviorMode + output: KeyboardOutput + + +@dataclass(frozen=True, slots=True) +class ActionBehavior: + """Ordered configured actions for component press and release.""" + + pressed_actions: tuple[RuntimeAction, ...] = () + released_actions: tuple[RuntimeAction, ...] = () + + +class HookDecision(str, Enum): + """Control decision returned by a blocking before-hook.""" + + CONTINUE = "continue" + CANCEL = "cancel" + REPLACE = "replace" + + +@dataclass(frozen=True, slots=True) +class HookOutcome: + """Side messages and optional default-control decision from one hook.""" + + decision: HookDecision = HookDecision.CONTINUE + messages: tuple[RuntimeEvent | RuntimeAction, ...] = () + replacement: tuple[RuntimeEvent | RuntimeAction, ...] = () diff --git a/src/axidev_osk/runtime/behaviors.py b/src/axidev_osk/runtime/behaviors.py new file mode 100644 index 0000000..04944a2 --- /dev/null +++ b/src/axidev_osk/runtime/behaviors.py @@ -0,0 +1,500 @@ +"""Runtime component behavior registration, validation, and routing.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeVar, cast + +from ..config.models import AppConfig, BehaviorBinding, BehaviorConfig, BehaviorHook +from ..messages import DataMap, DataValue, MessageResult, RuntimeAction, RuntimeMessage, runtime_action_to_data +from .actions import ( + keyboard_key_down, + keyboard_key_up, + keyboard_output_to_data, + keyboard_register_output, + state_replace, + decode_keyboard_output, +) +from .behavior_models import ( + ActionBehavior, + HookDecision, + HookOutcome, + KeyboardBehavior, + KeyboardBehaviorMode, + KeyboardOutput, +) +from .config_paths import iter_all_source_paths, iter_interactive_source_paths +from .decoding import map_value, require_keys, runtime_action_from_data, string_value +from .events import ( + COMPONENT_PRESSED, + COMPONENT_RELEASED, + KEYBOARD_KEY_STATE_CHANGED, + KEYBOARD_OUTPUT_REGISTERED, + ComponentPressedArguments, + ComponentReleasedArguments, + KeyboardKeyStateChangedArguments, + KeyboardOutputRegisteredArguments, + behavior_failed, +) +from .source import SourcePath, source_state_namespace + +if TYPE_CHECKING: + from .context import Context + +COMPONENT_ACTIONS = "component.actions" +KEYBOARD_KEY = "keyboard.key" +BEHAVIOR_ACTIONS = "behavior.actions" + +DecodedT = TypeVar("DecodedT") + + +@dataclass(frozen=True, slots=True) +class BehaviorInteraction: + """Decoded component interaction plus its previous and proposed state.""" + + event: str + source: SourcePath + previous_state: DataMap + proposed_state: DataMap + + +BehaviorDecoder = Callable[[DataMap], object] +BehaviorHandler = Callable[[object, BehaviorInteraction, "BehaviorRegistry"], MessageResult] +HookHandler = Callable[[object, BehaviorInteraction, "BehaviorRegistry"], HookOutcome] + + +@dataclass(frozen=True, slots=True) +class _CompiledHook: + config: BehaviorHook + decoded: object + handler: HookHandler + + +@dataclass(frozen=True, slots=True) +class _CompiledBinding: + config: BehaviorBinding + default_decoded: object + default_handler: BehaviorHandler + before_hooks: tuple[_CompiledHook, ...] + after_hooks: tuple[_CompiledHook, ...] + + +class BehaviorRegistry: + """Own registered behavior kinds and exact installed component bindings.""" + + def __init__(self) -> None: + self._behavior_kinds: dict[str, tuple[BehaviorDecoder, BehaviorHandler]] = {} + self._hook_kinds: dict[str, tuple[BehaviorDecoder, HookHandler]] = {} + self._bindings: dict[SourcePath, _CompiledBinding] = {} + self._context: Context | None = None + self._state_tags_by_source: dict[SourcePath, frozenset[str]] = {} + + def register_behavior( + self, + kind: str, + decoder: Callable[[DataMap], DecodedT], + handler: Callable[[DecodedT, BehaviorInteraction, "BehaviorRegistry"], MessageResult], + ) -> None: + if kind in self._behavior_kinds: + raise ValueError(f"Behavior kind {kind!r} is already registered") + self._behavior_kinds[kind] = ( + cast(BehaviorDecoder, decoder), + cast(BehaviorHandler, handler), + ) + + def register_hook( + self, + kind: str, + decoder: Callable[[DataMap], DecodedT], + handler: Callable[[DecodedT, BehaviorInteraction, "BehaviorRegistry"], HookOutcome], + ) -> None: + if kind in self._hook_kinds: + raise ValueError(f"Behavior hook kind {kind!r} is already registered") + self._hook_kinds[kind] = ( + cast(BehaviorDecoder, decoder), + cast(HookHandler, handler), + ) + + def load(self, config: AppConfig) -> None: + """Validate and compile every root behavior binding.""" + + all_paths = set(iter_all_source_paths(config)) + required_paths = set(iter_interactive_source_paths(config)) + targets = [binding.target for binding in config.behaviors] + duplicate_targets = {target for target in targets if targets.count(target) > 1} + if duplicate_targets: + raise ValueError(f"Duplicate behavior targets: {_format_paths(duplicate_targets)}") + + target_set = set(targets) + unresolved = target_set - all_paths + missing = required_paths - target_set + extra = target_set - required_paths + if unresolved: + raise ValueError(f"Behavior targets do not resolve: {_format_paths(unresolved)}") + if missing: + raise ValueError(f"Interactive controls lack behavior: {_format_paths(missing)}") + if extra: + raise ValueError(f"Behavior targets are not interactive controls: {_format_paths(extra)}") + + self._bindings = { + binding.target: self._compile_binding(binding) for binding in config.behaviors + } + + def bind_context(self, context: "Context") -> None: + """Bind state/dispatcher ownership and install behavior event handlers.""" + + self._context = context + context.dispatcher.add_event_handler(COMPONENT_PRESSED, self._handle_pressed) + context.dispatcher.add_event_handler(COMPONENT_RELEASED, self._handle_released) + context.dispatcher.add_event_handler( + KEYBOARD_OUTPUT_REGISTERED, + self._handle_output_registered, + ) + context.dispatcher.add_event_handler( + KEYBOARD_KEY_STATE_CHANGED, + self._handle_backend_state_changed, + ) + + def activate(self) -> None: + """Register keyboard outputs and initialize complete component snapshots.""" + + context = self._require_context() + layout_paths: set[SourcePath] = set() + keyboard_bindings: list[tuple[SourcePath, KeyboardBehavior]] = [] + for source, binding in self._bindings.items(): + state: DataMap = {"pressed": False} + if isinstance(binding.default_decoded, KeyboardBehavior): + state["latched"] = False + layout_paths.add(source.through("layout")) + keyboard_bindings.append((source, binding.default_decoded)) + context.dispatcher.dispatch_action(state_replace(source, state)) + for layout_path in sorted(layout_paths, key=repr): + context.dispatcher.dispatch_action( + state_replace(layout_path, {"state_tags": []}) + ) + for source, behavior in keyboard_bindings: + context.dispatcher.dispatch_action( + keyboard_register_output(source, behavior.output) + ) + + def state_snapshot(self, source: SourcePath) -> DataMap: + value = self._require_context().state.get(source_state_namespace(source), "snapshot", {}) + return value if isinstance(value, dict) else {} + + def active_state_tags(self, layout_path: SourcePath) -> frozenset[str]: + value = self.state_snapshot(layout_path).get("state_tags", []) + if not isinstance(value, list): + return frozenset() + return frozenset(item for item in value if isinstance(item, str)) + + def layout_state_action( + self, + source: SourcePath, + override_state: DataMap, + ) -> RuntimeAction: + layout_path = source.through("layout") + active_tags: set[str] = set() + for candidate, binding in self._bindings.items(): + if not isinstance(binding.default_decoded, KeyboardBehavior): + continue + try: + candidate_layout = candidate.through("layout") + except ValueError: + continue + if candidate_layout != layout_path: + continue + state = override_state if candidate == source else self.state_snapshot(candidate) + if bool(state.get("pressed", False)) or bool(state.get("latched", False)): + active_tags.update(self._state_tags_by_source.get(candidate, frozenset())) + state_tags: list[DataValue] = list(sorted(active_tags)) + return state_replace(layout_path, {"state_tags": state_tags}) + + def _compile_binding(self, binding: BehaviorBinding) -> _CompiledBinding: + default_registration = self._behavior_kinds.get(binding.default.kind) + if default_registration is None: + raise ValueError(f"Unknown behavior kind {binding.default.kind!r}") + decoder, handler = default_registration + default_decoded = decoder(binding.default.arguments) + return _CompiledBinding( + config=binding, + default_decoded=default_decoded, + default_handler=handler, + before_hooks=tuple(self._compile_hook(hook) for hook in binding.before_hooks), + after_hooks=tuple(self._compile_hook(hook) for hook in binding.after_hooks), + ) + + def _compile_hook(self, hook: BehaviorHook) -> _CompiledHook: + registration = self._hook_kinds.get(hook.config.kind) + if registration is None: + raise ValueError(f"Unknown behavior hook kind {hook.config.kind!r}") + decoder, handler = registration + return _CompiledHook(config=hook, decoded=decoder(hook.config.arguments), handler=handler) + + def _handle_pressed(self, event: ComponentPressedArguments) -> MessageResult: + return self._handle_interaction(COMPONENT_PRESSED, event.source) + + def _handle_released(self, event: ComponentReleasedArguments) -> MessageResult: + return self._handle_interaction(COMPONENT_RELEASED, event.source) + + def _handle_interaction(self, event_name: str, source: SourcePath) -> MessageResult: + binding = self._bindings.get(source) + if binding is None: + return [ + behavior_failed( + source, + "behavior.lookup", + "default", + "lookup", + "LookupError", + "No behavior binding is installed for this source", + ) + ] + + previous = self.state_snapshot(source) + proposed = dict(previous) + proposed["pressed"] = event_name == COMPONENT_PRESSED + interaction = BehaviorInteraction(event_name, source, previous, proposed) + messages: MessageResult = [state_replace(source, proposed)] + decision = HookDecision.CONTINUE + replacement: tuple[RuntimeMessage, ...] = () + blocked = False + + for hook in binding.before_hooks: + if event_name not in hook.config.events: + continue + try: + outcome = hook.handler(hook.decoded, interaction, self) + messages.extend(outcome.messages) + if outcome.decision is not HookDecision.CONTINUE: + if not hook.config.blocking: + raise ValueError("A non-blocking before-hook cannot cancel or replace default behavior") + decision = outcome.decision + replacement = outcome.replacement + except Exception as exc: + blocked = True + messages.append(self._failure(source, hook.config.config.kind, "before", exc)) + + if not blocked: + if decision is HookDecision.REPLACE: + messages.extend(replacement) + elif decision is HookDecision.CONTINUE: + try: + messages.extend( + binding.default_handler(binding.default_decoded, interaction, self) + ) + except Exception as exc: + messages.append( + self._failure(source, binding.config.default.kind, "default", exc) + ) + + for hook in binding.after_hooks: + if event_name not in hook.config.events: + continue + try: + outcome = hook.handler(hook.decoded, interaction, self) + messages.extend(outcome.messages) + if outcome.decision is not HookDecision.CONTINUE or outcome.replacement: + raise ValueError("After-hooks may extend behavior but cannot cancel or replace it") + except Exception as exc: + messages.append(self._failure(source, hook.config.config.kind, "after", exc)) + return messages + + def _handle_output_registered( + self, + event: KeyboardOutputRegisteredArguments, + ) -> MessageResult: + self._state_tags_by_source[event.source] = event.state_tags + return [] + + def _handle_backend_state_changed( + self, + event: KeyboardKeyStateChangedArguments, + ) -> MessageResult: + previous = self.state_snapshot(event.source) + state = dict(previous) + state["pressed"] = event.pressed + self._state_tags_by_source[event.source] = event.state_tags + return [ + state_replace(event.source, state), + self.layout_state_action(event.source, state), + ] + + def _failure( + self, + source: SourcePath, + kind: str, + phase: str, + exc: Exception, + ) -> RuntimeMessage: + return behavior_failed( + source, + kind, + phase, + "handler", + type(exc).__name__, + str(exc), + ) + + def _require_context(self) -> "Context": + if self._context is None: + raise RuntimeError("Behavior registry is not bound to a runtime context") + return self._context + + +def register_builtin_behaviors(registry: BehaviorRegistry) -> None: + registry.register_behavior(COMPONENT_ACTIONS, decode_action_behavior, _handle_actions) + registry.register_behavior(KEYBOARD_KEY, decode_keyboard_behavior, _handle_keyboard) + registry.register_hook(BEHAVIOR_ACTIONS, decode_hook_outcome, _handle_action_hook) + + +def action_behavior( + *, + pressed_actions: tuple[RuntimeAction, ...] = (), + released_actions: tuple[RuntimeAction, ...] = (), +) -> BehaviorConfig: + return BehaviorConfig( + COMPONENT_ACTIONS, + { + "pressed_actions": [runtime_action_to_data(action) for action in pressed_actions], + "released_actions": [runtime_action_to_data(action) for action in released_actions], + }, + ) + + +def keyboard_behavior(mode: KeyboardBehaviorMode, output: KeyboardOutput) -> BehaviorConfig: + return BehaviorConfig( + KEYBOARD_KEY, + {"mode": mode.value, "output": keyboard_output_to_data(output)}, + ) + + +def action_hook( + *, + decision: HookDecision = HookDecision.CONTINUE, + messages: tuple[RuntimeAction, ...] = (), + replacement: tuple[RuntimeAction, ...] = (), +) -> BehaviorConfig: + return BehaviorConfig( + BEHAVIOR_ACTIONS, + { + "decision": decision.value, + "messages": [runtime_action_to_data(action) for action in messages], + "replacement": [runtime_action_to_data(action) for action in replacement], + }, + ) + + +def decode_action_behavior(arguments: DataMap) -> ActionBehavior: + require_keys(arguments, ("pressed_actions", "released_actions")) + return ActionBehavior( + pressed_actions=_action_list(arguments, "pressed_actions"), + released_actions=_action_list(arguments, "released_actions"), + ) + + +def decode_keyboard_behavior(arguments: DataMap) -> KeyboardBehavior: + require_keys(arguments, ("mode", "output")) + mode_value = string_value(arguments, "mode") + try: + mode = KeyboardBehaviorMode(mode_value) + except ValueError as exc: + raise ValueError(f"Unknown keyboard behavior mode {mode_value!r}") from exc + return KeyboardBehavior( + mode=mode, + output=decode_keyboard_output(map_value(arguments, "output")), + ) + + +def decode_hook_outcome(arguments: DataMap) -> HookOutcome: + require_keys(arguments, ("decision", "messages", "replacement")) + decision_value = string_value(arguments, "decision") + try: + decision = HookDecision(decision_value) + except ValueError as exc: + raise ValueError(f"Unknown hook decision {decision_value!r}") from exc + replacement = _action_list(arguments, "replacement") + if decision is not HookDecision.REPLACE and replacement: + raise ValueError("Only a replace hook outcome may contain replacement actions") + return HookOutcome( + decision=decision, + messages=_action_list(arguments, "messages"), + replacement=replacement, + ) + + +def _handle_actions( + behavior: ActionBehavior, + interaction: BehaviorInteraction, + registry: BehaviorRegistry, +) -> MessageResult: + del registry + if interaction.event == COMPONENT_PRESSED: + return list(behavior.pressed_actions) + return list(behavior.released_actions) + + +def _handle_keyboard( + behavior: KeyboardBehavior, + interaction: BehaviorInteraction, + registry: BehaviorRegistry, +) -> MessageResult: + source = interaction.source + layout_path = source.through("layout") + active_tags = registry.active_state_tags(layout_path) + messages: MessageResult = [] + + if behavior.mode is KeyboardBehaviorMode.MOMENTARY: + action = ( + keyboard_key_down(source, active_tags) + if interaction.event == COMPONENT_PRESSED + else keyboard_key_up(source, active_tags) + ) + messages.append(action) + messages.append(registry.layout_state_action(source, interaction.proposed_state)) + return messages + + if interaction.event == COMPONENT_PRESSED: + if behavior.mode is KeyboardBehaviorMode.LOGICAL_TOGGLE or ( + behavior.mode is KeyboardBehaviorMode.HELD_TOGGLE + and not bool(interaction.previous_state.get("latched", False)) + ): + messages.append(keyboard_key_down(source, active_tags)) + messages.append(registry.layout_state_action(source, interaction.proposed_state)) + return messages + + state = dict(interaction.proposed_state) + state["latched"] = not bool(interaction.previous_state.get("latched", False)) + messages.append(state_replace(source, state)) + messages.append(registry.layout_state_action(source, state)) + if behavior.mode is KeyboardBehaviorMode.LOGICAL_TOGGLE or ( + behavior.mode is KeyboardBehaviorMode.HELD_TOGGLE + and not bool(state["latched"]) + ): + messages.append(keyboard_key_up(source, active_tags)) + return messages + + +def _handle_action_hook( + outcome: HookOutcome, + interaction: BehaviorInteraction, + registry: BehaviorRegistry, +) -> HookOutcome: + del interaction, registry + return outcome + + +def _action_list(arguments: DataMap, key: str) -> tuple[RuntimeAction, ...]: + value = arguments[key] + if not isinstance(value, list): + raise TypeError(f"Argument {key!r} must be a list") + actions: list[RuntimeAction] = [] + for index, item in enumerate(value): + if not isinstance(item, dict): + raise TypeError(f"Argument {key!r} item {index} must be a map") + actions.append(runtime_action_from_data(item)) + return tuple(actions) + + +def _format_paths(paths: set[SourcePath]) -> str: + return ", ".join(sorted(repr(path) for path in paths)) diff --git a/src/axidev_osk/runtime/config_paths.py b/src/axidev_osk/runtime/config_paths.py new file mode 100644 index 0000000..3a561dd --- /dev/null +++ b/src/axidev_osk/runtime/config_paths.py @@ -0,0 +1,119 @@ +"""Source-path construction for configured windows and components.""" + +from __future__ import annotations + +from collections.abc import Iterator + +from ..config.models import ( + AppConfig, + ButtonConfig, + ComponentConfig, + KeyboardGridConfig, + KeyConfig, + PromptConfig, + SpacerConfig, + WindowConfig, +) +from .source import SourcePath, SourcePathSegment + + +def app_source_path(config: AppConfig) -> SourcePath: + """Return the app/profile root shared by all configured source paths.""" + + return SourcePath( + ( + SourcePathSegment("app", config.app_id), + SourcePathSegment("profile", config.active_profile_id), + ) + ) + + +def window_source_path(config: AppConfig, window_id: str) -> SourcePath: + return app_source_path(config).child("window", window_id) + + +def surface_source_path(config: AppConfig, window_id: str, surface_id: str) -> SourcePath: + return window_source_path(config, window_id).child("surface", surface_id) + + +def iter_interactive_source_paths(config: AppConfig) -> Iterator[SourcePath]: + """Yield exact paths for every configured key and generic button.""" + + for window in config.windows: + yield from _iter_surface_controls(config, window) + yield from _iter_prompt_controls(config, config.quit_prompt) + yield from _iter_prompt_controls(config, config.linux_permission_prompt) + + +def iter_all_source_paths(config: AppConfig) -> Iterator[SourcePath]: + """Yield every configured source path, including containers and spacers.""" + + root = app_source_path(config) + yield root + for window in config.windows: + yield from _iter_window_paths(config, window) + for prompt in (config.quit_prompt, config.linux_permission_prompt): + yield from _iter_prompt_paths(config, prompt) + + +def _iter_window_paths(config: AppConfig, window: WindowConfig) -> Iterator[SourcePath]: + window_path = window_source_path(config, window.id) + yield window_path + surface_path = window_path.child("surface", window.surface.id) + yield surface_path + yield from _iter_components(surface_path, window.surface.components) + + +def _iter_surface_controls(config: AppConfig, window: WindowConfig) -> Iterator[SourcePath]: + surface_path = surface_source_path(config, window.id, window.surface.id) + for path, component in _walk_components(surface_path, window.surface.components): + if isinstance(component, (KeyConfig, ButtonConfig)): + yield path + + +def _iter_prompt_paths(config: AppConfig, prompt: PromptConfig) -> Iterator[SourcePath]: + window_path = window_source_path(config, prompt.window_id) + yield window_path + surface_path = window_path.child("surface", prompt.surface_id) + yield surface_path + prompt_path = surface_path.child("component", prompt.id) + yield prompt_path + for button in prompt.buttons: + yield prompt_path.child("component", button.id) + + +def _iter_prompt_controls(config: AppConfig, prompt: PromptConfig) -> Iterator[SourcePath]: + prompt_path = surface_source_path(config, prompt.window_id, prompt.surface_id).child( + "component", prompt.id + ) + for button in prompt.buttons: + yield prompt_path.child("component", button.id) + + +def _iter_components(parent: SourcePath, components: tuple[ComponentConfig, ...]) -> Iterator[SourcePath]: + for component in components: + path = parent.child("component", component.id) + yield path + if isinstance(component, KeyboardGridConfig): + layout_path = path.child("layout", component.layout.id) + yield layout_path + for grid in component.layout.grids: + grid_path = layout_path.child("grid", grid.id) + yield grid_path + for child in grid.components: + yield grid_path.child("component", child.id) + + +def _walk_components( + parent: SourcePath, + components: tuple[ComponentConfig, ...], +) -> Iterator[tuple[SourcePath, ComponentConfig | KeyConfig | SpacerConfig]]: + for component in components: + path = parent.child("component", component.id) + yield path, component + if isinstance(component, KeyboardGridConfig): + layout_path = path.child("layout", component.layout.id) + for grid in component.layout.grids: + grid_path = layout_path.child("grid", grid.id) + for child in grid.components: + yield grid_path.child("component", child.id), child diff --git a/src/axidev_osk/runtime/context.py b/src/axidev_osk/runtime/context.py index 230b650..7a85689 100644 --- a/src/axidev_osk/runtime/context.py +++ b/src/axidev_osk/runtime/context.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from .dispatcher import Dispatcher + from .behaviors import BehaviorRegistry from ..services.keyboard import KeyboardService @@ -25,6 +26,7 @@ class Context: state: Central state store. components: Component builder registry. surfaces: Surface builder registry. + behaviors: Runtime component behavior registry. """ config: AppConfig @@ -33,3 +35,4 @@ class Context: state: StateStore components: ComponentRegistry surfaces: SurfaceRegistry + behaviors: "BehaviorRegistry" diff --git a/src/axidev_osk/runtime/decoding.py b/src/axidev_osk/runtime/decoding.py index 4c5f98f..701762c 100644 --- a/src/axidev_osk/runtime/decoding.py +++ b/src/axidev_osk/runtime/decoding.py @@ -5,7 +5,6 @@ from collections.abc import Iterable from ..messages import DataMap, DataValue, RuntimeAction -from ..models import KeyDisplay, KeySpec def require_keys(arguments: DataMap, required: Iterable[str], *, optional: Iterable[str] = ()) -> None: @@ -84,78 +83,9 @@ def runtime_action_from_data(arguments: DataMap) -> RuntimeAction: ) -def key_spec_from_data(arguments: DataMap) -> KeySpec: - """Decode a native-data key specification.""" - - require_keys( - arguments, - ( - "label", - "row", - "column", - "width", - "height", - "is_spacer", - "secondary_label", - "key_id", - "latchable", - "io_key", - "holds_when_latched", - "honors_latched_modifiers", - "repeats", - "display_variants", - "action", - ), - ) - variants_value = arguments["display_variants"] - if not isinstance(variants_value, list): - raise TypeError("Argument 'display_variants' must be a list") - variants: list[KeyDisplay] = [] - for index, value in enumerate(variants_value): - if not isinstance(value, dict): - raise TypeError(f"Display variant {index} must be a map") - require_keys( - value, - ("label", "secondary_label", "requires_modifiers", "excludes_modifiers"), - ) - required = _string_set(value, "requires_modifiers") - excluded = _string_set(value, "excludes_modifiers") - variants.append( - KeyDisplay( - label=string_value(value, "label"), - secondary_label=optional_string_value(value, "secondary_label"), - requires_modifiers=required, - excludes_modifiers=excluded, - ) - ) - - action_value = arguments["action"] - action: RuntimeAction | None = None - if action_value is not None: - if not isinstance(action_value, dict): - raise TypeError("Argument 'action' must be a map or null") - action = runtime_action_from_data(action_value) - - return KeySpec( - label=string_value(arguments, "label"), - row=int_value(arguments, "row"), - column=int_value(arguments, "column"), - width=number_value(arguments, "width"), - height=int_value(arguments, "height"), - is_spacer=bool_value(arguments, "is_spacer"), - secondary_label=optional_string_value(arguments, "secondary_label"), - key_id=optional_string_value(arguments, "key_id"), - latchable=bool_value(arguments, "latchable"), - io_key=optional_string_value(arguments, "io_key"), - holds_when_latched=bool_value(arguments, "holds_when_latched"), - honors_latched_modifiers=bool_value(arguments, "honors_latched_modifiers"), - repeats=bool_value(arguments, "repeats"), - display_variants=tuple(variants), - action=action, - ) - +def string_set_value(arguments: DataMap, key: str) -> frozenset[str]: + """Decode a list of unique strings as an immutable set.""" -def _string_set(arguments: DataMap, key: str) -> frozenset[str]: value = arguments[key] if not isinstance(value, list): raise TypeError(f"Argument {key!r} must be a list") diff --git a/src/axidev_osk/runtime/event_handlers.py b/src/axidev_osk/runtime/event_handlers.py index 35bdbde..50e64ab 100644 --- a/src/axidev_osk/runtime/event_handlers.py +++ b/src/axidev_osk/runtime/event_handlers.py @@ -10,8 +10,9 @@ APP_QUIT, KEYBOARD_KEY_DOWN, KEYBOARD_KEY_UP, - KEYBOARD_REGISTER_KEY_SPEC, - KEYBOARD_SYNC_LATCHED_KEY, + KEYBOARD_REGISTER_OUTPUT, + PROMPT_RESOLVE, + STATE_REPLACE, STATE_SET, WINDOW_CLOSE, WINDOW_HIDE, @@ -19,15 +20,17 @@ WINDOW_TOGGLE_OPACITY, AppQuitArguments, KeyboardKeyArguments, - KeyboardRegisterKeySpecArguments, - KeyboardSyncLatchedKeyArguments, + KeyboardRegisterOutputArguments, + PromptResolveArguments, + StateReplaceArguments, StateSetArguments, WindowArguments, WindowToggleOpacityArguments, decode_app_quit, decode_keyboard_key, - decode_keyboard_register_key_spec, - decode_keyboard_sync_latched_key, + decode_keyboard_register_output, + decode_prompt_resolve, + decode_state_replace, decode_state_set, decode_window, decode_window_toggle_opacity, @@ -35,13 +38,15 @@ window_show, ) from .events import ( - COMPONENT_PRESSED, HOT_CORNER_TRIGGERED, WINDOW_CLOSE_REQUESTED, - ComponentPressedArguments, HotCornerTriggeredArguments, WindowCloseRequestedArguments, + keyboard_output_registered, + prompt_resolved, + state_changed, ) +from .source import source_state_namespace from .registries import EventHandlerRegistry @@ -62,18 +67,13 @@ def _handle_hot_corner_triggered( event: HotCornerTriggeredArguments, ) -> MessageResult: ... - def _handle_component_pressed( - self, - event: ComponentPressedArguments, - ) -> MessageResult: ... - def register_context_action_handlers(registry: EventHandlerRegistry) -> None: """Register context-owned built-in actions.""" registry.register_action_handler( - KEYBOARD_REGISTER_KEY_SPEC, - decode_keyboard_register_key_spec, + KEYBOARD_REGISTER_OUTPUT, + decode_keyboard_register_output, lambda context: lambda arguments: _keyboard_register(context, arguments), ) registry.register_action_handler( @@ -87,15 +87,20 @@ def register_context_action_handlers(registry: EventHandlerRegistry) -> None: lambda context: lambda arguments: _keyboard_up(context, arguments), ) registry.register_action_handler( - KEYBOARD_SYNC_LATCHED_KEY, - decode_keyboard_sync_latched_key, - lambda context: lambda arguments: _keyboard_sync_latch(context, arguments), + STATE_REPLACE, + decode_state_replace, + lambda context: lambda arguments: _state_replace(context, arguments), ) registry.register_action_handler( STATE_SET, decode_state_set, lambda context: lambda arguments: _state_set(context, arguments), ) + registry.register_action_handler( + PROMPT_RESOLVE, + decode_prompt_resolve, + lambda context: lambda arguments: _prompt_resolve(context, arguments), + ) def register_event_handlers(registry: EventHandlerRegistry) -> None: @@ -134,10 +139,6 @@ def register_event_handlers(registry: EventHandlerRegistry) -> None: HOT_CORNER_TRIGGERED, _hot_corner_triggered_handler, ) - registry.register_event_handler( - COMPONENT_PRESSED, - _component_pressed_handler, - ) def route_hot_corner_triggered( @@ -161,18 +162,6 @@ def route_hot_corner_triggered( return actions -def route_component_pressed( - event: ComponentPressedArguments, - runtime: object, -) -> MessageResult: - """Return the configured action attached to a pressed key.""" - - del runtime - if event.action is None: - return [] - return [event.action] - - def _window_close_requested_handler( runtime: _ApplicationEventRuntime, ) -> Callable[[WindowCloseRequestedArguments], MessageResult]: @@ -185,38 +174,31 @@ def _hot_corner_triggered_handler( return runtime._handle_hot_corner_triggered -def _component_pressed_handler( - runtime: _ApplicationEventRuntime, -) -> Callable[[ComponentPressedArguments], MessageResult]: - return runtime._handle_component_pressed - - -def _keyboard_register(context: object, arguments: KeyboardRegisterKeySpecArguments) -> MessageResult: - context.keyboard.register_key_spec( # type: ignore[attr-defined] - arguments.layout_id, - arguments.key_spec, - component_id=arguments.component_id, +def _keyboard_register(context: object, arguments: KeyboardRegisterOutputArguments) -> MessageResult: + output_key, state_tags = context.keyboard.register_output( # type: ignore[attr-defined] + arguments.source, + arguments.output, ) - return [] + return [keyboard_output_registered(arguments.source, output_key, state_tags)] def _keyboard_down(context: object, arguments: KeyboardKeyArguments) -> MessageResult: - context.keyboard.key_down(arguments.layout_id, arguments.component_id) # type: ignore[attr-defined] + context.keyboard.key_down(arguments.source, arguments.active_state_tags) # type: ignore[attr-defined] return [] def _keyboard_up(context: object, arguments: KeyboardKeyArguments) -> MessageResult: - context.keyboard.key_up(arguments.layout_id, arguments.component_id) # type: ignore[attr-defined] + context.keyboard.key_up(arguments.source) # type: ignore[attr-defined] return [] -def _keyboard_sync_latch(context: object, arguments: KeyboardSyncLatchedKeyArguments) -> MessageResult: - context.keyboard.sync_latched_key( # type: ignore[attr-defined] - arguments.layout_id, - arguments.component_id, - arguments.latched, +def _state_replace(context: object, arguments: StateReplaceArguments) -> MessageResult: + context.state.set( # type: ignore[attr-defined] + source_state_namespace(arguments.source), + "snapshot", + arguments.state, ) - return [] + return [state_changed(arguments.source, arguments.state)] def _state_set(context: object, arguments: StateSetArguments) -> MessageResult: @@ -224,6 +206,11 @@ def _state_set(context: object, arguments: StateSetArguments) -> MessageResult: return [] +def _prompt_resolve(context: object, arguments: PromptResolveArguments) -> MessageResult: + del context + return [prompt_resolved(arguments.prompt_id, arguments.result)] + + def _window_show(runtime: object, arguments: WindowArguments) -> MessageResult: runtime._window_manager.show(arguments.window_id) # type: ignore[attr-defined] # noqa: SLF001 return [] diff --git a/src/axidev_osk/runtime/events.py b/src/axidev_osk/runtime/events.py index daa806a..b955bf9 100644 --- a/src/axidev_osk/runtime/events.py +++ b/src/axidev_osk/runtime/events.py @@ -5,28 +5,22 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from ..messages import DataMap, RuntimeAction, RuntimeEvent, runtime_action_to_data -from .decoding import ( - bool_value, - map_value, - optional_string_value, - require_keys, - runtime_action_from_data, - string_value, -) +from ..messages import DataMap, DataValue, RuntimeEvent +from .decoding import bool_value, map_value, require_keys, string_set_value, string_value +from .source import SourcePath, source_path_from_data, source_path_to_data if TYPE_CHECKING: from .dispatcher import Dispatcher ACTION_FAILED = "action.failed" +BEHAVIOR_FAILED = "behavior.failed" COMPONENT_PRESSED = "component.pressed" COMPONENT_RELEASED = "component.released" -COMPONENT_STATE_CHANGED = "component.state_changed" HOT_CORNER_TRIGGERED = "hot_corner.triggered" -KEYBOARD_KEY_REGISTERED = "keyboard.key_registered" KEYBOARD_KEY_STATE_CHANGED = "keyboard.key_state_changed" -KEYBOARD_LATCH_CHANGED = "keyboard.latch_changed" +KEYBOARD_OUTPUT_REGISTERED = "keyboard.output_registered" PROMPT_RESOLVED = "prompt.resolved" +STATE_CHANGED = "state.changed" WINDOW_CLOSE_REQUESTED = "window.close_requested" @@ -40,21 +34,23 @@ class ActionFailedArguments: @dataclass(frozen=True, slots=True) -class ComponentPressedArguments: - component_id: str - action: RuntimeAction | None +class BehaviorFailedArguments: + source: SourcePath + kind: str + phase: str + stage: str + exception_type: str + message: str @dataclass(frozen=True, slots=True) -class ComponentReleasedArguments: - component_id: str +class ComponentPressedArguments: + source: SourcePath @dataclass(frozen=True, slots=True) -class ComponentStateChangedArguments: - component_id: str - key_id: str - latched: bool +class ComponentReleasedArguments: + source: SourcePath @dataclass(frozen=True, slots=True) @@ -62,26 +58,18 @@ class HotCornerTriggeredArguments: corner: str -@dataclass(frozen=True, slots=True) -class KeyboardKeyRegisteredArguments: - layout_id: str - component_id: str - io_key_name: str | None - - @dataclass(frozen=True, slots=True) class KeyboardKeyStateChangedArguments: - layout_id: str - key_id: str + source: SourcePath pressed: bool - latched: bool + state_tags: frozenset[str] @dataclass(frozen=True, slots=True) -class KeyboardLatchChangedArguments: - layout_id: str - key_id: str - latched: bool +class KeyboardOutputRegisteredArguments: + source: SourcePath + output_key: str + state_tags: frozenset[str] @dataclass(frozen=True, slots=True) @@ -90,26 +78,43 @@ class PromptResolvedArguments: result: str +@dataclass(frozen=True, slots=True) +class StateChangedArguments: + source: SourcePath + state: DataMap + + @dataclass(frozen=True, slots=True) class WindowCloseRequestedArguments: window_id: str -def component_pressed(component_id: str, action: RuntimeAction | None = None) -> RuntimeEvent: - return RuntimeEvent( - COMPONENT_PRESSED, - {"component_id": component_id, "action": runtime_action_to_data(action) if action is not None else None}, - ) +def component_pressed(source: SourcePath) -> RuntimeEvent: + return RuntimeEvent(COMPONENT_PRESSED, {"source": source_path_to_data(source)}) -def component_released(component_id: str) -> RuntimeEvent: - return RuntimeEvent(COMPONENT_RELEASED, {"component_id": component_id}) +def component_released(source: SourcePath) -> RuntimeEvent: + return RuntimeEvent(COMPONENT_RELEASED, {"source": source_path_to_data(source)}) -def component_state_changed(component_id: str, key_id: str, latched: bool) -> RuntimeEvent: +def behavior_failed( + source: SourcePath, + kind: str, + phase: str, + stage: str, + exception_type: str, + message: str, +) -> RuntimeEvent: return RuntimeEvent( - COMPONENT_STATE_CHANGED, - {"component_id": component_id, "key_id": key_id, "latched": latched}, + BEHAVIOR_FAILED, + { + "source": source_path_to_data(source), + "kind": kind, + "phase": phase, + "stage": stage, + "exception_type": exception_type, + "message": message, + }, ) @@ -117,24 +122,35 @@ def hot_corner_triggered(corner: str) -> RuntimeEvent: return RuntimeEvent(HOT_CORNER_TRIGGERED, {"corner": corner}) -def keyboard_key_registered(layout_id: str, component_id: str, io_key_name: str | None) -> RuntimeEvent: - return RuntimeEvent( - KEYBOARD_KEY_REGISTERED, - {"layout_id": layout_id, "component_id": component_id, "io_key_name": io_key_name}, - ) - - -def keyboard_key_state_changed(layout_id: str, key_id: str, pressed: bool, latched: bool) -> RuntimeEvent: +def keyboard_key_state_changed( + source: SourcePath, + pressed: bool, + state_tags: frozenset[str], +) -> RuntimeEvent: + tags: list[DataValue] = list(sorted(state_tags)) return RuntimeEvent( KEYBOARD_KEY_STATE_CHANGED, - {"layout_id": layout_id, "key_id": key_id, "pressed": pressed, "latched": latched}, + { + "source": source_path_to_data(source), + "pressed": pressed, + "state_tags": tags, + }, ) -def keyboard_latch_changed(layout_id: str, key_id: str, latched: bool) -> RuntimeEvent: +def keyboard_output_registered( + source: SourcePath, + output_key: str, + state_tags: frozenset[str], +) -> RuntimeEvent: + tags: list[DataValue] = list(sorted(state_tags)) return RuntimeEvent( - KEYBOARD_LATCH_CHANGED, - {"layout_id": layout_id, "key_id": key_id, "latched": latched}, + KEYBOARD_OUTPUT_REGISTERED, + { + "source": source_path_to_data(source), + "output_key": output_key, + "state_tags": tags, + }, ) @@ -142,6 +158,10 @@ def prompt_resolved(prompt_id: str, result: str) -> RuntimeEvent: return RuntimeEvent(PROMPT_RESOLVED, {"prompt_id": prompt_id, "result": result}) +def state_changed(source: SourcePath, state: DataMap) -> RuntimeEvent: + return RuntimeEvent(STATE_CHANGED, {"source": source_path_to_data(source), "state": state}) + + def window_close_requested(window_id: str) -> RuntimeEvent: return RuntimeEvent(WINDOW_CLOSE_REQUESTED, {"window_id": window_id}) @@ -157,29 +177,26 @@ def decode_action_failed(arguments: DataMap) -> ActionFailedArguments: ) -def decode_component_pressed(arguments: DataMap) -> ComponentPressedArguments: - require_keys(arguments, ("component_id", "action")) - action_value = arguments["action"] - if action_value is not None and not isinstance(action_value, dict): - raise TypeError("Argument 'action' must be a map or null") - return ComponentPressedArguments( - component_id=string_value(arguments, "component_id"), - action=runtime_action_from_data(action_value) if isinstance(action_value, dict) else None, +def decode_behavior_failed(arguments: DataMap) -> BehaviorFailedArguments: + require_keys(arguments, ("source", "kind", "phase", "stage", "exception_type", "message")) + return BehaviorFailedArguments( + source=source_path_from_data(arguments["source"]), + kind=string_value(arguments, "kind"), + phase=string_value(arguments, "phase"), + stage=string_value(arguments, "stage"), + exception_type=string_value(arguments, "exception_type"), + message=string_value(arguments, "message"), ) -def decode_component_released(arguments: DataMap) -> ComponentReleasedArguments: - require_keys(arguments, ("component_id",)) - return ComponentReleasedArguments(component_id=string_value(arguments, "component_id")) +def decode_component_pressed(arguments: DataMap) -> ComponentPressedArguments: + require_keys(arguments, ("source",)) + return ComponentPressedArguments(source=source_path_from_data(arguments["source"])) -def decode_component_state_changed(arguments: DataMap) -> ComponentStateChangedArguments: - require_keys(arguments, ("component_id", "key_id", "latched")) - return ComponentStateChangedArguments( - component_id=string_value(arguments, "component_id"), - key_id=string_value(arguments, "key_id"), - latched=bool_value(arguments, "latched"), - ) +def decode_component_released(arguments: DataMap) -> ComponentReleasedArguments: + require_keys(arguments, ("source",)) + return ComponentReleasedArguments(source=source_path_from_data(arguments["source"])) def decode_hot_corner_triggered(arguments: DataMap) -> HotCornerTriggeredArguments: @@ -187,31 +204,21 @@ def decode_hot_corner_triggered(arguments: DataMap) -> HotCornerTriggeredArgumen return HotCornerTriggeredArguments(corner=string_value(arguments, "corner")) -def decode_keyboard_key_registered(arguments: DataMap) -> KeyboardKeyRegisteredArguments: - require_keys(arguments, ("layout_id", "component_id", "io_key_name")) - return KeyboardKeyRegisteredArguments( - layout_id=string_value(arguments, "layout_id"), - component_id=string_value(arguments, "component_id"), - io_key_name=optional_string_value(arguments, "io_key_name"), - ) - - def decode_keyboard_key_state_changed(arguments: DataMap) -> KeyboardKeyStateChangedArguments: - require_keys(arguments, ("layout_id", "key_id", "pressed", "latched")) + require_keys(arguments, ("source", "pressed", "state_tags")) return KeyboardKeyStateChangedArguments( - layout_id=string_value(arguments, "layout_id"), - key_id=string_value(arguments, "key_id"), + source=source_path_from_data(arguments["source"]), pressed=bool_value(arguments, "pressed"), - latched=bool_value(arguments, "latched"), + state_tags=string_set_value(arguments, "state_tags"), ) -def decode_keyboard_latch_changed(arguments: DataMap) -> KeyboardLatchChangedArguments: - require_keys(arguments, ("layout_id", "key_id", "latched")) - return KeyboardLatchChangedArguments( - layout_id=string_value(arguments, "layout_id"), - key_id=string_value(arguments, "key_id"), - latched=bool_value(arguments, "latched"), +def decode_keyboard_output_registered(arguments: DataMap) -> KeyboardOutputRegisteredArguments: + require_keys(arguments, ("source", "output_key", "state_tags")) + return KeyboardOutputRegisteredArguments( + source=source_path_from_data(arguments["source"]), + output_key=string_value(arguments, "output_key"), + state_tags=string_set_value(arguments, "state_tags"), ) @@ -223,21 +230,29 @@ def decode_prompt_resolved(arguments: DataMap) -> PromptResolvedArguments: ) +def decode_state_changed(arguments: DataMap) -> StateChangedArguments: + require_keys(arguments, ("source", "state")) + return StateChangedArguments( + source=source_path_from_data(arguments["source"]), + state=map_value(arguments, "state"), + ) + + def decode_window_close_requested(arguments: DataMap) -> WindowCloseRequestedArguments: require_keys(arguments, ("window_id",)) return WindowCloseRequestedArguments(window_id=string_value(arguments, "window_id")) def register_builtin_events(dispatcher: "Dispatcher") -> None: - """Register every built-in event decoder on a dispatcher-shaped object.""" + """Register every built-in event decoder.""" dispatcher.register_event(ACTION_FAILED, decode_action_failed) + dispatcher.register_event(BEHAVIOR_FAILED, decode_behavior_failed) dispatcher.register_event(COMPONENT_PRESSED, decode_component_pressed) dispatcher.register_event(COMPONENT_RELEASED, decode_component_released) - dispatcher.register_event(COMPONENT_STATE_CHANGED, decode_component_state_changed) dispatcher.register_event(HOT_CORNER_TRIGGERED, decode_hot_corner_triggered) - dispatcher.register_event(KEYBOARD_KEY_REGISTERED, decode_keyboard_key_registered) dispatcher.register_event(KEYBOARD_KEY_STATE_CHANGED, decode_keyboard_key_state_changed) - dispatcher.register_event(KEYBOARD_LATCH_CHANGED, decode_keyboard_latch_changed) + dispatcher.register_event(KEYBOARD_OUTPUT_REGISTERED, decode_keyboard_output_registered) dispatcher.register_event(PROMPT_RESOLVED, decode_prompt_resolved) + dispatcher.register_event(STATE_CHANGED, decode_state_changed) dispatcher.register_event(WINDOW_CLOSE_REQUESTED, decode_window_close_requested) diff --git a/src/axidev_osk/runtime/identity.py b/src/axidev_osk/runtime/identity.py index aedf4a4..a0bbab7 100644 --- a/src/axidev_osk/runtime/identity.py +++ b/src/axidev_osk/runtime/identity.py @@ -30,24 +30,6 @@ def stable_id(parent_id: str, kind: str, *identity_fields: object, stable_overri return f"{kind}-{digest}" -def key_component_id( - parent_id: str, - kind: str, - *, - row: int, - column: int, - width: float, - height: int, - key_id: str | None, - io_key: str | None, - label: str, -) -> str: - """Return the deterministic component ID for a keyboard grid item.""" - - del key_id, io_key, label - return stable_id(parent_id, kind, row, column, width, height) - - def prompt_button_id(parent_id: str, role: str) -> str: """Return the deterministic component ID for a prompt action button.""" @@ -77,27 +59,3 @@ def validate_unique_ids(ids: Iterable[str], *, scope: str) -> None: if duplicates: duplicate_list = ", ".join(sorted(duplicates)) raise ValueError(f"Duplicate config IDs in {scope}: {duplicate_list}") - - -def state_namespace(kind: str, *identity_ids: str) -> str: - """Return a central-state namespace built from deterministic runtime IDs.""" - - return ":".join((kind, *identity_ids)) - - -def keyboard_key_states_namespace(layout_id: str) -> str: - """Return the key-state namespace for a deterministic keyboard layout ID.""" - - return state_namespace("keyboard.key_states", layout_id) - - -def keyboard_latches_namespace(layout_id: str) -> str: - """Return the latch-state namespace for a deterministic keyboard layout ID.""" - - return state_namespace("keyboard.latches", layout_id) - - -def component_state_namespace(component_id: str) -> str: - """Return the state namespace for a deterministic component ID.""" - - return state_namespace("component", component_id) diff --git a/src/axidev_osk/runtime/registries.py b/src/axidev_osk/runtime/registries.py index 3bdac13..d4b7883 100644 --- a/src/axidev_osk/runtime/registries.py +++ b/src/axidev_osk/runtime/registries.py @@ -21,10 +21,11 @@ if TYPE_CHECKING: from .context import Context from .dispatcher import Dispatcher + from .source import SourcePath ComponentBuilder = Callable[..., QWidget] -SurfaceBuilder = Callable[[SurfaceConfig, "Context"], QWidget] +SurfaceBuilder = Callable[[SurfaceConfig, "Context", "SourcePath"], QWidget] RuntimeT = TypeVar("RuntimeT") @@ -94,6 +95,7 @@ def build( config: ComponentConfig, context: "Context", *, + source_path: "SourcePath", host: QWidget | None = None, ) -> QWidget: """Build a component widget from config. @@ -115,7 +117,7 @@ def build( builder = self._builders.get(config.kind) if builder is None: raise ValueError(f"No component registered for kind {config.kind!r}") - return builder(config, context, host=host) + return builder(config, context, source_path=source_path, host=host) class SurfaceRegistry: @@ -152,7 +154,12 @@ def register(self, kind: str, builder: SurfaceBuilder) -> None: self._builders[kind] = builder - def build(self, config: SurfaceConfig, context: "Context") -> QWidget: + def build( + self, + config: SurfaceConfig, + context: "Context", + source_path: "SourcePath", + ) -> QWidget: """Build root window content from config. Args: @@ -169,7 +176,7 @@ def build(self, config: SurfaceConfig, context: "Context") -> QWidget: builder = self._builders.get(config.kind) if builder is None: raise ValueError(f"No surface registered for kind {config.kind!r}") - return builder(config, context) + return builder(config, context, source_path) class ServiceRegistry: diff --git a/src/axidev_osk/runtime/source.py b/src/axidev_osk/runtime/source.py new file mode 100644 index 0000000..ec751f8 --- /dev/null +++ b/src/axidev_osk/runtime/source.py @@ -0,0 +1,83 @@ +"""Structured runtime source paths used by events, behavior, and state.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +from ..messages import DataMap, DataValue +from .decoding import non_empty_string_value + + +@dataclass(frozen=True, slots=True) +class SourcePathSegment: + """One typed identity segment in a runtime source path.""" + + kind: str + id: str + + def __post_init__(self) -> None: + if not self.kind: + raise ValueError("Source path segment kind cannot be empty") + if not self.id: + raise ValueError("Source path segment ID cannot be empty") + + +@dataclass(frozen=True, slots=True) +class SourcePath: + """Ordered address of a configured runtime node.""" + + segments: tuple[SourcePathSegment, ...] + + def __post_init__(self) -> None: + if not self.segments: + raise ValueError("Source path cannot be empty") + + def child(self, kind: str, source_id: str) -> "SourcePath": + return SourcePath((*self.segments, SourcePathSegment(kind, source_id))) + + def through(self, kind: str) -> "SourcePath": + """Return this path through its last segment of ``kind``.""" + + for index in range(len(self.segments) - 1, -1, -1): + if self.segments[index].kind == kind: + return SourcePath(self.segments[: index + 1]) + raise ValueError(f"Source path has no {kind!r} segment") + + +def source_path_to_data(path: SourcePath) -> list[DataValue]: + """Encode a source path as queue-safe native data.""" + + return [{"kind": segment.kind, "id": segment.id} for segment in path.segments] + + +def source_path_from_data(value: DataValue) -> SourcePath: + """Decode a source path from queue-safe native data.""" + + if not isinstance(value, list): + raise TypeError("Source path must be a list") + segments: list[SourcePathSegment] = [] + for index, item in enumerate(value): + if not isinstance(item, dict): + raise TypeError(f"Source path segment {index} must be a map") + if set(item) != {"kind", "id"}: + raise ValueError(f"Source path segment {index} must contain exactly 'kind' and 'id'") + data: DataMap = item + segments.append( + SourcePathSegment( + kind=non_empty_string_value(data, "kind"), + id=non_empty_string_value(data, "id"), + ) + ) + return SourcePath(tuple(segments)) + + +def source_state_namespace(path: SourcePath) -> str: + """Return the central-state namespace for a source path.""" + + encoded = json.dumps( + source_path_to_data(path), + ensure_ascii=True, + separators=(",", ":"), + ) + return f"source:{encoded}" diff --git a/src/axidev_osk/runtime/testing.py b/src/axidev_osk/runtime/testing.py index ed7cbbc..d78c981 100644 --- a/src/axidev_osk/runtime/testing.py +++ b/src/axidev_osk/runtime/testing.py @@ -21,16 +21,15 @@ from ..services.keyboard import KeyboardService from ..messages import MessageResult from .actions import app_quit +from .behaviors import BehaviorRegistry, register_builtin_behaviors from .context import Context from .dispatcher import Dispatcher from .event_handlers import ( register_context_action_handlers, register_event_handlers, - route_component_pressed, route_hot_corner_triggered, ) from .events import ( - ComponentPressedArguments, HotCornerTriggeredArguments, WindowCloseRequestedArguments, register_builtin_events, @@ -76,10 +75,6 @@ def _handle_hot_corner_triggered(self, event: HotCornerTriggeredArguments) -> Me return route_hot_corner_triggered(event, self) - def _handle_component_pressed(self, event: ComponentPressedArguments) -> MessageResult: - """Route configured component actions through production helper.""" - - return route_component_pressed(event, self) def make_test_context( keyboard_backend: Any, @@ -87,8 +82,10 @@ def make_test_context( config: AppConfig | None = None, components: ComponentRegistry | None = None, surfaces: SurfaceRegistry | None = None, + behavior_registry: BehaviorRegistry | None = None, services: set[str] | None = None, event_handlers: bool = False, + activate_behaviors: bool = True, ) -> Context: """Build a runtime ``Context`` wrapping a test keyboard backend. @@ -110,10 +107,14 @@ def make_test_context( component builders are registered into it. surfaces: Optional pre-populated surface registry. Defaults to an empty registry. + behavior_registry: Optional pre-populated behavior registry. Defaults + to a fresh registry containing the built-in behavior kinds. services: Optional explicit service names to register and start. When omitted, only the supplied keyboard backend is bound. event_handlers: Whether to install bundled application-level event handler factories against a lightweight runtime adapter. + activate_behaviors: Whether to register configured outputs and publish + initial state snapshots. Focused service tests can disable this. Returns: A bound ``Context`` ready to pass into widgets and builders. @@ -125,6 +126,11 @@ def make_test_context( dispatcher = Dispatcher() register_builtin_events(dispatcher) keyboard = KeyboardService(cast(Any, keyboard_backend)) + resolved_config = config or build_default_app_config() + behaviors = behavior_registry or BehaviorRegistry() + if behavior_registry is None: + register_builtin_behaviors(behaviors) + behaviors.load(resolved_config) if components is None: # Lazy import: avoids pulling Qt-bound builders into modules # that import this helper purely for the Context type. @@ -133,16 +139,18 @@ def make_test_context( components = ComponentRegistry() register_components(components) context = Context( - config=config or build_default_app_config(), + config=resolved_config, dispatcher=dispatcher, keyboard=keyboard, state=StateStore(), components=components, surfaces=surfaces or SurfaceRegistry(), + behaviors=behaviors, ) context_handlers = EventHandlerRegistry() register_context_action_handlers(context_handlers) context_handlers.install(dispatcher, context) + behaviors.bind_context(context) if services is None: keyboard.bind_context(context) else: @@ -150,6 +158,8 @@ def make_test_context( register_services(service_registry, include=services, keyboard=keyboard) for service in service_registry.services(): service.start(context) + if activate_behaviors: + behaviors.activate() if event_handlers: handler_registry = EventHandlerRegistry() register_event_handlers(handler_registry) diff --git a/src/axidev_osk/services/keyboard/io.py b/src/axidev_osk/services/keyboard/io.py index 21a3ff9..efd2fae 100644 --- a/src/axidev_osk/services/keyboard/io.py +++ b/src/axidev_osk/services/keyboard/io.py @@ -8,9 +8,9 @@ from dataclasses import dataclass from pathlib import Path from threading import RLock -from typing import Any, Mapping +from typing import Any -from ...models import KeySpec +from ...runtime.behavior_models import KeyboardOutput from ...runtime.diagnostics import keyboard_debug_enabled _logger = logging.getLogger(__name__) @@ -32,6 +32,22 @@ } ) +_STATE_TAGS_BY_KEY = { + "shift": frozenset({"shift"}), + "shiftleft": frozenset({"shift"}), + "shiftright": frozenset({"shift"}), + "capslock": frozenset({"caps"}), + "ctrl": frozenset({"ctrl"}), + "ctrlleft": frozenset({"ctrl"}), + "ctrlright": frozenset({"ctrl"}), + "alt": frozenset({"alt"}), + "altleft": frozenset({"alt"}), + "altright": frozenset({"altgr"}), + "super": frozenset({"super"}), + "superleft": frozenset({"super"}), + "superright": frozenset({"super"}), +} + KeyStateListener = Callable[[str, bool], None] Unsubscribe = Callable[[], None] @@ -175,35 +191,37 @@ def is_key_down(self, key_name: str) -> bool: with self._key_state_lock: return canonical_name in self._pressed_key_names - def key_name_for_spec(self, spec: KeySpec) -> str | None: - """Resolve a key spec to a canonical backend key name.""" + def key_name_for_output(self, output: KeyboardOutput) -> str: + """Resolve keyboard output to a canonical backend key name.""" - key_name = self._resolve_key_name(spec) - if key_name is None: - return None - return self._canonical_key_name(key_name) + return self._canonical_key_name(output.output_key) or output.output_key - def key_down(self, spec: KeySpec, latched_keys: Mapping[str, bool]) -> KeyPressHandle | None: - """Emit a key press for ``spec`` and return a handle for release.""" + def state_tags_for_key(self, output_key: str) -> frozenset[str]: + """Return runtime state tags published by one backend key name.""" + + canonical = self._canonical_key_name(output_key) or output_key + return _STATE_TAGS_BY_KEY.get(canonical.casefold(), frozenset()) + + def key_down( + self, + output: KeyboardOutput, + active_state_tags: frozenset[str], + ) -> KeyPressHandle | None: + """Emit keyboard output and return a handle for release.""" if not self._ready or self._keyboard is None: return None - if spec.latchable and not spec.holds_when_latched: - return None try: - press = self._resolve_key_press(spec, latched_keys) - if press is None: - return None - - if spec.holds_when_latched: - self._debug_modifier("request-down", key_id=spec.key_id, press=self._describe_press(press)) + press = self._resolve_key_press(output, active_state_tags) + if _is_modifier_key_name(press.key_name): + self._debug_modifier("request-down", key_id=None, press=self._describe_press(press)) self._send_key_down(press) self._set_key_down(press.key_name, True) - if spec.holds_when_latched: - self._debug_modifier("press-active", key_id=spec.key_id, press=self._describe_press(press)) + if _is_modifier_key_name(press.key_name): + self._debug_modifier("press-active", key_id=None, press=self._describe_press(press)) return press except Exception as exc: - _logger.exception("axidev_io key_down failed for %r: %s", spec.label, exc) + _logger.exception("axidev_io key_down failed for %r: %s", output.output_key, exc) return None def key_up(self, press: object | None) -> None: @@ -225,13 +243,14 @@ def key_up(self, press: object | None) -> None: except Exception as exc: _logger.exception("axidev_io key_up failed for %r: %s", press.key_name, exc) - def _resolve_key_press(self, spec: KeySpec, latched_keys: Mapping[str, bool]) -> KeyPressHandle | None: - key_name = self._resolve_key_name(spec) - if key_name is None: - return None - - mods = self._resolve_sender_modifiers(spec, latched_keys) - return KeyPressHandle(key_name=key_name, mods=mods, repeats=spec.repeats) + def _resolve_key_press( + self, + output: KeyboardOutput, + active_state_tags: frozenset[str], + ) -> KeyPressHandle: + key_name = self.key_name_for_output(output) + mods = self._resolve_sender_modifiers(output, active_state_tags) + return KeyPressHandle(key_name=key_name, mods=mods, repeats=output.repeats) def _send_key_down(self, press: KeyPressHandle) -> None: if self._keyboard is None: @@ -257,13 +276,6 @@ def _describe_press(press: KeyPressHandle | None) -> str | None: return None return f"{press.key_name} mods={press.mods!r} repeat={press.repeats}" - def _resolve_key_name(self, spec: KeySpec) -> str | None: - if spec.io_key is not None: - return spec.io_key - if len(spec.label) == 1: - return spec.label - return None - def _canonical_key_name(self, key_name: str) -> str | None: if self._keyboard is None: return key_name @@ -277,18 +289,18 @@ def _canonical_key_name(self, key_name: str) -> str | None: def _resolve_sender_modifiers( self, - spec: KeySpec, - latched_keys: Mapping[str, bool], + output: KeyboardOutput, + active_state_tags: frozenset[str], ) -> str | None: - if not spec.honors_latched_modifiers: + if not output.uses_active_state_tags: return None - shift = bool(latched_keys.get("shift", False)) - caps = bool(latched_keys.get("caps", False)) + shift = "shift" in active_state_tags + caps = "caps" in active_state_tags shift_is_held = self.is_key_down("ShiftLeft") or self.is_key_down("ShiftRight") modifiers: list[str] = [] - if len(spec.label) == 1 and spec.label.isalpha(): + if len(output.output_key) == 1 and output.output_key.isalpha(): if (shift and not shift_is_held) ^ caps: modifiers.append("Shift") elif shift and not shift_is_held: diff --git a/src/axidev_osk/services/keyboard/service.py b/src/axidev_osk/services/keyboard/service.py index ecb3888..6b9be41 100644 --- a/src/axidev_osk/services/keyboard/service.py +++ b/src/axidev_osk/services/keyboard/service.py @@ -1,4 +1,4 @@ -"""Keyboard service boundary used by runtime actions and components.""" +"""Keyboard input/output service used by registered runtime actions.""" from __future__ import annotations @@ -7,9 +7,9 @@ from collections.abc import Callable from typing import TYPE_CHECKING -from ...models import KeySpec -from ...runtime.events import keyboard_key_registered, keyboard_key_state_changed, keyboard_latch_changed -from ...runtime.identity import keyboard_key_states_namespace, keyboard_latches_namespace +from ...runtime.behavior_models import KeyboardOutput +from ...runtime.events import keyboard_key_state_changed +from ...runtime.source import SourcePath from .io import AxidevIoKeyboardBackend if TYPE_CHECKING: @@ -21,102 +21,50 @@ class KeyboardService: - """Owns keyboard backend lifecycle and exposes action-friendly methods.""" + """Own backend lifecycle, output registration, and active press handles.""" def __init__(self, backend: AxidevIoKeyboardBackend | None = None) -> None: - """Create a keyboard service. - - Args: - backend: Optional backend, primarily for tests. - - Returns: - None. - - Side effects: - None until ``initialize`` is called. - """ - self._backend = backend or AxidevIoKeyboardBackend() self._shutdown = False self._context: Context | None = None - self._press_handles: dict[tuple[str, str], object | None] = {} - self._latched_keys: dict[tuple[str, str], bool] = {} - self._specs_by_key_name: dict[str, list[tuple[str, KeySpec]]] = {} - self._specs_by_component: dict[tuple[str, str], KeySpec] = {} - self._layouts: set[str] = set() + self._press_handles: dict[SourcePath, object | None] = {} + self._outputs_by_source: dict[SourcePath, KeyboardOutput] = {} + self._sources_by_key_name: dict[str, list[SourcePath]] = {} self._backend_listener_unsubscribe: Unsubscribe | None = None def bind_context(self, context: "Context") -> None: - """Bind the runtime context used for events and state updates.""" - self._context = context self._ensure_backend_listener() def start(self, context: "Context") -> None: - """Bind context and initialize keyboard output for runtime startup.""" - self.bind_context(context) self.initialize() def stop(self) -> None: - """Stop keyboard output through the generic service lifecycle.""" - self.shutdown() @property def ready(self) -> bool: - """Return whether keyboard output is available.""" - return self._backend.ready @property def status_text(self) -> str: - """Return the user-facing backend status text.""" - return self._backend.status_text @property def needs_permission_setup(self) -> bool: - """Return whether Linux input permissions need setup.""" - return self._backend.needs_permission_setup @property def permission_setup_text(self) -> str: - """Return user-facing Linux permission setup guidance.""" - return self._backend.permission_setup_text def initialize(self) -> bool: - """Initialize keyboard output. - - Args: - None. - - Returns: - ``True`` when keyboard output is ready. - - Side effects: - Initializes the backend and may start backend listeners. - """ - initialized = self._backend.initialize() self._ensure_backend_listener() return initialized def shutdown(self) -> None: - """Shut down keyboard output exactly once. - - Args: - None. - - Returns: - None. - - Side effects: - Releases latched keys and shuts down backend resources. - """ - if self._shutdown: _logger.info("Keyboard backend shutdown already completed") return @@ -127,97 +75,55 @@ def shutdown(self) -> None: self._backend.shutdown() _logger.info("Keyboard backend shutdown completed in %.3fs", time.perf_counter() - started_at) - def register_key_spec(self, layout_id: str, spec: KeySpec, *, component_id: str | None = None) -> str | None: - """Register a key spec for backend state updates and return its backend key name.""" - - key_name = self._backend.key_name_for_spec(spec) - state_key = self._state_key_for_spec(spec) - self._layouts.add(layout_id) - if component_id is not None: - self._specs_by_component[(layout_id, component_id)] = spec - if state_key is None: - return key_name - latched = self._is_spec_latched(layout_id, spec) - if spec.key_id is not None: - self._write_latch_state(layout_id, spec.key_id, latched) - if self._context is not None and self._context.state.get(keyboard_key_states_namespace(layout_id), state_key) is None: - self._write_key_state(layout_id, state_key, pressed=False, latched=latched) - if key_name is not None: - registrations = self._specs_by_key_name.setdefault(key_name, []) - registration = (layout_id, spec) - if registration not in registrations: - registrations.append(registration) - if self._backend.is_key_down(key_name): - self._emit_key_state(layout_id, state_key, pressed=True, latched=latched) - if component_id is not None and self._context is not None: - self._context.dispatcher.dispatch_event( - keyboard_key_registered(layout_id, component_id, key_name) - ) - return key_name + def register_output( + self, + source: SourcePath, + output: KeyboardOutput, + ) -> tuple[str, frozenset[str]]: + """Register backend output metadata and return canonical key metadata.""" + + key_name = self._backend.key_name_for_output(output) + state_tags = self._backend.state_tags_for_key(key_name) + self._outputs_by_source[source] = output + sources = self._sources_by_key_name.setdefault(key_name, []) + if source not in sources: + sources.append(source) + if self._backend.is_key_down(key_name): + self._emit_key_state(source, True, state_tags) + return key_name, state_tags + + def key_down( + self, + source: SourcePath, + active_state_tags: frozenset[str], + ) -> None: + """Emit registered output for one exact component source.""" + + output = self._registered_output(source) + press_handle = self._backend.key_down(output, active_state_tags) + if press_handle is None: + return + self._press_handles[source] = press_handle - def is_latched(self, layout_id: str, key_id: str) -> bool: - """Return the current latch state for a layout/key pair.""" + def key_up(self, source: SourcePath) -> None: + """Release the backend press associated with one exact source.""" - if (layout_id, key_id) in self._latched_keys: - return self._latched_keys[(layout_id, key_id)] - if self._context is None: - return False - return bool(self._context.state.get(keyboard_latches_namespace(layout_id), key_id, False)) + self._registered_output(source) + press_handle = self._press_handles.pop(source, None) + self._backend.key_up(press_handle) def reset_state(self) -> None: - """Reset keyboard-owned transient and durable state for profile/config reloads.""" + """Release active output and discard service-owned registration state.""" - layouts = set(self._layouts) - layouts.update(layout for layout, _key_id in self._press_handles) - layouts.update(layout for layout, _key_id in self._latched_keys) self._release_press_handles() - self._latched_keys.clear() - self._specs_by_key_name.clear() - self._specs_by_component.clear() - self._layouts.clear() - if self._context is None: - return - for layout_id in layouts: - self._context.state.clear_namespace(keyboard_key_states_namespace(layout_id)) - self._context.state.clear_namespace(keyboard_latches_namespace(layout_id)) + self._outputs_by_source.clear() + self._sources_by_key_name.clear() - def key_down(self, layout_id: str, component_id: str) -> None: - """Emit a key-down action through the backend.""" - - spec = self._registered_spec(layout_id, component_id) - latched_keys = self._latched_snapshot(layout_id) - press_handle = self._backend.key_down(spec, latched_keys) - state_key = self._state_key_for_spec(spec) - if state_key is not None and press_handle is not None: - self._press_handles[self._press_handle_key(layout_id, spec, state_key)] = press_handle - self._emit_key_state(layout_id, state_key, pressed=True, latched=self._is_spec_latched(layout_id, spec)) - - def key_up(self, layout_id: str, component_id: str) -> None: - """Emit a key-up action through the backend.""" - - spec = self._registered_spec(layout_id, component_id) - state_key = self._state_key_for_spec(spec) - latched = self._is_spec_latched(layout_id, spec) - press_handle = ( - self._press_handles.pop(self._press_handle_key(layout_id, spec, state_key), None) - if state_key is not None - else None - ) - self._backend.key_up(press_handle) - if state_key is not None: - self._emit_key_state( - layout_id, - state_key, - pressed=self._pressed_snapshot(spec, latched=latched), - latched=latched, - ) - - def sync_latched_key(self, layout_id: str, component_id: str, latched: bool) -> None: - """Synchronize logical latch state without changing backend activity.""" - - spec = self._registered_spec(layout_id, component_id) - if spec.key_id is not None: - self._set_latch_state(layout_id, spec.key_id, latched) + def _registered_output(self, source: SourcePath) -> KeyboardOutput: + output = self._outputs_by_source.get(source) + if output is None: + raise ValueError(f"No keyboard output registered for source {source!r}") + return output def _release_press_handles(self) -> None: for press_handle in tuple(self._press_handles.values()): @@ -225,80 +131,23 @@ def _release_press_handles(self) -> None: self._press_handles.clear() def _handle_backend_key_state_change(self, key_name: str, pressed: bool) -> None: - for layout_id, spec in self._specs_by_key_name.get(key_name, []): - key_id = self._state_key_for_spec(spec) - if key_id is None: - continue - self._emit_key_state(layout_id, key_id, pressed=pressed, latched=self._is_spec_latched(layout_id, spec)) - - def _set_latch_state(self, layout_id: str, key_id: str, latched: bool) -> None: - self._layouts.add(layout_id) - self._latched_keys[(layout_id, key_id)] = latched - self._write_latch_state(layout_id, key_id, latched) - if self._context is not None: - self._context.dispatcher.dispatch_event(keyboard_latch_changed(layout_id, key_id, latched)) - - def _emit_key_state(self, layout_id: str, key_id: str, *, pressed: bool, latched: bool) -> None: - self._write_key_state(layout_id, key_id, pressed=pressed, latched=latched) + state_tags = self._backend.state_tags_for_key(key_name) + for source in self._sources_by_key_name.get(key_name, []): + self._emit_key_state(source, pressed, state_tags) + + def _emit_key_state( + self, + source: SourcePath, + pressed: bool, + state_tags: frozenset[str], + ) -> None: if self._context is not None: self._context.dispatcher.dispatch_event( - keyboard_key_state_changed(layout_id, key_id, pressed, latched) - ) - - def _write_key_state(self, layout_id: str, key_id: str, *, pressed: bool, latched: bool) -> None: - if self._context is None: - return - self._context.state.set( - keyboard_key_states_namespace(layout_id), - key_id, - {"pressed": pressed, "latched": latched}, - ) - - def _write_latch_state(self, layout_id: str, key_id: str, latched: bool) -> None: - if self._context is not None: - self._context.state.set(keyboard_latches_namespace(layout_id), key_id, latched) - - def _latched_snapshot(self, layout_id: str) -> dict[str, bool]: - snapshot: dict[str, bool] = {} - if self._context is not None: - for _registered_layout, spec in self._registered_specs_for_layout(layout_id): - key_id = spec.key_id - if key_id is not None: - snapshot[key_id] = self.is_latched(layout_id, key_id) - for (latched_layout, key_id), latched in self._latched_keys.items(): - if latched_layout == layout_id: - snapshot[key_id] = latched - return snapshot - - def _registered_specs_for_layout(self, layout_id: str) -> list[tuple[str, KeySpec]]: - return [registration for registrations in self._specs_by_key_name.values() for registration in registrations if registration[0] == layout_id] - - def _registered_spec(self, layout_id: str, component_id: str) -> KeySpec: - spec = self._specs_by_component.get((layout_id, component_id)) - if spec is None: - raise ValueError( - f"No key specification registered for layout {layout_id!r}, component {component_id!r}" + keyboard_key_state_changed(source, pressed, state_tags) ) - return spec - - def _state_key_for_spec(self, spec: KeySpec) -> str | None: - return spec.io_key or spec.label or spec.key_id - - @staticmethod - def _press_handle_key(layout_id: str, spec: KeySpec, state_key: str) -> tuple[str, str]: - if spec.holds_when_latched and spec.key_id is not None: - return layout_id, spec.key_id - return layout_id, state_key - - def _is_spec_latched(self, layout_id: str, spec: KeySpec) -> bool: - return bool(spec.key_id is not None and self.is_latched(layout_id, spec.key_id)) - - def _pressed_snapshot(self, spec: KeySpec, *, latched: bool) -> bool: - if spec.holds_when_latched and latched: - return True - key_name = self._backend.key_name_for_spec(spec) - return self._backend.is_key_down(key_name) if key_name is not None else False def _ensure_backend_listener(self) -> None: if self._backend_listener_unsubscribe is None: - self._backend_listener_unsubscribe = self._backend.add_key_state_listener(self._handle_backend_key_state_change) + self._backend_listener_unsubscribe = self._backend.add_key_state_listener( + self._handle_backend_key_state_change + ) diff --git a/src/axidev_osk/windows/builder.py b/src/axidev_osk/windows/builder.py index 6e540f4..34fd8e4 100644 --- a/src/axidev_osk/windows/builder.py +++ b/src/axidev_osk/windows/builder.py @@ -8,6 +8,7 @@ from ..config.models import WindowConfig from ..runtime.context import Context +from ..runtime.config_paths import window_source_path from ..runtime.events import window_close_requested from .chrome import install_overlay_chrome from .overlay import configure_always_on_top_window, configure_plain_window @@ -51,7 +52,10 @@ def __init__(self, config: WindowConfig, context: Context, parent: QWidget | Non else: self._overlay = configure_plain_window(self) - central = context.surfaces.build(config.surface, context) + surface_path = window_source_path(context.config, config.id).child( + "surface", config.surface.id + ) + central = context.surfaces.build(config.surface, context, surface_path) if config.chrome.enabled and getattr(self._overlay, "uses_custom_chrome", False): central_layout = central.layout() if isinstance(central_layout, QVBoxLayout): diff --git a/src/axidev_osk/windows/surface.py b/src/axidev_osk/windows/surface.py index d01ee36..a790b43 100644 --- a/src/axidev_osk/windows/surface.py +++ b/src/axidev_osk/windows/surface.py @@ -8,6 +8,7 @@ from ..config.models import SurfaceConfig from ..runtime.context import Context from ..runtime.registries import SurfaceRegistry +from ..runtime.source import SourcePath def register_surfaces(registry: SurfaceRegistry) -> None: @@ -26,7 +27,11 @@ def register_surfaces(registry: SurfaceRegistry) -> None: registry.register("surface", build_surface) -def build_surface(config: SurfaceConfig, context: Context) -> QWidget: +def build_surface( + config: SurfaceConfig, + context: Context, + source_path: SourcePath, +) -> QWidget: """Build a generic root surface from child component configs. Args: @@ -50,6 +55,11 @@ def build_surface(config: SurfaceConfig, context: Context) -> QWidget: layout.setContentsMargins(*config.margins) layout.setSpacing(config.spacing) for component in config.components: - widget = context.components.build(component, context, host=central) + widget = context.components.build( + component, + context, + source_path=source_path.child("component", component.id), + host=central, + ) layout.addWidget(widget) return central diff --git a/tests/test_application_runtime.py b/tests/test_application_runtime.py index d3ab974..b261a03 100644 --- a/tests/test_application_runtime.py +++ b/tests/test_application_runtime.py @@ -42,10 +42,13 @@ class ApplicationRuntimePromptTests(unittest.TestCase): def test_linux_permission_prompt_has_one_setup_action(self) -> None: prompt = build_default_app_config().linux_permission_prompt - roles = [button.role for button in prompt.buttons] + button_ids = [button.id for button in prompt.buttons] - self.assertEqual(roles.count("open_terminal"), 1) - self.assertNotIn("setup_here", roles) + self.assertEqual( + button_ids.count("prompt:linux-permission:button:open_terminal"), + 1, + ) + self.assertFalse(any("setup_here" in button_id for button_id in button_ids)) def test_prompt_windows_remain_fully_opaque(self) -> None: config = build_default_app_config() diff --git a/tests/test_behaviors.py b/tests/test_behaviors.py new file mode 100644 index 0000000..26c0360 --- /dev/null +++ b/tests/test_behaviors.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import unittest +from dataclasses import replace +from types import SimpleNamespace + +from axidev_osk.config.defaults import build_default_app_config +from axidev_osk.config.models import BehaviorBinding, BehaviorConfig, BehaviorHook +from axidev_osk.messages import DataMap, MessageResult, RuntimeAction +from axidev_osk.runtime.behavior_models import HookDecision, HookOutcome, KeyboardOutput +from axidev_osk.runtime.behaviors import ( + KEYBOARD_KEY, + BehaviorInteraction, + BehaviorRegistry, + action_behavior, + action_hook, + register_builtin_behaviors, +) +from axidev_osk.runtime.events import ( + BEHAVIOR_FAILED, + COMPONENT_RELEASED, + BehaviorFailedArguments, + component_pressed, + component_released, +) +from axidev_osk.runtime.source import SourcePath +from axidev_osk.runtime.testing import make_test_context + + +class FakeKeyboardBackend: + ready = True + status_text = "ready" + needs_permission_setup = False + permission_setup_text = "" + + def __init__(self) -> None: + self.listeners = [] + self.pressed: set[str] = set() + self.down_calls: list[tuple[str, frozenset[str]]] = [] + self.up_calls: list[str] = [] + + def add_key_state_listener(self, listener): + self.listeners.append(listener) + return lambda: self.listeners.remove(listener) + + def key_name_for_output(self, output: KeyboardOutput) -> str: + return output.output_key + + def state_tags_for_key(self, output_key: str) -> frozenset[str]: + return { + "ShiftLeft": frozenset({"shift"}), + "ShiftRight": frozenset({"shift"}), + "CapsLock": frozenset({"caps"}), + }.get(output_key, frozenset()) + + def is_key_down(self, key_name: str) -> bool: + return key_name in self.pressed + + def key_down( + self, + output: KeyboardOutput, + active_state_tags: frozenset[str], + ) -> SimpleNamespace: + self.down_calls.append((output.output_key, active_state_tags)) + self._emit(output.output_key, True) + return SimpleNamespace(key_name=output.output_key) + + def key_up(self, handle) -> None: + if handle is not None: + self.up_calls.append(handle.key_name) + self._emit(handle.key_name, False) + + def _emit(self, key_name: str, pressed: bool) -> None: + if pressed: + self.pressed.add(key_name) + else: + self.pressed.discard(key_name) + for listener in tuple(self.listeners): + listener(key_name, pressed) + + +def _keyboard_source(config, output_key: str) -> SourcePath: + for binding in config.behaviors: + if binding.default.kind != KEYBOARD_KEY: + continue + output = binding.default.arguments.get("output") + if isinstance(output, dict) and output.get("output_key") == output_key: + return binding.target + raise AssertionError(f"No keyboard behavior emits {output_key!r}") + + +def _ghost_binding(config) -> BehaviorBinding: + for binding in config.behaviors: + for field in ("pressed_actions", "released_actions"): + actions = binding.default.arguments.get(field) + if isinstance(actions, list) and any( + isinstance(action, dict) and action.get("action") == "window.toggle_opacity" + for action in actions + ): + return binding + raise AssertionError("Ghost behavior was not found") + + +def _replace_binding(config, replacement: BehaviorBinding): + return replace( + config, + behaviors=tuple( + replacement if binding.target == replacement.target else binding + for binding in config.behaviors + ), + ) + + +def _decode_record(arguments: DataMap) -> str: + label = arguments.get("label") + if not isinstance(label, str): + raise TypeError("record label must be a string") + return label + + +def _record_action(label: str) -> RuntimeAction: + return RuntimeAction("test.record", {"label": label}) + + +class KeyboardBehaviorTests(unittest.TestCase): + def setUp(self) -> None: + self.backend = FakeKeyboardBackend() + self.config = build_default_app_config() + self.context = make_test_context(self.backend, config=self.config) + + def test_momentary_key_presses_and_releases_output(self) -> None: + source = _keyboard_source(self.config, "A") + + self.context.dispatcher.dispatch_event(component_pressed(source)) + self.assertEqual( + self.context.behaviors.state_snapshot(source), + {"pressed": True, "latched": False}, + ) + self.context.dispatcher.dispatch_event(component_released(source)) + + self.assertEqual(self.backend.down_calls, [("A", frozenset())]) + self.assertEqual(self.backend.up_calls, ["A"]) + self.assertEqual( + self.context.behaviors.state_snapshot(source), + {"pressed": False, "latched": False}, + ) + + def test_logical_toggle_taps_output_and_toggles_latched_state(self) -> None: + source = _keyboard_source(self.config, "CapsLock") + + self.context.dispatcher.dispatch_event(component_pressed(source)) + self.context.dispatcher.dispatch_event(component_released(source)) + + self.assertEqual(self.backend.down_calls, [("CapsLock", frozenset())]) + self.assertEqual(self.backend.up_calls, ["CapsLock"]) + self.assertEqual( + self.context.behaviors.state_snapshot(source), + {"pressed": False, "latched": True}, + ) + + self.context.dispatcher.dispatch_event(component_pressed(source)) + self.context.dispatcher.dispatch_event(component_released(source)) + self.assertEqual( + self.context.behaviors.state_snapshot(source), + {"pressed": False, "latched": False}, + ) + self.assertEqual(self.backend.up_calls, ["CapsLock", "CapsLock"]) + + def test_held_toggle_keeps_output_down_until_second_release(self) -> None: + source = _keyboard_source(self.config, "ShiftLeft") + layout = source.through("layout") + + self.context.dispatcher.dispatch_event(component_pressed(source)) + self.context.dispatcher.dispatch_event(component_released(source)) + + self.assertEqual(self.backend.down_calls, [("ShiftLeft", frozenset())]) + self.assertEqual(self.backend.up_calls, []) + self.assertEqual( + self.context.behaviors.state_snapshot(source), + {"pressed": False, "latched": True}, + ) + self.assertEqual(self.context.behaviors.active_state_tags(layout), frozenset({"shift"})) + + self.context.dispatcher.dispatch_event(component_pressed(source)) + self.context.dispatcher.dispatch_event(component_released(source)) + + self.assertEqual(self.backend.down_calls, [("ShiftLeft", frozenset())]) + self.assertEqual(self.backend.up_calls, ["ShiftLeft"]) + self.assertEqual( + self.context.behaviors.state_snapshot(source), + {"pressed": False, "latched": False}, + ) + self.assertEqual(self.context.behaviors.active_state_tags(layout), frozenset()) + + def test_active_layout_tags_are_passed_to_following_output(self) -> None: + shift = _keyboard_source(self.config, "ShiftLeft") + letter = _keyboard_source(self.config, "A") + self.context.dispatcher.dispatch_event(component_pressed(shift)) + self.context.dispatcher.dispatch_event(component_released(shift)) + + self.context.dispatcher.dispatch_event(component_pressed(letter)) + + self.assertEqual(self.backend.down_calls[-1], ("A", frozenset({"shift"}))) + + def test_left_and_right_modifiers_keep_independent_latches(self) -> None: + left = _keyboard_source(self.config, "ShiftLeft") + right = _keyboard_source(self.config, "ShiftRight") + layout = left.through("layout") + for source in (left, right): + self.context.dispatcher.dispatch_event(component_pressed(source)) + self.context.dispatcher.dispatch_event(component_released(source)) + + self.assertTrue(self.context.behaviors.state_snapshot(left)["latched"]) + self.assertTrue(self.context.behaviors.state_snapshot(right)["latched"]) + + self.context.dispatcher.dispatch_event(component_pressed(left)) + self.context.dispatcher.dispatch_event(component_released(left)) + + self.assertFalse(self.context.behaviors.state_snapshot(left)["latched"]) + self.assertTrue(self.context.behaviors.state_snapshot(right)["latched"]) + self.assertEqual(self.context.behaviors.active_state_tags(layout), frozenset({"shift"})) + + +class BehaviorHookTests(unittest.TestCase): + def _context_with_binding( + self, + binding: BehaviorBinding, + *, + registry: BehaviorRegistry | None = None, + ): + config = _replace_binding(build_default_app_config(), binding) + context = make_test_context( + FakeKeyboardBackend(), + config=config, + behavior_registry=registry, + ) + calls: list[str] = [] + context.dispatcher.register_action( + "test.record", + _decode_record, + lambda label: calls.append(label) or [], + ) + return context, calls + + def test_all_before_hooks_run_and_last_control_decision_wins(self) -> None: + original = _ghost_binding(build_default_app_config()) + binding = replace( + original, + default=action_behavior(released_actions=(_record_action("default"),)), + before_hooks=( + BehaviorHook( + frozenset({COMPONENT_RELEASED}), + True, + action_hook( + decision=HookDecision.CANCEL, + messages=(_record_action("before-1"),), + ), + ), + BehaviorHook( + frozenset({COMPONENT_RELEASED}), + True, + action_hook( + decision=HookDecision.REPLACE, + messages=(_record_action("before-2"),), + replacement=(_record_action("replacement"),), + ), + ), + ), + after_hooks=( + BehaviorHook( + frozenset({COMPONENT_RELEASED}), + False, + action_hook(messages=(_record_action("after"),)), + ), + ), + ) + context, calls = self._context_with_binding(binding) + + context.dispatcher.dispatch_event(component_released(binding.target)) + + self.assertEqual(calls, ["before-1", "before-2", "replacement", "after"]) + + def test_failing_before_hook_blocks_default_but_later_hooks_run(self) -> None: + registry = BehaviorRegistry() + register_builtin_behaviors(registry) + + def fail_hook( + decoded: object, + interaction: BehaviorInteraction, + owner: BehaviorRegistry, + ) -> HookOutcome: + del decoded, interaction, owner + raise RuntimeError("before broke") + + registry.register_hook("test.fail", lambda arguments: arguments, fail_hook) + original = _ghost_binding(build_default_app_config()) + binding = replace( + original, + default=action_behavior(released_actions=(_record_action("default"),)), + before_hooks=( + BehaviorHook( + frozenset({COMPONENT_RELEASED}), + True, + BehaviorConfig("test.fail", {}), + ), + BehaviorHook( + frozenset({COMPONENT_RELEASED}), + False, + action_hook(messages=(_record_action("later-before"),)), + ), + ), + ) + context, calls = self._context_with_binding(binding, registry=registry) + failures: list[BehaviorFailedArguments] = [] + context.dispatcher.add_event_handler( + BEHAVIOR_FAILED, + lambda event: failures.append(event) or [], + ) + + context.dispatcher.dispatch_event(component_released(binding.target)) + + self.assertEqual(calls, ["later-before"]) + self.assertEqual(len(failures), 1) + self.assertEqual(failures[0].phase, "before") + self.assertEqual(failures[0].message, "before broke") + + def test_failing_after_hook_reports_failure_after_default_effect(self) -> None: + registry = BehaviorRegistry() + register_builtin_behaviors(registry) + + def fail_hook( + decoded: object, + interaction: BehaviorInteraction, + owner: BehaviorRegistry, + ) -> HookOutcome: + del decoded, interaction, owner + raise RuntimeError("after broke") + + registry.register_hook("test.fail", lambda arguments: arguments, fail_hook) + original = _ghost_binding(build_default_app_config()) + binding = replace( + original, + default=action_behavior(released_actions=(_record_action("default"),)), + after_hooks=( + BehaviorHook( + frozenset({COMPONENT_RELEASED}), + False, + BehaviorConfig("test.fail", {}), + ), + ), + ) + context, calls = self._context_with_binding(binding, registry=registry) + failures: list[BehaviorFailedArguments] = [] + context.dispatcher.add_event_handler( + BEHAVIOR_FAILED, + lambda event: failures.append(event) or [], + ) + + context.dispatcher.dispatch_event(component_released(binding.target)) + + self.assertEqual(calls, ["default"]) + self.assertEqual(len(failures), 1) + self.assertEqual(failures[0].phase, "after") + + +class BehaviorValidationTests(unittest.TestCase): + def setUp(self) -> None: + self.config = build_default_app_config() + self.registry = BehaviorRegistry() + register_builtin_behaviors(self.registry) + + def test_missing_interactive_binding_fails_eagerly(self) -> None: + config = replace(self.config, behaviors=self.config.behaviors[1:]) + + with self.assertRaisesRegex(ValueError, "Interactive controls lack behavior"): + self.registry.load(config) + + def test_unresolved_binding_target_fails_eagerly(self) -> None: + binding = self.config.behaviors[0] + unresolved = replace( + binding, + target=binding.target.child("component", "missing"), + ) + config = replace( + self.config, + behaviors=(unresolved, *self.config.behaviors[1:]), + ) + + with self.assertRaisesRegex(ValueError, "Behavior targets do not resolve"): + self.registry.load(config) + + def test_unknown_behavior_kind_fails_eagerly(self) -> None: + binding = self.config.behaviors[0] + config = _replace_binding( + self.config, + replace(binding, default=BehaviorConfig("test.unknown", {})), + ) + + with self.assertRaisesRegex(ValueError, "Unknown behavior kind 'test.unknown'"): + self.registry.load(config) + + def test_duplicate_binding_target_fails_at_root_config_boundary(self) -> None: + with self.assertRaisesRegex(ValueError, "Duplicate config IDs in app behavior targets"): + replace( + self.config, + behaviors=(*self.config.behaviors, self.config.behaviors[0]), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hot_corner_events.py b/tests/test_hot_corner_events.py index 8f8b510..5628f1a 100644 --- a/tests/test_hot_corner_events.py +++ b/tests/test_hot_corner_events.py @@ -14,9 +14,12 @@ from axidev_osk.hot_corner.controller import HotCornerWindowToggleController, ScreenCorner from axidev_osk.runtime.application import ApplicationRuntime from axidev_osk.runtime.actions import ( + WINDOW_TOGGLE_OPACITY, WINDOW_SHOW, WindowArguments, + WindowToggleOpacityArguments, decode_window, + decode_window_toggle_opacity, window_hide, window_show, window_toggle_opacity, @@ -25,9 +28,11 @@ HOT_CORNER_TRIGGERED, HotCornerTriggeredArguments, component_pressed, + component_released, decode_component_pressed, hot_corner_triggered, ) +from axidev_osk.runtime.config_paths import surface_source_path from axidev_osk.runtime.testing import make_test_context from axidev_osk.windows.overlay.always_on_top import OverlayBackend @@ -59,23 +64,27 @@ def add_key_state_listener(self, listener): del listener return lambda: None - def key_down(self, spec): - del spec + def key_down(self, output, active_state_tags): + del output, active_state_tags return SimpleNamespace(name="press") def key_up(self, handle) -> None: del handle - def sync_latched_key(self, spec, latched: bool): - del spec, latched - return None - def is_key_down(self, key_name: str) -> bool: del key_name return False - def key_name_for_spec(self, spec) -> str | None: - return getattr(spec, "io_key", None) + def key_name_for_output(self, output) -> str: + return output.output_key + + def state_tags_for_key(self, output_key: str) -> frozenset[str]: + tags = { + "ShiftLeft": frozenset({"shift"}), + "ShiftRight": frozenset({"shift"}), + "CapsLock": frozenset({"caps"}), + } + return tags.get(output_key, frozenset()) class HotCornerEventTests(unittest.TestCase): @@ -242,22 +251,44 @@ def record_action(arguments: WindowArguments) -> MessageResult: def test_component_action_dispatches_configured_window_opacity_command(self) -> None: context = make_test_context(FakeKeyboardBackend()) + window = context.config.windows[0] + keyboard = window.surface.components[0] + grid = keyboard.layout.grids[0] ghost = next( component - for component in context.config.windows[0].surface.components[0].layout.grids[0].components - if component.spec.label == "Ghost" + for component in grid.components + if component.visual.label == "Ghost" ) - runtime = ApplicationRuntime.__new__(ApplicationRuntime) - runtime._dispatcher = context.dispatcher + source = ( + surface_source_path(context.config, window.id, window.surface.id) + .child("component", keyboard.id) + .child("layout", keyboard.layout.id) + .child("grid", grid.id) + .child("component", ghost.id) + ) + actions: list[WindowToggleOpacityArguments] = [] + + def record_action(arguments: WindowToggleOpacityArguments) -> MessageResult: + actions.append(arguments) + return [] - event = component_pressed(ghost.id, ghost.spec.action) - arguments = decode_component_pressed(event.arguments) - actions = runtime._handle_component_pressed(arguments) + context.dispatcher.register_action( + WINDOW_TOGGLE_OPACITY, + decode_window_toggle_opacity, + record_action, + override=True, + ) + context.dispatcher.dispatch_event(component_pressed(source)) + context.dispatcher.dispatch_event(component_released(source)) self.assertEqual( actions, [ - window_toggle_opacity("window:keyboard", ghost.id, 0.01) + WindowToggleOpacityArguments( + window_id="window:keyboard", + component_id=ghost.id, + opacity=0.01, + ) ], ) diff --git a/tests/test_key_state_listener.py b/tests/test_key_state_listener.py index d0eb093..c7de091 100644 --- a/tests/test_key_state_listener.py +++ b/tests/test_key_state_listener.py @@ -7,12 +7,12 @@ from PySide6.QtWidgets import QApplication, QPushButton from axidev_osk.components.button.key import create_key_button -from axidev_osk.components.button.state import KeyInteractionState, KeyStateMachine from axidev_osk.components.grid.keyboard import KeyboardWidget -from axidev_osk.config.defaults.us_iso import build_us_iso_layout_config -from axidev_osk.services.keyboard.io import AxidevIoKeyboardBackend -from axidev_osk.models import KeySpec +from axidev_osk.config.defaults import build_default_app_config +from axidev_osk.runtime.behavior_models import KeyboardOutput +from axidev_osk.runtime.config_paths import surface_source_path from axidev_osk.runtime.testing import make_test_context +from axidev_osk.services.keyboard.io import AxidevIoKeyboardBackend def _app() -> QApplication: @@ -47,6 +47,11 @@ def __init__(self) -> None: class FakeWidgetKeyboardBackend: + ready = True + status_text = "ready" + needs_permission_setup = False + permission_setup_text = "" + def __init__(self, pressed_key_names: set[str] | None = None) -> None: self._pressed_key_names = pressed_key_names or set() self._listeners = [] @@ -62,17 +67,28 @@ def unsubscribe() -> None: def is_key_down(self, key_name: str) -> bool: return key_name in self._pressed_key_names - def key_name_for_spec(self, spec: KeySpec) -> str | None: - return spec.io_key or (spec.label if len(spec.label) == 1 else None) - - def key_down(self, spec: KeySpec, latched_keys): - return SimpleNamespace(spec=spec) + def key_name_for_output(self, output: KeyboardOutput) -> str: + return output.output_key + + def state_tags_for_key(self, output_key: str) -> frozenset[str]: + return { + "ShiftLeft": frozenset({"shift"}), + "ShiftRight": frozenset({"shift"}), + "CapsLock": frozenset({"caps"}), + }.get(output_key, frozenset()) + + def key_down( + self, + output: KeyboardOutput, + active_state_tags: frozenset[str], + ) -> SimpleNamespace: + del active_state_tags + self.emit_key_state(output.output_key, True) + return SimpleNamespace(key_name=output.output_key) def key_up(self, press_handle) -> None: - return None - - def sync_latched_key(self, spec: KeySpec, latched: bool, press_handle=None): - return press_handle + if press_handle is not None: + self.emit_key_state(press_handle.key_name, False) def emit_key_state(self, key_name: str, pressed: bool) -> None: if pressed: @@ -85,31 +101,6 @@ def emit_key_state(self, key_name: str, pressed: bool) -> None: class KeyStateListenerTests(unittest.TestCase): - def test_latchable_release_hands_pressed_state_directly_to_latched(self) -> None: - machine = KeyStateMachine(latchable=True) - changes = [] - machine.add_listener(changes.append) - - machine.press() - changes.clear() - machine.release_and_toggle_latched() - - self.assertEqual(machine.state, KeyInteractionState.LATCHED) - self.assertTrue(machine.is_active) - self.assertEqual(len(changes), 1) - self.assertEqual(changes[0].previous, KeyInteractionState.PRESSED) - self.assertEqual(changes[0].current, KeyInteractionState.LATCHED) - - machine.press() - changes.clear() - machine.release_and_toggle_latched() - - self.assertEqual(machine.state, KeyInteractionState.IDLE) - self.assertFalse(machine.is_active) - self.assertEqual(len(changes), 1) - self.assertEqual(changes[0].previous, KeyInteractionState.LATCHED_PRESSED) - self.assertEqual(changes[0].current, KeyInteractionState.IDLE) - def test_backend_listener_updates_pressed_key_registry(self) -> None: backend = AxidevIoKeyboardBackend() fake_listener = FakeNativeListener() @@ -152,7 +143,7 @@ def test_sent_key_updates_registry_immediately_before_listener_echo(self) -> Non ) fake_module = ModuleType("axidev_io") fake_module.keyboard = fake_keyboard - spec = KeySpec(label="A", row=0, column=0) + output = KeyboardOutput("A") events: list[tuple[str, bool]] = [] with patch.dict("sys.modules", {"axidev_io": fake_module}): @@ -162,7 +153,7 @@ def test_sent_key_updates_registry_immediately_before_listener_echo(self) -> Non lambda key_name, pressed: events.append((key_name, pressed)) ) - press = backend.key_down(spec, {}) + press = backend.key_down(output, frozenset()) self.assertIsNotNone(press) self.assertTrue(backend.is_key_down("A")) self.assertEqual(events, [("A", True)]) @@ -177,14 +168,29 @@ def test_sent_key_updates_registry_immediately_before_listener_echo(self) -> Non fake_listener.callback(SimpleNamespace(key_name="A", pressed=False)) self.assertEqual(events, [("A", True), ("A", False)]) - def test_keyboard_widget_reflects_backend_key_state_for_sent_io_key(self) -> None: + def test_keyboard_widget_renders_initial_and_updated_backend_state(self) -> None: _app() backend = FakeWidgetKeyboardBackend(pressed_key_names={"A"}) - context = make_test_context(backend) - widget = KeyboardWidget(layout_config=build_us_iso_layout_config(), context=context) + config = build_default_app_config() + context = make_test_context(backend, config=config) + window = config.windows[0] + keyboard_config = window.surface.components[0] + source = surface_source_path(config, window.id, window.surface.id).child( + "component", keyboard_config.id + ) + widget = KeyboardWidget( + layout_config=keyboard_config.layout, + context=context, + source_path=source, + ) self.addCleanup(widget.close) + a_config = next( + component + for component in keyboard_config.layout.grids[0].components + if component.visual.label == "a" + ) + button = self._button_for_component(widget, a_config.id) - button = self._button_for_io_key(widget, "A") self.assertEqual(button.property("interactionState"), "pressed") backend.emit_key_state("A", False) @@ -195,17 +201,46 @@ def test_keyboard_widget_reflects_backend_key_state_for_sent_io_key(self) -> Non QApplication.processEvents() self.assertEqual(button.property("interactionState"), "pressed") - def test_key_button_runs_release_callback_immediately(self) -> None: + def test_keyboard_widget_renders_legends_from_layout_state_tags(self) -> None: + _app() + backend = FakeWidgetKeyboardBackend() + config = build_default_app_config() + context = make_test_context(backend, config=config) + window = config.windows[0] + keyboard_config = window.surface.components[0] + source = surface_source_path(config, window.id, window.surface.id).child( + "component", keyboard_config.id + ) + widget = KeyboardWidget( + layout_config=keyboard_config.layout, + context=context, + source_path=source, + ) + self.addCleanup(widget.close) + a_config = next( + component + for component in keyboard_config.layout.grids[0].components + if component.visual.label == "a" + ) + button = self._button_for_component(widget, a_config.id) + self.assertEqual(button.text(), "a") + + backend.emit_key_state("ShiftLeft", True) + QApplication.processEvents() + self.assertEqual(button.text(), "A") + + backend.emit_key_state("ShiftLeft", False) + QApplication.processEvents() + self.assertEqual(button.text(), "a") + + def test_key_button_runs_release_callback_without_owning_state(self) -> None: _app() calls: list[str] = [] - state_machine = KeyStateMachine() - key_button = create_key_button( + button = create_key_button( "A", - state_machine=state_machine, component_id="component:test-key", on_release=lambda: calls.append("released"), ) - button = key_button.button self.addCleanup(button.close) button.pressed.emit() @@ -214,11 +249,11 @@ def test_key_button_runs_release_callback_immediately(self) -> None: self.assertEqual(button.property("interactionState"), "idle") self.assertEqual(calls, ["released"]) - def _button_for_io_key(self, widget: KeyboardWidget, io_key_name: str) -> QPushButton: + def _button_for_component(self, widget: KeyboardWidget, component_id: str) -> QPushButton: for button in widget.findChildren(QPushButton): - if button.property("ioKeyName") == io_key_name: + if button.property("componentId") == component_id: return button - raise AssertionError(f"button for {io_key_name!r} was not found") + raise AssertionError(f"button for {component_id!r} was not found") if __name__ == "__main__": diff --git a/tests/test_keyboard_io_repeat.py b/tests/test_keyboard_io_repeat.py index 4614fc5..5f15d63 100644 --- a/tests/test_keyboard_io_repeat.py +++ b/tests/test_keyboard_io_repeat.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch -from axidev_osk.models import KeySpec +from axidev_osk.runtime.behavior_models import KeyboardOutput from axidev_osk.runtime.diagnostics import KEYBOARD_DEBUG_ENV from axidev_osk.services.keyboard.io import AxidevIoKeyboardBackend @@ -14,40 +14,35 @@ class KeyboardIoRepeatTests(unittest.TestCase): def test_key_down_sends_repeat_by_default(self) -> None: backend, sender = self._ready_backend() - backend.key_down(KeySpec("A", row=0, column=0, io_key="A"), {}) + backend.key_down(KeyboardOutput("A"), frozenset()) sender.key_down.assert_called_once_with("A", repeat=True) - def test_key_down_can_disable_repeat_from_key_spec(self) -> None: + def test_key_down_can_disable_repeat_from_output(self) -> None: backend, sender = self._ready_backend() - backend.key_down(KeySpec("A", row=0, column=0, io_key="A", repeats=False), {}) + backend.key_down(KeyboardOutput("A", repeats=False), frozenset()) sender.key_down.assert_called_once_with("A", repeat=False) def test_key_down_preserves_repeat_flag_with_modifiers(self) -> None: backend, sender = self._ready_backend() - backend.key_down(KeySpec("a", row=0, column=0, io_key="A"), {"shift": True}) + backend.key_down(KeyboardOutput("A"), frozenset({"shift"})) sender.key_down.assert_called_once_with("A", mods="Shift", repeat=True) def test_modifier_trace_records_transitions_without_typed_keys(self) -> None: backend, _sender = self._ready_backend() - shift = KeySpec( - "Shift", - row=0, - column=0, - key_id="shift", - io_key="ShiftLeft", - latchable=True, - holds_when_latched=True, + shift = KeyboardOutput( + "ShiftLeft", repeats=False, + uses_active_state_tags=False, ) with patch.dict(environ, {KEYBOARD_DEBUG_ENV: "1"}, clear=False): with self.assertLogs("axidev_osk.services.keyboard.io", level="INFO") as logs: - press = backend.key_down(shift, {}) + press = backend.key_down(shift, frozenset()) backend.key_up(press) trace = "\n".join(logs.output) diff --git a/tests/test_keyboard_metrics.py b/tests/test_keyboard_metrics.py index 1bfdbeb..af282ff 100644 --- a/tests/test_keyboard_metrics.py +++ b/tests/test_keyboard_metrics.py @@ -1,14 +1,21 @@ from __future__ import annotations +import unittest + from axidev_osk.components.grid import DEFAULT_KEYBOARD_METRICS -def test_keyboard_metrics_match_compact_layout_defaults() -> None: - metrics = DEFAULT_KEYBOARD_METRICS +class KeyboardMetricsTests(unittest.TestCase): + def test_metrics_match_compact_layout_defaults(self) -> None: + metrics = DEFAULT_KEYBOARD_METRICS + + self.assertEqual(metrics.key_unit_px, 48) + self.assertEqual(metrics.grid_gap_px, 4) + self.assertEqual(metrics.span_width(1.0), 48) + self.assertEqual(metrics.span_width(2.25), 108) + self.assertEqual(metrics.span_height(1), 48) + self.assertEqual(metrics.span_height(2), 100) + - assert metrics.key_unit_px == 48 - assert metrics.grid_gap_px == 4 - assert metrics.span_width(1.0) == 48 - assert metrics.span_width(2.25) == 108 - assert metrics.span_height(1) == 48 - assert metrics.span_height(2) == 100 +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_keyboard_service.py b/tests/test_keyboard_service.py index 746d5d9..722044a 100644 --- a/tests/test_keyboard_service.py +++ b/tests/test_keyboard_service.py @@ -1,393 +1,221 @@ from __future__ import annotations import unittest -from types import SimpleNamespace -from unittest.mock import Mock - -from PySide6.QtWidgets import QApplication, QPushButton - -from axidev_osk.components.grid.keyboard import KeyboardWidget -from axidev_osk.config.defaults.us_iso import build_us_iso_layout_config -from axidev_osk.messages import MessageResult, RuntimeAction -from axidev_osk.models import KeySpec -from axidev_osk.runtime.actions import ( - keyboard_key_down, - keyboard_sync_latched_key, - window_toggle_opacity, -) +from dataclasses import dataclass + +from axidev_osk.messages import MessageResult +from axidev_osk.runtime.behavior_models import KeyboardOutput from axidev_osk.runtime.events import ( - COMPONENT_PRESSED, KEYBOARD_KEY_STATE_CHANGED, - KEYBOARD_LATCH_CHANGED, - ComponentPressedArguments, KeyboardKeyStateChangedArguments, - KeyboardLatchChangedArguments, ) -from axidev_osk.runtime.identity import keyboard_key_states_namespace, keyboard_latches_namespace +from axidev_osk.runtime.source import SourcePath, SourcePathSegment from axidev_osk.runtime.testing import make_test_context -LAYOUT_ID = "layout:us-iso" - -def _app() -> QApplication: - app = QApplication.instance() - if app is None: - app = QApplication([]) - return app +@dataclass(frozen=True) +class PressHandle: + key_name: str class FakeKeyboardBackend: - def __init__(self, pressed_key_names: set[str] | None = None) -> None: - self.ready = True - self.status_text = "ready" - self.needs_permission_setup = False - self.permission_setup_text = "" - self._pressed_key_names = pressed_key_names or set() - self._listeners = [] - self.key_down = Mock(return_value=SimpleNamespace(name="press")) - self.key_up = Mock() - self.sync_latched_key = Mock(return_value=None) + ready = True + status_text = "ready" + needs_permission_setup = False + permission_setup_text = "" + + def __init__(self) -> None: + self.initialize_calls = 0 + self.shutdown_calls = 0 + self.listeners = [] + self.pressed: set[str] = set() + self.down_calls: list[tuple[KeyboardOutput, frozenset[str]]] = [] + self.up_calls: list[object | None] = [] def initialize(self) -> bool: + self.initialize_calls += 1 return True def shutdown(self) -> None: - return None + self.shutdown_calls += 1 def add_key_state_listener(self, listener): - self._listeners.append(listener) + self.listeners.append(listener) def unsubscribe() -> None: - self._listeners.remove(listener) + self.listeners.remove(listener) return unsubscribe - def is_key_down(self, key_name: str) -> bool: - return key_name in self._pressed_key_names + def key_name_for_output(self, output: KeyboardOutput) -> str: + return output.output_key.casefold() - def key_name_for_spec(self, spec: KeySpec) -> str | None: - return spec.io_key or (spec.label if len(spec.label) == 1 else None) + def state_tags_for_key(self, output_key: str) -> frozenset[str]: + return { + "shiftleft": frozenset({"shift"}), + "capslock": frozenset({"caps"}), + }.get(output_key.casefold(), frozenset()) - def emit_key_state(self, key_name: str, pressed: bool) -> None: + def is_key_down(self, key_name: str) -> bool: + return key_name in self.pressed + + def key_down( + self, + output: KeyboardOutput, + active_state_tags: frozenset[str], + ) -> PressHandle: + self.down_calls.append((output, active_state_tags)) + key_name = self.key_name_for_output(output) + self.emit(key_name, True) + return PressHandle(key_name) + + def key_up(self, handle: object | None) -> None: + self.up_calls.append(handle) + if isinstance(handle, PressHandle): + self.emit(handle.key_name, False) + + def emit(self, key_name: str, pressed: bool) -> None: if pressed: - self._pressed_key_names.add(key_name) + self.pressed.add(key_name) else: - self._pressed_key_names.discard(key_name) - for listener in tuple(self._listeners): + self.pressed.discard(key_name) + for listener in tuple(self.listeners): listener(key_name, pressed) -class KeyboardServiceTests(unittest.TestCase): - def test_runtime_actions_reject_non_namespaced_names(self) -> None: - with self.assertRaisesRegex(ValueError, "dot-separated"): - RuntimeAction(action="unknown", arguments={}) - - def test_action_keys_reject_keyboard_behavior(self) -> None: - action = window_toggle_opacity("window:keyboard", "key:ghost", 0.01) - - with self.assertRaisesRegex(ValueError, "keyboard output or latch behavior"): - KeySpec(label="Ghost", row=0, column=0, io_key="A", repeats=False, action=action) - with self.assertRaisesRegex(ValueError, "cannot repeat"): - KeySpec(label="Ghost", row=0, column=0, action=action) - - def test_ghost_key_emits_action_event_without_keyboard_output(self) -> None: - _app() - backend = FakeKeyboardBackend() - context = make_test_context(backend) - events: list[ComponentPressedArguments] = [] - - def record(event: ComponentPressedArguments) -> MessageResult: - events.append(event) - return [] - - context.dispatcher.add_event_handler(COMPONENT_PRESSED, record) - widget = KeyboardWidget(layout_config=build_us_iso_layout_config(), context=context) - self.addCleanup(widget.close) - ghost = next( - button - for button in widget.findChildren(QPushButton) - if button.text() == "Ghost" +def _source(component_id: str) -> SourcePath: + return SourcePath( + ( + SourcePathSegment("app", "axidev-osk"), + SourcePathSegment("profile", "default"), + SourcePathSegment("window", "keyboard"), + SourcePathSegment("surface", "keyboard"), + SourcePathSegment("component", "keyboard-grid"), + SourcePathSegment("layout", "us-iso"), + SourcePathSegment("grid", "main"), + SourcePathSegment("component", component_id), ) + ) - ghost.click() - - self.assertEqual(len(events), 1) - self.assertEqual(events[0].component_id, ghost.property("componentId")) - self.assertIsNotNone(events[0].action) - backend.key_down.assert_not_called() - backend.key_up.assert_not_called() - def test_service_emits_backend_key_state_changed_on_backend_update(self) -> None: - backend = FakeKeyboardBackend() - context = make_test_context(backend, services={"keyboard"}) - spec = KeySpec(label="A", row=0, column=0, io_key="A") - events: list[KeyboardKeyStateChangedArguments] = [] +class KeyboardServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.backend = FakeKeyboardBackend() + self.context = make_test_context( + self.backend, + activate_behaviors=False, + ) + self.service = self.context.keyboard + self.events: list[KeyboardKeyStateChangedArguments] = [] def record(event: KeyboardKeyStateChangedArguments) -> MessageResult: - events.append(event) + self.events.append(event) return [] - context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, record) + self.context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, record) - context.keyboard.register_key_spec(LAYOUT_ID, spec) - backend.emit_key_state("A", True) + def test_start_initializes_backend_and_listener_once(self) -> None: + self.service.start(self.context) + self.service.bind_context(self.context) - self.assertEqual(events, [KeyboardKeyStateChangedArguments(layout_id=LAYOUT_ID, key_id="A", pressed=True, latched=False)]) + self.assertEqual(self.backend.initialize_calls, 1) + self.assertEqual(len(self.backend.listeners), 1) - def test_service_writes_keyboard_key_state_namespace(self) -> None: - backend = FakeKeyboardBackend() - context = make_test_context(backend) - spec = KeySpec(label="A", row=0, column=0, io_key="A") + def test_register_output_returns_canonical_name_and_state_tags(self) -> None: + source = _source("shift-left") - context.keyboard.register_key_spec(LAYOUT_ID, spec) - backend.emit_key_state("A", True) - - self.assertEqual( - context.state.get(keyboard_key_states_namespace(LAYOUT_ID), "A"), - {"pressed": True, "latched": False}, + metadata = self.service.register_output( + source, + KeyboardOutput("ShiftLeft", repeats=False), ) - def test_widget_renders_from_snapshot_without_backend_press(self) -> None: - _app() - backend = FakeKeyboardBackend() - context = make_test_context(backend) - context.state.set(keyboard_key_states_namespace(LAYOUT_ID), "A", {"pressed": True, "latched": False}) - - widget = KeyboardWidget(layout_config=build_us_iso_layout_config(), context=context) - self.addCleanup(widget.close) - - button = self._button_for_io_key(widget, "A") - self.assertEqual(button.property("interactionState"), "pressed") - backend.key_down.assert_not_called() - backend.key_up.assert_not_called() - backend.sync_latched_key.assert_not_called() - - def test_service_emits_key_latch_changed_on_latch_toggle(self) -> None: - backend = FakeKeyboardBackend() - context = make_test_context(backend) - spec = KeySpec(label="Shift", row=0, column=0, key_id="shift", io_key="leftshift", latchable=True) - events: list[KeyboardLatchChangedArguments] = [] - - def record(event: KeyboardLatchChangedArguments) -> MessageResult: - events.append(event) - return [] - - context.dispatcher.add_event_handler(KEYBOARD_LATCH_CHANGED, record) - context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:shift") - context.dispatcher.dispatch_action(keyboard_sync_latched_key(LAYOUT_ID, "key:shift", True)) + self.assertEqual(metadata, ("shiftleft", frozenset({"shift"}))) - self.assertEqual(events, [KeyboardLatchChangedArguments(layout_id=LAYOUT_ID, key_id="shift", latched=True)]) - self.assertTrue(context.state.get(keyboard_latches_namespace(LAYOUT_ID), "shift")) + def test_register_output_publishes_existing_backend_state(self) -> None: + source = _source("caps-lock") + self.backend.pressed.add("capslock") - def test_non_held_latch_does_not_emit_backend_pressed_state(self) -> None: - backend = FakeKeyboardBackend() - context = make_test_context(backend) - spec = KeySpec(label="Caps", row=0, column=0, key_id="caps", io_key="capslock", latchable=True) - events: list[KeyboardKeyStateChangedArguments] = [] - - def record(event: KeyboardKeyStateChangedArguments) -> MessageResult: - events.append(event) - return [] + self.service.register_output(source, KeyboardOutput("CapsLock")) - context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, record) - context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:caps") - context.dispatcher.dispatch_action(keyboard_sync_latched_key(LAYOUT_ID, "key:caps", True)) - - self.assertEqual(events, []) - self.assertTrue(context.state.get(keyboard_latches_namespace(LAYOUT_ID), "caps")) - - def test_held_latch_uses_one_backend_press_until_unlatched(self) -> None: - _app() - backend = FakeKeyboardBackend() - context = make_test_context(backend) - widget = KeyboardWidget(layout_config=build_us_iso_layout_config(), context=context) - self.addCleanup(widget.close) - button = self._button_for_key_id(widget, "shift") - - button.pressed.emit() - button.released.emit() - - backend.key_down.assert_called_once() - backend.key_up.assert_not_called() - backend.sync_latched_key.assert_not_called() - self.assertEqual(button.property("interactionState"), "latched") - - button.pressed.emit() - button.released.emit() - - backend.key_down.assert_called_once() - backend.key_up.assert_called_once_with(backend.key_down.return_value) - self.assertEqual(button.property("interactionState"), "idle") - - def test_reset_state_releases_active_press_handles(self) -> None: - backend = FakeKeyboardBackend() - context = make_test_context(backend) - spec = KeySpec(label="Shift", row=0, column=0, key_id="shift", io_key="leftshift", holds_when_latched=True) - - context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:shift") - context.dispatcher.dispatch_action(keyboard_key_down(LAYOUT_ID, "key:shift")) - context.keyboard.reset_state() - - backend.key_up.assert_called_once_with(backend.key_down.return_value) - - def test_shared_latch_sibling_releases_original_backend_press(self) -> None: - _app() - backend = FakeKeyboardBackend() - context = make_test_context(backend) - widget = KeyboardWidget(layout_config=build_us_iso_layout_config(), context=context) - self.addCleanup(widget.close) - left_shift = self._button_for_io_key(widget, "ShiftLeft") - right_shift = self._button_for_io_key(widget, "ShiftRight") - - left_shift.pressed.emit() - left_shift.released.emit() - right_shift.pressed.emit() - right_shift.released.emit() - - backend.key_down.assert_called_once() - backend.key_up.assert_called_once_with(backend.key_down.return_value) - self.assertEqual(left_shift.property("interactionState"), "idle") - self.assertEqual(right_shift.property("interactionState"), "idle") - - def test_ctrl_shift_left_right_sequences_release_original_backend_presses(self) -> None: - _app() - - for ctrl_name in ("CtrlLeft", "CtrlRight"): - for shift_name in ("ShiftLeft", "ShiftRight"): - with self.subTest(ctrl=ctrl_name, shift=shift_name): - backend = FakeKeyboardBackend() - handles: list[SimpleNamespace] = [] - presses: list[tuple[str | None, dict[str, bool]]] = [] - - def key_down(spec: KeySpec, latched_keys: dict[str, bool]) -> SimpleNamespace: - handle = SimpleNamespace(key_name=spec.io_key) - handles.append(handle) - presses.append((spec.io_key, dict(latched_keys))) - return handle - - backend.key_down.side_effect = key_down - context = make_test_context(backend) - widget = KeyboardWidget( - layout_config=build_us_iso_layout_config(), - context=context, - ) - shift = self._button_for_io_key(widget, shift_name) - opposite_shift = self._button_for_io_key( - widget, - "ShiftRight" if shift_name == "ShiftLeft" else "ShiftLeft", - ) - ctrl = self._button_for_io_key(widget, ctrl_name) - shifted_letters = [ - self._button_for_io_key(widget, key_name) - for key_name in ("B", "C", "D") - ] - final_letter = self._button_for_io_key(widget, "A") - - ctrl.click() - shift.click() - ctrl.click() - for letter in shifted_letters: - letter.click() - - opposite_shift.click() - final_letter.click() - - self.assertEqual( - [handle.key_name for handle in handles], - [ctrl_name, shift_name, "B", "C", "D", "A"], - ) - self.assertEqual( - [call.args[0].key_name for call in backend.key_up.call_args_list], - [ctrl_name, "B", "C", "D", shift_name, "A"], - ) - for key_name, latched_keys in presses[2:5]: - self.assertIn(key_name, {"B", "C", "D"}) - self.assertFalse(latched_keys["ctrl"]) - self.assertTrue(latched_keys["shift"]) - final_latched_keys = presses[-1][1] - self.assertFalse(final_latched_keys["ctrl"]) - self.assertFalse(final_latched_keys["shift"]) - self.assertFalse(ctrl.property("latched")) - self.assertFalse(shift.property("latched")) - self.assertFalse(opposite_shift.property("latched")) - widget.close() - - def test_key_down_without_backend_press_does_not_emit_pressed_state(self) -> None: - backend = FakeKeyboardBackend() - backend.key_down.return_value = None - context = make_test_context(backend) - spec = KeySpec(label="Caps", row=0, column=0, key_id="caps", io_key="capslock", latchable=True) - events: list[KeyboardKeyStateChangedArguments] = [] + self.assertEqual( + self.events, + [KeyboardKeyStateChangedArguments(source, True, frozenset({"caps"}))], + ) - def record(event: KeyboardKeyStateChangedArguments) -> MessageResult: - events.append(event) - return [] + def test_backend_update_is_published_for_every_exact_registered_source(self) -> None: + first = _source("shift-left-first") + second = _source("shift-left-second") + output = KeyboardOutput("ShiftLeft") + self.service.register_output(first, output) + self.service.register_output(second, output) - context.dispatcher.add_event_handler(KEYBOARD_KEY_STATE_CHANGED, record) - context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:caps") - context.dispatcher.dispatch_action(keyboard_key_down(LAYOUT_ID, "key:caps")) + self.backend.emit("shiftleft", True) - self.assertEqual(events, []) self.assertEqual( - context.state.get(keyboard_key_states_namespace(LAYOUT_ID), "capslock"), - {"pressed": False, "latched": False}, + self.events, + [ + KeyboardKeyStateChangedArguments(first, True, frozenset({"shift"})), + KeyboardKeyStateChangedArguments(second, True, frozenset({"shift"})), + ], ) - def test_shared_latch_keys_keep_distinct_backend_pressed_state(self) -> None: - backend = FakeKeyboardBackend() - context = make_test_context(backend) - left_shift = KeySpec(label="Shift", row=0, column=0, key_id="shift", io_key="leftshift", latchable=True) - right_shift = KeySpec(label="Shift", row=0, column=1, key_id="shift", io_key="rightshift", latchable=True) + def test_key_down_passes_runtime_state_tags_and_publishes_once(self) -> None: + source = _source("a") + output = KeyboardOutput("A") + self.service.register_output(source, output) - context.keyboard.register_key_spec(LAYOUT_ID, left_shift) - context.keyboard.register_key_spec(LAYOUT_ID, right_shift) - backend.emit_key_state("rightshift", True) + self.service.key_down(source, frozenset({"shift", "caps"})) self.assertEqual( - context.state.get(keyboard_key_states_namespace(LAYOUT_ID), "leftshift"), - {"pressed": False, "latched": False}, + self.backend.down_calls, + [(output, frozenset({"shift", "caps"}))], ) self.assertEqual( - context.state.get(keyboard_key_states_namespace(LAYOUT_ID), "rightshift"), - {"pressed": True, "latched": False}, + self.events, + [KeyboardKeyStateChangedArguments(source, True, frozenset())], ) - def test_service_reset_state_clears_latches_for_registered_layout(self) -> None: - backend = FakeKeyboardBackend() - context = make_test_context(backend) - spec = KeySpec(label="Shift", row=0, column=0, key_id="shift", io_key="leftshift", latchable=True) - - context.keyboard.register_key_spec(LAYOUT_ID, spec, component_id="key:shift") - context.dispatcher.dispatch_action(keyboard_sync_latched_key(LAYOUT_ID, "key:shift", True)) - context.keyboard.reset_state() - - self.assertIsNone(context.state.get(keyboard_latches_namespace(LAYOUT_ID), "shift")) - self.assertFalse(context.keyboard.is_latched(LAYOUT_ID, "shift")) - - def test_widget_renders_latched_style_from_snapshot(self) -> None: - _app() - backend = FakeKeyboardBackend() - context = make_test_context(backend) - context.state.set(keyboard_latches_namespace(LAYOUT_ID), "shift", True) - - widget = KeyboardWidget(layout_config=build_us_iso_layout_config(), context=context) - self.addCleanup(widget.close) - - button = self._button_for_key_id(widget, "shift") - self.assertTrue(button.property("latched")) - self.assertIn(button.property("interactionState"), {"latched", "latched_pressed"}) - - def _button_for_io_key(self, widget: KeyboardWidget, io_key_name: str) -> QPushButton: - for button in widget.findChildren(QPushButton): - if button.property("ioKeyName") == io_key_name: - return button - raise AssertionError(f"button for {io_key_name!r} was not found") - - def _button_for_key_id(self, widget: KeyboardWidget, key_id: str) -> QPushButton: - for button in widget.findChildren(QPushButton): - if button.property("keyId") == key_id: - return button - raise AssertionError(f"button for {key_id!r} was not found") + def test_key_up_releases_matching_handle_and_publishes_once(self) -> None: + source = _source("a") + self.service.register_output(source, KeyboardOutput("A")) + self.service.key_down(source, frozenset()) + self.events.clear() + + self.service.key_up(source) + + self.assertEqual(self.backend.up_calls, [PressHandle("a")]) + self.assertEqual( + self.events, + [KeyboardKeyStateChangedArguments(source, False, frozenset())], + ) + + def test_unregistered_output_fails_before_backend_call(self) -> None: + with self.assertRaisesRegex(ValueError, "No keyboard output registered"): + self.service.key_down(_source("missing"), frozenset()) + + self.assertEqual(self.backend.down_calls, []) + + def test_reset_releases_active_handles_and_discards_outputs(self) -> None: + source = _source("a") + self.service.register_output(source, KeyboardOutput("A")) + self.service.key_down(source, frozenset()) + + self.service.reset_state() + + self.assertEqual(self.backend.up_calls, [PressHandle("a")]) + with self.assertRaisesRegex(ValueError, "No keyboard output registered"): + self.service.key_down(source, frozenset()) + + def test_shutdown_releases_active_handles_and_runs_once(self) -> None: + source = _source("a") + self.service.register_output(source, KeyboardOutput("A")) + self.service.key_down(source, frozenset()) + + self.service.shutdown() + self.service.shutdown() + + self.assertEqual(self.backend.up_calls, [PressHandle("a")]) + self.assertEqual(self.backend.shutdown_calls, 1) if __name__ == "__main__": diff --git a/tests/test_prompt_component.py b/tests/test_prompt_component.py index 82390e4..86e2e17 100644 --- a/tests/test_prompt_component.py +++ b/tests/test_prompt_component.py @@ -7,6 +7,7 @@ from axidev_osk.messages import MessageResult from axidev_osk.config.defaults import build_default_app_config from axidev_osk.runtime.events import PROMPT_RESOLVED, PromptResolvedArguments +from axidev_osk.runtime.config_paths import surface_source_path from axidev_osk.runtime.testing import make_test_context @@ -19,6 +20,17 @@ def add_key_state_listener(self, listener: object) -> object: del listener return lambda: None + def key_name_for_output(self, output): + return output.output_key + + def state_tags_for_key(self, output_key): + del output_key + return frozenset() + + def is_key_down(self, key_name): + del key_name + return False + def _app() -> QApplication: app = QApplication.instance() @@ -42,14 +54,24 @@ def record(event: PromptResolvedArguments) -> MessageResult: context.dispatcher.add_event_handler(PROMPT_RESOLVED, record) window = QWidget() self.addCleanup(window.close) - prompt_widget = context.components.build(prompt, context, host=window) + prompt_path = surface_source_path( + config, + prompt.window_id, + prompt.surface_id, + ).child("component", prompt.id) + prompt_widget = context.components.build( + prompt, + context, + source_path=prompt_path, + host=window, + ) prompt_widget.setParent(window) window.show() button = next( child for child in prompt_widget.findChildren(QPushButton) - if child.property("role") == "accepted" + if child.property("componentId") == prompt.buttons[0].id ) button.click() diff --git a/tests/test_runtime_identity.py b/tests/test_runtime_identity.py index 034d8b1..dac27dc 100644 --- a/tests/test_runtime_identity.py +++ b/tests/test_runtime_identity.py @@ -3,8 +3,15 @@ import unittest from axidev_osk.config.models import GridConfig, KeyConfig, LayoutConfig -from axidev_osk.models import KeySpec -from axidev_osk.runtime.identity import key_component_id, prompt_button_id, stable_id, validate_unique_ids +from axidev_osk.models import KeyVisual +from axidev_osk.runtime.identity import prompt_button_id, stable_id, validate_unique_ids +from axidev_osk.runtime.source import ( + SourcePath, + SourcePathSegment, + source_path_from_data, + source_state_namespace, + source_path_to_data, +) class RuntimeIdentityTests(unittest.TestCase): @@ -25,41 +32,52 @@ def test_duplicate_ids_are_reported_in_deterministic_order(self) -> None: def test_stable_id_override_returns_explicit_id(self) -> None: self.assertEqual(stable_id("parent", "component", "value", stable_override="component:explicit"), "component:explicit") - def test_key_component_id_collides_for_duplicate_grid_position(self) -> None: - first = key_component_id( - "grid:example", - "key", - row=1, - column=2, - width=1.0, - height=1, - key_id="a", - io_key="A", - label="A", + def test_source_path_round_trips_through_native_data(self) -> None: + source = SourcePath( + ( + SourcePathSegment("app", "axidev-osk"), + SourcePathSegment("profile", "default"), + SourcePathSegment("component", "key-a"), + ) + ) + + self.assertEqual(source_path_from_data(source_path_to_data(source)), source) + + def test_distinct_source_paths_have_distinct_state_namespaces(self) -> None: + first = SourcePath( + ( + SourcePathSegment("a", "b\x1fc"), + SourcePathSegment("d", "e"), + ) ) - second = key_component_id( - "grid:example", - "key", - row=1, - column=2, - width=1.0, - height=1, - key_id="b", - io_key="B", - label="B", + second = SourcePath( + ( + SourcePathSegment("a", "b"), + SourcePathSegment("c", "d\x1fe"), + ) ) - self.assertEqual(first, second) + self.assertNotEqual(source_state_namespace(first), source_state_namespace(second)) def test_layout_rejects_component_ids_reused_across_grids(self) -> None: first = GridConfig( id="grid:first", - components=(KeyConfig(id="component:shared", spec=KeySpec("A", 0, 0)),), + components=( + KeyConfig( + id="component:shared", + visual=KeyVisual(label="A", row=0, column=0), + ), + ), nav_start_column=0, ) second = GridConfig( id="grid:second", - components=(KeyConfig(id="component:shared", spec=KeySpec("B", 0, 0)),), + components=( + KeyConfig( + id="component:shared", + visual=KeyVisual(label="B", row=0, column=0), + ), + ), nav_start_column=0, ) diff --git a/tests/test_service_registry.py b/tests/test_service_registry.py index 5518255..27ee18d 100644 --- a/tests/test_service_registry.py +++ b/tests/test_service_registry.py @@ -10,6 +10,7 @@ from axidev_osk.config.defaults import build_default_app_config from axidev_osk.runtime.application import ApplicationRuntime from axidev_osk.runtime.registries import ComponentRegistry, ServiceRegistry, SurfaceRegistry +from axidev_osk.runtime.source import SourcePath, SourcePathSegment from axidev_osk.services.keyboard import KeyboardService @@ -40,8 +41,19 @@ def is_key_down(self, key_name: str) -> bool: del key_name return False - def key_name_for_spec(self, spec) -> str | None: - return getattr(spec, "io_key", None) + def key_name_for_output(self, output) -> str: + return output.output_key + + def state_tags_for_key(self, output_key: str) -> frozenset[str]: + del output_key + return frozenset() + + def key_down(self, output, active_state_tags): + del output, active_state_tags + return None + + def key_up(self, handle) -> None: + del handle class RecordingService: @@ -79,17 +91,27 @@ def exec_and_quit() -> int: class RegistryErrorTests(unittest.TestCase): + _source = SourcePath((SourcePathSegment("test", "source"),)) + def test_component_registry_reports_missing_kind(self) -> None: registry = ComponentRegistry() with self.assertRaisesRegex(ValueError, "No component registered for kind 'missing-component'"): - registry.build(SimpleNamespace(kind="missing-component"), None) # type: ignore[arg-type] + registry.build( + SimpleNamespace(kind="missing-component"), + None, + source_path=self._source, + ) # type: ignore[arg-type] def test_surface_registry_reports_missing_kind(self) -> None: registry = SurfaceRegistry() with self.assertRaisesRegex(ValueError, "No surface registered for kind 'missing-surface'"): - registry.build(SimpleNamespace(kind="missing-surface"), None) # type: ignore[arg-type] + registry.build( + SimpleNamespace(kind="missing-surface"), + None, + self._source, + ) # type: ignore[arg-type] if __name__ == "__main__": diff --git a/tests/test_single_instance.py b/tests/test_single_instance.py index ff080e3..9aa256c 100644 --- a/tests/test_single_instance.py +++ b/tests/test_single_instance.py @@ -26,10 +26,26 @@ def _app() -> QApplication: class FakeKeyboardBackend: + ready = True + status_text = "ready" + needs_permission_setup = False + permission_setup_text = "" + def add_key_state_listener(self, listener): del listener return lambda: None + def key_name_for_output(self, output): + return output.output_key + + def state_tags_for_key(self, output_key): + del output_key + return frozenset() + + def is_key_down(self, key_name): + del key_name + return False + class WindowsSingleInstanceServiceTests(unittest.TestCase): def test_service_registers_before_runtime_backends(self) -> None: diff --git a/tests/test_us_iso_layout.py b/tests/test_us_iso_layout.py index 853ebb8..907ddd4 100644 --- a/tests/test_us_iso_layout.py +++ b/tests/test_us_iso_layout.py @@ -1,108 +1,183 @@ from __future__ import annotations -from axidev_osk.config.defaults.us_iso import NAV_START -from axidev_osk.config.defaults.us_iso import build_us_iso_layout -from axidev_osk.config.defaults.us_iso import build_us_iso_layout_config +import unittest +from axidev_osk.config.defaults import build_default_app_config +from axidev_osk.config.defaults.us_iso import ( + NAV_START, + build_us_iso_behavior_configs, + build_us_iso_layout, + build_us_iso_layout_config, +) +from axidev_osk.runtime.behaviors import decode_keyboard_behavior -def test_super_keys_use_platform_neutral_labels_and_io_keys() -> None: - specs = build_us_iso_layout() - super_specs = [spec for spec in specs if spec.io_key in {"SuperLeft", "SuperRight"}] - assert [spec.label for spec in super_specs] == ["Super", "Super"] - assert [spec.io_key for spec in super_specs] == ["SuperLeft", "SuperRight"] - - -def test_held_modifiers_opt_into_backend_repeat() -> None: - held_modifiers = [spec for spec in build_us_iso_layout() if spec.holds_when_latched] - - assert held_modifiers - assert all(spec.repeats for spec in held_modifiers) - - -def test_us_iso_layout_config_preserves_key_geometry_and_ids() -> None: - specs = build_us_iso_layout() +def _visual_output_pairs(): config = build_us_iso_layout_config() - grid = config.grids[0] - - assert config.name == "us-iso" - assert len(grid.components) == len(specs) - assert [(item.spec.row, item.spec.column, item.spec.width) for item in grid.components] == [ - (spec.row, spec.column, spec.width) for spec in specs + behaviors = build_us_iso_behavior_configs() + return [ + ( + component.visual, + decode_keyboard_behavior(behaviors[component.id].arguments).output, + ) + for component in config.grids[0].components + if component.id in behaviors ] - assert len({item.id for item in grid.components}) == len(grid.components) -def test_us_iso_layout_config_covers_expected_sections_and_key_sizes() -> None: - config = build_us_iso_layout_config() - specs = [item.spec for item in config.grids[0].components] - - assert sorted({spec.row for spec in specs}) == [0, 1, 2, 3, 4, 5] - assert [spec.label for spec in specs if spec.row == 0] == [ - "Esc", - "F1", - "F2", - "F3", - "F4", - "F5", - "F6", - "F7", - "F8", - "F9", - "F10", - "F11", - "F12", - "PrtSc", - "ScrLk", - "Pause", - ] - assert {spec.label for spec in specs if spec.column >= NAV_START} == { - "PrtSc", - "ScrLk", - "Pause", - "Ins", - "Home", - "PgUp", - "Del", - "End", - "PgDn", - "↑", - "←", - "↓", - "→", - } - assert next(spec.width for spec in specs if spec.label == "Backspace") == 2.0 - assert next(spec.width for spec in specs if spec.label == "Space") == 6.25 - assert [spec.width for spec in specs if spec.label == "Shift"] == [1.25, 2.75] - - -def test_us_iso_layout_dense_body_columns_match_main_block_width() -> None: - config = build_us_iso_layout_config() - body_specs = [item.spec for item in config.grids[0].components if item.spec.row > 0] - occupied_columns: set[int] = set() - - for spec in body_specs: - occupied_columns.update(range(spec.column, spec.column + int(spec.width * 4))) - - assert len([column for column in occupied_columns if column < NAV_START]) == 60 - assert len([column for column in occupied_columns if column >= NAV_START]) == 12 - - -def test_ghost_key_uses_the_near_bracket_slot_and_targets_configured_window() -> None: - target_window_id = "window:alternate" - specs = build_us_iso_layout(target_window_id=target_window_id) - ghost = next(spec for spec in specs if spec.label == "Ghost") - config_ghost = next( - component - for component in build_us_iso_layout_config(target_window_id=target_window_id).grids[0].components - if component.spec.label == "Ghost" - ) - - assert (ghost.row, ghost.column, ghost.width) == (2, 54, 1.0) - assert ghost.io_key is None - assert ghost.repeats is False - assert ghost.action is not None - assert ghost.action.action == "window.toggle_opacity" - assert ghost.action.arguments["window_id"] == target_window_id - assert ghost.action.arguments["component_id"] == config_ghost.id - assert ghost.action.arguments["opacity"] == 0.01 +class UsIsoLayoutTests(unittest.TestCase): + def test_super_keys_use_platform_neutral_labels_and_outputs(self) -> None: + pairs = [ + (visual, output) + for visual, output in _visual_output_pairs() + if output.output_key in {"SuperLeft", "SuperRight"} + ] + + self.assertEqual([visual.label for visual, _output in pairs], ["Super", "Super"]) + self.assertEqual( + [output.output_key for _visual, output in pairs], + ["SuperLeft", "SuperRight"], + ) + + def test_held_modifiers_opt_into_backend_repeat(self) -> None: + held_keys = { + "ShiftLeft", + "ShiftRight", + "CtrlLeft", + "CtrlRight", + "SuperLeft", + "SuperRight", + "AltLeft", + "AltRight", + } + outputs = [ + output + for _visual, output in _visual_output_pairs() + if output.output_key in held_keys + ] + + self.assertEqual({output.output_key for output in outputs}, held_keys) + self.assertTrue(all(output.repeats for output in outputs)) + + def test_layout_config_preserves_visual_geometry_and_explicit_ids(self) -> None: + visuals = build_us_iso_layout() + config = build_us_iso_layout_config() + grid = config.grids[0] + + self.assertEqual(config.name, "us-iso") + self.assertEqual( + [component.visual for component in grid.components], + visuals, + ) + self.assertEqual(len({component.id for component in grid.components}), len(visuals)) + + def test_layout_covers_expected_sections_and_key_sizes(self) -> None: + visuals = [ + component.visual + for component in build_us_iso_layout_config().grids[0].components + ] + + self.assertEqual(sorted({visual.row for visual in visuals}), [0, 1, 2, 3, 4, 5]) + self.assertEqual( + [visual.label for visual in visuals if visual.row == 0], + [ + "Esc", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "PrtSc", + "ScrLk", + "Pause", + ], + ) + self.assertEqual( + {visual.label for visual in visuals if visual.column >= NAV_START}, + { + "PrtSc", + "ScrLk", + "Pause", + "Ins", + "Home", + "PgUp", + "Del", + "End", + "PgDn", + "↑", + "←", + "↓", + "→", + }, + ) + self.assertEqual( + next(visual.width for visual in visuals if visual.label == "Backspace"), + 2.0, + ) + self.assertEqual( + next(visual.width for visual in visuals if visual.label == "Space"), + 6.25, + ) + self.assertEqual( + [visual.width for visual in visuals if visual.label == "Shift"], + [1.25, 2.75], + ) + + def test_dense_body_columns_match_main_block_width(self) -> None: + visuals = [ + component.visual + for component in build_us_iso_layout_config().grids[0].components + if component.visual.row > 0 + ] + occupied_columns: set[int] = set() + + for visual in visuals: + occupied_columns.update( + range(visual.column, visual.column + int(visual.width * 4)) + ) + + self.assertEqual( + len([column for column in occupied_columns if column < NAV_START]), + 60, + ) + self.assertEqual( + len([column for column in occupied_columns if column >= NAV_START]), + 12, + ) + + def test_ghost_key_is_visual_only_and_has_root_behavior(self) -> None: + config = build_default_app_config() + keyboard = config.windows[0].surface.components[0] + grid = keyboard.layout.grids[0] + ghost = next( + component + for component in grid.components + if component.visual.label == "Ghost" + ) + binding = next( + behavior + for behavior in config.behaviors + if behavior.target.segments[-1].id == ghost.id + ) + actions = binding.default.arguments["pressed_actions"] + + self.assertEqual( + (ghost.visual.row, ghost.visual.column, ghost.visual.width), + (2, 54, 1.0), + ) + self.assertNotIn(ghost.id, build_us_iso_behavior_configs()) + self.assertIsInstance(actions, list) + self.assertEqual(actions[0]["action"], "window.toggle_opacity") + self.assertEqual(actions[0]["arguments"]["window_id"], "window:keyboard") + self.assertEqual(actions[0]["arguments"]["component_id"], ghost.id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_window_builder.py b/tests/test_window_builder.py index d9690cf..cc2069a 100644 --- a/tests/test_window_builder.py +++ b/tests/test_window_builder.py @@ -35,19 +35,24 @@ def add_key_state_listener(self, listener): def is_key_down(self, key_name: str) -> bool: return False - def key_name_for_spec(self, spec): - return spec.io_key or (spec.label if len(spec.label) == 1 else None) - - def key_down(self, spec, latched_keys): + def key_name_for_output(self, output): + return output.output_key + + def state_tags_for_key(self, output_key): + tags = { + "ShiftLeft": frozenset({"shift"}), + "ShiftRight": frozenset({"shift"}), + "CapsLock": frozenset({"caps"}), + } + return tags.get(output_key, frozenset()) + + def key_down(self, output, active_state_tags): + del output, active_state_tags return None def key_up(self, press_handle) -> None: return None - def sync_latched_key(self, spec, latched: bool, press_handle=None): - return press_handle - - class FakeOverlayController: def __init__(self, *, uses_custom_chrome: bool = True) -> None: self.uses_custom_chrome = uses_custom_chrome @@ -236,16 +241,15 @@ def test_runtime_window_and_components_expose_dynamic_identity_properties(self) key = next( button for button in window.findChildren(QPushButton) - if button.property("ioKey") == "A" + if button.text() == "a" ) self.assertEqual(key.property("componentType"), "key") self.assertIsInstance(key.property("componentId"), str) - self.assertIsNone(key.property("keyId")) - self.assertEqual(key.property("ioKey"), "A") + self.assertIsNone(key.property("ioKey")) self.assertEqual(key.property("interactionState"), "idle") self.assertFalse(key.property("latched")) self.assertFalse(key.property("pressed")) - self.assertEqual(key.property("profile"), "default") + self.assertEqual(key.property("profile"), "profile:default") self.assertEqual(key.property("layout"), "layout:us-iso") if __name__ == "__main__":