Coverage for paintmaskanimator/canvas_stroke_display.py: 92%
74 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Brush-stroke bookkeeping for PaintCanvas.
3Split out of ``canvas.py`` as a mixin. Brush stamps blend directly into the
4live layer image. Adjacent-color preview keeps a stroke-local display image
5and updates only touched pixels. This mixin manages the stroke lifecycle: the
6per-stroke tile snapshot that backs ``LayerTilesUndo`` and the brush-runtime
7warm-up that avoids first-stroke jank. They run against a live ``PaintCanvas``.
8"""
9import numpy as np
10from PySide6.QtCore import QPoint, QPointF, QRect, Qt
11from PySide6.QtGui import QColor, QImage, QPainter, QPen
12from ._canvas_members import CanvasMembers
13from . import imaging
14from .logging_setup import get_logger
15from .undo_entries import LayerTilesUndo
17log = get_logger(__name__)
20class StrokeDisplayMixin(CanvasMembers):
21 def _begin_opaque_brush_stroke(self):
22 """Start tile undo tracking and, when needed, an incremental preview."""
23 self._stroke_before_tiles = {}
24 self._stroke_dirty_rect = QRect()
25 self._stroke_undo_frame = int(self.current_frame)
26 self._stroke_undo_layer = int(self.active_layer_index)
27 self._stroke_prev_has_content = bool(self.active_layer.has_content)
28 self._brush_blended_colors = set()
29 self._stroke_view_preview = None
30 self._stroke_view_dirty = QRect()
31 if self.view_preview.map_colors:
32 image = self._display_layer_image(self.active_layer, self.active_layer_index)
33 if image is self.active_layer.image: 33 ↛ exitline 33 didn't return from function '_begin_opaque_brush_stroke' because the condition on line 33 was always true
34 self._stroke_view_preview = (
35 self.active_layer, self.view_preview.key(),
36 self.view_layer_image(self.active_layer, self.active_layer_index, image).copy(),
37 )
39 def _stroke_before_region(self, rect):
40 """Assemble a small immutable pre-stroke crop from saved tiles."""
41 rect = QRect(rect)
42 tiles = self._stroke_before_tiles
43 if tiles is None or rect.isEmpty(): 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 return QImage()
45 result = QImage(
46 rect.width(),
47 rect.height(),
48 self.active_layer.image.format(),
49 )
50 result.fill(Qt.GlobalColor.transparent)
51 painter = QPainter(result)
52 painter.setCompositionMode(
53 QPainter.CompositionMode.CompositionMode_Source
54 )
55 tile_size = 256
56 for tile_y in range(
57 rect.top() // tile_size,
58 rect.bottom() // tile_size + 1,
59 ):
60 for tile_x in range(
61 rect.left() // tile_size,
62 rect.right() // tile_size + 1,
63 ):
64 saved = tiles.get((tile_x, tile_y))
65 if saved is None: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 continue
67 tile_rect, tile_image = saved
68 overlap = QRect(tile_rect).intersected(rect)
69 if overlap.isEmpty(): 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 continue
71 source = QRect(
72 overlap.x() - tile_rect.x(),
73 overlap.y() - tile_rect.y(),
74 overlap.width(),
75 overlap.height(),
76 )
77 painter.drawImage(
78 QPoint(overlap.x() - rect.x(), overlap.y() - rect.y()),
79 tile_image,
80 source,
81 )
82 painter.end()
83 return result
85 def _finish_opaque_brush_stroke(self):
86 colors = tuple(
87 getattr(self, "_brush_blended_colors", set())
88 )
89 self._emit_actual_paint_colors(colors)
90 if (
91 self._stroke_before_tiles is not None
92 and self._stroke_dirty_rect is not None
93 and not self._stroke_dirty_rect.isEmpty()
94 ):
95 tiles = tuple(
96 (QRect(rect), image)
97 for rect, image in self._stroke_before_tiles.values()
98 )
99 self.push_undo(LayerTilesUndo(
100 int(self._stroke_undo_frame),
101 int(self._stroke_undo_layer),
102 tiles,
103 bool(self._stroke_prev_has_content),
104 ))
105 self._stroke_before_tiles = None
106 self._stroke_dirty_rect = None
107 self._stroke_view_preview = None
108 self._stroke_view_dirty = QRect()
109 self._brush_blended_colors = set()
111 def warm_up_brush_runtime(self):
112 """Prime Qt/numpy brush primitives without touching the document."""
113 if self._brush_runtime_warmed:
114 return
116 scratch = QImage(
117 64,
118 64,
119 QImage.Format.Format_ARGB32_Premultiplied,
120 )
121 scratch.fill(Qt.GlobalColor.transparent)
122 painter = QPainter(scratch)
123 painter.setRenderHint(
124 QPainter.RenderHint.Antialiasing,
125 False,
126 )
127 painter.setCompositionMode(
128 QPainter.CompositionMode.CompositionMode_SourceOver
129 )
130 painter.setPen(QPen(
131 QColor(32, 64, 96, 255),
132 8.0,
133 Qt.PenStyle.SolidLine,
134 Qt.PenCapStyle.RoundCap,
135 Qt.PenJoinStyle.RoundJoin,
136 ))
137 painter.drawLine(QPointF(12, 12), QPointF(52, 44))
138 painter.end()
140 # Exercise the same format conversion and ndarray view used by the
141 # first real stamp. Keep the scratch image local so no display cache,
142 # layer pixels, or undo state is affected.
143 rgba = scratch.convertToFormat(QImage.Format.Format_RGBA8888)
144 ptr = imaging.qimage_buffer(rgba)
145 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
146 (rgba.height(), rgba.bytesPerLine())
147 )
148 _ = rows[:, : rgba.width() * 4].reshape(
149 (rgba.height(), rgba.width(), 4)
150 )[:, :, 3].max()
151 rgba.convertToFormat(
152 QImage.Format.Format_ARGB32_Premultiplied
153 )
154 self.pressure_size_scale(1.0)
155 self._brush_runtime_warmed = True