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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ extract text, copy, save, or pin the result without breaking your flow.
- **Full screen** (`⌥S`) — every display at once, instantly.
- **Configurable after-capture flow** — open the annotation editor by default, or
make captures hands-free by auto-saving, auto-copying, or both.
- **Annotation editor** — rectangles, ellipses, arrows, lines, freehand pen,
highlighter, text, and **mosaic pixelation** for visual obfuscation. Mosaic
- **Annotation editor** — rectangle, rounded-rectangle, and ellipse spotlights
that dim everything outside the selected regions, plus arrows, lines,
freehand pen, highlighter, text, and **mosaic pixelation**. Mosaic
removes the original per-pixel detail from the exported image but retains
block-average information; use the solid-fill CLI / blocklist controls for
high-risk secrets instead.
Expand Down Expand Up @@ -261,8 +262,10 @@ auto-copy, or both:
When both auto-output toggles are off (or whenever you want to mark a shot up),
the editor opens with a toolbar:

- **Tools:** select, rectangle, ellipse, arrow, line, pen, highlighter, mosaic,
text — with adjustable color and stroke width, plus undo / redo.
- **Tools:** select, rectangle, rounded rectangle, ellipse, arrow, line, pen,
highlighter, mosaic, and text; spotlight is a separate shape style that keeps
selected regions unchanged while dimming the outside; color, width, undo, and
redo remain separate.
- **Copy Text** runs OCR on the capture and copies the recognized text.
- **Pin** floats the annotated shot on top of the desktop.

Expand Down
2 changes: 2 additions & 0 deletions src/shotquill/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
# Tools
"tool.select": {"en": "Select", "zh": "选择"},
"tool.rect": {"en": "Rectangle", "zh": "矩形"},
"tool.rounded_rect": {"en": "Rounded rectangle", "zh": "圆角矩形"},
"tool.ellipse": {"en": "Ellipse", "zh": "圆"},
"tool.arrow": {"en": "Arrow", "zh": "箭头"},
"tool.line": {"en": "Line", "zh": "直线"},
Expand All @@ -147,6 +148,7 @@
"tool.mosaic": {"en": "Mosaic", "zh": "马赛克"},
"tool.text": {"en": "Text", "zh": "文字"},
# Toolbar controls
"toolbar.spotlight": {"en": "Spotlight", "zh": "聚光"},
"toolbar.color": {"en": "Color", "zh": "颜色"},
"toolbar.width": {"en": "Width ", "zh": "粗细 "},
"toolbar.font_size": {"en": "Font size ", "zh": "字号 "},
Expand Down
68 changes: 51 additions & 17 deletions src/shotquill/ui/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
from shotquill.ui.geometry import crop_edge_hits
from shotquill.ui.items.arrow import ArrowItem
from shotquill.ui.items.mosaic import MosaicItem
from shotquill.ui.items.rounded_rect import RoundedRectItem
from shotquill.ui.items.spotlight import SpotlightOverlayItem, SpotlightRegionItem
from shotquill.ui.tools import Tool

if TYPE_CHECKING:
Expand All @@ -52,6 +54,7 @@
_DEFAULT_WIDTH = 4
_DEFAULT_FONT_SIZE = 32
_NEGLIGIBLE = 3.0
_SHAPE_TOOLS = (Tool.RECT, Tool.ROUNDED_RECT, Tool.ELLIPSE)
# Keys the editor window uses to adjust the crop region; the canvas must not
# swallow them (QGraphicsView would scroll, uselessly — scrollbars are off).
_CROP_ADJUST_KEYS = (Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down)
Expand Down Expand Up @@ -168,6 +171,9 @@ def __init__(self, background: QPixmap) -> None:
self._background = self._scene.addPixmap(background)
self._background.setZValue(-1000)
self._scene.setSceneRect(QRectF(background.rect()))
self._spotlight_overlay = SpotlightOverlayItem(self._scene.sceneRect())
self._spotlight_overlay.setZValue(self._background.zValue() + 1)
self._scene.addItem(self._spotlight_overlay)

self.setRenderHint(QPainter.Antialiasing)
self.setMouseTracking(True)
Expand All @@ -177,6 +183,7 @@ def __init__(self, background: QPixmap) -> None:
self._color = QColor(_DEFAULT_COLOR)
self._width = _DEFAULT_WIDTH
self._font_size = _DEFAULT_FONT_SIZE
self._shape_spotlight_enabled = False
self._z = 0.0
self._temp_item: QGraphicsItem | None = None
self._last_hit_item: QGraphicsItem | None = None
Expand Down Expand Up @@ -230,12 +237,15 @@ def set_background(self, background: QPixmap) -> None:
self._background_pixmap = background
self._background.setPixmap(background)
self._scene.setSceneRect(QRectF(background.rect()))
self._spotlight_overlay.set_scene_rect(self._scene.sceneRect())

def is_pristine(self) -> bool:
"""True while nothing has been annotated: no undo history, no text edit
ever started, and nothing on the scene beyond the background screenshot
(an uncommitted text item counts as an annotation)."""
return not self._text_started and self._undo.count() == 0 and len(self._scene.items()) == 1
"""True while no user annotation has started.

The permanent spotlight overlay is scene infrastructure, not an
annotation; an uncommitted text or spotlight region still counts.
"""
return not self._text_started and self._undo.count() == 0 and not self._annotation_items()

def color(self) -> QColor:
return QColor(self._color)
Expand All @@ -257,6 +267,12 @@ def set_tool(self, tool: Tool) -> None:
def set_color(self, color: QColor) -> None:
self._color = QColor(color)

def shape_spotlight_enabled(self) -> bool:
return self._shape_spotlight_enabled

def set_shape_spotlight_enabled(self, enabled: bool) -> None:
self._shape_spotlight_enabled = bool(enabled)

def set_width(self, width: int) -> None:
self._width = max(1, int(width))

Expand Down Expand Up @@ -354,14 +370,18 @@ def _annotation_items(self) -> list[QGraphicsItem]:
return [
item
for item in self._scene.items()
if item is not self._background and item.scene() is self._scene
if item is not self._background
and item is not self._spotlight_overlay
and item.scene() is self._scene
]

def _selected_annotation_items(self) -> list[QGraphicsItem]:
return [
item
for item in self._scene.selectedItems()
if item is not self._background and item.scene() is self._scene
if item is not self._background
and item is not self._spotlight_overlay
and item.scene() is self._scene
]

def _delete_fallback_item(self) -> QGraphicsItem | None:
Expand All @@ -379,7 +399,9 @@ def _item_under_cursor(self) -> QGraphicsItem | None:
if not self.viewport().rect().contains(pos):
return None
item = self.itemAt(pos)
return item if item is not self._background else None
if item is self._background or item is self._spotlight_overlay:
return None
return item

def _annotation_item_at_view_pos(self, pos) -> QGraphicsItem | None:
scene_pos = self.mapToScene(pos)
Expand Down Expand Up @@ -539,14 +561,24 @@ def mousePressEvent(self, event) -> None:
path_item = QGraphicsPathItem(self._path)
path_item.setPen(self._pen(highlighter=tool == Tool.HIGHLIGHTER))
item = path_item
elif tool == Tool.RECT:
rect_item = QGraphicsRectItem(QRectF(self._start, self._start))
rect_item.setPen(self._pen())
item = rect_item
elif tool == Tool.ELLIPSE:
ellipse_item = QGraphicsEllipseItem(QRectF(self._start, self._start))
ellipse_item.setPen(self._pen())
item = ellipse_item
elif tool in _SHAPE_TOOLS:
if self._shape_spotlight_enabled:
shape_item = SpotlightRegionItem(
self._spotlight_overlay,
ellipse=tool == Tool.ELLIPSE,
rounded=tool == Tool.ROUNDED_RECT,
)
elif tool == Tool.RECT:
shape_item = QGraphicsRectItem()
shape_item.setPen(self._pen())
elif tool == Tool.ROUNDED_RECT:
shape_item = RoundedRectItem()
shape_item.setPen(self._pen())
else:
shape_item = QGraphicsEllipseItem()
shape_item.setPen(self._pen())
shape_item.setRect(QRectF(self._start, self._start))
item = shape_item
elif tool == Tool.LINE:
line_item = QGraphicsLineItem(QLineF(self._start, self._start))
line_item.setPen(self._pen())
Expand Down Expand Up @@ -583,7 +615,7 @@ def mouseMoveEvent(self, event) -> None:
self._path.lineTo(pos)
self._last_path_pos = QPointF(pos)
self._temp_item.setPath(self._path)
elif tool in (Tool.RECT, Tool.ELLIPSE):
elif tool in _SHAPE_TOOLS:
self._temp_item.setRect(QRectF(self._start, pos).normalized())
elif tool in (Tool.LINE, Tool.ARROW):
self._temp_item.setLine(QLineF(self._start, pos))
Expand Down Expand Up @@ -630,6 +662,8 @@ def mouseReleaseEvent(self, event) -> None:
self._mosaic_rect = None

if self._is_negligible(item):
if isinstance(item, SpotlightRegionItem):
self._spotlight_overlay.remove_region(item)
self._scene.removeItem(item)
if self._is_click_release(event.position().toPoint()):
self._select_annotation_item(self._press_hit_item)
Expand Down Expand Up @@ -730,7 +764,7 @@ def _finish_text(self, item: _TextItem) -> None:

@staticmethod
def _is_negligible(item: QGraphicsItem) -> bool:
if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem)):
if isinstance(item, (QGraphicsRectItem, QGraphicsEllipseItem, RoundedRectItem)):
rect = item.rect()
return rect.width() < _NEGLIGIBLE and rect.height() < _NEGLIGIBLE
if isinstance(item, QGraphicsLineItem): # also covers ArrowItem
Expand Down
25 changes: 24 additions & 1 deletion src/shotquill/ui/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ def _draw_select(p: QPainter) -> None:


def _draw_rect(p: QPainter) -> None:
p.drawRoundedRect(QRectF(4.5, 6, 15, 12), 1.5, 1.5)
p.drawRect(QRectF(4.5, 6, 15, 12))


def _draw_rounded_rect(p: QPainter) -> None:
p.drawRoundedRect(QRectF(4.5, 6, 15, 12), 4, 4)


def _draw_ellipse(p: QPainter) -> None:
Expand Down Expand Up @@ -121,6 +125,23 @@ def _draw_highlighter(p: QPainter) -> None:
p.restore()


def _draw_spotlight(p: QPainter) -> None:
# Dark outer bands surrounding a clear circular focus region.
outer = QRectF(4.5, 5, 15, 14)
focus = QRectF(8, 8, 8, 8)
p.drawRoundedRect(outer, 1.5, 1.5)
shade = p.pen().color()
p.save()
p.setPen(Qt.NoPen)
p.setBrush(QBrush(shade))
p.drawRect(QRectF(5, 5.5, 14, 2.5))
p.drawRect(QRectF(5, 16, 14, 2.5))
p.drawRect(QRectF(5, 8, 3, 8))
p.drawRect(QRectF(16, 8, 3, 8))
p.restore()
p.drawEllipse(focus)


def _draw_mosaic(p: QPainter) -> None:
# Checkerboard: the outline plus alternating filled cells.
p.drawRect(QRectF(5, 5, 14, 14))
Expand Down Expand Up @@ -223,11 +244,13 @@ def _draw_save(p: QPainter) -> None:
_GLYPHS: dict[str, Callable[[QPainter], None]] = {
"select": _draw_select,
"rect": _draw_rect,
"rounded_rect": _draw_rounded_rect,
"ellipse": _draw_ellipse,
"arrow": _draw_arrow,
"line": _draw_line,
"pen": _draw_pen,
"highlighter": _draw_highlighter,
"spotlight": _draw_spotlight,
"mosaic": _draw_mosaic,
"text": _draw_text,
"color": _draw_color,
Expand Down
40 changes: 40 additions & 0 deletions src/shotquill/ui/items/rounded_rect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (C) 2026 wardmos
"""A rounded rectangle annotation and its shared path geometry."""

from __future__ import annotations

from PySide6.QtCore import QRectF
from PySide6.QtGui import QPainterPath
from PySide6.QtWidgets import QGraphicsPathItem

DEFAULT_CORNER_RADIUS = 12.0


def rounded_rect_path(rect: QRectF, radius: float = DEFAULT_CORNER_RADIUS) -> QPainterPath:
"""Return a rounded path whose radius stays valid for small rectangles."""
normalized = QRectF(rect).normalized()
corner_radius = max(
0.0,
min(float(radius), normalized.width() / 2.0, normalized.height() / 2.0),
)
path = QPainterPath()
path.addRoundedRect(normalized, corner_radius, corner_radius)
return path


class RoundedRectItem(QGraphicsPathItem):
"""A movable path item with the rect API used by canvas drag tools."""

def __init__(self, rect: QRectF | None = None) -> None:
super().__init__()
self._rect = QRectF()
if rect is not None:
self.setRect(rect)

def rect(self) -> QRectF:
return QRectF(self._rect)

def setRect(self, rect: QRectF) -> None: # noqa: N802 (Qt-compatible API)
self._rect = QRectF(rect).normalized()
self.setPath(rounded_rect_path(self._rect))
Loading