From 9bb5f96ac7f011106d401dc0fbe4df36e9effd87 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sat, 10 Oct 2026 18:19:17 +0200 Subject: [PATCH 1/6] docs(architecture): plan the settings strip and profile switching inayayousfi directed the work and made every decision. Claude Opus 5.5, running in Claude Code, carried inayayousfi's decisions out. A profile can put a strip of checkboxes, steppers, dropdowns, and menus at the bottom of any window, built in osk.std from existing node kinds and bound to registry variables. Dropdowns and menus open as inline panels because Qt popups fail on layer-shell windows. Profiles switch at runtime, keep a shared window's position, and roll back when the new one fails to build. The default keyboard gets a settings strip, and a small social profile gives switching a target. --- SETTINGS_STRIP_PLAN.md | 98 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 SETTINGS_STRIP_PLAN.md diff --git a/SETTINGS_STRIP_PLAN.md b/SETTINGS_STRIP_PLAN.md new file mode 100644 index 0000000..2c369ec --- /dev/null +++ b/SETTINGS_STRIP_PLAN.md @@ -0,0 +1,98 @@ +# Settings Strip Plan + +This file records the agreed plan for the settings strip and live profile switching. It is the source of truth for that work until the work is finished. It builds on the state registry described in `PURIFICATION_PLAN.md` (settled decisions 11 to 17 and step 8), which lands first in the branch below this one. + +Remove this file when the work below is merged. + +## Goal + +A profile can put a strip of controls at the bottom of any window: checkboxes, steppers, dropdowns, and menus. Each control reads and writes registry variables, so the profile decides what the strip shows, what it controls, and what its callbacks do. The engine adds no menu concept of its own. + +The bundled config uses the strip to show the feature: the default keyboard gets a settings strip, and a second small profile gives profile switching a real target. + +## Settled Decisions + +Each decision below was made by the project owner. Do not reopen one without a new reason. + +1. The strip is ordinary profile content placed at the bottom of a window's content, not part of the engine's title bar. It works on every backend, including those where the system draws the title bar. + +2. Dropdowns and menus open as inline panels inside the window, not as Qt popups. A test on KDE Plasma Wayland showed that Qt popups fail on layer-shell windows: LayerShellQt 6.7.5 logs `Cannot attach popup of unknown type` and neither a dropdown list nor a menu appears, even with the window set as the popup's parent. Popups were not tested on the input-panel backend because it only starts when KWin launches it. + +3. An inline panel grows the window. The engine shrinks the window back to its minimum size when content shrinks, the way `apply_startup_size` sizes it at show time. The same test showed a layer-shell window growing from 200x100 to 200x200 when a panel appeared and staying at 200x200 after it hid. + +4. Checkboxes, steppers, dropdowns, and menus are standard-library helpers built from existing node kinds, not new engine node kinds. A checkbox is a button with a `latched` binding. A dropdown or menu is a button that flips `std.menus..open` plus a box whose `visible` binding reads it. A stepper is a pair of buttons around a label, and its minimum, maximum, and value are bindings, so linked settings can bound each other. + +5. Profiles switch at runtime through `profile.switch {profile}`. The engine exposes `app.profile` and `app.profiles` as read-only variables. A switch requested from a callback runs once the queue is empty, the same way `app.quit` waits. + +6. A window whose id exists in both profiles keeps its screen position through `windows..position`, and its size refits to the new content. + +7. If building the new profile fails, the previous profile is rebuilt and the failure is reported. + +## Engine Work + +- Shrink a window back to its minimum size after its content shrinks. +- `profile.switch`, with teardown of the old profile's windows, bindings, callbacks, and attachments, then the build of the new one. +- `app.profile` and `app.profiles` as engine-declared, read-only variables. +- Keep `windows..position` across a rebuild on every backend. Layer shell places windows through margins, so each backend needs its own test. +- Rebuild the previous profile when a switch fails. + +## Standard Library + +- `checkbox`, `stepper`, `dropdown`, and `menu` in `python_defaults/axidev_osk/std/`, each taking `opts` merged last with `axidev_osk.merge`. +- Their state lives under `std`, declared in the registry, for example `std.menus` as a map of booleans. +- Tests in `tests/test_std_library.py`. + +## Bundled Config + +### Default Keyboard Strip + +A strip at the bottom of the `keyboard` window holds: + +- A profile dropdown listing `default` and `social`. +- An opacity stepper, 0.30 to 1.00 in steps of 0.05. +- A dwell panel with a checkbox for `enabled` and one stepper per dwell option: + +| Option | Range | Step | +| --- | --- | --- | +| `delay_ms` | 50 to 2000 ms | 50 | +| `dead_zone_px` | 0 to 50 px | 2 | +| `full_speed_px_s` | 0 to min(200, stop speed - 5) px/s | 5 | +| `stop_speed_px_s` | full speed + 5 to 1000 px/s | 20 | +| `maximum_progress_rate` | 1.00 to 4.00 | 0.25 | +| `indicator_start_progress` | 0 to 1 | 0.05 | +| `direction_reversal_progress_factor` | 0 to 1 | 0.05 | +| `movement_penalty_px` | 1 to 100 px | 1 | +| `distance_curve_full_px` | 10 to 1000 px | 10 | +| `velocity_release_ms` | 10 to 1000 ms | 10 | + +Each range contains today's default and stays inside the dwell validation in `config/models.py`. The full speed and stop speed bounds follow each other, so the two can never cross. + +Opacity and every dwell option are declared in the root config's `state`, so they survive switching to `social` and back. + +### Social Profile + +A second profile named `social`, a small keyboard for scrolling a feed: + +- Four keys: Up, Down, L, and K. +- A strip with only the profile dropdown, so the user can switch back. +- It reuses the window id `keyboard`, so it opens where the keyboard was. +- Its opacity binds to the same root variable as the default keyboard. +- It has a pointer locator. It has no dwell, hot corners, or lock-screen panel. + +Changing the default keyboard updates the expected data in `tests/test_default_profile.py` in the same change. + +## Known Risks + +- Opening a panel grows a centered layer-shell window, which re-centers it, so keys shift under a dwell pointer while the panel is open. Accepted. +- Theme bindings were not measured. Re-applying a stylesheet live may be slow or flicker. +- Restoring a window's position after a switch is untested on every backend. + +## Steps + +Each step keeps the full test suite, flake8, and pyright passing. + +- [ ] 1. Engine shrink-back after content shrinks. +- [ ] 2. `checkbox`, `stepper`, `dropdown`, and `menu` in `axidev_osk.std`. +- [ ] 3. `profile.switch`, `app.profile`, `app.profiles`, position kept across a rebuild, and rollback on a failed switch. +- [ ] 4. Default keyboard strip and the `social` profile, with updated parity data. +- [ ] 5. Document the strip helpers and profile switching in `AGENTS.md`. From 1aa391db471932a248d9386efb274b7518cb7781 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Sat, 10 Oct 2026 20:35:27 +0200 Subject: [PATCH 2/6] feat(defaults): add a collapsible settings strip and profile switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inayayousfi directed the work and made every decision. Claude Opus 5.5, running in Claude Code, carried inayayousfi's decisions out. A window now refits to its content: when a panel shows or hides, it resizes to its new minimum size, so it grows for the panel and shrinks back afterwards. osk.std.controls adds checkbox, stepper, menu, dropdown, and strip, built from existing node kinds and bound to state. Menus open as inline panels; menus in one group close each other. A strip shows only a small ⋯ button until it is opened, then its controls beside it and their panels above it. profile.switch runs another profile of the root config once the queue is empty. It closes the old windows and drops their bindings at once, reopens a window with the same ID where it was, and builds the previous profile again when the new one fails. app.profile and app.profiles are read-only, a profile without hot corners turns them off, and a prepared lock-screen panel refuses a profile without one. Every window of the bundled config gets the same strip, in place of its content's bottom margin: profile, keyboard opacity, and dwell settings. A social profile with Up, Down, L, and K gives switching a target. AGENTS.md documents the strip and switching, and the plan's steps are done. --- SETTINGS_STRIP_PLAN.md | 10 +- src/axidev_osk/attachments/__init__.py | 2 + src/axidev_osk/attachments/runtime.py | 2 + src/axidev_osk/config/profile.py | 19 +- src/axidev_osk/hot_corner/service.py | 7 + src/axidev_osk/nodes/registry.py | 24 +- .../python_defaults/axidev_osk/__init__.py | 51 +-- .../axidev_osk/std/__init__.py | 6 +- .../axidev_osk/std/controls.py | 299 ++++++++++++++++++ .../python_defaults/default_profile.py | 239 ++++++++++++-- src/axidev_osk/python_defaults/theme.py | 27 ++ src/axidev_osk/runtime/app_messages.py | 17 +- src/axidev_osk/runtime/application.py | 133 ++++++-- src/axidev_osk/runtime/engine_messages.py | 5 +- src/axidev_osk/runtime/window_manager.py | 6 + src/axidev_osk/windows/builder.py | 49 ++- .../windows/overlay/always_on_top.py | 21 ++ tests/test_application_runtime.py | 164 +++++++++- tests/test_attachments.py | 14 + tests/test_default_profile.py | 18 ++ tests/test_std_library.py | 102 +++++- tests/test_window_builder.py | 35 ++ 22 files changed, 1159 insertions(+), 91 deletions(-) create mode 100644 src/axidev_osk/python_defaults/axidev_osk/std/controls.py diff --git a/SETTINGS_STRIP_PLAN.md b/SETTINGS_STRIP_PLAN.md index 2c369ec..3e993c3 100644 --- a/SETTINGS_STRIP_PLAN.md +++ b/SETTINGS_STRIP_PLAN.md @@ -91,8 +91,8 @@ Changing the default keyboard updates the expected data in `tests/test_default_p Each step keeps the full test suite, flake8, and pyright passing. -- [ ] 1. Engine shrink-back after content shrinks. -- [ ] 2. `checkbox`, `stepper`, `dropdown`, and `menu` in `axidev_osk.std`. -- [ ] 3. `profile.switch`, `app.profile`, `app.profiles`, position kept across a rebuild, and rollback on a failed switch. -- [ ] 4. Default keyboard strip and the `social` profile, with updated parity data. -- [ ] 5. Document the strip helpers and profile switching in `AGENTS.md`. +- [x] 1. Engine shrink-back after content shrinks. +- [x] 2. `checkbox`, `stepper`, `dropdown`, and `menu` in `axidev_osk.std`. +- [x] 3. `profile.switch`, `app.profile`, `app.profiles`, position kept across a rebuild, and rollback on a failed switch. +- [x] 4. Default keyboard strip and the `social` profile, with updated parity data. +- [x] 5. Document the strip helpers and profile switching in `AGENTS.md`. diff --git a/src/axidev_osk/attachments/__init__.py b/src/axidev_osk/attachments/__init__.py index 79e1983..e928c39 100644 --- a/src/axidev_osk/attachments/__init__.py +++ b/src/axidev_osk/attachments/__init__.py @@ -36,6 +36,8 @@ def set_pointer_locator(self, settings: PointerLocatorConfig) -> None: ... class HotCornerTarget(Protocol): def configure(self, settings: HotCornerConfig, corners: frozenset[str]) -> None: ... + def clear(self) -> None: ... + class AttachmentHost(Protocol): """What an attachment kind may use when it is attached for the running profile.""" diff --git a/src/axidev_osk/attachments/runtime.py b/src/axidev_osk/attachments/runtime.py index 7c4d02e..f57a53f 100644 --- a/src/axidev_osk/attachments/runtime.py +++ b/src/axidev_osk/attachments/runtime.py @@ -73,6 +73,8 @@ def stop(self) -> None: self._dwell.clear() self._locators.clear() self._secure_input_panel_window = None + if self._hot_corners is not None: + self._hot_corners.clear() def for_window(self, window_id: str) -> WindowAttachments: """Return the attachments a window installs, with their current options.""" diff --git a/src/axidev_osk/config/profile.py b/src/axidev_osk/config/profile.py index 72183cb..7d36d28 100644 --- a/src/axidev_osk/config/profile.py +++ b/src/axidev_osk/config/profile.py @@ -243,6 +243,18 @@ def nodes(self) -> list[NodeConfig]: return [node for window in self.windows for node in window.content.walk()] +@dataclass(frozen=True, slots=True) +class RootConfig: + """Every decoded profile of a root config, and the one it starts with.""" + + active_profile: str + profiles: Mapping[str, ProfileConfig] + + @property + def active(self) -> ProfileConfig: + return self.profiles[self.active_profile] + + ProfileCheck = Callable[[ProfileConfig, str], None] """Engine checks a decoded profile must pass, given the profile and its config path.""" @@ -271,6 +283,11 @@ def __init__( def decode_root(self, data: object) -> ProfileConfig: """Validate every profile in a root config and return the active one.""" + return self.decode_config(data).active + + def decode_config(self, data: object) -> RootConfig: + """Validate every profile in a root config and return them all.""" + reader = ConfigReader(data, "config", self._functions) active = reader.string("active_profile") profiles = reader.items("profiles") @@ -284,7 +301,7 @@ def decode_root(self, data: object) -> ProfileConfig: ) if active not in decoded: raise ConfigError(f"config.active_profile names unknown profile {active!r}") - return decoded[active] + return RootConfig(active_profile=active, profiles=decoded) def _decode_profile( self, diff --git a/src/axidev_osk/hot_corner/service.py b/src/axidev_osk/hot_corner/service.py index 92bf86c..23bb2eb 100644 --- a/src/axidev_osk/hot_corner/service.py +++ b/src/axidev_osk/hot_corner/service.py @@ -33,6 +33,13 @@ def configure(self, settings: HotCornerConfig, corners: frozenset[str]) -> None: if self._dispatcher is not None: self._restart() + def clear(self) -> None: + """Forget the profile's settings and stop the sensors, for a profile without hot corners.""" + + self._settings = None + self._corners = None + self._stop_controller() + def start(self, dispatcher: Dispatcher) -> None: """Start corner sensors when the profile configured a hot_corners attachment, and follow display changes.""" diff --git a/src/axidev_osk/nodes/registry.py b/src/axidev_osk/nodes/registry.py index 079bf8d..f0379e0 100644 --- a/src/axidev_osk/nodes/registry.py +++ b/src/axidev_osk/nodes/registry.py @@ -9,7 +9,8 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -23,7 +24,7 @@ from ..widget_properties import CLASSES, COMPONENT_ID, COMPONENT_TYPE, PASSES_INPUT_BLOCK if TYPE_CHECKING: - from ..runtime.dispatcher import Dispatcher + from ..runtime.dispatcher import Dispatcher, Unsubscribe from ..runtime.profile_runtime import BindingTracker NodeBuild = Callable[[NodeConfig, "NodeBuilder"], QWidget] @@ -110,6 +111,23 @@ def __init__( self._kinds = kinds self._dispatcher = dispatcher self._bindings = bindings + self._collectors: list[list[Unsubscribe]] = [] + + @contextmanager + def collecting(self) -> Iterator[list[Unsubscribe]]: + """Collect the unbind of every node built inside the block, so its owner can drop them at once. + + A closed window drops its bindings right away instead of when Qt + deletes its widgets later, so they never decode against another + profile's state. + """ + + unbinds: list[Unsubscribe] = [] + self._collectors.append(unbinds) + try: + yield unbinds + finally: + self._collectors.pop() def build(self, node: NodeConfig) -> QWidget: kind = self._kinds[node.kind] @@ -125,6 +143,8 @@ def apply(values: NodeValues, previous: NodeValues | None) -> None: unbind = self._bindings.bind(node.values.decode, f"node:{node.id}", apply, fallback=node.values.initial) widget.destroyed.connect(lambda _object=None: unbind()) + if self._collectors: + self._collectors[-1].append(unbind) return widget def emit(self, event_name: str, node_id: str) -> None: diff --git a/src/axidev_osk/python_defaults/axidev_osk/__init__.py b/src/axidev_osk/python_defaults/axidev_osk/__init__.py index d87ed27..407e10c 100644 --- a/src/axidev_osk/python_defaults/axidev_osk/__init__.py +++ b/src/axidev_osk/python_defaults/axidev_osk/__init__.py @@ -8,7 +8,7 @@ - Attachment builders: ``dwell``, ``pointer_locator``, ``hot_corners``, ``secure_input_panel``. - Action builders grouped by subject: ``state``, ``keyboard``, ``window``, - ``process``, ``log``, ``app``, ``linux``. + ``profile``, ``process``, ``log``, ``app``, ``linux``. - State declarations: ``var`` with the types ``list_of``, ``map_of``, and ``record``. - Helpers: ``config``, ``profile``, ``merge``, ``read``. @@ -96,24 +96,37 @@ def config(*, active_profile: str, profiles: Mapping[str, Map], state: Map | Non return result -def profile( - *, - windows: list[Map], - state: Map | None = None, - attachments: list[Map] | None = None, - on: Mapping[str, Any] | None = None, - theme: Map | None = None, -) -> Map: - result: Map = {"windows": windows} - if state is not None: - result["state"] = state - if attachments is not None: - result["attachments"] = attachments - if on is not None: - result["on"] = dict(on) - if theme is not None: - result["theme"] = theme - return result +class _Profile: + """``axidev_osk.profile(...)`` builds a profile; ``axidev_osk.profile.switch(...)`` builds an action.""" + + def __call__( + self, + *, + windows: list[Map], + state: Map | None = None, + attachments: list[Map] | None = None, + on: Mapping[str, Any] | None = None, + theme: Map | None = None, + ) -> Map: + result: Map = {"windows": windows} + if state is not None: + result["state"] = state + if attachments is not None: + result["attachments"] = attachments + if on is not None: + result["on"] = dict(on) + if theme is not None: + result["theme"] = theme + return result + + @staticmethod + def switch(profile: str) -> Map: + """Run another profile of the root config once queued work finishes.""" + + return action("profile.switch", profile=profile) + + +profile = _Profile() def _node(kind: str, fields: Mapping[str, Any]) -> Map: diff --git a/src/axidev_osk/python_defaults/axidev_osk/std/__init__.py b/src/axidev_osk/python_defaults/axidev_osk/std/__init__.py index 5b37fe9..baf1b55 100644 --- a/src/axidev_osk/python_defaults/axidev_osk/std/__init__.py +++ b/src/axidev_osk/python_defaults/axidev_osk/std/__init__.py @@ -5,9 +5,9 @@ from collections.abc import Mapping from typing import Any -from . import keys, prompts, windows +from . import controls, keys, prompts, windows -__all__ = ["keys", "prompts", "windows", "with_handlers", "with_state"] +__all__ = ["controls", "keys", "prompts", "windows", "with_handlers", "with_state"] def with_state(state: Mapping[str, Any] | None = None) -> dict[str, Any]: @@ -16,7 +16,7 @@ def with_state(state: Mapping[str, Any] | None = None) -> dict[str, Any]: merged: dict[str, Any] = dict(state or {}) if "std" in merged: raise ValueError("Profile state cannot declare std; the library declares it") - merged["std"] = {**keys.STATE, **windows.STATE} + merged["std"] = {**keys.STATE, **windows.STATE, **controls.STATE} return merged diff --git a/src/axidev_osk/python_defaults/axidev_osk/std/controls.py b/src/axidev_osk/python_defaults/axidev_osk/std/controls.py new file mode 100644 index 0000000..06825a5 --- /dev/null +++ b/src/axidev_osk/python_defaults/axidev_osk/std/controls.py @@ -0,0 +1,299 @@ +"""Settings controls built only from ``axidev_osk`` primitives: checkbox, stepper, menu, dropdown, and strip. + +Each control reads and writes state, so a profile decides what it controls. +Menus and dropdowns open as inline panels inside the window, not as popups, +because popups fail on layer-shell windows. A menu is a button plus a panel +the profile places anywhere in the same window; the window grows while the +panel shows and shrinks back when it hides. A strip is a menu shaped as a +bottom row: closed it shows only a ``⋯`` button, open it shows its controls +beside the button and their panels above it. + +Library state lives under ``std.menus.``: the ID of the menu open in +that group. Menus in one group close each other; a menu without a group is +its own group. Menus inside a strip share the group ``strip_group(strip id)``. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import Any + +from ... import axidev_osk + +STATE = {"menus": axidev_osk.var(axidev_osk.map_of("string"))} +"""The library's control variables, merged under ``std`` by ``std.with_state``.""" + +Number = int | float +NumberOrBinding = Number | Callable[[Any], Number] +OnSelect = Callable[[Any, Any], "list[axidev_osk.Map]"] +"""``fn(ctx, value) -> [actions]``: what choosing a dropdown value does.""" + + +def menu_open(state: Any, menu: str, group: str | None = None) -> bool: + return axidev_osk.read(state, ["std", "menus", group or menu]) == menu + + +def open_menu(menu: str, group: str | None = None) -> list[axidev_osk.Map]: + """Open ``menu``, closing whichever menu of its group was open.""" + + return [axidev_osk.state.set(["std", "menus", group or menu], menu)] + + +def close_menu(menu: str, group: str | None = None) -> list[axidev_osk.Map]: + return [axidev_osk.state.set(["std", "menus", group or menu], None)] + + +def toggle_menu(state: Any, menu: str, group: str | None = None) -> list[axidev_osk.Map]: + return close_menu(menu, group) if menu_open(state, menu, group) else open_menu(menu, group) + + +def strip_group(strip: str) -> str: + """The menu group of the menus placed inside a strip.""" + + return f"{strip}:items" + + +def checkbox(id: str, label: Any, path: str | list[str], opts: Mapping[str, Any] | None = None) -> axidev_osk.Map: # noqa: A002 + """A button lit while the boolean at ``path`` is true; releasing it flips the value.""" + + def toggle(ctx: Any, event: Any) -> list[axidev_osk.Map]: + del event + return [axidev_osk.state.set(path, not axidev_osk.read(ctx.state, path))] + + defaults = { + "id": id, + "label": label, + "latched": lambda state: bool(axidev_osk.read(state, path)), + "on_release": toggle, + } + return axidev_osk.button(**axidev_osk.merge(defaults, opts)) + + +def stepper( + id: str, # noqa: A002 + path: str | list[str], + *, + minimum: NumberOrBinding, + maximum: NumberOrBinding, + step: Number, + label: str | None = None, + format: Callable[[Number], str] | None = None, # noqa: A002 + opts: Mapping[str, Any] | None = None, +) -> axidev_osk.Map: + """A caption, a minus button, the value at ``path``, and a plus button, in one row. + + ``minimum`` and ``maximum`` may be bindings, so linked settings can bound + each other. Each press moves the value by ``step`` and clamps it to the + bounds; a button is disabled once the value reaches its bound. ``format`` + turns the value into its label text. ``opts`` replaces fields of the row. + """ + + show = format or _format_number + + def value(state: Any) -> Number: + return axidev_osk.read(state, path) + + def bound(limit: NumberOrBinding, state: Any) -> Number: + return limit(state) if callable(limit) else limit + + def move(direction: int) -> axidev_osk.Callback: + def on_release(ctx: Any, event: Any) -> list[axidev_osk.Map]: + del event + current = value(ctx.state) + # Rounding keeps repeated decimal steps such as 0.05 from drifting. + moved = round(current + direction * step, 9) + clamped = min(max(moved, bound(minimum, ctx.state)), bound(maximum, ctx.state)) + if isinstance(current, int) and isinstance(step, int): + clamped = int(clamped) + return [] if clamped == current else [axidev_osk.state.set(path, clamped)] + + return on_release + + children: list[axidev_osk.Map] = [] + if label is not None: + children.append(axidev_osk.label(id=f"{id}:label", text=label, stretch=1)) + children.extend( + [ + axidev_osk.button( + id=f"{id}:down", + label="−", + enabled=lambda state: value(state) > bound(minimum, state), + on_release=move(-1), + style={"classes": ["stepperButton"]}, + ), + axidev_osk.label( + id=f"{id}:value", + text=lambda state: show(value(state)), + align="center", + style={"classes": ["stepperValue"]}, + ), + axidev_osk.button( + id=f"{id}:up", + label="+", + enabled=lambda state: value(state) < bound(maximum, state), + on_release=move(1), + style={"classes": ["stepperButton"]}, + ), + ] + ) + defaults = {"id": id, "direction": "horizontal", "spacing": 4, "children": children} + return axidev_osk.box(**axidev_osk.merge(defaults, opts)) + + +def menu( + id: str, # noqa: A002 + label: Any, + children: Sequence[axidev_osk.Map], + *, + group: str | None = None, + opts: Mapping[str, Any] | None = None, + panel_opts: Mapping[str, Any] | None = None, +) -> tuple[axidev_osk.Map, axidev_osk.Map]: + """Return a button that opens and closes the menu, and the panel it shows. + + The profile places both, usually the button in a strip and the panel in + that strip's panels. Opening the menu closes the other menus of ``group``. + ``opts`` replaces fields of the button and ``panel_opts`` fields of the panel. + """ + + button_defaults = { + "id": id, + "label": label, + "latched": lambda state: menu_open(state, id, group), + "on_release": lambda ctx, event: toggle_menu(ctx.state, id, group), + } + panel_defaults = { + "id": f"{id}:panel", + "visible": lambda state: menu_open(state, id, group), + "spacing": 4, + "children": list(children), + "style": {"classes": ["menuPanel"]}, + } + return axidev_osk.button(**axidev_osk.merge(button_defaults, opts)), axidev_osk.box(**axidev_osk.merge(panel_defaults, panel_opts)) + + +def dropdown( + id: str, # noqa: A002 + choices: Sequence[tuple[Any, str]], + *, + value: Callable[[Any], Any], + on_select: OnSelect, + label: str | None = None, + group: str | None = None, + opts: Mapping[str, Any] | None = None, + panel_opts: Mapping[str, Any] | None = None, +) -> tuple[axidev_osk.Map, axidev_osk.Map]: + """A menu listing ``choices`` as ``(value, text)`` pairs; picking one runs ``on_select`` and closes it. + + ``value`` is a binding naming the current choice, which the button shows + and the panel lights. + """ + + texts = {choice: text for choice, text in choices} + + def button_label(state: Any) -> str: + current = texts.get(value(state), str(value(state))) + return f"{label}: {current} ▾" if label is not None else f"{current} ▾" + + def pick(choice: Any) -> axidev_osk.Callback: + def on_release(ctx: Any, event: Any) -> list[axidev_osk.Map]: + del event + return [*close_menu(id, group), *on_select(ctx, choice)] + + return on_release + + items = [ + axidev_osk.button( + id=f"{id}:choice:{index}", + label=text, + latched=lambda state, choice=choice: value(state) == choice, + on_release=pick(choice), + ) + for index, (choice, text) in enumerate(choices) + ] + return menu(id, button_label, items, group=group, opts=opts, panel_opts=panel_opts) + + +def strip( + id: str, # noqa: A002 + children: Sequence[axidev_osk.Map], + *, + panels: Sequence[axidev_osk.Map] = (), + opts: Mapping[str, Any] | None = None, + toggle_opts: Mapping[str, Any] | None = None, +) -> axidev_osk.Map: + """A bottom row holding a ``⋯`` button that shows ``children`` beside it and ``panels`` above it. + + Closed, the strip is only that button: wide and easy to hit, but one short + row tall. Menus + among ``children`` take ``group=strip_group(id)`` and put their panels in + ``panels``. Closing the strip closes them too. ``opts`` replaces fields of + the strip's outer box and ``toggle_opts`` fields of the ``⋯`` button, whose + style name is ``stripToggle``. + """ + + def is_open(state: Any) -> bool: + return menu_open(state, id) + + def toggle(ctx: Any, event: Any) -> list[axidev_osk.Map]: + del event + if is_open(ctx.state): + return [*close_menu(id), axidev_osk.state.set(["std", "menus", strip_group(id)], None)] + return open_menu(id) + + defaults = { + "id": id, + "spacing": 4, + "children": [ + axidev_osk.box(id=f"{id}:panels", visible=is_open, spacing=4, children=list(panels)), + axidev_osk.box( + id=f"{id}:row", + direction="horizontal", + spacing=6, + children=[ + axidev_osk.box( + id=f"{id}:controls", + direction="horizontal", + spacing=6, + visible=is_open, + stretch=1, + children=list(children), + ), + axidev_osk.spacer(id=f"{id}:gap", visible=lambda state: not is_open(state), stretch=1), + axidev_osk.button( + **axidev_osk.merge( + { + "id": f"{id}:toggle", + "label": "⋯", + "latched": is_open, + "on_release": toggle, + "min_width": 64, + "max_width": 64, + "min_height": 24, + "max_height": 24, + "style": {"object_name": "stripToggle"}, + }, + toggle_opts, + ) + ), + ], + ), + ], + } + return axidev_osk.box(**axidev_osk.merge(defaults, opts)) + + +def with_strip(window: Mapping[str, Any], strip_node: axidev_osk.Map) -> axidev_osk.Map: + """Return ``window`` with ``strip_node`` placed under its content.""" + + content = window["content"] + return axidev_osk.window( + **axidev_osk.merge( + window, + {"content": axidev_osk.box(id=f"{content['id']}:with-strip", children=[content, strip_node])}, + ) + ) + + +def _format_number(value: Number) -> str: + return f"{value:g}" if isinstance(value, float) else str(value) diff --git a/src/axidev_osk/python_defaults/default_profile.py b/src/axidev_osk/python_defaults/default_profile.py index 295803c..61f1447 100644 --- a/src/axidev_osk/python_defaults/default_profile.py +++ b/src/axidev_osk/python_defaults/default_profile.py @@ -1,8 +1,8 @@ -"""Default profile: the bundled US ISO keyboard, written like a Lua config. +"""Default config: the bundled US ISO keyboard and a small social profile, written like a Lua config. Everything here is profile knowledge: the layout table, the grid math that -places keys, which keys latch, the prompts, and how corners and quit requests -behave. The engine knows none of it. +places keys, which keys latch, the settings strip, the prompts, and how +corners and quit requests behave. The engine knows none of it. """ from __future__ import annotations @@ -11,7 +11,7 @@ from . import axidev_osk from .axidev_osk import std -from .axidev_osk.std import keys, prompts, windows +from .axidev_osk.std import controls, keys, prompts, windows from .theme import DEFAULT_FONT, DEFAULT_PALETTE, DEFAULT_QSS, HOT_CORNER_INDICATOR, POINTER_LOCATOR KEYBOARD = "keyboard" @@ -176,6 +176,131 @@ def toggle(ctx: Any, event: Any) -> list[axidev_osk.Map]: return axidev_osk.button(id="dwell", label="Dwell", latched=enabled, on_release=toggle, **placement) +PROFILES = [("default", "Default"), ("social", "Social")] +STRIP_STYLE = {"object_name": "settingsStrip"} + +# Dwell option, caption, minimum, maximum, step, unit. Each range holds the +# default and stays inside the dwell validation; the full speed and stop speed +# bounds follow each other so the two never cross. +DWELL_STEPPERS: list[tuple[str, str, Any, Any, Any, str]] = [ + ("delay_ms", "Delay", 50, 2000, 50, " ms"), + ("dead_zone_px", "Dead zone", 0, 50, 2, " px"), + ( + "full_speed_px_s", + "Full speed", + 0, + lambda state: min(200, axidev_osk.read(state, "settings.dwell.stop_speed_px_s") - 5), + 5, + " px/s", + ), + ( + "stop_speed_px_s", + "Stop speed", + lambda state: axidev_osk.read(state, "settings.dwell.full_speed_px_s") + 5, + 1000, + 20, + " px/s", + ), + ("maximum_progress_rate", "Max progress rate", 1.0, 4.0, 0.25, ""), + ("indicator_start_progress", "Indicator start", 0.0, 1.0, 0.05, ""), + ("direction_reversal_progress_factor", "Reversal factor", 0.0, 1.0, 0.05, ""), + ("movement_penalty_px", "Movement penalty", 1, 100, 1, " px"), + ("distance_curve_full_px", "Distance curve", 10, 1000, 10, " px"), + ("velocity_release_ms", "Velocity release", 10, 1000, 10, " ms"), +] + + +def _with_unit(unit: str) -> Any: + def show(value: float) -> str: + text = f"{value:.2f}" if isinstance(value, float) and not float(value).is_integer() else f"{value:g}" + return f"{text}{unit}" + + return show + + +def _profile_dropdown(window: str, group: str) -> tuple[axidev_osk.Map, axidev_osk.Map]: + return controls.dropdown( + f"{window}:profile-menu", + PROFILES, + value=lambda state: axidev_osk.read(state, "app.profile"), + on_select=lambda ctx, profile: [axidev_osk.profile.switch(profile)], + label="Profile", + group=group, + panel_opts={"direction": "horizontal"}, + ) + + +def _dwell_menu(window: str, group: str) -> tuple[axidev_osk.Map, axidev_osk.Map]: + items: list[axidev_osk.Map] = [ + controls.checkbox( + f"{window}:dwell-enabled", + "Dwell enabled", + "settings.dwell.enabled", + {"cell": {"row": 0, "column": 0}}, + ) + ] + for index, (name, caption, minimum, maximum, step, unit) in enumerate(DWELL_STEPPERS, start=1): + items.append( + controls.stepper( + f"{window}:dwell-{name}", + f"settings.dwell.{name}", + minimum=minimum, + maximum=maximum, + step=step, + label=caption, + format=_with_unit(unit), + opts={"cell": {"row": index // 2, "column": index % 2}}, + ) + ) + # The panel is a two-column grid rather than the default column of controls. + return controls.menu( + f"{window}:dwell-menu", + "Dwell settings", + items, + group=group, + panel_opts={"kind": "grid", "spacing": 6}, + ) + + +def settings_strip(window: str) -> axidev_osk.Map: + """The collapsed settings strip every window carries: profile, keyboard opacity, and dwell settings.""" + + strip_id = f"{window}:strip" + group = controls.strip_group(strip_id) + profile_button, profile_panel = _profile_dropdown(window, group) + dwell_button, dwell_panel = _dwell_menu(window, group) + return controls.strip( + strip_id, + [ + profile_button, + controls.stepper( + f"{window}:opacity", + "settings.opacity", + minimum=0.3, + maximum=1.0, + step=0.05, + label="Opacity", + format=lambda value: f"{value:.2f}", + ), + dwell_button, + ], + panels=[profile_panel, dwell_panel], + opts={"margins": [10, 0, 10, 2], "style": STRIP_STYLE}, + ) + + +def _with_settings(windows: list[axidev_osk.Map]) -> list[axidev_osk.Map]: + """Give every window the settings strip, which takes the place of its content's bottom margin.""" + + framed = [] + for window in windows: + content = window["content"] + left, top, right, _bottom = content.get("margins", [0, 0, 0, 0]) + window = axidev_osk.merge(window, {"content": axidev_osk.merge(content, {"margins": [left, top, right, 0]})}) + framed.append(controls.with_strip(window, settings_strip(window["id"]))) + return framed + + def keyboard_window() -> axidev_osk.Map: return axidev_osk.window( id=KEYBOARD, @@ -224,23 +349,27 @@ def _notice(window: str, title: str, message: str | axidev_osk.Binding) -> axide ) +def _quit_prompt(hint: str | None) -> axidev_osk.Map: + return prompts.prompt_window( + QUIT_PROMPT, + title="Close axidev-osk?", + message="Do you want to close axidev-osk? This will stop OSK input.", + danger=True, + hint=hint, + opts=PROMPT_OPTS, + buttons=[ + prompts.prompt_button(f"{QUIT_PROMPT}:yes", "Yes", lambda ctx, event: [axidev_osk.app.quit()], accept=True), + prompts.prompt_button(f"{QUIT_PROMPT}:no", "No", _hide(QUIT_PROMPT), accept=False), + ], + ) + + def prompt_windows() -> list[axidev_osk.Map]: return [ - prompts.prompt_window( - QUIT_PROMPT, - title="Close axidev-osk?", - message="Do you want to close axidev-osk? This will stop OSK input.", - danger=True, - hint=( - "Tip: if you only want to hide OSK, move your cursor into " - "the screen corner; the hot-corner sensor will hide it without " - "shutting the app down." - ), - opts=PROMPT_OPTS, - buttons=[ - prompts.prompt_button(f"{QUIT_PROMPT}:yes", "Yes", lambda ctx, event: [axidev_osk.app.quit()], accept=True), - prompts.prompt_button(f"{QUIT_PROMPT}:no", "No", _hide(QUIT_PROMPT), accept=False), - ], + _quit_prompt( + "Tip: if you only want to hide OSK, move your cursor into " + "the screen corner; the hot-corner sensor will hide it without " + "shutting the app down." ), prompts.prompt_window( PERMISSION_PROMPT, @@ -310,8 +439,73 @@ def _on_permission_setup_opened(ctx: Any, event: Any) -> list[axidev_osk.Map]: return [axidev_osk.window.show(TERMINAL_NOTICE if event["opened"] else NO_TERMINAL_NOTICE)] +SOCIAL_KEYS = [("↑", "Up"), ("↓", "Down"), ("L", "L"), ("K", "K")] + + +def social_window() -> axidev_osk.Map: + """A small keyboard for scrolling a feed, opening where the main keyboard was.""" + + return axidev_osk.window( + id=KEYBOARD, + title="axidev OSK: social", + opacity=_setting("opacity"), + show_on_start=True, + default_close=False, + overlay=OVERLAY, + chrome={"enabled": True}, + content=axidev_osk.box( + id="social-surface", + margins=[10, 10, 10, 10], + spacing=8, + children=[ + axidev_osk.box( + id="social-keys", + direction="horizontal", + spacing=GRID_GAP_PX, + children=[ + keys.key(label, output, {"min_width": KEY_UNIT_PX * 2, "min_height": KEY_UNIT_PX * 2}) + for label, output in SOCIAL_KEYS + ], + ), + axidev_osk.label( + id="keyboard-status", + text=lambda state: axidev_osk.read(state, "keyboard.status") or "", + visible=lambda state: not axidev_osk.read(state, "keyboard.ready"), + word_wrap=True, + style={"object_name": "statusLabel"}, + ), + ], + ), + ) + + +def social_profile() -> axidev_osk.Map: + return axidev_osk.profile( + state=std.with_state(), + theme={"qss": DEFAULT_QSS, "palette": DEFAULT_PALETTE, "font": DEFAULT_FONT}, + windows=_with_settings([social_window(), _quit_prompt(None)]), + attachments=[ + axidev_osk.pointer_locator( + id="keyboard-locator", + window=KEYBOARD, + radius_percent=30, + maximum_opacity_percent=60, + radius_standard_deviations=3, + **POINTER_LOCATOR, + ), + ], + on=std.with_handlers( + { + "window.close_requested": _on_close_requested, + "app.quit_requested": lambda ctx, event: [axidev_osk.window.show(QUIT_PROMPT)], + "app.activated": lambda ctx, event: windows.reveal(ctx.state, KEYBOARD), + } + ), + ) + + def build_default_config() -> axidev_osk.Map: - """Return the root config of the bundled keyboard, the profile the app runs.""" + """Return the bundled root config: the keyboard the app starts with, and the social profile.""" return axidev_osk.config( active_profile="default", @@ -320,7 +514,7 @@ def build_default_config() -> axidev_osk.Map: "default": axidev_osk.profile( state=std.with_state(), theme={"qss": DEFAULT_QSS, "palette": DEFAULT_PALETTE, "font": DEFAULT_FONT}, - windows=[keyboard_window(), *prompt_windows()], + windows=_with_settings([keyboard_window(), *prompt_windows()]), attachments=[ axidev_osk.dwell( id=DWELL, @@ -352,6 +546,7 @@ def build_default_config() -> axidev_osk.Map: "linux.permission_setup_opened": _on_permission_setup_opened, } ), - ) + ), + "social": social_profile(), }, ) diff --git a/src/axidev_osk/python_defaults/theme.py b/src/axidev_osk/python_defaults/theme.py index 8430ef3..18abe75 100644 --- a/src/axidev_osk/python_defaults/theme.py +++ b/src/axidev_osk/python_defaults/theme.py @@ -232,4 +232,31 @@ background-color: rgba(15, 15, 22, 153); } +QWidget#settingsStrip QPushButton { + font-size: 13px; + font-weight: 500; + border-radius: 8px; + padding: 4px 10px; + min-height: 22px; +} +QWidget#settingsStrip QPushButton#stripToggle { + background: transparent; + border: none; + border-radius: 6px; + padding: 0px; + min-height: 24px; + font-size: 14px; + font-weight: 700; + color: #b9bbc7; +} +QWidget#settingsStrip QPushButton#stripToggle:hover { + background: rgba(255, 255, 255, 18); +} +QWidget#settingsStrip QPushButton#stripToggle[latched="true"] { + color: #e61e8c; +} +QWidget#settingsStrip QLabel { + color: #b9bbc7; + font-size: 13px; +} """ diff --git a/src/axidev_osk/runtime/app_messages.py b/src/axidev_osk/runtime/app_messages.py index ee8e8b0..2cf5d0c 100644 --- a/src/axidev_osk/runtime/app_messages.py +++ b/src/axidev_osk/runtime/app_messages.py @@ -1,7 +1,7 @@ """Messages the application runtime handles or sends. These cover windows, title-bar drags, displays, hot corners, the lock-screen -panel, quit requests, second-launch activation, and Linux permission setup. +panel, profile switching, quit requests, second-launch activation, and Linux permission setup. ``build_engine`` registers every event here, so profiles can handle them; the application registers the action handlers, because they need the objects it owns. Messages the engine itself handles (keyboard, processes, logging, @@ -42,6 +42,7 @@ WINDOW_DRAG_STARTED = "window.drag_started" APP_QUIT = "app.quit" +PROFILE_SWITCH = "profile.switch" LINUX_OPEN_PERMISSION_SETUP = "linux.open_permission_setup" SECURE_INPUT_PANEL_PREPARE = "secure_input_panel.prepare" SECURE_INPUT_PANEL_RELEASE = "secure_input_panel.release" @@ -77,6 +78,11 @@ class AppQuitArguments: exit_code: int +@dataclass(frozen=True, slots=True) +class ProfileSwitchArguments: + profile: str + + @dataclass(frozen=True, slots=True) class WindowArguments: window_id: str @@ -137,6 +143,10 @@ def app_quit(exit_code: int = 0) -> RuntimeAction: return validated_action(APP_QUIT, {"exit_code": exit_code}, decode_app_quit) +def profile_switch(profile: str) -> RuntimeAction: + return validated_action(PROFILE_SWITCH, {"profile": profile}, decode_profile_switch) + + def window_show(window_id: str) -> RuntimeAction: return validated_action(WINDOW_SHOW, {"window": window_id}, decode_window) @@ -186,6 +196,11 @@ def decode_app_quit(arguments: DataMap) -> AppQuitArguments: return AppQuitArguments(exit_code=int_value(arguments, "exit_code") if "exit_code" in arguments else 0) +def decode_profile_switch(arguments: DataMap) -> ProfileSwitchArguments: + require_keys(arguments, ("profile",)) + return ProfileSwitchArguments(profile=non_empty_string_value(arguments, "profile")) + + def decode_window(arguments: DataMap) -> WindowArguments: require_keys(arguments, ("window",)) return WindowArguments(window_id=non_empty_string_value(arguments, "window")) diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index 144a6ac..e7db58c 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -1,8 +1,9 @@ -"""Application runtime: run one profile on the engine. +"""Application runtime: run the profiles of one root config on the engine. The runtime owns process lifecycle, services, windows, and the queue. Every -decision about what appears and how controls behave comes from the active -profile. +decision about what appears and how controls behave comes from the running +profile, which ``profile.switch`` replaces with another profile of the same +root config. """ from __future__ import annotations @@ -18,7 +19,7 @@ from ..application.linux_permissions import open_permission_setup_terminal from ..application.quit_controller import ApplicationQuitController from ..attachments.runtime import AttachmentRuntime -from ..config.profile import ProfileConfig +from ..config.profile import ProfileConfig, RootConfig from ..hot_corner.service import HotCornerService from ..messages import MessageResult from ..services import register_services @@ -30,14 +31,17 @@ APP_QUIT, APP_QUIT_REQUESTED, LINUX_OPEN_PERMISSION_SETUP, + PROFILE_SWITCH, SECURE_INPUT_PANEL_PREPARE, SECURE_INPUT_PANEL_RELEASE, WINDOW_CLOSE_REQUESTED, AppQuitArguments, + ProfileSwitchArguments, WindowArguments, app_quit, app_quit_requested, decode_app_quit, + decode_profile_switch, linux_permission_setup_opened, secure_input_panel_prepared, secure_input_panel_released, @@ -45,7 +49,7 @@ window_show, ) from .decoding import EmptyArguments, decode_empty -from .dispatcher import Dispatcher +from .dispatcher import Dispatcher, Unsubscribe from .engine import build_engine from .event_handlers import PointerDragRouter, RelativePointer, register_display_recovery, register_window_actions from .qt_wake import QtDispatcherWake @@ -56,7 +60,7 @@ class ApplicationRuntime: - """Own QApplication-facing lifecycle, services, the queue, and profile windows.""" + """Own QApplication-facing lifecycle, services, the queue, and the running profile's windows.""" def __init__( self, @@ -100,16 +104,14 @@ def __init__( window_lookup=lambda window_id: self._window_manager.get(window_id), hot_corners=hot_corners if isinstance(hot_corners, HotCornerService) else None, ) - self._profile: ProfileConfig = engine.decoder().decode_root(root_config) - self._window_manager = WindowManager( - {window.id: self._window_factory(window.id) for window in self._profile.windows} - ) - # One decision: ask the profile only when quits may be confirmed and the profile handles the request. - asks = confirm_quit and APP_QUIT_REQUESTED in self._profile.on + self._root: RootConfig = engine.decoder().decode_config(root_config) + self._profile: ProfileConfig = self._root.active + self._unbind_theme: Unsubscribe | None = None + self._window_manager = WindowManager({}) self._quit_controller = ApplicationQuitController( app, send_quit=lambda: self._dispatcher.dispatch(app_quit()), - ask_to_quit=self._ask_to_quit if asks else None, + ask_to_quit=self._ask_to_quit if confirm_quit else None, parent=app, ) relative_pointer = cast("RelativePointer | None", self._services.find("wayland_relative_pointer")) @@ -118,6 +120,7 @@ def __init__( self._pointer_drag = PointerDragRouter(self._window_manager, relative_pointer) self._pointer_drag.register(self._dispatcher) self._dispatcher.register_action(APP_QUIT, decode_app_quit, self._quit, when_idle=True) + self._dispatcher.register_action(PROFILE_SWITCH, decode_profile_switch, self._switch_profile, when_idle=True) self._dispatcher.register_action(SECURE_INPUT_PANEL_PREPARE, decode_empty, self._prepare_secure_input_panel) self._dispatcher.register_action(SECURE_INPUT_PANEL_RELEASE, decode_empty, self._release_secure_input_panel) self._dispatcher.register_action(LINUX_OPEN_PERMISSION_SETUP, decode_empty, self._open_permission_setup) @@ -143,17 +146,10 @@ def start(self) -> int: self._quit_controller.register_quit_callback(service.stop) self._quit_controller.register_quit_callback(self._window_manager.close_all) try: - self.engine.profile.start(self._profile) - theme = self._profile.theme - self.engine.bindings.bind( - theme.decode, - "theme", - lambda values, _previous: apply_theme( - self._app, values, base_palette=self._base_palette, base_font=self._base_font - ), - fallback=theme.initial, + self._dispatcher.dispatch( + *self.engine.profile.set_observed(("app", "profiles"), list(self._root.profiles)) ) - self._attachments.start(self._profile) + self._activate(self._profile) for service in self._services.autostart_services(): service.start(self._dispatcher) if self._show_startup_windows: @@ -168,16 +164,101 @@ def start(self) -> int: QTimer.singleShot(0, self._keyboard.publish_status) return self._app.exec() - def _window_factory(self, window_id: str) -> WindowFactory: - window_config = self._profile.window(window_id) + def _window_factory(self, profile: ProfileConfig, window_id: str) -> WindowFactory: + window_config = profile.window(window_id) def build() -> RuntimeWindow: return build_profile_window(window_config, self.engine, attachments=self._attachments.for_window(window_id)) return build + def _activate(self, profile: ProfileConfig) -> None: + """Start ``profile``: its state and callbacks, theme, attachments, and window factories. + + No window is built here; the caller shows the profile's windows. + """ + + self._profile = profile + engine = self.engine + engine.profile.start(profile) + self._dispatcher.dispatch(*engine.profile.set_observed(("app", "profile"), profile.id)) + theme = profile.theme + self._unbind_theme = engine.bindings.bind( + theme.decode, + "theme", + lambda values, _previous: apply_theme( + self._app, values, base_palette=self._base_palette, base_font=self._base_font + ), + fallback=theme.initial, + ) + self._attachments.start(profile) + self._window_manager.set_factories({window.id: self._window_factory(profile, window.id) for window in profile.windows}) + + def _deactivate(self) -> None: + """Stop the running profile's callbacks, theme, and attachments, and close its windows.""" + + self.engine.profile.stop() + if self._unbind_theme is not None: + self._unbind_theme() + self._unbind_theme = None + self._attachments.stop() + self._window_manager.close_all() + + def _switch_profile(self, arguments: ProfileSwitchArguments) -> MessageResult: + """Replace the running profile once nothing else is waiting. + + Each window the new profile shows on start is built at the position a + window with the same ID last reported, and refits its size to its new + content. If the new profile fails to build, the previous one is built + again and the failure is reported as ``action.failed``. + """ + + target = self._root.profiles.get(arguments.profile) + if target is None: + raise ValueError(f"No profile named {arguments.profile!r} in the root config") + if target is self._profile: + return [] + if self._panel_prepared and not any(attachment.kind == "secure_input_panel" for attachment in target.attachments): + raise ValueError( + f"Profile {target.id!r} has no secure_input_panel attachment for the prepared lock-screen panel" + ) + previous = self._profile + self._deactivate() + try: + self._activate(target) + self._show_switched_windows() + except Exception: + _logger.exception("Failed to build profile %s; building %s again", target.id, previous.id) + self._deactivate() + self._activate(previous) + self._show_switched_windows() + raise + return [] + + def _show_switched_windows(self) -> None: + """Show the running profile's start windows where windows with their IDs last were.""" + + state = self.engine.profile.state + for window_config in self._profile.windows: + if not window_config.show_on_start or not self._show_startup_windows: + continue + window = self._window_manager.get_or_create(window_config.id) + position = state.get(("windows", window_config.id, "position")) + if isinstance(position, dict): + x, y = position["x"], position["y"] + assert isinstance(x, int) and isinstance(y, int) + window.restore_position(x, y) + self._window_manager.show(window_config.id) + if self._panel_prepared: + self._window_manager.show(self._secure_input_panel_window()).set_close_enabled(False) + def _ask_to_quit(self, reason: str) -> None: - self._dispatcher.dispatch(app_quit_requested(reason)) + """Ask the running profile to confirm a quit, or quit when it does not handle quit requests.""" + + if APP_QUIT_REQUESTED in self._profile.on: + self._dispatcher.dispatch(app_quit_requested(reason)) + else: + self._dispatcher.dispatch(app_quit()) def _default_close(self, event: WindowArguments) -> MessageResult: """Apply Qt's close rule unless the window turned it off with ``default_close``. diff --git a/src/axidev_osk/runtime/engine_messages.py b/src/axidev_osk/runtime/engine_messages.py index 877e8a9..83da9e1 100644 --- a/src/axidev_osk/runtime/engine_messages.py +++ b/src/axidev_osk/runtime/engine_messages.py @@ -12,7 +12,7 @@ from dataclasses import asdict, dataclass from typing import Protocol -from ..config.variables import MapOf, Record, Scalar, StatePath, Variable, record_default +from ..config.variables import ListOf, MapOf, Record, Scalar, StatePath, Variable, record_default from ..messages import DataMap, DataValue, MessageResult, RuntimeAction, RuntimeEvent from .decoding import ( bool_value, @@ -79,6 +79,7 @@ def _record(**fields: Variable) -> Variable: needs_permission_setup=_boolean(), permission_setup_text=_string(), ), + ("app",): _record(profile=_string(), profiles=Variable(ListOf(Scalar("string")), [])), ("windows",): Variable( MapOf( Record( @@ -93,7 +94,7 @@ def _record(**fields: Variable) -> Variable: {}, ), } -"""What the engine observes: keys and locks, keyboard output status, and each built window.""" +"""What the engine observes: keys and locks, keyboard output status, the running and available profiles, and each built window.""" @dataclass(frozen=True, slots=True) diff --git a/src/axidev_osk/runtime/window_manager.py b/src/axidev_osk/runtime/window_manager.py index a245c80..5d6cf47 100644 --- a/src/axidev_osk/runtime/window_manager.py +++ b/src/axidev_osk/runtime/window_manager.py @@ -26,6 +26,12 @@ def __init__(self, factories: Mapping[str, WindowFactory]) -> None: self._windows: dict[str, RuntimeWindow] = {} self._factories: dict[str, WindowFactory] = dict(factories) + def set_factories(self, factories: Mapping[str, WindowFactory]) -> None: + """Close every live window and build later windows from ``factories``, such as another profile's.""" + + self.close_all() + self._factories = dict(factories) + def get_or_create(self, window_id: str) -> RuntimeWindow: """Return a live window, building it from its factory if needed.""" diff --git a/src/axidev_osk/windows/builder.py b/src/axidev_osk/windows/builder.py index 33264ee..b0dec32 100644 --- a/src/axidev_osk/windows/builder.py +++ b/src/axidev_osk/windows/builder.py @@ -101,6 +101,7 @@ def __init__( self._chrome_widgets: OverlayChromeWidgets | None = None self._dwell_click: DwellClickController | None = None self._pointer_locators: dict[str, PointerLocator] = {} + self._unbinds: list[Callable[[], None]] = [] self.setProperty(COMPONENT_TYPE, "window") self.setProperty(COMPONENT_ID, window_id) self.setWindowTitle(values.initial.title) @@ -110,7 +111,9 @@ def __init__( else: self._overlay = configure_plain_window(self) try: - central = build_content() + with engine.node_builder.collecting() as node_unbinds: + central = build_content() + self._unbinds.extend(node_unbinds) if chrome.enabled and getattr(self._overlay, "uses_custom_chrome", False): central_layout = central.layout() if isinstance(central_layout, QVBoxLayout): @@ -138,6 +141,7 @@ def __init__( self._dwell_click = DwellClickController(self, central, dwell) self._opacity = WindowOpacityController(self) unbind = engine.bindings.bind(values.decode, f"window:{window_id}", self._apply_values, fallback=values.initial) + self._unbinds.append(unbind) self.destroyed.connect(lambda _object=None: unbind()) self.apply_startup_size(minimum_size=self._minimum_size) except Exception: @@ -230,9 +234,25 @@ def set_close_enabled(self, enabled: bool) -> None: if self._chrome_widgets is not None: self._chrome_widgets.title_bar.set_close_enabled(enabled) + def restore_position(self, x: int, y: int) -> None: + """Place the window's top-left corner at ``(x, y)`` through its overlay backend, before it shows.""" + + restore = getattr(self._overlay, "restore_position", None) + if restore is not None: + restore(x, y) + else: + self.move(x, y) + def release_platform_resources(self) -> None: - """Release native resources and the input block before Qt destroys this window.""" + """Release native resources, the input block, and every binding before Qt destroys this window. + Bindings are dropped here rather than when Qt deletes the widgets + later, so a closed window never follows state again. + """ + + for unbind in self._unbinds: + unbind() + self._unbinds.clear() self._remove_input_blocker() release = getattr(self._overlay, "release_resources", None) if release is not None: @@ -251,6 +271,13 @@ def apply_startup_size(self, *, minimum_size: tuple[int, int] = (0, 0)) -> None: Updates Qt minimum size and current size. """ + fitted = self._fitted_size(minimum_size) + self.setMinimumSize(fitted) + self.resize(fitted) + + def _fitted_size(self, minimum_size: tuple[int, int]) -> QSize: + """Return the smallest size that holds the content and respects ``minimum_size``.""" + self.ensurePolished() central_widget = self.centralWidget() if central_widget is not None: @@ -263,8 +290,22 @@ def apply_startup_size(self, *, minimum_size: tuple[int, int] = (0, 0)) -> None: # Wrapped text is taller at the final width than the size hint assumes. needed_height = central_widget.heightForWidth(resolved_minimum_size.width()) resolved_minimum_size.setHeight(max(resolved_minimum_size.height(), needed_height)) - self.setMinimumSize(resolved_minimum_size) - self.resize(resolved_minimum_size) + return resolved_minimum_size + + def event(self, event: QEvent) -> bool: # type: ignore[override] + """Refit the window when its content needs a different size, such as a panel that shows or hides. + + Only a change in what the content needs resizes the window, so a size + the user chose stays until the content changes. + """ + + handled = super().event(event) + if event.type() == QEvent.Type.LayoutRequest and self.centralWidget() is not None: + fitted = self._fitted_size(self._minimum_size) + if fitted != self.minimumSize(): + self.setMinimumSize(fitted) + self.resize(fitted) + return handled def closeEvent(self, event: QCloseEvent) -> None: # type: ignore[override] """Send ``window.close_requested`` instead of closing; a removed output's close is ignored.""" diff --git a/src/axidev_osk/windows/overlay/always_on_top.py b/src/axidev_osk/windows/overlay/always_on_top.py index edde26b..35d5e09 100644 --- a/src/axidev_osk/windows/overlay/always_on_top.py +++ b/src/axidev_osk/windows/overlay/always_on_top.py @@ -469,6 +469,22 @@ def position(self) -> tuple[int, int]: position = self._window.pos() return (position.x(), position.y()) + def restore_position(self, x: int, y: int) -> None: + """Put the window's top-left corner back at ``(x, y)``, as ``position`` reported it. + + A layer surface is anchored to the screen's left and bottom edges like + every drag leaves it, so a later drag continues from there. KWin + places the input panel itself, so it ignores the request. + """ + + if self._backend == OverlayBackend.WAYLAND_INPUT_PANEL: + return + if self._backend == OverlayBackend.WAYLAND_LAYER_SHELL: + self.move_to_anchored(QPoint(x, y), anchors=ANCHOR_LEFT | ANCHOR_BOTTOM) + return + self._window.move(x, y) + self._floating_position_initialized = True + def move_by(self, dx: int, dy: int) -> None: """Move the overlay by a bounded delta.""" @@ -769,6 +785,11 @@ def position(self) -> tuple[int, int]: position = self._window.pos() return (position.x(), position.y()) + def restore_position(self, x: int, y: int) -> None: + """Put the window's top-left corner back at ``(x, y)``.""" + + self._window.move(x, y) + def move_by(self, _dx: int, _dy: int) -> None: """Ignore overlay move requests for plain windows.""" diff --git a/tests/test_application_runtime.py b/tests/test_application_runtime.py index 4982f86..b64fa01 100644 --- a/tests/test_application_runtime.py +++ b/tests/test_application_runtime.py @@ -7,7 +7,8 @@ from PySide6.QtCore import QEvent from axidev_osk.app import _set_application_icon -from axidev_osk.messages import DataMap, MessageResult, RuntimeAction +from axidev_osk.messages import DataMap, MessageResult, RuntimeAction, RuntimeEvent +from axidev_osk.nodes import BUTTON_RELEASED from axidev_osk.python_defaults import axidev_osk from axidev_osk.python_defaults.default_profile import build_default_config from axidev_osk.runtime.app_messages import ( @@ -32,7 +33,8 @@ from axidev_osk.runtime.profile_runtime import state_set from axidev_osk.runtime.registries import ServiceRegistry from axidev_osk.services.keyboard import KeyboardService -from support import RecordingBackend, has_pending, qt_app +from axidev_osk.windows.builder import build_profile_window +from support import RecordingBackend, has_pending, nodes_by_id, qt_app _KEYBOARD_WINDOW = "keyboard" @@ -47,8 +49,7 @@ def _runtime_without_platform_services( qt_app(), root_config=root_config if root_config is not None else build_default_config(), services=services, - show_startup_windows=False, - **options, + **{"show_startup_windows": False, **options}, ) @@ -313,6 +314,33 @@ def test_default_profile_shows_quit_prompt_when_keyboard_close_is_requested(self close.assert_not_called() exit_app.assert_not_called() + def test_the_profile_dropdown_switches_to_the_social_keyboard_and_back(self) -> None: + runtime = _runtime_without_platform_services(show_startup_windows=True) + _start(self, runtime) + self.addCleanup(runtime._window_manager.close_all) + dispatcher = runtime.engine.dispatcher + dispatcher.dispatch(state_set("settings.opacity", 0.6)) + + def pick(choice: int) -> None: + for node in ("keyboard:strip:toggle", "keyboard:profile-menu", f"keyboard:profile-menu:choice:{choice}"): + dispatcher.dispatch(RuntimeEvent(BUTTON_RELEASED, {"node": node})) + + pick(1) + social = runtime._window_manager.get(_KEYBOARD_WINDOW) + assert social is not None + self.assertTrue(social.isVisible()) + self.assertEqual( + sorted(node for node in nodes_by_id(social) if node.startswith("key:")), + ["key:Down", "key:K", "key:L", "key:Up"], + ) + self.assertAlmostEqual(social.windowOpacity(), 0.6, delta=0.005) + + pick(0) + keyboard = runtime._window_manager.get(_KEYBOARD_WINDOW) + assert keyboard is not None + self.assertIn("keyboard-grid", nodes_by_id(keyboard)) + self.assertEqual(runtime.engine.profile.state.get(("app", "profile")), "default") + def test_the_theme_follows_the_state_it_reads(self) -> None: root = axidev_osk.config( active_profile="p", @@ -487,6 +515,134 @@ def test_window_actions_on_a_defined_window_that_is_not_built_do_nothing(self) - self.assertEqual(runtime._window_manager.all_windows(), []) +def _switch_root(on_go: Any, *, extra: dict[str, axidev_osk.Map] | None = None, panel: bool = False) -> axidev_osk.Map: + """Two profiles, ``a`` and ``b``, each showing a ``pad`` window and recording ``app.activated``.""" + + def profile(name: str, children: list[axidev_osk.Map]) -> axidev_osk.Map: + return axidev_osk.profile( + state={"count": axidev_osk.var("integer", 0), "activated": axidev_osk.var(axidev_osk.list_of("string"))}, + windows=[axidev_osk.window(id="pad", title=name, show_on_start=True, content=axidev_osk.box(id=f"{name}:root", children=children))], + attachments=[axidev_osk.secure_input_panel(id="panel", window="pad")] if panel and name == "a" else [], + on={"app.activated": lambda ctx, event: [axidev_osk.state.set("activated", [*ctx.state.activated, name])]}, + ) + + return axidev_osk.config( + active_profile="a", + state={"kept": axidev_osk.var("integer", 0)}, + profiles={ + "a": profile("a", [axidev_osk.button(id="a:go", on_release=on_go)]), + "b": profile("b", [axidev_osk.button(id="b:only")]), + **(extra or {}), + }, + ) + + +class ProfileSwitchTests(unittest.TestCase): + def _runtime(self, root: axidev_osk.Map, *, show_startup_windows: bool = True) -> ApplicationRuntime: + services = ServiceRegistry() + services.register("keyboard", KeyboardService(RecordingBackend()), autostart=False) + runtime = ApplicationRuntime(qt_app(), root_config=root, services=services, show_startup_windows=show_startup_windows) + _start(self, runtime) + self.addCleanup(runtime._window_manager.close_all) + return runtime + + def _state(self, runtime: ApplicationRuntime, *path: str) -> object: + return runtime.engine.profile.state.get(path) + + def _release(self, runtime: ApplicationRuntime, node: str) -> None: + runtime.engine.dispatcher.dispatch(RuntimeEvent(BUTTON_RELEASED, {"node": node})) + + def test_a_switch_waits_for_queued_work_then_runs_the_other_profile(self) -> None: + runtime = self._runtime( + _switch_root(lambda ctx, event: [axidev_osk.state.set("kept", 7), axidev_osk.profile.switch("b"), axidev_osk.state.set("count", 5)]) + ) + old_window = runtime._window_manager.get("pad") + assert old_window is not None + + self._release(runtime, "a:go") + + self.assertEqual(self._state(runtime, "app"), {"profile": "b", "profiles": ["a", "b"]}) + self.assertEqual(self._state(runtime, "count"), 0) + self.assertEqual(self._state(runtime, "kept"), 7) + new_window = runtime._window_manager.get("pad") + assert new_window is not None + self.assertIsNot(new_window, old_window) + self.assertFalse(old_window.isVisible()) + self.assertTrue(new_window.isVisible()) + self.assertIn("b:only", nodes_by_id(new_window)) + runtime.engine.dispatcher.dispatch(app_activated()) + self.assertEqual(self._state(runtime, "activated"), ["b"]) + + def test_a_window_with_the_same_id_keeps_its_position(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("b")])) + window = runtime._window_manager.get("pad") + assert window is not None + window.move(120, 80) + + self._release(runtime, "a:go") + + moved = runtime._window_manager.get("pad") + assert moved is not None + self.assertEqual((moved.x(), moved.y()), (120, 80)) + + def test_a_profile_that_fails_to_build_brings_the_previous_one_back(self) -> None: + broken = axidev_osk.profile( + windows=[axidev_osk.window(id="pad", title="broken", show_on_start=True, content=axidev_osk.box(id="broken:root"))], + ) + runtime = self._runtime( + _switch_root(lambda ctx, event: [axidev_osk.profile.switch("broken")], extra={"broken": broken}) + ) + failures = _record_failures(runtime) + + def build(config: Any, *args: Any, **kwargs: Any) -> Any: + if config.values.initial.title == "broken": + raise RuntimeError("the window cannot be built") + return build_profile_window(config, *args, **kwargs) + + with patch("axidev_osk.runtime.application.build_profile_window", side_effect=build), self.assertLogs(level="ERROR"): + self._release(runtime, "a:go") + + self.assertEqual( + [(failure.action, failure.message) for failure in failures], + [("profile.switch", "the window cannot be built")], + ) + self.assertEqual(self._state(runtime, "app", "profile"), "a") + window = runtime._window_manager.get("pad") + assert window is not None + self.assertTrue(window.isVisible()) + self.assertIn("a:go", nodes_by_id(window)) + runtime.engine.dispatcher.dispatch(app_activated()) + self.assertEqual(self._state(runtime, "activated"), ["a"]) + + def test_switching_to_an_unknown_profile_fails_and_changes_nothing(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("typo")])) + failures = _record_failures(runtime) + window = runtime._window_manager.get("pad") + + self._release(runtime, "a:go") + + self.assertEqual([failure.message for failure in failures], ["No profile named 'typo' in the root config"]) + self.assertIs(runtime._window_manager.get("pad"), window) + + def test_a_prepared_lock_screen_panel_refuses_a_profile_without_one(self) -> None: + runtime = self._runtime( + _switch_root(lambda ctx, event: [axidev_osk.profile.switch("b")], panel=True), show_startup_windows=False + ) + failures = _record_failures(runtime) + runtime.engine.dispatcher.dispatch(secure_input_panel_prepare()) + panel = runtime._window_manager.get("pad") + + self._release(runtime, "a:go") + + self.assertEqual( + [failure.message for failure in failures], + ["Profile 'b' has no secure_input_panel attachment for the prepared lock-screen panel"], + ) + self.assertIs(runtime._window_manager.get("pad"), panel) + assert panel is not None + self.assertTrue(panel.isVisible()) + + class SecureInputPanelLifecycleTests(unittest.TestCase): def _runtime(self, root_config: axidev_osk.Map | None = None) -> tuple[ApplicationRuntime, RecordingBackend, KeyboardService]: backend = RecordingBackend() diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 82b0789..1492008 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -52,9 +52,14 @@ def set_pointer_locator(self, settings: Any) -> None: class RecordingHotCorners: def __init__(self) -> None: self.configured: list[tuple[Any, frozenset[str]]] = [] + self.active: tuple[Any, frozenset[str]] | None = None def configure(self, settings: Any, corners: frozenset[str]) -> None: self.configured.append((settings, corners)) + self.active = (settings, corners) + + def clear(self) -> None: + self.active = None def _root(attachments: list[axidev_osk.Map], windows: tuple[str, ...] = ("pad",), state: axidev_osk.Map | None = None) -> axidev_osk.Map: @@ -204,6 +209,15 @@ def test_an_attachment_whose_service_is_missing_is_logged_and_the_others_still_a self.assertIn("'hc' does nothing", logs.output[0]) self.assertEqual([config.delay_ms for config in harness.windows["pad"].dwell], [250]) + def test_starting_a_profile_without_hot_corners_turns_them_off(self) -> None: + harness = AttachmentHarness([axidev_osk.hot_corners(id="hc", **HOT_CORNER_COLORS)]) + harness.start() + self.assertIsNotNone(harness.hot_corners.active) + + harness.runtime.start(harness.engine.decoder().decode_root(_root([]))) + + self.assertIsNone(harness.hot_corners.active) + class HotCornerFilterTests(unittest.TestCase): def test_controller_creates_sensors_only_for_configured_corners(self) -> None: diff --git a/tests/test_default_profile.py b/tests/test_default_profile.py index 2d45cf1..0ae2f30 100644 --- a/tests/test_default_profile.py +++ b/tests/test_default_profile.py @@ -231,6 +231,24 @@ def test_dwell_key_toggles_the_dwell_attachment(self) -> None: self.assertIs(harness.engine.profile.state.get(enabled), False) self.assertFalse(harness.nodes["dwell"].property("latched")) + def test_settings_strip_drives_opacity_and_the_dwell_attachment(self) -> None: + harness = self.harness + + self.assertTrue(harness.nodes["keyboard:strip:controls"].isHidden()) + harness.tap("keyboard:strip:toggle") + self.assertFalse(harness.nodes["keyboard:strip:controls"].isHidden()) + + harness.tap("keyboard:opacity:down") + self.assertAlmostEqual(harness.window.windowOpacity(), KEYBOARD_OPACITY - 0.05, delta=0.005) + + harness.tap("keyboard:dwell-menu") + self.assertFalse(harness.nodes["keyboard:dwell-menu:panel"].isHidden()) + harness.tap("keyboard:dwell-enabled") + dwell = harness.attachments.for_window("keyboard").dwell + assert dwell is not None + self.assertTrue(dwell.enabled) + self.assertTrue(harness.nodes["dwell"].property("latched")) + def test_hot_corner_toggles_the_keyboard(self) -> None: harness = self.harness harness.emit(_keyboard_shown()) diff --git a/tests/test_std_library.py b/tests/test_std_library.py index ddd2759..2de7fda 100644 --- a/tests/test_std_library.py +++ b/tests/test_std_library.py @@ -10,7 +10,7 @@ from axidev_osk.nodes import BUTTON_PRESSED, BUTTON_RELEASED from axidev_osk.python_defaults import axidev_osk from axidev_osk.python_defaults.axidev_osk import std -from axidev_osk.python_defaults.axidev_osk.std import keys, prompts, windows +from axidev_osk.python_defaults.axidev_osk.std import controls, keys, prompts, windows from axidev_osk.config.variables import decode_state from axidev_osk.runtime.engine_messages import OBSERVED_VARIABLES, input_key, keyboard_reset from axidev_osk.runtime.state import StateTree @@ -24,6 +24,7 @@ def __init__( children: list[axidev_osk.Map], *, on: dict[str, Any] | None = None, + state: dict[str, Any] | None = None, **window_fields: Any, ) -> None: qt_app() @@ -35,7 +36,7 @@ def __init__( profiles={ "p": axidev_osk.profile( windows=[axidev_osk.window(id="pad", title="Pad", content=axidev_osk.box(id="root", children=children), **window_fields)], - state=std.with_state(), + state=std.with_state(state), on=std.with_handlers(on), ) }, @@ -237,6 +238,103 @@ def custom(ctx: Any, event: Any) -> list[axidev_osk.Map]: self.assertEqual((node["id"], node["on_release"], node["cell"]), ("cow", custom, {"row": 0, "column": 0})) +class ControlTests(unittest.TestCase): + def test_checkbox_flips_its_variable_and_lights_while_true(self) -> None: + harness = StdHarness(self, [controls.checkbox("on", "On", "on")], state={"on": axidev_osk.var("boolean", False)}) + + harness.tap("on") + self.assertIs(harness.state("on"), True) + self.assertTrue(harness.widget("on").property("latched")) + + harness.tap("on") + self.assertIs(harness.state("on"), False) + self.assertFalse(harness.widget("on").property("latched")) + + def test_stepper_moves_by_exact_steps_and_stops_at_its_bounds(self) -> None: + harness = StdHarness( + self, + [controls.stepper("level", "level", minimum=0.3, maximum=1.0, step=0.05, label="Level")], + state={"level": axidev_osk.var("number", 0.85)}, + ) + up = harness.widget("level:up") + + for _tap in range(4): + harness.tap("level:up") + self.assertEqual(harness.state("level"), 1.0) + self.assertEqual(harness.widget("level:value").text(), "1") # type: ignore[attr-defined] + self.assertFalse(up.isEnabled()) + + harness.tap("level:down") + self.assertEqual(harness.state("level"), 0.95) + self.assertTrue(up.isEnabled()) + + def test_linked_steppers_never_cross(self) -> None: + harness = StdHarness( + self, + [ + controls.stepper("low", "low", minimum=0, maximum=lambda state: state.high - 5, step=5), + controls.stepper("high", "high", minimum=lambda state: state.low + 5, maximum=100, step=5), + ], + state={"low": axidev_osk.var("integer", 0), "high": axidev_osk.var("integer", 15)}, + ) + + for _tap in range(5): + harness.tap("low:up") + self.assertEqual(harness.state("low"), 10) + self.assertFalse(harness.widget("high:down").isEnabled()) + + harness.tap("high:up") + harness.tap("low:up") + self.assertEqual((harness.state("low"), harness.state("high")), (15, 20)) + + def test_dropdown_opens_inline_and_picking_a_choice_runs_on_select_and_closes_it(self) -> None: + mode, mode_panel = controls.dropdown( + "mode", + [("a", "Alpha"), ("b", "Beta")], + value=lambda state: state.mode, + on_select=lambda ctx, value: [axidev_osk.state.set("mode", value)], + label="Mode", + group="g", + ) + other, other_panel = controls.menu("other", "Other", [axidev_osk.label(id="other:text", text="hi")], group="g") + harness = StdHarness(self, [mode_panel, other_panel, mode, other], state={"mode": axidev_osk.var("string", "a")}) + panel = harness.widget("mode:panel") + self.assertEqual(harness.widget("mode").text(), "Mode: Alpha ▾") # type: ignore[attr-defined] + self.assertTrue(panel.isHidden()) + + harness.tap("mode") + self.assertFalse(panel.isHidden()) + self.assertTrue(harness.widget("mode:choice:0").property("latched")) + + harness.tap("mode:choice:1") + self.assertEqual(harness.state("mode"), "b") + self.assertTrue(panel.isHidden()) + self.assertEqual(harness.widget("mode").text(), "Mode: Beta ▾") # type: ignore[attr-defined] + + harness.tap("mode") + harness.tap("other") + self.assertTrue(panel.isHidden()) + self.assertFalse(harness.widget("other:panel").isHidden()) + + def test_strip_shows_only_its_toggle_until_opened_and_closes_its_menus_with_it(self) -> None: + group = controls.strip_group("s") + more, more_panel = controls.menu("more", "More", [axidev_osk.label(id="more:text", text="hi")], group=group) + harness = StdHarness(self, [controls.strip("s", [more], panels=[more_panel])]) + controls_row, panel = harness.widget("s:controls"), harness.widget("more:panel") + self.assertTrue(controls_row.isHidden()) + + harness.tap("s:toggle") + self.assertFalse(controls_row.isHidden()) + harness.tap("more") + self.assertFalse(panel.isHidden()) + self.assertFalse(controls_row.isHidden()) + + harness.tap("s:toggle") + self.assertTrue(controls_row.isHidden()) + harness.tap("s:toggle") + self.assertTrue(panel.isHidden()) + + class WindowHelperTests(unittest.TestCase): def test_ghost_button_fades_blocks_and_restores(self) -> None: harness = StdHarness( diff --git a/tests/test_window_builder.py b/tests/test_window_builder.py index cded53c..54094c9 100644 --- a/tests/test_window_builder.py +++ b/tests/test_window_builder.py @@ -352,6 +352,41 @@ def test_startup_size_honors_configured_minimum_size(self) -> None: self.assertGreaterEqual(window.minimumHeight(), 320) self.assertEqual(window.size(), window.minimumSize()) + def test_window_grows_for_a_shown_panel_and_shrinks_back_when_it_hides(self) -> None: + harness = WindowHarness( + state={"open": axidev_osk.var("boolean", False)}, + content=axidev_osk.box( + id="pad:root", + children=[ + axidev_osk.button(id="pad:a", label="a", min_width=200, min_height=100), + axidev_osk.box( + id="pad:panel", + visible=lambda state: state.open, + children=[axidev_osk.button(id="pad:b", label="b", min_height=100)], + ), + ], + ), + ) + window = harness.build() + self.addCleanup(window.deleteLater) + window.show() + app = qt_app() + app.processEvents() + closed_size = window.size() + + def settle() -> None: + # Qt passes a layout change up through each nested box in its own event round. + for _round in range(5): + app.processEvents() + + harness.engine.dispatcher.dispatch(state_set("open", True)) + settle() + self.assertGreaterEqual(window.height(), closed_size.height() + 100) + + harness.engine.dispatcher.dispatch(state_set("open", False)) + settle() + self.assertEqual(window.size(), closed_size) + def test_window_installs_pointer_locator_from_its_attachment(self) -> None: harness = WindowHarness([axidev_osk.pointer_locator(id="glow", window="pad", gap_color="#242424")], windows=("pad", "other")) window = harness.build("pad") From b3cdf78106ab1f5674b12f5af0a1aad983758999 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Mon, 12 Oct 2026 15:14:25 +0200 Subject: [PATCH 3/6] refactor(profiles): fix review findings in the settings strip and switching inayayousfi directed the work and made every decision. Claude Opus 5.5, running in Claude Code, carried inayayousfi's decisions out. A profile switch now runs in two steps. profile.switch tears the running profile down, releasing held keys, then sends profile.start, which also waits for an empty queue, so the closing windows' reports reach no profile. A switch requested in between only replaces the target, so the last one requested starts and two queued switches no longer loop. When the previous profile cannot start again after a failed switch, the report's exception type is ProfileRollbackFailed. Startup and a switch show start windows through one helper, and a floating window restored or moved off screen is moved back inside it. NodeBuilder.build_tree returns a tree's widget with its unbinds, and a window drops them all when Qt deletes it, so a failed build leaves no bindings behind. Setting window factories no longer closes windows, and starting attachments no longer stops them. SETTINGS_STRIP_PLAN.md now matches the code: menus keep the open menu's ID in std.menus., and every window of both profiles has the full strip. AGENTS.md describes the two-step switch. The default profile's parity data is unchanged. --- SETTINGS_STRIP_PLAN.md | 15 ++-- src/axidev_osk/attachments/runtime.py | 1 - src/axidev_osk/nodes/registry.py | 37 ++++---- src/axidev_osk/runtime/app_messages.py | 7 ++ src/axidev_osk/runtime/application.py | 89 ++++++++++++++----- src/axidev_osk/runtime/window_manager.py | 3 +- src/axidev_osk/windows/builder.py | 23 +++-- .../windows/overlay/always_on_top.py | 28 ++++-- tests/support.py | 6 ++ tests/test_application_runtime.py | 71 ++++++++++++++- tests/test_attachments.py | 3 +- tests/test_hot_corner_overlay.py | 28 ++++++ tests/test_nodes.py | 2 +- 13 files changed, 241 insertions(+), 72 deletions(-) diff --git a/SETTINGS_STRIP_PLAN.md b/SETTINGS_STRIP_PLAN.md index 3e993c3..56f4dcb 100644 --- a/SETTINGS_STRIP_PLAN.md +++ b/SETTINGS_STRIP_PLAN.md @@ -20,9 +20,9 @@ Each decision below was made by the project owner. Do not reopen one without a n 3. An inline panel grows the window. The engine shrinks the window back to its minimum size when content shrinks, the way `apply_startup_size` sizes it at show time. The same test showed a layer-shell window growing from 200x100 to 200x200 when a panel appeared and staying at 200x200 after it hid. -4. Checkboxes, steppers, dropdowns, and menus are standard-library helpers built from existing node kinds, not new engine node kinds. A checkbox is a button with a `latched` binding. A dropdown or menu is a button that flips `std.menus..open` plus a box whose `visible` binding reads it. A stepper is a pair of buttons around a label, and its minimum, maximum, and value are bindings, so linked settings can bound each other. +4. Checkboxes, steppers, dropdowns, and menus are standard-library helpers built from existing node kinds, not new engine node kinds. A checkbox is a button with a `latched` binding. A dropdown or menu is a button that sets or clears its ID in `std.menus.` plus a box whose `visible` binding reads it, so menus in one group close each other. A stepper is a pair of buttons around a label, and its minimum, maximum, and value are bindings, so linked settings can bound each other. -5. Profiles switch at runtime through `profile.switch {profile}`. The engine exposes `app.profile` and `app.profiles` as read-only variables. A switch requested from a callback runs once the queue is empty, the same way `app.quit` waits. +5. Profiles switch at runtime through `profile.switch {profile}`. The engine exposes `app.profile` and `app.profiles` as read-only variables. A switch requested from a callback runs once the queue is empty, the same way `app.quit` waits. It tears the running profile down, releasing held keys, then sends the engine's own `profile.start`, which also waits for an empty queue, so the closing windows' reports reach no profile. A switch requested while one is in progress only replaces its target: the last one requested starts. 6. A window whose id exists in both profiles keeps its screen position through `windows..position`, and its size refits to the new content. @@ -31,22 +31,23 @@ Each decision below was made by the project owner. Do not reopen one without a n ## Engine Work - Shrink a window back to its minimum size after its content shrinks. -- `profile.switch`, with teardown of the old profile's windows, bindings, callbacks, and attachments, then the build of the new one. +- `profile.switch`, with teardown of the old profile's windows, bindings, callbacks, attachments, and held keys, then `profile.start` to build the new one. - `app.profile` and `app.profiles` as engine-declared, read-only variables. - Keep `windows..position` across a rebuild on every backend. Layer shell places windows through margins, so each backend needs its own test. -- Rebuild the previous profile when a switch fails. +- Rebuild the previous profile when a switch fails, and report `ProfileRollbackFailed` when that fails too. ## Standard Library - `checkbox`, `stepper`, `dropdown`, and `menu` in `python_defaults/axidev_osk/std/`, each taking `opts` merged last with `axidev_osk.merge`. -- Their state lives under `std`, declared in the registry, for example `std.menus` as a map of booleans. +- `strip`, `with_strip`, and `strip_group`, which put a collapsible row of these controls under any window's content. +- Their state lives under `std`, declared in the registry, for example `std.menus` as a map from group to the ID of its open menu. - Tests in `tests/test_std_library.py`. ## Bundled Config ### Default Keyboard Strip -A strip at the bottom of the `keyboard` window holds: +Every window of both bundled profiles gets the same strip, collapsed behind a `⋯` button until it is opened. It holds: - A profile dropdown listing `default` and `social`. - An opacity stepper, 0.30 to 1.00 in steps of 0.05. @@ -74,7 +75,7 @@ Opacity and every dwell option are declared in the root config's `state`, so the A second profile named `social`, a small keyboard for scrolling a feed: - Four keys: Up, Down, L, and K. -- A strip with only the profile dropdown, so the user can switch back. +- The same strip as every other window, so the user can switch back. - It reuses the window id `keyboard`, so it opens where the keyboard was. - Its opacity binds to the same root variable as the default keyboard. - It has a pointer locator. It has no dwell, hot corners, or lock-screen panel. diff --git a/src/axidev_osk/attachments/runtime.py b/src/axidev_osk/attachments/runtime.py index f57a53f..8d41c81 100644 --- a/src/axidev_osk/attachments/runtime.py +++ b/src/axidev_osk/attachments/runtime.py @@ -50,7 +50,6 @@ def secure_input_panel_window(self) -> str | None: def start(self, profile: ProfileConfig) -> None: """Attach and bind every attachment of ``profile``; the config decoder already checked their references.""" - self.stop() for attachment in profile.attachments: try: apply = self._kinds[attachment.kind].attach(self, attachment.target) diff --git a/src/axidev_osk/nodes/registry.py b/src/axidev_osk/nodes/registry.py index f0379e0..109164c 100644 --- a/src/axidev_osk/nodes/registry.py +++ b/src/axidev_osk/nodes/registry.py @@ -9,8 +9,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterator, Mapping -from contextlib import contextmanager +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -111,25 +110,34 @@ def __init__( self._kinds = kinds self._dispatcher = dispatcher self._bindings = bindings - self._collectors: list[list[Unsubscribe]] = [] + self._unbinds: list[Unsubscribe] | None = None - @contextmanager - def collecting(self) -> Iterator[list[Unsubscribe]]: - """Collect the unbind of every node built inside the block, so its owner can drop them at once. + def build_tree(self, node: NodeConfig) -> tuple[QWidget, list[Unsubscribe]]: + """Build ``node`` and its children, returning the widget and every binding they made. - A closed window drops its bindings right away instead of when Qt - deletes its widgets later, so they never decode against another - profile's state. + The caller owns the bindings and drops them when it closes, so they + never decode against another profile's state. When building fails, + the bindings made so far are dropped before the error is raised. """ unbinds: list[Unsubscribe] = [] - self._collectors.append(unbinds) + self._unbinds = unbinds try: - yield unbinds + widget = self.build(node) + except BaseException: + for unbind in unbinds: + unbind() + raise finally: - self._collectors.pop() + self._unbinds = None + return widget, unbinds def build(self, node: NodeConfig) -> QWidget: + """Build one node inside ``build_tree``; node kinds call it for their children.""" + + unbinds = self._unbinds + if unbinds is None: + raise RuntimeError("Nodes are built through build_tree, which owns their bindings") kind = self._kinds[node.kind] widget = kind.build(node, self) widget.setProperty(COMPONENT_TYPE, node.kind) @@ -141,10 +149,7 @@ def apply(values: NodeValues, previous: NodeValues | None) -> None: apply_style(widget, values.style, previous.style if previous is not None else None) kind.apply(widget, values.options, previous.options if previous is not None else None) - unbind = self._bindings.bind(node.values.decode, f"node:{node.id}", apply, fallback=node.values.initial) - widget.destroyed.connect(lambda _object=None: unbind()) - if self._collectors: - self._collectors[-1].append(unbind) + unbinds.append(self._bindings.bind(node.values.decode, f"node:{node.id}", apply, fallback=node.values.initial)) return widget def emit(self, event_name: str, node_id: str) -> None: diff --git a/src/axidev_osk/runtime/app_messages.py b/src/axidev_osk/runtime/app_messages.py index 2cf5d0c..99f9046 100644 --- a/src/axidev_osk/runtime/app_messages.py +++ b/src/axidev_osk/runtime/app_messages.py @@ -43,6 +43,7 @@ APP_QUIT = "app.quit" PROFILE_SWITCH = "profile.switch" +PROFILE_START = "profile.start" LINUX_OPEN_PERMISSION_SETUP = "linux.open_permission_setup" SECURE_INPUT_PANEL_PREPARE = "secure_input_panel.prepare" SECURE_INPUT_PANEL_RELEASE = "secure_input_panel.release" @@ -147,6 +148,12 @@ def profile_switch(profile: str) -> RuntimeAction: return validated_action(PROFILE_SWITCH, {"profile": profile}, decode_profile_switch) +def profile_start() -> RuntimeAction: + """Start the profile a switch tore the running one down for; the switch sends it.""" + + return validated_action(PROFILE_START, {}, decode_empty) + + def window_show(window_id: str) -> RuntimeAction: return validated_action(WINDOW_SHOW, {"window": window_id}, decode_window) diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index e7db58c..9211129 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -31,6 +31,7 @@ APP_QUIT, APP_QUIT_REQUESTED, LINUX_OPEN_PERMISSION_SETUP, + PROFILE_START, PROFILE_SWITCH, SECURE_INPUT_PANEL_PREPARE, SECURE_INPUT_PANEL_RELEASE, @@ -43,10 +44,10 @@ decode_app_quit, decode_profile_switch, linux_permission_setup_opened, + profile_start, secure_input_panel_prepared, secure_input_panel_released, window_close, - window_show, ) from .decoding import EmptyArguments, decode_empty from .dispatcher import Dispatcher, Unsubscribe @@ -59,6 +60,16 @@ _logger = logging.getLogger(__name__) +class ProfileRollbackFailed(RuntimeError): + """The previous profile could not start again after a switch failed, so no profile runs.""" + + def __init__(self, previous: str, target: str, error: Exception) -> None: + super().__init__( + f"Profile {previous!r} could not start again after switching to {target!r} failed: " + f"{type(error).__name__}: {error}" + ) + + class ApplicationRuntime: """Own QApplication-facing lifecycle, services, the queue, and the running profile's windows.""" @@ -107,6 +118,7 @@ def __init__( self._root: RootConfig = engine.decoder().decode_config(root_config) self._profile: ProfileConfig = self._root.active self._unbind_theme: Unsubscribe | None = None + self._switching_to: str | None = None self._window_manager = WindowManager({}) self._quit_controller = ApplicationQuitController( app, @@ -121,6 +133,7 @@ def __init__( self._pointer_drag.register(self._dispatcher) self._dispatcher.register_action(APP_QUIT, decode_app_quit, self._quit, when_idle=True) self._dispatcher.register_action(PROFILE_SWITCH, decode_profile_switch, self._switch_profile, when_idle=True) + self._dispatcher.register_action(PROFILE_START, decode_empty, self._start_switched_profile, when_idle=True) self._dispatcher.register_action(SECURE_INPUT_PANEL_PREPARE, decode_empty, self._prepare_secure_input_panel) self._dispatcher.register_action(SECURE_INPUT_PANEL_RELEASE, decode_empty, self._release_secure_input_panel) self._dispatcher.register_action(LINUX_OPEN_PERMISSION_SETUP, decode_empty, self._open_permission_setup) @@ -133,8 +146,7 @@ def profile(self) -> ProfileConfig: def start(self) -> int: """Start the profile, services, and startup windows, then run the Qt event loop. - Startup windows are shown through the queue, so every window is built - inside a queue drain like the windows profiles show later. Shutdown + Startup and a profile switch show start windows the same way. Shutdown closes the queue, then stops the profile, so its callbacks never see teardown. The shutdown steps are registered first: when a step of starting fails, they run before the error is raised. @@ -152,10 +164,7 @@ def start(self) -> int: self._activate(self._profile) for service in self._services.autostart_services(): service.start(self._dispatcher) - if self._show_startup_windows: - self._dispatcher.dispatch( - *(window_show(window.id) for window in self._profile.windows if window.show_on_start) - ) + self._show_start_windows() except BaseException: self._quit_controller.shutdown(1) raise @@ -195,9 +204,15 @@ def _activate(self, profile: ProfileConfig) -> None: self._window_manager.set_factories({window.id: self._window_factory(profile, window.id) for window in profile.windows}) def _deactivate(self) -> None: - """Stop the running profile's callbacks, theme, and attachments, and close its windows.""" + """Tear the running profile down: callbacks, held keys, theme, attachments, then windows. + + This is the one place a profile stops while the app keeps running. + Held keys are released because the profile that latched them, and + its record of the latch, are gone. + """ self.engine.profile.stop() + self._keyboard.reset_state() if self._unbind_theme is not None: self._unbind_theme() self._unbind_theme = None @@ -205,38 +220,64 @@ def _deactivate(self) -> None: self._window_manager.close_all() def _switch_profile(self, arguments: ProfileSwitchArguments) -> MessageResult: - """Replace the running profile once nothing else is waiting. + """Tear the running profile down and send ``profile.start`` for the requested one. - Each window the new profile shows on start is built at the position a - window with the same ID last reported, and refits its size to its new - content. If the new profile fails to build, the previous one is built - again and the failure is reported as ``action.failed``. + Both actions run once nothing else is waiting, so the reports of the + closing windows reach no profile. A switch requested while one is in + progress only replaces its target: the last one requested starts. """ target = self._root.profiles.get(arguments.profile) if target is None: raise ValueError(f"No profile named {arguments.profile!r} in the root config") + self._refuse_without_panel(target) + if self._switching_to is not None: + self._switching_to = target.id + return [] if target is self._profile: return [] - if self._panel_prepared and not any(attachment.kind == "secure_input_panel" for attachment in target.attachments): - raise ValueError( - f"Profile {target.id!r} has no secure_input_panel attachment for the prepared lock-screen panel" - ) - previous = self._profile self._deactivate() + self._switching_to = target.id + return [profile_start()] + + def _start_switched_profile(self, arguments: EmptyArguments) -> MessageResult: + """Start the profile a switch is moving to. + + Each window it shows on start is built at the position a window with + the same ID last reported. If it fails to build, the previous profile + starts again, with its own variables back at their defaults, and the + failure is reported as ``action.failed``; if that fails too, the + report is ``ProfileRollbackFailed``. + """ + + del arguments + if self._switching_to is None: + raise ValueError("No profile switch is in progress") + target = self._root.profiles[self._switching_to] + self._switching_to = None + previous = self._profile try: self._activate(target) - self._show_switched_windows() - except Exception: + self._show_start_windows() + except Exception as error: _logger.exception("Failed to build profile %s; building %s again", target.id, previous.id) self._deactivate() - self._activate(previous) - self._show_switched_windows() + try: + self._activate(previous) + self._show_start_windows() + except Exception as rollback_error: + raise ProfileRollbackFailed(previous.id, target.id, error) from rollback_error raise return [] - def _show_switched_windows(self) -> None: - """Show the running profile's start windows where windows with their IDs last were.""" + def _refuse_without_panel(self, target: ProfileConfig) -> None: + """Reject a switch that would leave a prepared lock-screen panel without a window.""" + + if self._panel_prepared and not any(attachment.kind == "secure_input_panel" for attachment in target.attachments): + raise ValueError(f"Profile {target.id!r} has no secure_input_panel attachment for the prepared lock-screen panel") + + def _show_start_windows(self) -> None: + """Show the running profile's start windows where windows with their IDs last were, and a prepared panel.""" state = self.engine.profile.state for window_config in self._profile.windows: diff --git a/src/axidev_osk/runtime/window_manager.py b/src/axidev_osk/runtime/window_manager.py index 5d6cf47..86c8057 100644 --- a/src/axidev_osk/runtime/window_manager.py +++ b/src/axidev_osk/runtime/window_manager.py @@ -27,9 +27,8 @@ def __init__(self, factories: Mapping[str, WindowFactory]) -> None: self._factories: dict[str, WindowFactory] = dict(factories) def set_factories(self, factories: Mapping[str, WindowFactory]) -> None: - """Close every live window and build later windows from ``factories``, such as another profile's.""" + """Build later windows from ``factories``, such as another profile's; live windows stay as they are.""" - self.close_all() self._factories = dict(factories) def get_or_create(self, window_id: str) -> RuntimeWindow: diff --git a/src/axidev_osk/windows/builder.py b/src/axidev_osk/windows/builder.py index b0dec32..6033aae 100644 --- a/src/axidev_osk/windows/builder.py +++ b/src/axidev_osk/windows/builder.py @@ -81,7 +81,7 @@ def __init__( overlay: OverlayConfig, chrome: ChromeConfig, values: Live[WindowValues], - build_content: Callable[[], QWidget], + build_content: Callable[[], tuple[QWidget, list[Callable[[], None]]]], engine: Engine, dwell: DwellClickConfig | None = None, ) -> None: @@ -111,8 +111,7 @@ def __init__( else: self._overlay = configure_plain_window(self) try: - with engine.node_builder.collecting() as node_unbinds: - central = build_content() + central, node_unbinds = build_content() self._unbinds.extend(node_unbinds) if chrome.enabled and getattr(self._overlay, "uses_custom_chrome", False): central_layout = central.layout() @@ -142,7 +141,9 @@ def __init__( self._opacity = WindowOpacityController(self) unbind = engine.bindings.bind(values.decode, f"window:{window_id}", self._apply_values, fallback=values.initial) self._unbinds.append(unbind) - self.destroyed.connect(lambda _object=None: unbind()) + # A window Qt deletes without closing it first drops its bindings too. + unbinds = self._unbinds + self.destroyed.connect(lambda _object=None: [drop() for drop in unbinds]) self.apply_startup_size(minimum_size=self._minimum_size) except Exception: self.release_platform_resources() @@ -237,11 +238,7 @@ def set_close_enabled(self, enabled: bool) -> None: def restore_position(self, x: int, y: int) -> None: """Place the window's top-left corner at ``(x, y)`` through its overlay backend, before it shows.""" - restore = getattr(self._overlay, "restore_position", None) - if restore is not None: - restore(x, y) - else: - self.move(x, y) + self._overlay.restore_position(x, y) def release_platform_resources(self) -> None: """Release native resources, the input block, and every binding before Qt destroys this window. @@ -305,6 +302,7 @@ def event(self, event: QEvent) -> bool: # type: ignore[override] if fitted != self.minimumSize(): self.setMinimumSize(fitted) self.resize(fitted) + self._overlay.keep_on_screen() return handled def closeEvent(self, event: QCloseEvent) -> None: # type: ignore[override] @@ -380,7 +378,7 @@ def build_profile_window( installed = attachments or WindowAttachments() - def build_content() -> QWidget: + def build_content() -> tuple[QWidget, list[Callable[[], None]]]: surface = RootSurface() surface.setObjectName("rootSurface") surface.setProperty(COMPONENT_TYPE, "surface") @@ -389,8 +387,9 @@ def build_content() -> QWidget: layout = QVBoxLayout(surface) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - layout.addWidget(engine.node_builder.build(config.content)) - return surface + content, unbinds = engine.node_builder.build_tree(config.content) + layout.addWidget(content) + return surface, unbinds window = RuntimeWindow( window_id=config.id, diff --git a/src/axidev_osk/windows/overlay/always_on_top.py b/src/axidev_osk/windows/overlay/always_on_top.py index 35d5e09..f56933d 100644 --- a/src/axidev_osk/windows/overlay/always_on_top.py +++ b/src/axidev_osk/windows/overlay/always_on_top.py @@ -484,6 +484,7 @@ def restore_position(self, x: int, y: int) -> None: return self._window.move(x, y) self._floating_position_initialized = True + self.keep_on_screen() def move_by(self, dx: int, dy: int) -> None: """Move the overlay by a bounded delta.""" @@ -492,14 +493,26 @@ def move_by(self, dx: int, dy: int) -> None: self._move_layer_shell_by(dx, dy) return + self._window.move(self._window.x() + dx, self._window.y() + dy) + self._floating_position_initialized = True + self.keep_on_screen() + + def keep_on_screen(self) -> None: + """Move a floating window back inside its screen's available area. + + Layer-shell margins are bounded when they move, and KWin places the + input panel itself, so neither changes here. + """ + + if self._backend in (OverlayBackend.WAYLAND_LAYER_SHELL, OverlayBackend.WAYLAND_INPUT_PANEL): + return geometry = self._current_screen_geometry() max_x = geometry.x() + max(0, geometry.width() - self._window.width()) max_y = geometry.y() + max(0, geometry.height() - self._window.height()) - - next_x = max(geometry.x(), min(self._window.x() + dx, max_x)) - next_y = max(geometry.y(), min(self._window.y() + dy, max_y)) - self._window.move(next_x, next_y) - self._floating_position_initialized = True + next_x = max(geometry.x(), min(self._window.x(), max_x)) + next_y = max(geometry.y(), min(self._window.y(), max_y)) + if (next_x, next_y) != (self._window.x(), self._window.y()): + self._window.move(next_x, next_y) def resize_by(self, dx: int, dy: int) -> None: """Resize the overlay by a bounded delta.""" @@ -790,6 +803,11 @@ def restore_position(self, x: int, y: int) -> None: self._window.move(x, y) + def keep_on_screen(self) -> None: + """Leave plain windows where the window manager put them.""" + + return + def move_by(self, _dx: int, _dy: int) -> None: """Ignore overlay move requests for plain windows.""" diff --git a/tests/support.py b/tests/support.py index 9284ad0..7ad0a38 100644 --- a/tests/support.py +++ b/tests/support.py @@ -128,6 +128,12 @@ def position(self) -> tuple[int, int]: def move_by(self, dx: int, dy: int) -> None: del dx, dy + def restore_position(self, x: int, y: int) -> None: + del x, y + + def keep_on_screen(self) -> None: + return None + def resize_by(self, dx: int, dy: int) -> None: del dx, dy diff --git a/tests/test_application_runtime.py b/tests/test_application_runtime.py index b64fa01..f4559d5 100644 --- a/tests/test_application_runtime.py +++ b/tests/test_application_runtime.py @@ -18,6 +18,7 @@ app_activated, app_quit, pointer_motion_observed, + profile_switch, secure_input_panel_prepare, secure_input_panel_release, window_close, @@ -29,7 +30,7 @@ ) from axidev_osk.runtime.application import ApplicationRuntime from axidev_osk.runtime.dispatcher import ACTION_FAILED, ActionFailedArguments -from axidev_osk.runtime.engine_messages import keyboard_reset +from axidev_osk.runtime.engine_messages import keyboard_down, keyboard_reset from axidev_osk.runtime.profile_runtime import state_set from axidev_osk.runtime.registries import ServiceRegistry from axidev_osk.services.keyboard import KeyboardService @@ -537,10 +538,21 @@ def profile(name: str, children: list[axidev_osk.Map]) -> axidev_osk.Map: ) +def _profile_c() -> axidev_osk.Map: + """A profile that records the ``visible`` value of every ``window.state_changed`` it hears.""" + + return axidev_osk.profile( + state={"seen": axidev_osk.var(axidev_osk.list_of("boolean"))}, + windows=[axidev_osk.window(id="pad", title="c", show_on_start=True, content=axidev_osk.box(id="c:root"))], + on={"window.state_changed": lambda ctx, event: [axidev_osk.state.set("seen", [*ctx.state.seen, event["visible"]])]}, + ) + + class ProfileSwitchTests(unittest.TestCase): def _runtime(self, root: axidev_osk.Map, *, show_startup_windows: bool = True) -> ApplicationRuntime: + self.backend = RecordingBackend() services = ServiceRegistry() - services.register("keyboard", KeyboardService(RecordingBackend()), autostart=False) + services.register("keyboard", KeyboardService(self.backend), autostart=False) runtime = ApplicationRuntime(qt_app(), root_config=root, services=services, show_startup_windows=show_startup_windows) _start(self, runtime) self.addCleanup(runtime._window_manager.close_all) @@ -604,7 +616,7 @@ def build(config: Any, *args: Any, **kwargs: Any) -> Any: self.assertEqual( [(failure.action, failure.message) for failure in failures], - [("profile.switch", "the window cannot be built")], + [("profile.start", "the window cannot be built")], ) self.assertEqual(self._state(runtime, "app", "profile"), "a") window = runtime._window_manager.get("pad") @@ -614,6 +626,59 @@ def build(config: Any, *args: Any, **kwargs: Any) -> Any: runtime.engine.dispatcher.dispatch(app_activated()) self.assertEqual(self._state(runtime, "activated"), ["a"]) + def test_a_failed_rollback_reports_its_own_error(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("b")])) + failures = _record_failures(runtime) + + with ( + patch("axidev_osk.runtime.application.build_profile_window", side_effect=RuntimeError("no window")), + self.assertLogs(level="ERROR"), + ): + self._release(runtime, "a:go") + + self.assertEqual( + [(failure.action, failure.exception_type) for failure in failures], + [("profile.start", "ProfileRollbackFailed")], + ) + + def test_a_switch_releases_held_keys(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("b")])) + runtime.engine.dispatcher.dispatch(keyboard_down("A")) + + self._release(runtime, "a:go") + + self.assertEqual(self.backend.sent[-1], ("up", "A")) + self.assertEqual(self._state(runtime, "app", "profile"), "b") + + def test_the_last_of_several_queued_switches_wins(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [], extra={"c": _profile_c()})) + + runtime.engine.dispatcher.dispatch(profile_switch("b"), profile_switch("c")) + + self.assertEqual(self._state(runtime, "app", "profile"), "c") + window = runtime._window_manager.get("pad") + assert window is not None + self.assertIn("c:root", nodes_by_id(window)) + + def test_the_new_profile_hears_nothing_from_the_old_windows(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("c")], extra={"c": _profile_c()})) + + self._release(runtime, "a:go") + + self.assertEqual(self._state(runtime, "app", "profile"), "c") + # Only the new window's own reports while it is built and shown; the + # old window's closing reports ran before c started. + self.assertEqual(self._state(runtime, "seen"), [False, False, True]) + + def test_a_quit_queued_after_a_switch_still_exits(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [])) + + with patch.object(qt_app(), "exit") as exit_app: + runtime.engine.dispatcher.dispatch(profile_switch("b"), app_quit()) + + exit_app.assert_called_once_with(0) + self.assertEqual([window for window in runtime._window_manager.all_windows() if window.isVisible()], []) + def test_switching_to_an_unknown_profile_fails_and_changes_nothing(self) -> None: runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("typo")])) failures = _record_failures(runtime) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 1492008..bfc4213 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -209,11 +209,12 @@ def test_an_attachment_whose_service_is_missing_is_logged_and_the_others_still_a self.assertIn("'hc' does nothing", logs.output[0]) self.assertEqual([config.delay_ms for config in harness.windows["pad"].dwell], [250]) - def test_starting_a_profile_without_hot_corners_turns_them_off(self) -> None: + def test_stopping_turns_hot_corners_off_until_a_profile_with_them_starts(self) -> None: harness = AttachmentHarness([axidev_osk.hot_corners(id="hc", **HOT_CORNER_COLORS)]) harness.start() self.assertIsNotNone(harness.hot_corners.active) + harness.runtime.stop() harness.runtime.start(harness.engine.decoder().decode_root(_root([]))) self.assertIsNone(harness.hot_corners.active) diff --git a/tests/test_hot_corner_overlay.py b/tests/test_hot_corner_overlay.py index ef41ea8..1efff8d 100644 --- a/tests/test_hot_corner_overlay.py +++ b/tests/test_hot_corner_overlay.py @@ -255,6 +255,34 @@ def test_x11_move_by_remains_immediate(self) -> None: self.assertEqual(window.moves, [(12, 34)]) + def test_x11_move_by_keeps_the_window_on_screen(self) -> None: + window = FakeWindow() + with patch.object( + AlwaysOnTopWindowController, + "_detect_backend", + return_value=OverlayBackend.X11_UTILITY, + ): + controller = AlwaysOnTopWindowController(window) + + with patch.object(controller, "_current_screen_geometry", return_value=QRect(0, 0, 800, 600)): + controller.move_by(900, -50) + + self.assertEqual((window.x(), window.y()), (700, 0)) + + def test_x11_restored_position_is_kept_on_screen(self) -> None: + window = FakeWindow() + with patch.object( + AlwaysOnTopWindowController, + "_detect_backend", + return_value=OverlayBackend.X11_UTILITY, + ): + controller = AlwaysOnTopWindowController(window) + + with patch.object(controller, "_current_screen_geometry", return_value=QRect(0, 0, 800, 600)): + controller.restore_position(2000, 590) + + self.assertEqual((window.x(), window.y()), (700, 540)) + def test_wayland_layer_shell_manual_move_persists_across_show(self) -> None: window = FakeWindow() calls: list[tuple[int, QMargins]] = [] diff --git a/tests/test_nodes.py b/tests/test_nodes.py index e79ff01..828ddf7 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -110,7 +110,7 @@ def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 watcher = ShowWatcher() qt_app().installEventFilter(watcher) try: - row = self.engine.node_builder.build(profile.window("pad").content) + row, _unbinds = self.engine.node_builder.build_tree(profile.window("pad").content) finally: qt_app().removeEventFilter(watcher) self.addCleanup(row.deleteLater) From 96dc5b3e9cee6e6a01f5232bdfac898ee111d2bf Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Tue, 13 Oct 2026 13:04:55 +0200 Subject: [PATCH 4/6] refactor(profiles): fix the second round of review findings in switching inayayousfi directed the work and made every decision. Claude Opus 5.5, running in Claude Code, carried inayayousfi's decisions out. Between two profiles, a window action now reports action.failed instead of building one of the old profile's windows, which the new profile then reused. Tearing a profile down empties the window factories, and stopping attachments forgets the lock-screen panel's window. Starting attachments while others still run, or replacing window factories while windows are live, raises instead of relying on the caller's order. Startup still shows its windows outside a queue drain. That is safe now that a binding failing while it is bound posts its report, and the docstring says so. A nested build_tree keeps the outer build's bindings. The bundled profiles and the profile menu read one table of names, captions, and builders, so the two cannot disagree. Smaller cleanups: the position restore trusts the state registry's types, a float is no longer converted to float, and the strip docstring wraps evenly. AGENTS.md describes the gap between two profiles. --- src/axidev_osk/attachments/runtime.py | 8 +- src/axidev_osk/nodes/registry.py | 3 +- .../axidev_osk/std/controls.py | 9 +- .../python_defaults/default_profile.py | 94 ++++++++++--------- src/axidev_osk/runtime/application.py | 16 ++-- src/axidev_osk/runtime/window_manager.py | 8 +- tests/test_application_runtime.py | 23 ++++- tests/test_attachments.py | 7 ++ 8 files changed, 110 insertions(+), 58 deletions(-) diff --git a/src/axidev_osk/attachments/runtime.py b/src/axidev_osk/attachments/runtime.py index 8d41c81..d1102f1 100644 --- a/src/axidev_osk/attachments/runtime.py +++ b/src/axidev_osk/attachments/runtime.py @@ -48,8 +48,14 @@ def secure_input_panel_window(self) -> str | None: return self._secure_input_panel_window def start(self, profile: ProfileConfig) -> None: - """Attach and bind every attachment of ``profile``; the config decoder already checked their references.""" + """Attach and bind every attachment of ``profile``; the config decoder already checked their references. + Raises when another profile's attachments are still running: the + caller stops them first. + """ + + if self._unbinds: + raise RuntimeError("Attachments are still running; stop them before starting a profile's") for attachment in profile.attachments: try: apply = self._kinds[attachment.kind].attach(self, attachment.target) diff --git a/src/axidev_osk/nodes/registry.py b/src/axidev_osk/nodes/registry.py index 109164c..ad5d870 100644 --- a/src/axidev_osk/nodes/registry.py +++ b/src/axidev_osk/nodes/registry.py @@ -121,6 +121,7 @@ def build_tree(self, node: NodeConfig) -> tuple[QWidget, list[Unsubscribe]]: """ unbinds: list[Unsubscribe] = [] + outer = self._unbinds self._unbinds = unbinds try: widget = self.build(node) @@ -129,7 +130,7 @@ def build_tree(self, node: NodeConfig) -> tuple[QWidget, list[Unsubscribe]]: unbind() raise finally: - self._unbinds = None + self._unbinds = outer return widget, unbinds def build(self, node: NodeConfig) -> QWidget: diff --git a/src/axidev_osk/python_defaults/axidev_osk/std/controls.py b/src/axidev_osk/python_defaults/axidev_osk/std/controls.py index 06825a5..0fad32e 100644 --- a/src/axidev_osk/python_defaults/axidev_osk/std/controls.py +++ b/src/axidev_osk/python_defaults/axidev_osk/std/controls.py @@ -225,11 +225,10 @@ def strip( """A bottom row holding a ``⋯`` button that shows ``children`` beside it and ``panels`` above it. Closed, the strip is only that button: wide and easy to hit, but one short - row tall. Menus - among ``children`` take ``group=strip_group(id)`` and put their panels in - ``panels``. Closing the strip closes them too. ``opts`` replaces fields of - the strip's outer box and ``toggle_opts`` fields of the ``⋯`` button, whose - style name is ``stripToggle``. + row tall. Menus among ``children`` take ``group=strip_group(id)`` and put + their panels in ``panels``. Closing the strip closes them too. ``opts`` + replaces fields of the strip's outer box and ``toggle_opts`` fields of the + ``⋯`` button, whose style name is ``stripToggle``. """ def is_open(state: Any) -> bool: diff --git a/src/axidev_osk/python_defaults/default_profile.py b/src/axidev_osk/python_defaults/default_profile.py index 61f1447..a9a6fe9 100644 --- a/src/axidev_osk/python_defaults/default_profile.py +++ b/src/axidev_osk/python_defaults/default_profile.py @@ -7,6 +7,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any from . import axidev_osk @@ -176,7 +177,6 @@ def toggle(ctx: Any, event: Any) -> list[axidev_osk.Map]: return axidev_osk.button(id="dwell", label="Dwell", latched=enabled, on_release=toggle, **placement) -PROFILES = [("default", "Default"), ("social", "Social")] STRIP_STYLE = {"object_name": "settingsStrip"} # Dwell option, caption, minimum, maximum, step, unit. Each range holds the @@ -212,7 +212,7 @@ def toggle(ctx: Any, event: Any) -> list[axidev_osk.Map]: def _with_unit(unit: str) -> Any: def show(value: float) -> str: - text = f"{value:.2f}" if isinstance(value, float) and not float(value).is_integer() else f"{value:g}" + text = f"{value:.2f}" if isinstance(value, float) and not value.is_integer() else f"{value:g}" return f"{text}{unit}" return show @@ -221,7 +221,7 @@ def show(value: float) -> str: def _profile_dropdown(window: str, group: str) -> tuple[axidev_osk.Map, axidev_osk.Map]: return controls.dropdown( f"{window}:profile-menu", - PROFILES, + [(name, caption) for name, caption, _build in PROFILES], value=lambda state: axidev_osk.read(state, "app.profile"), on_select=lambda ctx, profile: [axidev_osk.profile.switch(profile)], label="Profile", @@ -504,49 +504,59 @@ def social_profile() -> axidev_osk.Map: ) +def keyboard_profile() -> axidev_osk.Map: + """The keyboard the app starts with.""" + + return axidev_osk.profile( + state=std.with_state(), + theme={"qss": DEFAULT_QSS, "palette": DEFAULT_PALETTE, "font": DEFAULT_FONT}, + windows=_with_settings([keyboard_window(), *prompt_windows()]), + attachments=[ + axidev_osk.dwell( + id=DWELL, + window=KEYBOARD, + **{name: _setting("dwell", name) for name in DWELL_SETTINGS}, + ), + axidev_osk.pointer_locator( + id="keyboard-locator", + window=KEYBOARD, + radius_percent=30, + maximum_opacity_percent=60, + radius_standard_deviations=3, + **POINTER_LOCATOR, + ), + axidev_osk.hot_corners( + id="hot-corners", + corners=["top_left", "top_right", "bottom_left", "bottom_right"], + **HOT_CORNER_INDICATOR, + ), + axidev_osk.secure_input_panel(id="lock-screen-panel", window=KEYBOARD), + ], + on=std.with_handlers( + { + "hot_corner.triggered": lambda ctx, event: windows.corner_toggle(ctx.state, KEYBOARD), + "window.close_requested": _on_close_requested, + "app.quit_requested": lambda ctx, event: [axidev_osk.window.show(QUIT_PROMPT)], + "app.activated": lambda ctx, event: windows.reveal(ctx.state, KEYBOARD), + "keyboard.permission_required": lambda ctx, event: [axidev_osk.window.show(PERMISSION_PROMPT)], + "linux.permission_setup_opened": _on_permission_setup_opened, + } + ), + ) + + +PROFILES: list[tuple[str, str, Callable[[], axidev_osk.Map]]] = [ + ("default", "Default", keyboard_profile), + ("social", "Social", social_profile), +] +"""Every bundled profile: its name, its caption in the profile menu, and how it is built.""" + + def build_default_config() -> axidev_osk.Map: """Return the bundled root config: the keyboard the app starts with, and the social profile.""" return axidev_osk.config( active_profile="default", state=SHARED_STATE, - profiles={ - "default": axidev_osk.profile( - state=std.with_state(), - theme={"qss": DEFAULT_QSS, "palette": DEFAULT_PALETTE, "font": DEFAULT_FONT}, - windows=_with_settings([keyboard_window(), *prompt_windows()]), - attachments=[ - axidev_osk.dwell( - id=DWELL, - window=KEYBOARD, - **{name: _setting("dwell", name) for name in DWELL_SETTINGS}, - ), - axidev_osk.pointer_locator( - id="keyboard-locator", - window=KEYBOARD, - radius_percent=30, - maximum_opacity_percent=60, - radius_standard_deviations=3, - **POINTER_LOCATOR, - ), - axidev_osk.hot_corners( - id="hot-corners", - corners=["top_left", "top_right", "bottom_left", "bottom_right"], - **HOT_CORNER_INDICATOR, - ), - axidev_osk.secure_input_panel(id="lock-screen-panel", window=KEYBOARD), - ], - on=std.with_handlers( - { - "hot_corner.triggered": lambda ctx, event: windows.corner_toggle(ctx.state, KEYBOARD), - "window.close_requested": _on_close_requested, - "app.quit_requested": lambda ctx, event: [axidev_osk.window.show(QUIT_PROMPT)], - "app.activated": lambda ctx, event: windows.reveal(ctx.state, KEYBOARD), - "keyboard.permission_required": lambda ctx, event: [axidev_osk.window.show(PERMISSION_PROMPT)], - "linux.permission_setup_opened": _on_permission_setup_opened, - } - ), - ), - "social": social_profile(), - }, + profiles={name: build() for name, _caption, build in PROFILES}, ) diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index 9211129..9162894 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -146,9 +146,11 @@ def profile(self) -> ProfileConfig: def start(self) -> int: """Start the profile, services, and startup windows, then run the Qt event loop. - Startup and a profile switch show start windows the same way. Shutdown - closes the queue, then stops the profile, so its callbacks never see - teardown. The shutdown steps are registered first: when a step of + Startup and a profile switch show start windows the same way. At + startup that runs outside a queue drain, which is safe because + building a window never drains: a binding that fails while it is + bound posts its report. Shutdown closes the queue, then stops the + profile, so its callbacks never see teardown. The shutdown steps are registered first: when a step of starting fails, they run before the error is raised. """ @@ -218,6 +220,8 @@ def _deactivate(self) -> None: self._unbind_theme = None self._attachments.stop() self._window_manager.close_all() + # Until the next profile starts, a window action fails instead of building an old window. + self._window_manager.set_factories({}) def _switch_profile(self, arguments: ProfileSwitchArguments) -> MessageResult: """Tear the running profile down and send ``profile.start`` for the requested one. @@ -285,10 +289,8 @@ def _show_start_windows(self) -> None: continue window = self._window_manager.get_or_create(window_config.id) position = state.get(("windows", window_config.id, "position")) - if isinstance(position, dict): - x, y = position["x"], position["y"] - assert isinstance(x, int) and isinstance(y, int) - window.restore_position(x, y) + if position is not None: + window.restore_position(position["x"], position["y"]) # type: ignore[index] self._window_manager.show(window_config.id) if self._panel_prepared: self._window_manager.show(self._secure_input_panel_window()).set_close_enabled(False) diff --git a/src/axidev_osk/runtime/window_manager.py b/src/axidev_osk/runtime/window_manager.py index 86c8057..0192117 100644 --- a/src/axidev_osk/runtime/window_manager.py +++ b/src/axidev_osk/runtime/window_manager.py @@ -27,8 +27,14 @@ def __init__(self, factories: Mapping[str, WindowFactory]) -> None: self._factories: dict[str, WindowFactory] = dict(factories) def set_factories(self, factories: Mapping[str, WindowFactory]) -> None: - """Build later windows from ``factories``, such as another profile's; live windows stay as they are.""" + """Build later windows from ``factories``, such as another profile's. + Raises while windows are live, so no window outlives the factories it + was built from. + """ + + if self._windows: + raise RuntimeError(f"Close the live windows {sorted(self._windows)} before replacing their factories") self._factories = dict(factories) def get_or_create(self, window_id: str) -> RuntimeWindow: diff --git a/tests/test_application_runtime.py b/tests/test_application_runtime.py index f4559d5..3f6873b 100644 --- a/tests/test_application_runtime.py +++ b/tests/test_application_runtime.py @@ -30,7 +30,7 @@ ) from axidev_osk.runtime.application import ApplicationRuntime from axidev_osk.runtime.dispatcher import ACTION_FAILED, ActionFailedArguments -from axidev_osk.runtime.engine_messages import keyboard_down, keyboard_reset +from axidev_osk.runtime.engine_messages import WINDOW_STATE_CHANGED, keyboard_down, keyboard_reset from axidev_osk.runtime.profile_runtime import state_set from axidev_osk.runtime.registries import ServiceRegistry from axidev_osk.services.keyboard import KeyboardService @@ -670,6 +670,27 @@ def test_the_new_profile_hears_nothing_from_the_old_windows(self) -> None: # old window's closing reports ran before c started. self.assertEqual(self._state(runtime, "seen"), [False, False, True]) + def test_a_window_shown_between_two_profiles_fails_instead_of_building_an_old_window(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("c")], extra={"c": _profile_c()})) + failures = _record_failures(runtime) + shown: list[str] = [] + + def show_once(event: DataMap) -> MessageResult: + if shown or runtime._switching_to is None: + return [] + shown.append(str(event["window"])) + return [window_show("pad")] + + runtime.engine.dispatcher.add_raw_event_handler(WINDOW_STATE_CHANGED, show_once) + self._release(runtime, "a:go") + + self.assertEqual(shown, ["pad"]) + self.assertEqual([failure.message for failure in failures], ["No window named 'pad' in the active profile"]) + window = runtime._window_manager.get("pad") + assert window is not None + self.assertIn("c:root", nodes_by_id(window)) + self.assertNotIn("a:go", nodes_by_id(window)) + def test_a_quit_queued_after_a_switch_still_exits(self) -> None: runtime = self._runtime(_switch_root(lambda ctx, event: [])) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index bfc4213..b145187 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -209,6 +209,13 @@ def test_an_attachment_whose_service_is_missing_is_logged_and_the_others_still_a self.assertIn("'hc' does nothing", logs.output[0]) self.assertEqual([config.delay_ms for config in harness.windows["pad"].dwell], [250]) + def test_starting_while_attachments_still_run_raises(self) -> None: + harness = AttachmentHarness([axidev_osk.dwell(id="d", window="pad")]) + harness.start() + + with self.assertRaisesRegex(RuntimeError, "still running"): + harness.runtime.start(harness.profile) + def test_stopping_turns_hot_corners_off_until_a_profile_with_them_starts(self) -> None: harness = AttachmentHarness([axidev_osk.hot_corners(id="hc", **HOT_CORNER_COLORS)]) harness.start() From 897f02d815e9920ffa07815c6be49f41a7ef3733 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Tue, 13 Oct 2026 15:42:08 +0200 Subject: [PATCH 5/6] refactor(docs): drop the finished strip plan and decode_root inayayousfi directed the work and made every decision. Claude Opus 5.5, running in Claude Code, carried inayayousfi's decisions out. SETTINGS_STRIP_PLAN.md is deleted because its steps are done. AGENTS.md adds profile switching to the lifecycle the engine keeps; the switch itself is described in the runtime's docstrings. ConfigDecoder.decode_root only wrapped decode_config for tests, so the tests call decode_config(...).active instead. --- AGENTS.md | 2 +- SETTINGS_STRIP_PLAN.md | 99 -------------------------------- src/axidev_osk/config/profile.py | 5 -- tests/support.py | 2 +- tests/test_attachments.py | 10 ++-- tests/test_default_profile.py | 2 +- tests/test_engine_messages.py | 2 +- tests/test_profile_runtime.py | 28 ++++----- tests/test_theme.py | 4 +- 9 files changed, 25 insertions(+), 129 deletions(-) delete mode 100644 SETTINGS_STRIP_PLAN.md diff --git a/AGENTS.md b/AGENTS.md index c0bcd8a..21130fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ When you add something, ask whether a profile author should be able to change it - The engine owns no colors or font families: a profile's `theme` and an attachment's options carry them. - Buttons never take keyboard focus: an on-screen keyboard must not steal typing from the target app. - A widget property the engine sets is listed in `widget_properties.py`, which both sets and reserves it; a profile style cannot set it. -- Quit sequencing, the lock-screen panel, and display recovery stay in the engine's lifecycle code; profiles only request them through actions. +- Profile switching, quit sequencing, the lock-screen panel, and display recovery stay in the engine's lifecycle code; profiles only request them through actions. ## Where Things Live diff --git a/SETTINGS_STRIP_PLAN.md b/SETTINGS_STRIP_PLAN.md deleted file mode 100644 index 56f4dcb..0000000 --- a/SETTINGS_STRIP_PLAN.md +++ /dev/null @@ -1,99 +0,0 @@ -# Settings Strip Plan - -This file records the agreed plan for the settings strip and live profile switching. It is the source of truth for that work until the work is finished. It builds on the state registry described in `PURIFICATION_PLAN.md` (settled decisions 11 to 17 and step 8), which lands first in the branch below this one. - -Remove this file when the work below is merged. - -## Goal - -A profile can put a strip of controls at the bottom of any window: checkboxes, steppers, dropdowns, and menus. Each control reads and writes registry variables, so the profile decides what the strip shows, what it controls, and what its callbacks do. The engine adds no menu concept of its own. - -The bundled config uses the strip to show the feature: the default keyboard gets a settings strip, and a second small profile gives profile switching a real target. - -## Settled Decisions - -Each decision below was made by the project owner. Do not reopen one without a new reason. - -1. The strip is ordinary profile content placed at the bottom of a window's content, not part of the engine's title bar. It works on every backend, including those where the system draws the title bar. - -2. Dropdowns and menus open as inline panels inside the window, not as Qt popups. A test on KDE Plasma Wayland showed that Qt popups fail on layer-shell windows: LayerShellQt 6.7.5 logs `Cannot attach popup of unknown type` and neither a dropdown list nor a menu appears, even with the window set as the popup's parent. Popups were not tested on the input-panel backend because it only starts when KWin launches it. - -3. An inline panel grows the window. The engine shrinks the window back to its minimum size when content shrinks, the way `apply_startup_size` sizes it at show time. The same test showed a layer-shell window growing from 200x100 to 200x200 when a panel appeared and staying at 200x200 after it hid. - -4. Checkboxes, steppers, dropdowns, and menus are standard-library helpers built from existing node kinds, not new engine node kinds. A checkbox is a button with a `latched` binding. A dropdown or menu is a button that sets or clears its ID in `std.menus.` plus a box whose `visible` binding reads it, so menus in one group close each other. A stepper is a pair of buttons around a label, and its minimum, maximum, and value are bindings, so linked settings can bound each other. - -5. Profiles switch at runtime through `profile.switch {profile}`. The engine exposes `app.profile` and `app.profiles` as read-only variables. A switch requested from a callback runs once the queue is empty, the same way `app.quit` waits. It tears the running profile down, releasing held keys, then sends the engine's own `profile.start`, which also waits for an empty queue, so the closing windows' reports reach no profile. A switch requested while one is in progress only replaces its target: the last one requested starts. - -6. A window whose id exists in both profiles keeps its screen position through `windows..position`, and its size refits to the new content. - -7. If building the new profile fails, the previous profile is rebuilt and the failure is reported. - -## Engine Work - -- Shrink a window back to its minimum size after its content shrinks. -- `profile.switch`, with teardown of the old profile's windows, bindings, callbacks, attachments, and held keys, then `profile.start` to build the new one. -- `app.profile` and `app.profiles` as engine-declared, read-only variables. -- Keep `windows..position` across a rebuild on every backend. Layer shell places windows through margins, so each backend needs its own test. -- Rebuild the previous profile when a switch fails, and report `ProfileRollbackFailed` when that fails too. - -## Standard Library - -- `checkbox`, `stepper`, `dropdown`, and `menu` in `python_defaults/axidev_osk/std/`, each taking `opts` merged last with `axidev_osk.merge`. -- `strip`, `with_strip`, and `strip_group`, which put a collapsible row of these controls under any window's content. -- Their state lives under `std`, declared in the registry, for example `std.menus` as a map from group to the ID of its open menu. -- Tests in `tests/test_std_library.py`. - -## Bundled Config - -### Default Keyboard Strip - -Every window of both bundled profiles gets the same strip, collapsed behind a `⋯` button until it is opened. It holds: - -- A profile dropdown listing `default` and `social`. -- An opacity stepper, 0.30 to 1.00 in steps of 0.05. -- A dwell panel with a checkbox for `enabled` and one stepper per dwell option: - -| Option | Range | Step | -| --- | --- | --- | -| `delay_ms` | 50 to 2000 ms | 50 | -| `dead_zone_px` | 0 to 50 px | 2 | -| `full_speed_px_s` | 0 to min(200, stop speed - 5) px/s | 5 | -| `stop_speed_px_s` | full speed + 5 to 1000 px/s | 20 | -| `maximum_progress_rate` | 1.00 to 4.00 | 0.25 | -| `indicator_start_progress` | 0 to 1 | 0.05 | -| `direction_reversal_progress_factor` | 0 to 1 | 0.05 | -| `movement_penalty_px` | 1 to 100 px | 1 | -| `distance_curve_full_px` | 10 to 1000 px | 10 | -| `velocity_release_ms` | 10 to 1000 ms | 10 | - -Each range contains today's default and stays inside the dwell validation in `config/models.py`. The full speed and stop speed bounds follow each other, so the two can never cross. - -Opacity and every dwell option are declared in the root config's `state`, so they survive switching to `social` and back. - -### Social Profile - -A second profile named `social`, a small keyboard for scrolling a feed: - -- Four keys: Up, Down, L, and K. -- The same strip as every other window, so the user can switch back. -- It reuses the window id `keyboard`, so it opens where the keyboard was. -- Its opacity binds to the same root variable as the default keyboard. -- It has a pointer locator. It has no dwell, hot corners, or lock-screen panel. - -Changing the default keyboard updates the expected data in `tests/test_default_profile.py` in the same change. - -## Known Risks - -- Opening a panel grows a centered layer-shell window, which re-centers it, so keys shift under a dwell pointer while the panel is open. Accepted. -- Theme bindings were not measured. Re-applying a stylesheet live may be slow or flicker. -- Restoring a window's position after a switch is untested on every backend. - -## Steps - -Each step keeps the full test suite, flake8, and pyright passing. - -- [x] 1. Engine shrink-back after content shrinks. -- [x] 2. `checkbox`, `stepper`, `dropdown`, and `menu` in `axidev_osk.std`. -- [x] 3. `profile.switch`, `app.profile`, `app.profiles`, position kept across a rebuild, and rollback on a failed switch. -- [x] 4. Default keyboard strip and the `social` profile, with updated parity data. -- [x] 5. Document the strip helpers and profile switching in `AGENTS.md`. diff --git a/src/axidev_osk/config/profile.py b/src/axidev_osk/config/profile.py index 7d36d28..f597a86 100644 --- a/src/axidev_osk/config/profile.py +++ b/src/axidev_osk/config/profile.py @@ -280,11 +280,6 @@ def __init__( self._check_profile = check_profile self._load_state = load_state - def decode_root(self, data: object) -> ProfileConfig: - """Validate every profile in a root config and return the active one.""" - - return self.decode_config(data).active - def decode_config(self, data: object) -> RootConfig: """Validate every profile in a root config and return them all.""" diff --git a/tests/support.py b/tests/support.py index 7ad0a38..e72592f 100644 --- a/tests/support.py +++ b/tests/support.py @@ -237,7 +237,7 @@ def make_keyboard_test_engine(keyboard_backend: Any) -> tuple[Engine, KeyboardSe def start_test_profile(engine: Engine, root_config: Mapping[str, Any]) -> ProfileConfig: """Decode ``root_config`` with ``engine`` and start its active profile.""" - profile = engine.decoder().decode_root(root_config) + profile = engine.decoder().decode_config(root_config).active engine.profile.start(profile) return profile diff --git a/tests/test_attachments.py b/tests/test_attachments.py index b145187..e674683 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -97,7 +97,7 @@ def __init__( window_lookup=self.windows.get, hot_corners=self.hot_corners if hot_corner_service else None, ) - self.profile = self.engine.decoder().decode_root(_root(attachments, windows, state)) + self.profile = self.engine.decoder().decode_config(_root(attachments, windows, state)).active self.engine.profile.start(self.profile) self.failures: list[DataMap] = [] self.engine.dispatcher.add_raw_event_handler(CALLBACK_FAILED, lambda event: self.failures.append(event) or []) @@ -120,7 +120,7 @@ def test_options_are_validated_with_their_config_path(self) -> None: } for message, attachment in cases.items(): with self.subTest(message), self.assertRaisesRegex(ConfigError, message): - engine.decoder().decode_root(_root([attachment])) + engine.decoder().decode_config(_root([attachment])).active def test_references_and_limits_are_checked_at_load_in_every_profile(self) -> None: @@ -142,14 +142,14 @@ def test_references_and_limits_are_checked_at_load_in_every_profile(self) -> Non spare = _root(attachments)["profiles"]["p"] root = axidev_osk.config(active_profile="ok", profiles={"ok": _root([])["profiles"]["p"], "spare": spare}) with self.subTest(message), self.assertRaisesRegex(ConfigError, rf"config.profiles.spare.attachments\[\d\].*{message}"): - engine.decoder().decode_root(root) + engine.decoder().decode_config(root).active def test_pointer_locators_have_no_limit(self) -> None: qt_app() engine = make_test_engine(RecordingBackend()) locators = [axidev_osk.pointer_locator(id=name, window="pad", gap_color="#242424") for name in ("a", "b")] - self.assertEqual(len(engine.decoder().decode_root(_root(locators)).attachments), 2) + self.assertEqual(len(engine.decoder().decode_config(_root(locators)).active.attachments), 2) class AttachmentRuntimeTests(unittest.TestCase): @@ -222,7 +222,7 @@ def test_stopping_turns_hot_corners_off_until_a_profile_with_them_starts(self) - self.assertIsNotNone(harness.hot_corners.active) harness.runtime.stop() - harness.runtime.start(harness.engine.decoder().decode_root(_root([]))) + harness.runtime.start(harness.engine.decoder().decode_config(_root([])).active) self.assertIsNone(harness.hot_corners.active) diff --git a/tests/test_default_profile.py b/tests/test_default_profile.py index 0ae2f30..9674303 100644 --- a/tests/test_default_profile.py +++ b/tests/test_default_profile.py @@ -103,7 +103,7 @@ def test_every_key_sends_the_original_output(self) -> None: class ConfigParityTests(unittest.TestCase): def setUp(self) -> None: - self.profile = make_test_engine(RecordingBackend()).decoder().decode_root(build_default_config()) + self.profile = make_test_engine(RecordingBackend()).decoder().decode_config(build_default_config()).active def _attachment(self, attachment_id: str) -> tuple[str | None, object]: attachment = next(attachment for attachment in self.profile.attachments if attachment.id == attachment_id) diff --git a/tests/test_engine_messages.py b/tests/test_engine_messages.py index 1b49919..9227149 100644 --- a/tests/test_engine_messages.py +++ b/tests/test_engine_messages.py @@ -55,7 +55,7 @@ def __init__(self, profile: dict[str, object] | None = None) -> None: } }, } - self.engine.profile.start(decoder.decode_root(root)) + self.engine.profile.start(decoder.decode_config(root).active) def act(self, name: str, **arguments: object) -> None: self.dispatcher.dispatch(RuntimeAction(name, arguments)) # type: ignore[arg-type] diff --git a/tests/test_profile_runtime.py b/tests/test_profile_runtime.py index 00f6ad9..e0f6413 100644 --- a/tests/test_profile_runtime.py +++ b/tests/test_profile_runtime.py @@ -263,7 +263,7 @@ def label(state: object) -> str: def pressed(ctx: CallbackContext, event: DataMap) -> None: del ctx, event - profile = self.decoder.decode_root( + profile = self.decoder.decode_config( _root( {"opacity": var("number", 0.85)}, state={"shift": var("boolean", False)}, @@ -280,7 +280,7 @@ def pressed(ctx: CallbackContext, event: DataMap) -> None: opacity=lambda state: state.opacity, ), ) - ) + ).active child = profile.window("pad").content.children[0] self.assertEqual(profile.state, declared({"shift": var("boolean", False)})) @@ -305,7 +305,7 @@ def test_errors_name_the_config_path(self) -> None: } for message, node in cases.items(): with self.subTest(message), self.assertRaisesRegex(ConfigError, message): - self.decoder.decode_root(_root(windows=_pad({"kind": "probe", "id": "root", "children": [node]}))) + self.decoder.decode_config(_root(windows=_pad({"kind": "probe", "id": "root", "children": [node]}))).active def test_state_declaration_errors_name_the_config_path(self) -> None: cases = { @@ -317,7 +317,7 @@ def test_state_declaration_errors_name_the_config_path(self) -> None: } for message, state in cases.items(): with self.subTest(message), self.assertRaisesRegex(ConfigError, message): - self.decoder.decode_root(_root(state=state)) + self.decoder.decode_config(_root(state=state)).active def test_declarations_overlapping_engine_or_shared_state_are_rejected(self) -> None: cases = { @@ -326,15 +326,15 @@ def test_declarations_overlapping_engine_or_shared_state_are_rejected(self) -> N } for name, root in cases.items(): with self.subTest(name), self.assertRaisesRegex(ConfigError, r"\.state: State variable .* overlaps"): - self.decoder.decode_root(root) + self.decoder.decode_config(root).active def test_keys_set_to_none_count_as_absent(self) -> None: - profile = self.decoder.decode_root( + profile = self.decoder.decode_config( _root( theme=None, windows=_pad({"kind": "probe", "id": "root", "size": None, "label": None}, opacity=None), ) - ) + ).active window = profile.window("pad") self.assertEqual((window.values.initial.opacity, window.content.values.initial.options), (1.0, {"size": 1, "label": None, "latched": False})) @@ -343,28 +343,28 @@ def test_inactive_profiles_are_validated_too(self) -> None: root = _with_spare_profile({"windows": [], "colour": "red"}) with self.assertRaisesRegex(ConfigError, "config.profiles.spare has unknown keys: colour"): - self.decoder.decode_root(root) + self.decoder.decode_config(root).active def test_nil_entries_are_skipped_and_only_lists_count_as_lists(self) -> None: - profile = self.decoder.decode_root( + profile = self.decoder.decode_config( _root( on={"hot_corner.triggered": None}, windows=_pad({"kind": "probe", "id": "root", "children": [None]}), ) - ) + ).active self.assertEqual((dict(profile.on), profile.window("pad").content.children), ({}, ())) with self.assertRaisesRegex(ConfigError, "children must be a list"): - self.decoder.decode_root(_root(windows=_pad({"kind": "probe", "id": "root", "children": ()}))) + self.decoder.decode_config(_root(windows=_pad({"kind": "probe", "id": "root", "children": ()}))).active def test_duplicate_node_ids_fail(self) -> None: with self.assertRaisesRegex(ConfigError, "Duplicate IDs"): - self.decoder.decode_root( + self.decoder.decode_config( _root(windows=_pad({ "kind": "probe", "id": "root", "children": [{"kind": "probe", "id": "a"}, {"kind": "probe", "id": "a"}], })) - ) + ).active class ProfileRuntimeTests(unittest.TestCase): @@ -404,7 +404,7 @@ def _decode(self, root: dict[str, object]) -> ProfileConfig: check_profile=self.profile_runtime.check, load_state=self.profile_runtime.load_state, ) - return decoder.decode_root(root) + return decoder.decode_config(root).active def _start(self, shared: dict[str, object] | None = None, **profile: object) -> None: self.profile_runtime.start(self._decode(_root(shared, **profile))) diff --git a/tests/test_theme.py b/tests/test_theme.py index 89c6e21..437e026 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -18,7 +18,7 @@ def _decode(theme: axidev_osk.Map) -> object: active_profile="p", profiles={"p": axidev_osk.profile(theme=theme, windows=[axidev_osk.window(id="w", title="w", content=axidev_osk.box(id="r"))])}, ) - return make_test_engine(RecordingBackend()).decoder().decode_root(root) + return make_test_engine(RecordingBackend()).decoder().decode_config(root).active class ThemeTests(unittest.TestCase): @@ -26,7 +26,7 @@ def test_default_profile_theme_sets_palette_font_and_stylesheet(self) -> None: app = qt_app() previous = (app.styleSheet(), app.palette(), app.font()) self.addCleanup(lambda: (app.setStyleSheet(previous[0]), app.setPalette(previous[1]), app.setFont(previous[2]))) - profile = make_test_engine(RecordingBackend()).decoder().decode_root(build_default_config()) + profile = make_test_engine(RecordingBackend()).decoder().decode_config(build_default_config()).active apply_theme(app, profile.theme.initial, base_palette=previous[1], base_font=previous[2]) From 1c7244c9ffded8005420df6ba335b5d9cb7961ad Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Tue, 13 Oct 2026 20:18:03 +0200 Subject: [PATCH 6/6] feat(engine): let a profile switch leave window positions alone inayayousfi directed the work and made every decision. Claude Opus 5.5, running in Claude Code, carried inayayousfi's decisions out. A window's fixed keep_position field (default on) decides whether a profile switch builds it where a window with the same ID last was. With it off, the window starts where its overlay places it, and the profile can place it itself with window.move_to and the observed windows..position. --- AGENTS.md | 2 +- src/axidev_osk/config/profile.py | 9 ++++++++- src/axidev_osk/runtime/application.py | 9 +++++---- tests/test_application_runtime.py | 27 +++++++++++++++++++++++++-- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9b5cc06..ede6381 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ A feature starts in a profile. One that most profiles need is promoted to the st - A profile is plain data (maps, lists, strings, numbers, booleans, `None`) plus functions, the same values Lua tables and functions produce. A key set to `None` counts as absent, as a Lua table has no key set to `nil`. - The `axidev_osk` helpers only build those maps. The engine decodes them in `config/profile.py`, rejects unknown keys with the full config path, and checks every profile of the root config, not only the active one. -- A binding is `fn(state) -> value`, accepted in place of any value field. Fixed fields (IDs, kinds, references, children, placement such as grid cells, `show_on_start`, `overlay`, `chrome`) stay plain values and reject a function. +- A binding is `fn(state) -> value`, accepted in place of any value field. Fixed fields (IDs, kinds, references, children, placement such as grid cells, `show_on_start`, `keep_position`, `overlay`, `chrome`) stay plain values and reject a function. - A callback is `fn(ctx, event) -> [actions]`. It reads `ctx.state`, receives the event as plain data, and returns actions as plain maps. It never touches widgets, services, or backends. - Functions live in the function registry; messages carry only their IDs. - Everything that crosses a subsystem boundary goes through the one queue in `runtime/dispatcher.py`. Events report what happened; actions request an effect. Both carry a lowercase dot-separated name and native data only, never Qt objects, backend objects, callables, or dataclass instances. diff --git a/src/axidev_osk/config/profile.py b/src/axidev_osk/config/profile.py index 3eab89c..73c25ca 100644 --- a/src/axidev_osk/config/profile.py +++ b/src/axidev_osk/config/profile.py @@ -170,11 +170,17 @@ class WindowValues: @dataclass(frozen=True, slots=True) class WindowConfig: - """One decoded window: fixed identity, platform surface, and content, plus its values.""" + """One decoded window: fixed identity, platform surface, and content, plus its values. + + ``keep_position`` builds the window, when a profile switch shows it, where + a window with the same ID last was; without it the window starts where + its overlay places it, and a profile may move it with ``window.move_to``. + """ id: str content: NodeConfig show_on_start: bool + keep_position: bool overlay: OverlayConfig chrome: ChromeConfig values: Live[WindowValues] @@ -379,6 +385,7 @@ def _decode_window(self, reader: ConfigReader, resolver: Resolver) -> WindowConf id=reader.string("id"), content=self._decode_node(reader.child("content"), None, resolver), show_on_start=reader.boolean("show_on_start", False), + keep_position=reader.boolean("keep_position", True), overlay=overlay, chrome=chrome, values=_live(reader, resolver, _decode_window_values), diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index cf95030..e3d7db0 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -247,8 +247,8 @@ def _switch_profile(self, arguments: ProfileSwitchArguments) -> MessageResult: def _start_switched_profile(self, arguments: EmptyArguments) -> MessageResult: """Start the profile a switch is moving to. - Each window it shows on start is built at the position a window with - the same ID last reported. If it fails to build, the previous profile + Each window it shows on start with ``keep_position`` is built at the + position a window with the same ID last reported. If it fails to build, the previous profile starts again, with its own variables back at their defaults, and the failure is reported as ``action.failed``; if that fails too, the report is ``ProfileRollbackFailed``. @@ -281,7 +281,8 @@ def _refuse_without_panel(self, target: ProfileConfig) -> None: raise ValueError(f"Profile {target.id!r} has no secure_input_panel attachment for the prepared lock-screen panel") def _show_start_windows(self) -> None: - """Show the running profile's start windows where windows with their IDs last were, and a prepared panel.""" + """Show the running profile's start windows, each with ``keep_position`` where a window with its ID last was, + and a prepared panel.""" state = self.engine.profile.state for window_config in self._profile.windows: @@ -289,7 +290,7 @@ def _show_start_windows(self) -> None: continue window = self._window_manager.get_or_create(window_config.id) position = state.get(("windows", window_config.id, "position")) - if position is not None: + if window_config.keep_position and position is not None: window.restore_position(position["x"], position["y"]) # type: ignore[index] self._window_manager.show(window_config.id) if self._panel_prepared: diff --git a/tests/test_application_runtime.py b/tests/test_application_runtime.py index bcbed9e..4963405 100644 --- a/tests/test_application_runtime.py +++ b/tests/test_application_runtime.py @@ -529,13 +529,23 @@ def test_window_actions_on_a_defined_window_that_is_not_built_do_nothing(self) - self.assertEqual(runtime._window_manager.all_windows(), []) -def _switch_root(on_go: Any, *, extra: dict[str, axidev_osk.Map] | None = None, panel: bool = False) -> axidev_osk.Map: +def _switch_root( + on_go: Any, *, extra: dict[str, axidev_osk.Map] | None = None, panel: bool = False, keep_position: bool = True +) -> axidev_osk.Map: """Two profiles, ``a`` and ``b``, each showing a ``pad`` window and recording ``app.activated``.""" def profile(name: str, children: list[axidev_osk.Map]) -> axidev_osk.Map: return axidev_osk.profile( state={"count": axidev_osk.var("integer", 0), "activated": axidev_osk.var(axidev_osk.list_of("string"))}, - windows=[axidev_osk.window(id="pad", title=name, show_on_start=True, content=axidev_osk.box(id=f"{name}:root", children=children))], + windows=[ + axidev_osk.window( + id="pad", + title=name, + show_on_start=True, + keep_position=keep_position, + content=axidev_osk.box(id=f"{name}:root", children=children), + ) + ], attachments=[axidev_osk.secure_input_panel(id="panel", window="pad")] if panel and name == "a" else [], on={"app.activated": lambda ctx, event: [axidev_osk.state.set("activated", [*ctx.state.activated, name])]}, ) @@ -610,6 +620,19 @@ def test_a_window_with_the_same_id_keeps_its_position(self) -> None: assert moved is not None self.assertEqual((moved.x(), moved.y()), (120, 80)) + def test_a_window_without_keep_position_starts_where_it_is_built(self) -> None: + runtime = self._runtime(_switch_root(lambda ctx, event: [axidev_osk.profile.switch("b")], keep_position=False)) + window = runtime._window_manager.get("pad") + assert window is not None + built_at = (window.x(), window.y()) + window.move(120, 80) + + self._release(runtime, "a:go") + + moved = runtime._window_manager.get("pad") + assert moved is not None + self.assertEqual((moved.x(), moved.y()), built_at) + def test_a_profile_that_fails_to_build_brings_the_previous_one_back(self) -> None: broken = axidev_osk.profile( windows=[axidev_osk.window(id="pad", title="broken", show_on_start=True, content=axidev_osk.box(id="broken:root"))],