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

1"""Brush-stroke bookkeeping for PaintCanvas. 

2 

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 

16 

17log = get_logger(__name__) 

18 

19 

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 ) 

38 

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 

84 

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() 

110 

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 

115 

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() 

139 

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