From 5128cf6fafecb2f9b67ea302972b4a1c1b14223b Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 9 Sep 2026 10:33:29 +0200 Subject: [PATCH] fix(hot-corner): use usable monitor corners Anchor Wayland sensors to matching layer-shell edges and use available geometry for polling and X11 bridge placement. Reposition sensors when either screen geometry changes. --- src/axidev_osk/hot_corner/controller.py | 61 ++++++++++- tests/test_hot_corner_events.py | 10 ++ tests/test_hot_corner_overlay.py | 129 ++++++++++++++++++++++-- 3 files changed, 189 insertions(+), 11 deletions(-) diff --git a/src/axidev_osk/hot_corner/controller.py b/src/axidev_osk/hot_corner/controller.py index ec2c21f..daf69ce 100644 --- a/src/axidev_osk/hot_corner/controller.py +++ b/src/axidev_osk/hot_corner/controller.py @@ -33,6 +33,7 @@ from ..platform.layer_shell import ( ANCHOR_BOTTOM, ANCHOR_LEFT, + ANCHOR_RIGHT, ANCHOR_TOP, KEYBOARD_INTERACTIVITY_NONE, LAYER_OVERLAY, @@ -121,6 +122,32 @@ def move_to(self, position: QPoint, *, screen_geometry: QRect | None = None) -> self._window.move(target) + def move_to_anchored( + self, + position: QPoint, + *, + anchors: int, + screen_geometry: QRect | None = None, + ) -> None: + """Move the helper relative to explicit layer-shell edges when available.""" + + if self._backend != OverlayBackend.WAYLAND_LAYER_SHELL: + self.move_to(position, screen_geometry=screen_geometry) + return + + target = QPoint(position) + geometry = QRect(screen_geometry) if screen_geometry is not None else self._current_screen_geometry() + left_margin = target.x() - geometry.x() if anchors & ANCHOR_LEFT else 0 + top_margin = target.y() - geometry.y() if anchors & ANCHOR_TOP else 0 + right_margin = geometry.right() - target.x() - self._window.width() + 1 if anchors & ANCHOR_RIGHT else 0 + bottom_margin = geometry.bottom() - target.y() - self._window.height() + 1 if anchors & ANCHOR_BOTTOM else 0 + + self._layer_shell_anchors = anchors + self._layer_shell_margins = QMargins(left_margin, top_margin, right_margin, bottom_margin) + self._layer_shell_position_initialized = True + self._window.move(target) + self._sync_wayland_layer_shell() + def handle_show(self) -> bool: """Apply backend-specific show-time configuration.""" @@ -418,7 +445,9 @@ def _detect_corner(self, cursor_pos: QPoint) -> ScreenCorner | None: if screen is None: return None - geometry = screen.geometry() + geometry = screen.availableGeometry() + if not geometry.contains(cursor_pos): + return None corner_size = max(1, self._config.corner_size_px) x = cursor_pos.x() y = cursor_pos.y() @@ -460,9 +489,10 @@ def _show_indicator_for_screen( screen: QScreen, progress: float, ) -> None: - geometry = screen.geometry() - self._indicator_overlay.move_to( + geometry = self._usable_geometry(screen, self._indicator_overlay) + self._indicator_overlay.move_to_anchored( self._indicator_position(geometry, corner), + anchors=self._corner_anchors(corner), screen_geometry=geometry, ) self._indicator.set_progress(progress) @@ -493,6 +523,23 @@ def _sensor_position(self, geometry: QRect, corner: ScreenCorner) -> QPoint: return QPoint(geometry.left(), geometry.bottom() - size + 1) return QPoint(geometry.right() - size + 1, geometry.bottom() - size + 1) + @staticmethod + def _corner_anchors(corner: ScreenCorner) -> int: + if corner == ScreenCorner.TOP_LEFT: + return ANCHOR_LEFT | ANCHOR_TOP + if corner == ScreenCorner.TOP_RIGHT: + return ANCHOR_RIGHT | ANCHOR_TOP + if corner == ScreenCorner.BOTTOM_LEFT: + return ANCHOR_LEFT | ANCHOR_BOTTOM + return ANCHOR_RIGHT | ANCHOR_BOTTOM + + @staticmethod + def _usable_geometry(screen: QScreen, overlay: object) -> QRect: + if overlay.backend == OverlayBackend.WAYLAND_LAYER_SHELL: + # Edge anchors let the compositor account for other surfaces' reserved space. + return screen.geometry() + return screen.availableGeometry() + def _create_sensor_handles(self) -> list[HotCornerSensorHandle]: handles: list[HotCornerSensorHandle] = [] app = QGuiApplication.instance() @@ -510,6 +557,9 @@ def _create_sensor_handles(self) -> list[HotCornerSensorHandle]: sensor_window.entered.connect(lambda handle=handle: self._sensor_entered(handle)) sensor_window.left.connect(lambda handle=handle: self._sensor_left(handle)) screen.geometryChanged.connect(lambda _geometry, handle=handle: self._position_sensor_window(handle)) + screen.availableGeometryChanged.connect( + lambda _geometry, handle=handle: self._position_sensor_window(handle) + ) self._position_sensor_window(handle) handles.append(handle) return handles @@ -526,9 +576,10 @@ def _hide_sensor_windows(self) -> None: handle.window.hide() def _position_sensor_window(self, handle: HotCornerSensorHandle) -> None: - geometry = handle.screen.geometry() - handle.overlay.move_to( + geometry = self._usable_geometry(handle.screen, handle.overlay) + handle.overlay.move_to_anchored( self._sensor_position(geometry, handle.corner), + anchors=self._corner_anchors(handle.corner), screen_geometry=geometry, ) diff --git a/tests/test_hot_corner_events.py b/tests/test_hot_corner_events.py index 2370b81..d66ebb8 100644 --- a/tests/test_hot_corner_events.py +++ b/tests/test_hot_corner_events.py @@ -25,6 +25,16 @@ def __init__(self, backend: OverlayBackend = OverlayBackend.X11_UTILITY) -> None def move_to(self, position: QPoint, *, screen_geometry: QRect | None = None) -> None: del position, screen_geometry + def move_to_anchored( + self, + position: QPoint, + *, + anchors: int, + screen_geometry: QRect | None = None, + ) -> None: + del anchors + self.move_to(position, screen_geometry=screen_geometry) + def handle_show(self) -> bool: return True diff --git a/tests/test_hot_corner_overlay.py b/tests/test_hot_corner_overlay.py index 7d96be6..73a091d 100644 --- a/tests/test_hot_corner_overlay.py +++ b/tests/test_hot_corner_overlay.py @@ -2,6 +2,7 @@ import os import unittest +from collections.abc import Callable from pathlib import Path from tempfile import TemporaryDirectory from unittest.mock import Mock, patch @@ -12,12 +13,13 @@ from axidev_osk.hot_corner.controller import ( _configure_hot_corner_window, HotCornerConfig, + HotCornerOverlayController, HotCornerWindowToggleController, ScreenCorner, ) from axidev_osk.runtime.dispatcher import Dispatcher from axidev_osk.windows.overlay import layer_shell -from axidev_osk.windows.overlay.layer_shell import ANCHOR_LEFT, ANCHOR_TOP +from axidev_osk.windows.overlay.layer_shell import ANCHOR_BOTTOM, ANCHOR_LEFT, ANCHOR_RIGHT, ANCHOR_TOP from axidev_osk.windows.overlay.always_on_top import ( AlwaysOnTopWindowConfig, AlwaysOnTopWindowController, @@ -153,14 +155,37 @@ def handle_show(self) -> bool: return True +class FakeSignal: + def __init__(self) -> None: + self.callbacks: list[Callable[[QRect], None]] = [] + + def connect(self, callback: Callable[[QRect], None]) -> None: + self.callbacks.append(callback) + + def emit(self, geometry: QRect) -> None: + for callback in self.callbacks: + callback(geometry) + + class FakeScreen: - def __init__(self, geometry: QRect, name: str = "Virtual-1") -> None: + def __init__( + self, + geometry: QRect, + name: str = "Virtual-1", + available_geometry: QRect | None = None, + ) -> None: self._geometry = QRect(geometry) + self._available_geometry = QRect(available_geometry or geometry) self._name = name + self.geometryChanged = FakeSignal() + self.availableGeometryChanged = FakeSignal() def geometry(self) -> QRect: return QRect(self._geometry) + def availableGeometry(self) -> QRect: + return QRect(self._available_geometry) + def name(self) -> str: return self._name @@ -587,6 +612,27 @@ def tearDownClass(cls) -> None: def setUp(self) -> None: self.dispatcher = Dispatcher() + def test_wayland_helper_uses_explicit_corner_anchors(self) -> None: + window = FakeWindow() + with patch.object( + HotCornerOverlayController, + "_detect_backend", + return_value=OverlayBackend.WAYLAND_LAYER_SHELL, + ), patch( + "axidev_osk.hot_corner.controller.apply_wayland_layer_shell", + return_value=True, + ) as apply_layer_shell: + overlay = HotCornerOverlayController(window) + overlay.move_to_anchored( + QPoint(800, 200), + anchors=ANCHOR_RIGHT | ANCHOR_TOP, + screen_geometry=QRect(100, 200, 800, 600), + ) + + self.assertEqual(window.moves[-1], (800, 200)) + self.assertEqual(apply_layer_shell.call_args.kwargs["anchors"], ANCHOR_RIGHT | ANCHOR_TOP) + self.assertEqual(apply_layer_shell.call_args.kwargs["margins"], QMargins(0, 0, 0, 0)) + def test_show_indicator_uses_overlay_controller_for_manual_position(self) -> None: overlay = FakeOverlayController() with patch( @@ -596,7 +642,10 @@ def test_show_indicator_uses_overlay_controller_for_manual_position(self) -> Non controller = HotCornerWindowToggleController(self.dispatcher, config=HotCornerConfig()) try: - screen = FakeScreen(QRect(100, 200, 800, 600)) + screen = FakeScreen( + QRect(100, 200, 800, 600), + available_geometry=QRect(100, 240, 800, 560), + ) move_count = len(overlay.moves) with patch.object( controller._indicator, @@ -610,8 +659,9 @@ def test_show_indicator_uses_overlay_controller_for_manual_position(self) -> Non self.assertEqual(len(overlay.moves), move_count + 1) position, geometry = overlay.moves[-1] - self.assertEqual(position, QPoint(834, 214)) - self.assertEqual(geometry, QRect(100, 200, 800, 600)) + self.assertEqual(position, QPoint(834, 254)) + self.assertEqual(geometry, QRect(100, 240, 800, 560)) + self.assertEqual(overlay.anchored_moves[-1][1], ANCHOR_RIGHT | ANCHOR_TOP) self.assertEqual(overlay.prepare_show_calls, 0) self.assertEqual(overlay.handle_show_calls, 1) show_indicator.assert_called_once() @@ -644,6 +694,32 @@ def test_sensor_position_uses_corner_size(self) -> None: controller.stop() controller._indicator.close() + def test_cursor_polling_uses_available_screen_corners(self) -> None: + overlay = FakeOverlayController() + with patch( + "axidev_osk.hot_corner.controller.configure_hot_corner_overlay", + return_value=overlay, + ): + controller = HotCornerWindowToggleController(self.dispatcher, config=HotCornerConfig()) + + try: + screen = FakeScreen( + QRect(100, 200, 800, 600), + available_geometry=QRect(100, 240, 800, 560), + ) + with patch( + "axidev_osk.hot_corner.controller.QGuiApplication.screenAt", + return_value=screen, + ): + self.assertIsNone(controller._detect_corner(QPoint(899, 200))) + self.assertEqual( + controller._detect_corner(QPoint(899, 240)), + ScreenCorner.TOP_RIGHT, + ) + finally: + controller.stop() + controller._indicator.close() + def test_x11_hot_corners_use_cursor_polling_without_sensor_windows(self) -> None: overlay = FakeOverlayController(backend=OverlayBackend.X11_UTILITY) with patch( @@ -686,15 +762,56 @@ def test_sensor_windows_use_overlay_controller_for_positions(self) -> None: controller = HotCornerWindowToggleController(self.dispatcher, config=HotCornerConfig()) try: - self.assertEqual(overlay.anchored_moves, []) + self.assertEqual(len(overlay.anchored_moves), len(self.app.screens()) * len(ScreenCorner)) self.assertEqual(len(overlay.moves), len(self.app.screens()) * len(ScreenCorner)) self.assertTrue(controller._sensor_handles) + self.assertEqual( + {anchors for _position, anchors, _geometry in overlay.anchored_moves}, + { + ANCHOR_LEFT | ANCHOR_TOP, + ANCHOR_RIGHT | ANCHOR_TOP, + ANCHOR_LEFT | ANCHOR_BOTTOM, + ANCHOR_RIGHT | ANCHOR_BOTTOM, + }, + ) for handle in controller._sensor_handles: self.assertIs(handle.overlay, overlay) finally: controller.stop() controller._indicator.close() + def test_available_geometry_change_repositions_x11_bridge_sensors(self) -> None: + overlay = FakeOverlayController(backend=OverlayBackend.X11_UTILITY_BRIDGE) + screen = FakeScreen( + QRect(100, 200, 800, 600), + available_geometry=QRect(100, 240, 800, 560), + ) + app = Mock() + app.screens.return_value = [screen] + with patch( + "axidev_osk.hot_corner.controller.configure_hot_corner_overlay", + return_value=overlay, + ), patch( + "axidev_osk.hot_corner.controller.QGuiApplication.instance", + return_value=app, + ): + controller = HotCornerWindowToggleController(self.dispatcher, config=HotCornerConfig()) + + try: + self.assertEqual(len(overlay.anchored_moves), len(ScreenCorner)) + screen._available_geometry = QRect(120, 260, 760, 520) + + screen.availableGeometryChanged.emit(screen.availableGeometry()) + + changed_moves = overlay.anchored_moves[-len(ScreenCorner) :] + self.assertEqual( + {geometry for _position, _anchors, geometry in changed_moves}, + {QRect(120, 260, 760, 520)}, + ) + finally: + controller.stop() + controller._indicator.close() + def test_x11_bridge_hot_corners_create_sensor_windows(self) -> None: overlay = FakeOverlayController(backend=OverlayBackend.X11_UTILITY_BRIDGE) with patch(