From 1e1efbf8ecd84eeabf1cb008c4fdf9a7a90ac0db Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Tue, 8 Sep 2026 19:33:33 +0200 Subject: [PATCH 1/3] feat(pointer): add configurable color locator inayayousfi directed the work and made every decision. gpt-5.6-sol, running in OpenCode, carried inayayousfi's decisions out. Attach pointer feedback through a reusable surface-decoration registry and keep RuntimeWindow independent of concrete decoration types. --- src/axidev_osk/config/__init__.py | 4 + src/axidev_osk/config/defaults/__init__.py | 17 ++ src/axidev_osk/config/models.py | 41 ++++ src/axidev_osk/runtime/application.py | 12 +- src/axidev_osk/runtime/context.py | 4 +- src/axidev_osk/runtime/registries.py | 44 +++- src/axidev_osk/runtime/testing.py | 17 +- src/axidev_osk/styles/theme.py | 44 ++++ src/axidev_osk/windows/builder.py | 8 +- src/axidev_osk/windows/pointer_locator.py | 273 +++++++++++++++++++++ src/axidev_osk/windows/surface.py | 36 ++- tests/test_pointer_locator.py | 244 ++++++++++++++++++ tests/test_surface_decorations.py | 81 ++++++ tests/test_theme.py | 12 + tests/test_window_builder.py | 51 +++- 15 files changed, 881 insertions(+), 7 deletions(-) create mode 100644 src/axidev_osk/windows/pointer_locator.py create mode 100644 tests/test_pointer_locator.py create mode 100644 tests/test_surface_decorations.py create mode 100644 tests/test_theme.py diff --git a/src/axidev_osk/config/__init__.py b/src/axidev_osk/config/__init__.py index 7dc8c89..6cc5914 100644 --- a/src/axidev_osk/config/__init__.py +++ b/src/axidev_osk/config/__init__.py @@ -11,8 +11,10 @@ KeyConfig, LayoutConfig, OverlayConfig, + PointerLocatorConfig, PromptConfig, SpacerConfig, + SurfaceDecorationConfig, SurfaceConfig, WindowConfig, ) @@ -28,8 +30,10 @@ "KeyConfig", "LayoutConfig", "OverlayConfig", + "PointerLocatorConfig", "PromptConfig", "SpacerConfig", + "SurfaceDecorationConfig", "SurfaceConfig", "WindowConfig", ] diff --git a/src/axidev_osk/config/defaults/__init__.py b/src/axidev_osk/config/defaults/__init__.py index 727faea..5bc4388 100644 --- a/src/axidev_osk/config/defaults/__init__.py +++ b/src/axidev_osk/config/defaults/__init__.py @@ -13,6 +13,7 @@ KeyboardGridConfig, KeyboardStatusConfig, OverlayConfig, + PointerLocatorConfig, PromptConfig, SurfaceConfig, WindowConfig, @@ -38,6 +39,12 @@ def build_default_app_config() -> AppConfig: keyboard_surface_id = stable_id(keyboard_window_id, "surface", "keyboard", stable_override="surface:keyboard") keyboard_grid_id = stable_id(keyboard_surface_id, "component", "keyboard-grid", stable_override="component:keyboard-grid") keyboard_status_id = stable_id(keyboard_surface_id, "component", "keyboard-status", stable_override="component:keyboard-status") + pointer_locator_id = stable_id( + keyboard_surface_id, + "decoration", + "pointer-locator", + stable_override="decoration:pointer-locator", + ) keyboard_window = WindowConfig( id=keyboard_window_id, title="axidev OSK", @@ -64,6 +71,16 @@ def build_default_app_config() -> AppConfig: ), ), chrome=ChromeConfig(enabled=True), + decorations=( + PointerLocatorConfig( + id=pointer_locator_id, + rows=4, + columns=4, + radius_percent=30, + maximum_opacity_percent=60, + radius_standard_deviations=3, + ), + ), opacity=0.85, ) diff --git a/src/axidev_osk/config/models.py b/src/axidev_osk/config/models.py index acc829d..f9625e1 100644 --- a/src/axidev_osk/config/models.py +++ b/src/axidev_osk/config/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from dataclasses import dataclass, field from enum import Enum from typing import Literal @@ -57,6 +58,43 @@ class ChromeConfig: enabled: bool = True +@dataclass(frozen=True, slots=True) +class PointerLocatorConfig: + """Color-grid pointer feedback configured for one window. + + Attributes: + id: Deterministic decoration ID. + rows: Number of color regions along the vertical axis. + columns: Number of color regions along the horizontal axis. + radius_percent: Glow radius as a percentage of the surface's shorter side. + maximum_opacity_percent: Glow opacity at the pointer position. + radius_standard_deviations: Number of Gaussian standard deviations inside the radius. + """ + + id: str + rows: int + columns: int + radius_percent: float + maximum_opacity_percent: float + radius_standard_deviations: float + kind: Literal["pointer-locator"] = "pointer-locator" + + def __post_init__(self) -> None: + """Reject grids that cannot define a visible color region.""" + + if self.rows <= 0 or self.columns <= 0: + raise ValueError("Pointer locator rows and columns must be positive") + if not 0.0 < self.radius_percent <= 100.0: + raise ValueError("Pointer locator radius percent must be greater than 0 and at most 100") + if not 0.0 < self.maximum_opacity_percent <= 100.0: + raise ValueError("Pointer locator maximum opacity percent must be greater than 0 and at most 100") + if not math.isfinite(self.radius_standard_deviations) or self.radius_standard_deviations < 0.1: + raise ValueError("Pointer locator radius standard deviations must be finite and at least 0.1") + + +SurfaceDecorationConfig = PointerLocatorConfig + + @dataclass(frozen=True, slots=True) class KeyConfig: @@ -299,6 +337,7 @@ class WindowConfig: surface: Root surface content declaration. overlay: Overlay behavior for this window. chrome: Optional custom chrome policy. + decorations: Optional surface decorations attached through the runtime registry. opacity: Normal window opacity from zero through one. """ @@ -307,6 +346,7 @@ class WindowConfig: surface: SurfaceConfig overlay: OverlayConfig = field(default_factory=OverlayConfig) chrome: ChromeConfig = field(default_factory=ChromeConfig) + decorations: tuple[SurfaceDecorationConfig, ...] = () opacity: float = 1.0 def __post_init__(self) -> None: @@ -314,6 +354,7 @@ def __post_init__(self) -> None: if not 0.0 <= self.opacity <= 1.0: raise ValueError("Window opacity must be between 0.0 and 1.0") + validate_unique_ids((decoration.id for decoration in self.decorations), scope=f"window {self.id!r} decorations") @dataclass(frozen=True, slots=True) diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index 439543b..926e552 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -17,6 +17,7 @@ from ..services.keyboard import KeyboardService from ..services.kwin_lock import KWinLockService from ..styles.theme import apply_theme +from ..windows.pointer_locator import register_surface_decorations from ..windows.surface import register_surfaces from .context import Context from .dispatcher import Dispatcher @@ -28,7 +29,13 @@ ) from .events import ScreenLockStateChanged, WindowCloseRequested from .prompt import PromptResolutionWaiter -from .registries import ComponentRegistry, EventHandlerRegistry, ServiceRegistry, SurfaceRegistry +from .registries import ( + ComponentRegistry, + EventHandlerRegistry, + ServiceRegistry, + SurfaceDecorationRegistry, + SurfaceRegistry, +) from .state_store import StateStore from .window_manager import WindowManager @@ -77,11 +84,13 @@ def __init__( self._state = StateStore() self._components = ComponentRegistry() self._surfaces = SurfaceRegistry() + self._surface_decorations = SurfaceDecorationRegistry() self._event_handlers = event_handlers or EventHandlerRegistry() if event_handlers is None: register_event_handlers(self._event_handlers) register_components(self._components) register_surfaces(self._surfaces) + register_surface_decorations(self._surface_decorations) self.context = Context( config=self._config, dispatcher=self._dispatcher, @@ -89,6 +98,7 @@ def __init__( state=self._state, components=self._components, surfaces=self._surfaces, + surface_decorations=self._surface_decorations, ) self._dispatcher.bind_context(self.context) context_handlers = EventHandlerRegistry() diff --git a/src/axidev_osk/runtime/context.py b/src/axidev_osk/runtime/context.py index befb144..03cef32 100644 --- a/src/axidev_osk/runtime/context.py +++ b/src/axidev_osk/runtime/context.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from ..config.models import AppConfig -from .registries import ComponentRegistry, SurfaceRegistry +from .registries import ComponentRegistry, SurfaceDecorationRegistry, SurfaceRegistry from .state_store import StateStore if TYPE_CHECKING: @@ -25,6 +25,7 @@ class Context: state: Central state store. components: Component builder registry. surfaces: Surface builder registry. + surface_decorations: Surface-decoration attachment registry. """ config: AppConfig @@ -33,3 +34,4 @@ class Context: state: StateStore components: ComponentRegistry surfaces: SurfaceRegistry + surface_decorations: SurfaceDecorationRegistry diff --git a/src/axidev_osk/runtime/registries.py b/src/axidev_osk/runtime/registries.py index e852aaa..025b514 100644 --- a/src/axidev_osk/runtime/registries.py +++ b/src/axidev_osk/runtime/registries.py @@ -13,9 +13,10 @@ from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, Protocol, TypeVar, cast +from PySide6.QtCore import QObject from PySide6.QtWidgets import QWidget -from ..config.models import ComponentConfig, SurfaceConfig +from ..config.models import ComponentConfig, SurfaceConfig, SurfaceDecorationConfig from .commands import RuntimeCommand from .events import RuntimeEvent @@ -26,6 +27,7 @@ ComponentBuilder = Callable[..., QWidget] SurfaceBuilder = Callable[[SurfaceConfig, "Context"], QWidget] +SurfaceDecorationBuilder = Callable[[SurfaceDecorationConfig, QWidget, "Context"], QObject | None] RuntimeT = TypeVar("RuntimeT") @@ -171,6 +173,46 @@ def build(self, config: SurfaceConfig, context: "Context") -> QWidget: return builder(config, context) +class SurfaceDecorationRegistry: + """Maps surface-decoration kinds to attachment functions.""" + + def __init__(self) -> None: + self._builders: dict[str, SurfaceDecorationBuilder] = {} + + def register(self, kind: str, builder: SurfaceDecorationBuilder) -> None: + """Register one surface-decoration attachment function.""" + + self._builders[kind] = builder + + def attach( + self, + config: SurfaceDecorationConfig, + surface: QWidget, + context: "Context", + ) -> QObject | None: + """Attach one configured decoration to a built surface.""" + + builder = self._builders.get(config.kind) + if builder is None: + raise ValueError(f"No surface decoration registered for kind {config.kind!r}") + return builder(config, surface, context) + + def attach_all( + self, + configs: Iterable[SurfaceDecorationConfig], + surface: QWidget, + context: "Context", + ) -> tuple[QObject, ...]: + """Attach configured decorations in declaration order.""" + + attached: list[QObject] = [] + for config in configs: + decoration = self.attach(config, surface, context) + if decoration is not None: + attached.append(decoration) + return tuple(attached) + + class ServiceRegistry: """Maintains named runtime services in deterministic startup order.""" diff --git a/src/axidev_osk/runtime/testing.py b/src/axidev_osk/runtime/testing.py index 8ba8570..f2a5a25 100644 --- a/src/axidev_osk/runtime/testing.py +++ b/src/axidev_osk/runtime/testing.py @@ -29,7 +29,13 @@ route_hot_corner_triggered, ) from .events import WindowCloseRequested -from .registries import ComponentRegistry, EventHandlerRegistry, ServiceRegistry, SurfaceRegistry +from .registries import ( + ComponentRegistry, + EventHandlerRegistry, + ServiceRegistry, + SurfaceDecorationRegistry, + SurfaceRegistry, +) from .state_store import StateStore from .window_manager import WindowManager @@ -86,6 +92,7 @@ def make_test_context( config: AppConfig | None = None, components: ComponentRegistry | None = None, surfaces: SurfaceRegistry | None = None, + surface_decorations: SurfaceDecorationRegistry | None = None, services: set[str] | None = None, event_handlers: bool = False, ) -> Context: @@ -109,6 +116,8 @@ def make_test_context( component builders are registered into it. surfaces: Optional pre-populated surface registry. Defaults to an empty registry. + surface_decorations: Optional pre-populated surface-decoration registry. + Defaults to the bundled decoration builders. services: Optional explicit service names to register and start. When omitted, only the supplied keyboard backend is bound. event_handlers: Whether to install bundled application-level event @@ -130,6 +139,11 @@ def make_test_context( components = ComponentRegistry() register_components(components) + if surface_decorations is None: + from ..windows.pointer_locator import register_surface_decorations + + surface_decorations = SurfaceDecorationRegistry() + register_surface_decorations(surface_decorations) context = Context( config=config or build_default_app_config(), dispatcher=dispatcher, @@ -137,6 +151,7 @@ def make_test_context( state=StateStore(), components=components, surfaces=surfaces or SurfaceRegistry(), + surface_decorations=surface_decorations, ) dispatcher.bind_context(context) context_handlers = EventHandlerRegistry() diff --git a/src/axidev_osk/styles/theme.py b/src/axidev_osk/styles/theme.py index 119727f..9624d23 100644 --- a/src/axidev_osk/styles/theme.py +++ b/src/axidev_osk/styles/theme.py @@ -142,6 +142,12 @@ def build_stylesheet() -> str: disabled_text = palette.disabled_text.name() disabled_fill = palette.disabled_fill.name() disabled_edge = palette.disabled_edge.name() + locator_shell_bar = _rgba(palette.shell_bar, 153) + locator_key_fill = _rgba(palette.key_fill, 153) + locator_key_hover = _rgba(palette.key_hover, 153) + locator_key_pressed = _rgba(palette.key_pressed, 153) + locator_active_fill = _rgba(palette.active_fill, 153) + locator_disabled_fill = _rgba(palette.disabled_fill, 153) return f""" QMainWindow {{ @@ -309,6 +315,44 @@ def build_stylesheet() -> str: background-color: {disabled_fill}; border-color: {disabled_edge}; }} + QWidget[pointerLocatorEnabled="true"] QPushButton {{ + background-color: qlineargradient( + x1: 0, + y1: 0, + x2: 1, + y2: 1, + stop: 0 {locator_shell_bar}, + stop: 1 {locator_key_fill} + ); + }} + QWidget[pointerLocatorEnabled="true"] QPushButton:hover {{ + background-color: qlineargradient( + x1: 0, + y1: 0, + x2: 1, + y2: 1, + stop: 0 {locator_key_hover}, + stop: 1 {locator_active_fill} + ); + }} + QWidget[pointerLocatorEnabled="true"] QPushButton:pressed, + QWidget[pointerLocatorEnabled="true"] QPushButton[interactionState="pressed"], + QWidget[pointerLocatorEnabled="true"] QPushButton[interactionState="latched_pressed"] {{ + background-color: {locator_key_pressed}; + }} + QWidget[pointerLocatorEnabled="true"] QPushButton[latched="true"] {{ + background-color: qlineargradient( + x1: 0, + y1: 0, + x2: 1, + y2: 1, + stop: 0 {locator_active_fill}, + stop: 1 {locator_key_hover} + ); + }} + QWidget[pointerLocatorEnabled="true"] QPushButton:disabled {{ + background-color: {locator_disabled_fill}; + }} QMessageBox QLabel#qt_msgbox_label {{ color: {text}; font-size: 15px; diff --git a/src/axidev_osk/windows/builder.py b/src/axidev_osk/windows/builder.py index d6323d0..ea81853 100644 --- a/src/axidev_osk/windows/builder.py +++ b/src/axidev_osk/windows/builder.py @@ -2,7 +2,7 @@ from __future__ import annotations -from PySide6.QtCore import QSize +from PySide6.QtCore import QObject, QSize from PySide6.QtGui import QCloseEvent, QShowEvent from PySide6.QtWidgets import QMainWindow, QVBoxLayout, QWidget @@ -44,6 +44,7 @@ def __init__(self, config: WindowConfig, context: Context, parent: QWidget | Non self._context = context self._quit_controller_managed = False self._chrome_widgets: OverlayChromeWidgets | None = None + self._surface_decorations: tuple[QObject, ...] = () self.setProperty("componentType", "window") self.setProperty("componentId", config.id) self.setWindowTitle(config.title) @@ -65,6 +66,11 @@ def __init__(self, config: WindowConfig, context: Context, parent: QWidget | Non on_resize=self._overlay.resize_by, ) self.setCentralWidget(central) + self._surface_decorations = context.surface_decorations.attach_all( + config.decorations, + central, + context, + ) self._opacity = WindowOpacityController(self) self.set_visual_opacity(config.opacity) self.apply_startup_size(minimum_size=config.surface.minimum_size) diff --git a/src/axidev_osk/windows/pointer_locator.py b/src/axidev_osk/windows/pointer_locator.py new file mode 100644 index 0000000..ae63178 --- /dev/null +++ b/src/axidev_osk/windows/pointer_locator.py @@ -0,0 +1,273 @@ +"""Non-interactive color feedback around a pointer inside a window.""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING + +from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, Qt, QTimer +from PySide6.QtGui import QColor, QCursor, QPaintEvent, QPainter, QRadialGradient +from PySide6.QtWidgets import QWidget + +from ..config.models import PointerLocatorConfig, SurfaceDecorationConfig +from .surface import SurfaceDecorationHost + +if TYPE_CHECKING: + from ..runtime.context import Context + from ..runtime.registries import SurfaceDecorationRegistry + +_logger = logging.getLogger(__name__) + +_GRADIENT_SEGMENTS = 32 + + +def register_surface_decorations(registry: "SurfaceDecorationRegistry") -> None: + """Register the pointer field as a reusable surface decoration.""" + + registry.register("pointer-locator", attach_pointer_locator) + + +def attach_pointer_locator( + config: SurfaceDecorationConfig, + surface: QWidget, + context: "Context", +) -> QObject | None: + """Attach pointer feedback to a compatible surface or warn and skip it.""" + + del context + if not isinstance(config, PointerLocatorConfig): + raise TypeError(f"Expected PointerLocatorConfig, got {type(config).__name__}") + if not isinstance(surface, SurfaceDecorationHost): + surface_id = surface.property("componentId") or surface.objectName() or type(surface).__name__ + _logger.warning( + "Surface decoration %s (%s) was skipped because surface %s does not support background decorations", + config.id, + config.kind, + surface_id, + ) + return None + + surface.setProperty("pointerLocatorEnabled", True) + locator = PointerLocator(config, surface) + surface.install_background_decoration(locator) + return locator + + +def _circular_distance(first: int, second: int, count: int) -> int: + distance = abs(first - second) % count + return min(distance, count - distance) + + +def _palette_stride(rows: int, columns: int) -> int: + """Choose a wheel traversal that separates both grid axes.""" + + count = rows * columns + if count == 1: + return 1 + + best_stride = 1 + best_score = (-1, -1) + for stride in range(1, count): + if math.gcd(stride, count) != 1: + continue + distances: list[int] = [] + weighted_distance = 0 + if columns > 1: + horizontal = _circular_distance(0, stride, count) + distances.append(horizontal) + weighted_distance += horizontal * rows * (columns - 1) + if rows > 1: + vertical = _circular_distance(0, columns * stride, count) + distances.append(vertical) + weighted_distance += vertical * columns * (rows - 1) + score = (min(distances), weighted_distance) + if score > best_score: + best_stride = stride + best_score = score + return best_stride + + +def build_pointer_palette(rows: int, columns: int) -> tuple[QColor, ...]: + """Build a deterministic saturated hue wheel arranged for a 2D grid.""" + + if rows <= 0 or columns <= 0: + raise ValueError("Pointer palette rows and columns must be positive") + count = rows * columns + stride = _palette_stride(rows, columns) + return tuple( + QColor.fromHsvF(((position * stride) % count) / count, 1.0, 1.0) + for position in range(count) + ) + + +def interpolate_pointer_color( + palette: tuple[QColor, ...], + *, + rows: int, + columns: int, + x: float, + y: float, + width: float, + height: float, +) -> QColor: + """Interpolate the four nearest color-region centers at one position.""" + + if len(palette) != rows * columns: + raise ValueError("Pointer palette size must match rows and columns") + if width <= 0 or height <= 0: + return QColor(palette[0]) + + grid_x = min(columns - 1.0, max(0.0, x * columns / width - 0.5)) + grid_y = min(rows - 1.0, max(0.0, y * rows / height - 0.5)) + left = int(math.floor(grid_x)) + top = int(math.floor(grid_y)) + right = min(columns - 1, left + 1) + bottom = min(rows - 1, top + 1) + x_weight = grid_x - left + y_weight = grid_y - top + + top_color = _mix_color(palette[top * columns + left], palette[top * columns + right], x_weight) + bottom_color = _mix_color( + palette[bottom * columns + left], + palette[bottom * columns + right], + x_weight, + ) + return _mix_color(top_color, bottom_color, y_weight) + + +def _mix_color(first: QColor, second: QColor, weight: float) -> QColor: + if weight <= 0.0: + return QColor(first) + if weight >= 1.0: + return QColor(second) + inverse = 1.0 - weight + return QColor.fromRgbF( + first.redF() * inverse + second.redF() * weight, + first.greenF() * inverse + second.greenF() * weight, + first.blueF() * inverse + second.blueF() * weight, + first.alphaF() * inverse + second.alphaF() * weight, + ) + + +def gaussian_opacity( + normalized_distance: float, + *, + maximum_opacity: float, + radius_standard_deviations: float, +) -> float: + """Return a truncated Gaussian opacity from the center through the radius.""" + + distance = min(1.0, max(0.0, normalized_distance)) + edge = math.exp(-0.5 * radius_standard_deviations**2) + value = math.exp(-0.5 * (radius_standard_deviations * distance) ** 2) + normalized = max(0.0, (value - edge) / (1.0 - edge)) + return maximum_opacity * normalized + + +class PointerLocator(QWidget): + """Paint a mouse-transparent radial color glow around the host pointer.""" + + def __init__(self, config: PointerLocatorConfig, parent: QWidget) -> None: + super().__init__(parent) + self._host = parent + self._config = config + self._palette = build_pointer_palette(config.rows, config.columns) + self._color = QColor(self._palette[0]) + self._cursor_position = QPoint() + self._pointer_inside = False + self._window = parent.window() + + self.setObjectName("pointerLocator") + self.setProperty("componentType", "pointer-locator") + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setGeometry(parent.rect()) + self.hide() + self._window.installEventFilter(self) + + self._timer = QTimer(self) + self._timer.setInterval(16) + self._timer.timeout.connect(self._poll_cursor) + self._timer.start() + + @property + def current_color(self) -> QColor: + """Return the color currently painted by the glow.""" + + return QColor(self._color) + + @property + def radius(self) -> float: + """Return the current glow radius in surface pixels.""" + + return min(self._host.width(), self._host.height()) * self._config.radius_percent / 100.0 + + def _poll_cursor(self) -> None: + if not self._pointer_inside: + self.hide() + return + self.update_from_global_position(QCursor.pos()) + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 + """Use real window boundary events instead of stale Wayland coordinates.""" + + window = getattr(self, "_window", None) + if watched is window: + if event.type() == QEvent.Type.Enter: + self._pointer_inside = True + self._poll_cursor() + elif event.type() in {QEvent.Type.Leave, QEvent.Type.Hide}: + self._pointer_inside = False + self.hide() + return super().eventFilter(watched, event) + + def update_from_global_position(self, global_position: QPoint) -> None: + """Update ring visibility, position, and color from a screen point.""" + + if not self._host.isVisible(): + self.hide() + return + + local_position = self._host.mapFromGlobal(global_position) + if not self._host.rect().contains(local_position): + self.hide() + return + + self._color = interpolate_pointer_color( + self._palette, + rows=self._config.rows, + columns=self._config.columns, + x=local_position.x(), + y=local_position.y(), + width=self._host.width(), + height=self._host.height(), + ) + self._cursor_position = local_position + if self.geometry() != self._host.rect(): + self.setGeometry(self._host.rect()) + self.update() + self.show() + + def paintEvent(self, event: QPaintEvent) -> None: # type: ignore[override] + """Paint the configured Gaussian glow behind surface controls.""" + + del event + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + gradient = QRadialGradient(QPointF(self._cursor_position), self.radius) + maximum_opacity = self._config.maximum_opacity_percent / 100.0 + for segment in range(_GRADIENT_SEGMENTS + 1): + position = segment / _GRADIENT_SEGMENTS + color = QColor(self._color) + color.setAlphaF( + gaussian_opacity( + position, + maximum_opacity=maximum_opacity, + radius_standard_deviations=self._config.radius_standard_deviations, + ) + ) + gradient.setColorAt(position, color) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(gradient) + painter.drawRect(self.rect()) diff --git a/src/axidev_osk/windows/surface.py b/src/axidev_osk/windows/surface.py index d01ee36..26e48e6 100644 --- a/src/axidev_osk/windows/surface.py +++ b/src/axidev_osk/windows/surface.py @@ -2,7 +2,10 @@ from __future__ import annotations +from typing import Protocol, runtime_checkable + from PySide6.QtCore import Qt +from PySide6.QtGui import QResizeEvent from PySide6.QtWidgets import QVBoxLayout, QWidget from ..config.models import SurfaceConfig @@ -10,6 +13,37 @@ from ..runtime.registries import SurfaceRegistry +@runtime_checkable +class SurfaceDecorationHost(Protocol): + """Surface capability for widgets painted between the background and content.""" + + def install_background_decoration(self, widget: QWidget) -> None: + """Install one widget behind the surface's content.""" + + +class RootSurface(QWidget): + """Generic root surface with a background-decoration layer.""" + + def __init__(self) -> None: + super().__init__() + self._background_decorations: list[QWidget] = [] + + def install_background_decoration(self, widget: QWidget) -> None: + """Parent and stack one decoration immediately above the styled background.""" + + widget.setParent(self) + widget.setGeometry(self.rect()) + widget.lower() + self._background_decorations.append(widget) + + def resizeEvent(self, event: QResizeEvent) -> None: # type: ignore[override] + """Keep all background decorations fitted to the surface.""" + + super().resizeEvent(event) + for decoration in self._background_decorations: + decoration.setGeometry(self.rect()) + + def register_surfaces(registry: SurfaceRegistry) -> None: """Register the generic surface builder. @@ -40,7 +74,7 @@ def build_surface(config: SurfaceConfig, context: Context) -> QWidget: Constructs child widgets via the component registry. """ - central = QWidget() + central = RootSurface() central.setObjectName("rootSurface") central.setProperty("componentType", "surface") central.setProperty("componentId", config.id) diff --git a/tests/test_pointer_locator.py b/tests/test_pointer_locator.py new file mode 100644 index 0000000..25920c3 --- /dev/null +++ b/tests/test_pointer_locator.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from PySide6.QtCore import QEvent, QPoint, Qt +from PySide6.QtGui import QColor, QImage +from PySide6.QtWidgets import QApplication, QWidget + +from axidev_osk.config.defaults import build_default_app_config +from axidev_osk.config.models import PointerLocatorConfig +from axidev_osk.windows.pointer_locator import ( + PointerLocator, + build_pointer_palette, + gaussian_opacity, + interpolate_pointer_color, +) + + +def _app() -> QApplication: + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +def _locator_config(**overrides: object) -> PointerLocatorConfig: + values = { + "id": "decoration:test-pointer-locator", + "rows": 4, + "columns": 4, + "radius_percent": 30, + "maximum_opacity_percent": 60, + "radius_standard_deviations": 3, + } + values.update(overrides) + return PointerLocatorConfig(**values) + + +class PointerLocatorPaletteTests(unittest.TestCase): + def test_default_keyboard_uses_four_by_four_locator(self) -> None: + decorations = build_default_app_config().windows[0].decorations + + self.assertEqual(len(decorations), 1) + config = decorations[0] + self.assertIsInstance(config, PointerLocatorConfig) + self.assertEqual(config.rows, 4) + self.assertEqual(config.columns, 4) + self.assertEqual(config.radius_percent, 30) + self.assertEqual(config.maximum_opacity_percent, 60) + self.assertEqual(config.radius_standard_deviations, 3) + + def test_config_rejects_non_positive_dimensions(self) -> None: + for rows, columns in ((0, 4), (4, 0), (-1, 4), (4, -1)): + with self.subTest(rows=rows, columns=columns): + with self.assertRaisesRegex(ValueError, "rows and columns must be positive"): + _locator_config(rows=rows, columns=columns) + + def test_config_rejects_radius_outside_percentage_bounds(self) -> None: + for radius_percent in (0, -1, 100.1, float("nan")): + with self.subTest(radius_percent=radius_percent): + with self.assertRaisesRegex(ValueError, "radius percent"): + _locator_config(radius_percent=radius_percent) + + def test_config_rejects_invalid_gaussian_values(self) -> None: + invalid_values = ( + {"maximum_opacity_percent": 0, "radius_standard_deviations": 3}, + {"maximum_opacity_percent": 100.1, "radius_standard_deviations": 3}, + {"maximum_opacity_percent": 60, "radius_standard_deviations": 0}, + {"maximum_opacity_percent": 60, "radius_standard_deviations": float("nan")}, + ) + for values in invalid_values: + with self.subTest(values=values): + with self.assertRaises(ValueError): + _locator_config(**values) + + def test_config_rejects_non_finite_or_tiny_standard_deviation_count(self) -> None: + for value in (float("inf"), float("nan"), 0.099): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, "finite and at least 0.1"): + _locator_config(radius_standard_deviations=value) + + def test_gaussian_opacity_has_configured_peak_and_transparent_edge(self) -> None: + center = gaussian_opacity( + 0, + maximum_opacity=0.6, + radius_standard_deviations=3, + ) + halfway = gaussian_opacity( + 0.5, + maximum_opacity=0.6, + radius_standard_deviations=3, + ) + edge = gaussian_opacity( + 1, + maximum_opacity=0.6, + radius_standard_deviations=3, + ) + + self.assertAlmostEqual(center, 0.6) + self.assertAlmostEqual(halfway, 0.19, delta=0.01) + self.assertEqual(edge, 0) + + def test_four_by_four_palette_is_deterministic_and_unique(self) -> None: + first = build_pointer_palette(4, 4) + second = build_pointer_palette(4, 4) + + self.assertEqual(first, second) + self.assertEqual(len(first), 16) + self.assertEqual(len({color.name() for color in first}), 16) + + def test_four_by_four_palette_separates_all_orthogonal_neighbors(self) -> None: + palette = build_pointer_palette(4, 4) + + for row in range(4): + for column in range(4): + index = row * 4 + column + neighbor_indexes = [] + if column < 3: + neighbor_indexes.append(index + 1) + if row < 3: + neighbor_indexes.append(index + 4) + for neighbor_index in neighbor_indexes: + first_hue = palette[index].hsvHueF() * 360 + second_hue = palette[neighbor_index].hsvHueF() * 360 + distance = abs(first_hue - second_hue) + distance = min(distance, 360 - distance) + self.assertGreaterEqual(distance, 89.9) + + def test_region_centers_keep_their_exact_palette_colors(self) -> None: + rows = 4 + columns = 4 + width = 800 + height = 400 + palette = build_pointer_palette(rows, columns) + + for row in range(rows): + for column in range(columns): + with self.subTest(row=row, column=column): + color = interpolate_pointer_color( + palette, + rows=rows, + columns=columns, + x=(column + 0.5) * width / columns, + y=(row + 0.5) * height / rows, + width=width, + height=height, + ) + self.assertEqual(color, palette[row * columns + column]) + + def test_position_uses_the_same_color_after_grid_stretching(self) -> None: + palette = build_pointer_palette(4, 4) + + original = interpolate_pointer_color( + palette, + rows=4, + columns=4, + x=312.5, + y=162.5, + width=500, + height=250, + ) + stretched = interpolate_pointer_color( + palette, + rows=4, + columns=4, + x=625, + y=325, + width=1000, + height=500, + ) + + self.assertEqual(original, stretched) + + def test_midpoint_blends_neighboring_colors(self) -> None: + palette = (QColor("#ff0000"), QColor("#0000ff")) + + color = interpolate_pointer_color( + palette, + rows=1, + columns=2, + x=50, + y=25, + width=100, + height=50, + ) + + self.assertAlmostEqual(color.redF(), 0.5, delta=0.01) + self.assertAlmostEqual(color.greenF(), 0.0, delta=0.01) + self.assertAlmostEqual(color.blueF(), 0.5, delta=0.01) + + +class PointerLocatorWidgetTests(unittest.TestCase): + def test_glow_tracks_pointer_inside_host_and_hides_outside(self) -> None: + app = _app() + host = QWidget() + host.resize(400, 200) + host.show() + app.processEvents() + locator = PointerLocator( + _locator_config(), + host, + ) + self.addCleanup(host.close) + + locator.update_from_global_position(host.mapToGlobal(QPoint(200, 100))) + + self.assertTrue(locator.isVisible()) + self.assertEqual(locator.geometry(), host.rect()) + self.assertEqual(locator.radius, 60) + self.assertTrue(locator.testAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)) + + image = QImage(locator.size(), QImage.Format.Format_ARGB32_Premultiplied) + image.fill(Qt.GlobalColor.transparent) + locator.render(image) + self.assertAlmostEqual(image.pixelColor(200, 100).alphaF(), 0.6, delta=0.02) + self.assertEqual(image.pixelColor(270, 100).alpha(), 0) + + locator.update_from_global_position(host.mapToGlobal(QPoint(500, 100))) + + self.assertFalse(locator.isVisible()) + + def test_stale_wayland_position_cannot_restore_glow_after_leave(self) -> None: + app = _app() + host = QWidget() + host.resize(400, 200) + host.show() + app.processEvents() + locator = PointerLocator( + _locator_config(), + host, + ) + self.addCleanup(host.close) + inside = host.mapToGlobal(QPoint(200, 100)) + locator.update_from_global_position(inside) + self.assertTrue(locator.isVisible()) + + app.sendEvent(host, QEvent(QEvent.Type.Leave)) + with patch("axidev_osk.windows.pointer_locator.QCursor.pos", return_value=inside): + locator._poll_cursor() + self.assertFalse(locator.isVisible()) + app.sendEvent(host, QEvent(QEvent.Type.Enter)) + + self.assertTrue(locator.isVisible()) diff --git a/tests/test_surface_decorations.py b/tests/test_surface_decorations.py new file mode 100644 index 0000000..a472a0c --- /dev/null +++ b/tests/test_surface_decorations.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import unittest +from unittest.mock import Mock + +from PySide6.QtCore import QObject, QPoint +from PySide6.QtWidgets import QApplication, QWidget + +from axidev_osk.config.models import PointerLocatorConfig +from axidev_osk.runtime.registries import SurfaceDecorationRegistry +from axidev_osk.windows.pointer_locator import attach_pointer_locator +from axidev_osk.windows.surface import RootSurface + + +def _app() -> QApplication: + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +def _config() -> PointerLocatorConfig: + return PointerLocatorConfig( + id="decoration:test-pointer-locator", + rows=4, + columns=4, + radius_percent=30, + maximum_opacity_percent=60, + radius_standard_deviations=3, + ) + + +class SurfaceDecorationRegistryTests(unittest.TestCase): + def test_registry_attaches_decorations_by_kind_in_order(self) -> None: + registry = SurfaceDecorationRegistry() + first = QObject() + second = QObject() + builder = Mock(side_effect=(first, second)) + registry.register("pointer-locator", builder) + surface = QWidget() + context = Mock() + + attached = registry.attach_all((_config(), _config()), surface, context) + + self.assertEqual(attached, (first, second)) + self.assertEqual(builder.call_count, 2) + + def test_registry_rejects_missing_decoration_kind(self) -> None: + registry = SurfaceDecorationRegistry() + + with self.assertRaisesRegex(ValueError, "No surface decoration registered"): + registry.attach(_config(), QWidget(), Mock()) + + +class RootSurfaceDecorationTests(unittest.TestCase): + def test_background_decoration_is_fitted_below_surface_content(self) -> None: + _app() + surface = RootSurface() + surface.resize(300, 160) + content = QWidget(surface) + content.setGeometry(surface.rect()) + content.show() + decoration = QWidget() + decoration.show() + + surface.install_background_decoration(decoration) + + self.assertIs(decoration.parentWidget(), surface) + self.assertEqual(decoration.geometry(), surface.rect()) + self.assertIs(surface.childAt(QPoint(20, 20)), content) + + def test_incompatible_surface_warns_and_skips_pointer_locator(self) -> None: + surface = QWidget() + surface.setProperty("componentId", "surface:incompatible") + + with self.assertLogs("axidev_osk.windows.pointer_locator", level="WARNING") as logs: + attached = attach_pointer_locator(_config(), surface, Mock()) + + self.assertIsNone(attached) + self.assertIn("decoration:test-pointer-locator", logs.output[0]) + self.assertIn("surface:incompatible", logs.output[0]) diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 0000000..aee2a50 --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,12 @@ +import unittest + +from axidev_osk.styles.theme import build_stylesheet + + +class PointerLocatorThemeTests(unittest.TestCase): + def test_pointer_locator_surface_uses_translucent_button_backgrounds(self) -> None: + stylesheet = build_stylesheet() + + self.assertIn('QWidget[pointerLocatorEnabled="true"] QPushButton', stylesheet) + self.assertIn("rgba(18, 18, 26, 153)", stylesheet) + self.assertIn("rgba(16, 16, 24, 153)", stylesheet) diff --git a/tests/test_window_builder.py b/tests/test_window_builder.py index da1c210..7620b52 100644 --- a/tests/test_window_builder.py +++ b/tests/test_window_builder.py @@ -1,6 +1,8 @@ from __future__ import annotations import unittest +import inspect +from dataclasses import replace from unittest.mock import Mock, patch from PySide6.QtCore import Qt @@ -11,10 +13,11 @@ from axidev_osk.config.defaults import build_default_app_config from axidev_osk.runtime.registries import ComponentRegistry, SurfaceRegistry from axidev_osk.runtime.testing import make_test_context -from axidev_osk.windows.builder import build_window +from axidev_osk.windows.builder import RuntimeWindow, build_window from axidev_osk.windows.chrome import OverlayResizeHandle, OverlayTitleBar from axidev_osk.windows.surface import register_surfaces from axidev_osk.windows.overlay.always_on_top import OverlayPlacement +from axidev_osk.windows.pointer_locator import PointerLocator class FakeKeyboardBackend: @@ -202,6 +205,52 @@ def test_startup_size_uses_minimum_size(self) -> None: self.assertLessEqual(window.minimumWidth(), window.width()) self.assertLessEqual(window.minimumHeight(), window.height()) + def test_default_keyboard_window_installs_configured_pointer_locator(self) -> None: + _app() + overlay = FakeOverlayController() + + with patch( + "axidev_osk.windows.builder.configure_always_on_top_window", + return_value=overlay, + ): + window = _build_keyboard_window(FakeKeyboardBackend(ready=True)) + + self.addCleanup(window.close) + locator = window.findChild(PointerLocator, "pointerLocator") + self.assertIsNotNone(locator) + self.assertIs(locator.parentWidget(), window.centralWidget()) + self.assertTrue(window.centralWidget().property("pointerLocatorEnabled")) + + def test_window_omits_pointer_locator_when_config_is_none(self) -> None: + _app() + app_config = build_default_app_config() + window_config = replace(app_config.windows[0], decorations=()) + components = ComponentRegistry() + surfaces = SurfaceRegistry() + register_components(components) + register_surfaces(surfaces) + context = make_test_context( + FakeKeyboardBackend(ready=True), + config=app_config, + components=components, + surfaces=surfaces, + ) + + with patch( + "axidev_osk.windows.builder.configure_always_on_top_window", + return_value=FakeOverlayController(), + ): + window = build_window(window_config, context) + + self.addCleanup(window.close) + self.assertIsNone(window.findChild(PointerLocator, "pointerLocator")) + + def test_runtime_window_has_no_pointer_locator_branch(self) -> None: + source = inspect.getsource(RuntimeWindow.__init__) + + self.assertNotIn("PointerLocator", source) + self.assertNotIn("pointerLocatorEnabled", source) + def test_keyboard_window_uses_center_overlay_placement(self) -> None: _app() overlay = FakeOverlayController() From b43c246097fdfe71c46f7c8313acddc476eee767 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Tue, 8 Sep 2026 20:08:58 +0200 Subject: [PATCH 2/3] refactor(pointer): build locator as surface component inayayousfi directed the work and made every decision. gpt-5.6-sol, running in OpenCode, carried inayayousfi's decisions out. Use the existing component registry for background layers and remove the parallel surface-decoration pipeline. --- src/axidev_osk/components/__init__.py | 1 + .../pointer_locator.py | 57 +++++-------- src/axidev_osk/config/__init__.py | 2 - src/axidev_osk/config/defaults/__init__.py | 24 +++--- src/axidev_osk/config/models.py | 28 ++++--- src/axidev_osk/runtime/application.py | 5 -- src/axidev_osk/runtime/context.py | 4 +- src/axidev_osk/runtime/registries.py | 44 +--------- src/axidev_osk/runtime/testing.py | 10 --- src/axidev_osk/windows/builder.py | 8 +- src/axidev_osk/windows/surface.py | 33 ++++---- tests/test_pointer_locator.py | 16 ++-- tests/test_surface_components.py | 76 +++++++++++++++++ tests/test_surface_decorations.py | 81 ------------------- tests/test_window_builder.py | 12 ++- 15 files changed, 160 insertions(+), 241 deletions(-) rename src/axidev_osk/{windows => components}/pointer_locator.py (84%) create mode 100644 tests/test_surface_components.py delete mode 100644 tests/test_surface_decorations.py diff --git a/src/axidev_osk/components/__init__.py b/src/axidev_osk/components/__init__.py index 0b921df..5fe6ee7 100644 --- a/src/axidev_osk/components/__init__.py +++ b/src/axidev_osk/components/__init__.py @@ -9,6 +9,7 @@ "axidev_osk.components.grid.builder", "axidev_osk.components.button", "axidev_osk.components.prompt", + "axidev_osk.components.pointer_locator", ) diff --git a/src/axidev_osk/windows/pointer_locator.py b/src/axidev_osk/components/pointer_locator.py similarity index 84% rename from src/axidev_osk/windows/pointer_locator.py rename to src/axidev_osk/components/pointer_locator.py index ae63178..19800c3 100644 --- a/src/axidev_osk/windows/pointer_locator.py +++ b/src/axidev_osk/components/pointer_locator.py @@ -2,56 +2,41 @@ from __future__ import annotations -import logging import math -from typing import TYPE_CHECKING from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, Qt, QTimer from PySide6.QtGui import QColor, QCursor, QPaintEvent, QPainter, QRadialGradient from PySide6.QtWidgets import QWidget -from ..config.models import PointerLocatorConfig, SurfaceDecorationConfig -from .surface import SurfaceDecorationHost - -if TYPE_CHECKING: - from ..runtime.context import Context - from ..runtime.registries import SurfaceDecorationRegistry - -_logger = logging.getLogger(__name__) +from ..config.models import ComponentConfig, PointerLocatorConfig +from ..runtime.context import Context +from ..runtime.registries import ComponentRegistry _GRADIENT_SEGMENTS = 32 -def register_surface_decorations(registry: "SurfaceDecorationRegistry") -> None: - """Register the pointer field as a reusable surface decoration.""" +def register(registry: ComponentRegistry) -> None: + """Register the pointer locator as a reusable background component.""" - registry.register("pointer-locator", attach_pointer_locator) + registry.register("pointer-locator", build_pointer_locator_component) -def attach_pointer_locator( - config: SurfaceDecorationConfig, - surface: QWidget, - context: "Context", -) -> QObject | None: - """Attach pointer feedback to a compatible surface or warn and skip it.""" +def build_pointer_locator_component( + config: ComponentConfig, + context: Context, + *, + host: QWidget | None = None, +) -> QWidget: + """Build pointer feedback for a root surface background.""" del context if not isinstance(config, PointerLocatorConfig): raise TypeError(f"Expected PointerLocatorConfig, got {type(config).__name__}") - if not isinstance(surface, SurfaceDecorationHost): - surface_id = surface.property("componentId") or surface.objectName() or type(surface).__name__ - _logger.warning( - "Surface decoration %s (%s) was skipped because surface %s does not support background decorations", - config.id, - config.kind, - surface_id, - ) - return None + if host is None: + raise RuntimeError("Pointer locator components require a root surface host") - surface.setProperty("pointerLocatorEnabled", True) - locator = PointerLocator(config, surface) - surface.install_background_decoration(locator) - return locator + host.setProperty("pointerLocatorEnabled", True) + return PointerLocator(config, host) def _circular_distance(first: int, second: int, count: int) -> int: @@ -176,15 +161,15 @@ def __init__(self, config: PointerLocatorConfig, parent: QWidget) -> None: self._color = QColor(self._palette[0]) self._cursor_position = QPoint() self._pointer_inside = False - self._window = parent.window() self.setObjectName("pointerLocator") self.setProperty("componentType", "pointer-locator") + self.setProperty("componentId", config.id) self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) self.setGeometry(parent.rect()) self.hide() - self._window.installEventFilter(self) + self._host.installEventFilter(self) self._timer = QTimer(self) self._timer.setInterval(16) @@ -212,8 +197,8 @@ def _poll_cursor(self) -> None: def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 """Use real window boundary events instead of stale Wayland coordinates.""" - window = getattr(self, "_window", None) - if watched is window: + host = getattr(self, "_host", None) + if watched is host: if event.type() == QEvent.Type.Enter: self._pointer_inside = True self._poll_cursor() diff --git a/src/axidev_osk/config/__init__.py b/src/axidev_osk/config/__init__.py index 6cc5914..c27bbbc 100644 --- a/src/axidev_osk/config/__init__.py +++ b/src/axidev_osk/config/__init__.py @@ -14,7 +14,6 @@ PointerLocatorConfig, PromptConfig, SpacerConfig, - SurfaceDecorationConfig, SurfaceConfig, WindowConfig, ) @@ -33,7 +32,6 @@ "PointerLocatorConfig", "PromptConfig", "SpacerConfig", - "SurfaceDecorationConfig", "SurfaceConfig", "WindowConfig", ] diff --git a/src/axidev_osk/config/defaults/__init__.py b/src/axidev_osk/config/defaults/__init__.py index 5bc4388..da7028d 100644 --- a/src/axidev_osk/config/defaults/__init__.py +++ b/src/axidev_osk/config/defaults/__init__.py @@ -41,9 +41,9 @@ def build_default_app_config() -> AppConfig: keyboard_status_id = stable_id(keyboard_surface_id, "component", "keyboard-status", stable_override="component:keyboard-status") pointer_locator_id = stable_id( keyboard_surface_id, - "decoration", + "component", "pointer-locator", - stable_override="decoration:pointer-locator", + stable_override="component:pointer-locator", ) keyboard_window = WindowConfig( id=keyboard_window_id, @@ -60,6 +60,16 @@ def build_default_app_config() -> AppConfig: ), KeyboardStatusConfig(id=keyboard_status_id), ), + background_components=( + PointerLocatorConfig( + id=pointer_locator_id, + rows=4, + columns=4, + radius_percent=30, + maximum_opacity_percent=60, + radius_standard_deviations=3, + ), + ), margins=(10, 10, 10, 10), spacing=8, ), @@ -71,16 +81,6 @@ def build_default_app_config() -> AppConfig: ), ), chrome=ChromeConfig(enabled=True), - decorations=( - PointerLocatorConfig( - id=pointer_locator_id, - rows=4, - columns=4, - radius_percent=30, - maximum_opacity_percent=60, - radius_standard_deviations=3, - ), - ), opacity=0.85, ) diff --git a/src/axidev_osk/config/models.py b/src/axidev_osk/config/models.py index f9625e1..aaa004c 100644 --- a/src/axidev_osk/config/models.py +++ b/src/axidev_osk/config/models.py @@ -60,10 +60,10 @@ class ChromeConfig: @dataclass(frozen=True, slots=True) class PointerLocatorConfig: - """Color-grid pointer feedback configured for one window. + """Color-grid pointer feedback configured as a surface component. Attributes: - id: Deterministic decoration ID. + id: Deterministic component ID. rows: Number of color regions along the vertical axis. columns: Number of color regions along the horizontal axis. radius_percent: Glow radius as a percentage of the surface's shorter side. @@ -92,10 +92,6 @@ def __post_init__(self) -> None: raise ValueError("Pointer locator radius standard deviations must be finite and at least 0.1") -SurfaceDecorationConfig = PointerLocatorConfig - - - @dataclass(frozen=True, slots=True) class KeyConfig: """Declarative key component placement and behavior. @@ -277,7 +273,15 @@ class KeyboardStatusConfig: kind: Literal["keyboard-status"] = "keyboard-status" -ComponentConfig = KeyConfig | SpacerConfig | ButtonConfig | PromptConfig | KeyboardGridConfig | KeyboardStatusConfig +ComponentConfig = ( + KeyConfig + | SpacerConfig + | ButtonConfig + | PromptConfig + | KeyboardGridConfig + | KeyboardStatusConfig + | PointerLocatorConfig +) @dataclass(frozen=True, slots=True) @@ -309,6 +313,7 @@ class SurfaceConfig: id: Deterministic surface ID. kind: Surface builder key. components: Child components mounted into the surface. + background_components: Components painted behind the surface content. margins: Qt layout margins in pixels ordered left, top, right, bottom. spacing: Qt layout spacing in pixels. minimum_size: Optional lower bound for startup size as ``(width, height)``. @@ -316,6 +321,7 @@ class SurfaceConfig: id: str components: tuple[ComponentConfig, ...] + background_components: tuple[ComponentConfig, ...] = () kind: Literal["surface"] = "surface" margins: tuple[int, int, int, int] = (10, 10, 10, 10) spacing: int = 8 @@ -324,7 +330,10 @@ class SurfaceConfig: def __post_init__(self) -> None: """Validate IDs at the surface composition boundary.""" - validate_unique_ids((component.id for component in self.components), scope=f"surface {self.id!r} components") + validate_unique_ids( + (component.id for component in (*self.background_components, *self.components)), + scope=f"surface {self.id!r} components", + ) @dataclass(frozen=True, slots=True) @@ -337,7 +346,6 @@ class WindowConfig: surface: Root surface content declaration. overlay: Overlay behavior for this window. chrome: Optional custom chrome policy. - decorations: Optional surface decorations attached through the runtime registry. opacity: Normal window opacity from zero through one. """ @@ -346,7 +354,6 @@ class WindowConfig: surface: SurfaceConfig overlay: OverlayConfig = field(default_factory=OverlayConfig) chrome: ChromeConfig = field(default_factory=ChromeConfig) - decorations: tuple[SurfaceDecorationConfig, ...] = () opacity: float = 1.0 def __post_init__(self) -> None: @@ -354,7 +361,6 @@ def __post_init__(self) -> None: if not 0.0 <= self.opacity <= 1.0: raise ValueError("Window opacity must be between 0.0 and 1.0") - validate_unique_ids((decoration.id for decoration in self.decorations), scope=f"window {self.id!r} decorations") @dataclass(frozen=True, slots=True) diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index 926e552..9e02fd8 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -17,7 +17,6 @@ from ..services.keyboard import KeyboardService from ..services.kwin_lock import KWinLockService from ..styles.theme import apply_theme -from ..windows.pointer_locator import register_surface_decorations from ..windows.surface import register_surfaces from .context import Context from .dispatcher import Dispatcher @@ -33,7 +32,6 @@ ComponentRegistry, EventHandlerRegistry, ServiceRegistry, - SurfaceDecorationRegistry, SurfaceRegistry, ) from .state_store import StateStore @@ -84,13 +82,11 @@ def __init__( self._state = StateStore() self._components = ComponentRegistry() self._surfaces = SurfaceRegistry() - self._surface_decorations = SurfaceDecorationRegistry() self._event_handlers = event_handlers or EventHandlerRegistry() if event_handlers is None: register_event_handlers(self._event_handlers) register_components(self._components) register_surfaces(self._surfaces) - register_surface_decorations(self._surface_decorations) self.context = Context( config=self._config, dispatcher=self._dispatcher, @@ -98,7 +94,6 @@ def __init__( state=self._state, components=self._components, surfaces=self._surfaces, - surface_decorations=self._surface_decorations, ) self._dispatcher.bind_context(self.context) context_handlers = EventHandlerRegistry() diff --git a/src/axidev_osk/runtime/context.py b/src/axidev_osk/runtime/context.py index 03cef32..befb144 100644 --- a/src/axidev_osk/runtime/context.py +++ b/src/axidev_osk/runtime/context.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from ..config.models import AppConfig -from .registries import ComponentRegistry, SurfaceDecorationRegistry, SurfaceRegistry +from .registries import ComponentRegistry, SurfaceRegistry from .state_store import StateStore if TYPE_CHECKING: @@ -25,7 +25,6 @@ class Context: state: Central state store. components: Component builder registry. surfaces: Surface builder registry. - surface_decorations: Surface-decoration attachment registry. """ config: AppConfig @@ -34,4 +33,3 @@ class Context: state: StateStore components: ComponentRegistry surfaces: SurfaceRegistry - surface_decorations: SurfaceDecorationRegistry diff --git a/src/axidev_osk/runtime/registries.py b/src/axidev_osk/runtime/registries.py index 025b514..e852aaa 100644 --- a/src/axidev_osk/runtime/registries.py +++ b/src/axidev_osk/runtime/registries.py @@ -13,10 +13,9 @@ from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, Protocol, TypeVar, cast -from PySide6.QtCore import QObject from PySide6.QtWidgets import QWidget -from ..config.models import ComponentConfig, SurfaceConfig, SurfaceDecorationConfig +from ..config.models import ComponentConfig, SurfaceConfig from .commands import RuntimeCommand from .events import RuntimeEvent @@ -27,7 +26,6 @@ ComponentBuilder = Callable[..., QWidget] SurfaceBuilder = Callable[[SurfaceConfig, "Context"], QWidget] -SurfaceDecorationBuilder = Callable[[SurfaceDecorationConfig, QWidget, "Context"], QObject | None] RuntimeT = TypeVar("RuntimeT") @@ -173,46 +171,6 @@ def build(self, config: SurfaceConfig, context: "Context") -> QWidget: return builder(config, context) -class SurfaceDecorationRegistry: - """Maps surface-decoration kinds to attachment functions.""" - - def __init__(self) -> None: - self._builders: dict[str, SurfaceDecorationBuilder] = {} - - def register(self, kind: str, builder: SurfaceDecorationBuilder) -> None: - """Register one surface-decoration attachment function.""" - - self._builders[kind] = builder - - def attach( - self, - config: SurfaceDecorationConfig, - surface: QWidget, - context: "Context", - ) -> QObject | None: - """Attach one configured decoration to a built surface.""" - - builder = self._builders.get(config.kind) - if builder is None: - raise ValueError(f"No surface decoration registered for kind {config.kind!r}") - return builder(config, surface, context) - - def attach_all( - self, - configs: Iterable[SurfaceDecorationConfig], - surface: QWidget, - context: "Context", - ) -> tuple[QObject, ...]: - """Attach configured decorations in declaration order.""" - - attached: list[QObject] = [] - for config in configs: - decoration = self.attach(config, surface, context) - if decoration is not None: - attached.append(decoration) - return tuple(attached) - - class ServiceRegistry: """Maintains named runtime services in deterministic startup order.""" diff --git a/src/axidev_osk/runtime/testing.py b/src/axidev_osk/runtime/testing.py index f2a5a25..1634a6d 100644 --- a/src/axidev_osk/runtime/testing.py +++ b/src/axidev_osk/runtime/testing.py @@ -33,7 +33,6 @@ ComponentRegistry, EventHandlerRegistry, ServiceRegistry, - SurfaceDecorationRegistry, SurfaceRegistry, ) from .state_store import StateStore @@ -92,7 +91,6 @@ def make_test_context( config: AppConfig | None = None, components: ComponentRegistry | None = None, surfaces: SurfaceRegistry | None = None, - surface_decorations: SurfaceDecorationRegistry | None = None, services: set[str] | None = None, event_handlers: bool = False, ) -> Context: @@ -116,8 +114,6 @@ def make_test_context( component builders are registered into it. surfaces: Optional pre-populated surface registry. Defaults to an empty registry. - surface_decorations: Optional pre-populated surface-decoration registry. - Defaults to the bundled decoration builders. services: Optional explicit service names to register and start. When omitted, only the supplied keyboard backend is bound. event_handlers: Whether to install bundled application-level event @@ -139,11 +135,6 @@ def make_test_context( components = ComponentRegistry() register_components(components) - if surface_decorations is None: - from ..windows.pointer_locator import register_surface_decorations - - surface_decorations = SurfaceDecorationRegistry() - register_surface_decorations(surface_decorations) context = Context( config=config or build_default_app_config(), dispatcher=dispatcher, @@ -151,7 +142,6 @@ def make_test_context( state=StateStore(), components=components, surfaces=surfaces or SurfaceRegistry(), - surface_decorations=surface_decorations, ) dispatcher.bind_context(context) context_handlers = EventHandlerRegistry() diff --git a/src/axidev_osk/windows/builder.py b/src/axidev_osk/windows/builder.py index ea81853..d6323d0 100644 --- a/src/axidev_osk/windows/builder.py +++ b/src/axidev_osk/windows/builder.py @@ -2,7 +2,7 @@ from __future__ import annotations -from PySide6.QtCore import QObject, QSize +from PySide6.QtCore import QSize from PySide6.QtGui import QCloseEvent, QShowEvent from PySide6.QtWidgets import QMainWindow, QVBoxLayout, QWidget @@ -44,7 +44,6 @@ def __init__(self, config: WindowConfig, context: Context, parent: QWidget | Non self._context = context self._quit_controller_managed = False self._chrome_widgets: OverlayChromeWidgets | None = None - self._surface_decorations: tuple[QObject, ...] = () self.setProperty("componentType", "window") self.setProperty("componentId", config.id) self.setWindowTitle(config.title) @@ -66,11 +65,6 @@ def __init__(self, config: WindowConfig, context: Context, parent: QWidget | Non on_resize=self._overlay.resize_by, ) self.setCentralWidget(central) - self._surface_decorations = context.surface_decorations.attach_all( - config.decorations, - central, - context, - ) self._opacity = WindowOpacityController(self) self.set_visual_opacity(config.opacity) self.apply_startup_size(minimum_size=config.surface.minimum_size) diff --git a/src/axidev_osk/windows/surface.py b/src/axidev_osk/windows/surface.py index 26e48e6..39abda5 100644 --- a/src/axidev_osk/windows/surface.py +++ b/src/axidev_osk/windows/surface.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Protocol, runtime_checkable - from PySide6.QtCore import Qt from PySide6.QtGui import QResizeEvent from PySide6.QtWidgets import QVBoxLayout, QWidget @@ -13,35 +11,28 @@ from ..runtime.registries import SurfaceRegistry -@runtime_checkable -class SurfaceDecorationHost(Protocol): - """Surface capability for widgets painted between the background and content.""" - - def install_background_decoration(self, widget: QWidget) -> None: - """Install one widget behind the surface's content.""" - - class RootSurface(QWidget): - """Generic root surface with a background-decoration layer.""" + """Generic root surface with a background-component layer.""" def __init__(self) -> None: super().__init__() - self._background_decorations: list[QWidget] = [] + self._background_components: list[QWidget] = [] - def install_background_decoration(self, widget: QWidget) -> None: - """Parent and stack one decoration immediately above the styled background.""" + def install_background_component(self, widget: QWidget) -> None: + """Parent and stack one component immediately above the styled background.""" widget.setParent(self) widget.setGeometry(self.rect()) - widget.lower() - self._background_decorations.append(widget) + self._background_components.append(widget) + for component in reversed(self._background_components): + component.lower() def resizeEvent(self, event: QResizeEvent) -> None: # type: ignore[override] - """Keep all background decorations fitted to the surface.""" + """Keep all background components fitted to the surface.""" super().resizeEvent(event) - for decoration in self._background_decorations: - decoration.setGeometry(self.rect()) + for component in self._background_components: + component.setGeometry(self.rect()) def register_surfaces(registry: SurfaceRegistry) -> None: @@ -80,6 +71,10 @@ def build_surface(config: SurfaceConfig, context: Context) -> QWidget: central.setProperty("componentId", config.id) central.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + for component in config.background_components: + widget = context.components.build(component, context, host=central) + central.install_background_component(widget) + layout = QVBoxLayout(central) layout.setContentsMargins(*config.margins) layout.setSpacing(config.spacing) diff --git a/tests/test_pointer_locator.py b/tests/test_pointer_locator.py index 25920c3..92d5b5c 100644 --- a/tests/test_pointer_locator.py +++ b/tests/test_pointer_locator.py @@ -7,14 +7,14 @@ from PySide6.QtGui import QColor, QImage from PySide6.QtWidgets import QApplication, QWidget -from axidev_osk.config.defaults import build_default_app_config -from axidev_osk.config.models import PointerLocatorConfig -from axidev_osk.windows.pointer_locator import ( +from axidev_osk.components.pointer_locator import ( PointerLocator, build_pointer_palette, gaussian_opacity, interpolate_pointer_color, ) +from axidev_osk.config.defaults import build_default_app_config +from axidev_osk.config.models import PointerLocatorConfig def _app() -> QApplication: @@ -26,7 +26,7 @@ def _app() -> QApplication: def _locator_config(**overrides: object) -> PointerLocatorConfig: values = { - "id": "decoration:test-pointer-locator", + "id": "component:test-pointer-locator", "rows": 4, "columns": 4, "radius_percent": 30, @@ -39,10 +39,10 @@ def _locator_config(**overrides: object) -> PointerLocatorConfig: class PointerLocatorPaletteTests(unittest.TestCase): def test_default_keyboard_uses_four_by_four_locator(self) -> None: - decorations = build_default_app_config().windows[0].decorations + background_components = build_default_app_config().windows[0].surface.background_components - self.assertEqual(len(decorations), 1) - config = decorations[0] + self.assertEqual(len(background_components), 1) + config = background_components[0] self.assertIsInstance(config, PointerLocatorConfig) self.assertEqual(config.rows, 4) self.assertEqual(config.columns, 4) @@ -236,7 +236,7 @@ def test_stale_wayland_position_cannot_restore_glow_after_leave(self) -> None: self.assertTrue(locator.isVisible()) app.sendEvent(host, QEvent(QEvent.Type.Leave)) - with patch("axidev_osk.windows.pointer_locator.QCursor.pos", return_value=inside): + with patch("axidev_osk.components.pointer_locator.QCursor.pos", return_value=inside): locator._poll_cursor() self.assertFalse(locator.isVisible()) app.sendEvent(host, QEvent(QEvent.Type.Enter)) diff --git a/tests/test_surface_components.py b/tests/test_surface_components.py new file mode 100644 index 0000000..3cf45a4 --- /dev/null +++ b/tests/test_surface_components.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import unittest +from unittest.mock import Mock + +from PySide6.QtCore import QPoint +from PySide6.QtWidgets import QApplication, QWidget + +from axidev_osk.components import register_components +from axidev_osk.components.pointer_locator import PointerLocator +from axidev_osk.config.models import PointerLocatorConfig, SurfaceConfig +from axidev_osk.runtime.registries import ComponentRegistry +from axidev_osk.windows.surface import RootSurface, build_surface + + +def _app() -> QApplication: + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +def _config() -> PointerLocatorConfig: + return PointerLocatorConfig( + id="component:test-pointer-locator", + rows=4, + columns=4, + radius_percent=30, + maximum_opacity_percent=60, + radius_standard_deviations=3, + ) + + +class RootSurfaceComponentTests(unittest.TestCase): + def test_surface_rejects_duplicate_ids_across_background_and_content(self) -> None: + config = _config() + + with self.assertRaisesRegex(ValueError, "Duplicate config IDs"): + SurfaceConfig( + id="surface:test", + components=(config,), + background_components=(config,), + ) + + def test_background_component_is_fitted_below_surface_content(self) -> None: + _app() + surface = RootSurface() + surface.resize(300, 160) + content = QWidget(surface) + content.setGeometry(surface.rect()) + content.show() + background = QWidget() + background.show() + + surface.install_background_component(background) + + self.assertIs(background.parentWidget(), surface) + self.assertEqual(background.geometry(), surface.rect()) + self.assertIs(surface.childAt(QPoint(20, 20)), content) + + def test_surface_builds_pointer_locator_through_component_registry(self) -> None: + registry = ComponentRegistry() + register_components(registry) + context = Mock(components=registry) + config = SurfaceConfig( + id="surface:test", + components=(), + background_components=(_config(),), + ) + + surface = build_surface(config, context) + locator = surface.findChild(PointerLocator, "pointerLocator") + + self.assertIsNotNone(locator) + self.assertIs(locator.parentWidget(), surface) + self.assertEqual(locator.property("componentId"), _config().id) diff --git a/tests/test_surface_decorations.py b/tests/test_surface_decorations.py deleted file mode 100644 index a472a0c..0000000 --- a/tests/test_surface_decorations.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import unittest -from unittest.mock import Mock - -from PySide6.QtCore import QObject, QPoint -from PySide6.QtWidgets import QApplication, QWidget - -from axidev_osk.config.models import PointerLocatorConfig -from axidev_osk.runtime.registries import SurfaceDecorationRegistry -from axidev_osk.windows.pointer_locator import attach_pointer_locator -from axidev_osk.windows.surface import RootSurface - - -def _app() -> QApplication: - app = QApplication.instance() - if app is None: - app = QApplication([]) - return app - - -def _config() -> PointerLocatorConfig: - return PointerLocatorConfig( - id="decoration:test-pointer-locator", - rows=4, - columns=4, - radius_percent=30, - maximum_opacity_percent=60, - radius_standard_deviations=3, - ) - - -class SurfaceDecorationRegistryTests(unittest.TestCase): - def test_registry_attaches_decorations_by_kind_in_order(self) -> None: - registry = SurfaceDecorationRegistry() - first = QObject() - second = QObject() - builder = Mock(side_effect=(first, second)) - registry.register("pointer-locator", builder) - surface = QWidget() - context = Mock() - - attached = registry.attach_all((_config(), _config()), surface, context) - - self.assertEqual(attached, (first, second)) - self.assertEqual(builder.call_count, 2) - - def test_registry_rejects_missing_decoration_kind(self) -> None: - registry = SurfaceDecorationRegistry() - - with self.assertRaisesRegex(ValueError, "No surface decoration registered"): - registry.attach(_config(), QWidget(), Mock()) - - -class RootSurfaceDecorationTests(unittest.TestCase): - def test_background_decoration_is_fitted_below_surface_content(self) -> None: - _app() - surface = RootSurface() - surface.resize(300, 160) - content = QWidget(surface) - content.setGeometry(surface.rect()) - content.show() - decoration = QWidget() - decoration.show() - - surface.install_background_decoration(decoration) - - self.assertIs(decoration.parentWidget(), surface) - self.assertEqual(decoration.geometry(), surface.rect()) - self.assertIs(surface.childAt(QPoint(20, 20)), content) - - def test_incompatible_surface_warns_and_skips_pointer_locator(self) -> None: - surface = QWidget() - surface.setProperty("componentId", "surface:incompatible") - - with self.assertLogs("axidev_osk.windows.pointer_locator", level="WARNING") as logs: - attached = attach_pointer_locator(_config(), surface, Mock()) - - self.assertIsNone(attached) - self.assertIn("decoration:test-pointer-locator", logs.output[0]) - self.assertIn("surface:incompatible", logs.output[0]) diff --git a/tests/test_window_builder.py b/tests/test_window_builder.py index 7620b52..6bc35bc 100644 --- a/tests/test_window_builder.py +++ b/tests/test_window_builder.py @@ -1,7 +1,7 @@ from __future__ import annotations -import unittest import inspect +import unittest from dataclasses import replace from unittest.mock import Mock, patch @@ -10,14 +10,14 @@ from axidev_osk.components import register_components from axidev_osk.components.grid.keyboard import KeyboardWidget +from axidev_osk.components.pointer_locator import PointerLocator from axidev_osk.config.defaults import build_default_app_config from axidev_osk.runtime.registries import ComponentRegistry, SurfaceRegistry from axidev_osk.runtime.testing import make_test_context from axidev_osk.windows.builder import RuntimeWindow, build_window from axidev_osk.windows.chrome import OverlayResizeHandle, OverlayTitleBar -from axidev_osk.windows.surface import register_surfaces from axidev_osk.windows.overlay.always_on_top import OverlayPlacement -from axidev_osk.windows.pointer_locator import PointerLocator +from axidev_osk.windows.surface import register_surfaces class FakeKeyboardBackend: @@ -219,12 +219,16 @@ def test_default_keyboard_window_installs_configured_pointer_locator(self) -> No locator = window.findChild(PointerLocator, "pointerLocator") self.assertIsNotNone(locator) self.assertIs(locator.parentWidget(), window.centralWidget()) + self.assertEqual(locator.property("componentId"), "component:pointer-locator") self.assertTrue(window.centralWidget().property("pointerLocatorEnabled")) def test_window_omits_pointer_locator_when_config_is_none(self) -> None: _app() app_config = build_default_app_config() - window_config = replace(app_config.windows[0], decorations=()) + window_config = replace( + app_config.windows[0], + surface=replace(app_config.windows[0].surface, background_components=()), + ) components = ComponentRegistry() surfaces = SurfaceRegistry() register_components(components) From 62a167d0f508f4038ab19bc838a70936b65d1615 Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 9 Sep 2026 09:39:43 +0200 Subject: [PATCH 3/3] refactor(pointer): assign locator colors by component inayayousfi directed the work and made every decision. gpt-5.6-sol, running in OpenCode, carried inayayousfi's decisions out. Use deterministic warm and cold palettes for nearby controls, keep a muted gap color, and stop cursor polling while the pointer is outside the surface. --- src/axidev_osk/components/pointer_locator.py | 239 +++++++++++-------- src/axidev_osk/config/defaults/__init__.py | 2 - src/axidev_osk/config/models.py | 10 +- tests/test_pointer_locator.py | 170 ++++++------- tests/test_surface_components.py | 2 - 5 files changed, 216 insertions(+), 207 deletions(-) diff --git a/src/axidev_osk/components/pointer_locator.py b/src/axidev_osk/components/pointer_locator.py index 19800c3..b733277 100644 --- a/src/axidev_osk/components/pointer_locator.py +++ b/src/axidev_osk/components/pointer_locator.py @@ -3,8 +3,9 @@ from __future__ import annotations import math +import statistics -from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, Qt, QTimer +from PySide6.QtCore import QEvent, QObject, QPoint, QPointF, QRect, Qt, QTimer from PySide6.QtGui import QColor, QCursor, QPaintEvent, QPainter, QRadialGradient from PySide6.QtWidgets import QWidget @@ -13,6 +14,7 @@ from ..runtime.registries import ComponentRegistry _GRADIENT_SEGMENTS = 32 +_GAP_COLOR = QColor("#242424") def register(registry: ComponentRegistry) -> None: @@ -39,100 +41,112 @@ def build_pointer_locator_component( return PointerLocator(config, host) -def _circular_distance(first: int, second: int, count: int) -> int: - distance = abs(first - second) % count - return min(distance, count - distance) - +def _rectangle_distance_squared(first: QRect, second: QRect) -> int: + horizontal = max(first.left() - second.right(), second.left() - first.right(), 0) + vertical = max(first.top() - second.bottom(), second.top() - first.bottom(), 0) + return horizontal**2 + vertical**2 + + +def _build_proximity_graph(rectangles: tuple[QRect, ...]) -> tuple[frozenset[int], ...]: + if not rectangles: + return () + + typical_size = statistics.median(min(rect.width(), rect.height()) for rect in rectangles) + nearby_distance_squared = (typical_size * 2.25) ** 2 + neighbors = [set() for _ in rectangles] + for index, rectangle in enumerate(rectangles): + for other_index, other in enumerate(rectangles[:index]): + if _rectangle_distance_squared(rectangle, other) <= nearby_distance_squared: + neighbors[index].add(other_index) + neighbors[other_index].add(index) + return tuple(frozenset(items) for items in neighbors) + + +def _greedy_dsatur_coloring(graph: tuple[frozenset[int], ...]) -> tuple[int, ...]: + colors = [-1] * len(graph) + while -1 in colors: + vertex = max( + (index for index, color in enumerate(colors) if color < 0), + key=lambda index: ( + len({colors[neighbor] for neighbor in graph[index] if colors[neighbor] >= 0}), + len(graph[index]), + -index, + ), + ) + forbidden = {colors[neighbor] for neighbor in graph[vertex] if colors[neighbor] >= 0} + colors[vertex] = next(color for color in range(len(graph)) if color not in forbidden) + return tuple(colors) -def _palette_stride(rows: int, columns: int) -> int: - """Choose a wheel traversal that separates both grid axes.""" - count = rows * columns - if count == 1: - return 1 +def _temperature_palette(*, warm: bool, color_count: int) -> tuple[QColor, ...]: + """Build deterministic vivid colors from one side of the hue wheel.""" - best_stride = 1 - best_score = (-1, -1) - for stride in range(1, count): - if math.gcd(stride, count) != 1: - continue - distances: list[int] = [] - weighted_distance = 0 - if columns > 1: - horizontal = _circular_distance(0, stride, count) - distances.append(horizontal) - weighted_distance += horizontal * rows * (columns - 1) - if rows > 1: - vertical = _circular_distance(0, columns * stride, count) - distances.append(vertical) - weighted_distance += vertical * columns * (rows - 1) - score = (min(distances), weighted_distance) - if score > best_score: - best_stride = stride - best_score = score - return best_stride - - -def build_pointer_palette(rows: int, columns: int) -> tuple[QColor, ...]: - """Build a deterministic saturated hue wheel arranged for a 2D grid.""" - - if rows <= 0 or columns <= 0: - raise ValueError("Pointer palette rows and columns must be positive") - count = rows * columns - stride = _palette_stride(rows, columns) + if color_count <= 0: + return () + start = 330.0 if warm else 150.0 + span = 90.0 if warm else 120.0 return tuple( - QColor.fromHsvF(((position * stride) % count) / count, 1.0, 1.0) - for position in range(count) + QColor.fromHsvF( + ((start + span * index / color_count) % 360.0) / 360.0, + 1.0, + 1.0 if index % 2 == 0 else 0.7, + ) + for index in range(color_count) ) -def interpolate_pointer_color( - palette: tuple[QColor, ...], - *, - rows: int, - columns: int, - x: float, - y: float, - width: float, - height: float, -) -> QColor: - """Interpolate the four nearest color-region centers at one position.""" - - if len(palette) != rows * columns: - raise ValueError("Pointer palette size must match rows and columns") - if width <= 0 or height <= 0: - return QColor(palette[0]) - - grid_x = min(columns - 1.0, max(0.0, x * columns / width - 0.5)) - grid_y = min(rows - 1.0, max(0.0, y * rows / height - 0.5)) - left = int(math.floor(grid_x)) - top = int(math.floor(grid_y)) - right = min(columns - 1, left + 1) - bottom = min(rows - 1, top + 1) - x_weight = grid_x - left - y_weight = grid_y - top - - top_color = _mix_color(palette[top * columns + left], palette[top * columns + right], x_weight) - bottom_color = _mix_color( - palette[bottom * columns + left], - palette[bottom * columns + right], - x_weight, - ) - return _mix_color(top_color, bottom_color, y_weight) - - -def _mix_color(first: QColor, second: QColor, weight: float) -> QColor: - if weight <= 0.0: - return QColor(first) - if weight >= 1.0: - return QColor(second) - inverse = 1.0 - weight - return QColor.fromRgbF( - first.redF() * inverse + second.redF() * weight, - first.greenF() * inverse + second.greenF() * weight, - first.blueF() * inverse + second.blueF() * weight, - first.alphaF() * inverse + second.alphaF() * weight, - ) +def _checkerboard_parities(rectangles: tuple[QRect, ...]) -> tuple[int, ...]: + typical_size = statistics.median(min(rect.width(), rect.height()) for rect in rectangles) + rows: list[list[int]] = [] + for index in sorted( + range(len(rectangles)), + key=lambda item: (rectangles[item].center().y(), rectangles[item].center().x()), + ): + if not rows or abs(rectangles[index].center().y() - rectangles[rows[-1][0]].center().y()) > typical_size / 2: + rows.append([index]) + else: + rows[-1].append(index) + + parities = [0] * len(rectangles) + for row_index, row in enumerate(rows): + row.sort(key=lambda index: rectangles[index].center().x()) + for column_index, index in enumerate(row): + parities[index] = (row_index + column_index) % 2 + return tuple(parities) + + +def build_component_palette(rectangles: tuple[QRect, ...]) -> tuple[QColor, ...]: + """Assign deterministic warm/cold checkerboard colors to components.""" + + if not rectangles: + return () + + graph = _build_proximity_graph(rectangles) + parities = _checkerboard_parities(rectangles) + colors = [QColor() for _ in rectangles] + partition_colorings: list[tuple[list[int], tuple[int, ...]]] = [] + for parity in (0, 1): + vertices = [index for index, value in enumerate(parities) if value == parity] + if not vertices: + partition_colorings.append((vertices, ())) + continue + lookup = {vertex: index for index, vertex in enumerate(vertices)} + subgraph = tuple( + frozenset(lookup[neighbor] for neighbor in graph[vertex] if neighbor in lookup) + for vertex in vertices + ) + coloring = _greedy_dsatur_coloring(subgraph) + partition_colorings.append((vertices, coloring)) + + warm_count = max(partition_colorings[0][1], default=-1) + 1 + cold_count = max(partition_colorings[1][1], default=-1) + 1 + warm_palette = _temperature_palette(warm=True, color_count=warm_count) + cold_palette = _temperature_palette(warm=False, color_count=cold_count) + for parity, (vertices, coloring) in enumerate(partition_colorings): + palette = warm_palette if parity == 0 else cold_palette + for vertex, color in zip(vertices, coloring, strict=True): + colors[vertex] = QColor(palette[color]) + return tuple(colors) def gaussian_opacity( @@ -157,10 +171,11 @@ def __init__(self, config: PointerLocatorConfig, parent: QWidget) -> None: super().__init__(parent) self._host = parent self._config = config - self._palette = build_pointer_palette(config.rows, config.columns) - self._color = QColor(self._palette[0]) + self._color = QColor.fromHsvF(0.0, 1.0, 1.0) self._cursor_position = QPoint() self._pointer_inside = False + self._color_targets: tuple[tuple[QWidget, QRect, QColor], ...] = () + self._color_target_signature: tuple[tuple[int, int, int, int, int], ...] = () self.setObjectName("pointerLocator") self.setProperty("componentType", "pointer-locator") @@ -169,12 +184,11 @@ def __init__(self, config: PointerLocatorConfig, parent: QWidget) -> None: self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) self.setGeometry(parent.rect()) self.hide() - self._host.installEventFilter(self) self._timer = QTimer(self) self._timer.setInterval(16) self._timer.timeout.connect(self._poll_cursor) - self._timer.start() + self._host.installEventFilter(self) @property def current_color(self) -> QColor: @@ -190,6 +204,7 @@ def radius(self) -> float: def _poll_cursor(self) -> None: if not self._pointer_inside: + self._timer.stop() self.hide() return self.update_from_global_position(QCursor.pos()) @@ -201,9 +216,11 @@ def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802 if watched is host: if event.type() == QEvent.Type.Enter: self._pointer_inside = True + self._timer.start() self._poll_cursor() elif event.type() in {QEvent.Type.Leave, QEvent.Type.Hide}: self._pointer_inside = False + self._timer.stop() self.hide() return super().eventFilter(watched, event) @@ -211,6 +228,7 @@ def update_from_global_position(self, global_position: QPoint) -> None: """Update ring visibility, position, and color from a screen point.""" if not self._host.isVisible(): + self._timer.stop() self.hide() return @@ -219,21 +237,42 @@ def update_from_global_position(self, global_position: QPoint) -> None: self.hide() return - self._color = interpolate_pointer_color( - self._palette, - rows=self._config.rows, - columns=self._config.columns, - x=local_position.x(), - y=local_position.y(), - width=self._host.width(), - height=self._host.height(), + self._refresh_color_targets() + target = next( + (target for target in self._color_targets if target[1].contains(local_position)), + None, ) + self._color = QColor(target[2] if target is not None else _GAP_COLOR) self._cursor_position = local_position if self.geometry() != self._host.rect(): self.setGeometry(self._host.rect()) self.update() self.show() + def _refresh_color_targets(self) -> None: + widgets = [ + widget + for widget in self._host.findChildren(QWidget) + if widget.property("componentType") in {"button", "key"} and widget.isVisibleTo(self._host) + ] + positioned = [ + ( + widget, + QRect(widget.mapTo(self._host, QPoint()), widget.size()), + ) + for widget in widgets + if widget.width() > 0 and widget.height() > 0 + ] + positioned.sort(key=lambda item: (item[1].center().y(), item[1].center().x())) + signature = tuple((id(widget), *rect.getRect()) for widget, rect in positioned) + if signature == self._color_target_signature: + return + colors = build_component_palette(tuple(rect for _, rect in positioned)) + self._color_targets = tuple( + (widget, rect, color) for (widget, rect), color in zip(positioned, colors, strict=True) + ) + self._color_target_signature = signature + def paintEvent(self, event: QPaintEvent) -> None: # type: ignore[override] """Paint the configured Gaussian glow behind surface controls.""" diff --git a/src/axidev_osk/config/defaults/__init__.py b/src/axidev_osk/config/defaults/__init__.py index da7028d..234c287 100644 --- a/src/axidev_osk/config/defaults/__init__.py +++ b/src/axidev_osk/config/defaults/__init__.py @@ -63,8 +63,6 @@ def build_default_app_config() -> AppConfig: background_components=( PointerLocatorConfig( id=pointer_locator_id, - rows=4, - columns=4, radius_percent=30, maximum_opacity_percent=60, radius_standard_deviations=3, diff --git a/src/axidev_osk/config/models.py b/src/axidev_osk/config/models.py index aaa004c..1ab5cde 100644 --- a/src/axidev_osk/config/models.py +++ b/src/axidev_osk/config/models.py @@ -60,30 +60,24 @@ class ChromeConfig: @dataclass(frozen=True, slots=True) class PointerLocatorConfig: - """Color-grid pointer feedback configured as a surface component. + """Component-aware pointer feedback configured as a surface component. Attributes: id: Deterministic component ID. - rows: Number of color regions along the vertical axis. - columns: Number of color regions along the horizontal axis. radius_percent: Glow radius as a percentage of the surface's shorter side. maximum_opacity_percent: Glow opacity at the pointer position. radius_standard_deviations: Number of Gaussian standard deviations inside the radius. """ id: str - rows: int - columns: int radius_percent: float maximum_opacity_percent: float radius_standard_deviations: float kind: Literal["pointer-locator"] = "pointer-locator" def __post_init__(self) -> None: - """Reject grids that cannot define a visible color region.""" + """Reject values that cannot define a visible glow.""" - if self.rows <= 0 or self.columns <= 0: - raise ValueError("Pointer locator rows and columns must be positive") if not 0.0 < self.radius_percent <= 100.0: raise ValueError("Pointer locator radius percent must be greater than 0 and at most 100") if not 0.0 < self.maximum_opacity_percent <= 100.0: diff --git a/tests/test_pointer_locator.py b/tests/test_pointer_locator.py index 92d5b5c..593b6d4 100644 --- a/tests/test_pointer_locator.py +++ b/tests/test_pointer_locator.py @@ -3,15 +3,15 @@ import unittest from unittest.mock import patch -from PySide6.QtCore import QEvent, QPoint, Qt -from PySide6.QtGui import QColor, QImage -from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtCore import QEvent, QPoint, QRect, Qt +from PySide6.QtGui import QImage +from PySide6.QtWidgets import QApplication, QPushButton, QWidget from axidev_osk.components.pointer_locator import ( PointerLocator, - build_pointer_palette, + _build_proximity_graph, + build_component_palette, gaussian_opacity, - interpolate_pointer_color, ) from axidev_osk.config.defaults import build_default_app_config from axidev_osk.config.models import PointerLocatorConfig @@ -27,8 +27,6 @@ def _app() -> QApplication: def _locator_config(**overrides: object) -> PointerLocatorConfig: values = { "id": "component:test-pointer-locator", - "rows": 4, - "columns": 4, "radius_percent": 30, "maximum_opacity_percent": 60, "radius_standard_deviations": 3, @@ -38,24 +36,16 @@ def _locator_config(**overrides: object) -> PointerLocatorConfig: class PointerLocatorPaletteTests(unittest.TestCase): - def test_default_keyboard_uses_four_by_four_locator(self) -> None: + def test_default_keyboard_uses_component_aware_locator(self) -> None: background_components = build_default_app_config().windows[0].surface.background_components self.assertEqual(len(background_components), 1) config = background_components[0] self.assertIsInstance(config, PointerLocatorConfig) - self.assertEqual(config.rows, 4) - self.assertEqual(config.columns, 4) self.assertEqual(config.radius_percent, 30) self.assertEqual(config.maximum_opacity_percent, 60) self.assertEqual(config.radius_standard_deviations, 3) - def test_config_rejects_non_positive_dimensions(self) -> None: - for rows, columns in ((0, 4), (4, 0), (-1, 4), (4, -1)): - with self.subTest(rows=rows, columns=columns): - with self.assertRaisesRegex(ValueError, "rows and columns must be positive"): - _locator_config(rows=rows, columns=columns) - def test_config_rejects_radius_outside_percentage_bounds(self) -> None: for radius_percent in (0, -1, 100.1, float("nan")): with self.subTest(radius_percent=radius_percent): @@ -101,93 +91,47 @@ def test_gaussian_opacity_has_configured_peak_and_transparent_edge(self) -> None self.assertAlmostEqual(halfway, 0.19, delta=0.01) self.assertEqual(edge, 0) - def test_four_by_four_palette_is_deterministic_and_unique(self) -> None: - first = build_pointer_palette(4, 4) - second = build_pointer_palette(4, 4) - - self.assertEqual(first, second) - self.assertEqual(len(first), 16) - self.assertEqual(len({color.name() for color in first}), 16) + def test_component_palette_is_deterministic_distinct_and_vivid(self) -> None: + rectangles = tuple(QRect(column * 52, row * 52, 48, 48) for row in range(3) for column in range(4)) - def test_four_by_four_palette_separates_all_orthogonal_neighbors(self) -> None: - palette = build_pointer_palette(4, 4) + first = build_component_palette(rectangles) + second = build_component_palette(rectangles) + graph = _build_proximity_graph(rectangles) - for row in range(4): + self.assertEqual(first, second) + for color in first: + self.assertAlmostEqual(color.hsvSaturationF(), 1.0) + self.assertGreaterEqual(color.valueF(), 0.69) + for index, neighbors in enumerate(graph): + for neighbor in neighbors: + self.assertNotEqual(first[index], first[neighbor]) + + for row in range(3): for column in range(4): - index = row * 4 + column - neighbor_indexes = [] - if column < 3: - neighbor_indexes.append(index + 1) - if row < 3: - neighbor_indexes.append(index + 4) - for neighbor_index in neighbor_indexes: - first_hue = palette[index].hsvHueF() * 360 - second_hue = palette[neighbor_index].hsvHueF() * 360 - distance = abs(first_hue - second_hue) - distance = min(distance, 360 - distance) - self.assertGreaterEqual(distance, 89.9) - - def test_region_centers_keep_their_exact_palette_colors(self) -> None: - rows = 4 - columns = 4 - width = 800 - height = 400 - palette = build_pointer_palette(rows, columns) - - for row in range(rows): - for column in range(columns): - with self.subTest(row=row, column=column): - color = interpolate_pointer_color( - palette, - rows=rows, - columns=columns, - x=(column + 0.5) * width / columns, - y=(row + 0.5) * height / rows, - width=width, - height=height, - ) - self.assertEqual(color, palette[row * columns + column]) - - def test_position_uses_the_same_color_after_grid_stretching(self) -> None: - palette = build_pointer_palette(4, 4) - - original = interpolate_pointer_color( - palette, - rows=4, - columns=4, - x=312.5, - y=162.5, - width=500, - height=250, - ) - stretched = interpolate_pointer_color( - palette, - rows=4, - columns=4, - x=625, - y=325, - width=1000, - height=500, + hue = first[row * 4 + column].hsvHueF() * 360 + if (row + column) % 2 == 0: + self.assertTrue(hue >= 329.9 or hue <= 60.1) + else: + self.assertTrue(149.9 <= hue <= 270.1) + + def test_nearby_components_receive_different_colors(self) -> None: + rectangles = ( + QRect(0, 0, 48, 48), + QRect(52, 0, 48, 48), + QRect(104, 0, 48, 48), ) - self.assertEqual(original, stretched) + colors = build_component_palette(rectangles) - def test_midpoint_blends_neighboring_colors(self) -> None: - palette = (QColor("#ff0000"), QColor("#0000ff")) + self.assertEqual(len({color.name() for color in colors}), 3) - color = interpolate_pointer_color( - palette, - rows=1, - columns=2, - x=50, - y=25, - width=100, - height=50, - ) + def test_distant_components_still_alternate_temperature(self) -> None: + colors = build_component_palette((QRect(0, 0, 48, 48), QRect(1000, 0, 48, 48))) - self.assertAlmostEqual(color.redF(), 0.5, delta=0.01) - self.assertAlmostEqual(color.greenF(), 0.0, delta=0.01) - self.assertAlmostEqual(color.blueF(), 0.5, delta=0.01) + first_hue = colors[0].hsvHueF() * 360 + second_hue = colors[1].hsvHueF() * 360 + self.assertTrue(first_hue >= 329.9 or first_hue <= 60.1) + self.assertTrue(149.9 <= second_hue <= 270.1) class PointerLocatorWidgetTests(unittest.TestCase): @@ -209,6 +153,7 @@ def test_glow_tracks_pointer_inside_host_and_hides_outside(self) -> None: self.assertEqual(locator.geometry(), host.rect()) self.assertEqual(locator.radius, 60) self.assertTrue(locator.testAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)) + self.assertIsNone(host.graphicsEffect()) image = QImage(locator.size(), QImage.Format.Format_ARGB32_Premultiplied) image.fill(Qt.GlobalColor.transparent) @@ -220,6 +165,39 @@ def test_glow_tracks_pointer_inside_host_and_hides_outside(self) -> None: self.assertFalse(locator.isVisible()) + def test_glow_uses_button_colors_and_a_muted_gap_color(self) -> None: + app = _app() + host = QWidget() + host.resize(220, 80) + left = QPushButton("Left", host) + left.setProperty("componentType", "key") + left.setGeometry(10, 10, 90, 60) + right = QPushButton("Right", host) + right.setProperty("componentType", "key") + right.setGeometry(120, 10, 90, 60) + host.show() + app.processEvents() + locator = PointerLocator(_locator_config(), host) + self.addCleanup(host.close) + + locator.update_from_global_position(host.mapToGlobal(QPoint(20, 40))) + left_color = locator.current_color + locator.update_from_global_position(host.mapToGlobal(QPoint(90, 40))) + self.assertEqual(locator.current_color, left_color) + + locator.update_from_global_position(host.mapToGlobal(QPoint(190, 40))) + self.assertNotEqual(locator.current_color, left_color) + + right.hide() + app.processEvents() + locator.update_from_global_position(host.mapToGlobal(QPoint(190, 40))) + self.assertLess(locator.current_color.hsvSaturationF(), 0.1) + + locator.update_from_global_position(host.mapToGlobal(QPoint(105, 40))) + self.assertNotEqual(locator.current_color, left_color) + self.assertLess(locator.current_color.hsvSaturationF(), 0.1) + self.assertLess(locator.current_color.valueF(), 0.2) + def test_stale_wayland_position_cannot_restore_glow_after_leave(self) -> None: app = _app() host = QWidget() @@ -239,6 +217,8 @@ def test_stale_wayland_position_cannot_restore_glow_after_leave(self) -> None: with patch("axidev_osk.components.pointer_locator.QCursor.pos", return_value=inside): locator._poll_cursor() self.assertFalse(locator.isVisible()) + self.assertFalse(locator._timer.isActive()) app.sendEvent(host, QEvent(QEvent.Type.Enter)) self.assertTrue(locator.isVisible()) + self.assertTrue(locator._timer.isActive()) diff --git a/tests/test_surface_components.py b/tests/test_surface_components.py index 3cf45a4..723f8ce 100644 --- a/tests/test_surface_components.py +++ b/tests/test_surface_components.py @@ -23,8 +23,6 @@ def _app() -> QApplication: def _config() -> PointerLocatorConfig: return PointerLocatorConfig( id="component:test-pointer-locator", - rows=4, - columns=4, radius_percent=30, maximum_opacity_percent=60, radius_standard_deviations=3,