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/components/pointer_locator.py b/src/axidev_osk/components/pointer_locator.py new file mode 100644 index 0000000..b733277 --- /dev/null +++ b/src/axidev_osk/components/pointer_locator.py @@ -0,0 +1,297 @@ +"""Non-interactive color feedback around a pointer inside a window.""" + +from __future__ import annotations + +import math +import statistics + +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 + +from ..config.models import ComponentConfig, PointerLocatorConfig +from ..runtime.context import Context +from ..runtime.registries import ComponentRegistry + +_GRADIENT_SEGMENTS = 32 +_GAP_COLOR = QColor("#242424") + + +def register(registry: ComponentRegistry) -> None: + """Register the pointer locator as a reusable background component.""" + + registry.register("pointer-locator", build_pointer_locator_component) + + +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 host is None: + raise RuntimeError("Pointer locator components require a root surface host") + + host.setProperty("pointerLocatorEnabled", True) + return PointerLocator(config, host) + + +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 _temperature_palette(*, warm: bool, color_count: int) -> tuple[QColor, ...]: + """Build deterministic vivid colors from one side of the hue wheel.""" + + 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( + ((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 _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( + 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._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") + 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._timer = QTimer(self) + self._timer.setInterval(16) + self._timer.timeout.connect(self._poll_cursor) + self._host.installEventFilter(self) + + @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._timer.stop() + 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.""" + + host = getattr(self, "_host", None) + 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) + + 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 + + local_position = self._host.mapFromGlobal(global_position) + if not self._host.rect().contains(local_position): + self.hide() + return + + 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.""" + + 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/config/__init__.py b/src/axidev_osk/config/__init__.py index 7dc8c89..c27bbbc 100644 --- a/src/axidev_osk/config/__init__.py +++ b/src/axidev_osk/config/__init__.py @@ -11,6 +11,7 @@ KeyConfig, LayoutConfig, OverlayConfig, + PointerLocatorConfig, PromptConfig, SpacerConfig, SurfaceConfig, @@ -28,6 +29,7 @@ "KeyConfig", "LayoutConfig", "OverlayConfig", + "PointerLocatorConfig", "PromptConfig", "SpacerConfig", "SurfaceConfig", diff --git a/src/axidev_osk/config/defaults/__init__.py b/src/axidev_osk/config/defaults/__init__.py index 727faea..234c287 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, + "component", + "pointer-locator", + stable_override="component:pointer-locator", + ) keyboard_window = WindowConfig( id=keyboard_window_id, title="axidev OSK", @@ -53,6 +60,14 @@ def build_default_app_config() -> AppConfig: ), KeyboardStatusConfig(id=keyboard_status_id), ), + background_components=( + PointerLocatorConfig( + id=pointer_locator_id, + radius_percent=30, + maximum_opacity_percent=60, + radius_standard_deviations=3, + ), + ), margins=(10, 10, 10, 10), spacing=8, ), diff --git a/src/axidev_osk/config/models.py b/src/axidev_osk/config/models.py index acc829d..1ab5cde 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,33 @@ class ChromeConfig: enabled: bool = True +@dataclass(frozen=True, slots=True) +class PointerLocatorConfig: + """Component-aware pointer feedback configured as a surface component. + + Attributes: + id: Deterministic component ID. + 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 + radius_percent: float + maximum_opacity_percent: float + radius_standard_deviations: float + kind: Literal["pointer-locator"] = "pointer-locator" + + def __post_init__(self) -> None: + """Reject values that cannot define a visible glow.""" + + 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") + @dataclass(frozen=True, slots=True) class KeyConfig: @@ -239,7 +267,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) @@ -271,6 +307,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)``. @@ -278,6 +315,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 @@ -286,7 +324,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) diff --git a/src/axidev_osk/runtime/application.py b/src/axidev_osk/runtime/application.py index 439543b..9e02fd8 100644 --- a/src/axidev_osk/runtime/application.py +++ b/src/axidev_osk/runtime/application.py @@ -28,7 +28,12 @@ ) from .events import ScreenLockStateChanged, WindowCloseRequested from .prompt import PromptResolutionWaiter -from .registries import ComponentRegistry, EventHandlerRegistry, ServiceRegistry, SurfaceRegistry +from .registries import ( + ComponentRegistry, + EventHandlerRegistry, + ServiceRegistry, + SurfaceRegistry, +) from .state_store import StateStore from .window_manager import WindowManager diff --git a/src/axidev_osk/runtime/testing.py b/src/axidev_osk/runtime/testing.py index 8ba8570..1634a6d 100644 --- a/src/axidev_osk/runtime/testing.py +++ b/src/axidev_osk/runtime/testing.py @@ -29,7 +29,12 @@ route_hot_corner_triggered, ) from .events import WindowCloseRequested -from .registries import ComponentRegistry, EventHandlerRegistry, ServiceRegistry, SurfaceRegistry +from .registries import ( + ComponentRegistry, + EventHandlerRegistry, + ServiceRegistry, + SurfaceRegistry, +) from .state_store import StateStore from .window_manager import WindowManager 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/surface.py b/src/axidev_osk/windows/surface.py index d01ee36..39abda5 100644 --- a/src/axidev_osk/windows/surface.py +++ b/src/axidev_osk/windows/surface.py @@ -3,6 +3,7 @@ from __future__ import annotations from PySide6.QtCore import Qt +from PySide6.QtGui import QResizeEvent from PySide6.QtWidgets import QVBoxLayout, QWidget from ..config.models import SurfaceConfig @@ -10,6 +11,30 @@ from ..runtime.registries import SurfaceRegistry +class RootSurface(QWidget): + """Generic root surface with a background-component layer.""" + + def __init__(self) -> None: + super().__init__() + self._background_components: list[QWidget] = [] + + 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()) + 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 components fitted to the surface.""" + + super().resizeEvent(event) + for component in self._background_components: + component.setGeometry(self.rect()) + + def register_surfaces(registry: SurfaceRegistry) -> None: """Register the generic surface builder. @@ -40,12 +65,16 @@ 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) 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 new file mode 100644 index 0000000..593b6d4 --- /dev/null +++ b/tests/test_pointer_locator.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +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_proximity_graph, + build_component_palette, + gaussian_opacity, +) +from axidev_osk.config.defaults import build_default_app_config +from axidev_osk.config.models import PointerLocatorConfig + + +def _app() -> QApplication: + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +def _locator_config(**overrides: object) -> PointerLocatorConfig: + values = { + "id": "component:test-pointer-locator", + "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_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.radius_percent, 30) + self.assertEqual(config.maximum_opacity_percent, 60) + self.assertEqual(config.radius_standard_deviations, 3) + + 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_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)) + + first = build_component_palette(rectangles) + second = build_component_palette(rectangles) + graph = _build_proximity_graph(rectangles) + + 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): + 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), + ) + + colors = build_component_palette(rectangles) + + self.assertEqual(len({color.name() for color in colors}), 3) + + def test_distant_components_still_alternate_temperature(self) -> None: + colors = build_component_palette((QRect(0, 0, 48, 48), QRect(1000, 0, 48, 48))) + + 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): + 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)) + self.assertIsNone(host.graphicsEffect()) + + 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_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() + 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.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 new file mode 100644 index 0000000..723f8ce --- /dev/null +++ b/tests/test_surface_components.py @@ -0,0 +1,74 @@ +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", + 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_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..6bc35bc 100644 --- a/tests/test_window_builder.py +++ b/tests/test_window_builder.py @@ -1,6 +1,8 @@ from __future__ import annotations +import inspect import unittest +from dataclasses import replace from unittest.mock import Mock, patch from PySide6.QtCore import Qt @@ -8,13 +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 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.surface import register_surfaces class FakeKeyboardBackend: @@ -202,6 +205,56 @@ 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.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], + surface=replace(app_config.windows[0].surface, background_components=()), + ) + 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()