Coverage for paintmaskanimator/color_chart.py: 43%
834 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"""Editable colour-chart window and Paint Mask Group (``.pmag``) data.
3The chart is deliberately separate from the live used-colour list. It stores
4planned parent/child relationships and role tags, then applies only the colours
5that actually exist in the current document when the user asks it to.
6"""
7from __future__ import annotations
9import copy
10import json
11import math
12import re
13from pathlib import Path
15from PySide6.QtCore import QEvent, QMimeData, QPointF, QRectF, QSize, Qt, Signal
16from PySide6.QtGui import QColor, QDrag, QPainter, QPen
17from PySide6.QtWidgets import (
18 QComboBox,
19 QDialog,
20 QDialogButtonBox,
21 QFormLayout,
22 QHBoxLayout,
23 QLabel,
24 QLineEdit,
25 QListWidget,
26 QListWidgetItem,
27 QMessageBox,
28 QPushButton,
29 QScrollArea,
30 QSizePolicy,
31 QToolButton,
32 QVBoxLayout,
33 QWidget,
34)
36from .errors import OperationError
37from .i18n import tr
38from .color_picker_dialog import ColorPickerDialog
41PMAG_FORMAT = "PaintMaskGroup"
42PMAG_VERSION = 1
43MAX_CHART_TILES = 1024
44MAX_CHART_CHILDREN = 4096
45MAX_TAGS = 1024
46TAG_MIME = "application/x-paintmaskanimator-color-tag"
49def _hex_color(value):
50 color = QColor(str(value or ""))
51 if not color.isValid():
52 return None
53 return color.name(QColor.NameFormat.HexRgb).upper()
56def _tag(value):
57 return str(value or "").strip()[:32]
60def _unique_id(value, used, fallback_index):
61 base = re.sub(r"[^A-Za-z0-9_-]+", "_", str(value or "")).strip("_")
62 base = base[:64] or f"group_{fallback_index}"
63 candidate = base
64 suffix = 2
65 while candidate in used: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 candidate = f"{base}_{suffix}"
67 suffix += 1
68 used.add(candidate)
69 return candidate
72def empty_color_chart():
73 return {
74 "format": PMAG_FORMAT,
75 "format_version": PMAG_VERSION,
76 "groups": [],
77 "parent_tags": [],
78 "tag_library": ["Base"],
79 "tag_colors": {"Base": "#FFFFFF"},
80 "group_names": {},
81 "tag_order": [],
82 "tiles": [],
83 }
86def normalize_color_chart(data):
87 """Return a bounded canonical chart, accepting every old PMAG variant."""
88 source = dict(data or {}) if isinstance(data, dict) else {}
89 raw_colors = source.get("tag_colors", {})
90 tag_colors = {"Base": "#FFFFFF"}
91 if isinstance(raw_colors, dict): 91 ↛ 98line 91 didn't jump to line 98 because the condition on line 91 was always true
92 for raw_name, raw_color in list(raw_colors.items())[:MAX_TAGS]:
93 name = _tag(raw_name)
94 color = _hex_color(raw_color)
95 if name and color: 95 ↛ 92line 95 didn't jump to line 92 because the condition on line 95 was always true
96 tag_colors[name] = color
98 raw_library = source.get("tag_library", [])
99 library = []
100 if isinstance(raw_library, list): 100 ↛ 105line 100 didn't jump to line 105 because the condition on line 100 was always true
101 for value in raw_library[:MAX_TAGS]:
102 name = _tag(value)
103 if name and name not in library: 103 ↛ 101line 103 didn't jump to line 101 because the condition on line 103 was always true
104 library.append(name)
105 if "Base" in library: 105 ↛ 107line 105 didn't jump to line 107 because the condition on line 105 was always true
106 library.remove("Base")
107 library.insert(0, "Base")
109 raw_order = source.get("tag_order", [])
110 order = []
111 if isinstance(raw_order, list): 111 ↛ 117line 111 didn't jump to line 117 because the condition on line 111 was always true
112 for value in raw_order[:MAX_TAGS]:
113 name = _tag(value)
114 if name and name.casefold() != "base" and name not in order: 114 ↛ 112line 114 didn't jump to line 112 because the condition on line 114 was always true
115 order.append(name)
117 raw_tiles = source.get("tiles")
118 tiles = []
119 used_ids = set()
120 total_children = 0
121 if isinstance(raw_tiles, list): 121 ↛ 164line 121 didn't jump to line 164 because the condition on line 121 was always true
122 for index, raw_tile in enumerate(raw_tiles[:MAX_CHART_TILES], 1):
123 if not isinstance(raw_tile, dict): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 continue
125 raw_parent = raw_tile.get("parent")
126 if raw_parent is None: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 raw_parent = {}
128 if not isinstance(raw_parent, dict): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 continue
130 parent_color = _hex_color(raw_parent.get("color"))
131 placeholder = bool(raw_tile.get("placeholder")) or parent_color is None
132 parent_tag = "" if placeholder else (_tag(raw_parent.get("tag")) or "Base")
133 children = []
134 seen_children = set()
135 raw_children = raw_tile.get("children", [])
136 if not isinstance(raw_children, list): 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 raw_children = []
138 for raw_child in raw_children:
139 if total_children >= MAX_CHART_CHILDREN: 139 ↛ 140line 139 didn't jump to line 140 because the condition on line 139 was never true
140 break
141 if not isinstance(raw_child, dict): 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 continue
143 child_color = _hex_color(raw_child.get("color"))
144 child_tag = _tag(raw_child.get("tag"))
145 if not child_color or child_color in seen_children: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 continue
147 if not placeholder and child_color == parent_color: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true
148 continue
149 children.append({"color": child_color, "tag": child_tag})
150 seen_children.add(child_color)
151 total_children += 1
152 if placeholder and not children: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 continue
154 tile_id = _unique_id(raw_tile.get("id"), used_ids, index)
155 tiles.append({
156 "id": tile_id,
157 "name": str(raw_tile.get("name", "")).strip()[:64],
158 "parent": {"color": None if placeholder else parent_color, "tag": parent_tag},
159 "placeholder": placeholder,
160 "children": children,
161 })
162 else:
163 # Convert the original flat relationship representation.
164 raw_groups = source.get("groups", [])
165 if not isinstance(raw_groups, list):
166 raw_groups = []
167 raw_parent_tags = source.get("parent_tags", [])
168 if not isinstance(raw_parent_tags, list):
169 raw_parent_tags = []
170 parent_tags = {}
171 for entry in raw_parent_tags[:MAX_CHART_TILES]:
172 if not isinstance(entry, dict):
173 continue
174 parent = _hex_color(entry.get("parent"))
175 name = _tag(entry.get("tag"))
176 if parent and name:
177 parent_tags[parent] = name
178 raw_names = source.get("group_names", {})
179 group_names = raw_names if isinstance(raw_names, dict) else {}
180 by_parent = {}
181 parent_order = []
182 for entry in raw_groups[:MAX_CHART_CHILDREN]:
183 if not isinstance(entry, dict):
184 continue
185 parent = _hex_color(entry.get("parent"))
186 child = _hex_color(entry.get("child"))
187 if not parent or not child or parent == child:
188 continue
189 if parent not in by_parent:
190 by_parent[parent] = []
191 parent_order.append(parent)
192 if child not in {item["color"] for item in by_parent[parent]}:
193 by_parent[parent].append({"color": child, "tag": _tag(entry.get("tag"))})
194 standalone = source.get("standalone", [])
195 if not isinstance(standalone, list):
196 standalone = []
197 for parent in parent_order:
198 tile_id = _unique_id("", used_ids, len(tiles) + 1)
199 tiles.append({
200 "id": tile_id,
201 "name": str(group_names.get(parent, "")).strip()[:64],
202 "parent": {"color": parent, "tag": parent_tags.get(parent, "Base")},
203 "placeholder": False,
204 "children": by_parent[parent],
205 })
206 for entry in standalone[:MAX_CHART_TILES - len(tiles)]:
207 if not isinstance(entry, dict):
208 continue
209 color = _hex_color(entry.get("color"))
210 if not color:
211 continue
212 tile_id = _unique_id("", used_ids, len(tiles) + 1)
213 tiles.append({
214 "id": tile_id,
215 "name": "",
216 "parent": {"color": color, "tag": _tag(entry.get("tag")) or "Base"},
217 "placeholder": False,
218 "children": [],
219 })
221 groups = []
222 parent_tags_out = []
223 group_names_out = {}
224 seen_parent_tags = set()
225 for tile in tiles:
226 parent = tile["parent"]["color"]
227 parent_tag = tile["parent"]["tag"]
228 if parent:
229 if tile["name"]:
230 group_names_out[parent] = tile["name"]
231 if parent_tag and parent not in seen_parent_tags: 231 ↛ 234line 231 didn't jump to line 234 because the condition on line 231 was always true
232 parent_tags_out.append({"parent": parent, "tag": parent_tag})
233 seen_parent_tags.add(parent)
234 for child in tile["children"]:
235 groups.append({
236 "child": child["color"],
237 "parent": parent,
238 "tag": child["tag"],
239 })
240 if parent_tag and parent_tag not in library: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true
241 library.append(parent_tag)
242 for child in tile["children"]:
243 name = child["tag"]
244 if name and name not in library: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true
245 library.append(name)
247 for name in list(tag_colors):
248 if name not in library: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 library.append(name)
250 for name in library:
251 tag_colors.setdefault(name, "#FFFFFF" if name == "Base" else "#58667A")
252 for name in library:
253 if name.casefold() != "base" and name not in order: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 order.append(name)
256 return {
257 "format": PMAG_FORMAT,
258 "format_version": PMAG_VERSION,
259 "groups": groups,
260 "parent_tags": parent_tags_out,
261 "tag_library": library[:MAX_TAGS],
262 "tag_colors": {name: tag_colors[name] for name in library[:MAX_TAGS]},
263 "group_names": group_names_out,
264 "tag_order": order[:MAX_TAGS],
265 "tiles": tiles,
266 }
269def merge_color_charts(existing, current):
270 """Append new palette relationships without losing earlier colour variants."""
271 old = normalize_color_chart(existing)
272 new = normalize_color_chart(current)
273 merged = copy.deepcopy(old)
274 by_parent = {
275 tile["parent"]["color"]: tile
276 for tile in merged["tiles"]
277 if not tile["placeholder"] and tile["parent"]["color"]
278 }
279 for source_tile in new["tiles"]:
280 parent = source_tile["parent"]["color"]
281 target = by_parent.get(parent) if parent else None
282 if target is None:
283 added = copy.deepcopy(source_tile)
284 added["id"] = _unique_id(
285 added.get("id"), {tile["id"] for tile in merged["tiles"]},
286 len(merged["tiles"]) + 1,
287 )
288 merged["tiles"].append(added)
289 if parent:
290 by_parent[parent] = added
291 continue
292 existing_children = {child["color"] for child in target["children"]}
293 for child in source_tile["children"]:
294 if child["color"] not in existing_children:
295 target["children"].append(copy.deepcopy(child))
296 existing_children.add(child["color"])
297 if source_tile["parent"]["tag"]:
298 target["parent"]["tag"] = source_tile["parent"]["tag"]
299 if source_tile["name"]:
300 target["name"] = source_tile["name"]
301 merged["tag_library"] = list(dict.fromkeys(
302 [*old["tag_library"], *new["tag_library"]]
303 ))
304 merged["tag_colors"] = {**old["tag_colors"], **new["tag_colors"]}
305 merged["tag_order"] = list(dict.fromkeys(
306 [*old["tag_order"], *new["tag_order"]]
307 ))
308 return normalize_color_chart(merged)
311def _tile(chart, group_id):
312 return next((item for item in chart["tiles"] if item["id"] == group_id), None)
315def move_chart_color(data, source_hit, target_hit=None):
316 """Move one colour occurrence between chart tiles and return a new chart."""
317 chart = normalize_color_chart(data)
318 source = _tile(chart, source_hit.get("group_id"))
319 if source is None: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 return chart, False
321 role = source_hit.get("role")
322 if role == "parent":
323 if source.get("placeholder"): 323 ↛ 324line 323 didn't jump to line 324 because the condition on line 323 was never true
324 return chart, False
325 moved = dict(source["parent"])
326 if source["children"]: 326 ↛ 330line 326 didn't jump to line 330 because the condition on line 326 was always true
327 source["parent"] = {"color": None, "tag": ""}
328 source["placeholder"] = True
329 else:
330 chart["tiles"].remove(source)
331 elif role == "child": 331 ↛ 339line 331 didn't jump to line 339 because the condition on line 331 was always true
332 index = int(source_hit.get("index", -1))
333 if index < 0 or index >= len(source["children"]): 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true
334 return chart, False
335 moved = source["children"].pop(index)
336 if source.get("placeholder") and not source["children"]: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 chart["tiles"].remove(source)
338 else:
339 return chart, False
341 target = _tile(chart, target_hit.get("group_id")) if target_hit else None
342 if target is None or target_hit is None:
343 used = {item["id"] for item in chart["tiles"]}
344 chart["tiles"].append({
345 "id": _unique_id("", used, len(chart["tiles"]) + 1),
346 "name": "",
347 "parent": {
348 "color": moved["color"],
349 "tag": moved.get("tag") or "Base",
350 },
351 "placeholder": False,
352 "children": [],
353 })
354 return normalize_color_chart(chart), True
356 target_role = target_hit.get("role")
357 if target_role == "parent" and target.get("placeholder"): 357 ↛ 366line 357 didn't jump to line 366 because the condition on line 357 was always true
358 target["parent"] = {
359 "color": moved["color"],
360 "tag": moved.get("tag") or "Base",
361 }
362 target["placeholder"] = False
363 else:
364 # A parent dropped on another group becomes a child. Base describes a
365 # parent role, so do not carry it into a child role.
366 child = {
367 "color": moved["color"],
368 "tag": "" if moved.get("tag") == "Base" else moved.get("tag", ""),
369 }
370 target["children"] = [
371 item for item in target["children"]
372 if item["color"] != child["color"]
373 ]
374 if target_role == "child":
375 insert_at = max(0, min(
376 int(target_hit.get("index", len(target["children"]))),
377 len(target["children"]),
378 ))
379 target["children"].insert(insert_at, child)
380 else:
381 target["children"].append(child)
382 return normalize_color_chart(chart), True
385def assign_chart_tag(data, hit, tag_name):
386 chart = normalize_color_chart(data)
387 tile = _tile(chart, hit.get("group_id"))
388 name = _tag(tag_name)
389 if tile is None or (hit.get("role") == "parent" and tile.get("placeholder")):
390 return chart, False
391 if hit.get("role") == "parent":
392 tile["parent"]["tag"] = name or "Base"
393 elif hit.get("role") == "child":
394 index = int(hit.get("index", -1))
395 if index < 0 or index >= len(tile["children"]):
396 return chart, False
397 tile["children"][index]["tag"] = name
398 else:
399 return chart, False
400 if name and name not in chart["tag_library"]:
401 chart["tag_library"].append(name)
402 chart["tag_colors"][name] = "#58667A"
403 return normalize_color_chart(chart), True
406class ColorTagDialog(QDialog):
407 def __init__(self, name="", color="#58667A", *, lock_name=False, parent=None):
408 super().__init__(parent)
409 self.setWindowTitle(tr("カラーチャートのタグ"))
410 self._color = QColor(color)
411 if not self._color.isValid():
412 self._color = QColor("#58667A")
413 layout = QVBoxLayout(self)
414 form = QFormLayout()
415 self.name_edit = QLineEdit(str(name))
416 self.name_edit.setMaxLength(32)
417 self.name_edit.setEnabled(not lock_name)
418 self.color_button = QPushButton()
419 self.color_button.clicked.connect(self._choose_color)
420 self._refresh_color_button()
421 form.addRow(tr("タグ名"), self.name_edit)
422 form.addRow(tr("タグ色"), self.color_button)
423 layout.addLayout(form)
424 buttons = QDialogButtonBox(
425 QDialogButtonBox.StandardButton.Ok
426 | QDialogButtonBox.StandardButton.Cancel
427 )
428 buttons.accepted.connect(self.accept)
429 buttons.rejected.connect(self.reject)
430 layout.addWidget(buttons)
432 def _choose_color(self):
433 def apply(color):
434 self._color = QColor(color)
435 self._refresh_color_button()
437 color = ColorPickerDialog.get_color(
438 self._color, self, tr("タグ色"), preview=apply
439 )
440 if color.isValid():
441 apply(color)
443 def _refresh_color_button(self):
444 value = self._color.name(QColor.NameFormat.HexRgb).upper()
445 self.color_button.setText(value)
446 self.color_button.setStyleSheet(f"background:{value};")
448 def values(self):
449 return _tag(self.name_edit.text()), self._color.name(
450 QColor.NameFormat.HexRgb
451 ).upper()
454class TagListWidget(QListWidget):
455 orderChanged = Signal(object)
457 def __init__(self, parent=None):
458 super().__init__(parent)
459 self.setDragEnabled(True)
460 self.setAcceptDrops(True)
461 self.setDropIndicatorShown(True)
462 self.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
464 def startDrag(self, _supported_actions):
465 item = self.currentItem()
466 if item is None:
467 return
468 mime = QMimeData()
469 mime.setData(TAG_MIME, item.text().encode("utf-8"))
470 drag = QDrag(self)
471 drag.setMimeData(mime)
472 drag.exec(Qt.DropAction.MoveAction | Qt.DropAction.CopyAction)
474 def dragEnterEvent(self, event):
475 if event.mimeData().hasFormat(TAG_MIME):
476 event.acceptProposedAction()
477 return
478 super().dragEnterEvent(event)
480 def dragMoveEvent(self, event):
481 if event.mimeData().hasFormat(TAG_MIME):
482 event.acceptProposedAction()
483 return
484 super().dragMoveEvent(event)
486 def dropEvent(self, event):
487 if not event.mimeData().hasFormat(TAG_MIME):
488 super().dropEvent(event)
489 return
490 name = bytes(event.mimeData().data(TAG_MIME)).decode("utf-8", "replace")
491 source_row = next(
492 (row for row in range(self.count()) if self.item(row).text() == name),
493 -1,
494 )
495 target_item = self.itemAt(event.position().toPoint())
496 target_row = self.row(target_item) if target_item is not None else self.count()
497 if source_row > 0: # Base remains first.
498 item = self.takeItem(source_row)
499 if source_row < target_row:
500 target_row -= 1
501 self.insertItem(max(1, target_row), item)
502 self.setCurrentItem(item)
503 self.orderChanged.emit([
504 self.item(row).text() for row in range(1, self.count())
505 ])
506 event.acceptProposedAction()
509class ColorChartCanvas(QWidget):
510 colorPicked = Signal(QColor)
511 tileMoveRequested = Signal(object, object)
512 tagDropRequested = Signal(object, str)
513 tileTagEditRequested = Signal(object)
514 groupNameChanged = Signal(str, str)
516 TILE_WIDTH = 184
517 TILE_HEIGHT = 112
518 HEADER_HEIGHT = 24
519 GAP = 8
520 COLUMNS = 3
522 def __init__(self, parent=None):
523 super().__init__(parent)
524 self.chart = empty_color_chart()
525 self.groups = []
526 self.zoom = 1.0
527 self.scroll_area: QScrollArea | None = None
528 self._color_hits = []
529 self._header_hits = []
530 self._drag_mode = ""
531 self._drag_start = QPointF()
532 self._drag_last = QPointF()
533 self._drag_scroll = (0, 0)
534 self._drag_zoom = 1.0
535 self._pressed_hit = None
536 self._space_down = False
537 self._ctrl_down = False
538 self._name_editor = None
539 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
540 self.setMouseTracking(True)
541 self.setAcceptDrops(True)
542 self._update_canvas_size()
543 self._update_mode_cursor()
545 def set_chart(self, chart):
546 self.chart = normalize_color_chart(chart)
547 rank = {name: index for index, name in enumerate(self.chart["tag_order"])}
548 self.groups = []
549 for tile in self.chart["tiles"]:
550 children = list(enumerate(tile["children"]))
551 children.sort(key=lambda pair: (
552 rank.get(pair[1]["tag"], len(rank) + 1),
553 pair[1]["tag"].casefold(),
554 pair[1]["color"],
555 ))
556 self.groups.append({
557 "id": tile["id"],
558 "name": tile["name"],
559 "parent": tile["parent"]["color"],
560 "parent_tag": tr("未設定") if tile["placeholder"] else tile["parent"]["tag"],
561 "placeholder": tile["placeholder"],
562 "children": children,
563 })
564 self._update_canvas_size()
565 self.update()
567 def _base_size(self):
568 count = max(1, len(self.groups))
569 columns = min(self.COLUMNS, count)
570 rows = max(1, int(math.ceil(count / self.COLUMNS)))
571 return (
572 columns * self.TILE_WIDTH + (columns + 1) * self.GAP,
573 rows * (self.HEADER_HEIGHT + self.TILE_HEIGHT) + (rows + 1) * self.GAP,
574 )
576 def _update_canvas_size(self):
577 width, height = self._base_size()
578 size = QSize(max(1, math.ceil(width * self.zoom)), max(1, math.ceil(height * self.zoom)))
579 self.setMinimumSize(size)
580 self.resize(size)
582 @staticmethod
583 def _text_color(color):
584 brightness = (color.red() * 299 + color.green() * 587 + color.blue() * 114) / 1000
585 return QColor("#111111" if brightness >= 145 else "#FFFFFF")
587 @staticmethod
588 def _draw_checker(painter, rect, size=10):
589 left, top = int(rect.left()), int(rect.top())
590 right, bottom = int(math.ceil(rect.right())), int(math.ceil(rect.bottom()))
591 for y in range(top, bottom, size):
592 for x in range(left, right, size):
593 color = QColor("#EEEEEE") if ((x - left) // size + (y - top) // size) % 2 == 0 else QColor("#B8B8B8")
594 painter.fillRect(QRectF(x, y, size, size), color)
596 def paintEvent(self, _event):
597 painter = QPainter(self)
598 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
599 painter.scale(self.zoom, self.zoom)
600 self._color_hits = []
601 self._header_hits = []
602 for position, group in enumerate(self.groups):
603 row, column = divmod(position, self.COLUMNS)
604 x = self.GAP + column * (self.TILE_WIDTH + self.GAP)
605 y = self.GAP + row * (self.HEADER_HEIGHT + self.TILE_HEIGHT + self.GAP)
606 header = QRectF(x, y, self.TILE_WIDTH, self.HEADER_HEIGHT)
607 painter.fillRect(header, QColor("#E6E6E6"))
608 painter.setPen(QColor("#777777"))
609 painter.drawRect(header)
610 painter.setPen(QColor("#222222"))
611 painter.drawText(
612 header.adjusted(4, 1, -4, -1),
613 Qt.AlignmentFlag.AlignCenter,
614 group["name"] or tr("(グループ名)"),
615 )
616 self._header_hits.append({"rect": QRectF(header), "group_id": group["id"]})
618 body_y = y + self.HEADER_HEIGHT
619 parent_rect = QRectF(x, body_y, self.TILE_WIDTH / 2, self.TILE_HEIGHT)
620 if group["placeholder"]:
621 self._draw_checker(painter, parent_rect)
622 parent_color = QColor("#C8C8C8")
623 else:
624 parent_color = QColor(group["parent"])
625 painter.fillRect(parent_rect, parent_color)
626 painter.setPen(QPen(QColor(0, 0, 0, 110), 1))
627 painter.drawRect(parent_rect)
628 painter.setPen(QColor("#303030") if group["placeholder"] else self._text_color(parent_color))
629 painter.drawText(parent_rect.adjusted(3, 3, -3, -3), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, group["parent_tag"])
630 self._color_hits.append({
631 "rect": QRectF(parent_rect), "group_id": group["id"],
632 "role": "parent", "index": -1, "color": group["parent"] or "",
633 "tag": group["parent_tag"],
634 })
636 children = group["children"]
637 child_height = self.TILE_HEIGHT / max(1, len(children))
638 for child_position, (source_index, child) in enumerate(children):
639 rect = QRectF(
640 x + self.TILE_WIDTH / 2,
641 body_y + child_position * child_height,
642 self.TILE_WIDTH / 2,
643 child_height,
644 )
645 color = QColor(child["color"])
646 painter.fillRect(rect, color)
647 painter.setPen(QColor(0, 0, 0, 110))
648 painter.drawRect(rect)
649 painter.setPen(self._text_color(color))
650 painter.drawText(rect.adjusted(3, 1, -3, -1), Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap, child["tag"])
651 self._color_hits.append({
652 "rect": QRectF(rect), "group_id": group["id"],
653 "role": "child", "index": source_index,
654 "color": child["color"], "tag": child["tag"],
655 })
656 if self._drag_mode == "tile" and self._pressed_hit and self._movement() > 4: 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 point = QPointF(self._drag_last.x() / self.zoom, self._drag_last.y() / self.zoom)
658 preview = QRectF(point.x() - 34, point.y() - 18, 68, 36)
659 color = QColor(self._pressed_hit.get("color", ""))
660 if color.isValid():
661 painter.setOpacity(0.72)
662 painter.fillRect(preview, color)
663 painter.setOpacity(1.0)
664 painter.setPen(QPen(QColor("#FFFFFF"), 2))
665 painter.drawRect(preview)
666 painter.end()
668 def _movement(self):
669 return math.hypot(self._drag_last.x() - self._drag_start.x(), self._drag_last.y() - self._drag_start.y())
671 def _base_position(self, position):
672 return QPointF(position.x() / self.zoom, position.y() / self.zoom)
674 def _hit_at(self, position):
675 point = self._base_position(position)
676 return next((dict(hit, rect=QRectF(hit["rect"])) for hit in reversed(self._color_hits) if hit["rect"].contains(point)), None)
678 def _header_at(self, position):
679 point = self._base_position(position)
680 return next((hit for hit in self._header_hits if hit["rect"].contains(point)), None)
682 def _update_mode_cursor(self):
683 if self._drag_mode == "pan": 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true
684 cursor = Qt.CursorShape.ClosedHandCursor
685 elif self._space_down and self._ctrl_down: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true
686 cursor = Qt.CursorShape.SizeVerCursor
687 elif self._space_down: 687 ↛ 688line 687 didn't jump to line 688 because the condition on line 687 was never true
688 cursor = Qt.CursorShape.OpenHandCursor
689 else:
690 cursor = Qt.CursorShape.CrossCursor
691 self.setCursor(cursor)
693 def handle_hold_key_event(self, event):
694 pressed = event.type() == QEvent.Type.KeyPress
695 if event.key() == Qt.Key.Key_Space:
696 self._space_down = pressed
697 elif event.key() == Qt.Key.Key_Control:
698 self._ctrl_down = pressed
699 self._update_mode_cursor()
701 def keyPressEvent(self, event):
702 if event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Control) and not event.isAutoRepeat():
703 self.handle_hold_key_event(event)
704 event.accept()
705 return
706 super().keyPressEvent(event)
708 def keyReleaseEvent(self, event):
709 if event.key() in (Qt.Key.Key_Space, Qt.Key.Key_Control) and not event.isAutoRepeat():
710 self.handle_hold_key_event(event)
711 event.accept()
712 return
713 super().keyReleaseEvent(event)
715 def clear_hold_keys(self):
716 self._space_down = False
717 self._ctrl_down = False
718 self._drag_mode = ""
719 self._update_mode_cursor()
721 def _set_zoom(self, value, anchor=None):
722 value = max(0.35, min(3.0, float(value)))
723 if abs(value - self.zoom) < 1e-6:
724 return
725 old_zoom = self.zoom
726 anchor = QPointF(anchor or QPointF(self.width() / 2, self.height() / 2))
727 base_anchor = QPointF(anchor.x() / old_zoom, anchor.y() / old_zoom)
728 scroll = self.scroll_area
729 viewport_anchor = QPointF(anchor)
730 if scroll is not None:
731 viewport_anchor -= QPointF(scroll.horizontalScrollBar().value(), scroll.verticalScrollBar().value())
732 self.zoom = value
733 self._update_canvas_size()
734 if scroll is not None:
735 scroll.horizontalScrollBar().setValue(int(base_anchor.x() * value - viewport_anchor.x()))
736 scroll.verticalScrollBar().setValue(int(base_anchor.y() * value - viewport_anchor.y()))
737 self.update()
739 def wheelEvent(self, event):
740 steps = event.angleDelta().y() / 120.0
741 if steps:
742 self._set_zoom(self.zoom * (1.12 ** steps), event.position())
743 event.accept()
745 def mousePressEvent(self, event):
746 self.setFocus(Qt.FocusReason.MouseFocusReason)
747 self._drag_start = QPointF(event.position())
748 self._drag_last = QPointF(event.position())
749 self._drag_scroll = (
750 self.scroll_area.horizontalScrollBar().value(),
751 self.scroll_area.verticalScrollBar().value(),
752 ) if self.scroll_area is not None else (0, 0)
753 self._drag_zoom = self.zoom
754 self._pressed_hit = self._hit_at(event.position())
755 ctrl_space = self._space_down and self._ctrl_down
756 if event.button() == Qt.MouseButton.MiddleButton or (event.button() == Qt.MouseButton.LeftButton and self._space_down and not ctrl_space):
757 self._drag_mode = "pan"
758 elif event.button() == Qt.MouseButton.LeftButton and ctrl_space:
759 self._drag_mode = "zoom"
760 elif event.button() == Qt.MouseButton.LeftButton and self._pressed_hit and self._pressed_hit.get("color"):
761 self._drag_mode = "tile"
762 else:
763 self._drag_mode = "pick"
764 self._update_mode_cursor()
765 event.accept()
767 def mouseMoveEvent(self, event):
768 self._drag_last = QPointF(event.position())
769 delta = self._drag_last - self._drag_start
770 if self._drag_mode == "pan" and self.scroll_area is not None:
771 self.scroll_area.horizontalScrollBar().setValue(int(self._drag_scroll[0] - delta.x()))
772 self.scroll_area.verticalScrollBar().setValue(int(self._drag_scroll[1] - delta.y()))
773 elif self._drag_mode == "zoom":
774 self._set_zoom(self._drag_zoom * (1.012 ** -delta.y()), self._drag_start)
775 elif self._drag_mode == "tile" and self._movement() > 4:
776 try:
777 self.grabMouse()
778 except RuntimeError:
779 pass
780 self.update()
781 event.accept()
783 def mouseReleaseEvent(self, event):
784 self._drag_last = QPointF(event.position())
785 movement = self._movement()
786 if self._drag_mode == "tile" and self._pressed_hit is not None:
787 if movement <= 4:
788 color = QColor(self._pressed_hit.get("color", ""))
789 if color.isValid():
790 self.colorPicked.emit(color)
791 else:
792 target = self._hit_at(event.position())
793 if target and all(target.get(key) == self._pressed_hit.get(key) for key in ("group_id", "role", "index")):
794 target = None
795 self.tileMoveRequested.emit(dict(self._pressed_hit), target)
796 elif self._drag_mode == "pick" and movement <= 4:
797 hit = self._hit_at(event.position())
798 if hit:
799 color = QColor(hit.get("color", ""))
800 if color.isValid():
801 self.colorPicked.emit(color)
802 try:
803 self.releaseMouse()
804 except RuntimeError:
805 pass
806 self._drag_mode = ""
807 self._pressed_hit = None
808 self._update_mode_cursor()
809 self.update()
810 event.accept()
812 def mouseDoubleClickEvent(self, event):
813 if event.button() == Qt.MouseButton.LeftButton:
814 header = self._header_at(event.position())
815 if header:
816 self._begin_name_edit(header)
817 event.accept()
818 return
819 hit = self._hit_at(event.position())
820 if hit and hit.get("color"):
821 self.tileTagEditRequested.emit(hit)
822 event.accept()
823 return
824 super().mouseDoubleClickEvent(event)
826 def _begin_name_edit(self, header):
827 if self._name_editor is not None:
828 self._name_editor.deleteLater()
829 tile = _tile(self.chart, header["group_id"])
830 if tile is None:
831 return
832 editor = QLineEdit(tile["name"], self)
833 rect = header["rect"]
834 editor.setGeometry(
835 math.floor(rect.x() * self.zoom), math.floor(rect.y() * self.zoom),
836 max(80, math.ceil(rect.width() * self.zoom)),
837 max(22, math.ceil(rect.height() * self.zoom)),
838 )
839 editor.setMaxLength(64)
840 self._name_editor = editor
841 editor.editingFinished.connect(
842 lambda group_id=header["group_id"], current=editor:
843 self._finish_name_edit(group_id, current)
844 )
845 editor.show()
846 editor.setFocus()
847 editor.selectAll()
849 def _finish_name_edit(self, group_id, editor):
850 if self._name_editor is not editor:
851 return
852 self._name_editor = None
853 value = editor.text().strip()[:64]
854 editor.deleteLater()
855 self.groupNameChanged.emit(group_id, value)
857 def dragEnterEvent(self, event):
858 if event.mimeData().hasFormat(TAG_MIME):
859 event.acceptProposedAction()
860 return
861 super().dragEnterEvent(event)
863 def dragMoveEvent(self, event):
864 if event.mimeData().hasFormat(TAG_MIME) and self._hit_at(event.position()):
865 event.acceptProposedAction()
866 return
867 event.ignore()
869 def dropEvent(self, event):
870 hit = self._hit_at(event.position())
871 if event.mimeData().hasFormat(TAG_MIME) and hit:
872 name = bytes(event.mimeData().data(TAG_MIME)).decode("utf-8", "replace")
873 self.tagDropRequested.emit(hit, name)
874 event.acceptProposedAction()
875 return
876 event.ignore()
879class ColorChartPanel(QWidget):
880 chartChanged = Signal(object)
881 captureRequested = Signal()
882 applyRequested = Signal()
883 saveRequested = Signal()
884 loadRequested = Signal()
885 colorPicked = Signal(QColor)
887 def __init__(self, parent=None):
888 super().__init__(parent)
889 self.chart = empty_color_chart()
890 self.setMinimumSize(440, 260)
891 layout = QVBoxLayout(self)
892 layout.setContentsMargins(4, 4, 4, 4)
893 layout.setSpacing(4)
894 note = QLabel(tr(
895 "親子色とタグを画像から独立して保持します。"
896 "タイルはドラッグで移動、ダブルクリックでタグを変更できます。"
897 ))
898 note.setWordWrap(True)
899 layout.addWidget(note)
900 self.summary = QLabel(tr("未登録"))
901 layout.addWidget(self.summary)
903 body = QHBoxLayout()
904 body.setContentsMargins(0, 0, 0, 0)
905 self.tile_scroll = QScrollArea()
906 self.tile_scroll.setWidgetResizable(False)
907 self.tile_scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
908 self.tile_canvas = ColorChartCanvas()
909 self.tile_canvas.scroll_area = self.tile_scroll
910 self.tile_scroll.setWidget(self.tile_canvas)
911 body.addWidget(self.tile_scroll, 1)
913 tag_box = QWidget()
914 tag_box.setObjectName("colorChartTagManager")
915 tag_box.setFixedWidth(132)
916 tag_layout = QVBoxLayout(tag_box)
917 tag_layout.setContentsMargins(6, 2, 2, 2)
918 tag_layout.addWidget(QLabel(tr("タグ")))
919 self.tag_list = TagListWidget()
920 self.tag_list.setToolTip(tr(
921 "ドラッグ:色タイルへ割り当て/一覧内で順序変更\n"
922 "ダブルクリック:名前とタグ色を編集"
923 ))
924 tag_layout.addWidget(self.tag_list, 1)
925 tag_buttons = QHBoxLayout()
926 add_tag = QToolButton()
927 add_tag.setText("+")
928 remove_tag = QToolButton()
929 remove_tag.setText("-")
930 tag_buttons.addWidget(add_tag)
931 tag_buttons.addWidget(remove_tag)
932 tag_layout.addLayout(tag_buttons)
933 body.addWidget(tag_box)
934 layout.addLayout(body, 1)
936 capture = QPushButton(tr("現在の親子付けを登録"))
937 apply_button = QPushButton(tr("チャートを使用色へ適用"))
938 file_row = QHBoxLayout()
939 save_button = QPushButton(tr("PMAG保存"))
940 load_button = QPushButton(tr("PMAG読込"))
941 file_row.addWidget(save_button)
942 file_row.addWidget(load_button)
943 layout.addWidget(capture)
944 layout.addWidget(apply_button)
945 layout.addLayout(file_row)
947 capture.clicked.connect(self.captureRequested)
948 apply_button.clicked.connect(self.applyRequested)
949 save_button.clicked.connect(self.saveRequested)
950 load_button.clicked.connect(self.loadRequested)
951 add_tag.clicked.connect(self._add_tag)
952 remove_tag.clicked.connect(self._remove_tag)
953 self.tag_list.itemDoubleClicked.connect(lambda item: self._edit_tag(item.text()))
954 self.tag_list.orderChanged.connect(self._set_tag_order)
955 self.tile_canvas.colorPicked.connect(self.colorPicked)
956 self.tile_canvas.tileMoveRequested.connect(self._move_color)
957 self.tile_canvas.tagDropRequested.connect(self._assign_tag)
958 self.tile_canvas.tileTagEditRequested.connect(self._choose_tile_tag)
959 self.tile_canvas.groupNameChanged.connect(self._rename_group)
960 self.set_chart(self.chart)
962 def set_chart(self, chart, *, emit=False):
963 self.chart = normalize_color_chart(chart)
964 self.tile_canvas.set_chart(self.chart)
965 self._refresh_tags()
966 child_count = sum(len(tile["children"]) for tile in self.chart["tiles"])
967 self.summary.setText(
968 tr("{group_count}グループ・{child_count}子色").format(
969 group_count=len(self.chart["tiles"]), child_count=child_count
970 )
971 if self.chart["tiles"] else tr("未登録")
972 )
973 if emit: 973 ↛ 974line 973 didn't jump to line 974 because the condition on line 973 was never true
974 self.chartChanged.emit(copy.deepcopy(self.chart))
976 def _refresh_tags(self):
977 current = self.tag_list.currentItem().text() if self.tag_list.currentItem() else ""
978 self.tag_list.clear()
979 order = ["Base", *self.chart["tag_order"]]
980 order.extend(name for name in self.chart["tag_library"] if name not in order)
981 for name in order:
982 if name not in self.chart["tag_library"]: 982 ↛ 983line 982 didn't jump to line 983 because the condition on line 982 was never true
983 continue
984 item = QListWidgetItem(name)
985 color = QColor(self.chart["tag_colors"].get(name, "#58667A"))
986 item.setBackground(color)
987 item.setForeground(ColorChartCanvas._text_color(color))
988 self.tag_list.addItem(item)
989 if name == current: 989 ↛ 990line 989 didn't jump to line 990 because the condition on line 989 was never true
990 self.tag_list.setCurrentItem(item)
992 def _move_color(self, source, target):
993 chart, changed = move_chart_color(self.chart, source, target)
994 if changed:
995 self.set_chart(chart, emit=True)
997 def _assign_tag(self, hit, name):
998 chart, changed = assign_chart_tag(self.chart, hit, name)
999 if changed:
1000 self.set_chart(chart, emit=True)
1002 def _choose_tile_tag(self, hit):
1003 choices = [tr("(タグなし)"), *self.chart["tag_library"]]
1004 current = hit.get("tag", "")
1005 if current == "未設定":
1006 return
1007 index = choices.index(current) if current in choices else 0
1008 value, accepted = QComboBox(), False
1009 # QInputDialog is avoided here so the choices retain the chart order and
1010 # the dialog remains easy to test with ordinary Qt widgets.
1011 dialog = QDialog(self)
1012 dialog.setWindowTitle(tr("色タイルのタグ"))
1013 box = QVBoxLayout(dialog)
1014 value.addItems(choices)
1015 value.setCurrentIndex(index)
1016 box.addWidget(value)
1017 buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
1018 buttons.accepted.connect(dialog.accept)
1019 buttons.rejected.connect(dialog.reject)
1020 box.addWidget(buttons)
1021 accepted = dialog.exec() == QDialog.DialogCode.Accepted
1022 if accepted:
1023 self._assign_tag(hit, "" if value.currentIndex() == 0 else value.currentText())
1025 def _rename_group(self, group_id, name):
1026 chart = normalize_color_chart(self.chart)
1027 tile = _tile(chart, group_id)
1028 if tile is not None and tile["name"] != name:
1029 tile["name"] = str(name).strip()[:64]
1030 self.set_chart(chart, emit=True)
1032 def _set_tag_order(self, order):
1033 chart = normalize_color_chart(self.chart)
1034 chart["tag_order"] = [name for name in order if name != "Base"]
1035 self.set_chart(chart, emit=True)
1037 def _add_tag(self):
1038 dialog = ColorTagDialog(parent=self)
1039 if dialog.exec() != QDialog.DialogCode.Accepted:
1040 return
1041 name, color = dialog.values()
1042 if not name:
1043 return
1044 chart = normalize_color_chart(self.chart)
1045 if name not in chart["tag_library"]:
1046 chart["tag_library"].append(name)
1047 if name != "Base":
1048 chart["tag_order"].append(name)
1049 chart["tag_colors"][name] = color
1050 self.set_chart(chart, emit=True)
1052 def _edit_tag(self, old_name):
1053 old_name = _tag(old_name)
1054 if not old_name:
1055 return
1056 dialog = ColorTagDialog(
1057 old_name,
1058 self.chart["tag_colors"].get(old_name, "#58667A"),
1059 lock_name=old_name == "Base",
1060 parent=self,
1061 )
1062 if dialog.exec() != QDialog.DialogCode.Accepted:
1063 return
1064 new_name, color = dialog.values()
1065 if old_name == "Base":
1066 new_name = "Base"
1067 if not new_name:
1068 return
1069 chart = normalize_color_chart(self.chart)
1070 if new_name != old_name and new_name in chart["tag_library"]:
1071 QMessageBox.information(
1072 self, tr("タグ編集"), tr("同じ名前のタグが既にあります。")
1073 )
1074 return
1075 for tile in chart["tiles"]:
1076 if tile["parent"]["tag"] == old_name:
1077 tile["parent"]["tag"] = new_name
1078 for child in tile["children"]:
1079 if child["tag"] == old_name:
1080 child["tag"] = new_name
1081 chart["tag_library"] = [new_name if name == old_name else name for name in chart["tag_library"]]
1082 chart["tag_order"] = [new_name if name == old_name else name for name in chart["tag_order"]]
1083 chart["tag_colors"].pop(old_name, None)
1084 chart["tag_colors"][new_name] = color
1085 self.set_chart(chart, emit=True)
1087 def _remove_tag(self):
1088 item = self.tag_list.currentItem()
1089 if item is None or item.text() == "Base":
1090 return
1091 name = item.text()
1092 chart = normalize_color_chart(self.chart)
1093 for tile in chart["tiles"]:
1094 if tile["parent"]["tag"] == name:
1095 tile["parent"]["tag"] = "Base"
1096 for child in tile["children"]:
1097 if child["tag"] == name:
1098 child["tag"] = ""
1099 chart["tag_library"] = [value for value in chart["tag_library"] if value != name]
1100 chart["tag_order"] = [value for value in chart["tag_order"] if value != name]
1101 chart["tag_colors"].pop(name, None)
1102 self.set_chart(chart, emit=True)
1105def read_pmag(path):
1106 source = Path(path)
1107 if source.stat().st_size > 16 * 1024 * 1024: 1107 ↛ 1108line 1107 didn't jump to line 1108 because the condition on line 1107 was never true
1108 raise OperationError(tr("PMAGファイルが大きすぎます。"))
1109 payload = json.loads(source.read_text(encoding="utf-8"))
1110 if not isinstance(payload, dict) or payload.get("format") != PMAG_FORMAT: 1110 ↛ 1111line 1110 didn't jump to line 1111 because the condition on line 1110 was never true
1111 raise OperationError(tr("PaintMaskGroup形式ではありません。"))
1112 return normalize_color_chart(payload)
1115def write_pmag(path, chart):
1116 destination = Path(path)
1117 destination.write_text(
1118 json.dumps(normalize_color_chart(chart), ensure_ascii=False, indent=2),
1119 encoding="utf-8",
1120 )