Skip to content
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ pyinstaller.log
listingData-*.csv

# IDE
.vscode/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
.idea/
*.swp
*.swo
Expand Down
7 changes: 7 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"recommendations": [
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy"
]
}
14 changes: 12 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
{
"python-envs.defaultEnvManager": "ms-python.python:system"
}
"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
}
47 changes: 43 additions & 4 deletions app/viewer/annotation_hud.py
Original file line number Diff line number Diff line change
@@ -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, QTransform
from PySide6.QtWidgets import QWidget, QHBoxLayout, QPushButton, QFrame
import qtawesome as qta

Expand Down Expand Up @@ -41,6 +41,45 @@ 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 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,
}


def _tool_qta_icon(name: str, color: str) -> QIcon:
"""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 rendered pixmap so
the transform is guaranteed to survive the icon → pixmap conversion
(qtawesome's ``rotated=`` kwarg does not always round-trip through
that path).
"""
rotation = _ICON_ROTATION.get(name, 0.0)
pix = qta.icon(name, color=color).pixmap(_ICON_PX, _ICON_PX)
if rotation:
transform = QTransform()
transform.rotate(rotation)
pix = pix.transformed(
transform, mode=Qt.TransformationMode.SmoothTransformation
)
return QIcon(pix)


class AnnotationHUD(QFrame):
"""Bottom-centred floating toolbar. Sibling of `AnnotationOverlay`,
Expand Down Expand Up @@ -209,7 +248,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)
Expand Down Expand Up @@ -271,11 +310,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:
Expand Down
112 changes: 103 additions & 9 deletions app/viewer/annotation_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

from PySide6.QtCore import Qt, QPoint
from PySide6.QtGui import (
QColor, QPainter, QPainterPath, QPainterPathStroker, QPen,
QColor, QCursor, QPainter, QPainterPath, QPainterPathStroker, QPen,
QPixmap, QTransform,
)
from PySide6.QtWidgets import QWidget
import qtawesome as qta


class ToolMode(IntEnum):
Expand All @@ -32,6 +34,100 @@ class Stroke:
_LASER_RADIUS = 14
_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:
"""Build a QCursor that mirrors the active HUD tool.

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. 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.

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)
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 = _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 = _icon_with_outline("fa5s.highlighter", icon_color, 90, size)
return QCursor(pix, 2 + _OUTLINE_PAD, 2 + _OUTLINE_PAD)
if tool == ToolMode.ERASER:
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)


class AnnotationOverlay(QWidget):
Expand Down Expand Up @@ -138,15 +234,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:
Expand Down
Loading