diff --git a/skitchg/canvas.py b/skitchg/canvas.py index cf6ac4d..ed195dc 100644 --- a/skitchg/canvas.py +++ b/skitchg/canvas.py @@ -87,6 +87,7 @@ def __init__(self, parent=None): self._move_snapshot = [] # (item, pos) at mouse press in select mode self._handle_drag = False # a reshape handle grabbed the mouse self._body_drag = False # an annotation body grabbed the mouse + self._text_click = None # (item, press pos): text tool press on a block self._auto_fit = True # refit on resize until the user zooms manually # ------------------------------------------------------------------ image @@ -215,11 +216,9 @@ def mousePressEvent(self, event): pos = self.mapToScene(event.position().toPoint()) if self._editing_text is not None: - # Clicking outside the text being edited commits it. - if self._editing_text not in self.items(event.position().toPoint()): - self.commit_text_edit() - else: - return + # Clicking anywhere commits the text being edited. Pressing on it + # again grabs it for moving; a plain click reopens it for editing. + self.commit_text_edit() # Reshape handles on a selected annotation always win over drawing a # new item, no matter which tool is active. @@ -236,8 +235,16 @@ def mousePressEvent(self, event): if self.tool == "text": item = self._editable_item_at(event.position().toPoint()) - if item is not None: - self.start_text_edit(item) + body = next((i for i in self.items(event.position().toPoint()) + if isinstance(i, AnnotationItem)), None) + if item is not None or body is not None: + # Drag to move the annotation; a click without dragging on a + # text block edits it. + self._text_click = (item, pos) if item is not None else None + self._body_drag = True + self._move_snapshot = [(i, QPointF(i.pos())) + for i in self.annotation_items()] + super().mousePressEvent(event) else: self._create_text(pos) return @@ -315,7 +322,15 @@ def mouseMoveEvent(self, event): elif isinstance(item, PenItem): item.add_point(pos) elif isinstance(item, MarkerItem): - item.set_geometry(head=pos) + # The pointer follows the drag: the tip rides under the cursor and + # the head trails behind toward where the drag started. A tiny + # jitter keeps the default upright pin on the click point. + d = pos - self._drag_start + if math.hypot(d.x(), d.y()) < item.radius * 0.5: + item.set_geometry(tip=self._drag_start, head=self._drag_start + + QPointF(0, -item.default_offset())) + else: + item.set_geometry(tip=pos, head=self._drag_start) elif self._crop_overlay is not None: self._crop_overlay.setRect(QRectF(self._drag_start, pos).normalized()) @@ -332,6 +347,16 @@ def mouseReleaseEvent(self, event): if self._body_drag: self._body_drag = False super().mouseReleaseEvent(event) + click, self._text_click = self._text_click, None + if click is not None: + item, start = click + if (self.mapToScene(event.position().toPoint()) - start).manhattanLength() < MIN_DRAG: + # Just a click: undo any jitter and open it for editing. + for i, old_pos in self._move_snapshot: + i.setPos(old_pos) + self._move_snapshot = [] + self.start_text_edit(item) + return self._push_moves() return diff --git a/skitchg/items.py b/skitchg/items.py index 66436e2..8fd22e2 100644 --- a/skitchg/items.py +++ b/skitchg/items.py @@ -505,7 +505,8 @@ class TextItem(AnnotationItem): """Bold annotation text with a white outline for readability. Editing is a simple append/backspace model driven by the canvas: - click to place, type, Enter commits, Escape cancels. + click to place, type, Enter commits, Escape cancels. Once placed, drag + the block to move it and its corner handles to scale it. """ def __init__(self, pos, color, point_size, outline=True): @@ -524,6 +525,7 @@ def font(self): def set_text(self, text): self.prepareGeometryChange() self.text = text + self._layout_handles() self.update() def set_style(self, color=None, stroke_width=None, outline=None, @@ -533,6 +535,7 @@ def set_style(self, color=None, stroke_width=None, outline=None, self.prepareGeometryChange() self.point_size = float(point_size) super().set_style(color=color, outline=outline) + self._layout_handles() def get_style(self): return { @@ -551,6 +554,50 @@ def _apply_geometry_state(self, state): def _display_text(self): return self.text + ("‸" if self.editing else "") + # ---- corner handles: drag to scale the text, opposite corner stays put ---- + + CORNERS = {"tl": "br", "tr": "bl", "bl": "tr", "br": "tl"} + + def _text_rect(self): + metrics = QFontMetricsF(self.font()) + width = max([metrics.horizontalAdvance(l) for l in self._lines()] + [10.0]) + return QRectF(0, 0, width, metrics.lineSpacing() * len(self._lines())) + + def _corner(self, role): + r = self._text_rect() + return {"tl": r.topLeft(), "tr": r.topRight(), + "bl": r.bottomLeft(), "br": r.bottomRight()}[role] + + def handle_roles(self): + return list(self.CORNERS) + + def handle_pos(self, role): + return self._corner(role) + + def begin_handle_drag(self): + super().begin_handle_drag() + self._resize_start_size = self.point_size + + def handle_moved(self, role, scene_pos): + start = self._drag_start_state + if start is None: + return + anchor_role = self.CORNERS[role] + # Measure against the geometry at drag start so the scale doesn't drift. + self._apply_geometry_state(start) + self.prepareGeometryChange() + self.setPos(start["pos"]) + anchor = self.mapToScene(self._corner(anchor_role)) + grabbed = self.mapToScene(self._corner(role)) + d0 = math.hypot(grabbed.x() - anchor.x(), grabbed.y() - anchor.y()) + d1 = math.hypot(scene_pos.x() - anchor.x(), scene_pos.y() - anchor.y()) + if d0 < 1e-3: + return + self.point_size = max(6.0, min(400.0, self._resize_start_size * d1 / d0)) + self.setPos(self.pos() + anchor - self.mapToScene(self._corner(anchor_role))) + self._layout_handles() + self.update() + def _lines(self): return self._display_text().split("\n") or [""] @@ -605,7 +652,7 @@ class MarkerItem(AnnotationItem): The tail tip sits on the thing you're pointing at; the head holds an auto-incremented number (editable, double-click). Drag while placing - to aim the tail in any direction. + and the tail follows the mouse: the tip ends up under the cursor. """ MAX_CHARS = 3 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index cf62888..cf364f5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -85,6 +85,18 @@ def test_smoke(): text.set_style(point_size=40) assert text.point_size == 40 + # Text corner handle: dragging outward scales it, opposite corner fixed + anchor = text.mapToScene(text.handle_pos("tl")) + corner = text.mapToScene(text.handle_pos("br")) + text.begin_handle_drag() + text.handle_moved("br", anchor + (corner - anchor) * 1.5) + text.end_handle_drag() + assert abs(text.point_size - 60) < 0.01 + moved = text.mapToScene(text.handle_pos("tl")) - anchor + assert abs(moved.x()) < 0.5 and abs(moved.y()) < 0.5 + stack.undo() + assert text.point_size == 40 + # Text edit flow: commit, discard-empty, cancel canvas._create_text(QPointF(50, 50)) canvas._editing_text.set_text("typed")