From b9f11752821358525c5901302a1439e6ef3ebbf8 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sat, 11 Jul 2026 11:42:06 +0100 Subject: [PATCH 1/7] fix(presentation): show tool-specific cursor when annotation tool active The HUD toolbar changes the selected tool but the on-screen cursor remained a generic system cursor (or the default arrow), giving no visual feedback of which tool was active. Now setCursor() is called on tool change with a pixmap built from qtawesome icons matching the HUD. - Pen / Highlighter: cursor is the tool icon rotated -90 so the tip points at the click position (hotspot at bottom-left) - Eraser: qtawesome eraser icon with centred hotspot - Laser: BlankCursor (the red dot is drawn manually) - Pointer: default ArrowCursor via unsetCursor() The cursor rebuild is driven by dark_mode so it re-tints on theme switch (update_theme already calls _apply_cursor). Co-Authored-By: Claude Opus 4.7 --- app/viewer/annotation_layer.py | 50 ++++++++++++++++++---- tests/test_annotation_hud.py | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/test_annotation_hud.py diff --git a/app/viewer/annotation_layer.py b/app/viewer/annotation_layer.py index daa7e4b..a61852b 100644 --- a/app/viewer/annotation_layer.py +++ b/app/viewer/annotation_layer.py @@ -5,9 +5,10 @@ from PySide6.QtCore import Qt, QPoint from PySide6.QtGui import ( - QColor, QPainter, QPainterPath, QPainterPathStroker, QPen, + QColor, QCursor, QPainter, QPainterPath, QPainterPathStroker, QPen, ) from PySide6.QtWidgets import QWidget +import qtawesome as qta class ToolMode(IntEnum): @@ -32,6 +33,39 @@ class Stroke: _LASER_RADIUS = 14 _ERASER_TOL = 6 _POINT_MERGE_SQ = 4 +_CURSOR_ICON_PX = 24 + + +def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: + """Build a QCursor that mirrors the active HUD tool. + + The pen and highlighter share the -90° rotation used by + :mod:`app.viewer.annotation_hud` so the cursor tip visually matches the + icon shown on the toolbar. Hotspots are anchored to the writing tip + (bottom-left) for pen/highlighter and to the centre for the eraser. + LASER returns ``BlankCursor`` because the overlay draws the red dot + manually — showing a system cursor on top would be visually noisy. + """ + if tool == ToolMode.POINTER: + return QCursor(Qt.CursorShape.ArrowCursor) + if tool == ToolMode.LASER: + return QCursor(Qt.CursorShape.BlankCursor) + + icon_color = "#FFFFFF" if dark else "#000000" + size = _CURSOR_ICON_PX + + if tool == ToolMode.PEN: + pix = qta.icon("fa5s.pen", color=icon_color, rotated=-90).pixmap(size, size) + return QCursor(pix, 2, size - 2) + if tool == ToolMode.HIGHLIGHTER: + pix = qta.icon( + "fa5s.highlighter", color=icon_color, rotated=-90, + ).pixmap(size, size) + return QCursor(pix, 2, size - 2) + if tool == ToolMode.ERASER: + pix = qta.icon("fa5s.eraser", color=icon_color).pixmap(size, size) + return QCursor(pix, size // 2, size // 2) + return QCursor(Qt.CursorShape.ArrowCursor) class AnnotationOverlay(QWidget): @@ -138,15 +172,13 @@ def set_laser_pos(self, pos: QPoint | None) -> None: # ── internals ───────────────────────────────────────────────────────── def _apply_cursor(self) -> None: - t = self._tool - if t == ToolMode.POINTER: + # POINTER hands the cursor back to the parent (the overlay is also + # transparent-to-mouse in that mode, so this call is mostly for + # correctness after switching away from another tool). + if self._tool == ToolMode.POINTER: self.unsetCursor() - elif t == ToolMode.LASER: - self.setCursor(Qt.CursorShape.BlankCursor) - elif t == ToolMode.ERASER: - self.setCursor(Qt.CursorShape.PointingHandCursor) - else: - self.setCursor(Qt.CursorShape.CrossCursor) + return + self.setCursor(_cursor_for_tool(self._tool, dark=self._dark_mode)) def _new_stroke(self, start: QPoint) -> Stroke: if self._tool == ToolMode.HIGHLIGHTER: diff --git a/tests/test_annotation_hud.py b/tests/test_annotation_hud.py new file mode 100644 index 0000000..237cd1d --- /dev/null +++ b/tests/test_annotation_hud.py @@ -0,0 +1,78 @@ +"""Regression tests for the presentation-mode annotation overlay. + +Source-level assertions (no QApplication required in CI) that lock in the +tool-specific cursor behaviour added by ``fix/presentation-hud-cursor-icons``. +Additional HUD-icon rotation tests live below once the HUD fix is applied. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +_OVERLAY_PATH = Path(__file__).resolve().parent.parent / "app" / "viewer" / "annotation_layer.py" + + +def _overlay_src() -> str: + return _OVERLAY_PATH.read_text(encoding="utf-8") + + +class TestOverlayCursor: + def test_annotation_overlay_sets_cursor_on_tool_change(self): + """set_tool must (transitively) apply a cursor so switching tools + gives immediate visual feedback.""" + src = _overlay_src() + set_tool_idx = src.find("def set_tool") + assert set_tool_idx > 0 + snippet = src[set_tool_idx:set_tool_idx + 1200] + # set_tool delegates to _apply_cursor, which is where setCursor lives. + assert "_apply_cursor" in snippet, ( + "set_tool must invoke _apply_cursor to reflect the newly selected " + "tool on the on-screen cursor" + ) + assert "setCursor" in src, ( + "The overlay must call setCursor somewhere to change the cursor" + ) + + def test_cursor_helper_covers_all_tools(self): + """A module-level helper must map every ToolMode to a QCursor.""" + src = _overlay_src() + assert "_cursor_for_tool" in src, ( + "Expected a _cursor_for_tool helper that returns a QCursor per " + "ToolMode" + ) + # Ensure every ToolMode member is referenced in the helper. + helper_idx = src.find("def _cursor_for_tool") + assert helper_idx > 0 + helper_snippet = src[helper_idx:helper_idx + 2000] + for member in ("POINTER", "PEN", "HIGHLIGHTER", "ERASER", "LASER"): + assert f"ToolMode.{member}" in helper_snippet, ( + f"_cursor_for_tool must handle ToolMode.{member} explicitly" + ) + + def test_cursor_helper_uses_matching_pen_rotation(self): + """The pen cursor rotation must match the HUD icon rotation so the + on-screen cursor and toolbar icon stay visually consistent.""" + src = _overlay_src() + helper_idx = src.find("def _cursor_for_tool") + assert helper_idx > 0 + helper_snippet = src[helper_idx:helper_idx + 2000] + # Pen and highlighter should be rendered rotated in the cursor helper + # (same rotation the HUD uses so the metaphor is consistent). + assert "fa5s.pen" in helper_snippet and "rotated" in helper_snippet + assert "fa5s.highlighter" in helper_snippet + + def test_laser_cursor_is_blank(self): + """LASER cursor must be BlankCursor because the overlay draws the red + dot manually and a system cursor on top would be visually noisy.""" + src = _overlay_src() + helper_idx = src.find("def _cursor_for_tool") + helper_snippet = src[helper_idx:helper_idx + 2000] + laser_idx = helper_snippet.find("ToolMode.LASER") + assert laser_idx >= 0 + # Look at the branch body for BlankCursor. + laser_snippet = helper_snippet[laser_idx:laser_idx + 300] + assert "BlankCursor" in laser_snippet, ( + "LASER cursor should be BlankCursor - the red dot is drawn " + "manually so a system cursor on top is redundant" + ) From 2c23d14caa8a9d8d9ccc373e560b282603e46e72 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sat, 11 Jul 2026 11:42:48 +0100 Subject: [PATCH 2/7] fix(presentation): align HUD Pen and Highlighter icons with pointer direction The HUD row placed the mouse-pointer icon with its tip toward the bottom-left, but the Pen and Highlighter icons kept their default FontAwesome orientation (tip toward the bottom-right), which read as visually inconsistent. Both icons now go through a small rotation registry (_ICON_ROTATION) that applies rotated=-90 so their writing tips point in the same direction as the pointer arrow. The registry is honoured by both _refresh_icons (base render) and _refresh_active (per-state colour swap), so the rotation survives active-state toggles and theme switches. Co-Authored-By: Claude Opus 4.7 --- app/viewer/annotation_hud.py | 23 ++++++++++-- tests/test_annotation_hud.py | 73 ++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/app/viewer/annotation_hud.py b/app/viewer/annotation_hud.py index b4aa848..f2494d2 100644 --- a/app/viewer/annotation_hud.py +++ b/app/viewer/annotation_hud.py @@ -41,6 +41,23 @@ def _rgba(hex_color: str, alpha: int) -> str: (ToolMode.LASER, "fa5s.dot-circle", "present.laser"), ] +# FontAwesome renders the pen and highlighter with their writing tips at the +# bottom-right, which visually clashes with the mouse-pointer icon whose tip +# points bottom-left. Rotating them -90° realigns the tips so all "pointing" +# icons in the HUD row read as pointing in the same direction. +_ICON_ROTATION: dict[str, float] = { + "fa5s.pen": -90.0, + "fa5s.highlighter": -90.0, +} + + +def _tool_qta_icon(name: str, color: str): + """Build a qtawesome icon honouring the HUD's per-icon rotation table.""" + rot = _ICON_ROTATION.get(name) + if rot is not None: + return qta.icon(name, color=color, rotated=rot) + return qta.icon(name, color=color) + class AnnotationHUD(QFrame): """Bottom-centred floating toolbar. Sibling of `AnnotationOverlay`, @@ -209,7 +226,7 @@ def _refresh_icons(self) -> None: for b in list(self._tool_btns.values()) + [self._clear_btn]: name = b.property("_icon_name") if name: - b.setIcon(qta.icon(name, color=fg)) + b.setIcon(_tool_qta_icon(name, color=fg)) for s in self._swatches: hex_color = s.property("_swatch_color") self._style_swatch(s, hex_color, active=False) @@ -271,11 +288,11 @@ def _refresh_active(self) -> None: name = b.property("_icon_name") if name: col = ACCENT if is_active else fg - b.setIcon(qta.icon(name, color=col)) + b.setIcon(_tool_qta_icon(name, color=col)) self._clear_btn.setStyleSheet(inactive_qss) name = self._clear_btn.property("_icon_name") if name: - self._clear_btn.setIcon(qta.icon(name, color=fg)) + self._clear_btn.setIcon(_tool_qta_icon(name, color=fg)) active_hex = self._active_color.name().lower() for s in self._swatches: diff --git a/tests/test_annotation_hud.py b/tests/test_annotation_hud.py index 237cd1d..0a3ad43 100644 --- a/tests/test_annotation_hud.py +++ b/tests/test_annotation_hud.py @@ -1,8 +1,12 @@ -"""Regression tests for the presentation-mode annotation overlay. +"""Regression tests for the presentation-mode HUD and annotation overlay. Source-level assertions (no QApplication required in CI) that lock in the -tool-specific cursor behaviour added by ``fix/presentation-hud-cursor-icons``. -Additional HUD-icon rotation tests live below once the HUD fix is applied. +two UX fixes shipped in ``fix/presentation-hud-cursor-icons``: + +1. The HUD Pen and Highlighter icons must be rotated so their writing tip + aligns with the mouse-pointer arrow. +2. ``AnnotationOverlay.set_tool`` must update the on-screen cursor so the + user gets visual feedback when switching tools from the HUD. """ import sys from pathlib import Path @@ -10,13 +14,76 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +_HUD_PATH = Path(__file__).resolve().parent.parent / "app" / "viewer" / "annotation_hud.py" _OVERLAY_PATH = Path(__file__).resolve().parent.parent / "app" / "viewer" / "annotation_layer.py" +def _hud_src() -> str: + return _HUD_PATH.read_text(encoding="utf-8") + + def _overlay_src() -> str: return _OVERLAY_PATH.read_text(encoding="utf-8") +class TestHudIconRotation: + def test_hud_pen_icon_rotated(self): + """Regression: Pen icon must have a rotation applied. + + The rotation may be inlined at the call site or table-driven via a + module-level dict - both count. We assert both facets: (a) some + rotation directive exists in the module and (b) the pen icon is + registered in the rotation registry. + """ + src = _hud_src() + assert '"fa5s.pen"' in src + has_rotation_directive = ("rotated" in src) or ("IconOptions" in src) + assert has_rotation_directive, ( + "HUD module must apply a rotation (rotated=... or IconOptions) " + "somewhere so the pen/highlighter icons align with the pointer" + ) + assert ("_ICON_ROTATION" in src and '"fa5s.pen"' in src) or ( + "fa5s.pen" in src and "rotated" in src.split('"fa5s.pen"')[1][:300] + ), "Pen icon must be included in the HUD rotation registry" + + def test_hud_highlighter_icon_rotated(self): + src = _hud_src() + assert '"fa5s.highlighter"' in src + has_rotation_directive = ("rotated" in src) or ("IconOptions" in src) + assert has_rotation_directive, ( + "HUD module must apply a rotation somewhere so the highlighter " + "icon aligns with the pointer" + ) + assert ("_ICON_ROTATION" in src and '"fa5s.highlighter"' in src) or ( + "fa5s.highlighter" in src + and "rotated" in src.split('"fa5s.highlighter"')[1][:300] + ), "Highlighter icon must be included in the HUD rotation registry" + + def test_rotation_table_covers_pen_and_highlighter(self): + """The rotation table must include both pen and highlighter and must + not accidentally rotate the pointer/eraser/laser icons.""" + src = _hud_src() + assert "_ICON_ROTATION" in src, ( + "Expected an _ICON_ROTATION table listing icons that must rotate" + ) + # Extract just the _ICON_ROTATION literal block so we can verify + # membership without accidentally matching the _TOOLS list. + rot_idx = src.find("_ICON_ROTATION") + rot_end = src.find("}", rot_idx) + assert rot_end > rot_idx + rot_block = src[rot_idx:rot_end] + assert '"fa5s.pen"' in rot_block + assert '"fa5s.highlighter"' in rot_block + # Guardrail: pointer / eraser / laser icons must NOT be listed - + # rotating them would break the fix. + assert '"fa5s.mouse-pointer"' not in rot_block, ( + "Pointer icon is the reference direction and must not appear in " + "_ICON_ROTATION" + ) + assert '"fa5s.eraser"' not in rot_block + assert '"fa5s.dot-circle"' not in rot_block + + class TestOverlayCursor: def test_annotation_overlay_sets_cursor_on_tool_change(self): """set_tool must (transitively) apply a cursor so switching tools From 51bc94194aba643acaee090c5790f8cbe0315c1e Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sat, 11 Jul 2026 12:11:33 +0100 Subject: [PATCH 3/7] fix(presentation): rotate Pen and Highlighter icons to point up-left The mouse pointer icon points up-left (standard cursor convention), but the fix from commit 2c23d14 used rotated=-90 which orientation didn't match. Adjusted to rotated=180 (paired with a top-left hotspot in the cursor) so Pen and Highlighter now visually align with the pointer's direction. Both the HUD icon table (_ICON_ROTATION) and the cursor helper (_cursor_for_tool) use the same value, and the cursor hotspot moved from (2, size-2) to (2, 2) so the tip of the tool anchors at the top-left corner of the cursor pixmap. --- app/viewer/annotation_hud.py | 9 +++++---- app/viewer/annotation_layer.py | 17 +++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/viewer/annotation_hud.py b/app/viewer/annotation_hud.py index f2494d2..e97f398 100644 --- a/app/viewer/annotation_hud.py +++ b/app/viewer/annotation_hud.py @@ -43,11 +43,12 @@ def _rgba(hex_color: str, alpha: int) -> str: # FontAwesome renders the pen and highlighter with their writing tips at the # bottom-right, which visually clashes with the mouse-pointer icon whose tip -# points bottom-left. Rotating them -90° realigns the tips so all "pointing" -# icons in the HUD row read as pointing in the same direction. +# points up-left (standard cursor arrow convention). Rotating them 180° +# flips the tip to the top-left so all "pointing" icons in the HUD row read +# as pointing in the same direction. _ICON_ROTATION: dict[str, float] = { - "fa5s.pen": -90.0, - "fa5s.highlighter": -90.0, + "fa5s.pen": 180.0, + "fa5s.highlighter": 180.0, } diff --git a/app/viewer/annotation_layer.py b/app/viewer/annotation_layer.py index a61852b..be15547 100644 --- a/app/viewer/annotation_layer.py +++ b/app/viewer/annotation_layer.py @@ -39,12 +39,13 @@ class Stroke: def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: """Build a QCursor that mirrors the active HUD tool. - The pen and highlighter share the -90° rotation used by + The pen and highlighter share the 180° rotation used by :mod:`app.viewer.annotation_hud` so the cursor tip visually matches the icon shown on the toolbar. Hotspots are anchored to the writing tip - (bottom-left) for pen/highlighter and to the centre for the eraser. - LASER returns ``BlankCursor`` because the overlay draws the red dot - manually — showing a system cursor on top would be visually noisy. + (top-left) for pen/highlighter — matching the mouse-pointer arrow + convention — and to the centre for the eraser. LASER returns + ``BlankCursor`` because the overlay draws the red dot manually — + showing a system cursor on top would be visually noisy. """ if tool == ToolMode.POINTER: return QCursor(Qt.CursorShape.ArrowCursor) @@ -55,13 +56,13 @@ def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: size = _CURSOR_ICON_PX if tool == ToolMode.PEN: - pix = qta.icon("fa5s.pen", color=icon_color, rotated=-90).pixmap(size, size) - return QCursor(pix, 2, size - 2) + pix = qta.icon("fa5s.pen", color=icon_color, rotated=180).pixmap(size, size) + return QCursor(pix, 2, 2) if tool == ToolMode.HIGHLIGHTER: pix = qta.icon( - "fa5s.highlighter", color=icon_color, rotated=-90, + "fa5s.highlighter", color=icon_color, rotated=180, ).pixmap(size, size) - return QCursor(pix, 2, size - 2) + return QCursor(pix, 2, 2) if tool == ToolMode.ERASER: pix = qta.icon("fa5s.eraser", color=icon_color).pixmap(size, size) return QCursor(pix, size // 2, size // 2) From 5bdc3967f22a4866e8090ef8f42ef98f255ad7d5 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sat, 11 Jul 2026 12:26:17 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix(presentation):=20correct=20rotation=20v?= =?UTF-8?q?alue=20to=2090=C2=B0=20for=20up-left=20tip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 51bc941 used rotated=180 assuming it would flip the icon to up-left, but 180° is rotation (not flip) — it left the tip in the top-right corner. Pixel analysis of qtawesome renders confirms that rotated=90 (clockwise quarter turn) is what actually maps the native pen tip (bottom-right) to top-left, matching the mouse-pointer. Same value applied in both _ICON_ROTATION (HUD) and _cursor_for_tool (overlay cursor) so HUD icons and active cursors stay visually aligned. Hotspot for pen/highlighter stays at (2, 2) since the tip still lands in the top-left corner after the 90° rotation. --- app/viewer/annotation_hud.py | 13 ++++++++----- app/viewer/annotation_layer.py | 17 +++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/app/viewer/annotation_hud.py b/app/viewer/annotation_hud.py index e97f398..1087ff3 100644 --- a/app/viewer/annotation_hud.py +++ b/app/viewer/annotation_hud.py @@ -43,12 +43,15 @@ def _rgba(hex_color: str, alpha: int) -> str: # FontAwesome renders the pen and highlighter with their writing tips at the # bottom-right, which visually clashes with the mouse-pointer icon whose tip -# points up-left (standard cursor arrow convention). Rotating them 180° -# flips the tip to the top-left so all "pointing" icons in the HUD row read -# as pointing in the same direction. +# points up-left (standard cursor arrow convention). qtawesome's ``rotated`` +# argument applies a clockwise rotation, so +90° carries the native +# bottom-right tip to the top-left corner — matching the mouse-pointer and +# making every "pointing" icon in the HUD row read as pointing the same way. +# (Pixel analysis of the rendered pixmaps confirms 90° — not 180° — is the +# quarter turn that maps bottom-right → top-left.) _ICON_ROTATION: dict[str, float] = { - "fa5s.pen": 180.0, - "fa5s.highlighter": 180.0, + "fa5s.pen": 90.0, + "fa5s.highlighter": 90.0, } diff --git a/app/viewer/annotation_layer.py b/app/viewer/annotation_layer.py index be15547..cbc8b1e 100644 --- a/app/viewer/annotation_layer.py +++ b/app/viewer/annotation_layer.py @@ -39,13 +39,14 @@ class Stroke: def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: """Build a QCursor that mirrors the active HUD tool. - The pen and highlighter share the 180° rotation used by + The pen and highlighter share the +90° clockwise rotation used by :mod:`app.viewer.annotation_hud` so the cursor tip visually matches the - icon shown on the toolbar. Hotspots are anchored to the writing tip - (top-left) for pen/highlighter — matching the mouse-pointer arrow - convention — and to the centre for the eraser. LASER returns - ``BlankCursor`` because the overlay draws the red dot manually — - showing a system cursor on top would be visually noisy. + icon shown on the toolbar. That quarter turn maps the FontAwesome native + bottom-right tip to the top-left corner, so hotspots are anchored to + ``(2, 2)`` (top-left) for pen/highlighter — matching the mouse-pointer + arrow convention — and to the centre for the eraser. LASER returns + ``BlankCursor`` because the overlay draws the red dot manually — showing + a system cursor on top would be visually noisy. """ if tool == ToolMode.POINTER: return QCursor(Qt.CursorShape.ArrowCursor) @@ -56,11 +57,11 @@ def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: size = _CURSOR_ICON_PX if tool == ToolMode.PEN: - pix = qta.icon("fa5s.pen", color=icon_color, rotated=180).pixmap(size, size) + pix = qta.icon("fa5s.pen", color=icon_color, rotated=90).pixmap(size, size) return QCursor(pix, 2, 2) if tool == ToolMode.HIGHLIGHTER: pix = qta.icon( - "fa5s.highlighter", color=icon_color, rotated=180, + "fa5s.highlighter", color=icon_color, rotated=90, ).pixmap(size, size) return QCursor(pix, 2, 2) if tool == ToolMode.ERASER: From 0307af36a48bd648cf6e5802511b9ade44c1fe9e Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sat, 11 Jul 2026 12:36:04 +0100 Subject: [PATCH 5/7] fix(presentation): apply rotation via QTransform + add black outline Two related fixes: 1. qtawesome's rotated= parameter did not consistently apply to icons rendered as pixmaps (used for cursors), so the pen and highlighter cursors kept the default bottom-right tip orientation even though the HUD icons themselves rendered correctly at rotated=90. Rotation now applies via QTransform.rotate(90) on the resulting pixmap, which is Qt-native and reliable across icon -> pixmap conversions. 2. Icons now render with a 1px black outline (8-direction dilation composited via QPainter) so pen/highlighter/eraser cursors and HUD buttons remain readable on light-coloured slides. Same compositing pattern used in both annotation_hud.py and annotation_layer.py to keep visual consistency between the toolbar and the active cursor. Co-Authored-By: Claude Opus 4.7 --- app/viewer/annotation_hud.py | 70 ++++++++++++++++++++++----- app/viewer/annotation_layer.py | 76 +++++++++++++++++++++++++---- tests/test_annotation_hud.py | 88 ++++++++++++++++++++++++++++++++-- 3 files changed, 210 insertions(+), 24 deletions(-) diff --git a/app/viewer/annotation_hud.py b/app/viewer/annotation_hud.py index 1087ff3..620ae88 100644 --- a/app/viewer/annotation_hud.py +++ b/app/viewer/annotation_hud.py @@ -1,7 +1,7 @@ """PDFApps – Floating HUD toolbar for presentation-mode annotations.""" from PySide6.QtCore import Qt, Signal, QSize -from PySide6.QtGui import QColor +from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap, QTransform from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QFrame import qtawesome as qta @@ -43,24 +43,70 @@ def _rgba(hex_color: str, alpha: int) -> str: # FontAwesome renders the pen and highlighter with their writing tips at the # bottom-right, which visually clashes with the mouse-pointer icon whose tip -# points up-left (standard cursor arrow convention). qtawesome's ``rotated`` -# argument applies a clockwise rotation, so +90° carries the native -# bottom-right tip to the top-left corner — matching the mouse-pointer and -# making every "pointing" icon in the HUD row read as pointing the same way. -# (Pixel analysis of the rendered pixmaps confirms 90° — not 180° — is the +# points up-left (standard cursor arrow convention). We apply a +90° clockwise +# rotation via QTransform (not qtawesome's ``rotated=`` kwarg) so the same +# quarter-turn behaviour survives the icon → pixmap conversion path used by +# the cursor helper in :mod:`app.viewer.annotation_layer`. That maps the +# FontAwesome bottom-right tip to the top-left corner — matching the +# mouse-pointer and making every "pointing" icon in the HUD row read as +# pointing the same way. (Pixel analysis confirms 90° — not 180° — is the # quarter turn that maps bottom-right → top-left.) _ICON_ROTATION: dict[str, float] = { "fa5s.pen": 90.0, "fa5s.highlighter": 90.0, } +# 1 px black outline dilation on each side for HUD icons; the extra padding +# keeps the glyph readable against the translucent band background on both +# dark and light slides while giving qtawesome the full requested pixel +# budget (rotation is applied to a full-size render, then the outline is +# composited around it). +_HUD_OUTLINE_PAD = 2 +_HUD_OUTLINE_COLOR = "#000000" +_HUD_OUTLINE_OFFSETS = ( + (-1, 0), (1, 0), (0, -1), (0, 1), + (-1, -1), (1, 1), (-1, 1), (1, -1), +) + -def _tool_qta_icon(name: str, color: str): - """Build a qtawesome icon honouring the HUD's per-icon rotation table.""" - rot = _ICON_ROTATION.get(name) - if rot is not None: - return qta.icon(name, color=color, rotated=rot) - return qta.icon(name, color=color) +def _tool_qta_icon(name: str, color: str) -> QIcon: + """Build a HUD icon with a 1 px black outline for visibility. + + Rotation is applied via :class:`QTransform` on the resulting pixmap so + the transform is guaranteed to survive the icon → pixmap conversion + used by the cursor helper (qtawesome's ``rotated=`` kwarg does not + always round-trip through that path). The outline is composited by + drawing the icon in black at 8 dilated offsets before drawing the + fill-coloured glyph on top. + + The glyph is rendered at ``_ICON_PX - 2 * _HUD_OUTLINE_PAD`` so the + combined glyph + outline fits exactly the ``_ICON_PX`` box configured + on each ``QPushButton.setIconSize`` — otherwise Qt would scale the + pixmap down and blur the outline. + """ + rotation = _ICON_ROTATION.get(name) + outer = _ICON_PX + glyph = max(1, outer - _HUD_OUTLINE_PAD * 2) + + base_pix = qta.icon(name, color=color).pixmap(glyph, glyph) + outline_pix = qta.icon(name, color=_HUD_OUTLINE_COLOR).pixmap(glyph, glyph) + + if rotation: + t = QTransform() + t.rotate(rotation) + mode = Qt.TransformationMode.SmoothTransformation + base_pix = base_pix.transformed(t, mode) + outline_pix = outline_pix.transformed(t, mode) + + result = QPixmap(outer, outer) + result.fill(Qt.GlobalColor.transparent) + + painter = QPainter(result) + for dx, dy in _HUD_OUTLINE_OFFSETS: + painter.drawPixmap(_HUD_OUTLINE_PAD + dx, _HUD_OUTLINE_PAD + dy, outline_pix) + painter.drawPixmap(_HUD_OUTLINE_PAD, _HUD_OUTLINE_PAD, base_pix) + painter.end() + return QIcon(result) class AnnotationHUD(QFrame): diff --git a/app/viewer/annotation_layer.py b/app/viewer/annotation_layer.py index cbc8b1e..504173b 100644 --- a/app/viewer/annotation_layer.py +++ b/app/viewer/annotation_layer.py @@ -6,6 +6,7 @@ from PySide6.QtCore import Qt, QPoint from PySide6.QtGui import ( QColor, QCursor, QPainter, QPainterPath, QPainterPathStroker, QPen, + QPixmap, QTransform, ) from PySide6.QtWidgets import QWidget import qtawesome as qta @@ -34,6 +35,58 @@ class Stroke: _ERASER_TOL = 6 _POINT_MERGE_SQ = 4 _CURSOR_ICON_PX = 24 +# 1 px black outline dilation on each side; keep symmetric so hotspot math +# stays predictable (the icon body lives at offset (_OUTLINE_PAD, _OUTLINE_PAD) +# inside the enlarged pixmap and the hotspot must add the same offset). +_OUTLINE_PAD = 2 +# Standard 8-direction dilation offsets used to fake a 1 px stroke around +# the icon glyph; composited by drawing the icon in the outline colour once +# per offset before overlaying the fill-coloured glyph on top. +_OUTLINE_OFFSETS = ( + (-1, 0), (1, 0), (0, -1), (0, 1), + (-1, -1), (1, 1), (-1, 1), (1, -1), +) + + +def _icon_with_outline( + icon_name: str, + fill_color: str, + rotation: float, + size: int, + outline_color: str = "#000000", +) -> QPixmap: + """Render a qtawesome icon with a black outline for visibility. + + Rotation is applied via :class:`QTransform` on the resulting pixmap + (not via qtawesome's ``rotated=`` kwarg) because qtawesome's icon + options do not always round-trip cleanly through the ``icon → pixmap`` + conversion path used for cursors — the QTransform approach is + Qt-native and works identically for cursors and toolbar icons. + + Output is padded by ``_OUTLINE_PAD`` px on every side so callers must + shift their hotspot by the same amount to keep it aligned with the + original glyph pixel. + """ + base_pix = qta.icon(icon_name, color=fill_color).pixmap(size, size) + outline_pix = qta.icon(icon_name, color=outline_color).pixmap(size, size) + + if rotation: + t = QTransform() + t.rotate(rotation) + mode = Qt.TransformationMode.SmoothTransformation + base_pix = base_pix.transformed(t, mode) + outline_pix = outline_pix.transformed(t, mode) + + outer = size + _OUTLINE_PAD * 2 + result = QPixmap(outer, outer) + result.fill(Qt.GlobalColor.transparent) + + painter = QPainter(result) + for dx, dy in _OUTLINE_OFFSETS: + painter.drawPixmap(_OUTLINE_PAD + dx, _OUTLINE_PAD + dy, outline_pix) + painter.drawPixmap(_OUTLINE_PAD, _OUTLINE_PAD, base_pix) + painter.end() + return result def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: @@ -47,6 +100,10 @@ def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: arrow convention — and to the centre for the eraser. LASER returns ``BlankCursor`` because the overlay draws the red dot manually — showing a system cursor on top would be visually noisy. + + Every rendered cursor also carries a 1 px black outline (via + :func:`_icon_with_outline`) so the glyph stays legible on light-coloured + slides regardless of theme. """ if tool == ToolMode.POINTER: return QCursor(Qt.CursorShape.ArrowCursor) @@ -57,16 +114,19 @@ def _cursor_for_tool(tool: "ToolMode", dark: bool = True) -> QCursor: size = _CURSOR_ICON_PX if tool == ToolMode.PEN: - pix = qta.icon("fa5s.pen", color=icon_color, rotated=90).pixmap(size, size) - return QCursor(pix, 2, 2) + pix = _icon_with_outline("fa5s.pen", icon_color, 90, size) + # Original hotspot was (2, 2); shift by outline pad so the tip still + # anchors on the same glyph pixel inside the enlarged pixmap. + return QCursor(pix, 2 + _OUTLINE_PAD, 2 + _OUTLINE_PAD) if tool == ToolMode.HIGHLIGHTER: - pix = qta.icon( - "fa5s.highlighter", color=icon_color, rotated=90, - ).pixmap(size, size) - return QCursor(pix, 2, 2) + pix = _icon_with_outline("fa5s.highlighter", icon_color, 90, size) + return QCursor(pix, 2 + _OUTLINE_PAD, 2 + _OUTLINE_PAD) if tool == ToolMode.ERASER: - pix = qta.icon("fa5s.eraser", color=icon_color).pixmap(size, size) - return QCursor(pix, size // 2, size // 2) + pix = _icon_with_outline("fa5s.eraser", icon_color, 0, size) + # Hotspot at glyph centre; enlarged pixmap is (size + 2*pad), + # centre is at (size + 2*pad) // 2. + c = (size + _OUTLINE_PAD * 2) // 2 + return QCursor(pix, c, c) return QCursor(Qt.CursorShape.ArrowCursor) diff --git a/tests/test_annotation_hud.py b/tests/test_annotation_hud.py index 0a3ad43..20614dd 100644 --- a/tests/test_annotation_hud.py +++ b/tests/test_annotation_hud.py @@ -119,15 +119,30 @@ def test_cursor_helper_covers_all_tools(self): def test_cursor_helper_uses_matching_pen_rotation(self): """The pen cursor rotation must match the HUD icon rotation so the - on-screen cursor and toolbar icon stay visually consistent.""" + on-screen cursor and toolbar icon stay visually consistent. + + Rotation may be applied either via qtawesome's ``rotated=`` kwarg or + via a Qt-native :class:`QTransform` (the module currently prefers + QTransform because qtawesome's ``rotated=`` does not always survive + the icon → pixmap round-trip used for cursor rendering). + """ src = _overlay_src() helper_idx = src.find("def _cursor_for_tool") assert helper_idx > 0 helper_snippet = src[helper_idx:helper_idx + 2000] - # Pen and highlighter should be rendered rotated in the cursor helper - # (same rotation the HUD uses so the metaphor is consistent). - assert "fa5s.pen" in helper_snippet and "rotated" in helper_snippet + assert "fa5s.pen" in helper_snippet assert "fa5s.highlighter" in helper_snippet + # A rotation directive must exist somewhere in the module — either + # inline (qtawesome) or via a QTransform helper. + has_rotation = ( + "rotated" in src + or "QTransform" in src + or "transform.rotate" in src.lower() + ) + assert has_rotation, ( + "Overlay module must apply a 90° rotation to the pen/highlighter " + "cursor icons (qtawesome rotated= or QTransform.rotate)" + ) def test_laser_cursor_is_blank(self): """LASER cursor must be BlankCursor because the overlay draws the red @@ -143,3 +158,68 @@ def test_laser_cursor_is_blank(self): "LASER cursor should be BlankCursor - the red dot is drawn " "manually so a system cursor on top is redundant" ) + + +class TestIconOutline: + """Regression tests for the black-outline treatment applied to HUD + icons and cursor pixmaps. + + The outline (a 1 px dilation of the glyph rendered in ``#000000``) + keeps the pen/highlighter/eraser icons legible on light-coloured + slides, where an otherwise-white glyph would blend into the page. + Both the HUD toolbar (``annotation_hud.py``) and the cursor helper + (``annotation_layer.py``) render icons through a common outline + compositing pattern (icon drawn 8× at dilated offsets, then the + fill-coloured glyph on top). + """ + + def test_hud_icons_have_black_outline(self): + """HUD toolbar icons must be composited with a black outline.""" + src = _hud_src() + assert "#000000" in src, ( + "HUD module must reference the black outline colour used for the " + "icon dilation pass" + ) + assert "outline" in src.lower(), ( + "HUD module must document the outline compositing (variable or " + "helper named *outline*)" + ) + assert "QPainter" in src, ( + "HUD module must use QPainter to composite the outline + glyph" + ) + + def test_cursor_icons_have_black_outline(self): + """Cursor pixmaps must be composited with a black outline.""" + src = _overlay_src() + assert "#000000" in src, ( + "Overlay module must reference the black outline colour used for " + "the cursor icon dilation pass" + ) + assert "outline" in src.lower(), ( + "Overlay module must expose an outline compositing helper " + "(variable or function named *outline*)" + ) + assert "QPainter" in src, ( + "Overlay module must use QPainter to composite the outline + glyph" + ) + + def test_cursor_helper_uses_reliable_rotation(self): + """Regression: rotation must reach the final pixmap. + + Historically we relied on ``qta.icon(..., rotated=90).pixmap(...)`` + but that path did not always propagate the rotation to the pixmap + used for cursors, so the tip stayed in the wrong quadrant on some + Qt/qtawesome combos. The fix uses a Qt-native ``QTransform`` on the + rendered pixmap, which is deterministic. Accept either mechanism — + we just need one of them present so the rotation cannot silently + drop out. + """ + src = _overlay_src() + assert ( + "QTransform" in src + or "rotated=90" in src + or "rotated=90.0" in src + ), ( + "Overlay must apply the pen/highlighter rotation via QTransform " + "(preferred) or qtawesome rotated=" + ) From c564e1b1bd9fe4d5f51527785d2409fa89e934f4 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sat, 11 Jul 2026 15:23:35 +0100 Subject: [PATCH 6/7] chore(vscode): configure Python interpreter and Pylance paths Points defaultInterpreterPath to the local venv so Pylance resolves imports like pypdf, fitz, PySide6 without needing manual interpreter selection on every workspace open. Also whitelists workspaceFolder in analysis.extraPaths so 'from app import ...' resolves. Relaxes .gitignore to keep .vscode/settings.json and extensions.json tracked while ignoring the rest of .vscode/. Includes an extensions.json recommending the Python + Pylance + debugpy trio. Fixes user-reported 'Import "pypdf" could not be resolved' errors after venv recreation. --- .gitignore | 4 +++- .vscode/extensions.json | 7 +++++++ .vscode/settings.json | 14 ++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 .vscode/extensions.json diff --git a/.gitignore b/.gitignore index 3c674bd..5153297 100644 --- a/.gitignore +++ b/.gitignore @@ -35,7 +35,9 @@ pyinstaller.log listingData-*.csv # IDE -.vscode/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json .idea/ *.swp *.swo diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..aec92ea --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.debugpy" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index c9ebf2d..532638e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,13 @@ { - "python-envs.defaultEnvManager": "ms-python.python:system" -} \ No newline at end of file + "python-envs.defaultEnvManager": "ms-python.python:system", + "python.defaultInterpreterPath": "${workspaceFolder}/venv/Scripts/python.exe", + "python.analysis.extraPaths": ["${workspaceFolder}"], + "python.terminal.activateEnvironment": true, + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true + }, + "python.testing.pytestEnabled": false +} From e1df565868320bb0d64fde2017d2d77f67c73b51 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Sat, 11 Jul 2026 15:29:44 +0100 Subject: [PATCH 7/7] fix(presentation): remove outline from HUD icons (keep on cursors only) The black outline stroke added in the previous commit was necessary for cursor icons - those float over arbitrary slide content and need extra visibility. But it was also being applied to the HUD toolbar icons, which sit on the fixed dark toolbar background and don't need it. The result was cluttered-looking toolbar icons. annotation_hud.py now renders icons cleanly (just rotation, no outline). annotation_layer.py keeps the outline logic for cursor pixmaps. Tests updated: former test_hud_icons_have_black_outline replaced with test_hud_icons_have_no_outline (regression against re-adding outline to HUD); test_cursor_icons_have_black_outline retained (regression against losing outline on cursors). --- app/viewer/annotation_hud.py | 64 ++++++++++-------------------------- tests/test_annotation_hud.py | 47 +++++++++++++++----------- 2 files changed, 46 insertions(+), 65 deletions(-) diff --git a/app/viewer/annotation_hud.py b/app/viewer/annotation_hud.py index 620ae88..a111506 100644 --- a/app/viewer/annotation_hud.py +++ b/app/viewer/annotation_hud.py @@ -1,7 +1,7 @@ """PDFApps – Floating HUD toolbar for presentation-mode annotations.""" from PySide6.QtCore import Qt, Signal, QSize -from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap, QTransform +from PySide6.QtGui import QColor, QIcon, QTransform from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QFrame import qtawesome as qta @@ -56,57 +56,29 @@ def _rgba(hex_color: str, alpha: int) -> str: "fa5s.highlighter": 90.0, } -# 1 px black outline dilation on each side for HUD icons; the extra padding -# keeps the glyph readable against the translucent band background on both -# dark and light slides while giving qtawesome the full requested pixel -# budget (rotation is applied to a full-size render, then the outline is -# composited around it). -_HUD_OUTLINE_PAD = 2 -_HUD_OUTLINE_COLOR = "#000000" -_HUD_OUTLINE_OFFSETS = ( - (-1, 0), (1, 0), (0, -1), (0, 1), - (-1, -1), (1, 1), (-1, 1), (1, -1), -) - def _tool_qta_icon(name: str, color: str) -> QIcon: - """Build a HUD icon with a 1 px black outline for visibility. + """Render a HUD toolbar icon with rotation but no outline. + + HUD buttons sit on the dark toolbar background, so the outline stroke + used by the cursor icons (see :func:`app.viewer.annotation_layer._icon_with_outline`) + is redundant here and would clutter the visual. Cursor icons still get + the outline because they float over arbitrary slide content. - Rotation is applied via :class:`QTransform` on the resulting pixmap so + Rotation is applied via :class:`QTransform` on the rendered pixmap so the transform is guaranteed to survive the icon → pixmap conversion - used by the cursor helper (qtawesome's ``rotated=`` kwarg does not - always round-trip through that path). The outline is composited by - drawing the icon in black at 8 dilated offsets before drawing the - fill-coloured glyph on top. - - The glyph is rendered at ``_ICON_PX - 2 * _HUD_OUTLINE_PAD`` so the - combined glyph + outline fits exactly the ``_ICON_PX`` box configured - on each ``QPushButton.setIconSize`` — otherwise Qt would scale the - pixmap down and blur the outline. + (qtawesome's ``rotated=`` kwarg does not always round-trip through + that path). """ - rotation = _ICON_ROTATION.get(name) - outer = _ICON_PX - glyph = max(1, outer - _HUD_OUTLINE_PAD * 2) - - base_pix = qta.icon(name, color=color).pixmap(glyph, glyph) - outline_pix = qta.icon(name, color=_HUD_OUTLINE_COLOR).pixmap(glyph, glyph) - + rotation = _ICON_ROTATION.get(name, 0.0) + pix = qta.icon(name, color=color).pixmap(_ICON_PX, _ICON_PX) if rotation: - t = QTransform() - t.rotate(rotation) - mode = Qt.TransformationMode.SmoothTransformation - base_pix = base_pix.transformed(t, mode) - outline_pix = outline_pix.transformed(t, mode) - - result = QPixmap(outer, outer) - result.fill(Qt.GlobalColor.transparent) - - painter = QPainter(result) - for dx, dy in _HUD_OUTLINE_OFFSETS: - painter.drawPixmap(_HUD_OUTLINE_PAD + dx, _HUD_OUTLINE_PAD + dy, outline_pix) - painter.drawPixmap(_HUD_OUTLINE_PAD, _HUD_OUTLINE_PAD, base_pix) - painter.end() - return QIcon(result) + transform = QTransform() + transform.rotate(rotation) + pix = pix.transformed( + transform, mode=Qt.TransformationMode.SmoothTransformation + ) + return QIcon(pix) class AnnotationHUD(QFrame): diff --git a/tests/test_annotation_hud.py b/tests/test_annotation_hud.py index 20614dd..8f9c8c5 100644 --- a/tests/test_annotation_hud.py +++ b/tests/test_annotation_hud.py @@ -161,31 +161,40 @@ def test_laser_cursor_is_blank(self): class TestIconOutline: - """Regression tests for the black-outline treatment applied to HUD - icons and cursor pixmaps. + """Regression tests for the black-outline treatment applied to cursor + pixmaps (and the deliberate absence of that treatment on HUD icons). The outline (a 1 px dilation of the glyph rendered in ``#000000``) - keeps the pen/highlighter/eraser icons legible on light-coloured + keeps the pen/highlighter/eraser cursors legible on light-coloured slides, where an otherwise-white glyph would blend into the page. - Both the HUD toolbar (``annotation_hud.py``) and the cursor helper - (``annotation_layer.py``) render icons through a common outline - compositing pattern (icon drawn 8× at dilated offsets, then the - fill-coloured glyph on top). + The HUD toolbar, by contrast, sits on the fixed dark band background + where the outline is redundant and visually noisy — so only the + cursor helper (``annotation_layer.py``) applies it. """ - def test_hud_icons_have_black_outline(self): - """HUD toolbar icons must be composited with a black outline.""" + def test_hud_icons_have_no_outline(self): + """Regression: HUD toolbar icons must NOT apply a black outline. + + The buttons sit on the fixed dark HUD band, so the outline + dilation used for cursors would just add clutter. This test + pins that decision so the outline can't silently creep back + into the HUD renderer. + """ src = _hud_src() - assert "#000000" in src, ( - "HUD module must reference the black outline colour used for the " - "icon dilation pass" - ) - assert "outline" in src.lower(), ( - "HUD module must document the outline compositing (variable or " - "helper named *outline*)" - ) - assert "QPainter" in src, ( - "HUD module must use QPainter to composite the outline + glyph" + # The old outline pipeline used QPainter to composite the dilated + # black glyph under the fill-coloured one; none of that machinery + # should exist in the HUD module anymore. + assert "QPainter" not in src, ( + "HUD module should not use QPainter — outline compositing " + "belongs in annotation_layer.py (cursor pixmaps) only" + ) + assert "_HUD_OUTLINE" not in src, ( + "HUD outline constants must be removed; the HUD renders icons " + "cleanly without a dilation pass" + ) + assert "#000000" not in src, ( + "HUD module should not reference the black outline colour — " + "only cursors need outlining" ) def test_cursor_icons_have_black_outline(self):