Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/axidev_osk/attachments/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
11 changes: 9 additions & 2 deletions src/axidev_osk/attachments/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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."""
Expand Down
27 changes: 23 additions & 4 deletions src/axidev_osk/config/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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."""

Expand All @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions src/axidev_osk/hot_corner/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
32 changes: 29 additions & 3 deletions src/axidev_osk/nodes/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
51 changes: 32 additions & 19 deletions src/axidev_osk/python_defaults/axidev_osk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions src/axidev_osk/python_defaults/axidev_osk/std/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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


Expand Down
Loading
Loading