diff --git a/src/axidev_osk/hot_corner/controller.py b/src/axidev_osk/hot_corner/controller.py index ec2c21f..a043ed6 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: + # Real edge anchors let the compositor apply other surfaces' exclusive zones. + return screen.geometry() + return screen.availableGeometry() + def _create_sensor_handles(self) -> list[HotCornerSensorHandle]: handles: list[HotCornerSensorHandle] = [] app = QGuiApplication.instance() @@ -526,9 +573,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..15929c6 100644 --- a/tests/test_hot_corner_events.py +++ b/tests/test_hot_corner_events.py @@ -25,6 +25,10 @@ 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 7816198..acd5884 100644 --- a/tests/test_hot_corner_overlay.py +++ b/tests/test_hot_corner_overlay.py @@ -12,12 +12,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, @@ -151,12 +152,16 @@ def handle_show(self) -> bool: class FakeScreen: - def __init__(self, geometry: QRect) -> None: + def __init__(self, geometry: QRect, available_geometry: QRect | None = None) -> None: self._geometry = QRect(geometry) + self._available_geometry = QRect(available_geometry or geometry) def geometry(self) -> QRect: return QRect(self._geometry) + def availableGeometry(self) -> QRect: + return QRect(self._available_geometry) + class OverlayWindowControllerTests(unittest.TestCase): def test_configure_window_disables_system_background_erase(self) -> None: @@ -534,6 +539,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( @@ -543,7 +569,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, @@ -557,8 +586,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() @@ -591,6 +621,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( @@ -633,9 +689,18 @@ 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: