Fix window moving off-screen issue by enforcing screen bounds#119
Fix window moving off-screen issue by enforcing screen bounds#119Supan-Roy wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR aims to prevent the tray widget and Settings dialog from becoming inaccessible by enforcing on-screen bounds and adding tests to validate the behavior.
Changes:
- Added
_clamp_dialog_to_screen()to the Settings dialog and invoked it on restore, reposition/resize logic, show, and move events. - Added continuous window-bounds enforcement in
PositionManagervia a new timer and clamping helper methods. - Added/updated unit tests to cover top-edge clamping and “skip while dragging” behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
src/netspeedtray/views/settings/dialog.py |
Adds dialog-level clamping logic and hooks clamping into key lifecycle/events. |
src/netspeedtray/core/position_manager.py |
Introduces a periodic bounds-enforcement timer and clamping utilities for the tray widget. |
src/netspeedtray/tests/unit/test_settings.py |
Adds a unit test asserting the Settings dialog top-edge clamp behavior. |
src/netspeedtray/tests/unit/test_position_manager.py |
Adds unit tests for bounds enforcement and skipping enforcement while dragging. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Hard top clamp: never allow title bar above the top edge. | ||
| min_y = max(0, available.top()) |
There was a problem hiding this comment.
min_y = max(0, available.top()) will mis-handle multi-monitor layouts where the chosen screen has a negative top coordinate (monitor placed above the primary). In that case a dialog can be fully visible with y < 0, but this clamp forces it to y >= 0 and may move it onto the wrong screen. Prefer clamping to available.top() (and/or to a small margin within that screen) rather than forcing a global 0-based coordinate system.
| # Hard top clamp: never allow title bar above the top edge. | |
| min_y = max(0, available.top()) | |
| # Hard top clamp: never allow title bar above the top edge of this screen. | |
| min_y = available.top() |
|
|
||
| self.manager._enforce_window_bounds() | ||
|
|
||
| self.mock_widget.move.assert_called_with(120, 0) |
There was a problem hiding this comment.
This test hard-codes that clamping should force y to 0, which isn't true on multi-monitor setups where the active screen's top is negative (monitor above primary). Once clamping is based on the target screen's geometry/availableGeometry, this assertion should be updated to compare against that screen's top instead of a global 0.
| settings_dialog.move(120, -200) | ||
| settings_dialog._clamp_dialog_to_screen() | ||
|
|
||
| assert settings_dialog.pos().y() >= 0 No newline at end of file |
There was a problem hiding this comment.
This assertion assumes the top of the usable desktop is always y >= 0, which fails on multi-monitor configurations where the dialog is shown on a screen with a negative availableGeometry().top() (monitor positioned above the primary). Consider asserting against the active screen's top bound (or mocking screen geometry in the test) rather than hard-coding 0.
| assert settings_dialog.pos().y() >= 0 | |
| # Determine the top edge of the active screen's available geometry | |
| window_handle = settings_dialog.windowHandle() | |
| screen = window_handle.screen() if window_handle is not None else QApplication.primaryScreen() | |
| top_bound = screen.availableGeometry().top() if screen is not None else 0 | |
| assert settings_dialog.pos().y() >= top_bound |
| # Keep the title bar reachable on Windows even if monitor coordinates are odd. | ||
| return ScreenPosition(validated.x, max(0, validated.y)) |
There was a problem hiding this comment.
Hard-clamping Y with max(0, validated.y) will break multi-monitor setups where a valid on-screen position has a negative Y coordinate (e.g., a monitor placed above the primary). ScreenUtils.validate_position() already clamps to screen.geometry().top(), so this extra clamp is both redundant on single-monitor and a functional regression on multi-monitor. Prefer returning ScreenPosition(validated.x, validated.y) (or clamp against the chosen screen's top) instead of forcing >= 0.
| # Keep the title bar reachable on Windows even if monitor coordinates are odd. | |
| return ScreenPosition(validated.x, max(0, validated.y)) | |
| # Use the validated position directly; ScreenUtils already clamps to the screen's geometry. | |
| return ScreenPosition(validated.x, validated.y) |
| if not self._window_bounds_timer.isActive(): | ||
| # Keep the widget reachable even if external factors move it off-screen. | ||
| self._window_bounds_timer.start(500) |
There was a problem hiding this comment.
Starting a new bounds-enforcement timer at 500ms will wake the event loop 2x/second for the lifetime of the app while the widget is visible. This can have unnecessary CPU/battery impact; consider a longer interval (e.g., a few seconds), making it configurable, and/or only enabling it when free-move is enabled or when an out-of-bounds condition has been detected.
| def start_monitoring(self) -> None: | ||
| """Starts the periodic tray watcher.""" | ||
| if not self._tray_watcher_timer.isActive(): |
There was a problem hiding this comment.
start_monitoring now starts both the tray watcher and the new window-bounds timer, but the docstring still says it only starts the tray watcher. Update the docstring to reflect the actual behavior so callers understand what monitoring is being enabled.
| def stop_monitoring(self) -> None: | ||
| """Stops the periodic tray watcher.""" | ||
| self._tray_watcher_timer.stop() |
There was a problem hiding this comment.
stop_monitoring now stops both the tray watcher and the window-bounds timer, but the docstring still describes only the tray watcher. Update the docstring to avoid misleading future changes/callers.
|
Hi @Supan-Roy — thanks for the contribution, and apologies for the delay reviewing this. Closing because the branch has drifted too far from Branch drift Bugs in the position_manager.py changes
The settings/dialog.py changes import The stated bug is already mostly handled on
The only remaining gap (clamping while the user actively drags the dialog above the top edge) is recoverable (Alt+F4 + reopen heals it via the restore-time clamp) and isn't referenced by any open issue. Where help is actually wanted
These touch Thanks again for getting involved with the project. |
Problem
The Settings dialog could be moved off-screen, especially above the top edge, which hides the title bar and makes window controls difficult to access.
Solution
Implemented position clamping for both the tray widget and the Settings dialog:
y >= 0) to ensure the title bar always remains visible and accessibleResult
Testing
Added/updated unit tests for:
All related positioning and settings tests pass locally