Skip to content
Open
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
58 changes: 53 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:
# 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()
Expand Down Expand Up @@ -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,
)

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

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