diff --git a/AGENTS.md b/AGENTS.md index 4867087..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. @@ -44,7 +44,7 @@ A feature starts in a profile. One that most profiles need is promoted to the st - Only the code that assembles the engine (`runtime/engine.py`, `runtime/application.py`, and the action handlers they register) calls services directly, to start, stop, and reset them and to carry out actions. - An observation that fires often is reported only for nodes that opt in through a fixed field: pointer presses and drags (`reports_pointer`), hovering (`reports_hover`), wheel turns (`reports_wheel`), and where the node sits in its window (`report_geometry`). - 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/src/axidev_osk/attachments/__init__.py b/src/axidev_osk/attachments/__init__.py index 560faf5..3722a34 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..d1102f1 100644 --- a/src/axidev_osk/attachments/runtime.py +++ b/src/axidev_osk/attachments/runtime.py @@ -48,9 +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. - self.stop() + 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) @@ -73,6 +78,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 6c314c8..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] @@ -252,6 +258,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.""" @@ -277,8 +295,8 @@ 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.""" + 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") @@ -293,7 +311,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, @@ -367,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/hot_corner/service.py b/src/axidev_osk/hot_corner/service.py index ce250c3..b30e160 100644 --- a/src/axidev_osk/hot_corner/service.py +++ b/src/axidev_osk/hot_corner/service.py @@ -32,6 +32,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 c36328f..54317a6 100644 --- a/src/axidev_osk/nodes/registry.py +++ b/src/axidev_osk/nodes/registry.py @@ -28,7 +28,7 @@ from .pointer import HoverReporter, PointerReporter, WheelReporter 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] @@ -115,8 +115,35 @@ def __init__( self._kinds = kinds self._dispatcher = dispatcher self._bindings = bindings + self._unbinds: list[Unsubscribe] | None = None + + def build_tree(self, node: NodeConfig) -> tuple[QWidget, list[Unsubscribe]]: + """Build ``node`` and its children, returning the widget and every binding they made. + + 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] = [] + outer = self._unbinds + self._unbinds = unbinds + try: + widget = self.build(node) + except BaseException: + for unbind in unbinds: + unbind() + raise + finally: + self._unbinds = outer + 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) @@ -137,8 +164,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()) + 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, **fields: DataValue) -> None: diff --git a/src/axidev_osk/python_defaults/axidev_osk/__init__.py b/src/axidev_osk/python_defaults/axidev_osk/__init__.py index b96912e..11b8e24 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 4e50036..27d2bb9 100644 --- a/src/axidev_osk/python_defaults/axidev_osk/std/__init__.py +++ b/src/axidev_osk/python_defaults/axidev_osk/std/__init__.py @@ -5,10 +5,10 @@ from collections.abc import Mapping, Sequence from typing import Any -from . import keys, locator, prompts, styles, windows +from . import controls, keys, locator, prompts, styles, windows from .styles import lit -__all__ = ["keys", "lit", "locator", "prompts", "styles", "windows", "with_handlers", "with_state"] +__all__ = ["controls", "keys", "lit", "locator", "prompts", "styles", "windows", "with_handlers", "with_state"] def with_state(state: Mapping[str, Any] | None = None) -> dict[str, Any]: @@ -17,7 +17,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, **locator.STATE} + merged["std"] = {**keys.STATE, **windows.STATE, **controls.STATE, **locator.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..621d0f3 --- /dev/null +++ b/src/axidev_osk/python_defaults/axidev_osk/std/controls.py @@ -0,0 +1,298 @@ +"""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 +from .styles import lit + +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, + "style": lit(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, + "style": lit(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, + style=lit(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": "⋯", + "on_release": toggle, + "min_width": 64, + "max_width": 64, + "min_height": 24, + "max_height": 24, + "style": {"object_name": "stripToggle", **lit(latched=is_open)}, + }, + 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 79ec392..06e39b3 100644 --- a/src/axidev_osk/python_defaults/default_profile.py +++ b/src/axidev_osk/python_defaults/default_profile.py @@ -1,17 +1,18 @@ -"""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 +from collections.abc import Callable from typing import Any 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" @@ -180,6 +181,130 @@ def _key_nodes() -> list[axidev_osk.Map]: return [_key_node(entry, cell) for entry, cell in zip(LAYOUT, _cells(), strict=True)] +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 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", + [(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", + 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, @@ -228,23 +353,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, @@ -314,49 +443,130 @@ 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_keys() -> list[axidev_osk.Map]: + return [ + keys.key(label, output, {"min_width": KEY_UNIT_PX * 2, "min_height": KEY_UNIT_PX * 2}) + for label, output in SOCIAL_KEYS + ] + + +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=std.locator.colored(_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), + }, + also=[std.locator.on(_social_keys())], + ), + ) + + +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, + }, + also=[std.locator.on(_key_nodes())], + ), + ) + + +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 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", state=SHARED_STATE, - profiles={ - "default": axidev_osk.profile( - state=std.with_state(), - theme={"qss": DEFAULT_QSS, "palette": DEFAULT_PALETTE, "font": DEFAULT_FONT}, - windows=[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, - }, - also=[std.locator.on(_key_nodes())], - ), - ) - }, + profiles={name: build() for name, _caption, build in PROFILES}, ) diff --git a/src/axidev_osk/python_defaults/theme.py b/src/axidev_osk/python_defaults/theme.py index 4760caa..c09f935 100644 --- a/src/axidev_osk/python_defaults/theme.py +++ b/src/axidev_osk/python_defaults/theme.py @@ -234,4 +234,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 65b620e..c94cb7f 100644 --- a/src/axidev_osk/runtime/app_messages.py +++ b/src/axidev_osk/runtime/app_messages.py @@ -1,7 +1,8 @@ """Messages the application runtime handles or sends. -These cover windows, title-bar and resize-grip drags, dwell clicks, displays, hot corners, the lock-screen -panel, quit requests, second-launch activation, and Linux permission setup. +These cover windows, title-bar and resize-grip drags, dwell clicks, displays, +hot corners, the lock-screen 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, @@ -48,6 +49,8 @@ WINDOW_RESIZE_DRAG_STARTED = "window.resize_drag_started" 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" @@ -87,6 +90,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 @@ -230,6 +238,16 @@ 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 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) @@ -301,6 +319,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 2967fde..e3d7db0 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,22 +31,26 @@ APP_QUIT, APP_QUIT_REQUESTED, LINUX_OPEN_PERMISSION_SETUP, + PROFILE_START, + PROFILE_SWITCH, SECURE_INPUT_PANEL_PREPARE, SECURE_INPUT_PANEL_RELEASE, WINDOW_CLOSE_REQUESTED, AppQuitArguments, + ProfileSwitchArguments, WindowCloseRequestedArguments, app_quit, app_quit_requested, 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 +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 @@ -55,8 +60,18 @@ _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 profile windows.""" + """Own QApplication-facing lifecycle, services, the queue, and the running profile's windows.""" def __init__( self, @@ -100,16 +115,15 @@ 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._switching_to: str | 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 +132,8 @@ 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(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) @@ -130,10 +146,11 @@ 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 - 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. """ @@ -143,23 +160,13 @@ 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: - 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 @@ -168,16 +175,134 @@ 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: + """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 + 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. + + 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 [] + 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 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``. + """ + + 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_start_windows() + except Exception as error: + _logger.exception("Failed to build profile %s; building %s again", target.id, previous.id) + self._deactivate() + 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 _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, 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: + 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 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: + 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: WindowCloseRequestedArguments) -> 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 d441ee7..cfa8060 100644 --- a/src/axidev_osk/runtime/engine_messages.py +++ b/src/axidev_osk/runtime/engine_messages.py @@ -82,6 +82,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( @@ -124,8 +125,8 @@ def _record(**fields: Variable) -> Variable: {}, ), } -"""What the engine observes: keys and locks, keyboard output status, each built window, and each node built -with ``report_geometry``.""" +"""What the engine observes: keys and locks, keyboard output status, the running and available profiles, each built +window, and each node built with ``report_geometry``.""" @dataclass(frozen=True, slots=True) diff --git a/src/axidev_osk/runtime/window_manager.py b/src/axidev_osk/runtime/window_manager.py index 02e4bf1..6cefe5f 100644 --- a/src/axidev_osk/runtime/window_manager.py +++ b/src/axidev_osk/runtime/window_manager.py @@ -26,6 +26,17 @@ 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: + """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: """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 18178dc..391865a 100644 --- a/src/axidev_osk/windows/builder.py +++ b/src/axidev_osk/windows/builder.py @@ -90,7 +90,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: @@ -112,6 +112,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) @@ -121,7 +122,8 @@ def __init__( else: self._overlay = configure_plain_window(self) try: - 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() if isinstance(central_layout, QVBoxLayout): @@ -157,7 +159,10 @@ def __init__( ) self._opacity = WindowOpacityController(self) unbind = engine.bindings.bind(values.decode, f"window:{window_id}", self._apply_values, fallback=values.initial) - self.destroyed.connect(lambda _object=None: unbind()) + self._unbinds.append(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() @@ -209,6 +214,7 @@ def _apply_values(self, values: WindowValues, previous: WindowValues | None) -> self._fit_content = values.fit_content if refit_now: self.resize(self._fitted_size(self._minimum_size)) + self._overlay.keep_on_screen() if previous is not None and values.minimum_size != previous.minimum_size: self._refit() @@ -287,9 +293,21 @@ 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.""" + + self._overlay.restore_position(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: @@ -329,6 +347,14 @@ def _fitted_size(self, minimum_size: tuple[int, int]) -> QSize: resolved_minimum_size.setHeight(max(resolved_minimum_size.height(), needed_height)) 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.""" + + handled = super().event(event) + if event.type() == QEvent.Type.LayoutRequest and self.centralWidget() is not None: + self._refit() + return handled + def _refit(self) -> None: """Follow a change in the smallest size that holds the content. @@ -343,6 +369,7 @@ def _refit(self) -> None: self.setMinimumSize(fitted) if self._fit_content: self.resize(fitted) + self._overlay.keep_on_screen() self._report_state() def closeEvent(self, event: QCloseEvent) -> None: # type: ignore[override] @@ -426,7 +453,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") @@ -435,8 +462,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 77a3fdf..a4f6787 100644 --- a/src/axidev_osk/windows/overlay/always_on_top.py +++ b/src/axidev_osk/windows/overlay/always_on_top.py @@ -475,6 +475,23 @@ 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 + self.keep_on_screen() + def move_by(self, dx: int, dy: int) -> None: """Move the overlay, kept inside its screen when ``keep_on_screen`` asks. @@ -485,19 +502,28 @@ def move_by(self, dx: int, dy: int) -> None: if self._backend == OverlayBackend.WAYLAND_LAYER_SHELL: 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, unless ``keep_on_screen`` is off. + + Layer-shell margins are bounded when they move, and KWin places the + input panel itself, so neither changes here. + """ + if not self._config.keep_on_screen: - self._window.move(self._window.x() + dx, self._window.y() + dy) - self._floating_position_initialized = True return - + 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, never below its minimum size, and within its screen when ``keep_on_screen`` asks. @@ -791,6 +817,16 @@ 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 keep_on_screen(self) -> None: + """Leave plain windows where the window manager put them.""" + + return + def move_by(self, dx: int, dy: int) -> None: """Move the window, or raise on Wayland, where an ordinary window cannot place itself.""" diff --git a/tests/support.py b/tests/support.py index 55dcb03..bff5bd5 100644 --- a/tests/support.py +++ b/tests/support.py @@ -145,6 +145,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 @@ -248,7 +254,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_application_runtime.py b/tests/test_application_runtime.py index cedeb23..4963405 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 ( @@ -17,6 +18,7 @@ app_activated, app_quit, pointer_motion_observed, + profile_switch, secure_input_panel_prepare, secure_input_panel_release, window_close, @@ -28,11 +30,12 @@ ) 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 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 -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 +50,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}, ) @@ -326,6 +328,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, "button": "left"})) + + 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", @@ -500,6 +529,242 @@ 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, 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, + 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])]}, + ) + + 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 {}), + }, + ) + + +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(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) + 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, "button": "left"})) + + 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_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"))], + ) + 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.start", "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_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_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: [])) + + 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) + 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..e674683 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: @@ -92,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 []) @@ -115,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: @@ -137,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): @@ -204,6 +209,23 @@ 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() + self.assertIsNotNone(harness.hot_corners.active) + + harness.runtime.stop() + harness.runtime.start(harness.engine.decoder().decode_config(_root([])).active) + + 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 ff9052f..2376c96 100644 --- a/tests/test_default_profile.py +++ b/tests/test_default_profile.py @@ -99,7 +99,7 @@ def test_grid_matches_the_original_keyboard_exactly(self) -> None: def test_every_key_glows_the_original_locator_color(self) -> None: harness = DefaultProfileHarness(self) harness.window.show() - buttons = [widget for widget in harness.nodes.values() if isinstance(widget, QPushButton)] + buttons = [harness.nodes[node_id] for node_id in EXPECTED_LOCATOR_COLORS] wait_until(lambda: all(button.property("locatorColor") for button in buttons)) @@ -124,7 +124,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) @@ -252,6 +252,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_dwell_click.py b/tests/test_dwell_click.py index dda9847..e58ad9f 100644 --- a/tests/test_dwell_click.py +++ b/tests/test_dwell_click.py @@ -134,7 +134,7 @@ def test_the_indicator_takes_its_size_from_the_profile_options(self) -> None: ) window = {"id": "pad", "title": "Pad", "content": {"kind": "label", "id": "text"}} root = {"active_profile": "p", "profiles": {"p": {"windows": [window], "attachments": [dwell]}}} - config = engine.decoder().decode_root(root).attachments[0].values.initial + config = engine.decoder().decode_config(root).active.attachments[0].values.initial assert isinstance(config, DwellClickConfig) self.controller.set_config(config) diff --git a/tests/test_engine_messages.py b/tests/test_engine_messages.py index 789f957..0ae9865 100644 --- a/tests/test_engine_messages.py +++ b/tests/test_engine_messages.py @@ -56,7 +56,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_hot_corner_overlay.py b/tests/test_hot_corner_overlay.py index f2e3784..8277276 100644 --- a/tests/test_hot_corner_overlay.py +++ b/tests/test_hot_corner_overlay.py @@ -256,6 +256,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_floating_window_starts_at_its_anchor(self) -> None: cases = { "top-right": (1000 - 100 - 16, 16), @@ -303,7 +331,9 @@ def test_floating_window_leaves_its_screen_only_without_keep_on_screen(self) -> window, config=AlwaysOnTopWindowConfig(keep_on_screen=keep_on_screen) ) controller.move_by(-50, 0) + self.assertEqual(window.moves[-1], expected) + controller.restore_position(-50, 0) self.assertEqual(window.moves[-1], expected) def test_wayland_layer_shell_manual_move_persists_across_show(self) -> None: diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 9ac8583..dc608f8 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -303,7 +303,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) 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_std_library.py b/tests/test_std_library.py index d999315..7d791ed 100644 --- a/tests/test_std_library.py +++ b/tests/test_std_library.py @@ -11,7 +11,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, locator, prompts, windows +from axidev_osk.python_defaults.axidev_osk.std import controls, keys, locator, 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 @@ -35,6 +35,7 @@ def __init__( children: list[axidev_osk.Map], *, on: dict[str, Any] | None = None, + state: dict[str, Any] | None = None, also: list[dict[str, Any]] | None = None, **window_fields: Any, ) -> None: @@ -47,7 +48,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, also or []), ) }, @@ -249,6 +250,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_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]) diff --git a/tests/test_window_builder.py b/tests/test_window_builder.py index c945208..a3c88db 100644 --- a/tests/test_window_builder.py +++ b/tests/test_window_builder.py @@ -517,6 +517,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")