Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 56 additions & 5 deletions src/axidev_osk/hot_corner/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from ..platform.layer_shell import (
ANCHOR_BOTTOM,
ANCHOR_LEFT,
ANCHOR_RIGHT,
ANCHOR_TOP,
KEYBOARD_INTERACTIVITY_NONE,
LAYER_OVERLAY,
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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,
)

Expand Down
10 changes: 10 additions & 0 deletions tests/test_hot_corner_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
129 changes: 123 additions & 6 deletions tests/test_hot_corner_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down