Coverage for paintmaskanimator/auto_paint_dialog.py: 95%
310 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"""完成したセルを絵で見比べて選ぶ、自動塗りの設定画面。"""
2from typing import List, Optional, Tuple
4import numpy as np
5from PySide6.QtCore import QItemSelectionModel, QPointF, QRect, QRectF, QSize, Qt
6from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPen, QPixmap
7from PySide6.QtWidgets import (
8 QAbstractItemView, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit,
9 QListView, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QSlider,
10 QSplitter, QStyledItemDelegate, QStyle, QVBoxLayout, QWidget,
11)
13from .i18n import tr
14from .imaging import qimage_rgba_array
15from .time_display import frame_position
17Cell = Tuple[int, int]
18TITLE_ROLE = int(Qt.ItemDataRole.UserRole) + 1
19FRAME_ROLE = TITLE_ROLE + 1
22def _content_rect(image: QImage) -> QRect:
23 alpha = qimage_rgba_array(image)[:, :, 3] != 0
24 ys, xs = np.flatnonzero(alpha.any(axis=1)), np.flatnonzero(alpha.any(axis=0))
25 if not ys.size or not xs.size: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 return image.rect()
27 return QRect(int(xs[0]), int(ys[0]), int(xs[-1] - xs[0] + 1), int(ys[-1] - ys[0] + 1))
30def _draw_image(painter, bounds, image, source=None):
31 """テーマによらず黒い線が見える、明るい透過背景。"""
32 painter.fillRect(bounds, QColor("#ffffff"))
33 painter.save()
34 painter.setClipRect(bounds)
35 for y in range(bounds.top(), bounds.bottom() + 1, 12):
36 for x in range(bounds.left(), bounds.right() + 1, 12):
37 if ((x - bounds.left()) // 12 + (y - bounds.top()) // 12) % 2 == 0:
38 painter.fillRect(x, y, 12, 12, QColor("#eeeeee"))
39 if not image.isNull(): 39 ↛ 47line 39 didn't jump to line 47 because the condition on line 39 was always true
40 source = image.rect() if source is None else source
41 size = source.size().scaled(bounds.size() - QSize(12, 12),
42 Qt.AspectRatioMode.KeepAspectRatio)
43 rect = QRectF(0, 0, size.width(), size.height())
44 rect.moveCenter(QRectF(bounds).center())
45 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
46 painter.drawImage(rect, image, QRectF(source))
47 painter.restore()
50class _ImagePreview(QWidget):
51 def __init__(self, parent=None):
52 super().__init__(parent)
53 self.image = QImage()
54 self.source = QRect()
55 self.setMinimumSize(200, 180)
56 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
58 def set_image(self, image, source=None):
59 self.image = image
60 self.source = image.rect() if source is None else source
61 self.update()
63 def paintEvent(self, event): # noqa: N802 - Qt override
64 painter = QPainter(self)
65 _draw_image(painter, self.rect(), self.image, self.source)
66 painter.end()
69class _ReferenceGrid(QListWidget):
70 def keyPressEvent(self, event): # noqa: N802 - Qt override
71 if event.key() == Qt.Key.Key_Space and self.currentItem() is not None: 71 ↛ 76line 71 didn't jump to line 76 because the condition on line 71 was always true
72 item = self.currentItem()
73 item.setSelected(not item.isSelected())
74 event.accept()
75 return
76 super().keyPressEvent(event)
79class _SearchBox(QLineEdit):
80 def keyPressEvent(self, event): # noqa: N802 - Qt override
81 # 検索を確定するつもりのEnterで、自動塗りを実行しない。
82 if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): 82 ↛ 85line 82 didn't jump to line 85 because the condition on line 82 was always true
83 event.accept()
84 return
85 super().keyPressEvent(event)
88class _CardDelegate(QStyledItemDelegate):
89 def __init__(self, grid):
90 super().__init__(grid)
91 self.grid = grid
93 def sizeHint(self, option, index): # noqa: N802 - Qt override
94 return self.grid.gridSize() - QSize(2, 2)
96 def paint(self, painter, option, index):
97 painter.save()
98 painter.setRenderHint(QPainter.RenderHint.Antialiasing)
99 rect = option.rect.adjusted(4, 4, -4, -4)
100 checked = index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked
101 # QVariant may expose CheckStateRole as an int on older PySide6 builds.
102 checked = checked or index.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value
103 accent = option.palette.highlight().color()
104 background = option.palette.base().color()
105 border = option.palette.mid().color()
106 if checked:
107 background = option.palette.alternateBase().color()
108 border = accent
109 elif option.state & (QStyle.StateFlag.State_MouseOver | QStyle.StateFlag.State_HasFocus):
110 border = option.palette.text().color()
111 painter.setBrush(background)
112 painter.setPen(QPen(border, 2 if checked else 1))
113 painter.drawRoundedRect(QRectF(rect), 9, 9)
114 image_rect = rect.adjusted(9, 9, -9, -49)
115 icon = index.data(Qt.ItemDataRole.DecorationRole)
116 if isinstance(icon, QIcon): 116 ↛ 118line 116 didn't jump to line 118 because the condition on line 116 was always true
117 icon.paint(painter, image_rect, Qt.AlignmentFlag.AlignCenter)
118 font = option.font
119 font.setBold(True)
120 painter.setFont(font)
121 painter.setPen(option.palette.text().color())
122 painter.drawText(rect.adjusted(10, rect.height() - 43, -10, -23),
123 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
124 str(index.data(TITLE_ROLE)))
125 font.setBold(False)
126 painter.setFont(font)
127 painter.setPen(option.palette.placeholderText().color())
128 painter.drawText(rect.adjusted(10, rect.height() - 22, -10, -3),
129 Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
130 str(index.data(FRAME_ROLE)))
131 if checked:
132 badge = QRectF(rect.right() - 29, rect.top() + 7, 23, 23)
133 painter.setPen(Qt.PenStyle.NoPen)
134 painter.setBrush(accent)
135 painter.drawEllipse(badge)
136 painter.setPen(QPen(option.palette.highlightedText().color(), 2.2))
137 x, y = badge.left(), badge.top()
138 painter.drawLine(QPointF(x + 6, y + 12), QPointF(x + 10, y + 16))
139 painter.drawLine(QPointF(x + 10, y + 16), QPointF(x + 17, y + 7))
140 painter.restore()
143class AutoPaintDialog(QDialog):
144 def __init__(self, parent, layer_name: str, cells: List[Cell], labels: List[str],
145 current: int, note: str = "", images: Optional[List[QImage]] = None,
146 numbers: Optional[List[Optional[int]]] = None, fps: int = 24):
147 super().__init__(parent)
148 self.cells, self.labels = cells, labels
149 # 全セルの切り抜き画像を複製せず、元画像と表示する矩形だけを保持する。
150 self.images = images or []
151 self.image_bounds = [_content_rect(image) for image in self.images]
152 self.setWindowTitle(tr("自動で塗る"))
153 self.resize(1060, 740)
154 self.setMinimumSize(780, 580)
155 layout = QVBoxLayout(self)
156 layout.setContentsMargins(18, 16, 18, 16)
157 layout.setSpacing(10)
158 splitter = QSplitter(Qt.Orientation.Horizontal)
159 gallery = QWidget()
160 gallery_layout = QVBoxLayout(gallery)
161 gallery_layout.setContentsMargins(0, 0, 0, 0)
162 toolbar = QHBoxLayout()
163 self.search = _SearchBox()
164 self.search.setPlaceholderText(tr("コマを検索"))
165 self.search.setClearButtonEnabled(True)
166 toolbar.addWidget(self.search, 1)
167 for text, row in ((tr("先頭へ"), 0), (tr("末尾へ"), len(cells) - 1)):
168 button = QPushButton(text)
169 button.setAutoDefault(False)
170 button.setObjectName("reference-first" if row == 0 else "reference-last")
171 button.clicked.connect(lambda _checked=False, row=row: self._inspect(row, reveal=True))
172 toolbar.addWidget(button)
173 gallery_layout.addLayout(toolbar)
174 self.references = _ReferenceGrid()
175 self.references.setAccessibleName(tr("見本にするコマ"))
176 self.references.setViewMode(QListView.ViewMode.IconMode)
177 self.references.setResizeMode(QListView.ResizeMode.Adjust)
178 self.references.setMovement(QListView.Movement.Static)
179 self.references.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
180 self.references.setMouseTracking(True)
181 self.references.setItemDelegate(_CardDelegate(self.references))
182 self.references.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
183 self._set_card_size(158)
184 for index, label in enumerate(labels):
185 item = QListWidgetItem(label, self.references)
186 item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
187 frame_text = frame_position(cells[index][0], fps)
188 number = numbers[index] if numbers else None
189 item.setData(TITLE_ROLE, f"{layer_name}{number if number is not None else index + 1}")
190 item.setData(FRAME_ROLE, frame_text)
191 item.setToolTip(label)
192 item.setCheckState(Qt.CheckState.Checked if index == current else Qt.CheckState.Unchecked)
193 if index < len(self.images): 193 ↛ 199line 193 didn't jump to line 199 because the condition on line 193 was always true
194 pixmap = QPixmap(280, 240)
195 painter = QPainter(pixmap)
196 _draw_image(painter, pixmap.rect(), self.images[index], self.image_bounds[index])
197 painter.end()
198 item.setIcon(QIcon(pixmap))
199 item.setSelected(index == current)
200 gallery_layout.addWidget(self.references, 1)
201 zoom = QHBoxLayout()
202 zoom.addWidget(QLabel(tr("表示サイズ")))
203 self.card_size = QSlider(Qt.Orientation.Horizontal)
204 self.card_size.setRange(130, 230)
205 self.card_size.setValue(158)
206 self.card_size.setMaximumWidth(180)
207 self.card_size.setAccessibleName(tr("サムネイルの大きさ"))
208 self.card_size.valueChanged.connect(self._set_card_size)
209 zoom.addWidget(self.card_size)
210 zoom.addStretch()
211 self.visible_status = QLabel()
212 zoom.addWidget(self.visible_status)
213 gallery_layout.addLayout(zoom)
214 splitter.addWidget(gallery)
216 side = QWidget()
217 side.setMinimumWidth(240)
218 side_layout = QVBoxLayout(side)
219 side_layout.setContentsMargins(12, 0, 0, 0)
220 preview_header = QLabel(tr("プレビュー"))
221 preview_header_font = preview_header.font()
222 preview_header_font.setBold(True)
223 preview_header.setFont(preview_header_font)
224 side_layout.addWidget(preview_header)
225 self.preview_title = QLabel()
226 side_layout.addWidget(self.preview_title)
227 self.preview = _ImagePreview()
228 side_layout.addWidget(self.preview, 1)
229 self.toggle_reference = QPushButton()
230 self.toggle_reference.setAutoDefault(False)
231 self.toggle_reference.clicked.connect(self._toggle_current)
232 side_layout.addWidget(self.toggle_reference)
233 selected_header = QHBoxLayout()
234 self.selection_status = QLabel()
235 selected_header.addWidget(self.selection_status, 1)
236 self.clear_button = QPushButton(tr("すべて解除"))
237 self.clear_button.setAutoDefault(False)
238 self.clear_button.clicked.connect(self._clear)
239 selected_header.addWidget(self.clear_button)
240 side_layout.addLayout(selected_header)
241 self.selected_refs = QListWidget()
242 self.selected_refs.setAccessibleName(tr("選んだ見本"))
243 self.selected_refs.setIconSize(QSize(42, 36))
244 self.selected_refs.setMinimumHeight(100)
245 self.selected_refs.setMaximumHeight(160)
246 self.selected_refs.itemClicked.connect(
247 lambda item, _column=0: self._inspect(item.data(Qt.ItemDataRole.UserRole), reveal=True))
248 side_layout.addWidget(self.selected_refs)
249 self.remove_button = QPushButton(tr("選んだ見本から外す"))
250 self.remove_button.setAutoDefault(False)
251 self.remove_button.clicked.connect(self._remove_selected)
252 side_layout.addWidget(self.remove_button)
253 splitter.addWidget(side)
254 splitter.setStretchFactor(0, 3)
255 splitter.setStretchFactor(1, 1)
256 splitter.setSizes([710, 300])
257 layout.addWidget(splitter, 1)
259 footer = QHBoxLayout()
260 self.summary = QLabel()
261 footer.addWidget(self.summary, 1)
262 buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
263 buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(tr("キャンセル"))
264 self.run_button = buttons.addButton(tr("この見本で塗る"), QDialogButtonBox.ButtonRole.AcceptRole)
265 self.run_button.setMinimumSize(150, 34)
266 self.run_button.setDefault(True)
267 buttons.accepted.connect(self.accept)
268 buttons.rejected.connect(self.reject)
269 footer.addWidget(buttons)
270 layout.addLayout(footer)
271 self.references.itemSelectionChanged.connect(self._from_selection)
272 self.references.itemChanged.connect(self._from_checks)
273 self.references.currentRowChanged.connect(self._inspect)
274 self.search.textChanged.connect(self._filter)
275 self._selection_changed()
276 self._inspect(current, reveal=True)
277 self._filter("")
279 def _set_card_size(self, width):
280 self.references.setGridSize(QSize(width + 10, int(width * 0.83) + 60))
281 self.references.setIconSize(QSize(width - 16, int(width * 0.83)))
283 def chosen(self) -> List[Cell]:
284 return [self.cells[i] for i in range(len(self.cells))
285 if self.references.item(i).checkState() == Qt.CheckState.Checked]
287 def _from_selection(self):
288 self.references.blockSignals(True)
289 for i in range(len(self.cells)):
290 item = self.references.item(i)
291 item.setCheckState(Qt.CheckState.Checked if item.isSelected() else Qt.CheckState.Unchecked)
292 self.references.blockSignals(False)
293 self._selection_changed()
295 def _from_checks(self, item):
296 self.references.blockSignals(True)
297 item.setSelected(item.checkState() == Qt.CheckState.Checked)
298 self.references.blockSignals(False)
299 self._selection_changed()
301 def _selection_changed(self):
302 indices = [i for i in range(len(self.cells))
303 if self.references.item(i).checkState() == Qt.CheckState.Checked]
304 self.selected_refs.clear()
305 for i in indices:
306 entry = QListWidgetItem(self.references.item(i).icon(), self.labels[i], self.selected_refs)
307 entry.setData(Qt.ItemDataRole.UserRole, i)
308 count = len(indices)
309 self.selection_status.setText(tr("選んだ見本 {count}枚").format(count=count))
310 self.clear_button.setEnabled(count > 0)
311 self.remove_button.setEnabled(count > 0)
312 self.run_button.setEnabled(0 < count < len(self.cells))
313 if count == 0:
314 text = tr("見本を選択してください")
315 elif count == len(self.cells):
316 text = tr("塗るコマを残してください")
317 else:
318 text = tr("見本 {references}枚 · 塗るコマ {targets}枚").format(
319 references=count, targets=len(self.cells) - count)
320 self.summary.setText(text)
321 self._update_toggle()
322 self.references.viewport().update()
324 def _inspect(self, row, reveal=False):
325 if not 0 <= row < len(self.cells): 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 return
327 self.preview_title.setText(self.labels[row])
328 if row < len(self.images): 328 ↛ 331line 328 didn't jump to line 331 because the condition on line 328 was always true
329 self.preview.set_image(self.images[row], self.image_bounds[row])
330 else:
331 self.preview.set_image(QImage())
332 if reveal:
333 self.search.clear()
334 self.references.setCurrentRow(row, QItemSelectionModel.SelectionFlag.NoUpdate)
335 self.references.scrollToItem(self.references.item(row))
336 self._update_toggle()
338 def _update_toggle(self):
339 item = self.references.currentItem()
340 checked = item is not None and item.checkState() == Qt.CheckState.Checked
341 self.toggle_reference.setText(tr("見本から外す") if checked else tr("見本に追加"))
342 self.toggle_reference.setEnabled(item is not None)
344 def _toggle_current(self):
345 item = self.references.currentItem()
346 if item is not None: 346 ↛ exitline 346 didn't return from function '_toggle_current' because the condition on line 346 was always true
347 item.setSelected(not item.isSelected())
349 def _remove_selected(self):
350 item = self.selected_refs.currentItem()
351 if item is None and self.selected_refs.count(): 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true
352 item = self.selected_refs.item(0)
353 if item is not None: 353 ↛ exitline 353 didn't return from function '_remove_selected' because the condition on line 353 was always true
354 self.references.item(item.data(Qt.ItemDataRole.UserRole)).setSelected(False)
356 def _clear(self):
357 self.references.clearSelection()
359 def _filter(self, text):
360 text = text.strip().casefold()
361 visible = 0
362 for i, label in enumerate(self.labels):
363 hidden = text not in label.casefold()
364 self.references.item(i).setHidden(hidden)
365 visible += not hidden
366 self.visible_status.setText(tr("表示 {visible} / {total}コマ").format(
367 visible=visible, total=len(self.cells)))